-
Notifications
You must be signed in to change notification settings - Fork 207
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
[patterns] Can we make type declarations and type tests look different? #2677
Comments
Sorry for the essay... I spent a little time refreshing my memory of what other languages in the space do: SwiftSwift doesn't support normal type annotations inside patterns at all. So you can write this: let a: Int = 1 But not this: let (a: Int, b: Int) = (1, 2) They must special case the variable declaration syntax for single-variable declarations. Variable binders in patterns are thus usually unannotated and their types are inferred. If you want to test the matched value's type at runtime, you can use a type cast pattern: class A {}
class B : A {}
let a: A = B()
switch a {
case let b as B: print("was actually a B")
// ^^^^ type cast pattern
// ^^^^^ subpattern
case _: print("default")
} Type annotations aren't the only possible patterns that do type tests, though. What about destructuring patterns like lists and tuples? As far as I can tell, Swift sidesteps that. They have no list patterns at all. Tuple patterns can only be used to match against values that are statically known to be tuples. If you upcast the tuple to let value: Any = (1, 2)
switch value {
case (let a, let b): print("was tuple")
case _: print("default")
} This fails to compile with:
So it looks like Swift does what you want here where any runtime type tests are clearly marked as such in patterns. ScalaYou can type annotate variables in patterns. When you do, it performs a runtime type test: class A {}
class B extends A {}
val a: A = new B()
a match {
case b: B => println("it was a B")
case _ => println("it was not a B")
} This prints "it was a B". Likewise, tuple patterns will do a runtime type test that the object is a tuple if the matched value isn't statically known to be a tuple type: val a: Object = (1, 2)
a match {
case (_, _) => println("it was a tuple")
case _ => println("it was not a tuple")
} C#C# has declaration patterns which use the normal type annotation syntax and perform a type test at runtime: Object value = "a string";
switch (value)
{
case String s:
Console.WriteLine("it was a string");
break;
default:
Console.WriteLine("it was not a string");
break;
} This compiles fine and primts "it was a string". However, list patterns do not do a runtime type test. This is fine: String[] value = {"a string"};
switch (value)
{
case [String s]:
Console.WriteLine("it was a list of string");
break;
default:
Console.WriteLine("it was not a string");
break;
} But if we change the matched value's type: String[] array = {"a string"};
Object value = array;
switch (value)
{
case [String s]:
Console.WriteLine("it was a list of string");
break;
default:
Console.WriteLine("it was not a string");
break;
} You get a compile error:
Other languagesThere aren't too many other languages we can use as a reference. To be relevant, the language needs to:
Rust doesn't have subtyping (except for with regard to lifetime annotations) as I understand it. Elixir is dynamically typed. Haxe has all three. It doesn't seem to have any type test patterns or type annotation patterns as far as I can tell. OCaml has all three but objects are sorted of bolted on. As far as I can tell, it doesn't use normal type annotations (which are supported in patterns) to match on different polymorphic variants (which are I think it's name for inheritance?). DartWhen I first designed this part of the proposal, I was mostly looking at Scala which does allow this. I can definitely see the concern here. It's a little odd that type annotations have no runtime effect anywhere else in the program but affect control flow when used in a refutable pattern. If we don't want type annotated variables to type test, then we need either:
Either way, we also have to think about the other patterns that do type tests too: lists, maps, records, and objects. You could argue equally well that you might use a list pattern when you don't intend it to do a type test and not realize that it does if the matched value's type is some non- Object obj = ...
switch (obj) {
case [var a, var b]: print('two elements');
} If you do want to test that something is a list and then apply a list pattern to destructure it, then we need either some general "test type and then match subpattern" form like the Object obj = ...
switch (obj) {
case [var a, var b] is List<Object?>: print('two elements');
} That's pretty verbose. Or we could maybe have explicit type tested forms of all of the patterns. So there's be a list pattern and a tested list pattern, map pattern and tested map pattern, etc. Maybe a prefix Object obj = ...
switch (obj) {
case is [var a, var b]: print('list with two elements');
case is (var a, var b): print('record with two fields');
case is {'a': var a, 'b': var b): print('map with two entries');
} I don't hate it. If we did this, we could also say that the type tested variable pattern can let you omit the identifier if you just want to type test but not bind: Object obj = ...
switch (obj) {
case is num: print('a number');
} Note that this is technically more verbose than using an object pattern or using today's type testing variables: Object obj = ...
switch (obj) {
case is num: print('a number');
case num(): print('a number');
case num _: print('a number');
} But the difference is marginal and I like that This would mean that when you know the type of what you're matching on, you can use the destructuring patterns directly: switch ([1, 2, 3]) {
case [var a, var b, var c]: print('three');
}
switch ((1, 2, 3)) {
case (var a, var b): print('three');
}
switch ({'a': 1, 'b': 2, 'c': 3}) {
case {'a': var a, 'b': var b, 'c': var c}: print('three');
} But if the matched value is a supertype, you need the explicit Object? json = ...;
switch (json) {
case is [var a, var b]: print('list');
case is {'a': var a, 'b' var b}: print('map');
case is num n: print('number');
case is String s: print('string');
}
|
The problem here is that we cannot distinguish an up-cast from a type check. We have the same problem with casts today, where you have to use an We are using an operation which can fail at runtime, to do something which we know cannot fail. And that hurts when you make mistakes, because it becomes a run-time error. int x = foo;
var y = x as double; // whoops, meant `num`. And it works on the web too! It's true for: if (o case Object(:double hashCode)) ... which will never work. (Except on the web, whoops.) OK, I admit it's a real problem. I like the terseness of the current syntax, but it does mean you have to conflate different intentions into the same syntax. Using a suffix Prefix I like it (and I'll say more below). I like using The cost is more verbosity. case (int x, int y): ... becomes case (is int x, is int y): ...
// or
case is (int, int) (var x, var y): ... Unless we can do: case is (int x, int y): ... and infer the required type of So:
It starts to feel like there will be entire switches where every case starts with switch (e) {
case 0: ....; // A constant
is [int x, int y]: // ... type schema is List<int>, so that's what we check for.
is Foo(: var x, : var y): // ...
case null: ....
case _: .... // Binding with no type check, but also cannot be a type failure.
} I don't know. It gets complicated. The ability to distinguish up-casts from type-check means that we have to be explicit about it in every pattern. Most patterns are not up-casting, so requiring the new/extra syntax for type checks might be going in the wrong direction. About The main worry would be that I like it that. Even if it's just allowing For generally requiring |
OK, I spent some time thinking about this more. I'm definitely sympathetic with the desire to distinguish type annotations from type tests. But given how many patterns do type tests now, I think it would be a very large effort to change the proposal to make this distinction. I think it's very unlikely we could come up with a coherent, complete design and get all the issues with it shaken out in time. Even if we could, it's not clear to me that doing so would be an improvement. The current proposal is fairly similar to how Scala and C# behave and it's seemed to work out well for those languages. Unless others feels strongly, I'm inclined to say that we should leave the current proposal as it is. We've had a lot of eyeballs on it from the community, and this aspect isn't one that I can recall many raising issues with, so I suspect the current design is OK. |
Closing this because we're going to stick with the current design. |
I'm a little bit worried about patterns taking a step backwards, so to speak, in that they use a syntax which is otherwise declaring the type of a variable in a way that turns it into a dynamic type test. We're going to get a match failure rather than a dynamic error, but otherwise it's very similar to the implicit downcasts that we had a couple of years ago.
My main worries are (1) it is difficult to discern the actual semantics based on reading the source code, and (2) it's easy to get those type annotations wrong, and then they are just silently turned into type tests (this makes the construct similar to implicit downcasts, except that we're getting different/buggy control flow rather than getting an exception).
The static type of
xs
is used to determine some elements of the list pattern (in particular, inferring the type argumentnum
). The three variables are treated differently.We might want to introduce a variable like
o
with a more general type because we might want to assign other values too
than the value that it gets via pattern matching. So it's not necessarily a mistake to giveo
the declared typeObject
, and we have to do it because the inferred type would otherwise benum
, and then we couldn't assign those other values later on.The variable
n
gets the typenum
that we would otherwise have gotten by inference. This may be useful for documentation purposes.The variable
i
gets the specified typeint
, but pattern matching will now include a type test: The element at[2]
which is known to be anum
may or may not be anint
as well, but we will test this at run time.I think it would be helpful if we used a different syntax for the cases where the declared type is guaranteed to be satisfied by the matched value, and the cases where it may or may not hold, and there will be a type test at run time.
One possible approach could be to simply say that the version with a dynamic type test is not supported: If we declare a type which is not statically known to hold for the matched value then it's a compile-time error. The developer must then perform the dynamic type test in some other way (for instance,
when i is int
).Another possibility would be to say that the type test can't be requested using the syntax
int i
(that's still an error), but you could use a different syntax, likevar i is int
. This would be similar to patterns using!= null
, and it would provide the promotion based on syntax which is already recognizable as such.If we do this then
num n
would actually be more useful: It would serve as a contract betweenn
and the surrounding source code: I'm expectingn
to be able to have the typenum
, and I'd like to get a heads-up if this is not the case.(Note that this wouldn't change anything for object patterns: We can still match an object using a dynamic type test for
int
by matching it with the object patternint()
—it is unsurprising that this kind of matching includes a type test, because the ability to handle different cases corresponding to a set of distinct subtypes of the scrutinee type is absolutely standard for all pattern matching mechanisms.)The text was updated successfully, but these errors were encountered: