-
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
Should we provide a way to query the status of late
variables?
#324
Comments
Not really, at least as I understand it. The |
My quick answer is "no", let's try to get by without it, at least for now. If you need access to that bit, you can always not use // Instead of:
class Memo<T> {
late T value_;
void store(T value) {
value_ = value;
}
T getOrDefault(T defaultValue) {
if (<magic...>) return value_;
return defaultValue;
}
}
// Do:
class Memo<T> {
T? value_;
bool hasValue_;
void store(T value) {
value_ = value;
hasValue_ = true;
}
T getOrDefault(T defaultValue) {
// Can't check for null value_ here since T might be a nullable type.
if (hasValue_) return value_!;
return defaultValue;
}
} My hunch is that this need is rare enough that the workaround isn't too onerous. It might be nice to eventually provide access to the state somehow, but that tiptoes towards something meta-programmy around working with identifiers as storage locations instead of as the values they contain. I'm not sure if we're ready to bite that off just now. This is a feature I think we could add later without undue pain today. Here's some more stuff that might be related: Detecting optional parameter passageWe have an analogous issue around detecting if an optional parameter was explicitly passed or not. The language does track that extra bit of data because it needs it to determine whether the default value should be used or not. (I hoped we could eliminate that bit of magic in Dart 2.0, but alas.) Consider: original([param = "some secret value only original knows"]) {
print("original got $param");
}
forward([param]) {
original(param);
}
main() {
original();
original(null);
forward();
forward(null);
} There's no way to implement Ancient Dart used to support this with a forward([param]) {
if (?param) {
original(param);
} else {
original();
}
} The problem, of course, is that it's scales exponentially (!) in the number of parameters you want to forward. To forward a function with five optional parameters, you have to write out all 32 combinations. The operator was killed but, unfortunately, the hidden state wasn't. I bring this up because it's another place where the runtime tracks state that the user can't access. If we want to solve it for late variables, we may want to see if that solution can cover this too. A third potential use is conditional logic mixed with definite assignment for locals. I think we want to support: int i; // Non-nullable.
if (condition) {
i = 1;
} else {
i = 2;
}
i.isEven; // OK. It's not inconceivable that a user might want to write something like: int i;
if (condition) i = 1;
stuff();
if (another) i = 2;
moreStuff();
if (<i wasn't caught by either of the above two cases) i = 3;
i.isEven; Capturing a parameter's nameWhen you throw an ArgumentError, it's useful to include the name of the argument in the error message: sqrt(int i) {
if (i < 0) throw ArgumentError.value(i, "i", "Must be positive.");
// ...
} Right now, you use a string literal, which is brittle in the face of typos and parameter renames. C# 6.0 added a sqrt(int i) {
if (i < 0) throw ArgumentError.value(i, nameof(i), "Must be positive.");
// ...
} You can also use it to capture other named elements like classes and maybe members. It is unfortunate that the syntax is much longer than the string literal. A C programmer would say this is a good place to use the preprocessor and the stringizing operator. |
Another vote for "no" – worried that we'd lose the ability to optimize this in many contexts if we need to be able to query the value. Revisit if there's a screaming user requirement. |
Also "no" from me. It smells like reflection, and it's at a local variable level, where we never did reflection before. Giving that ability would mean that we are forced to delay evaluation of And if our experience with allowing you to query a parameter for being passed or not was anything to go by, this ability might cause more problems than it solves. We will likely see So, too scary for me. (I do want a |
It's worse than that. If it was just for locals, we could easily statically tell if the query capability was ever used and if not still optimize the lateness away when possible. But because public fields can be marked
Everyone knows the correct three states are true, false, and fileNotFound. |
Ok, this seems like a clear no. |
I want to re-open this at least briefly to discuss this again in light of some experience with the migration. In migrating the core libraries, we have a number of examples of code that ends up being migrated to look like the following: E singleWhere(bool test(E element), {E orElse()?}) {
late E result;
bool foundMatching = false;
for (E element in this) {
if (test(element)) {
if (foundMatching) {
throw IterableElementError.tooMany();
}
result = element;
foundMatching = true;
}
}
if (foundMatching) return result;
if (orElse != null) return orElse();
throw IterableElementError.noElement();
} This isn't terrible, but it does have some redundancy in that you are forced to explicitly represent the state of the late variable (which must be tracked by the compiler anyway) . Does this example cause anyone to change their mind about this? |
Isn't this just the wrong application of E singleWhere(bool test(E element), {E orElse()?}) {
E? result;
for (E element in this) {
if (test(element)) {
if (result != null) {
throw IterableElementError.tooMany();
}
result = element;
}
}
if (result != null) return result;
if (orElse != null) return orElse();
throw IterableElementError.noElement();
} |
The tricky bit here is that |
Agree, you would still need the extra boolean, but it's true that it's duplicate effort to have an extra "isInitialized" boolean and a late field. So: E singleWhere(bool test(E element), {E orElse()?}) {
E? result;
bool foundMatching = false;
for (E element in this) {
if (test(element)) {
if (foundMatching) {
throw IterableElementError.tooMany();
}
result = element;
foundMatching = true;
}
}
if (foundMatching) return result as E;
if (orElse != null) return orElse();
throw IterableElementError.noElement();
} would be the corresponding non-late approach. Another approach is to do it with two loops: E singleWhere(bool test(E element), {E orElse()?}) {
var it = this.iterator;
while (it.moveNext()) {
E result = it.current;
if (test(result)) {
while (it.moveNext()) {
if (test(it.current)) {
throw IterableElementError.tooMany();
}
}
return result;
}
if (orElse != null) return orElse();
throw IterableElementError.noElement();
} We are storing a state into a variable instead of keeping it in the control flow. The value of |
It would be convenient to be able to use the data that the implementation must maintain for a late variable without initializer: E singleWhere(bool test(E element), {E orElse()?}) {
late E result;
for (E element in this) {
if (test(element)) {
if (result.isInitialized) {
throw IterableElementError.tooMany();
}
result = element;
}
}
if (result.isInitialized) return result;
if (orElse != null) return orElse();
throw IterableElementError.noElement();
} This code is nicer than the code that we'd use today: We avoid allocating the extra variable Given that it is semantically quite different from a member access, we might prefer a specialized syntax for it. However, no specialized operators seem to work really well, and we could also treat This would also make it non-breaking, and we could add it at any point in the future where we have the resources to do it. PS: I don't think it smells like reflection, it smells much more like using a resource which is guaranteed by the language semantics to be available anyway, and it's definitely not costly in a way that resembles reflection, so why not. ;-) |
My gut feeling is that we are breaking an abstraction, and that is always bad. In the long run it may cause us as much anguish as the "is optional parameter passed or not" query would have. If a late variable needs to be queried, it's no longer just a late variable, it's an optional variable. Use a nullable variable if the type is not nullable, or an |
Why wouldn't it be just as reasonable to consider a late variable along with its magic The With a late variable with no initializer the language does promise to be able to determine dynamically whether an initialization has taken place (such that we can throw if we're reading it too early, and we can throw if a |
It does feel weird that the runtime has to track some state that the user can't access. At the same time, I think using // foo.dart
late int i;
// main.dart
import 'foo.dart';
main() {
print(i.isInitialized);
} Here, if the maintainer of "foo.dart" removes We could say that you can only use the magic "is initialized" API inside the library where the member is declared, but in that case, it pushes even more towards having a different syntax instead of it looking like a getter. But perhaps the simpler way to think about this problem is: If you don't like that the runtime has a bit you can't access, instead of adding a way to access it, you can just choose to not use |
Thinking about this, an additional concern is that this potentially forces the compiler to maintain the "isInitialized" state even when it is not otherwise needed (especially in a modular setting). For example, I expect that in many cases the compiler will be able to prove that a |
@leafpetersen wrote:
How would we prove statically that a field with no initializer is always initialized before it is accessed, if that isn't because it always gets initialized in a constructor initializer list? In that case I don't think there is any reason for it to be @tatumizer wrote:
If anybody is worried that this could be confusing, we can always lint |
Erik Ernst <notifications@github.com> wrote:
@leafpetersen <https://github.com/leafpetersen> wrote:
prove that a late field is initialized before it is ever accessed
How would we prove statically that a *field* with no initializer is
always initialized before it is accessed, if that isn't because it always
gets initialized in a constructor initializer list? In that case I don't
think there is any reason for it to be late.
There's also the constructor body.
If you can see that `this` is not leaked before the late field is
initialized in the *body* of the constructor, then it can also be optimized.
It still has to be `late` because it's not initialized in the initializer
list/parameters of the constructor.
@tatumizer <https://github.com/tatumizer> wrote:
isInitialized call can be supported for any field,
If anybody is worried that this could be confusing, we can always lint
v.isInitialized when it is trivial (e.g., when v is not late, or when v
is a local variable which is 'definitely assigned' or 'definitely not
assigned' at the point where v.isInitialized occurs).
I'd prefer to just add an `Option` type. I believe one is already in
general use. Then use a non-final optional field instead of a late field.
Perfectly represents the behavior you are after.
No matter what you do, there will be a check before accessing the value.
There are already so many ways to do that, there is no need for one more.
|
Sure, if you're doing it really, really early in that constructor body. ;-) class A {
late int i, j;
A() {
i = 1; j = 2;
}
}
class B extends A {
set i(value) {
print(super.i);
print(j);
super.i = value;
}
}
main() => B(); (The tools don't currently implement enough to handle this code, but |
From discussion this morning, we will not provide support for this for now. There are too many concerns about this becoming part of the API, and about implications for optimizability, as well has how to present this nicely to the user. Adding an "external" extension method would be one approach, but this is unappealing, since without extensive custom static error checking, the method would be applicable in nonsensical places. If we choose to do this in the future, there is an expressed preference to instead make this state available via a "pseudo variable/field" in the syntax, something along the lines of: late (int x, bool isInitialized); or perhaps late int x {isInitialized}; |
I know this was closed a while ago, but don't the issues described go away if the feature is implemented like that?
I find myself writing the code with a separate bool value that I have to maintain quite often, and if the runtime can do that for me then that's great. |
I do think the API based problems go away if we, say, allow I think it's actually better, on average, to not do anything here. Now, if we had a different syntax for getter/setters, maybe one where you could encapsulate a field or other propertie, then it could fit in more easily. int x { // declare setter and getter with same name and type easily.
get => _x;
set => _x = it;
}
late int y { // Use *same kind* of block syntax for init property of late variable.
init _init;
} Then the syntax would not be as foreign. Even then I still think exposing initialization is a breach of abstraction and will prevent perfectly good optimizations, and that's reason enough to not do it. |
FWIW I ran into sort of wanting this just now. I don't think we should have a way to actually do it. What I had was a field that I'd only set once, to a non-null value, and later I would either use it if it was non-null or else not use it. I could express this as a nullable mutable field, but the mutability doesn't really convey that it should only be set once. I could use a late final nullable field, but then I'd have to explicitly set it to null in the constructor, which is awkward. Maybe that's the right answer though. |
We don't have a way to represent "initialized to null and then set once to non-null" (would that be "single promotion" rather than "single assignment"?). That's just a nullable variable. Using |
In the case I had, if it was ever initialized, it would be done in the constructor. I worked around it by adding an "else" branch that initialized it to null explicitly. It's a bit ugly but it works with |
I also see that |
Some tips I came up with from advice of different dart maintainers, and my self-analysis:
|
As proposed, the only way to query the state of a late initialized variable to see if it has been written would be to try to read it, and catch the resulting error. We explicitly discourage catching errors, so this seems non-ideal. Should we provide this functionality? And if so, how do we expose it?
For comparison, Kotlin provides a way to do this via reflection.
If we do provide this, then what is the right behavior for it on
late int x = 3 + 3
? Is this variable considered initialized before the first read/write (since it has an initializer)? Or is it considered uninitialized since the initializer has not yet been run (and may in principle never complete with a value and cause the variable to actually be initialized)?The text was updated successfully, but these errors were encountered: