시작하기 앞서...
C++은 기본적으로 프로그램의 속도를 위하여, 복잡한 형태를 가진 기능인 경우, 실행 시간의 처리보다는 컴파일을 하였을 때, 처리하는 경우가 다반사입니다.
// 대표적인 예시들
constexpr int a = 0; // constexpr은 const와 달리, 컴파일을 하였을 때, 상수가 결정된다.
// cosntexpr int a; // 컴파일을 할 때, 상수가 결정되지 않았기에 틀림
std::vector<int> a; // 제너릭 프로그래밍 같은 경우에도, 해당 타입을 컴파일 할 때, 추가되는 방식이다.
그러나, C++에서는 RTTI 기능이 존재하기에, 굳이 컴파일 타임 말고도, 런타임에서 결정화시키도록 진행한 것이 있습니다.
그럼 한번 알아볼까요?
RTTI
RTTI는 RunTime Type Informaion의 약자로, 말그대로 런타임 때, 객체의 자료형에 대한 정보를 결정하는 것입니다.
대표적으로 이러한 RTTI 기술이 접목 되어있는 곳이, dynamic_cast<>와 typeid가 존재합니다.
dynamic_cast<>
기존의 C언어의 캐스팅 방식은 크게 어렵지 않습니다. 컴파일 시간에서 캐스팅 안전 검사 이후, 캐스팅을 진행하죠.
그러나, 객체지향 프로그래밍에서 문제가 하나 발생합니다.
예시로 코드를 작성해봅시다.
class A {
protected:
int a;
public:
A() { }
int getA() { return a; }
};
class B : public A {
protected:
int b;
public:
B() { }
int getB() { return b; }
}
이런식으로 클래스를 만들어봅시다.
B라는 클래스는 A라는 부모 클래스로부터 상속을 받은 상태입니다.
만약 A클래스의 포인터가 B라는 클래스를 가르키게 되면, 어떤 현상이 발생할까요?
int main(void){
B b;
A *pb = &b;
}
사실 그리 큰 문제는 아닙니다. b라는 파생 클래스에 있던, 메소드, 변수를 사용하지 못 하는 문제만 제외를 한다면, 그리 큰 문제는 없습니다.
왜냐하면, 해당 가르키는 객체의 메소드를 가르키는 것이 없을 뿐이기 때문이죠.
A B
a -> a
NONE -> b
getA() -> getA
NONE -> getB
이런식으로 말이죠.
이런 경우에는 업캐스팅이라 부릅니다
하지만 반대로, B클래스가 A 클래스의 객체를 가진다면 어떤 현상이 발생할까요?
int main(void){
A a;
B *pa = &a; // 잘 작동이 될까?
}
아까 위에처럼 하나씩 표시해볼까요?
B A
a -> a
b -> NONE
getA() -> getA()
getB() -> NONE
A 클래스의 객체에는 b와 getB()가 존재하지 않습니다.
즉 B클래스 기준으로 존재하는 요소들은 A 클래스에서 정의조차 안 되어있기에, 오류가 발생하는 경우가 나올 수 있습니다.
이런 경우에는 단순히 정의 되지 않은 객체의 요소에 대해서, 문제가 안 발생할 수 도 있으나, 대부분은 사용가능한 위에 있는 업캐스팅과는 달리, 다운캐스팅은 상황에 따라서, 사용을 할 수 없는 경우도 존재합니다.
이러한 경우를 대비하기 위하여, 캐스팅에 대한 안전 검사와 캐스팅을 런타임 도중에 체크하기 위하여, 사용하는 것이 dynamic_cast<>입니다.
한마디로, 형변환을 런타임에 시도하는, RTTI의 대표적인 예시이죠.
단점
이렇게 안전하고, 좋은 RTTI가 C++에서는 제일 부각이 되는 단점 중에 하나라는 것을 알고계신가요?
바로 프로그램의 퍼포먼스의 저하 때문입니다.
위에서도 언급하였듯이 RTTI는 런타임 때, 객체의 자료형에 대한 정보를 정합니다.
이는 퍼포먼스 중심으로 생각하면, 안 좋은 것입니다.
예를 들어, A라는 객체의 정보를 컴파일 타임에 결정을 한다면, 컴파일이 완료된 시점, 즉 런타임에는 영향이 없습니다.
이미 만들어져있기 때문이죠.
하지만, 런타임 타임에 결정을 한다면, 컴파일에서 결정된 것이 아니기에, 실행하는 도중에 결정을 하기 때문에, 그 결정을 위해 사용되는 코드들로 인해, 속도가 감소될 수 밖에 없습니다.
장점
대표적인 장점은 객체에 대한 다형성을 부여할 수 있습니다.
다형성이란, 간단하게 설명하면, 캐스팅이라고 할 수 있습니다.
좀 더 복잡하게 설명하자면, 프로그래밍 언어의 요소들이 다양한 자료형에 속하는 것이 허용되는 성질입니다.
만약 어떠한 파생 클래스에서 인스턴스를 작동할 때는 포인터 자료형을 기준으로 하는 것이지, 가르키는 객체의 자료형을 기준으로 하지 않습니다.
이는 결국 A라는 포인터 자료형으로 파생 클래스인 B를 가르켜봤자, A 포인터 자료형을 기준으로 생성이 될 것입니다.
이러한 측면 등, 런타임에서 즉석으로 처리를 진행하기에, 컴파일 타임에서 처리하는 것과는 달리 더욱 안전할 수도 있습니다.
가상함수
위에서 나온 가르키는 객체에 대한 기준으로 하기위하여, 만든 것이 가상함수입니다.
virtual (함수);
이런식으로 작성하며, 클래스 내부에서 작성이 가능합니다.
예를들어
class Calculate {
public:
int calc(int a, int b){
return 1;
}
}
class Plus : public Calculate {
public:
int clac(int a, int b){
return a + b;
}
}
class Minus : public Calculate {
public:
int calc(int a, int b){
return a - b;
}
}
int main(){
Calculate a;
Plus b;
Minus c;
std::cout << a.calc(1, 2) << std::endl;
std::cout << b.calc(1, 2) << std::endl;
std::cout << c.calc(1, 2) << std::endl;
}
라고 작성한다면 당연히, 각각의 변수의 타입으로, calc는 오버라이딩 된 상태로, 각각 1, 3, -1이 출력될 것입니다.
그러나 만약, 포인터 변수라면 어떻게 될까요?
#include <iostream>
class Calculate {
public:
int calc(int a, int b){
return 1;
}
};
class Plus : public Calculate {
public:
int clac(int a, int b){
return a + b;
}
};
class Minus : public Calculate {
public:
int calc(int a, int b){
return a - b;
}
};
int main(){
Minus* a = new Minus();
Plus *b = new Plus();
Calculate *c = b;
std::cout << c->calc(1, 2) << std::endl;
c = a;
std::cout << c->calc(1, 2) << std::endl;
}
이것의 작동 방식은 어떻게 될까요?
Calculate 객체 그대로 값이 나온다는 것을 알 수 있습니다.
즉 객체를 포인터 변수로, 받는다고 가정하면, 그 포인터 변수의 기준은 포인터 변수 타입으로 됩니다.
이는 만약 A라는 객체를 상속받고 있는 B에 재정의된 함수 호출을 원한다면, 이는 잘 못 될 수 밖에 없습니다.
이는 다형성을 무시하기 때문입니다.
당연히 객체마다 종류가 달라지기에, 런타임에서야 그 객체의 정보를 토대로, 함수를 재정의를 하는 방식입니다.
그럼 지금까지 나온 것을 토대로, 가상함수를 만들어봅시다.
#include <iostream>
class Calculate {
public:
virtual int calc(int a, int b){
return 1;
}
};
class Plus : public Calculate {
public:
virtual int clac(int a, int b){
return a + b;
}
};
class Minus : public Calculate {
public:
virtual int calc(int a, int b){
return a - b;
}
};
int main(){
Minus* a = new Minus();
Plus *b = new Plus();
Calculate *c = b;
std::cout << c->calc(1, 2) << std::endl;
c = a;
std::cout << c->calc(1, 2) << std::endl;
delete b;
delete c;
}
이를 실행해본다면, 가르키는 객체의 정보로 결정이 되어야 하기에, 첫번째와 같은 결과가 나옵니다.
결과창에서 오버라이딩이 잘 되었음을 알 수 있었습니다.
순수 가상 함수
순수 가상 함수란 몸체, 즉 정의가 되지 않은 가상 함수를 의미합니다.
자바에서 abstract와 비슷한 역할입니다.
위에서 Calculate에 있는 calc 함수는 그저 정의를 위하여, 자식 클래스들의 오버라이딩을 위하였을 뿐이지, 사용 목적이 아닙니다.
즉, 이러한 불편한 상황을 막기위하여, 나온 것이 순수 가상 함수 입니다.
virtual (함수) = 0;
이런식으로 정의를 하시면 됩니다.
일반적으로 생각을 해본다면, 해당 문법은 말이 안 될 것입니다.
그냥 C++에서 순수 가상 함수를 정의하기 위하여, 사용하는 것이라고 생각하시면 됩니다.
#include <iostream>
class Calculate {
public:
virtual int calc(int a, int b) = 0;
};
class Plus : public Calculate {
public:
virtual int calc(int a, int b){
return a + b;
}
};
class Minus : public Calculate {
public:
virtual int calc(int a, int b){
return a - b;
}
};
int main(){
Minus* a = new Minus();
Plus *b = new Plus();
Calculate *c;
c = a;
std::cout << c->calc(1, 2) << std::endl;
c = b;
std::cout << c->calc(1, 2) << std::endl;
delete a;
delete b;
}
부모 클래스에서 함수의 원형만 작성하였기에, 아까 위처럼 결과가 나올 것입니다.