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 }
}
}
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
}
}
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(())
}
}
}
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;
}
}
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
andMutex
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)
Interesting. Thanks for sharing!