Návrhové principy SOLID
03.02.2022
Slovníček
SOLID | Akronym 5 principů objektového programování, které zpopularizoval Robert C. Martin. Principy si kladou za cíl vytvářet srozumitelnější a udržitelnější kód. |
Obsah
- Úvod
- Single Responsibility
- Open Closed
- Liskov Substitution
- Interface Segregation
- Dependency Inversion
Úvod
Každý programátor se dříve nebo později s termínem SOLID setká. SOLID je zkratkou pěti principů objektového programování, kterými se můžeme inspirovat, aby náš kód byl přehlednější, srozumitelnější a snadněji upravitelný. To zní velmi dobře, ale má to jeden háček 🙈 Principy jsou obecné a celkem abstraktní. Pro lepší představu je dobré si uvést několik příkladů konkrétního kódu. Všechny příklady budou na úrovni tříd, což je základní stavební jednotka OOP. Principy si dovolím volně zjednodušit následovně
Single Responsibility | Každá třída má pouze jednu zodpovědnost. |
---|---|
Open Closed | Třídy vytváříme tak, abychom při novém požadavku mohli upravit co nejméně kódu. |
Liskov Substitution | Pokud třída dědí od jiné třídy, měli bychom být touto třídou (subclass) schopni nahradit rodičovskou třídu (superclass). |
Interface Segregation | Třída by neměla obsahovat metody, které nepotřebuje. |
Dependency Injection | Třída si místo vytvoření objektu jiné třídy nechá tento objekt předat zvenčí, většinou 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í, resp. třída Weather. Výhodou také je, že jsme významně zjednodušili třídu Thor a odebrali jsme závislost Zeuse na Thorovi. Jinými slovy jsme zvýšili soudržnost (cohesion) třídy Thor (obsahuje větší poměr kódu, který souvisí s Thorem) a snížili jsme vazbu (coupling), mezi Thorem a počasím. Zrušili jsme úplně vazbu mezi Zeusem a Thorem a zpřímili jsme doteď nepřímou vazbu mezi Zeusem a počasím. Zní to divně, 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 používání polyformismu a využítí dědičnosti z abstraktní třídy nebo z interface, abychom se vyhli zbytečným změnám v existujícím kódu.
Příklad
Máme k dispozici bojovou arénu, ve které se spolu utkávají pokémoni 😂 Každý pokémon má blíž k nějakému přírodnímu elementu, které ovlivňují bojovou logiku. Například pokud útočí ohnivý pokémon na listnatého tak se mu zdvojnásobuje útok.
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 i přehledný. V kódu se ale používá enum pro seznam elementů. Obecná potíž s enumem je taková, že když enum upravíme, tak musíme překontrolovat všechna místa v kódu, kde se enum používá. Překontrolovat tedy všechny if-else podmínky, switch konstrukce a podobně, abychom měli jistotu, že jsme někde nezapomněli nový element zahrnout. Z tohoto důvodu se v metodě BattleArena.Attack() kontroluje, jestli některý z pokémonů nemá neznámý element, na který se při psaní bojové logiky nemyslelo. Pokud bychom přidali nový element, například vodu
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řidaný 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 schopnostma, které si nárokuje 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
Princip 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 nudlema, 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 😂😎