C++ 고급 문법 (19) — 함수 바인딩(Function Binding)

shape.area()라는 호출이 실제로 어느 함수로 이어질지는 두 시점 중 하나에 정해진다. 컴파일할 때 정적 타입만 보고 정하면 정적 바인딩(static binding, early binding), 실행할 때 객체의 실제 타입을 보고 정하면 동적 바인딩(dynamic binding, late binding)이다. C++은 virtual 함수만 동적으로 바인딩하고, 그 구현은 객체마다 들어 있는 vtable 포인터다. 이 글에서는 g++ 13.3의 어셈블리와 클래스 레이아웃 덤프로 두 바인딩의 차이를 확인하고, override/final, 기본 인수와 생성자 안 가상 호출처럼 바인딩 시점이 헷갈리는 경우를 정리한다. 마지막에 인수를 함수에 묶는 std::bind도 짧게 다룬다.

이 시리즈의 다른 글

정적 바인딩과 동적 바인딩의 기계어

struct Shape {
    virtual ~Shape() = default;
    virtual double area() const;          // 가상: 동적 바인딩
    double scale() const;                 // 비가상: 정적 바인딩
};

double call_virtual(const Shape& s)    { return s.area(); }
double call_nonvirtual(const Shape& s) { return s.scale(); }
$ g++ -std=c++20 -O2 -S -o - asm.cpp | c++filt | grep -vE '^\s*\.|^\.L|endbr64|^[0-9]+:'
call_virtual(Shape const&):
	movq	(%rdi), %rax
	jmp	*16(%rax)
call_nonvirtual(Shape const&):
	jmp	Shape::scale() const@PLT

비가상 함수는 주소가 정해진 직접 점프다. 가상 함수는 객체 첫 8바이트(vptr)에서 vtable 주소를 읽고, 그 안 16바이트 위치의 함수 포인터로 간접 점프한다.

vtable 들여다보기

struct Plain  { int x; void f(); };
struct Shape  { int x; virtual ~Shape(); virtual double area() const; virtual void draw() const; };
struct Circle : Shape { double r; double area() const override; };

Plain p; Circle c;
$ g++ -std=c++20 -c -fdump-lang-class=layout.txt layout.cpp
$ grep -A8 '^Vtable for Circle' layout.txt
Vtable for Circle
Circle::_ZTV6Circle: 6 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI6Circle)
16    (int (*)(...))Circle::~Circle
24    (int (*)(...))Circle::~Circle
32    (int (*)(...))Circle::area
40    (int (*)(...))Shape::draw

$ grep -E '^Class (Plain|Shape|Circle)' -A1 layout.txt | grep -E 'Class|size'
Class Plain
   size=4 align=4
Class Shape
   size=16 align=8
Class Circle
   size=24 align=8

Circle은 area만 재정의했으므로 그 칸만 Circle::area이고 draw는 Shape::draw를 그대로 가리킨다. 가상 함수가 생기면 vptr 8바이트가 붙어 int 하나짜리 클래스가 4바이트에서 16바이트가 된다.

vtable 오프셋내용
0offset-to-top (다중 상속 시 this 보정)
8RTTI type_info 포인터
16, 24소멸자 (complete / deleting)
32~선언 순서대로 가상 함수 (vptr이 가리키는 지점은 16)

override와 final

#include <cstdio>

struct Widget {
    virtual ~Widget() = default;
    virtual void draw(int scale) const { std::printf("Widget::draw(%d)\n", scale); }
};
struct Button : Widget {
    void draw(int scale) { std::printf("Button::draw(%d)\n", scale); }   // const 누락: 재정의가 아니다
};

int main()
{
    Button b;
    const Widget& w = b;
    w.draw(2);                       // Button 버전을 기대했지만
}
$ g++ -std=c++20 -Wall -o no nooverride.cpp 2>&1 | grep -E 'warning|note'
nooverride.cpp:5:18: warning: ‘virtual void Widget::draw(int) const’ was hidden [-Woverloaded-virtual=]
nooverride.cpp:8:10: note:   by ‘void Button::draw(int)’
$ ./no
Widget::draw(2)

const 하나가 빠져 시그니처가 달라지면 재정의가 아니라 새 함수가 되고, 기반 참조로 부르면 기반 버전이 호출된다. g++ 13은 -Wall에서 경고를 내지만, override를 붙이면 에러가 되어 확실히 막힌다.

$ g++ -std=c++20 -Wall -c override_err.cpp 2>&1 | grep error
override_err.cpp:8:10: error: ‘void Button::draw(int)’ marked ‘override’, but does not override

final은 더 이상 재정의·파생이 없다고 알려주므로, 컴파일러가 동적 바인딩을 직접 호출로 바꾼다(devirtualization).

struct Shape {
    virtual ~Shape() = default;
    virtual double area() const;
};
struct Square final : Shape {          // 더 이상 파생 불가
    double side;
    double area() const override;
};

double area_of(const Square& s) { return s.area(); }   // 동적 타입이 Square로 확정
double area_of(const Shape& s)  { return s.area(); }
$ g++ -std=c++20 -O2 -S -o - final.cpp | c++filt | grep -vE '^\s*\.|^\.L|endbr64|^[0-9]+:'
area_of(Shape const&):
	movq	(%rdi), %rax
	jmp	*16(%rax)
area_of(Square const&):
	jmp	Square::area() const@PLT

바인딩 시점이 다른 것들

기본 인수는 정적 바인딩

#include <cstdio>

struct Logger {
    virtual ~Logger() = default;
    virtual void log(const char* msg, int level = 1) const { std::printf("Logger   level=%d %s\n", level, msg); }
};
struct Verbose : Logger {
    void log(const char* msg, int level = 9) const override { std::printf("Verbose  level=%d %s\n", level, msg); }
};

int main()
{
    Verbose v;
    const Logger& l = v;
    v.log("direct");          // 정적 타입 Verbose -> 기본값 9
    l.log("via base");        // 함수는 Verbose, 기본값은 정적 타입 Logger의 1
}
$ g++ -std=c++20 -Wall -o da defarg.cpp
$ ./da
Verbose  level=9 direct
Verbose  level=1 via base

호출할 함수는 실행 시점에 Verbose::log로 정해지지만, 기본 인수 값은 컴파일 시점에 정적 타입 Logger 선언에서 가져온다. 가상 함수를 재정의할 때는 기본 인수를 바꾸지 않는다.

생성자·소멸자 안의 가상 호출

#include <cstdio>

struct Base {
    Base()          { std::printf("  in Base():  "); hello(); }   // 생성 중에는 Base로 바인딩
    virtual ~Base() { std::printf("  in ~Base(): "); hello(); }
    virtual void hello() const { std::puts("Base::hello"); }
    void later() const { std::printf("  later():    "); hello(); }
};
struct Derived : Base {
    void hello() const override { std::puts("Derived::hello"); }
};

int main()
{
    Derived d;
    d.later();
}
$ g++ -std=c++20 -Wall -o cc ctorcall.cpp
$ ./cc
  in Base():  Base::hello
  later():    Derived::hello
  in ~Base(): Base::hello

Base 생성자가 도는 동안 Derived 부분은 아직 없으므로 vptr은 Base의 vtable을 가리킨다. 소멸자에서도 Derived 부분이 이미 파괴된 뒤라 같은 규칙이 적용된다.

std::bind: 인수 바인딩

함수 바인딩은 함수와 인수 일부를 묶어 새 호출 가능 객체를 만드는 것을 가리키기도 한다. std::bind가 그 도구이고, 지금은 대부분 람다나 C++20 std::bind_front로 대체한다.

#include <cstdio>
#include <functional>

int clamp(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); }

struct Sensor {
    int offset = 5;
    int read(int raw) const { return raw + offset; }
};

int main()
{
    using namespace std::placeholders;

    auto to_byte  = std::bind(clamp, _1, 0, 255);              // 인수 일부를 미리 고정
    auto to_byte2 = [](int v) { return clamp(v, 0, 255); };    // 같은 일을 람다로
    auto lo_first = std::bind_front(clamp, 0);                 // C++20: 앞쪽 인수 고정 (lo=0)
    std::printf("%d %d %d\n", to_byte(300), to_byte2(-4), lo_first(-7, 10));

    Sensor s;
    auto reader = std::bind(&Sensor::read, &s, _1);            // 멤버 함수 + 객체 결합
    auto reader2 = std::bind_front(&Sensor::read, &s);
    std::printf("%d %d\n", reader(10), reader2(20));

    auto swapped = std::bind(clamp, _3, _1, _2);               // 인수 순서 바꾸기
    std::printf("%d\n", swapped(0, 10, 42));
}
$ g++ -std=c++20 -Wall -o sb stdbind.cpp
$ ./sb
255 0 0
15 25
10

bind_front(clamp, 0)은 lo를 0으로 고정해 clamp(0, -7, 10)이 되어 결과가 0이다. 자리표시자(_1, _2)로 순서를 바꾸는 코드는 읽기 어려워 람다가 권장된다.

주의사항

상황문제대응
재정의 함수 시그니처 불일치새 함수가 되어 기반 버전 호출항상 override
재정의에서 기본 인수 변경호출 경로마다 다른 기본값기본 인수는 비가상 래퍼에만
생성자·소멸자에서 가상 함수 호출파생 버전이 호출되지 않음초기화 후 별도 init() 또는 팩토리
비가상 함수를 파생에서 같은 이름으로 정의호출 타입에 따라 다른 함수 실행재정의할 함수는 virtual
핫 루프의 가상 호출간접 호출, 인라인 불가final, CRTP(다음 편)
std::bind 남용가독성 저하, 오버로드 함수 바인딩 어려움람다 또는 bind_front

마무리

  • 비가상 함수는 직접 호출(정적 바인딩), 가상 함수는 vptr → vtable → 함수 포인터 간접 호출(동적 바인딩)이다.
  • override는 시그니처 실수를 에러로 만들고, final은 동적 바인딩을 직접 호출로 바꿀 여지를 준다.
  • 기본 인수와 생성자·소멸자 안의 호출은 동적 바인딩이 적용되지 않는다.

참고

답글 남기기