DEV Community

Gregory Chris
Gregory Chris

Posted on

Making Your Structs Private but Usable

Making Your Structs Private but Usable: Encapsulate Implementation Details while Exposing Only What’s Needed

Rust developers quickly learn to love its strong focus on safety and encapsulation. But sometimes, finding the right balance between exposing functionality and hiding implementation details can feel like walking a tightrope. How do you design your structs so they’re usable without accidentally exposing sensitive details or allowing misuse? That’s what we’re here to tackle.

In this blog post, we’ll explore how to make your structs private but usable, leveraging techniques like pub(crate) visibility and smart constructors to enforce invariants. Along the way, I’ll explain concepts with practical examples, share real-world analogies, and highlight common pitfalls to avoid. Let’s dive in!


Why Encapsulation Matters

Encapsulation is all about controlling access to your data and ensuring it can only be used in a safe, consistent way. In Rust, this means keeping parts of your struct private while exposing only the behaviors and data that other parts of your code (or external users) need.

Why does this matter? Let’s consider a real-world analogy: Imagine designing a vending machine. You expose buttons for choosing snacks and a slot for inserting coins, but you wouldn’t want to expose the internal wiring or mechanisms to users. They don’t need direct access to the machine’s internals to use it effectively—and exposing them might even lead to misuse or damage.

Similarly, in Rust, exposing the internals of a struct (like fields) without proper safeguards can lead to bugs, broken invariants, or fragile codebases down the line.


Designing Private Structs: The Basics

Understanding Visibility in Rust

Rust has a hierarchical visibility system that controls access to items like structs, fields, and functions. Here’s a quick refresher:

  • pub: Makes the item public and accessible everywhere.
  • pub(crate): Restricts access to the current crate.
  • pub(super): Limits access to the parent module.
  • No modifier: Makes the item private to the current module.

When designing private structs, you’ll often rely on pub(crate) or no visibility modifier to hide implementation details.


Example: A Safe Temperature Struct

Let’s start with a simple example: a Temperature struct that represents a temperature in Celsius. We want users to create and interact with temperatures safely—without directly modifying the value in ways that could break our logic.

Bad Approach: Exposing Everything

Here’s what NOT to do:

pub struct Temperature {
    pub value: f64, // Completely exposed!
}

impl Temperature {
    pub fn new(value: f64) -> Self {
        Self { value }
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, anyone can directly access and modify the value field, potentially breaking invariants. For instance, what if we want to ensure temperatures are always above absolute zero (-273.15°C)? This code doesn’t enforce that.

Better Approach: Encapsulation with Private Fields

Instead, we keep the value field private and expose only safe ways to work with the struct:

pub struct Temperature {
    value: f64, // Private field
}

impl Temperature {
    // Smart constructor to enforce invariants
    pub fn new(value: f64) -> Option<Self> {
        if value >= -273.15 {
            Some(Self { value })
        } else {
            None // Invalid temperature
        }
    }

    // Public getter to expose the value safely
    pub fn value(&self) -> f64 {
        self.value
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, the value field is private, and the new constructor ensures that temperatures below absolute zero cannot exist. We’ve encapsulated the implementation details while exposing safe ways to interact with the struct.


Leveraging pub(crate) for Fine-Grained Control

Sometimes, you want to expose functionality to your crate but hide it from external users. This is where pub(crate) shines. It’s perfect for building APIs that are internally flexible but externally strict.

Example: A Bank Account Struct

Imagine a BankAccount struct designed for a financial application. We need to keep transaction details private while allowing internal modules to modify balances.

pub struct BankAccount {
    account_number: String,
    balance: f64, // Encapsulated balance
}

impl BankAccount {
    pub fn new(account_number: String, initial_balance: f64) -> Self {
        Self {
            account_number,
            balance: initial_balance.max(0.0), // Ensure non-negative balance
        }
    }

    pub fn balance(&self) -> f64 {
        self.balance
    }

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

    pub fn withdraw(&mut self, amount: f64) -> Result<(), String> {
        if amount > self.balance {
            Err(String::from("Insufficient funds"))
        } else {
            self.balance -= amount;
            Ok(())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

But what if you need additional functionality that shouldn’t be exposed to external users? For example, resetting balances in testing scenarios. You can use pub(crate):

impl BankAccount {
    pub(crate) fn reset_balance(&mut self) {
        self.balance = 0.0;
    }
}
Enter fullscreen mode Exit fullscreen mode

This method is accessible within the crate but hidden from external users. It’s a great way to maintain control.


Common Pitfalls and How to Avoid Them

1. Overexposing Fields

It’s tempting to mark fields as pub for convenience, but resist the urge! Always ask: “Should external code really have direct access to this field?” Use private fields and controlled access methods instead.

2. Ignoring Invariants

Your struct might have logical rules (invariants) that must always hold true. For instance, a Temperature should never be below absolute zero. Use smart constructors and validation to enforce these rules.

3. Overusing pub(crate)

While pub(crate) is powerful, overusing it can lead to tight coupling between modules. Ensure it’s justified, and avoid creating a “spaghetti” API where everything is accessible internally.

4. Forgetting Documentation

Encapsulation isn’t just about code—it’s also about communication. Document your structs, methods, and invariants clearly so users understand the intended usage.


Key Takeaways

  • Encapsulation is essential for safety and maintainability. Keep fields private and expose only what’s needed.
  • Use smart constructors to enforce invariants and ensure your structs are always in a valid state.
  • Leverage pub(crate) for internal flexibility while maintaining a clean external API.
  • Avoid common pitfalls like overexposing fields, ignoring invariants, and overusing pub(crate).

Next Steps for Learning

  • Explore advanced Rust features like RefCell and Mutex for runtime mutability within encapsulated structs.
  • Dive deeper into module organization and visibility with Rust’s mod system.
  • Experiment with building libraries that use pub(crate) to create clean, well-encapsulated APIs.

Encapsulation is a powerful tool in your Rust toolkit. By designing private-but-usable structs, you can create code that’s safe, maintainable, and a joy to work with. Happy coding, Rustaceans!


Feel free to share your thoughts or questions in the comments—let’s keep the conversation going!

Top comments (1)

Collapse
 
andriy_ovcharov_312ead391 profile image
Andriy Ovcharov

Interesting. Thanks for sharing!