Evaluation order

Evaluation order is source order - the receiver first, then the arguments as they are written, then the parameter defaults - so a side effect in an argument is exactly as predictable as reading the line.

"Left to right" can only mean the order a reader sees. Every place several subexpressions could run in more than one order is pinned to that one order, so a side effect inside an argument is never a question a reader has to think about.

Example#

fn traced(label: String, value: Int): Int {
  print label
  value
}

fn combine(a: Int, b: Int): Int {
  a + b
}

print combine(traced("first", 1), traced("second", 2))

"first" prints before "second", because the arguments run in the order they are written, left to right.

Syntax#

receiver.method(argument, argument)     receiver, then the arguments in written order
place = value                           the subexpressions of place, then value
condition1 && condition2                condition1, then condition2 only if condition1 is true
condition1 || condition2                condition1, then condition2 only if condition1 is false
value ?? fallback                       value, then fallback only if value is absent or a Fail

Rules#

  1. A call evaluates its receiver first, then its arguments in the order they are written. This holds whether the call is a command or has parentheses; the two are the same call with a different spelling. What is evaluated is the value: an argument that changes the binding an earlier operand named - through a var parameter - does not change the value the call is made on, whether the operand is a whole binding or a field of one.

    fn added(var table: List<Int>, value: Int): Int {
      table.append value
      value
    }
    
    var table = [1, 2, 3]
    print table.contains(added(table, 7))
    print table

    The first line prints false, because table was [1, 2, 3] when it was evaluated as the receiver; the second prints [1, 2, 3, 7].

  2. A labelled argument is evaluated where it is written, and only reordered into declaration order afterwards. The label decides which parameter gets the value, not when the value runs.

    fn traced(label: String, value: Int): Int {
      print label
      value
    }
    
    fn point(x: Int, y: Int): (Int, Int) {
      (x, y)
    }
    
    print point(y: traced("y", 2), x: traced("x", 1))

    "y" prints before "x", because that is the order they are written, even though x is declared first.

  3. A parameter default runs after every explicit argument, in the declaration order of the parameters that were left out. A default that reads another parameter reads the value that parameter ended up with, never a value still being computed.

  4. In place = value, every subexpression of place is evaluated before value is. list[index()] = compute() calls index() before it calls compute().

  5. && and || short-circuit. The right side of && runs only if the left side is true; the right side of || runs only if the left side is false.

    fn traced(label: String, value: Bool): Bool {
      print label
      value
    }
    
    print(traced("left", false) && traced("right", true))

    Only "left" prints: the left side is false, so && never evaluates the right side at all.

  6. ?? evaluates its right side only when the left side needs it. The right side is a lazy parameter, so an expensive fallback that is never used never runs (see Parameter modes).

What this is not#

Evaluation order is not "whichever is convenient for the back end." Both back ends - the interpreter and the compiled binary - produce the same order, because the order is a rule of the language and not an implementation detail either one is free to pick.

fn traced(label: String, value: Int): Int {
  print label
  value
}

const first = traced "a", 1
const second = traced "b", 2
print(first + second)
Does not compile
fn traced(label: String, value: Int): Int {
  print label
  value
}

print(traced("a", 1) && traced("b", 2))
// error: Expected `Bool`, found `Int64`
Esc