Get instant one-on-one help on your Unity project from community experts!
Chat with our experts on Unity Live Help to get some help on your project!
Please, before starting to read, remember that I am not saying that there are no better ways to implement something described in the article, but I just want to present another possibility and besides, I am not focusing on optimization or better practices, but only showing one way among many possibilities in an example that is not even a real game but only a simple idea.
In this series of articles I'm going to explain some details about SOLID programming principles and it's most important characteristics, and also discuss about how we can make use of it while writing our games in Unity with C#. This article will be divided into 6 parts and here is how I'll tackle the topics:
Part 1: SOLID Overview ( The intention is to revisit and understand SOLID from a generic perspective)
Part 2: Single Responsibility Principle ( in Unity and C#)
Part 3: Open/Closed Principle ( in Unity and C#)
Part 4: Liskov Substitution Principle ( in Unity and C#)
Part 5: Interface Segregation Principle ( in Unity and C#)
Part 6: Dependency Inversion Principle ( in Unity and C#)
What is SOLID after all?
Considering that you're a programmer or at least interested in coding, I'm pretty sure that you have heard at least once in your life someone talking or discussing about SOLID programming principles or something similar, maybe SOLID design. But what exactly is SOLID and what it's not?
Well, let's start by checking out what SOLID is not:
SOLID is not a programming language or a programming paradigm
It's not a specific design pattern either
It's not supposed to be a project or something like that
What is it then?
In object-oriented programming, SOLID is actually a mnemonic acronym for five different principles intended to make software design clearer, more flexible and maintainable. The principles are a subset of many principles and patterns promoted by Uncle Bob (Robert C. Martin) and though they apply to any object-oriented design, the SOLID principles can also form a core philosophy for methodologies such as agile development or adaptive software development.
The theory behind SOLID principles was introduced by Uncle Bob in 2000 on his paper Design Principles and Design Patterns.
Single Responsibility Principle (SRP)
-A class should have one, and only one, reason to change
In software development (and games are definitely included here), whenever the requirements suffer any kind of change, it implies that (probably all) the classes and components related to a given functionality will also suffer modifications to attend to the new specifications. In Unity besides demanding changes in code/scripts, it's quite possible to have also prefabs, assets and components changed in order to make a change possible. The more responsibilities (reason to change) a class has, it's going to get harder to implement some new features and the maintenance will be a growing pain that will burn more time as the project grows, adding more complexity and making the classes responsibilities strongly coupled to each other. We'll see this principle in details on Unity with C# in the Part 2 of this article.
Open/Close Principle (OCP)
-You should be able to extend a class's behavior, without modifying it
This principle is the building blocks for achieving a proper maintainability by creating reusable components and objects, and this is possible by creation abstractions instead of relying on concretions. This principle is possible by following two simple criteria:
Open for extension - Instead of going back to a same class and start adding hundreds of lines to it for each new feature, we should actually make a class behave differently in compliance with the new requirements
Closed for modification - Your class should not change it's main behavior to be reused by different parts of your code, make sure to start using interfaces or abstract classes as a basic type for your specific components and entities. In Unity if you're actually needing your specialized class to be also a MonoBehaviour, make sure to extend your abstract (base) class from MonoBehaviour and your classes extending from this base type will also inherit from it to attend your needs. We'll see this principle in details on Unity with C# in the Part 3 of this article.
Liskov Substitution Principle (LSP)
-Derived classes must be substitutable for their base classes
This principle basically describes that functions/methods using object references to base classes should be able to use instances from derived classes without actually knowing it. In another words it reinforces the usage of inheritance and polymorphism to make life easier later. Let's take as an example a class called DamageManagerthat receives through the method CalculateDamage, a character that should receive an amount of damage. Considering that the requirements for the game specify that each character should be affected by this damage differently depending on it's armor level and other attributes also defined in the character class. So, in a quick and dirty approach, we can handle all the logic in the function/method itself, so we can check the category of that character, it's armor level, attributes and deal the damage to it using a conditional statement like this:
No, of course this is not the best way to implement this feature, simply because this kind of code will keep growing over and over as we eventually need to add extra character categories, modifiers, extra damage and maybe new attributes. Not to mention that it forces the DamageManager to know way more about the character than it's really necessary, generating a strongly coupled code that will turn into a spaghetti sooner or later, impossible or at least painful to maintain.
By using Liskov Substitution Principle it's actually easy to sort things out and make it quite friendly to maintain and add new stuff later. First of all we should create new classes to represent each of the character categories, and all of them should extend from the base class Character. The character will still contain the life attribute but now it will have a method to describe it's behaviour for taking damage, let's call it TakeDamage, it will receive an int damage and each of the new character classes created should implement their own behaviour to TakeDamage. With this approach, we will make the CalculateDamage method shown above as simple as:
Interface Segregation Principle (ISP)
-Many client specific interfaces are better than one general purpose interface
This principle recommends breaking down monolithic interfaces into small ones, separating their responsibilities by role for example. Let's say that our previously used Character class presented in LSP needs now to implement a character attack, spell casting and dodge behaviours. Before going ahead and start writing all these abstract methods in the Character class let's think a little bit more about it. Hey, I guess we should not force the Warrior class for example to have a CastSpell method right? Yes, that's correct! By keeping in mind the Interface Segregation Principle we can actually start writing some small interfaces to be used as roles by the derived character classes.
All the character classes should be able to attack so we may create an interface called ICanAttack which will be implemented by the Warrior, Mage and Rogue classes and may contain a method called Attack.
To make the warrior a bit more unique, we can create another interface called ICanCharge and when implemented by the classe it can be, for example, a strong melee attack executed only by the warrior, and the specific method created in this interface can be called Charge.
As the Mage is the only spell caster created previously, we can have an interface ICanCastSpell and the method on it can be called CastSpell.
For the last character class, the Rogue, we can create an interface ICanDodge and the method to be implemented may be called Dodge to keep things in a same pattern and simple enough.
Dependency Inversion Principle (DIP)
-Depend on abstractions, not on concretions
The Dependency Inversion Principle is comprised by two basic rules:
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Abstractions should not depend on details. Details should depend on abstractions.
To better understand this principle, let's take a look at the following classes:
In this example above we can see that the class Warrior depends on the class sword directly, just because in our game the sword is the initial weapon used by the warrior. We can see that now in the Attack and Charge methods we receive a character as argument, considering that the attacks will only be performed against Characters, so we simply receive the target character/creature and then we pass over the sword's damage property to the target through the method TakeDamage , and to keep things simple here, an attack is simply the damage caused by the sword, and a charge in this case, doubles the damage amount.
So, what is the big issue in this implementation that breaks the dependency inversion principle? This one is simple right? Yes, the warrior class is depending on a concrete implementation of sword to be able to perform attacks, which is no so bad if we'll never have other weapons in the game. But let's say that actually in our case we need our Warrior to be able to equip different types of melee weapons. So, here is where the phrase "Depend on abstractions, not on concretions" get's into play. In order to achieve the requirements of our game we should not use a specific weapon as in our example above, instead we should create an abstraction for Weapon and use it as a dependency, so every time we need to equip a different weapon it will not be a problem.
Let's take a look at this second example:
As we can see in this second example, instead of using the Sword as a dependency of Warrior, we have now an interface IMeleeWeapon that defines a Damage property that will need to be defined by all the classes implementing the interface, as we see in both Sword and Axe above. Now our Warrior class can still have a weapon but it's also possible to equipe an Axe when needed without problems.
An Important note is that Dependency Inversion and Dependency Injection are not the same thing, the first one is a principle that helps us decoupling our classes a bit, while the second one is a technique used to make sure that all the dependencies of a given object are delivered to that object without needing to instantiate them manually inside that object, serving as a support for Dependency Inversion, but not explained in this article.