객체 잘림( object slicing )
클래스의 상속 관계를 이용할 때는, 기본 클래스의 포인터 타입( pointer )이나 참조 타입( reference )을 사용하여 접근해야 한다는 것을 알고 있을 것입니다.
다음은 기본 클래스 CBase
에서 상속받은 클래스 CDerived
의 객체에 접근하기 위해서, 기본 클래스의 참조 타입을 이용하는 것을 보여줍니다.
#include <iostream>
#include <string_view>
using std::string_view;
class CBase{ // 기본 클래스
int m_id;
public:
CBase( int id) : m_id( id){}
virtual string_view getName(){ return "Base"; } // 가상 함수
int getID() const { return m_id; }
};
class CDerived : public CBase{ // 파생 클래스
int m_nVal;
public:
CDerived( int id, int val) : CBase( id ),
m_nVal( val){}
virtual string_view getName(){ return "Derived"; }
int getValue() const { return m_nVal; }
};
int main(){
CDerived derived(1, 3); // id: 1
CBase& rBase = derived; // 참조 변수 사용
std::cout << rBase.getID() << " object is " << rBase.getName() << '\n';
}
▼출력
1 object is Derived
그런데, 참조 변수인 rBase
는 CDerived
객체가 갖고 있는 m_nVal
변수에 접근할 수 없습니다.
즉, CBase
타입의 참조 변수( 혹은 포인터 변수 )를 사용한다고 하더라고, 가상 함수를 통하지 않으면, 알 수 없는 파생 클래스의 데이터 멤버에 접근할 수 없다는 것입니다.
이와 마찬가지로, 만일 파생 클래스 객체를 기본 클래스 객체에 대입하면, 기본 클래스가 알 수 있는 데이터만이 복사됩니다.
int main(){
CDerived derived(1, 3); // id: 1
CBase base(2); // id: 2
base = derived;
std::cout << base.getID() << " object is " << base.getName() << '\n';
}
▼출력
1 object is Base
위에서 알 수 있듯이, CBase
가 알 수 있는 derived
객체의 데이터 멤버 m_id
는 base
객체로 복사가 됩니다.
하지만, derived
객체의 나머지 데이터( 이 경우는 m_nVal
)는 복사되지 않고 잘려 버려집니다.
이것을 객체 잘림( object slicing )이라고 합니다.
이 객체 잘림은 객체의 데이터를 삭제하는 것과 마찬가지이므로, 거의 모든 경우에 도움이 되지 않습니다.
그러므로, 파생 클래스를 기본 클래스에 대입하는 코드는 좋지 않다고 할 수 있습니다.
그래서, 기본 클래스를 통해서 파생 클래스에 접근하기 위해서, 참조 타입( 혹은 포인터 )을 사용하는 것입니다.
하지만, 참조 타입을 사용한다고 해서 항상 안전한 것은 아닙니다.
Franken Object
아래의 예문은 derived
객체에 접근하기 위해서, rBase
참조 변수를 사용하는 것을 보여줍니다.
int main(){
CDerived derived( 1, 3); // id: 1
CBase& rBase = derived;
std::cout << rBase.getID() << " object is " << rBase.getName() << '\n';
CDerived derived_2( 2, 5); // id: 2
rBase = derived_2; // rBase의 대상 변경 ??
// CBase 참조를 CDerived 참조로 변경
CDerived& rDerived = static_cast< CDerived& >(rBase);
std::cout << rDerived.getID() << " object has " << rDerived.getValue() << '\n';
}
▼출력
1 object is Derived
2 object has 3
그런데, derived
객체를 가상 함수( getName )를 통해서 사용한 후에, 다음 대상인 derived_2
를 사용하기 위해서, 다음과 같은 코드를 실행하게 되면 어떻게 될까요?
rBase = derived_2; // rBase의 대상 변경 ??
위의 코드를 통해서, rBase
가 참조하는 대상이 derived_2
객체로 변경될 것 같지만, 좌측값 참조( lvalue reference )는 한 번 초기화되면, 그 값을 다시 변경할 수 없습니다.
그럼에도 불구하고, 여기서 오류가 생기지 않는 것은, 위의 코드가 rBase
가 가리키는 대상에 derived_2
객체를 대입하는 기능을 수행하기 때문입니다.
즉, 위의 코드는 사실 다음 코드를 실행하는 것과 같습니다.
derived = static_cast< CBase >(derived_2);
그리고, 이 대입 연산은 derived_2
객체의 CBase
부분만을 복사하게 됩니다. ( CBase
클래스만 인식이 가능하므로 )
그 결과, 위 출력 결과가 보여주듯이, derived
객체는 짜깁기하듯이 derived_2
의 m_id
값을 갖지만, 기존의 derived
의 m_nVal
은 그대로 갖게 됩니다. ( 위의 Franken이 말하는 것이, 우리가 아는 영화의 그것이 맞습니다. )
안타깝지만, 이러한 오류는 프로그래머의 순간적인 착오에 의해 발생하므로, 생각보다 찾아내기 어렵고, 컴파일러도 경고하지 못합니다.
그리므로, 이 경우같이 참조 변수를 독립적으로 사용할 때 주의할 수밖에 없습니다.
vector에 다양한 파생 클래스를 저장하는 방법
std::vector에 이전 항목에서 사용한 파생 클래스 CDerived
의 객체를 저장하려면 다음과 같이 할 수 있습니다.
#include <iostream>
#include <string_view>
#include <vector>
using std::string_view;
using std::vector;
class CBase{ // 기본 클래스
int m_id;
public:
CBase( int id) : m_id( id){}
virtual string_view getName() const { return "Base"; }
int getID() const { return m_id; }
void print() const { std::cout << getName() << ": " << getID() << '\n'; };
};
class CDerived : public CBase{ // 파생 클래스
int m_nVal;
public:
CDerived( int id, int val) : CBase( id ),
m_nVal( val){}
virtual string_view getName(){ return "Derived"; }
int getValue() const { return m_nVal; }
virtual void print() const{ std::cout << getName() << ": " << getValue() << '\n';}
};
template< typename T >
void processDerived( const vector<T>& objects){
for( auto& x : objects){
x.print();
}
}
int main(){
vector< CDerived > objects; // 파생 클래스를 저장하기 위한 변수
CDerived obj1(1, 3);
objects.push_back( obj1);
CDerived obj2(2, 5);
objects.push_back( obj2);
processDerived( objects );
}
▼출력
Derived: 1
Derived: 2
그런데, 만약 CDerived
타입의 객체 외에, 아래와 같은 CDerived2
객체도 이 std::vector에 추가하려면 어떻게 해야 할까요?
class CDerived2 : public CBase{ // CBase에서 상속받은 추가 클래스
double m_dVal;
public:
CDerived2( int id, double val) : CBase( id ),
m_dVal( val){}
virtual string_view getName() const { return "Derived2"; }
virtual void print() const { std::cout << getName() << ": " << m_dVal << '\n';}
};
먼저 떠올릴 수 있는 것이 기본 클래스 타입 CBase
를 저장하는 std::vector를 만드는 것입니다.
int main(){
vector< CBase > objects; // CBase 타입의 객체 저장
CDerived obj1(1, 3);
objects.push_back( obj1);
CDerived2 obj2(2, 5); // CDerived2 객체
objects.push_back( obj2);
processDerived( objects );
}
▼출력
Base: 1
Base: 2
하지만 이렇게 하면, CDerived
와 CDerived2
의 모든 객체에 대해 객체 잘림( object slicing )이 발생합니다.
그럼, 참조 타입을 저장하면 어떨까요?
안타깝게도, 참조 타입은 객체가 아니어서( 단지, 대상의 별명 ), 배열은 물론 std::vector에도 저장할 수가 없습니다.
vector< CBase& > objects; // 참조 타입의 저장 error !!
그러면, 포인터 타입( pointer )을 저장하면 어떨까요?
이 타입은 물론 저장은 되지만 근본적인 단점이 있습니다.
포인터 타입은 가리키는 대상이 없는 nullptr 상태가 될 수 있다는 것입니다.
[C++] null 포인터와 nullptr 리터럴( literal )
null 포인터( pointer )포인터( pointer )는 객체의 메모리 주소를 저장하는 변수입니다.그리고 아래와 같이, 포인터 ptr이 객체 x의 주소를 저장하고 있는 상태를, "포인터 ptr이 객체 x를 가리키고 있다"
codingbonfire.tistory.com
물론, 이러한 상태가 있는 것을 활용하는 방법도 있지만( nullptr
인 경우 함수 실패를 의미하는 방법 등 ), 일반적으로 이 상태를 확인하는 추가 코드들이 함수들을 복잡하게 만들게 됩니다.
이럴 때를 위해서 C++ 표준 라이브러리는 std::reference_wrapper 클래스를 지원합니다.
std::reference_wrapper를 통한 참조 타입 저장
std::reference_wrapper는 대상 객체에 대한 참조 변수를 저장하는 객체( object )입니다.
그렇단 얘기는 참조( reference ) 자체는 std::vector에 저장할 수 없지만, 이 reference_wrapper 객체는 참조 변수를 담은 채, std::vector에 저장될 수 있다는 것입니다.
#include <functional> // for std::reference_wrapper
template< typename T >
void processDerived( const vector<T>& objects){
for( const auto& x : objects){
x.get().print(); // 참조 변수를 얻기 위해 get() 사용
}
}
int main(){
// reference_wrapper 객체를 저장
vector< std::reference_wrapper< CBase > > objects;
CDerived obj1(1, 3);
objects.push_back( obj1);
CDerived2 obj2(2, 5.38); // CDerived2 객체
objects.push_back( obj2);
processDerived( objects );
}
▼출력
Derived: 3
Derived2: 5.38
위의 결과를 보면 알 수 있듯이, CDerived
타입의 객체뿐만 아니라, CDerived2
타입의 객체를 담아도, 기반 클래스 CBase
타입의 참조에 접근할 수 있으므로, 각 파생 클래스의 가상 함수를 실행시킬 수 있습니다.
참고로, std::reference_wrapper 객체의 get 함수는 저장하고 있는 객체의 참조( reference )를 반환합니다.
따라서, 위 processDerived 함수의 x.get().print();
문장은 다음과 같습니다.
CBase& rBase = x.get(); // 저장하고 있던 참조를 구함
rBase.print();
정리
- 파생 클래스 객체를 기반 클래스의 객체에 대입하면, 기반 클래스 부분의 데이터만 복사됩니다. 이러한 대입의 결과, 클래스의 데이터가 손실되는 것을 객체 잘림( object slicing )이라고 합니다.
- std::reference_wrapper를 사용하면, 객체 잘림 걱정 없이, 기반 클래스 객체의 참조를 std::vector에 저장할 수 있습니다.
이 글과 관련 있는 글들
C-style 타입 변환과 C++의 static_cast