Encapsulation: Hiding Implementation Details

Encapsulation is one of the foundational principles of object-oriented programming. It means bundling related data and behavior together while hiding implementation details from the outside world. In Oxide, encapsulation is achieved through a combination of structs, access modifiers, and extension blocks.

What Is Encapsulation?

Encapsulation serves two main purposes:

  1. Data hiding - Keep internal state private so it can only be modified in controlled ways
  2. Interface stability - Expose a stable public interface while free to change implementation details

By making fields private and providing public methods to interact with them, you ensure that users of your type can't accidentally break its invariants.

Public and Private in Oxide

By default, struct fields and methods are private - they can only be accessed from within the same module. To make something accessible outside the module, use the public keyword:

public struct BankAccount {
    public accountNumber: String,
    private balance: Decimal,  // Only accessible within this module
}

Note: In Oxide, private is explicit for clarity, though fields without any visibility modifier are private by default.

Private Fields with Public Methods

The key to encapsulation is exposing a public interface while keeping the internal state private. Here's a complete example:

public struct BankAccount {
    public accountNumber: String,
    private balance: Decimal,
}

extension BankAccount {
    static fn create(accountNumber: String): Self {
        Self {
            accountNumber,
            balance: Decimal(0),
        }
    }

    public fn getBalance(): Decimal {
        self.balance
    }

    public fn deposit(amount: Decimal) {
        if amount > Decimal(0) {
            self.balance = self.balance + amount
        }
    }

    public fn withdraw(amount: Decimal): Bool {
        if amount > Decimal(0) && amount <= self.balance {
            self.balance = self.balance - amount
            return true
        }
        return false
    }
}

In this example:

  • accountNumber is public because it's just a reference number
  • balance is private because modifying it directly could break the account's invariants
  • All operations on balance go through public methods that maintain the account's validity

Users of BankAccount are forced to use the deposit and withdraw methods, ensuring that the balance never becomes negative unintentionally.

Extension Blocks for Encapsulation

Oxide uses extension blocks to add methods to types, which enables clean encapsulation patterns. Extension blocks can be in the same module as the struct or in external modules, giving you flexibility in organizing your code:

// In user.rs
public struct User {
    public id: UInt64,
    public username: String,
    private passwordHash: String,
}

// In auth.rs
import user.User

extension User {
    public fn verifyPassword(password: String): Bool {
        // Verify password against passwordHash
        // This method has access to the private passwordHash field
        // because it's in the same module
        hashPassword(password) == self.passwordHash
    }

    private fn hashPassword(password: String): String {
        // Implementation details hidden
        // ...
    }
}

Extension blocks in the same module can access private fields, enabling you to group related functionality together while maintaining encapsulation.

Getters and Setters

Use public methods to control access to private fields. This pattern is sometimes called "getter" and "setter" methods:

public struct Temperature {
    private celsius: Float,
}

extension Temperature {
    static fn fromCelsius(celsius: Float): Self {
        Self { celsius }
    }

    static fn fromFahrenheit(fahrenheit: Float): Self {
        Self { celsius: (fahrenheit - 32) * 5 / 9 }
    }

    public fn getCelsius(): Float {
        self.celsius
    }

    public fn getFahrenheit(): Float {
        self.celsius * 9 / 5 + 32
    }

    public fn setCelsius(celsius: Float) {
        self.celsius = celsius
    }
}

By controlling access through methods, you can:

  • Validate input before storing it
  • Perform calculations when retrieving values
  • Change the internal representation without affecting the public API
  • Add logging or other side effects

Invariant Enforcement

One of the primary benefits of encapsulation is maintaining object invariants - conditions that must always be true about an object's state. For example, a Stack type maintains the invariant that the number of elements stored should match the length of the internal vector:

public struct Stack<T> {
    private items: Vec<T>,
}

extension Stack<T> {
    public fn new(): Self {
        Self { items: Vec<T>() }
    }

    public fn push(item: T) {
        self.items.append(item)
    }

    public fn pop(): T? {
        if self.items.isEmpty() {
            return null
        }
        return self.items.removeLast()
    }

    public fn size(): UInt {
        self.items.count()
    }

    public fn isEmpty(): Bool {
        self.items.isEmpty()
    }
}

The invariant here is: "size equals the number of items in the internal vector." By making items private and only exposing operations through methods, you guarantee that this invariant is always maintained. Users cannot bypass these operations to corrupt the internal state.

Encapsulation and Module Boundaries

Encapsulation works hand-in-hand with Oxide's module system. A struct doesn't need to explicitly mark fields as private if they're only used within the module - they're private by default. The module boundary provides the first level of encapsulation:

// In banking.rs module
struct InternalTransaction {
    // No 'public' keyword - private to the module
    timestamp: UInt64,
    amount: Decimal,
    description: String,
}

public struct Account {
    public id: String,
    private transactions: Vec<InternalTransaction>,
}

extension Account {
    public fn getTransactionHistory(): Vec<(UInt64, Decimal, String)> {
        // Convert private transactions to public data
        // This controls what information is exposed
        self.transactions.map { (t) in
            (t.timestamp, t.amount, t.description)
        }
    }
}

Comparison with Rust

In Rust, encapsulation is achieved with the pub keyword:

#![allow(unused)]
fn main() {
pub struct BankAccount {
    pub account_number: String,
    balance: Decimal,  // Private by default
}

impl BankAccount {
    pub fn new(account_number: String) -> Self {
        Self {
            account_number,
            balance: Decimal::new(0),
        }
    }

    pub fn deposit(&mut self, amount: Decimal) {
        if amount > Decimal::new(0) {
            self.balance = self.balance + amount;
        }
    }
}
}

The key difference in Oxide is the use of public instead of pub and the extension block syntax instead of impl. The underlying semantics are identical.

Summary

Encapsulation in Oxide is achieved through:

  • Private fields by default - Only what you explicitly mark as public is exposed
  • Public methods - Control how external code can interact with your types
  • Extension blocks - Organize methods logically and group related functionality
  • Invariant maintenance - Ensure that object state remains valid through controlled access
  • Module boundaries - First level of access control, allowing module-level privacy

By using these tools effectively, you create robust, maintainable code where types control their own state and guarantee their own correctness. In the next section, we'll explore how to achieve code reuse through composition rather than inheritance.