Polymorphism in Rust - Enum vs Trait & Struct

Rust isn’t an object-oriented language in the same way as C++ and Java. You cannot write classes that extend behavior from another class for example. However, Rust does support polymorphism and there are two common approaches.

For this blog post I’m going to use an example of writing a crate for generating svg (scalable vector graphics) files based on a list of shapes. Ideally, it should be possible for users of this library to add their own shapes.

The two approaches I am going to demonstrate are algebraic data types (enumerations) and structs combined with traits.

Polymorphism using Enumerations

Let’s look at enumerations first. We can define an enumeration to model all of our shapes like this (full source code for this example is available here.

#[derive(Debug)]
pub enum Shape {
    Circle { x: u32, y: u32, radius: u32, stroke_width: u32,color: Color},
    Rect { x: u32, y: u32, width: u32, height: u32, stroke_width: u32, color: Color },
    Line { x1: u32, x2: u32, y1: u32, y2: u32, stroke_width: u32, color: Color },
    PolyLine { points: Vec<Point>, stroke_width: u32, color: Color }
}

It is now possible to write a function that can generate an svg file given a vector of shapes.The function can use pattern matching to determine the behavior for each type of shape.

pub fn write_svg(shapes: Vec<Shape>) {
    println!("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>");
    println!("<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\">");
    for shape in &shapes {
        match shape {
            &Shape::Line { x1, y1, x2, y2, stroke_width, ref color } =>
                println!("<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke-width=\"{}\" stroke=\"{:?}\" />",
                         x1, y1, x2, y2, stroke_width, color),

            &Shape::Circle { x, y, radius, stroke_width, ref color } =>
                println!("<circle cx=\"{}\" cy=\"{}\" r=\"{}\" stroke-width=\"{}\" fill=\"{:?}\" />",
                         x, y, radius, stroke_width, color),

            &Shape::PolyLine { ref points, stroke_width, ref color } => {
                let points: String = points.iter()
                    .map( | p | format !("{},{}", p.x, p.y))
                    .fold(String::from(""), | a, b | format ! ("{} {}", a, b) );

                println !("<polyline points=\"{}\" stroke-width=\"{}\" stroke=\"{:?}\" fill=\"none\" />",
                    points, stroke_width, color);
            },
            &Shape::Rect { x, y, width, height, stroke_width, ref color } =>
                println!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" stroke-width=\"{}\" stroke=\"{:?}\" />",
                         x, y, width, height, stroke_width, color),

        }
    }
    println!("</svg>");
}

This looks pretty clean, but a user of this crate cannot add their own shapes since enumerations do not support inheritance and there is no other mechanism to add a new shape to the existing enumeration.

Polymorphism using Traits and Structs

The second approach is to use a trait to define some behavior and use structs to represent the shapes. This time we define a separate struct for each shape like this (full source code for this example is available here.

#[derive(Debug)]
pub struct Circle { pub x: u32, pub y: u32, pub radius: u32, pub stroke_width: u32, pub color: Color }

#[derive(Debug)]
pub struct Rect { pub x: u32, pub y: u32, pub width: u32, pub height: u32, pub stroke_width: u32, pub color: Color }

#[derive(Debug)]
pub struct Line { pub x1: u32, pub x2: u32, pub y1: u32, pub y2: u32, pub stroke_width: u32, pub color: Color }

#[derive(Debug)]
pub struct PolyLine { pub points: Vec<Point>, pub stroke_width: u32, pub color: Color }

We also define a trait. Rather than call the trait ‘Shape’ we will call it ‘SvgWriter’ since we are using the trait to define behavior rather than type.

pub trait SvgWriter {
    fn write(&self);
}

Next, we need to provide an implementation of this trait on each of our shapes.

impl SvgWriter for Circle {
    fn write(&self) {
        println!("<circle cx=\"{}\" cy=\"{}\" r=\"{}\" stroke-width=\"{}\" fill=\"{:?}\" />",
            self.x, self.y, self.radius, self.stroke_width, self.color
        );
    }
}

impl SvgWriter for Line {
    fn write(&self) {
        println!("<line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke-width=\"{}\" stroke=\"{:?}\" />",
                 self.x1, self.y1, self.x2, self.y2, self.stroke_width, self.color
        );
    }
}

impl SvgWriter for Rect {
    fn write(&self) {
        println!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" stroke-width=\"{}\" stroke=\"{:?}\" />",
                 self.x, self.y, self.width, self.height, self.stroke_width, self.color
        );
    }
}

impl SvgWriter for PolyLine {
    fn write(&self) {
        let points: String = self.points.iter()
            .map(|p| format!("{},{}", p.x, p.y))
            .fold(String::from(""), |a,b| format!("{} {}", a, b) );

        println!("<polyline points=\"{}\" stroke-width=\"{}\" stroke=\"{:?}\" fill=\"none\" />",
                 points, self.stroke_width, self.color
        );
    }
}

Finally, we can write our function to generate the svg file given a vector of shapes.

pub fn write_svg(shapes: Vec<Box<SvgWriter>>) {
    println!("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>");
    println!("<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\">");
    for shape in shapes.iter() {
        shape.write();
    }
    println!("</svg>");
}

The advantage of this approach over enumerations is that a user of this crate can go ahead and define new structs to implement custom shapes. For example, the user could create a shape that combines a square and a circle:

struct CustomShape {
    x: u32, y: u32, radius: u32, stroke_width: u32, color: Color
}

impl SvgWriter for CustomShape {
    fn write(&self) {
        let rect = Rect {
            x: self.x - self.radius,
            y: self.y - self.radius,
            width: self.radius * 2,
            height: self.radius * 2,
            stroke_width: self.stroke_width,
            color: self.color
        };
        let circle = Circle {
            x: self.x - self.radius,
            y: self.y - self.radius,
            radius: self.radius,
            stroke_width: self.stroke_width,
            color: self.color
        };
        rect.write();
        circle.write();

    }
}

The svg writer can now be invoked with this custom shape as follows. Note that because we’re using traits, we cannot simply add structs to the vector because one of the requirements of a vector is that all of its elements must be the same size on the stack and traits do not have a fixed size (the data types implementing the trait have a size and there could be multiple types with different sizes). To work around these, we put the data types that implement the trait into a “box”. This is effectively a fixed size pointer to the data type (it’s actually a “fat pointer” containing a pointer and a v-table, but that’s beyond the scope of this blog post). The boxed trait is often referred to as a “trait object”.

fn main() {

    let shapes : Vec<Box<SvgWriter>> = vec![
        Box::new(CustomShape{ x: 100, y: 100, radius: 50, stroke_width: 1, color: Color::Red }),
    ];

    write_svg(shapes);
}

Mixing and Matching Enums, Traits, and Structs

It is possible to mix these two approaches by adding an enum variant to model a user-defined shape and have that contain a trait object, as follows:

#[derive(Debug)]
pub enum Shape {
    Circle { x: u32, y: u32, radius: u32, stroke_width: u32,color: Color},
    Rect { x: u32, y: u32, width: u32, height: u32, stroke_width: u32, color: Color },
    Line { x1: u32, x2: u32, y1: u32, y2: u32, stroke_width: u32, color: Color },
    PolyLine { points: Vec<Point>, stroke_width: u32, color: Color },
    UserDefinedShape { impl: Box<SvgShape> }
}

I have used this approach before but I think it is generally best to stick to one approach or the other for a more consistent coding style and reduced complexity.

Conclusion

Enumerations make for very readable code with pattern matching but are not extensible. They make sense for internal structures that users of your crate do not need to add functionality to. Enumeration values can be passed around efficiently on the stack as well.

Traits provide more flexibility at the cost of placing objects on the heap and using dynamic dispatch instead of static dispatch. This cost is unlikely to be an issue for the majority of applications and is a price worth paying for the additional flexibility, in my opinion.

Resources

Recommended Reading

Recent Posts

Sponsored