정신과 시간의 방
카테고리
작성일
2023. 2. 13. 09:28
작성자
risehyun

다형성이란 말 그대로 여러 형태를 가질 수 있는 성질로, 객체지향을 지원하는 언어뿐만 아니라 다른 언어에서도 똑같이 존재하는 개념이다. 다형성을 이해하기 위해서는 상속과 포인터의 개념에 대해 다시 떠올려볼 필요가 있다.

 

아래의 예시를 살펴보자.

CParent* pParent = &child; // 문법상 오류 X (부모 클래스가 자식 클래스를 포인터로 받아오는 경우)
CChild* pChild = &parent;  // 문법상 오류 O (자식 클래스가 부모 클래스를 포인터로 받아오는 경우)

 

위의 코드를 실행해보면 두 번째 라인의 코드에서 오류가 발생한다.

이것은 자식 포인터로 부모 클래스 객체를 가리킬 수 없기 때문에 발생한 오류이다.

 

Parent 클래스를 상속받은 자식(child) 포인터의 메모리 구조를 생각해 보면

부모 -> 자식 순으로 직렬로 할당이 되어 있는데, 이때 부모 포인터 타입으로 가리키면

포인터에서 가리키는 첫 번째 주소는 자식이 아닌 부모로 향해있으므로

부모 파트까지는 접근이 가능하기 때문에 문제가 되지 않지만, 부모 파트 이후의 주소는 제대로 찾을 수가 없다.

 

포인터 변수가 가르키고 있는 곳으로 접근하면 그곳에 Parent 객체가 있을 것이라고 애초에 선언을 해 놓은 포인터 변수이기 때문에 딱 그 선언에 맞게만 동작하기 때문이다. 포인터가 가리키는 곳에 갔을 때 실제로 어떤 객체가 있는지에 대한 정보가 없으므로 이렇게 동작할 수 밖에 없어진 것이다.

 

하지만 부모 포인터 타입으로 자식 클래스의 객체 주소를 받아오는 것은 가능하다.

심지어 캐스팅을 해줄 필요도 없다.

부모 클래스의 경우 자식 클래스의 주소에 접근시 메모리 구조가 부모 클래스 -> 자식 클래스 순서로 이루어져 있기 때문에

포인터로 주소에 접근해도 부모 클래스의 내용에 접근하게 되므로 유효하다.

 

하지만 이때 포인터 변수 만으로는 실제 객체가 뭔지 알 방법이 없다는 문제가 발생한다.

따라서 실제 객체가 무엇인지를 알아내 주어야 한다.

 

이처럼 우리가 기존에 알고 있던 상속의 기능만으로는 제대로된 다형성을 표현할 수 없다.

정말 다형성에 맞는 것이라고 한다면 최상위 부모 포인터 타입으로 주소 변수를 관리 하되 그 포인터가 가리키도록 한 주소로 갔을 때 실제 객체의 정체가 누구든지 될 수 있어야 한다.

 

이를 위해서 C++에서는 가상함수라는 기능을 지원한다. 아래는 CParent 클래스에 구현한 가상 함수의 예시이다.

virtual void Output()
{
    cout << "Parent" << endl;
}

 virtual 키워드를 사용하면 Parent쪽에 구현되어 있는 함수를 가상함수로 등록시킨다.

 

이렇게 Output 함수를 가상 함수로 만들어주면 Child쪽 주소에 접근하는 것이 가능해져

조금 전에 문제점으로 언급했던 부분이 해결된다.

 

CParent* pParent = &parent;

parent.Output();
pParent = &parent;
pParent->Output();

child.Output();
pParent = &child;
pParent->Output();

 

따라서 가상 함수가 있다면 포인터 타입은 최상위 부모로 지정해 놓았는데, 이 포인터가 뭐든지 가리킬 수 있고

해당 클래스를 부모로 하는 파생되는 모든 클래스 뿐만 아니라 그 파생 클래스 안에 구현되어 있는,

자체적으로 오버라이딩 해놓은 그 클래스 객체만의 기능도 호출이 된다.

 

그렇다면 가상함수 테이블에는 어떤 데이터가 들어있을까?

이 타입에 대한 정보뿐만 아니라 이 함수가 호출 할 때 적절한 실제 가상 함수가 안에 등록되어 있다.

 

실제 생성된 객체의 정체가 Parent인 경우 테이블을 열어보면 Output 함수가 Parent쪽에 구현되어 있는 함수의 주소가 들어있게 된다.

반대로 실제 생성된 객체가 Child인 경우에는 테이블을 열어보면 Child 함수의 주소가 등록되어 있다. 

 

즉, 가상 함수 호출 원리는 Parent 파트로 객체를 가리키고 있을 때 실제 정체가 누군지는 알 방법이 없다.

하지만 Output 함수를 호출하고 그 Output 함수가 가상함수 라는 것을 알았을 경우에는

접근한 객체 테이블로 가서 거기 테이블에 등록되어 있는 Output 함수로 호출하겠다는 것이다.

 

정리하자면, 부모 클래스와 자식 클래스가 각각 자기 쪽의 구현해놓은 멤버 함수들이 있었고

이 두 클래스들을 부모 포인터라는 가장 최상의 부모 포인터로 가리키게 했을 때,

실제 각자 구현한 쪽 버전의 함수가 호출되려면 virtual 이라는 키워드를 붙여줘야 한다.

 

이렇게 되는 순간 해당 함수들은 가상 함수로 취급이 되고 이런 객체들은 각자 클래스 정보의 해당 함수들을 등록시켜 놓게 된다.

그리고 해당 객체들은 만들어질 때 가상 함수 테이블 포인터가 각자 자기 쪽 정보들을 미리 세팅해 놓으면서  만들어졌을 것이기 때문에 이 함수를 호출하게 될 경우에는 이 테이블을 참조해서 거기 등록되어 있는 함수를 호출하라는 식으로 동작하게 할 거기 때문에, 자기 쪽의 실제 구현 된 쪽의 함수가 호출이 되는 것이다.

 

이렇게 자식에서만 완전히 생긴 함수들이 어느 경우에 필요한지 생각해보자.

 

먼저 가상 함수의 원리부터 다시 복습하자.

최상의 부모에서 여러 종류의 함수들을 미리 설계해 놓는다.

하지만 상속받는 자식 에서는 이 기능을 조금 다르게 사용하고 싶을 수 있다.

 

그때는 그 함수를 오버라이딩해서 재정의를 한다.

하지만 부모 포인터 타입으로 자식 객체를 가리킬 때 이쪽 기능이 수행되어야 하므로 이런 함수 목록들을 가상으로 만들어서 각자 실제 객체 정보 쪽에서는 본인의 실제 구현 함수를 테이블에 등록시켜 놓고 그걸 호출하도록 하는 것이다.

 

그렇다면, 완전히 자식 클래스에서 새롭게 추가된 함수들이 있을 수 있다.

이 함수들은 자식 클래스에서 새로 생긴 기능이기 때문에 부모쪽에 있는 기능을 오버라이딩으로 대체(재정의) 한 것이 아니다.

그렇기 때문에 이쪽 기능을 호출한다는 것은 어떤 부모 포인터의 담긴 객체의 실제 정체가 확실히 이것이라는 확신이 가지 않는 한, 호출 할 수 없는 상황이 된다.

 

실제 데이터를 설계하고 관리하다보면 다형성을 이용해서 부모 포인터를 하나의 타입으로 하여 모든 것들을 관리하지만 분명히 부모 포인터가 가리키고 있는 데이터가 자식 객체인 상황에선 일시적으로 자식 객체 타입으로 포인터를 캐스팅해서 자식 쪽에만 구현되어 있는 기능도 호출을 해 볼 수 있는 것이다. 이럴 때 사용하는 것을 '다운 캐스팅'이라고 한다.

 

다운 캐스팅이란 부모 클래스에서 선언되지 않은, 오직 자식쪽에서만 추가된 함수를 호출 하고 싶을 때

자식 포인터타입으로 일시적으로 캐스팅해서 호출하는 것을 의미한다.

하지만 이때 직전에 Parent 포인터의 실제 담겨 있는 객체의 주소를 Parent에 겹쳐 바꿔버리는 문제가 발생할 수 있다.

때문에 C++에서는 dynamic_cast 라는 것을 지원한다.

 

((CChild*)pParent)->NewFunc();

CChild* pChild = dynamic_cast<CChild*>(pParent);

if(nullptr != pChild)
{
   int a = 0;
}

 

dynamic_cast 을 사용하면 캐스팅이 실패했을 경우 null 포인터를 반환한다.

따라서 null 포인터가 아니라면 child 포인터로 받을 수 있도록 안전하게 확인을 해볼 수 있다.

 

 

이런 dynamic_cast 캐스트가 있으면 각자의 객체들은 자기의 실제 타입에 맞는 타입 정보를 가리키고 있을 것이기 때문에 런타임 중에 넣어준 주소의 실제 정체를 확인해 볼 수 있게 된다.

 

이렇게 런타임 중에 타입의 정보를 얻어 올수 있는 것을 RTTI이라고 한다.

'C,C++' 카테고리의 다른 글

[C/C++] 공용체(union)  (0) 2023.05.03
오버라이딩  (0) 2023.02.12
상속  (0) 2023.02.11
Tree 구현 + Enum  (0) 2023.02.10
[C++] list iterator  (0) 2023.02.06