Cometin'

Visitor pattern with TypeScript

2021-09-26 at DesignPattern category

본 게시물은 Java 언어로 배우는 디자인 패턴 입문 - Yuki Hiroshi 저를 기반으로 공부한 것을 정리하며 Typescript로 재작성해본 내용입니다.

Visitor 패턴이란 ?

Visitor은 방문자를 의미한다.

데이터 구조 안에 많은 요소가 저장되어 있고, 각 요소에 대해서 어떤 처리를 해야될 때 해당 처리의 코드는 어디에 써야할까 ?

일반적으로 데이터 구조를 표현하는 클래스 안에 기술할 것이다. 하지만 처리의 종류가 여러개거나 새로운 처리가 필요할 시 데이터 구조 클래스를 수정해야 한다.

Visitor 패턴에서는 데이터 구조와 처리를 분리한다. 그리고 데이터 구조 안을 돌아다니는 방문자에게 처리를 위임한다.

그렇다면 새로운 처리를 추가하고 싶을 때에는 새로운 방문자를 만들면 된다.

예제 프로그램

Composite 패턴으로 파일과 디렉터리로 이루어진 데이터 구조 안을 방문자가 돌아다니고 파일의 종류를 표시하는 프로그램이다.

이름 해설
Visitor 파일과 디렉터리를 방문하는 방문자를 나타내는 추상 클래스
Element Visitor 클래스의 생성자를 받아들이는 데이터 구조를 나타내는 인터페이스
ListVisitor Visitor 클래스의 하위 클래스로 파일과 디렉터리의 종류를 나타내는 클래스
Entry File과 Directory의 상위 클래스가 되는 추상 클래스
File 파일을 나타내는 클래스
Directory 디렉터리를 나타내는 클래스
FileTreatmentException File에 대해서 add한 경우에 발생하는 예외 클래스
Main 동작 테스트용 클래스

Visitor 클래스

import { File } from './File';
import { Directory } from './Directory';

export abstract class Visitor {
  public abstract visit(file: File): void;
  public abstract visit(directory: Directory): void;
}

Visitor 클래스는 방문자를 나타내는 추상 클래스이다.

인수를 File, Directory로 구분한 메소드가 Overload되어 선언되어 있다.

Element 인터페이스

export interface Element {
  accept: (v: Visitor) => void;
}

Element 인터페이스는 방문자를 받아들이는 인터페이스로써, Visitor 클래스를 인수로 갖는 메소드 accept를 선언하고 있다.

간단히 비유하자면 호텔은 Element, 호텔의 투숙객은 Visitor가 되는 것이다.

Entry 클래스

export abstract class Entry implements Element {
  public abstract accept: (v: Visitor) => void;
  public abstract getName: () => string;
  public abstract getSize: () => number;

  public add = (entry: Entry): Entry => {
    throw new FileTreatmentException();
  };

  public iterator = (): Entry[] => {
    throw new FileTreatmentException();
  };

  public toString = (): string => {
    return `${this.getName()} (${this.getSize()})`;
  };
}

Composite 패턴의 Component 역할을 하는 클래스를 Element를 구현하도록 한 클래스이다.

Element 인터페이스에 선언된 추상 메소드 accept는 하위 클래스인 File, Directory 클래스에서 구현한다.

add, iterator 메소드는 Directory 클래스에서만 유효하므로 에러로 처리하고 있다.

File 클래스

export class File extends Entry {
  private name: string;
  private size: number;

  constructor(name: string, size: number) {
    super();
    this.name = name;
    this.size = size;
  }

  public getName = (): string => {
    return this.name;
  };

  public getSize = (): number => {
    return this.size;
  };

  public accept = (v: Visitor): void => {
    v.visit(this);
  };
}

Composite 패턴의 Leaf 역할의 클래스이며 추가적으로 accept 메소드를 구현하고 있다.

this가 File 클래스의 생성자이기 때문에, Overload된 두 메소드 중 visit(File)이 호출되며,

visit 메소드를 호출하여 방문한 File의 인스턴스를 Visitor에게 알려준다.

Directory 클래스

export class Directory extends Entry {
  private name: string;
  private dir: Entry[] = [];

  constructor(name: string) {
    super();
    this.name = name;
  }

  public getName = (): string => {
    return this.name;
  };

  public getSize = (): number => {
    let size = 0;
    for (let entry of this.dir) size += entry.getSize();
    return size;
  };

  public add = (entry: Entry): Entry => {
    this.dir.push(entry);
    return this;
  };

  public iterator = (): Entry[] => {
    return this.dir;
  };

  public accept = (v: Visitor): void => {
    v.visit(this);
  };
}

Composite 패턴의 Composite 역할의 클래스이며 추가적으로 iterator, accept 메소드를 구현하고 있다.

iterator 메소드는 Directory에 포함되어 있는 Entry의 종류를 얻기 위해 사용되며,

accept 메소드는 위 File 클래스와 같은 이유로 visit(Directory)가 호출되며 Visitor에게 Directory의 인스턴스를 알려준다.

ListVisitor 클래스

export class ListVisitor extends Visitor {
  private currentdir: string = '';

  visit(file: File): void;
  visit(directory: Directory): void;

  public visit(value: File | Directory) {
    console.log(`${this.currentdir}/${value}`);
    if (value instanceof File) return;

    let savedir = this.currentdir;
    this.currentdir = `${this.currentdir}/${value.getName()}`;
    for (let entry of value.iterator()) {
      entry.accept(this);
    }
    this.currentdir = savedir;
  }
}

ListVisitor 클래스는 Visitor 클래스의 하위 클래스로써 visit(File), visit(Directory) 를 구현하고 있다.

currentdir 필드는 현재 주목하고 있는 디렉터리의 이름을 저장한다.

visit 메소드는 동일하게 현재 위치를 출력하며, Directory일 경우 해당 디렉터리의 각 엔트리에 대해서 accept 메소드를 호출한다.

accept 메소드는 visit 메소드를, visit 메소드는 accept 메소드를 호출하고 있는, 서로 상대를 호출하는 모습을 띄고 있다.

FileTreatmentException 클래스

export class FileTreatmentException {
  constructor(msg?: String) {
    if (msg) throw Error(msg as string);
  }
}

File에서 add, iterator를 호출하였을 때 Error를 throw하는 클래스이다.

Main 클래스

export class Main {
  main() {
    try {
      console.log('Making root entries...');
      const rootdir: Directory = new Directory('root');
      const bindir: Directory = new Directory('bin');
      const tmpdir: Directory = new Directory('tmp');
      const usrdir: Directory = new Directory('usr');
      rootdir.add(bindir);
      rootdir.add(tmpdir);
      rootdir.add(usrdir);
      bindir.add(new File('vi', 10000));
      bindir.add(new File('latex', 20000));
      rootdir.accept(new ListVisitor());

      console.log('\n');
      console.log('Making user entries...');
      const Kim: Directory = new Directory('Kim');
      const Oh: Directory = new Directory('Oh');
      const Park: Directory = new Directory('Park');
      usrdir.add(Kim);
      usrdir.add(Oh);
      usrdir.add(Park);
      Kim.add(new File('diary.html', 100));
      Kim.add(new File('foo.css', 300));
      const Wavy: Directory = new Directory('Wavy');
      Oh.add(Wavy);
      Wavy.add(new File('index.tsx', 100));
      Wavy.add(new File('App.tsx', 500));
      Park.add(new File('game.doc', 10000));
      rootdir.accept(new ListVisitor());
    } catch (error) {
      console.log(error);
    }
  }
}

Directory와 File을 이용하여 구조를 만든다.

그 후 출력이라는 처리를 위해 rootdir의 accept 메소드에 ListVisitor 클래스의 인스턴스를 인수로 실행한다.

실행 결과

Visitor 패턴의 구성요소

  • Visitor

    데이터 구조의 구체적인 요소마다 visit(foo) 메소드를 선언하는 역할을 한다. foo는 foo를 처리하기 위한 메소드이고, 실제 구현은 ConcreteVisitor 역할에 기술되어 있다. 예제 프로그램에서는 Visitor 클래스가 담당하였다.

  • ConcreteVisitor

    Visitor 역할의 인터페이스를 구현하는 역할을 한다. 예제 프로그램에서는 ListVisitor가 담당하였다. 이 때 ListVisitor의 currentdir 필드값이 변화하듯 visit을 처리하는 중 내부상태가 변화하는 일도 있을 수 있다.

  • Element

    Visitor 역할이 방문할 곳을 나타내는 역할이다. 방문자를 받아들이는 accept 메소드를 선언하며 인수에는 Visitor 역할이 전달된다. 예제 프로그램에서는 Element 인터페이스가 담당하였다.

  • ConcreteElement

    Element 역할의 인터페이스를 구현하는 역할이다. 예제 프로그램에서는 File, Directory 클래스가 담당하였다.

  • ObjectStructure

    Element 역할의 집합을 취급하는 역할로써 ConcreteVisitor 역할이 각각의 Element 역할을 취급할 수 있는 메소드를 구비하고 있다. 예제 프로그램에서는 Directory 클래스가 담당하였으며 각각의 Element 역할을 취급할 수 있는 메소드는 iterator였다.

Class diagram

visitor-diagram

데이터 구조를 담당하는 Element와 처리를 담당하는 Visitor가 분리되어 있다.

Sequence diagram

visitor-sequence

Element는 accept(Visitor)하며, Visitor는 visit(element)하는 형태를 띄고 있다.

이 처럼 두 역할을 하는 한 쌍에 의해 실제 처리가 되는 것을 일반적으로 더블 디스패치라고 한다.

왜 이렇게까지 복잡하게 해야하나 ?

Visitor 패턴의 목적은 처리를 데이터 구조에서 분리하는 일이다.

만약 처리 내용을 File, Directory 클래스의 메소드로서 프로그램을 작성하면, 새로운 처리가 생기거나 기능을 확장해야 할 때마다 각 File, Directory 클래스를 수정해야 한다.

하지만 Visitor 패턴을 사용하여 처리와 데이터 구조를 분리하였을 시, 데이터 구조와 독립적으로 ConcreteVisitor 역할을 개발하면 된다.

이 처럼 File, Directory 클래스의 부품으로써의 독립성을 높임과 데이터 구조와 처리를 분리함으로써 OCP 원칙을 지킬 수 있따.

Open-Closed Principle

확장에 대해서는 열려(open)있지만, 수정에 대해서는 닫혀(closed)있어야 한다.

기존의 클래스를 수정하지 않고 확장할 수 있도록 하는 것이 OCP 원칙이다.

ConcreteVisitor 추가

구체적인 처리는 ConcreteVisitor가 전담하며, 해당 처리를 위해 ConcreteElement의 수정은 필요치 않기 때문에 간단하다.

ConcreteElement 추가

ConcreteVisitor의 추가와 반대로 ConcreteElement의 추가는 곤란하다.

예제 프로그램에서 Entry 클래스의 하위에 Device 클래스를 만든다고 하였을 때,

Visitor 역할에 visit(Device) 메소드가 필요해지며, Visitor의 모든 하위 클래스에서 visit(Device)를 구현해야하기 때문이다.

Visitor가 필요한 것

처리(Visitor)와 데이터 구조(Element)를 분리하는 것은 그럴듯 하지만, Element는 Visitor에게 충분한 정보를 공개할 필요가 있다.

예제 프로그램에서는 각 Directory 안의 각 엔트리에 대해 accept를 하기 위해 iterator를 제공하고 있다.

이 처럼 Visitor는 데이터 구조에 필요한 정보를 취득해서 동작한다.

필요한 정보를 얻을 수 없으면 Visitor는 제대로 일할 수 없으며, 공개할 필요 없는 정보까지 공개하게 되면 미래의 데이터 구조를 수정하기 어렵게 된다.

관련 패턴

  • Iterator

  • Composite

    방문처가 되는 데이터 구조는 Composite 패턴이 되는 경우가 많다.

  • Interpreter

이미지 출처 : https://reactiveprogramming.io/blog/en/design-patterns/visitor

hyesungoh

Personal blog by hyesungoh.

I like to share my knowledge for those who wandering in issue.