Skip to content

IIFE insufficient flow analysis #2848

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

Open
modulovalue opened this issue Feb 15, 2023 · 12 comments
Open

IIFE insufficient flow analysis #2848

modulovalue opened this issue Feb 15, 2023 · 12 comments

Comments

@modulovalue
Copy link

modulovalue commented Feb 15, 2023

Consider the following example:

void main() {
  inlined();
  iife();
}

int inlined() {
  int? b;
  b = 0;
  return b;
}

int iife() {
  int? b;
  () {
    b = 0;
  }();
  return b;
}

The iife function causes the program to fail compilation.

I think it would improve the usability of () { ... }() constructs as IIFEs if such immediately invoked function expressions could participate in current, and future intraprocedural data flow analyses as a list of statements and not as a function expression whose statements are not considered (which seems to be the case today).

Edit: by "usability" I'm not just referring to the use of IIFEs by humans, but also by tools that offer refactorings.

Edit: I think that this issue does not apply to function literals that have been annotated with async/sync/sync* because, in general, the value that they produce can't be considered to be a list of statements.

@eernstg
Copy link
Member

eernstg commented Feb 15, 2023

The intermediate language "kernel" uses a construct that they call a BlockExpression in order to desugar a number of different constructs in Dart where there's a need to perform regular statements as part of the evaluation of an expression.

That construct could serve as a replacement for the () {...} () or any alternatives with the same effect, and it would eliminate the function entirely: It would simply be a scope where we can write code, together with a distinguished expression which is evaluated at the very end and whose value is the value of the block expression as a whole.

If we had a construct like that then there wouldn't be a need to keep track of function objects at all: It's just a block plus an expression. There is no syntax, but let's just assume that we could use this:

void main() {
  print(
    (*) { statement; statement; return expression; statement; ... return expression; ... }
  );
}

This construct is by definition not leaking anywhere, because there is no function, it's just code plus the ability to evaluate to a result.

It would be type checked just like () {...} (), but the context type would directly be made available for the return expressions.

Hence, assignments to local variables in the "body" would be known to occur exactly where the (*) {...} is placed.

@modulovalue
Copy link
Author

Are you proposing that a new construct (e.g. (*) {...}) could be introduced on the Dart-language-level whose purpose would be to act as sugar for BlockExpressions on the Kernel-language-level, or are you suggesting that a (){ ... }() on the Dart-language-level could be converted to a BlockExpression on the Kernel-language-level?

@eernstg
Copy link
Member

eernstg commented Feb 16, 2023

I was suggesting that we use new syntax (say, (*) {...}) to denote this feature, which will work very much like the immediately invoked function expression.

It is possible that it could simply be compiled into () {...} () and then translated normally, with the extra affordance that the static analysis would know that this function is definitely called at exactly the time we reach this point in the code, and also that it is definitely not called in any other way (including: it's not leaking into a global variable or anything like that). However, that wouldn't give us the improved inference unless we were to change the rules about how context types are propagated. We could also translate (*) {...} into iife(() {...}), cf. this comment, but that's even more special casing.

However, it might be better than that: The kernel BlockExpression has been used for years, as part of some 'lowering' steps where an expression in Dart is translated into a block of Kernel statements and an expression which is evaluated to yield the value of the block expression. That construct is already known to have those properties (it is executed at that position and nowhere else, and it won't leak as a function object because there is no function object). So we might already have almost all the machinery which would be needed in order to get this semantics, and we wouldn't need to worry about paying (in terms of time & space) for the function object creation and invocation.

Finally, I think it makes sense to have a visible (syntactic) hint on this construct itself that serves as a reminder that it has special properties: It is treated differently than an invoked function literal during flow analysis, and if it turns out to be nonsensical/useless/impractical to have (*) sync* {...} or (*) async {} etc. then we just make them syntax errors up front. Also, the use of (*) gives a strong hint that we can't specify any formal parameters. In short, we get those special properties when we opt in to use the special syntax, and that syntax in turn enforces the limitations of the construct.

I think that's more comprehensible/readable than an implicit mechanism which would simply find certain expressions (say, () {...} () and perhaps () sync* {...} ()) and give them a very special treatment.

@modulovalue
Copy link
Author

including: it's not leaking into a global variable or anything like that

Perhaps I'm missing something here, but since the function expression in () {...}() is immediately invoked, shouldn't there be no way to refer to the function expression and so it can't leak? In what ways could it leak?

So we might already have almost all the machinery which would be needed in order to get this semantics, and we wouldn't need to worry about paying (in terms of time & space) for the function object creation and invocation.

That sounds wonderful!

What I like about () { ... }()-based IIFEs is that Dart developers will eventually find this feature without having to be educated about it. It's a useful consequence of how the rest of Dart works.

Also, the use of (*) gives a strong hint that we can't specify any formal parameters.

I believe that custom analyses (see: #2852) would make () { ... }()-based IIFEs much more useful. And not just those without any parameters, but especially those with parameters as we could introduce pure scopes locally to simplify reasoning (See the Verifying total purity example on #2852).

@eernstg
Copy link
Member

eernstg commented Feb 16, 2023

In what ways could it leak?

I don't think the function object could leak. For instance, this in the body of a function literal could refer to the function object, and that could be used to leak the function object—but this doesn't refer to the function object like that, it refers to the current object whose class might be declared several scopes further out, so there are no leaks of that kind in Dart. So we're probably already safe in this respect.

But there are lots of properties of a program which could be shown to hold, perhaps by design or perhaps by accident, and there's no way the flow analysis could take all of them into account.

That's the reason why I tend to prefer that semantically significant properties are indicated in source code rather than being applied implicitly for any given entity that satisfies some kind of checklist.

@munificent
Copy link
Member

This issue could really benefit from some motivation. Why are you using IIFEs in the first place? What problem are they solving?

@lrhn
Copy link
Member

lrhn commented Mar 28, 2023

The only problem I'm aware of IIFEs solving is as a "statement-expression" which allows statements inside an expression, and which allows local return to emit the value of the expression from multiple statement branches.

Say:

 foo(a, b, last: () {
    while (cursor.next != null) cursor = cursor.next; return cursor;
 }());

I'd rather introduce some notion of statement-expression/do-block with a value than try to formally write IIFE's into the language semantics. (They also interact badly with async.)

So, what Erik said!

@modulovalue
Copy link
Author

I can think of roughly 3 situations where IIFEs are useful to me.

  1. To add support for statements in places where statements are not supported. (e.g. constructor initializer lists, collection elements).
class Foo {
  final int i;

  Foo() : i = (() {
    // ...
    return 0;
  }())
}

final bar = [
  for (final a in [1, 2, 3])
    ...() {
      // ...
      return [];
    }()
];
  1. To limit the scope of variables. (e.g. a block with 5 variables is much easier to reason about than a block with 10).
void foo() {
  // ...
  // 'b' and 'c' are only used to create 'a', so it
  // doesn't make much sense to make them available to others.
  final a = () {
    int b = 0;
    String c = "foo";
    // ...
    return ...;
  }();
  // ...
}
  1. To provide IDEs with hints about code that is more or less self-contained and can be collapsed without removing important context. IntelliJ, for example, supports collapsing blocks to a {...}. Keeping chunks-of-logic in IIFEs makes it easy to collapse those chunks when it becomes known that they are irrelevant for solving the task at hand.

Of course, many of these benefits can be had by declaring explicit functions. However, functions need a name (and naming is hard), pollute the namespace in which they have been declared in, and require context switching (e.g. one needs to find the function, go to its location, go back, ...).

@lrhn
Copy link
Member

lrhn commented Mar 28, 2023

For both variable scope and block collapsing, a block statement should enough. I can see that Dart-Code doesn't provide collapse-information for block statements, but that should be fixed, not worked around by introducing a new function nesting.
(IIFEs work badly with async, so they're not a good workaround.)

Using IIFEs is really a bad habit from back when JavaScript didn't have block-scoped variables.

@modulovalue
Copy link
Author

For both variable scope and block collapsing, a block statement should enough. not worked around by introducing a new function nesting.

I find it very useful to use the 'expand-selection' IDE feature inside of the block of an IIFE to quickly select and then cut and paste the whole IIFE to a place where an expression is expected. I agree that block statements would help here, but the UX wouldn't be as fluid with them because we couldn't return a value to turn them into expressions. Refactorings would help here, but I think it is hard to beat the convenience of an IIFE.


IIFEs work badly with async

@lrhn in what way?

not worked around by introducing a new function nesting.

It sounds to me like you are suggesting that IIFEs should be avoided? If that's the case, are there other reasons, besides the async issue, for why you think that?

@lrhn
Copy link
Member

lrhn commented Mar 29, 2023

IIFE's work badly with async, because if you have an async body, you need to add extra await and async to it.
The plain () { .... }() becomes await () async { ... }().

Not a big issue, but still more verbose and less efficient.

Also, while an IIFE allows a return to be local to the inner body, it doesn't allow a to not be, or allow any break or continue out of the inner block.

That's why a proper "statement expression" would be a better language feature. If combine with an "expression break with value", it should be possible to have everything.

foo(a, b, {:
  while (something) other;
  => 42;
:});

Inside the {: ... :} statement-expression block, you can use statements, but you can also use => e to exit the block with that value.
Or use ^42 to exit it. Or break: 42; which breaks the nearest surrounding statement expression

And you can give it a label too: label:{: .... break label: 42; :} so you can break other ones than the surrounding one.

@modulovalue
Copy link
Author

I agree with everything you said.

One thing that worries me is that, in my opinion, Dart is becoming quite big syntax-wise. I don't know if adding a novel construct for block expressions/statement expressions would be worth it when IIFEs can do most of what a new block expression/statement expression could give us.

... it doesn't allow a to not be, or allow any break or continue out of the inner block.

Note that this could be seen as a feature. With an IIFE I immediately know that all its exit points jump to the end of the IIFE. If it could, for example, break into its outer scope, then I would have to assume that it could jump somewhere else and not just to the end. I like this behavior because it allows me to "compartmentalize" control-flow into self-contained units that have a single entry and exit point.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants