An Example Program Using Structs

To understand when structs are useful, let's build a program that calculates the area of a rectangle. We'll start with simple variables and progressively refactor to use structs, demonstrating how they improve code organization and clarity.

Starting with Simple Variables

Here's a basic approach using individual variables:

fn main() {
    let width = 30
    let height = 50

    println!(
        "The area of the rectangle is \(area(width, height)) square pixels."
    )
}

fn area(width: Int, height: Int): Int {
    width * height
}

This works, but the area function has two parameters that conceptually belong together. The relationship between width and height isn't explicit in the code.

Refactoring with Tuples

We can group the dimensions using a tuple:

fn main() {
    let rect = (30, 50)

    println!(
        "The area of the rectangle is \(area(rect)) square pixels."
    )
}

fn area(dimensions: (Int, Int)): Int {
    dimensions.0 * dimensions.1
}

This groups the data, but now we've lost meaning. Is dimensions.0 the width or the height? The tuple doesn't convey this information, making the code harder to understand.

Refactoring with Structs

Structs solve this by giving meaningful names to both the type and its fields:

struct Rectangle {
    width: Int,
    height: Int,
}

fn main() {
    let rect = Rectangle {
        width: 30,
        height: 50,
    }

    println!(
        "The area of the rectangle is \(area(&rect)) square pixels."
    )
}

fn area(rectangle: &Rectangle): Int {
    rectangle.width * rectangle.height
}

Now the code clearly shows that width and height are dimensions of a Rectangle. The function signature area(rectangle: &Rectangle) immediately conveys what the function operates on.

Notice that area takes a reference &Rectangle. This means:

  • The function borrows the rectangle rather than taking ownership
  • The original rect remains valid after the function call
  • No data is copied, just a reference to the existing rectangle

Adding Debug Output

When developing, you often want to print struct values for debugging. Let's see what happens if we try to print our rectangle:

struct Rectangle {
    width: Int,
    height: Int,
}

fn main() {
    let rect = Rectangle {
        width: 30,
        height: 50,
    }

    println!("rect is \(rect)")  // This won't compile!
}

This fails because Rectangle doesn't implement the Display trait that string interpolation requires. For debugging purposes, we can use the Debug trait:

#[derive(Debug)]
struct Rectangle {
    width: Int,
    height: Int,
}

fn main() {
    let rect = Rectangle {
        width: 30,
        height: 50,
    }

    // Use {:?} for Debug formatting
    println!("rect is {:?}", rect)

    // Use {:#?} for pretty-printed Debug output
    println!("rect is {:#?}", rect)
}

Output:

rect is Rectangle { width: 30, height: 50 }
rect is Rectangle {
    width: 30,
    height: 50,
}

The #[derive(Debug)] attribute automatically generates an implementation of the Debug trait, enabling the {:?} and {:#?} format specifiers.

Using dbg! Macro

For quick debugging, the dbg! macro is even more convenient:

#[derive(Debug)]
struct Rectangle {
    width: Int,
    height: Int,
}

fn main() {
    let scale = 2
    let rect = Rectangle {
        width: dbg!(30 * scale),
        height: 50,
    }

    dbg!(&rect)
}

Output:

[src/main.ox:9:16] 30 * scale = 60
[src/main.ox:13:5] &rect = Rectangle {
    width: 60,
    height: 50,
}

The dbg! macro:

  • Prints the file and line number
  • Shows the expression being evaluated
  • Returns ownership of the value (so it can be used inline)
  • Outputs to stderr rather than stdout

Notice that we use dbg!(&rect) with a reference to avoid moving ownership.

Complete Working Example

Here's the complete program with all improvements:

#[derive(Debug)]
struct Rectangle {
    width: Int,
    height: Int,
}

fn main() {
    let rect = Rectangle {
        width: 30,
        height: 50,
    }

    let area = calculateArea(&rect)

    println!("Rectangle: {:#?}", rect)
    println!("Area: \(area) square pixels")

    // Example with multiple rectangles
    let rectangles = vec![
        Rectangle { width: 10, height: 20 },
        Rectangle { width: 30, height: 50 },
        Rectangle { width: 5, height: 15 },
    ]

    println!("\nAll rectangles:")
    for rect in rectangles.iter() {
        println!("  {:?} -> area: \(calculateArea(rect))", rect)
    }
}

fn calculateArea(rectangle: &Rectangle): Int {
    rectangle.width * rectangle.height
}

Output:

Rectangle: Rectangle {
    width: 30,
    height: 50,
}
Area: 1500 square pixels

All rectangles:
  Rectangle { width: 10, height: 20 } -> area: 200
  Rectangle { width: 30, height: 50 } -> area: 1500
  Rectangle { width: 5, height: 15 } -> area: 75

Deriving Multiple Traits

In practice, you'll often derive several traits together:

#[derive(Debug, Clone, PartialEq, Eq)]
struct Rectangle {
    width: Int,
    height: Int,
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 }
    let rect2 = rect1.clone()  // Create a copy
    let rect3 = Rectangle { width: 40, height: 50 }

    println!("rect1 == rect2: \(rect1 == rect2)")  // true
    println!("rect1 == rect3: \(rect1 == rect3)")  // false
}

Common derivable traits:

  • Debug: Enables {:?} formatting for debugging
  • Clone: Enables .clone() for deep copying
  • PartialEq: Enables == and != comparison
  • Eq: Indicates that equality is reflexive, symmetric, and transitive
  • Hash: Enables use as a key in HashMap

Why Use Structs?

This example demonstrates several benefits of structs:

  1. Semantic clarity: Rectangle is more meaningful than (Int, Int)
  2. Self-documenting code: Field names like width and height explain themselves
  3. Type safety: A Rectangle can't be confused with other (Int, Int) tuples
  4. Extensibility: Easy to add more fields or functionality later
  5. Maintainability: Changes to the struct definition are centralized

Moving Toward Methods

The calculateArea function works, but it's disconnected from the Rectangle type. Conceptually, calculating area is something a rectangle does, not something done to a rectangle.

In the next section, we'll learn about methods, which let us define functions that are directly associated with a struct:

extension Rectangle {
    fn area(): Int {
        self.width * self.height
    }
}

fn main() {
    let rect = Rectangle { width: 30, height: 50 }
    println!("Area: \(rect.area())")  // More natural!
}

This syntax, using extension blocks and implicit self, is one of Oxide's major features and is covered in detail in the next section.

Summary

In this section, we saw how to:

  • Refactor code to use structs for better organization
  • Derive the Debug trait for printing struct values
  • Use dbg! for quick debugging
  • Access struct fields through references
  • Appreciate the benefits of structured data

Next, we'll explore method syntax to make our struct-related functions even more intuitive and powerful.