Coding Memo
브릿지 패턴 (Bridge Pattern) + Pimpl 본문
브릿지 패턴은 구현과 추상화를 분리하여 독립적으로 변경 할 수 있도록 설계하는 디자인 패턴이다. 단순하게 말해서 어떤 컴포넌트가 다른 컴포넌트의 구체적인(concrete) 구현을 알지 못해도 연동할 수 있도록 설계한다.
브릿지 패턴을 사용하면 추상화를 통해, 클라이언트 코드가 특정 API에 의존하지 않도록 설계할 수 있다.
예를 들자면, 어떤 원을 그리는 Renderer 인터페이스가 있다고 하자. 이 Renderer에 렌더링 클래스를 제공하여 지정된 방식으로 렌더링을 하도록 한다. DirectX, OpenGL, Vulkan과 같은 다양한 렌더링 API를 독립적으로 관리하고 실행시킬 수 있다.
예로 들어던 Renderer를 직접 간단하게 구현하고 확인해보자.
1. Renderer
Renderer는 인터페이스로, 여기서는 간단하게 원을 그리는 인터페이스를 가지고 있다고 하자.
그리고 concrete Renderer는 DirectX, OpenGL, Vulkan을 사용한 렌더링으로 구현한다고 하자.
struct Renderer // interface
{
public:
virtual void render_circle(const float x, const float y, const float radius) = 0;
};
// concrete renderer
struct DirectXRenderer : public Renderer
{
public:
void render_circle(const float x, const float y, const float radius) override
{
std::cout << "A circle is rendered by DirectX." << std::endl;
}
};
struct OpenGLRenderer : public Renderer
{
public:
void render_circle(const float x, const float y, const float radius) override
{
std::cout << "A circle is rendered by OpenGL." << std::endl;
}
};
struct VulkanRenderer : public Renderer
{
public:
void render_circle(const float x, const float y, const float radius) override
{
std::cout << "A circle is rendered by Vulkan." << std::endl;
}
};
이렇게 추상화를 이용하면, 단순히 Renderer 클래스를 참조하므로써, 구체적인 렌더링 형식을 알지 못해도 단순히 render_circle 함수를 통해 원을 렌더링 할 수 있을 것이다.
2. Shape, Circle
Shape를 인터페이스로 가지는 Circle 클래스를 작성한다. Shape는 렌더링할 API 정보를 담고 있는 Renderer를 참조로 가지며 (이 부분이 바로 브릿지 패턴이다.), 포지션에 대한 값 x, y를 각각 가지고 있다.
struct Shape
{
protected:
Shape(Renderer& renderer, const float x, const float y) : _renderer(renderer), _x(x), _y(y) {}
public:
virtual void render() = 0;
protected:
Renderer& _renderer;
float _x;
float _y;
};
struct Circle : public Shape
{
public:
Circle(Renderer& renderer, const float x, const float y, const float radius) : Shape(renderer, x, y), _radious(radius)
{
}
void render() override
{
_renderer.render_circle(_x, _y, _radius);
}
private:
float _radius;
};
3. Test Code
int main()
{
// Render a circle through bridge(DirectX).
DirectXRenderer renderer;
Circle circle(renderer, 10, 10, 5);
circle.render();
return 0;
}
render 인터페이스를 통해, 어떤 렌더러인지에 상관없이 Shape, Circle에서 렌더링을 할 수 있다. Renderer는 자신을 사용하고 참조하는 것이 circle인지 모르고 알 필요도 없다.
만약, DirectX가 아닌, OpenGL로 렌더링을 하고 싶다면, 단순히, OpenGLRenderer를 생성하고 이를 Circle이 참조하면 된다. 단순 참조만하고, 실제 구현 방식은 전혀 알 필요가 없어, 의존성이 줄어들고, 코드 수정에도 유연하다.
브릿지 패턴을 사용하여, 양쪽 클래스가 반드시 서로 참조할 필요가 없어, 유연성과 유지보수성이 크게 향상될 수 있다.
Pimpl
Pimpl 관례 (혹은 패턴)은 Pointer to Implementation의 약자로, 클래스 구현부를 포인터로 참조하는 관례이자 패턴이다. 좀 더 구체적으로 말하면, 정보 은닉을 위해, 클래스 세부 내용을 헤더 파일이 아닌, cpp 파일 (소스 파일)에 정의하면서, 헤더 파일에 노출된 구현 사항을 숨기기 위해 사용 될 수 있다. 즉, 단순히 헤더 파일에는 'class ~~'와 같이 단순 선언만 포함되어 있기 때문에, 실제 구현 내용을 노출시키지 않을 수 있다.
한 예시로, 어떤 캐릭터 클래스가 있고, 이 캐릭터가 동작하는 구체적인 코드를 숨긴다고 생각해보자.
먼저 Pimpl을 적용한 헤더 파일을 보자.
class Chara
{
public:
Chara();
~Chara();
void Move(const float x, const float y);
private:
class CharaImpl;
CharaImpl* _impl;
};
생성자와 소멸자가 명시적으로 정의되어 있고, CharaImpl 클래스가 선언되어 있다. CharaImpl 클래스는 Chara가 수행하는 모든 동작을 실제로 수행하는 클래스이고, 헤더 파일에 정의가 포함되어 있지 않다. 단순히 class CharaImpl을 통해 전방 선언만 하고, Chara 클래스에서 멤버로 포인터 값을 가지고 있다.
class Chara::CharaImpl
{
public:
void Move(Chara* chara, const float x, const float y)
{
// TODO
}
};
Chara::Chara()
: _impl(new CharaImpl())
{
}
Chara::~Chara()
{
delete _impl;
}
void Chara::Move(const float x, const float y)
{
_impl->Move(this, x, y);
}
소스 파일에서 CharaImpl 클래스를 작성한다. 즉, 헤더파일에서는 CharaImpl 클래스에 대한 정보를 전혀 알 수가 없다.
Chara::Move는 CharaImpl::Move를 호출하는데, 이 때 실제 Move에 대한 내용을 CharaImpl에 제어를 위임하기 위해, Chara* 파라미터를 넘겨준다. 실제 Move를 어떻게 구현할 지는 CharaImpl::Move에 달려있는 것이다.
Pimpl은 브릿지 디자인 패턴의 매우 특별한 예라고 한다. (Design Pateerns in Modern C++)
위에서 보면, Chara가 바로 브릿지 역할을 하는 것으로 볼 수 있다.
Pimpl 패턴은 상당히 유용한 부분이 많다.
1. 정보 은닉 (Information Hiding)
헤더 파일에 구현을 하지 않고 소스 코드에 모든 구현을 넣어서, 헤더 파일에 실제 구현이 노출되는 것을 막을수 있다. 따라서 헤더 파일에서는 포인터로 실제 객체를 관리하게 된다.
2. 코드 수정과 컴파일
실제 구현이 있는 클래스를 수정을 해도, 이 클래스는 소스파일에 포함되어 있으니, 재컴파일 할 필요성이 없다. 즉, 숨겨진 구현 클래스에 대한 수정은 바이너리 호환성에 영향을 미치지 않는다. 헤더 파일이 변경된 것이 아니다.
3. 헤더 파일 참조
실제 구현에서 STL 라이브러리나 사용자 정의 헤더가 사용된다고 가정하자. Pimpl을 사용하지 않고 이 클래스를 사용한다고 가정한다면, 관련 헤더를 전부 include 해주어야 한다. 그러나, Pimpl을 사용하여 실제 구현 클래스를 소스파일로 한다면, 원래 사용했던 헤더 파일들을 헤더에 include 할 필요가 없게 된다. 단지, 소스 파일에서 include하면 될 뿐이다. 프로젝트가 커지거나, 의존성이 서로 커지는 클래스 사이에서 헤더파일을 줄이는 것은 컴파일 시간 단축에 도움이 되고, 컴파일 디버깅 또한 편해진다.
위와 같은 장점이 정말 특별하게 느껴지고 신선하다.
그러나 이 Pimpl을 실제 프로젝트에서 사용하려고 하면서 여러가지 고려해야할 사항들이 많았다.
아래는 내가 생각하기에 고려해야할 점을 정리한 리스트이다.
1. Pimpl에서 구현 클래스에 대한 접근자
위의 Chara에서 작성했던 것과 같이 private에 CharaPimpl을 선언한다면, 다른 클래스에서 사용할 수 없다. 이 이야기는 실제 구현부를 따로 구현하는 Pimpl 패턴에서 당연한 것이지만, 만약, private가 아닌, public으로 외부에 전방 선언을 해버린다면, 설계가 꼬여버릴 것이다. Pimpl은 어떤 클래스의 실제 구현부를 감추기 위해 사용하는 기법이라는 것을 명심하자.
2. 포인터
헤더 파일에 구체적인 구현이 포함되어 있지 않기 때문에, CharaImpl*과 같이 포인터 타입으로 멤버를 가지고 있다. 따라서 포인터로 값을 들고 있으려면, raw pointer나 smart pointer를 이용해야 한다. (스택에 생성된 객체를 포인터로 가지고 있으려고 하는 이상한 생각은 하지 말자.) 즉, 메모리 관리에 대해 좀 더 생각해보아야 할 수도 있다.
브릿지 패턴과 Pimpl 패턴에 대해서 살펴보았다.
항상 느끼는 건데, 클래스간 관계를 어떻게 설정하느냐에 따라 설계 방법이 크게 달라지는 것 같다. 이런 디자인 패턴으로 진행하고 있는, 그리고 앞으로 진행할 프로젝트에서 클래스간 상호의존도를 낮추도록 설계하는 방식을 알아갔으면 좋겠다.
Reference
모던 C++ 디자인 패턴 (드미트리 네스터룩, 권오인 옮김, 길벗)
'Language > C++' 카테고리의 다른 글
Factory 패턴 (팩토리 패턴) (0) | 2024.11.28 |
---|---|
[LNK2005] already defined in ~~.obj / inline 함수 (0) | 2024.10.28 |
템플릿 특수화, constexpr if 그리고 concept (3) | 2024.10.16 |
[C++20] concepts - requires (0) | 2024.10.02 |
Singleton 패턴 (0) | 2024.09.26 |