본문으로 바로가기
반응형

RTTI

실시간 타입 정보

RTTI(RunTime Type Information)은 실시간 타입 정보를 알아내는 기술이다. 변수의 이름이나 클래스 계층 구조는 컴파일할 때만 필요하며 실행 파일로 번역한 후에는 사용되지 않는다. 이름은 구분을 위한 명칭일뿐이고 타입은 길이와 비트 구조를 해석하는데 참조할 뿐이다.

 

클래스도 마찬가지로 컴파일되면 멤버의 오프셋으로 참조하되 가상 함수가 있으면 가상테이블을 가지는 정도만 특이하다. 컴파일러는 이름으로 변수를 구분하지 않고 타입으로 액세스 방법을 결정하여 적절한 기계어 코드를 생성한다. CPU는 타입을 인식하지 않으며 메모리에 있는 값을 지정한 길이만큼 읽고 쓸 뿐이다.

 

타입과 관련된 정보는 컴파일 중에만 사용되며 기계어로 바뀌면 남아 있지도 않는다. 컴파일이 끝나면 참조할 수 없고 참조할 필요도 없다. 그러나 클래스 계층을 다룰때는 가끔 타입 정보가 유용하게 사용되는 경우가 있다.

// 파일이름 : RTTI.cpp
#include <stdio.h>

class Parent
{
public:
	virtual void PrintMe() { printf("I am Parent\n"); }
};

class Child : public Parent
{
private:
	int num;

public:
	Child(int anum) : num(anum) {}
	virtual void PrintMe() { printf("I am Child\n"); }
	void PrintNum() { printf("Hello Child = %d\n", num); }
};

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

int main()
{
	Parent p;
	Child c(5);

	func(&c);
	func(&p);
}

상속 관계의 두 클래스가 정의되어 있다. Parent는 PrintMe 가상함수만 가지며 Parent로 부터 파생된 Child는 num 멤버 변수, 생성자와 PrintNum 멤버 함수를 가지고 PrintMe 가상 함수를 재정의한다.

 

func 함수는 Parent 파생 객체의 포인터를 전달받아 PrintMe 함수를 호출하고 Child일 경우 Child* 타입으로 강제 캐스팅하여 PrintNum 비가상함수도 호출한다. main에서 각 타입의 두 객체 p와 c를 생성한 후 func 함수로 전달했다.

Child 객체를 전달했을 때는 두 함수 모두 정상적으로 동작하지만 Parent 타입의 객체를 전달할때는 PrintNum이 엉뚱한 값을 출력한다.

 

가상 함수인 PrintMe는 객체의 동적 타입에따라 정확하게 호출되지만 비가상함수인 PrintNum은 정적 타입을 따르므로 항상 Child::PrintNum이 호출된다. Parent 객체는 num 멤버가 없으므로 쓰레기값이 출력된다.

이때 PrintNum 함수를 가상으로 바꾸면 컴파일은 잘 되지만 p가 가리키는 가상 테이블에는 PrintNum함수가 번지가 없어 다운된다.

부모 클래스는 자식이 정의한 가상 함수를 가지지 않는다. 아무리 가상으로 선언하더라도 없는 함수를 호출할 수는 없다. 문제의 원인은 func 함수가 전달받은 객체 p를 무조건 Child로 강제 캐스팅한 것인데 p가 진짜 Child 타입일때만 캐스팅해야한다.

void func(Parent *p)
{
	p->PrintMe();
    if(p가 Child 객체를 가리키면)
    {
    	((Child*)p)->PrintNum();
	}
}

p를 조사하여 이 포인터가 진짜 Child 객체를 가리키고 있을때만 캐스팅하면 안전하다.

그러나 포인터만 가지고 이 번지에 있는 객체가 무엇인지 알 수 있는 방법이 없다.

왜 불가능한지 단순 타입으로 바꿔서 생각해보자.

void sub(int *pi)
{
	//pi가 누구를 가리키는지?
}

int a;
unsigned b;
sub(&a);
sub((int *)&b);

sub 함수는 정수형 포인터 pi를 전달받는다. 이 번지에 정수형 변수가 있는지 정수 배열의 한 요소를 가리키는지 구조체의 정수형 멤버인지 알 수 없다. 단지 이 번지에 정수가 있다는 것만 알고 있으며 * 연산자로 그 값을 읽거나 변경할 수 있을 뿐이다. 심지어 정수가 아닌 unsigned 형 변수의 번지를 캐스팅해서 넘겨도되고 정수라고 믿을 수 밖에 없다.

 

그래서 실시간 타입 판별 기능이 필요하다. 이제부터 타입 판별 연산자에대해 알아보자.

 

 

typeid 연산자

RTTI 기능의 핵심은 객체의 타입을 조사하는 typeid 연산자이다. 피연산자로 객체나 객체의 포인터 또는 클래스 이름을 전달하면 타입에 대한 정보를 가지는 type_info 객체를 리턴한다. type_info 클래스는 컴파일러 제작사마다 약간 차이가 있는데 비주얼 C++의 경우 typeinfo 헤더 파일에 다음과 같이 선언되어 있다.

class type_info
{
public:
	type_info& operator=(type_info const&);
	bool operator==(type_info const& _Other) const;
	bool operator!=(type_info const& _Other) const;
	bool before(type_info const& _Other) const;
	const char* name() const;
	const char* raw_name() const;
private:
	mutable __std_type_info_data _Data;
};

name 함수는 클래스의 이름을 조사한다. raw_name 함수는 장식명을 조사하는데 사람이 직접 읽기는 어려우며 비교에만 사용한다. 타입 정보끼리 대입, 비교하는 연산자가 정의되어 있는데 == 연산자로 원하는 타입인지 알아낸다.

다음 예제는 두 객체에 대해 실시간 타입 정보를 조사한다.

// 파일이름 : typeid.cpp
#include <stdio.h>
#include <typeinfo>

class Parent
{
public:
	virtual void PrintMe() { printf("I am Parent\n"); }
};

class Child : public Parent
{
private:
	int num;

public:
	Child(int anum) : num(anum) {}
	virtual void PrintMe() { printf("I am Child\n"); }
	void PrintNum() { printf("Hello Child = %d\n", num); }
};

void func(){}

int main()
{
	Parent P, * pP;
	Child C(123), * pC;
	pP = &P;
	pC = &C;

	int val;

	printf("P = %s, pP = %s, *pP = %s\n",
		typeid(P).name(), typeid(pP).name(), typeid(*pP).name());
	printf("C = %s,  pC = %s,  *pC = %s\n",
		typeid(C).name(), typeid(pC).name(), typeid(*pC).name());

	pP = &C;
	printf("pP = %s, *pP = %s, val = %s\n",
		typeid(pP).name(), typeid(*pP).name(), typeid(val).name());


	printf("func = %s\n", typeid(func).name());  // 함수의 타입 확인 (cdecl : 함수 규약)
}

main에서 두 개의 객체 P와 C를 생성하고 두 객체를 가리키는 포인터를 선언하여 대응 객체를 가리키도록 했다.

부모 타입의 포인터인 pP는 두 객체를 번갈아 가리킨다.

 

이 상태에서 typeid 연산자로 각 객체와 포인터, 포인터의 대상체에 대한 타입을 조사하여 이름을 출력했다. 앞 두 줄의 결과는 당연하고 상식적이다. 객체 P나 이 객체를 가리키는 포인터 pP나 포인터의 대상체 *pP는 모두 Parent 타입으로 조사된다. 차일드인 C, pC의 경우도 마찬가지이다.

 

세번째 줄의 결과를 주목해보자. 부모 타입인 pP는 자식 객체인 C를 가리킬 수 있는데 이 상태에서 pP의 타입과 *pP의 타입이 다르게 조사된다. pP는 포인터 자체의 타입이므로 Parent * 라고 조사되며 현재 Child 객체를 가리키고 있으므로 대상체는 *pP는 Child로 조사된다. 부모 타입의 포인터가 자식 객체를 가리키고 있는 상황을 정확하게 구분한다.

 

RTTI 정보가 없다면 pP가 누구를 가리키는지 알 수 없지만 이 정보를 참조하는 typeid 연산자는 가리키는 객체를 정확히 구별해낸다. 이 기능을 사용하면 작성한 예제의 func 함수를 올바르게 작성할 수 있다.

typeinfo 헤더 파일을 포함하고 함수를 다음과 같이 수정하면된다.

// 파일이름 : RTTI2.cpp
#include <stdio.h>
#include <typeinfo>

class Parent
{
public:
	virtual void PrintMe() { printf("I am Parent\n"); }
};

class Child : public Parent
{
private:
	int num;

public:
	Child(int anum) : num(anum) {}
	virtual void PrintMe() { printf("I am Child\n"); }
	void PrintNum() { printf("Hello Child = %d\n", num); }
};

void func(Parent* p)
{
	p->PrintMe();
	if (typeid(*p) == typeid(Child))
	{
		((Child*)p)->PrintNum();
	}
	else
	{
		puts("이 객체는 num을가지고 있지 않습니다.");
	}
}

int main()
{
	Parent p;
	Child c(5);

	func(&c);
	func(&p);
}

인수로 전달받은 p의 타입 정보와 Child의 타입 정보를 == 연산자로 비교해본다. 원하는 타입일때만 Child로 캐스팅하여 멤버 함수를 호출하고 그렇지않으면 타입이 맞지 않다는 메세지를 대신 출력한다. 실행 중에 타입을 조사하여 적용하므로 항상 안전하다.

 

 

RTTI의 내부

실행 중에 객체의 타입을 조사하려면 타입 관련 정보가 클래스에 기록되어야한다. 이 정보는 가상 함수 테이블에 같이 기록되며 그러다 보니 RTTI는 가상 함수를 가진 클래스에 대해서만 사용할 수 있다.

단독 클래스나 비가상함수만 가진 클래스는 실행 중에 타입 정보를 조사할 필요가 전혀 없어 별다른 문제는 없다.

 

RTTI는 C++를 최초 설계할 때부터 포함된 것이 아니라 뒤늦게 추가된 확장 기능이어서 언어와의 통합성이 떨어진다.

타입 정보 저장을 위해 별도의 추가적인 정보가 필요해 용량이나 속도면에서 불이익이 있다. 이 기능이 항상 필요한 것도 아니어서 컴파일러 옵션으로 꼭 필요할때만 선택적으로 사용하도록 되어있으며 현업에서 사용하는 경우가 많지 않다.

 

사용 여부가 옵션이라는 것은 필수적이지 않다는 뜻이며 RTTI가 도입되기 전에도 여러가지 방법으로 객체의 타입을 판별할 수 있는 기능을 자체적으로 만들어 사용하기도 했다. 원리는 비슷한데 클래스 어딘가에 자신이 누구라는 표식을 남겨 두면 된다.

// 파일이름 : CStyleRTTI.cpp
#include <stdio.h>
#include <string.h>

class Parent
{
public:
	char classname[32];
	Parent()
	{
		strcpy_s(classname, "Parent");
	}

	virtual void PrintMe()
	{
		printf("I am Parent\n");
	}
};

class Child : public Parent
{
private:
	int num;

public:
	Child(int anum) : num(anum)
	{
		strcpy_s(classname, "Child");
	}
	virtual void PrintMe() { printf("I am Child\n"); }
	void PrintNum() { printf("Hello Child = %d\n", num); }
};

void func(Parent* p)
{
	p->PrintMe();
	if (strcmp(p->classname, "Child") == 0)
	{
		((Child*)p)->PrintNum();
	}
	else
	{
		puts("이 객체는 num을 가지고 있지 않습니다.");
	}
}

int main()
{
	Parent p;
	Child c(5);

	func(&c);
	func(&p);
}

Parent 클래스에 classname 문자열 배열을 선언하고 생성자에서 이 문자열에 "Parent"를 저장해 놓는다. 파생 클래스인 Child는 이 멤버를 상속받아 생성자에서 "Child"를 저장한다. 객체 스스로 자신의 이름표를 가지고 있으니 실행 중에 이 정보를 읽어 보면 누구인지 금방 알 수 있다. 포인터의 타입과 상관없이 객체 자체를 직접 참조하니 항상 정확하다.

 

이름표를 문자열로 붙여 비교가 번거로운데 숫자로 붙이면 속도를 높일 수 있다. 하지만 문자열은 클래스 이름과 일치시킬 수 있지만 숫자는 관리가 필요해 직관성이 떨어진다. 형식성을 더 따진다면 타입을 조사하는 멤버 함수를 가상으로 선언해서 함수로 비교할 수도 있고 == 연산자를 오버로딩하여 객체끼리 타입을 비교할 수도 있다.

 

RTTI도 사실 이 예제의 방식과 유사하게 동작한다. 

 

 

C++의 캐스트 연산자

캐스트 연산자의 한계

캐스트 연산자는 변수의 타입을 원하는 대로 바꿀 수 있어 편리하다. 타입은 가급적 맞추어 쓰는 것이 바람직하지만 불가피하게 타입을 바꿔야하는 상황이 있고 void * 처럼 캐스트 연산자가 꼭 필요한 경우도 있다.

그러나 지시를 너무 잘 따른다는 면에서 부작용이 있고 실수의 가능성도 높다.

#include <stdio.h>

int main()
{
	const char* str = "korea";
	int* pi;

	pi = (int*)str;
	printf("%d %x \n", *pi, *pi);
}

문자열 포인터 str이 가리키는 번지를 정수형 포인터(int *)로 강제 캐스팅해서 pi에 대입한 후 pi가 가리키는 곳의 내용을 읽었다. * 연산자는 포인터의 타입에 따라 읽을 길이와 비트 해석 방법을 결정한다. pi가 가리키는 곳에 문자열이 들어 있지만 pi가 정수형 포인터이므로 정수로 읽는다.

 

16진수로 출력한 값은 "korea"문자값을 반대로 나열한 것임을 어렴풋이 짐작할 수 있다.

그러나 10진수로 읽은 17억이라는 값은 실제 저장된 "korea"문자열과 pi로 읽은 17억은 논리적 연관이 없으며 쓸모없는 값이다. 캐스트 연산자는 이런 의미없는 변환까지도 허용해버려 실수했을때 엉뚱한 결과가 나오도록 방치한다.

그나마 이 경우는 할당은 되어있는 곳이므로 안정성에 문제는 없다.

int value = 12345678;
char *str = (char *)value;
puts(str);

정수값을 char*로 캐스팅하여 이 번지의 문자열을 읽었다. 이 번지가 다행히 읽을 수 있는 곳이라면 쓰레기 문자열이라도 나오겠지만 허가되지 않은 영역이라면 프로그램이 다운되어 버린다. 메모리는 운영체제가 알아서 관리하는데 절대 번지를 마음대로 액세스 하는 것은 실용성도 없고 위험하다.

 

C의 캐스트 연산자는 컴파일러는 문제가있다고 인식되지만 명시적으로 캐스팅함으로써 개발자가 책임을 지는 방법이다.

C++은 기능을 축소하고 엄격한 규칙을 적용한 4개의 새로운 캐스트 연산자를 도입했다.

어떤 변환인지 의도를 분명히 밝히도록해서 의도치 않은 실수를 줄이도록 만들었다.

 

 

static_cast

static_cast 연산자는 논리적으로 변환 가능한 타입만 변환하며 그 외의 변환은 에러로 처리한다.

사용 방법이 다소 생소한데 나머지 캐스트 연산자도 형식은 비슷하다.

static_cast<타입>(대상)

static_cast는 키워드이며 <> 괄호 안에 변환할 타입을 적고 () 괄호 안에 캐스팅할 대상을 적는다.

// static_cast.cpp
#include <stdio.h>

int main()
{
	const char* str = "korea";
	int* pi;
	double d = 123.456;
	int i;
	int ary[] = { 1, 2, 3, 4, 5 };
	char aary[] = { 'a', 'b' };

	int* ptr = static_cast<int*>(ary);  // 배열은 int형으로 바꿀 수 있다.
	//ptr = static_cast<int*>(aary);    // 포인터끼리의 형변환 => 에러

	i = static_cast<int>(d);        // 가능
	//pi = static_cast<int*>(str);  // 에러 : static_cast는 포인터끼리 형변환은 허용되지않음.
	pi = (int*)str;                 // 가능
}

실수형을 정수형으로 캐스팅하거나 반대로 캐스팅하는 것은 허용된다. 두 타입은 수치형이라는 점에서 공통적이고 약간의 정밀도 희생은 있지만 호환가능한 타입이다. 열거형과 정수형 간의 변환이나 double과 float 형 간의 변환도 허용된다.

 

그러나 포인터의 타입을 다른 것으로 변경하는 것은 금지된다. 문자형 포인터 str을 정수형 포인터로 캐스팅하면 컴파일 에러로 처리하여 실수를 방지한다. 논리적으로 이런 변환이 필요한 경우가 드물어 의도한 것이라기보다 실수일 가능성이 높기 때문이다.

 

상속 관계에 있는 포인터끼리는 상호 연관성이 있어 static_cast 연산자로 변환할 수 있다. 자식 객체의 포인터가 부모 객체 타입으로 바뀔 수 있고 부모 타입으로 받았다가 자식 타입으로 바꿀 수도 있다. 그 외의 경우는 변환을 거부하고 컴파일 에러로 처리한다.

// static_cast2
#include <stdio.h>

class Parent {};
class Child : public Parent {};

int main()
{
	Parent P, *pP;
	Child C, *pC;
	int i = 1234;

	pP = static_cast<Parent*>(&C);    // 가능
	pC = static_cast<Child*>(&P);     // 가능은하지만 위험
	//pP = static_cast<Parent*>(&i);  // 에러 => 하극상 형태가되면 안됨..
	//pC = static_cast<Child*>(&i);   // 에러
}

계층을 이루는 두 개의 클래스를 선언하고 4종류의 변환을 해보았다. 첫번째는 자식 객체를 부모 타입으로 바꾸는데 이것은 언제나 가능하다. 상속 계층의 위쪽으로 변환하는 업캐스팅(Upcasting)은 항상 안전하며 따라서 캐스팅할 필요도 없다.

 

두번째는 부모 객체의 번지를 자식 타입의 포인터로 바꾸는 다운 캐스팅(DownCasting)을 하는데 항상 안전하지는 않아 캐스트 연산자를 사용해야한다. 부모 객체가 자식 타입의 모든 멤버를 가지고있지 않아 위험하지만 static_cast는 실행 중 타입 체크까지는 하지 않으므로 이 변환의 위험성을 판단할 수 없다.

 

뒤쪽 두 개의 변환은 정수형 포인터를 아무런 관련이 없는 클래스형 포인터로 변환하므로 금지된다. 정수형 변수가 Parent나 Child 멤버를 가지고 있지 않으니 변환할 필요조차 없다. 의미없는 변환이라 판단하여 에러 처리한다.

 

 

dynamic_cast

다운 캐스팅은 대상 변수가 실제 어떤 객체를 가리키는가에 따라 안전성 여부가 결정된다.

다운 캐스팅이 안전하려면 부모 타입의 포인터가 진짜 자식 객체를 가리키고 있어야한다. 이를 정확하게 판단하려면 실행 중 타입 정보를 참조해야하는데 dynamic_cast는 RTTI 정보를 참조하여 안전한 변환만 허용한다.

 

안전한 변환이 아닌 경우 NULL을 리턴하여 위험한 변환을 허가하지 않는다. 실행 중 타입 정보를 사용하므로 이 연산자를 사용하려면 RTTI 기능이 켜져있어야 한다. 앞으로 typeid 연산자로 작성했던 RTTI2 예제를 dynamic_cast 연산자로 다시 작성해보자.

// 파일이름 : dynamic_cast.cpp
#include <stdio.h>
#include <typeinfo>

class Parent
{
public:
	virtual void PrintMe() { printf("I am Parent\n"); }
};

class Child : public Parent
{
private:
	int num;

public:
	Child(int anum) : num(anum) {}
	virtual void PrintMe() { printf("I am Child\n"); }
	void PrintNum() { printf("Hello Child = %d\n", num); }
};

void func(Parent* p)
{
	p->PrintMe();
	Child* c = dynamic_cast<Child*>(p);
	if (c)
	{
		c->PrintNum();
	}
	else
	{
		puts("이 객체는 num을가지고 있지 않습니다.");
	}
}

int main()
{
	Parent p;
	Child c(5);

	func(&c);
	func(&p);
}

인수로 전달된 포인터 p를 Child *로 동적캐스팅한다. 이때 p가 가리키는 객체가 Child 타입이면 제대로 변환되며, 그렇지 않으면 NULL이 리턴된다. dynamic_cast가 무사히 캐스팅했다면 p의 대상체는 분명 Child 객체이며 따라서 이 객체로부터 PrintNum을 호출해도 안전하다. 실행 중 타입 점검과 캐스팅까지 한꺼번에 할 수 있어 typeid 연산자보다 간편하다.

 

dynamic_cast는 상속관계의 포인터끼리 변환할때 사용하는데 레퍼런스에 대해서도 사용할 수 있다.

다만 레퍼런스는 NULL이 없으므로 캐스팅할 수 없을때 bac_cast 예외를 던진다. 레퍼런스에 대해 이 캐스팅을 적용할때는 코드를 try 블록에 작성하고 bad_cast 예외를 처리해야한다.

 

정적 캐스트와 동적 캐스트의 차이점

static_cast 는 정적 실행되기전에 컴파일러가 타입을미리 결정해서 반환

dynamic_cast는 프로그램 실행중에 타입이 결정된다. 가상함수가 있는 클래스안에서 동작된다.

 

 

const_cast

const_cast 연산자는 포인터의 상수성만 변경한다. 상수 지시 포인터를 비상수 지시 포인터로 잠시 바꾸고 싶을때 이 연산자를 사용한다. 반대의 경우도 사용할 수 있지만 바로 대입 가능해 굳이 캐스팅할필요는 없다.

// const_cast.cpp
#include <stdio.h>

int main()
{
	char str[] = "string";
	const char* c1 = str;
	char* c2;
	c2 = const_cast<char*>(c1);
	c2[0] = 'a';
	puts(c2);
}

상수 지시 포인터 c1에 str 문자 배열을 대입했다. str 자체는 변경 가능하지만 c1이 상수만 가리키는 포인터여서 대상체를 변경할 수 없다. c2는 상수가 아닌 일반 포인터이므로 c2 = c1으로 c1의 번지를 대입할 수 없다. 이 대입을 허락하면 c2로 상수 문자열을 부주의하게 바꿀 위험이 있다.

 

c1은 상수지만 대상체는 상수가 아니라는 것을 확실히 알고 있다면 char *로 캐스팅하여 상수성을 없앨 수 있으며 그 결과를 c2에 대입하면 c2로 대상체를 변경할 수 있다. str은 배열이므로 상수가 아니며 따라서 c2가 이 번지를 가져도 이상 없다. 만약 str이 배열이 아닌 문자열 리터럴을 가리키는 상수라면 c2가 대입받아서는 안된다.

 

const_cast에 의해 const char* 타입이 char * 포인터 타입으로 잠시 바뀐다. 이 연산자는 포인터의 상수성만 바꿀 뿐이며 대상체 타입을 바꾼다거나 기본 타입을 포인터 타입으로 바꿀 수는 없다.

 

 

C++ 캐스트 연산자의 용도

static_cast        : 상속 관계의 클래스 포인터 및 레서런스, 기본 타입, 타입 체크 안함

dynamic_cast    : 상속 관계의 클래스 포인터 및 레퍼런스, 타입 체크, RTTI 기능 필요

const_cast        : const, volatile 등의 속성 변경

reinterpret_cast : 포인터끼리, 포인터와 수치형 간의 변환(생략)

 

변환하고자하는 목적에 맞게 연산자를 잘 골라 사용하며 잘못 사용하면 컴파일중에 에러처리가 된다. 꼭 필요한 변환만 최소한으로 수행하므로 부주의한 캐스팅을 방지하고 실수를 금방 알 수 있게 해준다. 특이한 모양으로 인해 캐스트 연산자인지 금방 알아 볼 수 있어 가독성에도 유리하다.

 

C는 타입을 맞춰서 써야하지만 불가피하게 강제로 타입을 바꿔야하는 경우도있다. 하지만 캐스팅을 너무 남발하면 컴파일러의 타입 체크 기능을 방해하여 안정성을 떨어뜨리고 실수의 가능성이 높아진다. 그래서 기능을 제한하는 캐스트 연산자를 새로 도입한 것이다. C++의 캐스트 연산자는 C에 비해 훨씬 안전하지만 그래도 위험한건 마찬가지라 주의 깊게 사용해야한다.

 

셀프테스트) 정수형 변수 sum에 124, num에 5가 있을때 두 수를 나누어 평균을 구해 출력하되 소수점 이하까지 정밀하게 계산하기.

#include <iostream>
using namespace std;

int main()
{
	int sum = 124;
	int num = 5;
	printf("평균 : %f", static_cast<double>(sum) / num);

	// 두 값을 연산할때는 두 값의 타입의 통일이 되어야한다.
	// 정수끼리 나누면 정수 나눗셈으로 소수점이 짤린다.
	// 따라서 소수형으로 캐스팅을 해줘야하는데 이때 하나만 캐스팅해주면 자동으로 나머지 하나도 캐스팅된다
	// 정수와 실수는 호환이 가능하기때문에 static_cast 연산자로 double형으로 캐스팅을 해주었다.
}

 

 

멤버 포인트 연산자

멤버 포인터 변수

멤버 포인터 변수는 특정 클래스에 속한 특정 타입의 멤버를 가리키는 포인터이다. 일반 포인터는 메모리 상의 임의 지점을 가리키는데 비해 멤버 포인터는 클래스 내의 한 지점을 가리킨다는 점이 다르다.

타입클래스::*이름

대상체의 타입과 소속 클래스 이름을 밝히고 *구두점과 변수명을 적는다. 

일반 포인터 선언문에 소속 클래스에 대한 정보가 추가된다.

// MemberPointer.cpp
#include <stdio.h>

class MyClass
{
public:
	int i, j;
	double d;
};

int main()
{
	MyClass C;
	int MyClass::* pi;
	double MyClass::* pd;
	int num;

	pi = &MyClass::i;
	pi = &MyClass::j;
	
	pd = &MyClass::d;
	//pd = &MyClass::i;

	//pi = &MyClass::d;
	//pi = &num;
}

MyClass에는 정수형 멤버 변수 i, j와 실수형 멤버 변수 d가 포함되어있다. main에서 두 개의 멤버 포인터 변수 pi, pd를 선언한다.

 

pi는 MyClass에 속한 정수형 변수만 가리킬 수 있고 pd는 MyClass에 속한 실수형 변수만 가리킨다.

멤버 포인터에 값을 대입할 때는 &class::member 형식으로 클래스 멤버 이름을 밝힌다. pi는 MyClass::i나 MyClass::j를 가리킬 수 있으며 타입이나 소속이 다른 변수를 가리킬 수 없다.

d는 소속은 같지만 정수가 아니므로 가리킬 수 없고 num은 정수이지만 클래스 소속이 아닌 지역 변수 이므로 역시 가리킬 수 없다.

 

pd는 MyClass 소속의 실수만 가리킬 수 있는데 이 예제에서는 d밖에 없다. i, j는 소속이 다르고 num은 소속과 타입이 모두 달라 가리키지 못한다. 클래스 외부에 있는 pi, pd 포인터로 클래스 내부의 멤버를 읽으려면 대상 멤버는 public으로 선언되어있어야한다.  멤버 포인터 변수에 멤버 변수의 번지를 대입하는 것은 가리킬 대상을 알려줄 뿐 객체의 멤버를 직접 가리키는 것은 아니다. 클래스를 작은 주소 공간으로 간주하고 가리키는 멤버가 클래스의 어디쯤에 있는지 위치 정보만 가진다. 실제 객체의 멤버를 액세스 할때는 다음 두 연산자를 사용한다.

obj.*mp
pObj->*mp

.* 연산자는 좌변의 객체에서 멤버 포인터 변수 mp가 가리키는 멤버를 읽는다. ->* 연산자는 좌변이 객체를 가리키는 포인터라는 점만 다르다. 멤버 포인터 변수는 클래스 내의 멤버 위치인 오프셋을 기억해 두었다가 .* 연산자나 ->* 연산자로 읽을때 이 위치의 대상체를 포인터 타입만큼 읽어낸다.

// MemberPointOp
#include <stdio.h>

class Time
{
public:
	int hour, min, sec;

	void OutTime()
	{
		printf("현재 시간은 %d:%d:%d입니다.\n", hour, min, sec);
	}
};

int main()
{
	Time now;
	int Time::* pi;

	pi = &Time::hour;
	now.*pi = 12;

	pi = &Time::min;
	now.*pi = 34;

	pi = &Time::sec;
	now.*pi = 56;

	now.OutTime();
}

Time은 세 개의 정수형 멤버를 가진다. 멤버 포인터로 가리키기 위해 모두 public으로 공개했다.

main에서 Time형 객체 now를 선언하고 Time의 정수형 멤버 변수를 가리키는 pi를 선언했다. 

pi가 Time의 hour를 가리키도록하면 이 멤버의 위치를 기억해둔다. 그리고 now.*pi에 값을 대입하면 now 객체의 hour값이 바뀐다.

 

멤버 포인터는 변수이므로 실행 중에 가리키는 대상을 바꿀 수 있다. 같은 방식으로 min과 sec를 가리키도록하여 두 멤버에도 값을 대입했다. pi 멤버 포인터가 세 개의 정수형 멤버를 번갈아 가리키며 값을 대입한다.

 

Time형 객체를 가리키는 포인터를 가지고 있다면 .* 연산자대신 ->* 연산자를 사용한다. 같은 타입의 임의 변수를 가리킨다는 면에서 일반 포인터와 기능이 같되 범위가 클래스 내부의 멤버로 국한된다는 점이 특수하다.

 

 

멤버 포인터 연산자의 활용

멤버 포인터 변수로 객체의 멤버를 간접적으로 액세스하는데 한 단계를 더 거치면 중간에서 여러 가지 조작이 가능해진다. 멤버 변수를 간접 액세하는 것은 큰 의미가 없으며 멤버 함수를 간접 호출하는 기법이 실용적이다.

// Memptr1.cpp
#include <stdio.h>

class Calc
{
public: 
	void Op1(int a, int b) { printf("연산결과 : %d\n", a + b); }
	void Op2(int a, int b) { printf("연산결과 : %d\n", a - b); }
	void Op3(int a, int b) { printf("연산결과 : %d\n", a * b); }
};

void main()
{
	int ch;
	Calc c;
	int a = 3, b = 4;

	printf("연산 방법을 선택하세요. 0 = 더하기, 1 = 빼기, 2 = 곱하기 : ");
	scanf_s("%d", &ch);
	switch (ch)
	{
	case 0:
		c.Op1(a, b);
		break;
	case 1:
		c.Op2(a, b);
		break;
	case 2:
		c.Op3(a, b);
		break;
	}
}

Calc 클래스는 세 종류의 연산을 하는 원하는 멤버 함수를 가지며 모두 원형이 같다. main에서 사용자의 입력을 받아 원하는 연산을 선택하고 switch 문으로 분기하여 적절한 계산 함수를 호출한다.

호출할 함수가 많고 그 중 하나를 미리 선택해 놓을때는 함수 포인터를 쓰는 것이 정석이다. 

그렇다면 다음 코드로 멤버 함수를 선택했을때의 경우를 살펴보자.

void (*pf)(int, int);
pf = c.Op1;

문제가 없는 코드인것같지만 막상 컴파일할때는 에러 처리된다. 왜냐하면 멤버 함수는 호출 객체인 this를 암시적 인수로 전달해야하므로 일반 함수와는 원형이 다르다. 호출 방법이 다르기 때문에 함수 포인터로는 멤버 함수를 가리킬 수 없으며 클래스 내의 멤버를 가리키는 멤버 포인터 변수가 필요하다.

// Memptr2.cpp
#include <stdio.h>

class Calc
{
public:
	void Op1(int a, int b) { printf("연산결과 : %d\n", a + b); }
	void Op2(int a, int b) { printf("연산결과 : %d\n", a - b); }
	void Op3(int a, int b) { printf("연산결과 : %d\n", a * b); }
};

void main()
{
	int ch;
	Calc c;
	int a = 3, b = 4;

	void(Calc:: * arop[3])(int, int) = { &Calc::Op1, &Calc::Op2, &Calc::Op3 };

	printf("(Memptr2)연산 방법을 선택하세요. 0 = 더하기, 1 = 빼기, 2 = 곱하기 : ");
	scanf_s("%d", &ch);

	if (ch >= 0 && ch <= 2)
	{
		(c.*arop[ch])(3, 4);
	}

}

 

main에서 멤버 함수 포인터의 배열 arop를 선언하고 세 맴버 함수의 주소로 초기화했다.

arop는 두 개의 정수 인수를 취하고 리턴값이 없는 Calc의 멤버 함수를 가리키는 크기 3의 배열이다. 

선언문이 복잡한데 이럴때는 사용자 정의형을 먼저 선언하고 배열을 선언하는 것이 쉽다.

typedef void (Calc::*fpop)(int, int);

멤버 함수 포인터 타입을 fpop로 선언해 두면 이 타입으로 배열을 간편하게 선언할 수 있다. arop가 세 멤버 함수의 위치를 기억하고 있으므로 사용자가 입력한 연산 종류를 첨자로하여 arop의 멤버 함수를 선택한다. 이 방식이면 호출할 멤버 함수가 아무리 많아도 배열만 늘리면 된다.

 

함수 포인터는 변수여서 다른 함수의 인수로 전달할 수 있다. 대표적인 예시가 qsort인데 비교함수를 인수로 전달하여 사용자가 원하는 방식으로 정렬을 수행한다. 멤버 포인터 변수도 마찬가지 장점이 있는데 멤버 함수에게 다른 멤버 함수를 전달한다.

// MemFuncArgument.cpp
#include <stdio.h>

class Calc
{
public:
	void Op1(int a, int b) { printf("연산결과 : %d\n", a + b); }
	void Op2(int a, int b) { printf("연산결과 : %d\n", a - b); }
	void Op3(int a, int b) { printf("연산결과 : %d\n", a * b); }
	void DoCalc(void (Calc::* fp)(int, int), int a, int b) // 인수 3개짜리 멤버 함수(함수포인터로 만들어놨음)
	{
		puts("연산 결과");
		printf("%d와 %d의 연산 결과 : ", a, b);
		(this->*fp)(a, b);
		puts("연산 결과 끝!");
	}
};

int main()
{
	int ch;
	Calc c;
	int a = 3, b = 4;
	void(Calc:: * arop[3])(int, int) = { &Calc::Op1, &Calc::Op2, &Calc::Op3 }; // 멤버 포인터
    // 배열에 Op1, Op2, Op3가 저장되있음

	printf("연산 방법을 선택하세요. 0 = 더하기, 1 = 빼기, 2 = 곱하기 : ");
	scanf_s("%d", &ch);

	if (ch >= 0 && ch <= 2)
	{
		c.DoCalc(arop[ch], a, b);
	}
}

DoCalc 멤버 함수는 앞뒤로 친절한 메세지를 출력하고 Op1, Op2, Op3를 대신 호출하는 래퍼런스이다.

연산할 멤버 함수와 피연산자를 인수로 전달하면 DoCalc가 연산 함수를 호출한다. 멤버 함수 실행 중에 다른 멤버 함수를 선택적으로 호출해야 한다면 대상 멤버 함수를 인수로 받아야하는데, 이때 필요한 것이 멤버 포인터 함수이다.

 

DoCalc의 fp 인수가 Calc의 멤버 함수 하나를 가리키며 DoCalc는 본체에서 this->*fp 표현식으로 전달받은 멤버 함수를 호출한다. 복잡한 알고리즘을 수행하는 중에 세부 동작을 실행할 때 이런 기법이 종종 사용된다고 한다.

예를 들어 트리를 순회하며 검색이나 통계를 낼 때 전위, 중위, 후위, 층별 순회를 할 수 이는데 어떤 방법을 쓸 것인가를 멤버 함수 포인터로 전달받는다.

 

 

멤버 포인터의 특징

멤버 포인터는 클래스 내의 한 지점을 가리킨다는 면에서 일반 포인터와 다른 면이 많다. 

상속 계층에서 멤버를 가리킬 때의 특징을 살펴보자.

// MemPtrInherit
#include <stdio.h>

class Parent
{
public:
	int p;
};

class Child : public Parent
{
public:
	int c;
};

int main()
{
	int Parent::* pp;
	int Child::* pc;

	pp = &Parent::p;
	pc = &Child::c;

	pc = &Parent::p;
	pc = &Child::p;
	// pp = &Child::c;
}

Parent는 p 멤버 변수를 가지고 자식 클래스인 Child는 또 다른 멤버 변수 c를 가진다.

main에서 Parent의 정수형 멤버를 가리키는 pp와

             Child의 정수형 멤버를 가리키는 pc를 선언하고 각 클래스의 멤버에 대입해보았다.

앞 두 개의 대입은 선언한 타입과 완전히 일치하므로 문제는 없다.

 

Child의 정수 멤버를 가리키는 pc가 Parent::p를 대입받는 것은 가능한데 상속에 의해 Child에도 p 멤버가 존재하기 때문이다. 소속 클래스를 부모로 지정해도 되고 자신인 Child::p로 지정해도 상관없다.

그러나 부모의 정수 멤버를 가리키는 pp가 Child::c를 가리키는 것은 안된다. 자식은 부모의 멤버를 가지지만 부모가 자식의 멤버를 가지지는 않기 때문이다.

 

멤버 포인터는 정적 멤버를 가리킬 수 없다. 정적 멤버는 클래스 소속일뿐 객체와는 상관없고 객체내의 위치를 가지지도 않아 멤버 포인터와 맞지 않으며 가리킬 때는 일반 포인터 변수를 사용한다. 멤버에 대한 레퍼런스라는 개념은 없어 레퍼런스 멤버도 가리킬 수 없다.

정적멤버는 생성과 동시에 초기화되기때문에 소속은 클래스꺼가 되고, 각 객체들이 공유하기때문에 멤버 포인터로 접근이 불가능하다(객체의 멤버가 아니라는뜻임) 따라서 일반 포인터를 사용해야함.

 

포인터 연산도 일반 포인터와는 약간 다르다. 증감 연산자로 앞뒤의 다른 멤버로 이동할 수 없는데 이는 함수 포인터와 마찬가지이다. 같은 타입의 멤버가 인접하여 무리지어 있을 리 없으니 증감에 의해 다음 멤버로 이동할 수 없고 그래야할 이유도 없다. 멤버 포인터 변수는 단지 클래스내의 한 지점을 가리킬 뿐이다.

 

반응형