2

Context

Let's say I have a Shape class (I'll use C# for the code snippets) that represents a 2D shape, like a triangle or a circle. These shapes have an area, so I'll include a method for calculating it. Since I only care about the calculations, I don't want the Shape class or any derived class to contain any data. Instead, I work with flexible method arguments that each have a different implementation to get the same result.

For example: the area of a triangle can be calculated from its base and height, but also from its three side lengths using Heron's formula.

public interface IAreaArguments
{
    // Can also be empty, it's still independent of implementation
}

public interface ITriangleAreaArguments : IAreaArguments
{
    // Can yet again be empty, it's still independent of implementation
}
public class StandardTriangleAreaArguments : ITriangleAreaArguments
{
    public StandardTriangleAreaArguments(double triangleBase, double triangleHeight)
        => (Base, Height) = (triangleBase, triangleHeight);

    public double Base { get; set; }
    public double Height { get; set; }
}

public class HeronTriangleAreaArguments : ITriangleAreaArguments
{
    public HeronTriangleAreaArguments(double side1, double side2, double side3)
        => (Side1, Side2, Side3) = (side1, side2, side3);

    public double Side1 { get; set; }
    public double Side2 { get; set; }
    public double Side3 { get; set; }
}

And the Triangle class could contain some overridden function GetArea(IAreaArguments areaArgs) that can be implemented like this:

public class Triangle : Shape
{
    // Base area calculation from "Shape"
    public override double GetArea(IAreaArguments areaArguments)
        => GetArea(areaArguments as ITriangleAreaArguments);

    // Specific area calculation for "Triangle"
    private static double GetArea(ITriangleAreaArguments? areaArguments)
        => areaArguments switch
        {
            StandardTriangleAreaArguments stdArgs => GetStandardArea(stdArgs),
            HeronTriangleAreaArguments heronArgs => GetHeronArea(heronArgs),
            _ => throw new ArgumentException("The given arguments do not implement ITriangleAreaArguments or there was no corresponding method inside Triangle to calculate the area using the given arguments.", nameof(areaArguments))
        };
    
    // Area from base and height
    private static double GetStandardArea(StandardTriangleAreaArguments stdArgs)
    {
        var (b, h) = (stdArgs.Base, stdArgs.Height);
        return b * h / 2;
    }

    // Area from side lengths
    private static double GetHeronArea(HeronTriangleAreaArguments heronArgs)
    {
        var (a, b, c) = (heronArgs.Side1, heronArgs.Side2, heronArgs.Side3);
        double s = (a + b + c) / 2;
        return Math.Sqrt(s * (s - a) * (s - b) * (s - c));
    }
}

This way, the same function can be used to return the area of a triangle using different solution strategies, and if you use the data from one triangle converted into both arguments, the result should obviously be the same.

Question

In the scenario above, I hide all the actual implementations from the person who consumes my class, but what if there existed some other method (turns out there's quite a lot) for calculating the area of a triangle with different input parameters? How could I change my implementation to allow new implementations to be added to the class, while still having a single entry point to calculate it (i.e. it only depends on the type of IAreaArguments that you pass into GetArea).

Would that even be a good programming style?

8
  • 6
    What's your actual goal here? It seems you are contorting yourself to avoid writing abstract double GetArea() in IAreaArguments. Jan 11, 2022 at 12:34
  • 3
    This seems like a lot of work to avoid having normal virtual dispatch do the job for you - or alternatively just provide a bunch of free functions?
    – pjc50
    Jan 11, 2022 at 12:52
  • What you have here seems to be some convoluted/inverted version of the Visitor pattern, where GetArea() plays the role of the Visit() method, and different types of arguments correspond to different Elements (I'm using the Visitor pattern terminology), and your different Shapes (which really model operations) correspond to different concrete Visitors (operations). It's just that you're dispatching manually, via a switch statement. But it doesn't quite work, because you cannot use those same arguments for other kinds of shapes, nor can you easily add new argument types / calculation methods. Jan 11, 2022 at 13:46
  • @PhilipKendall I believe I've used a bad name for my class in the question, my goal is to have a generalised "shape calculator" that calculates properties of a shape using implementations specific to that shape. Jan 11, 2022 at 14:56
  • @FilipMilovanović I hadn't heard of that pattern before, it sounds like it would be a better fit than what I'm currently doing, especially if I can encorporate Greg's strategy in his answer below. Definitely checking it out, thanks! Jan 11, 2022 at 15:12

2 Answers 2

3

Putting aside the question whether it would be simpler to design Shape and Triangle as classes with state (or not), let us look at your approach of creating individual calculators.

What strikes me first here is the separation of a class like HeronTriangleAreaArguments (with specific parameters) and the related calculation Triangle.GetHeronArea. I think this is the core point where the design should be improved: each specific calculation requires specific arguments, so it makes IMHO absolutely no sense to split them up. Instead, better rename HeronTriangleAreaArguments to HeronTriangleAreaCalculator, and implement construction as well as area calculation in this class.

public class HeronTriangleAreaCalculator : ShapeCalculator
{
    public HeronTriangleAreaCalculator (double side1, double side2, double side3)
        => (Side1, Side2, Side3) = (side1, side2, side3);

    public double Side1 { get; set; }
    public double Side2 { get; set; }
    public double Side3 { get; set; }

    public override double GetArea()
    {
        var (a, b, c) = (Side1, Side2, Side3);
        double s = (a + b + c) / 2;
        return Math.Sqrt(s * (s - a) * (s - b) * (s - c));
    }
}

As you see, I introduced also a common base class (or interface) ShapeCalculator, with an abstract method GetArea. You may choose to create your inheritance hierarchy a little bit differently, maybe using an additional class TriangleCalculator, or some ShapeAreaCalculator if this will be used just for area calculation and nothing else, but I guess you get the idea.

This design makes it possible to use ShapeCalculator in some generic context, just calling an abstract area calculation, and still have a specific part in your program (maybe some factory) which does the construction process, knowing exactly about the kind of parameters and the related type of calculation. And if new calculations need to be added, you just need to extend the factory, but don't have to change the generic part.

5
  • I personally still don't see how this is different to defining different representations of a triangle. Don't get me wrong, this implementation is flexible and it makes sense, but in hindsight I'm not really sure what I'm even asking anymore. I just got so tripped up over what a triangle should be or what a shape calculator should do. Jan 12, 2022 at 15:38
  • @JansthcirlU: you were the one who asked for an isolated triangle area calculation in favor of a full-blown triangle class, not me ;-) But a triangle class would probably require a more complex implementation, like taking the different parameter sets and converting it into some kind of normalized form. Moreover, wouldn't a triangle shape require some coordinates to be useful in your system? An isolated area calculation does not need that.
    – Doc Brown
    Jan 12, 2022 at 15:47
  • Yeah, I'm just gonna have a good long think about what I wanted to accomplish exactly. Unfortunately your answer and Greg's are quite similar in spirit so I'm leaning towards accepting his because he was faster. I appreciate your feedback though! Jan 12, 2022 at 15:48
  • @JansthcirlU: actually, I think Greg's answer has gone off-track with his last edit. The way he tried to use the strategy pattern does in no way solve the issue that the different formulas require different input parameters. Once you have a triangle object with a normalized internal representation, picking different approaches to calculate the area becomes pretty obsolete.
    – Doc Brown
    Jan 12, 2022 at 15:52
  • You make a good point. Jan 13, 2022 at 11:00
7

I would expect the sides of a triangle to be owned by the Triangle object. I find it very odd to pass in the sides as arguments to GetArea. This is more apparent when writing the code that uses your classes:

Shape shape = new Triangle(20, 13, 32);

var args = new HeronTriangleAreaArguments(20, 13, 32);
var area = shape.GetArea(args);

Since the length of the sides define a triangle, I would expect the resulting Shape object to calculate the area based on its own internal state:

Shape shape = new Triangle(20, 13, 32);
double area = shape.GetArea();

The algorithm for determining area varies by shape, and therefore varies by derived type. I would expect the Shape class to have an abstract GetArea() method which is implemented in Triangle. This makes using the object more obvious and prevents silly programmer mistakes, like passing the wrong kind of arguments to GetArea().

Small differences in the algorithm for different kinds of triangles should be a concern for the Triangle class. It should inspect its own state to determine the correct algorithm. This relieves the consumers of Triangle objects from memorizing the different kinds of triangles. To be honest, as I wrote this answer I had to look up the different kinds myself. I understand the concept of the area of a shape, but I should not need to know the exact algorithms necessary to calculate the area. I would expect the Triangle class to know enough to just calculate the value and return it.

Passing different kinds of arguments to GetArea() forces the programmer to know more about the implementation of a Triangle than is necessary with proper object oriented design.

Allowing Runtime Selection of Algorithm

The Strategy Pattern is an object-oriented design pattern that utilizes polymorphism to encapsulate a group of related algorithms. You could define one class per algorithm, and then pass it to your shape. The downside is you will likely need to know specifically which kind of shape you are dealing with:

Triangle triangle = new Triangle(12, 45, 22);
TriangleAreaCalculator areaCalculator = new HeronTriangleAreaCalculator();

double area = areaCalculator.GetArea(triangle);

This would break the Shape abstraction, since it doesn't make sense to use Heron's formula to calculate the area of a circle. Be careful to select the appropriate level of abstraction when you decide to separate an algorithm from its data. Casting from an abstract type down to a concrete type is an indication you are working at the wrong level of abstraction.


I also realize my examples of initializing a Triangle object are contrived. Each triangle has 3 sides, but each side also has a corresponding angle. This should be a concern for the Triangle class and its constructors.

16
  • Perhaps I chose a bad name, the purpose really is to be more of a calculator. In other words, given a shape, calculate its area, its perimeter, ... using calculations specific to the shape you're working with. So there's no need for the shapes to hold their own data. A circle calculator class would for example also override the GetArea method but have an implementation that uses ICircleAreaArguments instead of ITriangleArguments or IAreaArguments. Jan 11, 2022 at 14:52
  • 2
    Yes, my argument is that your use of object orientation is making life much harder for you because you insist on not putting data inside the classes that work with that data. You'd be better off renaming the "arguments" classes as TriangleBaseHeightData and TriangleSidesData, with no common base class, and just have functions that accept those.
    – pjc50
    Jan 11, 2022 at 15:49
  • 1
    @GregBurghardt Or you make Triangle an interface, which is implemented by a multitude of combinations of sufficient data.
    – Caleth
    Jan 11, 2022 at 15:53
  • 1
    @Caleth I can see where you're coming from, but shouldn't a triangle just be a triangle, independent of what you calculate about it and how you're calculating it? Jan 11, 2022 at 16:42
  • 1
    @JansthcirlU Maybe not if you have multiple sources giving you different representations of triangle data.
    – Caleth
    Jan 11, 2022 at 16:44

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.