Inheritance의 이해 - ⑥ Object Slicing
C++의 상속관계에서 Object slicing은 의도하지 않았던 결과를 일으킬 수 있기 때문에 알아두는 것이 필요하다. 아래의 코드를 살펴보자.
class Animal
{
public:
virtual void Speak() { std::cout << "Animal!" << std::endl; }
virtual ~Animal() = default;
private:
double animalData = 0.0f;
};
class Cat : public Animal
{
public:
Cat(double d) : catData{ d } {}
void Speak() override { std::cout << "meow~" << std::endl; }
private:
double catData;
};
int main()
{
Cat kitty{ 1.0f };
kitty.Speak();
Animal& animalRef = kitty;
animalRef.Speak();
Animal animalObj = kitty; // object slicing
animalObj.Speak();
return 0;
}
meow~
meow~
Animal!
animalObj
는 kitty로 만든 객체이지만 Animal의 Speak 함수를 호출함을 확인할 수 있다. 이렇게 동작하는 이유가 object slicing 때문이다. 위 코드가 메모리 레벨에서 어떻게 동작하는지 그림과 함께 살펴보자.
먼저 animalRef
에 kitty를 대입할 경우 위 그림처럼 animalRef
가 kitty 오브젝트를 참조하게 된다. 이때 Speak 함수를 호출하게 되면 kitty 오브젝트의 가상 함수 테이블을 참조하여 Speak 함수를 호출하기 때문에 Cat::Speak 가 호출되는 것이다.
반면에 아래의 코드에서는 Animal 클래스의 복사 생성자가 호출되면서 kitty
오브젝트를 animalObj
로 복사하게 된다.
Animal animalObj = kitty; // object slicing, copy constructor
animalObj.Speak();
일반 double
타입인 animalData
는 같은 값으로 복사될 수 있지만, animalObj
의 가상함수 포인터(vfptr)는 특수한 데이터 타입으로 복사가 진행되지 않는다. 그래서 animalObj
의 가상함수가상 함수 포인터는 Animal 클래스의 가상 함수 테이블을 가리키게 되어서 Speak 함수를 호출할 경우 Animal::Speak 함수가 호출되는 것이다.
또 하나의 특징으로는 복사가 일어날 때 kitty
오브젝트의 catData
정보는 잘려나가게 된다. 그래서 이런 현상을 object slicing이라고 부른다.
Object Slicing을 막는 방법
Object Slicing으로 흔하게 발생할 수 있는 실수는 다음과 같은 상황이다.
// 의도한 방식: Dynamic polymorphism
void f(Animal& animal)
{
animal.Speak();
}
// 실수: Object slicing
void f(Animal animal)
{
animal.Speak();
}
함수의 인자를 참조로 받아서 다형성을 구현하려고 했으나 실수로 &
빼먹게될 경우 object slicing이 발생하며 의도하지 않은 방식으로 동작할 수 있다. 그래서 보다 안전한 코드를 작성하기 위해서는 다음과 같이 복사 생성자를 없애서 원천적으로 object slicing을 막아줄 수 있다.
class Animal
{
public:
Animal() = default;
virtual void Speak() { std::cout << "Animal!" << std::endl; }
virtual ~Animal() = default;
Animal(const Animal& a) = delete; // delete copy constructor
Animal& operator=(Animal other) = delete; // delete copy assignment
private:
double animalData = 0.0f;
};
class Cat : public Animal
{
public:
Cat(double d) : catData{ d } {}
void Speak() override { std::cout << "meow~" << std::endl; }
private:
double catData;
};
void f(Animal animal)
{
animal.Speak();
}
int main()
{
Cat kitty{ 1.0f };
f(kitty); // error C2280: attempting to reference a deleted function
return 0;
}
자식 클래스간의 복사
위 방법의 문제는 자식 클래스 간의 복사 생성자 호출까지 막아버린다는 점이다. 자식의 복사 생성자는 부모 클래스의 복사 생성자를 호출해야 하는데 부모 클래스의 복사 생성자가 지워졌기 때문에 자식 클래스 또한 복사 생성자를 호출할 수 없게 된 것이다. 이를 해결하기 위해서는 아래와 같이 부모 클래스의 복사 생성자를 protected
영역에 정의할 수 있다.
class Animal
{
public:
Animal() = default;
virtual void Speak() { std::cout << "Animal!" << std::endl; }
virtual ~Animal() = default;
Animal& operator=(Animal other) = delete; // delete copy assignment
protected:
Animal(const Animal& a) = default; // protected copy constructor
private:
double animalData = 0.0f;
};
class Cat : public Animal
{
public:
Cat(double d) : catData{ d } {}
void Speak() override { std::cout << "meow~" << std::endl; }
private:
double catData;
};
이런 방법 이외에도 복사 생성자는 삭제하고 복사가 필요한 경우를 위해 별도의 clone 함수를 제공하는 방법도 존재한다. 그리고 부모 클래스를 애초부터 순수 가상 함수로 이루어진 클래스로 구성하는 것도 좋은 방법이라고 할 수 있다.
출처: https://www.youtube.com/channel/UCHcG02L6TSS-StkSbqVy6Fg