비지터 패턴과 함수형 프로그래밍

dusunim·2023년 2월 18일
0

version 1. 기본형

비지터 패턴은 방문자(visitor)와 방문 공간(알고리즘이 작동하는 객체 또는 자료구조)을 분리하는 패턴이다. 전형적인 비지터 패턴의 모습은 아래 위키 페이지 UML에서 확인할 수 있고, 대부분의 패턴 책에서 소개되는 형태도 크게 다르지 않다.

이 UML 클래스 다이어그램을 그대로 옮기면 아래처럼 되는데,
(public:을 매번 적기 귀찮아서 class 대신 struct 키워드를 사용함)

struct ElementA;
struct ElementB;

struct Visitor
{
	virtual void visit(ElementA* element) = 0;
	virtual void visit(ElementB* element) = 0;
};

struct Element
{
	virtual void accept(Visitor* visitor) = 0;
};

struct ElementA : public Element
{
	void accept(Visitor* visitor) override { visitor->visitElementB(this); }
};

struct ElementB : public Element
{
	void accept(Visitor* visitor) override { visitor->visitElementA(this); }
};

struct Visitor1 : public Visitor
{
	void visitElementA(ElementA* element) override { ... }
	void visitElementB(ElementB* element) override { ... }
};

요 패턴의 문제점은 Element의 구현을 알아야 Visitor의 인터페이스를 만들 수 있다는 거다. ElementB, ElementA를 알아야 Visiter::visitElementA(), Visitor::visitElementB() 를 만들 수 있다는 의미.
그러다보니 실제로 사용하게 되면 (OCP를 가능하게 해 주는 거라고 위키피디어에 설명되어 있음에도) Visitor2, Visitor3 형태로만 확장할 수 있을 뿐 ElementC, ElementD 형태로의 구현객체 확장은 불가능하다.

version 2. 확장형

그래서 난 보통 Visitor인터페이스를 아래처럼 임의의 Element 파생 클래스만을 대상으로 단순화해서 정의한다.

  • 인터페이스 클래스에 접두어 I를 사용
  • IElementname() 메쏘드를 갖는 걸로...
class IElement;

struct IVisitor
{
	virtual void visit(IElement* element) = 0;
};

struct IElement
{
	virtual void accept(IVisitor* visitor) = 0;

	virtual const std::string& name() const = 0;
};

Element 들로 nested-list 형태의 tree를 만들 수 있는 ElementList란 놈을 구현하면 대략 아래처럼 적을 수 있겠다.

struct Element : public IElement
{
	Element(const std::string& name) : name_(name) {}

	void accept(IVisitor* visitor) override { visitor->visit(this); }

	const std::string& name() const override { return name_; }

private:
	std::string name_;
};

struct ElementList : public IElement
{
	ElementList(const std::string& name) : name_(name) {}

	void accept(IVisitor* visitor) override
	{
	    visitor->visit(this);
		for (auto element : elements_) element->accept(visitor);
	}

	void add(IElement* element) { elements_.push_back(element); }

	const std::string& name() const override { return name_; }

private:
	std::string name_;
	std::list<IElement*> elements_;
};

PrintingVisitor란 걸 만들어서 트리를 순회하면서 이름을 출력하는 예제를 만들면 대략 아래와 같다.

struct PrintingVisitor : public IVisitor
{
	void visit(IElement* element) override
	{
		printf("%s\n", element->name().c_str());
	}
};

int main()
{
	auto root = new ElementList("root");
	auto elements1 = new ElementList("element list 1");
	auto elements2 = new ElementList("element list 2");

	elements1->add(new Element("element 1-1"));
	elements1->add(new Element("element 1-2"));
	elements2->add(new Element("element 2-1"));
	elements2->add(new Element("element 2-2"));

	root->add(elements1);
	root->add(elements2);
	root->add(new Element("element 3"));

    PrintingVisitor printer;
	root->accept(&printer);

    return 0;
}

online example

이 방법은 IVisitor의 메쏘드를 하나로 제한한 결과 VisitorN, ElementX 양쪽으로 OCP를 가능하게끔 한다는 장점을 갖는다. 대신 구현 클래스별 분기를 visit() 함수 내부에서 책임져야 한다는 문제가 생기는데, Visitor::visitElementX()를 호출하는 쪽에서의 고민이 Visitor::vist() 함수 내부로 옮겨진 것에 불과하므로 실전적으로는 큰 문제가 되지 않는다.

version 3. 함수형

앞서 확장한 비지터 패턴은 함수형 버전으로 만들기에 대단히 용이하다. IVisitor 인터페이스를 visitor 람다로 대체한 것에 불과하기 때문이다.
(좀 더 자연스럽다는 생각에 IElement::accept() 메쏘드의 이름을 IElement::visit()로 변경함)

struct IElement
{
	virtual const std::string& name() const = 0;

	virtual void visit(std::function<void(IElement*)> visitor) = 0;
};

ElementElementList의 구현도 람다를 사용하는 것으로 대체된다.

struct Element : public IElement
{
	Element(const std::string& name) : name_(name) {}

	void visit(std::function<void(IElement*)> visitor) override { visitor(this); }

	const std::string& name() const override { return name_; }

private:
	std::string name_;
};

struct ElementList : public IElement
{
	ElementList(const std::string& name) : name_(name) {}

	void visit(std::function<void(IElement*)> visitor) override
	{
	    visitor(this);
		for (auto element : elements_) element->visit(visitor);
	}

	void add(IElement* element) { elements_.push_back(element); }

	const std::string& name() const override { return name_; }

private:
	std::string name_;
	std::list<IElement*> elements_;
};

함수형 비지터의 강력한 점은 비지터를 구현하는 시점에 드러난다.

int main()
{
	auto root = new ElementList("root");
	auto elements1 = new ElementList("element list 1");
	auto elements2 = new ElementList("element list 2");

	elements1->add(new Element("element 1-1"));
	elements1->add(new Element("element 1-2"));
	elements2->add(new Element("element 2-1"));
	elements2->add(new Element("element 2-2"));

	root->add(elements1);
	root->add(elements2);
	root->add(new Element("element 3"));

	root->visit([](auto&& element) {
		printf("%s\n", element->name().c_str());
	});

    return 0;
}

차이를 한 눈에 비교할 수 있게끔 아래 클래스형 비지터를 version 2 아래로, 함수형 비지터를 version 3 아래로 옮겨 적었다.

// version 2. extended visitor
struct PrintingVisitor : public IVisitor
{
	void visit(IElement* element) override
	{
		printf("%s\n", element->name().c_str());
	}
};

    PrintingVisitor printer;
	root->accept(&printer);

// version 3. functional visitor
	root->visit([](auto&& element) {
		printf("%s\n", element->name().c_str());
	});

online example

version 4. shared_ptr 버전

샘플 코드가 지나치게 불어나는 것을 피하기 위해 new 한 객체를 delete 하는 걸 생략했는데, shared_ptr을 도입하는 방식으로 해결해 보겠다.
생성자를 private으로 감추고 각 객체 별로 Element::create() 형태로 변형된 팩토리 함수를 추가했는데, shared_ptr이 아닌 객체를 생성할 수 없도록 강제하는 기법이다.

class Element : public IElement,
	public std::enable_shared_from_this<Element>
{
	Element(const std::string& name) : name_(name) {}

public:
	static auto create(const std::string& name)
	{
		return std::shared_ptr<Element>(new Element(name));
	}

	void visit(std::function<void(std::shared_ptr<IElement>)> visitor) override
	{
		visitor(shared_from_this());
	}

	const std::string& name() const override { return name_; }

private:
	std::string name_;
};

class ElementList : public IElement
	, public std::enable_shared_from_this<ElementList>
{
	ElementList(const std::string& name) : name_(name) {}

public:
	static auto create(const std::string& name)
	{
		return std::shared_ptr<ElementList>(new ElementList(name));
	}

	void visit(std::function<void(std::shared_ptr<IElement>)> visitor) override
	{
	    visitor(shared_from_this());

		for (auto element : elements_)
		{
			element->visit(visitor);
		}
	}

	void add(std::shared_ptr<IElement> element)
	{
		elements_.push_back(element);
	}

	const std::string& name() const override { return name_; }

private:
	std::string name_;
	std::list<std::shared_ptr<IElement>> elements_;
};

online example

profile
C++로 기하 알고리즘과 애플리케이션 아키텍처 위주로 작업해온 시니어 개발자였습니다만, 요즘은 React.js, Node.js, Kubernetes 등등... 프론트와 백엔드 이것저것을 배워가는 중입니다.

0개의 댓글