New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
An outline of part based augmentations #3741
Comments
I still maintain that we don't need a flattening, and can (and should!) define the semantics on the actual syntax that the user provides, without rewriting it first. Flattening can be an implementation choice. It's OK to make sure that it's possible, but flattening at the kernel level or below shouldn't need to worry about name resolution. |
That's a noble goal, but I do not think it's realistic. It's simply not manageable if we do not allow ourselves to say that "a class has a declaration", and instead insist that we must say "assume that the class has the declaration D and augmentations A1 .. Ak" and similarly for every instance member of that class (oh, and static members, too, by the way, and constructors). It seems obvious to me that we must talk about the result of merging all augmentations, yielding a library in Dart-without-augmentations. You may insist that this is a semantic property, and we're never talking about syntax that differs from the syntax that the developers wrote, but the outcome is the same: We must eliminate augmentation in an early phase of the specification (and, presumably, implementation) of the language, such that we can proceed to do "normal Dart stuff", because we already have a pretty good idea about how to do that. I don't think it's going to help anybody to stick to the raw syntax of the augmentations for any longer than we absolutely must. |
I think it is possible to define out way out of the "declaration = stack of declarations" ambiguity. We will have to distinguish two concepts:
In the current specification, those two are the same. When we ask "does the declaration of C have a declared superclass", we look at the syntactic declaration and checks if it has an In the new distinguishing approach, a name does not denote a syntactic declaration, but all the syntactic declarations with that name (which must be one non-augmentation declaration and a number of augmentation declarations, in augmentation application order, which have been checked to be compatible augmentations of the same kind of declaration, otherwise we'd have had an error earlier). Then we have to define, for every query we make today against a declaration, if it's a query on the semantic declaration, how the result is derived from the stack of syntactic declarations. For example:
Generally, define a property on a stack of syntactic declarations, usually inductively. We use this to define the necessary properties of a semantic declartion, just like we do today, and then define the overlying semantics in terms of those properties, rather than direct syntactic declaration inspection. We still have to define the rules for which stacks of syntactic declaratons are allowed, those that we can give a consistent meaning to, and we likely have to do multiple validation passes to ensure that something that seems to be provisionally valid before we even have a type hierarchy, is also valid when we have a type hierarchy, and types, and type inference. |
I generally agree except for the merging, and some of the scoping. The scoping I suggest is:
Further, for nested scopes, I suggested in #3738 that the lexical scope for syntactic "scope-bearing declaration" declation (a syntactic class, mixin, enum, extension, or extension-type declaration, with or without If we use only the textual scope here, then the names in the lexical scope of a name to resolve are, in order:
A name lookup will search those scopes until it finds the name (or same base name), then decide that that is what the name refers to. This design allows
There is no "merging strategy" here, because I don't think we should have one. |
Closing: The language team prefers to talk about the semantics of augmentation in terms of a different model that does not rely on any operation which can be considered to be "moving code around". I like that perspective better, too, by the way. Note also that the don't-move-any-code approach has the same behavior as the model described here, if the model described here is modified slightly: It should then not reduce a sequence of one original declaration and one or more augmentations to a single declaration. They just stay separate. This yields a slightly different binding environment for each identifier in an augmented declaration, because names declared in distinct enclosing declarations (augmenting or not) are not in the scope of each other. For example, we'll need to use |
We have considered changing library augmentations such that they would become parts. In other words, we would reuse the existing part syntax (
'part' <uri> ';'
and'part' 'of' <uri> ';'
) and allow each part to have its own imports, and allow augmenting declarations to occur anywhere (in parts or libraries). Here is an outline of how that could work.Let's use the word module to denote an entity which is either a library or a part.
Example
This example is mentioned a few times below, in order to make some rules or considerations concrete.
Preliminaries
We would presumably have to preserve the existing semantics of parts because anything else would be a massively breaking change, for any manually written code using parts, but also for various code generators.
We could make a distinction between a part of a library and a part of a part, but I'll assume here that we try to treat part-parts the same as parts.
This implies that every name declared in the top-level scope of any of these modules is in the library scope of the library at the top. This implies in turn that the set of modules that constitute a tree with a library at the top and some parts below it must declare distinct sets of top-level names. If there is a name clash between any two top-level declarations in any two nodes in the tree then it will also be a name clash in the library, and hence there will be a compile-time error.
Each node in the tree will have a top-level scope where all names in the top-level scope of all parents (including imported names) are available, plus all names in the top-level scope of all nodes in the tree under this node.
For example, the top-level scope of
part1.dart
would contain all names declared at the top level ofmain.dart
including imported names and import prefixes, plus all names declared at the top level ofpart1.dart
including names imported bypart1.dart
, plus all names declared at the top level ofpartpart1.dart
andpartpart2.dart
(but nothing from the imports of these child modules).In order to preserve readability, each augmenting declaration must augment a declaration (original or augmenting) of the same name which occurs in the same module, textually earlier, or in a parent (direct or indirect) in the module tree.
This implies, for example, that it is a compile-time error if an augmenting top-level declaration named
n
occurs in bothpart1.dart
andpart2.dart
. If the original declaration occurs in a moduleM
(which can be the library or a part) then every augmenting top-level declaration with the same name must occur on a path fromM
downwards in the tree.Note that this path restriction implies that the ordering of augmentations is independent of the tree traversal ordering, as long as it is a pre-order traversal. As an aside, this means that we can sort
part
directives without disrupting the semantics.Merging of augmentations
With these preliminaries in place we can discuss the merging step itself.
The augmentation feature specification mentions a merging process which will produce a single library from a module tree as described above. It has been discussed, e.g., in #3643. In any case, some details are still unresolved.
In order to simplify the following, we introduce a constraint on import prefixes: It is a compile-time error if a module contains an import with a prefix
p
, andp
is also the name of a top-level declaration in the module tree.This may be helpful during implementation, but the crucial point is that this eliminates a source of ambiguity for the human reader of the code. The assumption is that it is a good trade-off to simplify code comprehension slightly by having this constraint, in return for the inconvenience of having to choose unique names also for import prefixes.
The merging step then proceeds as follows:
In each module in the tree, each identifier expression is marked as originating in that module. The module tree is then flattened: For each module
Mj
in the tree, depth-first, append the top-level declarations from each immediate part toMj
in the textual order of thepart
directives. This yields a single library containing all the code from the entire module tree. Call itM0
.Add the import and export directives from each part to
M0
. Each import directive is modified to have a fresh name as prefix, unless it already has a prefix.Augmenting declarations of type-introducing declarations (classes, mixins, etc.) are eliminated by appending each augmenting declaration to the original declaration, in source order. This step is repeated recursively for members of each declaration that has members with augmenting declarations.
For function augmentations, the last augmenting declaration retains its name. The previous declaration is renamed to a fresh private name, and
augmented
is replaced by that name. Similarly for variable declarations whereaugmented
can occur in an augmenting declaration's initializing expression. There will be more details about this.At this time,
M0
contains pre-augmentation code: All augmenting declarations are gone.Next, name resolution occurs, following the rules of current Dart insofar as the given identifier expression denotes declaration in
M0
(at the top level, or in some nested scope).If this is not the case then the name is imported, or undefined. Next, let
Mj
be the module that the given identifier expressionid
originated from. Then transformid
tofreshName.id
ifid
is in the imported namespace of the import with prefixfreshName
and that import originated fromMj
andid
is imported via that prefix. Otherwise repeat the lookup in the same way for the parent ofMj
, recursively, until the library is reached.This implies that every name that originated from
Mj
is resolved according to the standard Dart scope rules in the merged libraryM0
, but if it is imported then it is taken from the imports intoMj
and its parents, recursively, in that order.For example, if
main.dart
andpart1.dart
both import a declaration namedfoo
andpartpart2.dart
containsfoo
as an identifier expression then it will be resolved to the declaration which was imported intopart1.dart
. An import intopartpart1.dart
with the same name would be ignored, and so would an import of the same name intopart2.dart
orpartpart3.dart
.Finally, we introduce some compile-time errors associated with augmentation merging, to improve on the resulting code comprehensibility: It is a compile-time error if an identifier expression
id
in a moduleM
is bound to a declaration D1 using the merging process described above, but it is proto-bound to a different declaration D2 when viewed inM
in context of the module tree, not in context of the final, merged library.Note that proto binding is a new concept. We cannot just rely on regular Dart name resolution because a module can contain identifiers whose declaration is provided by a different module which is neither a child nor a parent.
A proto binding is the result of a variant of lexical lookup whose outcome can be a declaration or nothing. In the case where the outcome is nothing, the given identifier is resolved as
unresolved
(which is not an error, and also does not imply that the identifierid
is transformed intothis.id
). It is applied to identifier expressions in modules that take part in an augmentation merging process. For a given moduleMj
, the proto-binding of an identifier is performed with respect to the lexically enclosing scopes (where anaugment class
declaration is treated the same as aclass
declaration, and similarly for other declaration kinds), where the top-level scope contains all declarations from all parents ofMj
in the module tree as well as all children, recursively.Proto bindings can be computed for selectors that are identifiers or operators as well (so we can proto-bind
y
inx.y
and+
inx + y
), in the case where the receiver has been proto-bound to a declaration.In summary, the augmentation processing step consists of a proto-binding step where identifiers (identifier expressions as well as selectors, including operators) are resolved "as far as possible", followed by a merging step, followed by a check that the final name resolution does not give rise to bindings that are different from the ones that were produced by proto binding (no error occurs when the proto binding is
unresolved
, no matter how the name is resolved after merging).The point is that the tree of modules is more comprehensible if it is possible to trust the lookups that we can see before merging. The bad case that we're avoiding is when a name seems, locally in a module, to resolve to one specific declaration, but it actually resolves to a completely different declaration after merging.
[Edit May 1st: Added headers, clarified the structure, and added a few paragraphs about re-binding errors.]
The text was updated successfully, but these errors were encountered: