References and Snapshots

The issue with the tuple code in Listing 4-5 is that we have to return the Array to the calling function so we can still use the Array after the call to calculate_length, because the Array was moved into calculate_length.

Snapshots

In the previous chapter, we talked about how Cairo's ownership system prevents us from using a variable after we've moved it, protecting us from potentially writing twice to the same memory cell. However, it's not very convenient. Let's see how we can retain ownership of the variable in the calling function using snapshots.

In Cairo, a snapshot is an immutable view of a value at a certain point in time. Recall that memory is immutable, so modifying a value actually creates a new memory cell. The old memory cell still exists, and snapshots are variables that refer to that "old" value. In this sense, snapshots are a view "into the past".

Here is how you would define and use a calculate_length function that takes a snapshot to an array as a parameter instead of taking ownership of the underlying value. In this example, the calculate_length function returns the length of the array passed as parameter. As we're passing it as a snapshot, which is an immutable view of the array, we can be sure that the calculate_length function will not mutate the array, and ownership of the array is kept in the main function.

Filename: src/lib.cairo

use debug::PrintTrait;

fn main() {
    let mut arr1 = ArrayTrait::<u128>::new();
    let first_snapshot = @arr1; // Take a snapshot of `arr1` at this point in time
    arr1.append(1); // Mutate `arr1` by appending a value
    let first_length = calculate_length(
        first_snapshot
    ); // Calculate the length of the array when the snapshot was taken
    //ANCHOR: function_call
    let second_length = calculate_length(@arr1); // Calculate the current length of the array
    //ANCHOR_END: function_call
    first_length.print();
    second_length.print();
}

fn calculate_length(arr: @Array<u128>) -> usize {
    arr.len()
}

Note: It is only possible to call the len() method on an array snapshot because it is defined as such in the ArrayTrait trait. If you try to call a method that is not defined for snapshots on a snapshot, you will get a compilation error. However, you can call methods expecting a snapshot on non-snapshot types.

La salida de este programa es:

[DEBUG]	                               	(raw: 0)

[DEBUG]	                              	(raw: 1)

Run completed successfully, returning []

First, notice that all the tuple code in the variable declaration and the function return value is gone. Second, note that we pass @arr1 into calculate_length and, in its definition, we take @Array<u128> rather than Array<u128>.

Veamos más de cerca la llamada a la función aquí:

use debug::PrintTrait;

fn main() {
    let mut arr1 = ArrayTrait::<u128>::new();
    let first_snapshot = @arr1; // Take a snapshot of `arr1` at this point in time
    arr1.append(1); // Mutate `arr1` by appending a value
    let first_length = calculate_length(
        first_snapshot
    ); // Calculate the length of the array when the snapshot was taken
    let second_length = calculate_length(@arr1); // Calculate the current length of the array
    first_length.print();
    second_length.print();
}

fn calculate_length(arr: @Array<u128>) -> usize {
    arr.len()
}

The @arr1 syntax lets us create a snapshot of the value in arr1. Because a snapshot is an immutable view of a value at a specific point in time, the usual rules of the linear type system are not enforced. In particular, snapshot variables are always Drop, never Destruct, even dictionary snapshots.

De manera similar, la firma de la función utiliza @ para indicar que el tipo del parámetro arr es una instantánea. Añadamos algunas anotaciones explicativas:

fn calculate_length(
    array_snapshot: @Array<u128>
) -> usize { // array_snapshot is a snapshot of an Array
    array_snapshot.len()
} // Here, array_snapshot goes out of scope and is dropped.
// However, because it is only a view of what the original array `arr` contains, the original `arr` can still be used.

El alcance en el que la variable array_snapshot es válida es el mismo que el alcance de cualquier parámetro de función, pero el valor subyacente del snapshot no se eliminará cuando array_snapshot deje de usarse. Cuando las funciones tienen snapshots como parámetros en lugar de los valores reales, no necesitamos devolver los valores para devolver la propiedad del valor original, porque nunca la tuvimos.

Desnap Operator

To convert a snapshot back into a regular variable, you can use the desnap operator *, which serves as the opposite of the @ operator.

Only Copy types can be desnapped. However, in the general case, because the value is not modified, the new variable created by the desnap operator reuses the old value, and so desnapping is a completely free operation, just like Copy.

In the following example, we want to calculate the area of a rectangle, but we don't want to take ownership of the rectangle in the calculate_area function, because we might want to use the rectangle again after the function call. Since our function doesn't mutate the rectangle instance, we can pass the snapshot of the rectangle to the function, and then transform the snapshots back into values using the desnap operator *.

use debug::PrintTrait;

#[derive(Copy, Drop)]
struct Rectangle {
    height: u64,
    width: u64,
}

fn main() {
    let rec = Rectangle { height: 3, width: 10 };
    let area = calculate_area(@rec);
    area.print();
}

fn calculate_area(rec: @Rectangle) -> u64 {
    // As rec is a snapshot to a Rectangle, its fields are also snapshots of the fields types.
    // We need to transform the snapshots back into values using the desnap operator `*`.
    // This is only possible if the type is copyable, which is the case for u64.
    // Here, `*` is used for both multiplying the height and width and for desnapping the snapshots.
    *rec.height * *rec.width
}

But, what happens if we try to modify something we’re passing as snapshot? Try the code in Listing 4-6. Spoiler alert: it doesn’t work!

Filename: src/lib.cairo

#[derive(Copy, Drop)]
struct Rectangle {
    height: u64,
    width: u64,
}

fn main() {
    let rec = Rectangle { height: 3, width: 10 };
    flip(@rec);
}

fn flip(rec: @Rectangle) {
    let temp = rec.height;
    rec.height = rec.width;
    rec.width = temp;
}

Listing 4-6: Attempting to modify a snapshot value

Aquí está el error:

error: Invalid left-hand side of assignment.
 --> ownership.cairo:15:5
    rec.height = rec.width;
    ^********^

El compilador nos impide modificar los valores asociados a las instantáneas.

Mutable References

We can achieve the behavior we want in Listing 4-6 by using a mutable reference instead of a snapshot. Mutable references are actually mutable values passed to a function that are implicitly returned at the end of the function, returning ownership to the calling context. By doing so, they allow you to mutate the value passed while keeping ownership of it by returning it automatically at the end of the execution. In Cairo, a parameter can be passed as mutable reference using the ref modifier.

Note: In Cairo, a parameter can only be passed as mutable reference using the ref modifier if the variable is declared as mutable with mut.

In Listing 4-7, we use a mutable reference to modify the value of the height and width fields of the Rectangle instance in the flip function.

use debug::PrintTrait;
#[derive(Copy, Drop)]
struct Rectangle {
    height: u64,
    width: u64,
}

fn main() {
    let mut rec = Rectangle { height: 3, width: 10 };
    flip(ref rec);
    rec.height.print();
    rec.width.print();
}

fn flip(ref rec: Rectangle) {
    let temp = rec.height;
    rec.height = rec.width;
    rec.width = temp;
}

Listing 4-7: Use of a mutable reference to modify a value

Primero, cambiamos rec a mut. Luego, pasamos una referencia mutable de rec a flip con ref rec y actualizamos la firma de la función para aceptar una referencia mutable con ref rec: Rectangle. Esto deja muy claro que la función flip modificará el valor de la instancia de Rectangle pasada como parámetro.

La salida del programa es:

[DEBUG]
                                (raw: 10)

[DEBUG]	                        (raw: 3)

Como era de esperar, los campos height y width de la variable rec se han intercambiado.

Small recap

Let’s recap what we’ve discussed about the linear type system, ownership, snapshots, and references:

  • At any given time, a variable can only have one owner.
  • You can pass a variable by-value, by-snapshot, or by-reference to a function.
  • If you pass-by-value, ownership of the variable is transferred to the function.
  • If you want to keep ownership of the variable and know that your function won’t mutate it, you can pass it as a snapshot with @.
  • If you want to keep ownership of the variable and know that your function will mutate it, you can pass it as a mutable reference with ref.
Last change: 2023-12-08, commit: 7c6a72a