The Why and the How (Part 2) (original) (raw)
Definition Checked Generics
Chandler Carruth
Josh Levenberg
Richard Smith
CppNow 2023
The how of checked generics
There are many hard problems
This talk is about 4:
- Type equality
- Termination
- Coherence
- Specialization
Learning from the experience of others
- Have benefited a lot from open language development process
- posts from the designers of these languages
- public discussion of language evolution
- This talk is going to try and pay that forward
- new solutions to these problems, with better properties
Type equality is hard
Recall from part 1…
interface Iterator {
let ValueType:! type;
fn Deref[self: Self]() -> ValueType;
}
interface Container {
let ValueType:! type;
let IteratorType:! Iterator `<1>where .ValueType = ValueType`;
fn Begin[self: Self]() -> IteratorType;
fn End[self: Self]() -> IteratorType;
}
Interfaces can have associated types
Associated types can have equality constraints
Problem statement
How do we determine if two type expressions are equal?
Can we always determine this?
Ideally, the answer to “Is this a valid program?” should not be
¯\_(ツ)_/¯
This is hard
Given arbitrary where A.B = C.D
constraints, is type T
the same as type U
?
- Constraints are rewrite rules on type expressions
- Rephrase problem as: given these rewrites, can we rewrite string
T
to stringU
?
This is hard
Example:
interface X { let A:! X; let B:! X; }
where X.A.B = X.B.A
.
X.A.B.A = X.A.A.B
X.A.B.B.A.B.A.B = X.A.A.A.B.B.B.B
Types are equal if they have the same number of
A
s and they have the same number ofB
s; this is the semigroup {a, b | ab=ba}.Type equality is the word problem for the semigroup.
Can model any finitely-generated semigroup this way.
This is hard
Example (G.S. Céjtin, An associative calculus with an insoluble problem of equivalence, 1957):
interface X { let A:! X; let B:! X; let C:! X; let D:! X; let E:! X; }
where X.A.C = X.C.A
, X.A.D = X.D.A
, X.B.C = X.C.B
, X.B.D = X.D.B
,X.C.E = X.E.C.A
, X.D.E = X.E.D.B
, X.C.C.A = X.C.C.A.E
.
The word problem for this semigroup is unsolvable.
Type-equality in this system of constraints is undecidable.
Determining whether you’re in such a case is undecidable.
Swift
Swift’s approach
- Permits arbitrary equality constraints on associated types
protocol IteratorProtocol {
associatedtype Element
}
protocol Sequence {
associatedtype Iterator : IteratorProtocol
associatedtype Element
where Element == Iterator.Element
associatedtype SubSequence : Sequence
where Element == SubSequence.Element,
SubSequence.SubSequence == SubSequence
}
Given T: Sequence
, these types are all the same:
T.Element
,T.Iterator.Element
,T.SubSequence.Element
,T.SubSequence.Iterator.Element
,T.SubSequence.SubSequence.Element
, …
Swift’s approach
Pursuing Knuth-Bendix completion algorithm
- Analyze a protocol and try to compute an efficient set of rewrite rules
- Algorithm may give up and may not terminate
- Swift compiler provides an iteration limit
- Common examples work, but hard to understand and debug failures
Rust
Rust’s approach
- Can constrain the value of an associated type
trait Iterator {
type ValueType;
}
trait Container {
type ValueType;
type IteratorType: Iterator<ValueType = Self::ValueType>;
}
Rust’s approach
- Unclear what algorithm is used, but like Swift there is a recursion limit
trait Recursive : Sized {
// A.B == B.A
type A: Recursive<B = <Self::B as Recursive>::A>;
// B.A == A.B
type B: Recursive<A = <Self::A as Recursive>::B>;
}
error[E0275]: overflow evaluating the requirement
``<<Self as Recursive>::B as Recursive>::A == _``
- No clear guidance on how to write self-referential constraints that work
Consider changing your trait bounds so that they’re less self-referential.
-- Rust documentation
Carbon
Carbon’s approach
- Want a decidable, efficient, comprehensible, general rule
- Split the problem into two parts
- 90+%, ergonomic solution to automatically handle easy cases
- General solution for the hard cases
Easy cases: member rewrite rules
interface Iterator {
let Element:! type;
}
interface Sequence {
let Element:! type;
// ✅ Sequence.IteratorType.Element = Sequence.Element
let IteratorType:! Iterator
where .Element = Element;
// ❌ Cannot access Sequence.Element because Sequence is not complete
let SubSequence:! Sequence
where .Element = Element and .SubSequence = .Self;
}
When a type variable or associated type is introduced, specify rewrite rules for its immediate members with
where .Member = Value
Cannot constrain members whose type is the enclosing interface
Consequence: all cycles are easily detectable
Consequence: rewrite sequence always terminates
Easy cases: member rewrite rules
interface Iterator {
let Element:! type;
}
interface Sequence {
let Element:! type;
let IteratorType:! Iterator where .Element = Element;
}
interface SliceableSequence {
extend Sequence;
let SubSequence:! Sequence where .IteratorType = IteratorType;
}
impl forall [T:! type] T* as Iterator where .Element = T {}
fn F[C:! SliceableSequence where .IteratorType = i32*](c: C) ->
C.SubSequence.IteratorType.Element;
Example: return type of F
is…
C.SubSequence.IteratorType
**C.SubSequence.IteratorType**
.Element
**i32**
Easy cases: member rewrite rules
- Not all constraints can be written in this way, but most constraints that we’ve seen in practice can be.
- Produces a canonical type that determines the operations and
impl
s available. - Still want an answer for “hard” cases.
Hard cases: single-step equality conversions
interface Container {
// ...
let IteratorType:! Iterator `<0>where .ValueType == ValueType`;
// ...
}
fn AddAndExtract[W:! Widget, C:! Container `<0>where .ValueType == W`](c: C, w: W) {
c.Add(w);
// OK, convert C.IteratorType.ValueType to C.ValueType
let w1: C.ValueType = c.Begin().Deref();
// OK, convert C.ValueType to W
let w2: W = w1;
}
General type equality constraints are written as
where T == U
Can implicitly convert between types constrained to be equal
- And between (eg)
Vector(T)
andVector(U)
- And between (eg)
Hard cases: single-step equality conversions
interface Container {
// ...
let IteratorType:! Iterator where .ValueType == ValueType;
// ...
}
fn AddAndExtract[W:! Widget, C:! Container where .ValueType == W](c: C, w: W) {
c.Add(w);
// Error, can't convert C.IteratorType.ValueType to W
let w: W = c.Begin().Deref();
}
- Do not compute transitive closure of equality rules
- Similar to C++’s “at most one user-defined conversion” rule
Hard cases: single-step equality conversions
interface Container {
// ...
let IteratorType:! Iterator where .ValueType == ValueType;
// ...
}
fn AddAndExtract[W:! Widget, C:! Container where .ValueType == W](c: C, w: W) {
c.Add(w);
// OK, equality proof provided by developer
observe C.IteratorType.ValueType == C.ValueType == W;
let w: W = c.Begin().Deref();
}
- If more than one equality step is required, must be performed manually
Hard cases: single-step equality conversions
- Fully general
- Efficient to type-check
- Not ergonomic
- Not transitive
Summary
Type equality is hard
- Swift: type equality is undecidable, hard cases can hit iteration limit
- Rust: type equality is undecidable, hard cases can hit iteration limit
- Carbon: type equality is decidable, hard cases are less ergonomic
Termination rules are hard
Problem statement
impl forall [T:! type where T* impls Interface] T as Interface;
T
implementsInterface
ifT*
implementsInterface
ifT**
implementsInterface
if …Can express arbitrary computation in this way:
()
implementsTuringMachineHalts(state1, tape1)
if
()
implementsTuringMachineHalts(state2, tape2)
if …
Ideally, the answer to “Is this a valid program?” should not be
¯\_(ツ)_/¯
Alternative: do nothing
- Ignore the problem
- Compiler will run out of memory or time out
- This appears to be what the Swift compiler currently does
Alternative: recursion limits
This is a familiar problem in C++, with a familiar solution
The same approach is used in Rust
Alternative: recursion limits
Brittle and order-dependent
Not composable
Verbose unhelpful diagnostics
Alternative: disallow recursion
If an
impl
declaration recursively tries to use itself, rejectOnly finitely many
impl
declarations, so this always haltsRejects important use cases
interface Hashable { fn Hash[self: Self](); }
impl forall [T:! Hashable] Vector(T) as Hashable { ... }
fn Hash2dVector(v: Vector(Vector(i32*))) {
v.(Hashable.Hash)();
}
Carbon’s approach
Disallow “bad” recursion
- Allow recursion, but only if we don’t reach a step that is strictly more complex
- Or a cycle
Disallow recursion with more complex queries
- Query:
Vector(Vector(i32*)) as Hashable
Vector(Vector(i32*)) as Hashable
^ ^ ^ ^ ^
- Count the number of times each label appears
{``Vector``: 2, ``i32``: 1, ``*``: 1, ``Hashable``: 1}
- Recursive query using same
impl
:
Vector(i32*) as Hashable
^ ^ ^ ^
{``Vector``: 1, ``i32``: 1, ``*``: 1, ``Hashable``: 1}
- Reject if none are < and at least one is >
- That is, if the multiset of labels is a strict superset
Disallow recursion with more complex queries
Theorem: this guarantees termination
- Finite # labels in the program
- no infinite subsequence of differing multisets
- Finite # arrangements of a multiset of names into a query
- no infinite subsequence of equal multisets
Non-type arguments
Proof relies on # labels being finite
- … but infinitely many non-type values
Integer values are given a synthetic label and a count of abs(n)
Example:
(Array(5, i32), IntInRange(-8, 7)) as Hashable
^ ^ ^
{``()``: 1, ``Array``: 1, ``i32``: 1, ints: 5+8+7=20, ``Hashable``: 1}
^ ^ ^
Can recurse if sum of ints decreases, even if nothing else changes
Non-type arguments
Proof relies on # labels being finite
- … but infinitely many non-type values
Non-integer values are erased prior to the check
Disallow recursion with more complex queries
Good:
- Precise errors, no giant stack trace
error: <source>:16: impl matching recursively performed a more complex match
using the same impl: number of ``*``s increasing
outer match: Vector(Vector(i32)) as Hashable
inner match: Vector(Vector(i32)*) as Hashable
- Always terminates
- No arbitrary limits
- Composable and predictable
- Can still express any computation with a computable time bound
Bad:
- Open question whether this disallows any important use cases
Summary
Terminating type checking is hard
- Swift: compilation may time out
- C++: recursion limit
- Rust: recursion limit
- Carbon: always terminating
Carbon rule can be used in other languages
Coherence is hard
What is coherence about?
- How much can the meaning of code change when its moved between files?
- For example, in C++, the “One Definition Rule” says that the definition of some entities must match across translation units.
- Otherwise, the program is ill-formed, no diagnostic required.
Coherence for generics
Rust enforces “trait coherence”: a given type has at most one implementation of any trait
A choice, and different languages make different choices
- For example, Swift does not enforce coherence
Terminology decoder
Swift | Rust | Carbon |
---|---|---|
protocol | trait | interface |
conformance | implementation (impl) | implementation (impl) |
module | crate | library |
Swift
Protocol conformance in Swift is not restricted
Module
MyGUI
defines a protocolRenderable
Module
Formatting
defines a typeFormattedString
Module
Widgets
defines a conformance for typeFormattedString
to protocolRenderable
- a retroactive conformance
Application uses module
Widgets
to put aFormattedString
into a dialog box fromMyGUI
User is happy that they can use the
MyGUI
andFormatting
modules together, even though they were not aware of each other
What if there were two widget modules that did this?
What if two modules provide the same conformance?
Swift compiler tries to statically resolve the protocol implementation
Each module uses its own conformance
So far, no problem
Problems arise
What if the protocol was Hashable
?
- Now two ways to hash a single type
Dictionary<AnyHashable, Int>
Two entries in the hash table for the same value?
Can’t find a value in the table?
Hard to trigger this in practice
- Have to pass objects between unrelated modules
Problems arise
Having two retroactive conformances can cause similar problems in other situations
Dynamic type test might find a different conformance of a type to a protocol
Some “foundation” types have their code compiled into shared objects provided by the OS
- Conformances can live in both the shared object and the application
Regret
- Listed asa “regret” in Jordon Rose’s Swift retrospective
- There is now a Swift proposal in active review to add a warning (SE-0364)
- Will be able to suppress the warning with an annotation
Rust
Coherence in Rust
Trait coherence in Rust is achieved through rules that enforce two properties:
- no orphans
- no overlaps
First property: no orphans
- Property: each implementation is in crate that will definitely be imported if it is used
- Orphan rules: restrictions on where (which “crates”) an implementation can be defined
- An implementation defined somewhere else is an orphan
Terminology decoder
Swift | Rust | Carbon |
---|---|---|
protocol | trait | interface |
conformance | implementation (impl) | implementation (impl) |
module | crate | library |
retroactive conformance | orphan | orphan |
Second property: no overlaps
- Property: There will never be two implementations for the same type and trait combination
- Two implementations that apply to the same type and trait is an overlap
- Overlap rules: restriction on whether an implementation is allowed at all
Example
Hashtable
crate:
pub trait Hash { ... }
Company
crate:
pub struct Employee { ... }
Local type: allowed
Hashtable
crate:
pub trait Hash { ... }
Company
crate:
use Hashtable::Hash;
pub struct `<0>Employee` { ... }
impl Hash for `<0>Employee` { ... }
Local trait: allowed
Hashtable
crate:
pub trait Hash { ... }
Company
crate:
pub struct Employee { ... }
Hashtable
crate:
use Company::Employee;
pub trait `<0>Hash` { ... }
impl `<0>Hash` for Employee { ... }
Orphan, not allowed
Hashtable
crate:
pub trait Hash { ... }
Company
crate:
pub struct Employee { ... }
HashForEmployee
crate:
use Hashtable::Hash;
use Company::Employee;
impl Hash for Employee { ... }
❌
Local trait or local type -> not an orphan
- Only crates that (transitively) depend on both
Hash
andEmployee
can possibly use this implementation - Dependency relationship between the
Hash
andEmployee
crates determines which crate can define the implementation- If there isn’t a dependency relationship, no crate can define that implementation
Generic types, traits, and implementations
- Things get more complicated when talking about generic (meaning “parameterized”) types, traits, and implementations
- Particularly if you want to allow libraries to evolve
- Rust RFC #1023 Rebalancing Coherencesays:
The problem is that due to coherence, the ability to define impls is a zero-sum game: every impl that is legal to add in a child crate is also an impl that a parent crate cannot add without fear of breaking downstream crates.
Generic types, traits, and implementations
- Things get more complicated when talking about generic (meaning “parameterized”) types, traits, and implementations
- Particularly if you want to allow libraries to evolve
- Rust RFC #1023 Rebalancing Coherencesays:
The problem is that due to coherence, the ability to define impls is a zero-sum game: every impl that is legal to add in a child crate is also an impl that a parent crate cannot add without fear of breaking downstream crates.
Blanket implementations
Consider an implementation of the
Hash
trait for any type that implementsSerialize
impl<T> Hash for T where T: Serialize
This is called a blanket implementation
Must be defined in the crate with the
Hash
traitCompiler will reject two different blanket implementations for the
Hash
trait when compiling the crate definingHash
Otherwise there might be a type where both blanket implementations apply, breaking the overlap rule
Blanket implementations and evolution
A blanket implementation for trait
Hash
means no other crate can define implementations ofHash
for any type implementingSerialize
Fine if the blanket implementation is created at the same time as the trait
Adding a blanket implementation later might break downstream dependencies / child crates
- a backwards-incompatible change
Issue: no way to pick between conflicting implementations
- So far no specialization rule in stable Rust
- With a specialization rule, a more-specific implementation in a child crate would override instead of conflicting with the blanket implementation
- A specialization rule can be coherent as long as the specific implementation chosen for a trait and type combination is the same across the whole program
Rust’s coherence rules have some complexity
- Rust’s orphan and overlap rules haveevolved with time to allow more implementations to be written
- The order of parameters can matter
- The “first” local type has to “cover” earlier types
- Different rules for “fundamental” types and traits
Carbon
Simpler coherence rules by supporting specialization
- Can have two implementations that apply, as long as we can choose between them coherently
- Need: all relevant implementations are in the transitive dependencies
- Accomplish this by requiring an implementation to have something local in the type or interface
Orphan rule in Carbon
Local type => allowed:
import HashTable;
class Employee;
impl `Employee` as HashTable.Hash;
Orphan rule in Carbon
Local interface => allowed:
interface IntLike;
impl i32 as `IntLike`;
Orphan rule in Carbon
Local type parameter => allowed:
import HashTable;
class Employee;
impl Vector(`Employee`) as HashTable.Hash;
Orphan rule in Carbon
Local interface parameter => allowed:
class BigInt;
impl i32 as AddWith(`BigInt`);
Orphan rule in Carbon
Nothing local => orphan:
impl i32 as AddWith(bool);
❌ orphan!
Orphan rule in Carbon
Local constraint => insufficient, orphan:
interface IntLike;
class BigInt;
impl forall [T:! `<0>IntLike`,
U:! ImplicitAs(`<1>BigInt`)]
T as AddWith(U);
❌ orphan!
Orphan rule in Carbon
An
impl
declaration is allowed as long as anything in the type or interface is localSince Carbon doesn’t have circular dependencies, this requirement can only be satisfied in at most a single library
Trade off: coherence reduces surprise but gives less flexibility
- What do we do when we want to make two independent libraries work together?
Carbon: using independent libraries
package GUI;
interface `<0>Renderable` { ... }
fn DrawAt[T:! `<0>Renderable`]
(x: i32, y: i32, p: T*);
package Formatting;
class `<1>FormattedString` {
fn Make(s: String) -> Self;
// ...
}
import GUI;
import Formatting;
fn Run() {
var t: auto = Formatting.FormattedString.Make("...");
GUI.DrawAt(200, 100, `<2>&t`);
}
❌ Error: Formatting.FormattedString
does not implement GUI.Renderable
Carbon: using independent libraries
package GUI;
interface Renderable { ... }
fn DrawAt[T:! Renderable]
(x: i32, y: i32, p: T*);
package Formatting;
class FormattedString {
fn Make(s: String) -> Self;
// ...
}
import GUI;
import Formatting;
impl `<0>Formatting.FormattedString as GUI.Renderable` { ... }
fn Run() {
var t: auto = Formatting.FormattedString.Make("...");
GUI.DrawAt(200, 100, &t);
}
❌ Error: orphan impl
Adapters to use independent libraries
package GUI;
interface Renderable { ... }
fn DrawAt[T:! Renderable]
(x: i32, y: i32, p: T*);
package Formatting;
class FormattedString {
fn Make(s: String) -> Self;
// ...
}
import GUI;
import Formatting;
class FormattedString {
`<2>extend` `<0>adapt Formatting.FormattedString;`
}
impl `<3>FormattedString` as GUI.Renderable { ... }
fn Run() {
var t: `<1>FormattedString = Formatting.FormattedString.Make("...")`;
GUI.DrawAt(200, 100, &t);
}
There are other ways of addressing this problem
Options for the future if this isn’t enough
- “This library must be imported (or is automatically imported) anytime these two others are”
- Would have to be part of a consistent configuration of the whole binary
- Similar concern: low level library exports API but does not provide an implementation for it
- Example: memory allocator, logger
- Could be used to break dependency cycles
- In C++ this can be sorted out at link time
- In Rust, libraries can useCargo “features” to_optionally_ depend on another library, and conditionally compile the implementation of a trait in that other library
Different solutions to coherence
- C++: the one definition rule (ODR)
- violations leave the program ill-formed, no diagnostic required
- Swift: no coherence
- has the “what if two modules did that?” problem
- adding a warning
- Rust: enforced coherence
- complex, restrictive rules to ensure no overlap between implementations
- Carbon: enforced coherence
- simpler, more permissive rules
- overlap resolved by a specialization rule
Specialization is hard
What is specialization?
Have multiple applicable implementations, and we pick the most specific
- For example, the implementation of the
Sort
interface does one thing for linked lists and another for containers that offer random access
Specialization support across languages
- C++: supports specialization, including partial specialization
- Rust: not supported yet, long-term effort
- Swift: no planned conformance specialization
C++
- Specialization is important for performance
- “Is more specific” rule is not a total order
- Can get an error if there is ambiguity between which of two specializations to pick
Rust considering impl specialization for a long time
- Been discussed as early as 2015
- A version of specialization has been in unstable Rust since 2016 (Rust RFC 1210)
The current plan is to dramatically relax these [overlap] rules with a feature called “specialization”.
Hard to add specialization without breaking existing code
trait A {
type Out;
}
impl<T> A for T {
type `<0>Out = i32`;
}
fn f<T>(_x: T) -> `<0><T as A>::Out` {
return 3;
}
Hard to add specialization without breaking existing code
trait A {
type Out;
}
impl<T> A for T {
type Out = i32;
}
fn f<T>(_x: T) -> <T as A>::Out {
return 3;
}
struct S {}
impl A for S {
type `Out = bool`;
}
Hard to add specialization without breaking existing code
- Means specialization has to be added opt-in
- Much easier to include specialization from the start
Large design space
- Lots of choices for defining “more specialized”
- implementations match a subset of types?
- in a child crate?
final
implementations are more specific thandefault
implementations
- Can restrict relationships between implementations
- tree: require that implementations either have no overlap, or are properly contained
- lattice: require that the intersection implementation exists for every pair of overlapping implementations
- Many others have been considered
Desirable properties
- Coherence
- Always pick the same specialization for a given type and interface combination, no matter what file
- Composition
- Can mix and match libraries without ending up with ambiguity about which implementation will be chosen
Carbon’s solution
Total “more specific” ordering
- Has a tie-breaking rule so all implementations can be compared
- Enables composition:
* Adding implementations never makes implementation selection ambiguous
Ordering uses the type structure of
impl
declarations- erase type parameters
- just like with Carbon’s orphan rule
Type structure rule
Erasing type parameters from the impl
declaration
impl forall [T:! Printable] Vector(`<1>T`) as Printable
becomes:
Vector(`<1>❓`) as Printable
Type structure rule
Erasing type parameters from the impl
declaration
impl forall [T:! Ordered] `<1>T` as PartiallyOrdered
becomes:
`<1>❓` as PartiallyOrdered
Type structure rule
Erasing type parameters from the impl
declaration
impl forall [U:! type, T:! As(U)] Optional(`<1>T`) as As(Optional(`<1>U`))
becomes:
Optional(`<1>❓`) as As(Optional(`<1>❓`))
Type structure rule
Erasing type parameters from the impl
declaration
impl forall [T:! type] `<1>T` as CommonType(`<1>T`)
becomes:
`<1>❓` as CommonType(`<1>❓`)
Which type structure is more specific?
Rule: look at the first difference (reading left-to-right). One will have the type matching the query, one will have ❓
. Prefer the first.
`<1>BigInt` as AddWith(❓)
is more specific than:
`<1>❓` as AddWith(BigInt)
Which type structure is more specific?
Rule: look at the first difference (reading left-to-right). One will have the type matching the query, one will have ❓
. Prefer the first.
Vector(`<1>bool`) is AddWith(❓)
is more specific than:
Vector(`<1>❓`) is AddWith(❓)
Which type structure is more specific?
Rule: look at the first difference (reading left-to-right). One will have the type matching the query, one will have ❓
. Prefer the first.
Vect3D is AddWith(`<1>Vect3D`)
is more specific than:
Vect3D is AddWith(`<1>❓`)
This rule breaks ties by the order of the parameters
import IntLib;
class `<0>BigInt`;
impl `<2>BigInt as IntLib.IntLike`;
impl forall [T:! IntLib.IntLike]
`<3>BigInt as AddWith(T)`;
impl forall [T:! IntLib.IntLike]
`<4>T as AddWith(BigInt)`;
import IntLib;
class `<1>FancyInt`;
impl `<2>FancyInt as IntLib.IntLike`;
impl forall [T:! IntLib.IntLike]
`<3>FancyInt as AddWith(T)`;
impl forall [T:! IntLib.IntLike]
`<4>T as AddWith(FancyInt)`;
let b: BigInt = ...;
let f: FancyInt = ...;
let x: auto = b + f;
let y: auto = f + b;
This rule breaks ties by the order of the parameters
import IntLib;
class BigInt;
impl BigInt as IntLib.IntLike;
impl forall [T:! IntLib.IntLike]
BigInt as AddWith(T);
impl forall [T:! IntLib.IntLike]
T as AddWith(BigInt);
import IntLib;
class FancyInt;
impl FancyInt as IntLib.IntLike;
impl forall [T:! IntLib.IntLike]
FancyInt as AddWith(T);
impl forall [T:! IntLib.IntLike]
T as AddWith(FancyInt);
let b: BigInt = ...;
let f: FancyInt = ...;
// Uses ``BigInt as AddWith(❓)``
let x: auto = `b + f`;
let y: auto = f + b;
Uses BigInt as AddWith(❓)
This rule breaks ties by the order of the parameters
import IntLib;
class BigInt;
impl BigInt as IntLib.IntLike;
impl forall [T:! IntLib.IntLike]
BigInt as AddWith(T);
impl forall [T:! IntLib.IntLike]
T as AddWith(BigInt);
import IntLib;
class FancyInt;
impl FancyInt as IntLib.IntLike;
impl forall [T:! IntLib.IntLike]
FancyInt as AddWith(T);
impl forall [T:! IntLib.IntLike]
T as AddWith(FancyInt);
let b: BigInt = ...;
let f: FancyInt = ...;
// Uses ``BigInt as AddWith(❓)``
let x: auto = b + f;
// Uses ``FancyInt as AddWith(❓)``
let y: auto = `f + b`;
Uses FancyInt as AddWith(❓)
What if they have the same type structure?
Ask the user to manually prioritize between all
impl
declarations with the same type structure- Gives the user control and often what they want
- Scales much better than defining all the intersections
The orphan rule for coherence guarantees they must all be in the same library!
- Specialization simplifies coherence and coherence simplifies specialization
Local check for the compiler
Specialization summary
Total ordering
means
no ambiguity when picking an implementation specialization
means
can compose libraries safely
- C++ and Carbon both support specialization
- It is hard to retroactively add impl specialization to a language
- Specialization helps with both coherence and performance
- Total “more specific” order => no ambiguity when picking an implementation specialization => allows composition of libraries
Compositional: can combine libraries without worrying about introducing ambiguity
- No way for additional specializations to create ambiguity
- If the type checker sees that an implementation applies, can assume some implementation exists, even if it might be a more specialized implementation instead
- Convenient and expected by users
- Otherwise implementation details become viral requirements that leak into APIs
If you can see an implementation, an implementation must exist
interface RepresentationOfOptional;
impl `forall [T:! Move] T as RepresentationOfOptional`;
class Optional(`T:! Move`) {
var repr: `T.(RepresentationOfOptional.ReprType)`;
}
- Allows other types to customize their
Optional
representation - Users of
Optional
need not be concerned with implementation details likeRepresentationOfOptional
The language foundations that support checked generics
Generics imposes constraints on the rest of the language
- Carbon is using different foundations than are present in C++
Checked generics need: Name lookup isn’t very context-sensitive
- Helpful for readers of the code
- Necessary if you want code to have the same meaning whether it is generic or not
- To be able to type check a function call, must be able to say what its signature is
- Carbon has package namespacing, noargument-dependent lookup, and no open overloading to reduce context-sensitivity
How can we type check code using vector<T>
without knowing T
if its API changes when T==bool
?
Checked generics need: no circular dependencies
Shows up in unexpected ways in Carbon’s specialization support
Checked generics need: coherence
- Coherence is something that Carbon takes seriously even outside the context of generics
- We don’t want the meaning of code to change if an import is added
- We think it makes code much easier to understand and manage at scale
- Coherence and specialization are both good; they work even better together
Checked generics need: simplification
- Carbon’s interface implementation is its only mechanism for open extension, and its only mechanism for specialization
- Means there is only one way to overload an operator, iterate through a container, and so on
- Simplicity elsewhere in the language, particularly in the type system, reduces complexity of checked generics geometrically
Conclusion
Talked about four problems
Carbon has new solutions to:
- Type equality
- Termination rules
- Coherence
- Specialization
Plus non-generics parts of the language that supports checked generics.
Other problems?
Checked generics are an active area of research
There are a lot more problems than those covered in this talk
- Checked variadic generics
- Generic associated types
- Interaction between checked and template generics
- Interaction between generics and implicit conversions
Checked generics are an active area of research
There are a lot more problems than those covered in this talk
- Checked variadic generics
- Generic associated types
- Interaction between checked and template generics
- Interaction between generics and implicit conversions
Promise to keep working on this
- Carbon is continuing to work on finding good solutions to issues that arise with checked generics
- We are working in the public, and are happy to share the results of our research
Questions?
Thank you!
Resources and more information:
- This talk: https://chandlerc.blog/slides/2023-cppnow-generics-2
- https://github.com/carbon-language/carbon-lang#getting-started
- Carbon Generics design overview:
- Proposal #920: Generic parameterized impls
- Proposal #2173: Associated constant assignment versus equality
- Proposal #2687: Termination algorithm for impl selection
- “Generics and templates” channel of our Discord server