사각형, 원, 삼각형을 그리고 복사·붙여넣기하고 파일에서 다시 읽어 들이는 도형 편집기는 객체지향 설계를 연습하는 고전적인 예제다. 도형 종류를 값으로 구분해 switch로 처리하면 새 도형을 추가할 때마다 흩어진 switch를 모두 찾아 고쳐야 한다. 이 글에서는 도형 편집기를 타입 코드 방식에서 출발해 가상 함수, 템플릿 메서드, 프로토타입(Prototype)의 clone(), 자기 등록 팩토리(Factory)까지 단계별로 바꿔가며 g++ 13.3으로 확인한다.
이 시리즈의 다른 글
- C++ 예제로 배우는 소프트웨어 설계 기법 (3) — 윈도우 클래스와 메시지 처리(Window)
- C++ 예제로 배우는 소프트웨어 설계 기법 (4) — 싱글톤(Singleton)
- C++ 예제로 배우는 소프트웨어 설계 기법 (6) — 입력 검증으로 보는 템플릿 메서드와 전략(LineEdit)
- C++ 예제로 배우는 소프트웨어 설계 기법 (7) — 템플릿 인자로 전달하는 정책(Template Strategy)
타입 코드와 switch
#include <cstdio>
#include <vector>
enum Kind { RECT, CIRCLE }; // 도형 종류를 값으로 구분
struct Shape {
Kind kind;
double a, b; // RECT: w,h CIRCLE: r,(unused)
};
void draw(const Shape& s)
{
switch (s.kind) { // 도형이 늘면 이 switch를 고친다
case RECT: std::printf(" rect %gx%g\n", s.a, s.b); break;
case CIRCLE: std::printf(" circle r=%g\n", s.a); break;
}
}
double area(const Shape& s)
{
switch (s.kind) { // ...그리고 이 switch도
case RECT: return s.a * s.b;
case CIRCLE: return 3.14159 * s.a * s.a;
}
return 0;
}
int main()
{
std::vector<Shape> v{{RECT, 2, 3}, {CIRCLE, 1, 0}};
for (auto& s : v) { draw(s); std::printf(" area=%.2f\n", area(s)); }
}$ g++ -std=c++20 -Wall -o tc typecode.cpp
$ ./tc
rect 2x3
area=6.00
circle r=1
area=3.14
# TRIANGLE을 enum에만 추가하고 switch는 고치지 않았을 때
sed 's/enum Kind { RECT, CIRCLE };/enum Kind { RECT, CIRCLE, TRIANGLE };/' typecode.cpp > typecode2.cpp
g++ -std=c++20 -Wall -c typecode2.cpp 2>&1 | grep warning$ bash add_kind.sh
typecode2.cpp:13:12: warning: enumeration value ‘TRIANGLE’ not handled in switch [-Wswitch]
typecode2.cpp:21:12: warning: enumeration value ‘TRIANGLE’ not handled in switch [-Wswitch]
enum에 TRIANGLE만 추가하면 -Wall이 고칠 곳을 알려주지만, 경고일 뿐이라 빌드는 된다. switch가 다른 파일에 흩어져 있거나 default:가 있으면 경고조차 나오지 않는다.
가상 함수로 바꾸기
#include <cstdio>
#include <memory>
#include <vector>
struct Shape {
virtual ~Shape() = default;
virtual void draw() const = 0;
virtual double area() const = 0;
};
struct Rect : Shape {
double w, h;
Rect(double w, double h) : w(w), h(h) {}
void draw() const override { std::printf(" rect %gx%g\n", w, h); }
double area() const override { return w * h; }
};
struct Circle : Shape {
double r;
explicit Circle(double r) : r(r) {}
void draw() const override { std::printf(" circle r=%g\n", r); }
double area() const override { return 3.14159 * r * r; }
};
// 새 도형: 기존 코드는 한 줄도 고치지 않는다
struct Triangle : Shape {
double b, h;
Triangle(double b, double h) : b(b), h(h) {}
void draw() const override { std::printf(" triangle b=%g h=%g\n", b, h); }
double area() const override { return b * h / 2; }
};
double total_area(const std::vector<std::unique_ptr<Shape>>& v)
{
double sum = 0;
for (auto& s : v) sum += s->area();
return sum;
}
int main()
{
std::vector<std::unique_ptr<Shape>> v;
v.push_back(std::make_unique<Rect>(2, 3));
v.push_back(std::make_unique<Circle>(1));
v.push_back(std::make_unique<Triangle>(4, 5));
for (auto& s : v) s->draw();
std::printf(" total area=%.2f\n", total_area(v));
}$ g++ -std=c++20 -Wall -o po poly.cpp
$ ./po
rect 2x3
circle r=1
triangle b=4 h=5
total area=19.14
Triangle을 추가해도 total_area 등 기존 코드는 그대로다. 수정에는 닫혀 있고 확장에는 열린 개방-폐쇄 원칙(OCP)이다.
공통 절차는 기반 클래스에
#include <cstdio>
#include <memory>
#include <vector>
class Shape {
bool selected_ = false;
public:
virtual ~Shape() = default;
void select(bool s) { selected_ = s; }
void draw() const // 공통 절차는 고정 (비가상)
{
if (selected_) std::printf(" [");
else std::printf(" ");
draw_shape(); // 도형마다 다른 부분만 가상 호출
std::printf("%s\n", selected_ ? "] <- handles" : "");
}
protected:
virtual void draw_shape() const = 0;
};
struct Rect : Shape { void draw_shape() const override { std::printf("rect"); } };
struct Circle : Shape { void draw_shape() const override { std::printf("circle"); } };
int main()
{
std::vector<std::unique_ptr<Shape>> v;
v.push_back(std::make_unique<Rect>());
v.push_back(std::make_unique<Circle>());
v[1]->select(true);
for (auto& s : v) s->draw();
}$ g++ -std=c++20 -Wall -o tm template_method.cpp
$ ./tm
rect
[circle] <- handles
선택 표시처럼 모든 도형에 똑같은 절차는 비가상 draw()에 두고, 도형마다 다른 부분만 draw_shape()로 열어둔다. 1편 Thread의 start()/run()과 같은 템플릿 메서드 구조다.
복사-붙여넣기와 clone
struct Shape { virtual ~Shape() = default; virtual void draw() const = 0; };
struct Circle : Shape { double r = 1; void draw() const override {} };
Shape* copy_of(const Shape& s)
{
return new Shape(s); // 실제 타입(Circle)을 모르니 복사할 수 없다
}$ g++ -std=c++20 -c clone_err.cpp 2>&1 | grep error
clone_err.cpp:6:23: error: invalid new-expression of abstract class type ‘Shape’
선택된 도형을 Shape&로만 알면 복사 생성자를 부를 타입을 정할 수 없다.
#include <cstdio>
#include <memory>
#include <vector>
struct Shape {
int color = 0;
virtual ~Shape() = default;
virtual void draw() const = 0;
virtual std::unique_ptr<Shape> clone() const = 0; // 자기 자신의 복사본을 만든다
};
struct Rect : Shape {
double w, h;
Rect(double w, double h) : w(w), h(h) {}
void draw() const override { std::printf(" rect %gx%g color=%d\n", w, h, color); }
std::unique_ptr<Shape> clone() const override { return std::make_unique<Rect>(*this); }
};
struct Circle : Shape {
double r;
explicit Circle(double r) : r(r) {}
void draw() const override { std::printf(" circle r=%g color=%d\n", r, color); }
std::unique_ptr<Shape> clone() const override { return std::make_unique<Circle>(*this); }
};
int main()
{
std::vector<std::unique_ptr<Shape>> doc;
doc.push_back(std::make_unique<Rect>(2, 3));
doc.push_back(std::make_unique<Circle>(5));
doc[1]->color = 7;
// "복사-붙여넣기": 선택된 도형의 실제 타입을 몰라도 복사된다
const Shape& selected = *doc[1];
doc.push_back(selected.clone());
for (auto& s : doc) s->draw();
}$ g++ -std=c++20 -Wall -o cl clone.cpp
$ ./cl
rect 2x3 color=0
circle r=5 color=7
circle r=5 color=7
실제 타입을 아는 것은 객체 자신이므로 복사도 가상 함수 clone()에 맡긴다. 이렇게 기존 객체를 복제해 새 객체를 만드는 방식이 프로토타입 패턴이다.
파일에서 읽을 때: 자기 등록 팩토리
저장된 문서에는 "rect" 같은 이름만 있으므로, 이름을 보고 객체를 만들 곳이 필요하다.
#pragma once
#include <cstdio>
#include <functional>
#include <map>
#include <memory>
#include <string>
struct Shape {
virtual ~Shape() = default;
virtual void draw() const = 0;
};
class ShapeFactory { // 이름 -> 생성 함수
using Creator = std::function<std::unique_ptr<Shape>()>;
static std::map<std::string, Creator>& table() { static std::map<std::string, Creator> t; return t; }
public:
static bool add(const std::string& name, Creator c) { table()[name] = std::move(c); return true; }
static std::unique_ptr<Shape> create(const std::string& name)
{
auto it = table().find(name);
return it == table().end() ? nullptr : it->second();
}
static void list() { for (auto& [n, _] : table()) std::printf(" registered: %s\n", n.c_str()); }
};
// 도형 파일 안에서 자기 자신을 등록하는 매크로
#define REGISTER_SHAPE(name, T) \
static const bool reg_##T = ShapeFactory::add(name, [] { return std::make_unique<T>(); })#include "shape.h"
struct Rect : Shape { void draw() const override { std::puts(" draw rect"); } };
REGISTER_SHAPE("rect", Rect);#include "shape.h"
struct Circle : Shape { void draw() const override { std::puts(" draw circle"); } };
REGISTER_SHAPE("circle", Circle);#include "shape.h"
struct Star : Shape { void draw() const override { std::puts(" draw star"); } };
REGISTER_SHAPE("star", Star);#include <sstream>
#include <vector>
#include "shape.h"
int main()
{
ShapeFactory::list();
std::istringstream file("rect circle star rect hexagon"); // 저장된 문서라고 가정
std::vector<std::unique_ptr<Shape>> doc;
for (std::string name; file >> name; )
if (auto s = ShapeFactory::create(name)) doc.push_back(std::move(s));
else std::printf(" unknown shape: %s\n", name.c_str());
for (auto& s : doc) s->draw();
}$ g++ -std=c++20 -Wall -o ed editor.cpp rect.cpp circle.cpp
$ ./ed
registered: circle
registered: rect
unknown shape: star
unknown shape: hexagon
draw rect
draw circle
draw rect
$ g++ -std=c++20 -Wall -o ed editor.cpp rect.cpp circle.cpp star.cpp
$ ./ed
registered: circle
registered: rect
registered: star
unknown shape: hexagon
draw rect
draw circle
draw star
draw rect
각 도형 파일이 static 변수 초기화로 자신을 등록하므로, star.cpp를 빌드에 넣기만 하면 editor.cpp를 고치지 않아도 star를 읽는다. 등록 테이블을 함수 내 static으로 둔 이유는 4편의 초기화 순서 문제다.
| 단계 | 새 도형 추가 시 고칠 곳 |
|---|---|
| — | — |
타입 코드 + switch | enum, 모든 switch |
| 가상 함수 | 새 클래스 + 생성하는 코드 |
| 가상 함수 + 자기 등록 팩토리 | 새 파일 하나 |
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| — | — | — |
clone() 재정의 누락 | 중간 클래스의 clone()이 불려 잘린 복사본 생성 | 리프 클래스마다 재정의, CRTP로 자동화 |
| 등록 테이블을 전역 객체로 선언 | 도형 파일의 등록이 테이블 생성보다 먼저 실행 | 함수 내 static 테이블 |
| 정적 라이브러리(.a)에 도형 파일 포함 | 참조되지 않은 오브젝트가 링크에서 빠져 등록 안 됨 | --whole-archive 또는 명시적 등록 함수 |
| 기반 클래스에 가상 소멸자 없음 | unique_ptr<Shape> 소멸 시 파생 소멸자 누락 | virtual ~Shape() = default |
| 공통 절차를 공개 가상 함수로 둠 | 파생 클래스가 절차를 빠뜨림 | 비가상 공개 함수 + 보호된 가상 함수 |
마무리
- 종류마다 달라지는 동작은
switch대신 가상 함수로 각 클래스에 둔다. - 타입을 모르는 객체의 복사는
clone()(프로토타입), 이름으로 생성하는 것은 팩토리에 맡긴다. - 팩토리에 자기 등록을 더하면 새 도형 추가가 파일 하나 추가로 끝난다.