Hiding the implementation

A type that shows its fields is a type a caller can break. Every rule about its values lives in the functions that use it, and every new caller has to know the rules. Hiding the fields moves the rules into the type: the only way to build it is a constructor that checks, and the only way to read it is a method that is part of the promise.

This lesson puts private fields behind public methods, refuses a temperature below absolute zero, hides a strategy behind a boxed trait the way C++ hides a class behind a pointer, hands out an opaque type from a module, and ends with the compiler error for the field a caller tried to read.

Private fields and public methods

A field without pub is visible inside the module that declares it and nowhere else. The struct below lives in a module of its own, so main can name the type and cannot name its field: the balance is reachable through balance only.

The two methods are the whole interface. deposit takes &mut self because it changes the value, and balance takes &self because it only reads.

mod wallet { pub struct Wallet { cents: i64, } impl Wallet { pub fn new(cents: i64) -> Wallet { Wallet { cents } } pub fn deposit(&mut self, amount: i64) { self.cents += amount; } pub fn balance(&self) -> i64 { self.cents } } } fn main() { let mut wallet = wallet::Wallet::new(500); wallet.deposit(250); println!("{}", wallet.balance()); }

rustc --edition 2024 wallet.rs
./wallet

750

The module boundary is the boundary of the promise. A change from i64 cents to two fields, or to a decimal type, breaks nothing outside the module as long as the three methods keep their signatures. The lesson on modules covers pub and the privacy rules that the compiler applies at every level.

A value that checks itself

Once the field is private the constructor can refuse. That turns a rule that used to be a comment into something the type guarantees: every Temperature that exists is above absolute zero, because the only way to make one is a function that returns Option.

The program asks for a warm value and for one that cannot exist. The first prints in both units, and the second is None.

struct Temperature { celsius: f64, } impl Temperature { pub fn from_celsius(celsius: f64) -> Option<Temperature> { if celsius < -273.15 { None } else { Some(Temperature { celsius }) } } pub fn celsius(&self) -> f64 { self.celsius } pub fn fahrenheit(&self) -> f64 { self.celsius * 9.0 / 5.0 + 32.0 } } fn main() { let warm = Temperature::from_celsius(21.5).unwrap(); println!("{} {}", warm.celsius(), warm.fahrenheit()); println!("{}", Temperature::from_celsius(-300.0).is_none()); }

rustc --edition 2024 temp.rs
./temp

21.5 70.7
true

A struct in the same module as the constructor could still write the field, which is why the type and its constructor usually live together in a module of their own. The lesson on Option is the reason the constructor returns one instead of panicking: a caller that has a wrong number writes the error message it wants.

The same shape is how a newtype enforces a unit. A struct Meters(f64) with a private field cannot be confused with struct Feet(f64) even when both wrap the same number, and the compiler rejects the mix at the call site.

A newtype like that should not implement Deref. The trait would let a Meters be used anywhere an f64 is expected, and the unit would disappear at exactly the line where it matters. A conversion is the honest interface: a method that returns the number, and a constructor that takes one, so the two units are converted on purpose rather than by accident.

Box<dyn Trait> as the pimpl

C++ hides a class by holding a pointer to a forward-declared implementation, which keeps the header small and the build fast. Rust has no forward declaration, and the equivalent is a field of type Box<dyn Trait>: the struct shows the behaviour and hides which type provides it.

The program has two engines and a document that owns one of them. The constructor picks the engine from a string, and the two calls show the same method on two different types.

trait Engine { fn render(&self, text: &str) -> String; } struct Markdown; impl Engine for Markdown { fn render(&self, text: &str) -> String { format!("<p>{text}</p>") } } struct Escape; impl Engine for Escape { fn render(&self, text: &str) -> String { text.replace('<', "&lt;").replace('>', "&gt;") } } pub struct Document { engine: Box<dyn Engine>, } impl Document { pub fn new(kind: &str) -> Document { let engine: Box<dyn Engine> = match kind { "escape" => Box::new(Escape), _ => Box::new(Markdown), }; Document { engine } } pub fn render(&self, text: &str) -> String { self.engine.render(text) } } fn main() { let markdown = Document::new("markdown"); let escape = Document::new("escape"); println!("{}", markdown.render("hi")); println!("{}", escape.render("<hi>")); }

rustc --edition 2024 pimpl.rs
./pimpl

<p>hi</p>
&lt;hi&gt;

An enum of engines would also hide the choice and would keep the value on the stack, at the price of a match in every method and a size equal to the largest variant. The box wins when the set of engines is open or large, and the enum wins when it is closed and small. Neither shape lets the caller see which engine the document holds, which is the point.

The caller writes Document and never names an engine, so a new one is an added type and a line in the match rather than a change at every call site. The cost is one allocation and one virtual call per render, which is the same cost the C++ pimpl pays. The lesson on trait objects covers the vtable; Box covers the heap value.

An opaque type from a module

A module can go further and never expose the type at all: a function returns it and the caller keeps it in a variable, prints it or passes it back, and cannot spell its fields or construct it.

The slug module builds an identifier from a title. The type is public because the function returns it; the field is private, so the only text a slug can hold is the text its own builder produced.

mod slug { pub struct Slug { text: String, } pub fn make(input: &str) -> Slug { let mut text = String::new(); let mut last_dash = true; for c in input.chars() { if c.is_ascii_alphanumeric() { text.push(c.to_ascii_lowercase()); last_dash = false; } else if !last_dash { text.push('-'); last_dash = true; } } if text.ends_with('-') { text.pop(); } Slug { text } } impl Slug { pub fn as_str(&self) -> &str { &self.text } } } fn main() { let slug = slug::make("Hello, Rust World!"); println!("{}", slug.as_str()); }

rustc --edition 2024 slug.rs
./slug

hello-rust-world

The module is also the unit of testing. A test inside it can build a Slug from a string and read the private field, so the invariant has one test that does not go through the public interface. A caller outside has the four methods and no way to break the rule, and that is the whole difference between a comment that asks for a rule and a type that enforces one.

A hyphen collapse rule, a trailing separator and the case of the letters are decisions the caller never sees and never repeats. The module keeps one invariant in one function, and a test of that function is enough. The lesson on strings and UTF-8 covers chars and why the loop is over characters rather than bytes.

The field that is private

The error for a field read from outside the module is E0616, and it names the field and the struct. No layout reaches the caller, so a field that is private really is unreadable rather than merely discouraged.

mod wallet { pub struct Wallet { cents: i64, } impl Wallet { pub fn new() -> Wallet { Wallet { cents: 0 } } } } fn main() { let wallet = wallet::Wallet::new(); println!("{}", wallet.cents); }

rustc --edition 2024 private_err.rs

error[E0616]: field `cents` of struct `Wallet` is private
--> private_err.rs:15:27
|
15 | println!("{}", wallet.cents);
| ^^^^^ private field

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0616`.

The fix is a method, which is the first program of this lesson. A field that a caller must read is a field the type should have turned into an accessor, and the error is the compiler pointing at the missing part of the interface. The next lesson takes the other route: keeping the type open but erasing which one it is.

Exercises

  1. Add a withdraw method that returns Result and refuses to go below zero.
  2. Turn the temperature into a newtype by unit (Celsius and Fahrenheit) and make the compiler reject a mix.
  3. Add a third engine to the document and change nothing outside the module.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What makes a field private in Rust?

    Answer

    The absence of pub on the field

    Privacy is the default; pub opens a field or an item to other modules.

  2. Why is the constructor the right place for a validity check?

    Answer

    It is the only way to create the value, so every instance has passed the check

    With the fields private, no caller can build an instance that skipped the check.

  3. What does a Box&lt;dyn Trait&gt; field hide?

    Answer

    The name and the type of the implementation behind the trait

    The caller sees the trait and the box, and which concrete type provides it stays inside.

  4. What does an opaque type from a module let the caller do?

    Answer

    Hold it, pass it on and call the public methods

    The type is nameable but its fields are not, so the module keeps control of every value.

  5. What is the cost of the pimpl shape in Rust?

    Answer

    One allocation and one virtual call per method

    The box owns a heap value and the call goes through the vtable, as it does in C++.

  6. What does this program print? mod counter { pub struct Counter { value: u32, } impl Counter { pub fn new() -> Counter { Counter { value: 0 } } pub fn tick(&mut self) { self.value += 2; } pub fn value(&self) -> u32 { self.value } } } fn main() { let mut counter = counter::Counter::new(); counter.tick(); counter.tick(); println!("{}", counter.value()); }

    Answer

    4

    Two ticks of two reach four, and the call goes through the public method.

  7. What does this program print? struct Meters(f64); impl Meters { fn new(value: f64) -> Meters { Meters(value) } fn value(&self) -> f64 { self.0 } } fn main() { let distance = Meters::new(12.5); println!("{}", distance.value() * 2.0); }

    Answer

    25

    The method reads the private field inside the module, and the caller doubles it.

  8. What does this program print? mod slug { pub struct Slug { text: String, } pub fn make(input: &str) -> Slug { Slug { text: input.to_lowercase().replace(' ', "-") } } impl Slug { pub fn as_str(&self) -> &str { &self.text } } } fn main() { println!("{}", slug::make("Rust Course").as_str()); }

    Answer

    rust-course

    The builder lowercases the text and replaces the space with a hyphen.

  9. What does this program print? struct Temperature { celsius: f64, } impl Temperature { fn from_celsius(celsius: f64) -> Option<Temperature> { if celsius < -273.15 { None } else { Some(Temperature { celsius }) } } } fn main() { let values = [20.0, -300.0, 0.0]; let count = values.iter().filter(|v| Temperature::from_celsius(**v).is_some()).count(); println!("{count}"); }

    Answer

    2

    Only the value below absolute zero is refused, so two of the three are Some.

  10. What is the first line of the compiler output for this program? mod wallet { pub struct Wallet { cents: i64, } impl Wallet { pub fn new() -> Wallet { Wallet { cents: 0 } } } } fn main() { let wallet = wallet::Wallet::new(); println!("{}", wallet.cents); }

    Answer

    error[E0616]: field cents of struct Wallet is private

    The struct is public and its field is not, and E0616 names the field.

The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev