odwiedzacz, double dispatch

1

Często odwiedzacz pattern jest rzucany wszędzie tam, gdzie pojawia się potrzeba przejścia po drzewie, ale jakoś nie potrafię zrozumieć jego zalety względem zwykłego przejścia (czy rekursywnego czy iteratywnego) oraz sensownego triku języka lub w ostateczności switcha

z jednej strony fajnie bo pozwala nam zgrabnie dodawać wielu visitorów, ale co gdy tego nie potrzebujemy?

Oczywiście przez przypadek ;) gdy szukałem info nt. visitora to trafiłem na krytykę
http://nice.sourceforge.net/visitor.html

i generalnie wyszły mi takie kody:

Visitor:

public class Document
{
    public List<DocumentPart> Parts = new List<DocumentPart>();

    public void Accept(IVisitor visitor)
    {
        foreach (var child in Parts)
        {
            child.Accept(visitor);
        }
    }
}

public interface IVisitor
{
    void Visit(AddPart addPart);

    void Visit(MultiplyPart multiplyPart);
}

public abstract class DocumentPart
{
    public List<DocumentPart> Children { get; set; } = new List<DocumentPart>();
    public string Text { get; set; }
    public abstract void Accept(IVisitor visitor);
}

public class AddPart : DocumentPart
{
    public int Value { get; set; } = 15;
    public override void Accept(IVisitor visitor)
    {
        visitor.Visit(this);

        foreach (var child in Children)
        {
            child.Accept(visitor);
        }
    }
}

public class MultiplyPart : DocumentPart
{
    public int Value { get; set; } = 35;
    public override void Accept(IVisitor visitor)
    {
        visitor.Visit(this);

        foreach (var child in Children)
        {
            child.Accept(visitor);
        }
    }
}

public class MyVisitor : IVisitor
{
    public void Visit(AddPart addPart)
    {
        Console.WriteLine($"AddPart {addPart.Text + addPart.Value}");
    }

    public void Visit(MultiplyPart multiplyPart)
    {
        Console.WriteLine($"MultiplyPart {multiplyPart.Text + multiplyPart.Value}");
    }
}

użycie:

public static void Main()
{
    var doc = new Document();

    var part = new AddPart() { Text = "add" };
    part.Children.Add(new MultiplyPart() { Text = "multiply"});

    doc.Parts.Add(part);

    doc.Accept(new MyVisitor());
}

oraz wersja bez Visitora:

public class Document
{
    public List<DocumentPart> Parts = new List<DocumentPart>();

    public void Process(DocProcessor docProc)
    {
        foreach (var child in Parts)
        {
            docProc.Process(child);
        }
    }
}

public abstract class DocumentPart
{
    public List<DocumentPart> Children { get; set; } = new List<DocumentPart>();
    public string Text { get; set; }
}

public class AddPart : DocumentPart
{
    public int Value { get; set; } = 15;
}

public class MultiplyPart : DocumentPart
{
    public int Value { get; set; } = 35;
}

public class DocProcessor
{
    public void Process(DocumentPart node)
    {
        // tutaj dzieje się cały hack/trick/magic
        // odnajdujemy metodę o nazwie 'Visit' która ma taki typ parametru, jak nasz obiekt, a zatem (AddPart | MultiplyPart)

        var type = node.GetType();
        var method = typeof(DocProcessor).GetMethods().Where(x => x.Name == nameof(Visit) && x.GetParameters()[0].ParameterType == type).First();
        method.Invoke(this, new[] { node });

        foreach (var child in node.Children)
            Process(child);
    }

    public void Visit(AddPart node)
    {
        Console.WriteLine($"AddPart {node.Text + node.Value}");
    }

    public void Visit(MultiplyPart node)
    {
        Console.WriteLine($"MultiplyPart {node.Text + node.Value}");
    }
}

oraz użycie:

public static void Main()
{
    var doc = new Document();

    var part = new AddPart() { Text = "add" };
    part.Children.Add(new MultiplyPart() { Text = "multiply" });

    doc.Parts.Add(part);

    doc.Process(new DocProcessor());
}

i wydaje mi się, że Visitor w tym przypadku dodaje jedynie dużo boilerplate typu te overridy, interfejs, wymusza aby nasze "obiekty domenowe" miały jakiś Accept oraz chyba zgubiłem gdzie właściwie powinienem dać te pętle od przechodzenia po Childach - Accept czy Visit (co i tak nie jest fajne, bo nadmiarowe), lub inaczej?

2

Visitor jest trasznie śmierdzącym patternem. Widziałem kilka razy w kodzie produkcyjnym, za każdym razie użyto w celu masturbowania się patternami - sensu nie było widać. (A kod powstaje taki, że chce się płakać)

W nowszych językach jest kilka konstrukcji, które zupełnie zmniejszają sens wizytora:

  • Exhaustive pattern matching
  • biblioteki i wsparcie (system typów) dla recursion schemes

To pierwsze powoduje, że wygodnie i bezpiecznie obsługuje mniej więcej przypadek taki jak podałeś.
To drugie umożliwia nam automatyczne traversowanie po własnych strukturach danych.

1

http://nice.sourceforge.net/visitor.html

Another possibility would be to define a static method in the new package. But then it would be necessary to test the argument with instanceof and use downcasts. In short, lose the benefits of object-orientation.

Jak widać, podstawową zaletą wizytatora jest to, że jest obiektowy :] Jeśli komuś bardzo zależy na OOPie to wizytator spełni jego oczekiwania.

http://nice.sourceforge.net/visitor.html

The Visitor pattern is a trick to introduce multiple dispatch in a language that lacks it. However, it raises serious issues. Language support for multi-methods makes it much easier and elegant to handle the common situation where the Visitor pattern applies.

Multimetody są dalej obiektowe i generalnie można nimi zastąpić wizytatora, w dodatku wygodniej, czytelniej i zwięźlej. C# ma multimetody (według https://en.wikipedia.org/wiki/Multiple_dispatch#C# ), ale coś nie do końca działają:

using System;
using System.Collections;
using System.Collections.Generic;

class Printer {
    public void print(object o) {
        Console.WriteLine("got object");
    }
    public void print(int x) {
        Console.WriteLine("got int");
    }
}

class ChildPrinter : Printer {
    public void print(string s) {
        Console.WriteLine("got string");
    }

}

class Program {
    static Printer makePrinter() {
        return new ChildPrinter();
    }
    static void Main(string[] args) {
        var printer = makePrinter();
        printer.print(5 as dynamic); // got int, ok
        printer.print("ala ma kota" as dynamic); // got object, not ok
        printer.print(new List<double>() as dynamic); // got object, ok
    }
}

Multimetody to takie trochę przeciążąnie na sterydach. Nie jestem fanem przeciążania, więc multimetod też pewnie byłbym fanem.

Pattern matching jest nieobiektowy, ale to nie znaczy, że gorszy. Jak dla mnie nawet lepszy :) Scala ma go od wieków, C# ma go od wersji 8: https://github.com/dotnet/ros[...].md#arithmetic-simplification Takie zagnieżdżone wzorce jak w tym przykładzie (ale oczywiście nie dotyczące upraszczania wyrażeń arytmetycznych tylko innych rzeczy) często stosuję w kodzie scalowym i się to zdecydowanie przydaje. Wizytator i multimetody raczej nie wyglądają mi na sensowne alternatywy dla zagnieżdżonych wzorców w pattern matchingu (ale może się mylę i jest jakiś trik, dzięki któremu można jedno drugim sensownie zaemulować). Pattern matching dla Javy jest w przygotowaniu https://cr.openjdk.java.net/~[...]oetz/amber/pattern-match.html jako część https://openjdk.java.net/projects/amber/

0

@Wibowit:

Multimetody są dalej obiektowe i generalnie można nimi zastąpić wizytatora, w dodatku wygodniej, czytelniej i zwięźlej.
C# ma multimetody (według https://en.wikipedia.org/wiki/Multiple_dispatch#C# ), ale coś nie do końca działają:

no właśnie próbowałem coś z castami tam poczarować ale mi nie szło i udało mi się uzyskać zadowalający efekt tymi w/w 3 linijakmi

var type = node.GetType();
var method = typeof(DocProcessor).GetMethods().Where(x => x.Name == nameof(Visit) && x.GetParameters()[0].ParameterType == type).First();
method.Invoke(this, new[] { node });
1

Niby niezła próba, ale weź pod uwagę, że typy wcale nie muszą się zgadzać. Jeśli mam typy A (rodzic) > B > C > D (potomkowie), metody dla A, B i D oraz obiekt typu C to powinna się wywołać metoda dla B. Nawet to jest i tak za mało dla pełnej obsługi multimetod, bo te mogą mieć wiele parametrów, a nie tylko jeden (jak w wizytatorze).

0

Po co Visitor tego nie rozumiem.

https://refactoring.guru/design-patterns/visitor/java/example

Na czym pracuje?
Na Interfejs Shape i implementacje: Dot, Line

Kto pracuje?
Interfejs Visitor który ma zdefiniowane metody dla Dot, dla Line

OK. Działa

A co z SOLID?
Open for extending, closed for modification

No to dodajemy do Dot, Line jeszcze Plane

Literę O z SOLID diabli wzięli bo teraz do interfejsu Visitor tzreba dołożyć visit(Plane plane)

Jakby dokładać Triangle, Rectangle, Circle, Arc itd. to interfejs Visitor spuchnie o nowe metody dla visit(Triangle triangle) itd

Drugi koniec kija, każda konkretna implementacja Visotr MUSI "zająć się" metodami zakontraktowanymi w interfejsie Visitor. Nawet gdy nie mają logicznie sensu.

Więc IMHO zamiast Visitor to Iterator powinien wejść w jego miejsce.

PS
Każda implementacja która ma być wizytowana musi implementować interfejs Visitable i metodę

accept(Visitor visotor){
visitor.visit(this);
}

Loose coupling diabli wzięli, bo po co wszystko co ma być wizytowane musi być oznaczone implements Visitable?
Iterator tego nie potrzebuje.

Sorry, mogą być nieścisłości, pisane z palca, bez IDE

Moja teoria? Visitor został z czasów kiedy Banda Czworga pisała Biblijne Design Patterns i nie ma nikogo, kto by powiedział sorry, szaty króla z kolekcji Visitor są już nie bardzo an czasie.

1
BraVolt napisał(a):

Literę O z SOLID diabli wzięli bo teraz do interfejsu Visitor tzreba dołożyć visit(Plane plane)

Jakby dokładać Triangle, Rectangle, Circle, Arc itd. to interfejs Visitor spuchnie o nowe metody dla visit(Triangle triangle) itd

Nie ma rozwiązania bez wad. Wizytator czy pattern matching działają dobrze, jeśli liczba podklas (wizytowanego typu) rzadko się zmienia, a znacznie częściej dodajemy nowe metody (w sensie zewnętrzne, takie za pośrednictwem tego wizytatora czy pattern matchingu). Z drugiej strony OOPowe dziedziczenie działa dobrze jeśli rzadko dodajemy nowe metody, a znacznie częściej dodajemy nowe podklasy.

Weźmy dla przykładu te typy Traingle, Rectangle, Circle, etc oraz metody typu area, perimeter, goodHashCode, printDebug, etc.

Jeżeli chcę dodać nowy kształt (który ma unikalne implementacje metod) to:

  • w OOPowym podejściu (dziedziczenie) wystarczy dodać nową klasę i gotowe
  • przy wizytatorze czy pattern matchingu trzeba poprawić wszystkie wizytatory i drabinki w pattern matchingach

Jeżeli chcę dodać nową metodę (która zachowuje się inaczej dla każdego podtypu) to:

  • w OOPowym podejściu muszę zmodyfikować całą hierarchię klas, czyli dać metodę abstrakcyjną w nadtypie i konkretne implementacje w podtypach
  • przy wizytatorze czy pattern matchingu wystarczy dodać nowego wizytatora bądź nową drabinkę pattern matchingu, bez ingerowania w istniejącą hierarchię klas

Z drugiej strony są jeszcze https://en.wikipedia.org/wiki/Type_class i tutaj sprawa jest ciekawsza, bo implementację type classy T dla typu X można umieścić obok tej type class T lub obok samego typu X. Nie rozwiązuje to wszystkich problemów, ale nierzadko się przydaje.

Ze wszystkich tutaj mechanizmów (czyli gdy chcemy mieć coś więcej niż zwykłe typowo javowe (względnie c++owe czy c#owe) dziedziczenie) w praktyce najlepiej wypada moim zdaniem pattern matching. Zwięzły, czytelny, rzadko prowadzi do nieczytelnych błędów kompilacji czy zaskakującego działania w czasie wykonania, a dodatkowo (czego nie ułatwiają pozostałe mechanizmy) można nim sprawdzać zagnieżdżone wzorce, co często jest przydatne.

0

@Wibowit:

Wizytator czy pattern matching działają dobrze, jeśli liczba podklas (wizytowanego typu) rzadko się zmienia, a znacznie częściej dodajemy nowe metody (w sensie zewnętrzne, takie za pośrednictwem tego wizytatora czy pattern matchingu).

Jakby stosować jak pierwotnie GoF, AFAIR do GUI, to pasuje tak jak Czwórka opisała. Klasy się nie zmieniają ale to, co z klasami (elementami interfejsu) robimy to tylko sprawa nowej implementacji następnego wizytatora. Z tej strony patrząc jest jak najbardziej OK.

PS
Gdybym miał "wizytować" to odczytałbym (zawsze?) jako "iterować". Iterator? Tak. Visitor? Nie przyszedłby mi na myśl.

1
BraVolt napisał(a):

PS
Gdybym miał "wizytować" to odczytałbym (zawsze?) jako "iterować". Iterator? Tak. Visitor? Nie przyszedłby mi na myśl.

Co ma wizytator do iteratora? Możesz wizytować jeden obiekt kilkoma wizytatorami. Możesz wizytować kilka obiektów jednym wizytatorem. Analogicznie możesz odpalić kilka metod na jednym obiekcie lub odpalić jedną metodę na kilku obiektach. Czy metody są iteratorami?

0

Można wchodzić w dyskusje jak: https://stackoverflow.com/que[...]se-the-visitor-design-pattern
albo stwierdzić, że da się obejść bez Visitor Pattern. Bo inny DP będzie odpowiedni.

PS
https://refactoring.guru/design-patterns/iterator
https://refactoring.guru/design-patterns/visitor
You can treat Visitor as a powerful version of the Command pattern. Its objects can execute operations over various objects of different classes. You can use Visitor to execute an operation over an entire Composite tree. You can use Visitor along with Iterator to traverse a complex data structure and execute some operation over its elements, even if they all have different classes.

I zrobi się dłuższa teoretyczna dyskusja

EDIT
The visitor pattern is a solution to a more general design problem:
https://softwareengineering.s[...]g-the-need-of-visitor-pattern

interface Animal {
  String name();
  String sound();
}

z zastosowaniem Visitor dostajemy "solution to a more general design problem" a Animal wyglądać ma tak

interface Animal {
  String name();
  String sound();
  <R> R acceptVisitor(AnimalVisitor<R> visitor);
}

Więc gdy Zwierzak ma nie tylko, jak to zwierzak, mieć imię Azor, Pysia, szczekać, miauczeć, ale jeszcze być świadomym swojej "wizytowalności" to IMHO przesadzono tu z "solution to a more general design problem"

1 użytkowników online, w tym zalogowanych: 0, gości: 1, botów: 0