The problem with patterns allowed to be expressions that simply evaluate to an object to be matched is that this gets in the way of doing potentially more useful things, like predicates, so it's kind of a waste:
TXR Lisp:
1> (match (@a @(= (+ a 1))) '(1 3) a)
** match: (@a @(= (+ a 1))) failed to match object (1 3)
1> (match (@a @(= (+ a 1))) '(1 2) a)
1
An expression is treated as a predicate. The object being matched, like 2, is inserted as a right argument into it: (= (+ a 1) <obj>).
If you try the following, you get a surprising result:
2> (match (@a @(+ a 1)) '(1 3) a)
1
It is more verbose, but we can do more. We don't have to test for exact equality; e.g. @(< a) will match something that is greater than the previously matched a.
Capture a b that is greater than previously captured a:
3> (match (@a @(< a @b)) '(1 3) (list a b))
(1 3)
Or fail trying:
4> (match (@a @(< a @b)) '(1 0) (list a b))
** match: (@a @(< a @b)) failed to match object (1 0)
We could treat certain functions differently; since + isn't a relational function producing a Boolean result, equality could be inserted implicitly to generate a predicate.
But, rather than that hack, there is a different one: user-defined patterns. We can have @(+ a 1) as a pattern by defining + as a pattern operator:
1> (defmatch + (. args) ^@(= (+ ,*args)))
+
Then:
2> (match (@a @(+ a 1)) '(1 2) a)
1
3> (match (@a @(+ a 1)) '(1 3) a)
** match: (@a @(+ a 1)) failed to match object (1 3)
So I think what the author is suggesting is what was called “n+k patterns” in Haskell. I don’t recall the specific history, but over time there was a strong concensus that it was a bad idea. This might have been related to how signed integers work semantically, or some other gotcha
I think it might just have been that the capability makes the pattern matching unbounded in human complexity. The right side's expression is already practically unbounded in complexity, making the left side too flexible becomes insanely difficult to figure out. And when the Haskell community is telling you that some feature is too difficult for humans to use sensibly it's best to listen.
I think simple pattern matching is useful, especially type-based pattern matching, including which element of a sum type is matched. But you want to watch out for how complicated it gets on the matching side. Complex matching code is a very strong form of coupling. It may be a good thing but I think as a language designer you don't want to overpower it because of the coupling it introduces as the power gets used.
Why don't languages who need pattern matching use Unification? Turing-complete pattern matching, with known fast implementations. Why does everyone need to reinvent the wheel ad-hoc, and end up with a square wheel that only rolls on Tuesdays?
Because people have other goals. Doing Turing-complete pattern matching may not be desired, or someone might want something even more powerful (…or just different) than this.
What I really love about Rusts pattern matching is how well it integrates with the type system.
match user {
ImportUser{id, account: {AccountData: email, ..}, ..
} => println({id}: {Some(email)}),
_ => println("Not an ImportUser or has no mail")
}
Of course this specific case could also be written with `if let`, but imagine how long this specific case would have been using the traditional conditionals.
I really enjoy Rust's enums; but I feel that that actually their integration could be even better. TypeScript's flow typing or Rust's work on pattern types [0] are better version of what we have currently.
The main issue is that it's very hard to propagate information found through matching .
What I want is something similar to the switch from lexical to non-lexical lifetimes; but for enums and pattern matching. `@` is a construct depending on lexical scoping
In following code, it would be nice if the second match could use the early return from the first match to avoid extra checks. It would also be nice if it worked across function boundaries. It would help removing boilerplate conversions or some `unreachable!()` branches.
enum Auth { Guest, User(u32), Bot(u32) }
fn check_permissions(Auth &auth) -> Result<Permission, PermissionError> {
match auth {
Auth::Guest => return PermissionError::Unauthorized,
Auth::User(_) | Auth::Bot(_) => { ... }
// ...
match auth {
Auth::User(_) => {...}
Auth::Bot(_) => {...}
// At this point the compiler should know that `Auth::Guest` is not possible
}
I 100% agree. I tried a few workarounds to get the same result, but it quickly gets out of hand. The two main solutions I'm using is to define the individual variants as in your post, and then one enum per union I care about; even with macros it gets very verbose. There's also the problem that it requires explicit conversions (so it's not zero cost). As an alternative, I define a generic enum where each variant corresponds to a generic param and then you can set it to an inhabited type (such as Infallible) when the variant is not possible. Converting is not zero-cost again.
You can retrieve the zero cost manually, but then you need to deal with discriminators yourself / the compiler can't help you there.
The pattern type work I mentioned earlier would bring us very close to what you have at the end:
enum Auth { Guest, User(u32), Bot(u32) }
type FullAuth = pattern_type!(Auth, Auth::Guest | Auth::User(_) | Auth::Bot(_)); // useless, but works
type IdAuth = pattern_type!(Auth, Auth::User(_) | Auth::Bot(_));
The full enum still needs to be defined. In particular, it serves as the basis the in-memory representation. All the subset can be handled by the compiler though.
The case at the beginning, where we know our pattern is irrefutable but the compiler doesn't (let (x, 2) = (1, 1+1)) ,is something the compiler could easily prove (as long as the irrefutably could be proven locally as it is here). At first, I struggled to think of why that capability would ever be helpful. In the end, though, I came up with this example, which can (and does) come up:
The compiler here knows the `infallible` function will always return an `Ok` because of the return type. Here you could just `unwrap` the result, but what if it was inside a tuple, or something similar?
The latter is shown to be type-based static analysis, even though it "looks like" there's a runtime component: the Never plugs one of only two holes in Result, so it statically becomes equivalent to a newtype.
The former can't be restricted to type analysis (without something like dependent types I think?). You're right that we could apply constant folding to check in this case. My understanding is that the Rust design philosophy is to skip things like this because of the potential to produce surprising results upon refactoring.
My point was that both are static, but you’re right that one being done at the type checking stage probably makes a big difference in the feasibility.
> the potential to produce surprising results
That’s actually the big reason for that capability. If you do
let result: Result<_,!> = …
let ok(fine) = result
, if result ever changes from infallible to fallible, you get a compile error rather than the runtime error you would get with unwrap. In fact, when looking it up, it turns out that specific case was a big part of the motivation for the “!” type in the first place! I think the above code probably compiles in nightly because of that.
I'm not a fan of forcing a left-to-right order in the author's proposed syntax here. Sure the example given by OP is `while let Some((a, a + 1)) = iterator.next()` but if you want to write `while let Some((b - 1, b)) = iterator.next()`?
This is a contrived example because you can switch expressions to make it work. Now what if you want to match a tuple where the first element is the SHA256 hash of the second?