Back to the basics - Data structures

Until now, we've used the read_string() function to read a single data item. In this chapter, we'll explore more complex data structures.

There are many cases where you need to work on multiple data elements that somehow belong together. Think if an address, that consists of: the street, house number, postal code and city. Also, names are typically split in a first name and last name.

In Rust this type of data can be grouped in a struct. Like so:

struct Address {
    street: String,
    house_number: i16,
    postal_code: String,
    city: String
}

Like variables, field names are defined in snake_case. The name of the structure is defined in PascalCase. This means that the name is written without spaces, using capital letters to separate words.

We'll extend our example to ask for both the first- and the last-name.

struct Person {
    first_name: String,
    last_name: String,
}

fn main() {
    println!("What is your first name?");
    let first_name = read_string();
    println!("What is your last name?");
    let last_name = read_string();
    let person = Person {
        first_name: first_name,
        last_name: last_name,
    };
    print_person(&person);
}

fn read_string() -> String {
    let mut input = String::new();
    std::io::stdin()
        .read_line(&mut input)
        .expect("can not read user input");
    let cleaned_input = input.trim().to_string();
    cleaned_input
}

fn print_person(person: &Person) {
    println!("Hello {} {}", person.first_name, person.last_name);
}

Exercise

Run cargo clippy and see if there are any recommendations for this code. Implement the recommendations until Clippy is happy.

Although it is not strictly needed, it is a convention to write data definitions, like the Person structure at the top of your code.

We can access individual fields of a struct with the dot . separator, as you can see in the print_person function.

Note that we lend our person to the print_person function with the & operator. The & must be added to the signature of the print_person function which os taking a &Person as input, as well as the calling function that is passing &person.

Exercise

Add an "age" field to your person and ask the user for their age. Change the print_person function to write "You are {} years old." after the greeting.

Adding an associated function

Remember that when we looked at the String type there was an associated function new(), that we used to construct a new String. We can do the same for type Person type.

Rewrite the example like this:

struct Person {
    first_name: String,
    last_name: String,
}

impl Person {
    fn new(first_name: String, last_name: String) -> Person {
        Person {
            first_name,
            last_name,
        }
    }
}

fn main() {
    println!("What is your first name?");
    let first_name = read_string();
    println!("What is your last name?");
    let last_name = read_string();
    let person = Person::new(first_name, last_name);
    print_person(&person);
}

fn read_string() -> String {
    let mut input = String::new();
    std::io::stdin()
        .read_line(&mut input)
        .expect("can not read user input");
    let cleaned_input = input.trim().to_string();
    cleaned_input
}

fn print_person(person: &Person) {
    println!("Hello {} {}", person.first_name, person.last_name);
}

The impl Person { } block is used to define associated functions, or methods that belong to the Person type. We've added a new() function that takes two input parameters: first_name and last_name and returns a new Person object.

Adding a method

You can add methods in the same impl block. The only difference between the associated function and a method is in the type signature. A method always takes &self as the first input parameter. We can re-write the example to change print_person into a print method on the Person type:

struct Person {
    first_name: String,
    last_name: String,
}

impl Person {
    fn new(first_name: String, last_name: String) -> Person {
        Person {
            first_name,
            last_name,
        }
    }

    fn print(&self) {
        println!("Hello {} {}", self.first_name, self.last_name);
    }
}

fn main() {
    println!("What is your first name?");
    let first_name = read_string();
    println!("What is your last name?");
    let last_name = read_string();
    let person = Person::new(first_name, last_name);
    person.print();
}

fn read_string() -> String {
    let mut input = String::new();
    std::io::stdin()
        .read_line(&mut input)
        .expect("can not read user input");
    let cleaned_input = input.trim().to_string();
    cleaned_input
}

Note the use of self.first_name and self.last_name in the print method to access the fields of the Person. We've also changed print_person(&person) from the previous example to person.print().

Exercise

Try to add an associated function new_from_input that reads first- and last-name from input and constructs a new Person with this data. All the needed instructions can be found in the above example, within the main() and Person::new() functions.

A Rust struct can embed other structures. This can be useful when you want to add an address to the Person. Like so:

struct Address {
    street: String,
    house_number: i16,
    postal_code: String,
    city: String
}

struct Person {
    first_name: String,
    last_name: String,
    address: Address
}