Open a14n opened 3 years ago
dynamic<Null>
anddynamic<Object>
will be the same thing
Indeed but there are no problems because they both describe the same shape of object.
it's not clear whether parameterized types can be used as type parameters - e.g.
dynamic<List<int>>
- what does it mean
dynamic<List<int>>
will refer to any type having all methods that a List<int>
has (e.g. a void add(int value)
whereas dynamic<List<String>>
will have void add(String value)
).
I my mind dynamic<T>
means any class able to implement T without modification and error. In my initial example the class A could have been defined as class A implements HasLength
without any additional change. So A is valid as dynamic<HasLength>
.
Duck typing would be equivalent to dynamic
(invocations can't be v-table based), but with non-subtype based restrictions on which types of values can flow into them. That makes it potentially inefficient, and we might be better off with proper Rust-like traits. Alternatively, the compiler can recognize which types are actually assigned to a duck type and implement dispatch tables for those, giving us the same good performance, but costing more memory/code size.
If we take dynamic<T>
as strawman syntax, only objects satisfying the signatures of T
can be used. It's true that dynamic<Null>
and dynamic<Object>
would be completely equivalent (and mutually assignable).
How would subtyping work otherwise? I'd expect:
X
\<: dynamic<X>
for all types X
dynamic<X>
\<: dynamic
for all types X
(including X
= dynamic
!)dynamic<X>
\<: dynamic<Y>
if X
would be a valid structural subtype of Y
(adding implements Y
to X
would not introduce an error).From 1., 3. and transitivity we get that Bar
\<: dynamic<Foo>
if Bar
could implement Foo
.
It would introduce yet another top type since Object?
\<: dynamic<Object?>
\<: dynamic
. Nothing new there.
So, what about the special types in the Dart type system (we have quite a lot of those!)
The type dynamic<dynamic>
should probably be considered equivalent to just dynamic
. It's a supertype of dynamic
, all objects are assignable to it. What members does it have then? Likely ... all possible members, dynamically invoked. So, dynamic
. Or maybe disallow it and require you to just write dynamic
.
The type dynamic<void>
should also allow any object, and maybe not have any members at all (not even Object
members). Or just be equal to void
. Generally, dynamic<top-type>
seems to be equivalent to just top-type
- it has the same members, and all objects are already assignable to it. Or again, disallow and require you to write it directly.
The type dynamic<Function>
probably won't work without a hack. We'd want any subtype of Function
to be assignable to it, but it has no signature for its call
method. So, we'll require a call
member and do dynamic invocation of it. Or disallow it, and just make people use Function
directly.
The type dynamic<int Function(int)>
(or any other function type) could require having a call
method of that function signature. That could work, but would mean that callable classes satisfy the same interface. Just using the function type itself would force a tear-off when assigning a callable class instance to it, which is better. Allowing both to share the same type gets us back to the problem we tried to avoid during Dart 2.0 design, where we don't know if a callable value is a function or an object. So again, prefer to disallow. Just use the function type itself, it's already structural!
The type dynamic<Never>
is unsatisfiable by any type other than Never
. Just make it equivalent to Never
, we don't need a second un. That's fair. Or disallow and require Never
.
The type dynamic<int?>
should require all members that can be invoked on int?
, but that's just dynamic<Null>
/dynamic<Object?>
. So we can disallow it.
The type dynamic<FutureOr<X>>
is likewise equivalent to dynamic<Object?>
. If we ever add general union types with intersection members, we might fine tune that. For now, just don't do it.
The type dynamic<T>
where T
is a type variable with bound B
... should probably be disallowed. We don't want to do structural type-assignability checks at run-time. If it's anything, it's dynamic<B>
, and then you can just write that.
So, all in all, we can just disallow all special types and require that X
in dynamic<X>
is an interface type (declared by a class
or mixin
declaration, discounting Future
and FutureOr
which are not really classes, they just have class-looking placeholders in the platform library source). Maybe disallow Null
too since it's redundant with Object
.
(This feature differs from the idea of having dynamic Foo
which allows only current subtypes of Foo
, and does type-safe invocations on Foo
methods, but allows unchecked/dynamic invocation otherwise, a "dynamically invocable subtype of Foo
").
@tatumizer Very good questions.
I'd personally disallow is
and as
since the rely on the run-time type of foo
, and we don't want to do run-time structural subtype tests.
The type parameter bound is iffy. It's probably doable, but it also suggests that a structural type can be a type parameter, and that's unlikely to fly. If I make a List<dynamic<Bar>>
and cast it to List<dynamic>
(allowed by subtyping relation and unsafe covariant generics), and then try to add a Foo
to that list, it needs to do an is dynamic<Bar>
in the add
method (as required of covariance by generics). And I didn't want to allow that. So, no using it as a type parameter either.
Probably won't allow implements dynamic<Bar>
, it's not an interface. If it was an interface, it would be the same interface as Bar
, so you'd just have to write implements Bar
.
We can allow implements dynamic<Bar>
to mean that your class must satisfy the interface of Bar
, but doesn't have Bar
as a superinterface. It's just an extra static requirement on the class (which will allow it to be assigned to dynamic<Bar>
).
If we treat dynamic<Foo>
as a Rust-like trait with run-time reification of the proof-of-implementation (fat pointers with a V-table for the trait along with the this
pointer), then ... is
still won't work, as
might work if we have the evidence in scope so we can reify it, and the List<Object>.add
problem is still real. We can only add "trait values" to that list (fat pointers), but we can't see from the type that we need that. Might be doable, not easy.
I don't see self-types coming from this approach, not any easier than what we have today, so dynamic<T extends dynamic<Addable<T>>>
is probably not going to fly.
It would be more natural (but more complicated to implement, I'm sure) if the type system allowed checking for this when all else fails. In other words, if String
and List
should be considered subtypes of a class HasLength
so long as they both implement all of HasLength
's members (supposedly just int get length
). To adapt @tatumizer's example:
abstract class Addable<T> {
T operator +(T other);
}
T add<T extends Addable<T>>(T a, T b) => a + b;
void main() {
/// Strings are `Addable<String>` since [String.+] is defined as `String +(String other)`
String one = "Hello";
Addable<String> two = "World"; // both declarations are valid
String three = add(one, two); // T is String
}
We have several data structures in our app that could work with anything that has a length and is indexable.
However, in these cases, we're forced to declare the type signature as a very broad List<T>
because there's no way to say "this function accepts anything that's indexable with a length".
There may be a risk of misusing the interface, but having worked in languages with structural typing, I think the benefits of less boilerplate and more flexibility outweigh the risks of passing in the wrong thing.
all the information is already there in the body of extension methods.
Dart doesn't check the body of methods for type information, that's all in the signature. Accessing members on an object in code and telling the compiler "this is okay" seems like a good use for dynamic
. Otherwise, how would the compiler know the difference between an actual error and a getter that you know is there?
Still doesn't answer the question -- we don't usually plan to make errors in our code, so a keyword saying "this code has no errors" isn't that accurate.
What's wrong with abstract classes? Abstract classes are meant to specify "here's what I want a type to have, and here's what I want to call that type". In your code, you want Dart to define an interface with .zero
and .+
, so why not just skip the middleman and do it ourselves? I feel like extensions are coming from an entirely different domain here.
As far as developer UX, my preference would be for the syntax to be similar to abstract classes since we already use them as interfaces (class MyClass implements SomeAbstractClass
).
The main differences I am aware of are:
Abstract classes can have function bodies while an interface wouldn't
That's fine though, since you can (and often do) have abstract classes with only abstract methods
Abstract classes need to be implemented explicitly while a (structurally typed) interface wouldn't
That's why I would suggest that the type system automatically check if a class could implement an abstract class if all else fails. So String
definitely does not implement HasLength
, but since it could, Dart should treat it as if it does. This is kinda like extensions but for typing, in that you get to abuse the type system to use 3rd party code as if you wrote it yourself.
@Levi-Lesches
The problem with implicitly making a class implement a compatible interface is that its a static view of the world.
The class might currently implement the interface, but if it didn't promise to, then either it or the interface might change in the future, and then the code relying on Foo
implementing Bar
will no longer work.
It doesn't even have to be a (big) breaking change. If the interface was not intended for anyone else to implement, then changing a method from void foo(int x);
to void foo(num x);
, and updating all the classes that were intended to satisfy that interface, will be a non-breaking change for clients.
If someone explicitly implemented the interface, even if they weren't supposed to, then they'd be broken, but would have only themselves to blame.
If a class was made to implicitly implement the interface, just because it had an int foo(int x, [int? y])
method (a valid implementation of the signature), and someone starts using it at that interface, then the compiler is to blame when the interface changes and the code breaks.
We'd need to have away to declare interfaces either "open" or "closed", and only implicitly implement "open" interfaces (with the default being "closed"). Being an open interface would be a promise to users.
Right, this is definitely a fragile system.
What if the onus was on the client, not the original interface? Some keyword like as
that tells Dart "do what you can to make this work, and I understand it may not work for long". My understanding is that whatever we do here is basically a replacement for dynamic
, where the scenario you mentioned will cause an error anyway, so I think that's an acceptable risk.
Little case for checking Map
, List
and user defined collections. It's not pretty if you rely on an error.
abstract class Collection<T> {
int get length;
T operator [](int index);
}
bool isCollection(dynamic value) {
if (value is Collection) {
return true;
}
try {
value.length;
value[0];
return true;
} on RangeError {
return true;
} catch (_) {
return false;
}
}
Also #736 .
If a class was made to implicitly implement the interface, just because it had an
int foo(int x, [int? y])
method (a valid implementation of the signature), and someone starts using it at that interface, then the compiler is to blame when the interface changes and the code breaks.
@lrhn to make sure I understand this right, how would it look from a developer experience perspective? I suppose you mean these situations?
HasLength
interface: my editor tells me "missing the length property for the HasLength interfaceDoes it come down to error noise? Or am I missing some other considerations?
And I suppose this is less of a problem within the same codebase (easy to fix the errors), but once we start thinking cross-package--where you don't know who depends on you--it becomes more problematic.
What if it was possible for a developer to explicitly implement an interface but it didn't have to be in the class declaration itself? So I could define it like List implements HasLength
interface and then use it.
And I believe Typescript interfaces are structurally typed. I wonder how it's handled with them.
That's fine though, since you can (and often do) have abstract classes with only abstract methods
@Levi-Lesches I think we're in agreement. I just meant that's as a small difference. So it feels like abstract classes are the closest concept to interfaces that we have today.
After giving it more thought, I think dynamic
is a valid language feature to use here. From Effective Dart:
The type
dynamic
not only accepts all objects, but it also permits all operations. Any member access on a value of typedynamic
is allowed at compile time, but may fail and throw an exception at runtime. If you want exactly that risky but flexible dynamic dispatch, thendynamic
is the right type to use.
@lrhn made the point that
The class might currently implement the interface, but if it didn't promise to, then either it or the interface might change in the future
And they're both correct. We don't have any guarantee that String
and List
have a common int get length
, since they don't commit to it explicitly. Which means that the only reason we're okay with doing this is because we just "know" that they do. There's no reason why Dart 3.0 can't change List.length
to List.size()
. This is where that "risky but flexible dynamic dispatch" that Effective Dart was talking about. And again to @venkatd's point that no matter how we do this, if List.length
turns into List.size()
, we still have an error we need to fix.
And even if we could force the types to implement some common interface, that wouldn't be enough. To borrow from @eernstg in #281:
class Plant { void shoot() { /* ..break out of the seed and start creating a cute little plant.. */ } } class Cowboy { void shoot() { /* PULL A TRIGGER! */ } } main() { Plant rose = Cowboy(); // OK if using structural subtyping rules. standInFrontOf(rose); rose.shoot(); }
If we adopt the idea that an interface can imply a certain set of expectations about the meaning of each of the members (such as
shoot
), and each member name may not be similarly well-defined (shoot
in different interfaces could mean different things) then the choice of structural typing is not just a matter of "I can do more", it is also about "can I trust this operation to do the right thing?".
In other words, we seem to all agree that this is inherently unsafe, and neither the API creator nor user can make this decision on their own and call it safe. IMO, dynamic
is fine in this case, or to stay type-safe, use Object
with manual checks for each type. This would allow changes such as .length
--> .size()
to happen seamlessly.
Another use case to share. We have a sort of hacked implicit interface implements in part of our app. We generate types from a GraphQL schema and we want it to be possible to use some of the objects interchangeably.
For example a User with an id, name, photo should be allowed to be used in place of an id, name because one is a superset of the other. So if someone fetches more data than is necessary in a query, they are still allowed to use the response in widgets/functions that need less info.
So we end up with some long implements
lists as such:
class User
implements
AuthUser,
BasicUser,
BasicUserWithTimezone,
DocumentToken,
FeedSource,
GetChatsCurrentUser,
GetCurrentUserProjectsCurrentUser,
GetUnreadBadgesCurrentUser,
ManagementUserWithProfile
Also
class AuthUser implements BasicUser
We loop over all the fields to figure out who could implement what and generate the implements based on that. This is because our widgets don't necessarily care about the name of the class that's being passed in.
We have control over the code-generation so we have the luxury to do this. The benefit is we get compile time type checking. We pass in a class that the fields and types don't match up, and we get an instant error in our editor.
As a similar thought experiment, suppose I wrote the following:
@Implicit()
abstract class HasLength {
int get length;
}
Then, before compiling the app, I rewrote all the classes in the pubspec.lock
file with int get length
to also implement HasLength
.
(Not suggesting this is the solution, but posing an idea to understand trade-offs better.)
What would be the main downsides of that in terms of the Dart compiler and developer experience?
As an alternative -
what about having something like dart:core
exposing a bunch of abstract interfaces for this? e.g. a Disposable
class, a Summable
class, etc?
For the length one, it'd be really nice to have something like abstract class Countable
and a subtype abstract class EfficientCountable
, so that you could tell whether calling length
was expensive or needed to be cached or not.
IOW - I'd find it really hard to reason about calling a method that just wants my type to implement some method without knowing why, but I'd find it really pleasant to label my type as having a method you can use with a well defined contract.
plus for EfficientCountable []
and .length
what about having something like
dart:core
exposing a bunch of abstract interfaces for this? e.g. aDisposable
class, aSummable
class, etc?For the length one, it'd be really nice to have something like
abstract class Countable
and a subtypeabstract class EfficientCountable
, so that you could tell whether callinglength
was expensive or needed to be cached or not.
This seems orthogonal to this issue IMHO.
Imagine we have a Countable
interface and our hasEvenLength(HasLength object)
function. If a class A
from another package has a length
getter (but doesn't implement Countable
) and you want to use it as parameter of hasEvenLength
. You have 2 choises : (1) change hasEvenLength(HasLength object)
function to hasEvenLength(dynamic object)
but now any class can be used... even class without length
getter. (2) make a PR to the foreign package to make this class implement Countable
interface, wait for acceptation/merge, wait for release, update the dep and finally use it in hasEvenLength
. And you have to do that for all classes in all packages you want to pass to hasEvenLength
.
At the end of the day we will always have the dynamic
type. The goal of this proposal is to bring some type check on dynamic calls and make it less error prone. This issue is quite similar to union types. That is dynamic<HasLength>
== union of all classes with a int get length
getter.
Won't that do lots of nasty things to tree shaking though?
For example, once the compiler sees that you dynamically call operator==
, it can't shake that operator from any remaining types in your library...
You could always decorate your unmarked type, although that does add some work/overhead.
@dnfield I like the idea to have more granular interfaces introduced to the dart data structures.
However, I think this wouldn't solve the broader issue. This would help the narrow use case of built-in data structures, but it would be hard for the Dart/Flutter teams to anticipate all of the use cases that would arise. Similarly, any package author would have to anticipate all of the possible usages of a class and create a bunch of interfaces trying to account for all of these possibilities.
I'd find it really pleasant to label my type as having a method you can use with a well defined contract.
I agree with this. Part of the problem is that, if a class hasn't explicitly implemented an interface, I have to resort to dynamic or wrap that object if I want to get the benefits of static typing.
I'd want to allow to declare an interface after-the-fact. Here I'd be able to give it a proper name.
I acknowledge there is the downside where you may incorrectly pass in an object, but that is no worse than the dynamic
keyword we are using. In both situations you can mistakenly pass in objects that shouldn't be passed in. The goal is to add more structure to the parts of the code where we're currently resorting to dynamic.
There are ways to do this such as wrapping the types and having the wrapper explicitly implement the interface, but the amount of boilerplate required leads me to reach for the lower friction dynamic
option.
this could help with serializing packages (which is increasing the amount each day along with state management packages).
dynamic<JsonObjEncodable> foo;
class JsonObjEncodable {
Map<String, dynamic> toJson();
}
@lrhn , I would probably suggest considering this syntax.
bool hasEvenLength<T implements HasLength>(T object) => object.length.isEven;
abstract class HasLength {
int get length;
}
bool hasEvenLength<T implements HasLength>(T object) => object.length.isEven;
class A { int get length => ...; }
class B { int get length => ...; }
main() {
hasEvenLength(A());
hasEvenLength(B());
}
From @lrhn, above:
Probably won't allow
implements dynamic<Bar>
, it's not an interface. If it was an interface, it would be the same interface asBar
, so you'd just have to writeimplements Bar
.
Is that what this quote said?
class Foo implements dynamic<HasLength> {
...
}
But my version of the syntax is about something else. In this variant we say that the generic T type should implement HasLength without forcing the T type to have a v-table. This variant may remove some of the problems mentioned above (T isn't dynamic)
That said, commenting on the need for structural typing seems pointless, there are situations where we cannot influence external code and it is hard to do anything about it.
What if we use abstract interface class
es as equivalents to Rust's traits. Later on, we can use extension
s to satisfy the interface:
abstract interface class Countable {
int get length;
}
abstract interface class HasIsEmpty {
bool get isEmpty;
}
class EmptyArray {
final int length = 0;
}
extension E on EmptyArray implements Countable, HasIsEmpty {
// No need to add the [length] getter, as [EmptyArray] already has it.
// However, we need to add [isEmpty].
bool get isEmpty => length == 0;
}
void f(Countable c) => print(c.length);
void main() {
f(EmptyArray()); // prints "0"
}
Hi! Found this issue today. As mentioned above by HosseinYousefi:
What if we use abstract interface classes as equivalents to Rust's traits
I had created an issue for that without knowing that Rust had this implemented and what name it was: https://github.com/dart-lang/language/issues/3024.
Just commenting here so we can have all the links.
Also potentially related to: https://github.com/dart-lang/language/issues/2166
In several classes you can find methods with the same signature but those classes usually don't share a common interface defining this methods. For instance
int get length
is available on String, Ìterable, and Map.If you define a function that only need as parameter an object with a
int get length
you have to usedynamic
for this parameter. For instance:Today there is no way to express this contraint : the parameter object needs to have a length getter returning an int.
A straw-man solution could be to allow
dynamic
to be parametrized with a type that the object should conform to. Thusdynamic<HasLength>
would mean a class that would be valid if it would implement HasLength :Reusing
dynamic
makes some sense as it's still a dynamic call but on something that has a known shape.Another solution without langage enhancement could be to have an annotation to express the shape of object that is expected and let the analyzer trigger errors when the provided type is not conform. This solution would be less powerful as the shape will not be carry by the type.