상속
자식 클래스가 부모 클래스의 함수와 변수를 재사용할 수 있게 해줌
- 컴파일러가 정확히 알 수 있는 클래스만 상속할 수 있음.
public상속 → IS-A 관계부모의public은자식에서 그대로public부모의protected는protected로 유지
protected상속 → 외부에서는 숨기고,자식를 상속 받는 파생 클래스는 사용 가능private상속 → 외부에서 완전히 숨김,자식를 상속받는 파생 클래스에도 숨김
class 부모 {
public:
int a;
protected:
int b;
private:
int c;
};
class 자식1 : public Base {};
class 자식2 : protected Base {};
class 자식3 : private Base {};
class 자식1_1 : public 자식1
{
void f()
{
a = 1;
b = 1;
// c = 1; X
}
};
class 자식2_1 : 자식2
{
void f()
{
a = 1;
b = 1;
// c = 1; X
}
};
class 자식3_1 : public 자식3
{
void f()
{
// a = 1; X
// b = 1; X
// c = 1; X
}
};
int main()
{
자식1 d1;
d1.a;
// d1.b; X
// d1.c; X
자식2 d2;
// d2.a; X
// d2.b; X
// d2.c; X
자식3 d3;
// d3.a; X
// d3.b; X
// d3.c; X
부모* base = new 자식1();
// 부모* base = new 자식2(); X
// 부모* base = new 자식3(); X
부모* base = new 자식1_1();
// 부모* base = new 자식2_1(); X
// 부모* base = new 자식3_1(); X
return 0;
}
protected , private 로 상속 받은 파생은 인스턴스로 올 수 없음.
IS - A 관계가 성립하지 않기 때문자식-부모 관계에서 이름이 겹치는 변수는 범위 연산자를 통해 명시적으로 스코프를 지정해야한다.
class A
{
public:
int x = 4;
}
class B : public A
{
public:
int x = 5;
int func() { return A::x + x;}
}
B* b = new B();
cout << b->func(); // 9함수 virtual
클래스의 포인터/참조를 통해 호출되었을 때, 실제 객체의
타입에 맞는 함수가 실행되도록하는 함수
virtual을 붙인 함수는 vtable에 기록되어 vptr이 접근할 수 있게된다.
또한 virtual을 붙인 함수의 구현부는 자식 클래스에서 재정의(Override) 할 수 있다.virtual 을 사용하게 되면 virtual함수를 담고 있는 vtable과 vtable 포인터인 vptr 이 생긴다.
C++에서의 virtual 함수 호출은 정적 테이블 기반의 동적 바인딩 이라고 할 수 있다.
Override(재정의), Overload
Overriding : virtual을 붙인 부모 함수를 자식 클래스에서 재정의, 함수의 시그니처는 같지만 구현 내용을 다르게 할 수 있음Overloading : 함수의 이름만 같고 리턴값, 매개변수, 구현부를 모두 다르게 할 수 있음.
호출 인수에 맞는 시그니처를 가진 함수가 호출된다.
Override는 virtual과 함께 사용되는 런타임 다형성 개념.Overload는 컴파일 타임에 호출 대상을 결정하는 정적 바인딩 기능
virtual Override인 함수에 Overload를 사용하면 Override의 시그니처를 사용하는 함수만 다형성이고 나머지는 Overload의 기능을 사용한 컴파일 타임의 정적 바인딩이다.
struct A
{
int a;
virtual void func() { cout << "A\n"; }
};
struct B
{
virtual void func(){ cout << "B\n"'; }
};
>
struct C : public A, public B
{
virtual void func() override { cout << "C\n"; }
};C::func()는 B::func()도 override 한다.C는 B의 파생 클래스이기 때문에 override가 성립한다.
그렇기 때문에 B::func()나 A::func()에 final키워드를 붙이게 되면
컴파일 에러가 발생한다.
class A
{
public:
char ch = 'A';
void func() { cout << this->ch << '\n'; }
virtual void vfunc() { cout << this->ch << '\n'; }
};
class B
{
public:
char ch = 'B';
void func() { cout << this->ch << '\n'; }
virtual void vfunc() { cout << this->ch << '\n'; }
};
class D : public A, public B
{
public:
char ch = 'D';
void func() { cout << this->ch << '\n'; }
virtual void vfunc() { cout << this->ch << '\n'; }
};
int main()
{
D d;
B* pb = &d;
pb->func();
pb->vfunc();
A* pa = &d;
pa->func();
pa->vfunc();
return 0;
}출력
B
D
A
Dfunc()는 컴파일 타임에 함수가 결정되고
vfunc()는 vtable을 사용해 런타임에 결정된다.
런타임에서의 함수호출의 주체를 포인터 타입으로 생각하면 헷갈린다. 객체 타입을 보자
final
클래스 상속 방지와 함수 재정의 방지에 쓰인다.
클래스 상속 방지시 해당 클래스를 부모 클래스로 사용할 수 없다.
class Base final {};가상 함수 뒤에 final을 붙이면 자식 클래스에서 더 이상 override할 수 없다.
class A
{
public:
virtual void func() {}
}
class B
{
public:
virtual void func() override final {}
}
public C : public B
{
public:
//virtual void func() {} 할수 없음
}컴파일러가 final을 확인하면 vtable을 거치는 간접 호출 대신 직접 호출로 최적화할 확률이 높아진다.
vtable
vtable 내부 요소
- RTTI포인터 : 객체 타입 정보를 담고 있는
std::type_info객체에 대한 포인터 - 시작주소까지의 거리
- 가상 함수 주소
- 실제 함수 주소 : 오버라이딩된 함수의 직접적인 주소
- Adjust Thunk 주소 : this를 조정해야하는 경우 함수로 바로 가지 않고 여기로옴.
| 오프셋 | 내용 | 설명 |
|---|---|---|
| -2 | Offset to Top | 현재 위치에서 객체 시작점까지의 상대 거리 |
| -1 | RTTI Pointer | typeid 등을 위한 클래스 타입 정보 |
| 0 | Virtual Function 1 | 첫 번째 가상 함수 (또는 Thunk) 주소 |
| +1 | Virtual Function 2 | 두 번째 가상 함수 (또는 Thunk) 주소 |
가상 함수는 void* 타입으로 저장되어진다.vtable은 컴파일 타임에 생성한다.
함수 주소의 위치는 메모리의 Code 영역이다.
부모 클래스가 가상 함수를 가지고 있다면
자식 클래스는 가상 함수를 사용하지 않아도 부모 클래스의 vtable 을 상속 받게된다.
자식에서 가상 함수를 생성할 때 자식 클래스의 vtable이 새로이 만들어지게 된다.
vtable 은 정적으로 생성되기 때문에 함수에 스코프 지정 연산자(::)로 접근 가능하다.
이렇게 되면 정적으로 함수에 접근하기 때문에 동적 바인딩이 아니다.
class A_Derived : public A
{
public:
virtual void func()
{
// 가능
A::func();
}
}가상 함수 호출이 런타임에 이루어진다는 말은vptr이 런타임에 만들어지고 vptr이 vtable을 탐색해 함수 호출하는 시점이 런타임이기 때문이다.
vtable 은 클래스당 하나만 생긴다.
다중상속의 경우에서 부모 클래스들의 정보들을 vtable 에 가지게 된다.
class A
{
public:
virtual void afunc() { }
virtual void afunc1() { }
};
class B
{
public:
virtual void bfunc() { }
};
class A_Derived : public A, public B
{
public:
virtual void afunc()
{
A::afunc();
}
virtual void bfunc() { }
};
vtable이 여러개가 생 긴 것처럼 보이지만
디버거가 내부 구조를 트리 형태로 풀어서 보여준 것.
아래 형태를
[ vtable[0] | vtable[1] | vtable[2] ]
↑ ↑
vptr1 vptr2이렇게 변형
vptr1
┗ [0]
[1]
vptr2
┗ [0]일반적인 함수는 레지스터에서 직접 호출이라고 한다.vtable을 통한 함수 호출은 아래와 같은 일련의 과정을 거치기 때문에
간접 호출이라고 한다.
vptr을 레지스터에 로드
→ vtable을 로드
→ 함수 call
call A::Func ; 직접 호출
mov rax, [rcx] ; vptr load
mov rax, [rax + 8] ; vtable slot load
call rax ; 간접 호출class A
{
public:
virtual void afunc() {}
void afunc1() {}
};
class B
{
public:
virtual void bfunc() {}
};
class AB_Derived : public A, public B
{
public:
virtual void afunc() { cout << "afunc()\n"; }
virtual void bfunc() { cout << "bfunc()\n"; }
virtual void abfunc() { cout << "abfunc()\n"; }
virtual void abfunc1() { cout << "abfunc1()\n"; }
};
AB_Derived가 따로 자신의 virtual 함수를 가지고 있음에도 vtable에 명시적으로 표기되지 않는다.AB_Derived(파생 클래스)가 자신만의 새로운 가상 함수를 정의하면,
컴파일러는 완전히 새로운 테이블을 따로 만드는 것이 아니라
부모로부터 물려받은 테이블의 끝에 자식의 함수 주소를 덧붙인다.
사진을 보면 A의 vptr이 가리키는 vtable의 크기가 4이다.A 1개, B 1개, AB_Derived 2개 니까 모두 다 추가되어있다.
디버거는 현재 심볼(Symbol) 정보를 바탕으로 객체를 보여주는데, 객체를 부모 타입으로 인식하거나 혹은 vtable의 경계를 부모 클래스 기준으로만 표시하기 때문이다.
AB_Derived* ab = new AB_Derived();
long long* vptr1 = (long long*)ab;
long long* vtable1 = (long long*)*vptr1;
cout << "객체 주소 : " << (void*)ab << '\n';
cout << "vtable 주소 : " << (void*)vtable1 << '\n';
cout << "vtable[0] " << (void*)vtable1[0] << '\n';
cout << "vtable[1] " << (void*)vtable1[1] << '\n';
cout << "vtable[2] " << (void*)vtable1[2] << '\n';B는 offset이 따로 존재한다.
long long* vptr2 = (long long*)((char*)ab + sizeof(A));
long long* vtable2 = (long long*)*vptr2;
cout << "vtable[3] " << (void*)vtable2[0] << '\n';
typedef void(*FuncPtr)();
FuncPtr f1 = (FuncPtr)vtable1[0];
FuncPtr f2 = (FuncPtr)vtable1[1];
FuncPtr f3 = (FuncPtr)vtable1[2];
FuncPtr f4 = (FuncPtr)vtable2[0];
f1();
f2();
f3();
f4();객체 주소 : 000001F8EE796050
vtable 주소 : 00007FF72285CEA0
vtable[0] 00007FF72285147E
vtable[1] 00007FF72285133E
vtable[2] 00007FF722851276
vtable[3] 00007FF722851046
afunc()
abfunc()
abfunc1()
bfunc()vptr
함수 포인터 배열을 가리키기 위한 포인터
void** 타입의 포인터, vtable 의 함수 포인터를 가리키기 위한 포인터
- 가상 함수를 가진 클래스는 자기 자신 영역에
vptr을 하나 갖는다. - 다중 상속에서는 부모 클래스마다 별도의 영역이 존재
class A
{
public:
~A()
{
cout << "~A()\n";
}
virtual void func() {}
};
class A_Derived : public A
{
public:
~A_Derived()
{
cout << "~A_Derived()\n";
}
virtual void func() {}
};A타입에A로 인스턴스화
A* = new A();A_Derived타입에A_Derived로 인스턴스화
A_Derived* a = new A_Derived();A타입에A_Derived으로 인스턴스화
A* a = new A_Derived();
세번째의 경우 보이는 vptr이 두개지만 모두 같은 주소.
시작주소가 같은 첫번째 상속 요소라서 vptr의 시작주소도 같다.
같은 시작주소를 가지는 vptr들은 컴파일러가 병합한다.
다중 상속 구조일 때
class A
{
public:
virtual void funcA() { cout << "A\n"; }
};
class B
{
public:
virtual void funcB() { cout << "B\n"; }
};
class AB_Derived : public A, public B
{
public:
virtual void funcA() { cout << "Derived\n"; }
};
다중 상속의 경우에는 시작주소가 같은 서브오브젝트는 각 vptr이 시작주소를 공유하고 다른 서브오브젝트는 따로 vptr이 존재한다. 논리적으로 vptr이 3개지만 물리적으론 2개가 존재한다.
가상 상속 구조일 때
class A
{
public:
virtual void funcA() { cout << "A\n"; }
};
class B : virtual public A
{
public:
virtual void funcB() { cout << "B\n"; }
};
class C : virtual public A
{
public:
virtual void funcB() { cout << "B\n"; }
};
class BC_Derived : public B, public C
{
public:
virtual void funcA() { cout << "Derived\n"; }
};
빨간색은 공유하는 A에 대한 vptr이고
파란색은 시작주소가 같은 BC_Derived와 B의 vptr이다.
나머지 C가 겹치지 않는 시작주소를 가진다.
마찬가지로 시작주소를 공유하는 vptr은 vptr과 vtable이 합쳐진다.
여기서는 물리적인 vptr이 3개가 존재한다.
vptr은 this가 불러온다.
소멸자 virtual
부모 포인터로 자식 객체를 삭제할 때, 자식 소멸자를 거쳐 부모 소멸자까지 안전하게 호출(소멸자 체인)
부모 소멸자가 virtual이면 자식 소멸자는 이름을 무시하고 vtable의 소멸자 슬롯을 갱신한다.
상속과 virtual 을 사용하고 있어서 vtable 이 이미 만들어져서 소멸자가 vtable 에 있어도
소멸자에 명시적으로 virtual 키워드를 붙여주지 않는다면 자식 타입의 소멸자가 호출되지 않는다.

아래와 같은 상황일 때
class Base1
{
public:
virtual ~Base1() { cout << "Base1\n"; }
};
class Base2
{
public:
virtual ~Base2() { cout << "Base2\n"; }
};
class Derived : public Base1, public Base2
{
public:
virtual ~Derived() { cout << "Derived\n"; }
};Base2* b2 = new Derived();
delete b2;delete b2가 실행될 때 컴파일러는 vtable에 있는
각 클래스의 시작주소 offset으로 this 포인터를 Derived*로 조정한다.
this를 통해 ~Derived() 호출하고 나머지 서브오브젝트들을
메모리 레이아웃(아래 virtual bass class는 왜 제일 아래 배치될까? 참고)에 따라
차례로 소멸자를 호출한다.
출력
Derived
Base2
Base1this가 Derived로 갔기 때문에 역순으로 소멸자 호출
+16 바이트 지점: Derived 자신의 데이터
+8 바이트 지점: Base2 서브오브젝트
+0 바이트 지점: Base1 서브오브젝트
생성자 virtual
생성자는 virtual로 선언할 수 없으며, 시도할 경우 컴파일 에러가 발생한다.

이는 생성자가 객체의 동적 타입을 확정하고 구성하는 단계이기 때문이다.
생성자 실행 시점에는 객체 메모리는 이미 할당되어 있고 vptr도 존재하지만, vptr은 현재 생성 중인 클래스의 vtable만을 가리킨다.
파생 클래스의 동적 타입에 대해 어떤 메서드를 실행시킬지 런타임에 결정하는것이(virtual dispatch) 아직 성립하지 않는다.
이러한 이유로 생성자는 virtual 함수가 될 수 없다.
생성자 내부에서 virtual 함수 호출
일반 가상 함수의 경우 현재 실행 중인 생성자가
속한 클래스의vtable을 참조하여 함수를 호출한다.
순수 가상 함수인 경우 컴파일러가 에러를 발생시키지 않고 런타임에 크래시를 낸다.
정적 호출이 아닌 경우 런타임에 링크에러를 발생 시킨다.
pure virtual 함수
구현이 없고 반드시 파생 클래스에서 재정의해야 하는 가상 함수. 순수 가상 함수도 vtable을 가진다.
class Interface
{
public:
virtual void Update() = 0;
}순수 가상 함수를 가지고 있는 클래스는 추상(abstract) 클래스가 된다.
abstract class
하나 이상의 pure virtual 함수를 포함하는 클래스pure virtual 함수라는게 결국 구현되지 않은 함수이기 때문에
추상 클래스를 인스턴스화 하려고 하면 컴파일 에러가 발생한다.
서브 객체로는 존재하지만 객체로 사용될 수 없다.
객체로 사용될 수 없기 때문에 형변환으로, 매개변수로 사용 될 수 없다.
복사 / 이동에도 사용 될 수 없다.
순수 가상 함수도 기본 구현이 가능하다
class Base
{
public:
virtual void Foo() = 0;
};
void Base::Foo()
{
// 기본 구현
}구현되지 않은 순수 가상 함수를 보통 호출 할 수 없지만 어떻게든 호출되면 런타임 에러가 발생한다.
클래스를 추상 클래스로 사용하지만 순수 가상 함수로 사용할 일반 함수가 없을 때 순수 가상 소멸자를 사용한다.
이 경우에는 소멸자의 구현부를 따로 적어주어야한다.
class A
{
public:
virtual ~A() = 0;
};
A::~A() {}interface, abstract class
C++에는 interface가 없어서 abstract class로 대신한다.interface는 멤버 변수를 사용할 수 없는 순수 함수 구현에 특화되어있다.
abstract와 마찬가지로 함수 구현이 되어 있지 않으니
인스턴스화 할 수 없다.
모든 interface는 abstract class로 구현되지만
모든 abstract class가 interface인 것은 아니다.abstract class ⊃ interface
다중상속
상속을 여러개 사용하는 상황
class B : public A, public C상속이 많아 질수록 되는 코드 가독성이 떨어지고
같은 시그니처 함수가 많아질수록 관리가 어려워진다.
부모 중복 문제도 발생할 수 있다.
A : Z
C : Z
B : A, C (Z가 겹침)가상 상속
다중 상속 구조에서 부모 중복 문제를 해결해줌.
가상 기반 클래스의 위치를 서브객체 기준으로 런타임에 딱 하나만 만든다.
가상 기반 클래스는 컴파일 타임에 생성되지 않는다.
최후 파생 클래스가 생성될 때 vbtable에 저장된 offset을 통해 생성한다.
vbtable(virtual base table) : 가상 기반 클래스들의 메모리 오프셋 정보가 저장되어 있는 테이블vtable과 마찬가지로 정적으로 만들어짐virtual상속을 사용하는 클래스마다 만들어짐vbptr(virtual base pointer) : 가상 기반 클래스 테이블을 가리키는 포인터
class A {};
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};
int main()
{
D* d = new D();
return 0;
}
가상 상속을 통해 A를 공유하게 되면, 아래와 같이 공유 A가 생겨난다.
이 경우에 B, C는 직접 A를 생성하지 않고A가 나중에 만들어질 위치를 가리키는 포인터 정보 vbptr 만 갖고 있음
모든 가상 기반 클래스는 최하위 클래스에서 만들어짐.
위 코드에서 가상 기반 클래스는 A이다.
가상 기반 클래스(virtual base class)는 제일 아래 배치된다.
B와 C가 A를 공유할 때, A의 위치가 고정되어 있으면 자식 클래스마다 구조가 완전히 뒤바뀌게 된다.
가장 하단에 두면 B와 C는 자신의 고정된 레이아웃을 유지하면서, vbptr을 통해서만 아래로 얼마나 내려가면 A가 있는지 오프셋만 알면 된다.
또한 위에 두면 insert처럼 기존 멤버들의 위치를 매번 밀어내야 하지만, 아래에 두면 push_back처럼 기존 레이아웃을 유지하면서 공유 객체만 추가할 수 있다.
공유 객체는 최종 파생 클래스가 마지막에 생성하는 서브오브젝트라는 점을 잊지말자.

class A {};
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};Test.exe!B::B(void):
...
mov rax,qword ptr [this] // rax = this (객체 시작 주소)
lea rcx,[B::`vbtable' (07FF680B9ADE8h)] // rcx = &B::vbtable
mov qword ptr [rax],rcx // *(void**)this = &B::vbtable (vbptr 초기화)
...
// B의 vbtable 주소를 B 객체의 첫 8바이트(vbptr)에 저장
...
------------
Test.exe!C::C(void):
...
mov rax,qword ptr [this] // rax = this
lea rcx,[C::`vbtable' (07FF680B9ADF8h)] // rcx = &C::vbtable
mov qword ptr [rax],rcx // *(void**)this = &C::vbtable (vbptr 초기화)
// C의 vbtable 주소를 C 객체의 첫 8바이트(vbptr)에 저장
...
call A::A (07FF680B91433h) // 생략된다.
...
------------
Test.exe!D::D(void):
...
mov rax,qword ptr [this] // rax = this
lea rcx,[D::`vbtable' (07FF680B9AE08h)] // rcx = &D::vbtable_for_B
mov qword ptr [rax],rcx // *(void**)this = &D::vbtable_for_B;
// offset 0 : B 서브객체의 vbptr
mov rax,qword ptr [this] // rax = this
lea rcx,[D::`vbtable' (07FF680B9AE18h)] // rcx = &D::vbtable_for_C
mov qword ptr [rax+8],rcx // *(void**)this = &D::vbtable_for_C;
// offset 8 : C 서브객체의 vbptr
...
mov rax,qword ptr [this]
...
call A::A (07FF680B91433h)
...
mov rcx,qword ptr [this] // rcx = this
call B::B (07FF6B4441366h)
mov rax,qword ptr [this] // rax = this
add rax,8 // rax + 8 (B 서브객체 offset만큼 이동)
...
call C::C (07FF680B911EFh)
...컴파일러는 최종 파생 클래스(D)의 메모리 레이아웃을 먼저 결정하고,
그 레이아웃을 기준으로 각 virtual 상속 서브객체(B, C)의 vbtable에
가상 기반 클래스(A)까지의 offset을 기록한다.
MSVC / Itanium ABI기준 가상 기반 클래스를 직접 상속 받지 않는
최종 파생 클래스에서도vbtable이 만들어진다.
D(최종 파생 클래스)에서는B,와C에 대한vbtable이 각각 만들어진다.
B ---
vbtable B:
A_offset = offset(A) - offset(B)
---
>
C ---
vbtable C:
A_offset = offset(A) - offset(C)
---
>
D ---
vbtable_for_B_in_D:
A_offset = offset(D::A) - offset(D::B)
>
vbtable_for_C_in_D:
A_offset = offset(D::A) - offset(D::C)
---
>가상 기반 클래스(A)의 서브객체 생성은
최종 파생 클래스(D)의 생성 과정에서
단 한 번 수행된다.
최종 파생 클래스가 아닌 가상 기반 클래스를 상속 받는 클래스는
가상 기반 클래스의 생성자를 호출할 수 없다.
struct B : virtual A
{
B() : A() {} // A() 호출이 생략됨.
};
struct D : B
{
D() : A() {}
};
서브 객체(Subobject)
인스턴스이지만 독립객체가 아닌것
struct A { int a; }; struct B : A { int b; };B객체 안에 A의 데이터 영역이 포함
메모리에 A의 표현이 존재한다.
이를 object라고 칭하기에는 독립적이지 않으니
A는 B의 서브객체이다 라고 표현
Object : Instance + independent lifetime
| 항목 | 생성 시점 | 위치 | 설명 |
|---|---|---|---|
| vbtable | 컴파일 타임 | 전역 영역(.rdata) | 클래스마다 하나씩 정적 생성 |
| vbptr | 객체 생성 시 | 객체 내부 | vbtable 주소를 가리키는 포인터 |
| vbtable | 컴파일 타임 | 전역 영역(.rdata) | 가상 함수 테이블 |
| vptr | 객체 생성 시 | 객체 내부 | vtable 주소를 가리키는 포인터 |
virtual은 객체의 실제 타입을 런타임에 확인하여 적절한 함수 주소를 vtable에서 찾아 호출하고, 필요시 this 포인터를 조정하여 복잡한 상속 구조에서도 다형성을 안전하게 보장하는 메커니즘이다.
문제
기본 + 심화 30문제
https://gemini.google.com/share/bdd26a073f8c
https://learn.microsoft.com/ko-kr/cpp/cpp/virtual-functions?view=msvc-170
https://learn.microsoft.com/ko-kr/cpp/cpp/single-inheritance?view=msvc-170
https://learn.microsoft.com/ko-kr/cpp/cpp/inheritance-cpp?view=msvc-170
내용에 대한 질의나, 수정 요청은 저에게 큰 도움이 됩니다.
