Skip to content

Rust Structs, Methods and Associated Functions

A Rust struct groups related values under a meaningful type name. In this lesson, a fixture reading combines its device label and temperature, while methods make it clear which operations inspect, change or consume that reading.

Prerequisites and learning outcome

Complete references, borrowing and slices. You will define named fields, construct a value, write an impl block and choose between &self, &mut self and self. The example uses fixture data, not live temperature measurements.

Model one reading with a Rust struct

A tuple can hold a String and an i32, but names explain their roles more clearly. Our type uses label and millidegrees; it deliberately does not validate a sensor's operating range. A struct groups data, but that alone does not guarantee physically meaningful data or prevent every invalid operation.

cargo new pi_structs
cd pi_structs

Replace src/main.rs with this complete program:

struct Reading {
    label: String,
    millidegrees: i32,
}

impl Reading {
    // Associated function: no receiver, so call it as Reading::new(...).
    fn new(label: String, millidegrees: i32) -> Self {
        Self {
            label,
            millidegrees,
        }
    }

    fn celsius(&self) -> f64 {
        f64::from(self.millidegrees) / 1_000.0
    }

    fn set_millidegrees(&mut self, millidegrees: i32) {
        self.millidegrees = millidegrees;
    }

    fn into_label(self) -> String {
        self.label
    }
}

fn main() {
    let mut reading = Reading::new(String::from("Pi 4B"), 46_700);
    println!("{}: {:.1} C", reading.label, reading.celsius());

    reading.set_millidegrees(48_200);
    println!("updated: {:.1} C", reading.celsius());

    let label = reading.into_label();
    // reading was consumed; the returned label is now owned here.
    println!("owned label: {label}");
}

#[cfg(test)]
mod tests {
    use super::Reading;

    #[test]
    fn named_fields_preserve_the_input() {
        let reading = Reading::new(String::from("Pi"), -500);
        assert_eq!(reading.label, "Pi");
        assert!((reading.celsius() + 0.5).abs() < 1e-9);
    }

    #[test]
    fn mutable_method_updates_the_reading() {
        let mut reading = Reading::new(String::from("Pi"), 0);
        reading.set_millidegrees(1_000);
        assert_eq!(reading.celsius(), 1.0);
    }

    #[test]
    fn consuming_method_returns_owned_text() {
        let reading = Reading::new(String::from("Pi"), 0);
        assert_eq!(reading.into_label(), "Pi");
    }

    #[test]
    fn struct_literal_does_not_require_new() {
        let reading = Reading {
            label: String::from("fixture"),
            millidegrees: 0,
        };
        assert_eq!(reading.celsius(), 0.0);
    }
}
1
2
3
cargo run --quiet
cargo test
cargo fmt --check

Expected output:

1
2
3
Pi 4B: 46.7 C
updated: 48.2 C
owned label: Pi 4B

Fields, literals and ownership

Reading { label, millidegrees } is field-init shorthand: it means Reading { label: label, millidegrees: millidegrees }. The String moves into its field; the i32 is Copy. Access a field through a dot, such as reading.millidegrees.

Rust has no special constructor keyword for new. It is an ordinary associated function named by convention. The literal test constructs a Reading directly. Later, module visibility can restrict construction and force callers through a validating API; our single-module example does not yet do that.

Borrowing an entire struct borrows access to its fields through that reference. Moving a non-Copy field out can leave a partially moved value: you may still access untouched fields in suitable cases, but cannot borrow the whole value as though it were intact. Types with Drop impose additional restrictions, discussed in the resource-management lessons.

The structs chapter covers field access and literals. The structs reference supplies the declaration rules.

Methods declare an access contract

An impl Reading block attaches operations to the type. Self names the implementing type, while lowercase self is the receiver value. An associated function without a receiver uses Reading::function(...); a method is normally called with value.method(...).

Receiver Purpose in this example Caller requirement
&self Inspect temperature A valid shared borrow
&mut self Replace temperature Exclusive mutable access
self Consume Reading and return its label Ownership of the Reading

Method-call syntax can borrow the receiver automatically: reading.celsius() borrows rather than moving Reading. The setter requires a mutable binding in our main program. into_label consumes it, so no subsequent whole-Reading use is allowed. We did not derive Copy or Clone; merely defining methods does not change those rules.

See method syntax and associated items.

Other struct forms

Tuple structs such as struct Millidegrees(i32); give a type identity to positional fields. Unlike a type alias for i32, this is a distinct type, which can help avoid mixing units. Unit-like structs such as struct Marker; have no fields and can represent a marker or a behaviour-bearing type. We return to these choices after traits are introduced.

Struct update syntax, Reading { millidegrees: 50_000, ..old }, fills unspecified fields from old. It is not an implicit clone: a non-Copy label field moves. Prefer explicit construction while learning ownership rather than assuming an update leaves the original struct intact.

Deliberately failing consumed receiver

Use a separate scratch project for this complete example:

struct Reading {
    label: String,
}

impl Reading {
    fn into_label(self) -> String {
        self.label
    }
}

fn main() {
    let reading = Reading { label: String::from("Pi") };
    let label = reading.into_label();
    println!("{}", reading.label); // Error: Reading was consumed.
    println!("{label}");
}

cargo check should reject use of the moved receiver (E0382). A borrowed getter can be appropriate when the caller wants inspection rather than ownership; that is a different contract, not a reason to clone every value automatically.

Exercises and checks

  1. Remove mut from the main program's reading binding: the setter should fail to compile (E0596). Restore it.
  2. Change the setter argument to 0: expect updated: 0.0 C; the label is unchanged. Check that the setter has no hidden range-validation policy.
  3. Construct Reading with a literal rather than new: the fourth test demonstrates that both forms are valid here.
  4. Before consuming the value, predict whether the field access, print and celsius call move or borrow it. Formatting borrows the label; into_label transfers it.

If a field is missing from a literal, supply it explicitly rather than assuming an automatic default. If mutation is rejected, inspect the receiver contract and overlapping references. Structs have neither inheritance nor automatically generated getters; abstraction through traits comes later.

Verification and next step

On October 10, 2026, the example was verified on a Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Cargo check, all 4 tests, formatting and debug/release output comparisons passed. The consumed-receiver and missing-mut examples were rejected with E0382 and E0596 respectively. The zero-update exercise also ran with the expected output. No sensor or performance measurement is involved.

Continue with enums, match and patterns to represent alternative states.

Previous: borrowing and slices · Course overview

Donate