Vector (Note 03) can hold any number of components, so it needs a heap-allocated Vec. For 2-D geometry the size is always 2, and Rust has better tools for fixed-size data. This note covers the rest of src/math/matrix.rs: Vector2, Matrix2 and rotate_vector.

1. Arrays: [T; N]

[f64; 2]

This is an array: exactly 2 f64 values, stored side by side, with no heap involved.

The length is part of the type. So [f64; 2] and [f64; 3] are different types, and the compiler checks the length:

let a: [f64; 2] = [1.0, 2.0, 3.0];
error[E0308]: mismatched types
help: consider specifying the actual array length

Indexing starts at 0, as in Python: a[0], a[1].

2. Type aliases: Vector2

pub type Vector2 = [f64; 2];

type ... = ... creates a type alias: a second name for an existing type. It makes signatures readable, as in fn mul_vector(self, v: Vector2) -> Vector2.

An alias is not a new type. Vector2 and [f64; 2] are completely interchangeable, so a plain [f64; 2] can be passed wherever a Vector2 is expected. If you wanted the compiler to keep them apart, you’d need a wrapper struct instead (like Vector wraps Vec<f64>).

3. Array or Vec?

  [f64; 2] (array) Vec<f64>
Length fixed, known while compiling can change while the program runs
Memory inline on the stack, 16 bytes heap buffer plus 24 bytes on the stack
Allocation none yes
Dimension check done by the compiler (types) must happen at runtime (DimensionMismatch)
Copy yes (if T is Copy) no

For 2-D rotations the length is always 2, so the array is both faster (no allocation) and safer (a wrong length doesn’t even compile). Compare that with Vector::checked_add, which has to check lengths at runtime (Note 08).

4. Matrix2: a nested array inside a struct

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Matrix2 {
    rows: [[f64; 2]; 2],
}

[[f64; 2]; 2] is an array of 2 arrays, each holding 2 f64 values: 4 × 8 = 32 bytes, all inline. There’s no heap data, so the type can derive Copy (Note 06). Like Vector, the field is private.

5. Compile-time constants: const, associated constants and const fn

impl Matrix2 {
    pub const IDENTITY: Matrix2 = Matrix2::new(1.0, 0.0, 0.0, 1.0);

    pub const fn new(m11: f64, m12: f64, m21: f64, m22: f64) -> Self {
        Self {
            rows: [[m11, m12], [m21, m22]],
        }
    }
}
  • const declares a constant. The compiler computes its value while compiling and bakes it into the program.
  • Because it’s inside impl Matrix2, you use it as Matrix2::IDENTITY. That’s an associated constant, like a class attribute in Python (Matrix2.IDENTITY).
  • Convention: constants use UPPER_CASE names.

const fn: functions the compiler can run

A const fn is a function the compiler is able to run at compile time. That’s why IDENTITY can call Matrix2::new(...). A const fn can still be called normally while the program runs.

A constant may only call const fn functions. rotation is a normal fn (it calls sin_cos), so this fails:

const R: Matrix2 = Matrix2::rotation(0.0);
error[E0015]: cannot call non-const associated function `Matrix2::rotation` in constants

In the demo, new, from_rows, rows, get and transpose are const fn, while determinant and mul_vector (which do float arithmetic) aren’t.

A version detail I checked: floating-point arithmetic inside const fn was only allowed starting with Rust 1.82. On 1.99, this compiles fine:

const fn det(a: f64, b: f64, c: f64, d: f64) -> f64 { a * d - b * c }
const D: f64 = det(1.0, 2.0, 3.0, 4.0);   // -2, computed while compiling

So keeping determinant non-const is a choice (or a requirement for projects that support older compilers), not a rule of the language.

6. self by value for small Copy types

pub const fn rows(self) -> [[f64; 2]; 2] {
    self.rows
}

The receiver is self, not &self. For Vector, self meant “consume the value” (Note 01). Matrix2 is Copy, though, so the method receives a copy, and the caller keeps the original.

Why not &self? A reference is an 8-byte pointer to the 32 bytes, and every read has to follow it. For small Copy types, passing the bytes directly is often just as cheap or cheaper, and the values can stay in CPU registers. That’s why all the Matrix2 methods take self by value.

7. Destructuring: taking data apart in one line

pub const fn transpose(self) -> Self {
    let [[a, b], [c, d]] = self.rows;
    Self::new(a, c, b, d)
}

let [[a, b], [c, d]] = self.rows; is a destructuring pattern. It unpacks the nested array and names all four values in one line. The Python equivalent is (a, b), (c, d) = rows.

The pattern must match the shape exactly, and the compiler checks it. A 2×2 pattern on a 3×3 array is rejected:

error[E0527]: pattern requires 2 elements but array has 3

Self::new(...) calls the type’s own associated function new.

The same technique gives a very readable matrix–vector product:

/// [ a  b ] [ x ]   [ a·x + b·y ]
/// [ c  d ] [ y ] = [ c·x + d·y ]
pub fn mul_vector(self, v: Vector2) -> Vector2 {
    let [[a, b], [c, d]] = self.rows;
    let [x, y] = v;
    [a * x + b * y, c * x + d * y]
}

The last line builds a new array and returns it. Everything stays on the stack: nothing is allocated. determinant (a * d - b * c) and mul_matrix follow the same pattern.

8. Tuples: fixed groups of values

pub fn rotation(theta: f64) -> Matrix2 {
    let (sin, cos) = sin_cos(theta);
    Matrix2::new(cos, -sin, sin, cos)
}
  • sin_cos (from src/math/trigonometry.rs) returns a tuple, (f64, f64): a fixed group of values, like a Python tuple.
  • let (sin, cos) = ... destructures the tuple into two variables.
  • -sin is the negated value.

This builds the rotation matrix R(θ) = [[cos θ, −sin θ], [sin θ, cos θ]].

Arrays vs tuples: an array holds values of one type, accessed by index (a[0]). A tuple can mix types, accessed by position (t.0, t.1):

let p = (1.0, "label", 3_u8);
let (x, name, n) = p;     // destructure
println!("{}", p.1);      // "label"

9. Free functions: rotate_vector

pub fn rotate_vector(v: Vector2, theta: f64) -> Vector2 {
    Matrix2::rotation(theta).mul_vector(v)
}

This function sits outside every impl block, so it’s a free function, like a normal module-level function in Python. It chains two calls: build the rotation matrix, then multiply it with v.

Try it with a quarter turn, rotate_vector([1.0, 0.0], FRAC_PI_2). Mathematically the answer is [0, 1], but the program prints:

[6.123233995736766e-17, 1.0]

cos(π/2) isn’t exactly 0 in floating point. That tiny error is why the tests compare floats with a tolerance (Note 11).

10. Number literals: Rust doesn’t mix integers and floats

  • 1.0 is a float (f64 by default). 1 is an integer (i32 by default).
  • Rust never converts between them automatically:
let x = 1.0 + 1;      // error[E0277]: cannot add an integer to a float
let y: f64 = 1;       // error[E0308]: mismatched types
  • Scientific notation: 1e10 is 1 × 10¹⁰ and 1e-3 is 0.001. Both are floats.
  • Suffixes pick an exact type: 2.5_f64, 3_u8, 10_usize.
  • Underscores make big numbers readable: 1_000_000.

Summary: Rust arrays, tuples and constants

  • [T; N] is a fixed-size array. Its length is part of the type, so length mistakes are compile errors.
  • type Vector2 = [f64; 2]; is an alias, a second name for the same type, not a new type.
  • Use arrays for small fixed sizes (no heap, Copy), and Vec when the size is only known at runtime.
  • const values and associated constants (Matrix2::IDENTITY) are computed while compiling. Constants can only call const fn. Float maths in const fn needs Rust 1.82+.
  • Small Copy types often take self by value instead of &self.
  • Destructuring (let [[a, b], [c, d]] = rows;, let (sin, cos) = ...) names the parts of arrays and tuples in one line, and the compiler checks the shape.
  • Free functions live outside any impl. Integers and floats never mix implicitly.