Forward impl
declaration of an incomplete interface
Table of contents
- Abstract
- Problem
- Background
- Proposal
- Details
- Future work
- Rationale
- Alternatives considered
- Allow implementing multiple interfaces with a single
impl
declaration - An
impl
of an interface also implements the interfaces it extends - Use the contents of definitions if available
- Delayed checking of incomplete types
- Allow
impl
declarations with rewrites of defined but not complete interfaces - Can omit function declarations from
impl
body - Different introducer for assigning associated constants in an
impl
definition
- Allow implementing multiple interfaces with a single
Abstract
Revise rules for what is required and provided by declarations and definitions of interfaces and impls. In particular:
- allow
impl
declarations of incomplete interfaces, and - shift from a “use the information from the type definition if it happens to be complete” model to a “only use the information from the definition in contexts where it is required to be defined or complete” model.
Resolves questions-for-leads issues #4566, #4672, #4579.
Problem
There are several kinds of entities in Carbon, and most of them support separate forward declaration from definition. You might declare an entity before defining it for a few reasons:
- Non-generic runtime functions may be called using only a declaration in an api file, as long as they are defined in the corresponding impl file. This hides implementation details, and allows build work to be parallelized by way of separate compilation.
- By the information accumulation principle, we don’t want code to rely on information written later in the file. For example, names can’t be used before the declaration that introduces that name. This requires the developer to order their code in a way that satisfies those constraints.
For example, functions and types (classes, interfaces, named constraints, and so on) can reference the names of any kind of type in their declaration and definition.
In some cases, two types will reference each other, requiring forward declarations:
A more complex example with interfaces can be found in the generics design, that requires introducing named constraint forward declarations to represent constraints that can’t be expressed yet.
We don’t want to create a situation where Carbon is introducing a lot of accidental complexity into the programming process by creating a declaration ordering puzzle that is difficult or impossible to solve. We would like ordering rules that are straightforward to satisfy, and simple to remember.
There are a few different options for ordering requirements, all of which have downsides:
- Light requirements are easy to satisfy, but don’t provide as much information to use. For example, if you don’t require that an interface is defined at a point, then you can’t use the interface requirements from its definition.
- Strong requirements are difficult to satisfy, and in the worst case can create dependency cycles that can’t be satisfied at all.
- We could have light requirements, but then use additional information when it is available. This creates complexity. There is complexity in the implementation since there are many cases to consider, and if something goes wrong it could be for many different possible reasons. It increases the danger of introducing coherence concerns, where reordering declarations and definitions could change the meaning of the code. This gives some (situational) flexibility to the developer to solve ordering problems.
The design prior to this proposal often uses this last option, creating implementation complexity. We would like to find an alternate approach that gives similar flexibility without the downsides.
Background
Related work and past discussion
- Proposal #722: Nominal classes and methods
- Proposal #818: Constraints for generics (generics details 3)
- Introduced implied constraints.
- PR #1026: Clarify class declaration syntax
- Proposal #1084: Generics details 9: forward declarations
- Proposal #2760: Consistent class and interface syntax
- Proposal #3762: Merging forward declarations
- Proposal #3763: Matching redeclarations
- Updated the rules of
impl
declarations and matching
- Updated the rules of
- Proposal #3980: Singular extern declarations
- PR #4230: Add documentation for entity declaration design work
- Pending leads question #4566: Implementing multiple interfaces with a single impl definition
- The currently proposed resolution includes
impl
ing an interface does notimpl
the interfaces itextend
s, reducing the need to see theinterface
definition inimpl
declarations.
- The currently proposed resolution includes
- Pending leads question #4579: When are interface requirements enforced for an
impl
?- Considers what is required and provided by
impl
forward declarations and definitions. This proposal offers a resolution to these questions.
- Considers what is required and provided by
- Pending leads question #4672: Declaration and definition of impls and their associated functions
- The currently proposed resolution includes moving assignment of associated constants into the
impl
definition in the normal case, reducing the need to see theinterface
definition inimpl
declarations.
- The currently proposed resolution includes moving assignment of associated constants into the
- Proposal #5087: Qualified lookup into types being defined
- Allows member access inside a type’s definition, in addition to afterwards. Introduces the defined terminology.
Terminology
- A declaration specifies information about an entity. Declarations are either forward declarations (which generally end with a semicolon
;
) or definitions (which generally end with a body enclosed in curly braces{
…}
). - We say an entity is declared by the first declaration textually in the source. If that declaration is a definition, we say the entity is declared at the point the body begins (at the open curly
{
). - We say an entity is defined once the body of its definition is started (at the open curly
{
). We allow name lookup into an entity once it is defined. See proposal #5087: Qualified lookup into types being defined. - We say an entity is complete at the end of its definition (at the close curly
}
). - We say a facet type is identified if all the interfaces it references are declared and all of its named constraints are complete. An identified facet type is associated with a known set of interfaces.
-
An implied constraint is a condition that must hold if a type is used in a given type expression, which we enforce in some way other than checking that the condition holds at that point. For example:
interface Hash { ... } class HashSet(T:! Hash) { ... } // `U` must satisfy the constraints on the `T` parameter to `HashSet`. // In this case, we enforce it using an implied constraint, so we don't // require `U impls Hash` beforehand (say by being declared `U:! Hash`). fn Contains[U:! type](needle: U, haystack: HashSet(U)) -> bool; // In this case, `Contains` is equivalent to this declaration that // doesn't use implied constraints: fn Contains[U:! type where .Self impls Hash] (needle: U, haystack: HashSet(U)) -> bool; // In this example, the implied constraint is on something more // complicated than a single symbolic parameter. fn F[V:! type, W:! type](x: HashSet(Pair(V, W))); // is equivalent to: fn F[V:! type, W:! type where Pair(V, .Self) impls Hash] (x: HashSet(Pair(V, W)));
- We generally require that there always be a way to explicitly declare constraints so implied constraints are not needed.
- Implied constraints allow declarations to be more concise, particularly we hope that common requirements can be omitted since they are implied.
-
Implied constraints are also a tool we can choose to use to say that a requirement on a parameter can be enforced at a later point. In this example,
interface B {} interface A; class C(T:! B); fn F(U:! A, x: C(U)); interface A { require Self impls B; }
the symbolic type parameter
U
has a constraint that it implsA
, and we can add an implied constraint that it also implsB
because it is used as an argument toC
. This constraint might be redundant with a requirement from the definition ofA
, which may or may not have been available at the point whereF
is declared. Callers have to provide arguments that satisfy the additional requirement if it turns out thatA
alone is not sufficient.
Other considerations
Different kinds of entities have different requirements (ignoring extern
declarations):
- A compile-time function must be defined before a call to it is evaluated.
- A generic function must be defined before the end of each file in which a call to it is type-checked.
- Non-generic runtime functions may be defined separately. They may be forward declared in an api file and defined in a corresponding impl file.
- Types must be complete before they are used in a function definition. This includes the parameter types, the return type, the argument types of functions that are called in the body, and the types of local variables.
- Interfaces and named constraints must be defined in the same file they are declared.
- We want to allow some uses of classes that are declared in an api file and defined in a corresponding impl file – in particular, we treat the “pointer to
C
” typeC*
to be complete even ifC
is not.
Our main tool for making ordering easier is forward declarations. Declarations necessarily come after all the declarations of entities named in its parameter and return types. So declarations need to be ordered in some topological sorted order that respects those dependencies. This is unavoidable, though, as long as we are not allowing forward references, following the information accumulation principle.
Definitions will have all the dependencies of a declaration, plus perhaps some more. As a result we will want to put them later in the file. This is possible if a forward declaration is good enough for other declarations to use that name in all the ways that they need to.
Proposal
The goal is to allow forward declarations in more situations, and allow them to satisfy requirements in more situations. In particular, we make these changes:
- We accept the proposed resolution of leads question #4566:
- An
impl
of an interfaceI
does notimpl
any interfaceI
extends. - A single
impl
can be for only a single interface.
- An
- We accept the proposed resolution of leads question #4672:
- Assignment of associated constants is moved into the
impl
definition in the normal case, though may still be present in awhere
clause at the end of the declaration. - Associated constants are assigned using
where X = value;
declarations, with semantics matching rewrite constraints inwhere
clauses. - No part of the
impl
declaration will be excluded from syntactic match. - Every
impl
must be defined in the same file (not just the same library) as its declaration.- However, the definitions of its member functions may be separate in the impl file, out of line, as provided in proposal #3763.
- Assignment of associated constants is moved into the
- An
impl
may be forward declared without the interface being defined.- This is enabled by not needing access to the definition to see other interfaces required or extended or to see the associated constants that need to be given values.
- Instead we require the interface to be identified. This means that if the facet type to the right of the
as
is given by a named constraint, that named constraint needs to be complete so we can see which interface it corresponds to. (It is an error if it doesn’t correspond to a single interface, by the resolution of #4566.) - However, an
impl
declaration of a facet type with.A =
… rewrite constraints (for example in awhere
clause), does still require the interface to be complete.
- We answer questions from leads issue #4579:
- Since
impl
forward declarations do not require the interface to be defined, any requirements that other interfaces be defined from the interface definition are ignored. - An
impl
definition of an interfaceI
requires first establishing that the type implements any interfaces required byI
, but that can be satisfied by animpl
declaration.
- Since
- We shift from a “use the information from the type definition if it happens to be complete” model to a “only use the information from the definition in contexts where it is required to be defined or complete” model
- However, an interface
I
containing arequire Self impls J
orextend J
declaration adds a fact that “typesT
satisfyingT impls I
also satisfyT impls J
” to the environment.
- However, an interface
These changes are intended to allow developers to write their api files in an order that roughly follows:
- namespace declarations in any order;
- other declarations in any topological dependency order;
- type (interface, constraint, class) definitions in any order;
- impl definitions in any order;
- function definitions, including both those that are needed in the api file plus those the developer wishes to include, in any order.
This would be sufficient except we have a few additional constraints, which remain:
- Performing member access into a type requires the type to be defined, not just declared.
- Members of an effectively final impl may only be accessed after their value has been established.
- A function that is called at compile time must be defined before that call is executed by the compiler.
Prior to this proposal
| | prereq | provides | complete by EOF | | :———————————————————————————- | :————————————————————————————- | :————————————————————————————- | :———————————————————– | | impl C as Y;
| Y
complete | C impls Y
| | | impl C as Y where .A =
… | Y
complete | C impls Y
| | | impl C as Y {
… }
| Y
complete | C impls Y
| C
| | interface I;
| | I
declared | I
| | interface Y {
` require Self impls Z; <br>
} | **
Z declared** | **
Y complete** |
Z | |
interface Y { <br>
require Self impls Z; <br>
} <br>
impl C as Y { ...
} | **open question [\#4579](https://github.com/carbon-language/carbon-lang/issues/4579)** | **open question [\#4579](https://github.com/carbon-language/carbon-lang/issues/4579)** | | |
fn FT:! I; |
I declared | |
F,
I | |
fn FT:! I { ...
} |
I complete |
F complete | | |
interface I; <br>
class C; <br>
class D(T:! I); <br>
fn F(x: D(C)); |
C impls I | |
I,
C?,
D | |
interface I; <br>
class D(T:! I); <br>
fn FU:! type; | |
U impls I <br> (implied constraint) |
I,
D,
F` |
Proposed rules
| | prereq | provides | complete by EOF | | :———————————————————————————- | :—————– | :————————————————————————————- | :———————————————————– | | impl C as Y;
| Y
identified | C impls Y
| Y
, impl C as Y
| | impl C as Y where .A =
… | Y
complete | C impls Y
| impl C as Y
… | | impl C as Y {
… }
| Y
complete | C impls Y
| C
| | interface I;
| | I
declared | I
| | interface Y {
` require Self impls Z; <br>
} | **
Z identified** | **for any symbolic type
T, <br>
T impls Y implies
T impls Z; <br>
Y complete** |
Z | |
interface Y { <br>
require Self impls Z; <br>
} <br>
impl C as Y { ...
} | **
C impls Z** | **
C impls Y** | | |
fn FT:! I; |
I declared | |
F,
I | |
fn FT:! I { ...
} |
I complete |
F complete | | |
interface I; <br>
class C; <br>
class D(T:! I); <br>
fn F(x: D(C)); |
C impls I | |
I,
C?,
D | |
interface I; <br>
class D(T:! I); <br>
fn FU:! type; | |
U impls I <br> (implied constraint) |
I,
D,
F` |
Whether classes need to be complete by the end of the file is not the subject of this proposal.
Details
An impl
implements a single interface
We resolve leads question #4566 with two rules:
- Implementing an interface does not implement any of the interfaces it extends.
- An impl declaration should implement a single interface.
We allow facet type expressions to the right of impl
…as
, as long as that facet type corresponds to a single interface (ignoring interfaces it extends, which incidentally seems to match Rust supertraits). This supports the use case of Core.Add
actually being a named constraint defined in terms of Core.AddWith
. This requires that the facet type is identified, at a minimum, when used in an impl
declaration, so it can be resolved into the single interface actually being implemented.
The “subsumption” use case of “when an interface X is implemented, Y will be implemented without the user having to write a separate impl definition” will instead be handled using blanket implementations. There are a couple of variations:
-
One interface is a strict superset of the other. In this case it would be a lot less confusing/surprising if the interfaces use the same implementations of functions that overlap. This is accomplished using a
final
blanket implementation. For example, we will definefinal impl forall [T:! type, U:! ImplicitAs(T)] U as As(T)
and haveAs(T).Convert
forward toImplicitAs(T).Convert
. This way types will either implementImplicitAs(T)
orAs(T)
, and will get an error if they try to implement both. -
One interface can be implemented in terms of the other. For example, if
Ordered
implies an implementation ofIsEqual
, types might still want to provide an explicit definition ofIsEqual
when they can do so more efficiently. This would use a non-final
blanket implementation.
Note that the orphan rule prevents this blanket impl from being written unless the two interfaces in a subsumption relationship are in the same library. Support for these use cases is future work. For now, extend I;
in an interface definition continues to mean that I
is required and the names of I
are included as aliases, matching the meaning in named constraints, see “interface extension” in proposal #553.
Associated constants may be assigned in the body of an impl
definition
We resolve leads question #4672 with the following updated rules:
- The
impl
definition must be in the same file as its owning declaration. - The first declaration establishes that the type implements the interface.
- That declaration may optionally specify a subset of the associated constants in a
where
clause. Thewhere
clause syntax is the same as before this proposal, but the requirement that all associated constants be given their final values (or accept their defaults where available) is removed.- This adds some flexibility to ordering, allowing a developer to say that a type implements an interface before being able to specify the values of its associated constants, while also allowing a subset to be specified if some later declaration needs to access their values.
- Redeclarations of an
impl
no longer have any special treatment ofwhere
clauses.- Each redeclaration is required to match syntactically, so all will have the same
where
clause, if any. - The part between
impl
and the end of the declaration (marked by a{
or;
) is the name of theimpl
. This will be used in places where we want to name theimpl
, such as in an impl priority (“match first”) block or out-of-line definitions ofimpl
members.
- Each redeclaration is required to match syntactically, so all will have the same
- Associated constant assignments should be encouraged to be placed in the
impl
definition rather than in awhere
clause in the declaration, unless needed in the declaration to resolve dependencies.- This makes the name of the
impl
shorter and easier to state. - The syntax for specifying the value of an associated constant in an
impl
definition iswhere X = value;
, just like a rewrite clause in awhere
expression, except thatwhere
in this case is an introducer rather than a binary operator, and the names of the associated constants are in scope, so there is no need to prefix them with a period (.
).
- This makes the name of the
- The non-function associated constants are fixed at the closing curly
}
of theimpl
definition. At that point, each associated constant that has not been assigned a value is given its default value. If it does not have a default, an error diagnostic is issued. - The associated function declarations are fixed by the end of the
impl
definition.- Only associated functions with defaults may be omitted from the
impl
definition. - Not mentioning an associated function with a default in the
impl
definition means the default version from the interface will be used. - Associated functions declared in the
impl
definition must have a matching definition. - If the declared signature of an associated function in the
impl
definition does not match the signature given in theinterface
definition, but are compatible, a stub function that does the conversion is generated to bridge between the two versions.
- Only associated functions with defaults may be omitted from the
- Out-of-line function bodies are allowed in the impl file, even when the
impl
definition is in the api file.- This parallels how function definitions are not part of the definition of classes.
- Missing function body definitions are diagnosed at link time.
An impl
may be forward declared without the interface being complete
We have removed the reasons to require that the interfaces being implemented are defined for an impl
forward declaration:
- We no longer need to see which interfaces are required or extended from the definition of the interface, since the
impl
no longer implements those. - We no longer need to see which associated constants are members of the interface to see if a declaration specifies values for all of them.
- If a
where
clause is used in the declaration, the interface must be defined in order to name the associated constants, butwhere
clauses are discouraged.
However, if the facet type being implemented is a named constraint, we do need that to be complete so we can resolve the interface it resolves to. (It is an error if it doesn’t correspond to a single interface, by the resolution of #4566.)
This means that generally we can establish that a type implements an interface right after they are both declared.
Interface requirements
Leads issue #4579 concerns itself with interfaces that require other interfaces to be implemented, as in:
interface Z;
interface Y {
require Self impls Z;
}
There are two sides to this: what do you have to establish about Z
before an impl
declaration or definition that a type implements Y
, and what does a declaration or definition that a type implements Y
establish about Z
?
We adopt the following rules:
-
Since
impl
forward declarations do not require the interface to be defined, any requirements that other interfaces be defined from the interface definition are ignored.class C1; // ✅ Allowed impl C1 as Y; // `class C1` and `impl C1 as Y` must be defined in the same file.
-
An
impl
definition of an interfaceI
requires first establishing that the type implements any interfaces required byI
, but that can be satisfied by animpl
declaration.class C2; // ❌ Invalid, must first establish `C2 impls Z`. Still invalid if // `impl C2 as Z` appears later in the file. impl C2 as Y {} class C3; // ✅ Allowed impl C3 as Z; impl C3 as Y {} class C4; // ✅ Allowed impl C4 as Z {} impl C4 as Y {} // The classes and `impl C3 as Z` must be defined in the same file.
This is is aligned with the shift from a “use the information from the type definition if it happens to be complete” model to a “only use the information from the definition in contexts where it is required to be defined or complete” model that this proposal this is switching to. To recapture some of that context sensitivity, we say that the definition of an interface with requirements, like Y
above, introduces the extra information that “symbolic types T
that satisfy T impls Y
also satisfy T impls J
.”
For example:
interface Z;
interface Y {
require Self impls Z;
}
class C(T:! Z);
class D(U:! Y) {
// ✅ U impls Y so it also impls Z.
//
// Wouldn't use implied constraints here since `U` is
// from a containing scope.
fn F(x: C(U));
}
This rule only applies to symbolic types, since we want to only say that concrete types implement an interface if we have a declared impl
to witness that fact. Symbolic types depend on the value of some generic parameters and we accept that some accesses of interface members will result in symbolic values that will only have known values once concrete argument values are supplied for the generic parameters. For concrete types, the access is performed immediately, and it is an error if we don’t have an impl
declaration we can point to with the witness. For concrete types, there is no later point where an argument is supplied to delay these checks. For example:
interface Z;
interface Y {
require Self impls Z;
}
class C(T:! Z);
class D {}
// ✅ Allowed, since `impl` declarations are allowed for declared
// entities. Interface requirements are not considered.
impl D as Y;
// ❌ Error: D is not known to implement Z. The fact that Y
// requires Z is not used since D is a concrete type.
fn F(x: C(D));
// Too late to affect the previous declaration, by the information
// accumulation principle.
impl D as Z;
Similarly, it is also an error to access a member of an impl
of a concrete type that doesn’t have a known value, even if it is given a value later in the file. For example:
interface I {
let A:! type;
let B:! type;
}
fn F(T:! I) -> T.A;
class C {}
class D {}
impl C as I;
fn G1() {
// ❌ Error: C impls I, but C.(I.A) is unknown.
var x: auto = F(C);
// ❌ Error: C.(I.A) is unknown.
let y: C.(I.A) = 0;
// ❌ Error: C.(I.B) is unknown.
let b: C.(I.B) = false;
}
impl D as I where .A = i32;
fn H() {
// ✅ Allowed: D.(I.A) = i32;
var x: auto = F(D);
var y: D.(I.A) = 0;
// ❌ Error: D.(I.B) is unknown.
let b: D.(I.B) = false;
}
impl C as I {
where A = i32;
where B = bool;
}
fn G2() {
// ✅ Allowed: C.(I.A) = i32;
var x: auto = F(C);
let y: C.(I.A) = 0;
// ✅ Allowed: C.(I.B) = bool;
let b: C.(I.B) = false;
}
Examples
This example shows using incomplete entities following the rules of this proposal:
interface X;
// ✅ Allowed to use incomplete interfaces in function declarations.
fn F(U:! X);
class C;
// ✅ Allowed to use incomplete types and interfaces in impl declarations.
impl C as X;
interface Y;
interface X {
// ✅ Allowed to use an incomplete interface.
require Self impls Y;
}
// Classes must be defined before being used in a function definition.
class C { ... }
fn G() {
// ✅ Allowed since C is complete and we have a declaration `impl C as X;`
F(C);
}
// The above declarations require that `interface Y`, `fn F` (since it is
// generic), and `impl C as X` are defined in the same file.
interface Y { ... }
fn F(U:! X) { ... }
// Required for `impl C as X` definition.
impl C as Y;
impl C as X { ... }
impl C as Y { }
This example demonstrates using associated constants and interface requirements:
interface X2 {
let A:! type;
let B:! type;
}
interface Y2 {
require Self impls X2;
// ✅ Allowed since `X2` is required and we can access
// its `A` member since `X2` is complete.
fn F() -> X2.A;
}
class C2 {}
impl C2 as X2 where .A = i32;
// ✅ Allowed since `C2` and `Y2` are complete, and
// `C2 impls X2` so `C2` satisfies the requirement in `Y2`.
impl C2 as Y2 {
// ✅ Allowed since the value of `C2.(X2.A)` is known to
// be `i32`, even though `impl C2 as X2` is not complete.
fn F() -> i32;
}
// There needs to be a definition of `C2 as X2 where .A = i32`
// in the same file. The `where` clause needs to be repeated
// verbatim, since redeclaration requires a syntactic match.
impl C2 as X2 where .A = i32 {
// Remaining members of `X2` that do not have default
// values need to be assigned.
where B = i32;
}
Future work
Addressing the subsumption use case previously addressed by extend
in interfaces
We do expect to have collections of interfaces that have a stronger “extending” relationship than is provided by the current extend
declaration in interfaces. For example, a type implementing ImplicitAs
should not have to have a separate declaration that it also implements As
. Addressing this use case is out of scope of this proposal, though, and will be addressed in a future proposal. This future proposal may include:
-
A feature to copy the members of an interface into another to make the subsumption use case easier to write and involve less duplication.
-
A feature to define an implementation of an interface in terms of another by forwarding, for similar reasons.
-
A
final
version of thematch_first
/impl_priority
feature to resolve conflicts when multiple interfaces want to subsume a common interface. We likely want a feature like this for function overloading as well. -
Some way of handling an
impl
that could overlap with afinal impl
, but doesn’t in practice. -
Possible support for implementing multiple interfaces with a single impl definition, as the result of using an
&
operator or named constraint to the right ofas
, as in the considered alternative.
For now, we leave extend
as meaning “requires plus include aliases of the names,” matching the behavior in named constraints. But this will be reconsidered once we have support for this other use case.
Opt-in to using the interface’s default definition of an associated function
We’ve considered that we may want to allow an impl
to opt into using the default definition of a function from the interface by writing = default;
instead of an inline body in curly braces {
…}
. We will see if that is a desirable construct to add with experience. This idea was suggested in issue #4672.
Rationale
The specific solution was chosen to align with the information accumulation principle. In particular, allowing impl
declarations for incomplete interfaces gives additional flexibility to developers to satisfy those constraints.
By reducing the different behavior based on whether a previous declaration was a definition, this proposal reduces complexity in the toolchain and tools that operate on Carbon source code. This benefits the Language tools and ecosystem and Fast and scalable development Carbon goals.
This is intended to also help humans have a simpler mental model of the compiler, to help the Code that is easy to read, understand, and write goal.
Alternatives considered
The trade offs and alternatives were discussed in this document and in open discussion meetings on these dates:
Allow implementing multiple interfaces with a single impl
declaration
This was considered in leads question #4566. There are definite use cases for this feature, particularly arising from evolution. For example, you might want to split an interface into two new interfaces, and have a named constraint with the original name extend both so that existing code continues to work the same. With this proposal, those changes will be harder and have more steps.
Two specific approaches to implementing multiple interfaces were eliminated from consideration in that issue:
- The “all or nothing” approach where the
impl
definition is used for all of the interfaces, or none of them are. This would create too much uncertainty about whether animpl
is applicable, particularly since constraints in generic code are not sensitive to whether something is specialized. - The “Constrained impls” approach where an
impl
of multiple interfaces is treated as a collection ofimpl
s of the individual interfaces with the additional constraint that no specialization changes the values of any non-function associated constants of any of the interfaces. Those constraints though are ultimately circular and not well defined.
The remaining “independent impls” approach seemed possible. In this approach an impl of multiple interfaces is treated as a collection of impls of the individual interfaces. In particular, the definition of a member of one interface can assume that the other interfaces are implemented, but not that the associated types (or other non-function associated constants) have expected values. This would introduce some complexities and a number of questions would need to be answered around how a single impl
definition would be split into definitions of the individual interfaces, how dependencies between those pieces would be resolved, and how these restrictions would be exposed to the user in diagnostics.
An impl
of an interface also implements the interfaces it extends
This was the design before this proposal, but in leads question #4566 we found a number of problems with that approach:
-
A parameterized interface extending a non-parameterized interface, or an interface with fewer parameters, leads to multiple implementations of the extended interface.
-
There are multiple possible semantics you might want, and having a single
impl
does not provide the affordances for choosing between those options, where oneimpl
per interface would. For example, inimpl forall [T:! type] C(T) as I & J where .(I.x) = i32 and .(J.y) = .(I.x)
, if there is a specialization ofC(T)
forI
, willJ.y
have the valuei32
or theI.x
from the specialization? In practice, the semantics of rewrites mean that.(I.x)
is replaced withi32
at an early stage in the compiler (to support things like.(J.y) = .(I.x).D
), and so only the first option is consistent. This is a particular concern for the “Independent impls” option above. If thisimpl
is split into two, then the different possible meanings have different spellings:-
impl forall [T:! type] C(T) as J where .(J.y) = i32
meansJ.y
will bei32
independent of any specialization ofC(T)
forI
-
impl forall [T:! type where C(T) impls I] C(T) as J where .(J.y) = .(I.x)
meansJ.y
matchesI.x
even ifC(T)
is specialized -
impl forall [T:! type where C(T) impls (I where .x = i32)] C(T) as J where .(J.y) = .(I.x)
means this impl won’t be used unlessI.x
isi32
. Note this last form approximates the “Constrained impls” approach above, but with an explicit ordering to determine the semantics, and the existing language rules preventing the code from declaring cycles that would make it ambiguous.
-
-
If an interface
J
extendsI
but they are defined in distinct libraries, there is no guarantee that an implementation ofJ
belongs in the same library as an implementation ofI
for the same type due to the orphan rule. -
The current documented rule for which interfaces an impl implements is those whose members are defined in the interface definition. This rule is ambiguous for empty interfaces or interfaces where all the associated functions have defaults. It also requires a lot of context to answer that question (this was intentional, to allow options for refactoring an interface without having to update implementations of it). In addition to being the source of readability concerns, this muddies the meaning of
impl
declarations, and make the compiler implementation much trickier (the compiler can’t say what an impl declaration provides at the point where it is written, making it hard to give that declaration a clear type in the SemIR). -
Ideas we considered to determine the interfaces implemented from only the impl declaration ran into problems. Without being able to control which interfaces an impl is defining, then it isn’t clear how to handle implementing two interfaces that have common interface they both extend unless you implement them both in a single impl definition (which may not even be possible due to the orphan rule). Another idea we had in this space was a way to say “this interface minus one of the interfaces it extends” (maybe
J \ I
?).
We considered some restrictions on extend
to address some of these concerns:
-
Perhaps implementing an interface only implemented the interfaces it extends that have to be defined in this library by the orphan rule.
-
Perhaps an interface may only extend an interface defined in the same library.
-
Perhaps an interface may only be extended by a single interface. This precluded motivating use cases for
extend
, though, like the subtyping relationships between different kinds of graphs used in the Boost Graph library).
Changing the meaning of extend
is left for a future proposal, when we address the subsumption use case previously addressed by extend
in interfaces.
Use the contents of definitions if available
In the course of implementing the existing design, uses of “TryToCompleteType” function were found to be prone to leading to coherence issues. If being incomplete does not lead to an error, we need to establish that the results of the definition appearing before and after that test are the same. When there were multiple types involved, this led to an explosion of combinations to test. The new model has less conditional logic, and as a result less complexity.
Delayed checking of incomplete types
We could delay checking uses of incomplete types until some later point, either when the type is complete, a use that requires that type to be complete, or the end of the file where we know the most about it. This is an option, and we might adopt it if we found that it was too hard in practice to satisfy the constraints adopted by this proposal. However it would significantly increase the complexity of the toolchain implementation, which would lead to a corresponding increase in the difficulty of understanding how the code will be interpreted.
Allow impl
declarations with rewrites of defined but not complete interfaces
We at first did not have a rule saying that I
needed to be complete in impl C as I where .X = ...
. The rationale was that accessing members of I
only needed I
to be defined, not complete. However, an impl
declaration inside the definition of the interface being implemented could ultimately never be defined, even if we allowed the declaration. This is because the impl
definition would have to be in a different scope than its declaration, as can be seen in this example that uses a lambda function to get a scope that can have an impl
declaration inside an interface
definition:
interface I {
let U:! type;
default let T:! type = (fn() -> type {
class C { }
// ✅ Allowed since `I` is declared, with the exception that
// this requires a definition before the end of the file.
impl C as I;
// ✅ Would be allowed since `I` is defined, including its
// member `U`. Again this requires a definition in this file.
impl C as I where .U = C;
// ❌ Neither of the above impls may be defined:
// - Can't be defined here since `I` is not complete.
// - Can't define after `I` is complete, since redeclarations
// must match syntactically and have no hope of naming this
// `C` that way.
// - In general, the impl definition would have to re-enter the
// same scope.
impl C as I where .U = C {}
return C;
})();
}
This simplifies the toolchain implementation of this feature, since it means we can create an impl
witness for declarations that use rewrite constraints with the full knowledge of the interface’s definition.
Can omit function declarations from impl
body
We considered saying that an impl
definition does not need to include declarations that are unchanged from the interface definition. However, this raised a number of questions and problems:
- The
interface
definition is in a different scope from theimpl
, and potentially a different file. This, combined with the evolution problems of depending on the specific token sequence used in theinterface
definition, suggest that we would need a different matching rule that was semantic instead of syntactic. - Having a different declaration matching rule was anticipated as creating a bunch of difficulties once function overloading is added to Carbon, particularly overloaded functions in interfaces.
- Without a declaration in the
impl
body, there were a number of problems resolving how to handle a function in theimpl
that was compatible with but a different signature from the interface, and when that different signature would be used. - These issues seemed to magnify if the interface had a default definition for the function.
Different introducer for assigning associated constants in an impl
definition
Other options we considered were provides
, alias
, and whatever we eventually choose for #5028. We ultimately liked matching how you would assign associated constants in the declaration using the where
operator and a rewrite constraint, particularly since we wanted to use the same semantics.