This post is part of a nine post series about the SOLID principles and code architecture. You can check all the posts here:
- Software Architecture in Game Development
- The Single Responsibility Principle
- The Open Closed Principle
- The Liskov Substitution Principle
- Conceptual Meaning Of Interfaces
- The Interface Segregation Principle
- The Dependency Inversion Principle
- SOLID: How to use it, Why and When
- SOLID For Unity Monobehaviours
Introduction: What is The Opened Closed Principle
The Opened Closed Principle states that a module should be open for extension but closed for modification.
This may sound as a paradox, but from all the SOLID principles, the OCP is probably the most important. The reason is that the open for extension part, is the goal of every object oriented design. As we architect our code, we want it to be able to change easily. When new features need to be added, these features change the behaviours of some of our classes. Although we want to be able to add new features which is the open to extension part, we also want to do it, without changing any code we have already written. This is the closed to modification part.
These two things may seem contradictory. After all, how to change the behaviour of a class without touching the code that is already written in this class. At the heart of the OCP, there is the need for abstraction. The techniques that exist so that we can follow the OCP, rely primarily on abstraction. But before that, let’s see the origins of the OCP.
Origins of the Opened Closed Principle
The OCP is based on the work of Bertrand Meyer and his book Object-Oriented Software Construction 1988. The whole idea is that a module should be able to have new behaviours, without anyone touching that module. That means that any class we have written and we find the need to add new behaviour, won’t have to be recompiled and redeployed.
Although the OCP is the most important from the SOLID principles, it is also the hardest one to follow. In fact, it is impossible to follow 100%, but that doesn’t mean it shouldn’t be applied as much as we can. Being able to change the behaviour of our program, without every change to cause a domino of changes in classes already written, increases productivity and decreases bugs in the code.
As I mentioned, abstraction is key in following the OCP. With static and dynamic polymorphism, our classes can have new behaviours without changing their code. Some examples in code are better for showing how we can follow the OCP. Here are some C# examples with classes that are open to modification and how we can refactor the code so that those classes can become open to extension but closed for modification.
Examples of OCP
Let’s suppose that we need to create a character, that can attack with three different types of attack: normal, fire and earth attack. We can create our class like this:
public enum AttackType
{
Normal,
Fire,
Earth
}
public class Character
{
public void Attack(AttackType attackType)
{
if (attackType == AttackType.Earth)
{
Console.WriteLine("Earth attack");
}
else if (attackType == AttackType.Fire)
{
Console.WriteLine("Fire Attack");
}
else if (attackType == AttackType.Normal)
{
Console.WriteLine("Normal Attack");
}
}
}
This class doesn’t follow the OCP. The reason is that when we need to add a new attack type, we have to go and change the code of the class. We have to add another if statement.
This might not seem like a problem here, because this class is small and simple for the needs of this example, but in a normal program, each if statement will hold everything that is needed for the particular attack type to be performed. There would be many statements, that handle the specifics of each attack, probably some nested if statements and probably some global variables that are being used between all those ifs.
In this example, if we want to follow the OCP, we should be able to add new attack types, without ever changing the code and without the need to recompile the class. Here is the class, that has the same behaviour, but follows the OCP:
public class Character2
{
public void Attack(IAttackType attackType)
{
attackType.Perform();
}
}
public interface IAttackType
{
void Perform();
}
public class EarthAttack : IAttackType
{
public void Perform()
{
Console.WriteLine("Earth attack");
}
}
public class FireAttack : IAttackType
{
public void Perform()
{
Console.WriteLine("Fire attack");
}
}
public class NormalAttack : IAttackType
{
public void Perform()
{
Console.WriteLine("Normal attack");
}
}
Instead of an enum, that has to be modified every time we add a new attack type and those if statements inside the character class, we created an interface, the IAttackType
, which abstracts the type of attack. Now every attack exists in its own class and when the need for a new attack type is required, all we have to do, is to create a new class that implements the IAttackType
interface.
This example used dynamic polymorphism. Let’s see the same example, with static polymorphism.
public class Character3
{
public void Attack<T>(T attackType) where T: IAttackType
{
attackType.Perform();
}
}
How to conform to the OCP
By using abstractions we can have classes that don’t need to change. Whenever we need a new functionality we can create new classes that act as plug-ins to the already existing ones using polymorphism.
The above example was simple, but in reality things are never simple. If we try to create our code in a way that all classes follow the OCP, we can create too many abstractions. Eventually we will make our code hard to understand because of all the dependencies that we create.
Here is a slightly more complicated example. Let’s say that we have a character that given an input, the character moves. At the beginning, the requirements are that when the a
key is pressed the character moves left and when the d
key is pressed the character moves right. Our code will look something like this:
public class Character
{
public void Move()
{
var key = Console.ReadKey();
if (key.KeyChar == 'a')
{
Console.WriteLine("Moving left");
}
else if (key.KeyChar == 'd')
{
Console.WriteLine("Moving right");
}
}
}
This code doesn’t follow the OCP. Obviously this code is open to modifications. We can try to refactor the class so that it can adhere to the OCP, but at what cost ?
Here we can try and anticipate all the possible changes. The input might change, for example we may need to change the keys to be different from the keys a
and d
to something else or we may need to add new keys, but also the output might change. For example we may need to add the move up functionality.
If we try to abstract all that, our code will become unnecessary complicated:
public class Character2
{
public void Move(IMovement movement)
{
movement.Perform();
}
}
public interface IInputFactory
{
public IInput CreateInput(char key);
}
public class InputFactory : IInputFactory
{
public IInput CreateInput(char key)
{
if (key == 'a')
return new LeftMovementInput();
if (key == 'd')
return new RightMovementInput();
throw new ArgumentOutOfRangeException(nameof(key)," Not a valid key!");
}
}
public interface IInput
{
IMovement GetMovement();
}
public interface IMovement
{
void Perform();
}
public class MoveRight : IMovement
{
public void Perform() => Console.WriteLine("Moving Right");
}
public class MoveLeft : IMovement
{
public void Perform() => Console.WriteLine("Moving Left");
}
public class RightMovementInput : IInput
{
public IMovement GetMovement() => new MoveRight();
}
public class LeftMovementInput : IInput
{
public IMovement GetMovement() => new MoveLeft();
}
and the class that calls this code will look like this:
Character2 player = new();
IInputFactory inputFactory = new InputFactory();
var key = Console.ReadKey();
var inputAction = inputFactory.CreateInput(key.KeyChar);
player.Move(inputAction.GetMovement());
Although our code as an algorithm is now simpler in each class and changes can be made easily in behaviour by not modifying those classes but by extending the behaviour with the addition of new classes, the dependencies we have created make the whole system much more complicated.
This is still a simple example, but I believe it is enough to show that the more we abstract, the more our dependencies increase and this will create problems in the understanding of the system as a whole.
Why the OCP is the hardest principle to follow
The above example, is a small glimpse of why the OCP is the hardest principle to follow. There are two problems that we have to be careful when using the OCP.
The first problem is that eventually, no matter how much we abstract, there will be a class in our program that will be open to modifications. We can move the problem as much as we want, even going up to the main
method by using builders and factories, but eventually there will be some classes that don’t obey the OCP. That doesn’t mean that we shouldn’t try to follow the OCP, but that no program will have all its classes OCP compliant. In the above example, our InputFactory
class is open to modifications, if ever a new key is to be added for input.
Even if our InputFactory
class is open for modification, the code it contains, is isolated from the rest of our program, so any changes will be easier to make and we will have a smaller chance of creating bugs. There is a more important problem though.
The more abstractions we create so that we can easily extend our classes without modifying them, the more complex our systems become because of the dependencies that we create. If we try to code in a way that our code is easy to change in all kinds of changes that may be asked in the future, we will be overengineering and as I said in a previous post , code architecture is all about time investment.
Benefits of the OCP
Even if the OCP is the hardest principle to follow and we can never follow it 100% in our code. In spite of that, it is the most important one because it tries to solve the biggest problem in coding. Making our code easy to change.
By not changing already written code, we increase productivity, we decrease bugs by avoiding changes that create a domino of changes in other classes and we are actually adding new features by adding new code, not by changing code that already exists.
Abstraction is key in OCP and many patterns help so that our code can be OCP compliant. Command and strategy patterns, as well as the template method pattern can help.
Conclusion
If we could predict the future, so that we could know where changes are going to happen in our code, then we could follow the OCP only for those parts of our code. There are certain methods for decreasing the uncertainty of what changes are going to be asked in our code, like for example always separating the business rules from the UI, as the UI is one of most likely things to change, or by not making big designs upfront but instead creating an MVP and working with small and rapid iterations, as the best predictor of changes are past changes, but these methods are not the focus of this post and probably deserve a post (or ten) of their own.
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 always subscribe to my newsletter or the RSS feed.