관리 메뉴

기억을 위한 기록들

[Fundamental C++] 6. 클래스 (6) - 클래스 타입 변환 본문

C & CPP/Fundamental C++

[Fundamental C++] 6. 클래스 (6) - 클래스 타입 변환

에드윈H 2021. 5. 6. 16:32

타입 변환의 기초

클래스 타입 변환을 알아보기 전에 타입 변환의 기초를 살펴보면,

int main()
{
	int a = 5;

	double d1 = a;
	double d2 = (double)a;
	double d3 = (double&)a;


	cout << d1 << endl;
	cout << d2 << endl;
	cout << d3 << endl;

	return 0;
};

코드를 실행하게 되면 결과가 위와 같이 나오게 된다.

 

d1, d2는 5 값이 제대로 나오는데 d3값은 이상한 값이 나온다. 그 이유는 d1, d2는 타입 변환된 값이 임시로 생성된 값(rValue)이고, d3는 실제 객체(lValue)로 a객체가 가리키는 주소를 기준으로 8바이트의 메모리 덩어리를 double로 객체로 변환하는 것이다.

 

즉, d1, d2는 임시 값 5가 생성되는 값 타입 변환하는 것이고, double&로 타입 변환할 경우 a가 차지하던 메모리 영역을 8바이트로 확장만 한 후 반환하는 참조 타입 변환하는 것이다.

 

 

다른 예를 살펴보면 

 

	int a = 5;
        
         //double& d4 = a;  //error
	//double& d5 = (double)a; //error
	double& d6 = (double&)a;

 

d5부터 보면 임시 값 5는 rValue이기 때문이다. 참조 타입 객체는 rValue로 초기화할 수 없기에 에러가 발생한다.

 

d4는 a가 차지하던 메모리 영역을 단지 확장하여 double로 만드는 것은 위험할 수 있기 때문이다.

참조 타입 변환을 수행할 경우 메모리만 반환되기 때문에 제대로 된 5라는 값이 들어있지 않다. 따라서 컴파일러가 이런 위험한 타입 변환을 허용하지 않는다.

 

d6는 에러가 발생하지 않는데 이유는 d4는 암묵적인 변환을 사용하는 반면에, d6는 명시적으로 변환 연산자를 사용하기 때문이다. 직접 명시적으로 사용한 경우 컴파일러는 위험한 걸 알아도 타입 변환을 허용해준다. 그에 따른 책임은 개발자에게 있는 것이다.

 

클래스 간의 타입 변환

기본 타입인 int와 double의 타입 변환처럼 클래스 간의 타입 변환도 같은 원리를 따르게 되어있다. 기본 틀을 비슷하지만, 클래스라서 조금 다른 점도 있다. int형 멤버를 갖고 있는 두 개의 클래스가 있다고 치자.

class AClass
{
public:
	int mInt;
};


class BClass
{
public:
	int mInt;
};

 

두개의 클래스 A, B의 멤버 int 타입 객체 하나로 이름도 똑같다. 메모리 구조가 완전히 일치하는 것이다.

두 클래스의 타입 변환은 자유롭게 될까??

int main()
{
	AClass a;
	a.mInt = 10;

	BClass b1 = a;  //error
	BClass b2 = (BClass)a; //error
	BClass b3 = static_cast<BClass>(a); //error
	BClass b4 = reinterpret_cast<BClass>(a); //error
	return 0;
};

A클래스의 a객체를 B클래스로 타입 변환하려 했으나 컴파일 에러가 발생한다. 컴파일러는 a로부터 변환된 임시 객체(값)를 생성할 수 없는 것이다.  기본 타입과는 확실히 다르다. 이전에 int와 double 타입 변환은 성공했지만, 클래스 객체가 다른 클래스 타입으로 변환될 수는 없는 것이다.

 

그 이유로는 int와 double은 상식적으로 받아들일 수 있게 잘 정의가 되어 있지만, 서로 관련 없는 클래스 간의 타입 변환은 어떤 기준으로 해야 할지 알 수 없기 때문이다.

어떤 기준으로 해줘야 할지 정의를 해주는 방법은 생성자를 추가해주면 된다.

 

//..나머지는 그대로
class BClass
{
public:
	int mInt;

	BClass(const AClass& ref)  //추가
	{}
};
//..나머지는 그대로

 

B클래스에서 A클래스에 대한 복사 생성자를 생성해주게 되면 컴파일은 한 개 빼고 성공한다.

 

int main()
{
	AClass a;
	a.mInt = 10;

	BClass b1 = a;     //ok
	BClass b2 = (BClass)a;   //ok
	BClass b3 = static_cast<BClass>(a);  //ok
	//BClass b4 = reinterpret_cast<BClass>(a); //여전히 error
	return 0;
};

 

rereinterpret_cast는 잠시 보류해두고 참조 타입인 BClass& 타입 변환을 수행해보자.

(이전에도 설명했듯이 참조 타입으로 변환할 경우 해당 객체의 메모리 크기 만을 변경하여 그대로 반환하게 된다.)

 

int main()
{
	AClass a;
	a.mInt = 10;

	//BClass& b1 = a;    //error
	BClass& b2 = (BClass&)a;  //ok
	//BClass& b3 = static_cast<BClass&>(a);  //error
	BClass& b4 = reinterpret_cast<BClass&>(a);  //ok
	return 0;
};

 

b1는 암묵적인 타입 변환으로 컴파일러는 에러를 발생시키고, 

b2는 명시적으로 강제 타입 변환 하기에 성공하는 것이다. (BClass&) 같은 타입 변환을 C 스타일 타입 변환을 강제 타입 변환이라고 하는데, 사용자가 변환을 지시했기에 컴파일러는 그냥 수행하는 것이다.

b3의 static_cast는 상속 관계에 있을 때만 컴파일러가 변환을 허용해준다. 즉 사용자가 원하더라도 A클래스 B클래스가 상속관계가 아니라서 컴파일 에러가 발생한다.

 

b4의 reinterpret_cast는 일종의 강제적 타입 변환이다. 따라서 허용된다. b2와 같은 강제 타입 변환과의 차이는 나중에 다시 설명하도록 하겠다.

 

아 그리고 일반 타입 변환처럼 생성자가 있든 없든 간에 결과는 달라지지 않는다. 

생성자는 말 그대로 새로운 객체가 생성될 때 호출되는 것인데, 참조 타입 변환처럼 원래 있던 객체의 메모리 영역 크기만 변경하여 반환하는 경우에는 사용될 수가 없다. 

 

이것이 클래스 간의 타입 변환에서 값 타입 변환과 참조 타입 변환의 가장 큰 차이점이라고 할 수 있다.

 

제약이 많은 것처럼 보여도 안전을 위한 조치들이다. 개발자의 실수나, 잘못된 메모리 참조를 막기 위해서이다.

반대로 안전하다면 컴파일러는 허용해주게 되는데, 상속 관계 게 있는 클래스 간의 타입 변환이 이에 해당된다.

 

 

상속 관계 클래스 간의 타입 변환

Parent를 상속받는 Child클래스가 있다고 하자.

class Parent
{
public:
	int mPInt;
};


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

Child객체 c는 Parent 클래스 타입 변환이 가능하다. c 객체에는 Parent의 클래스의 mPInt의 값이 있기 때문에 복사해주면 되기에 문제가 되지 않는다.

int main()
{
	Child c;
	c.mPInt = 1;
	c.mCInt = 15;

	Parent p1 = c;
	Parent p2 = (Parent)c;
	Parent p3 = static_cast<Parent>(c);
	//Parent p4 = reinterpret_cast<Parent>(c); //error


	Parent p;
	p.mPInt = 5;

	//Child c1 = p;   //error
	//Child c2 = (Child)p;   //error
	//Child c3 = static_cast<Child>(p);   //error
	//Child c4 = reinterpret_cast<Child>(p);   //error

	return 0;
};

하지만 Parent클래스에는 mCInt의 값이 없기때문에 타입 변환해주기가 애매하다.

그래도 Child에 이전처럼 생성자를 생성해주면 타입변환이 가능해질 수는 있다.

class Child :public Parent
{
public:
	int mCInt;
	Child(const Parent& ref) //이렇게
	{
		mCInt = 0;
	}
};

 

이제 참조 타입변환을 해보자. 두 클래스는 그대로 두고 (위의 생성자 빼고)

int main()
{
	Child c;
	c.mPInt = 1;
	c.mCInt = 15;

	Parent& p1 = c;
	Parent& p2 = (Parent&)c;
	Parent& p3 = static_cast<Parent&>(c);
	Parent& p4 = reinterpret_cast<Parent&>(c);


	Parent p;
	p.mPInt = 5;

	//Child& c1 = p;   //error
	Child& c2 = (Child&)p;   
	Child& c3 = static_cast<Child&>(p);  
	Child& c4 = reinterpret_cast<Child&>(p); 

	return 0;
};

자식-> 부모 클래스 참조 타입 변환은 상식적으로 받아들일 수 있어서 모두 허용된다.

하지만 부모->자식 참조 타입 변환은 주의가 필요하다.

Parent클래스 객체 p를 Child& 타입 변환하면 p가 차지하는 메모리 영역을 확장하여, Child객체로 만들게 된다.

문제는 확장되면서 새로 생성되는 멤버 mCInt의 영역이 불분명해진다는 것이다. 영역이 유효할 수도 있으나, 미정의 값이 들어갈 수도 있고, 메모리 접근 자체가 안돼서 예외가 발생할 수도 있다. 즉 컴파일러는 정확히 예측하기 어려워진다.

그에 비해서 유효영역인 mPInt를 접근할 경우엔 문제가 발생하지 않는다.

 

컴파일러는 선택을 하게 되는데 c1와 같이 암묵적인 변환할 경우 타입 변환을 거부하지만, c2, c3, c4와 같이 타입 변환 연산자를 명시적으로 직접 사용할 경우 개발자가 직접 책임진다는 가정하에(mCInt에 접근하지 않거나, mCInt에 접근해도 유효하다던가 등) 변환을 허용해주게 되는 것이다.

 

유효하게 되는 경우가 아래와 같은 상황이다.

int main()
{
	Child c;
	c.mCInt = 5; c.mPInt = 10;

	Parent& rp = c;
	Child& rc = (Child&)rp;

	return 0;
};

c객체가 Parent&로 참조 타입변환해서 Parent객체 rp가 다시 Child&로 참조 타입 변환하여 Child객체인 rc가 된다.

즉 객체 c가 차지하던 메모리 영역이 그대로 rc가 되는 것이기에 rc를 통해서 mCInt에 접근하는 것이 유효하게 된다.

이런 쓰임새를 고려하여(rp를 Child& 변환) 타입변환 연산자를 직접 사용할 경우 부모-> 자식 클래스 참조 타입 변환을 허용해주는 것이다.

 

 

정리하자면

1. 자식 클래스에서 부모클래스로 타입 변환(값 타입, 참조 타입 모두)을 하는 것은 상식적인 일이므로 컴파일러가 허용해준다.

2. 부모 클래스에서 자식 클래스로 타입 변환을 할 경우, 값 타입 변환일 때는 금지되지만, 참조 타입 변환일 경우 개발자가 직접 타입 변환 연산자를 사용하는 경우에만 허용해준다.

3. static_cast는 오직 상속 관계에서만 허용되며, reinterpret_cast는 참조 타입이나 포인터 타입으로 변환만 가능하다.

 

클래스간의 타입 변환은 그리 많이 사용되진 않고, 주로 사용되는 것은 포인터 타입 변환이다. 두려워할 게 없는 게 참조 타입 변환과 그대로 일치한다. 애초에 참조 타입이라는 것이 내부적으로는 포인터를 사용하기 때문이다.

 

 

 

클래스 포인터 타입 변환

포인터는 결국 메모리 주소를 담는 4바이트 혹은 8바이트 메모리 블록일뿐이다. 따라서 클래스를 포함해서 모든 타입의 포인터 타입은 상호 변환이 무제한으로 이루어져도 큰 문제가 없을 것 같다. 그러나 이런 무제한적 허용은 상당한 자유를 주기도 하지만, 큰 문제를 발생 시킬 수 있는 위험성도 함께가지고 있다.

 

클래스 포인터 변환을 살펴보기전에 위에서 작성한 클래스 참조 변환을 보면

int main()
{
	AClass a;
	a.mInt = 10;

	//BClass& b1 = a;    //error
	BClass& b2 = (BClass&)a;  //ok
	//BClass& b3 = static_cast<BClass&>(a);  //error
	BClass& b4 = reinterpret_cast<BClass&>(a);  //ok
	return 0;
};

두개는 에러가 발생하고, 두개는 ok가 된다. 포인터형식도 마찬가지이다.

int main()
{
	AClass* a = new AClass;
	BClass* b = a;

	//BClass* b1 = a;    //error
	BClass* b2 = (BClass*)a;  //ok
	//BClass* b3 = static_cast<BClass*>(a);  //error
	BClass* b4 = reinterpret_cast<BClass*>(a);  //ok
	return 0;
};

포인터 타입이 즉 참조타입이다. 즉 포인터 타입변환을 하게 되면 기존 객체가 차지하는 메모리 영역을 적절히 조정하여 주소를 반환하는 것이다.

 

상속관계 클래스의 타입변환도 마찬가지로,

int main()
{
	Child* c = new Child;


	Parent* p1 = c;
	Parent* p2 = (Parent*)c;
	Parent* p3 = static_cast<Parent*>(c);
	Parent* p4 = reinterpret_cast<Parent*>(c);


	Parent* p = new Parent;
	

	//Child* c1 = p;   //error
	Child* c2 = (Child*)p;   
	Child* c3 = static_cast<Child*>(p);  
	Child* c4 = reinterpret_cast<Child*>(p); 

	return 0;
};

상속관계 클래스의 참조타입변환과 동일한 결과이다. 마찬가지로 주의해야 할점은 c2,c3,c4 객체는 명시적인 타입 변환 연산자를 사용하기에 변환이 허용된다. c1는 암시적 변환으로 컴파일러가 허용하지 않는다.

c3의 static_cast는 상속관계의 클래스사이에서만 변환이 허용 된다는것을 기억하자.

 

간단히 정리하면 상속관계는 서로 자유롭게 타입 변환이 허용되지만, 부모->자식 포인터로 암시적 변환이 허용되지 않는다.

 

 

타입변환이란 메모리블록의 내용을 변환될 타입에 맞게 적절하게 변형하는 것이다.