기본적인 내용은 안 씀.
상속
상속은 왜 쓸까?
- 클래스 간의 관계 : 동물 -> 고양이 사람..
- 코드 재사용 : 동물은 잠을 자.. 그럼 고양이 사람도 다 잠을 자
- 일관적인 클래스 인터페이스 (class interface consistency) : abstract, interface , pure virtual function
- dynamic function binding (virtual function, virtual table)
상속을 받을 때는 public, protected, private 키워드를 붙일 수 있다. 설명은 주석으로 대신한다.
class A
{
public:
int x;
protected:
int y;
private:
int z;
};
class B : public A
{
// x is public
// y is protected
// z is not accessible from B
};
class C : protected A
{
// x is protected
// y is protected
// z is not accessible from C
};
class D : private A // 'private' is default for classes
{
// x is private
// y is private
// z is not accessible from D
};
Constructor / Destructor
animal 클래스를 상속한 cat 클래스가 있다고 하자.
class animal
{
public :
animal()
{
cout << "animal Constructor" << endl;
}
~animal()
{
cout << "animal Destructor" << endl;
}
};
class cat : public animal
{
public :
cat()
{
cout << "cat Constructor" << endl;
}
~cat()
{
cout << "cat Destructor" << endl;
}
};
int main()
{
cat cat;
return 0;
}
위의 코드는 다음을 출력한다.
만약 다중 상속인 경우, 먼저 상속한 클래스의 생성자가 처음에 그리고 소멸자가 마지막에 호출된다.
그럼 다음과 같은 코드는 무엇을 출력할까?
int main()
{
animal * pCat = new cat();
delete pCat;
}
cat의 Destructor가 호출되지 않는다. 왜 그런지 자세한 이유는 이후 가상 함수 테이블을 보면 알 수 있다.
아무튼 이러한 이유 때문에 클래스 간 상속을 할 경우 base 클래스의 destructor는 protected / virtual public 둘 중 하나로 하는 게 좋다.
만약 위 코드에서 animal의 destructor를 protected로 해준다면 delete pCat; 을 호출할 수 없다.
가상 함수 테이블 (VT)
class animal
{
public :
void speak()
{
cout << "animal" << endl;
}
private:
double animalData;
};
class cat : public animal
{
public :
void speak()
{
cout << "animal" << endl;
}
private:
double catData;
};
int main()
{
cout << sizeof(animal) << endl;
cout << sizeof(cat) << endl;
}
위 코드는 8/16을 출력한다.
그런데 animal::speak()에 virtual 키워드를 붙여주면 각각의 클래스의 사이즈는 16/24 (byte)가 된다. 객체의 크기가 각각 8바이트가 증가한 것이다. 그 이유는 가상함수 테이블(VT)을 가리키는 포인터가 객체에 추가되었기 때문이다. (64비트 운영체제이므로 8바이트가 추가됨)
이렇게 되면, cat객체를 생성한 뒤 그것을 가리키는 게 animal 포인터이든지 cat 포인터이든지 상관없이 cat의 VT를 참조하여 speak()를 찾는다. 위에서 말한 base 클래스의 destructor도 같은 이유로 virtual로 해주어야 한다.
순수 가상 함수 (pure virtual function)
virtual void speak() = 0;
이렇게 지정해주면 해당 함수는 순수 가상 함수가 된다.
순수 가상 함수를 포함한 클래스는 추상(abstract) 클래스가 되어 오브젝트를 생성할 수 없게 된다.
인터페이스 클래스를 만들 땐 멤버 변수가 없고 모든 함수가 순수 가상 함수라면 유동성을 확보할 수 있다. 다만, 이렇게 되면 코드 중복이 발생할 수 있으니 이런 경우 interface class와 implementation class를 다중 상속하여 사용할 수 있다.
다중 상속
이번에는 Lion와 Tiger 클래스를 다중 상속한 Liger 클래스가 있다고 가정하자. Lion과 Tiger에서 둘 다 메서드 중 하나에 virtual 키워드를 지정했다면 오브젝트의 구조는 다음과 같을 것이다.
Liger의 사이즈를 출력하면 40바이트가 나온다.
다이아몬드 상속
다이아몬드 상속은 보통 안 쓰지만 그래도 원리는 알아야 하니까..
만약 Lion과 Tiger 클래스가 Animal 클래스를 상속받고 있다면 다이아몬드 상속 관계가 된다.
그리고 Liger 객체를 생성한 뒤 생성자와 소멸자의 호출 순서를 보면 위와 같이 출력이 되는데, animal의 생성자와 소멸자가 각각 2번씩 호출된 것을 확인할 수 있다. 그 이유는 위의 Liger의 객체의 구조를 보면 Lion, Tiger, Liger 객체를 순서대로 생성하는데, 이때 Lion과 Tiger가 생성될 때 Animal 객체를 생성하므로 Animal 객체가 두 번 생성되는 것이다.
이런 문제는 다음의 가상 상속을 이용해 해결할 수 있다.
가상 상속
class animal
{
public :
virtual void speak()
{
cout << " animal speak" << endl;
}
private:
double animalData;
};
class Lion : public animal
{
public:
void speak() override
{
cout << "LION" << endl;
}
private:
double lionData;
};
int main()
{
cout<<sizeof(Lion);
}
위 코드는 24를 출력한다.
오브젝트의 메모리 구조를 보면,
이렇게 되어 있을 것이다.
만약 Lion이 Animal을 가상 상속하게 하면
class Lion : virtual public animal
사이즈가 36이 되는데 메모리 구조가 다음과 같이 되기 때문이다.
참고로 Lion과 Animal의 메모리 시작점이 달라졌음에 주목하자.
자 다시 일반 상속으로 돌아와 보자
class animal
{
public :
virtual void speak() = 0;
private:
double animalData;
};
class Lion : public animal
{
public:
void speak() override
{
cout << "Lion : "<< lionData << endl;
}
private:
double lionData = 1.5;
};
int main()
{
animal * polyAnimal = new Lion();
polyAnimal->speak();
delete polyAnimal;
return 0;
}
여기서 polyAnimal는 animal 포인터이기 때문에 animal 오브젝트의 VT와 animalData밖에 보지 못한다. 즉, lionData를 알 수 없다.
그럼에도 불구하고 polyAnimal을 통해 lionData를 사용하는 Lion::speak()를 정상적으로 호출할 수 있는데, 왜냐하면 polyAnimal은 lionData를 보지 못하더라도 시작점을 통해 16byte의 offset으로 해당 값을 알아낼 수 있기 때문이다.
가상 상속의 경우에, 만약 Lion::Speak가 lionData를 사용하지 않는다면 정상적으로 polyAnimal을 통해 Lion::speak()를 호출할 수 있지만 만약 Lion::speak 가 lionData를 사용한다면 문제가 생길 수 있다.
왜냐면 아래 구조에서 보면 알 수 있듯이 polyAnimal은 animal 포인터이므로 초록색 블록의 시작 영역을 가리키는데 이렇게 되면 lionData가 어디에 있는지 알 수 없기 때문임.
다시 말하면, 일반 상속의 경우 Lion객체를 Lion 포인터로 가리키든 animal 포인터로 가리키든 시작 위치는 같지만 가상 상속의 경우 포인터가 가리키는 시작 위치가 달라져서 lionData의 위치를 알 수 없다는 것.
그래서 Lion VT에서는 offset을 저장한다. 이때 animal의 VT를 통해 speak()를 호출하더라도 Lion::speak()를 호출하지는 않는다. 대신 '오프셋을 계산해서 호출하는' speak() 함수를 만드는데 바로 thunk이다. 그런데 이렇게 되면 만약 Lion 포인터를 이용한 경우에는 offset을 계산하면 안 되므로 일반적인 Lion::speak()를 또 VT에 저장한다.
참고로 thunk의 위키 정의는..
썽크(Thunk)는 기존의 서브루틴에 추가적인 연산을 삽입할 때 사용되는 서브루틴이다.
이렇다. (https://ko.wikipedia.org/wiki/%EC%8D%BD%ED%81%AC)
이제 가상 상속에 대해 알았으므로 다이아몬드 상속에서 왜 가상 상속을 한 경우 animal constructor가 한 번만 호출되는지는 다음의 오브젝트 구조를 보면 알 수 있다.
참고 :
https://youtube.com/playlist?list=PLDV-cCQnUlIar6Wx3rkXHs7wbfmlz34Fz
'언어 > C++' 카테고리의 다른 글
Cpp 함수 (C++11 lambda, std::function) (2) | 2021.07.31 |
---|---|
Cpp - 상속#2 (2) | 2021.07.22 |
C++ (복사/이동) 생성자, 할당자, Rule of Three(Five) (0) | 2020.11.08 |
스마트 포인터 (0) | 2020.11.08 |
L_value와 R_value (0) | 2020.10.11 |