Deferred loading & types
There's some weird inconsistencies in the way types can be used in a library if those types come from a deferred import:
import 'def.dart' deferred as D;
dynamic dynamicValue;
main() async {
await D.loadLibrary();
// -----------------
// Question 1:
// -----------------
print(D.Foo); // No compile-time-error: Can use as type literal
print(<D.Foo>[]); // compile-time-error: Cannot use in type expressions
// => Why is one valid the other invalid.
// -----------------
// Question 2:
// -----------------
// I can create variables with `D.Foo` type this way:
var variableWithFooType = D.makeFoo(); // No compile-time-error, implicit `D.Foo` type
variableWithFooType = dynamicValue; // No compile-time-error, implicit `as D.Foo` cast.
// But I cannot do either of these:
D.Foo variableWithFooType = D.makeFoo(); // compile-time-error: explicit `D.Foo` type
variableWithFooType = dynamicValue as D.Foo; // compile-time-error: explicit `as D.Foo` cast
}
with def.dart
class Foo {}
Foo makeFoo() => Foo();
/cc @lrhn @eernstg @johnniwinther @chloestefantsova as we recently talked about deferred loading
Great questions!
import 'def.dart' deferred as D;
dynamic dynamicValue;
main() async {
await D.loadLibrary();
print(D.Foo); // No compile-time error.
print(<D.Foo>[]); // Compile-time error.
var variableWithFooType = D.makeFoo(); // No compile-time error, implicit `D.Foo` type
variableWithFooType = dynamicValue; // No compile-time error, implicit `as D.Foo` cast.
D.Foo variableWithFooType = D.makeFoo(); // Compile-time error: explicit `D.Foo` type
variableWithFooType = dynamicValue as D.Foo; // Compile-time error: explicit `as D.Foo` cast
}
The ability to use D.Foo as a type literal is consistent with the perspective that library members which are accessed via a deferred import can be used at run time (subject to the extra run-time check that the deferred library has actually been loaded successfully).
The fact that D.Foo cannot be used as an actual type argument and it cannot be used as a type annotation is consistent with the perspective that members of a library which is accessed via a deferred import can not be used "before run time". Another example is that deferred types cannot be used in constant expressions.
I'd say that the first actual inconsistency here is the use of type inference to introduce D.Foo as the (implicitly provided) declared type of variableWithFootype. This should not happen. The declaration might be flagged as an error, or type inference might be able to choose a supertype which is not an error to use as a type annotation, or it might choose a general type like Object?. But we shouldn't be able to have a variable whose declared type is deferred. This situation is probably not specified, so we'd need to do that too (presumably, in the nnbd feature spec).
Next, the language specification does not mention deferred types in the section about type casts (that is, expressions of the form e as T), which implies that it is not an error to cast to a type which is obtained via a deferred import. Hence, implementations should stop raising that error at compile time, and they should guard it as usual at run-time by checking that the library has indeed been loaded.
Agree. Types are used during compilation, fx during type inference, and exists whether code is executed or not.
Values, including instances of Type from type literals, only exists when code has been executed, which ensures that loadLibrary has been run. (The expression should have thrown before creating a value if loadLibrary has not been run.)
There is a declaration D.Foo. You can access it as a declaration, fx to access statics D.Foo.staticMember, after it has been loaded.
You should not be able to get a deferred type through type inference. IMO that's a bug. Same for casting to a deferred type, that's also using the type as a type.
You should not be able to refer to a deferred type is a type clause, not directly or indirectly through a deferred type alias.
You can have extension on D.Foo, extension type X(D.Foo _) or <D.Foo>[], no try { ... } on D.Foo { ... }, no is D.Foo or as D.Foo or case D.Foo():. It's not a type.
I'd be very willing to disallow type literals too, since they are "denoting a type", but since D.Foo().runtimeType is valid, it won't make much difference. It may be a little more consisitent, but not worth breaking anything over.
Even if we allow D.Foo as a type literal, we shouldn't allow D.Foo<D.Foo>, the second use is as a real type, not as a "Type object creator".
I made some checks, and we have a number of inconsistencies and missing errors, differing between cfe/analyzer:
import "dart:async" show FutureOr;
import "dart:collection" deferred as p show ListBase, ListMixin;
void main() async {
await p.loadLibrary();
// Literals.
var lit = p.ListBase;
var litg = p.ListBase<int>; // Odd error CFE.
print(lit == litg); // Not.
var o = [] as dynamic;
// Runtime type checks.
// is-check
if (o is p.ListBase<int>) print("No?");
// try-on
try {
// as-cast (inside to avoid promoting).
o as p.ListBase<int>;
} on p.ListBase<int> {
print("No?");
}
// Do we allow *implicit downcasts* to a deferred type?
// (Can it happen if we can't write the type? Yes it can, if the deferred
// library uses it as parameter type!)
for (p.ListBase v = o; v != v;) {}
for (p.ListBase v in Iterable<dynamic>.empty()) {} // No error: analyzer
await for (p.ListBase v in Stream<dynamic>.empty()) {} // No error: analyzer
if (o case p.ListBase<int>()) { // No error: analyzer
print("No?");
} else if (o case p.ListBase<int> _) { // No error: analyzer
print("No?");
} else if (o case (int, p.ListBase<int>)) {
print("No?");
} else if (o case const (p.ListBase)) {
print("No?");
} else if (o case const (p.ListBase<int>)) { // (Invalid const!)
print("No?");
}
switch (o) {
case p.ListBase<int>(): // No error: analyzer
print("No?");
case p.ListBase<String> v: // No error: analyzer
print("No?");
case (p.ListBase<Record>,) v: // No error: analyzer
print("No?");
case const (p.ListBase<LocalType>): // (Invalid const, but odd error?)
print("No?");
}
print(switch (o) {
p.ListBase<int>() => "No?", // No error: analyzer
p.ListBase<String> v => "No?", // No error: analyzer
(p.ListBase<Record>,) v => "No?", // No error: analyzer
const (p.ListBase<LocalType>) => "No?", // cfe: odd error
_ => "Yes!",
});
p.ListBase function<T extends p.ListBase>(
p.ListBase x1,
p.ListBase? x2,
FutureOr<p.ListBase> x3,
List<p.ListBase> x4,
p.ListBase Function() x5, // No error: analyzer
void Function(p.ListBase) x6,
void Function([p.ListBase]) x7,
void Function({p.ListBase v}) x8,
(int, p.ListBase) x9, // No error: analyzer
) {
p.ListBase l1 = x1;
p.ListBase? l2 = x2;
FutureOr<p.ListBase> l3 = x3;
List<p.ListBase> l4 = x4;
p.ListBase Function() l5 = x5; // No error: analyzer
void Function(p.ListBase) l6 = x6;
void Function([p.ListBase]) l7 = x7;
void Function({p.ListBase v}) l8 = x8;
(int, p.ListBase) l9 = x9; // No error: analyzer
p.ListBase l = x1;
var il1 = x1; // Inferred type. No error.
// Type tests.
return il1;
}
}
abstract class C1 extends p.ListBase<Object?> {}
abstract class C2 implements p.ListBase<Object?> {}
abstract class C3 with p.ListMixin<Object?> {}
abstract mixin class MC1 implements p.ListBase<Object?> {}
abstract class MAC1 = Object with p.ListMixin<Object?>;
abstract class MAC2 = Object with NSM implements p.ListBase<Object?>;
mixin M1 on p.ListBase<Object?> {}
mixin M2 implements p.ListBase<Object?> {}
mixin M3 on Iterable<Object?>, p.ListBase<Object?> {}
enum N1 with NSM implements p.ListBase<Object?> { v }
enum N2 with NSM, p.ListMixin<Object?> { v }
extension E1 on p.ListBase<Object?> {} // No error: analyzer, cfe
extension E2<T extends p.ListBase<Object?>> on T {} // NO error: cfe
extension type X1(p.ListBase<Object?> _) {} // No error: analyzer
extension type X2(p.ListBase<Object?> _) implements p.ListBase<Object?> {}
extension type X3<T extends p.ListBase<Object?>>(T _) {} // No error: cfe
typedef LB1<T> = p.ListBase<T>; // No error: analyzer, cfe
typedef LB2<T extends p.ListBase<T>> = T; // No error: cfe
typedef LBF1<T> = p.ListBase<T> Function(); // No error: analyzer, cfe
typedef LBF2<T> = void Function(p.ListBase<T>); // No error: cfe
typedef LBR1<T> = (int, p.ListBase<T>); // No error: analyzer, cfe
typedef LBR2<T> = (p.ListBase<T>,); // No error: analyzer, cfe
// Helpers.
class LocalType {}
mixin NSM {
noSuchMethod(i) => super.noSuchMethod(i);
}
casting to a deferred type, that's also using the type as a type
I actually think we should keep that as a construct which is supported (i.e., not an error).
Let's assume that type inference will not give rise to an inferred declared type that is or contains a deferred type (that is, let's assume that the first inconsistency has been fixed).
In this case, it does not violate any invariants that the type of an expression like e as D.Foo is a deferred type like D.Foo. This is also true for any regular reference like D.foo() where foo is a function whose return type is Foo. I think it's fair to say that the cast is a pure run-time mechanism that references a type which is accessed via a deferred import. It will throw if the given library hasn't been loaded when e is D.Foo is executed, but it is not specified to be a compile-time error, and I would recommend that it is allowed.
@mkustermann, do you expect any unpleasant implications associated with a decision to implement support for casting to a deferred type?
https://github.com/dart-lang/language/issues/4581#issuecomment-3611985405 was updated: That's a great test, @lrhn!
Thinking more about inferring deferred types.
We can't assume that every type that is mentioned in the deferred import's API is itself deferred. The only things we know are deferred are the ones we access through the prefix.
If a deferred class has a String toString();, we don't need to worry about inferring var s = p.Foo().toString();. Not even if the library also re-exports String, and you could refer to it as p.String.
However, if we allow inferring a type through a deferred access, then we risk getting a static type that is not loaded. Take:
var x = (hasLoaded ? p.Foo() : null);
var l = [x];
The static type of x is Foo? where Foo is the return type of the p.Foo() constructor.
It could have been a static function, we can't assume the return type is "a deferred type".
When you then do var l = [x];, then the runtime type of l will be List<Foo?>.
If Foo hasn't been loaded, there may be no current representation of Foo in the runtime,
and creating that object may not be possible.
And if you do:
var x = (hasLoaded ? p.Foo() : null);
x = (something as dynamic);
it should try to do an implicit down-cast to Foo?, which likely means checking if the value is a Foo.
Againt only works if Foo is loaded, otherwise that check has to throw.
When should that error occur? (How do we know to do a runtime check for the type at all? Should there be no test if the type is known to be loaded? The type could be String, so probably yes.
What if the type can be loaded through two different deferred libraries?
var x = loaded1 ? P.Foo() : loaded2 ? Q.Foo() : null; // Static type `Foo`, same type, can be imported through either import.
var l = [x];
print(x.runtimeType);
Should we check whether the type is loaded through at least one of the sources in this library? Or just whether it has been loaded at all? (Probably the only way to be safe.)
// lib1
import 'foo.dart' deferred as p1;
bool _loaded = false;
Future<void> init() async {
await p1.loadLibrary();
_loaded = true;
}
final value = _loaded ? p1.Foo() : null; // Static *public* type `p1.Foo`.
Then have lib2.dart doing the same thing, and
// lib3
import 'lib1.dart' deferred as p1;
import 'lib2.dart' deferred as p2;
bool _loaded1 = false;
bool _loaded2 = false;
Future<void> init1([bool rec = false]) async {
await p1.loadLibrary();
if (rec) await p1.init();
_loaded1 = true;
}
Future<void> init2([bool rec = false]) async {
await p2.loadLibrary();
if (rec) await p2.init();
_loaded2 = true;
}
final value = _loaded1 ? p1.value : _loaded2 ? p2.value : null;
Then the type of lib3's value is Foo?. The Foo type may be imported by lib1.dart or lib2.dart, or both or neither.
If you then do:
var l = [value];
you need to create a List<Foo?> withouth really knowing where to check if Foo has been loaded.
Maybe lib1.dart and/or lib2.dart have been loaded, even then foo.dart might not.
I think the only safe thing to do is to check if Foo has been loaded before creating a List<Foo?> instance,
and not try to guess how it may have been loaded.
If the compiler can see that a type is always loaded (like String), there is nothing to do.
If a type is only potentially loaded (all imports are behind a deferred import), then any attempt to use the type concretely need to check if it has been loaded.
It's easy for direct accesses to deferred types, where you can see the prefix. Also, you can't use them as type arguments directly. For inferred types, either we need to stop inferring them (which is very tricky and probably requires specifying what a "Transitively deferred type" is), or we need to recognize when we reify them and add a check there. Which we also need to specify.
That may be easier: "If a type has not been included by the program, it's an error to use it. A type that is only included through deferred imports is only guaranteed to be included if there is an inclusion path to it where all deferred imports have been loaded. (define transitive inclusion paths in some way).
Even that is a problem for tree-shaking. I may deferred-import a library that transitively imports another library, but nothing I do through that deferred import relies on a specific type of the other library. The chunk of code that the deferred loading loads could have tree-shaken that type. We don't want to, somehow, guarantee that the type will be loaded by a deferred import just because there is an import to it. That would grow deferred imports far too much.
I don't know if there is a good solution here. Remove deferred imports? Don't infer deferred types? (But "deferred types" is not well-defined in that context, it only refers to syntax for referring to a type, not to the type itself). Define, somehow, when "a type is made accessible by an import" in a way that doesn't include types that are never mentioned? Not sure how.
Probably at the point where the type is reified. If the compiler cannot guarantee that a type is available without a deferred import, it may have to throw at any time that type is needed at runtime, even if it's available statically.
The var x = ..; is fine. The [x], aka, <Foo>[x], needs to recognize that Foo may not have been loaded at runtime,
and check for it.
(Maybe. Sounds a little too far from the deferred access to be easy to reason about.)
About as D.Foo, it's definitely using D.Foo "as a type". I'd have expected it to not be allowed, but I think it's safe to allow it. It'll have to throw if D isn't loaded, but that's fair, it's accessing the prefix in the expression.
It's the references that are not part of an expression that doesn't leave us with a good place to test and throw. Those should be compile-time errors.
We can/should also allow is D.Foo then, it is basically the same x as D.Foo.
x as D.Foo~=x is D.Foo ? x : (throw TypeError('Not!')).x is D.Foo~=(() { try { x as D.Foo; return true; } on Error { return false; }})()
We don't allow try { throw o; } on D.Foo catch (o) { ... }, even though that's also an equivalent of o as D.Foo.
That's probably a good thing, because otherwise we'd have to throw at the on D.Foo, during the handling of another throw, risking that you lose an error. It's a reference that is not part of an expression, it just "happens" during control flow.
OK, having thought more about it, and Erik saying that we can decide whether a type is deferred or not in general, not just through a prefix, how about:
- A declaration is included by a library if and only if:
- It's declared in the library.
- It's included by another library which is exported by the library.
- It's included by another library which is imported non-deferredly by the library.
Basically all the declarations in the transitive dependencies of the library, except those that go through deferred imports.
(There is no attempt to filter by show/hide. It's too easily foiled by a typedef.)
- A type is included by a library if:
- It's
voidor a type exported by a platform library. (IncludesdynamicandNeverexported bydart:core.) - It's a type variable.
- It's
S?andSis included by the library. - It's
FutureOr<S>andSis included by the library. - It's a record type and every field type is included by the library.
- It's a function type and the return type, type parameter bound types and parameter types are all included by the library,
- It's an "interface type" introduced by a
class,enum,mixinorextension typedeclaration, and that declaration is included by the library, and if generic, every type argument type is included by the library.
- It's
Basically all types that only reference nominative types from platform libraries or included libraries.
Then type inference will not infer a type for a type argument if the type is not included by the surrounding library.
If it would otherwise have done that, it instead infers Object?.
That will always be because inference of something accessed using a deferred import prefix, it's the only kind of types that can exist at all inside the dependecies of the library and not be an included type.
It could potentially try to find the nearest superclass which is included, but that could easily be arbitrary or ambiguous. It's better to make the author write the type they intend.
It's only a problem for reification. Types that are entirely static are fine, the compiler is assumed to have seen the deferred libraries. When the runtime code needs a representation of a type which may not have been loaded,
it needs to be able to either cause a runtime error, or not use the type. Type inference cannot predict whether a type has been loaded, so it cannot sanction inferring [x] as <Foo?>[x] where Foo is only available through deferred imports.
Rather than trying to figure out if Foo was somehow loaded (not the same as checking than an explicit D.Foo reference is allowed because D has been loaded), it infers <Object?>[x]. It's not wrong, and it doesn't need to create an object using information that may not exist.
This design tries to not get in the way of tree-shaking.
Introducing a type argument like <Foo?> gives the library a reference to the Foo type, which means that type can't be tree-shaken unless the inferred type argument reference is also unreachable.
By not inferring runtime dependencies on types across a deferred import, the deferred part can be tree-shaken independently, and can't be forced to include a type just because code outside of it tries to use the type. (If there are two deferred imports that share a dependency, each can theoretically include only the parts of the shared library that they need themselves, as long as the runtime can merge them when both are loaded.)
It would probably have to make it an error to do implicit downcast to a type that's not included.
var x = loaded ? D.Foo() : null;
x = (o as dynamic);
Here there is an implicit downcast to Foo?, without an explicit deferred import prefix.
The compiler doesn't know how to check if that type is available, so it should reject the downcast at compile time. If you want a cast, you can write as D.Foo, so the compiler knows to check whether the D prefix has been loaded (and knows to not tree-shake Foo when compiling that deferred import).