shape.area()라는 호출이 실제로 어느 함수로 이어질지는 두 시점 중 하나에 정해진다. 컴파일할 때 정적 타입만 보고 정하면 정적 바인딩(static binding, early binding), 실행할 때 객체의 실제 타입을 보고 정하면 동적 바인딩(dynamic binding, late binding)이다. C++은 virtual 함수만 동적으로 바인딩하고, 그 구현은 객체마다 들어 있는 vtable 포인터다. 이 글에서는 g++ 13.3의 어셈블리와 클래스 레이아웃 덤프로 두 바인딩의 차이를 확인하고, override/final, 기본 인수와 생성자 안 가상 호출처럼 바인딩 시점이 헷갈리는 경우를 정리한다. 마지막에 인수를 함수에 묶는 std::bind도 짧게 다룬다.
이 시리즈의 다른 글
- C++ 고급 문법 (17) — private 상속(private Inheritance)
- C++ 고급 문법 (18) — 업캐스팅(Upcasting)
- C++ 고급 문법 (20) — CRTP(Curiously Recurring Template Pattern)
- C++ 고급 문법 (21) — 가상 소멸자(Virtual Destructor)
정적 바인딩과 동적 바인딩의 기계어
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 오프셋 | 내용 |
|---|---|
| 0 | offset-to-top (다중 상속 시 this 보정) |
| 8 | RTTI 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은 동적 바인딩을 직접 호출로 바꿀 여지를 준다.- 기본 인수와 생성자·소멸자 안의 호출은 동적 바인딩이 적용되지 않는다.