Skip to content
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 dynamically checked function type? #295

Open
leafpetersen opened this issue Apr 3, 2019 · 6 comments
Open

Should we provide a dynamically checked function type? #295

leafpetersen opened this issue Apr 3, 2019 · 6 comments

Comments

@leafpetersen
Copy link
Member

leafpetersen commented Apr 3, 2019

Edited Editing this to re-point this to the more general problem of having a type for dynamically checked functions, without being specific as to the mechanism (using Never as the marker being one particular strategy).

The type int Function(Never) is useful only as a top type for unary functions. It could be useful to allow calls to this to be done using dynamic runtime checks, since in practice all deliberate uses of such function types currently require going through a dynamic call.

A concern is the possibility of inadvertent uses - are there scenarios this might arise accidentally through inference?

@eernstg
Copy link
Member

eernstg commented Apr 3, 2019

It's partially dynamic: The callee is already known to be a function object that accepts invocations with exactly one positional argument (assuming the example type int Function(Never)), so we only have to check that the type of the actual argument satisfies the declared parameter type for the actual callee.

With a separate entry point where all actual arguments are checked against the parameter types at the beginning of the function body, it should not be more expensive than an invocation of an instance method with covariant parameters. It may or may not be worthwhile to allow the checked entry point to check just some actual arguments (perhaps using an extra parameter which is a bitmask), rather than all of them, to avoid spending time on checks that are guaranteed to succeed.

This could make every closure a bit bigger, but it would also allow compilers to avoid generating any code at call sites concerned with downcasts on actual arguments, they'll just call the checked entry point.

@munificent
Copy link
Member

I think I (and anyone else reading along, I guess) are missing some context here. What would a deliberate use of Function(Never) look like?

@eernstg
Copy link
Member

eernstg commented Apr 4, 2019

The main purpose of a function type with some or all parameter types specified as the bottom type is that a variable/parameter of that type can hold many different types of function (because it's a supertype of all function types where those occurrences of Never are replaced by any other type), and at the same time it's more specific than dynamic or Function.

// The type system will never be able to understand that `args[j]` is always
// a suitable argument for `funs[j]`, but let's say that we keep them consistent
// based on the business rules of the application and some manual thought.
var funs = <void Function(Null)>[(num i) {...}, (Object o) {...}, (String s) {...}];
var args = [1, true, "Three"];

main() {
  for (int index = 0; index < funs.length; ++index) funs[index](args[index]);
}

Currently, we have to cast funs[index] to Function or dynamic in order to allow for the invocation. This causes a loss of static type information: We have forgotten that the return type and the shape of the argument list are guaranteed statically; presumably, the invocation will also be significantly slower because those things will be checked afresh each time.

If we ensure during code generation that the body of the callee will check the type of its actual arguments (say, because we generate code to call a "checked" entry point of the function object) then there is no need for any dynamic type checks at all at the call site. Alternatively, we could perform just the argument type check at the call site, treating it as a "slightly dynamic" invocation.

We already treat invocations of instance methods with parameters that are covariant-by-class similarly, e.g., myList.add(e): The only statically safe check that we can express at the call site is e is Never, but because we may want to add elements to lists, and because we maintain a guarantee that there will be a check in the body of add (which is sufficient to maintain soundness) we do not generate code to perform this e is Never check.

Similarly, we can allow meaningful code like funs[index](args[index]) to run, and at the same time ensure soundness by checking the actual argument against the actual parameter type.

We do, somewhat arbitrarily and definitely wastefully, generate static checks based directly on the statically known type arguments at the call site:

class A<X> {
  void foo(X x) {}
}

class B extends A<int> {
  void foo(num n) {}
}

main() {
  A<int> a = B();
  num n = 3.14;
  a.foo(n); // Throws.
}

The invocation of foo throws because it is checked that n is int, even though it is statically known that foo will check that its actual argument has the type which is required for soundness (in B.foo it will check that n is num).

It would be a correct optimization to omit these static checks for invocations where it is statically known that the required check is performed by the callee, and not throwing is of course a non-breaking change. If we do this then invocations of a function with static type like void Function(Never) would automatically be allowed, given that we also maintain a guarantee that the callee will perform the required check (or we generate dynamic-call-ish code to do it at the call site). In any case, the point is that we check the actual requirement, not its overly strict static approximation.

@munificent
Copy link
Member

Thanks for clarifying.

Currently, we have to cast funs[index] to Function or dynamic in order to allow for the invocation. This causes a loss of static type information: We have forgotten that the return type and the shape of the argument list are guaranteed statically; presumably, the invocation will also be significantly slower because those things will be checked afresh each time.

Ah, right. You can't cast funs[index] to Function(dynamic) before you call it because allowing that would imply some sort of callee-side checking, which we don't want to do.

Allowing an invocation of a function whose parameter type is Never intuitively feels weird to me. I can sort of see the utility of treating like a strange "contravariant top type" but I think silently allowing that in invocations is more likely to confuse than illuminate. I'd lean towards requiring users to do a cast, even if it throws away from static information, just so to acknowledge that they're doing something odd with the type system.

But I don't have a strong opinion or intuition about this. Maybe the simplest answer is to leave it out and see if users miss it.

@eernstg
Copy link
Member

eernstg commented Apr 5, 2019

I'd lean towards requiring users to do a cast

That's exactly the idea that made me bring up this topic: There is no way to express that cast, short of adding an 'existential open' construct to the language (cf. #170):

main() {
  void Function(Null) fun = (num i) {...};
  Object arg = 1;

  fun(arg as T); // But there is no way to write that `T`!

  // .. except if we introduce a way to extract it (which is an 'existential open'):
  if (fun is void Function(var T)) {
    if (arg is T) fun(arg); // Safe.
  }
}

So we could make it possible for developers to establish the required context for calling fun in a way which is statically safe, but it might still be useful to allow developers to choose the kind of invocation where the check is added implicitly: They can lint such calls away if they want to, and otherwise they can make the choice to write simpler and more concise invocations of this kind, accepting that they can fail.

@leafpetersen leafpetersen removed the nnbd NNBD related issues label Dec 9, 2019
@leafpetersen leafpetersen changed the title Should we make calls to functions with argument type Never be dynamic calls? Should we provide a dynamically checked function type? Dec 9, 2019
@leafpetersen
Copy link
Member Author

Removing this from NNBD and re-titling to reflect discussion. There's still some interest in supporting a type which is more precise than dynamic in that it describes only functions of a specific arity, and possibly imposes additional static checks, but which is dynamically checked when called, allowing for more flexibility in usage.

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

3 participants