Introduction
Abstractions are a big part of OOP, they can be used in almost every part of our code, from the implementation of the basic patterns to polymorphism and are responsible for our code’s architecture.
Because of that, good abstractions can make our code a lot easier to read, refactor, debug and test. In contrast, bad abstractions can make our code a nightmare to deal with. Abstractions are not necessary interfaces, although interfaces are abstractions, abstractions can be represented by different structures. An abstract base class is an abstraction, a scriptable object in Unity that its only use is to act as a mediator, can be an abstraction etc.
Every aspect of our code architecture has only one purpose, to make our code easier to be used by humans. Our code is like a food recipe, it is a series of steps, but in contrast with the food recipe that has only one audience (other humans), the code we write has two audiences the compiler and other humans. Code architecture, is a way to structure our code in a way that will make it easier to be understood and used by its human audience.
How to correctly create and use abstractions, is based on two rules:
- The first is that an abstraction, should not only be a technical abstraction but also have an abstract meaning.
- The second is that an abstraction should be created based on the needs of its users not based on the needs of its concrete implementations.
In this post, I will use two examples to show how abstractions should be created. One of the examples will be theoretical to show the basic idea and the other will be a more realistic, the Game Manager class that is usually a sign of bad architecture in a game.
Ultimately, because abstractions should be created based on the needs of their users, they are responsible for our final code architecture, not the other way around. Our code architecture should emerge from our abstractions, abstractions should not be the result of an already decided architecture.
The Chicken and Egg Situation
Let’s suppose that we have two classes. A Chicken
class that has a method that gives birth to an egg, and an Egg
class that gives birth to a chicken.
This is a simple program that has only two classes that depend on each other, but because of the different rules that exist and advocate the use of abstractions we decide to create them, so that our classes can benefit from those abstractions in areas such as the use of polymorphism, the strategy pattern, The Dependency Inversion Principle etc.
So we create two interfaces, the IChicken
interface and the IEgg
interface. Let’s see if now our program is easier to understand: Now we have an IChicken
interface, that is implemented by a concrete Chicken
class, that has a method that gives birth to an IEgg
interface, that is implemented by an Egg
class, that has a method that gives birth to an IChicken
interface.
This doesn’t seem simpler than before and eventually will lead to what I call death by a thousand abstractions. But why this makes our program harder to understand when in reality abstractions should have made it easier to understand and refactor?
The reason is that we have violated the idea that an abstraction should not only be a technical abstraction but also have an abstract meaning. An interface that is called IChicken
and has all the behaviors of a chicken, what else can it be? The only thing that this abstraction offers, is just another layer that the programmer has to deal with. It does not give any advantage in any area that abstractions can be useful.
For example, with the The Dependency Inversion Principle we have the advantage that we don’t depend anymore on concrete implementations but on abstractions, so that we can substitute the concrete implementations easily and change our program’s behavior while following The Open Closed Principle. Because our abstraction here does not have an abstract meaning, this is not possible anymore. If we wanted our egg to give birth to something else, for example an Ostrich, we would still need to go and change the Egg’s implementation, because the only thing that can implement an IChicken
is a chicken.
Before finding the solution to this problem, with the help of the second rule, let’s see a more realistic example.
The Game Manager Problem
Anyone that is coding in Unity for some time, will know that a GameManager
class usually indicates a problem with our architecture. The GameManager
is a class that has no specific meaning but can be responsible for anything in our game. Game managers tend to develop into god classes that are responsible for many unrelated things that “manage the game” and those things can be anything.
A game manager, can have information about the player character, the level, the state of the game, the enemies and any other data that we need access to. Because of the big amount of data the GameManager
class handles, many other classes will end up depending on it. For this reason, we decide that we should not depend on the concrete implementation but on an abstraction, so we create the IGameManager
interface.
Can you see the similarities with the Chicken
class? We haven’t actually created a useful abstraction that can help us refactor our game code. Any class that implements the IGameManager
interface will have the same behaviors for its users as the original GameManager
class. Eventually we will end up creating the same class, maybe with some different implementations on how those behaviors are achieved, but all those modifications will be internal to the class. For the users of the IGameManager
, for all intends and purposes, it will be the same class and in spite of all the internal modifications, it will still be a god class that has to handle the same unrelated data.
Choosing The Correct abstractions For The Chicken And The Egg
By following the second rule, that an abstraction should be created based on the needs of its users not based on the needs of its concrete implementations, we can create abstractions that will help us refactor our code in a way that is easier to maintain, debug and test.
In the chicken and egg situation, if we decide that abstractions are needed, so that our program can be easily changed in the future, or for any other reason, we have to look at the needs of the classes that use the IChicken
and IEgg
interfaces. In reality, any chicken, egg or any other animal gives birth to an offspring that can do all the things that an offspring can do in our game. An offspring in our game, may be able to do things like sitting still for a certain amount of time until it reaches maturity, make certain sounds etc., but all these behaviors are common to offsprings and have nothing to do with the concrete implementations.
A Chicken can implement the IOffspring
and the Egg can implement it too. The chicken with the give birth behavior will return an IOffspring
that is a new egg and the Egg with the give birth behavior will return an IOffspring
that is a new chicken.
Now our program can be easily extended, by implementing the IOffspring
with any class that can produce a child. An Ostrich can have an IOffspring
that is an OstrichEgg
and a dog can have an IOffspring
that is another dog.
This allows us, to see another common behavior. All the classes that can give birth actually have a common method, a GiveBirth
method that returns an IOffspring
. This can easily be an IParent
abstraction.
Although we might have ended with the same number of classes in the end, as with the IChicken
and IEgg
abstractions, there are three important differences here.
The first is that anything can be an
IParent
and/orIOffspring
. Even if we have fifty different animals in our game, we still need only those two abstractions, not fifty different abstractions.The second is that those abstractions, tell us nothing about the concrete implementations, we only care about their behaviors and not what they represent in our game.
The third is that those abstractions are created based on our needs. Our architecture is created by our needs as we create new classes and behaviors, and as we find ourselves in need to bundle those common behaviors and not the other way around.
A “good” architecture is not responsible for forcing us to create abstractions, our abstractions are responsible for ending up with a good architecture.
Solving The Game Manager Problem
The same can be applied to the GameManager
situation. If we don’t want to depend on the concrete implementation of our game manger class, then we have to check the needs of the classes that depend on it. The classes that depend upon our GameManager
probably use only a subset of its public methods. The methods that each of those classes use, can be bundled into one or more common groups that represent something.
If our player character for example needs the level info, then this probably is an ILevelInfo
interface, and if the enemies need the game state and the player character’s info then those can be represented by the IGameState
and IPlayerInfo
interfaces.
When our game manager implements those interfaces, our player class doesn’t need to know anything about the game manager, it just needs the ILevelInfo
. This in turn, will allow us to refactor our code into smaller classes. Eventually we can get rid of the game manager in favor of smaller classes, each with a strictly defined role that represents something less generic than a class that generally is responsible for managing the game.
How To Create The Right Abstractions
Our abstractions, should not only be technical abstractions, but they should also have an abstract meaning, they should be created based on the needs of the classes that use them and not from the classes that implement them.
An abstraction should tell us what an object can do, a concrete implementation should tell us what an object is.
The example with the GameManager
class is just one of the possible places in our code that abstractions should be created based on the needs of the users of the class. Here are some other examples:
The Water Container Example
A game that has a system that the player gathers and uses water, doesn’t actually need a Bucket
class and a Glass
class with IBucket
and IGlass
interfaces. Those classes need to implement an IWaterContainer
interface that only has the behaviors that are needed for collecting and using water.
Now, anything can be turned into a water container, just by implementing this interface. For example, the clothes of the player can be water containers. When it is raining or when the player travels through a lake, we can “add” water to the player’s clothes.
The Container Example
Expanding on the previous example, a chest in the game probably doesn’t need an IChest
interface.
An IContainer
interface that is responsible for adding/removing items can be implemented. This in turn will allow us to use it on any class that can interact with our player to add or remove items.
An enemy can implement the IContainer
and the behavior will be implemented with a pickpocket system, an NPC vendor can implement the IContainer
and the behavior will be implemented with a barter system and so on.
When the player needs to add or remove items, the player class doesn’t need to know if it is doing it from a chest, an enemy or an NPC vendor, it just needs to know that whatever it is, that is depending upon, has methods that allow the addition or removal of items.
The Damageable Example
The same can be said for anything that can take damage. When a player, casts a fireball spell, anything that implements an IDamageable
interface has a method that will be implemented and will be responsible for what taking damage means for that class. An enemy will lose hit points and a door will be destroyed.
Conclusion
In this post, I gave examples with interfaces, but as I mentioned at the beginning, there are many ways to create abstractions, depending on the language features or frameworks that are being used.
The important thing for abstractions is that they should not define what something is, but what it can do. They should not only be technical abstractions but also have an abstract meaning, and they should be created based on the needs of the classes that use them. This will give us a way to use them alongside the concrete implementations to allow for an architecture to emerge that helps with any modification, testing and debugging of our code.
The opposite, deciding first on the architecture that our program should have and then trying to force that architecture by creating abstractions, will actually make the code harder to understand and refactor, by creating layers that increase the cognitive load without offering any benefits to other people in our team and to our future selves.
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.