Keeping track of scopes and lifetimes
In Python, we do have the concept of scope. It is generally enforced in functions. For instance, we can call the Python function defined here:
def add_and_square(one: int, two: int) -> int: total: int = one + two return total * total
In this case, we can access the return variable. However, we will not be able to access the total
variable. Outside of this, most of the variables are accessible throughout the program. With Rust, it is different. Like typing, Rust is aggressive with scopes. Once a variable is passed into a scope, it is deleted when the scope is finished. Rust manages to maintain memory safety without garbage collection with the borrowing rules. Rust deletes its variables without garbage collection by wiping all variables out of scope. It can also define scopes with curly brackets. A classic way of demonstrating scopes can be done by the following code:
fn main() { let one: String = String::from("one"); // start of the inner-scope { println!("{}", &one); let two: String = String::from("two"); } // end of the inner-scope println!("{}", one); println!("{}", two); }
If we try and run this code, we get the error code defined here:
println!("{}", two); ^^^ not found in this scope
We can see that the variable one
can be accessed in the inner-scope as it was defined outside the outer-scope. However, the variable two
is defined in the inner-scope. Once the inner-scope is finished, we can see by the error that we cannot access the variable two
outside the inner-scope. We must remember that the scope of functions is a little stronger. From revising borrowing rules, we know that when we move a variable into the scope of a function, it cannot be accessed outside of the scope of the function if the variable is not borrowed as it is moved. However, we can still alter a variable inside another scope like another function, and still then access the changed variable. To do this, we must do a mutable borrow, and then must dereference (using *
) the borrowed mutable variable, alter the variable, and then access the altered variable outside the function, as we can see with the following code:
fn alter_number(number: &mut i8) { *number += 1 } fn print_number(number: i8) { println!("print function scope: {}", number); } fn main() { let mut one: i8 = 1; print_number(one); alter_number(&mut one); println!("main scope: {}", one); }
This gives us the following output:
print function scope: 1 main scope: 2
With this, we can see that that if we are comfortable with our borrowing, we can be flexible and safe with our variables. Now that we have explored the concept of scopes, this leads naturally to lifetimes, as lifetimes can be defined by scopes. Remember that a borrow is not sole ownership. Because of this, there is a risk that we could reference a variable that's deleted. This can be demonstrated in the following classic demonstration of a lifetime:
fn main() { let one; { let two: i8 = 2; one = &two; } // -----------------------> two lifetime stops here println!("r: {}", one); }
Running this code gives us the following error:
one = &two; ^^^^ borrowed value does not live long enough } // -----------------------> two lifetime stops here - 'two' dropped here while still borrowed println!("r: {}", one); --- borrow later used here
What has happened here is that we state that there is a variable called one
. We then define an inner-scope. Inside this scope, we define an integer two
. We then assign one
to be a reference of two
. When we try and print one
in the outer-scope, we can't, as the variable it is pointing to has been deleted. Therefore, we no longer get the issue that the variable is out of scope, it's that the lifetime of the value that the variable is pointing to is no longer available, as it's been deleted. The lifetime of two
is shorter than the lifetime of one
.
While it is great that this is flagged when compiling, Rust does not stop here. This concept also translates functions. Let's say that we build a function that references two integers, compares them, and returns the highest integer reference. The function is an isolated piece of code. In this function, we can denote the lifetimes of the two integers. This is done by using the '
prefix, which is a lifetime notation. The names of the notations can be anything you wish, but it's a general convention to use a
, b
, c
, and so on. Let's look at an example:
fn get_highest<'a>(first_number: &'a i8, second_number: &'\ a i8) -> &'a i8 { if first_number > second_number { return first_number } else { return second_number } } fn main() { let one: i8 = 1; { let two: i8 = 2; let outcome: &i8 = get_highest(&one, &two); println!("{}", outcome); } }
As we can see, the first_number
and second_number
variables have the same lifetime notation of a
. This means that they have the same lifetimes. We also have to note that the get_highest
function returns an i8
with a lifetime of a
. As a result, both first_number
and second_number
variables can be returned, which means that we cannot use the outcome
variable outside of the inner-scope. However, we know that our lifetimes between the variables one
and two
are different. If we want to utilize the outcome
variable outside of the inner-scope, we must tell the function that there are two different lifetimes. We can see the definition and implementation here:
fn get_highest<'a, 'b>(first_number: &'a i8, second_ \ number: &'b i8) -> &'a i8 { if first_number > second_number { return first_number } else { return &0 } } fn main() { let one: i8 = 1; let outcome: &i8; { let two: i8 = 2; outcome = get_highest(&one, &two); } println!("{}", outcome); }
Again, the lifetime a
is returned. Therefore, the parameter with the lifetime b
can be defined in the inner-scope as we are not returning it in the function. Considering this, we can see that lifetimes are not exactly essential. We can write comprehensive programs without touching lifetimes. However, they are an extra tool. We don't have to let scopes completely constrain us with lifetimes.
We are now at the final stages of knowing enough Rust to be productive Rust developers. All we need to understand now is building structs and managing them with macros. Once this is done, we can move onto the next chapter of structuring Rust programs. In the next section, we will cover the building of structs.