- Problem
- Background
- Proposal
- Rationale based on Carbon's goals
- Alternatives considered
We want to Carbon to have a high quality generics feature that achieves the goals set out in #24. This is too big to land in a single proposal. This proposal continues #553 defining the details of:
- adapters
- associated types and other constants
- parameterized interfaces
This is a follow on to these previous generics proposals:
- #24: Generics goals
- #447: Generics terminology
- #524: Generics overview
- #553: Generics details part 1
The content for this proposal was extracted from a larger Generics combined draft proposal.
This is a proposal to add multiple sections to this design document on generics details.
Much of this rationale was captured in the Generics goals proposal.
We considered replacing the adapter
keyword with the alternate spelling of
"adaptor". Both spellings can be used for the intended meaning, but the "-er"
spelling is more common in English text and in code. The final deciding factor
was that the
GoF Design Patterns book spells
the "adapter pattern" with the
"-er" spelling.
Issue #739: Associated type syntax decided that the syntax for assigning a value to an associated constant, such as an associated type.
The decision was to use let
with :!
to express that these are compile-time
values, matching the use in classes described in proposal
#772.
interface Stack {
let ElementType:! Type;
fn Push[addr me: Self*](value: ElementType);
...
}
class DynamicArray(T:! Type) {
...
impl as Stack {
let ElementType:! Type = T;
fn Push[addr me: Self*](value: ElementType);
...
}
}
One advantage was this opened the door for a type to satisfy the associated
types of two interfaces with the same name with a single let
declaration using
constraints satisfying the requirements of both interfaces.
This type can be replaced with auto
, to have it determined automatically.
class DynamicArray(T:! Type) {
...
impl as Stack {
let ElementType:! auto = T;
fn Push[addr me: Self*](value: ElementType);
...
}
}
This would avoid needing to change the impl
when the constraints in the
interface changed as long as the value to the right of the =
satisfied the new
constraints. Otherwise if the constraints are being weakened, first functions
relying on the capabilities being removed would have to change, then they would
be changed in the interface, and finally the implementations for types. If the
constraints are being strengthened, the implementations for types would have to
change first followed by the interface.
We also considered omitting the type in the impl
, always using the type
declared in the interface.
class DynamicArray(T:! Type) {
...
impl as Stack {
let ElementType = T;
fn Push[addr me: Self*](value: ElementType);
...
}
}
This would provide the advantage of reducing the number of changes when changing the constraint specified in the interface. If the constraints were being weakened, then functions that used the capability that was being removed would break or need to be modified. If the constraints were being strengthened, then only type implementations that didn't satisfy the new constraints would break or need to be modified.
The biggest difference from the selected option is when adding a constraint. In that case the selected option would have more churn, because all implementations would be updated even if they already satisfied the new constraint. This comes with the advantage of making it easier incrementally enforce greater constraints.
On the whole, it seems like both could be made to work. You could explicitly
specify constraints with this option by using an alias to a normal
let ..:! TypeOfType
declaration that has extra constraints. Conversely, you
can specify auto
as the constraints in the selected option.
But on balance, it seemed better to try putting the explicit constraints into the implementations so that we have more tools to incrementally roll out changes to interface constraints even though those rollouts will as a consequence be more noisy in some cases. If experience shows that this is a really bad tradeoff, we should revisit it.
The last option considered is used by Swift.
Swift allows the value of an associated type to be omitted when it can be determined from the method signatures in the implementation.
For the above example, this would mean figuring out ElementType == T
from
context:
class DynamicArray(T:! Type) {
...
impl as Stack {
// Not needed: let ElementType:! Type = T;
fn Push[addr me: Self*](value: T);
...
}
}
One benefit is that it allows an interface to evolve by adding an associated type, without having to then modify all implementations of that interface.
One concern is this might be a little more complicated in the presence of method overloads with default implementations, since it might not be clear how they should match up, as in this example:
interface Has2OverloadsWithDefaults {
let T:! StackAssociatedType;
fn F[me: Self](x: DynamicArray(T), y: T) { ... }
fn F[me: Self](x: T, y: T.ElementType) { ... }
}
class S {
impl as Has2OverloadsWithDefaults {
// Unclear if T == DynamicArray(Int) or
// T == DynamicArray(DynamicArray(Int)).
fn F[me: Self](
x: DynamicArray(DynamicArray(Int)),
y: DynamicArray(Int)) { ... }
}
}
Not to say this can't be resolved, but it does add complexity. Swift considered removing this feature because it was the one thing in Swift that required global type inference, which they otherwise avoided. They ultimately decided to keep the feature.
This option was only very briefly discussed and not preferred because:
- It came with complexity of inference.
- It seemed unnecessary.
We considered an alternative to the type_of
approach from
the parameterized interfaces section
for binding T
to a type mentioned later in the parameter list. We could
instead allow functions to have value patterns without a :
, as in:
fn PeekAtTopOfStackParameterized
[T:! Type, StackType:! StackParameterized(T)]
(s: StackType*, T) -> T { ... }
However, we don't want to allow value patterns more generally so we can reject
declarations like fn F(Int)
when users almost certainly meant fn F(i: Int)
.
The Carbon team considered and then rejected the idea that we would have two
kinds of interface parameters. "Multi" parameters would work as described in the
detailed design document.
"Deducible" type parameters would only allow one implementation of an interface,
not one per interface & type parameter combination. These deducible type
parameters could be inferred like
associated types are. For
example, we could make a Stack
interface that took a deducible ElementType
parameter. You would only be able to implement that interface once for a type,
which would allow you to infer the ElementType
parameter like so:
fn PeekAtTopOfStack[ElementType:! Type, StackType:! Stack(ElementType)]
(s: StackType*) -> ElementType { ... }
This can result in more concise code for interfaces where you generally need to
talk about some parameter anytime you use that interface. For example,
NTuple(N, type)
is much shorter without having to specify names with the
arguments.
- Having only one type of parameter simplifies the language.
- Multi parameters express something we need, while deducible parameters can always be changed to associated types.
- One implementation per interface & type parameter combination is more
consistent with other parameterized constructs in Carbon. For example,
parameterized types
Foo(A)
andFoo(B)
are distinct, unconnected types. - It would be hard to give clear guidance on when to use associated types versus deducible type parameters, since which is best for a particular use is more of a subtle judgement call.
- Deducible parameters in structural interfaces require additional rules to ensure they can be deduced unambiguously.
In addition, deducible interface parameters would complicate the lookup rules for impls.
Interface implementation is Carbon's only language construct that allows open extension, and this sort of open extension is needed to address the "expression problem" in programming language design. However, we need to limit which libraries can implement an interface for a type so we can be guaranteed to see the implementation when we try and use it.
So the question becomes: can we allow an implementation of a parameterized
interface I(T)
for a type A
to be in the same library as T
, or can it only
be provided with I
or A
? The answer is "yes" if T
is "multi" and "no" if
T
is deducible.
The problem with defining the implementation with a deducible T
is that it
would allow users to violate
coherence. Consider this collection
of libraries, where there are implementations for an interface I(T)
for a type
A
, and those implementations are in the libraries defining the type parameter:
package X library "I and A" api;
interface I(Type:$ T) { ... }
struct A { ... }
package Y library "T1" api;
import X library "I and A";
struct T1 { ... }
// Type `X.A` has an implementation for `X.I(T)` for `T == Y.T1`.
impl X.I(T1) for X.A { ... }
package Z library "T2" api;
import X library "I and A";
struct T2 { ... }
// Type `X.A` has an implementation for `X.I(T)` for `T == Z.T2`.
impl X.I(T2) for X.A { ... }
package Main api;
import X library "I and A";
// Consider what happens if we include different combinations
// of the following two statements:
// import Y library "T1";
// import Z library "T2";
// Function `F` is called with value `a` with type `U`,
// where `U` implements interface `X.I(T)` for some type `T`.
fn F[Type:$ T, X.I(T):$ U](U:$ a) { ... }
fn Main() {
var X.A: a = X.A.Init();
F(a);
}
(In the example, each library is in a different package, but the packages are not the important part here.)
The F(a)
call triggers a lookup for implementations of the interface X.I(T)
for some T
. There exists such implementations in both libraries Y.T1
and
Z.T2
for different values of T
. This has a number of sad consequences:
- "Import what you use" is hard to measure: libraries
Y.T1
andZ.T2
are important and used even thoughY
andZ
are not mentioned outside theimport
statement. - The call
F(a)
has different interpretations depending on what libraries are imported:- If neither is imported, it is an error.
- If both are imported, it is ambiguous.
- If only one is imported, you get totally different code executed depending on which it is.
- We have no way of enforcing a "one implementation per interface" rule that
would prevent the call to
F
from being ambiguous.
Basically, there is nothing guaranteeing that we import libraries defining the types that are used as interface parameters if we allow the interface parameters to be deduced.
This is what Swift does, but doesn't allow us to use interfaces to express operator overloads. For example, a vector should be able to be added to either a vector or a point. So we follow Rust, which has trait parameters in addition to associated types and uses them to define the behavior of operators.
Other alternatives considered will be in a future proposal. Some of them can be seen in a rough form in #36.