-
Notifications
You must be signed in to change notification settings - Fork 205
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
NNBD support for generic functions where the result nullability depends on if an optional parameter is passed or not #836
Comments
One way to think about this is that we'd want two function types of the form In the first type we probably don't want to specify that the type argument is non-nullable, that is, But with two distinct types that we can't immediately merge to something that has all the desired properties, maybe they are just two different functions/methods? One way to bridge the gap would be to use a case function (of course, we don't have case functions, yet): X? doSomething<X>({X orElse()}) {
X? case() => ...
X case({required X orElse()}) => ...
} This would allow call sites with sufficient type information to choose either case and get the desired return type (no nulls when |
The suggested syntax looks confusing. It is weird to have a return type defined on But other than that, that's an interesting idea! |
The point is that we want to preserve the notion of having a single function (e.g., for dynamic invocations, or any usage that doesn't justify a choice among multiple entities with the same name), so there is an "overall" function, and it needs to have a return type and a run-time representation. But then we may know more at particular usage points. When that is true, it is allowed for the compiler to resolve the invocation to the first case that matches and generate a call directly to a helper-function whose body is that case (so each case must also have a representation as an actual function at run time). This also allows us to rely on the specified return type for that case, and it is certainly possible that specific cases can have more specific return types than the overall function. Of course, this implies that the cases must be part of the type of the function. We may or may not wish to enable this feature in function types of Dart in general. One approach which is less powerful, but maybe more comprehensible (and maybe more performant), is to say that case functions can only dispatch to specific cases when the function itself is statically resolved. One obvious step up would be to also support cases in instance methods, and allow for invocations to call a specific case, and then require all overriding declarations to preserve the set of cases and their ordering (but they could add new cases after the existing ones). |
How would that apply to an IDE's type preview or the |
That depends on the degree to which we include cases in function types. The most comprehensive level of support would allow all functions to be case functions, and all function types to contain a full list of cases. A "normal" function type would then simply be the ones we know today, and that would be a supertype of similar ones where we have added some cases. |
That issue is about the functions that already return It's about those functions that default to returning |
I agree. |
One thought I had during the design process was to allow a default value that wasn't valid for all possible type arguments, and then you would have to provide a better value if the default value wasn't valid. So: static Null kNull() => null;
T first<T>(Iterable<T> values, [T orElse() = kNull]) { ... } Then a That idea doesn't work. The problem is that default values are not part of function types, so if I do: T Function<T>(Iterable<T>) f = first; then it's seemingly valid, but it doesn't preserve the safety of the Er definitely do not want the default value to be part of the function type, because that would make a lot of otherwise compatible function types be different. (Doesn't even work if we use the default value's type in inference when not passing it, for the same reasons). So, any proposal here, for a very real problem we have had in the platform libraries too, has to solve the problem of the default value not being visible in the function type. IdeaCould we let the type of the default value be part of the function type? Since we only care about the type, the exact value might not be necessary. Then Let's define this function type as having a potentially unsafe default value type because it has a default value type which is not a subtype of the parmeter type. If neither the parameter type nor the default value type referred to a type variable, then there'd be nothing potential about it. The it's just a compile-time error. The default value would never be a valid argument. If the parameter type or the default value type contains a type argument, either from the function or from a surrounding function or class, and the default value type is not a subtype of the parameter type, then we include the default value's type in the function parameter's signature. So You can specify a potentially unsafe default value type that is completely unrelated to the parameter type, as long as at least one of them contains a type variable. We will not try to solve for whether it's possible to bind type variables such that the default value becomes valid. We'd have to define subtype relations on such types. As usual, we'll want "soundly substitutable" as the underlying principle, so a subtype of the above type would be one which allows the same arguments. Subtypes of
Potentially unsafe default value types in function types are covariant, and they are supertypes of corresponding safe function types. Since all current functions are safe (you cannot declare a potentially unsafe default value in the current type system), introducing these extra function types should be non-breaking. That is, until we start using them in the platform libraries. Even then, the constraints we'd introduce with NNBD won't break any legacy code because all legacy types are nullable. I have not considered whether this introduces something bad into the type system (like, say, undecidability). Summary: You can declare function types with potentially unsafe default value types. Type1 Function([Type2 x = Type3]) If A function can be declared with a default value which is potentially not a subtype of the parameter type. Maybe that needs extra syntax Null _kNull();
T first<T>(Iterable<T> values, [T orElse() <= _kNull]) {
var it = values.iterator;
if (it.moveNext) return it.current;
return orElse();
} The type of first is Calling
Omitting the second argument means that the default value type can be used in inference:
|
Cool idea! ;-) One thing to think about: When we rely on 'the actual parameter type of the invocation' and that could be determined by the choice of actual type arguments passed to the callee when that is a generic function, inference would generally be able to choose a super-type for some of the type arguments, and thus make the difference between a valid and an unusable default value. Null _kNull();
T first<T>(Iterable<T> values, [T orElse() <= _kNull]) => ...
void main() {
var x = first([1, 2]); // Succeeds, passing `int?` to `first` and to `[1, 2]`.
} The developer who writes this might be happy because "it works", but the This could serve as a warning about tractability and comprehensibility issues with inference. So we might prefer to ignore the potential default value during inference, and then simply make it an error if the default value is an error with that typing, and no actual argument is provided. The underlying mechanism is (1) in some context there is an option to specify a default value, (2) a default value is specified, but for some configurations (e.g., for some values of some type variables) that default value is an error, so (3) we just consider the default value to be provided when it's not an error, and omitted when it is an error. We could use this kind of mechanism in several different contexts, preferably with some syntactic marker (such that we don't just silently accept wrong default values all over the place). For example: abstract class A<X extends num> {
X foo() <=
int foo() => 42; // Default implementation.
}
class B extends A<int> {} The default implementation of The next step could be to have a list of candidates and taking the first one that works. And so on. And along the way we need to consider when the complexity cost outweighs the benefits, of course. ;-) |
The "actual parameter type" would still have to be the static type of the actual parameter of the function type being called, just after any known type variables have been instantiated. For the nullable type coming back to bite us, I think null safety will actually make that issue go away. When the type of It's a clever idea to use the same approach with interface methods or (please) interface default methods. If you are compatible, you get the default implementation, and if not, you don't. This s also something we have always wanted for |
@tatumizer It will actually work because it will infer that The inference for Indeed, |
About the "default implementation that uses a different type", this would only partially support my use-case. On freezed, I generate a Such that for: @freezed
abstract class Example with _$Example {
const factory Example.person(String name, int age) = Person;
const factory Example.city(String name, int population) = City;
const factory Example.country(String name, int population) = Country;
} We have: Example example;
String name = example.map<String>(
person: (Person person) => person.name,
city: (City city) => city.name,
country: (Country country) => country.name,
orElse: () => '',
); Now, in the ideal world, with NNBD
Right now, if I want to achieve the same thing with NNBD I need to define three different functions when they are effectively three times the same thing. This leads to confusing naming ( |
@tatumizer wrote:
During inference of the actual type arguments for an invocation of a generic function, the context type may provide some constraints on the type variables based on the return type, and the static types of the actual arguments may provide some constraints based on the declared types of the formal parameters. So there is no special magic about taking the types of value arguments into account when inferring type arguments, which may in turn influence the return type of the invocation. However, the context type is given a high priority during inference, which may give the impression that the types of the value arguments are ignored during inference: main() async {
Map<X, Y> f<X, Y>(X x, Y y) => {};
var map = f(1, true);
print(map.runtimeType); // 'JsLinkedHashMap<int, bool>'.
Map<num, double> map2 = f(2, 3);
print(map2.runtimeType); // 'JsLinkedHashMap<num, double>'.
} This shows that the types of the actual arguments fully determine the return type in the first call of You could say that it is an anomaly to infer a union type (like void main() {
f(bool b) {
if (b) return null;
return 42;
}
String s = f; // Error message reveals static type of `f` is `int? Function(bool)`.
} So I don't think there's a need for new magic in order to handle the inference in the example. |
There is a certain amount of guesstimation involved in this discussion because the declaration of If we allow it and give it the meaning that the default value is considered to exist unless the inferred type makes it an error then you are right that we would need to have some extra support during inference in order to take So the question is not so much whether
because that's a standard property of type inference: When we have chosen a value for It's certainly possible that we should rather avoid putting this kind of extra smarts into type inference, because that might eliminate exactly those cases that were described as questionable in this discussion. |
For anyone else finding this issue, nullable methods have been added, but you have to import
You can then use
This was the first issue I hit with NNBD. Would it be worth adding something to the documentation for e.g. |
Any update? Or if permitted I'd like document on it |
No update and no plans on this as far as I know. |
I understand this issue is broader than just singleWhere or firstWhere, which are only edge cases for a conceptual problem. However I would like to mention that other languages like C# solve this by introducing redundant functionality for the two use cases, particularly Single and SingleOrDefault. Where the former requires a single match and the latter returns null in case of no match. I do think this addition would be a solution for most of us here as well. Thank you. |
A common pattern in Dart is to have an "orElse" callback on functions that needs to have a fallback behavior.
A concrete example is the
Iterable.firstWhere
:Now, this is not specific to
Iterable.firstWhere
and is very common to Dart in general.NNBD doesn't cause any issue with
Iterable.firstWhere
specifically, as by default, it will throw if it needs a fallback but noorElse
was provided.The problem comes with a variant of that pattern, where instead of throwing, the default behavior of their
orElse
would be:() => null
.A typical implementation would be:
The issue with that variant is that it is impossible to migrate to NNBD without some inconvenient change.
This pattern gets stuck between two un-ideal solutions:
either make
orElse
required:This is not ideal because it creates a lot of duplicate code.
We suddenly have to add tons of
orElse: () => null
.or make the return type always nullable:
This is not ideal either because if an
orElse
is specified, then the result may effectively never be null but will still be considered as nullable because of a language limitation.So in the end, with NNBD enabled we either have pointless boilerplate or an incorrectly inferred type.
The text was updated successfully, but these errors were encountered: