Hi! I am considering submitting a KEEP for pattern...
# language-proposals
n
Hi! I am considering submitting a KEEP for pattern matching. You can read a draft here. I have seen some discussion about it here and have drawn from that to write up the proposal. In particular, the case for it matching stuff inside classes and testing for equality is strong if we would like to avoid nested `if`s and `when`s. It of course helps with readability. It is a tried and tested feature in many languages, and Kotlin seems to want to come close to it through destructuring and smart casting but it's not quite there. I suggest using those existing semantics to make pattern matching intuitive and predictable. I would love to receive some feedback/contributions on the draft, and if there is enough support, to follow through with an actual KEEP submission. Cheers!
👍 15
👀 2
🎉 5
👏 5
d
Your examples might not be correct. The Arrow example should be
true
for
(None, None)
, and your Jake Wharton example ignores the "Someone else's" case.
n
Thanks for pointing those out. I just pushed a corrected version
👏 1
m
is (addr, true)
is confusing as the latter is not a type. What about simply
(addr, true)
? Note that
(1, 2) -> …
would throw in
component2()
when matching against
listOf(1)
. I’m not sure that this would be intended.
👍 2
Copy code
is Closed(valueException) -> throw valueException
is State<*>(UNDEFINED) -> throw IllegalStateException("No value")
is State<*>(value) -> return value as E
else -> error("Invalid state $state)
This won’t work. The compiler would have to guess whether you want to pass a value for matching (like
UNDEFINED
) or you want to define a variable (like
valueException
). The latter should need a
val
. Then again I don’t see much benefit in destructuring into new variables here since the smart cast already makes it very easy (
state.valueException
).
n
Thanks for your feedback! I see the issue with omitting the type parameter at
is (_, true)
. At the same time, I thought it would be appropriate if in order to pattern match the keyword
is
was necessary. Having
(a, b)
directly looks as if we were calling
(a, b) == subject
(which is what would happen if instead of that tuple we had anything else). I personally prefer
is
but I will add your alternative to the proposal see what people like
As for the
UNDEFINED
example, I don't see the problem. It is an identifier like any other, and seeing it's already present in scope should tell apart one case from the other
Whether it is the identifier of an imported enum or some object should surely not change things?
m
Current when matching doesn’t require
is
. It’s used exclusively for instance checks, whether in when matching or otherwise and I think it’s better if it stays that way 🙂
Having 
(a, b)
directly looks as if we were calling 
(a, b) == subject
I don’t know what you mean here. There is no call and it doesn’t look like one either. It’s perfectly in sync with destructuring, and most other languages :)
As for the UNDEFINED example, I don’t see the problem. It is an identifier like any other, and seeing it’s already present in scope should tell apart one case from the other
That would be a very big source of errors. Just mistyping a variable name and instead of providing a value you create a new variable. It doesn’t make sense for a static language like Kotlin. “If variable exists then use value else create new variable” 😅 Sounds more like something that PHP would do (wrong).
n
I know current matching does not use
is
, but all current matching does is call
equals()
on subject and target. Whether here the semantics on some
(a, b)
as a target are different and I thought it would be important to differentiate that. But then again, using
is
has exactly the same problem so that's a fair point!
That is also a good point, and it is a big decision to take, which is why it is discussed under the Design Decisions section and is completely up to debate
m
Why would it be different? Pattern matching two values would basically call
equals
for both anyway?
It’s good to discuss things. But in this case I think the chances that the Kotlin team even considers implicit variable creation are close to zero 😅
n
It would call
equals
for those already defined, but would recursively pattern match otherwise. Consider
(3, Person("Alice"))
. Here you are doing a type check in the pattern but there is no
is
. And we cannot fully remove
is
for non nested patterns because we cannot pattern match on
Person("Alice")
as that is creating a new
Person
and calling equals on it (which may have different semantics from
componentN()
, aside from extra overhead.
As for the implicit variable declaration, it is a design choice. Scala allows it explicitly, Rust and Haskell shadow the existing one, etc. Again, completely open to debate really not I'd something I consider essential to have
m
Are you sure that Scala even allows matching against values? From a quick search I can only find variable assignments but no value matching.
Regarding data class matching, I see are several issues here that should be discussed independently. • Omitting values in data class matching should probably not be allowed.
Person("Alice", _)
but not
Person("Alice")
, the latter which can be ambiguous and confusing. • The same goes for partial matching of desctructuring. Will
(1)
match
listOf(1)
and
listOf(1, 2)
or just the former? I think most languages would only match the first case and you cannot do that with only
componentN()
functions.
n
Yes, it's called a stable identifier. See https://stackoverflow.com/questions/7078022/why-does-pattern-matching-in-scala-not-work-with-variables The clever bit is that the check is explicit
m
Ah okay, that’s quite different though.
n
I agree it is! But I could not come up with some nice syntax for it in Kotlin :))
m
Person("Alice", _)
can indeed not use
equals
of a data class, which would break existing code when not using wildcards, because in that case
equals
is currently used.
is
only makes sense when the type isn’t checked yet.
Copy code
val person: Person = …
when (person) {
   is Person -> …
}
That would be an unnecessary instance check already and yield a warning.
n
The thing about destructuring is that I did not define the semantics on data classes, but on
componentN()
functions. You cannot enforce matching on every single
componentN
for data classes but not for lists. Again, I think the expected behaviour of destructuring in a pattern match should be as close as possible to the destructuring already present in the language. Currently, writing
val (name) = Person("Alice")
is valid so I do not see a problem with carrying that over to the KEEP
m
is Person("Alice", _)
is two things in one:
is Person
and matching the first component
n
I agree with your last statement and that is how I defined it in the draft
m
If you make an exception it leads to at least two confusing cases:
Copy code
when (listOf(1, 2)) {
   (1) -> … // (1) unexpectedly matches (1, 2) - Tuples and Lists are very similar and it can easily lead to confusion.
   (1, 2, 3) -> // Expectation would be that it does not match. What actually happens is that an exception is thrown.
}
n
Again, those are the existing Kotlin destructuring semantics
m
Yes, but this isn’t destructuring. It’s matching.
n
val (a, b, c) = listOf(1, 2)
compiles but throws a runtime exception
Destructuring is just part of the process of matching
And kind of the point of it too!
m
If my tuple isn’t three elements long I would expect it to not match, not to throw. When trying to actually extract the third element an exception makes sense, because the third element cannot be extracted. But it’s existence can be checked without throwing.
It’s like
is
vs
as
n
This is a very specific edge case for lists. For normal classes we know whether
componentN
exists. We could give lists special treatment to fail a match depending on their size, I can agree with that
m
componentN
is a very simple construct. Whether or not it throws depends on the case and
List
is just one example. not an edge case.
For data classes it’s easy, for all other cases it’s not.
n
And if you have a 2 element long tuple (not some collection) then your match won't even compile 🙂
m
How would the compiler know how long my list is if it’s in a variable? 🤔
n
I think it is safe to say you only expect
componentN
to fail for collections
m
Absolutely not 😅
n
No, it would not, but at runtime we can check and fail the match
And proceed to the enxt
next
As would be the case in Haskell or Scala
m
Yes you could, but you would only fix it for
List
instead of generically for
componentN
what you’re aiming for.
What’s missing is a
componentCount()
^^
n
Again, I think it is safe to say you only expect componentN to fail for collections. Either we treat them the same as other classes or we give them special treatment through`Collection.size`
m
No, why is that safe to say?
Everyone can implement
componentN
for everything. Including throwing if destructing doesn’t make sense.
n
I am for keeping the proposal simple and treating them as other classes, but special treatment can be done and seems intuitive
m
If you want to keep it simple I’d avoid problematic functionality and focus on a subset of pattern matching at first.
n
Yes, but that is true of a normal destructure as well (and of any getter for that matter)
m
Matching data classes seems simpler than destructuring which has many edge cases.
n
If we restrict matching to data classes it would close the door on extending classes with
componentN
to enable matching on them. I am thinking in particular of Java classes that users might want to extend to use more idiomatically in Kotlin
Additionally, at the moment all data classes are is normal classes with some generated code. Restricting pattern matching to them would mean to change their semantics to be more 'unique'
m
Why would it close that door? Data class matching is completely unrelated to destructuring 🤔 It’s basically introducing wildcards for data class matching.
You can already match entire data class instances.
n
It would close the door to matching on Java classes or just anything that is not a Kotlin data class
m
The question was why
I don’t see any door closed 🙂
n
Currently, we can destructure on Map.Entry for example thanks to the Kotlin stdlib extension functions
If we restrict pattern matching to data classes, we can't match on a Map's entries
That's just an example of a non data class you might want to destructure
m
Yes. But that doesn’t mean that you can never add that functionality later on.
The point is to approach pattern matching slowly in multiple steps, as it’s complex.
There’s also a problem with wildcard matching and data classes 😕
Person("Alice", true)
and
Person("Alice", _)
could work totally different than expected if a custom
equals
was provided, because it’s ignored when a wildcard is used.
n
So you propose only matching data classes, by their total number of parameters only, and by their
componentN
functions?
m
I don’t think that
componentN
is necessary as the compiler knows what parameter is at what position.
n
That is why we use
is
. In that case we only call
equals
on "Alice" and
true
, not on Person(..) itself
m
Person(“Alice”, true) and Person(“Alice”, _) could work totally different than expected if a custom equals was provided, because it’s ignored when a wildcard is used.
Another one: The former would call the/a constructor while the latter won’t.
I wouldn’t use
is
then but something else.
is
is confusing. It’s already a
Person
.
n
That is why
is Person("Alice", true)
will not create a new alice
It will check subject is a Person and then
equals
on members
m
is Person(String, Boolean)
would make sense for
is
😄
n
And then by not using
componentN
in data classes you are introducing breaking changes if we ever to use it in the future (like for Map.Entry maybe)
m
Maybe a new keyword?
like Person("Alice", true)
?
n
But then you are not matching the values of
Person
which is the whole point of pattern matching
m
It’s not a breaking change
Destructuring is a totally different case
Person(x, y)
is data class, otherwise you wouldn’t write
Person
n
Pattern Matching is destructuring with added checks
m
You won’t write
Map.Entry(a, b)
but
(a, b)
which can happily use
componentX
Then you should omit the
Person
n
You can make a check and destructure if the
is
check succeeds
Please write up a new proposal replacing all my examples with your proposed syntax and semantics, do a pull request, and we can go 1 by 1 on what we think is best :))
Because otherwise the new syntax and semantics you are proposing are not very clear to me
m
I’m not proposing any but merely highlight issues with the current one. I see a lot of rabbit holes already after a short time for mostly little benefit. Variable assignments are not really needed - we have smart casts. Destructuring isn’t useful here as it leads to confusing results - tuples would be useful. I agree that the current matching is a little verbose in some cases. I’d start with improving them one by one. That’s also how Swift’s pattern matching has evolved over time.
Copy code
val result = when(download) {
  is App(name, Person("Alice", _)) -> "Alice's app $name"
  is Movie(title, Person("Alice", _)) -> "Alice's movie $title"
  is App, Movie -> "Not by Alice"
}
For example could also be written like this:
Copy code
val result = when(download) {
  is App where download.developer.name == "Alice" -> "Alice's app ${download.name}"
  is Movie where download.developer.name == "Alice" -> "Alice's movie ${download.title}"
  is App, is Movie -> "Not by Alice"
}
That’s closer to what we have today and avoids the problem with data classes and destructuing for now, yet vastly improves such use cases already.
The
Without pattern matching
example for that case also isn’t ideal and can be improved with today’s Kotlin. It unnecessarily uses destructuring.
Oh, and there’s a bug in the example. The name of the app is compared to
Alice
, not the name of the developer.
The Binary Trees example without counter-example looks like converted Java code, not idiomatic Kotlin code. It can also be improved.
n
Thanks for spotting the bug, that's fixed now. As for your Swift example, it cannot be called full blown pattern matching by Haskell's or Scala's standards: it is simply a type check with a guard. Admittedly, my proposal could benefit from guards, but Swift cannot do nested patterns nor extract variables. You will see the value of nested patterns with the Arrow example, or the binary tree one
As for the Binary tree, I do agree that it is very java-like, but I did not write it, I borrowed it from baeldung. It is code used in a Kotlin tutorial. Additionally, a lot of the Kotlin written is very Java like and although I prefer idiomatic Kotlin, I do not see a problem with choosing the alternative. In particular, Baledung's mutable java-like tree is cheaper than the immutable alternative, and benefits from Kotlin's null safety still.
All in all, I suggest you read up on Scala's implementation, or Haskell's. Brian Goetz wrote an ongoing proposal for java that heavily inspired mine. These are all examples of full blown pattern matching that Swift does not offer. But I am sure Haskell and Scala will interest you in that they offer full blown pattern matching in addition to the guards that you know from Swift. C# offers them too.
👍 1
b
Have you considered only allowing structural pattern matching on data classes when matching a type? (Or, at least using that as a more restrictive starting point, before loosening restrictions to allow any type with
componentN
)
n
I have (and I am still open to the possibility if it arises in a formal KEEP submission) but I believe it would close the door on extending classes that are not our own (like Java's or some library's) for the purpose of matching on them.
But then the specifics of the proposal can be discussed when I submit it. In this channel I am mainly looking for feedback on whether this is a desired feature 🙂 and whether it is worth I spend more time on it (by writing a transpiler for example)
b
A very good point! This is definitely a feature I want, and I'm glad you're working on it 🙂
🙂 2