
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이라고 한다.
그럼 결국, 객체의 타입을 결정하는 게 문제군?(컴파일 타임 vs 런타임)
특정 함수에 대해 동적 바인딩이 일어나게 하려면 C++에서는 base class의 함수를 선언할 때 virtual 키워드 필요.
동적 바인딩은 1) virtual 함수와 2) 그 함수가 존재하는 base class의 주소를 사용할 때만 일어남.
동적 바인딩은 어떻게 일어나는 걸까?
프로그래머가 virtual function을 호출하게 되면 컴파일러가 동적 바인딩이 일어나기 위한 과정을 수행한다.
가상 함수를 호출할 때 정확히 어떤 일이 일어나는지 살펴보자.

A는 Instrument의 포인터로 이뤄진 배열이다.
이 배열은 요소에 대한 특정 type 정보를 갖고 있지 않고 각 요소는 Instrument 타입의 객체를 가리키고 있다.
그러나 컴파일러는 이것들이 Instrument 객체라는 것 이상으론 모르기 때문에 기본적으론 base-class(Instrument) 버전의 함수를 호출하려고 한다.
하지만 모든 함수들이 virtual로 선언되어 있기 때문에 다른 일이 발생한다.
가상 함수를 선언한 클래스 또는 이를 파생한 클래스에는
VTABLE에 대한 VPTR가 초기화되면, 객체는 자신의 타입이 뭔지 "안다"
base class의 주소를 통해 가상 함수를 호출하려고 할 때 특별한 일이 벌어지는데,
어셈블리 언어에서 특정 함수를 호출하는 CALL을 호출하는 대신, 가상 함수 호출을 수행하기 위해 다른 코드를 생성한다.

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

C++의 함수 호출은 오른쪽에서 왼쪽으로 스택에 푸시되기 때문에 함수 인자인 1이 먼저 스택에 쌓인다.
regist si에 i에 대한 주소가 스택에 푸시된다.

만약에 adjust함수가 비가상 함수라면 vptr을 찾는 과정이 사라지기 때문에 2단계의 어셈블리 코드가 사라진다.
즉, 다음 2가지다.
반면에 비가상함수의 호출은 call adjust 부분에서 Brass의 adjust 함수의 주소를 바로 호출하기 때문에 vptr을 찾는 과정이 줄어들기 때문에 더 효율적이라고 할 수 있다.
Vpointer가 알맞는 VTABLE을 가리켜야 알맞는 가상 함수를 호출할 수 있기 때문에 초기화가 보장되는 게 중요하다고 할 수 있다.
이는 default 생성자가 수행하는데 더 자세한 내용은 추후에 다루도록 한다.
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이 일어난다.
즉, 컴파일 시 데이터의 타입을 정확히 안다면 당연히 그 객체의 함수를 호출할 것이고 주소인 경우에는 접근해야만 알 수 있기 때문에 컴파일 때는 모른다는 게 핵심이다.
컴파일 때는 해당 타입의 주소라는 것만 안다는 건데 왜 그럴까? 가 궁금해진다.
C++에서는 1개 이상의 순수 가상 함수만 있으면 추상(abstract) 클래스를 만들 수 있다. 순수 가상 함수는 다음과 같이 만들 수 있다 .
virtual return type function() = 0;
이 때 추상 클래스를 상속하면, 모든 순수 가상 함수는 subclass에서 구현되어야 하고 구현하지 않으면 상속한 클래스도 추상 클래스가 된다.
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는 인터페이스 역할이므로 순수 가상 함수를 선언하고 이를 상속 받은 클래스에서 순수 가상 함수의 구현을 맡겼다. 그리고 이를 실행해보면 다음과 같은 결과가 나온다.

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();
근데 이렇게 사용하게 되면 이미 타입을 컴파일 때 알 수 있기 때문에 가상 함수를 활용한다고 볼 수 없기 때문에 동적 바인딩을 활용할 가치가 없다.
이 때까지 다형성을 활용하기 위해 참조(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"가 되는 것이다.