phillip england

[structs traits and enums in rust]

written 12/15/2024

how to model data in rust

Sandbox

Here is the repo where I coded around to get a bit familiar with these concepts.

Overview

The concepts discussed in this article have to do with modeling data in Rust. We will explore Structs, Enums, and Traits, all of which give us ways to define data and group behaviour amongst types.

I used chatGPT to walk me through these topics.

Structs

Structs are a way to group related data, not much different than any other language which uses structs. One thing I noticed while reading the book is that if you try to make their field of a struct a reference, then a lifetime will be required. This does not mean we cannot use shared data within a struct, but if we do use shared data within a struct, we will need to annotate the lifetime.

You have multiple types of structs:

  • classic structs
  • unit structs
  • tuple structs

Each other these have their own use-case.

Classic Structs

The name pretty much defines them. They are structs in the way you would normally think about in other languages. Like so:

1#[derive(Debug)]
2struct User {
3    username: String,
4    email: String,
5    age: u32,
6    active: bool,
7}

I include the #[derive(Debug)] derive macro, which is a construct I need to learn more about. However, I do know it allows us to print out our type like so:

1fn using_classic_structs() {
2    let user = User {
3        username: String::from("alice"),
4        email: String::from("alice@gmail.com"),
5        age: 30,
6        active: true,
7    };
8    println!("{:?}", user);
9}

Unit Structs

Unit struct are struct with a specified type, but no real underlying data. Here is an example of two unit structs which are used as options:

1#[derive(Debug)]
2struct ReadOnly;
3#[derive(Debug)]
4struct WriteOnly;
5
6fn using_file_modes<T: Debug>(_mode: T) {
7    println!("{:?}", _mode);
8}

We can then make use of the options like:

1using_file_modes(ReadOnly{});
2using_file_modes(WriteOnly{});

Take note: the above function makes use of a trait bound <T: Debug> which ensures any type passed into the function as _mode: T has the Debug derive macro enabled on itself.

I also learned the unit struct can be used as a singleton. For example:

 1struct Logger;
 2impl Logger {
 3    fn log(&self, message: &str) {
 4        println!("Log: {}", message);
 5    }
 6}
 7fn using_singletons() {
 8    let logger = Logger{};
 9    logger.log("I am using a singleton!");
10}

Tuple Structs

Tuple structs are when you have a set of unnamed data whose relationships are obvious, for example:

1#[derive(Debug)]
2struct Color(u8, u8, u8);

In this instance, we know the values are r, b, g, so there is no need to name them explicitly.

We make use of the tuple struct like so:

1fn using_tuple_structs() {
2    let color = Color(255, 0, 0);
3    println!("{:?}", color);
4}

Traits

Traits are behaviours that you can associate with a type. For example, here, we have a trait called "Describe" which has it's own default implementation:

1trait Describe {
2    fn describe(&self) -> String {
3        String::from("this is an object with no specific description")
4    }
5}

We can then implement this trait on different types:

 1struct Animal {
 2    name: String,
 3}
 4
 5struct Vehicle {
 6    model: String,
 7}
 8
 9struct Unknown;
10
11impl Describe for Animal {
12    fn describe(&self) -> String {
13        format!("This is an animal named: {}", self.name)
14    }
15}
16
17impl Describe for Vehicle {
18    fn describe(&self) -> String {
19        format!("This is a vehicle with the model: {}", self.model)
20    }
21}
22
23impl Describe for Unknown {} // uses the default implementation

To make use of these traits in a function:

 1fn using_traits_to_describe() {
 2    let animal = Animal{
 3        name: String::from("Tiger John"),
 4    };
 5    println!("{}", animal.describe());
 6    let car = Vehicle{
 7        model: String::from("Honda"),
 8    };
 9    println!("{}", car.describe());
10    let unknown = Unknown{};
11    println!("{}", unknown.describe());
12}

Enums

Enums allow us to define a type which may be one of many different variants. For example, here is an enum named, Message:

1enum Message {
2    Quit,
3    Move { x: i32, y: i32 },
4    Write(String),
5    ChangeColor(u8, u8, u8),
6}

When we use an enum, we match it against all of it's possible variants. Then depending on which variant we get, we perform some actions. For example, here is a function which take sin a Message and prints out a statement depending on which variant we get:

1fn using_enum_with_match_arm(message: Message) {
2    match message {
3        Message::Quit => println!("quitting!"),
4        Message::Move { x, y } => println!("Move to ({}, {})", x, y),
5        Message::Write(text) => println!("Write message: {}", text),
6        Message::ChangeColor(r, b, g) => println!("Change color to RBG({}, {}, {})", r, b, g),
7    }
8}

Notice, each variant can have it's own unique set of data. The variants do not have to share data between each other. This is what makes enums unique. Also, the match statements must cover possible outcome.

When we want to call the above function, we can do so like:

1using_enum_with_match_arm(Message::ChangeColor(222, 222, 201));