-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
Create a variant of omit_local_variable_types that permits non-trivial declared types #58773
Comments
I agree that the results of type inference can sometimes be difficult for a human reader to predict, and that this can significantly impact the user experience. And I agree that adding explicit type annotations is one way to solve that issue by removing the need for the reader to predict the results. My concern with this proposal is that this lint currently enforces the style guide, and I wouldn't want to change the lint without changing the style guide to match. |
@bwilkerson wrote:
That would indeed be nice! The whole point with this issue is that it can be counterproductive to ban all declared types on local variables, because some of them are actually helpful (good for code readability and maintainability). I'm just asking that developers are allowed to use good judgment in this matter. By the way, the current version of // Define `f`, `g`, and `A` as before.
int v2 = g(f(A(null))); // No lint. I don't know exactly where the threshold is, but my proposal is simply to omit the lint in a larger set of cases, such that we only push developers towards I'm afraid we can't say that this is a bug, because the lint is implemented in a way that corresponds pretty well to the documentation and the style guide. So I'll remove the 'bug' label for now. But I'll be very happy if we decide to change the recommended approach slightly as proposed here, and in that case it will become an implementation request. |
Sounds good. @munificent Just FYI. |
I actually quite strongly prefer the current guidance in Effective Dart. I wouldn't support the lint deviating from it. My arguments are:
I think we should just leave the lint and rule as they are. |
@munificent, this is of course a topic where we disagree substantially, so I'll have to comment on it. ;-)
But I wouldn't expect you to argue that we should drop all static typing? I thought an argument against this proposal should be something which is specific to local variables.
Sure, and I would certainly expect that to be the typical approach in Dart. I even think we should nudge people in that direction in as many cases as possible when the type doesn't provide useful information, that is, when the type is obvious in the initializing expression.
So what's the type of No, I'm not going to tell you which type arguments So the type is not redundant for a reader of the code, and I don't understand why we need to shout at people who prefer to inform readers about such non-obvious types, in some carefully selected particularly complex situations.
Any user who wishes to omit the type of a local variable can freely do so because the lint will never ask for the type to be added, it will only ask to get the type removed (when it's obvious). So the only case where anyone would want to change the heuristic is when they want to have a type annotation in a case where the lint says that the type is obvious. I don't think that's very likely to happen, presumably the obvious types are obvious, and will remain so.
No, we never require a type annotation, we just stop complaining about a type annotation which isn't obvious. But it is true that the opposite change (static method turns into constructor) would cause the lint to ask for the type annotations to be removed, if any.
That's an interesting point! It could be useful to be able to detect the cases where the specification of a declared type changes the run-time semantics of the initializing expression (because type inference, coercion, generic function instantiation, etc. have a different outcome). However, I don't see how a mere upcast would be more valuable to document than a non-obvious type. After all, if we seriously don't care about the type then why is there an upcast in the first place? And if we do care about the type then why must it be impossible to document that type just because a potentially very complex type inference, coercion etc. step produced exactly the same type as the one that we wish to document? |
In: bar(foo(x))`; What's the type of argument passed to
All of this applies to my example too. We seem to be perfectly happy with not seeing type annotations on subexpressions, so I don't see a very compelling argument for them on local variables.
The linter shouts at people for lots of reasons that absolutely don't matter at all when it comes to understanding their code. The answer for that is almost always that consistency has its own value. If we let people redundantly annotate local variables, then readers will be presented with some code that chooses to do that and some code that chooses to not. That difference will get their attention and distract them from other aspects of the code. They may wonder, "Is this an upcast?" (Or worse, it will be an upcast and they won't realize it because they think it's just a redundant annotation.) They may wonder why the author chose to annotate this one and not others nearby. Was there a reason for that, or was it just that one author had one preference and other authors of nearby code had others? Overall, I haven't seen any compelling evidence that allowing variation here carries its weight.
They can opt out of annotating in code they write, but they can't opt out of being presented with code written by others who made a different choice.
I have literally watched this happen on large teams multiple times over my career, including in Dart.
Because it tells the reader that the variable's type is not what they would intuit from the initializer expression.
So that the variable can be assigned a value later whose type isn't a subtype of the initializer's type. So seeing the type annotation on the local sends a useful signal of "hey this is going to be mutated later to a different type".
It's not impossible. This is just a lint. But the lint does reflect what we think leads to the best, most consistently readable code across the ecosystem. |
Certainly, but the point is that software engineering quality includes readability and comprehensibility as core elements, and one of the foundational tools used to improve on the readability of a snippet of code is to split up large expressions by evaluating subexpressions and storing their value in local variables. So let's say that var name = foo(x); // Where `name` is chosen such that it helps the reader.
... bar(name) ... One difficulty with this kind of refactoring is that the type inference process works differently when SomeType name = foo(x);
... bar(name) ... However, the declared type can be helpful for a reader of the code, because it helps them understand the meaning and purpose of I'm simply saying "let's stop shouting at the developer" in the case where
This would be a likely outcome if half of the community enables What I'm proposing is that the people who agree with me that documenting local types can be good for readability in a few cases can enable But in their daily work they'll have lots of little nudges in the direction of omitting that type, because there are so many cases where the type is obvious, and So the few local variables with a type will stand out, and it's a rather safe bet that the developer who wrote it wanted to have the type in that particular case because they needed to think hard about that particular type when they wrote the code, and then it's probably helpful to have it there when we read the code. So the proposal will very specifically not nudge developers in the direction of writing the types just because that's a habit, each local variable with a type is there because someone actively made that declaration different from most others.
Today, those others would simply not enable With this proposal, they might accept enabling
We don't quite agree. ;-) It just rubs me the wrong way to force developers into lowering the software engineering quality of their code, just because we refuse to give them a little bit of extra freedom to use human judgment when needed. |
So far, The proposal in this issue sounds like a nice compromise to me between never specifying types and always specifying types since the latter can get tedious in the obvious cases as well. If a middle ground is implemented, it would probably be easier to get that included in |
I think this is worth getting some real world feedback on. Given @goderbauer's endorsement, we might even see some traction in flutter which would be fantastic. I'd support an experimental lint implementation that can be experimented with and iterated on. Ideally with links to the relevant conversations and a caveat in the docs. |
IIRC at least some of the folks who did not want to use |
Yes, I for one would strongly prefer that we can fully automate where types need to go and where not to avoid back and forth in code reviews about this. |
I think if we can get to a point where types are almost always omitted, but not flagged in some cases, that's good enough (I believe that's @eernstg's prototype above). I do wonder if it's just easier to push us on to
There isn't a world where we are going to write the perfect the lint that will be universally adopted with the exact definition of "ambiguous" type, so we either need to be pragmatic (allow code-reviewer/author-driven type annotations) or we need to be dogmatic (just use In other words, I understand your request @goderbauer - I don't think it's feasible. Consider the following cases: // Should this be OK?
var x = 5;
// Should this be OK?
int x = 5;
T identity<T>(T value) => value;
// Should this be OK?
var x = identity(5);
// Should this be OK?
int x = identity(5);
// Should this be OK?
var x = identity<int>(5);
// Should this be OK?
int x = identity<int>(5); This is a tiny subset of the cases that will need to be considered. For example in @eernstg's prototype: test_10_newGeneric_ok() async {
await assertNoDiagnostics(r'''
f() {
A<num> a = A<int>();
}
class A<X> {}
''');
} This doesn't generate a diagnostic, but AFAICT neither does this: test_10B_newGeneric_withVarTypes() async {
await assertNoDiagnostics(r'''
f() {
var a = A<num>();
}
class A<X> {}
''');
} In other words, authors/reviewers now have a choice between multiple styles. |
Those points are well taken! @natebosch wrote:
Creation of software has many degrees of freedom, and we already entrust authors and reviewers with a lot of freedom to make good decisions about a huge number of things. That's the way it should be! For example, they make choices all the time about which local variables to create in the first place: void main() {
// Approach 1.
var x = someExpression;
foo(x);
// Approach 2.
foo(someExpression);
} The difference does matter (it affects the readability of the code, it may change the type of However, I don't see us introducing rigid rules about preferring approach 1 over 2 or vice versa, based on the complexity of the expression (or indeed based on anything). Similarly, I think we can and should rely on good human judgment about local variable type annotations. Surely we can handle that!
We already have a situation where num i = 1; // No complaints from `omit_local_variable_types`.
@goderbauer wrote:
I agree with @matanlurey here, I honestly don't think that's realistic. We could approach it from above (saying "take away this type annotation" in cases where it is considered obviously unnecessary) and from below (saying "add a type annotation here" for whatever reason, e.g., "the initializing expression has a non-obvious type").
I suspect that the approximation from below would be much more difficult to support mechanically. To give a silly example, we could single out local variable declarations whose initializing expressions have at least 3 of the following dependencies:
There are lots of ways to establish delicate typing related dependencies, and I'm just saying that we shouldn't yell at people who want to narrow the ambiguity a some situations by declaring the type of a local variable. @matanlurey wrote:
Indeed! But I'd say that this is inherently a property of software construction and maintenance, and we should be able to handle it also in the particular case of type annotations on local variables. Moreover, |
To be clear my comments about choice seeming like a bad thing are in the context of wanting a single enforceable lint that does everything. (In my own projects I happily use omit_local_variable_types with no issue, but having a omit_local_variable_types_sometimes if it doesn't have opinionated heuristics about when types are required seems like a reasonable workaround for the Dash org). |
The fact that the lint permits This lint is pretty much a cornerstone of google3 Dart style after being enforced 5 years ago, if there is a need for a variant with heuristics then I do think a new lint would be better than a change to this one. Thanks. |
Certainly, and this property is preserved. Note, however, that List<X> listOfWhatever<X>() => <X>[];
void main() {
double d = 1; // LINT.
List<int> xs = listOfWhatever(); // LINT.
}
Right, and https://dart-review.googlesource.com/c/sdk/+/374601 is indeed a separate lint whose name is different ( |
This CL adds an implementation of a lint named 'omit_obvious_local_variable_types' which is a variant of 'omit_local_variable_types' that only flags type annotations of local variables when the static type of the initializing expression is obvious, as defined in https://github.com/dart-lang/linter/issues/3480. Change-Id: I7e9b5adde5839ad8f9dfa88833da82d7bc1b65de Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/374601 Commit-Queue: Erik Ernst <eernst@google.com> Reviewed-by: Phil Quitslund <pquitslund@google.com>
linter:
rules:
- omit_obvious_local_variable_types Feedback is welcome, of course! |
FWIW, this is firing in fewer situations that I would expect (i.e., |
That was my reasoning as well, initially. We can change it back and forth, until we're mostly happy, it's a one-liner. ;-) One thing that might matter is that any integer literal will now (where the type of an integer literal is considered non-obvious) potentially cause a bigger type to be needed: void main() {
// Needs a type.
Map<String, Map<bool, Map<List<double>, int>>> map1 = {
'a': {
true: {[2.5]: 1}
}
};
// Doesn't.
var map2 = {'a': {true: {[2.5]: 1.5}}};
} |
I just got back from vacation and have been catching up on this issue. I am super excited about the latest developments here! Thank you @eernstg and everyone else for all the work that went into this so far. I saw that the bleeding edge dart version that contains these new lints just rolled into the Flutter framework. I plan to experiment a bit with them on our code base and will report back my experiences. |
@matanlurey this motivating example from earlier (which IIRC came from real world code that I was testing against):
...is pretty compelling to me. If we replace "int" with "var" then it's very easy to get in situations where the code looks very much like it is doing one thing, even though it's unambiguously doing the other thing if you happy to know all the rules, and editing the code later leads to unexpected behaviour (e.g. if you add a call to a function that expects a double, and pass @eernstg That example is really interesting. It's a perfect example of why I think there's value in treating int literals as unobvious. Without that, you'd get no type annotations there, but the type would be very much not what you intended, and would change if you changed |
Right, that's also a static error, your code will not compile and the analyzer will complain. We will not optimize for folks using a plain-text editor to edit Dart source code in 2024. |
I'm not sure what you mean about plain-text editors. My point is that when you're reading the code, despite it being technically unambiguous (all statically valid code is technically unambiguous), it's not at all intuitive what the type is in this kind of scenario. |
@Hixie wrote:
I do see the value of treating integer literals as unobvious because an integer literal can have the type However, if it is evident that there is no context type then the integer literal will definitely have the type In the particular case where a local variable declaration has a shape like For the example where We might then use silly tactics like this: void main() {
// Doesn't need a type any more, because we fixed that integer literal.
var map1 = {'a': {true: {[2.5]: (1 as dynamic) as int}}};
// Doesn't need a type, either. Same as before.
var map2 = {'a': {true: {[2.5]: 1.5}}};
} A type cast is considered to have an obvious type, so we can use The point is that even in a situation where an integer really, seriously is intended to have the type It is a separate discussion that we might not want complex terms to have an obvious type (because we have to read a lot of code before we know what that type is, even in the case where each "atom" in the composite term has an obvious type). I don't see an easy way out of that, but we could of course say that any expression which has more than two levels of nesting is non-obvious, or anything more than 50 characters is non-obvious, etc. |
Trying out these two new lints in the Flutter framework I am running into two technical issues (I haven't looked at what the code with reduced types looks like yet): dynamicThe void _foo(Size size) {
final dynamic bar = size.width; // 🔥 LINT: specify_nonobvious_local_variable_types
print(bar);
} We have about ~140 of those cases in the Flutter code base (mostly in tests). I believe an explicit dynamic should not trigger the lint. LinkedHashMapIn a handful of instances we have code like this, which the new lint now complains about: LinkedHashMap<String, int> _bar() {
final LinkedHashMap<String, int> foo = LinkedHashMap<String, int>(); // 🔥 LINT: omit_obvious_local_variable_types
return foo;
} Removing the "obvious" type triggers another lint: LinkedHashMap<String, int> _bar() {
final foo = LinkedHashMap<String, int>(); // 🔥 Unnecessary constructor invocation. (prefer_collection_literals)
return foo;
} Fixing this lint warning results in an error: LinkedHashMap<String, int> _bar() {
final foo = <String, int>{};
return foo; // 🔥 A value of type 'Map<String, int>' can't be returned from the method '_bar' because it has a return type of 'LinkedHashMap<String, int>'. (return_of_invalid_type)
} How should this code be fixed? I am now waiting for https://dart-review.googlesource.com/c/sdk/+/380500 to roll into the flutter framework before taking a closer look at the code. |
Good point, thanks!
Here is one approach: LinkedHashMap<String, int> _bar() {
final LinkedHashMap<String, int> foo = LinkedHashMap();
return foo;
} The point is that With abbreviated references to static properties, this could be abbreviated as follows, which could be a justification for using this LinkedHashMap<String, int> _bar() {
final LinkedHashMap<String, int> foo = .new();
return foo;
} By the way, I don't understand why |
Regarding the |
The rule we settled on is "you can only use There was still some desire to allow |
In the cases where you don't notice, it won't matter. In the cases where it matters a static error will make you notice. I don't think "this snippet of code might be ambiguous" is at all convincing when the details that are ambiguous don't cause bugs. What are the specific wrong decisions an author would make, that would not be caught by the compiler before they cause problems, due to not understanding whether a loop variable is an |
I mean, it's your call, obviously, but personally I find the programming experience to be much more pleasant (and faster) when I find I understand the code well enough to not make mistakes that the analyzer or compiler then has to tell me about. |
As I mentioned here, integer literals are considered non-obvious at this time. I'd suggest that we gather some experience with that. |
You can always write a type if you want to, and not use this lint (or an We'll assign an "obvious type" to some expressions, independent of context type and always the type they would have with no context type, and if your local variable is declared to have the obvious type of its initializer expression, that declared type is unnecessary. Then we can define two lints from this:
You can use the latter without the former to allow omitting any non-obvious types, if you think they are obvious ( The rest is just deciding on which expressions have obvious types, and which obvious types they have. I'm with @matanlurey and would consider There are places where the type of Which means: var x = 1; // Fine.
int y = 2; // LINT
double z = 3; // Fine. The That does require the "compute obvious type" to have access to the original source code. If a prior step throws away the One worry is that any later change to which types are obvious will cause a lot of warnings. That's why it's important to get it right, and make it simple enough that new language features are unlikely to change the obvious type of expressions that don't use the new feature. (Or where the new feature isn't visible in the syntax, but that's problematic design to begin with.) (Also, never type your variable as |
On When a given piece of code relies on that insertion-order semantics, it seems appropriate to express that in the type. There are a handful of places in Flutter that do that, like If we wanted a cleaner way to express that required semantics, it could look like a marker interface |
@lrhn wrote:
This is exactly the behavior we had with earlier versions of the lints, and I'm still in the camp that prefers this approach. However, in the current version of the lints we do consider an integer literal to have a non-obvious type, unconditionally. If this behavior doesn't give rise to any complaints then that's a result which is worth having in mind as well. It should be noted that |
@gnprice Types and semantics and APIs are correlated, but they are not the same. I'd still write the code as // Map preserves insertion order.
final Map<T, int> _map = <T, int>{}; It seems preserving insertion order is the goal. It's an implementation choice to use (For the code in question, I'm not even sure preserving insertion order is that relevant. If you add the same element more than once, and remove it again, you effectively always remove the last one, because the first ones interation position is kept. That seems arbitrary, you could have removed the first one, like what Anyway, sorry for the diversion. |
Count me as complaining if But then, I'm probably not going to use the lint anyway. I might want a "do type non-obvious values" lint, but the "don't type obvious values" lint isn't worth the hassle. If I've written a type that is correct, and I simplify the expression to the point where its type becomes obvious, I don't want to be urged to remove the type. That's simply not worth my effort. |
As of 79e8869, |
Can we call this closed, @eernstg? With the rules out in the wild, we can address any issues that arise as individual issues? |
Yes, thanks! |
[Edit Aug 2024: Changed the title: New lints are introduced,
omit_local_variable_types
is not modified.]This issue is a proposal that
omit_local_variable_types
is mildened such that it only flags local variable declarations with a declared type in the case where the variable has an initializing expression with a type which is not 'obvious' (as defined below).This is a non-breaking change, because it makes
omit_local_variable_types
flag a smaller set of situations.It should still flag cases like
int i = 1;
(which should bevar i = 1;
),List<int> xs = [1, 2, 3];
(which should bevar xs = [1, 2, 3];
),C<num> c = C(1.5);
whereC
is a class (it should bevar c = C<num>(1.5);
),C<double> c = C(1.5)
(which should bevar c = C(1.5)
, assuming that it infers asC<double>(1.5)
),etc, because the initializing expression has an 'obvious' type.But it should allow
Foo<int> foo = someFunction(a, b());
even though that might also be expressible asvar foo = someFunction<Whatever, It, Takes, To, Make, It, A, Foo_int>(a, b<Perhaps, More, Stuff>());
. It should actually also allow it even in the case where the same thing is expressible asvar foo = someFunction(a, b());
, because the type of the initializing expression isn't obvious.The motivation for doing this is the following:
The declared type of a local variable may be considered to be a simple, self-documenting, declarative approach to determine which actual type arguments to provide at various locations in the initializing expression. If, alternatively, we pass some actual type arguments in the initializing expression, we might infer the same declared type, but (1) it is not documenting the type of the variable (which means that it may be hard to understand how to use it correctly), (2) it is not simple (because we may need to look up various declarations in order to determine the type of the initializing expression), and (3) it is not declarative (because we provide feed-forward information in the initializing expression, rather than specifying the desired end result and relying on type inference to sort out the details). For example:
The lint
omit_local_variable_types
will flagv2
, because the declared type is seen as unnecessary. The perspective that motivates this issue is that it is indeed possible to infer exactly the same declared type, but it may still be helpful for all of us to have that declared type when we're reading the code. In particular, we'll at least need to look up the declaration ofg
before we can even start guesstimating the type ofv1
. So the proposal here is to makeomit_local_variable_types
stop linting these non-obvious cases, thus allowing developers to document the type where it's actually needed.(Interestingly,
omit_local_variable_types
does not flagint v2 = g(f(A(null)));
, which seems to contradict the documentation of the lint, so maybe the lint is already doing some of the things which are proposed here. It clearly does not flag every local variable declaration that has a declared type which is also the inferred type of the initializing expression).Note that it is assumed that
omit_local_variable_types
will never flag a declarationT v = e;
where the declared typeT
differs from the type ofe
which is inferred with an empty context type, or where the type inference one
with an empty context type produces a different result (different actual type arguments, anywhere ine
) than type inference one
with the context typeT
. This proposal is not intended to change anything about that, and it should not affect any of the issues where it is reported thatomit_local_variable_types
has a false positive, because it lints a case where it does make a difference. This proposal is only about not shouting at people when they have a declared type that may arguably be helpful.Obvious Expression Types
In order to have a mechanically decidable notion of what it takes for a type to be 'obvious' we need a precise definition. The point is that it should recognize a number of types that are indeed obvious, and then all other types are considered non-obvious.
This means that
omit_local_variable_types
will allow (not force) developers to declare the type of a local variable with an initializing expressione
whene
has a non-obvious type, but it will continue to flagT v = e;
when the type ofe
is obvious.An expression
e
has an obvious type in the following cases:e
is a non-collection literal. For instance,1
,true
,'Hello, $name!'
.e
is a collection literal with actual type arguments. For instance,<int, bool>{}
.e
is a list literal or a set literal where each element has an obvious type, and all elements have the same type. For instance,[1, 2]
,{ [true, false], <bool>[] }
, but not[1, 1.5]
.e
is a map literal where all key-value pair have a key with an obvious type and a value with an obvious type, and all keys have the same type, and all values have the same type. For instance,{ #a: <int>[] }
, but not{1: 1, 2: true}
.e
is a record literal where every component has an obvious type. *For instance,(1, enabled: true)
, but not(1, enabled: foo())
. *e
is a local variable that has not been promoted. *For instance,int i = 1;
thenvar x = i;
.e
is an instance creation expression whose class part is not raw. For instanceC(14)
ifC
is a non-generic class, orC<int>(14)
ifC
accepts one type argument, but notC(14)
ifC
accepts one or more type arguments.e
is a cascade whose target has an obvious type. For instance,1..isEven..isEven
has an obvious type because1
has an obvious type.e
is a type cast. For instance,myComplexpression as int
.e
is a conditional expression where both branches have an obvious type, and they have the same type. For instance,condition ? 'yes' : 'no'
.e
is a type test. *For instance,myVariable is String
.e
is a throw expression. For instance,throw myError
.e
is a parenthesized expression where the contained expression has an obvious type.e
isthis
.e
is a type literal. For instance,String
.Discussion
We may need to add some more cases to the definition of an obvious type, because some other types may actually be obvious to a human observer. However, it is not a big problem that some types are obvious but are not recognized as such, because this means that developers have the freedom to declare a type when they want to do that. If the set of obvious types is enlarged later on, it will be a breaking change, but if the given type is actually obvious then there will be few locations where the new, more strict, version of
omit_local_variable_types
will flag a declaration, because few developers have actually declared the type in that case. So the changes that are actually breaking are also the ones that may be questionable, in which case the type might not be so obvious after all.One outcome that we could hope for if
omit_local_variable_types
is mildened as proposed here is that this lint could become a broader standard (for example, it could be a member of the recommended set of lints), because a larger percentage of the community would be happy about the way it works.Versions
e as T
) is now considered obviously typed.specify_nonobvious_local_variable_types
, and broaden the notion of what it takes to be an expression that has an obvious type.The text was updated successfully, but these errors were encountered: