Bohdan Stupak
Posted on July 6, 2023
The world of object-oriented programming is a bit confusing. It requires mastering a lot of things: SOLID principles, design patterns to name a few. This gives birth to a lot of discussions: are design patterns still relevant, is SOLID intended solely for object-oriented code? It is said that one should prefer composition to inheritance but what is the exact rule of thumb when one should choose one or another?
Since numerous opinions on this matter were voiced I don't think that mine will be the final but nevertheless, in this article, I'll present the system that helped me in my everyday programming using C#. But before we jump to that let's have a look at another question. Consider the code.
public class A
{
public virtual void Foo()
{
Console.WriteLine("A");
}
}
public class B : A
{
public override void Foo()
{
Console.WriteLine("B");
}
}
public class C : A
{
public void Foo()
{
Console.WriteLine("C");
}
}
var b = new B();
var c = new C();
b.Foo();
c.Foo();
Can you tell what the code will output in each case? If you've answered correctly that respective output will be "B" and "C" then why does override
keyword matter?
Enter dynamic polymorphism
Polymorphism is believed to be one of the pillars of object-oriented programming. But what exactly it means? Wikipedia tells us that polymorphism is the provision of a single interface to entities of different types or the use of a single symbol to represent multiple different types.
I don't expect you to grasp this definition from the first time so let's have a look at some examples.
string Add(string input1, string input2) => string.Concat(input1, input2);
int Add(int input1, int input2) => input1 + input2;
Above is an example of ad-hoc polymorphism which refers to polymorphic functions that can be applied to arguments of different types, but that behave differently depending on the type of the argument to which they are applied. So why is polymorphism so important for object-oriented code? This snippet does not provide a clear answer to this question. Let's look through more examples.
class List<T> {
class Node<T> {
T elem;
Node<T> next;
}
Node<T> head;
int length() { ... }
}
This is an example of parametric polymorphism and to be honest it looks more functional than object-oriented. Let's have a look at the final example.
interface IDiscountCalculator
{
decimal CalculateDiscount(Item item);
}
class ThanksgivingDayDiscountCalculator : IDiscountCalculator
{
public decimal CalculateDiscount(Item discount)
{
//omitted
}
}
class RegularCustomerDiscountCalculator : IDiscountCalculator
{
public decimal CalculateDiscount(Item discount)
{
//omitted
}
}
This is an example of dynamic polymorphism which is the term for applying polymorphism at the runtime (mostly via subtyping). And if you've tried to memorize all these design patterns before the interview or some SOLID principles you may notice the shape of something familiar. Let's see how dynamic polymorphism manifests itself in these concepts.
Dynamic polymorphism and design patterns
Most design patterns (strategy, command, decorator, etc) rely on injecting the abstract class or the interface and choosing the implementation of it at runtime. Let's have a look at some class diagrams to make sure it's the case.
Above is the diagram of strategy pattern where Client
works with abstraction and its concrete implementation is chosen during the runtime.
And here's decorator.
In this case, wrapper accepts wrappee which is an instance of abstraction and its implementation may vary during the runtime.
Dynamic polymorphism and SOLID
When asking about SOLID during the interview the regular answer I hear is "S stands for single responsibility and O stands for uhm...". On the contrary, I argue that the latter four letters of this acronym are more important since they represent the set of preconditions for your dynamic polymorphism to run smoothly.
For instance, open-closed principle represents a way of thinking in which you tackle every new problem as the subtype for your abstraction. Recall IDiscountCalculator
example. Imagine the case when you have to add another discount (say for father's day). To satisfy open-closed principle you have to add another subclass FathersDayDiscountCalculator
that performs the calculation.
Let's move on to the Liskov substitution principle. Imagine the situation that breaks it: we have to check whether the user is actually a father and it's a matching date. So we add the public method which checks whether the user is eligible.
class FathersDayDiscountCalculator : IDiscountCalculator
{
public decimal CalculateDiscount(Item discount)
{
//omitted
}
public bool IsEligible(User user, DateTime date)
{
//omitted
}
}
Now the calling code will face some complications
private IReadOnlyCollection<IDiscountCalculator> _discountCalculators;
public decimal CalculateDiscountForItem(Item item, User user)
{
decimal result = 0;
foreach (var discountCalculator in _discountCalculators)
{
if (discountCalculator is FathersDayDiscountCalculator)
{
var fathersDayDiscountCalculator = discountCalculator as FathersDayDiscountCalculator;
if (fathersDayDiscountCalculator.IsEligible(user, DateTime.UtcNow))
{
result += fathersDayDiscountCalculator.CalculateDiscount(item);
}
}
else
{
result += discountCalculator.CalculateDiscount(item);
}
}
return result;
}
Pretty verbose, isn't it? So in order to satisfy Liskov substitution principle we have to force all our implementations to exhibit the same public contract provided by the abstraction. Otherwise, it will complicate the application of dynamic polymorphism.
Another thing that complicates the application of dynamic polymorphism is having abstraction too broad. Imagine that we've made IsEligible
the part of our interface and now all concrete classes implement it. The calling code is greatly simplified.
private IReadOnlyCollection<IDiscountCalculator> _discountCalculators;
public decimal CalculateDiscountForItem(Item item, User user)
{
decimal result = 0;
foreach (var discountCalculator in _discountCalculators)
{
result += discountCalculator.CalculateDiscount(item);
}
return result;
}
But now imagine (I know the example is a bit contrived but just for the sake of the argument!) that one of the implementations threw NotImplementedException
because it doesn't make sense for this particular type of discount. At this point, you may forsee the problem CalculateDiscountForItem
failing with the runtime exception.
That is what interface-segregation principle is about: don't make abstractions too broad so that concrete types won't have trouble with implementing them thus complicating your dynamic polymorphism with unnecessary NotImplementedException
s.
And by this time you may observe dependency inversion principle in action. In the example above we deal with the collection of abstractions and have no idea of their runtime types.
Prefer composition over inheritance
I won't dive a lot into why composition is preferable. There are numerous examples of how inheritance complicates things. But now when you have a question about what are legit cases for inheritance here's an answer for you: when it facilitates dynamic polymorphism.
Virtual and override
At this point, those of you who didn't know the answer correctly to the question at the beginning of the article might have a suspicion that it was a tricky question. And indeed while the behavior is similar when we use var
keyword, differences start to arise when we apply dynamic polymorphism. For that matter let's convert both instances to parent type.
A b = new B();
A c = new C();
Now the output will be "B" and "A" respectively. Hot memorize this? The goal of override
keyword is to facilitate dynamic polymorphism. So think of it this way: when we inject abstraction we expect to work with the concrete type realization and as override
facilitates this goal so implementation of B
will be invoked.
Why this matters?
So now you know how to memorize all these pesky interview questions. But the most curious of you may be asking: what are the benefits of such a style of programming? Why do we strive to apply dynamic polymorphism in our object-oriented codebases?
Imagine we have two methods somewhere in our codebase.
public string GetCurrencySign(string currencyCode)
{
return currencyCode switch
{
"US" => "$",
"JP" => "¥",
_ => throw new ArgumentOutOfRangeException(nameof(currencyCode)),
};
}
public decimal GetRoundUpAmount(decimal amount, string currencyCode)
{
return currencyCode switch
{
"US" => Math.Floor(amount + 1),
"JP" => Math.Floor(amount / 100 + 1) * 100,
_ => throw new ArgumentOutOfRangeException(nameof(currencyCode))
};
}
Now imagine we have to add another country support. Doesn't look like a big deal but imagine these two methods hiding in one of those "real-world" codebases with thousands of classes and hundreds of thousands of lines of code. Most likely you'll forget all the places where you should add country support. This is exactly what Shotgun surgery code smell is.
How do we fix it? Let's extract all the information related to country code in a single place.
public interface IPaymentStrategy
{
string CurrencySign { get; }
decimal GetRoundUpAmount(decimal amount);
}
Now when we have to add a new country code we are forced to implement the interface above so we definitely won't forget anything. We use factory to return instances of IPaymentStrategy
.
public string GetCurrencySign(string currencyCode)
{
var strategy = _strategyFactory.CreateStrategy(currencyCode);
return strategy.CurrencySign;
}
In the example above we've fixed a code smell by applying dynamic polymorphism. Occasionally we've managed to satisfy some SOLID principles (namely Open-Closed by crafting new functionality with extensions instead of modification) and applying design patterns. Bunch of cool enterprise stuff for your CV by applying just a single OOD principle!
Conclusion
Software engineers, just like most of us, tend to follow a lot of principles without questioning their rationale. When this is done principles tend to get distorted and diverted from their original goal. So by questioning what was the original goal we might apply these principles as they were intended to be applied.
In this article, I've argued that one of the core principles beyond OOD was the application of dynamic polymorphism and a lot of principles (SOLID, design patterns) are just mnemonics built around it.
Posted on July 6, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.