공부/C || C++

C++ 클래스 상속, 다중 상속, 가상 함수 그리고 다형성(Inheritance & virtual function & polymorphism)

sudo 2022. 9. 25. 13:54

C++ 클래스와 상속 그리고 큰 특징 중 한가지인 다형성에 대해 알아볼 것이다.

 

그전에 상속과 관계 없지만 조금 신기한 것을 알게 됐는데, 아래처럼 클래스를 정의해놓고 아무런 멤버 변수를 선언하지 않고 size를 확인하면 신기하게도 1이 나온다.

class CTest1
{

};

struct CTest2
{

};

int main()
{
	CTest1 test1;
	CTest2 test2;

	std::cout << sizeof(test1) << std::endl;
	std::cout << sizeof(test2) << std::endl;

	return 0;
}

이유는 간단하다. 객체가 생성되면 일단은 고정된 메모리 어딘가에 각자 저장되어야 하기 때문이다. 그렇기 때문에 메모리 최소 단위인 1byte가 할당된 것이다. 이에 대한 답변은 stackoverflow에서도 찾을 수 있었다.

https://stackoverflow.com/questions/2362097/why-is-the-size-of-an-empty-class-in-c-not-zero

 

클래스 상속 형태

먼저 클래스 상속을 할때 3가지 형태로 상속할 수 있다는 건 다들 알고 있을 것이다. 여기서 말하는 상속의 형태란 아래 코드에서 xxx에 들어가는 형태에 따른 3가지를 의미한다.

class Parent
{

};

class Child : xxx Parent // <- 여기 xxx에 public, private, protected중 뭐가 붙는지
{ 

};
  public 상속 private 상속 protected 상속
설명 자식 클래스 내부에서는 부모의 private 멤버 제외 접근 가능, 외부(ex. main함수)에서는 부모의 public만 접근 가능.  private보다 접근 범위가 넓은 protected, public을 모두 private로 만든다. 따라서 자식 클래스 내부, 외부에서 부모의 모든 멤버에 접근 불가 자식 클래스 내부에서는 private제외 접근 가능, 외부에서는(ex. main 함수) 부모의 모든 것에 접근 불가

 

다중 상속(Multiple Inheritance)

하나의 자식의 둘 이상의 부모에게 상속 받고 있는 형태

class CParent1
{
public:
	CParent1()
	{
		std::cout << "CParent1 생성자" << std::endl;
	}

	virtual ~CParent1()
	{
		std::cout << "CParent1 소멸자" << std::endl;
	}

};

class CParent2
{

public:
	CParent2()
	{
		std::cout << "CParent2 생성자" << std::endl;
	}
	~CParent2()
	{
		std::cout << "CParent2 소멸자" << std::endl;
	}
};


class CChild : public CParent1, CParent2
{
public:
	CChild()
	{
		std::cout << "CChild 생성자" << std::endl;
	}

	~CChild()
	{
		std::cout << "CChild 소멸자" << std::endl;
	}


};

여기서 CChild 객체를 하나 생성하면 출력 결과는 아래와 같다.

부모의 생성자는 먼저 적힌 순서대로 호출되고 소멸자는 생성자의 역순으로 출력 되는 것을 확인할 수 있다.

 

다중 상속을 쓰면 유용한 경우

둘 이상의 클래스를 결합해서 하나의 클래스를 만들어야 하는 경우. 예를 들어 KoreanStudent라는 클래스가 있고, Korean 클래스, Student 클래스가 있다고 생각해보자. 이런 경우 KoreanStudent는 Korean 클래스와 Student 클래스를 조합해서 만들 수 있다.

 

다중 상속이 문제가 되는 경우

자식 클래스가 Parent1, Parent2 클래스를 다중 상속 받은 경우라고 생각해보자. 만약 Parent1, Parent2가 동일한 이름의 멤버 함수가 있다면, 자식 클래스는 어떤 부모의 멤버 함수를 호출한지 모호하다.

 

다형성과 가상함수(virtual function)

게임 제작을 하다 보면 상속을 정말 비일비 재하게 사용한다. 정말 간단한 예를 들어 Object라는 클래스를 부모 클래스를 상속하는 Player, Monster, UI 등의 자식 클래스들을 만들 수 있을 것이다. 그런데 이런 다양한 타입의 자식 클래스들을 쉽게 관리할 수 있는 방법이 있다. 바로 업 캐스팅을 통해 부모 클래스(여기선 Object클래스)의 타입으로 업 캐스팅을 해서 부모 클래스 타입으로 관리 하는 것이다. 예를 들면 아래와 같은 방법일 것이다. 

class CParent
{
public:
	CParent()
	{
		std::cout << "CParent 생성자" << std::endl;
	}

	~CParent()
	{
		std::cout << "CParent 소멸자" << std::endl;
	}
};

class CChild1 : public CParent
{
public:
	CChild1()
	{
		std::cout << "CChild 생성자" << std::endl;
	}

	~CChild1()
	{
		std::cout << "CChild 소멸자" << std::endl;
	}

};

class CChild2 : public CParent
{
public:
	CChild2()
	{
		std::cout << "CChild2 생성자" << std::endl;
	}

	~CChild2()
	{
		std::cout << "CChild2 소멸자" << std::endl;
	}
};

class CChild3 : public CParent
{
public:
	CChild3()
	{
		std::cout << "CChild3 생성자" << std::endl;
	}

	~CChild3()
	{
		std::cout << "CChild3 소멸자" << std::endl;
	}
};

int main()
{
	std::list<CParent*> ObjList;

	CParent* child1 = new CChild1;  // Upcasting(CChild1 -> CParent)
	CParent* child2 = new CChild2;  // Upcasting(CChild2 -> CParent)
	CParent* child3 = new CChild3;  // Upcasting(CChild3 -> CParent)

	ObjList.push_back(child1);
	ObjList.push_back(child2);
	ObjList.push_back(child3);

	delete child1;
	delete child2;
	delete child3;

	return 0;
}

main에서 볼 수 있듯이, 서로 다른 CChild1*, CChild2*, CChild3* 타입의 객체들이 생성과 동시에 업캐스팅이 돼서 하나의 타입(CParent*)의 list안에 들어가고 있는 것을 볼 수 있다. 이렇게 되면 Object 관리자 객체에서 list에 들어간 다양한 자식 클래스 타입의 객체들을 손쉽게 한 곳에서 관리가 가능하다. 이렇게 부모와 자식 간의 형(type) 변환이 가능한 성질을 다형성이라고 한다.

 

그런데 사실 이 코드에는 문제가 있다. 호출 결과를 보면

생성자는 괜찮은데 소멸자들이 부모 클래스의 소멸자만 호출되고 자식 클래스의 소멸자는 호출이 안되고 있다. 문제는 CChild1 클래스의 생성자에서 동적 할당을 해주고 소멸자에서 그걸 해제해주는 코드를 추가된다면 더 심각해진다. CChild1클래스의 소멸자가 호출되지 않으면 Memory leak 발생할 것이다. 

 

이런 현상이 일어나는 이유는 간단하다. 기본적으로 위 코드처럼 일반적인 경우엔 컴파일 타임에 고정된 주소로의 바인딩이 일어나는데, 이걸 가리켜 정적 바인딩(Static binding) 이라고 한다. 정적 바인딩이 되므로 컴파일러는 컴파일 타임에 보이는, 코드에 적혀 있던 타입만 보고 객체의 타입을 판단할 수 밖에 없다. 그리고 위 코드에서 child1, child2, child3 객체는 모두 CParent* 타입이다. 그래서 CParent의 소멸자만 호출한다.

 

그렇다면 해결 방법은 컴파일러에게 "이 함수를 호출 할 때는 컴파일 타임에 타입을 결정하지 말고 런타임에 결정해" 라는 지시가 필요하다. 그렇게 하는 것이 동적 바인딩(Dynamic binding) 이다. 그럼 그걸 어떻게 하느냐? 동적 바인딩이 필요한 함수 앞에 "virtual" 키워드를 붙이면 가상 함수로 만들어 줄 수 있다. 위에서 언급한 경우처럼 자식 클래스의 생성자에서 동적 할당을 하고 소멸자에서 해제를 해주는 경우에 부모의 소멸자를 반드시 가상 함수로 만들어야 자식 클래스의 소멸자가 호출된다.

class CParent
{
public:
	CParent()
	{
		std::cout << "CParent 생성자" << std::endl;
	}

	virtual ~CParent()	// 소멸자를 가상함수로!
	{
		std::cout << "CParent 소멸자" << std::endl;
	}
};

오버라이딩(Overriding)

오버라이딩도 위에서 언급한 경우랑 비슷한 것인데 자식 클래스에서 부모 클래스에서 정의된 함수를 재정의하는 것이다. 당연히 재정의라고 말하기 위해선 반환 타입, 모든 인자 그리고 함수 이름까지도 당연히 동일해야 한다. 예를 들면 이런 것이다.

class CParent
{
public:
	CParent()
	{
		std::cout << "CParent 생성자" << std::endl;
	}

	~CParent()
	{
		std::cout << "CParent 소멸자" << std::endl;
	}
    
	virtual void Output()
	{
		std::cout << "Parent Output" << std::endl;
	}
};

class CChild1 : public CParent
{
public:
	CChild1()
	{
		std::cout << "CChild 생성자" << std::endl;
	}

	~CChild1()
	{
		std::cout << "CChild 소멸자" << std::endl;
	}
    
	virtual void Output()
	{
		std::cout << "CChild1 Output" << std::endl;
	}

};

class CChild2 : public CParent
{
public:
	CChild2()
	{
		std::cout << "CChild2 생성자" << std::endl;
	}

	~CChild2()
	{
		std::cout << "CChild2 소멸자" << std::endl;
	}
    
	virtual void Output()
	{
		std::cout << "CChild2 Output" << std::endl;
	}
};

class CChild3 : public CParent
{
public:
	CChild3()
	{
		std::cout << "CChild3 생성자" << std::endl;
	}

	~CChild3()
	{
		std::cout << "CChild3 소멸자" << std::endl;
	}
};

int main()
{
	CParent* MyChild1 = new CChild1;
	CParent* MyChild2 = new CChild2;
	CParent* MyChild3 = new CChild3;

	MyChild1->Output();
	MyChild2->Output();
	MyChild3->Output();

	delete MyChild1;
	delete MyChild2;
	delete MyChild3;

	return 0;
}

 

CChild1, CChild2에선 output함수를 재정의 했으므로 각자의 Output함수가 호출되고, CChild3는 재정의하지 않았으므로 부모의 Output함수가 호출되는 것을 확인할 수 있다. 참고로 부모에서 정의된 함수에 virtual 키워드가 붙어있으면 대응되는 자식의 함수에서는 virtual을 붙이지 않아도 되지만 가독성을 위해서 붙여줬다.

만약에 Object 클래스를 상속받은 Player, Monster 클래스가 각자 Attack 이라는 같은 이름의 함수에서 서로 다른 동작을 해야한다면 부모 클래스에 일단 Attack을 가상함수로 정의하고, 자식 클래스에서 각자 다른 동작을 구현하면 Attack을 호출할 때 호출한 객체 타입에 따라 알아서 맞는 타입의 멤버 함수가 호출 될 것이므로, 굉장히 편리할 것이다. 

 

순수 가상 함수(pure virtual function)

가상함수 뒤에 = 0 을 붙여주면 순수 가상 함수가 된다. 부모가 순수 가상함수를 만들었을 때 자식클래스에서 무조건 재정의를 해야된다. 만약 자식클래스에서 재정의를 안한다면 해당 자식클래스는 추상클래스가 되어서 해당 클래스 타입의 객체 생성이 불가능하다. 순수가상함수를 만든 부모클래스에서 해당 순수가상함수를 만들어주는 클래스에 구현 부분을 만들어도 되고 안만들어도 된다.

 

순수 가상함수를 갖고 있는 클래스를 추상클래스라고 한다.

class CParent
{
public:
	CParent()
	{
	}

	virtual ~CParent()
	{
	}

public:
	virtual void Output()
	{
		std::cout << "CParent Output" << std::endl;
	}

	virtual void OutputPure() = 0;
	virtual void OutputPure1() = 0
	{
		std::cout << "CParent OutputPure1" << std::endl;
	}
};

class CChild : public CParent
{
public:
	CChild()
	{
	}

	virtual ~CChild()
	{
	}

public:
	virtual void Output()
	{
		std::cout << "CChild Output" << std::endl;
	}

	virtual void OutputPure()
	{
		std::cout << "CChild OutputPure" << std::endl;
	}

	virtual void OutputPure1()
	{
		std::cout << "CChild OutputPure1" << std::endl;
	}
};

class CChild1 : public CParent
{
public:
	CChild1()
	{
	}

	virtual ~CChild1()
	{
	}

public:
	virtual void OutputPure()
	{
		std::cout << "CChild1 OutputPure" << std::endl;
	}

	virtual void OutputPure1()
	{
		std::cout << "CChild1 OutputPure1" << std::endl;
	}
};