Ruby

Rust Design Considerations with Borrowing

Rust has a fairly unique way of handling memory-freeing by implementing an ownership system. Each code item you create in Rust is assigned ownership/lifetime at the time of its creation. From that point on, you may choose to let the item be borrowed by things further on within the same scope or allow the ownership to be consumed (taken over) by something along the way.

Beyond ownership, we have a couple of ways we may duplicate the item through the Copy, Clone, and Cow trait types. How we choose to implement ownership throughout our code base should have at least some consideration for our overall goals and costs when it comes to performance, readability, and capability.

We’ll cover the basics of ownership and discuss the pros and cons of creating a library that uses borrowing specifically in Rust.

Basics in Ownership

{
  let x = "hello";
  println!("{} world", x);
}

let in Rust is dual-purposed, providing value assignment and optionally pattern-matching (which won’t be covered in this post). The scope demonstrated above starts with a curly brace and ends with the curly brace. Since the last line within the scope ends with a semicolon, the return type is (), which is probably best compared to having no return type and is much like C’s void type.

The x value in this scope is given the ownership and lifetime of the scope it’s in. If we were to pass x to an inner scope or method that takes ownership of x, then x would no longer be available later in its original scope.

{
  let x = "hello".to_string();

  let greet_friend = |y: String| println!("{} friend", y);
  greet_friend(x);

  println!("{} world", x);
}

This raises an error for us when we try to run it because the closure called greet_friend takes ownership of the item it receives as a parameter and is not available as the last command. Here’s the error output:

error[E0382]: use of moved value: `x`
 --> src/main.rs:8:20
  |
6 |     greet_friend(x);
  |                  - value moved here
7 | 
8 |     println!("{}", x);
  |                    ^ value used here after move
  |
  = note: move occurs because `x` has type `std::string::String`,
          which does not implement the `Copy` trait

Here we’re given a clue about how Rust handles items that implement the Copy trait on them; it would automatically conduct a copy of the item with the Copy trait, and we wouldn’t have to concern ourselves with the ownership as the entire scope would always own a copy should it need it. This can be seen by the internal numerical types in Rust as they already implement the trait Copy on them.

{
  let x: u32 = 1;
    
  let greet_friend = |y: u32| println!("{} friend", y);
  greet_friend(x);

  println!("{} world", x);
}

And this outputs:

1 friend
1 world

If we wanted our earlier example to work even with the closure greet_friend taking ownership of its input, we can do so by using the clone method greet_friend(x.clone());, and that would give us:

hello friend
hello world

And for return values in a scope, it is the value returned that is then given ownership to the outer scope, not the variable name in the inner scope which it was originally assigned.

let y = {
  let x = "hello".to_string();
  x
};

// `x` is not available in this scope.
// It's value, and ownership of it, were
// given to `y` in the outerscope. `y`'s
// lifetime begins at that point.

println!("{} neighbor", y);
// Outputs:
// hello neighbor

Copy Versus Clone

Copy can only exist in items that have a known fixed size at compile time. This excludes dynamically sized things such as Vec and String. Also if you create a struct and everything in it has the Copy trait, you can derive Copy on your struct and it will work the same way. If any of the items in the struct don’t have the trait Copy, then you’ll need to use Clone as a substitute.

#[derive(Clone,Copy)]
struct Point {
  x: u32,
  y: u32,
}
    
fn main() {
  let a = Point { x: 124, y: 136 };
  
  let say_x = |v: Point| println!("x is {}", v.x);
  say_x(a);
  
  println!("y is {}", a.y);
}

This outputs:

x is 124
y is 136

A copy is automatically performed at say_x() because the Point type has the trait Copy implemented on it, say_x takes ownership of its parameter, and the Point is used later in the scope. If you don’t derive Copy or you use types in your struct like a Vec or a String, then you’ll have to use the clone method in say_x(a.clone());. The other way around that is to use borrowing in the closure rather than taking ownership of the parameter.

!Sign up for a free Codeship Account

Borrowing

A great way to worry less about using Copy or Clone within your code blocks is to use borrowing for ownership. Borrowing should be less expensive when it comes to memory, as you’re not instantiating the use of more system memory. But with it, you have situations that become simpler and many that become more complex.

In the last example, you don’t need to derive Copy or Clone at all if you implement the say_x closure to only borrow a Point from the current scope; the value will remain in that scope once the say_x method is done with what it’s designed to do.

fn main() {
  let a = Point { x: 124, y: 136 };
  
  let say_x = |v: &Point| println!("x is {}", v.x);
  say_x(&a);
  
  println!("y is {}", a.y);
}

Here the ampersand & on the closure definition is saying that we’ll be borrowing ownership to the Point provided. When we then pass the Point to it, we precede it with an ampersand there as well. The ownership remains within the scope it was defined and we don’t get any errors when a is used again at the end of the code block.

And that’s the simple case for borrowing.

When we start using borrowing within types like struct, then things start getting much more complex. The reason for this is that we’re defining our own type of thing to hold a borrow for something else that doesn’t exist yet (not until instantiated).

Since the thing we’re going to be referring to doesn’t exist yet, the lifetime for that borrowed thing is unclear. Rust needs us to clarify the lifetimes by giving some extra input on our type and letting it know how to associate the length of the lifetime(s). Lifetimes are descriptive, not prescriptive.

I found this out in great detail by writing a fairly large Rust library, Digits, and defining a struct type named Digits which borrowed another struct I created from another library named base_custom. Digits is a character sequencer implementing custom numeric bases via a linked list, so the struct definition ended up looking like this:

#[derive(Clone)]
pub struct Digits<'a> {
  mapping: &'a BaseCustom<char>,
  digit: u64,
  left: Option<Box<Digits<'a>>>,
}

The `’a` mark is a lifetime marker. It allows the compiler to not let one item outlive the other by not allowing its lifetime to end prematurely while still being depended upon. With this kind of struct implementation, all of the traits that get implemented for this struct with their method definitions need lifetimes scattered throughout their definition. You end up with complex traits like the `From` conversion trait I wrote with this signature:

impl<'a,'b> From<(Digits<'a>, Digits<'b>)> for Digits<'a> {
  fn from(d: (Digits<'a>, Digits<'b>)) -> Digits<'a> {

Still, once you get used to this you can overlook the extra verbose syntax scattered all over the code, and it doesn’t seem like a bad thing. After four months of building my library in this way, I ran into a limitation of using borrowing for a struct…and that was implementing any kind of defaults.

Consider the following:

impl<'a> Default for Digits<'a> {
  fn default() -> Digits<'a> {
    let base10 = BaseCustom::<char>::new("0123456789".chars().collect());
    Digits::new_zero(&base10)
  }
}

Here we clearly have a problem. The assigned variable base10 isn’t going to live outside the scope of the method default(). So trying to return a new instance of Digits with a borrow of base10 fails.

So scope is the issue here. Why not try using const or static to do a program-wide scoped value, and then borrow that within the Default definition?

If you try that, you’ll discover that const and static require the types to be a known fixed size at compile time and adhere to the same rules as Copy. Since my struct BaseCustom has several internal types that aren’t of the Copy trait, it won’t work for either of these solutions, and we’re stuck with the same borrowing and scope limitation issue.

For my library, I decided that cloning wasn’t too expensive for the tasks I needed to complete, so I rewrote the whole thing to not use borrowing but to take ownership.

And I no longer need to worry about the scope from which my BaseCustom is defined, as it’s not borrowed anymore from that scope but rather ownership is taken of it and returned to any higher scope that uses the default. The code looks a lot easier to read now as well without lifetimes written all over the place.

It’s not impossible to have defaults with a library where the struct borrows for its type. But you’d need to include the lazy_static library and use its macro to make it work. If I had kept with my borrowing implementation, the Default would look more like this:

#[macro_use]
extern crate lazy_static;

lazy_static! {
  static ref BC_BASE10: BaseCustom<char> =
    BaseCustom::<char>::new("0123456789".chars().collect());
}

impl<'a> Default for Digits<'a> {
  fn default() -> Digits<'a> {
    Digits::new_zero(&BC_BASE10)
  }
}

And that’s a perfectly fine solution to get around the generic limitation in Rust of structs using borrowing and creating your own defaults.

Implementation Considerations

If performance is your core goal in your implementation, then you should benchmark alternative ways of implementing your code, whether to clone or borrow. If code clarity is more important, you may choose not to use borrowed types in your struct. But this also depends on the size and costliness of instantiating your struct. The more memory your struct requires, the less feasible using Copy or Clone becomes. For this reason, we have the clone-on-write Cow enum.

Cow is best for situations where the data set you’re using uses a lot of system memory and the need to modify it is rare or negligible. This allows our item to be borrowed everywhere and only perform the copy if we make a change or write to it. This way our system doesn’t spend that much filling up memory space with a bunch of cloned data and minimizes the overall memory footprint.

It’s possible cloning and owning may be faster in your use case, in which case you get the best of both worlds with clean-looking code (no need to define lifetimes). If you can use Copy type, then the code will look even cleaner without .clone() being scattered throughout.

Summary

These are many of the aspects for you to consider when choosing how to write your library in Rust and how to handle memory ownership. In my case, I started out borrowing because I like to err on the side of high performance, but I later changed my mind mainly because this particular code for my application is not where the performance mattered. It’s still fast and my struct types are small and have minimal impact with memory during cloning.

Choose what values are most important as you delve into creating your library. Experiment lightly with the alternatives for your memory handling situation. It may turn out you can have the best of all situations here for your particular data set. If not, you’ll at least have learned how these aspects may affect your design choices.

Published on Web Code Geeks with permission by Daniel P. Clark, partner at our WCG program. See the original article here: Rust Design Considerations with Borrowing

Opinions expressed by Web Code Geeks contributors are their own.

Daniel P. Clark

Daniel P. Clark is a freelance developer, as well as a Ruby and Rust enthusiast. He writes about Ruby on his personal site.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Inline Feedbacks
View all comments
Back to top button