Návrhové principy SOLID
03.02.2022
Slovníček
SOLID | Akronym označující pět principů objektového programování, které zpopularizoval Robert C. Martin. Tyto principy jsou zaměřeny na tvorbu srozumitelnějšího a udržitelnějšího kódu. |
Obsah
- Úvod
- Single Responsibility
- Open-Closed
- Liskov Substitution
- Interface Segregation
- Dependency Inversion
Úvod
Každý programátor se dříve nebo později setká s termínem SOLID. Tento akronym označuje pět principů objektového programování, které nám mohou pomoci vytvořit přehlednější, srozumitelnější a snadno upravitelný kód. Zní to skvěle, ale má to jeden háček. 🙃 Tyto principy jsou obecné a poměrně abstraktní. Pro lepší pochopení si proto ukážeme několik konkrétních příkladů kódu. Všechny příklady budou na úrovni tříd, protože ty tvoří základní stavební prvek OOP. Principy si dovolím volně zjednodušit takto:
Single Responsibility | Každá třída by měla mít pouze jednu zodpovědnost, tedy jeden jasně definovaný účel. |
---|---|
Open-Closed | Třídy by měly být navrženy tak, aby bylo možné přidávat nové funkcionality bez nutnosti upravovat stávající kód. |
Liskov Substitution | Každá odvozená třída (subclass) by měla být schopna nahradit rodičovskou třídu (superclass) bez změny chování programu. |
Interface Segregation | Třída by neměla být nucena implementovat metody, které nevyužívá. |
Dependency Injection | Místo vytváření instancí jiných tříd by třída měla obdržet tyto objekty zvenčí, například pomocí konstruktoru. |
Single Responsibility
Definice principu je následovná:
- Each software module should have one and only one reason to change.
- Gather together the things that change for the same reasons. Separate those things that change for different reasons.
- If you think about this you’ll realize that this is just another way to define cohesion and coupling. We want to increase the cohesion between things that change for the same reasons, and we want to decrease the coupling between those things that change for different reasons.
Příklad
Ocitli jsme se v říši bohů a polobohů, kde Thor a Zeus umí přivolat bouři.class Thor
{
public void ThrowHammer() { /* implementation */ };
public void SummonStorm()
{
// Implementation of non-trivial task (storm) with dependency on outer system (weather).
// Only Thor now knows how to handle weather, if we want to prevent duplication.
}
}
class Zeus
{
public void MakeLoveToSemele() { /* implementation */ }
public void SummonStorm()
{
// Power to call Thor to proxy summon storm (seems as unnecessary dependency).
var thor = new Thor();
thor.SummonStorm();
}
}
class Thor
{
public void ThrowHammer() { /* implementation */ };
public void SummonStorm() { Weather.SummonStorm(); }
}
class Zeus
{
public void MakeLoveToSemele() { /* implementation */ }
public void SummonStorm() { Weather.SummonStorm(); }
}
public static class Weather
{
public static void SummonStorm() {
// Implementation of non-trivial task (storm).
// Only Weather is manipulationg weather which follows SRP principle.
}
}
Nyní je za manipulaci s počasím zodpovědné pouze počasí, respektive třída Weather. Výhodou je také to, že jsme výrazně zjednodušili třídu Thor a odstranili závislost Zeuse na Thorovi.
Jinými slovy, zvýšili jsme soudržnost (cohesion) třídy Thor (obsahuje větší podíl kódu, který přímo souvisí s Thorem) a snížili vazbu (coupling) mezi Thorem a počasím. Kompletně jsme zrušili vazbu mezi Zeusem a Thorem a zpřímili dříve nepřímou vazbu mezi Zeusem a počasím. Zní to možná zvláštně, ale je to tak! 🙂
Open-Closed
Definice principu je následovná:
- Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
- Implementations can be changed and multiple implementations could be created and polymorphically substituted for each other.
- this definition advocates inheritance from abstract base classes. Interface specifications can be reused through inheritance but implementation need not be. The existing interface is closed to modifications and new implementations must, at a minimum, implement that interface.
Princip doporučuje využívat polymorfismus a dědičnost z abstraktní třídy nebo rozhraní (interface), abychom se vyhnuli zbytečným změnám v existujícím kódu.
Příklad
Máme k dispozici bojovou arénu, ve které se utkávají pokémoni. 😅 Každý pokémon je spojen s určitým přírodním elementem, který ovlivňuje bojovou logiku. Například pokud ohnivý pokémon útočí na listnatého, jeho útok se zdvojnásobí.
enum Element { Grass, Fire }
abstract class Pokemon
{
public int Health { get; protected set; }
public int Attack { get; protected set; }
public abstract Element GetElement();
public void ReceiveDamage(int damage) { Health -= damage; }
}
class Bulbasaur : Pokemon
{
public Bulbasaur() { Health = 100; Attack = 10; }
public override Element GetElement() { return Element.Grass; }
}
class Charmander : Pokemon
{
public Charmander() { Health = 100; Attack = 10; }
public override Element GetElement() { return Element.Fire; }
}
static class BattleArena
{
public static void Attack(Pokemon attacker, Pokemon defender)
{
CheckElement(attacker);
CheckElement(defender);
var damage =
IsFireOnGrass(attacker, defender)
? attacker.Attack * 2
: attacker.Attack;
defender.ReceiveDamage(damage);
}
private static void CheckElement(Pokemon pokemon)
{
var element = pokemon.GetElement();
var knownElement =
element == Element.Fire
|| element == Element.Grass;
if (!knownElement) throw new InvalidEnumArgumentException();
}
private static bool IsFireOnGrass(Pokemon attacker, Pokemon defender)
{
return attacker.GetElement() == Element.Fire && defender.GetElement() == Element.Grass;
}
}
Kód je funkční a celkem přehledný. V kódu se však používá enum pro seznam elementů. Obecný problém s enum objektem spočívá v tom, že při jeho úpravě, například přidání nového elementu, musíme překontrolovat všechna místa, kde se používá. To zahrnuje například všechny if-else podmínky, switch konstrukce a podobně, abychom se ujistili, že jsme nový element nezapomněli zahrnout.
Z tohoto důvodu se v metodě BattleArena.Attack() kontroluje, zda některý z pokémonů nemá neznámý element, na který se při psaní bojové logiky nemyslelo. Pokud bychom například přidali nový element, jako je voda
enum Element { Grass, Fire, Water }
Kód můžeme upravit následovně:
public interface IElement
{
public string Name { get; }
public IElement Weakness { get; }
}
class ElementEqualityComparer : IEqualityComparer<IElement>
{
public bool Equals(IElement left, IElement right)
{
return right != null &&
left.Name == right.Name &&
EqualityComparer<IElement>.Default.Equals(left.Weakness, right.Weakness);
}
public int GetHashCode([DisallowNull] IElement element)
{
return System.HashCode.Combine(element.Name, element.Weakness);
}
}
public class GrassElement : IElement
{
public string Name { get; } = "Grass";
public IElement Weakness { get; } = new FireElement();
}
public class FireElement : IElement
{
public string Name { get; } = "Fire";
public IElement Weakness { get; } = null;
}
abstract class Pokemon
{
public int Health { get; protected set; }
public int Attack { get; protected set; }
public abstract IElement GetElement();
public void ReceiveDamage(int damage) { Health -= damage; }
}
class Bulbasaur : Pokemon
{
public Bulbasaur() { Health = 100; Attack = 10; }
public override IElement GetElement() { return new GrassElement(); }
}
class Charmander : Pokemon
{
public Charmander() { Health = 100; Attack = 10; }
public override IElement GetElement() { return new FireElement(); }
}
static class BattleArena
{
public static void Attack(Pokemon attacker, Pokemon defender)
{
var damage =
HasDefenderWeakness(attacker, defender)
? attacker.Attack * 2
: attacker.Attack;
defender.ReceiveDamage(damage);
}
private static bool HasDefenderWeakness(Pokemon attacker, Pokemon defender)
{
var comparer = new ElementEqualityComparer();
return comparer.Equals(attacker.GetElement(), defender.GetElement().Weakness);
}
}
Problematický enum jsme změnili na interface IElement a z jednotlivých typů jsme udělali třídy, které tento interface implementují. Poté byl přidán ElementEqualityComparer, který má za úkol porovnávat, jestli elementy jsou nebo nejsou stejné. Změnila se i metoda BattleArena.Attack(), která je více obecná a kontroluje, jestli obránce má slabost proti elementu útočníka. Pokud bychom přidali nový element jako v předchozím příkladě:
public class WaterElement : IElement
{
public string Name { get; } = "Water";
public IElement Weakness { get; } = null;
}
public class FireElement : IElement
{
public string Name { get; } = "Fire";
public IElement Weakness { get; } = new WaterElement();
}
public class WaterElement : IElement
{
public string Name { get; } = "Water";
public IElement StrongAgainst { get; } = new FireElement();
}
Liskov Substitution
Definice principu je následovná:
- If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program
- Contravariance of method parameter types in the subtype.
- Covariance of method return types in the subtype.
- New exceptions cannot be thrown by the methods in the subtype, except if they are subtypes of exceptions thrown by the methods of the supertype.
- Preconditions cannot be strengthened in the subtype.
- Postconditions cannot be weakened in the subtype.
- Invariants must be preserved in the subtype.
- History constraint (the "history rule"). Objects are regarded as being modifiable only through their methods (encapsulation). Because subtypes may introduce methods that are not present in the supertype, the introduction of these methods may allow state changes in the subtype that are not permissible in the supertype. The history constraint prohibits this.
Princip nám ukládá několik podmínek, po jejichž splnění můžeme použít dědění a zjednodušeně říká, že podtypem bychom měli být vždy schopní nahradit typ, ze kterého dědíme.
Příklad
Máme k dispozici zbrojírnu Imperiální armády, která má za úkol připravit vojáky k boji. K dispozici máme třídu StormTrooper a učně z Imperiální akademie StromTrooperTrainee.
public class Armor
{
public int Weight { get; init; }
}
public static class Armory
{
public static void PrepareTroopers(List<StormTrooper> troopers)
{
var armor = new Armor() { Weight = 100 };
foreach (var trooper in troopers)
{
trooper.PutOnArmor(armor);
}
}
}
public class StormTrooper
{
public Armor Armor { get; protected set; }
public virtual void PutOnArmor(Armor armor)
{
Armor = armor;
}
}
public class StormTrooperTrainee : StormTrooper
{
public int ArmorWeightLimit { get; private set; } = 50;
public override void PutOnArmor(Armor armor)
{
if (armor.Weight > ArmorWeightLimit)
{
throw new InvalidOperationException("Storm trooper cannot use so heavy armor.");
}
Armor = armor;
}
}
List<StormTrooper> troopers = new()
{
new StormTrooper(),
new StormTrooper(),
new StormTrooperTrainee()
};
Armory.PrepareTroopers(troopers);
Interface Segregation
Definice principu je následovná:
- No code should be forced to depend on methods it does not use.
- ISP splits interfaces that are very large into smaller and more specific ones so that clients will only have to know about the methods that are of interest to them.
Princip říká, že třída by neměla obsahovat metody, které nepotřebuje a má je k dispozici například z důvodu dědění, což je nejčastěji uváděný příklad. V našem případě máme interface se super schopnostmi, které si nárokují Wolverine a Professor X.
Příklad
interface ISuperPower
{
void Heal();
void UseTelepathy();
}
class Wolverine : ISuperPower
{
public void Heal() { /* implementation */ }
public void UseTelepathy() { throw new NotImplementedException(); }
}
class ProfessorX : ISuperPower
{
public void Heal() { throw new NotImplementedException(); }
public void UseTelepathy() { /* implementation */ }
}
interface IHealPower
{
void Heal();
}
interface ITelepathyPower
{
void UseTelepathy();
}
class Wolverine : IHealPower
{
public void Heal() { /* implementation */ }
}
class ProfessorX : ITelepathyPower
{
public void UseTelepathy() { /* implementation */ }
}
Dependency Injection
Definice principu je následovná:
- Depend upon Abstractions. Do not depend upon concretions.
- Technique in which an object receives other objects that it depends on, called dependencies. Typically, the receiving object is called a client and the passed-in ('injected') object is called a service. The code that passes the service to the client is called the injector. Instead of the client specifying which service it will use, the injector tells the client what service to use. The 'injection' refers to the passing of a dependency (a service) into the client that uses it.
Říká nám, že by třída měla být závislá na abstrakci a nikoli na konkrétní implementaci. Pokud například objekt potřebuje volat jiný objekt, tak si nemusí tento objekt vytvořit (není zodpovědný za jeho životní cyklus), ale pouze si ho nechá předat (většinou pomocí interface v konstruktoru).
Příklad
Nacházíme se v Údolí Míru ve vyhlášené restauraci s nudlemi, kterou nevlastní nikdo jiný než Mr. Ping. V této restauraci Mr. Ping učí svého adoptivního syna, pandu Po, starodávnému umění nudlí. Když je čas večeře a Po dostane hlad, je jasné, že mu Mr. Ping připraví nejlepší nudle v okolí.class MrPing
{
public void MakeDinner() { Console.WriteLine("Noodles"); }
}
class Po
{
public void GoForDinner()
{
var father = new MrPing();
father.MakeDinner();
}
}
interface IFather { void MakeDinner(); }
class MrPing : IFather
{
public void MakeDinner() { Console.WriteLine("Noodle"); }
}
class LiShan : IFather
{
public void MakeDinner() { Console.WriteLine("Dumplings"); }
}
class Po
{
private readonly IFather _father;
public Po(IFather father) { _father = father; }
public void GoForDinner() { _father.MakeDinner(); }
}
static class ValleyOfPeace // injector and dependency provider
{
public static void DinnerTime()
{
var father = new LiShan(); // service
var po = new Po(father); // injecting service into the client
po.GoForDinner();
}
}
Závěr
V tomto článku jsme si na několika příkladech ukázali SOLID principy, které nám pomáhají vytvářet lepší kód a umocňovat radost budoucích vývojářů, kteří po nás budou kód přebírat. 😎