8편에서 만든 메뉴 시스템은 항목을 선택하면 command()가 불리는 데서 끝났다. 실제 프로그램에서는 "Save를 선택하면 문서를 저장한다"처럼 메뉴 항목과 애플리케이션의 동작을 연결해야 한다. 그런데 메뉴는 라이브러리 쪽 코드이고 문서는 애플리케이션 쪽 코드라, 메뉴가 문서 클래스를 직접 알게 만들 수는 없다. 이 글에서는 메뉴 이벤트를 전달하는 방법을 파생 클래스, 리스너 인터페이스, std::function 핸들러 순으로 바꿔 보고, 핸들러 수명 문제와 되돌리기(undo)가 가능한 커맨드(Command) 객체까지 g++ 13.3으로 확인한다.
이 시리즈의 다른 글
- C++ 예제로 배우는 소프트웨어 설계 기법 (7) — 템플릿 인자로 전달하는 정책(Template Strategy)
- C++ 예제로 배우는 소프트웨어 설계 기법 (8) — 메뉴 시스템과 컴포지트(Menu System)
- C++ 예제로 배우는 소프트웨어 설계 기법 (10) — 컨테이너 설계(Container)
- C++ 예제로 배우는 소프트웨어 설계 기법 (11) — 반복자(Iterator)
메뉴마다 파생 클래스
#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 | 호출 시그니처 | 아무 함수나 연결 | 수신 객체 수명 관리 필요 |
| 커맨드 객체 | ICommand | undo, 이력, 매크로 | 동작마다 클래스 |
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| — | — | — |
| 람다가 객체를 raw 포인터·참조로 캡처 | 객체 소멸 후 호출 시 use-after-free | weak_ptr 캡처 또는 소멸 시 등록 해제 |
| 핸들러 안에서 같은 목록에 연결·해제 | 순회 중 컨테이너 변경 | 복사본을 순회하거나 지연 처리 |
shared_ptr를 그대로 캡처 | 메뉴가 객체 수명을 늘림, 순환 참조 | weak_ptr 캡처 |
| 핸들러 미설정 | 빈 std::function 호출 시 bad_function_call | 호출 전 검사 |
| undo가 필요한 동작을 람다로만 구현 | 되돌릴 정보가 없음 | 상태를 가진 커맨드 객체 |
마무리
- 메뉴가 애플리케이션 클래스를 알지 않도록 이벤트는 인터페이스나
std::function으로 전달한다. std::function핸들러는 가장 유연하지만 캡처한 객체의 수명을 따로 관리해야 한다.- 되돌리기·이력이 필요하면 실행할 일을 커맨드 객체로 만든다.