wren-lang / wren

The Wren Programming Language. Wren is a small, fast, class-based concurrent scripting language.
http://wren.io
MIT License
6.91k stars 552 forks source link

[RFC] Switch Statement #956

Open clsource opened 3 years ago

clsource commented 3 years ago

It would require to add a switch keyword. But case and default can be replaced with other available symbols like | and else. It has implicit return.

Other alternatives to else as suggested by @CrazyInfin8 : _, default, otherwise. But it would probably be needed another keyword. If else is not preferred my vote would be for _ (underscore).


// simple
switch (value) {
    | 1 : true,
    | 2 : false,
    | else : Fiber.abort()
}

// multiple instructions per case
switch (value) {
    | "green": 
        myfunc.call()
        myfunc2.call()
        return true
    | 2: 
        myfunc2.call()
        return myClass.new()
    | else:
        return Fiber.abort()
}

// fall throught
switch (value) {
  | 1 : continue
  | 2 : true // if the value is 1 or 2 would return true
  | 3 : Fiber.abort()
  | else : false
}

Another way without adding a new keyword. if and is


// simple
if (value) is {
    | 1 : true,
    | 2 : false,
    | else : Fiber.abort()
}

// multiple instructions per case
if (value) is {
    | "green": 
        myfunc.call()
        myfunc2.call()
        return true
    | 2: 
        myfunc2.call()
        return myClass.new()
    | else:
        return Fiber.abort()
}

// fall throught
if (value) is {
  | 1 : continue
  | 2 : true // if the value is 1 or 2 would return true
  | 3 : Fiber.abort()
  | else : false
}

Another syntax variation


// simple
if (value) is {
    | 1 | : true
    | 2 | : false
} 

// simple (mutiple cases combined)
if (value) is {
 | [1, 2, 3, 4] | : true // value is 1 or 2 or 3 or 4
 | > 4 | : false
} else {
  Fiber.abort()
}

// multiple instructions per case
if (value) is {
    | "green" | : 
        myfunc.call()
        myfunc2.call()
        return true
    | 2 | :
        myfunc2.call()
        return myClass.new()
} else {
   Fiber.abort()
}

// fall throught
if (value) is {
  | 1 | : continue
  | 2 | : true // if the value is 1 or 2 would return true
  | 3 | : Fiber.abort()
} else {
  return false
}
mhermier commented 3 years ago

Thought about an extra stuff, following while loop, maybe it would be interesting to do something like:

switch (my_long_expression) as myVariable {
  // what ever syntax here we have access to the switch value as myVariable in the block
}
// myVariable is now undefined...
PureFox48 commented 3 years ago

: would need to be searched to determine if the expression is a case statement starting with a binary expression or a real statement starting with a unary expression.

I don't entirely follow that because the case could not begin with a unary expression anyway. The only unary operators we have are: -, ! and ~ and none of those could be used in isolation. A binary operator is needed to refer back to the switch expression.

In my experience, fallthrough is almost always not needed. If it is, you can always fall back to if-else. So I tend towards mandating the break, except for adjacent cases.

I'm struggling to follow your logic re fallthrough here. I agree that it's seldom needed and one of the things I find tedious about programming in C is that you have to remember to put break at the end of each case clause to avoid it from falling through. This in turn means that you can't then use break to exit a loop enclosing a switch.

Surely, it's best for Wren not to allow automatic fallthrough (the 'majority' case) so break can then be freed to fulfil its normal function of exiting loops.

Regarding requiring a default clause, I don't think this is a good idea.

Well, it's been a long thread and I may have missed something but I don't think anyone has suggested that we should require a default clause. I certainly don't think we should.

ChayimFriedman2 commented 3 years ago

I'm struggling to follow your logic re fallthrough here. I agree that it's seldom needed and one of the things I find tedious about programming in C is that you have to remember to put break at the end of each case clause to avoid it from falling through. This in turn means that you can't then use break to exit a loop enclosing a switch.

Surely, it's best for Wren not to allow automatic fallthrough (the 'majority' case) so break can then be freed to fulfil its normal function of exiting loops.

I agree, I just want to keep the break for C familiarity.

Well, it's been a long thread and I may have missed something but I don't think anyone has suggested that we should require a default clause

Oh, you're right. I was confused by:

To avoid potential confusion, perhaps we could mandate that the else case, if there is one, always comes last in the switch.

Skipped "if there is one" 😄.

mhermier commented 3 years ago

I don't entirely follow that because the case could not begin with a unary expression anyway. The only unary operators we have are: -, ! and ~ and none of those could be used in isolation. A binary operator is needed to refer back to the switch expression.

You are assuming the future here, we might introduce more operators that might conflict with your statement here. I spoke about the case where the code is something like this:

  switch(expr0) {
    test:
       expr1
       expr2
       expr3
  }

If we don't put brace around expr1/2/3, the whole expressions need to be parsed to see that there is no terminal : to determine if it is an expression (because of unary operators and the continuation of expression with .methodName in next line)

mhermier commented 3 years ago

To summary, I only see 2 viable witting style:

switch(expr0) {
  test1: one_liner_expr
  test2: {
    multiline_exprs
  }
  else // `:``is optional here followed by one liner or multiline expressions in a block 
}

or

switch(expr0) {
  case test1: oneliner_expr
  case test2:
    multiline_exprs
  default // `:``is optional here, but `else` can't really be used because it could be mixed with multiline expressions
}

case and default can be replaced with anything.

First syntax introduce only 1 symbol while second one requires at worse 3 (not counting required fallthrough or break keyword that is still debated).

Personally I prefer first one, it will introduce less keywords and looks more like the rest of the wren syntax regarding one liners and blocks.

PureFox48 commented 3 years ago

Yes, I think you're right that we're left with basically 2 viable styles.

Like it or not, I suspect the deciding factors here will be:

  1. The need for blocks which I don't think most commenters like given that they're not generally needed in other C-family languages.

  2. Familiar syntax.

As you don't think your own approach can work without blocks for multi-line cases, I'm coming to the conclusion that we need to make the more traditional switch/case syntax work - perhaps on the lines of @ChayimFriedman2's latest approach but (please!) with no automatic fallthrough.

We could get by with 2 new keywords (switch and case) by calling the default case case else. If we're to allow fallthrough, I personally would prefer that to be the keyword but could live with proceed instead.

Incidentally, I think your idea of a variable alias:

switch (my_long_expression) as myVariable {
  // blah
}

is a good one, particularly in the context of guard clauses.

ChayimFriedman2 commented 3 years ago

Just BTW, VB has Case Else.

mhermier commented 3 years ago

Thinking at the switch (my_long_expression) as myVariable for the sake of uniformity maybe it should also be extended to 'if' and also to all loops.

PureFox48 commented 3 years ago

@ChayimFriedman2

I've been studying your latest approach.

Presumably, if guard clauses could be made to work, then we could cater for more general expressions involving 'x' and perhaps other in scope variables as well or am I being over-optimistic.

For (very silly) example:

switch (x) {
    case x > 7:               // open ended range 
    case x != 9:              // exclusive value
    case x >= 3 && x <= 10:   // closed range (non-integral)
    case x + y == 6:          // using another variable
    case x.isEven:            // method call
    case Int.isOdd(x)         // method call with 'x' as parameter
    case else:                // default case
}
ChayimFriedman2 commented 3 years ago

BTW, in C (and similar languages) complicated conditions can be represented using the following trick:

switch (true) {
    case x > 7:               // open ended range 
    case x != 9:              // exclusive value
    case x >= 3 && x <= 10:   // closed range (non-integral)
    case x + y == 6:          // using another variable
    case x.isEven:            // method call
    case Int.isOdd(x)         // method call with 'x' as parameter
    default:                // default case
}

I don't like this trick, but there are others that do.

Edit: It turns out that it does not work in C, but it does in other languages.

mhermier commented 3 years ago

I don't like, even if polished, it brings no adventages over regular if. Would the following corrected would make sense ?

switch (expr) as x {
    case > 7:               // open ended range 
    case != 9:              // exclusive value
    case >= 3 && x <= 10:   // closed range (non-integral)
    case + y == 6:          // using another variable
    case .isEven:           // method call
    case .isOdd:            // method call with 'x' as parameter
    else:                   // default case
}
ChayimFriedman2 commented 3 years ago

And then how do you perform a method call with expr as argument (not receiver)?

mhermier commented 3 years ago

Using the trick I explained ealier using a method/operator in object, or as @cxw42 named it later '~~'.

switch (expr) as x {
    case ~~ MyUnaryFunction: ...
    case ~~ Fn.new {|foo| ...}: ... // Edited for clarity that it is invoked with an argument
    case ~~ Fn.new { /* use x directly*/ ...}: ...
    ...
}
PureFox48 commented 3 years ago

It would be better than regular if/else if the complex expressions could co-exist with very simple expressions in the same statement.

Personally, I don't really see how you can have complex expressions without repeating 'x' (or whatever) as it just doesn't look right.

mhermier commented 3 years ago

It is interesting in the general case, since usually it is either a value equality check or a property test. For complex expression, I think it start to be interesting when you start to think functional and reusable.

If we go the route you propose, it means that the as become mandatory, meaning we could replace it with something almost equivalent:

with (expr) as x {
    if (x > 7)               // open ended range 
    else if (x != 9)              // exclusive value
    else if (x >= 3 && x <= 10)   // closed range (non-integral)
    else if (x + y == 6)          // using another variable
    else if (x.isEven)            // method call
    else if (Int.isOdd(x))         // method call with 'x' as parameter
    else ...                // default case
}

And complete equivalence can be achieved by adding fallthrought to if.

PureFox48 commented 3 years ago

Are you talking there about a possible implementation for switch/case or adding a with statement as well or instead of it?

mhermier commented 3 years ago

I mean if we provide full complexity, it only replace else if with a fancier case...

PureFox48 commented 3 years ago

Well, leaving aside possible compiler optimizations, that's all switch/case really is - syntax sugar for an if/else ladder based on the value of a single expression, albeit more elegant.

mhermier commented 3 years ago

Well I see it more like a way to perform a test without the explicit need to named a variable, removing repeat of it XX times. This brings some interest to switch. If the named variable is required all the times, it add no value over the if/else ladder.

PureFox48 commented 3 years ago

Yes, I agree that not having to repeat the switch variable is one of the things that makes it more elegant.

But, I'm not suggesting here that we repeat 'x' all the time - just on those occasions when a complex condition is needed as well as simple ones.

mhermier commented 3 years ago

Maybe we can make an hybrid, but it depending if we have block imposed or not, add an extra keyword, by doing something like:

switch(expr) as x {
  case 1:
  case 2:
  other_keyword x + y > 42:
}

But I don't really like it...

PureFox48 commented 3 years ago

You could use case if for complex expressions.

It would be like a guard but without any preceding expression.


switch(expr) as x {
  case 1:
  case 2:
  case if (x + y) > 42:
  case else:
}
mhermier commented 3 years ago

While it works, I think it become a burden in case of consecutive matching by other complex mean than equality.

If we had method resolution or something we could provide a secondary argument to change the meaning of case to something like:

switch (expr, .~~(_)) as x {
  case foo: // Would perform: x~~(foo)
  ...
}
PureFox48 commented 3 years ago

I don't think it would become any more of a burden in the case of consecutive matching than an if/else ladder.

There is no need for cases to be mutually exclusive and the first case to be satisfied would be the one executed.

If guards were in place, the compiler could replace case if with:


switch(expr) as x {
  case x if (x + y) > 42:
  case else:
}
cxw42 commented 3 years ago

Re. @mhermier https://github.com/wren-lang/wren/issues/956#issuecomment-817302284:

Thanks for the code example! I agree the expression and the statement need to be separated by the parser unambiguously. A keyword can do that, or parens, or any other symbol. There just needs to be something that's not valid in an expression after the expression, e.g., a colon or a terminal right paren. I think the keyword at the front is optional, since the parser will know what it's looking for.

Re. @PureFox48 https://github.com/wren-lang/wren/issues/956#issuecomment-817316026:

The need for blocks which I don't think most commenters like given that they're not generally needed in other C-family languages.

I don't want Wren to look like C! I want it to look like Wren. I understand not everyone agrees with me :) . Wren already has well-defined one-liner and block syntax. I suggest that it's more important to be able to go easily from Wren's if to Wren's switch than from C's switch to Wren's switch.

PureFox48 commented 3 years ago

Well, Wren's if and while statements are exactly the same as in C. Only the for statement differs.

@mhermier and I were discussing earlier what makes switch/case more elegant than an if/else ladder. As well as not usually having to repeat the switch variable, I would humbly suggest that not having to use blocks would be another aspect.

cxw42 commented 3 years ago

Adding to the list from @mhermier https://github.com/wren-lang/wren/issues/956#issuecomment-817313384 , I propose option 3 (what I said above, but in a compact form):

switch(expr0) {
  (test1) one_liner_expr

  (test2) {
    multiline_exprs
  }

  /* optional feature --- not needed in minimum viable product */
  {|v| v.someFn() } <statement>  // statement can be oneliner or {\n block\n }     

  else <statement> 
}

Pro: Only one additional keyword (plus whatever we pick for fallthrough), and we can re-use existing parser logic that handles what comes after an if or while keyword for each individual case.

Con: doesn't look like C (but I don't think it needs to)

I support fallthrough being opt-in, not the default.

Minimum viable product?

As you know, I like pattern-matching/smartmatch :) . Would it be worth discussing those later? Even if we only had == tests, switch would still make a big difference. I suggest we narrow the scope of this issue to == until we agree on syntax and semantics for the == case. What say you?

cxw42 commented 3 years ago

@PureFox48 Would you please tell me more about the downsides of blocks in your experience? How do you see those downsides translating to Wren?

PureFox48 commented 3 years ago

I'm talking purely about aesthetics here and trying to add some value to switch/case compared to if/else.

To my mind, blocks make the switch statement look more cluttered and not that much different than an if/else ladder. May be it's just me but that's how I see it.

cxw42 commented 3 years ago

Fair enough --- thanks for the explanation! I agree to disagree :)

mhermier commented 3 years ago

@cxw42 grammatically wise your option is almost equivalent to case 1, with some minor twist to evaluation like the last development. I find it too much confusing because basically it transform "expr expr" as an if statement, which not something we really want in a grammar...

@PureFox48 true, but this is how wren manage multi-line in if in wren... edit well in general, it is also true for methods.

PureFox48 commented 3 years ago

Perhaps I should make it clear that I've no problem with blocks when they're necessary.

However, if they're not necessary (and I don't think they are in traditional switch/case syntax) then I'd prefer to dispense with them on aesthetic grounds.

cxw42 commented 3 years ago

@mhermier

almost equivalent to case 1

Yes, on purpose :) . Option 3 is just option 1, but with parens instead of case and :.

basically it transform "expr expr" as an if statement, which not something we really want in a grammar...

I'm not sure I understand what you mean here. Let me try this --- I am thinking of BNF something like:

switch_stmt ::= 'switch' '(' <expr> ')' '{' <cases> '}'
cases  ::= <case>+
<case> ::=   'else' <stmt>   /* has to be the last <case>, if it exists */
           | '(' <expr> ')' <stmt>

where <stmt> is Wren's regular statement production, and covers both single-line and multi-line.

Anyway, if case <expr>: turns out to be the winner, I'll still use it :) . I just think it will face an uphill battle, given comments I've seen from the maintainers on other issues.

cxw42 commented 3 years ago

I'll selectively quote Bob from Crafting Interpreters ---

Do you start from a blank slate and first principles? Or do you weave your language into the rich tapestry of programming history and give your users a leg up by starting from something they already know?

There is no perfect answer here, only trade-offs.

Edit I am very glad that we are working through the trade-offs! I do think switch will be a big win, whatever it winds up looking like.

mhermier commented 3 years ago

I think, the following would be good for me:

switch (expr[, optionalEqualityCallable]) as x {
  case foo: // Would perform: optionalEqualityCallable.call(x, foo)
  ...
  case if expr:
  case else: // or default which is more natural
}

It should work for most common cases, and let the user customize the notion of equality for case. Like transforming case string literal to do pattern matching, or any other kind of transform required to perform an equality.

cxw42 commented 3 years ago

The idea of a custom equality checker is an interesting one --- lighter weight than full smartmatch, and more flexible than just ==.

If we are going to use as x, why outside the parens? That conflicts with our proposal in #964. And, if you do include optionalEqualityCallable, the x could be very far from where it's used. What about---

switch([var x = ]<expr>[; optionalEqualityCallable]) {...}

where x's scope is the scope of the switch statement. (Sorry if this was mentioned above and I missed it!)

(Edit I'm stepping away from the console for a while. I'm going to ask @ruby0x1 on Discord to comment on whether case <expr>: ... break; would be acceptable. I suspect it won't be since it adds a new compound-statement form, but I could well be wrong.)

mhermier commented 3 years ago

@cxw42 I would have loved to be able to do that, but there is a strange feature from if that take precedence, that is bugging me since wren creation.

if (someNewVariableName = 42) { ... }

For me is inconsistent, since it creates a new variable if the variable was not defined earlier. Instead it should be an error in a regular bloc/function, and result in using a setter in a method. This would be more logical.

cxw42 commented 3 years ago

@mhermier agreed --- the docs say "Wren doesn’t roll with implicit variable definition", so it shouldn't be allowed in an if statement. Have you opened an issue for that? I don't see one.

mhermier commented 3 years ago

If there is, it was decided so long ago it was decided/resolved by @munificent himself.

PureFox48 commented 3 years ago

Sorry, if I'm missing something, but this doesn't compile for me:

if (someNewVariableName = 42) System.print(someNewVariable)

I get: Error at 'someNewVariableName': Variable is used but not defined.

cxw42 commented 3 years ago

@mhermier it looks like that might have been fixed. I just tried it in wren_cli ---

> if(w=3) System.print("yes")
[repl line 1] Error at 'w': Variable is used but not defined.

edit @PureFox48 you beat me to it :laughing:

PureFox48 commented 3 years ago

Yeah, it looks like all is well after all :)

cxw42 commented 3 years ago

@ruby0x1 says

I'm letting the thread play out atm. since it's 50+ notifications when I woke up today, for me it's better to just let all the thoughts be explored and when it makes sense to respond, do so holistically

Now I actually am going to log off for now :D

mhermier commented 3 years ago

Seems that it is not how I remembered. Anyway the as or var is optional to the declaration and can be resolved in another issue.

ChayimFriedman2 commented 3 years ago

All of this long discussion made me realize how hard it is to bake switch into Wren.

I think we should instead focus on making if-else if-else chains better.

One thing that I always hated is that without braces you cannot really use if-else. The following is invalid in Wren:

if (1 == 0) System.print("Hah??")
else System.print("OK.")

Instead it should be:

if (1 == 0) System.print("Hah??") else System.print("OK.")

Which makes it unusable for if-else if-else chains.

I suggest we change this to allow the else on another line. This is very easy to fix (something I can do in some minutes), yet very useful and much less controversial than switch.

PureFox48 commented 3 years ago

I wish you'd said this 90 odd posts ago :)

Although I'd personally welcome such a change, it wouldn't alter the fact that switch/case can be much more elegant than if/else ladders otherwise languages wouldn't bother with it at all.

One further point is that if we are to have a switch statement, it would be best for it to be introduced sooner rather than later because, as time passes and more and more Wren code is written, it becomes more difficult to introduce new keywords without breaking a lot of existing code.

mhermier commented 3 years ago

While I understand the motivation I'm not sure it helps for readability, But this is another topic for another issue.

And the topic of switch is one of the most interesting exchange for wren I had in a long time. I think it is a good addition, by conformance to other languages and ease of write. Even if there is no concencus/adoption at the end, all that effort is not wasted. It makes people think on language design and make them dig/learn the code.

cxw42 commented 3 years ago

Below is a side-by-side comparison of a a real-world if/else-if chain and the three options as I currently understand them. The source files are in the above-linked commit if you want to see where this came from. The original code is here in wren-toml. I hope this helps!

// Original - from wren-toml                    // option 1 as I understand it                  // option 2 as I understand it                  // option 3
if (TOMLScanner.isAlphanumeric(char)) {         if (TOMLScanner.isAlphanumeric(char)) {         if (TOMLScanner.isAlphanumeric(char)) {         if (TOMLScanner.isAlphanumeric(char)) {
  scanAlphanumeric()                              scanAlphanumeric()                              scanAlphanumeric()                              scanAlphanumeric()
} else if (TOMLScanner.isWhitespace(char)) {    } else if (TOMLScanner.isWhitespace(char)) {    } else if (TOMLScanner.isWhitespace(char)) {    } else if (TOMLScanner.isWhitespace(char)) {  
  // No-op.                                       // No-op.                                       // No-op.                                       // No-op.
} else if (char == "=") {                       } else switch(char) {                           } else switch(char) {                           } else switch(char) {
  addToken(Equal)                                 "=": addToken(Equal)                            case "=": addToken(Equal)                       ("=") addToken(Equal)
} else if (char == "\"") {                        "\"": {                                         case "\"":                                      ("\"") {
  if (peek() == "\"") {                             if (peek() == "\"") {                           if (peek() == "\"") {                           if (peek() == "\"") {
    advance()                                         advance()                                       advance()                                       advance()
    elidedMultiline() // removed for brevity          elidedMultiline()                               elidedMultiline()                               elidedMultiline()
  } else {                                          } else {                                        } else {                                        } else {
    scanString()                                      scanString()                                    scanString()                                    scanString()
  }                                                 }                                               }                                               }
} else if (char == "#") {                         }                                               case "#": scanComment()                         }
  scanComment()                                   "#": scanComment()                              case "[": addToken(LeftBracket)                 ("#") scanComment()
} else if (char == "[") {                         "[": addToken(LeftBracket)                      case "]": addToken(RightBracket)                ("[") addToken(LeftBracket)
  addToken(LeftBracket)                           "]": addToken(RightBracket)                     case "'": scanString("'")                       ("]") addToken(RightBracket)
} else if (char == "]") {                         "'": scanString("'")                            case ",": addToken(Comma)                       ("'") scanString("'")
  addToken(RightBracket)                          ",": addToken(Comma)                            case "{": addToken(LeftBrace)                   (",") addToken(Comma)
} else if (char == "'") {                         "{": addToken(LeftBrace)                        case "}": addToken(RightBrace)                  ("{") addToken(LeftBrace)
  scanString("'")                                 "}": addToken(RightBrace)                       default throwScannerError()                     ("}") addToken(RightBrace)
} else if (char == ",") {                         else throwScannerError()                      }                                                 else throwScannerError()
  addToken(Comma)                               }                                                                                               }
} else if (char == "{") {
  addToken(LeftBrace)
} else if (char == "}") {
  addToken(RightBrace)
} else {
  throwScannerError()
}

If I have misunderstoood option 1 or option 2, please let me know!

(note: I picked this code sample by browsing through the ecosystem modules listed in the wiki. This was the first complex if/else chain I found.)

cxw42 commented 3 years ago

My personal order of preference is option 3 first (still), then option 1, then option 2. Any of them is a significant improvement! I noticed that option 1 is the most concise by number of characters typed, which is part of why I prefer it over option 2.

mhermier commented 3 years ago

Lastest devellopement the header can be changed to:

if (TOMLScanner.isAlphanumeric(char)) {
    scanAlphanumeric()
} else if (TOMLScanner.isWhitespace(char)) {
    // No-op.
} else switch(char) {
    ...

can be compressed to:

switch(char) {
    case if TOMLScanner.isAlphanumeric(char): scanAlphanumeric()
    case if TOMLScanner.isWhitespace(char): { } // No-op. or break or whatever
    ...

I think it is generalisable with and without case.

And if equality argument is accepted to, with a little bit more of functionnal programing:

var myCustomEquality = Fn.new {|lhs, rhs|
    if (rhs is Fn) return rhs.call(lhs)
    return lhs == rhs
}
switch(char, myCustomEquality) {
    case TOMLScanner.isAlphanumericFn: scanAlphanumeric()
    case TOMLScanner.isWhitespaceFn: { } // No-op. or break or whatever
    ...