Doporučené postupy v programování

Návrh tříd

Abstrakce · Zapouzdření · Dědičnost vs. kompozice · Polymorfizmus · Immutability

Lubomír Bulej

KDSS MFF UK

Abstrakce

Abstrakce

Zjednodušený pohled na složité věci

  • eliminace (pro daný kontext) nepodstatných detailů

Třída jako nositel abstrakce

  • reálné a abstraktní objekty z domény problému
  • abstraktní datové typy + dědičnost a polymorfizmus

Abstraktní datové typy?

Abstraktní matematické struktury

  • mějme množinu ..., prvky mají následující vlastnosti ...
  • definujme operace ..., předpokládejme ..., lze ukázat ...
  • definice ..., lemma ..., věta ..., důkaz ..., složitost ...

Nástroj pro práci v jazyce problému

  • definují data a operace pro manipulaci s nimi
  • umožňují manipulovat s "reálnými" entitami, i když ty nemusí být nutně hmatatelné
    • HttpRequest, Player, Shape, Font
  • při přemýšlení o problému umožňují nezabývat se implementačními detaily
    • místo vložení položky do seznamu se bavíme o vložení buňky do tabulky, přidání nového typu okna do seznamu typů, přidání vagónu k soupravě při simulaci vlaku, atd.

Příklad: práce s fonty

Ad-hoc řešení


                currentFont.size = 16
                currentFont.size = PointsToPixels (12);

                currentFont.sizeInPixels = PointsToPixels (12);

                currentFont.attribute |= 0x02;
                currentFont.attribute |= FONT_ATTRIBUTE_BOLD;

                currentFont.bold = true
            

Abstraktní datový typ


                    currentFont.setSizeInPixels (sizeInPixels);
                    currentFont.setSizeInPoints (sizeInPoints);
                    currentFont.setWeight (FontWeight.BOLD);
                    currentFont.setTypeFace (typeFaceName);
                    currentFont.setStyle (FontStyle.ITALIC);
                

Hlavní výhody ADT

Skrytí implementačních detailů

  • omezuje složitost, se kterou je nutno pracovat
  • umožňuje výměnu implementace
  • omezuje šíření změn programem

Informativní rozhraní

  • správnost programu je zjevnější
  • operace jsou samovysvětlující

Související věci jsou pohromadě

  • není nutné např. předávat struktury po celém programu

Třída jako nositel abstrakce

Abstrakce je určena rozhraním třídy

  • abstrahuje od implementačních detailů, které skrývá
  • důležité je vytvářet dobré, konzistentní abstrakce
  • rozhraní je tedy nejdůležitější částí návrhu třídy

Jak se pozná dobrá abstrakce?

  • orientace na problém
  • konzistence

Zásady pro návrh třídy

Jasně definujte povinnosti/zodpovědnost

  • třída by měla dělat jednu věc, a dělat ji dobře
    • Třída ErrorMessages představuje seznam chybových hlášení, reprezentovaných třídou Message.
    • pozor na "božské" třídy, které všechno ví a všechno umí
  • uvažujte na úrovni abstraktních datových typů
    • Jaký ADT třída reprezentuje?

Specifikujte kontrakt objektu

  • invarianty
  • interakce s okolím
  • kontrakty jednotlivých metod

Příklad: třída reprezentující program

Rozhraní plné různých konceptů


                public class Program {
                  ...
                  public void initializeCommandStack();
                  public void pushCommand(Command command);
                  public Command popCommand();
                  public void shutdownCommandStack();
                  ...
                  public void initializeReportFormatting();
                  public void formatReport(Report report);
                  public void printReport(Report report);
                  ...
                }
            

Zjednodušené rozhraní


                    public class Program {
                      ...
                      public void initializeProgram();
                      public void shutdownProgram();
                      ...
                    }
                

Zásady pro návrh třídy

Vytvářejte konzistentní abstrakce

  • poskytuje zjednodušený pohled na složité věci
    • umožňuje členit inherentní a odfiltrovat zavlečenou složitost
    • umožňuje zabývat se pouze (pro daný kontext) podstatnými detaily
  • u software eliminuje nutnost znalosti implementačních detailů
    • projevuje se na všech úrovních návrhu
  • řada objektů v reálném světě představuje nějakou formu abstrakce
    • dům, dveře, klika, auto, ...
Mansion
Down Arrow
House Outline

Zásady pro návrh třídy

Dbejte na jednotnou úroveň abstrakce v rozhraní

  • sledujte kohezi metod poskytovaných v rozhraní
    • nízká koheze indikuje špatnou abstrakci – opačně to však neplatí
  • nepřidávejte do rozhraní metody, které nejsou konzistentní s poskytovanou abstrakcí
    • eroze rozhraní v důsledku modifikace – broken windows
    • pozor na použití dědičnosti – přebírá rozhraní base class
  • nepřidávejte do rozhraní metody jen proto, že používají pouze jeho veřejné metody

Příklad: třída reprezentující tým sportovců

Rozhraní s různými úrovněmi abstrakce


                public class Team extends ArrayList<Athlete> {
                  ...
                  // public methods
                  public void setName(TeamName teamName);
                  public void setCountry(CountryCode countryCode);
                  ...
                  // public methods inherited from ArrayList
                  public void add(Athlete athlete);
                  public void clear();
                  public boolean isEmpty();
                  public void ensureCapacity(int minCapacity);
                  ...
                }
            

Příklad: třída reprezentující tým sportovců

Rozhraní s konzistentní úrovní abstrakce


                public class Team {
                  ...
                  // public methods
                  public void setName(TeamName teamName);
                  public void setCountry(CountryCode countryCode);
                  ...
                  public void addMember(Athlete athlete);
                  public void removeMember(Athlete athlete);
                  ...
                  private List<Athlete> members;
                }
            

Příklad: třída reprezentující zaměstnance

Původní rozhraní (před modifikací)


                public class Employee {
                  ...
                  public Employee(...);
                  public FullName getFullName();
                  public Address getAddress();
                  public PhoneNumber getWorkPhone();
                  public PhoneNumber getHomePhone();
                  public TaxId getTaxId();
                  public JobClassification getJobClassification();
                  ...
                }
            

Příklad: třída reprezentující zaměstnance

Eroze rozhraní při modifikaci


                public class Employee {
                  ...
                  public Employee(...);
                  public FullName getFullName();
                  public Address getAddress();
                  public PhoneNumber getWorkPhone();
                  public PhoneNumber getHomePhone();
                  public TaxId getTaxId();
                  public JobClassification getJobClassification();
                  ...
                  public boolean isJobClassificationValid(JobClassification job);
                  public boolean isZipCodeValid(Address address);
                  public boolean isPhoneNumberValid(PhoneNumber phoneNumber);
                  ...
                  public SqlQuery getQueryToCreateNewEmployee();
                  public SqlQuery getQueryToModifyEmployee();
                  ...
                }
            

Zapouzdření

Zapouzdření

Doplněk k abstrakci

  • abstrakce odfiltruje nepodstatné detaily

Zapouzdření znemožňuje abstrakci opustit

  • WYSIWYG → WYSIAYG (What You See Is All You Get)
  • skrývání vnitřních informací o objektu před okolím
  • důsledkem je volnější vazba ke zbytku systému
    • poskytuje větší flexibilitu implementaci
    • umožňuje nezávislé testování malých celků
House Outline
Crossed Down Arrow
Mansion

Jak dosáhnout zapouzdření?

Minimalizujte viditelnost všeho

  • veřejné třídy by neměly mít žádné veřejné atributy
  • modifikátor protected používejte po pečlivém zvážení
  • přístup se dá uvolnit vždy, omezit už málokdy

Přístup k atributům tříd

  • mutable atributy znemožňují vynucování invariantů a jsou thread-unsafe
  • atributy jsou součástí rozhraní, nelze je např. přesunout do nadtřídy
  • použijte pomocné metody – getters/setters, accessors
  • analogie ke SmallTalkovské komunikaci pomocí zpráv

Jak dosáhnout zapouzdření?

Skryjte implementační detaily

  • implementace perzistence, low-level výjimky, ...
  • výjimečně možno lehce porušit – "Leaky abstraction"
  • pozor na dědičnost – porušuje zapouzdření

Jak dosáhnout zapouzdření?

Vyhněte se sémantickému porušení zapouzdření

  • používání znalostí o fungování vnitřností třídy, které nejsou uvedeny v jejím kontraktu
    • spoléhání na automatické zavření souboru při destrukci příslušného objektu
    • předpoklady o platnosti ukazatelů vrácených metodou objektu, který už není viditelný
  • zrádné – kompilátor ani jiný nástroj nezkontroluje
  • porušujete kdykoliv se koukáte na kód třídy místo její dokumentace

Dědičnost vs. kompozice

Kompozice

Objekt obsahuje více jiných objektů

  • A «HAS-A» B
  • A car has an engine and four wheels.
    
                            class Car {
                              private Engine engine;
                              private Wheel[] wheels;
                            }
                        
agregace
objekt obsahuje jiné objekty jako části, ty však mohou být součástí více objektů a často mohou existovat i bez samotného agregátu
kompozice
objekty mohou být součástí pouze jednoho objektu, jejich existence bez kompozitu často nedává smysl

Dědičnost

Objekt je specializací jiného objektu

  • A «IS-A» B
generalizace
vymezení společných vlastností nějaké množiny objektů
specializace
vymezení podmnožin v nějaké množině objektů
dědičnost rozhraní
po nadtřídě dědíme pouze a jen signaturu rozhraní
dědičnost implementace
po nadtřídě dědíme signaturu rozhraní, kód i vnitřní reprezentaci

Hlavní výhody dědičnosti

Omezení duplicity kódu

  • společné rysy skupiny třídy sdílejí definici [a implementaci]
    • base class definuje společné rysy na jednom místě
    • odvozené třídy definují své specifické rysy

Stejné zacházení s více třídami

  • pro některé operace stačí rysy, které vykazuje společná nadtřída
  • mechanizmus pro realizaci polymorfizmu

Použití dědičnosti

Substituční princip (Barbara Liskov)

  • nejdůležitější zásada dědičnosti
  • použití dědičnosti pouze pokud je (IS-A) podtřída skutečně specializací nadtřídy
  • test – Hunt & Thomas
    • kdekoliv lze použít nějakou třídu, musí být možné použít i její podtřídu (aniž by uživatel poznal rozdíl)
  • netýká se jen syntaxe, ale i sémantiky
    • podtřída musí dodržovat kontrakt nadtřídy

Vztah «IS-A» musí být trvalý

  • třída Employee dědí z třídy Person
  • třída Supervisor dědí z třídy ... ?
  • Employee a Supervisor mohou být role

Problémy s dědičností

Dědičnost má tendenci dělat věci složitější

  • nadužívána pro technické vlastnosti
  • porušuje zapouzdření, často sémanticky

Přemýšlejte v pojmech «IS-A» a «HAS-A»

  • nepoužívejte dědičnost jen kvůli ušetření kódu
  • dědičnost propaguje rozhraní nadtřídy (včetně nedostatků)
  • kompozice vyžaduje forwarding metod, ale umožňuje definovat vlastní rozhraní

Preferujte kompozici před dědičností

  • (typicky) nemá přímou jazykovou podporu – pracnější při prvním použití

Příklad: počet přidání elementu do množiny

S použitím dědičnosti...


                        public class CountingHashSet<E> extends HashSet<E> {
                          private int addCount = 0;
                          
                          public CountingHashSet() {}
                          
                          public CountingHashSet(int initCap, float loadFactor) {
                            super(initCap, loadFactor);
                          }
                          
                          @Override
                          public boolean add(E e) {
                            addCount++;
                            return super.add (e);
                          }
                          
                          @Override
                          public boolean addAll(Collection<? extends E> c) {
                            addCount += c.size();
                            return super.addAll(c);
                          }
                          
                          public int getAddCount() {
                            return addCount;
                          }
                        }
                    

Co zobrazí následující kód?


                            CountingHashSet<String> s = new CountingHashSet<String>();
                            s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
                            System.out.println("Element additions: " + s.getAddCount());
                        

                        6
                    

Příčiny?

  • super.addAll() nejspíš volá add()
  • mylné předpoklady o implementaci!

Co s tím?

Příklad: počet přidání elementu do množiny

1. část řešení: obecná forwardovací třída


                    public class ForwardingSet<E> implements Set<E> {
                      private final Set<E> target;
                      
                      public ForwardingSet(Set<E> target) { this.target = target; }
                      
                      public void clear() { target.clear(); }
                      public boolean contains(Object obj) { return target.contains(obj); }
                      ...
                      public boolean add(E element) { return target.add(element); }
                      
                      public boolean addAll(Collection<? extends E> elements) {
                        return target.addAll(elements);
                      }
                      ...
                      @Override public boolean equals(Object obj) { return target.equals(obj); }
                      @Override public int hashCode() { return target.hashCode(); }
                      @Override public String toString() { return target.toString(); }
                    }
                

Příklad: počet přidání elementu do množiny

2. část řešení: rozšíření forwardovací třídy


                public class CountingSet<E> extends ForwardingSet<E> {
                  private int addCount = 0;
                  
                  public CountingSet() {}
                  
                  public CountingSet(Set<E> target) { super(target); }
                  
                  @Override public boolean add(E element) {
                    addCount++;
                    return super.add(element);
                  }
                  
                  @Override public boolean addAll(Collection<? extends E> elements) {
                    addCount += elements.size();
                    return super.addAll(elements);
                  }
                  
                  public int getAddCount() {
                    return addCount;
                  }
                }
            

Příklad: reálná a komplexní čísla

Chceme definovat třídy Real a Complex, představující reálná a komplexní čísla – jak bychom použili dědičnost?

  1. Complex dědí od Real
    • Komplexní čísla generalizují, nikoliv specializují reálná čísla
    • Je-li vyžadováno reálné číslo, můžeme dosadit i komplexní
    • Je-li vyžadováno komplexní číslo, nemůžeme dosadit reálné
  2. Real dědí od Complex
    • V matematickém smyslu reálné číslo "is a" komplexní číslo
    • Real bude jednoduše mít nulovou komplexní složku
    • Nulová komplexní složka zabírá paměť

Jak z toho ven?

Příklad: reálná a komplexní čísla

Nepoužijeme dědičnost, ale kompozici!

Komplexní číslo je složené z reálných

  • Komplexní číslo "has a" reálné číslo (dvě instance)
  • Třídy Complex a Real poskytnou operace relevatní typu
  • Použití Real jako Complex umožní typová konverze/přetížené operace

Zásady pro práci s dědičností

Třídu navrhněte pro dědění, nebo jej zakažte

  • 2. nejdůležitější zásada dědičnosti
  • k návrhu patří i dokumentace, tj. jakým způsobem se má využít rozhraní dědičnosti
  • zákaz dědičnosti: final (Java), sealed (C#)

Návrh pro dědičnost = netriviální rozhodnutí a odpovědnost

  • nutno navrhnout vnější rozhraní a rozhraní pro dědičnost
  • rozhraní pro dědičnost je náchylné k porušení zapouzdření

Návrh pro dědičnost

Co vše je nutné zvážit?

  • Jaká má být viditelnost atributů, metod a dalších prvků?
  • Které metody mají být virtuální?
    • virtuální metoda = extension point
    • pozor na jazykové defaults (Java vs C#)
  • Které metody mají být abstraktní?
  • Použít abstraktní bázovou třídu a šablonové metody? Jaké?
  • Nesnížíme příliš flexibilitu pokud dědičnost zakážeme?
    • Příklad: String v Javě

Zásady pro práci s dědičností

Vyhněte se příliš složitým hierarchiím

  • Více jak 3 úrovně ⇒ co je opravdu cílem?
  • Někdy pomůže návrhový vzor Decorator

Společné věci přesuňte v hierarchii co nejvýše

  • usnadňuje jejich použití v podtřídách
  • pozor na zachování konzistence abstrakce

Pozor na třídy s jen jednou podtřídou

  • signalizuje "přemýšlení dopředu"
  • nemusí platit pro knihovny

Zásady pro práci s dědičností

Pozor na prázdnou předefinovanou metodu

  • Možné narušení sémantiky (absence chování)
  • Příklad: Stream.flush a MemoryStream.flush

Nevolejte virtuální metody z konstruktoru

  • atributy, které metoda používá, nemusí být ještě inicializovány
  • týká se i metod clone () nebo readObject () v Javě

Zásady pro práci s dědičností

Vyhněte se vícenásobné dědičnosti implementace

The one indisputable fact about multiple inheritance in C++ is that it opens up a Pandora's box of complexities that simply do not exist under single inheritance. – Scott Meyers

  • málokdy opravdu potřeba, výjimkou jsou např. mixiny
  • vícenásobná dědičnost rozhraní je OK

Polymorfizmus

Co je to polymorfizmus?

Schopnost vystupovat v různých formách

  • specificky v OOP schopnost jazyka zpracovávat objekty různými způsoby v závislosti na jejich typu

Mechanizmy pro implementaci polymorfizmu

  • přetěžování metod
  • dědičnost rozhraní
  • dědičnost rozhraní a implementace
    • bázová třída specifikuje rozhraní
    • podtřídy v rámci něj implementují odlišné chování
    • v kódu jednotlivé podtřídy nerozlišujeme
    • technicky: virtuální metody, pozdní vazba

Polymorfizmus a příkaz switch

Anytime you find yourself writing code of the form "if the object is of type T1, then do something, but if it's of type T2, then do something else," slap yourself. – Scott Meyers, Effective C++

Switch je promarněná příležitost k polymorfizmu

  • Na každý příkaz switch (nebo ekvivalentní if) se dívejte s podezřením a přemýšlejte, zda není lepší ho nahradit polymorfismem
  • Jedna z nejdůležitějších zásad OOP!
  • Podstata návrhových vzorů State a Strategy

Příklad: promarněná příležitost k polymorfizmu

Explicitní změna chování podle druhu objektu

class Shape {
  public void drawRectangle ();
  public void drawShape ();
}

class Graphics {
  public void drawShapes (Collection<Shape> shapes) {
    for (Shape shape : shapes) {
      draw (shape);
    }
  }

  private void draw (Shape shape) {
    if (shape.kind == ShapeKind.RECTANGLE) {
      shape.drawRectangle ();
    } else if (shape.kind == ShapeKind.CIRCLE) {
      shape.drawCircle ();
    } else {
      throw new AssertionError ("unexpected shape: "+ shape.kind);
    }
  }
}

Příklad: promarněná příležitost k polymorfizmu

Využití polymorfizmu

interface Shape {
  public void draw ();
}

class Rectangle implements Shape {
  public void draw () {
    // draw rectangle
  }
}

class Circle implements Shape {
  public void draw () {
    // draw circle
  }
}

class Graphics {
  public void drawShapes (Collection<Shape> shapes) {
    for (Shape shape : shapes) {
      shape.draw ();
    }
  }
}

Příklad: promarněná příležitost k polymorfizmu

Využití polymorfizmu + composite patternu

interface Shape {
  public void draw ();
}

class Rectangle implements Shape {
  public void draw () {
    // draw rectangle
  }
}

class Circle implements Shape {
  public void draw () {
    // draw circle
  }
}

class Graphics implements Shape {
  Collection<Shape> shapes;
  
  public void draw () {
    for (Shape shape : shapes) {
      shape.draw ();
    }
  }
}

Immutability

Definice immutability

Třída je immutable právě tehdy, pokud po vytvoření instance nejdou data instance žádným způsobem změnit.

  • Všechna data jsou tedy zafixována v konstruktoru.

Jak vyrobit immutable třídu?

Prostředky jazyka

  • všechny atributy private a final (Java)
  • žádná metoda třídy nemění data
  • žádné metody nejdou předefinovat v podtřídách

Vnitřní struktura objektu

  • není možné změnit obsažené objekty
    • exkluzivní přístup
    • defenzivní kopie
    • jsou rovněž immutable

Výhody immutability

Redukce počtu možných stavů objektu na jeden

  • invarianty stačí ohlídat v konstruktoru

Inherentně thread-safe

  • nemůže dojít ke kolizím při změnách stavu

Dobré klíče hashovacích tabulek

  • hashCode objektu se nesmí měnit, dokud je použit jako klíč

Výhody immutability

Snadné sdílení objektů

  • není nutné kopírování, klidně ho i zakázat

Snadné sdílení vnitřností

  • např. BigInteger.negate()

Snadné cacheování

  • cache bude vždy aktuální

Výhody immutability

Umožňuje použít techniky funkcionálního programování

  • transformace objektů na objekty
  • žádné vedlejší efekty
  • důsledkem opět redukce stavového prostoru

Poskytuje dobré "stavební bloky"

  • složitější data složená z jednodušších
  • v důsledku zjednodušuje i návrh mutable tříd

Příklad: dobré "stavební bloky"

Immutable DateInterval postavený z mutable tříd Date


                class DateInterval {
                  private Date begin;
                  private Date end;

                  // JE nutné používat defenzivní kopie.

                  public Date getBegin() { return begin.clone(); }
                  public Date getEnd() { return end.clone(); }

                  public DateInterval(Date begin, Date end) {
                    this.begin = begin.clone();
                    this.end = end.clone();
                  }
                }
            

Podobně problematické použití mutable třídy Date


                    Date date = new Date ();
                    Scheduler.scheduleTask (task1, date);
                    date.setTime (d.getTime() + ONE_DAY);
                    Scheduler.scheduleTask (task2, date);
                

Příklad: dobré "stavební bloky"

Immutable DateInterval postavený z immutable tříd Date


                class DateInterval {
                  private Date begin;
                  private Date end;

                  // NENÍ nutné používat defenzivní kopie.

                  public Date getBegin() { return begin; }
                  public Date getEnd() { return end; }

                  public DateInterval(Date begin, Date end) {
                    this.begin = begin;
                    this.end = end;
                  }
                }
            

Nevýhody immutability

S každou změnou atributu vzniká nová instance

  • vadí při mnoha malých operacích za sebou
    • alokace objektů téměř nic nestojí
    • problémem je zvýšený tlak na garbage collector
  • možno vyřešit dočasným použitím "mutable counterpart"
    • String vs. StringBuilder

Paradoxně mutability může vést ke stejné situaci

  • Dimension vracená metodou Component.getSize ()
  • použití mutable tříd ke stavbě immutable třídy

Kdy má být třída immutable?

Pokud nemáte velmi dobrý důvod, aby byla mutable

  • mutable třídy by se měly měnit co nejméně
  • immutabilitu zdokumentovat

                /**
                 * Class which stores information about timing of the experiments
                 * in the regression analysis.
                 *
                 * The class is immutable and especially Misho should never ever
                 * try to make it mutable :-)
                 *
                 * @author David Majda
                 */
                public class SchedulerInfo implements Serializable {
                  /* ... */
                }
            

Častí kandidáti na immutabilitu

Obecně malé "value objects"

  • identifikátory, data, časy
  • intervaly, dvojice, trojice,...
  • geometrické útvary (bod, úsečka,...)
  • třídy popisující layout/strukturu něčeho (dokument, GUI,...)
  • třídy vzešlé z DSL
  • uzly v AST
  • metadata o nějaké entitě (soubor, proces,...)

Nevhodní kandidáti na immutabilitu

  • velké objekty, kontejnerové objekty
  • postupně konstruované objekty