wren icon indicating copy to clipboard operation
wren copied to clipboard

[RFC] Switch Statement

Open clsource opened this issue 3 years ago • 169 comments

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
}

clsource avatar Apr 04 '21 18:04 clsource

I think we should enforce the block after the ':' for multiline to follow the logic of the rest of the grammar. Single line statement is a bit confusing, but I would expect it to continue by default like would do an if statement and not return of the function, so that part of your implementation is a no go for me.

If possible I would prefer to have something more like this:

switch (value) {
  == 42: {
    System.print("The answer")
    System.print("to the universe")
    continue
  }
  .isEven: {
    System.print("This is odd")
    break
  }
  is String: System.print("How did we get there") // Implicit continue
  else System.print("Karamba")
}

Optionnaly, for ease it would be nice to have a Object::map(fn) { fn.call(this) } so we can write something like:

switch (value) {
  .map {|value| etc... } : etc...
  etc...
}

mhermier avatar Apr 04 '21 20:04 mhermier

I like the way that @mhermier has done this which is very natural and flexible. It's also easy to cater for ranges and multiple values:

switch (value) {
   in 1..6: {
      // blah
   }
   in [7, 10, 12]: {
      // blah
   }
}

However, my personal preference would be for the case clauses (whether blocks or single statements) not to fall through at all because, most of the time, this is what you want.

The break and continue statements are best employed to break out of or continue a loop enclosing the switch statement if there is one.

Instead, I'd introduce a second new keyword fallthrough which would do what it says on the tin. Go uses this word and it's unlikely to have been used much as an ordinary identifier in the past.

Unfortunately, switch probably will have been used a lot and, although I'd prefer to still use it, a possible alternative here would be when.

PureFox48 avatar Apr 04 '21 22:04 PureFox48

Although I really like complicated pattern matching, I don't think it's a good fit for Wren, and even not generally for dynamic languages (although Python will have them in 3.10).

I vote for simple switch, but without fallthrough:

switch (a) {
  case 1: System.print("1")
  case "2":
    System.print("1")
    System.print("A string")
  default: System.print("Nothing of the above")
}

Though I'm not sure this is necessary.

ChayimFriedman2 avatar Apr 05 '21 05:04 ChayimFriedman2

Although a simple switch (without fallthrough) is nice, I'm not sure there's enough value in it to be worth doing.

What I liked about @mhermier's approach is that we can do some simple pattern matching in a natural way.

If we don't bother with fallthrough at all (no great loss IMO), it might even be possible for the compiler to just turn it into an if/else if/else ladder.

Apart from languages which don't support ranges, fallthrough isn't often needed in my experience and, if it is, you could do stuff like:

switch (value) {
    in 1..2: {
        if (value == 1) doSomething.call()
        doSomethingMore.call()
    }
    // etc
}

PureFox48 avatar Apr 05 '21 11:04 PureFox48

In that case I suggest that the keyword fallthrough is added, and must be the last expression in the block. Having switch nested in loops is pretty frequent and reusing continue here would be to much impractical.

mhermier avatar Apr 05 '21 14:04 mhermier

Go also insists on fallthrough being the last statement in a case clause. In other words, it can't be conditional which is presumably to make the implementation easier.

PureFox48 avatar Apr 05 '21 14:04 PureFox48

Well it can be augmented in a second revision. This is a bare minimum so it is working.

mhermier avatar Apr 05 '21 14:04 mhermier

I personally struggle a little bit with the word fallthrough since it has many double letters and gh position. How about pass?

clsource avatar Apr 05 '21 17:04 clsource

The problem is that fallthrough is the commonly accepted term for this behavior. pass just doesn't have the same meaning - it's used in Python to mean 'do nothing' - and it's a commonly used identifier in any case.

As well as Go, fallthrough is used by Swift and by C++ 17 (as an attribute). I couldn't find any alternatives at all.

I suppose we could spell it fallthru or use a made-up word such as nextcase but neither look right to me. Perhaps if you think of it as two words, which is really what it is, then it'll seem more palatable.

PureFox48 avatar Apr 05 '21 18:04 PureFox48

I have some ideas I want to share.

switch (expr) {
  |  _: System.print("value is a different number (This is default case)")
  // Alternative default case
  | default: System.print("alternative default case")
  |  1, 2, 3: System.print("value is 1, 2, or 3")
  |  4: System.print("value is 4 but will fall through to next case")
  |> 5: System.print("value may be 4 or 5, because 4 can fall though")
  |  6:
    System.print("this case...")
    System.print("has multiple")
    System.print("instructions!")
}
  • I think I like @clsource's original syntax where case is replaced with the pipe character.
  • I think for default case we could use underscore (or if we want to, we could make default a keyword). Also we shouldn't try to constrain the default case to the end especially if we want to add fallthough behavior
  • I don't think we need the brackets for list of options for a case. instead we can read comma separated items until we hit a colon for multiple values.
  • I also don't think we need to enclose multiple instructions within blocks. it should probably be enough to do instructions until a line begins with the pipe character (to indicate the next case), or until the closing bracket (the end of the switch)
  • I think for fallthrough-like behavior, we could add an open angle brace to the next case immediately following the pipe character.

CrazyInfin8 avatar Apr 05 '21 20:04 CrazyInfin8

I like the idea of using |> to indicate falltrought. My preference would be using it at the right position since using if before can be confused with "greater than".

Another option instead of default or underscore (_). Would be using true since it will always be true regardless of the case.

switch (expr) {
  |  1, 2, 3 : System.print("value is 1, 2, or 3")
  |  4      |> System.print("value is 4 but will fall through to next case")
  |  5      : System.print("value may be 4 or 5, because 4 can fall though")
  |  6      :
    System.print("this case...")
    System.print("has multiple")
    System.print("instructions!")
 | true : System.print("This is the default case")
}

This more or less can be translated to ifs/elses

  if (expr == 1 || expr == 2 || expr == 3) {
    return System.print("value is 1, 2, or 3")
  }
 
if (expr == 4 || expr == 5) {
  if (expr == 4) {
   System.print("value is 4 but will fall through to next case")
  }
  if (expr == 5) { 
     return System.print("value may be 4 or 5, because 4 can fall though")
  }
  return 
}

  if (expr == 6) {
    System.print("this case...")
    System.print("has multiple")
    return System.print("instructions!")
  }
  
 return System.print("This is the default case")
}

clsource avatar Apr 05 '21 21:04 clsource

What syntax would be used to express the following cases:

  1. expr > 6
  2. expr != 2
  3. expr in 1..50
  4. expr.isEven
  5. expr is String

PureFox48 avatar Apr 05 '21 21:04 PureFox48

Regarding the placing of the default case first in the switch, it's something I've never personally liked even though there may be a performance benefit if the default case is much more common than the other cases.

But would it even be possible in a language such as Wren with a single pass compiler?

PureFox48 avatar Apr 05 '21 21:04 PureFox48

I think one of the things I hoped switch statements should do was optimize itself for list of numbers so it isn't checking every option like an if-else ladder. because of that, I was also thinking that we should only hold values in cases instead of expressions (similar to how it is done in C).


I'm unfamilliar with the bytecode for now so this is more my concept rather than a what the VM can actually do right now but...

  1. The compiler makes a map for each case value which points to a set of instructions.
  2. If the next case is a fallthrough, then the current case could also make a reference to the next cases and it's instructions (could maybe pose a challange for a single pass compiler but may not be the only way to get the next set of instructions)
  3. If the expression passed to the switch statement matches a case's value, execute the instructions. Or if the expression passed does not match any of the cases but a default case was passed, then run the instructions the default case points to.
  4. if the case selected references any fallthough cases, select that case and execute it's instructions. repeat this step if that case references more fallthough cases.

I guess it may be limiting to not put expressions in a switch statement cases though so maybe we could also have a match statement or switch statement without an expression passed to it. This could behave more like if-else ladders

something like:

// checks for first truthy value
switch {
  |  expr > 5: // do stuff
  |  expre.method: // do other stuff
}
// maybe might reduce ambiguity
match {
  |  expr > 5: // do stuff
  |  expre.method: // do other stuff
}

Also, in my opinion, I disagree with each cases interacting with switch statements. I think operators should still have expressions on both sides, type checks should be as they are, and methods should still contain their receivers even in switch cases.

so none of these would work:

switch (expr) {
  |  >= 5:
  |  .isOdd:
  |  > 7:  // thus reducing ambiguity with my previous example
  |  is String:
}

Lastly, I don't like this very much either

Another option instead of default or underscore (_). Would be using true since it will always be true regardless of the case.

It may not be often that people are comparing bools in a switch but it still could be possible and this suggestion brings ambiguity,

var aBool = false
switch (aBool) {
  |  true: System.print("aBool is definitely true")
  |  false: System.print("aBool is definitely false")
  |  5: System.print("aBool isn't even a bool")
}

CrazyInfin8 avatar Apr 05 '21 21:04 CrazyInfin8

Adding a switch statement was in fact first discussed back in #352 and I think the conclusion then was that it needs to go some way towards pattern matching to be worth doing at all.

Although one can argue about the details, I did feel that @mhermier's variant satisfied that criterion but the other variants discussed did not though I'm grateful to @clsource for giving the matter another airing.

It seems to me that the other variants can't really handle pattern matching without repeating the control variable in each case which negates one of the benefits of having a switch statement in the first place. The only benefit it really leaves us with is nicer, more readable syntax and the possibility of fallthrough compared to an if/else ladder.

If some sort of 'jump table' were possible on the lines you suggest, then that would be another reason why a switch statement might be advantageous though I don't know how feasible that would be in Wren.

PureFox48 avatar Apr 05 '21 22:04 PureFox48

I don't understand the point of the | as a separator, parsing wise at best it serve as a separator for blocks but it fells out off place for some reasons...

mhermier avatar Apr 05 '21 22:04 mhermier

I don't understand the point of the | as a separator, parsing wise at best it serve as a separator for blocks but it fells out off place for some reasons...

I think the | character as a separator seems similar to saying "or", so something like value1 | value2 is like value1 or value2. The syntax suggested there is the syntax used in Rust.

In my opinion (and this is just an opinion fueled by my bias so it can be disregarded), I don't want wren to resemble Rust as it's sometimes pretty awkward and Rust traumatizes me ;_;

CrazyInfin8 avatar Apr 05 '21 23:04 CrazyInfin8

Also I might add, having the pipe character precede case values may make it easier for the single pass compiler to tell that this is a start of a new case instead of just having variable names, lists, and maps followed by an arrow. Again just guessing as I haven't dug too deep into wren source code.

CrazyInfin8 avatar Apr 05 '21 23:04 CrazyInfin8

In all the case _ as default is not an option since it is a valid member variable name.

mhermier avatar Apr 05 '21 23:04 mhermier

In all the case _ as default is not an option since it is a valid member variable name.

Ah true, I guess if we are putting expressions in the switch statement, might have to use else or default for the default case. In my opinion (gah I'm so opinionionated), I prefer default especially if the default case is not restricted to the last case because putting else sounds like the last case.

CrazyInfin8 avatar Apr 05 '21 23:04 CrazyInfin8

Other languages like Elixir have separate simple case conditions from pattern matched ones

https://elixir-lang.org/getting-started/case-cond-and-if.html

case is useful when you need to match against different values. However, in many circumstances, we want to check different conditions and find the first one that does not evaluate to nil or false. In such cases, one may use cond:


iex> cond do
...>   2 + 2 == 5 ->
...>     "This will not be true"
...>   2 * 2 == 3 ->
...>     "Nor this"
...>   1 + 1 == 2 ->
...>     "But this will"
...> end
"But this will"

This is equivalent to else if clauses in many imperative languages (although used much less frequently here).

If all of the conditions return nil or false, an error (CondClauseError) is raised. For this reason, it may be necessary to add a final condition, equal to true, which will always match:

iex> cond do
...>   2 + 2 == 5 ->
...>     "This is never true"
...>   2 * 2 == 3 ->
...>     "Nor this"
...>   true ->
...>     "This is always true (equivalent to else)"
...> end
"This is always true (equivalent to else)"

Finally, note cond considers any value besides nil and false to be true:

iex> cond do
...>   hd([1, 2, 3]) ->
...>     "1 is considered as true"
...> end
"1 is considered as true"

The idea of using the pipe was taken from F#

https://docs.microsoft.com/en-us/dotnet/fsharp/language-reference/pattern-matching

let printColorName (color:Color) =
    match color with
    | Color.Red -> printfn "Red"
    | Color.Green -> printfn "Green"
    | Color.Blue -> printfn "Blue"
    | _ -> ()

clsource avatar Apr 06 '21 03:04 clsource

Some ideas. Maybe two separate statements can be is and match.

promoting the existing keyword is to be part of an if.

  • if is would be similar to C switch matching only bools, strings, nums, and lists of possible values. A simpler if table
  • match would be using pattern matching for each case.
  • Fall through keyword replaced with :>

if is (value) { // another possible way would be if (value) is {}
  | 1 : System.print("Case 1")
  | 2 :> System.print("Fallthough") // to case 3
  | 3 : System.print("Case 3.")
  | 4, 5 : System.print("Case 4 or 5")
  | true : System.print("considers any value besides null and false to be true")
} else {
  // The default case
}

match (value) {
  | in 1..6: System.print("Value is in range 1..6")
  | in [7, 10, 12]:> System.print("Value is 7 10 or 12") // fallthough to the next case
  | true: System.print("considers any value besides null and false to be true") 
} else {
  // default
}

clsource avatar Apr 06 '21 18:04 clsource

Given that we don't want to add more syntax to Wren than we need to, I think that switch (or whatever we decide to call it) needs to be a single statement and it needs to somehow combine traditional usage with simple pattern matching otherwise it's just not worth doing.

An if/else ladder can after all deal with anything except falling through to the next clause - it's only problem is that it lacks elegance.

PureFox48 avatar Apr 09 '21 16:04 PureFox48

OK. following my previous post, I've tried to come up with a design which we might all be able to unite around by going back to traditional switch/case syntax, with no automatic fallthrough, but with default replaced by else to save a keyword. Blocks would not be required for the cases which could be one of the following:

  1. A single value.

  2. A list of values.

  3. A range of values.

  4. A type.

  5. A boolean expression.

Option 5 would only be possible if the switch expression were a variable (no problem in practice) which would itself feature in the boolean expression. Other variables which were in scope could also be included.

Fallthrough would be catered for by a new (and spellable) fallthru keyword which would have to be the final statement in a case.

If break and/or continue were present in the code, they would refer to an enclosing loop, not to the switch statement itself.

Here's how it would look:

switch (expr) {
    case 1 :
        System.print("A single value.")     
    case 2, 3, 5 :
        System.print("One of a list of values.")
    case 6..10 :
        System.print("One of a range of values.")
    case is Num :
        System.print("The type of the expression.")
    case expr <= 0 || expr == 4:
        System.print("A boolean expression.")
        fallthru // fall through here
    else :
        System.print("Default case.")
   }

The code would simply iterate through the cases until it found the first match. Cases could therefore overlap each other. It's possible that the compiler might be able to implement this as an if/else ladder though fallthrough might be tricky.

PureFox48 avatar Apr 09 '21 18:04 PureFox48

Don't use acronym, autocompletion is a thing since more than 10 years... Also, I really don't like that each case is specific, it implies special handling in the compiler for each specific usage, aka code explosion in the compiler. And last case is not realistic at all. If expr has to be repeated, it is a simple if.

mhermier avatar Apr 09 '21 18:04 mhermier

I assume when you say: "don't use acronym" you mean don't use fallthru - use fallthrough and rely on auto-completion if you have trouble spelling it. I'm not going to argue with that as I don't like the former anyway.

The problem we have with option 5 is that, technically, option 1 is also a boolean expression which evaluates to true unless it happens to be false or null. Unless 'expr' is repeated in option 5 the compiler may therefore have a problem in distinguishing between the two. It also enables the usage of && and || and addresses the dislike some commenters have of cases beginning with an operator.

It seems to me that some 'code explosion' is inevitable if traditional and pattern matching approaches are to be combined in a single statement.

PureFox48 avatar Apr 09 '21 19:04 PureFox48

My solution is a little bit more elegant, you only have to trick the compiler to dupe the value on each case, and start evaluating from there. While it seems to be a lot of case, in fact there is only the general evaluation of an implicit case value expanded with the explicit expression. The rest is boiler plate to control the execution flow.

With your solution each case must be parsed and compiled differently. And this is were you solution ends in code explosion.

I don't say my code is better at all. The importance is also in the ease of writing, learning and other considerations. But my solution should have less impact with more extensibility. But this not always the way to go. ex I really don't like the new tag system, I would have preferred python decorators, and a proper type annotation, or at minimum that they follow the wren syntax to avoid the introduction of a DSL...

mhermier avatar Apr 09 '21 21:04 mhermier

Misc. thoughts:

  • Syntax: I prefer keywords to | since Wren mostly uses words rather than symbols outside of expressions.
  • "fallthrough": the Raku programming language uses "proceed" - https://docs.raku.org/language/control#given

cxw42 avatar Apr 10 '21 23:04 cxw42

Syntax: I prefer keywords to | since Wren mostly uses words rather than symbols outside of expressions.

Agree

"fallthrough": the Raku programming language uses "proceed"

Nicer alternative

OK. following my previous post, I've tried to come up with a design that we might all be able to unite around by going back to traditional switch/case syntax.

Considering the points made by @cxw42 I think the following syntax can be using only two new keywords: switch and proceed. This syntax was inspired by the @mhermier syntax.

switch (value) {
    (== 42) : // implicit value
      System.print("The answer")
      System.print("to the universe")
      proceed // fallthrough
    
    (value.isEven):  // or can be used explicit
      System.print("This is odd")
    
    (4):
     System.print("How did we get there")
 } else {
   System.print("Default case.")
 }

clsource avatar Apr 11 '21 00:04 clsource

(By the way, thank you all for letting me join you in this discussion! I enjoy language design :) .)

I agree we are getting closer.

Syntax question

switch (value) {
...
    (value.isEven):  // or can be used explicit

@clsource is value a reserved word here? It is not currently in Wren, as far as I can tell, but rather a convention (ref). Would it need to be reserved for something like this to work?---

switch(2+2) {
  (value.isEven) System.print("yes")
}

Syntax comments

  • I don't think we need the colons if we are using parens --- we should be able to use Wren's regular statement forms. I like this because it is analogous to if(foo) stmt. E.g.,

     switch(value) {
       (4) System.print("one-line statement")
       (5) {
         System.print("multi-line block")
        }
      }
    
  • I would rather not list the default case as a separate block, for the sake of fallthrough-to-default. For example, in C, I sometimes write:

     switch(expr) {
       case 1: ...; break;
       case 2:...;
         /* FALL THROUGH */
       default: ...; break;
     }
    

    I think this would be clearer in Wren as

    switch(value) {
      (4) {
        System.print("four")
        proceed
      }
      (any) {  // or whatever syntax we pick for the default case
        System.print("default, and also 4")
      }
    }
    

    than as

    switch(value) {
      (4) {
        System.print("four")
        proceed   // To what?  This makes me look down past two closing braces to find where control will go.
      }
    } else {
      System.print("default, and also 4")
    }
    

cxw42 avatar Apr 11 '21 01:04 cxw42