
virtual table
class Base
{
public:
std::string_view getName() const { return "Base"; }
virtual std::string_view getNameVirtual() const { return "Base"; }
};
class Derived : public Base
{
public:
std::string_view getName() const { return "Derived"; }
virtual std::string_view getNameVirtual() const override { return "Derived"; }
};
int main()
{
Derived derived{};
Base& base{ derived };
std::cout << "base has static type " << base.getName() << '\n';
std::cout << "base has dynamic type " << base.getNameVirtual() << '\n';
return 0;
}
위 코드의 결과는 Base,Derived가 나오게 된다, 이유는 getName()은 가상함수가 아닌 일반 멤버 함수이기 때문에 정적 타입인 Base클래스 타입의 함수가 호출되는 것이고 getNameVirtual()은 가상함수이기 때문에 런타임에 실제로 어떤 객체를 가리키고 있는지 확인 후 호출되어 Derived클래스 타입의 함수가 호출되는 것이다
쉽게 말해 정적 타입(static type)은 코드 상에 써있는 타입을 의미하고 동적 타입(dynamic type)은 런 타임에 변수가 실제로 가리키는 객체의 타입을 의미한다
이러한 가상함수는 어떤 방식으로 동작할까?
virtual table은 동적 바인딩(late binding)으로 함수 호출에 관여하는 함수의 lookup table이다
가상함수를 사용하는 클래스나 가상 함수를 사용하는 클래스로부터 파생된 클래스는 각각에 해당하는 virtual table을 가진다
이 virtual table은 사실 컴파일러가 컴파일 타임에 set하는 정적 배열이다, virtual table은 해당 클래스가 호출할 수 있는 가상 함수에 대한 entry들을 포함한다 (단순 함수 포인터, 해당 클래스 타입 객체가 호출할 수 있는 가장 마지막에 파생된 함수임)
컴파일러는 이러한 상황에서 기반 클래스에 virtual pointer(vptr)을 추가하여 클래스 객체가 생성될 때 해당 클래스의 virtual table을 가리키도록 설정된다, 이러한 vptr은 상속된다
vptr은 실제 멤버로 들어가기 때문에 클래스 객체의 크기가 포인터 크기만큼 커진다
class Base
{
public:
virtual void Foo1() { std::cout << "Base::Foo1\n"; }
virtual void Foo2() { std::cout << "Base::Foo2\n"; }
};
class Derived : public Base
{
public:
virtual void Foo1() override { std::cout << "Derived::Foo1\n"; }
};
class Derived2 : public Base
{
public:
virtual void Foo1() override { std::cout << "Derived2::Foo1\n"; }
};
이러한 상황이라고 가정하면 컴파일러는 3개의 virtual table을 설정하고 (Base, Derived, Derived2) Base클래스에 vptr을 추가한다
virtual table에는 Foo1(), Foo2() entry들이 추가된다, 이때 Derived클래스의 virtual table의 entry는 Base::Foo1()이 아니라 Derived::Foo1()을 가리킨다 (호출할 수 있는 가장 마지막에 파생된 가상함수 entry) 그리고 Foo2 entry는 override되지 않았기 때문에 Base::Foo2()를 가리킨다
Base클래스 타입 객체가 생성되면 Base클래스의 vptr은 Base클래스의 virtual table을 가리키고 Derived 클래스 타입 객체가 생성되면 Derived클래스의 vptr은 Derived클래스의 virtual table을 가리킨다
Derived d{};
Base* b{ &d };
b->Foo1();
Base 타입인 b는 Derived클래스 타입 객체인 d를 가리킨다, b는 Base타입이기 때문에 d의 Base부분을 가리키게 되고 vptr에 접근이 가능해진다 (vptr은 부모 클래스로부터 상속받기 때문에)
이때 vptr이 Derived클래스 virtual table을 가리키고 있다
따라서 b->Foo1()을 하게 되면 Foo1()이 가상함수임을 인지하고 vptr을 이용하여 entry를 검색 후 Derived클래스의 virtual table의 entry에는 Derived::Foo1() 함수 포인터가 있다는걸 알고 해당 함수를 호출하게 된다
위와 같은 과정이 있기 때문에 가상 함수를 호출하는건 비가상 함수를 호출하는것 보다 더 느리다
vptr을 이용하여 virtual table에 접근해야 하고 virtual table이 가지고 있는 entry 함수를 찾고 호출해야 하는 단계가 필요하기 때문이다
또한 위에서 정리했듯 가상 함수를 가지는 클래스는 vptr을 가지기 때문에 클래스 객체의 크기가 포인터 하나 크기만큼 증가한다
순수 가상 함수
순수 가상 함수란 가상 함수를 선언하고 정의가 없는 가상함수를 의미한다
정의를 하는 대신에 0을 할당하면 된다
class Base
{
public:
virtual void Foo1() = 0; //순수 가상 함수
};
이러한 순수 가상 함수의 역할은 이 함수의 정의는 자식 클래스의 책임이라고 선언하는 것이다
순수 가상 함수를 가진 클래스는 abstract class (추상 클래스)가 되어 해당 클래스 타입의 인스턴스를 만들 수 없게 된다 (인스턴스화 불가)
Base b1{}; //error
인스턴스화가 불가능한 이유는 생각해보면 쉽다, Base::Foo1()을 호출했을 때 의미가 없기 때문이다
이러한 순수 가상 함수를 상속받은 클래스에서는 이 함수를 반드시 override 해야 한다, 만약 override하지 않는다면 해당 자식 클래스도 추상 클래스로 간주된다
class Animal
{
public:
virtual void Speak() const
{
std::cout << "Animal speaks" << std::endl;
}
};
class Dog : public Animal
{
public:
virtual void Speak() const override
{
std::cout << "Dog barks" << std::endl;
}
};
class Cat : public Animal
{
public:
};
int main()
{
Dog d1{};
Cat c1{};
d1.Speak();
c1.Speak();
return 0;
}
위 코드는 Dog barks와 Animal speaks가 출력되게 된다, Cat클래스는 Dog클래스와 달리 Speak()을 override하지 않았기 때문이다
이렇게 함수를 override하는걸 잊게 되는 경우 의도치 않은 동작이 발생할 가능성이 높다
만약 Speak()이 순수 가상 함수였다면 위 코드는 컴파일 에러가 발생하여 동작하지 않았을 것이다
(순수 가상 함수를 override하지 않았기 때문에 추상 클래스가 되고 인스턴스화가 불가능해서 에러가 발생한다, 휴먼에러 방지에 좋다)
class Animal
{
public:
virtual void Speak() const = 0;
};
class Dog : public Animal
{
public:
virtual void Speak() const override
{
std::cout << "Dog barks" << std::endl;
}
};
class Cat : public Animal
{
public:
virtual void Speak() const override
{
std::cout << "Cat Meows" << std::endl;
}
};
int main()
{
Dog d1{};
Cat c1{};
d1.Speak();
c1.Speak();
return 0;
}
다른 함수와 마찬가지로 참조, 포인터를 사용하여 호출도 가능하다
Dog d1{};
Animal& anim{ d1 };
anim.Speak(); //Dog barks 출력
정의를 가지는 순수 가상 함수도 만들 수 있다, 단 클래스 외부에서 정의해야 한다
class Animal
{
public:
virtual void Speak() const = 0;
};
void Animal::Speak() const
{
std::cout << "Animal speaks" << std::endl;
}
class Dog : public Animal
{
public:
virtual void Speak() const override
{
std::cout << "Dog barks" << std::endl;
}
};
class Cat : public Animal
{
public:
virtual void Speak() const override
{
std::cout << "Cat Meows" << std::endl;
}
};
마찬가지로 Animal 클래스는 추상 클래스로 간주되고 Speak()은 순수 가상 함수로 간주된다, 그래서 Animal타입 객체 인스턴스도 만들 수 없다
이러한 방식은 부모 클래스가 해당 순수 가상 함수에 대한 기본 구현을 제공하되 반드시 override 시키고 싶을때 사용한다
만약 자식 클래스에서 부모 클래스의 순수 가상 함수 정의에 만족한다면 범위 지정 연산자를 이용하여 호출하면 된다
class Animal
{
public:
virtual void Speak() const = 0;
};
void Animal::Speak() const
{
std::cout << "Animal speaks" << std::endl;
}
class Dog : public Animal
{
public:
virtual void Speak() const override
{
Animal::Speak();
}
};
물론 추상 클래스도 virtual table을 가진다, 하지만 virtual table의 entry에는 순수 가상 함수(정의가 없어서 호출할 수 없음)의 주소값 대신 0이나 nullptr이 들어가게 된다 (어차피 이 entry 함수가 호출될 일이 없음, 인스터스화 되지 않기 때문에) 혹은 오류를 출력하는 함수 __purecall 함수의 주소를 가리킨다
Interface class
인터페이스 클래스는 멤버 변수가 없고 모든 멤버 함수가 순수 가상 함수인 클래스를 의미한다
결론적으로 이러한 인터페이스 클래스는 자식 클래스가 이러한 기능들을 반드시 구현해야 한다는 규약 클래스와 다름없다
보통 인터페이스 클래스는 I접두사를 사용한다
class IErrorLog
{
public:
virtual bool openLog(std::string_view filename) = 0;
virtual bool closeLog() = 0;
virtual bool writeError(std::string_view errorMessage) = 0;
virtual ~IErrorLog() {}
};
//이 IErrorLog클래스를 상속받는 클래스는 해당 함수들을 전부 구현해야 인스턴스화가 가능하다
인터페이스가 사용되면 좋은 상황은 보통 다음과 같다
FileErrorLog라는 log를 출력하는 특정 클래스가 있다고 가정해보자
void Foo(FileErrorLog& log)
{
log.writeError("TestLog");
}
이렇게 코드를 구현하게 되면 Foo()를 호출할 때 인자로 반드시 FileErrorLog클래스 타입만을 사용해야 한다는 단점이 있다
이를 인터페이스 클래스로 사용하게 되면 해당 인터페이스 클래스를 상속받는 모든 클래스를 인자로 사용이 가능해진다
class IErrorLog
{
public:
virtual bool openLog(std::string_view filename) = 0;
virtual bool closeLog() = 0;
virtual bool writeError(std::string_view errorMessage) = 0;
virtual ~IErrorLog() {}
};
class ConsoleErrorLog : public IErrorLog
{
public:
virtual bool openLog(std::string_view filename) override {};
virtual bool closeLog() override {};
virtual bool writeError(std::string_view errorMessage) override {};
};
void Foo(IErrorLog& log)
{
log.writeError("TestLog");
}
int main()
{
ConsoleErrorLog log{};
Foo(log); //IErrorLog라는 인터페이스 클래스를 상속받는 ConsoleErrorLog 클래스도 사용이 가능한 모습
return 0;
}
확장성과 유지보수가 좋기 때문에 굉장히 자주 사용되는 클래스이다
virtual base class
C++에서 다중상속은 다이아몬드 구조 문제가 발생할 수 있다고 정리했었다
class PoweredDevice {
public:
PoweredDevice(int power)
{
std::cout << "PoweredDevice: " << power << '\n';
}
};
class Scanner : public PoweredDevice {
public:
Scanner(int scanner, int power)
: PoweredDevice{ power }
{
std::cout << "Scanner: " << scanner << '\n';
}
};
class Printer : public PoweredDevice {
public:
Printer(int printer, int power)
: PoweredDevice{ power }
{
std::cout << "Printer: " << printer << '\n';
}
};
class Copier : public Scanner, public Printer {
public:
Copier(int scanner, int printer, int power)
: Scanner{ scanner, power }, Printer{ printer, power }
{
}
};
여기서 Copier는 Scanner, Printer를 다중 상속 받는 클래스이고 이를 그림으로 나타내면 다음과 같다

따라서 Copier객체를 생성하게 되면 PoweredDevice()가 2번 호출되고 Scanner가 1번, Printer()가 1번 , Copier()가 1번 호출되게 된다
결론적으로 Copier객체에는 PoweredDevice가 2개 존재한다는 의미이다
하지만 보통은 이러한 경우에 PoweredDevice는 1개만 있기를 원한다, 이럴때 virtual base class를 사용한다
virtual base class는 기본 클래스를 공유할 수 있게 해준다
class PoweredDevice
{
public:
PoweredDevice(int power)
{
std::cout << "PoweredDevice: " << power << '\n';
}
};
class Scanner : virtual public PoweredDevice
{
public:
Scanner(int scanner, int power)
: PoweredDevice{ power }
{
std::cout << "Scanner: " << scanner << '\n';
}
};
class Printer : virtual public PoweredDevice
{
public:
Printer(int printer, int power)
: PoweredDevice{ power }
{
std::cout << "Printer: " << printer << '\n';
}
};
class Copier : public Scanner, public Printer
{
public:
Copier(int scanner, int printer, int power)
: PoweredDevice{ power }, //공유 기본 클래스 생성자 호출
Scanner { scanner, power},
Printer{ printer, power }
{
}
};
Scanner와 Printer클래스가 PoweredDevice클래스를 상속받을 때 virtual로 상속받아 해당 클래스를 공유 기본 클래스로 만들었다 (쉽게 말해 Scanner객체와 Printer객체는 공유된 하나의 PoweredDevice객체를 사용한다)
이제 Copier객체를 생성하게 되면 Scanner, Printer가 공유하는 PoweredDevice 하나만 갖게 된다
그렇다면 여기서 생각해볼점은 Scanner, Printer가 하나의 PoweredDevice 기본 클래스를 공유한다면 누가 이 부모클래스인 PoweredDevice 객체를 생성할까?
바로 Copier에서 PoweredDevice객체를 생성하게 된다 (가장 최하위에 있는 파생 클래스가 공유 기본 클래스 객체를 생성할 책임이 있다)
따라서 Copier에서 공유 기본 클래스 생성자 호출이 가능해진다
결과는 PoweredDevice(), Scanner(), Printer(), Copiere()가 전부 1번씩 호출되게 된다
이때 최하위 클래스 생성자에서 다른 생성자보다 virtual base class생성자가 가장 먼저 호출된다 (다른 파생 클래스보다 가상 기본 클래스가 먼저 생성되어야 하기 때문)
또한 중간 클래스 생성자에서의 생성자 호출은 무시된다
위에서 보면 Scanner와 Printer클래스 생성자에서 PoweredDevice()를 호출하는데 이는 무시된다 (virtual base class이기 때문에 최하위 파생 클래스에서 이 base class 객체를 생성하는 책임이 있기 때문)
마지막으로 virtual base class를 상속받는 모든 클래스는 virtual table을 가지게 된다 따라서 해당 클래스 객체의 크기는 vptr크기만큼 크기가 더 커지게 된다
virtual table을 이용하여 공유하는 virtual base class의 멤버에 접근할 수 있다 (각 하위 클래스로부터 공유받은 virtual base class까지의 offset을 저장하기 때문에 접근이 가능)
이로인해 약간의 메모리 오버헤드가 발생한다
객체 잘림 현상
상속 관계에 있는 클래스 타입 객체를 참조나 포인터가 아닌 값으로 할당했을때 객체 잘림 현상이 발생한다
class Base
{
public:
Base(int inValue) : value{ inValue }
{
}
virtual int GetValue() const { return value; }
int value{};
};
class Derived : public Base
{
public:
Derived(int inValue) : Base{ inValue } {}
virtual int GetValue() const override { return value * 2; }
};
int main()
{
Derived d1{ 100 };
Base& refDer{ d1 };
Base* ptrDer{ &d1 };
std::cout << refDer.GetValue() << std::endl;
std::cout << ptrDer->GetValue() << std::endl;
return 0;
}
이 경우에는 refDer은 d1을 참조하고 ptrDer은 d1을 가리킨다
Derived클래스 타입 객체는 Base를 상속받기 때문에 Derived부분과 Base부분을 둘 다 가지고 있다
여기서 refDer, ptrDer은 결국에 Base타입이기 때문에 d1의 Base부분만을 볼 수 있다 (d1의 Derived부분은 존재하지만 볼 수 없음)
이때 GetValue()는 virtual function이기 때문에 실질적으로 함수 호출 타임에 가리키는 객체 타입을 파악하여 알맞는 함수를 호출하게 된다 (동적 바인딩)
하지만 여기서 &나 *가 아닌 값으로 할당하면 어떨까?
Derived d1{ 100 };
Base valDer{ d1 };
std::cout << valDer.GetValue() << std::endl;
분명 d1은 Base부분과 Derived부분을 둘 다 가지고 있지만 Base타입 객체에 값으로 대입될 때 Base부분만 복사되게 된다 (Derived부분은 복사되지 않음)
따라서 valDer은 d1의 Base부분만 복사된 사본을 받아 Derived부분은 잘려나가게 된 것이다 이를 Object Slicing이라고 한다
애초에 d1에서 Base부분만 복사된 사본 객체가 할당되었기 때문에 실질적으로 가리키는 객체 타입도 Base가 되어 Base::GetValue()가 호출되는 것이다
함수의 인자 전달 시 유의해야 한다
void Foo(const Base inBase)
{
std::cout << inBase.GetValue() << std::endl;
}
int main()
{
Derived d1{ 100 };
Foo(d1);
return 0;
}
인자가 Base 값타입이기 때문에 Object Slicing이 발생한다 따라서 가상함수라도 원하는대로 호출되지 않는다
이렇게 함수의 인자를 넘기는 과정에서 Object Slicing이 발생한다면 디버깅하기 굉장히 힘들어질 수 있다, 따라서 값 대신 참조나 포인터로 넘기는걸 강력하게 권장한다
std::vector에서의 객체 잘림
std::vector와 같은 컨테이너에 element를 객체로 추가할 때도 객체 잘림 현상이 발생할 수 있다
std::vector<Base> v{};
v.push_back(Base{ 5 });
v.push_back(Derived{ 6 });
for (const auto& element : v)
{
std::cout << element.GetValue() << '\n';
}
이 element.GetValue()는 전부 Base::GetValue()로 호출된다, 왜 그럴까?
std::vector의 element타입이 Base로 선언되었기 때문에 Derived{ 6 }을 element로 추가 시 객체 잘림이 발생한 것이다
std::vector< Base >는 Base객체만 저장할 수 있는 공간이기 때문이다
이러한 std::vector의 Object Slicing문제를 해결하기 위해 std::vector의 element타입을 참조로 사용하면 컴파일 에러가 발생한다
(std::vector의 element는 재할당이 가능해야 하기 때문, 참조는 재할당이 불가능하다)
따라서 포인터로 만들어 사용하는 방법이 있다
std::vector<Base*> v{};
Base b{ 5 };
Derived d{ 6 };
v.push_back(&b);
v.push_back(&d);
for (const auto& element : v)
{
std::cout << element->GetValue() << '\n';
}
이제 Object Slicing이 발생하지 않고 정상적인 가상 함수 호출이 된다
포인터를 사용하는 방법보다 더 권장하는 방법은 std::reference_wrapper를 사용하는 방법이다
이 std::reference_wrapper는 참조처럼 동작하는 클래스이지만 재할당이 가능하다
#include <functional>
std::vector<std::reference_wrapper<Base>> v{};
Base b{ 5 };
Derived d{ 6 };
v.push_back(b);
v.push_back(d);
for (const auto& element : v)
{
std::cout << element.get().GetValue() << '\n';
}
std::vector의 element를 참조처럼 동작하는 클래스인 std::reference_wrapper<>로 처리하여 값을 push_back할때도 더 편하고 문법적으로 깔끔해보이게 구현할 수 있다
반드시 get()으로 얻어온 후 멤버 함수를 호출해야 한다
그렇다면 다음과 같은 코드는 어떨까?
Derived d1{ 5 };
Derived d2{ 6 };
Base& b{ d2 };
b = d1;
Derived클래스 타입의 객체인 d1,d2 그리고 Derived클래스 타입 d2를 참조하는 Base&타입 b가 존재하고 b에 d1을 할당한다
b = d1에서 큰 문제가 발생한다
간단하게 생각해보면 b는 d2를 참조하고 있기 때문에 b = d1을 하게 되면 d2에 d1값이 복사될 것이라고 생각할 수 있지만 그렇지 않다
b는 Base&타입이고 operator=는 virtual이 아니기 때문에 Base::operator=가 호출되어 Derived부분은 잘리고 Base부분만 복사된다
이렇게 되면 d2는 d1의 Base부분과 d2의 Derived부분을 갖게 되어 굉장히 혼란스러운 상태가 된다 (굉장히 조심해야 한다)