C++ 예제로 배우는 소프트웨어 설계 기법 (8) — 메뉴 시스템과 컴포지트(Menu System)

메뉴는 실행할 수 있는 항목(Open, Save)과 다른 메뉴를 담는 팝업 메뉴(File, Recent)로 이루어지고, 팝업 안에 팝업이 다시 들어가는 트리 구조다. 두 종류를 따로 다루면 "이 항목이 팝업인가?"를 묻는 분기가 곳곳에 생기고, 팝업 안에 팝업을 넣을 수도 없다. 컴포지트(Composite) 패턴은 개별 객체와 객체를 담는 그릇을 같은 기반 클래스로 묶어 똑같이 다루게 한다. 이 글에서는 콘솔 메뉴 시스템을 컴포지트로 만들고, 재귀 연산과 add()를 어디에 둘지(투명성 vs 안전성)를 g++ 13.3으로 확인한다.

이 시리즈의 다른 글

항목만 담는 팝업 메뉴

#include <string>
#include <vector>

struct MenuItem {
    std::string title;
    void command() {}
};

struct PopupMenu {
    std::string title;
    std::vector<MenuItem*> items;                 // MenuItem만 담을 수 있다
    void add(MenuItem* m) { items.push_back(m); }
};

int main()
{
    PopupMenu root{"root"}, file{"File"};
    MenuItem open{"Open"};
    file.add(&open);
    root.add(&file);                              // 하위 메뉴를 넣고 싶다
}
$ g++ -std=c++20 -c naive.cpp 2>&1 | grep -E "error|note: +no known" | head -2
naive.cpp:20:14: error: cannot convert ‘PopupMenu*’ to ‘MenuItem*’

PopupMenu가 MenuItem*만 담으므로 하위 메뉴를 넣을 수 없다.

공통 기반 클래스로 묶기

#pragma once
#include <cstdio>
#include <iostream>
#include <memory>
#include <string>
#include <vector>

class BaseMenu {                                  // 메뉴 항목과 팝업 메뉴의 공통 기반
    std::string title_;
public:
    explicit BaseMenu(std::string t) : title_(std::move(t)) {}
    virtual ~BaseMenu() = default;
    const std::string& title() const { return title_; }
    virtual void command() = 0;                   // 선택됐을 때 할 일
};

class MenuItem : public BaseMenu {
    int id_;
public:
    MenuItem(std::string t, int id) : BaseMenu(std::move(t)), id_(id) {}
    void command() override { std::printf("  >> run '%s' (id=%d)\n", title().c_str(), id_); }
};

class PopupMenu : public BaseMenu {
    std::vector<std::unique_ptr<BaseMenu>> items_;    // 항목도, 하위 팝업도 담는다
public:
    using BaseMenu::BaseMenu;
    PopupMenu* add(std::unique_ptr<BaseMenu> m) { items_.push_back(std::move(m)); return this; }
    std::size_t size() const { return items_.size(); }
    BaseMenu* at(std::size_t i) const { return items_[i].get(); }

    void command() override                       // 팝업이 선택되면 목록을 보여주고 다시 선택받는다
    {
        for (;;) {
            std::printf("  [%s]", title().c_str());
            for (std::size_t i = 0; i < items_.size(); ++i) std::printf(" %zu.%s", i + 1, items_[i]->title().c_str());
            std::printf(" 0.back\n");
            std::size_t sel;
            if (!(std::cin >> sel) || sel == 0) return;
            if (sel <= items_.size()) items_[sel - 1]->command();   // 항목인지 팝업인지 묻지 않는다
        }
    }
};
#include "menu.h"

int main()
{
    auto file = std::make_unique<PopupMenu>("File");
    file->add(std::make_unique<MenuItem>("Open", 11))
        ->add(std::make_unique<MenuItem>("Save", 12));

    auto recent = std::make_unique<PopupMenu>("Recent");
    recent->add(std::make_unique<MenuItem>("a.txt", 21))
          ->add(std::make_unique<MenuItem>("b.txt", 22));
    file->add(std::move(recent));                 // 팝업 안에 팝업

    PopupMenu root("Main");
    root.add(std::move(file))
        ->add(std::make_unique<MenuItem>("Exit", 99));
    root.command();
}
$ g++ -std=c++20 -Wall -o cp composite.cpp
$ printf "1\n3\n2\n0\n1\n0\n2\n0\n" | ./cp
  [Main] 1.File 2.Exit 0.back
  [File] 1.Open 2.Save 3.Recent 0.back
  [Recent] 1.a.txt 2.b.txt 0.back
  >> run 'b.txt' (id=22)
  [Recent] 1.a.txt 2.b.txt 0.back
  [File] 1.Open 2.Save 3.Recent 0.back
  >> run 'Open' (id=11)
  [File] 1.Open 2.Save 3.Recent 0.back
  [Main] 1.File 2.Exit 0.back
  >> run 'Exit' (id=99)
  [Main] 1.File 2.Exit 0.back

입력은 1 3 2 0 1 0 2 0(File → Recent → b.txt → 뒤로 → Open → 뒤로 → Exit → 종료)이다. PopupMenu::command()는 선택된 것이 항목인지 팝업인지 묻지 않고 command()만 부르고, 팝업이면 다시 자기 목록을 보여준다.

트리 전체에 대한 재귀 연산

#include <cstdio>
#include <memory>
#include <string>
#include <vector>

struct BaseMenu {
    std::string title;
    explicit BaseMenu(std::string t) : title(std::move(t)) {}
    virtual ~BaseMenu() = default;
    virtual void print(int depth) const = 0;
    virtual int count_items() const = 0;          // 실행 가능한 항목 수
    virtual const BaseMenu* find(const std::string& t) const { return t == title ? this : nullptr; }
};

struct MenuItem : BaseMenu {
    using BaseMenu::BaseMenu;
    void print(int d) const override { std::printf("%*s- %s\n", d * 2 + 2, "", title.c_str()); }
    int count_items() const override { return 1; }
};

struct PopupMenu : BaseMenu {
    std::vector<std::unique_ptr<BaseMenu>> items;
    using BaseMenu::BaseMenu;
    template <typename... M> PopupMenu(std::string t, M... m) : BaseMenu(std::move(t))
    {
        (items.push_back(std::unique_ptr<BaseMenu>(m)), ...);
    }
    void print(int d) const override
    {
        std::printf("%*s+ %s\n", d * 2 + 2, "", title.c_str());
        for (auto& m : items) m->print(d + 1);   // 자식에게 같은 요청을 위임
    }
    int count_items() const override
    {
        int n = 0;
        for (auto& m : items) n += m->count_items();
        return n;
    }
    const BaseMenu* find(const std::string& t) const override
    {
        if (t == title) return this;
        for (auto& m : items) if (auto r = m->find(t)) return r;
        return nullptr;
    }
};

int main()
{
    PopupMenu root("Main",
        new PopupMenu("File", new MenuItem("Open"), new MenuItem("Save"),
                      new PopupMenu("Recent", new MenuItem("a.txt"), new MenuItem("b.txt"))),
        new PopupMenu("Edit", new MenuItem("Copy"), new MenuItem("Paste")),
        new MenuItem("Exit"));
    root.print(0);
    std::printf("  items=%d\n", root.count_items());
    std::printf("  find(b.txt)=%s, find(Redo)=%s\n",
                root.find("b.txt") ? "found" : "-", root.find("Redo") ? "found" : "-");
}
$ g++ -std=c++20 -Wall -o rc recursive.cpp
$ ./rc
  + Main
    + File
      - Open
      - Save
      + Recent
        - a.txt
        - b.txt
    + Edit
      - Copy
      - Paste
    - Exit
  items=7
  find(b.txt)=found, find(Redo)=-

출력, 항목 수 세기, 이름으로 찾기 모두 팝업은 자식에게 같은 요청을 위임하고 항목은 자기 몫만 처리한다. 트리 깊이가 늘어도 호출하는 쪽 코드는 root.xxx() 한 줄이다.

add()는 어디에 둘까

#include <cstdio>
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>

// 투명성 우선: add()를 기반 클래스에 둔다
struct BaseMenu {
    std::string title;
    explicit BaseMenu(std::string t) : title(std::move(t)) {}
    virtual ~BaseMenu() = default;
    virtual void add(std::unique_ptr<BaseMenu>) { throw std::logic_error("cannot add to '" + title + "'"); }
    virtual BaseMenu* submenu(std::size_t) { return nullptr; }
};

struct MenuItem : BaseMenu { using BaseMenu::BaseMenu; };

struct PopupMenu : BaseMenu {
    std::vector<std::unique_ptr<BaseMenu>> items;
    using BaseMenu::BaseMenu;
    void add(std::unique_ptr<BaseMenu> m) override { items.push_back(std::move(m)); }
    BaseMenu* submenu(std::size_t i) override { return i < items.size() ? items[i].get() : nullptr; }
};

int main()
{
    PopupMenu root("Main");
    root.add(std::make_unique<PopupMenu>("File"));
    root.add(std::make_unique<MenuItem>("Exit"));

    root.submenu(0)->add(std::make_unique<MenuItem>("Open"));   // 캐스팅 없이 추가
    std::printf("  File has an item now\n");
    try {
        root.submenu(1)->add(std::make_unique<MenuItem>("Oops"));   // 항목에 추가: 실행 중 에러
    } catch (const std::exception& e) {
        std::printf("  error: %s\n", e.what());
    }
}
$ g++ -std=c++20 -Wall -o tr transparency.cpp
$ ./tr
  File has an item now
  error: cannot add to 'Exit'

add()를 기반 클래스에 두면 캐스팅 없이 어떤 메뉴에든 추가할 수 있지만(투명성), 항목에 추가하는 실수는 실행 중에야 드러난다.

#include <memory>
#include <string>
#include <vector>

// 안전성 우선: add()는 PopupMenu에만 있다
struct BaseMenu { std::string title; virtual ~BaseMenu() = default; };
struct MenuItem : BaseMenu {};
struct PopupMenu : BaseMenu {
    std::vector<std::unique_ptr<BaseMenu>> items;
    void add(std::unique_ptr<BaseMenu> m) { items.push_back(std::move(m)); }
};

int main()
{
    PopupMenu root;
    root.add(std::make_unique<PopupMenu>());
    BaseMenu* first = root.items[0].get();
    first->add(std::make_unique<MenuItem>());      // 컴파일 에러: 기반 타입에는 add가 없다
    if (auto* p = dynamic_cast<PopupMenu*>(first)) p->add(std::make_unique<MenuItem>());   // 대신 캐스팅 필요
}
$ g++ -std=c++20 -c safety.cpp 2>&1 | grep error
safety.cpp:18:12: error: ‘struct BaseMenu’ has no member named ‘add’

PopupMenu에만 두면 실수가 컴파일 에러가 되는 대신(안전성), 기반 포인터로 받은 메뉴에 추가하려면 dynamic_cast가 필요하다.

구성 요소메뉴 시스템역할
———
ComponentBaseMenu공통 인터페이스 (title, command)
LeafMenuItem실제 동작 수행
CompositePopupMenu자식 보관, 요청을 자식에게 위임
add() 위치장점단점
———
기반 클래스 (투명성)모든 노드를 같은 타입으로 조작항목에 add 시 실행 중 에러
Composite에만 (안전성)잘못된 호출이 컴파일 에러기반 포인터에서 캐스팅 필요

주의사항

상황문제대응
———
자식을 raw 포인터로 보관소유권 불명확, 누수·이중 해제std::unique_ptr로 부모가 소유
팝업이 자기 조상을 자식으로 추가순환으로 재귀 연산이 끝나지 않음add()에서 조상 검사
자식에게 부모 포인터 저장부모 이동 시 dangling부모를 이동 불가로 하거나 이동 시 갱신
깊은 트리에서 재귀 연산스택 사용 증가명시적 스택으로 순회
Leaf에 맞지 않는 연산을 기반에 추가의미 없는 기본 구현 증가공통 연산만 기반에 두고 나머지는 Visitor 등으로

마무리

  • 항목과 그릇을 같은 기반 클래스로 묶으면 클라이언트는 둘을 구분하지 않고 다룬다.
  • 그릇은 요청을 자식에게 위임하므로 트리 전체 연산이 재귀 한 번으로 끝난다.
  • add()를 기반에 둘지 그릇에만 둘지는 투명성과 안전성 사이의 선택이다.

참고

답글 남기기