Crystal: [RFC] Have `case` (non-exhaustive) and `case!` (exhaustive)

Created on 8 Apr 2020  ·  37Comments  ·  Source: crystal-lang/crystal

Now that #8424 is merged, and now that we'll soon make it an error and the implicit else nil will be gone, I think we all agree on these points:

  • having the compiler check exhaustiveness is great! No more else raise "can't happen"
  • having a single construct for this is a bit annoying. In many cases having to add an else nil or else # skip is tedious

In my original proposal, #8001, I envisioned using a different keyword for this: case!. The reasons against these were:

  • case! looks ugly, or variants of this (a keyword with a bang looks strange, etc.)
  • having two constructs which are similar but different can be confusing

However, now that we put exhaustive case in practice, I can see having two constructs as something good. Some benefits:

  • case keeps working like in Ruby, and it keeps working like in Crystal 0.33.0. That means no breaking changes, no warnings to fix, etc.
  • case! can be seen as a more strict case, where exaustiveness will be checked by the compiler
  • case! will only allow "patterns" that are provable by the compiler. You won't be able to do case! exp; when 'a' because char literals, number literals, etc., can't be proved for exhaustiveness. So you will only be able to put types and enum members. That will also get rid of the mysterious "can't prove exhaustiveness" warning where you for sure know that there's no way to prove exhaustiveness.
  • given that case! can only allow provable patterns, using a symbol there can only mean matching against an enum member, and translating that to enum code is straight-forward
  • case! is not a breaking change because it's a new keyword

If you don't like case! we could consider using another name, like match. The problem is, match is a very common name for when you get a regex match, so it's probably a big breaking change. Then there's case ... in instead of case ... when, but for me, case! looks more like a more assertive version of case, whereas case ... in vs. case ... when doesn't really shouts the difference to me.

Most helpful comment

@asterite I like match even if it's a breaking change. We should evaluate the impact. Other languages like Rust already have it and is already exhaustive. Also in the future we could evaluate adding pattern matching options within the same keyword.

Going back to non exhaustive case makes it familiar for Ruby users where this language inherited many of the semantics. @RX14 I understand you were already usted to the new behaviour. Me too. But if we decide having two separate keywords I think it's better to return case its previous semantics.

All 37 comments

Actually, I think I would prefer to have a case-construct that is exaustive by default, and "something else" that is not.

So that it's easier to write exaustive-checked code, and if we need the non-exhaustive version, we can use it sometimes.

Though I'm not sure what the non-exhaustive keyword to use ^^ #naming-language-things-is-hard

_another comment to make sure downvote on this one are not put for the idea of my previous message_

Crazy thought: if we could put annotations on arbitrary ast node, we could have:

@[NotExaustive]
case foo
when "bar"
end

@bew @[NotExhaustive] is harder than writing else nil

Ahah true, but which one is more explicit? (or immediately spot-able when reading code)

I don't know really, it's probably not worth the burden

@bew @[NotExhaustive] is harder than writing else nil

Then I would argue that else nil is also better than having two similar keywords with slightly different behaviour 🤷‍♂
Also, the minimum is only else...
But: else also offers a great way to add a comment about why every other value is ignored/skipped.

Anyways, I'd hesitate to immediately change the newly introduced semantics. Let's see how it works out. If we want to change case back and add a new case!, that's a non-breaking change and could even be done after 1.0. So there's really no hurry.
Our current perception is strongly influenced by "the old way". The new behaviour feels unfamiliar and annoying. But let's not cloud our decision by temporary feelings.

My opinion is the opposite would be better, as previously said.
One reason is case! looks uglier, as said by @asterite , and less safe.

For example, to_i! has no overflow check.
case! would have no exhaustive check.

@straight-shoota Good point. I think I'll close this. We can always reopen it if we get tired of else nil.

@asterite I believe @ysbaddaden is already tired :smile:

I couldn't even remove one _single_ else raise unreachable, I even added a bunch of them :sob:

I guess my coding style is not fit for exhaustive case. All my reluctance and fears were proven true, to my dismay.

Let's let this feature sit and mature in practice for a while before we discuss changing it again. At least a month or so, using the new case, and we'll see how it affects newly written code.

Can we consider this once more? It's clear more and more people are complaining about only having exhaustive case and nothing else. Having both things is clearly better, as I explained in the original comment.

I agree. There have been enough complaints now, that we should consider options to improve this.
Separating exhaustive and non-exhaustive case using different keywords seems like a good solution. There's just too many situations where you explicitly want one or the other behaviour and there should be a way to express that.

I am however not sure if case and case! is ideal. It's only a subtle difference, and - most importantly - only in the first line. Judging by real examples, case expressions can easily span a couple of lines (was like 700 somewhere in the parser? 😆 ). If you're somewhere in the middle or at the end, there's no way to tell whether it's actually an exhaustive case or not. So maybe an alternative to when would be better show the differnce in behaviour in all parts that belong to the expression.

incase @foo 
is String
  stringystuff
is Int32
  mathystuff
end

🤣

I think exhaustive should be the default. And I still think adding an empty else to bring back the old behaviour is cleaner than two syntaxes.

If case! instead meant "yeah this is definitely exhaustive, please add the else raise "..." for me" it'd seem more useful. I'd still like to wait until the subclassing issue is fixed, since you all seem to actually like inheritance.

@RX14 it's not just the subclass case. For large enums it doesn't make sense to check exhaustiveness. If you want to use case as a big if, there's no need for exhaustiveness.

The way I see it now, the old case was fine except when you had to put that else raise can't happen. That's where you would tell the compiler that you covered all cases and that an else is not needed.

The lexer is an outlier, let's not design this feature around it.

case in, like Ruby, is another option that we can consider, given that it's also exhaustive.

The thing is, for me, these "large enums" and "using case as if" seem like outliers. Most of the time I use case cause I want it to be exhaustive. In fact, this was the only change which I had to make in all my shards to fix an exhaustive case warning. And I consider that to be a very good change.

Making case not the "default" and asking me to use case! makes me scared that I'll forget to use case! one time, by missing a single character, and miss a bug.

Perhaps it suits certain coding styles more than others and I'm the outlier.

@RX14 In the compiler most of the changes where "else # go on"

"using case as if" seem like outliers

I just skimmed through the changes in #8424 and found well over 150 of such outliers in stdlib and compiler (I stopped at some point, there are likely 200+ total) which just work as a no-op default branch. Often they're annotated with a comment like # go on. Some have more meaningful comments and in those cases it's probably good to be expressive. But many use cases when case is used as a series of if don't care about the else branch. When exhaustiveness can't be proven anyway, I don't think it makes sense to force an else everywhere. It can be useful, but not always.
And I very much like the simple case as a cleaner way to write a series of ifs.

Well, I thought the compiler code was unrepresentative, but it turns out my code is unrepresentative. I guess do what you want, but I'm a little sad it won't suit how I write code.

Since we already make case to be exhaustive, with warnings (and in master with errors) it could make sense to consider cond as the non-exhaustive counterpart.

The downside is it's a new keyword. The up sides are: equal length, easy to type, similar semantics to Elixir (?)

@bcardiff What's wrong with case! or case ... in? Both are non-breaking changes, they don't introduce a new keyword, etc.

cond in Elixir doesn't take a condition. It seems more similar to expression-less case:

# Elixir
cond do
  foo -> ...
  bar -> ...
end
# Crystal
case
when foo
  ...
when bar
 ... 
end

The equivalent in Elixir would be case, which is already exhaustive.

Another name to consider: match. But it's also a breaking change, and a very common name to use for regex match.

It keeps the exhaustive the default and it avoids going back and forth with changes on the semantics.

I was an advocate of useing case! for exhaustive in the first place, but on top of that I prefer to avoid going back and forth.

Having a different name has also de advantage that it is not one alternative vs the other. So is not that one is the default case and then is the _second_ alternative case!. But there are two different constructs to be used.


ok, yeah I don't know elixir that much :-) but it searches for the first condition that is truthy. That wind of reflects the idea.

I like the idea of having two keywords, since it makes it harder to use the wrong one.

@asterite I like match even if it's a breaking change. We should evaluate the impact. Other languages like Rust already have it and is already exhaustive. Also in the future we could evaluate adding pattern matching options within the same keyword.

Going back to non exhaustive case makes it familiar for Ruby users where this language inherited many of the semantics. @RX14 I understand you were already usted to the new behaviour. Me too. But if we decide having two separate keywords I think it's better to return case its previous semantics.

match sounds good to me, despite the fact that it's a breaking change, though I still prefer case! despite the fact that it's subjectively uglier

I like case! (exhaustive) ¯\_(ツ)_/¯
Nothing to add

Actually, ignore the above comment. I also think case! is a bit ugly too. We might go with case ... in. But it's still not decided. We might just end up with having to put an else, like right now, which is maybe not that bad.

At least the current case behaviour is only a warning. Moving it to another keyword wouldn't be so bad, I'd just learn to never use case, just like I never use postfix rescue.

I think if we have two cases you will use both. It just doesn't make sense to check for exhaustiveness when the compiler can't prove it for numbers, ranges, etc.

The change I showed above was using numbers. I appreciated making that exhaustive.

I want to have all of my cases be exhaustive, or document why not, in the else.

@RX14 Sorry if I missed it... what change?

In fact, this was the only change which I had to make in _all_ my shards to fix an exhaustive case warning. And I consider that to be a _very_ good change.

We had a small meeting today with the core team and decided that:

  • case is going to be reverted to work as it was before: non-exhaustive
  • we'll introduce case ... in as an experimental feature that will be there in 1.0. It's likely that this will stay as-is, but since it's experimental we could change its syntax or enhance its behavior in the future

i update my project, and meet many can't prove case is exhaustive, it quite not fun to add else to such cases, which i use much:

i use case to match many values single line:

case a
when "text", "l", "head", "body"
  do_something
end

i use case to cast types:

case a
when Type1
  #a is Type1
end

in both this cases i not need else

This is done!

Was this page helpful?
0 / 5 - 0 ratings