이전 글에서는 팩토리 메서드(Factory Method) 패턴을 모던 C++ 관점에서 재해석하며, 상속 없이도 람다와 Concepts, std::expected, coroutine, Ranges, std::format 등을 활용해 객체 생성 로직을 유연하게 교체할 수 있음을 확인했습니다. 이제는 생성(Creational) 패턴 중 프로토타입(Prototype) 패턴을 다룹니다.
프로토타입 패턴은 기존 객체를 "복제(Clone)"하는 방식으로 새로운 객체를 생성하는 패턴입니다. 전통적으로는 Prototype 인터페이스와 Clone 메서드를 정의하고, 각 구체 클래스에서 Clone을 오버라이드하는 상속 기반 구조를 사용했습니다. 그러나 이는 새로운 클래스 추가 시 코드 증가, 다양한 복제 로직 추가나 비동기 복제, 에러 처리, 로깅 등 기능 구현 시 복잡성을 초래합니다.
C++20 이상에서는 std::variant, std::visit, Concepts, 람다, std::expected, coroutine, Ranges, std::format 등을 활용해 상속 없이도 복제 로직을 값 기반으로 표현할 수 있습니다. 이를 통해 조건부 복제, 비동기 복제, 로깅, 에러 처리 등 다양한 요구사항에도 적은 코드로 대응 가능하며, 전통적 구현 대비 유지보수성과 확장성이 크게 개선됩니다.
패턴 소개: 프로토타입(Prototype)
의도:
- 기존 객체(프로토타입)를 복제하여 새로운 객체를 생성하는 패턴.
- 객체 생성 비용이 크거나, 객체 생성을 다양한 형태로 변형하고 싶을 때 유용.
- 예: 문서 템플릿을 프로토타입으로 두고 복제, 게임에서 복합 오브젝트 복제 등.
전통적 구현 문제점:
- Prototype 인터페이스 + Clone 메서드, 각 구체 클래스 오버라이드 필요
- 새로운 클래스 추가 시 Clone 구현 필요, 상속 기반 구조로 인한 클래스 증가
- 조건부 복제, 비동기 복제, 로깅, 에러 처리 구현 시 복잡성 증가
기존 C++ 스타일 구현 (전통적 방식)
예를 들어, Shape(도형) 클래스와 Circle, Square를 정의하고 Clone 메서드로 복제:
#include <iostream>
#include <memory>
#include <string>
struct Shape {
virtual ~Shape()=default;
virtual std::unique_ptr<Shape> clone()=0;
virtual void draw()=0;
};
struct Circle : Shape {
std::unique_ptr<Shape> clone() override {
return std::make_unique<Circle>(*this);
}
void draw() override { std::cout<<"Circle\n"; }
};
struct Square : Shape {
std::unique_ptr<Shape> clone() override {
return std::make_unique<Square>(*this);
}
void draw() override { std::cout<<"Square\n"; }
};
int main() {
auto c = std::make_unique<Circle>();
auto c2 = c->clone();
c2->draw(); // Circle
}
문제점:
- Shape 인터페이스, Circle, Square 클래스 필요
- 객체 추가 시 클래스 증가, 복제 로직 상속 구조로 인한 유지보수 어려움
- 비동기 복제나 조건부 복제 등 기능 추가 시 복잡성 증가
모던 C++20 이상 개선: std::variant, std::visit, Concepts
1. 값 기반 타입 정의: std::variant
struct Circle2 { int radius; };
struct Square2 { int side; };
using ShapeVariant = std::variant<Circle2, Square2>;
비교:
- 전통적: Shape 인터페이스 상속
- C++20: std::variant로 상속 없이 타입 표현
2. 복제 함수: std::visit와 if constexpr
복제 함수: (const ShapeVariant&) -> std::expected<ShapeVariant,std::string> 반환. 에러 발생 가능 가정.
#include <expected>
std::expected<ShapeVariant,std::string> cloneShape(const ShapeVariant& shape) {
return std::visit([](auto& s)->std::expected<ShapeVariant,std::string> {
using T = std::decay_t<decltype(s)>;
// 단순히 같은 값 복사로 복제 가능
return ShapeVariant(s);
}, shape);
}
사용 예
int main() {
ShapeVariant c = Circle2{10};
auto c2 = cloneShape(c);
if(!c2) std::cerr<<"Error:"<<c2.error()<<"\n";
else std::cout<<"Cloned circle\n";
ShapeVariant s = Square2{5};
auto s2 = cloneShape(s);
if(!s2) std::cerr<<"Error:"<<s2.error()<<"\n";
else std::cout<<"Cloned square\n";
}
출력:
Cloned circle
Cloned square
비교:
- 전통적: Clone 메서드 오버라이드 필요
- C++20: std::visit로 복제 로직 처리, 상속 없음
조건부 복제, 로깅, 비동기 처리, Ranges
조건부 복제(예: radius가 일정 이상이면 복제 불가):
auto conditionalClone = [&](auto baseClone) {
return [=](const ShapeVariant& shape)->std::expected<ShapeVariant,std::string> {
return std::visit([&](auto& s)->std::expected<ShapeVariant,std::string> {
using T = std::decay_t<decltype(s)>;
if constexpr(std::is_same_v<T,Circle2>) {
if(s.radius>100) return std::unexpected("Radius too large");
}
return baseClone(shape);
}, shape);
};
};
auto safeClone = conditionalClone(cloneShape);
로깅(std::format):
auto loggingClone = [&](auto baseClone) {
return [=](const ShapeVariant& shape)->std::expected<ShapeVariant,std::string> {
std::cout << "[LOG] Cloning shape\n";
auto res = baseClone(shape);
if(!res) std::cout << std::format("[LOG] Error: {}\n", res.error());
else std::cout << "[LOG] Cloned successfully.\n";
return res;
};
};
auto loggedClone = loggingClone(safeClone);
비동기 복제: coroutine으로 비동기 Clone 구현 가능.
Ranges: 여러 도형 리스트 파이프라인 처리:
#include <vector>
#include <ranges>
std::vector<ShapeVariant> shapes = {Circle2{10}, Square2{5}, Circle2{200}};
for (auto cres : shapes | std::views::transform(loggedClone)) {
if(!cres) std::cerr<<"One shape clone error:"<<cres.error()<<"\n";
}
비교:
- 전통적: 조건부 복제, 비동기, 로깅 추가 시 Clone 메서드 수정, 클래스 증가
- C++20: 람다 합성으로 기능 추가, coroutine으로 비동기 처리, Ranges로 파이프라인 처리, std::expected로 에러 처리 등 다양한 요구사항 쉽게 대응
전통적 구현 vs C++11/14/17 vs C++20 이상 비교
전통적(C++98/03):
- Prototype 인터페이스 + ConcretePrototype 상속 구조
- Clone 메서드 오버라이드 필요, 상태 변화 시 클래스 수정 증가
- 조건부 복제, 비동기 복제, 로깅, 에러 처리 등 요구사항 추가 시 복잡성 증가
C++11/14/17:
- 람다, std::function 일부 도움, 하지만 Concepts, coroutine, std::expected, Ranges 미지원
- 정적 타입 제약, 비동기/에러 처리 표현 한계
C++20 이상(모던 C++):
- std::variant로 객체 타입 표현, std::visit로 복제 로직 선언적 처리
- Concepts, std::expected, coroutine, Ranges, std::format 활용으로 상속 없이 다양한 요구사항 대응
- 클래스 증가 없이 확장 가능, 유지보수성과 확장성, 타입 안전성 대폭 향상
결론
프로토타입(Prototype) 패턴은 기존 객체를 복제해 새로운 객체를 생성하는 패턴이지만, 전통적 구현은 Prototype 인터페이스와 ConcretePrototype 상속을 통한 Clone 메서드 구현으로 인한 클래스 증가와 유지보수 어려움을 가져왔습니다.
C++20 이상에서는 std::variant, std::visit, Concepts, 람다, std::expected, coroutine, Ranges, std::format 등을 활용해 상속 없이 복제 로직을 값 기반으로 선언적이고 타입 안전하게 표현할 수 있습니다. 이를 통해 조건부 복제, 비동기 복제, 로깅, 에러 처리 등 다양한 요구사항에도 쉽게 대응 가능하며, 전통적 구현 대비 유지보수성과 확장성이 크게 개선됩니다.