Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Clarify behavior of mutating functions when they return values and are chained. #401

Open
jeshecdom opened this issue Sep 26, 2024 · 0 comments
Labels
docs: Book /book section of the docs: Guides, Cheatsheets, and a streamlined sequence of educational materials

Comments

@jeshecdom
Copy link

The behavior of mutates functions needs to be clarified, specially of those mutates that return a value and those that can be chained.

The Tact Book states that "mutable functions are performing mutation of a value replacing it with an execution result. To perform mutation, the function must change the self value".

So,

struct A {
  a: Int
}

extends mutates fun incr(self: A) {
    self.a += 1;
}

will mutate variable s.a to 3 in this code snippet:

let s = A {a: 2};
s.incr();  // s.a is now 3

because the incr functions changes the self struct.

Since mutable functions change their self argument (something that simple extends functions do not do), some users may believe that mutates functions pass their self argument by reference. This is NOT what happens. All functions in Tact pass their arguments by value, but mutates functions carry out a special step once they finish execution: they assign the result in their self variable back into the variable that the mutable function was called upon [there is an exception to this, see Note 1 below]. For example, in the code s.incr() above, what happens is the following:

  • Function incr is called by instantiating self with a copy of s. Denote the copy as s'.
  • incr changes the a field in s' to 3.
  • At this moment, incr finishes execution, so it assigns to s whatever value is currently stored in self, which is s' but with 3 in its a field.
  • Hence, s is now A {a: 3}.

So far, a user that thinks that self is passed by reference in mutates functions would get the same correct conclusions as one that actually knows how mutates functions work, because the above example is simple. The confusion starts with mutable functions that return values, specially when those functions are chained. For example, let us modify the incr function as follows:

extends mutates fun incr(self: A): A {
    self.a += 1;
    return self;
}

The above may be written by a user that thinks that self is passed by reference, in an attempt to chain the incr function:

let s = A {a: 2};
s.incr().incr().incr();

The user incorrectly thinks: "Since self is passed by reference, when the first call to incr finishes, s.a = 3. Then, the modified s is given (by reference) to the next call of incr, and so on". This user will conclude that s.a = 5 in the above code snippet. This is NOT what happens.

In the above code snippet, s.a will actually be 3 (not 5). The reason is as follows:

  • incr is called by instantiating self with a copy of s. Denote the copy by s1.
  • incr modifies s1.a = 3 and returns s1.
  • incr assigns back to s whatever is in its self variable, which is s1 with s1.a = 3.
  • But then, the result of incr, which is s1, is given as input to the second call of incr.
  • The second call of incr instantiates self with a copy of s1. Denote it s2.
  • The second call finishes modifying s2.a = 4. The step that assigns self back into "s1" is ignored this time because s1 is not an actual variable [see Note 1 below]. But incr gives the returned value (which is s2 with s2.a = 4) as input to the third call to incr.
  • And so on.

Note that in the above steps, s is only modified by the first call to incr. Hence, s.a = 3. If the user actually wants to modify s with the value returned by the third call to incr, variable s needs to be explicitly assigned:

 s = s.incr().incr().incr();

In order to avoid the above confusing behavior, it seems to me that a better approach would be simply to avoid mutates chains by breaking the chain into independent steps, i.e.,

s.incr();
s.incr();
s.incr();

because the meaning is clearer.

The incr function is a bit confusing because it actually returns TWO values: the self which is automatically assigned back into the variable calling the mutate function, and the value in the return statement.

The fact that mutates functions can return two values is better exemplified with the following code:

// Increments the argument, but returns the previous integer to the argument
extends mutates fun incr(self: Int): Int {
    let prev = self - 1;
    self += 1;
    return prev;
}

What do you think is the final value of the variables in this code snippet?

let s = 5;
let t = s.incr();

Answer: s = 6 and t = 4.


Note 1: When there is a chain of mutates functions, like s.mutFun1().mutFun2()...., Tact will assign the self value back into s only in function mutFun1, because there is no variable to assign back in functions mutFun2, and so on. However, the return value of mutFun1 will be given as input to mutFun2. The return value of mutFun2 to mutFun3, and so on.

@novusnota novusnota added the docs: Book /book section of the docs: Guides, Cheatsheets, and a streamlined sequence of educational materials label Sep 30, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
docs: Book /book section of the docs: Guides, Cheatsheets, and a streamlined sequence of educational materials
Projects
None yet
Development

No branches or pull requests

2 participants