6. A shape hierarchy
The task
Section titled “The task”A drawing has circles and rectangles. Compute the total area of the drawing, and count the round shapes.
This is the classic Java exercise: abstract class Shape with Circle extends Shape and Rectangle extends Shape.
The versions
Section titled “The versions”WorseThe abstract class, translated: a
ShapeBasestruct, abase()method in every type, andas_any()downcasts forinstanceof./// The "abstract class": shared fields.pub struct ShapeBase {pub name: String,pub color: String,}pub trait Shape {fn base(&self) -> &ShapeBase; // every subclass must return its basefn area(&self) -> f64;fn as_any(&self) -> &dyn Any; // to imitate `instanceof`fn name(&self) -> &str {&self.base().name}}pub struct Circle {pub base: ShapeBase,pub radius: f64,}pub struct Rectangle {pub base: ShapeBase,pub width: f64,pub height: f64,}impl Shape for Circle {fn base(&self) -> &ShapeBase {&self.base}fn area(&self) -> f64 {PI * self.radius * self.radius}fn as_any(&self) -> &dyn Any {self}}impl Shape for Rectangle {fn base(&self) -> &ShapeBase {&self.base}fn area(&self) -> f64 {self.width * self.height}fn as_any(&self) -> &dyn Any {self}}pub fn summary(shapes: &[Box<dyn Shape>]) -> (f64, usize) {let area = shapes.iter().map(|s| s.area()).sum();let round = shapes.iter().filter(|s| s.as_any().downcast_ref::<Circle>().is_some()).count();(area, round)}OKA trait with
areaand anis_rounddefault method, andBox<dyn Shape>. No downcasts.pub trait Shape {fn area(&self) -> f64;fn is_round(&self) -> bool {false}}pub struct Circle {pub radius: f64,}pub struct Rectangle {pub width: f64,pub height: f64,}impl Shape for Circle {fn area(&self) -> f64 {PI * self.radius * self.radius}fn is_round(&self) -> bool {true}}impl Shape for Rectangle {fn area(&self) -> f64 {self.width * self.height}}pub fn summary(shapes: &[Box<dyn Shape>]) -> (f64, usize) {let area = shapes.iter().map(|s| s.area()).sum();let round = shapes.iter().filter(|s| s.is_round()).count();(area, round)}BetterAn enum with one variant per shape, and
match. No boxes, no vtables, no traits.#[derive(Debug, Clone, Copy, PartialEq)]pub enum Shape {Circle { radius: f64 },Rectangle { width: f64, height: f64 },}impl Shape {pub fn area(&self) -> f64 {match self {Self::Circle { radius } => PI * radius * radius,Self::Rectangle { width, height } => width * height,}}}pub fn summary(shapes: &[Shape]) -> (f64, usize) {let area = shapes.iter().map(Shape::area).sum();let round = shapes.iter().filter(|s| matches!(s, Shape::Circle { .. })).count();(area, round)}
The “Worse” version imitates each part of the Java design:
ShapeBaseis the abstract class’s fields. Rust traits cannot have fields, so every shape stores abaseand implementsbase()to return it. The code repeats in every type.as_any()isinstanceof. To count circles,summarydowncasts each shape toCircle. Add anEllipse, and it is not counted as round. The compiler says nothing, because a downcast that fails is not an error.- The fields are never used.
nameandcolorare in the task only because the Java class had them.
See S13: Trait hierarchy that copies a Java class hierarchy.
The “OK” version asks each shape the question instead: is_round() is a trait method with a default of false, and Circle overrides it. This is good Java style too (“tell, don’t ask”). It needs no downcasts, and a new shape type can be added in another module. The cost is Box<dyn Shape>: one heap allocation per shape, and a dynamic call for each area.
The “Better” version uses an enum, because the set of shapes is closed: the drawing has circles and rectangles, and nothing else.
- The data is in the variants.
Shape::Circle { radius: 1.0 }is a plain value in theVec, with no box. areais onematch. All the formulas are in one place, and the compiler checks that every shape has one.- A new shape is a compile error in every
match, until each one handles it. In “Worse” and “OK”, a new type gets the default behavior silently.
Verdict
Section titled “Verdict”| Criterion | OK | Better |
|---|---|---|
| Correctness | Better | Better |
All three give the same result today. "Worse" counts only Circle as round, and a new round type is silently wrong. | ||
| Failure behavior | Same | Same |
| Nothing can fail. | ||
| Performance | Same | Better |
"Worse" and "OK" allocate one Box per shape and call through a vtable. "Better" stores the shapes inline in the Vec. | ||
| Readability | Better | Better |
| "Worse" spreads one concept over a struct, a trait, and two downcast-ready impls. "Better" shows all shapes and formulas on one screen. | ||
| API ergonomics | Better | Better |
"OK" lets other modules and crates add shapes. "Better" does not, but its values are Copy and easy to build in tests. | ||
Trade-offs
Section titled “Trade-offs”- The enum is closed. A library whose users must add their own shapes needs a trait. Choose “OK” then, and ask if a generic (
fn total<S: Shape>(shapes: &[S])) works before you useBox<dyn>. See S22:Box<dyn Trait>where a generic or enum is sufficient. - The trait is better when each type has many methods. With ten operations and three shapes, an enum has ten
matchblocks. With a trait, each shape’s code is in oneimpl. Both are fine; choose the one that groups the code that changes together. - Shared fields go in a struct, not a base. If every shape needs a
color, usestruct Drawn { color: Color, shape: Shape }. That is composition, not abase()method. - “Worse” has no case where it is the best choice.
as_any()downcasts are a sign that the abstraction is wrong.