Semantics of circuit member functions

Currently circuit types include member variables and member functions. The latter may be static or not.

There are plans to eliminate the static/non-static distinction and have just member functions, with three possibilities:

  1. Functions without a self parameter.
  2. Functions with a self parameter (as first parameter).
  3. Functions with a mut self parameter (as first parameter).

Here we assume we are already in this situation.

Let C be a circuit type, whose three kinds of member functions are discussed below.

Functions without a self parameter (#1 above) are today’s static functions. They are not very different from top-level functions, except that (i) their bodies may use Self as a synonym of C and (ii) their calls must be prefixed by C::. If Leo is extended with access control on circuit members (e.g. public or not), then another difference with top-level functions could be the ability to access non-public members of C. However, these differences with top-level functions are not profound. Like top-level functions, circuit functions without self can be described purely functionally (in the sense of pure functional programming, i.e. without side effects).

Functions with a self parameter (#2 above) are not allowed to modify self, i.e. the circuit value they are called on. They are not very different from top-level functions with a C as first parameter, except that (i) their bodies may use Self as a synonym for C, (ii) the name of the first parameter is self instead of an identifier, and (iii) the call is written c.f(...) instead of f(c,...). Another possible future difference is the ability to access non-public members, as noted for functions of kind #1 above. If f is a function of this kind, a call c.f(...), where c is any expression of type C, is much like a call f(c,...) if f were a top-level function. The semantics of this call can be described as evaluating the expression c to a value of type C, evaluating any additional argument expressions of the call, and then executing f on all these values, obtaining a result value. This can be described purely functionally, as with kind #1 above.

Functions with a mut self parameter (#3 above) are allowed to modify self, and the modifications are visible to the caller – this is what the current examples and use cases show. This cannot be described purely functionally: it is an imperative feature. (Mathematically, we can still use functions to describe imperative semantics, but the functions must take “state” as additional input and output.) In effect, inside these functions, self is not really a circuit value, but a reference to an “external” circuit value: this way, any assignment to its member variables happens on the external circuit value, not any “internal” copy of it. (Alternatively, we could say that self is a copied circuit value inside the function, and that its final value overwrites the external value when the function returns. But the substance does not change, it is just a different way to describe what happens.)

The fact that a function f of kind #3 is passed self by reference and not by value constrains the form of the circuit expressions on which the function may be called. That is, c.f(...) cannot be allowed for any expression c of type C, but only for expressions that denote a “location”, i.e. what the current Pest grammar calls ‘assignees’. (In the C language, these would be called lvalues, for ‘left values’, i.e. expressions that may appear on the left of an assignment operator. In fact, the ‘value’ in ‘lvalue’ is a bit of a misnomer in the C language terminology; it should perhaps be ‘lexpression’.) In Leo, a call c.f(...), besides returning a result value like all other functions, behaves like an assignment to c, so c must be something that can be on the left side of an assignment.

This, i.e. mut self, is the only case in which a Leo function argument is passed by reference to a function. In all other cases, it is passed by value, i.e. a copy of the value is passed – at least conceptually, but a compiler can transparently optimize that to pass a reference just to avoid the copy, if it can establish that it is safe to do so. If Leo is extended with the ability to pass other arguments by reference (e.g. to call a function to swap the contents of two integer variables), then mut self could be described not as a special case, but as one form of pass-by-reference.

1 Like

In the case of #3, the circuit that is being modified must also be mutable.

circuit Foo {
    mut a: u32
    
    function set_a(mut self, a: u32) {
         self.a = a;
    }
}

function main() {
    let mut f = Foo { a: 1u32 };
    f.set_a(0u32) // VALID since f is mutable

    let g = Foo { a: 1u32 };
    f.set_a(0u32) // INVALID since g is not mutable
}

This syntax makes it clear that the values of the circuit f can be changed after it is defined.