가상함수와 동적바인딩

Junhyeok Yun·2022년 10월 20일
0

C++

목록 보기
3/5
post-thumbnail

오늘의 목표

C++의 오버라이딩에서 가상함수를 이용한 동적바인딩이 어떤 원리로 작동하는지 알아보자


오버라이딩(Overriding)

오버라이딩은 파생 클래스에서 기본 클래스에 작성된 가상 함수를 재작성하여, 기본 클래스에 작성된 가상 함수를 무시하고, 재정의된 파생 클래스의 함수를 사용하는 것을 말한다.

기본 클래스의 가상 함수는 상속받는 파생 클래스에서 구현해야 할 일종의 함수 인터페이스를 제공하는 역할을 한다. 즉, 가상 함수는 하나의 인터페이스에 대해 서로 다른 모양의 구현 이라는 객체 지향 언어의 다형성(polymorphism)을 실현하는 도구이다.

오버라이딩의 특징을 오버로딩, 함수 재정의와 비교하여 표로 나타내면 다음과 같다.

그렇다면 함수 재정의 시 기본 클래스의 함수는 무시되지 않는데 왜 오버라이딩 시 기본 클래스의 함수는 무시되는 걸까?

그것은 virtual 키워드가 컴파일러에게 자신에 대한 호출 바인딩을 실행 시간까지 미루도록 지시하기 때문이다.


정적 바인딩 vs. 동적 바인딩

그렇다면 바인딩이란 무엇일까?

바인딩(binding)이란 프로그램에 사용된 구성 요소의 실제 값 또는 프로퍼티를 결정짓는 행위를 의미한다. 예를 들어 변수의 이름, 타입, 자료값 각각에 구체적인 값을 할당하는 과정이나 함수를 호출하는 부분에서 실제 함수가 위치한 메모리를 연결하는 것도 바로 바인딩이다.

바인딩은 실행되는 시간에 따라 두 가지로 분류할 수 있다.

  1. 정적 바인딩(static binding) : 컴파일 타임에 일어나고, 런타임에는 변하지 않은 상태로 유지되는 바인딩.
    C++에서 가상 함수가 아닌 함수를 호출하는 코드는 컴파일 타임에 고정된 메모리 주소로 변환되는데 이것이 정적 바인딩 중 하나이다.

  2. 동적 바인딩(dynamic binding) : 런타임에 일어나거나 변경되는 바인딩.
    런타임에 변수의 자료형을 결정하는 Python이나, 가상함수를 예로 들 수 있다.

즉, 가상 함수를 호출하는 코드를 컴파일할 때(virtual 키워드를 만나면), 컴파일러는 바인딩을 실행 시간에 결정하도록 미루어둔다. 그리고 나중에 가상 함수가 호출되면, 실행 중에 객체 내에 오버라이딩된 가상 함수를 동적으로 찾아 호출하는데, 이 때 파생 클래스에 오버라이딩된 가상 함수가 호출되는 것이다.
(단, 파생 클래스가 아닌 기본 클래스의 객체에 대해서는 가상 함수가 호출된다고 하더라도 객체 내에 오버라이딩된 가상 함수가 없기 때문에 그냥 가상 함수가 그대로 호출된다.)

그렇다면 컴파일러는 어떻게 실행 중에 기본 클래스의 가상 함수가 아닌 파생 클래스의 오버라이딩된 가상 함수를 찾아서 호출하는 것일까?


가상함수 테이블(virtual function table)

C++에서는 가상 함수의 정의와 동작 방식만을 규정하고 있으며, 그에 따른 구현은 컴파일러마다 다르다. 하지만 컴파일러가 가상 함수를 다루는 가장 일반적인 방식은 가상 함수 테이블을 이용하는 것이다.

C++ 컴파일러는 가상 함수를 단 하나라도 가지는 클래스에 대해서 가상 함수 테이블을 작성하는데 이 가상 함수 테이블에는 해당 클래스의 객체들을 위해 선언된 가상 함수들의 주소가 저장되게 된다.
즉, 가상 함수 테이블은 가상 함수들의 배열이라고 할 수 있는데, 이 때, 가상 함수의 개수에 상관 없이 가상 함수 테이블은 한 개 생성되며 클래스의 숨겨진 멤버로 가상 함수 테이블을 가리키는 포인터를 삽입한다.

그 다음 프로그램 실행 중에 가상 함수를 호출하면, C++ 프로그램은 가상 함수 테이블에 접근하여 자신이 필요한 함수의 주소를 찾아 호출하는 것이다.

가상 함수를 사용하면 이처럼 함수의 호출 과정이 복잡해지므로, 메모리와 실행 속도 측면에서 약간의 부담을 가지게 된다. 따라서 C++에서 기본 바인딩은 정적 바인딩이며, 필요한 경우에만 가상 함수로 선언하여 동적바인딩을 사용하게 된다.

이제 코드를 실행하면서 직접 확인해보자.

먼저,

  • 멤버 변수 한 개와 일반 멤버 함수 한 개가 존재하는 클래스
  • 멤버 변수 한 개와 가상 멤버 함수 한 개가 존재하는 클래스
  • 멤버 변수 한 개와 가상 멤버 함수 3개가 존재하는 클래스

이 세 클래스를 각각 비교하여 가상 함수 테이블이 클래스에서 얼마만큼의 크기를 차지하는지, 그리고 가상 함수의 개수가 늘어남에 따라서 가상 함수 테이블의 크기가 변하는지 확인해보도록 하자.

#include <iostream>
using std::cout;
using std::endl;

class Test1 {
    int num;
    void f1();
};

class Test2 {
    int num;
    virtual void f2();
};

class Test3 {
    int num;
    virtual void f3();
    virtual void f4();
    virtual void f5();
};

int main() {
    cout << "멤버 변수 1, 일반 멤버 함수 1 클래스의 크기 = " << sizeof(Test1) << endl;
    cout << "멤버 변수 1, 가상 멤버 함수 1 클래스의 크기 = " << sizeof(Test2) << endl;
    cout << "멤버 변수 1, 가상 멤버 함수 3 클래스의 크기 = " << sizeof(Test3) << endl;
    return 0;
}

실행 결과 :

여기서 알고 넘어가야할 점은, 클래스의 static 멤버는 Data 영역, 멤버 함수는 Text(code)영역에 존재하므로 객체(클래스)의 크기에 영향을 미치지 않고, 비 static 멤버 변수만 Stack 영역에 존재하므로 객체(클래스)의 크기에 영향을 미친다는 것이다.

따라서 Test1 클래스의 크기가 int형 멤버 변수의 크기인 4byte인 것을 볼 수 있다.

그 다음 Test2, Test3 클래스를 비교했을 때 크기에 차이가 없는 것으로 보아, 가상 함수의 개수에 상관 없이 가상 함수 테이블 하나를 생성하고 그 가상 함수 테이블을 가리키는 포인터를 클래스의 숨겨진 멤버로 삽입한다는 사실을 확인해볼 수 있다.

그런데 여기서 이상한 점이 있다. 포인터의 크기는 x64 시스템에서 8byte인데 int형 멤버 변수 한 개와 가상 함수 테이블을 가리키는 포인터 한 개를 가진 클래스의 크기가 4 + 8 = 12byte가 아닌 16byte로 출력된다는 점이다. 그 이유가 무엇일까?

바이트 패딩(Byte Padding)

바이트 패딩이란 클래스 또는 구조체에 사용하지 않는 더미 바이트를 추가하여 CPU가 메모리에 접근하는 횟수를 줄여주는 기법이다.

무슨 뜻인지 그림으로 확인해 보자.

위와 같은 그림의 메모리에서 바이트 패딩 없이 클래스 Test2의 객체를 할당한다면

위와 같이 할당될 것이다.
x64 CPU는 메모리에서 한 번에 64bit, 즉 8byte 단위로 접근하여 연산한다. 그렇다면 int형 멤버 변수 num에 값을 읽어오는 연산을 한다고 가정했을 때,

위 처럼 Test2 객체의 시작 주소부터 8byte를 가져와서 그 안에 존재하는 num에 접근하여 값을 읽어올 것이다. 여기 까지는 문제가 없다.

그러나 가상 함수를 사용하기 위해 가상 함수 테이블 포인터에 접근한다고 가정하면, 가져온 8byte의 정보 안에는 가상 함수 테이블 포인터의 정보가 다 들어있지 않으므로

위와 같이 그 다음 8byte를 한 번 더 가져와서 포인터의 값을 읽을 수 밖에 없다. 이 경우, 가상 함수 테이블 포인터에 접근하기 위해 CPU는 두 번이나 메모리에 접근해야 한다.

하지만, 위 처럼 int형 변수 num과 가상 함수 테이블 포인터 사이에 사용하지 않는 더미 바이트 4byte를 집어넣게 된다면, 즉 8byte 단위에 멤버가 하나씩만 존재하도록 한다면

메모리는 다소 낭비되지만 한 번의 CPU 연산으로 하나의 멤버에 접근할 수 있게 되므로 성능이 향상된다.

따라서 클래스나 구조체에서 멤버를 메모리에 저장할 때는 위와 같은 바이트 패딩을 사용하는 것이다.

단, 정확히 말하면 8byte 단위로 끊어서 저장하는 것은 위의 경우와 같이 클래스의 멤버 변수 중 가장 크기가 큰 변수의 크기가 8byte 일 때 해당하고, 만약 4byte 크기의 변수가 가장 크다면 4byte 단위로 끊어서 저장한다. 그 이유는 낭비되는 더미 바이트를 줄이면서도 CPU의 메모리에 대한 접근 횟수를 줄일 수 있기 때문이다. (한 번 접근 시, 두 개의 4byte 변수에 접근 가능) 즉, 변수 타입의 크기와 8byte 중 작은 쪽의 최소 배수가 되도록 조정한다.

실제로 x64 시스템에서 클래스에 char, int형 변수만 넣고 오프셋을 조사해보면

#include <iostream>
using std::cout;
using std::endl;

class Test {
public:
    char c;
    int i;
};

int main() {
    long offsetChar = (long)(&(((Test*)0)->c));
    long offsetInt = (long)(&(((Test*)0)->i));
    
    cout << "char형 멤버 변수 c의 offset = " << offsetChar << endl;
    cout << "int형 멤버 변수 i의 offset = " << offsetInt << endl;
    return 0;
}

위와 같음을 확인할 수 있다.

그렇다면 다시 가상 함수 테이블 얘기로 돌아와서 이제 디버깅을 통해 가상 함수 테이블의 존재를 직접 확인해보도록 하겠다.

#include <iostream>
using std::cout;
using std::endl;

class Car {
protected:
	virtual void excel() { cout << "Go" << endl; }
    virtual void breaks() { cout << "Stop" << endl; }
    virtual void madeIn() { cout << "-" << endl; }
};

int main() {
	Car car;
    return 0;
}

위의 코드에서 main함수 종료 전에 브레이크 포인트를 걸고 디버깅한 결과

위와 같이 car 객체 안에 직접 추가하진 않았지만, 가상 함수들의 주소를 배열의 형태로 저장하고 있는 가상 함수 테이블과 그것을 가리키는 포인터 _vfptr 의 존재를 확인할 수 있다.

그렇다면 가상 함수 테이블을 어떻게 활용하여 기본 클래스의 가상 함수가 아닌 파생 클래스의 오버라이딩된 가상 함수를 찾아서 호출하는지 확인하기 위해 Car 클래스를 상속하는 Porsche 클래스를 새로 생성하고, Car 타입의 포인터로 Porsche 객체를 업캐스팅하여 Car 객체와 비교해 보자.

#include <iostream>
using std::cout;
using std::endl;

class Car {
protected:
	virtual void excel() { cout << "Go" << endl; }
    virtual void breaks() { cout << "Stop" << endl; }
    virtual void madeIn() { cout << "-" << endl; }
};

class Porsche : public Car {
public:
	void madeIn() { cout << "Germany" << endl; }
};

int main() {
	Car* car = new Car;
    Car* boxster = new Porsche;
    return 0;
}

가상 함수 테이블을 총 3개 확인할 수 있는데, 위에서부터 차례대로 car 객체의 가상함수 테이블, boxster 객체의 기본 클래스(Car 클래스) 가상 함수 테이블, boxster 객체의 가상 함수 테이블이다.

이 때, 주목해야 할 점은 바로 boxster 객체의 기본 클래스 가상 함수 테이블이다. 재정의 되지 않은 excel() 함수와 breaks() 함수는 그대로지만, 파생 클래스 Porsche에서 재정의 된 madeIn() 함수는 원래 Car::madeIn() 함수의 주소값 0x002711b8 이 저장되어있어야 할 인덱스 2번 자리가 Porsche::madeIn() 함수의 주소값 0x00271447 로 덮어씌워져 있다.

즉, 다시말해 컴파일러가 상속 관계에 있는 기본 클래스를 다룰 때, 파생 클래스에 재정의된 함수가 있는지 확인한 뒤, 없다면 그대로 두고 있다면 가상 함수 테이블에 해당 함수 주소를 재정의된 함수의 주소로 다시 바인드 하여 상속함을 알 수 있다.

이러한 원리로 오버라이딩 시 기본 클래스의 가상 함수가 무시되는 것이다.


Reference

profile
개발 공부 일지

0개의 댓글