Structs

Dear Computer

Chapter 10: Everything Revisited

Structs

Rust structs are like tuples in that they are homogeneous and defined statically, but their fields are named. They are to Rust what classes are to Java and C++: the primary means of building abstractions. But they do not put data and code together like a class does. Nor do they support inheritance.

A new struct is defined as a set of keys and value types. This struct models a zoo animal:

Rust
struct Animal {
  name: String,
  binomial: String,
  is_captive_born: bool,
  annual_cost: u32,
  annual_visitors: u32,
}

We create a new instance of a Animal using this syntax:

Rust
let elephant = Animal {
  name: String::from("Hank"),
  binomial: String::from("Elephas maximus"),
  is_captive_born: true,
  annual_cost: 82_000,
  annual_visitors: 1_100_000, 
};

The ordering of the key-value pairs in the initializer doesn't matter. There is no new keyword. In the current version of Rust, all struct instances begin their life on the stack. We will see how to explicitly copy them to the heap when we later discuss memory management. As we will see, we use the heap less often in Rust.

We extract fields from a struct as either lvalues or rvalues using the . operator:

Rust
println!("{} ({})", elephant.name, elephant.binomial);
elephant.annual_cost += 1000;  // compile error

Assigning to these fields is legal only if the receiver is mutable. Variable elephant is not, so this code does not compile.

As with JavaScript objects and Haskell records, we may destructure a struct to bind its fields to new local variables:

Rust
let Animal { name, binomial, .. } = elephant;
println!("{} ({})", name, binomial);

To extract only a subset of the fields, as is done here, an explicit .. token is needed to discard the rest.

Methods are not defined in a Rust struct as they are in Java class. Rather, they are defined separately in an impl or implementation block. This block adds a cost_per_visitor method to all instances of Animal:

Rust
struct Animal {
  // ...
}

impl Animal {
  fn cost_per_visitor(&self) -> f64 {
    self.annual_cost as f64 / self.annual_visitors as f64
  }

  // other methods...
}

The receiver of the method is explicitly listed as the first parameter. It must be named self if the method is to be considered an instance method. The type of self is usually omitted, but we could write the formal parameter as either self: &Self or self: &Animal. Either way, it is an immutable reference. The compiler replaces the Self token with the type of the impl block.

If a method mutates the fields of a struct, then the receiver must be declared as &mut self instead of &self. This qualifier is used by the compiler to determine if the method may be called on a receiver. If the receiver is not declared mutable, a call to a &mut self method won't compile.

Methods in an impl block that don't have self as their first parameter are associated functions. These are the equivalent of static methods in classes. They are tied to the overall type, but not a particular instance. This associated function concatenates a genus and species together to form a binomial:

Rust
impl Animal {
  fn make_binomial(genus: &str, species: &str) -> String {
    format!("{} {}", genus, species)
  }
}

Calls to associated functions are qualified by the struct type, and the type and function name are separated by :: rather than .:

Rust
let binomial = Animal::make_binomial("Macropus", "rufus");
println!("binomial: {:?}", binomial);  // prints "Macropus rufus"

Associated functions are often used to write constructors. This associated function constructs a new instance of Animal but uses Self as its return type:

Rust
impl Animal {
  fn blank() -> Self {
    Animal {
      name: String::from(""),
      binomial: String::from(""),
      is_captive_born: false,
      annual_cost: 0,
      annual_visitors: 0,
    }
  }

  // Others: from_json, from_file, ...
}

This isn't an official constructor as Rust doesn't have a notion of constructors beyond the struct initialization syntax. Many struct writers create an associated function named new to act as a default constructor. But others may exist.

The advantage of associated constructor functions over overloaded constructors found in Java and C++ is that each function may be given a meaningful name that describes how or from what data a new instance is built. For example, a LinearFunction class can have constructors with the meaningful names from_slope_intercept, from_two_points, and from_point_slope. In Java, all constructors would be labeled with the vague name LinearFunction.

← TuplesEnums →