[모던 C++ 디자인 패턴] 책을 바탕으로 공부하는 내용을 정리한 내용이다.
어떻게 공부할지
오늘은 그 첫 번째 내용으로 책의 개요에 해당하는 내용을 정리하고, 앞으로 어떻게 공부할 지에 대한 내용이다.
해당 책은 모던 C++(C++11)을 기준으로 디자인 패턴을 공부할 수 있도록 재정리한 책이다.
(고전적인 디자인 패턴 원저 GoF를 최신 버전의 C++을 이용해 업데이트한 것이라고 소개하고 있다.)
현재 회사에서는 C++98을 기준으로 코드를 작성하고 있다. 납품해야 하는 고객사들 서버 기준으로 아직까지도 호환성을 고려해야 하기 때문이다.
그래서 나는 앞으로 디자인 패턴을 디자인 패턴을 공부하면서, 책에서 모던 C++ 예제를 C++98 기준으로 최대한 재정의해보고 공부를 해보자 한다. 다만, 언어 자체에서 지원해주는 범위도 무시할 수 없겠지만, 결국 디자인 패턴을 만드는 과정은 언어에 크게 영향을 받지 않는다고 생각한다. 때문에, 언어 버전을 낮추는 작업에 함몰되지 않도록 주의할 것이고 디자인 패턴 자체에 대한 공부를 집중적으로 할 것이다.
요약
특정한 문제에 일반적이고 포괄적인 해법을 적용하려고 디자인 패턴을 적용하려들면 오버 엔지니어링이 되기 쉽다. 패턴 자체에 집착하면 실제 필요한 것보다 훨씬 더 복잡해질 수 있다. 많은 경우에 실제 패턴을 그대로 적용하기에는 부적합할 수 있다.
이상한 재귀 템플릿 패턴 (CRTP, Curiously Recurring Template Pattern)
패턴은 패턴이지만, 하나의 디자인 패턴으로서 자격이 있다고 보기에는 어렵다. 자기 자신을 베이스 클래스의 템플릿 인자로 상속받는 구조가 그렇다.
struct Foo : SomeBase<Foo> {
...
}
이런 상속을 하는 이유는, 베이스 클래스의 구현부에서 타입이 지정된 this 포인터를 사용할 수 있다는 것이다.
예를 들어 SomeBase를 상속받는 모든 서브 클래스가 begin()/end() 메서드 쌍을 구현한다고 가정해보자. SomeBase 메서드 안에서 서브 클래스의 객체를 순회하기 위해서는 부모 클래스인 SomeBase에 begin()/end() 인터페이스를 정의하지 않는 한은 불가능하다. 이때 CRTP를 적용하면 this를 서브 클래스 타입으로 캐스팅이 가능하다.. 고 한다.
template <typename Derived>
struct SomeBase {
void foo() {
for (auto &item : *static_cast<Derived *>(this)) {
...
}
}
}
CRTP에 대해서 좀 더 조사한 결과, CRTP를 사용하게 되면, 기반 클래스에서 파생 클래스의 이름을 사용할 수 있으며, 가상함수(virtual) 없이 가상 함수를 override 한 것과 같은 효과를 볼 수 있다. 가상함수를 호출하는 데에 많은 오버헤드 비용(동적 다형성의 비용)이 발생하게 되는데 해당 비용을 없앨 수 있다.
예를 들어, 아래의 코드를 보면 X와 Y 클래스의 object_created / object_alive 값은 각각 다르게 증가되어 별도로 관리된다. X클래스 생성/종료 시에는 counter <X>의 생성자/소멸자가 호출되며, Y클래스 생성/종료 시에는 counter <Y>의 생성자/소멸자가 호출된다.
template <typename T>
struct counter {
counter()
{
++object_created;
++object_alive;
}
~counter()
{
--object_alive;
}
static int object_created;
static int object_alive;
};
// 초기화
template <typename T>
int counter<T>::object_created(0);
template <typename T>
int counter<T>::object_alive(0);
struct X : counter<X> {
// something
};
struct Y : counter<Y> {
// something
};
첨가(Mixin) 상속
클래스 정의 시 자기 자신을 템플릿 인자로 하는 경우를 첨가 상속(mixin inherintance)이라고 한다고 한다. 이 말이 어려워 다른 것들을 살펴보니, 템플릿을 상속받는 클래스를 만드는 것을 의미하는 것으로 보인다.
아래와 같은 형태를 나타내는 클래스를 의미한다.
template <typename T>
struct Mixin : T {
// something
}
이와 같은 형태를 갖는 첨가 상속을 이용하면 계층적으로 어러 타입(클래스)을 합성할 수 있다. 즉, 완전한 is-a 구조의 상속을 통한 파생 클래스 생성 없이 추가적인 기능을 add-on 할 수 있다.
책에서의 예제로는 Foo<Bar<Baz>> x;
와 같이 변수를 선언할 경우, 새로운 타입 FooBarBaz
를 구현하지 않아도 된다고 한다.
마찬가지로 이 말이 어려워 다른 예제를 살펴보니 확실히 이해할 수 있었다. 아래의 예제 코드 및 참고 사이트를 보게 되면, 정확한 상속 클래스를 명시하지 않고 템플릿을 상속해 기존 Base 클래스에 대해 여러 기능을 add-on 할 수 있는 것을 알 수 있다.
#include <iostream>
using namespace std;
struct Number {
typedef int value_type;
int n;
void set(int v) {
n = v;
}
int get() const {
return n;
}
};
template <typename BASE, typename T = typename BASE::value_type>
struct Undoable : BASE {
typedef T value_type;
T before;
void set(T v) {
before = BASE::get();
BASE::set(v);
}
void undo() {
BASE::set(before);
}
};
template <typename BASE, typename T = typename BASE::value_type>
struct Redoable : BASE {
typedef T value_type;
T after;
void set(T v) {
after = v;
BASE::set(v);
}
void redo() {
BASE::set(after);
}
};
typedef Redoable< Undoable<Number> > ReUndoableNumber;
int main() {
ReUndoableNumber mynum;
mynum.set(42);
mynum.set(84);
cout << mynum.get() << '\n'; // 84
mynum.undo();
cout << mynum.get() << '\n'; // 42
mynum.redo();
cout << mynum.get() << '\n'; // back to 84
}
속성
흔히들 클래스 내부 멤버 변수로, get/set 메서드를 갖는 변수를 보통 클래스의 속성이라고 부른다.
다른 대부분의 언어들과 달리 C++은 속성을 언어 자체의 내장 기능으로 제공하지 않는다. 그럼에도 대부분의 컴파일러(MSVC, Clang, Intel)에서 비표준적인 방법으로 지원하고 있다. 컴파일러에서 지원하는 방식은 아래와 같이 자동으로 변환함으로써 동작한다.
class Person {
int age; //< 속성값
public:
int get_age() const {
return age;
}
void set_age(int val) {
age = value;
}
// 컴파일러에서 비표준적인 방식으로 getter/setter 지원하기 위함
__declspec(property(get=get_age, put=set_age)) int age;
}
int main(void) {
Person person;
p.age = 20; //< calls p.set_age(20)
return 0;
}
SOLID 디자인 원칙
SOLID 디자인 원칙은 2000년대 초 로버트 마틴에 의해 소개되었다. 수십 가지 원칙 중 5가지 원칙을 선정해서 소개하고 있다.
1. 단일 책임 원칙(SRP, Single Responsibility princile)
원칙의 이름에서 알 수 있듯이, 각 클래스는 단 한 가지의 책임을 부여받아, 수정이 필요할 때 수정할 이유가 단 한 가지여야 한다. 작은 수정을 여러 클래스에 걸쳐서 해야 한다면 아키텍처에 문제가 있다는 징조다. 이를 보통 'Code smell'이라고 부른다. 참고로, 수정할 곳이 수백 군데라도 단순히 심볼 이름을 일괄적으로 바꾸는 것과 같은 것들은 문제 상황이 아니다.
메모를 하기 위해 메모장 클래스(Journal)를 만드는 것을 예로 들고 있다. 이 메모장에는 제목 하나에 여러 항목이 저장(add() 멤버 함수)될 수 있다. 각 항목을 기록/관리할 책임은 메모장에 있기 때문에, 여러 항목을 저장하기 위한 기능은 Journal 클래스에 포함되는 것은 자연스럽고 상식적이다. 다만, 영구적으로 파일에 저장하는 기능은 메모장의 역할이 아니기 때문에 문제가 발생한다.
struct Journal {
std::string m_title;
std::vector<std::string> m_entires;
explicit Journal(const std::string &sTitle) : m_title(sTitle) {}
void add(const std::string &sEntry) {
static int count = 1;
char szNum[12] = { 0, };
snprintf(szNum, 12, "%d", count++);
std::string sTarget;
sTarget.append(szNum).append(":").append(sEntry);
m_entries.push_back(sTarget);
}
// SRP 원칙 위배
void save(const std::string &sFilename) {
std::ofstream ofs(sFilename);
for ( std::vector<std::string>::iterator itEntry = m_entries.begin(); itEntry != m_entries.end(); ++itEntry ) {
if ( itEntry != m_entries.begin() ) {
ofs.write("\n", ::strlen("\n"));
}
ofs.write(itEntry->c_str(), itEntry->size());
}
}
};
int main() {
Journal journal("Dear Diary");
journal.add("I cried today");
journal.add("I ate a bug");
journal.save("./diary.txt");
}
메모장의 책임은 메모 항목을 기입/관리하는 것이며, 디스크에 쓰는 것이 아니다. 디스크에 파일을 쓰는 기능이 바뀔 때마다 데이터 기입/관리 클래스가 수정되어서는 안된다. 따라서, 파일 저장 기능은 메모장과 별도의 클래스(PersistenceManager)로 만드는 것이 바람직하다.
기입/관리 방식이 변경될 때는 Journal 클래스가 수정되어야 하고, 영구적인 저장 방식이 바뀌어야 할 때는 PersistanceManager 클래스가 수정되어야 한다.
struct Journal {
std::string m_title;
std::vector<std::string> m_entires;
explicit Journal(const std::string &sTitle) : m_title(sTitle) {}
void add(const std::string &sEntry) {
static int count = 1;
char szNum[12] = { 0, };
snprintf(szNum, 12, "%d", count++);
std::string sTarget;
sTarget.append(szNum).append(":").append(sEntry);
m_entries.push_back(sTarget);
}
};
struct PersistenceManager {
static void save(const Journal &journal, const std::string &sFilename) {
const std::vector<std::string> &vecEntries = journal.m_entries;
std::ofstream ofs(sFilename);
for ( std::vector<std::string>::const_iterator itEntry = vecEntries.begin(); itEntry != vecEntries.end(); ++itEntry ) {
if ( itEntry != vecEntries.begin() ) {
ofs.write("\n", ::strlen("\n"));
}
ofs.write(itEntry->c_str(), itEntry->size());
}
}
}
int main() {
Journal journal("Dear Diary");
journal.add("I cried today");
journal.add("I ate a bug");
PersistenceManager.save(journal, "./diary.txt");
}
2. 열림-닫힘 원칙(OCP, Open-Closed Principle)
OCP는 '확장에는 열고, 수정에는 닫고'로 요약할 수 있다. 이번 내용은 책의 내용으로도 충분히 이해가 됐기 때문에 책의 내용을 바탕으로 요약을 한다.
데이터베이스에 어떤 제품군에 대한 정보가 저장되어 있고, 개별 제품은 색상과 크기를 각각 갖는다. 이때 특정 색상을 기준으로 필터링하려 한다.
// 색 종류
enum COLOR_e {
COLOR_RED,
COLOR_GREEN,
COLOR_BLUE
};
// 사이즈 종류
enum SIZE_e {
SIZE_SMALL,
SIZE_MEDIUM,
SIZE_LARGE
};
// 제품군
struct Product {
std::string m_name;
COLOR_e m_color;
SIZE_e m_size;
};
// 제품군 필터링 조회
struct ProductFilter {
typedef std::vector<Product *> Items;
// 색 기준 필터
static Items by_color(Items items, COLOR_e color) {
Items result;
for ( Items::iterator itItem = items.begin(); itItem != items.end(); ++itItem ) {
Product *pProduct = *itItem;
if ( pProduct->m_color == color ) {
result.push_back(pProduct);
}
}
return result;
}
};
만약 여기서 크기에 대한 필터링을 추가하고 싶다면, ProductFilter
클래스 내부에 아래와 같은 함수를 추가할 것이다.
// 크기 기준 필터 (색 기준 필터와 동일하게 동작함)
static Items by_size(Items items, SIZE_e size) {
Items result;
for ( Items::iterator itItem = items.begin(); itItem != items.end(); ++itItem ) {
Product *pProduct = *itItem;
if ( pProduct->m_size == size ) {
result.push_back(pProduct);
}
}
return result;
}
이제부터는 확실히 반복작업을 하는 느낌이 든다.
또한, 요구사항이 생길때마다 코드에 직접적으로 수정이 되어야 한다. 여기서 기존 코드의 수정 없이 검색 필터에 대한 확장성을 제공할 수 있는 방법이 OCP 원칙을 적용했을 때다.
우선 필터링 절차를 SRP 원칙을 통해 개념적으로 두 단계로 구분하게 된다. 첫 번째는 "필터" 과정(항목 집합을 받아 일부를 반환)이고, 다른 하나는 "명세" 과정(데이터 항목을 구분하기 위한 조건 정의)이다.
먼저, "명세"는 아래와 같이 간단하게 정의할 수 있다. 맥락상 template 타입은 Product
이지만 다른 타입일 수도 있기 때문에 아래와 같이 명세를 재사용 가능하도록 한다.
// 명세
template <typename T>
struct Specification {
virtual bool is_satisfied(T *item) = 0;
};
이제는 명세(Specification<T>
)에 기반해 필터링을 수행할 방법이 필요하다. 즉, "필터"는 아래와 같이 작성할 수 있다.
// 필터
template <typename T>
struct Filter {
// 입력 아이템 목록과 명세를 기준으로 필터링된 결과 아이템 목록을 반환
virtual std::vector<T *> filter(std::vector<T *> items, Specification<T> &spec) = 0;
};
이 두 개를 통해 이제 OCP를 적용할 수 있는 준비가 되었다.
이제 실제로 개선된 필터와 명세를 구현해보면 아래처럼 구현될 수 있고, 필터를 적용할 수 있다.
// 여러 명세를 적용할 수 있는 필터링 방법
struct BetterFilter : Filter<Product> {
std::vector<Product *> filter(std::vector<Product *> items, Specification<Product> &spec) override {
std::vector<Product *> result;
for ( std::vector<Product *>::iterator itItem = items.begin(); itItem != items.end(); ++itItem ) {
Product *pProduct = *itItem;
if ( true == spec.is_satisfied(pProduct) ) {
result.push_back(pProduct);
}
}
return result;
}
};
// 색에 대한 명세
struct ColorSpecification : Specification<Product> {
COLOR_e m_color;
explicit ColorSpecification(const COLOR_e color) : m_color(color) {}
bool is_satisfied(Product *item) override {
return (item->m_color == m_color) ? true : false;
}
};
int main(void) {
std::vector<Product *> items;
Product apple{ "Apple", COLOR_e::COLOR_GREEN, SIZE_e::SIZE_SMALL };
Product tree{ "Tree", COLOR_e::COLOR_GREEN, SIZE_e::SIZE_LARGE };
Product house{ "House", COLOR_e::COLOR_BLUE, SIZE_e::SIZE_LARGE };
items.push_back(&apple);
items.push_back(&tree);
items.push_back(&house);
BetterFilter bf;
ColorSpecification specGreen(COLOR_e::COLOR_GREEN);
std::vector<Product *> greenItems = bf.filter(items, specGreen);
}
필터 조건(명세)에 대해 두 개 이상 설정하고 싶을 시에는 &&연산자를 오버로딩하게 되면 쉽게 필터 조건을 더 쉽게 만들 수 있다.
template <typename T>
struct Specification {
virtual bool is_satisfied(T *item) = 0;
AndSpecification<T> operator &&(Specification &&other) {
return AndSpecification<T>(*this, other);
}
};
template <typename T>
struct AndSpecification : Specification<T> {
Specification<T> &first;
Specification<T> &second;
AndSpecification(Specification<T> &first, Specification<T> &second) : first(first), second(second) {}
bool is_satisfied(T *item) override {
return (first.is_satisfied(item) && second.is_satisfied(item)) ? true : false;
}
};
이렇게 진행되면 필터에 대한 코드는 절대 수정이 되지 않을 것이다. 그러나, 필터에 대한 명세는 언제나 확장이 가능한 형태가 되었다. 이것이 확장에는 열려있지만 수정에는 닫혀있는 OCP 원칙이다.
3. 리스코프 치환 원칙(LSP, Liskov Substitution Principle)
LSP는 자식 객체에 접근할 때 부모 객체의 인터페이스로 접근하더라도 어떠한 문제(동작 상의)가 없어야 한다는 것을 의미한다. 이번 예제도 책의 예제를 그대로 사용한다.
/* 직사각형 */
class Rectangle {
public:
Rectangle(const int width, const int height) : m_width(width), m_height(height) {}
int getWidth() const {
return m_width;
}
virtual void setWidth(const int width) {
this->m_width = width;
}
int getHeight() const {
return m_height;
}
virtual void setHeight(const int height) {
this->m_height = height;
}
int getArea() const {
return m_width * m_height;
}
protected:
int m_width, m_height;
};
/* 정사각형 */
class Square : public Rectangle {
public:
Square(int size) : Rectangle(size, size) {}
void setWidth(const int width) override {
this->m_width = m_height = width;
}
void setHeight(const int height) override {
this->m_height = m_width = height;
}
};
void process(Rectangle &rectangle) {
// 정사각형인지 모르는 상태에서 직사각형의 높이를 10으로 변경한다면, 의도된 동작과 달리 문제가 발생한다.
int width = rectangle.getWidth();
rectangle.setHeight(10);
/*
* 기댓값 50, 구해진 값 25라는 책의 내용과 달리,
* 기댓값 50, 구해진 값 100이 나온다.
*/
printf("expected area = %d, got %d\n", width * 10, rectangle.getArea());
}
int main() {
Square square(5);
process(square);
return 0;
}
LSP가 위배되지 않게 하기 위해, 저자가 가장 선호하는 방법은 서브 클래스를 만들지 않는 것이다. 서브 클래스 대신, Factory 클래스를 사용해 클래스를 분리하지 않고 직사각형/정사각형 따로 생성을 하면 된다. 여기에 정사각형 여부를 체크하는 멤버 함수만 두면 된다.
/* 3장 Factory 클래스 배울때 자세히 공부 */
struct RectangleFactory {
static Rectangle createRectangle(int w, int h);
static Rectangle createSquare(int size);
};
다른 방법으로, Square 클래스의 멤버 함수인 set_width()/set_height()에서 Exception을 발생시키는 방법이 있다. 그리고, Square 클래스에서는 set_size()라는 별도의 멤버 함수를 두면 된다. 이 방법은 쉽지만, 놀람 최소화 원칙을 위배할 수 밖에 없다. 정상적인 값을 넘겨주고 정상적으로 동작할 것이라고 예상한 것과 달리 Exception이 발생하기 때문이다. (Exception이 문제가 아닌, 예상과 다른 동작을 수행하기 때문에 문제인 것이다.)
4. 인터페이스 분리 원칙(ISP, Interface Segregation Principle)
ISP는 말 그대로 한 덩어리의 복잡한 인터페이스가 아닌 목적에 따라 분리할 것을 말한다. 한 덩어리로 여러 목적의 인터페이스가 섞여 있다면, 구현할 때 목적에 맞지 않는 다른 모든 항목도 구현을 강제받게 된다. 이번 예제도 책의 예제를 따른다.
복합기와 같은 프린터를 만든다고 가정해보자. 프린터에 스캔/팩스 등의 기능을 합칠 것이다. 그럴 경우, 아래처럼 정의할 수 있다.
/* 복합 기능을 갖는 프린터 */
struct MyPrinter /* : IMachine */ {
void print(std::vector<Document *> docs) override;
void fax(std::vector<Document *> docs) override;
void scan(std::vector<Document *> docs) override;
};
위와 같이 정의한 내용을 바탕으로, 프린터의 구현은 하청 업체에 맡길 것이다. 제품별로 다양한 기능을 갖도록 할 예정이다. 각 업체가 프린터를 구현할 수 있도록 아래와 같은 인터페이스를 추출하게 된다.
/* 도출된 인터페이스 */
struct IMachine {
virtual void print(std::vector<Document *> docs) = 0;
virtual void fax(std::vector<Document *> docs) = 0;
virtual void scan(std::vector<Document *> docs) = 0;
};
이때, 스캔 기능이나 팩스 기능을 추가하지 않을 프린터라 할지라도 위 인터페이스는 모든 기능을 구현하도록 강제한다. 빈 함수를 만들어서 대응할 수는 있겠지만, 너무 많은 인터페이스가 있는 경우에는 어떤 인터페이스가 실제로 필요한 것인지 알기 어려울 수 있다. 수십 개의 함수가 빈 껍데기라면, 그건 잘못된 것이고 인터페이스 설계자가 ISP를 위배한 것이다.
스캐너와 프린터의 역할/목적은 다르다. 따라서 프린트 함수와 스캔 함수는 인터페이스부터 분리되어야 한다. 그리고 분리된 인터페이스에 기능적인 필요에 따라서 각각 구현될 수 있어야 한다. 복합기에 여러 기능을 추가하고 싶을 때는 해당 인터페이스들을 ㅎ바치면 된다.
/* 프린트 인터페이스 */
struct IPrinter {
virtual void print(std::vector<Document *>) docs) = 0;
};
/* 스캔 인터페이스 */
struct IScanner {
virtual void scan(std::vector<Document *> docs) = 0;
};
/* 프린트 구현 */
struct Printer : IPrinter {
void print(std::vector<Document *> docs) override;
};
/* 스캔 구현 */
struct Scanner : IScanner {
void scan(std::vector<Document *> docs) override;
};
/* 복합기 인터페이스 */
struct IMachine : IPrinter, IScanner {
};
/* 복합기 구현 */
struct Machie : IMachine {
IPrinter &m_printer;
IScanner &m_scanner;
Machie(IPrinter &printer, IScanner &scanner) : m_printer(printer), m_scanner(scanner) {}
void print(std::vector<Document *> docs) override {
m_printer.print(docs);
}
void scan(std::vector<Document *> docs) override {
m_scanner.scan(docs);
}
};
5. 의존성 역전 원칙(DIP, Dependency Inversion Principle)
DIP는 책의 예제로도 이해가 잘 안 가서 추가적으로 조사했다. 참고한 블로그들에서 너무 잘 정리가 되어 있어서, 저 글들만 봐도 이해가 될 것 같다.
DIP는 두 개로 정리하고 있다.
- 사위 모듈이 하위 모듈에 종속성을 가져서는 안 된다. 양쪽 모두 추상화에 의존해야 한다.
- 추상화가 세부 사항에 의존해서는 안 된다. 세부사 사항이 추상화에 의존해야 한다.
즉, 구현되는 클래스는 변경될 수 있으며, 변하지 않는 추상화 대상인 인터페이스에 의존해야 한다. 유명한 자동차-타이어 예제가 그것을 잘 설명한다.
자동차는 구현되고 나면 코드가 바뀌어서는 안 된다. 즉, 타이어 종류를 바꿔 교체할 때마다 자동차에 대한 코드가 수정되어서는 안 된다. OCP 원칙과도 연관이 있으며, 이는 자동차가 특정한 '스노우 타이어'에 종속성을 갖기 때문이다. 자동차가 추상화된 '타이어'에 종속성을 갖고 있으면, 실제 구체화된 '스노우 타이어'든 '일반 타이어'든 변경이 발생했을 때 쉽게 적용할 수 있다. 이렇게, DIP가 만족되면 보다 유연한 코드가 된다.
즉, 변경이 발생하기 쉬운 저수준 모듈(구현 객체)이 아닌, 변경이 발생하지 않는 고수준 모듈(인터페이스 또는 추상화 객체)에 의존하도록 하는 것이 DIP다.
DIP를 만족하면 의존성 주입(DI, Dependency Injection) 기술을 이용해 쉽게 수용 가능한 코드를 작성할 수 있다. 의존성 주입이란, 대상 클래스가 의존하는 클래스의 인터페이스를 대상 클래스에 주입하는 것이다. 런타임에 의존 클래스가 결정되며 일관되게 의존성을 주입시키게 된다.
주입 방법은 Setter를 이용해 조립하거나, 생성자를 통해 주입하는 방법이 있다. 핵심은 특정 클래스가 의존하는 인터페이스에 특정 구현 클래스를 주입시킨다는 것이다.
책에서는 이 DI에 대한 도구로 Boost.DI를 소개한다.(Boost 라이브러리에 속해있지는 않는다고 한다.)
코드는 다른 블로그들에도 잘 나와있어서, 생략한다.
여기까지도 내용이 당연한듯 어려웠다. 앞으로도 험난하겠지만 열심히 공부해보자.
'study > design pattern' 카테고리의 다른 글
[디자인패턴][구조패턴] 어댑터 Adapter - C++ (0) | 2022.05.11 |
---|---|
[디자인패턴][생성패턴] 싱글턴 Singleton - C++ (0) | 2022.04.30 |
[디자인패턴][생성패턴] 프로토타입 Prototype - C++ (0) | 2022.04.28 |
[디자인패턴][생성패턴] 팩터리 Factory - C++ (0) | 2022.03.12 |
[디자인패턴][생성패턴] 빌더 Builder - C++ (0) | 2022.03.09 |