[C++] 파생 클래스 객체에 발생하는 객체 잘림( object slicing )

반응형

객체 잘림( 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

그런데, 참조 변수인 rBaseCDerived 객체가 갖고 있는 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_idbase 객체로 복사가 됩니다.

하지만, 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_2m_id 값을 갖지만, 기존의 derivedm_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

하지만 이렇게 하면, CDerivedCDerived2의 모든 객체에 대해 객체 잘림( 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

 

 

 

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유