čtvrtek 27. ledna 2011

Princip jedné odpovědnosti

Princip jedné odpovědnosti (SRP - Single Responsibility Principle) je jeden z pěti základních principů metodologie SOLID objektového návrhu. Jedna z jeho nejčastějších definic zní:

Třída by měla mít právě jeden důvod ke změně.

Třída by tedy měla mít pouze jednu odpovědnost - pouze jednu logickou funkcionalitu. Svou definicí patří SRP mezi nejjednodušší principy objektového návrhu, nezkušení objektoví návrháři však tento princip často ve svých programech porušují.

Vysvětleme si problém na příkladě třídy Uzivatel:

public class Uzivatel
{
    public String Id { get; set; }
    public String Heslo { get; set; }

    public void Ulozit(IUloziste uloziste)
    {
        uloziste.Ulozit<Uzivatel>(this);
    }
}

Třída nese informace o uživateli (Id a Heslo) a zajišťuje jeho uložení do úložiště, např. databáze. Zkušenější čtenáři ihned rozpoznali porušení princip jedné odpovědnosti. První odpovědností třídy je reprezentace entity typu uživatel v systému (veřejné atributy Id a Heslo). Druhou odpovědností je uložení stavu objektu do úložiště - metoda Ulozit. Provedeme refaktorizaci do dvou samostatných tříd:

public class Uzivatel
{
    public String Id { get; set; }
    public String Heslo { get; set; }
}

public class UzivatelDao
{
    private IUloziste uloziste;

    public UzivatelDao(IUloziste uloziste)
    {
        this.uloziste = uloziste;
    }

    public void Ulozit(Uzivatel uzivatel) 
    {
        uloziste.Ulozit<Uzivatel>(uzivatel);
    }
}

Ze třídy Uzivatel jsme odstranili metodu Ulozit. Vznikla nová třída UzivatelDao, jejíž odpovědností je zpřístupňovat funkcionalitu související s úložištěm entit Uzivatel, tedy například i uložení.

Pro úplnost zjednodušená definice rozhraní IUloziste:

interface IUloziste
{
    void Ulozit<T>(T objekt);
}

Proč je nutné princip dodržovat

  • Čitelnost kódu. Někdy dochází k obavám, že rozdělením větší třídy na více menších jednoúčelových tříd se zvýší složitost systému a bude problematické porozumět celku. Opak je pravdou. Navigace ve třídách, které dělají to, co jejich název deklaruje, je mnohem efektivnější. Budete-li procházet velké třídy s více odpovědnostmi, budete zatěžování další funkcionalitou, která Vám bude překážet v nalezení toho, co hledáte. Robert C. Martin přirovnává třídy mající více odpovědností k velké krabici v garáži, ve které máte naházeno všechno různé nářadí. Jednoúčelové třídy naopak působí jako úhledně urovnané malé krabičky obsahující pouze související věci jednoho typu.
  • Robustnost. Rozdělením tříd podle odpovědnosti docílíme toho, že případné pozdější požadavky na změnu ovlivní pouze třídu, které by se měly týkat. Např. začnete evidovat nový atribut bool PovolenPristup třídy Uzivatel. Rozšíříte třídu Uzivatel, ale třída UzivatelDao tím nebude nijak zasažena.
  • Snížení vzájemné provázanosti tříd. Velké třídy budou potřebovat vazby na více jiných tříd. Např. v našem příkladě problematická třída Uzivatel odkazovala rozhraní IUloziste. Ale entita typu uživatel je přeci v systému na business vrstvě nezávislá na způsobu jeho uložení.
  • Testovatelnost. Třída mající pouze jednu odpovědnost je lépe testovatelná. Souvisí to také se snížením provázanosti tříd. Test bude omezen pouze na jednu funkcionalitu. Např. pro otestování třídy Uzivatel nebudete potřebovat instanci IUloziste.
  • Soudržnost. Malé třídy mají větší soudržnost, která je v OO návrhu doporučovaná. Více o soudržnosti v některém z dalších příspěvků.

Jak odhalit porušení principu jedné zodpovědnosti

Důležité je samozřejmě získat zkušenosti s objektovým návrhem. Měli byste být také schopni se kriticky ohlédnout za dokončenou prací a zhodnotit dodržování SRP. V případě potřeby třídy refaktorovat.

Jedna poučka říká, že pokud při popisu významu třídy použijete spojku "a", měli byste zbystřit a prověřit dodržování SRP. Podezřelé jsou však i souvětí jiného typu. Pozor na třídy typu "reprezentace auta a výpočet jeho spotřeby", "sestava se zpracováním parametrů", "kreditní karta, která si umí sama strhnout poplatek", apod.

Dejte si pozor na slučování reprezentace entity a funkcionality jejího vytvoření. V těchto případech lze refaktorovat funkcionalitu vzniku instance třídy podle některého z návrhových vzorů pro vytváření objektů.

Další zdroje a ukázky

6 komentářů:

  1. Dobrý den,nerozumím této části:

    Robustnost - ovlivnění pouze třídy, kt. se týkají, ale UzivatelDAO nebude zasažena.

    Copak není při změně vlastností třídy, kt. jsou i v DB, současně upravit i třídu, která ty vlastnosti do DB ukládá?

    Jakpak by se to stalo?

    (př.)
    třída Uživatel - vlastnosti Jméno, Příjmení...
    třída ukládající Uživatele -
    ...
    cmd.Parameters.AddWithValue("@jmeno", _jmeno)
    cmd.Parameters.AddWithValue("@prijmeni", _prijmeni)
    ...

    a pokud upravím Uživatel, tak je třeba upravit i ukládání..

    Vladimír Hála

    OdpovědětVymazat
    Odpovědi
    1. Děkuji Vladimíre za přečtení a připomínku.

      Implementace UzivatelDao uvedená v příkladu by se při přidání atributu opravdu nemusela měnit. Sama totiž nezná mapování vlastností uživatele na databázové sloupce a pouze volá metodu IUloziste.Ulozit().

      IUloziste může být implementováno jako wraper pro NHibernate nebo nějaký jiný persistentní framework. V případě NHibernate je potřeba při inicializaci předat O/R mapovací pravidla (xml nebo Fluent NHibernate). Tzn. přidání atributu změní tuto mapovací definici. Ne však implementaci UzivatelDao.

      Může samozřejmě dojít k tomu, že třídu UzivatelDao naimplementujete tak, že bude přímo zapisovat do hodnot nějaké struktury v úložišti. Ale IMHO je to méně přehledné a tím pádem hůře udržovatelné, než dát O/R mapování zvlášť. Pokud navíc využíváte pro O/R mapovací pravidla generování z logického modelu, je oddělení do samostatné třídy (souboru) ještě více vhodné.

      Vymazat
    2. Děkuji Vám za odpověď, pokusil jsem se o implementaci, ale nedaří se mi v metodě "Uložit" pracovat s předanou třídou (tr_misto) - její vlastnosti nejsou přístupné.
      Váš příklad jsem převáděl do VB, s C# nemám zkušenost.

      Zkoušel jsem zápis snad na tisíc způsobů, ale nedaří se mi ty vlastnosti zpřístupnit.

      Mohl byste mi napovědět, kde dělám chybu?

      Public Class tr_misto

      Private ID As Integer
      Public Property _ID() As Integer
      Get
      Return ID
      End Get
      Set(ByVal value As Integer)
      ID = value
      End Set
      End Property
      Private nazev As String
      Public Property _nazev() As String
      Get
      Return nazev
      End Get
      Set(ByVal value As String)
      nazev = value
      End Set
      End Property

      End Class


      Public Class tr_misto_DAO
      Implements IUloziste

      Private uloziste As IUloziste

      Private Shared csDB As String = ConfigurationManager.ConnectionStrings("csDB").ToString

      Public Sub New(uloziste As IUloziste)
      Me.uloziste = uloziste
      End Sub

      Public Sub Ulozit(Of tr_misto)(ByVal misto As tr_misto) Implements IUloziste.Ulozit
      Dim vysledek As String
      Dim conn As SqlConnection = New SqlConnection(csDB)
      Dim cmd As SqlCommand = New SqlCommand("spx_misto_INSERT", conn)
      Try
      With cmd
      .CommandType = System.Data.CommandType.StoredProcedure
      .Parameters.AddWithValue("@ID", misto._ID)
      .Parameters.AddWithValue("@Nazev", misto._nazev)
      End With

      conn.Open()
      vysledek = cmd.ExecuteScalar()
      Catch e As Exception
      Finally
      conn.Close()
      End Try
      End Sub


      Interface IUloziste
      Sub Ulozit(Of T)(objekt As T)
      End Interface

      End Class



      Děkuji, Vladimír Hála

      Vymazat
    3. Bohužel VisualBasic vůbec neznám :(
      Metoda tr_misto_DAO by mohla mít signaturu:
      Public Sub Ulozit(Of T)(ByVal misto As tr_misto)
      Pokud to děláte podle příkladu, tak DAO objekt by neměl implementovat IUloziste. Odkaz na tuto instanci dostane při vzniku, tzn. v konstruktoru.

      Můj příklad je dost abstraktní a předpokládá, že je inicializován persistentní framework, který umí sám uložit instance příslušných tříd, které jsou mapovány do databáze.

      Vymazat
  2. Vážně používáte ve zdrojácích češtinu?

    OdpovědětVymazat
    Odpovědi
    1. Ano, z kulturně-historicko-personálních důvodů. ;) Dřív mi to tolik nevadilo, ale už jsem v tomto ohledu trochu dospěl a anglicko-česká polévka mně přestala chutnat.

      Vymazat