cft
Become a CreatorSign inGet Started

Rust structs - How well are you using it?

We will learn several ways to build a struct instance and use them in Rust


user

Hoon Wee

3 months ago | 9 min read
Follow

rust-structs-using-it-zqm3b

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;

}

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.

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.

/* Fix by using & */

let harry_name = &harry.name;

let another_name = &harry.name;

  • Rust has a special rule called 'ownership', which means that if the value doesn't implement Copy trait,the value moves to new variable.
    • You can fix this with using & operator by borrowing the value, yet the better way is make a getter method.

/* 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();

  • 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 customizingwhich 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);

}

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.

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());

  • 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.

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}");

}

  • Defining the method gives you whole another power of control. You can choose whether to return the borrowed value or owned value.

Cons: Verbose

  • Bunch of codes to write!
    • If you have 3 fields in your struct, then you will have to write3 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

}

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();

}

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();

}

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 thequote above. More you write your code, you provide better developer experience foryou and others who use your crate. It's completely up to you, but here I give yousome 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 :)

Upvote


user
Created by

Hoon Wee

Follow

Freelance Typescript & Rust developer

Hello! I am a freelance web developer from South Korea. I write stuffs about programming in general, but mostly about web.


people
Post

Upvote

Downvote

Comment

Bookmark

Share


Related Articles