언어/C++

Cpp - 상속#1

tsyang 2021. 7. 18. 13:59

기본적인 내용은 안 씀.

 

상속


상속은 왜 쓸까?

  1. 클래스 간의 관계 : 동물 -> 고양이 사람..
  2. 코드 재사용 : 동물은 잠을 자.. 그럼 고양이 사람도 다 잠을 자
  3. 일관적인 클래스 인터페이스 (class interface consistency) : abstract, interface , pure virtual function
  4. 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://stackoverflow.com/questions/860339/difference-between-private-public-and-protected-inheritance

https://youtube.com/playlist?list=PLDV-cCQnUlIar6Wx3rkXHs7wbfmlz34Fz

https://ko.wikipedia.org/wiki/%EC%8D%BD%ED%81%AC

'언어 > 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