Skip to content
academia.sh

Lesson 07 / 12

Structs

The same Point value is built three separate ways — full syntax, update syntax, associated function — and all three produce a value as an expression; the struct declaration itself produces no value, it only introduces a name the following lines can use.

Published Updated

In this lesson

  • Shows that all three syntaxes for constructing a struct value are expressions
  • Explains that a struct declaration is an item and produces no value on its own
  • Distinguishes a method from an associated function by the presence of a receiver
  • Compares the identity of field order in a named struct versus a tuple struct
Contents

The Control Flow lesson measured, one by one, whether each of five control-flow forms produces a value: if and match were expressions, loop carried a value only through break, and while and for never gave one. This lesson carries the same question to different material. There are no longer control-flow forms but data types, and the question does not change: is constructing a value from a type an expression, or is writing the type itself to the source an expression?

In Rust, the way to gather several fields under one type is a struct. This lesson measures the difference between a struct’s declaration and the construction of a value from that struct using three separate syntaxes — full syntax, update syntax, and an associated function — and shows that all three fall into the same class. The declaration itself never falls into that class. The second half of the lesson returns to what the declaration contains, namely fields: the same set of fields behaves quite differently across two forms of struct — the named and the unnamed — and that difference is measured separately.

A Declaration Is an Item, Constructing a Value Is an Expression

The line struct Point { x: i32, y: i32 } produces nothing when it runs. This line is not a computation but a declaration that introduces a name to the compiler: it is the precondition for later lines being able to use the name Point, but it is not itself a value. In the previous lesson’s term, this is an item — it does not “run” at any point in the source, it merely sits there and adds a type the compiler will know about.

By contrast, the syntax Point { x: 3, y: 4 } produces a value when it runs: it takes two integers and packs them into a single value of type Point. This is an expression — its result can be bound to a variable, passed as an argument to a function, appended to a list. The difference here is exactly the distinction the previous lesson set up: one sits there without being evaluated, the other is evaluated and gives back a value.

DM1. — The claim that a struct declaration by itself produces no output is not verified here by a separate run but by the structure of the program below: the line struct Point { ... } sits before main, not inside it, and no print call triggers that line. The only output the program shows when it runs comes from the lines where the name Point is used.

The reason behind this distinction is that the two kinds of lines are processed by the compiler at different times. A declaration is read at compile time: the compiler records the name Point, its fields, and their types into a table, but this recording leaves no trace at the program’s runtime. A value-construction syntax, however, acts at runtime: when the line Point { x: 3, y: 4 } executes, two integers are actually assembled in memory. This is the same order as a binding built with let (the subject of the Variables and Immutability lesson) taking place at runtime — the difference is that what sits to the left of a binding must be an expression, not an item.

This is one of the places where the three classes separate most clearly. A statement is still evaluated — only its result falls to () or is discarded — but an item is never evaluated at all; there is nothing that “runs” where it sits in the source. A struct declaration falls into this third category: it is not a step passed over while the program runs, but a record the compiler processes once while reading the source.

The run below places three different ways of constructing a value from the same Point type side by side.

// struct_construction.rs — a struct declaration is an item, constructing a struct value is an expression
struct Point {
    x: i32,
    y: i32,
}

impl Point {
    fn origin() -> Point {
        Point { x: 0, y: 0 }
    }

    fn magnitude(&self) -> i32 {
        self.x.abs() + self.y.abs()
    }
}

fn main() {
    let a = Point { x: 3, y: 4 };
    println!("{:<21}: x={} y={}", "full syntax", a.x, a.y);

    let b = Point { y: 9, ..a };
    println!("{:<21}: x={} y={}", "update syntax", b.x, b.y);

    let c = Point::origin();
    println!("{:<21}: x={} y={}", "associated function", c.x, c.y);

    println!("{:<21}: {}", "method result", b.magnitude());
}
full syntax          : x=3 y=4
update syntax        : x=3 y=9
associated function  : x=0 y=0
method result        : 12

Full syntax gives every field one by one: x and y are written explicitly, and the result is a value with x=3 y=4. Update syntax (..a) is a shorthand for that: only the y field is given again, and the remaining fields are copied from a. The result is still a single Point value produced in one call — the syntax differs, the class does not. The associated function (Point::origin()) reaches the same class by an entirely different route: without ever writing x and y, the function call itself produces a Point. What the three syntaxes share is that all three can be bound to a name with let — something an expression can do by definition, and something meaningless for an item.

Method vs. Associated Function: the Receiver’s Presence

The calls Point::origin() and b.magnitude() look similar on the surface: both are defined inside an impl Point block, and both return a value. The difference between them shows up in the call syntax and comes down to a single point: the presence of a receiver.

The origin function’s signature has no self; it is therefore called through the type name (Point::origin()) and requires no instance. In Rust’s own terms this is an associated function — it is associated with the Point type, but not with any particular Point value. The magnitude function’s signature, on the other hand, has &self as its first parameter; this parameter states which value the call is being made on, and it does not appear at the call site — the b in b.magnitude() takes its place. This is what Rust calls a method.

The distinction is measured not by what the work does but by what the signature requires: with a self parameter, the call goes through a value (method); without one, through the type name (associated function). In the run above, origin() produced a value from nothing, needing no existing Point; magnitude() could only be called through the already-existing value b. Both are expressions — both return a value — and this adds no new branch to the expression/statement/item axis: every Rust function call that does not return () is already an expression, whether method or associated function. The distinction lies outside that axis, in the call syntax, in whether a receiver is present.

The magnitude function computes the point’s distance from the origin as the sum of two absolute values: self.x.abs() + self.y.abs(). In the run, b carried the value x=3 y=9, so the magnitude() call gave 3 + 9 = 12. origin, as its name suggests, plays the role of a constructor: it is not the only one of the three ways to produce a Point value, but a common naming habit — presenting a type’s most common starting value through an associated function like Type::origin(), Type::empty(), or Type::default(). There is no limit at the declaration stage on how many such functions there can be; the impl Point block gathers all of them in the same place.

Named-struct syntax asks for a field name only when constructing a value; a field name never appears at a method call. In the syntax b.magnitude(), b states which value the call is made on, but inside the magnitude function, the fields self.x and self.y are reached by name. Even though the receiver appears reduced to a single dot (.) in the call syntax, inside the function body it is the full Point value whose fields are reached by name.

The self parameter carries a single & sign in front of it. The exact meaning of this sign, and why the receiver can be used without being copied, is the subject of the Ownership and the Memory Model course; what is measured here is only how the receiver’s presence determines the call syntax, not how the receiver is passed.

There is no upper bound on how many associated functions or methods a type’s impl block can hold. Even a small type like Point can have several constructor associated functions — alongside origin(), names such as unit() -> Point or pair(x: i32, y: i32) -> Point could offer other default values. Which names get used is a naming habit, not a language rule; the compiler treats all of them as the same class, the associated function.

Field Order Carries No Identity in a Named Struct

When constructing a struct value, the order in which the fields are written in the source does not have to match the order in the declaration. Point { x: 3, y: 4 } and Point { y: 4, x: 3 } produce the same value; the compiler matches each field by its name, not its position.

// field_order.rs — field order carries no identity in a named struct
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let d = Point { x: 3, y: 4 };
    let e = Point { y: 4, x: 3 };
    println!("{:<18}: x={} y={}", "d (x first)", d.x, d.y);
    println!("{:<18}: x={} y={}", "e (y first)", e.x, e.y);
    println!("{:<18}: {}", "d and e equal", d.x == e.x && d.y == e.y);
}
d (x first)       : x=3 y=4
e (y first)       : x=3 y=4
d and e equal     : true

d and e were written in different order in the source, but both carry the same x and y values. A field name here is not a position marker but a matching key: the writing order is a matter of readability preference, it does not change the meaning.

This is an exception that falls outside Rust’s general call convention. In a function call, arguments are matched by position — in the syntax double(3), which parameter the 3 goes to is decided by its position in the parameter list, not by a name. Struct-construction syntax is the one exception to this: each field: value pair inside { } finds its match in the struct declaration by name. This privilege belongs only to construction syntax; when accessing a struct value’s field (as in a.x), it is already accessed by name, and no “order” question ever arises there.

DM2. — The claim that “order does not change meaning” is measured here not by a separate field-comparison method but directly by the line d.x == e.x && d.y == e.y; this line reads that both values’ fields are equal to each other one by one, it does not compare d and e themselves.

Position Is the Only Identity in a Tuple Struct

The exact opposite of the named struct is the tuple struct: its fields have no name, only a position and a type. The declaration Color(u8, u8, u8) builds a type that carries three bytes, but it is not name but position that decides which of these three bytes is “red” and which is “blue.”

// tuple_identity.rs — position is the only identity in a tuple struct
struct Color(u8, u8, u8);

fn main() {
    let red = Color(220, 20, 20);
    let Color(r1, g1, b1) = red;
    println!("{:<18}: r={} g={} b={}", "Color(220,20,20)", r1, g1, b1);

    let blue = Color(20, 20, 220);
    let Color(r2, g2, b2) = blue;
    println!("{:<18}: r={} g={} b={}", "Color(20,20,220)", r2, g2, b2);
}
Color(220,20,20)  : r=220 g=20 b=20
Color(20,20,220)  : r=20 g=20 b=220

This is the limiting measurement: while field order was free in a named struct (the previous run showed this with d and e), position is the sole identity in a tuple struct. Color(220, 20, 20) and Color(20, 20, 220) do not carry the same three numbers — in the first, the first position is large; in the second, the third position is — and the result is two different color values. In a named struct, a name answers the question “which field”; in a tuple struct, only position answers that same question. In the writing Color(20, 20, 220), which field the first two 20s belong to is resolved by looking at where that value sits.

DM3. — The claim “position is the only identity” in a tuple struct is measured here by placing the same three numbers into different positions, that is by producing the pair Color(220, 20, 20) and Color(20, 20, 220): the two calls carry the same three values (a permutation of 20, 20, 220), but because their positions differ, so do the results.

Destructuring also comes into play here: the line let Color(r1, g1, b1) = red; binds the three fields of the value red in order to the names r1, g1, b1. This writing is in fact a pattern — it matches the shape of the value on the right against the names on the left — and the full scope of taking values apart with patterns is the subject of the next lesson. Here it is only visible that accessing a tuple struct’s fields goes through this route: where a.x can be written for a named struct, a tuple struct’s field is reached either by position (color.0, color.1, color.2) or by destructuring, not by name.

A single-field tuple struct is also used to keep two quantities of the same underlying type distinct from each other. struct Pixel(i32); and struct Millimeter(i32); both carry an i32 inside, but for the compiler they are two separate types; a Pixel value does not stand in for a Millimeter value, even though its field looks the same. This is the tuple struct’s most common use: not giving an existing type a new name, but a new identity. A named struct reaches the same goal with a field name (Location { pixel: i32 }); a tuple struct does it with a single wrapper field, no field name ever written.

DM4. — The contrast between the named struct and the tuple struct is measured by placing two separate runs side by side: field_order.rs changes the order and shows the result stays the same, tuple_identity.rs changes the position and shows the result is different. Both runs use the same three inputs (three numbers); what changes is only whether the type is named or unnamed.

A struct is Rust’s most direct way of holding data together: a fixed number of fields, each tied to a single type, all present at once. The three syntaxes this lesson measured — full syntax, update syntax, associated function — all arrive at the same result because all of them fill the same fixed set of fields. But not every struct value is bound to a fixed set of fields; the data a program has to model often takes more than one possible shape in an “either this or that” way. A struct alone cannot meet this — a Point always carries both x and y, there is no third “carries nothing” state. The next lesson builds the type that fills exactly that gap.

Summary

  • A struct declaration is an item: it produces no value when it runs, it only introduces a name.
  • All three syntaxes for constructing a struct value — full syntax, update syntax, associated function — are expressions and produce a value.
  • The difference between a method and an associated function is the presence of a receiver (self): a method is called through a value, an associated function through a type name.
  • Field order carries no meaning in a named struct; fields are matched by name.
  • A tuple struct’s fields have no name, only a position; position is the sole identity and the result changes when it is changed.

Next Step

This lesson measured how many fields a struct value carries and how those fields are constructed — but a struct value always carries the same set of fields. The next lesson carries the same question to a different shape: can a type have more than one form, where which fields it carries changes depending on state? An enum sets up a value being able to carry one of several separate states under the same type; the next question is how these states are read from the source.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close