Návrhový vzor Visitor

V tomto článku se podíváme na zoubek návrhovému vzoru Visitor a jeho implementaci v C# bez použití i s použitím Reflexe. Tento návrhový vzor patří k těm složitějším a kvůli svému ne úplně šťastně zvolenému názvu je často zdrojem nejasností i pro zkušenější programátory. Domnívám se, že problém často vězí v nepochopení účelu a motivace pro tento vzor, proto se na začátku článku zaměříme na tyto aspekty a pak až se podíváme na implementaci samotného vzoru.

Methods dispatching

Nejprve zavedeme jednoduchou terminologii — statický typ objektu a aktuální typ objektu. V následující ukázce je Stream statickým typem objektu „obj“ a FileStream je jeho aktuálním typem. Aktuálním typem objektu „obj“ může být kterýkoliv potomek abstraktní třídy Stream.

Stream obj = new FileStream("foo.txt", FileMode.Create);
// ... změníme aktuální typ:
obj = new MemoryStream(/* ... */);

Začneme menším kvízem: co bude výstupem následujícího program (nebo-li, která verze metody Foo se zavolá)?

public class Program {
  public static void Foo(object o) {
    Console.WriteLine("Foo:object");
  }

  public static void Foo(string str) {
    Console.WriteLine("Foo:string");
  }

  public static void Main(string[] args) {
    object obj = new string("ahoj svete");
    Foo(obj);
  }
}

Správná odpověď je „Foo:object“, protože kompilátor vybírá mezi Foo(string) a Foo(object) už během kompilace na základě statického typu parametru „obj“ a tím je object, ne string. Jediný případ (zatím neuvažujeme Reflection), kdy je skutečně zavolaná metoda vybrána až za běhu, jsou virtuální metody jako u následujícího příkladu, jehož výstupem je „Foo:Bar“.

public class Program {
  class Baz {
    public virtual void Foo() {
      Console.WriteLine("Foo:Baz");
    }
  }

  class Bar : Baz {
    public override void Foo() {
      Console.WriteLine("Foo:Bar");
    }
  }

  public static void Main(string[] args) {
    Baz b = new Bar();
    b.Foo();
  }
}

Zde je metoda Foo vybraná na základě aktuálního typu objektu b, tedy je správně vybraná metoda Foo ze třídy Bar. Proč tomu tak je a jak to celé funguje? C# kód se překládá do mezikódu (MSIL) a tento mezikód potom překládá Just-in-time kompilátor přímo do strojového kódu. .NET mezikód je takový trochu „higher-level assembler“ a poskytuje několik instrukcí pro volání metod. Nejdůležitější jsou .call a .callvirt. Čím se tyto dvě instrukce liší? První z nich, .call, překládá Just-in-time kompilátor čistě jako volání konkrétní metody pomocí její pevně zadané adresy. Na druhou stranu instrukce .callvirt je přeložena do strojového kódu, který adresu volané metody vybere podle tabulky virtuálních metod. Důsledkem je, že instrukce .callvirt je pomalejší než .call, ale poskytuje lepší flexibilitu.

Tomu jak se vybírá metoda, která je nakonec skutečně zavolaná, se říká dispatching od anglického dispatch (vypravit, expedovat). C# podporuje takzvaný single dispatch, kdy je skutečná metoda vybraná na základě aktuálního typu objektu, na kterém je zavolaná. Některé jazyky, především dynamické, podporují takzvaný multiple dispatch, kdy je skutečně zavolaná metoda vybraná na základě aktuálních typů všech jejích argumentů i objektu, na kterém je zavolaná (pokud je instanční). Kód ekvivalentní úvodní ukázce by v takovém jazyce po spuštění vypsal „Foo:string“.

Multiple dispatch lze použít i v C# pomocí klíčového slova dynamic. Následující kód vypíše „Foo:string“. Důležité je přetypování parametru na „dynamic“.

public class Program {
  public static void Foo(object o) {
    Console.WriteLine("Foo:object");
  }

  public static void Foo(string str) {
    Console.WriteLine("Foo:string");
  }

  public static void Main(string[] args) {
    object obj = new string("ahoj svete");
    Foo((dynamic)obj);
  }
}

K čemu se hodí multiple dispatch?

Uvažme následující hierarchii tříd

public class Employee {
  double Salary { get; set; }
}

public class Worker : Employee {}
public class Manager : Employee {}

a nyní následující situaci: máme instanci kolekce objektů typu Employee a chceme všem managerům (třída Manager) zvýšit plat o 1000 a pracovníkům (třída Worker) snížit o 500. Méně zkušený vývojář by v takovém případě možná napsal následující kód:

public void Foo(IEnumerable<Employee> employees) {
  foreach(var e in employees) {
    if (e is Manager) {
      e.Salary += 1000;
    } else {
      e.Salary -= 500;
    }
  }
}

Takový „if“ je přesně věc, která by se v pořádném OOP návrhu neměla objevit a tento kód přímo volá po použití virtuální metody. Problém je ale v tom, že jednou budeme chtít přidávat/ubírat peníze, jednou zase něco úplně jiného a ve výsledku bude třída Employee plná nekonzistentních virtuálních metod. (Puristé v tom už vidí poručení Open/Closed principu.) Nyní přichází na řadu multiple dispatch, přesněji double dispatch, protože budeme chtít vybírat metodu na základě aktuálního typu prvního parametru.

class ChangeSalary {
  public void Visit(Manager m) { m.Salary += 1000; }
  public void Visit(Worker m) { m.Salary -= 500; }
}
// ...
public void Foo(IEnumerable<Employee> employees) {
  var visitor = new ChangeSalary();
  foreach(var e in employees) {
    visitor.Visit((dynamic)e);
  }
}

Nyní můžeme snadno přidávat a odebírat různé další třídy podobné ChangeSalary, není třeba upravovat kód v třídě Employee a zbavili jsme se nechtěného ifu. Výše uvedený kód je v podstatě implementace návrhového vzoru Visitor, ovšem díky podpoře pro double dispatch přes dynamic je skutečně jednoduchá. Někteří vývojáři zastávají názor, že se v takovém případě už ani nedá mluvit o návrhovém vzoru, prostě jazyky, které podporují double dispatch, podle nich návrhový vzor Visitor nepotřebují. Lze se na to dívat i tak, že ačkoliv je implementace vzoru Visitor v tomto případě velmi jednoduchá, pořád za ní stojí základní myšlenka tohoto vzoru — odstranění příkazů if nebo switch, které testují aktuální typu objektu.

Co je tedy na vzoru Visitor tak složitého? Na to se podíváme v následující sekci.

Visitor bez double dispatch

Některé jazyky vůbec nepodporují double dispatch, například C++, a v některých jazycích použití double dispatch znamená drobné snížení výkonnosti (pamatujete na rozdíl mezi .call a .callvirt, v tomto případě je rozdíl ještě větší). Jak tedy implementovat double dispatch, když pro něj nemáme přímou podporu? Odpovědí je dvojité použití virtuálních metod. Následuje ukázka takové implementace a její popis.

abstract class Visitor {
  public virtual void Visit(Manager m) {}
  public virtual void Visit(Worker w) {}
}

abstract class Employee {
  public abstract void Accept(Visitor v);
}

class Manager : Employee {
  public override void Accept(Visitor v) {
    v.Visit(this);
  }
}

class Worker : Employee {
  public override void Accept(Visitor v) {
    v.Visit(this);
  }
}

class ChangeSalary : Visitor {
  public override void Visit(Manager m) {
    m.Salary += 1000;
  }
  public override void Visit(Worker w) {
    w.Salary -= 500;
  }
}

//...
var visitor = new ChangeSalary();
foreach(var e in employees) {
  e.Accept(visitor);
}

Pomocí virtuální metody Accept ve třídě Employee zjistíme aktuální typ objektů, které mají statický typ Employee — to znamená že je potřeba tuto metodu překrýt v každém potomkovi třídy Employee. Metoda Accept dostane instanci třídy Visitor, zavolá na ní další virtuální metodu Visit a jako parametr předá this. V tuhle chvíli má this správný statický typ, protože pomocí mechanizmu virtuálních metoda byla zavolaná metoda Accept na nějakém potomkovi Employee. Tohle už je trochu složitější. Důležité je, že pomocí uvedených tříd a metod se snažíme dosáhnout něčeho jako double dispatch. To je hlavní podstata návrhového vzoru Visitor.

Uvedený kód může být i názornou ukázkou, že správný vývojář by měl používat adekvátní postupy a metody. Počet řádků oproti původní verzi bez vzoru Visitor značně narostl. Přidali jsme nové třídy a virtuální metody, které je potřeba správně překrývat, i když kompilátor to nijak nevynucuje! Z pohledu našeho jednoduchého příkladu se původní kód proměnil v poměrně komplexní soustavu tříd zajišťující netriviální funkcionalitu (double dispatch) a přínos vzoru není tak úplně vidět.

Je tedy důležité podotknout, že návrhový vzor Visitor je doporučeno použít v situaci, kdy

  • důležitost, rozsah a životnost projektu si vyžaduje použití tak komplexních řešení jako je návrhový vzor Visitor. Ještě horší než nerozumět návrhovým vzorům, je snažit se je aplikovat všude a bez rozmyslu.
  • „Navštěnovanou“ hierarchii (Employee-Manager-Worker) tvoří spíše stabilní třídy, jejichž kód se často nemění a celkově do dané hierarchie nejsou často přidávány nové třídy.
  • Operace na hierarchií, které jsou vlastně implementacemi třídy Visitor, často přibývají/ubývají.

Stejně jako většina ostatních postupů, kterými se typicky dosahuje zachování principu Open/Closed, porušuje návrhový vzor Visitor princip zapouzdření, protože „navštěvované“ objekty musí poskytovat veřejné vlastnosti (properties) a další metody, které jednotlivé implementace třídy Visitor potřebují, aby odvedly svoji práci.

Kde je to navštěvování?

Návrhový vzor Visitor podle svého názvu může evokovat představu, že jeho podstatou je rekurzivní navštěvování hierarchie tříd, jako v následující ukázce.

abstract class Visitor {
  public virtual void Visit(Layout layout) {}
  public virtual void Visit(Label label) {}
}

abstract class VisualElement {
  public abstract void Accept(Visitor v);
}

class Layout : VisualElement {
  private IList<VisualElement> children;
  //...**Důležitá část:**
  public override void Accept(Visitor v) {
    v.Visit(this);
    foreach(var c in this.children) {
      c.Accept(v);
    }
  }
}

class Label : VisualElement {
  public override void Accept(Visitor v) {
    v.Visit(this);
  }
}

class CountElements : Visitor {
  int layouts = 0;
  int labels = 0;
  public override void Visit(Layout layout) {
    this.layouts++;
  }
  public override void Visit(Label label) {
    this.labels++;
  }
}

//...
var visitor = new CountElements();
foreach(var e in visualElements) {
  e.Accept(visitor);
}

Ovšem kód, kde objekt typu Layout volá metodu Accept na agregovaných widgetech, není esenciální součástí vzoru Visitor, ale spíš ukázka kompozice vzoru Visitor a Composite (velmi často používaná varianta). V bibli návrhových vzorů „Gang of Four“ se můžeme dočíst, že procházení kolekce nebo hierarchie „navštěvovaných“ objektů může být realizováno v těchto objektech právě pomocí vzoru Composite, v samotné třídě Visitor nebo dokonce může být celé ponecháno na klientech.

Závěr

Návrhový vzor Visitor nám umožní implementovat double dispatch v jazycích, kde buďto není vůbec podporovaný, nebo jeho použití přináší určitý dopad na výkonnost. Díky double dispatch potom není třeba vytvářet konstrukce typu switch nebo zřetězené příkazy if-else-if, které testují aktuální typ objektu, což je obtížně rozšiřitelný a spravovatelný kód. Pokud povaha našeho programu povoluje použití klíčového slova dynamic, i přes nepatrné snížení výkonu, je vhodné jej použít pro implementaci double dispatch a zjednodušit tak výsledný kód; návrhový vzor Visitor se v takovém případě z kódu téměř vytrácí.

Post a Comment

Your email is never published nor shared. Required fields are marked *