Skip to content
rusthacks.dev
Rust

Structs in Rust With Fields Methods and Associated Functions

Define a struct, give it methods in an impl block, add associated functions like new, and see tuple and unit structs plus update syntax and derive.

8 min read
Summarize in AI
Flat illustration for the post “Structs in Rust With Fields Methods and Associated Functions”, composed around enums.

A struct is how you give a name and a shape to data that belongs together, and an impl block is how you attach behaviour to it. Rust has no classes and no inheritance, so these two pieces carry nearly everything that classes do in other languages. This post builds a struct with the three kinds of method, shows the four errors you will meet in your first week, and ends with a fact about memory layout that surprises people from C. All programs ran on Rust 1.99, edition 2024.

Fields, methods and an associated function

src/main.rsrust
#[derive(Debug, Clone)]
struct User {
    name: String,
    email: String,
    logins: u32,
    active: bool,
}

impl User {
    // Associated function: no self, called as User::new(..)
    fn new(name: &str, email: &str) -> User {
        User { name: name.to_string(), email: email.to_string(), logins: 0, active: true }
    }

    // &self: read-only method
    fn display(&self) -> String {
        format!("{} <{}>", self.name, self.email)
    }

    // &mut self: changes the value
    fn record_login(&mut self) {
        self.logins += 1;
    }

    // self: consumes the value
    fn deactivate(mut self) -> User {
        self.active = false;
        self
    }
}

fn main() {
    let mut ada = User::new("Ada", "[email protected]");
    ada.record_login();
    ada.record_login();
    println!("{} has {} logins", ada.display(), ada.logins);

    let copy = User { name: "Ada II".to_string(), ..ada.clone() }; // struct update syntax
    println!("{copy:?}");

    let gone = ada.deactivate();
    println!("{} active={}", gone.display(), gone.active);
}
Text
Ada <[email protected]> has 2 logins
User { name: "Ada II", email: "[email protected]", logins: 2, active: true }
Ada <[email protected]> active=false

User has four named fields. The impl User block holds everything that belongs to it, and the first parameter of each function says what the function does with the value.

  • new has no self at all. It is an associated function, called with the type name: User::new(...). The name new is a convention, not a keyword, and a constructor in Rust is just a function that returns the struct.
  • display(&self) borrows the value read-only. You can call it any number of times and the value is untouched.
  • record_login(&mut self) borrows it mutably, so it can change self.logins. The caller needs a let mut binding for this to be allowed.
  • deactivate(mut self) takes the value by ownership. After the call the original is gone, and the method hands a changed value back.

The line with ..ada.clone() is struct update syntax: the fields you list are set, and the rest are copied or moved from the other value. Here the new user keeps Ada's email and her two logins but has a new name. Without the .clone() the update would move ada.email out of ada, which would partially consume it.

The method receiver is the contract

The three receivers matter because the compiler enforces them at the call site. Take ownership and then use the value again, and you get one of the most common errors in the language:

src/main.rsrust
#[derive(Debug)]
struct User {
    name: String,
}

impl User {
    fn deactivate(self) -> User {
        self
    }
}

fn main() {
    let ada = User { name: "Ada".to_string() };
    let gone = ada.deactivate();
    println!("{:?} {:?}", ada, gone);
}
Text
error[E0382]: borrow of moved value: `ada`
  --> src/main.rs:15:27
   |
13 |     let ada = User { name: "Ada".to_string() };
   |         --- move occurs because `ada` has type `User`, which does not implement the `Copy` trait
14 |     let gone = ada.deactivate();
   |                    ------------ `ada` moved due to this method call
15 |     println!("{:?} {:?}", ada, gone);
   |                           ^^^ value borrowed here after move
   |
note: `User::deactivate` takes ownership of the receiver `self`, which moves `ada`
  --> src/main.rs:7:19

E0382 says ada was moved by deactivate, and the note even names the cause: the method takes ownership of the receiver self. If you meant to keep using ada, the method should take &self or &mut self and change the value in place, or you should clone before calling. If you did mean to consume it, the error is correct, because ada no longer exists. Borrow of moved value explains the family, and ownership explains why.

A &mut self method needs a mutable binding:

src/main.rsrust
struct Counter {
    n: u32,
}

impl Counter {
    fn bump(&mut self) {
        self.n += 1;
    }
}

fn main() {
    let c = Counter { n: 0 };
    c.bump();
    println!("{}", c.n);
}
Text
error[E0596]: cannot borrow `c` as mutable, as it is not declared as mutable
  --> src/main.rs:13:5
   |
13 |     c.bump();
   |     ^ cannot borrow as mutable
   |
help: consider changing this to be mutable
   |
12 |     let mut c = Counter { n: 0 };

The fix is the one the compiler prints: let mut c. The rule is the same as for any mutation, covered in variables and mutability, and the receiver type is what tells the compiler that bump mutates.

Two construction errors

A struct literal must name every field, and a method must exist:

src/main.rsrust
struct Config {
    host: String,
    port: u16,
}

fn main() {
    let c = Config { host: "localhost".to_string() };
    println!("{}", c.host);
}
Text
error[E0063]: missing field `port` in initializer of `Config`
 --> src/main.rs:7:13
  |
7 |     let c = Config { host: "localhost".to_string() };
  |             ^^^^^^ missing `port`

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0063`.

E0063, missing field port. There are no default values unless you provide them, which is why structs with many optional fields usually offer a builder or implement Default, and then use ..Default::default() in the literal. See builder pattern variants for the options.

src/main.rsrust
struct Point {
    x: f64,
    y: f64,
}

fn main() {
    let p = Point { x: 3.0, y: 4.0 };
    println!("{}", p.distance());
}
Text
error[E0599]: no method named `distance` found for struct `Point` in the current scope
 --> src/main.rs:8:22
  |
1 | struct Point {
  | ------------ method `distance` not found for this struct
...
8 |     println!("{}", p.distance());
  |                      ^^^^^^^^ method not found in `Point`

E0599, no method named distance. Methods do not appear because a field is named x and y; you write them. The compiler also does not guess that you meant a function from somewhere else. If the method is defined in a trait, the trait must be in scope as well, which is the other common cause of this error.

Tuple structs and unit structs

Structs do not have to name their fields:

src/main.rsrust
// Tuple struct: a distinct type with unnamed fields (the newtype pattern)
struct Meters(f64);
struct Seconds(f64);

// Unit struct: no data, useful as a marker or a place to hang trait impls
struct Marker;

impl Marker {
    fn hello(&self) -> &'static str {
        "marker says hi"
    }
}

fn speed(distance: Meters, time: Seconds) -> f64 {
    distance.0 / time.0
}

fn main() {
    println!("{}", speed(Meters(100.0), Seconds(9.58)));
    println!("{}", Marker.hello());
    println!("{} bytes", std::mem::size_of::<Marker>());
}
Text
10.438413361169102
marker says hi
0 bytes

Meters(f64) is a tuple struct: its field is reached as .0. The value is just an f64, but the type is Meters, and the compiler treats Meters and Seconds as different types even though both wrap the same number. That is the newtype pattern, and its payoff is concrete:

src/main.rsrust
struct Meters(f64);
struct Seconds(f64);

fn speed(distance: Meters, time: Seconds) -> f64 {
    distance.0 / time.0
}

fn main() {
    println!("{}", speed(Seconds(9.58), Meters(100.0)));
}
Text
error[E0308]: arguments to this function are incorrect
 --> src/main.rs:9:20
  |
9 |     println!("{}", speed(Seconds(9.58), Meters(100.0)));
  |                    ^^^^^ -------------  ------------- expected `Seconds`, found `Meters`
  |                          |
  |                          expected `Meters`, found `Seconds`

Swapping the two arguments is a compile error, and the compiler suggests swapping them back. A function signature of fn speed(distance: f64, time: f64) would have accepted the swap and returned a wrong number. The newtype pattern goes into the cost (none) and the trade-offs. Marker is a unit struct: no fields, zero bytes, as the last line shows. It exists to carry methods or trait implementations without any data, and to act as a tag in the type system.

Rust may reorder your fields

In C, a struct is laid out in the order you declared it, with padding inserted to align each field. Rust makes no such promise by default, and uses the freedom:

src/main.rsrust
use std::mem::{align_of, size_of};

#[allow(dead_code)]
struct Padded { a: u8, b: u32, c: u8 }

#[allow(dead_code)]
struct Reordered { b: u32, a: u8, c: u8 }

#[allow(dead_code)]
#[repr(C)]
struct CLayout { a: u8, b: u32, c: u8 }

fn main() {
    println!("Padded    size={} align={}", size_of::<Padded>(), align_of::<Padded>());
    println!("Reordered size={} align={}", size_of::<Reordered>(), align_of::<Reordered>());
    println!("repr(C)   size={} align={}", size_of::<CLayout>(), align_of::<CLayout>());
}
Text
Padded    size=8 align=4
Reordered size=8 align=4
repr(C)   size=12 align=4

All three structs hold a u8, a u32 and another u8: six bytes of data. Under repr(C), which asks for the C layout, the fields stay in order, the u32 needs 4-byte alignment, so three bytes of padding follow the first u8, and another three pad the end, for 12 bytes. In Rust's default representation, the compiler is free to place the u32 first and the two bytes after it, and it did: 8 bytes, with two bytes of padding at the end. The order you write fields in does not matter for size, but if you pass a struct to C code, write it to a file as raw bytes or share it between processes, you must say #[repr(C)], because the default layout is unspecified and can change between compiler versions. The padding rules are in alignment and padding.

Visibility and where the struct lives

Fields are private to the module that defines the struct unless marked pub. That is how a struct enforces its invariants: a Temperature that must stay above absolute zero can keep its field private and offer a constructor that checks the value. The rules are in modules and visibility.

Habits

  • Give a struct private fields and a new that validates them when it has invariants to protect.
  • Take &self by default, &mut self when the method changes the value and self only when it should consume it.
  • Derive Debug on every struct; add Clone, PartialEq and Default when they are useful.
  • Use a newtype whenever two values of the same primitive type could be confused.
  • Do not add a getter and a setter for every field. If a field is public and has no invariant, access it directly.

When a struct is the wrong shape

If a value can be one of several different shapes, with different fields in each, use an enum, not a struct with many Option fields; enums shows why. If behaviour varies and you want to call it through a common interface, define a trait and implement it for several structs. And if you reach for inheritance to share fields between two structs, compose instead: put the shared struct inside both as a field and forward the few methods you need.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.