Ownership Using a Linear Type System
Cairo uses a linear type system. In such a type system, any value (a basic type, a struct, an enum) must be used and must only be used once. 'Used' here means that the value is either destroyed or moved.
Destruction can happen in several ways:
- a variable goes out of scope.
- a struct is destructured.
- explicit destruction using
destruct()
.
Moving a value simply means passing that value to another function.
This results in somewhat similar constraints to the Rust ownership model, but there are some differences. In particular, the Rust ownership model exists (in part) to avoid data races and concurrent mutable access to a memory value. This is obviously impossible in Cairo since the memory is immutable. Instead, Cairo leverages its linear type system for two main purposes:
- Ensuring that all code is provable and thus verifiable.
- Abstracting away the immutable memory of the Cairo VM.
Ownership
In Cairo, ownership applies to variables and not to values. A value can safely be referred to by many different variables (even if they are mutable variables), as the value itself is always immutable. Variables however can be mutable, so the compiler must ensure that constant variables aren't accidentally modified by the programmer. This makes it possible to talk about ownership of a variable: the owner is the code that can read (and write if mutable) the variable.
This means that variables (not values) follow similar rules to Rust values:
- Each variable in Cairo has an owner.
- There can only be one owner at a time.
- When the owner goes out of scope, the variable is destroyed.
Now that we’re past basic Cairo syntax, we won’t include all the fn main() {
code in examples, so if you’re following along, make sure to put the following examples inside a main function manually. As a result, our examples will be a bit more concise, letting us focus on the actual details rather than boilerplate code.
Variable Scope
As a first example of the linear type system, we’ll look at the scope of some variables. A scope is the range within a program for which an item is valid. Take the following variable:
let s = 'hello';
The variable s
refers to a short string. The variable is valid from the point at
which it’s declared until the end of the current scope. Listing 4-1 shows a
program with comments annotating where the variable s
would be valid.
//TAG: ignore_fmt
fn main() {
{ // s is not valid here, it’s not yet declared
let s = 'hello'; // s is valid from this point forward
// do stuff with s
} // this scope is now over, and s is no longer valid
}
In other words, there are two important points in time here:
- When
s
comes into scope, it is valid. - It remains valid until it goes out of scope.
At this point, the relationship between scopes and when variables are valid is similar to that in other programming languages. Now we’ll build on top of this understanding by using the Array
type we introduced in the previous "Arrays" section.
Moving values
As said earlier, moving a value simply means passing that value to another function. When that happens, the variable referring to that value in the original scope is destroyed and can no longer be used, and a new variable is created to hold the same value.
Arrays are an example of a complex type that is moved when passing it to another function. Here is a short reminder of what an array looks like:
fn main() {
let mut arr: Array<u128> = array![];
arr.append(1);
arr.append(2);
}
How does the type system ensure that the Cairo program never tries to write to the same memory cell twice? Consider the following code, where we try to remove the front of the array twice:
fn foo(mut arr: Array<u128>) {
arr.pop_front();
}
fn main() {
let arr: Array<u128> = array![];
foo(arr);
foo(arr);
}
In this case, we try to pass the same value (the array in the arr
variable) to both function calls. This means our code tries to remove the first element twice, which would try to write to the same memory cell twice - which is forbidden by the Cairo VM, leading to a runtime error.
Thankfully, this code does not actually compile. Once we have passed the array to the foo
function, the variable arr
is no longer usable. We get this compile-time error, telling us that we would need Array to implement the Copy Trait:
$ scarb cairo-run
Compiling no_listing_02_pass_array_by_value v0.1.0 (listings/ch04-understanding-ownership/no_listing_02_pass_array_by_value/Scarb.toml)
warn: Unhandled `#[must_use]` type `core::option::Option::<core::integer::u128>`
--> listings/ch04-understanding-ownership/no_listing_02_pass_array_by_value/src/lib.cairo:3:5
arr.pop_front();
^*************^
error: Variable was previously moved.
--> listings/ch04-understanding-ownership/no_listing_02_pass_array_by_value/src/lib.cairo:9:9
foo(arr);
^*^
note: variable was previously used here:
--> listings/ch04-understanding-ownership/no_listing_02_pass_array_by_value/src/lib.cairo:8:9
foo(arr);
^*^
note: Trait has no implementation in context: core::traits::Copy::<core::array::Array::<core::integer::u128>>.
error: could not compile `no_listing_02_pass_array_by_value` due to previous error
error: `scarb metadata` exited with error
The Copy
Trait
The Copy
trait allows simple types to be duplicated by copying felts, without allocating new memory segments. This contrasts with Cairo's default "move" semantics, which transfer ownership of values to ensure memory safety and prevent issues like multiple writes to the same memory cell. Copy
is implemented for types where duplication is safe and efficient, bypassing the need for move semantics. Types like Array
and Felt252Dict
cannot implement Copy
, as manipulating them in different scopes is forbidden by the type system.
All basic types previously described in "Data Types" implement by default the Copy
trait.
While Arrays and Dictionaries can't be copied, custom types that don't contain either of them can be.
You can implement the Copy
trait on your type by adding the #[derive(Copy)]
annotation to your type definition. However, Cairo won't allow a type to be annotated with Copy if the type itself or any of its components doesn't implement the Copy trait.
#[derive(Copy, Drop)]
struct Point {
x: u128,
y: u128,
}
fn main() {
let p1 = Point { x: 5, y: 10 };
foo(p1);
foo(p1);
}
fn foo(p: Point) { // do something with p
}
In this example, we can pass p1
twice to the foo function because the Point
type implements the Copy
trait. This means that when we pass p1
to foo
, we are actually passing a copy of p1
, so p1
remains valid. In ownership terms, this means that the ownership of p1
remains with the main
function.
If you remove the Copy
trait derivation from the Point
type, you will get a compile-time error when trying to compile the code.
Don't worry about the Struct
keyword. We will introduce this in Chapter 5.
Destroying Values - Example with FeltDict
The other way linear types can be used is by being destroyed. Destruction must ensure that the 'resource' is now correctly released. In Rust, for example, this could be closing the access to a file, or locking a mutex.
In Cairo, one type that has such behaviour is Felt252Dict
. For provability, dicts must be 'squashed' when they are destructed.
This would be very easy to forget, so it is enforced by the type system and the compiler.
No-op Destruction: the Drop
Trait
You may have noticed that the Point
type in the previous example also implements the Drop
trait.
For example, the following code will not compile, because the struct A
is not moved or destroyed before it goes out of scope:
struct A {}
fn main() {
A {}; // error: Variable not dropped.
}
However, types that implement the Drop
trait are automatically destroyed when going out of scope. This destruction does nothing, it is a no-op - simply a hint to the compiler that this type can safely be destroyed once it's no longer useful. We call this "dropping" a value.
At the moment, the Drop
implementation can be derived for all types, allowing them to be dropped when going out of scope, except for dictionaries (Felt252Dict
) and types containing dictionaries.
For example, the following code compiles:
#[derive(Drop)]
struct A {}
fn main() {
A {}; // Now there is no error.
}
Destruction with a Side-effect: the Destruct
Trait
When a value is destroyed, the compiler first tries to call the drop
method on that type. If it doesn't exist, then the compiler tries to call destruct
instead. This method is provided by the Destruct
trait.
As said earlier, dictionaries in Cairo are types that must be "squashed" when destructed, so that the sequence of access can be proven. This is easy for developers to forget, so instead dictionaries implement the Destruct
trait to ensure that all dictionaries are squashed when going out of scope.
As such, the following example will not compile:
use core::dict::Felt252Dict;
struct A {
dict: Felt252Dict<u128>
}
fn main() {
A { dict: Default::default() };
}
If you try to run this code, you will get a compile-time error:
$ scarb cairo-run
Compiling no_listing_06_no_destruct_compile_fails v0.1.0 (listings/ch04-understanding-ownership/no_listing_06_no_destruct_compile_fails/Scarb.toml)
error: Variable not dropped.
--> listings/ch04-understanding-ownership/no_listing_06_no_destruct_compile_fails/src/lib.cairo:9:5
A { dict: Default::default() };
^****************************^
note: Trait has no implementation in context: core::traits::Drop::<no_listing_06_no_destruct_compile_fails::A>.
note: Trait has no implementation in context: core::traits::Destruct::<no_listing_06_no_destruct_compile_fails::A>.
error: could not compile `no_listing_06_no_destruct_compile_fails` due to previous error
error: `scarb metadata` exited with error
When A
goes out of scope, it can't be dropped as it implements neither the Drop
(as it contains a dictionary and can't derive(Drop)
) nor the Destruct
trait. To fix this, we can derive the Destruct
trait implementation for the A
type:
use core::dict::Felt252Dict;
#[derive(Destruct)]
struct A {
dict: Felt252Dict<u128>
}
fn main() {
A { dict: Default::default() }; // No error here
}
Now, when A
goes out of scope, its dictionary will be automatically squashed
, and the program will compile.
Copy Array Data with clone
If we do want to deeply copy the data of an Array
, we can use a common method called clone
. We’ll discuss method syntax in a dedicated section in Chapter 5, but because methods are a common feature in many programming languages, you’ve probably seen them before.
Here’s an example of the clone
method in action.
fn main() {
let arr1: Array<u128> = array![];
let arr2 = arr1.clone();
}
When you see a call to clone
, you know that some arbitrary code is being executed and that code may be expensive. It’s a visual indicator that something different is going on.
In this case, the value arr1
refers to is being copied, resulting in new memory cells being used, and a new variable arr2
is created, referring to the new copied value.
Return Values and Scope
Returning values is equivalent to moving them. Listing 4-2 shows an example of a function that returns some value, with similar annotations as those in Listing 4-1.
Filename: src/lib.cairo
#[derive(Drop)]
struct A {}
fn main() {
let a1 = gives_ownership(); // gives_ownership moves its return
// value into a1
let a2 = A {}; // a2 comes into scope
let a3 = takes_and_gives_back(a2); // a2 is moved into
// takes_and_gives_back, which also
// moves its return value into a3
} // Here, a3 goes out of scope and is dropped. a2 was moved, so nothing
// happens. a1 goes out of scope and is dropped.
fn gives_ownership() -> A { // gives_ownership will move its
// return value into the function
// that calls it
let some_a = A {}; // some_a comes into scope
some_a // some_a is returned and
// moves ownership to the calling
// function
}
// This function takes an instance some_a of A and returns it
fn takes_and_gives_back(some_a: A) -> A { // some_a comes into scope
some_a // some_a is returned and
// moves ownership to the calling
// function
}
While this works, moving into and out of every function is a bit tedious. What if we want to let a function use a value but not move the value? It’s quite annoying that anything we pass in also needs to be passed back if we want to use it again, in addition to any data resulting from the body of the function that we might want to return as well.
Cairo does let us return multiple values using a tuple, as shown in Listing 4-3.
Filename: src/lib.cairo
fn main() {
let arr1: Array<u128> = array![];
let (arr2, len) = calculate_length(arr1);
}
fn calculate_length(arr: Array<u128>) -> (Array<u128>, usize) {
let length = arr.len(); // len() returns the length of an array
(arr, length)
}
But this is too much ceremony and a lot of work for a concept that should be common. Luckily for us, Cairo has two features for passing a value without destroying or moving it, called references and snapshots.