Despite some popular anti-OOP takes, there is a lot of overlap between basic OOP and good Rust design. By “basic” I mean concepts more like encapsulation — private fields and methods used as an interface to perform only valid operations on those fields — and less like high-level application architecture. Don’t feel you need to throw these concepts out.
- When the data is inconvenient to pass around separately because it has too many parts.
- When there is an invariant that should be maintained between parts of the data, and encapsulation can maintain that invariant.
- When the data’s original type is too broad and a struct can give it useful type-safety (“newtype pattern”):
struct RecordId(u64); and such.
Getting this truly right is a very tricky subject, because you're balancing three considerations where most languages have only two:
- Useful subdivisions of the program’s data that help the code be understandable
- Encapsulation boundaries around invariants
- Avoiding overly-broad borrows (unique to Rust)
There are no simple universal rules that can be clearly stated, here. Write what seems good, see how well it works, and be prepared to refactor. Never assume you can get the design right up front.
One Rust-specific thing to keep in mind is that it is often useful to have more than one data type where you might initially think you only need one. This is often the case when you have enums involved in a way that you might otherwise write as an inheritance hierarchy:
pub struct Shape {
pub origin: Point,
pub kind: ShapeKind,
}
pub enum ShapeKind {
Circle { radius: f64 },
Polygon(Vec<Point>),
Text(String),
}
Common beginner mistakes are to either define separate structs for Circle, Polygon, and Text, or to use an enum with an origin field in every variant. Both of those will result in more code duplication than the above design.
Another case where two structs are often useful is when sharing something mutable; you’ll often need an Arc and a Mutex, and when that’s true, a useful encapsulation pattern is to put the Mutex and maybe the Arc inside your struct rather than letting it be accessed from outside, so that all access goes through your public methods.
#[derive(Clone)]
pub struct Database(Arc<Mutex<Inner>>);
struct Inner {
records: HashMap<Id, Record>,
}
There are many more uses for defining multiple types that make up a single abstraction.
The right way to organize a program depends on where the complexity lies in that program; there is no single general principle. (There isn’t in OOP either; a lot of “the bad kind of OOP” is principles with fancy names getting over-enthusiastically applied when they don’t make the actual problem at hand better.)
When your organization is by topic, you do that with modules instead of classes. I mentioned above that there are often reasons to create multiple closely related types — those types go in modules.