C++로 Polymorphism과 가상 함수 이해하기

Kang Chang Hwan·2024년 6월 1일

Computer Science

목록 보기
4/5
post-thumbnail

Upcasting

Upcasting은 자식 클래스의 type을 부모 클래스의 type으로 취급할 수 있는 것을 말한다.

구체적으로는 포인터나 참조로 객체의 주소를 가지고 이를 base type의 주소로 취급할 수 있는 것을 upcasting이라고 한다.

간단한 예제를 통해 이해해보자.


enum note { middleC, Csharp, Eflat };

class Instrument {
public:
  void play(note) const {
    cout << "Instrument::play()" << endl;
  }
};

class Wind : public Instrument {
public:
  void play(note) const {
    cout << "Wind::play()" << endl;
  }
};

void tune(Instrument& i) {
  i.play(middleC);
}

int main () {
  Wind flute;
  tune(flute);

  return 0;
};

Wind 클래스는 Instrument를 상속한 클래스다. tune 메서드를 보면 Instrument 타입의 변수를 참조로 받고 있다.

main 함수에서 Wind 타입의 flute를 선언하여 Wind 객체를 초기화하고 이를 tune의 인자(Instrument의 참조)로 넘겨도 오류가 없음을 알 수 있다.

이렇게 객체의 주소(여기선 참조)를 base type의 주소(Instrument& i)로 다룰 수 있는 것을 upcasting이라고 한다.

The Problem

Function call binding

  • Connecting a function call to a function body is called binding
    • 함수 호출을 함수 body에 연결하는 걸 binding이라고 한다는데 이게 정확히 무슨 말임?
    • Early binding: 프로그램이 실행되기 전에 컴파일러와 링커에 의해 binding이 수행되는데 이를 얼리 바인딩이라고 함.
    • C compiler에서는 딱 한 종류의 함수 호출만 있었고 그게 얼리 바인딩이기 때문에 C programmer들은 못들어봤을 수 있음.
    • 위 문제는 컴파일러가 Instrument의 주소만 가지고 있을 때는 올바른 함수 호출을 알 수 없기 때문에 일어남.
      • 그럼 Instrument* wind = new Wind(); 는 뭐임? Wind의 주소를 들고 있는거 아닌가?
  • 솔루션은 late binding
    • 객체의 type에 따라 런타임에 바인딩이 일어나는 것
      • 즉, 런타임에 객체의 type을 결정하고 적절한 멤버 함수를 호출해야 함.

그럼 결국, 객체의 타입을 결정하는 게 문제군?(컴파일 타임 vs 런타임)

virtual functions

특정 함수에 대해 동적 바인딩이 일어나게 하려면 C++에서는 base class의 함수를 선언할 때 virtual 키워드 필요.

동적 바인딩은 1) virtual 함수와 2) 그 함수가 존재하는 base class의 주소를 사용할 때만 일어남.

  • 멤버 함수를 가상함수로 만드려면 멤버 함수 선언 앞에 virtual 키워드만 선언하면되고 정의는 하지 않아도 됌(구현은 안해도 됌)
  • 만약에 base class에 함수가 virtual로 선언되면, 파생된 클래스의 함수는 모두 가상함수임.
  • 파생된 클래스에서 virtual 함수를 재정의하는 것을 overriding이라고 부름.

How C++ implements late binding

동적 바인딩은 어떻게 일어나는 걸까?

프로그래머가 virtual function을 호출하게 되면 컴파일러가 동적 바인딩이 일어나기 위한 과정을 수행한다.

  • 컴파일러는 Virtual Table이라는 1개의 테이블을 생성하고
    • virtual 함수를 포함하는 각 class 별로 생성됨.
  • 컴파일러는 VTABLE에 특정 클래스의 가상 함수의 주소를 둔다.
  • 가상 함수를 가진 각 클래스에는 Virtual pointer(각 객체마다 VTABLE에 대한 포인터)를 두고
  • 프로그래머가 base-class의 포인터를 통해 가상 함수를 호출하면 컴파일러는 VPTR을 받아오는 코드를 삽입하고
  • VTABLE에서 해당 함수에 대한 주소를 찾는다.
  • 그렇게 함수의 주소를 호출해 올바른 객체의 함수를 호출하게 되어 동적 바인딩이 일어나게 되는 것이다.

Storing type infomation

  • C++에서는 객체가 고유한 주소를 가져야 하기 때문에(왜?)
  • 클래스에 멤버가 없으면 컴파일러가 "더미" 멤버를 강제로 추가함.
  • 근데 가상 함수가 선언된 클래스의 경우, 컴파일러가 vptr을 생성하기 때문에
    • 해당 클래스에 멤버가 없어도 VPTR을 통해 객체를 식별할 수 있음.

Picturing virtual functions

가상 함수를 호출할 때 정확히 어떤 일이 일어나는지 살펴보자.

  • A는 Instrument의 포인터로 이뤄진 배열이다.

  • 이 배열은 요소에 대한 특정 type 정보를 갖고 있지 않고 각 요소는 Instrument 타입의 객체를 가리키고 있다.

    • Wind, Percussion, Stringed, Brass는 전부 Instrument에서 파생된 클래스기 떄문에 Instrument와 같은 인터페이스를 가지며 동일한 메시지에 응답할 수 있다. 따라서 그들의 주소를 이 배열에 넣을 수 있다.
  • 그러나 컴파일러는 이것들이 Instrument 객체라는 것 이상으론 모르기 때문에 기본적으론 base-class(Instrument) 버전의 함수를 호출하려고 한다.

  • 하지만 모든 함수들이 virtual로 선언되어 있기 때문에 다른 일이 발생한다.

  • 가상 함수를 선언한 클래스 또는 이를 파생한 클래스에는

    • 컴파일러가 각 객체 별로 unique한 VTABLE을 생성한다.(위 그림의 오른쪽)
    • 그 테이블에는 클래스에 선언된 모든 가상 함수의 주소가 놓인다.
    • 만약에 base class의 가상 함수를 재정의하지 않았다면 base class의 가상 함수의 주소가 삽입된다. (위 그림에서 Brass VTABLE의 adjust 메서드)
    • 그리고 VPTR에 할당되는 주소는 VTABLE의 시작 주소를 할당해야 한다.
  • VTABLE에 대한 VPTR가 초기화되면, 객체는 자신의 타입이 뭔지 "안다"

    • 이 말은 VTABLE에는 자신의 타입 정보와 가상 함수 시그니처와 주소가 저장되기 때문에 이를 보고 자신의 객체를 알 수 있다는 말인 것 같다.

base class의 주소를 통해 가상 함수를 호출하려고 할 때 특별한 일이 벌어지는데,
어셈블리 언어에서 특정 함수를 호출하는 CALL을 호출하는 대신, 가상 함수 호출을 수행하기 위해 다른 코드를 생성한다.

  • 위 예시로 들어보자면,
    • 컴파일러는 Instrumnet의 포인터로 객체의 시작 주소를 가리킴.
    • 이 때 VPTR은 보통 객체의 시작 부분에 위치하므로 컴파일러는 객체에서 VPTR을 선택할 수 있음.
    • VPTR은 VTABLE의 시작 주소를 가리키고
    • 모든 VTABLE의 함수 주소는 객체의 특정 타입에 관계없이 동일한 순서로 나열된다.
      • 위 예시에서 컴파일러는 Wind의 adjust 메서드의 주소를 "절대 주소"가 아니라 "VPTR+2위치에서 함수를 호출해" 라는 코드를 생성한다.

내부에선 무슨 일이 발생하는 거냐?

가상 함수가 호출될 때 어떤 일이 벌어지는 지 이해하기 위해 어셈블리 코드를 살펴보자.

  1. C++의 함수 호출은 오른쪽에서 왼쪽으로 스택에 푸시되기 때문에 함수 인자인 1이 먼저 스택에 쌓인다.

  2. regist si에 i에 대한 주소가 스택에 푸시된다.

  • 이 때 si에는 i의 this에 대한 값으로 객체의 시작 주소가 담긴다.
  • 이 객체의 시작 주소는 vptr 이다.
  • this 값(객체의 시작 주소이자 vptr)이 스택에 푸시되는 이유는 어떤 객체의 함수를 호출하는지 알아야 하기 때문.
  1. 그리고 si에 있는 vptr(VTABLE의 시작 주소)가 레지스터 bx로 옮겨지고
  2. VTABLE 내 함수 포인터들의 주소를 결정한 다음(이게 가능한 이유는 클래스에 선언한 가상 함수의 순서를 보장함)
  3. call word ptr [bx+4]
  • 원하는 가상 함수의 주소를 호출한다.
  1. 호출 전에 푸시했던 인자들을 없애고 스택 포인터가 다시 돌아온다.

그럼 비가상함수랑은 무슨 차이가 있는 걸까?

만약에 adjust함수가 비가상 함수라면 vptr을 찾는 과정이 사라지기 때문에 2단계의 어셈블리 코드가 사라진다.

즉, 다음 2가지다.

  • push si
  • mov bx, word ptr [si]

반면에 비가상함수의 호출은 call adjust 부분에서 Brass의 adjust 함수의 주소를 바로 호출하기 때문에 vptr을 찾는 과정이 줄어들기 때문에 더 효율적이라고 할 수 있다.

Installing the vpointer

Vpointer가 알맞는 VTABLE을 가리켜야 알맞는 가상 함수를 호출할 수 있기 때문에 초기화가 보장되는 게 중요하다고 할 수 있다.

이는 default 생성자가 수행하는데 더 자세한 내용은 추후에 다루도록 한다.

Objects are different

ealry binding과 late binding을 코드 예시를 통해 이해해보자!

class Pet {
public:
  virtual void speak() { cout<< "" << endl; }
};

class Dog : public Pet {
public:
  void speak() override { cout << "멍멍" << endl; }
};

int main() {
  Pet ralph;
  
  Pet* p1 = &ralph;
  Pet& p2 = ralph;
  Pet p3 = Pet();
  
  // Late-binding
  p1->speak();
  p2.speak();
  
  // early-binding
  p3.speak(); 
  
  return 0;
};

핵심은 upcasting은 주소를 통해서만 일어난다는 것이다. p3의 경우에는 컴파일러가 컴파일 시 Pet이라는 type을 정확히 알기 때문에 late binding이 일어나지 않는다.

하지만 p1과 p2는 Pet type의 주소이거나 Pet에서 파생된 무언가일 수도 있다는 것이기 때문에 late binding이 일어난다.

즉, 컴파일 시 데이터의 타입을 정확히 안다면 당연히 그 객체의 함수를 호출할 것이고 주소인 경우에는 접근해야만 알 수 있기 때문에 컴파일 때는 모른다는 게 핵심이다.

컴파일 때는 해당 타입의 주소라는 것만 안다는 건데 왜 그럴까? 가 궁금해진다.

Abstract base classes and pure virtual functions

C++에서는 1개 이상의 순수 가상 함수만 있으면 추상(abstract) 클래스를 만들 수 있다. 순수 가상 함수는 다음과 같이 만들 수 있다 .

virtual return type function() = 0;

이 때 추상 클래스를 상속하면, 모든 순수 가상 함수는 subclass에서 구현되어야 하고 구현하지 않으면 상속한 클래스도 추상 클래스가 된다.

순수 가상 함수를 선언하면 VTABLE에는 무슨 일이 일어날까?

VTABLE에 슬롯은 채우지만 해당 함수의 주소는 넣지 않는다. 그래서 이를 객체로 만드는 건 불완전하기 때문에(함수의 주소가 없는 함수를 호출하면 ..)

컴파일러는 에러 메시지를 내놓는다.

이게 추상 클래스의 인스턴스화가 불가능한 이유였다!

class Instrument {
public:
  virtual void play(note) const = 0;
  virtual string what() const  = 0;
  virtual void adjust(int) = 0;
};

class Wind : public Instrument {
public:
  void play(note) const {
    cout << "Wind::play" << endl;
  }
  string what() const {
    return "Wind";
  }
  void adjust(int) {}
};

class Percussion : public Instrument {
public:
  void play(note) const {
    cout << "Percussion::play" << endl;
  }
  string what() const {
    return "Percussion";
  }
  void adjust(int) {}
};

class Stringed : public Instrument {
public:
  void play(note) const {
    cout << "Stringed::play" << endl;
  }
  string what() const {
    return "Stringed";
  }
  void adjust(int) {}
};

// adjust 메서드를 재정의하지 않았지만 에러 X
// 상속 위계에서 가장 가까운(Wind) 해당 함수의 정의(definition)가 자동으로 사용됨.
// 컴파일러가 가상 함수의 바인딩을 보장함.
class Brass : public Wind {
public:
  void play(note) const {
    cout << "Brass::play" << endl;
  }
  string what() const {
    return "Brass";
  }
};

void tune(Instrument& i) {
  i.play(middleC);
}

void f(Instrument& i) { i.adjust(1); }

int main() {
  Wind flute;
  Percussion drum;
  Stringed violin;
  Brass flugelhorn;
  tune(flute);
  tune(drum);
  tune(violin);
  tune(flugelhorn);

  return 0;
};

Instrument는 인터페이스 역할이므로 순수 가상 함수를 선언하고 이를 상속 받은 클래스에서 순수 가상 함수의 구현을 맡겼다. 그리고 이를 실행해보면 다음과 같은 결과가 나온다.

Inheritance and the VTABLE

만약에 상속을 받은 클래스에 새로운 가상 함수를 추가한다면 어떻게 될까?

class Pet {
  string pname;
public:
  Pet(const string& petName) : pname(petName) {}
  virtual string name() { return pname; }
  virtual string speak() { return ""; }
};

class Dog : public Pet {
public:
  Dog(const string& petName) : Pet(petName) {}
  virtual string sit() {
    return Pet::name() + " sits";
  }
  string speak() override {
    return Pet::name() + " says 'Bark'!";
  }
};

int main() {
  
  Pet* p[] = { new Pet("generic"), new Dog("bob") };
  cout << "p[0]->speak()= " << p[0]->speak() << endl;
  cout << "p[1]->speak()= " << p[1]->speak() << endl;
  //! cout << "p[1]->sit()= " << p[1]->sit() << endl;
  return 0;  
};

구조는 Dog 클래스는 Pet을 상속받아 Pet class의 가상 함수인 name은 그대로 speak은 재정의하여 사용하였고 sit()이라는 가상 함수를 추가했다.

그럼 Pet과 Dog의 VTABLE은 다음과 같이 생긴다.

위에서 배운 것처럼 base class의 가상 함수의 순서와 이를 상속 받은 클래스의 가상 함수의 순서가 VTABLE에 똑같이 위치한다.

그리고 만약에 Dog를 상속한 Sichu라는 클래스는 Dog의 VTABLE의 가상 함수의 순서와 똑같이 형성될 것이다.

하지만 위에서 볼 수 있었듯이 base class의 포인터 또는 참조를 이용해 동적 바인딩이 일어나므로 base class의 인터페이스 부합하는 가상 함수만 동적 바인딩이 가능하다.

그 이유는 컴파일러는 컴파일 타임에 Pet 포인터에 어떤 타입의 객체가 들어있는지 모르고 만약에 Dog가 아닌 다른 객체라면 그 객체의 VTABLE의 Sit(Vptr+2)에 접근해 함수를 호출할 것이다. 그럼 다른 결과값이 나올 것이므로 컴파일러는 이를 막아서 안전성을 보장한다.

꼭 이렇게 써야하는 경우에는 포인터의 타입을 캐스팅하는 방법을 취할 수 있다.

(Dog*)p[1]->sit();

근데 이렇게 사용하게 되면 이미 타입을 컴파일 때 알 수 있기 때문에 가상 함수를 활용한다고 볼 수 없기 때문에 동적 바인딩을 활용할 가치가 없다.

Object slicing

이 때까지 다형성을 활용하기 위해 참조(reference)나 포인터로 함수의 인자를 활용해 왔다. 즉, 파생된 클래스의 객체의 주소의 크기와 base 클래스의 객체의 주소의 크기가 같기 때문에 가능했다.

이게 무슨 말일까?
[ 기반 클래스 객체의 메모리 구조 ]
기반 클래스 객체의 주소 (예: 0x100) -> [기반클래스 멤버1][기반클래스 멤버2]...[기반클래스 멤버N]

[ 파생 클래스 객체의 메모리 구조 ]
파생 클래스 객체의 주소 (예: 0x200) -> [기반클래스 멤버1][기반클래스 멤버2]...[기반클래스 멤버N][파생클래스 멤버1][파생클래스 멤버2]...[파생클래스 멤버M]

base class나 derived class 객체의 주소의 크기는 같다. 따라서 base type의 포인터로 derived type의 주소를 가리켜도 크기가 같기 때문에 문제가 없다.

그럼 값(value)으로 객체를 함수 인자에 전달하면 어떤 일이 벌어질까?

class Pet {
  string pname;
public:
  Pet(const string& name) : pname(name) {}
  virtual string name() const { return pname; }
  virtual string description() const {
    return "This is " + pname;
  }
};

class Dog : public Pet {
  string favoriteActivity;
public:
  Dog(const string& name, const string& activity) : Pet(name), favoriteActivity(activity) {}
  string description() const {
    return Pet::name() + " likes to " + favoriteActivity;
  }
};

void describe(Pet p) {
  cout << p.description() << endl;
}

int main() {
  Pet p("Alfred");
  Dog d("Fluffy", "sleep");
  describe(p);
  describe(d);

  return 0;
};

위 코드에서 describe 함수는 인자로 Pet 타입의 인자 p를 값으로 받고 있다. 그리고 실행해보면..

Dog의 description 함수가 아니라 Pet의 description 함수가 실행됨을 확인할 수 있었다.

그 이유는 describe()함수를 호출할 때 Pet 크기 만큼의 객체가 stack에 push되기 때문이다. 즉, 값으로 전달 시 컴파일러는 Pet 객체의 크기 만큼(위에서 Dog.favortiteActivity 제외)만 복사한다.

근데, virtual은 Dog::describe()를 호출해서 "Fluffy is likes to"가 출력되어야 하는 거 아님?

이 때 값으로 파생된 클래스의 객체를 전달하면 그 복사본이 함수 인자에 할당되는데 함수 인자는 base type(Pet)으로 선언되어 있기 때문에 Base class의 복사 생성자가 호출되어 base의 vptr이 복사되어

위 코드에서 description(d(Dog))의 경우의 출력값이 "This is Fluffy"가 되는 것이다.

profile
아쉬움 없이 살자. 모든 순간을 100%로!

0개의 댓글