C++ 예제로 배우는 소프트웨어 설계 기법 (3) — 윈도우 클래스와 메시지 처리(Window)

GUI 시스템의 C API는 윈도우마다 윈도우 프로시저(window procedure) 하나를 등록받아, 클릭·키 입력 같은 메시지를 핸들과 함께 넘겨준다. C로 쓰면 메시지마다 switch의 case가 늘어나고, 버튼·에디터처럼 비슷한 윈도우가 많아지면 같은 switch가 복사된다. 이 글에서는 2편의 매핑 테이블 기법으로 Window 클래스를 만들고, 가상 함수 핸들러, 부모 윈도우로 메시지 넘기기, MFC식 메시지 맵(message map)을 콘솔용 가짜 윈도우 API로 확인한다.

이 시리즈의 다른 글

C 스타일 윈도우 프로시저

GUI 없이 실행 결과를 보이려고 Win32 형태의 윈도우 API를 콘솔용으로 흉내 냈다.

// Win32 스타일 C 윈도우 API 흉내: 윈도우 프로시저는 핸들과 메시지만 받는다
#pragma once
#include <cstdio>
#include <vector>

enum { WM_CREATE = 1, WM_LBUTTONDOWN, WM_KEYDOWN, WM_PAINT, WM_CLOSE };
typedef int (*WNDPROC)(int hwnd, int msg, int param);   // 처리했으면 1

struct WndSlot { const char* title; WNDPROC proc; int parent; };
inline std::vector<WndSlot> g_wnds{{"desktop", nullptr, -1}};

inline int create_window(const char* title, WNDPROC proc, int parent = 0)
{
    g_wnds.push_back({title, proc, parent});
    int h = (int)g_wnds.size() - 1;
    proc(h, WM_CREATE, 0);
    return h;
}
inline int send_message(int hwnd, int msg, int param) { return g_wnds[hwnd].proc(hwnd, msg, param); }
inline int get_parent(int hwnd) { return g_wnds[hwnd].parent; }
inline const char* get_title(int hwnd) { return g_wnds[hwnd].title; }
#include "cwin.h"

int main_proc(int hwnd, int msg, int param)
{
    switch (msg) {                               // 메시지마다 case가 늘어난다
    case WM_CREATE:      std::printf("  %s: created\n", get_title(hwnd)); return 1;
    case WM_LBUTTONDOWN: std::printf("  %s: click x=%d\n", get_title(hwnd), param); return 1;
    case WM_KEYDOWN:     std::printf("  %s: key '%c'\n", get_title(hwnd), param); return 1;
    }
    return 0;
}

int main()
{
    int h = create_window("main", main_proc);
    send_message(h, WM_LBUTTONDOWN, 120);
    send_message(h, WM_KEYDOWN, 'q');
    send_message(h, WM_PAINT, 0);                // 처리 안 함
}
$ g++ -std=c++20 -Wall -o cs c_style.cpp
$ ./cs
  main: created
  main: click x=120
  main: key 'q'

메시지를 가상 함수로 바꾸는 Window 클래스

#include <map>
#include "cwin.h"

class Window {
    int hwnd_ = 0;
    static inline std::map<int, Window*> table_;   // 2편 Clock과 같은 핸들 -> 객체 매핑
    static inline Window* creating_ = nullptr;     // WM_CREATE는 create_window 안에서 오므로

    static int proc(int hwnd, int msg, int param)
    {
        if (creating_) { table_[hwnd] = creating_; creating_->hwnd_ = hwnd; creating_ = nullptr; }
        auto it = table_.find(hwnd);
        return it == table_.end() ? 0 : it->second->dispatch(msg, param);
    }
    int dispatch(int msg, int param)
    {
        switch (msg) {                           // switch는 기반 클래스 한 곳에만
        case WM_CREATE:      return on_create();
        case WM_LBUTTONDOWN: return on_lbutton_down(param);
        case WM_KEYDOWN:     return on_key_down(param);
        }
        return 0;
    }
protected:
    const char* title() const { return get_title(hwnd_); }
    virtual int on_create() { return 0; }         // 기본 구현: 처리 안 함
    virtual int on_lbutton_down(int) { return 0; }
    virtual int on_key_down(int) { return 0; }
public:
    virtual ~Window() { table_.erase(hwnd_); }
    void create(const char* title) { creating_ = this; create_window(title, &Window::proc); }
    int handle() const { return hwnd_; }
};

class MainWindow : public Window {               // 필요한 메시지만 재정의
    int clicks_ = 0;
protected:
    int on_create() override { std::printf("  %s: created\n", title()); return 1; }
    int on_lbutton_down(int x) override { std::printf("  %s: click #%d x=%d\n", title(), ++clicks_, x); return 1; }
};

class Editor : public Window {
protected:
    int on_key_down(int k) override { std::printf("  %s: key '%c'\n", title(), k); return 1; }
};

int main()
{
    MainWindow w;
    Editor e;
    w.create("main");
    e.create("editor");
    send_message(w.handle(), WM_LBUTTONDOWN, 10);
    send_message(w.handle(), WM_LBUTTONDOWN, 20);
    send_message(e.handle(), WM_KEYDOWN, 'a');
    std::printf("  editor handles click? %d\n", send_message(e.handle(), WM_LBUTTONDOWN, 5));
}
$ g++ -std=c++20 -Wall -o wc window_class.cpp
$ ./wc
  main: created
  main: click #1 x=10
  main: click #2 x=20
  editor: key 'a'
  editor handles click? 0

switch는 기반 클래스의 dispatch 한 곳에만 남고, 파생 클래스는 필요한 on_xxx만 재정의한다. WM_CREATE는 create_window가 돌아오기 전에 오므로 creating_으로 핸들을 미리 연결했다.

처리하지 않은 메시지는 부모에게

#include <map>
#include "cwin.h"

class Window {
    int hwnd_ = 0;
    static inline std::map<int, Window*> table_;
    static int proc(int hwnd, int msg, int param)
    {
        auto it = table_.find(hwnd);
        if (it == table_.end()) return 0;
        if (it->second->dispatch(msg, param)) return 1;
        int parent = get_parent(hwnd);           // 처리하지 않은 메시지는 부모에게
        return parent > 0 ? send_message(parent, msg, param) : 0;
    }
    int dispatch(int msg, int param)
    {
        switch (msg) {
        case WM_LBUTTONDOWN: return on_click(param);
        case WM_KEYDOWN:     return on_key(param);
        }
        return 0;
    }
protected:
    const char* title() const { return get_title(hwnd_); }
    virtual int on_click(int) { return 0; }
    virtual int on_key(int) { return 0; }
public:
    virtual ~Window() { table_.erase(hwnd_); }
    void create(const char* t, Window* parent = nullptr)
    {
        hwnd_ = create_window(t, &Window::proc, parent ? parent->hwnd_ : 0);
        table_[hwnd_] = this;
    }
    int handle() const { return hwnd_; }
};

struct Frame : Window {
    int on_key(int k) override { std::printf("  %s handles key '%c' (shortcut)\n", title(), k); return 1; }
};
struct Button : Window {
    int on_click(int) override { std::printf("  %s clicked\n", title()); return 1; }
};

int main()
{
    Frame frame;
    Button ok;
    frame.create("frame");
    ok.create("ok-button", &frame);
    send_message(ok.handle(), WM_LBUTTONDOWN, 0);   // 버튼이 처리
    send_message(ok.handle(), WM_KEYDOWN, 's');     // 버튼은 무시 -> 부모 frame이 처리
}
$ g++ -std=c++20 -Wall -o ch child.cpp
$ ./ch
  ok-button clicked
  frame handles key 's' (shortcut)

버튼이 처리하지 않은 키 입력이 부모 프레임으로 올라가 단축키로 처리된다. 처리할 객체를 찾을 때까지 사슬을 따라 넘기는 책임 연쇄(Chain of Responsibility) 패턴이다.

가상 함수가 너무 많아질 때

#include <cstdio>

// 메시지 수만큼 가상 함수를 두면 파생 클래스마다 같은 크기의 vtable이 생긴다
#define H(n) virtual int on_msg##n(int) { return 0; }
#define H10(n) H(n##0) H(n##1) H(n##2) H(n##3) H(n##4) H(n##5) H(n##6) H(n##7) H(n##8) H(n##9)
struct BigWindow { virtual ~BigWindow() = default; H10(1) H10(2) H10(3) H10(4) H10(5) H10(6) H10(7) H10(8) H10(9) H10(10) };
struct Button : BigWindow { int on_msg11(int) override { return 1; } };
struct Edit   : BigWindow { int on_msg12(int) override { return 1; } };
struct List   : BigWindow { int on_msg13(int) override { return 1; } };

BigWindow* make(int k) { if (k == 0) return new Button; if (k == 1) return new Edit; return new List; }
$ g++ -std=c++20 -O2 -c vtbl_size.cpp
$ nm -S -C vtbl_size.o | grep "vtable for" | grep -v cxxabi
0000000000000000 0000000000000340 V vtable for Edit
0000000000000000 0000000000000340 V vtable for List
0000000000000000 0000000000000340 V vtable for Button

메시지 100개를 가상 함수로 두면 함수 하나만 재정의한 클래스도 832바이트(포인터 104개)짜리 vtable을 각자 갖는다. 메시지가 수백 개인 실제 GUI 라이브러리에서는 클래스 수만큼 이 비용이 곱해진다.

멤버 함수 포인터로 만드는 메시지 맵

#include <cstdio>
#include <map>
#include "cwin.h"

class Window;
using Handler = int (Window::*)(int);
struct MsgEntry { int msg; Handler fn; };

class Window {
    int hwnd_ = 0;
    static inline std::map<int, Window*> table_;
    static int proc(int hwnd, int msg, int param)
    {
        auto it = table_.find(hwnd);
        if (it == table_.end()) return 0;
        Window* w = it->second;
        for (const MsgEntry* e = w->message_map(); e->msg; ++e)   // 표를 검색
            if (e->msg == msg) return (w->*e->fn)(param);
        return 0;
    }
protected:
    const char* title() const { return get_title(hwnd_); }
    virtual const MsgEntry* message_map() const { static const MsgEntry none[] = {{0, nullptr}}; return none; }
public:
    virtual ~Window() { table_.erase(hwnd_); }
    void create(const char* t) { hwnd_ = create_window(t, &Window::proc); table_[hwnd_] = this; }
    int handle() const { return hwnd_; }
};

// 가상 함수는 message_map() 하나뿐, 핸들러는 일반 멤버 함수
class Canvas : public Window {
    int on_click(int x) { std::printf("  %s: draw at %d\n", title(), x); return 1; }
    int on_paint(int)   { std::printf("  %s: repaint\n", title()); return 1; }
    const MsgEntry* message_map() const override
    {
        static const MsgEntry map[] = {
            {WM_LBUTTONDOWN, static_cast<Handler>(&Canvas::on_click)},
            {WM_PAINT,       static_cast<Handler>(&Canvas::on_paint)},
            {0, nullptr}};
        return map;
    }
};

int main()
{
    Canvas c;
    c.create("canvas");
    send_message(c.handle(), WM_LBUTTONDOWN, 42);
    send_message(c.handle(), WM_PAINT, 0);
    std::printf("  key handled? %d\n", send_message(c.handle(), WM_KEYDOWN, 'x'));
}
$ g++ -std=c++20 -Wall -o mm msgmap.cpp
$ ./mm
  canvas: draw at 42
  canvas: repaint
  key handled? 0

클래스마다 {메시지, 멤버 함수 포인터} 표만 두고 가상 함수는 message_map() 하나로 줄였다. MFC의 BEGIN_MESSAGE_MAP이 이 표를 매크로로 만든다.

방식장점단점
———
C switch단순, 의존성 없음윈도우 종류마다 switch 중복
가상 함수 핸들러재정의만 하면 됨, 컴파일러가 시그니처 검사메시지 수만큼 vtable 비대
메시지 맵vtable 작음, 처리 메시지만 등록표 검색 비용, static_cast 실수 위험

주의사항

상황문제대응
———
생성 중 메시지(WM_CREATE)아직 매핑 테이블에 없음생성 전에 임시 포인터로 연결
소멸 후 메시지 도착해제된 객체 호출소멸자에서 테이블 항목 삭제
부모로 넘기는 메시지에 순환무한 재귀부모 사슬이 트리인지 보장
메시지 맵의 멤버 함수 포인터 형변환다른 클래스 함수를 등록해도 컴파일됨등록을 매크로·템플릿으로 감싸 타입 고정
처리 결과 반환 누락부모 전달 여부가 잘못 결정됨처리했으면 1, 아니면 0을 일관되게 반환

마무리

  • 윈도우 프로시저 C API는 static 함수 + 핸들 → 객체 매핑으로 클래스에 연결한다.
  • 메시지를 가상 함수로 바꾸면 파생 클래스는 필요한 핸들러만 재정의하고, 처리하지 않은 메시지는 부모로 넘길 수 있다.
  • 메시지가 많으면 가상 함수 대신 멤버 함수 포인터 표(메시지 맵)로 vtable 크기를 줄인다.

참고

답글 남기기