Skip to content

6. A shape hierarchy

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.

  1. WorseThe abstract class, translated: a ShapeBase struct, a base() method in every type, and as_any() downcasts for instanceof.

    /// 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 base
    fn 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)
    }
  2. OKA trait with area and an is_round default method, and Box<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)
    }
  3. 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:

  • ShapeBase is the abstract class’s fields. Rust traits cannot have fields, so every shape stores a base and implements base() to return it. The code repeats in every type.
  • as_any() is instanceof. To count circles, summary downcasts each shape to Circle. Add an Ellipse, 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. name and color are 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 the Vec, with no box.
  • area is one match. 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.
Each version compared with the Worse version
CriterionOKBetter
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.
  • 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 use Box<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 match blocks. With a trait, each shape’s code is in one impl. 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, use struct Drawn { color: Color, shape: Shape }. That is composition, not a base() method.
  • “Worse” has no case where it is the best choice. as_any() downcasts are a sign that the abstraction is wrong.