Introduction
Polymorphism stands as a pivotal aspect of Object-Oriented Programming (OOP), yet, like many other programming concepts, it can be misapplied.
In this post, I will elucidate that the sole acceptable utilization of polymorphism lies in delineating diverse implementations in the behavior of distinct types, rather than variations in the values of data.
At times, there may be an inclination to create different types to depict real-world objects and concepts. However, in reality, if these objects solely diverge in their data and not in the implementation of their behaviors, they likely can be represented by the same type.
Let’s commence with a simple example, one that has been previously employed in some posts, and subsequently, advance to more intricate examples.
Kinds of Polymorphism
Polymorphism empowers us to employ objects of varying types through the same interface. However, this post doesn’t delve deeply into the intricacies of polymorphism in Object-Oriented Programming (OOP). Here’s a succinct overview.
In C#, we encounter three primary forms of polymorphism: Method and operator overloading, generics, and method overriding.
Method overloading and operator overloading are resolved during compile time, earning them the moniker of static polymorphism.
Method and Operator Overloading
Below is a swift example of a class that incorporates both method and operator overloading:
public class BasketOfFlowers
{
private int _amount;
public BasketOfFlowers(int amount) => _amount = amount;
public void Add(int amount) => _amount += amount;
public void Add(BasketOfFlowers basketOfFlowers) => _amount += basketOfFlowers._amount;
public static bool operator ==(BasketOfFlowers basketOfFlowers1, BasketOfFlowers basketOfFlowers2)
{
return basketOfFlowers1._amount == basketOfFlowers2._amount;
}
public static bool operator !=(BasketOfFlowers basketOfFlowers1, BasketOfFlowers basketOfFlowers2)
{
return !(basketOfFlowers1 == basketOfFlowers2);
}
}
The Add
method can accept either an integer or another BasketOfFlowers
, illustrating our method overload. We can redefine the notion of equality for our baskets to signify having the same quantity of flowers by overriding the ==
and !=
operators.
However, it’s worth noting that this implementation is not comprehensive, and further code should have been included to handle equality. I opted to keep it concise here. For a more comprehensive exploration of equality and comparisons, I recommend referring to my three posts:
Equality and Comparisons part 1
Equality and Comparisons part 2
Equality and Comparisons part 3
Generics
We can also utilize static polymorphism by employing generics and the Curiously Recurring Template Pattern. For more detailed information on this topic, please refer to here
Method overriding
Lastly, we encounter dynamic polymorphism, which can be attained through method overriding. Here’s a quick example:
public interface IEnemy
{
void Attack();
}
public class Skeleton : IEnemy
{
public void Attack()
{
Console.WriteLine("The Skeleton Attacks");
}
}
public class Goblin : IEnemy
{
public void Attack()
{
Console.WriteLine("The Goblin Attacks");
}
}
Even though we have an IEnemy
type, the outcome of calling the Attack
method varies depending on the concrete implementation.
The same principle applies to inheritance from a concrete base class, utilizing the abstract, virtual, and override keywords:
public abstract class Parent
{
public abstract void Method1();
public virtual void Method2() => Console.WriteLine("Method2 of Parent");
}
public class FirstChild : Parent
{
public override void Method1() => Console.WriteLine("Method 1 of FirstChild");
public override void Method2()
{
Console.WriteLine("Method 2 of FirstChild");
}
}
public class SecondChild : Parent
{
public override void Method1() => Console.WriteLine("Method 1 of SecondChild");
public override void Method2()
{
base.Method2();
Console.WriteLine("Method 2 of SecondChild");
}
}
These are fundamental concepts, and while these examples serve to illustrate polymorphism, they do not represent optimal uses of it.
The rationale behind this, is that polymorphism revolves around alterations in behavior rather than data. Here’s how the IEnemy
example could have been implemented if it were actual code.
Data vs Logic
public class Enemy
{
public void Attack(string message) => Console.WriteLine(message);
}
The sole variation between our Skeleton and Goblin is the message printed. One might argue that this approach necessitates supplying the message as a parameter each time, and they would be correct. A more suitable manner to write the IEnemy
example would be as follows:
public class Enemy(string attackMessage)
{
public void Attack() => Console.WriteLine(attackMessage);
}
Here, we have only one type, the Enemy
type. A goblin and a skeleton can be declared and initialized as follows:
Enemy goblin = new Enemy("The Goblin Attacks");
Enemy skeleton = new Enemy("The Skeleton Attacks");
Our goblin and skeleton differ solely in their data values, making them distinct objects of the same type rather than different types.
Polymorphism entails a shared interface for diverse types, yet types are defined by their behaviors, not their data. If two real-world objects possess identical behaviors but divergent data, they belong to the same type, rendering polymorphism unnecessary.
Now, let’s delve into a more intricate example:
public interface IEnemy
{
int HitPoints { get; set; }
int AttackDamage { get; set; }
void Attack(IEnemy enemy);
}
public class Skeleton : IEnemy
{
public int HitPoints { get; set; } = 50;
public int AttackDamage { get; set; } = 30;
public void Attack(IEnemy enemy)
{
Console.WriteLine($"The Skeleton Attacks the {enemy}");
enemy.HitPoints -= AttackDamage;
}
}
public class Goblin : IEnemy
{
public int HitPoints { get; set; } = 100;
public int AttackDamage { get; set; } = 20;
public void Attack(IEnemy enemy)
{
Console.WriteLine($"The Goblin Attacks the {enemy}");
enemy.HitPoints -= AttackDamage;
}
}
Is all of this necessary?
Actually no, the Goblin
and the Skeleton
classes aren’t needed, because they only differ in their data, not in their behaviors. A more suitable approach would be to write only one class:
public class Enemy(string attackMessage, int hitPoints, int attackDamage)
{
public int HitPoints { get; private set; } = hitPoints;
public void Attack(Enemy enemy)
{
Console.WriteLine(attackMessage);
enemy.HitPoints -= attackDamage;
}
}
and then:
Enemy goblin = new Enemy("The Goblin Attacks", 50, 30 );
Enemy skeleton = new Enemy("The Skeleton Attacks", 100, 20);
There are two problems we have to consider now.
Representing different types
The first consideration is that we might indeed require the specific type of enemy we have. In our example, we cannot indicate in the console which enemy is being attacked each time, as all enemies belong to one type, the Enemy
type.
However, this doesn’t imply that we need to create new types (classes) to accommodate this information. Such information simply constitutes another piece of data rather than a modification in behavior. Since it’s data, it can be represented in various ways, such as using an enum, and the value can be provided at creation time:
public enum EnemyType
{
Goblin,
Skeleton
}
public class Enemy(string attackMessage, int hitPoints, int attackDamage, EnemyType enemyType)
{
public int HitPoints { get; private set; } = hitPoints;
private EnemyType EnemyType { get; } = enemyType;
public void Attack(Enemy enemy)
{
Console.WriteLine($"{attackMessage} the {enemy.EnemyType}");
enemy.HitPoints -= attackDamage;
}
}
now we can call:
Enemy goblin = new Enemy("The Goblin Attacks", 50, 30, EnemyType.Goblin );
Enemy skeleton = new Enemy("The Skeleton Attacks", 100, 20, EnemyType.Skeleton);
A couple of observations to note here.
Firstly, consider the enemy.EnemyType
call within the Attack method. Although the EnemyType
field is private, calling enemy.EnemyType
compiles and functions correctly. This is because encapsulation, another crucial aspect of OOP, serves to encapsulate types, not objects. An object can access the private internals of another object as long as they belong to the same type.
Secondly, any potential modifications to the type of our Enemy
are now trivial. If the requirements change and our enemies can alter their type during runtime, we can simply make the EnemyType
field public, and the task is complete. This process is remarkably straightforward compared to the extensive effort required if different types of enemies were implemented using distinct classes, as demonstrated in the IEnemy
example.
While this may not appear significant in this particular example, consider a scenario where we must implement various employee types (contractor, part-time, full-time). If we had generated different classes based on a common IEmployee
interface, any alterations to an employee’s work type—such as transitioning from part-time to full-time—would be laborious to implement. We would need to create a new object, transfer pertinent data from the previous object, and then destroy the initial one.
As long as two objects differ solely in their data, polymorphism is unnecessary because they can be represented by the same type (class), even if they seem distinct in our conceptualization. In code, two types are distinguished by their disparate behaviors, not by their contained values.
Moreover, due to the shared data among our objects, manually crafting each goblin, skeleton, and various enemy types in our game can introduce errors and consume considerable time. This leads us to the second issue addressed: the utilization of a single class capable of generating diverse enemies.
This is where a factory can be advantageous.
Creating a Factory
A factory can be useful not only for generating concrete implementations of our abstractions but also for creating objects with boilerplate data.
Consider the scenario where all our goblins in the previous example possess the same message and hit points, but vary in their attack damage. In such cases, a factory could automate this process for us:
public static class EnemyFactory
{
public static Enemy CreateGoblin(int attackDamage) =>
new Enemy("The Goblin Attacks", 50, attackDamage, EnemyType.Goblin);
public static Enemy CreateSkeleton(int attackDamage) =>
new Enemy("The Skeleton Attacks", 100, attackDamage, EnemyType.Skeleton);
}
now the creation of each “type” of Enemy is simpler:
Enemy goblin = EnemyFactory.CreateGoblin(30);
Enemy skeleton = EnemyFactory.CreateSkeleton(20);
Certainly, the shared data doesn’t necessarily have to be managed by a static simple factory. Any creational pattern that is sensible and enables us to centralize the data for each enemy type in one location would suffice.
Conclusion
Polymorphism, is about providing a shared interface for different behaviors of various types. If there exists variability in the data of each real-world object or concept representation, but not in their behavior, then these entities don’t necessitate separate representation with different types, in our code. They are simply distinct objects of the same type.
Therefore, polymorphism ought not to be employed to furnish an interface that manages data values differently. Instead, it should be utilized solely when the implementations of behaviors among different types differ.
Thank you for reading, and as always, if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.