본문 바로가기

컴퓨터공학/C, C++

C,C++> 타입 정보

RTTI

실시간 타입 정보

RTTI(RunTime Type Information)은 실시간 타입 정보라는 뜻이다. 

일반적으로 변수 이름이나 구조체, 클래스 타입은 컴파일러가 컴파일하는 동안에만 필요하다.

이진 파일로 번역되고 나면 이 정보들은 필요 없다. 

변수는 언제나 번지로만 참조될 뿐이다. 예쁜 이름을 남겨도 그 이름이 실행 파일에 남을 필요 없고 구조체 멤버들도 오프셋으로만 참조된다. 변수 타입은 읽어들일 길이와 비트를 해석하는 정보로만 사용한다. 기계어 수준에서 길이와 비트해석 방법에 따라 생성되는 기계어 코드가 달라진다. 

클래스도 마찬가지로 기계어로 바뀌면 구조체와 똑같되 다만 가상함수가 있을 경우 vtable을 가리키는 포인터를 하나 더 가진 정도만 다르다. 

멤버 함수는 일반 함수와 동일하지만 첫 번째 인수가 this로 고정되어 있는 호출 규약을 사용한다.

이 함수를 호출할 때는 항상 호출 객체 포인터가 같이 전달되도록 컴파일할 뿐이다. 

 

기계는 타입을 인식하지 않으며 메모리에 있는 값을 지정한 길이만큼 읽고 쓰고 할 뿐이다. 

상속된 클래스 계층을 다루는 C++에서 가끔 이런 타입 정보가 실행 중에 필요한 경우가 흔하지 않지만 가끔 있다.

 

class Parent {
public:
	virtual void PrintMe() { printf("");}
};

class Child: public Parent {
private:
	int num;

public:
	Child(int anum=1234) : num(anum) { }
    virtual void PrintMe() { printf(""); }
    void printNum() { printf(""); }
};

void func(Parent *p){
	p->PrintMe();
    ((Child *)p)->PrintNum();
}

void main() {
	Parent p;
    Child c(5);
    
    func(&c);
    func(&p);
}

함수 printNum은 Child 타입일 때만 있으므로 이 함수를 호출하려면 Child * 타입으로 강제 캐스팅해야 한다. 

func 함수를 동작할 때 Child 타입을 전달할 때만 printNum이 제대로 작동하고 Parent 타입 객체는 이상한 값을 뱉는다.

func(&p)에서 이상하다. 왜냐하면 Parent는 num 멤버 변수를 갖고 있지 않는데 num 멤버에 대한 오프셋 위치를 무조건 읽기 때문이다. 이상한 곳을 읽기에 쓰레기값이 출력되는 것이다. 

 

만약 PrintNum을 가상 함수로 바꾸면 어떻게 될까? Child 클래스의 PrintNum 함수 앞에 virtual 키워드를 넣고 컴파일하고 실행하면 즉사한다. 왜냐하면 Parent의 vtable에는 PrintNum 이 없기 때문이다. Parent 클래스의 vtable에는 가상 함수 PrintMe 정보만 있고 Child 클래스 vtable에는 PrintMe와 PrintNum 번지가 들어 있다. func 함수에서 p를 Child *로 캐스팅하면 컴파일러는 PrintNum이 있을 거라고 판단하고 에러를 내지 않는다. 그러나 실행하면 p가 가리키는 vtable 두 번째 항목에는 PrintNum이 없고 쓰레기 값이 있다. 이 번지로 점프하면 다음부터 프로그램은 어떻게할 지 모른다. 변수를 잘못 읽으면 쓰레기값을 출력하지만 함수를 잘못 호출하면 예측할 수 없어서 다운된다. 

 

따라서 정상 작동하려면 func를 작성할 때 p가 Child 객체인지 확인하는 조건문을 걸고 캐스팅해서 printNum을 실행해야 한다.  그런데 func함수가 객체를 포인터로 받는 경우 이 포인터가 Parent객체를 가리키는 건지 Child 객체를 가리키는 건지 판별할 수 없다. 포인터는 객체 주소값을 가리킬 뿐이고, 이 주소에 객체의 실제 데이터가 들어가 있을 뿐 누구라는 정보가 없다. 

 

void func(int *pi){
	...
}

int i, j;
unsigned k;
func(&i);
func(&j);
func((int *)&k);

예를 들어 위와 같은 예시가 있다고 치자. unsigned 변수를 int *로 캐스팅해서 전달하면 함수는 그저 부호가 있는 값으로 믿어버린다.

func가 아는 건 정수형 포인터를 전달받은 것과 이 포인터가 가리키는 곳에는 정수형 값이 있다는 것밖에 모른다. 

func은 * 연산자를 이용하여 이 번지에 있는 값을 읽거나 변경할 뿐이다. 

 

위와 같은 이유로 실행 중 타입을 판별하는 기능이 필요해진 것이다. 

C++은 언어 차원에 포함했으며 이것이 바로 RTTI이다. 

RTTI는 가상 함수가 있는 클래스에 대해서만 동작한다. 

왜냐하면 클래스 타입 관련 정보는 vtable에 같이 저장하기 때문이다. 

사실 가상 함수가 없는 클래스는 단독 클래스이거나 정적으로만 호출되므로 실행 중 타입 정보를 알아야 할 필요가 전혀 없다. 

 

RTTI가 제대로 동작하려면 모든 클래스에 타입과 관련된 정보를 작성해야 하며 그러면 프로그램이 느려깆고 용량이 커진다. 그래서 대부분 컴파일러는 RTTI 기능을 사용할 건지 아닌지 옵션으로 조정할 수 있다. 

 

RTTI가 아니더라도 이 문제를 풀 수 있는 여러 가지 방법이 있다. 예를 들어 가상함수로도 풀 수 있다. 위 예시에서 PrintNum을 Parent에 작성하고 가성으로 선언하면 되지만 근본적인 해결책이라 볼 수 없다. 왜냐하면 기반 클래스를 건드려야 하기 때문이다. 기반 클래스는 함부러 수정할 수 있는 대상이 아닌 경우도 많다. 

 

typeid 연산자

RTTI는 typeid 연산자로 사용한다. 이 연산자는 클래스 이름이나 객체 또는 객체를 가리키는 포인터를 피연산자로 취한다.

리턴 타입은 const type_info & 이며 type_info는 클래스 타입에 대한 정보를 가지는 또 다른 클래스이다. 

typeid 헤더 파일에 다음과 같이 선언되어 있다.

class type_infO {
public:
	virtual ~type_info();
    int operator==(const type_info& rhs) const;
    int operator!=(cosnt type_info& rhs) const;
    int before(const type_info& rhs) const;
    const char* name() const;
    const char* raw_name() const;
private:
	void *_m_data:
    char _m_d_name[1];
    type_info(const type_info& rhs);
    type_info& operator=(const type_info& rhs);
};

name 멤버 함수는 문자열로 된 타입의 이름을 조사한다.  클래스 이름이라고 보면 된다. 

raw_name은 장식명을 조사하는데 사람이 읽을 수 없는 문자열이라 비교에만 사용할 수 있다.

type_info 객체가 같은 건지 아닌지 조사하는 ==와 !=이 오버로딩되어 있다.

만약 typeid의 피연산자가 NULL 포인터로부터 읽은 값일 경우 bad_typeid 예외를 발생한다. 예를 들어 p 가 NULL일 때 typeid(* p) 연산식은 예외로 처리한다.

 

각 객체와 클래스에 타입 정보가 없다면 누구를 가리키는지 아는 것은 불가능하며 RTTI에 의해 이런 정보가 유지되고 조사된다. 

 

typeid 연산자는 객체나 객체 포인터뿐만 아니라 클래스 타입도 인수로 받을 수 있다. 

외에 관련 기능은 dynamic_cast가 있다.

 

RTTI 내부

type_info는 vtable을 통해 각 클래스마다 하나씩 생성된다.

직접 클래스에 정의하면 객체마다 하나씩 생성되기 때문에 용량 낭비가 심하다.

정적 멤버를 사용하면 클래스마다 하나씩 타입 정보를 생성할 수 있지만 정적 멤버는 상속되지 않아서 각 파생 클래스마다 고유 멤버를 따로 만들어야 하는 번거로움이 있다. 

(다시 복습하자면 상속되지 않는 멤버는 기반 클래스만의 고유 처리를 담당하는 

생성자와 파괴자, 대입 연산자, 정적 멤버와 정적 멤버 함수, 프렌드 관계 지정이 있다.)

 

 

C++ 캐스트 연산자

C의 캐스트 연산자

C언어의 캐스트 연산자는 원하는 대로 바꿀 수 있지만 결과는 개발자가 책임져야 한다. 그래서 C++에서는 변환 목적에 맞게 골라 쓸 수 있는 4개의 새로운 캐스트 연산자를 제공한다. 

 

static_cast

지정한 타입으로 무조건 변경하는 게 아니라 논리적으로 변환 가능한 타입만 변환한다. 

기본 문법은 다음과 같다.

static_cast<타입>(대상)

<> 괄호 안에 원하는 타입을 적고 () 괄호 안에 캐스팅할 대상을 적는다. 

 

실수형을 정수형으로 바꾸거나 열거형과 정수형과의 변환, double과 float 변환 등도 허용한다. 

그러나 포인터 타입을 다른 것으로 변환하는 건 허용되지 않는다. 

포인터끼리 타입 변환할 때는 상속 관계가 있는 포인터끼리만 변환이 허용된다. 

 

상속 계층 위로 이동하는 변화는 업 캐스팅(Up casting)이라고 한다.

부모 객체를 자식 객체 포인터로 다운 캐스팅(Down casting). 부모 객체가 자식 클래스 모든 멤버를 갖고 잊지 않으므로 이는 위험한 변환이다. 

 

dynamic_cast

 RTTI  정보를 사용하여 위험한 변환을 막아 준다. 이 캐스트 연산자는 포인터끼리 또는 레퍼런스끼리 변환하는데 반드시 포인터는 포인터로 변환해야 하고 레퍼런스는 레퍼런스로 변환해야 한다. 포인터를 레퍼런스로 바꾸거나 레퍼런스를 포인터로 변환하는 건 필요하지도 않고 가능하지도 않다. 포인터끼리 변환할 때도 상속 계층에 속한 클래스끼리만 변환할 수 있다. int *를 char * 로 변환하는 건 안된다.

 

업 캐스팅은 원래 허용되는 거라 당연히 가능하다. 다운 캐스팅할 때 무조건 변환을 허용하지 않고 안전하다고 판단될 때 허용한다. 안전한 경우란 변환 대상 포인터가 부모 클래스형 포인터 타입이되 실제 자식 객체를 가리키고 있을 경우 자식 클래스형 포인터로 다운 캐스팅할 때이다. 

 

부모 클래스형 포인터가 부모 객체를 가리키고 있는데 자식 클래스형으로 다운 캐스팅은 안전하지 않다.

왜냐하면 부모가 갖고 있지 않는 자식에게만 있는 멤버를 참조할 수 있기 때문이다. 

이런 경우 캐스팅을 허용하지 않고 NULL을 반환한다. 

 

레퍼런스는 NULL을 리턴할 수 없어서 bad_cast 예외를 던진다. 따라서 레퍼런스를 변환할 때 try catch 블록을 작성하고 bad_cast 예외를 잡아서 처리해야 한다. 

 

다중 상속된 한 객체를 가리키는 부모 포인터를 또 다른 부모 포인터 타입으로 변환하는 교차 캐스팅(cross cast)도 있다.

그러나 다중 상속은 권장되지 않는 문법으로 배울 가치가 없다. 

 

const_cast

포인터의 상수성만 변경하고 싶을 때 사용한다. 상수 지시 포인터를 비상수 지시 포인터로 바꾸고 싶을 때 const_cast 연산자를 쓴다. 반대 경우도 이 연산자를 사용할 수 있다. 그러나 비상수 지시 포인터는 상수 지시 포인터로 항상 변환 가능하다. 따라서 캐스트 연산자를 쓸 필요가 없이 그냥 대입하면 된다. 

 

비상수 지시 포인터에 상수 지시 포인터를 대입할 수 없는 이유는 읽기 전용 값을 비상수 지시 포인터로 바꿀 위험이 있기 때문이다. 

 

이 캐스트 연산자 외 다른 캐스트 연산자는 포인터의 상수성을 변경할 수 없다.

이 연산자는 포인터의 const 속성을 넣거나 뺄 수 있다.

비슷한 성격 지정자인 volatile 속성과 __unaligned 속성도 변경할 수 있다.

물론 C 캐스트 연산자는 모든 걸 마음대로 할 수 있다.

 

변수의 상수성만 바꿀 수 있을 뿐 대상체 타입을 바꾼다거나 기본 타입을 다른 타입으로 바꾸는 건 허용되지 않는다. 

 

reinterpret_cast

이 캐스트 연산자는 임의 포인터 타입끼리 변환을 허용하는 위험한 캐스트 연산자이다. 

심지어 정수형과 포인터간 변환도 허용한다. 

정수형 값을 포인터 타입으로 바꾸어 절대 번지를 가리킬 때 이 연산자를 사용한다. 

이 연산자는 포인터 타입간 변환이나 포인터와 수치형 데이터 변환에만 사용한다.

기본 타입들끼리 변환에는 사용할 수 없다. 예를 들어 정수형을 실수형으로 바꾸거나 실수형을 정수형으로 바꾸는 건 허락되지 않는다. 이럴 때는 static_cast를 이용한다. 

 

 

멤버 포인터 연산자

멤버 포인터 변수

멤버 포인터 변수란 특정 클래스(구조체 포함)에 속한 멤버만을 가리키는 포인터이다. 

포인터는 메모리상의 임의 지점을 가리킬 수 있지만 객체 내 한 지점만을 가리킨다는 점에서 다르다. 

형식은 다음과 같다.

 

타입 클래스::*이름;

 

당연한 이야기지만 외부에 있는 포인터 변수는 클래스 내부 변수를 가리킬려면 대상체 멤버는 public으로 선언되어야 한다.  private이면 컴파일 에러이다. 

 

멤버 포인터 변수를 초기화할 때는 &Class::Member 이런 식으로 대입한다. 

이 대입식은 특정 변수 번지를 가리키도록 하는 것이 아니라 클래스의 어떤 멤버를 가리킬 것인가 초기화하는 것이다.

이 상태에서 멤버 포인터 변수에 대입되는 번지가 결정되는 건 아니다. 

가리키는 멤버가 클래스의 어디쯤 있는지 위치에 대한 정보만을 가리킬 뿐이다.

클래스 전체를 하나의 작은 주소 공간으로 보고 클래스 내 멤버 위치를 기억하는 것이다.

멤버 포인터 변수로 객체 실제 멤버를 액세스할 때 는 멤버 포인터 연산자라는 특수 연산자가 필요하다. 

 

Obj.*mp

pObj->*mp

 

.* 연산자는 좌변 객체에서 멤버 포인터 변수 mp가 가리키는 멤버를 읽는다. 

Obj가 상수 객체가 아니고 mp가 상수가 아닌 데이터 멤버를 가리킨다면 Obj.*mp 자체는 좌변값이므로 이 식을 좌변에 놓아 멤버 값을 변경하는 것도 가능하다.

->* 좌변의 포인터가 가리키는 객체에서 mp가 가리키는 멤버를 읽는다. 

좌변이 객체인가 포인터인가만 다를 뿐이며 기능은 동일하다. 

 

컴파일러 구현 방식에 따라 다르지만 주로 클래스 내 멤버 위치인 오프셋을 기억했다가 .* 연산자가 적용되면 객체 오프셋을 대상체 타입만큼 읽는 방법을 쓴다. 

 

멤버 포인터 연산자 활용

멤버 포인터 변수로 간접적으로 멤버를 액세스하는 건 무슨 의미가 있을까?

사실 멤버 변수를 간접적으로 액세스하는 건 큰 의미가 없다.  왜냐하면 배열을 구성하고 한 요소를 가리키는 데 첨자가 더 편리하기 때문이다. 주로 멤버 함수를 간접적으로 호출하기 위해 멤버 포인터 연산자를 사용한다. 조건에 따라서 적절한 멤버 함수를 선택하여 호출한다.

 

예를 들어서 클래스 멤버 함수가 수십 개가 있고 입력값에 따라서 작동하기 위해 switch문으로 다중 분기한다고 가정하자.

어떤 한 함수를 여러 번 반복 호출한다면 이 방법은 낭비가 많다.

이럴 땐 쓰고 싶은 걸 미리 포인터에 저장하고 필요할 때 빨리 호출하여 사용하는 문법적 장치가 함수 포인터이다.

함수 포인터를 배열로 만들어 놓고 입력된 첨자로부터 어떤 함수를 호출할 건지 결정하면 빠르게 작동한다. 

 

함수 포인터 변수를 선언하고 함수를 가리키면 될 거 같은데 컴파일 에러가 난다. 

왜냐하면 클래스 멤버 함수는 일반 함수와는 달리 호출하는 방법이 다르며 이런 함수를 가리키는 포인터를 선언하는 문법도 달라야 하기 때문이다. 

 

다른 장점으로 함수를 다른 함수의 인수로 전달할 수 있다. 

 

멤버 포인터의 특징

멤버 포인터 변수는 정적 멤버 변수를 가리킬 수 없으며 레퍼런스 멤버를 가리킬 수도 없다. 

 

멤버 포인터는 일반 포인터와 다르게 증감 연산자를 쓸 수 없다. 이는 함수 포인터를 증가할 수 없는 것과 같다. 

 

'컴퓨터공학 > C, C++' 카테고리의 다른 글

C,C++> 네임 스페이스  (2) 2022.09.02
C,C++> 예외 처리  (0) 2022.09.01
C,C++> 템플릿  (0) 2022.08.31
C,C++> 다형성  (0) 2022.08.22
C,C++> 상속  (0) 2022.08.19