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

Issue with generic function definition ((String?) => Future<String>' is not a subtype of type '((dynamic) => FutureOr<String?>)?') #52168

Closed
nblum37 opened this issue Apr 25, 2023 · 4 comments

Comments

@nblum37
Copy link

nblum37 commented Apr 25, 2023

Hi,

I run into a runtime exception with Dart 2.19.6 (used with Flutter 3.7.12) when running the following code as a test:

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';

typedef FunctionProto<T> = FutureOr<String?> Function(T? value);

@immutable
class TestClass<T> {
  final FunctionProto<T>? function;

  const TestClass({
    this.function,
  });
}

void main() {
  group('Generic function subtype problem', () {
    test('Infeasible workaround regarding the use case', () async {
      TestClass<String> testClass = TestClass<String>(
        function: (value) async {
          return 'return some value';
        },
      );

      if (testClass.function != null) {
        print('Call function');
        String? err = await testClass.function!('handover some value');
        print('err: $err');
      }
    });

    test('Ugly workaround 1 -> Linter warning due to unnecessary cast', () async {
      TestClass testClass = TestClass<String>(
        function: (value) async {
          return 'test';
        } as FunctionProto<String>,
      );

      if (testClass.function != null) {
        print('Call function');
        String? err = await testClass.function!('handover some value');
        print('err: $err');
      }
    });

    test('Ugly workaround 2 -> Linter warning due to unnecessary cast', () async {
      TestClass testClass = TestClass<String>(
        function: (value) async {
          return 'test';
        } as FunctionProto,
      );

      if (testClass.function != null) {
        print('Call function');
        String? err = await testClass.function!('handover some value');
        print('err: $err');
      }
    });

    test('Main issue', () async {
      TestClass testClass = TestClass<String>(
        function: (value) async {
          return 'test';
        },
      );

      if (testClass.function != null) {
        print('Call function');
        String? err = await testClass.function!('handover some value');
        print('err: $err');
      }
    });
  });
}

The mentioned code will throw a runtime exception at line 68 at the snippet testClass.validator with the message type '(String?) => Future' is not a subtype of type '((dynamic) => FutureOr<String?>)?'. In my production code, I have a List where the specific type of each entry could vary, that's why the first workaround in my sample code is not feasible. The other two workarounds are helping for now but on the one side unnecessary code and additionally are marked by the linter as unnecessary.

The Flutter team forwarded my issue to the Dart Repository, as it seems to be a Dart-related issue. The original issue is here issue 125387.

Thank you very much!

@eernstg
Copy link
Member

eernstg commented Apr 25, 2023

You should probably never use an expression of the form <functionLiteral> as <type>. For example,

(value) async {
  return 'test';
} as FunctionProto<String>

The problem is that there is no syntax that allows you to declare the return type of a function literal, and the placement of the function literal as the left operand of as causes the function to get the context type _ (that is: nobody wants anything specific, do whatever you want). So you can specify the parameter types if you want (and/or need) to do that, but there is no way you could declare the return type.

In order to ensure that the function literal gets a useful context type you'd need to put the function literal directly in the location where that type is expected:

TestClass testClass = TestClass<String>(function: (value) async => 'test');

It is still possible that type inference cannot select the desired types (of the parameter value, and the return type), and in that case you can at least specify the parameter types explicitly (or some of them, whatever is needed). This would usually enable type inference to choose a useful return type, because it (typically) knows a lot more when the parameter types are useful.

I would assume that the bad typing of function literals which is caused by using as is the main reason why you're getting run-time errors.

The mentioned code will throw a runtime exception at line 68

Please mark that location in the code using a comment or something like that. Most people won't count to 68 in this situation. ;-)

I have a List where the specific type of each entry could vary, that's why the first workaround in my sample code is not feasible.

That's not particularly easy to understand. Perhaps you could create a minimal example?

One thing that I noticed is that you could use a conditional invocation:

      if (testClass.function != null) {
        print('Call function');
        String? err = await testClass.function!('handover some value');
        print('err: $err');
      }

      // OK, assuming you don't really care about the `print` statements, this could be:
      String? err = await testClass.function?.call('handover some value');

The point is that you don't have to use the unsafe ! in order to call a nullable function expression, you can just call it using the "magic" member call that all functions have, and then use ?. to make it null aware.

I'm not sure I get the part about 'not feasible' (which might be the most important part), but I hope this helps anyway. ;-)

@nblum37
Copy link
Author

nblum37 commented Apr 25, 2023

Thank you very much for your feedback!
Probably the namings of the tests are not self-describing that I used in the example. So actually where the code is failing is on the last test at

    test('Main issue', () async {
      TestClass testClass = TestClass<String>(
        function: (value) async {
          return 'test';
        },
      );

      if (testClass.function != null) {
        print('Call function');
        String? err = await testClass.function!('handover some value');
        print('err: $err');
      }
    });

and there at the snippet testClass.function before the first print statement. So it is specifically the access of the class property that leads to the runtime error. The ugly workarounds with the as statement are actually working and I fully agree, that this should not be the way. Btw, if you would run the code, you would get the following console output:

Testing started at 2:03 pm ...

Call function
err: return some value
Call function
err: test
Call function
err: test
test/widget_test.dart 68:21  main.<fn>.<fn>

type '(String?) => Future<String>' is not a subtype of type '((dynamic) => FutureOr<String?>)?'

Pls find attached an example of how we would use the generic type approach:

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';

typedef FunctionProto<T> = FutureOr<String?> Function(T? value);

@immutable
class TestClass<T> {
  final T? value;
  final FunctionProto<T>? function;

  const TestClass({
    this.value,
    this.function,
  });
}

@immutable
class ForExampleSomeApiModel {
  final String? id;

  const ForExampleSomeApiModel({
    this.id,
  });
}

List<TestClass> testClassList = [
  TestClass<String>(
    value: 'test',
    function: (value) async {
      if (value == 'test') {
        return 'return some good value';
      }
      return 'return some other value';
    },
  ),
  TestClass<String>(
    value: 'test2',
    function: (value) async {
      if (value == 'test') {
        return 'return some good value';
      }
      return 'return some other value';
    },
  ),
  TestClass<ForExampleSomeApiModel>(
    value: const ForExampleSomeApiModel(id: 'test'),
    function: (value) async {
      if (value?.id == 'test') {
        return 'return some good value';
      }
      return 'return some other value';
    },
  ),
  TestClass<ForExampleSomeApiModel>(
    value: const ForExampleSomeApiModel(id: 'test2'),
    function: (value) async {
      if (value?.id == 'test') {
        return 'return some good value';
      }
      return 'return some other value';
    },
  ),
];

void main() {
  group('Generic function subtype problem', () {
    test('Main issue', () async {
      for (TestClass testClass in testClassList) {
        if (testClass.function != null) {
          print('Call function');
          String? err = await testClass.function?.call(testClass.value);
          print('err: $err');
        }
      }
    });
  });
}

The code would run with the workarounds (... as as FunctionProto) but not as stated here. Again the snippet testClass.function at the bottom will cause the runtime error.
I hope this additional information helps.

@eernstg
Copy link
Member

eernstg commented Apr 26, 2023

Taking another look, I noticed that there was a raw type, TestClass, in the code, and also that your code has a "contravariant member", cf. dart-lang/language#297. You might very well want to use a more strict static analysis. You can put the following into an analysis_options.yaml in the root of your package:

analyzer:
  language:
    strict-raw-types: true
    strict-inference: true
    strict-casts: true

linter:
  rules:
    - avoid_dynamic_calls

The 'strict-raw-types' setting will give you a diagnostic message when a generic type has been specified with no actual type arguments (that's a raw type), and the meaning of that type includes the type dynamic. For instance, List x could declare a formal parameter of a function, and this means List<dynamic> x. The raw type List would be flagged when 'strict-raw-types' is enabled.

In the example, TestClass is used as the type of a variable, and this means TestClass<dynamic>.

The general rules about how a raw type G is turned into a parameterized type G<T1 .. Tk> are somewhat complicated; check out the section about instantiation to bound to see all the details. The main idea is that the default type argument is the bound, and it is dynamic when there is no bound.

Here is a small reproduction of the failure which is caused by the combination of the raw type and the "contravariant member":

import 'dart:async';

class TestClass<T> {
  final FutureOr<String?> Function(T? value) function;
  const TestClass(this.function);
}

void main() {
  TestClass testClass = TestClass<String>((value) async => 'test');
  testClass.function; // Throws! We don't even have to call it, just look at it.
}

If you use var testClass then testClass will have the type TestClass<String> rather than TestClass<dynamic>, and the programs runs without exceptions.

The instance variable function is "contravariant", as mentioned above. Dart class type variables are considered covariant, and that is typically backed up by the actual properties. For instance, with class A<X> { X x; ... }, the x of an instance of A<num> has type num and for an instance of A<int> the x has type int: The receiver type and the instance member type co-vary, so it's fair to say that the type variable X is covariant.

However, when a type variable like T in TestClass occurs in a non-covariant position in the type of an instance variable (or in some other locations including the return type of a method), that member is "contravariant". This means that the type of the receiver and the type of the member do not co-vary, i.e., the type of the member may be a supertype or an unrelated type when the type of the receiver is a subtype.

In those cases there is a 'caller-side check' on the operation where the instance variable is evaluated, and that caller-side check can fail at run time. This is the reason why testClass.function throws, just because we read the variable.

The simple fix is to stop using contravariant members. For instance, the type of function could be Function. This is pretty much like dynamic for functions, but at least it doesn't promise anything that isn't true.

The genuine fix is to have statically checked declaration-site variance, cf. dart-lang/language#524. You could vote for that if you wish to support that feature. There is an example here illustrating how you could make a class like TestClass statically safe by making the type argument invariant (declaration-site variance will support that same thing as a built-in language mechanism, but we can emulate it already). Here's the code:

import 'dart:async';

typedef _Inv<X> = X Function(X);
typedef TestClass<X> = _TestClass<X, _Inv<X>>;

class _TestClass<X, Invariance> {
  final FutureOr<String?> Function(X? value) function;
  const _TestClass(this.function);
}

void main() {
  TestClass testClass = TestClass<String>((value) async => 'test'); // Compile-time error.
  testClass.function; // Won't throw when there is no covariance.
}

With this modified version of TestClass/_TestClass, the client code remains the same, but we get a compile-time error if we try to assign an expression of type TestClass<String> to a variable of type TestClass<dynamic> (they are now unrelated types). This means that the caller-side check is guaranteed to succeed (and with a real language mechanism supporting invariant type parameters there wouldn't be a caller-side check).

If you change TestClass testClass to TestClass<S> testClass for some S you'll note that nothing other than String will work as the value of S. This is exactly the behavior we need because that'll make function statically safe.

@eernstg
Copy link
Member

eernstg commented Apr 26, 2023

I think we can close this issue: The described behaviors are well-known, and 'strict-raw-types' deals with the raw type issue. Moreover, dart-lang/language#524 proposes a mechanism that would make "contravariant members" safe.

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

2 participants