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

Function generic type parameters are really inconvenient to extend/cast #3007

Open
DrafaKiller opened this issue Apr 18, 2023 · 7 comments
Open
Labels
request Requests to resolve a particular developer problem

Comments

@DrafaKiller
Copy link

DrafaKiller commented Apr 18, 2023

Problem

When creating a function, it's really inconvenient to use extends for generic type parameters, mostly because it's always invariant.

When having a function type that takes an int as a type parameter, it's not possible to give a more ambiguous type parameter, like num, which would handle both int and double types.

final Type Function<T extends int>() function = <T extends num>() => T; // Error
  • Why can't <S extends num>() be used to handle <S extends int>(), since num will always handle the same cases as int?
  • Is there a workaround for this, like a design pattern?

Cheap Workaround

This invariant behavior seems to be more of an inconvenience rather than an impossibility, since you can have a middleman function that casts the type parameter to the target type.

final Type Function<T extends int>() function = <T extends int>() => (<T extends num>() => T)<T>();

But this doesn't work with my use case.

Simplified Goal

The goal would be to have an handler which can handle any subtype of num. This means with T extends num, it should be able to handle int and double types.

final Type Function<T extends num>() handler = <T extends num>() => T;

final Type Function<T extends int>() intHandler = handler; // Exception
final Type Function<T extends double>() doubleHandler = handler; // Exception

handler<int>(); // OK
handler<double>(); // OK

/* -= Inconvinient workaround =- */

final Type Function<T extends int>() intHandler2 = <T extends int>() => handler<T>();
final Type Function<T extends double>() doubleHandler2 = <T extends double>() => handler<T>();

New feature solution

To keep it both compile and runtime safe, it would be nice to have an extends condition.

if (T extends S)

This is similar to the following famous workaround, but it would actually change T during compile time.

if (<T>[] is List<S>)

Practical Example

This becomes a problem in this following case, because we don't have enough information to create a middleman function to cast.

Even thought there's no compile-time error, the function will throw an exception at runtime.

class BoxedType<T> {
  R use<R>(UseFunction<T, R> callback) => callback<T>();
}

typedef UseFunction<T, R> = R Function<S extends T>();

/* -= Usage =- */

void main() {
  final types = [
    BoxedType<int>(),
    BoxedType<double>(),
  ];

  R resolve<R, T>(UseFunction<T, R> callback) {
    for (final type in types.whereType<BoxedType<T>>()) {
      return type.use(callback);
    }
    throw Exception('No type found');
  }

  print(resolve<Type, num>(<T extends num>() => T));
  // It's supposed to print `int`, but it throws a "... not a subtype of ..." exception.
}

Even thought we proved that .whereType<BoxedType<T>>() is the same T in .use(<S extends T>() => ...), and it makes sense because <S extends num>() => ... can technically handle a BoxedType<int>, it doesn't work because the only allowed type parameter in .use() is T extends int. Throwing a runtime exception.

I don't know how to work around the situation above.

Environment

  • Dart SDK version: 3.0.0-400.0.dev
  • Operating System: Windows 11
@DrafaKiller DrafaKiller added the request Requests to resolve a particular developer problem label Apr 18, 2023
@DrafaKiller DrafaKiller changed the title Function type parameters are really inconvenient to extend/cast Function generic type parameters are really inconvenient to extend/cast Apr 18, 2023
@eernstg
Copy link
Member

eernstg commented Apr 18, 2023

Function types in Dart are invariant in the bounds of type parameters. I believe the main reason for this is that type inference becomes intractable if we allow them to be covariant. @leafpetersen would have more detailed information about this topic.

In order to handle the type parameter bounds as invariant, we could use the following emulation of invariance (the extra type parameter Invariance is just there in order to ensure that BoxedType<T1> and BoxedType<T2> are unrelated types unless T1 and T2 are subtypes of each other, that is, "unless they are the same type or very nearly so"):

typedef UseFunction<T, R> = R Function<S extends T>();

class _BoxedType<T, Invariance> {
  R use<R>(UseFunction<T, R> callback) => callback<T>();
}

typedef _Inv<X> = X Function(X);
typedef BoxedType<X> = _BoxedType<X, _Inv<X>>;

/* -= Usage =- */

void main() {
  final types = [
    BoxedType<int>(),
    BoxedType<double>(),
  ];

  R? resolve<R, T>(UseFunction<T, R> callback) {
    for (final type in types.whereType<BoxedType<T>>()) {
      return type.use(<S extends T>() => callback<S>());
    }
    print('No type found');
  }

  print(resolve<Type, int>(<T extends int>() => T)); // Prints 'int'.
  print(resolve<Type, num>(<T extends num>() => T)); // Prints 'No type found'.
}

The result is that the code is safe (because the invariance property is enforced where it is needed), but also that you can't expect a BoxedType<int> to be included when you run whereType<BoxedType<num>>(), which is the reason why you reach 'No type found' for the invocation with type arguments Type, num.

@DrafaKiller
Copy link
Author

DrafaKiller commented Apr 18, 2023

@eernstg When resolving for num, I would want to get any subtype of num. I would be able to get int or double.
resolve<Type, num> would mean that, when resolving the generic type would be at least a num.

In my original situation, this is used for a mapper. BoxedType<T> will also contain a validator, which means .use<Type, num> could resolve for int or double depending of the input.

So instead of:

print(resolve<Type, int>(<T extends int>() => T)); // Prints 'int'.
print(resolve<Type, num>(<T extends num>() => T)); // Prints 'No type found'.

How would I be able to get this?

print(resolve<Type, int>(<T extends int>() => T)); // Prints 'int'.
print(resolve<Type, num>(<T extends num>() => T)); // Prints 'int'.

@eernstg
Copy link
Member

eernstg commented Apr 18, 2023

But, with invariance, it's simply not true that BoxedType<int> and BoxedType<double> are subtypes of BoxedType<num>, they're just three completely unrelated types.

Without invariance, you do have those subtype relationships, but in that case the member declaration R use<R>(UseFunction<T, R> callback) => callback<T>() is inherently unsafe: If you ever call use on an instance of BoxedType<T> where the actual value of T is different from the statically known value then it will throw (it's not even maybe, it will throw).

The member signature of use is an example of a "contravariant member", cf. #297.

@eernstg
Copy link
Member

eernstg commented Apr 18, 2023

I forgot one thing: If you are willing to drop the bound of the type parameter entirely then you can get the covariant typing without difficulties, and without unavoidable run-time type errors:

typedef UseFunction<R> = R Function<S>();

class BoxedType<T> {
  R use<R>(UseFunction<R> callback) => callback<T>();
}

/* -= Usage =- */

void main() {
  final types = [
    BoxedType<int>(),
    BoxedType<double>(),
  ];

  R resolve<R, T>(UseFunction<R> callback) {
    for (final type in types.whereType<BoxedType<T>>()) {
      return type.use(<S>() => callback<S>());
    }
    throw Exception('No type found');
  }

  print(resolve<Type, num>(<T>() => T)); // Prints 'int'.
}

This means that the body of the given callback can't use the fact that the actual value of T will be a subtype of the second type argument to resolve (in the example: num), but I suspect that this doesn't actually lower the type safety of the program. ;-)

@DrafaKiller
Copy link
Author

DrafaKiller commented Apr 19, 2023

If you are willing to drop the bound of the type parameter entirely then you can get the covariant typing without difficulties

print(resolve<Type, num>(<T>() => T)); // Prints 'int'.

@eernstg That was the first thing I tried to do, but I do need it to have a bound. That's where having an extends condition would be useful, to lower the bound.

If I can't have a bound at the resolver, I wish I could lower the bound later, like this.

class MyNumberValue<T extends num> {}
print(
  resolve<MyNumberValue, num>(<T>() {
    if (T extends num) return MyNumberValue<T>();
    throw Exception('Invalid type'); 
    // It's never supposed to throw because the `BoxedType<T>` list was filtered to only have subtypes of `num`
  })
); // Prints 'MyNumberValue<int>'

Right now, I'm not sure if it even is possible to lower the bound. How would you lower T to at least be a num?

@eernstg
Copy link
Member

eernstg commented Apr 19, 2023

In a situation like this where you know, but can't get the type system to understand, that T <: num, you will need to use a dynamic invocation (using the type dynamic or Function as the type of the constructor, with Function as the slightly more specific choice):

typedef UseFunction<R> = R Function<S>();

class BoxedType<T> {
  R use<R>(UseFunction<R> callback) => callback<T>();
}

/* -= Usage =- */

class MyNumberValue<T extends num> {}

void main() {
  final types = [
    BoxedType<int>(),
    BoxedType<double>(),
  ];

  R resolve<R, T>(UseFunction<R> callback) {
    for (final type in types.whereType<BoxedType<T>>()) {
      return type.use(<S>() => callback<S>());
    }
    throw Exception('No type found');
  }

  print(
    resolve<MyNumberValue, num>(<T>() {
        if (<T>[] is List<num>) {
          Function constructor = MyNumberValue.new;
          return constructor<T>();
        }

        // It's never supposed to throw because the `BoxedType<T>`
        // list was filtered to only have subtypes of `num`
        throw Exception('Invalid type'); 
    })
  ); // Prints 'MyNumberValue<int>'
}

@eernstg
Copy link
Member

eernstg commented Apr 19, 2023

This is an interesting problem, I just took one more look at the potential strategies for making this design type safe. ;-)

typedef UseFunction<R, B> = R Function<X extends B>();

class BoxedType<X> {
  R use<R, B>(UseFunction<R, B> callback) => (callback as Function)<X>();
}

/* -= Usage =- */

void main() {
  final types = [
    BoxedType<int>(),
    BoxedType<double>(),
  ];

  R? resolve<R, B>(UseFunction<R, B> callback) {
    for (final type in types.whereType<BoxedType<B>>()) {
      return type.use(callback);
    }
    print('No type found');
  }

  // The first one that fits is chosen:
  print(resolve<Type, int>(<X extends int>() => X)); // Prints 'int'.
  print(resolve<Type, num>(<X extends num>() => X)); // Prints 'int'.
  print(resolve<Type, Object>(<X extends Object>() => X)); // Prints 'int'.
  print(resolve<Type, double>(<X extends double>() => X)); // Prints 'double'.

  // We can of course ask for a type that isn't handled.
  resolve<Type, String>(<X extends String>() => X); // Prints 'No type found'.

  // But `BoxedType.use` isn't statically safe.
  BoxedType<int>().use(<X extends String>() => X); // Throws.
}

In this variant, we allow the use call site to choose the type parameter bound by introducing yet another type parameter U, and then we call the callback dynamically using (callback as Function)<X>(). That's the last bit of type unsafety.

The point is that we can get rid of that last bit of unsafety! Cf. #1674, where I'm proposing that we can use X super T as a type parameter declaration of a function (it won't work with a class type parameter, but it would be part of the function type).

If we can do that then we can change the above example as follows:

class BoxedType<X> {
  R use<R, B super X>(UseFunction<R, B> callback) => callback<X>();
}

It is now statically safe to call callback<X>() because it is known for a receiver r that it has static type BoxedType<T> for some T, which means that the call must pass an actual type argument B0 for B such that T <: B0. But the actual value of the type parameter of r is some type T0 such that T0 <: T, which means that T0 <: B0. But T0 is the actual value of X when we call callback<X>(), and B0 is the actual value of the bound of callback, so this implies that the bound constraint is satisfied.

Voila, no dynamic calls!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
request Requests to resolve a particular developer problem
Projects
None yet
Development

No branches or pull requests

2 participants