Handle this scenario without inheritance?

Suppose I start with this Shape type:

struct Shape {
  vertices: Vec<(i32, i32)>
}

impl Shape {
  fn perimeter(&self) {...}
  fn area(&self) {...}
}

and now I want to add methods to calculate the three different centers of a triangle, but only for triangles. With inheritance, I would extend Shape, restrict it to three vertices, and simply add the methods. What's the most elegant, idiomatic way to tackle this problem in Rust, without inheritance?

You would make Shape a trait, and implement it for every "shape", like Triangle.

3 Likes

To take a stab at answering myself: I might make Shape a trait with the perimeter and area methods undefined, and make a Triangle struct that implements that trait while also providing those extra center methods. I would do this knowing that the area implementation for other shapes would invoke the triangle area method along the way. I would then make a ComplexShape struct with the alternative implementation of area().

Define your triangle type and store the polygonal Shape as a member:

struct Triangle(Shape);

impl Triangle {
    fn circumcenter(&self) -> Point { todo!() }
}
4 Likes

If you have a limited amount of shapes, you can also use an enum. An easy way to understand the differences between enums and traits is:

  • Enums are for countable infinite number of variants (you know them beforehand)
  • Traits are for uncountable infinite number of variants (you don't know them beforehand)
1 Like

Another option:

type Point = (i32, i32);

struct Shape<V:?Sized = [Point]> {
    vertices: V
}

impl<V> Shape<V> where V:AsRef<[Point]> {
    fn perimeter(&self) {}
    fn area(&self) {}
}

impl Shape<[Point;3]> {
    fn circumcenter(&self) -> Point { todo!() }
}
5 Likes

On the one hand, this example is one of oop's great promises: reusability. On the other, this example is also a trap that many oop devs fall into. If it's possible to add more than 3 vertices to a shape, but it's not possible with a triangle, then a triangle isn't a shape. One possible fix: move the vertices out of shape and give it a pure virtual instead, probably one that returns an immutable slice (mutable would turn a square into something else). The result is equivalent to this:

An approach that I've found to work really well is to introduce a trait for each "property" that your geometric objects may have.

For example, ages ago I was working on a CAD engine called arcs. There were several algorithms, each with their own traits:

pub trait AffineTransformable<Space = DrawingSpace> {
    fn transform(&mut self, transform: Transform2D<f64, Space, Space>);
}

pub trait Translate {
    fn translate(&mut self, displacement: Vector);
}

pub trait Bounded {
    fn bounding_box(&self) -> BoundingBox;
}

pub trait Approximate {
    type Iter: Iterator<Item = Point>;
    fn approximate(&self, tolerance: f64) -> Self::Iter;
}

pub trait Length {
    fn length(&self) -> f64;
}

pub trait Scale {
    fn scale(&mut self, scale_factor: f64);
}

I found this approach worked really well.

It's even better than making one all-encompassing DrawingObject trait because if something implements AffineTransformable, it'll pick up implementations for scale, rotations, flips, and transformations for free.

Similarly, there might be traits like ClosestPoint that are only implementable for simple objects like points, lines, and circles. If you want to calculate the closest point for something complex like a spline, you'd need to explicitly approximate the spline (which is expensive and therefore shouldn't happen implicitly) and do closest point on each of the line segments.

Each type of object (lines, points, circles, etc.) got its own custom struct and didn't try to reuse internal details from other primitives - that way you don't fall into the "is a square a rectangle or is a rectangle a type of square?" problem.

5 Likes

All the answers so far have provided static or up-front approaches, where triangles are a different type or distinguished via an enum. However, what if you want to be able to build a polygon and then, if it is a triangle, compute triangle properties? In that case, it would make sense to use an optional operation.

#[derive(Debug)]
pub struct Shape {
  vertices: Vec<(i32, i32)>,
}

impl Shape {
    pub fn as_triangle(&self) -> Option<Triangle> {
        let vertices: [(i32, i32); 3] =
            self.vertices.as_slice().try_into().ok()?;
        Some(Triangle { vertices })
    }
}

#[derive(Clone, Copy, Debug)]
pub struct Triangle {
    vertices: [(i32, i32); 3],
}

impl Triangle {
    pub fn circumcenter(&self) -> (i32, i32) {
        todo!()
    }
}

This way you can have just a Vec<Shape> for a collection of shapes, and there is no requirement that triangles must be handled distinctly until there is a reason to care. Checking if a shape is a triangle is checking the Vec's length, which is basically the same as checking an enum discriminant.

5 Likes

Ooh, I like this. Turning a Shape into a Triangle dynamically is cool. It could also have a method to turn it back into a Shape. It would be especially useful if triangles could be implicitly casted back into shapes, sort of like upcasting. Is there a trait for implicit casting like that? It seems like there could be, implementing it with the type you want to cast into as a generic. The compiler would simply have to execute the conversion instead of generating an error when a Triangle is passed as a Shape parameter. Even if the conversion must be explicit, it would still be useful to implement a trait that enables type casting via an as expression.

If Shape were a trait then you can coerce Triangle into dyn Shape. But there is no implicit conversion of concrete types. In general, Rust won't ever do potentially-expensive things like that implicitly (in this case, it would have to allocate a new Vec of points).

Rust code solves this problem via impl From<Triangle> for Shape.

After some thought, here's my preferred solution: Shape should be a trait with the two methods. The perimeter and area methods are reused a lot across implementations, but that code reuse can be handled with dependency injection a la the strategy pattern. OOP programmers, note this separation of common interface and code reuse. This may feel like inheritance with extra steps, right up until you encounter the need for another perimeter or area calculation for a new kind of shape, and then it will be easy to add without the need to override anything or refactor anything major.

3 Likes