게임프로그래밍/C++

[C++ 객체지향] 다형성(Polymorphism)

marmelo12 2022. 1. 9. 21:10
반응형

다형성(Polymorphism)

객체지향 프로그래밍에서의 다형성은 간단히 말해 모습은 같은데 형태는 다른 것을 의미한다.

다형성을 뜻하는 영어 단어인 Polymorphism은 여러 개를 의미하는 'poly'와 모습 및 모양을 뜻하는 그리스어 'morphism'에서 온 단어로 여러 가지 형태라는 의미.

C++에서의 다형성은 가상 함수(virtual, 함수, 연산자 오버 로딩, 템플릿 등이 있다.

 

C++과 같은 객체지향 언어에서는 어떤 객체의 포인터 변수에서 자식(파생) 클래스 객체의 포인터 주소 값도

할당이 될 수 있다. 물론 그 반대도 가능하다. (다운 캐스팅, 업 캐스팅)

여기서 부모 클래스의 객체 타입(Type)에서 자식 클래스의 객체가 할당되어있다면 virtual 키워드(가상 함수)를

이용해 부모 클래스의 함수를 재정의함으로써 자식 객체의 함수를 호출할 수 있다.

 -> 위와 같이 부모 클래스의 함수를 자식 클래스에서 재정의 하는 것을 오버 라이딩(overriding)이라 한다. 

 -> 즉 부모 타입 하나로 파생된 객체들의 가상 함수를 호출하여 이름은 같지만 내용은 다른 함수가 호출이 가능한 것.

 -> 가상 함수가 선언된 클래스는 하나의 가상 테이블이 만들어지고 해당 클래스를 통해 만들어진 객체 주소의 첫 번째 offset에 가상 함수 테이블의 주소가 할당된다.

   --> 가상함수는 런타임에 가상함수 테이블을 참조하여 함수를 호출하는 방식으로 약간의 오버헤드(overhead)가 발생한다.

 

기본적으로 C++은 클래스의 타입에 따라 그에 맞는 멤버를 호출한다.

이를 정적 바인딩이라 하는데 이는 컴파일 타임에 어떤 함수가 호출될지 정해지는 것이다.

반대로 가상 함수. virtual 키워드가 붙은 함수는 동적 바인딩은 컴파일 시에 어떤 함수가 실행될지 정해지지 않고 런타임 시에 정해지는 것을 가리켜 동적 바인딩이라 한다.

 

예를 보자.

class Base
{
public:
	void Talk()
	{
		std::cout << "나는 Base" << std::endl;
	}
};

class Derived : public Base
{
public:
	void Talk()
	{
		std::cout << "나는 Derived" << std::endl;
	}
};

위와 같이 부모 클래스인 Base. Base를 상속한 자식 클래스인 Derived가 있다.

Base의 클래스에서 Talk라는 함수가 존재하고, 자식 클래스에서도 똑같이 Talk라는 멤버 함수가 존재한다.

부모 클래스의 함수를 자식에서 '재정의'했으므로 부모 타입에 자식 객체를 생성하여 Talk를 호출하면 Derived의 함수가

호출되지 않을까?라는 생각이 들 수 있다.

 

하지만

위 그림과 같이 Base클래스의 멤버 함수가 호출이 된다.

그 이유는 위에서도 말했지만 C++은 참조하는 객체의 타입에 맞게 멤버를 호출하는 게 아닌, 포인터 변수(혹은 지역 객체 변수)의 클래스 타입에 따라 그 타입에 맞는 멤버를 호출하기 때문 -> 정적 바인딩.

이게 중요한가? 싶은 생각도 들 수 있다. 매번 그 자식 객체만 사용한다거나, 부모 타입으로 왠지는 몰라도 자식 객체를 가리키고 있다면 매번 캐스팅해서 사용하면 되지 않나?라는 생각.

이에 반박하는 여러 이유가 있지만, (기본적으로 실수할 가능성이 높다)

Base로부터 파생된 클래스가 여러 개 있다고 보자. 어떤 함수에서 그 객체의 함수 하나만을 호출하고 싶은데 virtual키워드 없이 그 객체들의 모든 함수들에 대응하기 위해서

void TellMe(Base* p)
{
	p->Talk();
}
void TellMe(Derived* d)
{
	d->Talk();
}

위와 같이 똑같은 기능이지만 매번 오버 로딩해서 사용할 수밖에 없다. 위에서는 파생된 클래스가 하나라서 간단하지만

그게 하나가 아니라면, 또한 이러한 상속관계를 가지는 클래스가 위의 Base, Derived만 있다는 보장도 절대 없다.

그럼 함수 템플릿을 사용하면 안 되는가?

가능은 하다.

하지만 위의 함수 템플릿에서 다른 사용자가 Talk멤버 함수를 가진 Base나 Derived 객체를 가리키는 포인터 변수가 아닌

전혀 다른 클래스의 객체 포인터 변수를 넣는다면.

(Human은 Talk멤버 함수가 존재하지 않는다)

당연히 오류가 날것이고 문제는 에디터 창에서 에러를 잡아주지 않아 빌드를 해야 에러 사항을 확인하고 고칠 수 있다.

더 문제는 만약 정말 운이 좋게도 똑같은 이름의 멤버 함수가 존재한다 하더라도 그것은 우리가 원치 않는 결과를 초래할 것이다.

 

이제 문제점은 알았으니 virtual 키워드를 이용한 가상 함수를 사용하여 동적 바인딩을 통한 위의 문제들을 수정해보자.

class Base
{
public:
	virtual void Talk()
	{
		std::cout << "나는 Base" << std::endl;
	}
};

class Derived : public Base
{
public:
	void Talk()
	{
		std::cout << "나는 Derived" << std::endl;
	}
};

이제 부모 클래스에서 virtual키워드를 붙였다.

 -> 참고로 오버라이딩의 조건은 부모 함수의 반환형, 함수명, 매개변수(타입, 개수), const까지 모두 똑같아야 한다.

 -> 위의 조건을 만족한 자식의 오버라이딩된 함수는 자동으로 virtual키워드가 붙게 된다.(명시하지 않더라도)

 -> 정확하게 부모의 가상 함수를 오버라이딩 했다는 것을 알려주기 위해 override 문법도 존재한다.

 

이제 부모 타입의 포인터 변수에 자식 객체를 생성하고 Talk를 호출해보면

즉 이제는 아까의 문제점들 함수 오버로딩, 함수 템플릿을 사용할 필요도 없이

부모 타입 하나만을 이용하여 파생된 객체들의 모든 오버 라이딩된 함수를 호출되게 할 수 있다.

 

이렇게 부모 타입이라 할지라도 객체가 가지고 있는 가상 함수 테이블의 주소를 참조해 자식의 Talk를 호출하게 된다.

그러면 모든 클래스의 함수들에 virtual키워드를 붙이면 좋지 않을까? 왜 굳이 사용자가 직접 virtual키워드를 붙여서

사용하게 하였을까. 처음에 설명하면서도 말했지만 virtual(가상)함수의 호출은 약간의 오버헤드가 발생한다고 헀다.

즉 컴파일 타임에 어떤 함수가 호출이 될지 결정이 되는 게 아닌(정적 바인딩)

런타임에 어떤 함수가 호출이 될지 결정이 되는 것이기에(동적 바인딩) 정적 바인딩보다 함수의 호출시간이 좀 더 걸린다.

 

이것을 정말 쉽게 설명하는 예제가 있는데 (예제 출처 - https://modoocode.com/211_)

class Parent {
 public:
  virtual void func1();
  virtual void func2();
};
class Child : public Parent {
 public:
  virtual void func1();
  void func3();
};

위와 같이 Parent클래스와 이 클래스를 상속받는 Child클래스가 있다.

부모 클래스의 func1과 func2는 모두 가상 함수이고 자식 클래스에서는 func1을 오버 라이딩했으며 func3은 비가상 함수로 Child 자기 자신만이 갖는 멤버를 정의하였다.

 

이렇게 가상 함수가 구현된 클래스에서는 가상 함수 테이블이 하나씩 만들어진다고 했는데

가상함수 테이블 출처 - https://modoocode.com/211

위와 같이 부모, 자식은 하나의 가상 테이블을 가지며 가상 함수 테이블에서는 가상 함수들의 목록을 가지며

각 가상 함수는 자기 자신의 정의(재정의)된 함수를 가리키고 있다.

 

여기서 Child에 func2가 가상 테이블에 들어간 이유는 부모로부터 상속을 받았기 때문.

단. 부모 멤버 함수를 직접 재정의(Overriding)한 것이 아니기 때문에 자식 클래스의 가상 테이블의 func2 가상 함수는

부모의 func2를 가리키게 된다. (Parent::func2())

 

이렇게 가상 함수는 객체가 자기 클래스의 가상 테이블을 참고하여 호출해야 될 함수를 찾는 과정이 존재하기에 약간의 속도가 걸린다. (다시 말하지만 가상 함수가 존재하는 클래스는 하나의 가상 테이블이 존재하고 객체 주소의 첫번째 offset에 그 가상함수 테이블의 주소가 들어가게 된다)

비 가상 함수의 경우에는 가상 함수가 존재하는 클래스에 있다 할지라도 특별한 단계를 거치지 않고 바로 func3(정적 바인딩)을 호출하게 된다.

 

참고로 오버라이딩을 오버로딩과 헷갈려서는 안된다.

오버로딩은 같은 이름의 함수를 매개변수의 자료형이나 수를 달리하여 생성하는 것.

 - 매개변수의 자료형, 매개변수의 개수만 다르다면(오버로딩 조건) 된다. (반환형은 영향x)

오버라이딩은 상위(부모) 클래스의 멤버함수를 자식 클래스에서 재정의하여 사용하는 것.

 - 오버라이딩의 조건은 재정의 할 부모 클래스의 멤버 함수의 반환형,함수명,매개변수(타입,수),const까지 모두 동일해야함.

 

 

가상 소멸자(Virtual Destuctor)

여기서 더 중요한 점이 있는데 소멸자에 virtual을 붙이는 것. 클래스의 상속관계에서 virtual 소멸자(가상 소멸자)는 필수라고 봐야 한다.

소멸자도 함수다. 즉 Base타입의 포인터 변수가 동적 할당된 자식 객체를 가리키고, Base타입의 포인터 변수 b를 delete를 하면 Base의 소멸자가 호출될 것이다.

부모타입에 자식객체를 가리키고, 그 부모타입의 변수를 대상으로 delete를 했다해서 힙에 할당된 자식객체의 메모리가 모두 해제가 안된다거나 하는건 아니지만, 만약 자식객체의 멤버로 동적할당된 멤버가 있고 그것을 소멸자에서 처리해주는데, 부모 타입의 소멸자만 호출된다면 자식 객체의 멤버로 남아있는 메모리는 누수가 될 것.

class Base
{
public:
	Base() {}
	virtual ~Base() { std::cout << "Base 소멸자 호출" << std::endl; }
public:
	virtual void Talk()
	{
		std::cout << "나는 Base" << std::endl;
	}
};

class Derived : public Base
{
public:
	Derived() {}
	~Derived() { std::cout << "Derived 소멸자 호출" << std::endl; }
public:
	void Talk()
	{
		std::cout << "나는 Derived" << std::endl;
	}
};

실행해보면.

자식의 소멸자가 먼저 호출이 되고 상위 부모로 올라가면서 소멸자가 깔끔하게 호출이 된다.

(생성자는 부모>자식순 소멸자는 부모<자식순 잊지말아야 함)

 

순수 가상 함수

순수 가상 함수는 상속에서 이미 이야기를 다했지만, 간단하게 문법만 다시 짚고 넘어가자

class Base
{
public:
	Base() {}
	virtual ~Base() { std::cout << "Base 소멸자 호출" << std::endl; }
public:
	virtual void Talk()
	{
		std::cout << "나는 Base" << std::endl;
	}
	virtual void Anything() = 0;
};

위와 같이 virtual void Anything() = 0;. 가상함수의 끝에 0을 대입하는 키워드를 넣어주면 해당 함수는 순수 가상함수가 되며 해당 클래스는 추상 클래스가 되어 객체 생성이 불가능해진다.

또한 이 클래스를 상속받는 자식 클래스에서는 순수 가상함수의 오버라이딩의 강제성이 생겨 그냥 자식 객체를 생성하려 하면

이렇게 오류를 띄운다.

재정의 하는 방법은 똑같고, 몇가지 개념이 추가 되었을 뿐이다.

모던 C++문법에서는 abstract라는 키워드를 사용하여 순수 가상함수로 만들 수 있다.

virtual void Anything() abstract;

 

 

반응형