DEV Community

Cover image for Rust structs - How well are you using it?
Hoon Wee
Hoon Wee

Posted on • Updated on

Rust structs - How well are you using it?

How well are you using structs?

There are several ways to use structs, and we will see some of them and analyze the pros and cons.

Method 1: Public struct with public fields

For public struct with public fields, the easiest way to create struct instance is to do like StructName { field1: .., field2: .. }.

struct Student<'a> {
    name: String,
    age: i32,
    friends: Vec<&'a str>
}

fn main() {
    let harry = Student {
        name: "Harry Potter".to_string(),
        age: 12,
        name: vec!["Ron Wealsey"],
    };

    /* using '.' operator */
    let _harry_name = harry.name;
    let _harry_age = harry.age;
    let _harry_friends = harry.friends;

    /* struct destructuring */
    let Student {
        _harry_name,
        _harry_age,
        _harry_friends
    } = harry;
}
Enter fullscreen mode Exit fullscreen mode

Pros: Super Simple

  • Super simple to define struct.
    • You don't need to create any other methods to get and set the field data.
  • Getting the field value out of the struct is very easy.
    • You can use . operator to take the field value out. (ex) harry.name
    • You can also use struct destructuring by StructName { field1, field2, .. }.

Cons: Too easy to access

  • There's no private data for this struct.
    • If you need any encapsulation for these data fields, this method is not for you.
  • Getting a field like this will give a owned value, which is normally unwanted for most of the case.

    • Rust has a special rule called 'ownership', which means that if the value doesn't implement Copy trait, the value moves to new variable.
  let harry = Student {
      name: "Harry Potter".to_string(), // This field is `String` type, which doesn't implement a `Copy` trait.
      age: 12,
      name: vec!["Ron Wealsey"],
  }

  let harry_name = harry.name; // `harry.name` is moved into a variable `harry_name`
  let another_name = harry.name; // COMPILE ERROR, since now `harry` doesn't have a value of `name` field.
Enter fullscreen mode Exit fullscreen mode
  • You can fix this with using & operator by borrowing the value, yet the better way is make a getter method.
  let harry_name = &harry.name;
  let another_name = &harry.name;
Enter fullscreen mode Exit fullscreen mode
  • It can be dangerous to allow both reading and writing by just getting field value with . operator.
  /* Reading the value */
  let harry_name = harry.name;

  /* By adding a `mut` keyword, you can write the value of the field */
  let mut write_harry_name = harry.name;
  write_harry_name = "James Potter".to_string();
Enter fullscreen mode Exit fullscreen mode

The only difference that separates from reading to writing the field value is whether there's a mut keyword or not, which is prone to mistakes.
For simple programs this would be no problem, but still it's important not to make situations that accidentally writes a value in context where we shouldn't.

Method 2: Using Getter and Setter

The method above is the simplest, yet it's maybe too publicly accessible. You may want to encapsulate the struct by customizing
which field can be read or written from public context. The most common way to accomplish this is to make a getter and setter methods.

You can implement the methods for structs using impl.

/* This struct is public, yet it's fields are private */
struct Student<'a> {
    name: String,
    age: i32,
    friends: Vec<&'a str>,
}

impl<'a> Student<'a> {
    pub fn name(&self) -> &str {
        &self.name
    }
    pub fn age(&self) -> i32 {
        self.age
    }
    pub fn friends(&self) -> &[&str] {
        self.friends.as_slice()
    }
}

fn main() {
    let harry = Student {
        name: "Harry Potter".to_string(),
        age: 12,
        friends: vec!["Ron Weasley", "Hermione Granger"],
    };

    let harry_name = harry.name();
    let harry_age = harry.age();
    let harry_friends = harry.friends();

    println!("{harry_name}, {harry_age}, {:?}", harry_friends);
}
Enter fullscreen mode Exit fullscreen mode

Pros: Nicely encapsulated

  • No we have some level of encapsulation.
    • With the code like above, we provide only two functionalities - building a struct instance, and reading each fields. Our code won't let others to set different value in the field.
  • Segregating the role for read/writing delivers better understanding while reading the code.

    • By getting the value with method, it will give a better understanding whether this is reading or writing it. While in the public struct and public fields method, it was hard to know whether the code is reading or writing the value.
  let harry_name = harry.name;

  // This is easier to understand the intention of the code.
  let harry_name = harry.name();
  let harry_name = harry.set_name("Harry".to_string());
Enter fullscreen mode Exit fullscreen mode
  • You can choose a custom type for getting a field value.

    • Defining the method gives you whole another power of control. You can choose whether to return the borrowed value or owned value.
  impl Student {
      // Returning the borrowed value - this will live until the struct instance is alive
      fn borrowed_name(&self) -> &str {
          &self.name
      }
      // Returning the owned and cloned value - this will live as long as it's now moved.
      fn owned_name(&self) -> String {
          self.name.clone()
      }
  }

  fn main() {
      let harry = Student {
          name: "Harry potter".to_string(),
      };
      let borrowed_name = harry.borrowed_name();
      let owned_name = harry.owned_name();

      println!("{borrowed_name}, {owned_name}");

      drop(harry); // now you can't access to borrowed value

      println!("{owned_name}");
  }
Enter fullscreen mode Exit fullscreen mode

Cons: Verbose

  • Bunch of codes to write!
    • If you have 3 fields in your struct, then you will have to write 3 getter methods and 3 setter methods if you want to make a full Read/Write API. And that can be a burden for the developer.

For the lazy developers - use getset

If you are too lazy for writing all those methods, try using getset crate.
It provides several macros which is really easy and intuitive to use.

Here's a quick example how to use it.

use getset::{Getters, Setters};

#[derive(Getters, Setters)]
struct Student {
    #[getset(get, set)]
    name: String,
}

fn main() {
    let mut harry = Student {
        name: "Harry Potter".to_string(),
    };

    let _get_name = harry.name(); // returns &String type
    let _set_name = harry.set_name("Harry".to_string()); // returns &mut Student type
}
Enter fullscreen mode Exit fullscreen mode

Method 3: Builder Pattern

For those who have studied enough about object-oriented programming, you might have heard of Design Patterns.
Simply put, it's a collections of idiomatic solution for solving problems in OOP project.
From one of the design patterns, there's a builder pattern, which you build a struct instance by setting the field values step-by-step.

Ok...talk is cheap, I'll show you the code. Here's how you implement builder pattern in Rust.

struct Student<'a> {
    name: String,
    age: i32,
    friends: Vec<&'a str>,
}

impl<'a> Student<'a> {
    pub fn builder() -> StudentBuilder<'a> {
        StudentBuilder::new()
    }
    // This can be used after the building process is complete
    pub fn name(&self) -> &str {
        &self.name
    }
}

struct StudentBuilder<'a> {
    name: String,
    age: i32,
    friends: Vec<&'a str>,
}

impl<'a> StudentBuilder<'a> {
    pub fn new() -> Self {
        Self {
            name: "".to_string(),
            age: 0,
            friends: vec![],
        }
    }
    pub fn name(self, name: String) -> Self {
        // `..self` is a syntax sugar for `age: self.age, friends: self.friends`
        Self { name, ..self }
    }
    pub fn age(self, age: i32) -> Self {
        Self { age, ..self }
    }
    pub fn friends(self, friends: Vec<&'a str>) -> Self {
        Self { friends, ..self }
    }
    pub fn build(self) -> Student<'a> {
        let StudentBuilder { name, age, friends } = self;
        Student { name, age, friends }
    }
}

fn main() {
    let harry = Student::builder()
        .name("Harry Potter".to_string())
        .age(12)
        .friends(vec!["Ron Weasley", "Hermione Granger"])
        .build();
}
Enter fullscreen mode Exit fullscreen mode

With builder pattern, we use intermediary type called StudentBuilder(or it should be any [StructName]Builder) to assign field types step-by-step.

Pros: Complete Segregation

  • It provides a segragation between intializer and getter/setter.
    • Initializer is a special kind of method - it doesn't use any self data, yet it creates them. The role of the initializer and the setter methods should not be confused, while the latter is just a mutator for the previously initialized value.
  • By using intermediary Builder struct, we restrict the access of struct fields until it's intialized.
    • Before finalizing the construct of Student struct with build() method, it doesn't give you an exact Student struct, so we cannot use any getters and setters. This will prevent our code from making errors of using unset values.

Cons: Even longer code

  • Implementing a builder pattern gives a better encapsulation than Method 2, which results in creating more code as a trade-off.
  • We have to set all the field values in single chain like builder().field_one(val).field_two(val).field_three(val).build(), which can be restrictive because there might be some situations that we cannot afford all the field values beforehand.
    • This can be solved with implementing ergonomic version.

Ergonomic Builder Pattern

The restrictiveness of 2nd problem we had in builder pattern can be solved by providing mutability in builder methods.
The implementation code should be changed like this.

struct Student<'a> {
    name: String,
    age: i32,
    friends: Vec<&'a str>,
}

impl<'a> Student<'a> {
    pub fn builder() -> StudentBuilder<'a> {
        StudentBuilder::new()
    }
    pub fn name(&self) -> &str {
        &self.name
    }
}

struct StudentBuilder<'a> {
    name: String,
    age: i32,
    friends: Vec<&'a str>,
}

impl<'a> StudentBuilder<'a> {
    pub fn new() -> Self {
        Self {
            name: "".to_string(),
            age: 0,
            friends: vec![],
        }
    }
    pub fn name(&mut self, name: String) {
        self.name = name;
    }
    pub fn age(&mut self, age: i32) {
        self.age = age;
    }
    pub fn friends(&mut self, friends: Vec<&'a str>) {
        self.friends = friends;
    }
    pub fn build(self) -> Student<'a> {
        let StudentBuilder { name, age, friends } = self;
        Student { name, age, friends }
    }
}

fn main() {
    let mut harry_builder = Student::builder();

    let name = "Harry Potter".to_string();
    harry_builder.name(name);

    let age = 12;
    harry_builder.age(age);

    let friends = vec!["Ron Weasley", "Hermione Granger"];
    harry_builder.friends(friends);

    let harry = harry_builder.build();
}
Enter fullscreen mode Exit fullscreen mode

This way, we can place the code for setting the field values in place where they are affordable.
This small fix hasn't even increased any code lengths, so that's super-awesome!

For the detailed explanation for builder patterns in Rust, find it here.

Conclusions

So, what method should we choose for our project? It's hard to answer, but there's an old wisdom for any kind of craftsmanship.

" The more difficult for the maker, the better for the user. "

Three methods that we've seen in this article shows the best example for the
quote above. More you write your code, you provide better developer experience for
you and others who use your crate. It's completely up to you, but here I give you
some obvious recommendations.

  • For projects which needs rapid development, go for the easiest first, and then the more difficult one when refactoring.
  • For projects that should have solid foundations from the beginning,
    • Implement getter/setter or builder pattern by your own, if you have a lot of time.
    • Or use helper crates like getset to quickly implement getter/setter, if you have less time to finish.

One thing you should remember is that using getset is great, but for better customization (like configuring the return types of methods) , you should implement it by yourself.

I hope this article will guide you feel lost when using struct in Rust. I'll come back with more Rust-related posts!

Until then, happy coding :)

Discussion (0)