본문 바로가기

study/design pattern

[디자인패턴][구조패턴] 컴포지트 Composite - C++

728x90

[모던 C++ 디자인 패턴] 책을 바탕으로 공부하는 내용을 정리한 내용이다.


Composite Pattern

보통 공부할 때 Aggregation 관계와 Composite 관계는 생명주기에서 다르다고 말을 한다. 하지만, 이 책에서는 두 개를 서로 동등한 것으로 취급한다.

 

객체가 복수의 구성 요소로 이루어져 있다는 것을 의도적으로 사용자에게 알려줄 방법은 많지 않다.

우선, 명시적으로 멤버 변수에 대한 getter/setter를 노출하는 방법이 있다. 이때, begin/end 반복자와 같은 것으로 구성 요소의 집합이라는 것을 알려야 한다.

다른 방법으로는 컨테이너 타입을 상속받는 것이다. 하지만, STL 컨테이너는 virtual 소멸자가 없기 때문에 소멸자를 쓸 일이 없어야 한다.

 

Composite 패턴은 기본적으로 어떤 객체들의 집합에 대해 개별 인터페이스를 동일하게 가져갈 수 있게 하는 것이다. 각 객체에 동일 인터페이스를 구현하면 되겠지만, 덕 타이핑(duck typing)을 적용해 begin/end 인터페이스를 만들 수도 있다. 일반적으로 덕 타이핑은 최악의 아이디어이며, 명시적 인터페이스 대신 숨겨진 정보에 의존한다.


배열에 기반한 속성

Composite 패턴은 보통 전체 클래스를 대상으로 적용된다. 먼저, 클래스의 "속성"에서 어떻게 사용될 수 있는지를 보자. "속성"이란 멤버 변수는 물론이고 API를 통해 사용자에게 필드가 노출되는 방식을 포괄해 지칭한다.

 

어떤 게임에 크리쳐들을 만들려고 한다. 크리쳐들은 숫자로 표현되는 서로 다른 여러 특성을 갖는다. 이러한 크리처를 클래스로 정의하면 아래와 같다.

class Creature {
public:
	int getStrengt() const {
		return m_strength;
	}

	int setStrength(int strength) {
		m_strength = strength;
	}

	// 다른 getter/setter

private:
	int m_strength;
	int m_agility;
	int m_intelligence;
};

 

여기서 추가로 집합적인 무언가를 하려고 한다면 문제가 있다. 예를 들어, 크리처들의 특성 값들에 대한 통계를 내야한다고 해보자. 능력치의 총합, 평균, 가장 높은 수치의 능력치의 값 등 통계적 값을 구하기 위해서는 필드 별로 흩어져 있는 값들을 모아 계산해야 한다. 단순히 생각하면 다음과 같은 함수를 추가할 수 있을 것이다.

	int sum() const {
		return m_strength + m_agility + m_intelligence;
	}

	double average() const {
		return sum() / 3.0;
	}

	int max() const {
		return std::max(std::max(m_strength, m_agility), m_intelligence);
	}

 

하지만, 위의 구현은 바람직하지 않다.

  • 전체 합계 계산 시 필드 중 하나를 빠트리기 쉽다.
  • 평균 계산 시 상수 3.0을 사용하는데, 필드 개수가 바뀔 때마다 같이 바뀌어야해 의도치 않은 종속성을 야기한다.
  • 최대 값을 구할 때 모든 필드 쌍마다 std::max()를 반복 호출해야 한다.
  • 속성 추가 시 모든 집계함수에 영향이 간다.

이를 보완하기 위한 바업으로 배열 기반 속성을 사용하면 된다.

class Creature {
public:
	enum Abilities {
		str, 
		agl, 
		intl, 
		count, //< enum 개수
	};

public:
	int getStrengt() const {
		return m_abilities[str];
	}

	int setStrength(int strength) {
		m_abilities[str] = strength;
	}

	// 다른 getter/setter

	int sum() const {
		return std::accumulate(m_abilities.begin(), m_abilities.end(), 0);
	}

	double average() const {
		return sum() / (double) count;
	}

	int max() const {
		return *max_element(m_abilities.begin(), m_abilities.end());
	}

private:
	std::array<int, count> m_abilities;
};

이제는 보다 유지보수 하기도 쉽고, 새로운 속성을 추가하기도 쉬워졌다. 추가되는 getter/setter 외에는 건드릴 필요가 없다.


그래픽 객체의 그루핑

파워포인트 같은 프로그램을 생각해보자. 서로 다른 객체들을 드래그로 여러 개 선택해서 하나의 객체처럼 다루는 작업을 한다고 해보자. 이때 객체 하나를 더 선택해서 그룹에 추가할 수도 있다.

객체를 화면에 그리는 렌더링 작업도 마찬가지다. 개별 그래픽 객체를 렌더링 할 수도 있고, 여러 개의 도형을 하나의 그룹으로 렌더링 할 수도 있다. 이 사용 방식은 쉽게 구현할 수 있다.

struct GraphicObject {
	virtual void draw() = 0;
};

이 클래스 이름에서 GraphicObject가 한 개의 그래픽 객체만 나타낸다고 생각할 수 있다. 그러나, 여러 사각형과 원들이 모인 그래픽 객체들도 집합적으로 하나의 그래픽 객체를 나타낼 수 있다. 여기서 Composite 패턴이 드러난다.

 

원을 아래의 Circle 클래스(구조체)처럼 만들 수 있듯이, 비슷한 방식으로 여러 그래픽 객체를 갖는 Group 클래스를 구현할 수 있다.

struct Circle : GraphicObject {
	void draw() override {
		std::cout << "Circle" << std::endl;
	}
};

struct Group : GraphicObject {
	explicit Group(const std::string &name) : m_name(name) {
	}

	void draw() override {
		std::cout << "Group " << m_name.c_str() << " contains: " << std::endl;
		for ( auto &&o : m_objects ) {
			o->draw();
		}
	}

public:
	std::string m_name;
	std::vector<GraphicObject *> m_objects;
};

 

개별 원 객체든 그룹 그래픽 객체든 draw() 함수를 구현할 수 있는 그릴 수 있는 도형이다. 이 예제는 가장 단순한 형태의 Composite 패턴의 구현으로 볼 수 있다.


뉴럴 네트워크

머신 러닝의 한 분야로, 뇌의 동작 방식을 흉내 낸 인공 신경망이다. 뉴럴 네트워크의 중심 개념은 뉴런이며, 뉴런은 함수와 같다. 뉴런의 입력과 출력의 연결이 모여 뉴럴 네트워크를 이룬다. 뉴런 간의 연결만을 생각해보기로 하자. 다음과 같은 모델이 나올 것이다.

struct Neuron {
public:
	Neuron() {
		static int id = 1;
		this->m_id = id++;
	}

	template<typename T = Neuron>
	void connectTo(T &other) {
		m_out.push_back(&other);
		other.m_in.push_back(this);
	}

public:
	unsigned int m_id;
	std::vector<Neuron *> m_in;
	std::vector<Neuron *> m_out;
};

 

뉴런들의 레이어를 만들어보자. 레이어는 단순히 특정 개수의 뉴런들이 모인 것이다. 바람직하지는 않지만, std::vector 컨테이너 객체를 상속받아 레이어를 구현해보자.

struct NeuronLayer : std::vector<Neuron> {
public:
	NeuronLayer(int count) {
		while ( 0 < count-- ) {
			emplace_back(Neuron());
		}
	}
};

 

여기서 뉴런 대 뉴런이 아닌, 뉴런 대 레이어 연결, 레이어 대 레이어 연결 등을 하고 싶다면 connectTo() 하나로는 처리할 수 없다. 연결 대상 종류가 점차 늘어난다면 그때마다 함수를 만드는 것도 비효율 적이다. 따라서, 베이스 클래스에 연결 함수를 만들고 다중 상속을 사용하도록 하자.

template <typename Self>
struct SomeNeurons {
	template <typename T>
	void connectTo(T &other) {
		for ( Neuron &from : *static_cast<Self *>(this) ) {
			for ( Neuron &to : other ) {
				from.m_out.push_back(&to);
				to.m_in.push_back(&from);
			}
		}
	}
};

 

connectTo를 보도록 하자. connectTo()는 타입 T에 대한 템플릿 멤버 함수다. 지정받은 타입 T의 인자 other에 대한 *this를 순회하며 other의 뉴런을 연결한다. *this는 SomeNeurons & 타입이기 때문에 그냥 순회할 수 없어 실제 타입(Self)으로 캐스팅해야 한다.

따라서, SomeNeurons & 을 템플릿 클래스로 선언할 수밖에 없는 이유다. 실제 Self 타입은 나중에 구현 클래스에서 SomeNeurons 상속받을 때 struct Neuron : SomeNeurons<Neuron>과 같이 템플릿 인자로 지정된다. 이를 통해 연결 함수를 각 조합별로 일일이 만드는 대신 치러야 할 약간의 비용이다. 남은 것은 범위 기반 for 루프가 동작할 수 있도록 SomeNeurons::begin()/end()를 Neuron과 NeuronLayer에서 구현하는 것이다.

NeuronLayer는 vetor를 상속 중이라 별도 추가가 필요없지만, Neuron에서는 자기 자신을 항목으로 하여 순회할 수 있도록 구현이 필요하다. 비록 자기 자신 하나밖에 없지만 말이다.

	Neuron *begin() {
		return this;
	}

	Neuron *end() {
		return this + 1;
	}

요약

Composite 디자인 패턴은 개별 객체와 컬렉션 객체 모두 동일 인터페이스를 사용할 수 있게 해준다. 명시적으로 인터페이스 멤버를 두어도 되고, 덕 타이핑을 적용해도 된다. 범위 기반 for 루프를 이용해 객체의 타입이 갖는 계층적 의미를 훼손하지 않고 begin/end 멤버 함수만 의존성을 갖게 할 수도 있다.

 

단일 객체를 컬렉션처럼 보이게 해주는 키 포인트는 begin/end 멤버 함수다. 주목할 사항은 Neuron의 begin/end와 vector<Neuron>::iterator의 beginend는 완전히 다른 반복자이지만, 템플릿 덕분에 호환되고 있다.

728x90