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?
2 Answers 2
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.
-
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.JansthcirlU– JansthcirlU2022年01月12日 15:38:39 +00:00Commented 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– Doc Brown2022年01月12日 15:47:40 +00:00Commented 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!JansthcirlU– JansthcirlU2022年01月12日 15:48:48 +00:00Commented 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– Doc Brown2022年01月12日 15:52:53 +00:00Commented Jan 12, 2022 at 15:52
-
You make a good point.JansthcirlU– JansthcirlU2022年01月13日 11:00:01 +00:00Commented Jan 13, 2022 at 11:00
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.
-
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 usesICircleAreaArguments
instead ofITriangleArguments
orIAreaArguments
.JansthcirlU– JansthcirlU2022年01月11日 14:52:05 +00:00Commented Jan 11, 2022 at 14:52 -
2Yes, 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– pjc502022年01月11日 15:49:21 +00:00Commented 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– Caleth2022年01月11日 15:53:33 +00:00Commented 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?JansthcirlU– JansthcirlU2022年01月11日 16:42:23 +00:00Commented Jan 11, 2022 at 16:42
-
1@JansthcirlU Maybe not if you have multiple sources giving you different representations of triangle data.Caleth– Caleth2022年01月11日 16:44:18 +00:00Commented Jan 11, 2022 at 16:44
abstract double GetArea()
inIAreaArguments
.