Complex types

So far, we have been using simple types. In our business, we're typically dealing with complex data types. In Rust, we use struct to name and group together multiple values that somehow belong together.

We can adopt our previous example to handle a more complex data type.

struct Person {
    name: String,
    age: u8,
}

fn age_group(person: &Person) -> String {
    if person.age > 150 {
        panic!("age is out of range");
    }

    if person.age < 10 {
        return "child".to_string();
    }

    if person.age >= 18 {
        return "adult".to_string();
    }

    format!("teenager of {} years old", person.age)
}

fn main() {
    let person = Person {
        name: "Marcel".to_string(),
        age: 40,
    };

    let description = age_group(&person);
    println!("{} is a {}", person.name, description);
}

In this example, we've grouped "name" and "age" in a structure called "Person". We've typed age to be of type u8, and name to be a String. We're borrowing person to the age_group function, such that we can still use it later on in our println! statement.

In Rust, struct content is not only limited to types, but you can actually add functionality to a struct type. Let's say we want to add the age_group() function to Person, we can rewrite the example like this:

struct Person {
    name: String,
    age: u8,
}

impl Person {
    fn age_group(&self) -> String {
        if self.age > 150 {
            panic!("age is out of range");
        }

        if self.age < 10 {
            return "child".to_string();
        }

        if self.age >= 18 {
            return "adult".to_string();
        }

        format!("teenager of {} years old", self.age)
    }
}

fn main() {
    let person = Person {
        name: "Marcel".to_string(),
        age: 40,
    };

    println!("{} is a {}", person.name, person.age_group());
}

Notice that the age_group method is borrowing self (&self) in order to reference its own properties.

Functions that take &self as the first parameter, are referred to as methods

For smaller struct types, it may be fine to create the struct directly, like in the above example. Often however, it is a better choice to implement a new function to construct the structure with the mandatory fields:

struct Person {
    name: String,
    age: u8,
}

impl Person {
    fn new(name: String, age: u8) -> Self {
        Person { name, age }
    }

    fn age_group(&self) -> String {
        if self.age > 150 {
            panic!("age is out of range");
        }

        if self.age < 10 {
            return "child".to_string();
        }

        if self.age >= 18 {
            return "adult".to_string();
        }

        format!("teenager of {} years old", self.age)
    }
}

fn main() {
    let person = Person::new("Marcel".to_string(), 40);
    println!("{} is a {}", person.name, person.age_group());
}

Notice that the new function is not referencing &self. Other languages may call the new function a static function. In Rust these types of functions are called associated functions. The function is returning Self which is the same as if it was returning Person.

Also notice the shorthands for age and name in the new() function where Person is constructed.

Destructuring structs

You can destructure a struct into its parts with a simple let statement:

struct Person {
    name: String,
    age: u8,
}

impl Person {
    fn new(name: String, age: u8) -> Self {
        Person { name, age }
    }
}

fn main() {
    let person = Person::new("Marcel".to_string(), 40);
    let Person { name: my_name, age } = person;
    println!("{my_name} is {age} years old");
}

Reference material