C++ 예제로 배우는 소프트웨어 설계 기법 (5) — 도형 편집기(Shape)

사각형, 원, 삼각형을 그리고 복사·붙여넣기하고 파일에서 다시 읽어 들이는 도형 편집기는 객체지향 설계를 연습하는 고전적인 예제다. 도형 종류를 값으로 구분해 switch로 처리하면 새 도형을 추가할 때마다 흩어진 switch를 모두 찾아 고쳐야 한다. 이 글에서는 도형 편집기를 타입 코드 방식에서 출발해 가상 함수, 템플릿 메서드, 프로토타입(Prototype)의 clone(), 자기 등록 팩토리(Factory)까지 단계별로 바꿔가며 g++ 13.3으로 확인한다.

이 시리즈의 다른 글

타입 코드와 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편의 초기화 순서 문제다.

단계새 도형 추가 시 고칠 곳
——
타입 코드 + switchenum, 모든 switch
가상 함수새 클래스 + 생성하는 코드
가상 함수 + 자기 등록 팩토리새 파일 하나

주의사항

상황문제대응
———
clone() 재정의 누락중간 클래스의 clone()이 불려 잘린 복사본 생성리프 클래스마다 재정의, CRTP로 자동화
등록 테이블을 전역 객체로 선언도형 파일의 등록이 테이블 생성보다 먼저 실행함수 내 static 테이블
정적 라이브러리(.a)에 도형 파일 포함참조되지 않은 오브젝트가 링크에서 빠져 등록 안 됨--whole-archive 또는 명시적 등록 함수
기반 클래스에 가상 소멸자 없음unique_ptr<Shape> 소멸 시 파생 소멸자 누락virtual ~Shape() = default
공통 절차를 공개 가상 함수로 둠파생 클래스가 절차를 빠뜨림비가상 공개 함수 + 보호된 가상 함수

마무리

  • 종류마다 달라지는 동작은 switch 대신 가상 함수로 각 클래스에 둔다.
  • 타입을 모르는 객체의 복사는 clone()(프로토타입), 이름으로 생성하는 것은 팩토리에 맡긴다.
  • 팩토리에 자기 등록을 더하면 새 도형 추가가 파일 하나 추가로 끝난다.

참고

답글 남기기