Spustit prezentaci

Doporučené postupy v programování

Návrh tříd

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

Lubomír Bulej, David Majda

KSI MFF UK

Abstrakce

Abstrakce

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

Třída jako nositel abstrakce

Steve McConnel:

Způsob uvažování při programování se vyvíjel spolu se složitostí programů, které bylo nutné vytvářet. Zatímco v dávných dobách programátor přemýšlel o jednotlivých příkazech a jejich sekvenci, v 70. a 80. letech 20. století začal pracovat s konceptem rutin. V 90. letech se rozšířilo (vniklo již dříve) objektové programování, které stále představuje hlavní paradigma pro tvorbu programů. Dnes tedy programátoři pracují s konceptem tříd a objektů.

Třída představuje spojení dat a rutin, které slouží nějakému dobře definovanému účelu. Třída se obejde i bez dat – v takovém případě představuje sdružení služeb, které opět pojí nějaký společný účel. Klíčem k efektivitě programátora je maximalizace části programu, kterou je možné pustit z hlavy aniž by to mělo negativní důsledky tu na část, se kterou pracuje. V tomto ohledu třídy představují hlavní nástroj, jak toho dosáhnout.

Bez ohledu na podporu v konkrétmím jazyce třídy představují pouze technický prostředek k zápisu programů. Pokrok v programování však přicházel vždy především se zvyšováním úrovně abstrakce, na které se o programu přemýšlí. Tento posun v abstrakci představují abstraktní datové typy, které popisují zároveň data i operace, které s těmito daty pracují. Objektově-orientované programování je tedy primárně o práci s abstraktními datovými typy.

Abstraktní datové typy?

Abstraktní matematické struktury

Abstraktní datové typy?

Abstraktní matematické struktury

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

Slovo "reálné" je v uvozovkách, protože problém, který řešíme, se reality mimo počítač vůbec nemusí týkat. Třeba třída HttpRequest nepředstavuje nic opravdu hmatatelného, je to (obvykle) jen sekvence bajtů zaslaná po síti.

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ů

Informativní rozhraní

Související věci jsou pohromadě

Třída jako nositel abstrakce

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

Jak se pozná dobrá abstrakce?

Zásady pro návrh třídy

Jasně definujte povinnosti/zodpovědnost

Specifikujte kontrakt objektu

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

Abstrakce samozřejmě nejsou samospasitelné, většina z nich má nějaké díry a k jejich efektivnímu používání většinou potřebujeme vědět, co abstrahují:

Což však z pohledu návrhu zas tolik nevadí, důležité je, že nám umožňují se některými nepodstatnými detaily nezabývat, pokud to není potřeba.

Zásady pro návrh třídy

Dbejte na jednotnou úroveň abstrakce v rozhraní

Eroze v tomto případě znamená tendenci přidávat do objektů různé pomocné metody, které se hodí uživatelům objektu v různých jiných částech kódu. Zejména u větších projektů s více programátory se často stává, že přidané metody nesouvisí s abstrakcí představovanou objektem a jeho zodpovědností. Vzniká tak zamotaný, nestrukturovaný kód.

Asi jediný způsob, jak se erozi vyhnout, je disciplína programátorů a případně jednoznačné vlastnictví kódu (kdy vlastník nepovolí nesystematický zásah do svého kódu).

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

Zapouzdření znemožňuje opuštění poskytované abstrakce

Jak dosáhnout zapouzdření?

Minimalizujte viditelnost všeho

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

Jak dosáhnout zapouzdření?

Skryjte implementační detaily

K porušování skrývání implementačních detailů: Existuje mnoho toolkitů obalujících část Win32 API pro tvorbu GUI. Typicky jde o hierarchii tříd, na jejímž konci se nacházejí třídy pro jednotlivé ovládací prvky. Málokdy je možné a vhodné implementovat do obalující třídy všechny vlastnosti, které dotyčný ovládací prvek ve Win32 API má – třída by až příliš narostla. Pragmatické řešení je porušit zapouzdření a zveřejnit handle ovládacího prvku z Win32 API. Kdo bude potřebovat, může vlastnosti ovládacího prvku, které zapouzdřující třída neobsahuje, používat přímo pomocí handle a Win32 API funkcí. Takto je problém například vyřešen v Borland Delphi.

Sémantické porušení zapouzdření

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

K porušování sémantického zapouzdření: Pokud jste nuceni se podívat do zdrojového kódu používané třídy, znamená to, že třída má buď špatnou úroveň abstrakce nebo nedostatečnou dokumentaci. Správnou akcí je v tuto chvíli donutit autora třídy k nápravě, je-li to možné.

Dědičnost vs. kompozice

Kompozice

Objekt obsahuje více jiných objektů

Agregace vs. kompozice

agregace
objekt obsahuje jiné objekty jako části, ty však mohou být součástí více objektů a mohou existovat i bez samotného agregátu
kompozice
objekty mohou být součástí pouze jednoho objektu, jejich existence bez kompozitu nedává smysl

Dědičnost

Reprezentace vztahu typu "is a"

Generalizace vs. specializace

generalizace
vymezení společných vlastností nějaké množiny objektů
specializace
vymezení podmnožin v nějaké množině objektů

Dědění rozhraní vs. dědění implementace

dědění rozhraní
po nadtřídě dědíme jen signaturu rozhraní, nikoliv kód
dědění implementace
po nadtřídě dědíme signaturu rozhraní i kód

Hlavní výhody dědičnosti

Omezení duplicity kódu

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

Použití dědičnosti

Substituční princip (Barbara Liskov)

Vztah (is-a) musí být trvalý

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

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

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

Viz Effective Java: Programming Language Guide, Item 15.

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

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;
  }
}       

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

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

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

Návrh pro dědičnost

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

K virtuálním metodám: Každá virtuální metoda je extension point, do kterého může svůj kód umístit odvozená třída. Ta může "vyvádět psí kusy", porušit některé invarianty, způsobit reetrantnost, na kterou nejste připraveni apod. V praxi naštěstí zdá se k těmto problémům příliš nedochází – kvůli nim si zatím nikdo příliš nestěžoval, že v Javě jsou všechny metody implicitně virtuální.

Často se však objevují stížnosti z důvodu výkonu. Zavolání virtuální metody trvá o něco déle než zavolání obyčejné, takže ve výkonnostně kritických aplikacích (např. hry) nebo úsecích kódu se někdy vyplatí nad virtuálními metodami uvažovat i z tohoto hlediska. Nejjistější je jako obvykle takové rozhodnutí založit na nějakých faktech.

Ke snižování flexibility: Tím, že zabráníme uživateli dědit od nějaké třídy, zabraňujeme mu vytvořit si její upravené verze, ve kterých si např. dopíše různé pomocné metody. To je velký problém u tříd v knihovnách Javy, které jsou často final a přitom jsou navrženy poměrně minimalisticky. Důsledek je, že skoro v každém větším projektu v Javě se najde spousta pomocných tříd (např. StringUtils), které potřebné metody implementují jako statické.

Jednou ze zajímavých vlastností C# 3.0 je, že obsahauje mechanizmus, který zajišťuje, že se tyto pomocné statické metody dají volat, jako by to byly metody původní třídy. Těžko říct, zda je to ošklivý hack nebo elegantní řešení obtížného problému :-)

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

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

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

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

K třídám s jen jednou podtřídou: Podobně platí i pro rozhraní s jen jednou implementací, nicméně tam může být situace trochu odlišná. Rozhraní může být použito k omezení rozhraní poskytovaného třídou nebo k vytvoření určitého pohledu (facet) na třídu.

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

Pozor na prázdnou předefinovanou metodu

Nevolejte virtuální metody z konstruktoru

K prázdné předefinované metodě: Příkladem budiž abstraktní třída Stream, představující nějaký výstupní datový proud. Mezi jejími metodami je i metoda flush, která zapíše na disk data držená v bufferu. V memory-based streamu MemoryStream taková metoda pochopitelně bude prázdná. To je špatně. Správné by bylo zjemnit hierarchii tříd, rozdělit je na bufferované/nebufferované nebo disk-based/memory-based a metodu flush přidat jen do té větve hierarchie, kde ji třídy budou opravdu implementovat.

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

K mnohonásobné dědičnosti: Dědičnost rozhraní je v C++ realizována ryze abstraktními třídami, v Javě a C# pak pomocí interfaců.

Příklad: kompozice vs. dědičnost

Chceme definovat třídy Real a Complex, představující reálná a komplexní čísla – jaký má být mezi nimi vztah?

Příklad: kompozice vs. dědičnost

Chceme definovat třídy Real a Complex, představující reálná a komplexní čísla – jaký má být mezi nimi vztah?

  1. Complex dědí od Real
    • Myšlenka: Komplexní čísla rozšiřují reálná čísla
    • Ale: Je-li vyžadováno reálné číslo, můžeme dosadit i komplexní
    • Ale: Je-li vyžadováno komplexní číslo, nemůžeme dosadit reálné

Příklad: kompozice vs. dědičnost

Chceme definovat třídy Real a Complex, představující reálná a komplexní čísla – jaký má být mezi nimi vztah?

  1. Complex dědí od Real
    • Myšlenka: Komplexní čísla rozšiřují reálná čísla
    • Ale: Je-li vyžadováno reálné číslo, můžeme dosadit i komplexní
    • Ale: Je-li vyžadováno komplexní číslo, nemůžeme dosadit reálné
  2. Real dědí od Complex
    • Myšlenka: reálné číslo "is a" komplexní číslo
    • Real bude jednoduše mít nulovou komplexní složku
    • Ale: Nulová komplexní složka zabírá paměť
    • Ale: Co když budeme potřebovat Quaternion?

Příklad: kompozice vs. dědičnost

Chceme definovat třídy Real a Complex, představující reálná a komplexní čísla – jaký má být mezi nimi vztah?

  1. Complex dědí od Real
    • Myšlenka: Komplexní čísla rozšiřují reálná čísla
    • Ale: Je-li vyžadováno reálné číslo, můžeme dosadit i komplexní
    • Ale: Je-li vyžadováno komplexní číslo, nemůžeme dosadit reálné
  2. Real dědí od Complex
    • Myšlenka: reálné číslo "is a" komplexní číslo
    • Real bude jednoduše mít nulovou komplexní složku
    • Ale: Nulová komplexní složka zabírá paměť
    • Ale: Co když budeme potřebovat Quaternion?

Co je správně?

Příklad: kompozice vs. dědičnost

Správně není ani jedno!



Nepoužijeme dědičnost, ale kompozici

Proč tolik povyku kolem dědičnosti?

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

Přemýšlejte v pojmech "is a" a "has a"

Preferujte kompozici před dědičností

Viz Effective Java: Programming Language Guide, Item 14.

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 (); }
}       
Viz Effective Java, 2nd Edition: Item 17.

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;
  }
}       

Polymorfizmus

Co je to polymorfizmus?

Schopnost vystupovat v různých formách

Mechanizmy pro implementaci polymorfizmu

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++

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++

Každý switch je promarněná příležitost k polymorfizmu

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

Viz Effective Java: Programming Language Guide, Item 13.

Definice immutability

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

Výraz "immutable" zde nepřekládám, protože neznám žádný překlad, který by nezněl divně. Pokud vás nějaký napadne, rád o něm uslyším.

Jak vyrobit immutable třídu?

Prostředky jazyka

Vnitřní struktura objektu

Pro C# stačí nahradit final za sealed. V C++ jde podobného efektu dosáhnout pomocí const.

Výhody immutability

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

Inherentně thread-safe

Dobré klíče hashovacích tabulek

Ke klíčům hashtabulek: Jedním z požadavků na klíč hashtabulky v Javě je, aby se po dobu, co je klíčem, nezměnila hodnota, kterou vrací jeho metoda hashCode. Tato hodnota je v drtivé většině případů počítána z atributů objektu. V případě immutable objektů se atributy nikdy nezmění a tím pádem se nezmění ani hodnota vracená metodou hashCode objektu. Podmínka kladená na klíč hashtabulky je tedy automaticky splněna.

Výhody immutability

Snadné sdílení objektů

Snadné sdílení vnitřností

Snadné cacheování

K snadnému sdílení vnitřností: Třída BigInteger ze standardní knihovny Javy implementuje "velká čísla". Technicky jsou implementována pomocí pole. Metoda negate vrátí nové velké číslo, které bude negací toho, na kterém byla zavolána. Protože instance třídy BigInteger jsou immutable, původní i znegované číslo můžou sdílet pole s číslicemi bez obav, že bude přepsáno. Každá instance musí mít jen svoji informaci o znaménku. Ušetří se tak paměť.

Výhody immutability

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

Poskytuje dobré "stavební bloky"

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.begin = end.clone();
  }
}

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

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 immmutable 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.begin = end;
  }
}

Nevýhody immutability

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

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

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

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

/**
 * 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 {
  /* ... */
}
Příklad je převzat z našeho softwarového projektu.

Častí kandidáti na immutabilitu

Obecně malé "value objects"

Nevhodní kandidáti na immutabilitu