Wzorzec projektowy Visitor - okładka

Wzorzec projektowy Visitor

Opublikowano Kategorie Czysty kodCzas czytania 8min

Wzorzec projektowy Visitor (Odwiedzający) to jeden z behawioralnych wzorców projektowych opisanych przez Gang of Four. W tym artykule poznasz zasadę działania tego wzorca oraz jego przykładowe zastosowania. Ten wpis jest kolejnym wpisem z serii o wzorcach projektowych. Jeśli chcesz poznać inne wzorce projektowe lub dowiedzieć się czym są wzorce projektowe, to koniecznie sprawdź mój wpis o wzorcach projektowych. Przykłady kodu w artykule, przygotowane zostały w TypeScripcie.

Charakterystyka wzorca

Cel wzorca Visitor Gang of Four zdefiniował następująco:

Represent an operation to be performed on the elements of an object structure. Visitor lets you define a new operation without changing the classes of the elements on which it operates.

By zrozumieć cel Visitora, najprościej będzie zacząć od drugiego zdania z wcześniejszej definicji. Celem Visitora jest umożliwienie przeprowadzania nowych operacji na jakimś zestawie obiektów bez konieczności modyfikacji ich klas. Zakładamy, że klasy te pochodzą z jednej rodziny (object structure z pierwszego zdania), np. definiuje je wspólny interfejs lub dziedziczą po tej samej klasie abstrakcyjnej. Sam Visitor jest klasą zawierającej logikę, do której przeprocesowania konieczne jest przekazanie obiektu klasy z określonej rodziny.

Takie podejście ma sens w kilku sytuacjach. Może być tak, że zmiana na samych obiektach może być trudna lub wręcz niemożliwa. Przykładowo, gdy pochodzą z jakiejś zależności z zewnątrz lub obecny coupling w aplikacji czyni to zadanie bardzo trudnym. Warto pamiętać, że kod nie zawsze będzie piękny i kolorowy. Wykorzystanie Visitora może tu nie tyle coś ulepszyć co zostawić kod w stanie niepogorszonym.

Nie zawsze też dane zachowanie jest bezpośrednio powiązane z daną klasą. Przykładowo, jeśli mamy klasę Car z metodami pali(), jeździ()skręca(), to są to bezsprzecznie metody do implementacji w klasie Car. Auto do jazdy potrzebuje też paliwa. Czy implementacja metody zatankuj() w klasie Car, rozwiązuje problem? Niby tak, ale jednak samochód nie tankuje się sam, tylko tankuje go kierowca. Moim zdaniem bliższe stanowi faktycznemu byłoby zaimplementowanie metody zatankuj() w klasie Driver i jako parametr przekazania obiektu klasy Car. Analogiczne zachowanie pozwala zachować dobrze zaimplementowany Visitor. Klasa nie jest „zanieczyszczana” metodami, których nie potrzebuje lub, które nie są z nią bezpośrednio związane.

Visitor może pomóc też w pewnych przypadkach nieco uprzątnąć kod z duplikatów. W niektórych przypadkach kod wewnątrz Visitora może być wspólny dla kilku przypadków. Dzięki zamknięciu logiki w Visitorze można wyciągnąć wspólną logikę do prywatnej metody, co nie byłoby możliwe, gdyby logika była porozrzucana po poszczególnych klasach. Jednak to jest raczej efekt uboczny, jaki może dać Visitor, nie zaś jego podstawowe zadanie.

Praktyczny przykład

Do przedstawienia Visitora w praktyce przygotowałem aplikację do zarządzania plikami. Interfejsem bazowym w naszym rozwiązaniu będzie IFile, czyli interfejs opisujący jakikolwiek plik. Dla uproszczenia w przykładzie zaimplementowałem dwie klasy — dla formatów PDF i PNG. Chcąc odwzorować rzeczywistość, takich klas pewnie byłoby co najmniej kilkadziesiąt. Aplikacja ma umożliwiać przeprowadzanie operacji na plikach. Może to być np. sprawdzanie reguł waliacyjnych, operacje na metadanych czy wyciąganie danych z plików.


interface IFile<T> {
  name: string;
  sizeInBytes: number;
  metadata: T;
  accept<U>( visitor: Visitor<U> ): U;
}

interface IFileParams<T> {
  name: string;
  sizeInBytes: number;
  metadata: T;
}

interface IPDFMetadata {
  a: string;
  b: string;
}

interface IPNGMetadata {
  a: string;
  b: string;
  c: string;
}

class PDF implements IFile<IPDFMetadata> {
  public readonly name: string;

  public readonly sizeInBytes: number;

  public readonly metadata: IPDFMetadata;

  public constructor( { name, metadata, sizeInBytes }: IFileParams<IPDFMetadata> ) {
    this.name = name;
    this.metadata = metadata;
    this.sizeInBytes = sizeInBytes;
  }

  public accept( visitor: Visitor ): T {
    return visitor.visitPDF( this );
  }
}

class PNG implements IFile<IPNGMetadata> {
  public readonly name: string;

  public readonly sizeInBytes: number;

  public readonly metadata: IPNGMetadata;

  public constructor( { name, metadata, sizeInBytes }: IFileParams<IPNGMetadata> ) {
    this.name = name;
    this.metadata = metadata;
    this.sizeInBytes = sizeInBytes;
  }

  public accept<T>( visitor: Visitor<T> ): T {
    return visitor.visitPNG( this );
  }
}

Mając zdefiniowane klasy, można zabrać się za implementację Visitora. Zawczasu dodałem metodę accept(), która będzie wykorzystywana przez klasy wizytujące obiekty. Interfejs został tak zdefiniowany, by móc dynamicznie zdefiniować zwracany typ danych. Dzięki temu TypeScript jest w stanie na podstawie typu zdefiniowanego w Visitorze określić zwracany typ danych. Do pokazania możliwości Visitora w praktyce przygotowałem dwie przykładowe implementacje. Zadaniem pierwszej jest „translacja” metadanych z różnych formatów plików do obiektu o jednolitej strukturze. Druga implementacja służy do sprawdzenia poprawności metadanych pliku. Oczywiście implementacja „logiki” jest pewnym uproszczeniem rzeczywistości na potrzeby demo.


interface Visitor<T> {
  visitPDF( file: PDF ): T;
  visitPNG( file: PNG ): T;
}

interface ICommonMetadata {
  x: string;
  y: string;
}

class TranslateMetadataVisitor implements Visitor<ICommonMetadata> {
  public visitPNG( file: PNG ): ICommonMetadata {
    return {
      x: file.metadata.a,
      y: file.metadata.c
    };
  }

  public visitPDF( file: PDF ): ICommonMetadata {
    return {
      x: file.metadata.a,
      y: file.metadata.b
    };
  }
}

class CheckMetadataCorrectnessVisitor implements Visitor<boolean> {
  public visitPNG( file: PNG ): boolean {
    return this._checkMetadataCorrectness( file );
  }

  public visitPDF( file: PDF ): boolean {
    return this._checkMetadataCorrectness( file );
  }

  private _checkMetadataCorrectness( file: PDF | PNG ): boolean {
    return file.metadata.a  === 'a' && file.metadata.b === 'b';
  }
}

Mając wszystkie klocki, można stworzyć instancje klas. Ostatecznie całość przygotowanego przykładu wygląda następująco.


interface IFile<T> {
  name: string;
  sizeInBytes: number;
  metadata: T;
  accept<U>( visitor: Visitor<U> ): U;
}

interface IFileParams<T> {
  name: string;
  sizeInBytes: number;
  metadata: T;
}

interface IPDFMetadata {
  a: string;
  b: string;
}

interface IPNGMetadata {
  a: string;
  b: string;
  c: string;
}

class PDF implements IFile<IPDFMetadata> {
  public readonly name: string;

  public readonly sizeInBytes: number;

  public readonly metadata: IPDFMetadata;

  public constructor( { name, metadata, sizeInBytes }: IFileParams<IPDFMetadata> ) {
    this.name = name;
    this.metadata = metadata;
    this.sizeInBytes = sizeInBytes;
  }

  public accept<T>( visitor: Visitor<T> ): T {
    return visitor.visitPDF( this );
  }
}

class PNG implements IFile<IPNGMetadata> {
  public readonly name: string;

  public readonly sizeInBytes: number;

  public readonly metadata: IPNGMetadata;

  public constructor( { name, metadata, sizeInBytes }: IFileParams<IPNGMetadata> ) {
    this.name = name;
    this.metadata = metadata;
    this.sizeInBytes = sizeInBytes;
  }

  public accept<T>( visitor: Visitor<T> ): T {
    return visitor.visitPNG( this );
  }
}

interface Visitor<T> {
  visitPDF( file: PDF ): T;
  visitPNG( file: PNG ): T;
}

interface ICommonMetadata {
  x: string;
  y: string;
}

class TranslateMetadataVisitor implements Visitor<ICommonMetadata> {
  public visitPNG( file: PNG ): ICommonMetadata {
    return {
      x: file.metadata.a,
      y: file.metadata.c
    };
  }

  public visitPDF( file: PDF ): ICommonMetadata {
    return {
      x: file.metadata.a,
      y: file.metadata.b
    };
  }
}

class CheckMetadataCorrectnessVisitor implements Visitor<boolean> {
  public visitPNG( file: PNG ): boolean {
    return this._checkMetadataCorrectness( file );
  }

  public visitPDF( file: PDF ): boolean {
    return this._checkMetadataCorrectness( file );
  }

  private _checkMetadataCorrectness( file: PDF | PNG ): boolean {
    return file.metadata.a  === 'a' && file.metadata.b === 'b';
  }
}

const pdfFile = new PDF( { name: 'foo', sizeInBytes: 1234, metadata: { a: 'a', b: 'b' } } );
const pngFile = new PNG( { name: 'bar', sizeInBytes: 4567, metadata: { a: 'x', b: 'b', c: 'c' } } );

const translateMetadataVisitor = new TranslateMetadataVisitor();
const checkMetadataCorrectnessVisitor = new CheckMetadataCorrectnessVisitor();

const translatedPdfMetadata = pdfFile.accept( translateMetadataVisitor );
const translatedPngMetadata = pngFile.accept( translateMetadataVisitor );

const isPDFValid = pdfFile.accept( checkMetadataCorrectnessVisitor );
const isPNGValid = pngFile.accept( checkMetadataCorrectnessVisitor );

console.log( 'METADATA', translatedPdfMetadata, translatedPngMetadata );
console.log( 'METADATA - IS VALID', isPDFValid, isPNGValid );

Mając już cały kod, warto zwrócić uwagę na kilka czynników:

  • wszystkie założenia wzorca Visitor w podanym przykładzie są spełnione;
  • nie ma potrzeby modyfikacji poszczególnych klas plików, by dodawać nowe funkcje w aplikacji. Spełnione jest tu OCP — kod łatwo można wzbogacać o nowe możliwości, dopisując nowe Visitory, a nie modyfikując istniejące klasy plików;
  • każda klasa implementująca interfejs Visitor ma jedną odpowiedzialność, czyli SRP również jest spełnione.

Podsumowanie

Mam nadzieję, że po lekturze tego artykułu, wykorzystanie wzorca Visitor nie będzie stanowiło dla Ciebie problemu. Z Visitorem często w parze idzie wykorzystanie wzorców Iteratora i Kompozytu. Jeśli nie znasz tych wzorców, to zachęcam do zapoznania się z nimi w dalszej kolejności. Z kolei, jeśli znałeś/aś już Visitora, to chętnie poznam konkretne przykłady wykorzystania. Będzie super, jeśli podzielisz się nimi w komentarzu 🙂

Dla mnie Visitor był jednym z trudniejszych wzorców do zrozumienia, a co dopiero do opisania. Mając już prawie gotowy artykuł, nie podobał mi on się do tego stopnia, że prawie w całości go usunąłem i napisałem od nowa. Moim zdaniem było warto. W razie pytań, niejasności lub wątpliwości zachęcam do zostawienia komentarza. Zachęcam też do sprawdzenia źródeł, materiałów dodatkowych i książki autorstwa Bandy Czworga.

Książkę Design Patterns: Elements of Reusable Object-Oriented Software możesz kupić klikając w TEN link. Kupując z tego linku, wspierasz rozwój bloga.

Źródła i materiały dodatkowe

Dominik Szczepaniak

Zawodowo Senior Software Engineer w CKSource. Prywatnie bloger, fan włoskiej kuchni, miłośnik jazdy na rowerze i treningu siłowego.

Inne wpisy, które mogą Cię zainteresować

Zapisz się na mailing i odbierz e-booka

Zapisując się na mój mailing, otrzymasz darmowy egzemplarz e-booka 106 Pytań Rekrutacyjnych Junior JavaScript Developer! Będziesz też otrzymywać wartościowe treści i powiadomienia o nowych wpisach na skrzynkę e-mail.

Subscribe
Powiadom o
guest

2 komentarzy
oceniany
najnowszy najstarszy
Inline Feedbacks
View all comments
Comandeer
9 dni temu

W nieco innej formie ten wzorzec jest używany m.in. w Babelu (ale też w innych parserach JS-a, takich jak Acorn) do modyfikowania AST → https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md#toc-visitors