Mutation and var paths

A change needs an unbroken var path from the binding down to the field being changed, and a var parameter or a var fn receiver is a reference that cannot outlive the call it belongs to.

Nothing in the language is changed through an alias. A change always goes through a var path: a var binding, var parameter or a var fn receiver, then var fields, indices and ranges all the way down to the value that changes.

Example#

type Counter {
  var count: Int = 0

  var fn increment() {
    count = count + 1
  }
}

fn incrementTwice(var target: Counter) {
  target.increment()
  target.increment()
}

var counter = Counter()
incrementTwice counter
print counter.count

Syntax#

<var-path> ::= (<var-binding> | <var-parameter> | "self" in a var-self member) <step>*
<step>     ::= "." <var-field> | "[" <index-or-range> "]"

Rules#

  1. A var parameter works on the caller's value, and its argument has to be a var path. incrementTwice changes counter itself; there is no marker at the call site, the parameter's own var is what promises it.

    Does not compile
    type Counter {
      var count: Int = 0
    
      var fn increment() {
        count = count + 1
      }
    }
    
    fn incrementTwice(var target: Counter) {
      target.increment()
      target.increment()
    }
    
    const frozen = Counter()
    incrementTwice frozen
    // error: `incrementTwice` changes `target`, and `frozen` is a `const`
  2. A path through a[key] or a[from..to] changes the collection in place, without a copy. samples[1..4].sort { value => value } sorts that slice of samples itself, the same way a field step would.

  3. A reference is second-class: it exists only as a var parameter or a var fn receiver, for the duration of one call. It cannot be stored in a field, returned, or captured by a closure that outlives the call - which is exactly what keeps the language free of lifetimes and dangling references. A closure may capture one only where it stands straight as the argument of a call whose parameter only calls it (rule 4), and the checker enforces it.

    Does not compile
    type Counter {
      var count: Int = 0
    
      var fn watcher(): () => Void {
        { count = count + 1 }
      }
    }
    // error: This closure captures the receiver `self` and may outlive the call
  4. A captured var binding follows the same rule as a reference. The closure shares the binding with its scope rather than copying it, so it may only run while the binding exists: it may stand straight as the argument of a call whose parameter only calls it (items.forEach { total = total + _ }, a receiver closure, a block of a DSL), and it may not be bound to a name, stored, returned or handed to a parameter that keeps it. See Closures, rule 8.

    Does not compile
    fn counter(): () => Int {
      var count = 0
      {
        count = count + 1
        count
      }
    }
    // error: This closure captures the `var` binding `count` and may outlive it

    State that has to outlive its scope is a shared type, which says it has an identity; recursion is a local fn, which captures nothing.

  5. The base of a var path can never be a temporary. Changing the result of a call directly is always rejected, because the change would be thrown away the moment the call returns - the value has to be bound to a name first.

    Does not compile
    type Counter {
      var count: Int = 0
    
      var fn increment() {
        count = count + 1
      }
    }
    
    fn makeCounter(): Counter {
      Counter()
    }
    
    makeCounter().increment()
    // error: This is a temporary, and `increment` changes its receiver: the change would be thrown away

    As the argument of a var parameter a temporary is fine: the callee becomes its only owner, so nothing is ever written back anywhere. tick(makeCounter()) needs nothing bound first.

  6. The variable of a for loop is a const; the slot of a for var is a path. for var element in items binds each slot of the container in turn, and every use of element is the access items[key] of the turn - see Changing elements in place. The variable of a plain for is a copy, so changing an element through it needs the path (items[index].x = 1) or a new collection built with map.

    type Counter {
      var hits: Int
    }
    
    var counters = [Counter(0), Counter(5)]
    for var counter in counters {
      counter.hits = counter.hits + 1
    }
    print counters // prints [Counter(hits: 1), Counter(hits: 6)]

What this is not#

Taking a value out of a collection is not a way to reach its path. It is a copy, so changing it changes nothing the collection holds - this is the copy trap, and reading counters[0] again is the only way to see whether a change went where it was meant to.

var counters = [Counter(), Counter()]
counters[0].increment()          // Through the path: changes the element in place
print counters[0].count
var counters = [Counter(), Counter()]
var first = counters[0]          // A copy
first.increment()                // Changes the copy. `counters[0]` is still at 0.
print counters[0].count
  • Exclusivity - the rule for when two var accesses to the same path may not overlap.
  • Fields - which field modifier a path is allowed to end on.
  • Declaring a type - var fn on a verb, and its participle that needs no path at all.
  • Bindings - const and var on the binding a path starts from.
Esc