dart-lang / lints

Official Dart lint rules; the core and recommended set of lints suggested by the Dart team.
https://pub.dev/packages/lints
BSD 3-Clause "New" or "Revised" License
118 stars 30 forks source link

Consider `avoid_dynamic_calls` as a lint rule. #44

Open srawlins opened 3 years ago

srawlins commented 3 years ago

I think avoid_dynamic_calls would be a great addition to the lint rule sets. Either in core or in recommended.

https://dart-lang.github.io/linter/lints/avoid_dynamic_calls.html

It is currently marked experimental. See https://github.com/dart-lang/linter/issues/2666

mit-mit commented 2 years ago

Not approved: dynamic is fully supported in the language, and something a developer is free to use if they desire.

yjbanov commented 2 years ago

@mit-mit How does a lint prevent a developer from using dynamic calls if they desire? :thinking: My understanding is that the lint would be used (optionally) by developers who do not desire to have dynamic calls in their codebase, or at least, in rare situations where they do need one, they want it flagged and worked around using an // ignore: avoid_dynamic_calls.

I was looking forward to this lint to use in the Flutter Web engine. We don't need dynamic invocations in the engine, but our codebase is big, so we don't know if we have some dynamic calls that sneaked in by accident, potentially hurting performance and/or code size.

Also, my understanding is that the lint doesn't lint against dynamic. It only lints against dynamic invocations. For example, the following code is totally legal:

dynamic json = parseJson(jsonString);
if (json is Map) {
  dynamic someField = json['some_field'];
  if (someField is num) {
    print(someField.toStringAsFixed(2));
  } else if (someField is String) {
    print(someField);
  } else {
  // ... etc ...
}

What would be flagged is this:

dynamic json = parseJson(jsonString);
json['some_field'].toStringAsFixed(3);
//  ^              ^
//  |              | dynamic invocation of toStringAsFixed
//  dynamic invocation of operator[]

Those invocations can crash, and they can cause retention of methods on unrelated classes. In codebases, such as the Flutter Engine, flagging such situations would be super useful.

(Edit: minor fixes; clarification; examples)

mit-mit commented 2 years ago

This issue doesn't track the availability of this lint; it tracks whether the lint is enable by default in package:lints / package:flutter_lints. Thus the triage decision is wrt. what we decide to enforce out of the box, not what you can individually configure in your analysis_options.yaml file. Does that explain our triage decision?

mit-mit commented 2 years ago

Oh, but maybe the confusion is that @srawlins logged this as a request to have this as a supported lint? Then we can certainly reactivate and transfer to https://github.com/dart-lang/linter/issues...

srawlins commented 2 years ago

The rule exists. This was a request to add the rule to the lints:core or lints:recommended rule set.

yjbanov commented 2 years ago

Ah, cool. If this can be optionally added to analysis options, I have no further concerns.

goderbauer commented 2 years ago

I hear there were some recent discussion around dynamic in the dart language team and the notes of that discussion made me wonder whether it would actually be a good thing to push people towards avoiding dynamic calls by adding this lint to "recommended" after all.

/cc @munificent @eernstg you were part of these discussions. Curious to hear what you think.

eernstg commented 2 years ago

I guess it is possible to disable a recommended lint in a package and certainly using // ignore_for_file:.... That might make it a good trade-off to enable this lint for anyone who doesn't actively turn it off. After all, a dynamic invocation which is not seen as such can be rather misleading when it comes to potential run-time errors.

goderbauer commented 2 years ago

@mit-mit Can you reopen this issue for reconsideration during our next lint triage?

goderbauer commented 1 year ago

It came up in a recent discussion that a surprisingly high number of the top 200 runtime errors reported by our flutter analytics are related to dynamic. There is some hope that enforcing a lint like this (and/or https://github.com/dart-lang/lints/issues/125 could help in reducing that number).

devoncarew commented 1 year ago

I guess it is possible to disable a recommended lint in a package and certainly using

From a discussion, I believe another way that was proposed to disable this lint was via an explicit cast; so:

(value as dynamic).fooBar();

cc @leafpetersen

lrhn commented 1 year ago

Together with strict-casts (#125), this would completely remove dynamic from the language - no implicit down-casts and no dynamic invocations.

If we're not willing to actually remove dynamic from the language, I don't think we should enable these restrictions by default, which is what adding to core or recommended would imply.

srawlins commented 1 year ago

If we're not willing to actually remove dynamic from the language

A big "if"! Surely not a settled decision, but an open possibility.

goderbauer commented 1 year ago

If we're not willing to actually remove dynamic from the language

To me, the lints we recommend are about best practices - and from our crash reporting we can see that a lot of people are struggling with dynamic. Therefore, it would make sense to me to guide people away from this particular language feature. That doesn't necessarily mean that the feature has to be removed from the language (that can be a separate discussion). We enable some other lints as well where we guide people away from certain coding patterns that are technically possible in Dart, but good practice has shown they should be avoided.

munificent commented 1 year ago

and from our crash reporting we can see that a lot of people are struggling with dynamic.

When I've written an app that's talking to a JSON API I don't know well, sometimes I just guess at the API by casting stuff and then let the crashes incrementally teach me the API's schema. I wonder if some fraction of these crashes are coming from users that are just doing "let me just try it and see what happens" and don't indicate a real source of pain in the language.

mit-mit commented 1 year ago

I wonder if some fraction of these crashes are coming from users that are just doing "let me just try it and see what happens"

Are we able to pivot the crash reporting on release mode (debug vs release)? I'd suspect such "let me just try it" development would be all debug mode?

eernstg commented 1 year ago

The fundamental question here is: Why does Dart support the type dynamic?

One answer could be: Because it is an irreplaceable extension of the expressive power of the language in situations where very generic code is assumed type safe based on an ad-hoc proof which is beyond the capabilities of the built-in type system. In other words, it's a specialist's tool, and it is no problem at all if dynamically typed code requires some extra syntax. (Like an explicit as dynamic here and there, or // ignore: avoid_dynamic_calls, or whatever we wish to require via lints or other mechanized rules and guidelines.)

A different answer could be: Because dynamically typed code is easier to create for inexperienced developers. Static typing is an option that developers may adopt over time when they are ready to make the extra investment into the correctness of their code. If this is the reason for having dynamic then all usages of this type should occur easily and without syntactic overhead. For instance, the type dynamic should be used implicitly when no type is specified and type inference has no other information.

I'd very much prefer to consider dynamically typed coding as a specialist's tool, that is: I prefer the first answer.

I think the second answer is much less convincing today than it might have been years ago. In particular, Dart supports a very substantial level of static analysis (type inference, variable promotion, flow analysis) that enables statically typed code with few explicit type annotations. Admittedly, this kind of coding can appear to be more complex than dynamically typed coding, but that's because the statically typed code, even with type inference etc, does more than the dynamically typed code: It includes a proof of certain kinds of correctness. I believe the Dart community as a whole is willing to do the extra work here, in return for less buggy programs.

Based on this perspective on dynamically typed coding, I'd have no problems recommending that we include avoid_dynamic_calls in a widely used set of lints.

lrhn commented 1 year ago

If we actually want to take dynamic out of the language as a type, we probably could.

I'd just remove the type, and add an "operator"/special-syntax of .dynamic that can be applied to any expression, and then that single expression behaves as if it has type dynamic today - can invoke any method, can assign to any type. The result of the invocation will have type Object?, you'd need to do .dynamic again if you want more dynamism.

It might be hard on JSON access, but I hope pattern matching will help with that.

The global migration would add .dynamic at every existing dynamic invocation or implicit downcast, then change every occurrence of dynamic to Object?. Migration order will be a problem, if something changes to returning Object? before the downstream code migrates, it can't see where to add the .dynamic (but it might guess based on the type errors). Or there could be a phase where we allow you to write dynamic, but language version X.Y and above treats it as Object? and requires .dynamic, those below do not. Possibly automatable, but not easy.

munificent commented 1 year ago

I strongly agree with everything @eernstg said. With Dart 2.0, we pivoted Dart away from the "gradual typing" user experience where a use uses dynamic because they just don't want to deal with types (or don't want to deal with them yet). Once we made List<dynamic> no longer a subtype of other list types (and likewise other generics), we basically lost that use case. Dart doesn't support the "don't worry about types" use case when code like this is prohibited:

var a = [];
var b = [1, 2, 3];
b.addAll(a);

I'm happy to give up this use case (though I believe it does have value!) because losing that means much better user experience for users who do want the full benefit of types. I think the trade-off is a very clear net win.

But it does mean that, like Erik suggests, dynamic is basically just a specialized tool for specialized use cases. Given that, it probably doesn't warrant the level of direct language support that it has. If we had designed Dart without ever having dynamic, I suspect we wouldn't be considering adding it now.

lrhn commented 1 year ago

If we had designed Dart without ever having dynamic, I suspect we wouldn't be considering adding it now.

I think we would be considering adding something that allows dynamically invoking a method without knowing the type declaring it. We might not do so, but we would be considering it, because there would be requests for it.

The implicit downcast, probably not.

I do think that using is checks and promotion is better than just "knowing" the type, and that pattern matching and destructing gives a way to do that which works for recursive dynamically typed structures like JSON, but there will likely be a core of very dynamic patterns that cannot be implemented without dynamic dispatch. But I don't know all those cases, so I can't say whether the cost of supporting them is worse than the loss of not supporting them at all.

mateusfccp commented 1 year ago

I am completely in favor of considering avoid_dynamic_calls a lint rule, along with #125.

Even if we don't remove dynamic entirely form the language (which I am also in favor of), I think we should make dynamic the more explicit as possible. Thus, requiting an explicit cast and/or explicit lint ignore commentary would be positive and surely avoid a lot of runtime errors, specially from beginners.

natebosch commented 9 months ago

What about an avoid_dynamic lint that combines the behavior of no_implicit_dynamic as well as more strictness around using dynamic anywhere. As a rough proposal, disallow:

If we come up with what we think are the total set of best practices around dynamic, it would be nicer to enable that all together than to have to piece together different bits of config.

I do think that what a lot of our customers want is effectively Dart without dynamic, but a way to make dynamic calls when there is no other choice. I don't think it matters much whether the syntax for that is verbose like (expression as dynamic).dynamicCall() or if it had sugar like expression!.dynamicCall().

We can align the analysis towards removing dynamic from the language entirely, without actually making the decision about doing that now.

srawlins commented 9 months ago

Implicit or explicit dynamic argument types.

This one is very hard to comply with today, without making a lot of exceptions. There are gobs and gobs of generic types (with dynamic as the bound) where it is not typical, and often not useful, to specify type arguments. I've been trying to get the flutter devtools codebase to be compliant with strict-inference, which requires type arguments to be explicitly written where they are not inferred, and I think it would make the code much harder to read and a pain to write. A few examples:

These examples can be alleviated, but it requires some work:

I would 100% support a lint rule that required you to change bounds from dynamic. I don't think we have anything that enforces this. It also might be an annoying amount of churn if we're going to eventually change the default bound to Object?. I imagine that, while that would be a breaking change to change the default bound, it would break an extremely small amount of code. (Except the cases of List and Map maybe, because JSON parsing; those bounds could be explicitly made dynamic.)

Invoking any member on a dynamic expression - use (expression as dynamic).dynamicCall(); when it is intended.

Available to day as avoid_dynamic_calls lint rule.

Downcasts from dynamic.

Available today as language: strict-casts. Painful to implement in JSON-heavy code, but I'm very curious to see if macros can alleviate this pain (or hide it under the rug).

If we come up with what we think are the total set of best practices around dynamic, it would be nicer to enable that all together than to have to piece together different bits of config.

We could release an analysis options file that enables different bits of config.

mit-mit commented 9 months ago

I'm starting to come around to re-considering if https://dart.dev/tools/linter-rules/avoid_dynamic_calls should indeed be added to the recommended lint set. Do we think this would trigger a bunch of prevalent code being written today?

lrhn commented 9 months ago

There aren't that many dynamic type variable bounds. The SDK contains none.

In Dart 2.12, the default bound of a generic type variable with no specified bound was changed to Object?.

In hindsight, that phrasing was problematic, because there was no concept of "default bound" before, so we failed to enumerate the precise places where one should now use Object? instead of what was used before. The most obvious is that invoking members on a T-typed value was not doing it on Object but on Object? (which might matter for extensions). But it didn't change what instantiate to bounds did for type variables with no bound, which is to use dynamic. Because that's what raw types have always used, since the dawn of Dart 1. It really should (IMO) have changed that to instantiating to the (default) bound. That might have been very breaking, but the null safety migration was the right place for such a breaking change, especially since this one was automatable by just inserting <dynamic> in all the places where instantiate to bound would have inferred it.

I'd love to change instantiate-to-bounds to not insert spurious dynamics. Separately from everything else. (And I'd love to avoid any other way to accidentally get dynamic, like var x; or untyped parameters having type dynamic. Those are all accidental dynamics, where using Object? is just as good for subtyping and promotion, but won't hide typo'ed member access. I'd even consider making var x; an "always promote on assignment" variable, which should be a relatively cheap feature to introduce, if we think that's better for users.)

TL;DR: The issue with Future.delayed is not the bound, it's the instantiation-to-bound algorithm used for raw types.

About:

  • Invoking any member on a dynamic expression - use (expression as dynamic).dynamicCall(); when it is intended.

The problem with taking that literally is that the result will have type dynamic, and now you have to deal with that. Can you do a dynamic invocation on something that does have type dynamic? If not, doing String name = json["context"]["project"]["name"]; would turn into:

  var name = (((json as dynamic)["context"] as dynamic)["project"] as dynamic)["name"] as String;

Sure, you can use as Map<String, dynamic> instead of as dynamic, and it might be more efficient, but sure won't be shorter.

If you can, then var name = (json as dynamic)["context"]["project"]["name"] as String; will be enough. But that also means that any API that does return dynamic will opt you in, and it'll be hard to tell the difference between returnsDynamic().arglebargle and Map()[42].argleBargele. The former explicitly returns dynamic. The latter is a Map<dynamic, dynamic>, by instantiate to bounds today, which just as explicitly returns dynamic. (Unless we want to distinguish a verbatim dynamic from a generic bound to dynamic, which is going to get complicated.)

(Example valid only until someone creates an extension type wrapper for JSON. I know I will. :wink:)

srawlins commented 9 months ago

In Dart 2.12, the default bound of a generic type variable with no specified bound was changed to Object?. But it didn't change what instantiate to bounds did for type variables with no bound, which is to use dynamic. It really should (IMO) have changed that to instantiating to the (default) bound. I'd love to change instantiate-to-bounds to not insert spurious dynamics.

Yes, this is what I meant, sorry for the imprecision. Instead of, "There are gobs and gobs of generic types (with dynamic as the bound)" I should have said "There are gobs and gobs of generic types (with no specified bound, in which case instantiate-to-bounds uses dynamic.)

Instead of "Alternatively, change the default bound to Object?." I should have said "change the default bound used in instantiate-to-bounds to be Object?."

Can you do a dynamic invocation on something that does have type dynamic?

Nope, that's the goal :D

(Example valid only until someone creates an extension type wrapper for JSON. I know I will. 😉)

Do you mean something like these? https://github.com/flutter/devtools/pulls?q=is%3Apr+extension+is%3Aclosed+author%3Asrawlins Or more generic?

munificent commented 9 months ago

Would it help if the language allowed a type parameter bound to be extends void which means "in the absence of any other context type, instantiate with void"?

eernstg commented 9 months ago

@munificent wrote:

[allow] a type parameter bound to be extends void

Interesting! That's actually a kind of "don't use this member when the receiver type is raw" mechanism:

typedef Void = void;

class C<X extends Void> {
  final X x;
  C(this.x);
  void foo() {}
}

void main() {
  C c = C(1);
  c.foo(); // No problem.
  print(c.x); // Error!
}
munificent commented 9 months ago

Yeah, it's basically allowing bounds that let you pick which top type you want if the type parameter is unconstrained. We could also treat the default instantiate-to-bound as Object? and then allow extends dynamic for backwards compatibility in places where a user really does want that to be what inference defaults to.

natebosch commented 9 months ago

The problem with taking that literally is that the result will have type dynamic, and now you have to deal with that. Can you do a dynamic invocation on something that does have type dynamic? If not, doing String name = json["context"]["project"]["name"]; would turn into:

  var name = (((json as dynamic)["context"] as dynamic)["project"] as dynamic)["name"] as String;

Sure, you can use as Map<String, dynamic> instead of as dynamic, and it might be more efficient, but sure won't be shorter.

FWIW I would personally be fine with this verbosity, but I agree that many users would find this annoying.

There are also alternative ways to write this today, both approaches that give more opportunity to custom handle inputs with unexpected shape:

  var name = switch(json) {
      {'context': {'project': {'name':String name}}} => name,
      _ => throw FormatException('Expected a Map with <format>'),
  };

Or a terse approach:

var {'context': {'project': {'name': String name}}} = json;
matanlurey commented 7 months ago

Also folks could just disable the lint for a particular file.

Outside of toy CLI scripts, everyone uses some sort of JSON code generation solution at this point, or a wrapper/helper.