[design-patterns] 방문자 패턴에서 accept () 메소드의 요점은 무엇입니까?

알고리즘을 클래스에서 분리하는 것에 대한 많은 이야기가 있습니다. 그러나 한 가지는 설명되지 않은 채로 남아 있습니다.

그들은 이렇게 방문자를 사용합니다

abstract class Expr {
  public <T> T accept(Visitor<T> visitor) {visitor.visit(this);}
}

class ExprVisitor extends Visitor{
  public Integer visit(Num num) {
    return num.value;
  }

  public Integer visit(Sum sum) {
    return sum.getLeft().accept(this) + sum.getRight().accept(this);
  }

  public Integer visit(Prod prod) {
    return prod.getLeft().accept(this) * prod.getRight().accept(this);
  }

visit (element)를 직접 호출하는 대신 Visitor는 해당 요소가 방문 메소드를 호출하도록 요청합니다. 이는 방문객에 대한 계급 무 인식의 선언과 모순된다.

PS1 자신의 말로 설명하거나 정확한 설명을 가리 키십시오. 두 가지 응답이 일반적이고 불확실한 것을 언급했기 때문입니다.

PS2 내 추측 : getLeft()기본을 반환하기 때문에 Expression호출 visit(getLeft())하면 결과가 visit(Expression)되지만 getLeft()호출 visit(this)하면 다른 더 적절한 방문 호출이 발생합니다. 따라서 accept()유형 변환 (일명 캐스팅)을 수행합니다.

PS3 Scala의 패턴 매칭 = 스테로이드 의 방문자 패턴은 수락 방법없이 방문자 패턴이 얼마나 단순한 지 보여줍니다. Wikipedia는accept()반영이 가능할 때 방법이 불필요 하다는 것을 보여주는 논문을 연결 함으로써이 기술에 대해 ‘Walkabout’이라는 용어를 도입했습니다.”



답변

방문자 패턴의 visit/ accept구조는 C와 유사한 언어 (C #, Java 등) 의미 체계로 인해 필요한 악입니다. 방문자 패턴의 목표는 코드를 읽을 때 예상하는대로 이중 디스패치를 ​​사용하여 통화를 라우팅하는 것입니다.

일반적으로 방문자 패턴이 사용되는 경우 모든 노드가 기본 Node유형 에서 파생되는 개체 계층 구조가 포함 됩니다 Node. 본능적으로 다음과 같이 작성합니다.

Node root = GetTreeRoot();
new MyVisitor().visit(root);

여기에 문제가 있습니다. MyVisitor클래스가 다음과 같이 정의 된 경우 :

class MyVisitor implements IVisitor {
  void visit(CarNode node);
  void visit(TrainNode node);
  void visit(PlaneNode node);
  void visit(Node node);
}

런타임에 실제 유형에 관계없이 root호출이 overload로 들어가면 visit(Node node). 이것은 type으로 선언 된 모든 변수에 적용됩니다 Node. 왜 이런거야? Java 및 기타 C 계열 언어 는 호출 할 오버로드를 결정할 때 매개 변수 의 정적 유형 또는 변수가 선언 된 유형 만 고려 하기 때문입니다. Java는 런타임시 모든 메서드 호출에 대해 “좋아요, 동적 유형이 root무엇입니까? “라는 추가 단계를 거치지 않습니다 . 유형의 매개 변수를 허용하는 TrainNode메서드가 있는지 살펴 보겠습니다.MyVisitorTrainNode… “. 컴파일러는 컴파일 타임에 호출 될 메서드를 결정합니다. (Java가 실제로 인수의 동적 유형을 검사했다면 성능이 매우 끔찍할 것입니다.)

자바는 메소드가 호출 될 때 객체의 런타임 (즉, 동적) 유형을 고려하는 하나의 도구를 제공합니다 . 가상 메소드 디스패치 . 가상 메서드를 호출 할 때 실제로 호출 은 함수 포인터로 구성된 메모리 의 테이블 로 이동합니다 . 각 유형에는 테이블이 있습니다. 특정 메서드가 클래스에 의해 재정의 된 경우 해당 클래스의 함수 테이블 항목에는 재정의 된 함수의 주소가 포함됩니다. 클래스가 메서드를 재정의하지 않으면 기본 클래스의 구현에 대한 포인터가 포함됩니다. 여전히 성능 오버 헤드가 발생합니다 (각 메서드 호출은 기본적으로 두 개의 포인터를 역 참조합니다 : 하나는 유형의 함수 테이블을 가리키고 다른 하나는 함수 자체를 가리킴). 매개 변수 유형을 검사하는 것보다 여전히 빠릅니다.

방문자 패턴의 목표는 이중 디스패치 를 수행하는 것입니다. 고려되는 호출 대상 유형 ( MyVisitor, 가상 메서드를 통해)뿐만 아니라 매개 변수 유형 (어떤 유형을 Node보고 있는지)도 고려해야합니다 . 방문자 패턴을 사용하면 visit/ accept조합으로 이를 수행 할 수 있습니다 .

다음과 같이 라인을 변경합니다.

root.accept(new MyVisitor());

원하는 것을 얻을 수 있습니다. 가상 메서드 디스패치를 ​​통해 서브 클래스에 의해 구현 된 올바른 accept () 호출을 입력합니다.이 예제에서는 의 구현을 TrainElement입력합니다 .TrainElementaccept()

class TrainNode extends Node implements IVisitable {
  void accept(IVisitor v) {
    v.visit(this);
  }
}

무엇의 범위 내에서,이 시점에서 컴파일러 노하우를 수행 TrainNodeaccept? 그것은의 정적 유형이 있음을 알고 this입니다TrainNode . 거기가 알고있는 모든 약이 컴파일러는 우리 발신자의 범위를 알고 아니었다 정보의 중요한 추가 조각입니다 root그것은을이었다이었다 Node. 이제 컴파일러는 this( root)가 단순한 것이 Node아니라 실제로는 TrainNode. 결과적으로 accept(): 안에있는 한 줄은 v.visit(this)완전히 다른 것을 의미합니다. 의 컴파일러는 이제 과부하를 찾습니다 visit()그이 소요됩니다 TrainNode. 찾을 수없는 경우에는 호출을 컴파일하여Node. 둘 다 존재하지 않으면 컴파일 오류가 발생합니다 (를 사용하는 오버로드가없는 경우 object). 실행 따라서 우리 모두가 함께 구성했던 것을 입력합니다 : MyVisitor의를의 구현 visit(TrainNode e). 캐스트가 필요하지 않았고 가장 중요한 것은 반사가 필요하지 않았다는 것입니다. 따라서이 메커니즘의 오버 헤드는 다소 낮습니다. 포인터 참조로만 구성되고 다른 것은 없습니다.

귀하의 질문이 맞습니다. 캐스트를 사용하여 올바른 행동을 얻을 수 있습니다. 그러나 종종 우리는 Node가 어떤 유형인지조차 모릅니다. 다음 계층의 경우를 살펴보십시오.

abstract class Node { ... }
abstract class BinaryNode extends Node { Node left, right; }
abstract class AdditionNode extends BinaryNode { }
abstract class MultiplicationNode extends BinaryNode { }
abstract class LiteralNode { int value; }

그리고 우리는 소스 파일을 구문 분석하고 위의 사양을 준수하는 객체 계층을 생성하는 간단한 컴파일러를 작성했습니다. 방문자로 구현 된 계층 구조에 대한 인터프리터를 작성하는 경우 :

class Interpreter implements IVisitor<int> {
  int visit(AdditionNode n) {
    int left = n.left.accept(this);
    int right = n.right.accept(this);
    return left + right;
  }
  int visit(MultiplicationNode n) {
    int left = n.left.accept(this);
    int right = n.right.accept(this);
    return left * right;
  }
  int visit(LiteralNode n) {
    return n.value;
  }
}

우리의 유형을 알고하지 않기 때문에, 아주 멀리 우리를 얻을 수 없겠죠 주조 left또는 rightvisit()방법을. 우리의 파서는 Node계층 구조의 루트를 가리키는 유형의 객체도 반환 할 가능성이 높 으므로 안전하게 캐스팅 할 수 없습니다. 따라서 간단한 인터프리터는 다음과 같이 보일 수 있습니다.

Node program = parse(args[0]);
int result = program.accept(new Interpreter());
System.out.println("Output: " + result);

방문자 패턴을 사용하면 매우 강력한 작업을 수행 할 수 있습니다. 객체 계층 구조가 주어지면 계층 구조의 클래스 자체에 코드를 넣을 필요없이 계층 구조에서 작동하는 모듈 식 작업을 만들 수 있습니다. 방문자 패턴은 예를 들어 컴파일러 구성에서 널리 사용됩니다. 특정 프로그램의 구문 트리가 주어지면 해당 트리에서 작동하는 많은 방문자가 작성됩니다. 유형 검사, 최적화, 기계어 코드 방출은 일반적으로 모두 다른 방문자로 구현됩니다. 최적화 방문자의 경우 입력 트리가 주어지면 새로운 구문 트리를 출력 할 수도 있습니다.

물론 단점이 있습니다. 계층 구조에 새 유형을 추가하면 visit()해당 새 유형에 대한 메서드 도 IVisitor인터페이스에 추가하고 모든 방문자에게 스텁 (또는 전체) 구현을 만들어야합니다. accept()위에서 설명한 이유 때문에 메서드도 추가해야합니다 . 성능이 그다지 의미가 없다면를 필요로하지 않고 방문자를 작성하는 솔루션이 accept()있지만 일반적으로 반사가 수반되므로 상당한 오버 헤드가 발생할 수 있습니다.


답변

물론 그것이 Accept가 구현 되는 유일한 방법 이라면 그것은 어리석은 일입니다 .

그러나 그렇지 않습니다.

예를 들어 방문자는 비 터미널 노드의 구현이 다음과 같은 계층 구조를 다룰 때 정말 유용 합니다.

interface IAcceptVisitor<T> {
  void Accept(IVisit<T> visitor);
}
class HierarchyNode : IAcceptVisitor<HierarchyNode> {
  public void Accept(IVisit<T> visitor) {
    visitor.visit(this);
    foreach(var n in this.children)
      n.Accept(visitor);
  }

  private IEnumerable<HierarchyNode> children;
  ....
}

봤어? 당신이 어리 석다고 묘사하는 것은 계층을 순회 하는 해결책입니다.

방문자를 이해하게 만든 훨씬 더 길고 깊이있는 기사가 있습니다.

편집 :
명확히하기 위해 : 방문자의 Visit방법에는 노드에 적용 할 논리가 포함되어 있습니다. 노드의 Accept메서드에는 인접 노드로 이동하는 방법에 대한 논리가 포함되어 있습니다. 당신이 사건 에만 이중 파견 더 간단로 이동하는 인접 노드가있는 특별한 경우이다.


답변

방문자 패턴의 목적은 객체가 방문자가 작업을 마치고 떠난시기를 알 수 있도록하여 클래스가 나중에 필요한 정리를 수행 할 수 있도록하는 것입니다. 또한 클래스가 내부를 ‘ref’매개 변수로 “일시적으로”노출 할 수 있으며 방문자가 사라지면 내부가 더 이상 노출되지 않음을 알 수 있습니다. 정리가 필요하지 않은 경우 방문자 패턴은 그다지 유용하지 않습니다. 이러한 작업을 수행하지 않는 클래스는 방문자 패턴의 이점을 얻지 못할 수 있지만 방문자 패턴을 사용하도록 작성된 코드는 액세스 후 정리가 필요할 수있는 향후 클래스에서 사용할 수 있습니다.

예를 들어 원자 적으로 업데이트해야하는 많은 문자열을 보유한 데이터 구조가 있지만 데이터 구조를 보유한 클래스가 수행해야하는 원자 적 업데이트 유형을 정확히 알지 못한다고 가정합니다 (예 : 한 스레드가 “의 모든 항목을 대체하려는 경우). X “, 다른 스레드는 숫자 시퀀스를 숫자가 한 단계 더 높은 시퀀스로 바꾸려고하지만 두 스레드의 작업이 성공해야합니다. 각 스레드가 단순히 문자열을 읽고 업데이트를 수행 한 다음 다시 쓴 경우 두 번째 스레드 문자열을 다시 쓰려면 첫 번째 문자열을 덮어 씁니다). 이를 수행하는 한 가지 방법은 각 스레드가 잠금을 획득하고 작업을 수행하고 잠금을 해제하도록하는 것입니다. 안타깝게도 자물쇠가 그런 식으로 노출되면

방문자 패턴은 이러한 문제를 피하기 위해 (적어도) 세 가지 접근 방식을 제공합니다.

  1. 레코드를 잠그고 제공된 함수를 호출 한 다음 레코드를 잠금 해제 할 수 있습니다. 제공된 함수가 무한 루프에 빠지면 레코드가 영원히 잠길 수 있지만 제공된 함수가 예외를 반환하거나 throw하면 레코드가 잠금 해제됩니다 (함수가 예외를 throw하면 레코드를 유효하지 않은 것으로 표시하는 것이 합리적 일 수 있음). 잠긴 것은 아마도 좋은 생각이 아닙니다.) 호출 된 함수가 다른 잠금을 얻으려고하면 교착 상태가 발생할 수 있다는 점에 유의하십시오.
  2. 일부 플랫폼에서는 문자열을 ‘ref’매개 변수로 포함하는 저장 위치를 ​​전달할 수 있습니다. 그런 다음 해당 함수는 문자열을 복사하고, 복사 된 문자열을 기반으로 새 문자열을 계산하고, 이전 문자열을 새 문자열과 비교하고, CompareExchange가 실패하면 전체 프로세스를 반복 할 수 있습니다.
  3. 문자열의 복사본을 만들고 문자열에서 제공된 함수를 호출 한 다음 CompareExchange 자체를 사용하여 원본을 업데이트하고 CompareExchange가 실패하면 전체 프로세스를 반복 할 수 있습니다.

방문자 패턴이 없으면 원자 적 업데이트를 수행하려면 호출 소프트웨어가 엄격한 잠금 / 잠금 해제 프로토콜을 따르지 않으면 잠금을 노출하고 실패 할 위험이 있습니다. 방문자 패턴을 사용하면 원자 적 업데이트를 비교적 안전하게 수행 할 수 있습니다.


답변

수정이 필요한 클래스는 모두 ‘accept’메소드를 구현해야합니다. 클라이언트는이 accept 메서드를 호출하여 해당 클래스 제품군에 대해 몇 가지 새로운 작업을 수행하여 기능을 확장합니다. 클라이언트는이 하나의 accept 메서드를 사용하여 각 특정 작업에 대해 다른 방문자 클래스를 전달하여 광범위한 새 작업을 수행 할 수 있습니다. 방문자 클래스에는 패밀리 내의 모든 클래스에 대해 동일한 특정 작업을 수행하는 방법을 정의하는 재정의 된 여러 방문 메서드가 포함되어 있습니다. 이러한 방문 방법은 작동 할 인스턴스를 전달받습니다.

방문자는 기능의 각 항목이 각 방문자 클래스에서 별도로 정의되고 클래스 자체를 변경할 필요가 없기 때문에 안정적인 클래스 제품군에 기능을 자주 추가, 변경 또는 제거하는 경우 유용합니다. 클래스 패밀리가 안정적이지 않은 경우 많은 방문자가 클래스를 추가하거나 제거 할 때마다 변경해야하기 때문에 방문자 패턴이 덜 사용됩니다.


답변

좋은의 예는 소스 코드를 컴파일에 있습니다 :

interface CompilingVisitor {
   build(SourceFile source);
}

클라이언트는을 구현할 수 JavaBuilder, RubyBuilder, XMLValidator, 등을 프로젝트의 모든 소스 파일을 수집하고 방문의 구현은 변경이 필요하지 않습니다.

각 소스 파일 유형에 대해 별도의 클래스가있는 경우 이는 잘못된 패턴입니다.

interface CompilingVisitor {
   build(JavaSourceFile source);
   build(RubySourceFile source);
   build(XMLSourceFile source);
}

그것은 맥락과 시스템의 어떤 부분을 확장 할 수 있는지에 달려 있습니다.


답변