← 문제로

28. 멤버 함수 포인터(pointer-to-member function)의 내부 표현

난이도 상
내 리뷰 · C++
해설 · C++

해설 — 멤버 함수 포인터(pointer-to-member function)의 내부 표현

난이도: 상

답변 프레임워크: 요약 → 핵심 개념 → 단계별 내부 동작 → 흔한 오해·함정 → 면접 포인트

요약

void (PacketHandler::* handler)(const Packet&) 는 일반 함수 포인터와 완전히 다른 타입이다. 일반 함수 포인터는 코드 주소 하나만 담으면 되지만, 멤버 함수 포인터는 호출 시점에 어느 객체의 어느 서브오브젝트에서, 가상이면 vtable 의 몇 번째 슬롯을, 비가상이면 어느 고정 주소를 부를지를 결정해야 한다. 그래서 컴파일러(GCC/Itanium ABI)는 이를 "함수 주소 + 가상 여부 판별용 정보 + this 보정치(offset)"를 담은 작은 구조체로 구현하며, 대개 sizeof(void*) 의 1~2배가 된다. &PacketHandler::OnAttack 처럼 가상 함수를 가리키는 값을 저장해도 실제 호출은 obj.*handler 시점에 obj 의 동적 타입을 통해 vtable 조회로 디스패치되므로, 위 코드는 MonsterHandler::OnAttack 이 호출된다.

출력 예측

OnMove payload=42
OnAttack(monster) payload=42
sizeof(handler) = 16
sizeof(void*)   = 8

(sizeof 값은 ABI/컴파일러에 따라 다를 수 있다 — Itanium ABI(g++/clang, Linux/macOS)에서는 보통 16, MSVC 는 상속 형태에 따라 4~16까지 달라진다. 정확한 숫자보다 "왜 void* 하나로 표현할 수 없는가"가 핵심이다.)

핵심 개념

  • 타입의 일부로서의 클래스: R (C::*)(Args...) 타입은 리턴 타입·소속 클래스 C·파라미터가 모두 타입 시그니처에 포함된다. 그래서 일반 함수 포인터 R (*)(Args...) 와는 암시적 변환도, reinterpret_cast 로도 안전하게 상호 변환할 수 없다(표준상 결과가 정의되지 않음).
  • 호출 문법 .* / ->*: 멤버 함수 포인터는 그 자체로는 호출할 수 없고, 반드시 객체를 함께 지정해야 한다(obj.*handler, pobj->*handler). 이는 멤버 함수가 암묵적으로 this 를 첫 인자로 받는 것과 대응된다.
  • 가상 디스패치와의 결합: 멤버 함수 포인터가 가상 함수를 가리켜도, 포인터 "값" 자체는 정적으로 결정되는 게 아니라 "이 슬롯을 가상 테이블에서 찾아라"는 표식을 담을 수 있다. 실제 어떤 함수가 불릴지는 .* 연산 시점의 객체의 동적 타입에 달려 있다.

단계별 내부 동작

  1. &PacketHandler::OnMove (비가상): 컴파일러는 이 멤버 함수의 코드 주소를 그대로 저장한다 (Itanium ABI에서는 최하위 비트를 0으로 유지해 "비가상"임을 표시). 여기에 다중/가상 상속이 섞이면 호출 시 this 포인터를 몇 바이트 보정(offset)해야 하는 경우가 있어, 그 보정치를 담을 adjustment 필드가 함께 저장된다.
  2. &PacketHandler::OnAttack (가상): Itanium ABI 는 함수 주소 자리에 "vtable 오프셋 + 1" 값을 넣고 최하위 비트를 1로 세팅해 "가상 함수를 가리킨다"는 표식을 남긴다. 즉 이 시점엔 실제 코드 주소가 아니라 vtable 인덱스가 저장된다.
  3. (baseRef.*handler)(p) 호출: 컴파일러가 생성하는 코드는 대략 다음과 같다.
    • 저장된 값의 최하위 비트를 검사한다.
    • 0(비가상)이면 저장된 주소로 직접 call 한다(+ this 를 adjustment 만큼 보정).
    • 1(가상)이면 baseRefvptr 을 읽고, (vtable-offset) 위치의 함수 포인터를 읽어 그 주소로 call 한다 — 이때 baseRef동적 타입(MonsterHandler) 의 vtable 이 조회되므로 오버라이드된 MonsterHandler::OnAttack 이 실행된다.
  4. sizeof(handler)void* 보다 큰 이유: 위 두 경우를 하나의 필드로 표현할 수 없어 { 함수주소_또는_vtable오프셋, this_adjustment(, 다중상속 시 vbase 정보) } 를 묶은 구조체가 되기 때문이다. 다중 상속·가상 상속이 섞인 클래스일수록 정보가 더 필요해 크기가 커질 수 있다 (MSVC 는 상속 형태별로 별도의 표현을 골라 크기가 4/8/12/16 바이트로 달라진다).

흔한 오해·함정

  • "함수 포인터니까 void* 로 캐스팅해서 아무 데나 저장하면 된다" ✗ — 멤버 함수 포인터는 단일 주소가 아닐 수 있어(가상 함수의 경우 vtable 오프셋) void* 캐스팅 자체가 표준적으로 정의되지 않는다. 디스패치 테이블에 저장하려면 union/reinterpret_cast 트릭 대신 원래 타입 그대로 배열/맵에 저장하거나 std::function 으로 감싸야 한다.
  • "멤버 함수 포인터 하나로 아무 객체에나 호출할 수 있다" ✗ — 포인터는 "어떤 멤버인지"만 알고 "어느 객체인지"는 모른다. 반드시 obj.*handler 처럼 객체와 짝지어야 호출된다. 게임서버 디스패치 테이블을 만들 때 {opcode, handler} 뿐 아니라 **호출 대상 객체(this)**도 함께 관리해야 한다.
  • "가상 함수를 가리키는 포인터를 저장하면 저장 시점의 오버라이드가 고정된다" ✗ — 위 예제처럼 &PacketHandler::OnAttack 을 저장해도, 실제 호출 시점의 객체 동적 타입에 따라 MonsterHandler::OnAttack 이 불린다. 저장은 "슬롯 지정"일 뿐 "함수 고정"이 아니다.
  • 성능 오해: 멤버 함수 포인터 호출은 일반 함수 포인터 호출보다 **분기(가상/비가상 판별)**가 하나 더 있을 수 있어 살짝 무겁다. 오히려 순수 가상 함수 하나만 부를 거라면 직접 가상 호출 (baseRef.OnAttack(p))이 더 단순하고 빠르다. 멤버 함수 포인터는 "호출할 함수를 런타임에 바꿔 끼워야 할 때"(디스패치 테이블)만 이점이 있다.

면접 포인트 (예상 질문)

  1. 왜 멤버 함수 포인터는 sizeof(void*) 보다 클 수 있는가? 일반 함수 포인터와 무엇이 근본적으로 다른가?
  2. 멤버 함수 포인터가 가상 함수를 가리킬 때, 실제 호출 시점에 어떤 방식으로 올바른 오버라이드가 선택되는가(vtable 오프셋 인코딩)?
  3. opcode → 핸들러 디스패치 테이블을 멤버 함수 포인터 배열과 std::function 으로 각각 구현한다면, 크기/캐시 지역성/가상 호출 오버헤드/유연성 측면에서 어떤 트레이드오프가 있는가?