C++ 예제로 배우는 소프트웨어 설계 기법 (9) — 메뉴 이벤트 처리(Menu Event)

8편에서 만든 메뉴 시스템은 항목을 선택하면 command()가 불리는 데서 끝났다. 실제 프로그램에서는 "Save를 선택하면 문서를 저장한다"처럼 메뉴 항목과 애플리케이션의 동작을 연결해야 한다. 그런데 메뉴는 라이브러리 쪽 코드이고 문서는 애플리케이션 쪽 코드라, 메뉴가 문서 클래스를 직접 알게 만들 수는 없다. 이 글에서는 메뉴 이벤트를 전달하는 방법을 파생 클래스, 리스너 인터페이스, std::function 핸들러 순으로 바꿔 보고, 핸들러 수명 문제와 되돌리기(undo)가 가능한 커맨드(Command) 객체까지 g++ 13.3으로 확인한다.

이 시리즈의 다른 글

메뉴마다 파생 클래스

#include <cstdio>
#include <string>

struct MenuItem {
    std::string title;
    virtual ~MenuItem() = default;
    virtual void command() = 0;
};

struct Document { void save() { std::puts("  doc saved"); } void open() { std::puts("  doc opened"); } };

// 메뉴마다 파생 클래스가 하나씩 생긴다
struct SaveItem : MenuItem {
    Document& doc;
    explicit SaveItem(Document& d) : doc(d) { title = "Save"; }
    void command() override { doc.save(); }
};
struct OpenItem : MenuItem {
    Document& doc;
    explicit OpenItem(Document& d) : doc(d) { title = "Open"; }
    void command() override { doc.open(); }
};

int main()
{
    Document doc;
    SaveItem s(doc);
    OpenItem o(doc);
    MenuItem* menu[] = {&o, &s};
    for (auto* m : menu) m->command();
}
$ g++ -std=c++20 -Wall -o sc subclass.cpp
$ ./sc
  doc opened
  doc saved

동작하지만 메뉴 항목 수만큼 클래스가 생기고, 모두 doc 참조를 들고 함수 하나를 부를 뿐이다.

리스너 인터페이스

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

struct IMenuListener {                            // 메뉴 이벤트를 받을 쪽이 구현
    virtual ~IMenuListener() = default;
    virtual void on_command(int id) = 0;
};

class MenuItem {
    std::string title_;
    int id_;
    std::vector<IMenuListener*> listeners_;       // 여러 리스너 등록 가능
public:
    MenuItem(std::string t, int id) : title_(std::move(t)), id_(id) {}
    void add_listener(IMenuListener* l) { listeners_.push_back(l); }
    void command() { for (auto* l : listeners_) l->on_command(id_); }   // 누가 듣는지 모른다
};

enum { ID_OPEN = 1, ID_SAVE };

struct App : IMenuListener {
    void on_command(int id) override
    {
        switch (id) {                             // 결국 id로 분기
        case ID_OPEN: std::puts("  app: open"); break;
        case ID_SAVE: std::puts("  app: save"); break;
        }
    }
};
struct StatusBar : IMenuListener {
    void on_command(int id) override { std::printf("  status: command %d\n", id); }
};

int main()
{
    App app;
    StatusBar bar;
    MenuItem open("Open", ID_OPEN), save("Save", ID_SAVE);
    open.add_listener(&app);
    save.add_listener(&app);
    save.add_listener(&bar);
    open.command();
    save.command();
}
$ g++ -std=c++20 -Wall -o ls listener.cpp
$ ./ls
  app: open
  app: save
  status: command 2

메뉴는 IMenuListener만 알고 누가 듣는지는 모른다. 한 이벤트를 여러 객체가 받을 수 있지만(관찰자 패턴), 리스너 안에서 결국 id로 switch를 하게 된다.

std::function 핸들러

#include <cstdio>
#include <functional>
#include <string>
#include <vector>

class MenuItem {
    std::string title_;
    std::function<void()> handler_;              // 호출할 수 있는 무엇이든
public:
    explicit MenuItem(std::string t) : title_(std::move(t)) {}
    void set_handler(std::function<void()> h) { handler_ = std::move(h); }
    void command() { if (handler_) handler_(); else std::printf("  '%s' has no handler\n", title_.c_str()); }
};

struct Document {
    std::string name = "a.txt";
    void save() { std::printf("  saved %s\n", name.c_str()); }
    void save_as(const std::string& n) { name = n; save(); }
};

void quit() { std::puts("  bye"); }

int main()
{
    Document doc;
    MenuItem save("Save"), save_as("Save As"), exit_("Exit"), help("Help");
    save.set_handler(std::bind(&Document::save, &doc));            // 멤버 함수 + 객체
    save_as.set_handler([&doc] { doc.save_as("b.txt"); });          // 인자 고정은 람다로
    exit_.set_handler(quit);                                        // 일반 함수
    for (MenuItem* m : {&save, &save_as, &exit_, &help}) m->command();
}
$ g++ -std=c++20 -Wall -o hd handler.cpp
$ ./hd
  saved a.txt
  saved b.txt
  bye
  'Help' has no handler

호출 가능한 것이면 무엇이든 받으므로 멤버 함수, 람다, 일반 함수를 메뉴 항목마다 바로 연결한다. 인터페이스 상속도 id 분기도 필요 없다.

핸들러가 가리키는 객체가 먼저 사라지면

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

class MenuItem {
    std::vector<std::function<void()>> handlers_;
public:
    void connect(std::function<void()> h) { handlers_.push_back(std::move(h)); }
    void command() { for (auto& h : handlers_) h(); }
};

struct Logger {
    std::string prefix = "[log] ";
    void log() { std::fprintf(stderr, "  %smenu selected\n", prefix.c_str()); }
};

int main()
{
    MenuItem save;
    {
        auto lg = std::make_unique<Logger>();
        save.connect([p = lg.get()] { p->log(); });   // raw 포인터 캡처
        save.command();
    }                                                  // Logger 소멸, 등록은 남아 있다
    save.command();
}
$ g++ -std=c++20 -g -fsanitize=address -o dg dangling.cpp
$ ./dg 2>&1 | grep -E "menu selected|ERROR|#[0-9] .*dangling.cpp" | head -4 | sed -E "s#/tmp/[^ ]*/##; s# at pc .*##; s#0x[0-9a-f]+ in ##"
  [log] menu selected
==4659==ERROR: AddressSanitizer: heap-use-after-free on address 0x503000000040
    #2 Logger::log() dangling.cpp:16
    #3 operator() dangling.cpp:24

람다가 raw 포인터를 캡처했기 때문에 Logger가 소멸한 뒤에도 핸들러가 남아 해제된 메모리를 읽는다. 2편 Clock의 등록 해제 누락과 같은 문제다.

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

class MenuItem {
    std::vector<std::function<bool()>> handlers_;   // false를 반환하면 등록 해제
public:
    template <typename T>
    void connect(std::shared_ptr<T> obj, void (T::*fn)())
    {
        std::weak_ptr<T> w = obj;                      // 수명을 늘리지 않고 관찰만
        handlers_.push_back([w, fn] {
            if (auto p = w.lock()) { ((*p).*fn)(); return true; }
            return false;
        });
    }
    void command()
    {
        std::erase_if(handlers_, [](auto& h) { return !h(); });
        std::printf("  (%zu handlers left)\n", handlers_.size());
    }
};

struct Logger { void log() { std::puts("  logger: selected"); } };
struct Stats  { int n = 0; void count() { std::printf("  stats: %d\n", ++n); } };

int main()
{
    MenuItem save;
    auto stats = std::make_shared<Stats>();
    save.connect(stats, &Stats::count);
    {
        auto lg = std::make_shared<Logger>();
        save.connect(lg, &Logger::log);
        save.command();
    }
    save.command();                                    // Logger는 자동으로 빠진다
}
$ g++ -std=c++20 -Wall -o wk weak.cpp
$ ./wk
  stats: 1
  logger: selected
  (2 handlers left)
  stats: 2
  (1 handlers left)

weak_ptr로 관찰만 하면 객체가 사라진 핸들러를 호출 시점에 감지해 목록에서 빼낸다.

되돌리기가 필요하면 커맨드 객체

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

struct ICommand {                                  // 실행할 일을 객체로: 되돌리기도 함께
    virtual ~ICommand() = default;
    virtual void execute() = 0;
    virtual void undo() = 0;
};

struct Editor { std::string text; };

class InsertCommand : public ICommand {
    Editor& ed_;
    std::string s_;
public:
    InsertCommand(Editor& e, std::string s) : ed_(e), s_(std::move(s)) {}
    void execute() override { ed_.text += s_; }
    void undo() override { ed_.text.erase(ed_.text.size() - s_.size()); }
};

class CommandManager {
    std::vector<std::unique_ptr<ICommand>> history_;
public:
    void run(std::unique_ptr<ICommand> c) { c->execute(); history_.push_back(std::move(c)); }
    void undo()
    {
        if (history_.empty()) { std::puts("  nothing to undo"); return; }
        history_.back()->undo();
        history_.pop_back();
    }
};

struct MenuItem {
    std::string title;
    std::function<void()> handler;
    void command() { handler(); }
};

int main()
{
    Editor ed;
    CommandManager cm;
    MenuItem hello{"Insert Hello", [&] { cm.run(std::make_unique<InsertCommand>(ed, "Hello")); }};
    MenuItem world{"Insert World", [&] { cm.run(std::make_unique<InsertCommand>(ed, " World")); }};
    MenuItem undo{"Undo", [&] { cm.undo(); }};

    for (MenuItem* m : {&hello, &world, &undo, &undo, &undo}) {
        m->command();
        std::printf("  %-13s -> \"%s\"\n", m->title.c_str(), ed.text.c_str());
    }
}
$ g++ -std=c++20 -Wall -o cu command_undo.cpp
$ ./cu
  Insert Hello  -> "Hello"
  Insert World  -> "Hello World"
  Undo          -> "Hello"
  Undo          -> ""
  nothing to undo
  Undo          -> ""

실행할 일을 함수가 아니라 execute()/undo()를 가진 객체로 만들어 이력에 쌓았다. 함수는 실행만 할 수 있지만, 객체는 상태를 들고 있어 되돌리기·재실행·매크로 기록이 가능하다.

방식메뉴가 아는 것장점단점
————
파생 클래스자기 자신단순항목마다 클래스
리스너 인터페이스IMenuListener여러 수신자수신자 안에서 id 분기
std::function호출 시그니처아무 함수나 연결수신 객체 수명 관리 필요
커맨드 객체ICommandundo, 이력, 매크로동작마다 클래스

주의사항

상황문제대응
———
람다가 객체를 raw 포인터·참조로 캡처객체 소멸 후 호출 시 use-after-freeweak_ptr 캡처 또는 소멸 시 등록 해제
핸들러 안에서 같은 목록에 연결·해제순회 중 컨테이너 변경복사본을 순회하거나 지연 처리
shared_ptr를 그대로 캡처메뉴가 객체 수명을 늘림, 순환 참조weak_ptr 캡처
핸들러 미설정빈 std::function 호출 시 bad_function_call호출 전 검사
undo가 필요한 동작을 람다로만 구현되돌릴 정보가 없음상태를 가진 커맨드 객체

마무리

  • 메뉴가 애플리케이션 클래스를 알지 않도록 이벤트는 인터페이스나 std::function으로 전달한다.
  • std::function 핸들러는 가장 유연하지만 캡처한 객체의 수명을 따로 관리해야 한다.
  • 되돌리기·이력이 필요하면 실행할 일을 커맨드 객체로 만든다.

참고

답글 남기기