Objects Contain Data and Behavior in Rust

Understand the classic definition of objects as bundles of data and behavior and how Rust structs and enums with impl blocks fulfill this role

The Gang of Four book, Design Patterns: Elements of Reusable Object-Oriented Software, defines object-oriented programs as being made up of objects, where an object packages both data and the procedures that operate on that data. Those procedures are typically called methods or operations. By this definition, a language only needs to let you group state and functionality together; it does not require inheritance, class-based hierarchies, or even the word “class.”

Rust supports this directly through structs and enums for data and impl blocks for behavior. While Rust never calls them objects, a struct with its associated impl block delivers exactly the same capability: a single type that owns its data and exposes operations on that data.

Data and Behavior as One Unit

In traditional object-oriented languages, a class contains fields (data) and methods (behavior). The class is the blueprint; instances of the class are the actual objects that hold the state. Rust separates the two syntactically but keeps them logically bound. A struct or enum defines the shape of the data, and impl blocks attach functions to that shape. The compiler enforces that methods can only be called on values of the corresponding type, so the coupling is tight even though the definitions live in separate blocks.

The Gang of Four Definition:

The definition only requires that objects combine data and operations. Rust structs and enums do exactly that, regardless of whether the language calls them objects. This is why Rust is considered object-oriented under the most widely cited academic definition.

Think of a bank account. It needs to store a balance (data) and support deposits and withdrawals (behavior). In Rust, you model that with one struct and one impl block. The data stays private, and the public methods become the only way to interact with it.

struct BankAccount {
    owner: String,
    balance: f64,
}
impl BankAccount {
    fn new(owner: String, initial_balance: f64) -> Self {
        BankAccount {
            owner,
            balance: initial_balance,
        }
    }
    fn deposit(&mut self, amount: f64) {
        self.balance += amount;
    }
    fn withdraw(&mut self, amount: f64) -> Result<f64, String> {
        if amount > self.balance {
            Err(String::from("Insufficient funds"))
        } else {
            self.balance -= amount;
            Ok(self.balance)
        }
    }
    fn balance(&self) -> f64 {
        self.balance
    }
}

The BankAccount struct holds two pieces of data. The impl block gives it four methods: a constructor-like associated function new (which does not take self), and three instance methods that operate on the account. Code that uses BankAccount does not reach into the fields directly because they are not marked pub — that encapsulation is a separate concern, but the data and behavior are packaged together from the start.

When you create an account and call methods on it, you see the typical object-oriented flow:

let mut account = BankAccount::new(String::from("Aria"), 100.0);
account.deposit(50.0);
println!("Balance: {}", account.balance()); // Balance: 150.0
match account.withdraw(200.0) {
    Ok(bal) => println!("New balance: {}", bal),
    Err(e) => println!("Error: {}", e),        // Error: Insufficient funds
}

The instance account bundles the state (owner, balance) and exposes operations (deposit, withdraw, balance) that read and modify that state. Nothing else is needed to meet the “objects contain data and behavior” criterion.

Everything is working if…:

If your struct compiles and you can call instance methods with dot notation, the data‑and‑behavior unit is correctly formed. The compiler guarantees that methods can only be called on values of the right type, so accidental mismatches are caught at compile time.

Enums as Objects with Data and Behavior

Structs are not the only way to bundle data and operations. Enums in Rust can hold data inside variants, and they can have impl blocks just like structs. This means an enum can represent a type whose instances take different shapes but still share a common set of methods.

Consider a messaging system where a message can be a text, an image, or a quit signal. Each variant carries different data, and the enum’s method knows how to handle all of them.

enum Message {
    Text { content: String },
    Image { url: String, alt_text: String },
    Quit,
}
impl Message {
    fn summarize(&self) -> String {
        match self {
            Message::Text { content } => format!("Text: {}", content),
            Message::Image { url, alt_text } => {
                format!("Image at {} (alt: {})", url, alt_text)
            }
            Message::Quit => String::from("Quit message"),
        }
    }
}

The Message enum packages several possible data shapes, and the summarize method provides shared behavior that works uniformly across all variants. Even though the internal data layout differs, the enum and its impl block act as a single object that knows how to describe itself.

A method must match on all variants:

When you implement a method for an enum, you almost always need a match expression that handles every variant. If you forget a variant, the compiler will refuse to compile the code. This guarantees that no variant is silently ignored — a sharp contrast to many OOP languages where forgetting to override a method can lead to runtime errors.

This pattern shows that “objects contain data and behavior” does not require a flat, struct-like shape. A type that can be one of several things, each with its own data, still satisfies the definition as long as it exposes behavior through methods.

The self Parameter Controls Access to Data

Methods in Rust always declare how they receive the instance. The three forms — &self, &mut self, and self — are the mechanism that determines whether a method reads, modifies, or consumes the object. This is similar to this being implicit in other languages, but Rust makes the access level explicit in the signature.

  • &self borrows the instance immutably. The method can read fields but not change them. Multiple immutable borrows can coexist, so you can call several &self methods at the same time.
  • &mut self borrows the instance mutably. The method can read and modify fields. Only one mutable borrow can exist at a time, preventing data races.
  • self takes ownership of the instance. The method consumes the object, and the caller can no longer use it afterwards unless the method returns something.
impl BankAccount {
    // Consumes the account, transferring ownership
    fn close(self) -> String {
        format!("Account for {} closed. Final balance: {}", self.owner, self.balance)
    }
}

Calling close moves the account into the method. Any attempt to use the account afterwards triggers a compile error, preventing use-after-move bugs.

Accidental consumption can break code:

If you define a method that takes self instead of &self, calling it will move the value. The compiler will then reject any further use of that variable. This is a deliberate safety feature, but it can be surprising when you are used to languages where this is always a reference. Always check whether your method truly needs ownership or if a borrow would suffice.

This explicit control is not just a Rust quirk — it is the language’s way of enforcing memory safety while still letting objects contain data and behavior. The compiler tracks how data flows through methods and refuses to compile programs that could cause dangling pointers or data races.

Why the Separation of Data and Behavior Matters

In languages like Java or C++, the fields and methods are written inside the class body. Rust places the data in the struct/enum definition and the behavior in separate impl blocks. The separation is not a rejection of object-oriented principles; it is a design choice that brings practical benefits.

Because behavior is not baked into the data definition, you can add methods to a type from anywhere in the same crate without modifying the original struct. You can even have multiple impl blocks for the same type, each adding a set of related methods. This keeps code organized without forcing everything into one giant class definition. Traits extend this further by allowing you to define shared behavior and implement it for types you own, even across crate boundaries under the orphan rule.

All of this still satisfies the data‑and‑behavior definition. A Vec<i32> has data (the elements) and behavior (push, pop, len) even though those methods are provided through multiple impl blocks, some of which come from traits. The object — in the Gang of Four sense — is intact.

Common Mistake: Assuming Inheritance Is Required

Many programmers equate object-oriented programming with class-based inheritance. They see that Rust lacks traditional inheritance and conclude it cannot be object-oriented. The Gang of Four definition refutes this: objects are about packaging data with the procedures that act on it, not about parent‑child class relationships. Rust provides that packaging directly and adds other mechanisms (traits, trait objects) for code reuse and polymorphism. For now, know that a struct with methods already qualifies as an object under the most widely accepted definition.

Summary

Rust structs and enums satisfy the core object-oriented requirement that objects contain both data and behavior. The data lives in the type definition, and the behavior lives in impl blocks, which the compiler binds tightly to the type. The self parameter gives fine-grained control over how methods access instance data, and this control is enforced at compile time. Enums extend the concept by allowing a single type to represent multiple data shapes, all sharing the same methods.