-
Notifications
You must be signed in to change notification settings - Fork 207
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
How should non-nullability handle fields that are initialized after object creation? #146
Comments
I think there is a request to support a "late initialize" for finals as well, including final locals, though I can't find it. e.g. void f(bool x) {
final int i;
if (x) {
i = 7;
} else {
i = 42;
}
// ...
} This could be a very similar request; could maybe be solved with one solution. Broadly, declaring locals with |
For locals, I think that we will likely have some form of definite assignment analysis to allow you to write code like your example. It's an interesting question as to whether this should be allowed for final variables as well: it seems to me that it would be nice, but on the other hand single assignment definite analysis is a bit trickier. |
Kotlin also allows to initialize final non-nullable fields in the constructor, even without marking them as lateinit. Dart doesn't support this either, which always gives me a good bit of frustration as this often means you need to write a factory constructor if you want final fields. Kotlin supports this, and it works the same for final locals as in @srawlins example. fun main(args: Array<String>) {
print(MyClass(true).i);
f(false);
}
class MyClass(x: Boolean) {
val i: Int;
init {
if (x) {
i = 7;
} else {
i = 42;
}
}
}
fun f(x: Boolean) {
val i: Int;
if (x) {
i = 7;
} else {
i = 42;
}
println(i);
} I think supporting this style is more elegant then lateinit, and should handle most cases. Note that the value of |
I think we can split this into a few problems: AnalyzableThese are storage locations (variables, fields, top-level variables) that aren't initialized at their declaration but where we can reasonably statically determine that it is assigned before use. For local variables, definite assignment analysis works for most common cases: void f(bool x) {
var i;
if (x) {
i = 7;
} else {
i = 42;
}
print(i);
} Whether you can make a local like this as For instance fields, Dart does have a solution, factory constructors and constructor initialization lists. @kasperpeulen, I think you're right to point out that it's not a very usable solution, but it's there. Given that, I think we should move discussion of improving this to a separate issue, or just not worry about it for now. The good thing about analyzable locations is that we can make them soundly non-nullable without needing the performance cost of any runtime checking to validate that it isn't null. The bad part is that we can't reasonably do this for all storage locations. The analysis just isn't feasible, or would require adding too much complexity to the language (i.e. some kind of complex typestate mechanism that tracks which fields for a given instance have been known to be initialized) to be worth it. A variable in this category:
"Latched non-null"These are storage locations where we can't statically prove that one will be initialized to a non-null value before its read. But we can statically prove that it will only ever be assigned a non-null value. This means that if you ever read it and get a non-null result, you know it will be non-null forever after. That implies that we could safely promote it: class Box {
// Not real syntax.
@latched int value;
}
main() {
var box = Box();
print(box.value); // "null".
box.value = null; // Static error. Setter is non-nullable.
box.value = 3; // OK.
if (box.value != null) {
box.value.isEven; // OK. Can promote.
}
} We don't even need to do additional runtime checking inside the promoted body. For this to work, we do need more control over overriding to ensure that there couldn't be some malicious subclass of Box that overrides the So this basically means a "latched variable":
"Non-null by policy"Then I think we may also run into cases where a variable may get assigned null, but where the code has some surrounding policy that ensures its never used when it is null and where the overhead of manually null-asserting on every use would be frustrating. These are addressed by Swift's "Implicitly Unwrapped Optionals". For example: class Border {
int _thickness;
Color _color;
Border(this._thickness, [this._color]) {
if (_thickness > 0 && _color == null) {
throw ArgumentError("Must have color if border has non-zero thickness.");
}
}
void draw() {
if (_thickness > 0) {
paintBox(_thickness, _color.red, _color.green, _color.blue);
}
}
} There's a policy here, and the class enforces it, but the type system can't see it. But, if stuff like this comes up often, we could add another kind of implicitly checked variable that:
It's basically syntax sugar to not require |
Note that Kotlin constructor initialization is also not null-sound though - you can see and call methods on non-nullable variables in their uninitialized state. |
I don't understand why you would want this. Kotlin gives a runtime error, if you read or use it before it is initilized. I think your model is that it assings I think a better anology is that Kotlin implicitly assings something like an JS
That is not true. It is a compile time error if you see and call methods on non-nullable variables in their uninitialized state. With lateinit it is a runtime exception. |
This seems to disagree with you, and the following Kotlin program compiles, runs and prints out the value of an uninitialized non-nullable field in the Kotlin playground. fun main(args: Array<String>) {
print("${TestClass().v}\n")
}
class AA() {
override fun toString() : String { return "AA" }
}
class TestClass() {
val v: AA;
init {
setup();
v = AA();
}
fun setup() {
print("$v + \n");
}
} |
@leafpetersen I think I was mistaken in my analogy as you have to normally explicitly set it to null. A better anology would then be Dart’s ‘void’, as the compiler tries to make sure you don’t use a void variable but at runtime it has no special value, if you “escape it”. However, I would rather have a runtime exception like with lateinit, if the compiler fails to recognize. |
LMK if locals should be a separate discussion, but I had a thought about how this will affect test code: void main() {
Completer<bool> c1;
List<int> l1;
// ...
setUp(() {
c1 = ...;
l1 = [1, 2, 3];
});
// tests use and modify c1, l1, whose values and state are always set/reset between tests.
test('list remove removes', () {
l1.remove(1);
expect(l1, not(contains(1));
});
} (E.g. this typical test in angular) This is a super common pattern in all testing, and it would be great if users didn't have to make them all nullable (both in the short term for the migration pain, and in the long term, for the benefits of NNBD. But this code is not analyzably non-nullable (from Bob's analyzable above); it could be encoded as latched non-nullable. Non-null by policy, @munificent, would just mean that these are declared as |
Well, the problem is that without some syntax, I don't know how to tell the difference between that code (for which you would want runtime checking) and other cases (for which you want the compiler to tell you that you've forgotten to assign a local). Unless you're suggesting that all locals be latched by default? This seems a bit loose, but maybe. I guess another option would be to say that
Then you could suppress definite assignment warnings for test files. |
No, I'm not suggesting that, but it sure sounds like I am 🙁. I think I just painted myself into a corner and realized there will probably be a migration for tests with uninitialized variables declared in If we say that an NN local declared without an assigned value is legal (because it is latched), that might be confusing... sort of changes the whole idea of NN. And I imagine back-ends want to weigh in on the whole latching concept anyhow. I'm not casting a vote in any direction here, just noting that this will probably require that every test file go through a migration for uninitialized locals. |
There are two different use cases:
The "latched non-null" I describe is for the former, but I think you have in mind the latter. For the latter, something more like the "non-null by policy" is a better fit. |
The descriptions here sound very much like Dart's lazily initialized static fields. If you read it before you write it, it executes some code to get the initial value. We could define lazily initialized instance fields, or even local variables, in almost the same way: If you read before writing, some code is run. That code can either initialize the variable lazily or (if the initializer expression is omitted) just throw an That approach would be using an existing language feature in a more general way, rather than inventing a new one (which is great, because we don't want too many similar features). The only issue is that static fields are lazy by default, but instance fields or local variables are not, so we'd need more syntax to opt-in to it. Some options:
We actually do need to modify what we do to static fields too, because if the initialization fails (throws, perhaps due to cyclic references), then it currently stores null in the field. We should probably not do that any more, since we can't always store |
Very interesting! I assume for instance fields, you would then be able to access
I like it. This should be able to be combined with I think a modifier keyword is the right approach for this, and |
Note that Kotlin has both. Combining them may be reasonable. This does seem like it has a substantial implication on the cost model though. A lateinit field is going to be a lot cheaper (at least in terms of code size) than a lazy field is, so if all we give you is laziness, that makes lateinit expensive. |
The main remaining concern that I have with combining these is the cost model. The latched non-null model has a very clear and relatively cheap implementation strategy:
The lazy model is more expensive. You can't count on using cc @rakudrama @a-siva @mraleph for thoughts on cost model. |
Have you considered postponing solving this problem until we know more about how nnbd without this feature ends up being used? It would be interesting to know how often this comes up and neither initializer lists nor factories help. BTW, Go solves this issue with zero values. However, Go does not have non-null pointers, which may be why this solution works. |
We always consider the null hypothesis (do nothing!) :) That said, discussions with the angular team point to a lot of potential use cases for this (they are doing some digging for data on whether it would actually work for them). And the fact that Swift, Kotlin, and Typescript, all have some feature aimed specifically at this kind of use (implicit unwrapping, lateinit, and definite assignment assertions, respectively) is pretty strong evidence in favor of a need for this (though of course, every language is different). |
There are a couple if interesting cases that come up in code I am familiar with.
class Thing {
final arg;
Helper? _myHelper;
Thing(this.arg) {
this.prepare();
_myHelper = Helper(this);
}
}
class Helper {
final Thing myThing;
Helper(this.mything);
} By the time
class FrameworkThing {
SubObject? _sub;
Derived? _derived;
FrameworkThing() { ...}
activity1(arg) {
_sub = Sub(this, arg, ...);
_derived = Derived(_sub);
}
activity2() {
_sub.activity2a(_derived);
_sub.activity2b();
}
activity3() {
_sub.activity3();
}
}
The cost model of late-init final is reasonable, it can be defined as modified getters and setters, and overriding definitions explained in terms of the getter and setter just like regular fields. Sub? _sub_field;
void set _sub(Sub value) {
assert(_sub_field == null);
_sub_field = value;
}
Sub get _sub {
assert(_sub_field != null); // or a non-assert check.
return _sub_field;
} Every case I have seen has been I think the initializer case and the general case are subtly different. The initializer case does not need extra syntax, since the class declaration can be checked to see if the final field is assigned once in all constructor bodies, and some constructors could do a regular early initialization. The C++ style initializer list is needlessly complex and uncomfortable, and I can imagine the front end 'promoting' trivial assignments to initializers. In the general case, some syntax would be needed final Sub _sub late-init; I have been experimenting with an annotation on the field:
By putting the annotation on one example of the initializer pattern in AppView, a large angular app was reduced in size by 0.18%. This is entirely due to the load-elimination optimization knowing that the field is effectively final - dominating stored value or load can be reused. |
For the final cycles, a late-init/write-once semantics would work. class Thing {
final arg;
lateinit final Helper _myHelper;
Thing(this.arg) {
_myHelper = Helper(this);
this.prepare();
_myHelper.init();
}
// May be overwritten in subclasses.
void preare() {}
} then the compiler should be able to see that the assignment to the lateinit final field dominates everything, so it can make access cheap, even if you have to delay initialization until after Or using lazy initialization: class Thing {
final arg;
lazy final Helper _myHelper = Helper(this); // initialized on first read. There can be no write.
Thing(this.arg) {
this.prepare();
_myHelper; // Force initialization here, if not earlier.
}
} For the 2. final late-init field, I don't see a simple rewrite to laziness. You need the initialization to depend on arguments to a method, so there must be a write operation, and then you really do need a "write once" semantics. On the other hand, not all protocol invariants need to be encoded as a state invariant. Nothing in the example code documents that As you state, this protocol invariant can be implemented using There are many true things about programs that cannot be expressed statically. In this case, I'd probably just do:
This would ensure that I only assign once (but won't prevent me from trying again). Defensive programming inside a single class shouldn't be necessary, the threat model there is someone who can edit the class/library source. If the code is too complicated to ensure proper internal invariants, then you might need more documentation and more asserts. |
Briefly summarizing some white board discussion from last week (partially also captured in comments from @rakudrama above). It seems clear that we could unify late init and lazy under one syntax, but in order to get all of the benefit, we would need to allow lazy fields to be declared with no initializer, and essentially treat them as late init. So you could write
|
There was a brief locals-in-tests discussion above, and I'll just say that having late init or lazy available before (or along with) NNBD is important for test readability and ergonomics. Otherwise all shared variables must be nullable. A test would look like: void main() {
Foo? foo; // Honestly a real Foo, instantiated in setUp().
Bar? bar1; // Honestly a real Bar, instantiated in setUp().
Bar? bar2; // Honestly a real Bar, instantiated in setUp().
Baz? baz; // Honestly a real Baz, instantiated in setUp().
// ...
setUp(() {
foo = Foo();
bar1 = Bar1(foo!!); // Ouch.
// ...
});
// Tests use and modify foo, bar1, bar2, baz, whose values and state are always
// set/reset between tests.
test('foo something', () {
foo.m1(bar1!!); // Ouch.
var nnList = <Bar>[bar1!!, bar2!!]; // Ouch ouch.
// ...
// I imagine we'll have a Warning/Hint/Lint about unguarded access on a
// nullable object?
foo?.m1(); // Ouch.
});
} |
My thinking is more towards implicit laziness.
A lazy variable is in one of four states:
A non-null variable with no intializer is always lazy and starts as "uninitialized". Reading an "uninitialized" variable throws. Reading an "initializer" variable changes the state to "initializing" and evaluates the initializer expression and, if successful, stores the result in the variable and makes it "initialized", and if unsuccessful, makes the variable "uninitialized" (future reads will throw, but will not evaluate initializer again). Reading a variable which is "initializing" throws a cyclic initialization error (otherwise it's similar to uninitialized, it throws on access). Reading a variable which is "initialized" returns the value. Writing a non-final variable stores the value and makes it "initialized". This does not cover computed "late-init" of a final variable. That's not a new problem, either, so I'm not sure we need to handle it now. For local variables, we might be able to address most concerns with assignment-based type promotion. If you make the variable nullable and non-final, then assign a non-null value to it on all branches, maybe we can deduce that the variable is non-nullable, and promote to that locally. For instance variables, we can still go the two-constructor way. |
That's cool! But why wouldn't it work to do the same thing for final variables, cutting it down to the paths through this status diagram that make sense: They can be 'initializer' variables (which means that we can have final instance variables whose initializing expression has access to The point is that this would allow developers some extra flexibility (laziness and access to I believe that the discipline that goes with |
I did intend it to work for final variables too, and I think it does as written. It won't allow "late init" write-once to a final variable, because you just can't write to a final variable. A final variable will need an initializer, but then it should just work. |
I need a lot of convincing to go this route. It feels like a complete foot gun to me that the evaluation order of the program is completely changed by minor (and potentially inadvertent) changes. Change a static method to an instance method, and all of the sudden some initializers that used to run eagerly start running lazily (because they call that method). Refactor a nullable field to be non-nullable, get no static warning. Moreover, if I want a lazy init field, I have to make sure it references this in order to get laziness. If we're going to support the feature, make it available to everyone.
This doesn't address the case that @srawlins described above.
It's a nice one to handle en passant though, no? There was a comment in another thread that you thought this could be handled via laziness. Do you have a better pattern to handle this than the following? class A {
B? _b;
void set_b(B _b) {
if (this._b != null) throw DuplicateInit;
this._b = _b;
};
final lazy B b = _b!;
}
class B {
A? _a;
void set_a(A _a) {
if (this._a != null) throw DuplicateInit;
this._a = _a;
};
final lazy A a = _a!;
}
A buildCycle() {
var a = new A();
var b = new B();
a.set_b(b);
b.set_a(a);
return a;
} It's true that you can do this... but yikes. |
A lazy init field would obviously need a syntactic marker (e.g., a With respect to the final cycle, we could at least make a static decision about where to break the cycle, and then initialize everything except the "breaking point" in the topologically required order. Not very pretty, but at least it's one step less ugly. ;-) class A {
final lazy B b = _b!;
B? _b;
void set_b(B _b) {
if (this._b != null) throw DuplicateInit;
this._b = _b;
};
}
class B {
final A a;
B(this.a);
}
A buildCycle() {
var a = new A();
var b = new B(a);
a.set_b(b);
return a;
} However, this means that the |
I'm sorry I didn't get caught up on this discussion before you all talked about it in AAR. A couple of questions: 1. What about Sam's scenario above: void main() {
Foo? foo; // Honestly a real Foo, instantiated in setUp().
Bar? bar1; // Honestly a real Bar, instantiated in setUp().
Bar? bar2; // Honestly a real Bar, instantiated in setUp().
Baz? baz; // Honestly a real Baz, instantiated in setUp().
// ...
setUp(() {
foo = Foo();
bar1 = Bar1(foo!!); // Ouch.
// ...
});
// Tests use and modify foo, bar1, bar2, baz, whose values and state are always
// set/reset between tests.
test('foo something', () {
foo.m1(bar1!!); // Ouch.
var nnList = <Bar>[bar1!!, bar2!!]; // Ouch ouch.
// ...
// I imagine we'll have a Warning/Hint/Lint about unguarded access on a
// nullable object?
foo?.m1(); // Ouch.
});
} Can those local variables be marked 2. Do lazy fields need initializers? Can I do: class C {
lazy int f;
initialize(int value) { f = value; }
} If so, How about 3. Can lazy final fields be assigned to? I would assume no. It's final, after all. But Leaf's last example does that. Is that a mistake, or does |
I spent some more time trying to work through how
(There is a fourth, "does it have a non-nullable type?". But I don't think we want that to affect the semantics beyond the usual type error checking, so I'll ignore that.) If we allow all the combinations, here's what I think the semantics could be:
Those are the behaviors I would intuit and that I think are useful. In particular, Sam's example is covered by allowing void main() {
lazy Foo foo;
setUp(() {
foo = Foo();
});
// Tests use and modify foo, etc.
test('foo something', () {
foo.m1(); // Fine. Checked at runtime.
});
} If those are the semantics you all have in mind, then the next step is thinking about how we explain them (and whether
I think that works, though I worry I spent a little time talking to Stephen about this and he's worried about the code size implications of the runtime invariant checking for lazy fields. He'd like a production build to be able to eliminate all of those checks. When you say "will throw an error", is it enough to say that that's an AssertionError and that dart2js can simply not throw those? |
Sounds good, @munificent! I was wondering about the exact same thing that Stephen was worried about. For instance, do we actually wish to enforce this property?:
It sounds like such a variable would need to start out having a special value meaning "uninitialized". If it is allocated inline as a bit array of length 64 and interpreted as a two's complement signed integer encoding then we can't use null for the special value, but we could have a second field storing the "has_been_initialized" boolean state for A deployed application could omit these checks, assuming that it is acceptable to have non-standard semantics (maybe this means that the variable starts out with the value zero, and there are no checks to enforce that it is initialized before use). But it doesn't seem likely to me that the deployed application could maintain the precise semantics and eliminate those checks (or those extra "has_been_initialized" storage locations). |
1.The variable declarations will be 2.Lazy fields do not need initializers, If they do not have one, they will throw if they are read before being written. It is as if the default initializer expression of a lazy variable is CombinationsLazy means initialized on first read. That makes it a static type error to have no initializer on a non-lazy non-nullable variable because So, the only one that is wrong is:
This could be a compile-time error for any non-instance variable, because it's an error to read it before it's assigned (lazy+no initializer) and it's a compile-time error to assign to it (final), so the variable is as useless as a local In any case, this is not "write once" semantics, and we do not have any "write once" variables. Omitting errorsIt's not an assertion error, it's a proper run-time error situation in the run-time semantics that has no alternative valid behavior. Omitting the throw means that the code has no meaning. The variable has no value, so reading it cannot return a value. The reason for lazy variables throwing is that if they are non-nullable, they cannot have a value until they are assigned. It can't just be an assertion, reading them before that cannot possibly return any type-safe result (and the specification will not specify a type-unsafe behavior. If Dart2js ignores this check and returns |
I think some of this has been covered, but some additional context and comments. There are a couple of different questions in play here:
Starting with the cost semantics. Throughout this discussion, unless otherwise specified, I'm going to assume that we are in a situation where we are not able to devirtualize the field read. Question 1: Is there a performance difference between That is, for the non-final late init use case:
On the callee side, I believe the implementations are equivalent. On the caller side, I believe that in the absence of override restrictions, there is essentially no performance benefit to
So in the absence of override restrictions, I don't see a perf benefit here to having If you don't allow a class A {
late int x; // We compile this to just a field, and use `null` as a sentinel value
late int? y; // We add a compiler private __y_sentinel value (or use getter)
lazy int l; // One approach:
// We have a compiler private backing store __l__backing
// We use `null` as a sentinel value
// We must provide the getter as well
}
void test(A a) {
a.x; // First read, compiles to `load(a.x)!`
a.x; // Dominated second read, compiles to `load(a.x)`
a.y; // First read, compiles to `if(!a.__y__sentinel) throw NullError; load(a.y);`
a.y; // Second read, compiles to `load(a.y)`
a.l; // First read of lazy, could compile to `a.__l__backing ?? call_getter(a.l)` if we use a sentinel
a.l; // Dominated second read, compiles to `load(a.__l__backing)`
} This is all a bit speculative, but my read on it is that if we were to restrict overriding, then there are perf benefits to having both, otherwise no perf reason to have both. Question 2: Do we do cyclic initialization checking on lazy? In my initial draft spec, I am proposing removing cyclic initialization checking from lazy variables in general (i.e. existing toplevel and static fields), and specifically for the new lazy variables. I do not believe that the benefit of catching this error early is worth the cost: the implementation of the checking requires quite a bit of heavy mechanism (e.g. wrapping the evaluation of the initializer in a try catch, keeping an extra bit of state around to see whether you are in process of being initialized, etc). The code gets a fair bit smaller if you elide all of that. In the rare case that you actually do accidentally do introduce an initialization cycle, you will almost certainly get a stack overflow immediately anyway, so the benefit of doing this checking seems close to zero to me. However, there is one unfortunate side effect of this, which is that it is possible to do a cyclic read which does not cause an infinite loop. See, e.g. the code above. This is ok for non-final variables, but @lrhn was unhappy about the fact that this allows a final variable to be observed to have two different values. So per the referenced comment, we are proposing to make that a checked error for final variables. So the implementation of var __x_backing_store = __private_sentinel
int get x {
if (__x_backing_store != __private_sentinel) return __x_backing_store;
var tmp = e;
if (__x_backing_store != _private_sentinel) throw DoubleWriteToFinal;
return __x_backing_store = tmp;
} For locals, you don't need the check. For fields, in the fairly common case that I would expect that dart2js would elide this check in production mode.
My example does not assign to any final fields. I don't see any mistakes in it. It does illustrate the fact that without cyclic initialization checking, you can end up initializing a final field to two different values. Question 3: Given that context, do we disallow overriding late with non-late etc? I'm very tempted by this, but my sense is that it's a bit un-Dart like to do so. If I have an interface that specifies that there is an So at a minimum, it seems to me that we want to allow overriding a non-lazy/late with a lazy/late. Just allowing that direction might be reasonable, and it might actually not hurt optimization. You need to support non-lazy/late access, which means that you do need to provide the getter access path, but when you have an instance that has late/lazy fields, you can use the optimized path. It's not clear to me how much expressiveness we lose by forbidding overriding a lazy/late with a non-lazy/late. If you really want non-lazy semantics or non-late semantics in a subclass, you might have to jump through small hoops: class A {
lazy int x;
late int y;
}
class B {
int _x = foo(); // I really want this to be run at allocation time, but x has to be lazy, so I cache it here.
lazy int x = _x;
late int y = 3; // We could just allow you to write this, I guess?
} So this is a tenable position, and if we made this restriction, I would be more strongly in favor of having both. Question 4: What combinations of semantics do we want to cover? It is a hard requirement, from my standpoint, that we cover the use case of a non-nullable variable with no initializer that is initialized after allocation. We have copious evidence from other languages that this is a very useful feature. There are three ways to get this:
It is a nice to have (but not a requirement) to have a way to write lazy fields and lazy locals. This is mostly orthogonal to NNBD.
It is a nice to have (but not a requirement) to support the use case of final non-nullable variables that are not initialized in the constructor header.
My take on the last two is that both are fairly niche, but that the first (lazy fields/variables) is probably more generally useful than the second (final late). My take on the question of allowing Summary
Thoughts, comments, corrections welcome. |
Q2: "For fields, in the fairly common case that |
Yeah, I think this is the least compelling of the combinations I went through. What I had in mind is that it would let you write code like: class Cache {
lazy final Object _value;
void cache(Object value) {
_value = value;
}
void get() => _value;
} The intent is that you have a field that can't be eagerly initialized, but you only want to allow it to be initialized once. Today, I usually write that using an explicit assert: void cache(Object value) {
assert(value == null, "Can only cache once.");
_value = value;
} This shows up fairly frequently when you have cyclic references between objects. You want those to be immutable, but only one can be actually eagerly
I may have been unclear in my comment, but by
Dumb mistake on my part. I misread
+1. And from our own code. Look at any test inside Google that contains a call to
That was a concern of mine too. That's why I tried to come up with a new plausible explanation for each modifier such that those explanations do roughly compose and produce the semantics I sketched out for each combination. I think the explanations I came up with for |
I'm not particularly worried about overriding lazy fields with non-lazy fields or vice-versa, overriding fields with fields of the same name is likely very rare (and mostly a mistake). I do care about allowing a getter/setter declaration to override any getters/setters introduced by a field. class SubClass extends SuperClass {
// can't override lazy field foo?!?
int _myFoo;
int get foo => _myFoo;
void set foo(int value) { _myFoo = value; }
} So, I don't see any value to a restriction around that which doesn't preclude getters and setters, and I do not want to prohibit getters and setters. About the missing "late-init final" write-once case, it is a useful case and we are not covering it with the "lazy" modifier. The late write-once case is not covered by Dart today, and the change to non-nullable types is mostly orthogonal to the feature. We need to do something for the non-nullable-variable-without-initializer case which is allowed by Dart today (because everything is nullable), but which won't be allowed under NNBD. The So, the changes to lazy, and the allowing it in new places, were necessitated by the NNBD change, which is why we are planning them now. The "write-once" variable is not made necessary by NNBD. If we can introduce it at the same time, and we have a good, consistent syntax and semantics for it, and the necessary time to implement it, then that's fine. If not, we are still no worse off than we already are, and we can add the feature at a later time. |
It's not that rare. We did that experiment, remember... :(
Won't argue with that... :)
This isn't really true. The most direct way to solve the NNBD issues is to add I think I am of the opinion that if I can only have one of |
Following up with some notes from discussion with @rakudrama . There's an interesting initialization pattern that he sees in code (particularly angular code) where a number of fields are initialized outside of the constructor, in one location, and in sequence:
Making these final would have optimization benefits elsewhere (you can eliminate redundant loads). You could optimize the initialization code very nicely, since you only need to check the first write (if the first write was not done, you haven't entered this code, and if was done, then it's an error). |
Based on discussion, I think I'm inclined towards the following:
I still have some doubts about re-using |
True, the problem we need to solve for NNBD is the ability to have non-nullable variables with no immediate initializer, which are then initialized later. Both So, both solve the immediate problem, both have extra uses, and it may or may not be too confusing to add both. Or we could combine them into one feature (using the word int x; // Compile-time error (unless instance variable initialized by all constructors)
int? x; // Allowed, eagerly initialized to null.
int x = 2; // eagerly initialized to 2.
int x? = 2; // eagerly initialized to 2.
final int x; // Compile-time error (unless instance variable initialized by all constructors).
final int x?; // Compile-time error (unless instance variable initialized by all constructors).
final int x = 2; // eagerly initialized to 2. Cannot be written.
final int x? = 2; // eagerly initialized to 2. Cannot be written.
late int x; // Throws when read, until written.
late int x?; // Throws when read, until written.
late int x = 42; // Initializes when read, unless written first.
late int x? = 42; // Initializes when read, unless written first.
final late int x; // Throws when read, until written, can only be written once.
final late int? x; // Throws when read, until written, can only be written once.
final late int x = 42; // Initializes when read, cannot be written.
final late int x? = 42; // Initializes when read, cannot be written. The There is the option of allowing a single write to |
I think your table is exactly the same as all of the cases I suggested/inferred here, so those all look great to me.
I could be wrong (and it would be great to get some UX data on this), but I'm not too worried about this. A reader would hopefully see I think it would have been a much worse foot gun to infer lateness from the nullability of the type because then it's really non-obvious what's happening. |
No, top-level variables and static fields would remain implicitly Also, implicit laziness is a good thing for these in terms program startup time. (Many years ago, there was discussion of also making instance fields be implicitly lazily initialized, but the language team felt that would be too surprising to users coming from other languages.) |
I don't think Leaf and Lasse are proposing to make top-level and static variables behave exactly like an instance variable with Making them behavior exactly like they implicitly have With an instance field or local variable, you see both the I don't think that implication carries over to implicit
I don't think it's reasonable to assume that you don't want a compile-time error that you forgot to initialize it. |
True. I think you should be able to write int x; // Compile-time error.
int? x; // Allowed, eagerly initialized to null.
int x = 2; // lazily initialized to 2.
int x? = 2; // lazily initialized to 2.
final int x; // Compile-time error.
final int x?; // Compile-time error.
final int x = 2; // lazily initialized to 2. Cannot be written.
final int x? = 2; // lazily initialized to 2. Cannot be written. |
Filed a discussion issue on the question of |
Is it I'd be fine with either way. I'm not sure which one I would prefer if we have to pick one order. |
I'm proposing
I also don't have super strong feelings though. |
Ok, I think this is fully resolved, at least until we get more data from prototyping. |
Playing around with the preview of NNBD, I've found a corner case where void main() {
late final int a;
void cb() {
a = 42;
}
print(a); // null
} or: void main() {
late final int a;
void cb() {
a = 42;
}
cb();
cb(); // assignment performed twice
} Is this intended? From my understanding, the compiler shouldn't allow assigning Nor should the compiler consider that the late variable is initialized if the only init is performed by a local function/closure. |
Yes, it is intended. Late variables are not sound. At all. You are allowed to assign a late final variable anywhere, except if the compiler can say with absolute 100% certainty that the variable is already initialized. The compiler isn't that clever about it. The compiler also doesn't consider the variable initialized. It considers it possibly initialized, which is why it would allow you to read it. There exists an assignment, and the compiler isn't clever enough to rule out that it has been executed, and since you said the variable was late, it assumes you know what you are doing. So, in short:
A late variable with an initializer is considered definitely assigned. |
I see. Then is there a plan to infer the situations where it's obvious that we're doing something illogical? For example I've also found we can do: late final a:
a = 0;
a = 1; or: late final a = 0;
a = 1; |
Yes. There is a "definite assignment"/"definite unassigned" analysis which tries (best effort, not too clever) to recognize when a variable is definitely assigned, when it's definitely not assigned, or when it's potentially either (can't say for sure). The last one is what happens if your code is too clever for the analyzer to figure out, and probably what happens for all non-local variables except in very clear cases. A case like The difference between late and non-late variables is what happens in the potentially assigned/unassigned case. A non-late variable cannot be read if it's only potentially assigned, it must be definitely initialized. A late variable can be read unless its definitely unassigned. In that case, the compiler trusts that you know what you are doing, but adds a run-time check if it's not definitely assigned, just to be sure. A final non-late variable can only be assigned if it's definitely unassigned. A final late variable can be assigned unless it's already definitely assigned. Again, in that case, the compiler trusts you, but adds a run-time check if the variable isn't definitely unassigned. I'm not sure about the current status of the implementation of this analysis. I believe most of it is working, but there can easily be edge cases we haven't covered yet. |
how thi let assign in this code https://laratuto.com/non-nullable-instance-field/ |
@rajkananirk class Question {
String questionText;
bool questionAnswer;
Question({required String q, required bool a}) {
questionText = q;
questionAnswer = a;
}
} The canonical way to write this in Dart, before and after null safety, is class Question {
final String questionText;
final bool questionAnswer;
Question({required String q, required bool a}) : questionText = q, questionAnswer = a;
} (I'd question the variable naming too. I'd probably go with: class Question {
final String question;
final bool answer;
Question({required this.question, required this.answer});
} but I can see that "q" and "a" are common abbreviations that make sense in the context.) |
A pattern that comes up with some frequency is to have fields in an object which is not initialized in the constructor (or at least not in the initializer list), but which should never be observed to be null once some further initialization is done. How should Dart with non-nullable types handle this? Some options that have been used in other languages:
cc @lrhn @eernstg @munificent
The text was updated successfully, but these errors were encountered: