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

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.
Je zřejmé, že princip se odkazuje na koncept soudružnosti (cohesion) a vazeb (coupling), který je velmi dobře popsaný zde. Zjednodušeně lze ale říci, že
Věci které spolu souvisí bychom měli dávat na jedno místo a věci které spolu nesouvisí by naopak u sebe být neměli. Zároveň odlišné věci by mezi sebou měli mít co nejméně závislostí.
Super! právě jsme shrnuli do dvou vět několik akademických prací 😎

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();
    }
}
Problémem třídy Thor (ale v našem případě je to spíše problém Zeuse 😂) je, že má zodpovědnost za manipulaci s počasím, což lze považovat za komplikovaný a zároveň vnější systém. Například Zeus by také rád ovládal počasí, ale musí si kvůli tomu vždy povolat Thora. Jedná se o nadbytečnou závislost a porušení SRP principu. Kód můžeme upravit následovně.
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 }
tak nám bojová aréna vyhodí chybu a tím pádem náš kód není otevřený pro rozšíření. Zároveň musíme bojovou logiku upravit a přidat do ní funkci, že oheň má slabost pro vodu a tím pádem kód není uzavřený pro modifikaci. Příklad tedy porušuje OCP.

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;
}
tak nám kód bude stále fungovat (je tedy otevřený pro rozšíření). Pokud přidáme funkci, že oheň má slabost pro vodu
public class FireElement : IElement
{
    public string Name { get; } = "Fire";
    public IElement Weakness { get; } = new WaterElement();
}
tak už nemusíme upravovat bojovou arénu (kód je tedy uzavřený pro modifikaci). V rámci OCP principu ale můžeme s refactoringem pokračovat dále a pokud bychom změnili, že každý element nemá slabost, ale výhodu proti jinému elementu
public class WaterElement : IElement
{
    public string Name { get; } = "Water";
    public IElement StrongAgainst { get; } = new FireElement();
}
tak při přidávání nových elementů už nemusíme upravovat stávající elementy a kód bude ještě více uzavřený pro změnu. Výhodou dodržování OCP je, že při změnách v kódu snižujeme pravděpodobnost zanesení nové chyby.

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;
    }
}
Problém s příkladem je takový, že pokud zavoláme následující kód
List<StormTrooper> troopers = new()
{
    new StormTrooper(),
    new StormTrooper(),
    new StormTrooperTrainee()
};

Armory.PrepareTroopers(troopers);
tak sice vyzbrojíme dva storm troopery, ale na traineem nám kód spadne, protože neunese těžké brnění. Naše abstrakce je tedy chybná, protože jsme porušili jedno z pravidel LSP principu (konkrétně "preconditions cannot be strengthened in the subtype") a musíme kód vymyslet jinak 😅

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 */ }
}
I přesto, že oba mutanti umí používat super schopnosti, tak Wolverine neumí použít telepatii a Professor X, byť je jeden z nejmocnějších mutantů, se zase neumí léčit. Příklad tedy porušuje princip SIP a to v obou třídách. Kód můžeme upravit následovně.
interface IHealPower
{
    void Heal();
}

interface ITelepathyPower
{
    void UseTelepathy();
}

class Wolverine : IHealPower
{
    public void Heal() { /* implementation */ }
}

class ProfessorX : ITelepathyPower
{
    public void UseTelepathy() { /* implementation */ }
}
Nyní jsme interface super schopností rozdělily do dvou menších interface objektů a Wolverin a Professor X se již nemusí starat o schopnosti, které nemají 😅 Kód je nyní více univerzální a client, který bude volat API nějakého mutanta, omylem nezavolá špatnou schopnost, která by mu vyhodila výjimku.

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();
    }
}
Jednoho dne se ale stane, že se v Údolí Míru objeví Li Shan, ohromná panda, která překoná rekord v pojídání knedlíčků. To strhne velkou pozornost a překvapí místní, neboť do té doby nikdo nepojídal knedlíčky tak rychle jako právě náš Po. Po nakonec zjistí, že Li Shan je jeho pravý otec a od té doby se může vždy rozhodnout, jestli si k večeři dá nejlepší nudle s Mr. Pingem a nebo bude pojídat knedlíčky se svým druhým otcem Li Shan.
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();
    }
}
V prvním příkladě Po vždy chodil na večeři k Mr. Pingovi a jedná se o tzv. těsnou vazbu (tight coupling). Ve druhém příkladě jsme pomocí DI vazbu zvolnili (loose coupling). To má několik výhod, můžeme Poa posílat na večeři dle potřeby k Mr. Pingovi nebo Li Shanovi a zároveň si můžeme vytvořit dalšího fiktivního otce pro účely testování.

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 😂😎

Odkazy