-
Notifications
You must be signed in to change notification settings - Fork 208
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
Users want to define union or union-like APIs #145
Comments
Is that true? Can't dynamic dispatch to |
It could. It does mean though, for overloads at least, you do not know the return type. In practice I'm not sure this is worth it. If it was a feature specifically for trying to help migrate existing (non-overloaded) APIs to overload-based ones, I could see value in that. |
In cases where the compiler can't statically determine which overload to call, it could use a union type for the return: C foo(A a) {}
D foo(B b) {}
void bar(Object obj) {
var result = foo(obj); // The compiler would infer the type of `result` as `C | D`.
} |
What does it mean to know the return type in dynamic dispatch? |
I agree. I also do not see a lot of value in dynamic dispatch as of Dart 2. But that's different from saying that overloads do not support dynamic dispatch. They do. The question is whether we want it. |
@mdebbar Right, that's a second type of dispatch. Unless @matanlurey and I misunderstood each other, we were talking about dispatching |
I'm curious if the web platform APIs could provide use cases and example problems which we could add to this request? |
https://github.com/Microsoft/TypeScript/blob/master/lib/lib.dom.d.ts is a good source of web platform examples (look for |
There are a couple of separate pieces here that I want to try to tease out to understand better. That way we can be more precise about what the actual user need is. OverloadingThis is the ability to have two methods with the same name but different parameter lists. In your example, it's: void writeLog(String log) {
_writeLog(log);
}
void writeLog(List<String> logs) {
logs.forEach(_writeLog);
} One key question for this is whether overloads should be chosen dynamically or statically. Given: Object log;
if (isMonday) {
log = "A string";
} else {
log = ["Some", "strings"];
}
writeLog(log); Would you expect this to do the right thing on all days of the week? Or is this a static error because it doesn't know which overload to call at compile-time? Which answer you choose has profound impact on the design... Dynamic overloadingIf the dispatch does happen at runtime, then you're talking about something like multimethods—runtime dispatch of methods based on the types of their parameters. This is a really cool, powerful feature. It's also very rare in object-oriented languages. Doing this would let us do things in Dart that few other languages can do, but it could also be fiendishly complex. Consider: int weird(String s) => 3;
bool weird(List l) => true;
main() {
var fn = weird;
Object unknown;
var o = fn(unknown);
} What is the static type of Static overloadingThis is what C++, Java, C#, etc. do. It's definitely well-explored territory. It solves several real, concrete problems. For example, in Dart, adding a method to a base class may always be a breaking change because some subclass could have a method with the same name but a different signature. In the listed languages, that's much safer. If the signature is compatible, there's no problem. If it isn't, it just becomes a separate overload. The only risk if there's a compatible signature but an incompatible return type. Static overloading also has a deserved reputation for adding a ton of complexity to the language. It complicates generics and implicit conversions, sometimes leads to exponential performance cliffs during type-checking, and confuses users. Union typesThis is the ability to define a structural type that permits all values of any two given types. That's: void writeLog(String | List<String> logOrListOfLogs) {
if (stringOrListOfString is String) {
_writeLog(stringOrListOfString);
} else if (stringOrListOfString is List<String>) {
stringOrListOfString.forEach(_writeLog);
} else {
// Bonus: Can remove this once we have non-nullable types.
throw ArgumentError.null(logOrListOfLogs);
}
} Dart has already taken steps in this direction with I wouldn't be surprised if we eventually get union types, though we don't have plans for it currently. (Non-nullable types will keep us more than busy enough for the immediate future.) Union types are nice, but don't solve as many problems as users think. Consider class int {
int | double operator +(int | double rhs) => ...
} But the union types aren't precise enough. This declaration loses the fact that Overloading can express that, but union types can't. Literal typesThe TypeScript example introduces an entirely new feature, singleton types that only contain a single value. That lets you use an That's a lot of type system machinery to add, and I'm not sure how useful it is. It quickly falls down if you don't compare to actual literal values. It might be worth looking at, but I'd be surprised if it fit well within a more nominal language like Dart. |
Thanks @munificent. I agree this is probably a few issues and needs more investigation. Without a longer reply, my 2 cents:
|
#148 introduces 'case functions', which is one way to handle the issues described here. Considering some points raised above: @matanlurey wrote:
Case functions do allow that. @munificent wrote:
Right, case functions rely on a simple, user-specified approach to disambiguation (so you won't ever get "ambiguous invocation" errors, which is otherwise a source of a long list of fine papers ;-). It is guaranteed in some (but not all) cases that the semantics of a case function invocation is exactly the same for a statically resolved case and for a dynamically resolved case, and I expect that this could be subject to 'strict' warnings. For instance, sealed classes would give some useful guarantees. So you could say that case functions are a pragmatic take on multimethods.
When giving an argument of type
It is probably not too hard to introduce constants as patterns for case functions. But we might want to design a general pattern declaration and matching feature first, such that we can use the same approach everywhere. |
Web APIs are littered with these. Search for WebIDL explicitly supports Union types - https://www.w3.org/TR/WebIDL-1/#idl-union - so this is always an issue when interfacing with web/JS apis CC @sethladd |
Another random data point: I've been using C# again recently (for a silly hobby project), and wow, having static overloads is so nice. I'd forgotten how nice they are. Really helps with API design, too. |
Would syntax sugar over callable classes works? Right now we can achieve the equivalent of named constructors for functions using callable classes: const someFunction = _SomeFunction();
class _SomeFunction {
const _SomeFunction();
void call() {}
int customName() => 42;
} which allows someFunction();
int result = someFunction.customName(); It works but is not very convenient to write. allowing void someFunction() {}
int someFunction.customName() => 42; The bonus point here is that since it's not actual method overload, dynamic invocation still works just fine. |
This issue seems related to #83. Is there a canonical issue for discussing union / sum types? Can we close the others as duplicates? Adding my 2 cents hereApproach A: Common SuperclassImagine I want to create a tree data structure that has 2 types of nodes: a abstract class Node {}
class ChildNode extends Node {
final String value;
ChildNode(this.value);
}
class InternalNode extends Node {
final List<Node> children;
InternalNode(this.children);
} This works, because I control the definition of both classes, and so I can have them inherit from a common superclass. To make use of a String nodeToString(Node node) {
if (node is ChildNode) {
return (node as ChildNode).value;
}
assert(node is InternalNode, "Unexpected Node type. Expected a ChildNode or an InternalNode");
var buffer = StringBuffer();
for(var child of (node as InternalNode).children) {
buffer.write(nodeToString(child));
}
return buffer.toString();
} There are 2 things to note here:
Despite the disadvantages (1-3) above, this approach can work when the child types are written by the developer themselves. However, what about something like an Approach B: Union WrapperIn the case of a union on types that are not "owned" by the code author, a wrapper class can be created. For example, in the case of a class StringOrInt {
final String? _string;
final int? _int;
StringOrInt.fromString(this._string);
StringOrInt.fromInt(this._int);
bool get isString => _string != null;
bool get isInt => _int != null;
String asString() {
assert(isString);
return _string!;
}
int asInt() {
assert(isInt);
return _int!;
}
} One thing to note here is that, in the class Foo {
String name;
int id;
}
List<Foo> foos;
Foo? findFooByName(String name) {
// omitted
}
Foo? findFooById(int id) {
// omitted
}
Foo? findFoo(StringOrInt query) {
if (query.isString) {
return findFooByName(query.asString());
}
return findFooById(query.asInt());
} This is definitely awkward to write and use, but it has some advantages:
ExtensionsWe can extend this further by adding methods to easily convert from the base type: extension IntUnion on String {
StringOrInt toUnion() => StringOrInt.fromString(this);
}
extension StringUnion on int {
StringOrInt toUnion() => StringOrInt.fromInt(this);
} GenericsAlternatively, we can generalize this as follows: class Union<T, U> {
final T? _left;
final U? _right;
Union.left(this._left) : _right = null;
Union.right(this._right) : _left = null;
bool get isLeft => _left != null;
bool get isRight => _right != null;
T get left {
assert(isLeft);
return _left!;
}
U get right {
assert(isRight);
return _right!;
}
dynamic get deref {
return isLeft ? left : right;
}
} This has the advantage of not needing to create type-specific unions for each combination that needs one. The disadvantage is that the naming is very generic. Similarly, you could create a sort of extension method with this as follows: extension ToUnion on String {
Union<String, T> toUnion<T>() => Union.left(this);
} You could use this as follows: "some string".toUnion(); The problem with this is that it doesn't scale: extension ToUnion on int {
Union<int, T> toUnion<T>() => Union.left(this);
}
Union<String, int> query = "name".toUnion<int>(); // this works
query = 42.toUnion<String>(); // this doesn't work. Union<int, String> != Union<String, int> You could try to fix this using some sort of
The resulting code would work like this: Union<String, int> query = "name".toUnion<int>();
query = 42.toUnion<String>().flip(); This is quite awkward, and the use of it is even more so: Approach C: Full-blown
|
Bonus idea: Could union types be used as an implicit interface for a sealed type as per https://github.com/dart-lang/language/blob/master/working/0546-patterns/exhaustiveness.md#sealed-types ? If so, we could piggy-back exhaustiveness checking for sealed types off of type narrowing for unions types. |
@TzviPM Your "Improvement 2" already works. You may try the following code, that uses methods based on type promotion ( import 'dart:math';
void main() {
final value = Random().nextBool() ? "I'm a String" : 10;
if (value is String) {
print(value.toUpperCase());
} else if (value is int) {
print('Is odd? ${value.isOdd}');
}
} Regarding the Left useLeftAsNonNullable() {
final left = getLeft(); // Nullable
final isLeft = left != null;
if (!isLeft) throw StateError('Left should not be null here!');
return left;
} |
@TzviPM wrote:
[Edit: Checking again, But promotion works just fine with a current version of dart, and some small adjustments of the code: abstract class Node {}
class ChildNode extends Node {
final String value;
ChildNode(this.value);
}
class InternalNode extends Node {
final List<Node> children;
InternalNode(this.children);
}
String nodeToString(Node node) {
if (node is ChildNode) {
return node.value;
} else if (node is InternalNode) {
var buffer = StringBuffer();
for (var child in node.children) {
buffer.write(nodeToString(child));
}
return buffer.toString();
} else {
throw "Unexpected subtype of Node";
}
}
void main() {
print(nodeToString(ChildNode('A node')));
}
Dart is quite likely to introduce a notion of 'sealed' or 'switch' classes, and this concept is specifically aimed at enabling exhaustiveness checks. So there is no solution right now, but that is likely to change. |
You might be interested in a new package, https://pub.dev/packages/extension_type_unions. More details in this comment. |
Kotlin now did this: https://kotlinlang.org/docs/whatsnew20.html#type-checks-with-logical-or-operator ![]() |
@bernaferrari that seems like smart cast which Dart already |
@jodinathan if you do class A { void a(); void b(); }
class B { void b(); void c(); }
void fn(something: Object) {
if (something is A || something is B) { something.b() }
} will it work? I don't think it will. |
class I {
void b() {};
}
class A extends I {}
class B extends I {}
void fn(Object something) {
if (something is A || something is B) something.b(); // doesn't work, we don't promote to I
} We will use least upper bound in some other situations, not sure exactly when/where though. But based on the Kotlin description linked, it is just promoting to |
I too was surprised that this doesn't work! Thought I'd add |
@stereotype441 could probably speak a lot more intelligently than me on the topic of the nuances of type promotion 🤣. |
The reason it doesn't work is that joins of type promotions only uses the exact types in the promotion chains, it doesn't do Up. The code promotes from It doesn't try to do Up( Maybe we should see joins of promotions more like joins of values, but today we do not. Also because that would likely make that type a type of interest. |
Based on conversations with @yjbanov, @leonsenft, @mdebbar.
Currently, the Dart language lacks a way to provide static union or union-like semantics or APIs. Multiple other platforms take different approaches - anything from user-definable union types, algebraic/tagged unions, method overloading, and I'm sure other approaches we missed.
Let's look at two examples:
APIs that take nominal types A or B
Problems:
Octopus
, and only receive an error at runtime:Solutions
... unfortunately, this now means you often need to think of convoluted API names like
writeLogList
.... unfortunately this (a) Can't have different return types, and (b) might have complex side-effects with reified types (i.e. expensive performance reifying and storing
writeLog<T>(T | List<T> | Map<T, List<T> | ....)
, and (c) just looks ugly compared to the rest of the language.@yjbanov did mention a first-class
match
orwhen
could help with(c)
, but not(a)
or(b)
:... this solves all of the above concerns. It does not allow dynamic calls, but neither will static extension methods and neither do, say, named constructors or separate methods (used today), so I don't see this as a net negative.
APIs that structural types A or B
@DanTup ran into this while defining Microsoft Language Service protocols. Imagine the following JSON:
Modeling this in Dart is especially difficult:
You can write this by hand, of course, but imagine large auto-generated APIs for popular services. At some point you'll drop down to using code generation, and it's difficult to generate a good, static, model for this.
Problems
Let's imagine we get value types or data classes of some form, and let's even assume NNBD to boot.:
This works, but like the problems in the nominal types above, you need runtime checks to use the API correctly. This can get very very nasty on giant, popular APIs (like Microsoft's Language Service, but many many others including Google's own):
Solutions
One way this could be solved is having user-definable tagged unions.
TypeScript would model this as:
The text was updated successfully, but these errors were encountered: