[C++] 다형성과 추상화 (+ 다운 캐스팅, RTTI)

2024. 4. 5. 17:53Programming Language/C++

아래 링크 클릭 시 해당 본문으로 이동

다형성

추상화

다운 캐스팅

RTTI(RunTime Type Identification or Information)


다형성(Polymorphism)

• 다양한 형태를 갖는 성질로, 같은 이름으로 다른 기능을 구현하는 것

  ex) 동음이의어, 동명이인

class CParent
{
protected:
	int m_i;

public:
	void SetInt(int _a)
	{
		m_i = _a;
	}

public:
	CParent()
		:m_i(0)
	{}

	// 생성자 오버로딩
	CParent(int _a)
		: m_i(_a)
	{}

	~CParent()
	{}
};

// 클래스 CChild가 클래스 CParent를 상속받는다.
class CChild : public CParent
{
private:
	float m_f;

public:
	CChild()
		: m_f(0.f)
	{}

	~CChild()
	{}
};

int main()
{
	CParent parent;
	CChild child;

	// 다형성
	CParent* pParent = &child;  // 부모 포인터로 자식 클래스의 객체는 받을 수 있다.
		// ex. 동물
			// 동물*로 조류, 포유류, 양서류 등의 자식 클래스의 객체를 받을 수 있다.
				// 동물* p동물 = &조류;
	//CChild* PChild = &parent;  // 오류 => 자식 포인터로 부모 클래스 객체를 가리킬 수 없다.

	// 포인터 변수만으로 실제 객체가 뭔지 알 수가 없다.
	// 포인터는 주소에 접근했을 때 그곳에 Parent가 있다고 보기 때문에 그곳의 실제 객체는 알 길이 없다.
	// 메모리 예시 [객체] : [ Parent | ??? ] => 여기에서 접근해서 알 수 있는 부분은 Parent뿐이다.

	return 0;
}

추상화(Abstraction)

• 실제 객체를 생성할 목적의 클래스가 아닌, 상속을 통해서 구현해야 할 내용을 전달하는 상속 목적으로 만들어진 클래스

즉, 완전한 클래스가 아닌, 다른 클래스를 위한 기본틀을 제공하는 클래스이다.

• 공통적인 인터페이스를 정의하고, 자식 클래스에서 구체적인 기능을 구현하도록 한다.

`virtual` 키워드

• 가상 함수(virtual function)를 선언하는 데 사용된다.

• 가상 함수는 상속 관계에서 다형성을 구현하는 데 핵심적인 역할을 한다.

• 부모 클래스에서 가상 함수로 선언된 함수는 자식 클래스에서 재정의(override)될 수 있다.

• 실행 시점에 실제 객체의 타입에 따라 호출될 함수가 결정된다.

 

가상 함수가 있다면 포인터 타입은 부모 하나로 지정해 놔도 파생되는 모든 자식들 중 뭐든 가리킬 수 있다.

실제 클래스 쪽에 구현되어 있는, 자체적으로 따로 오버라이딩한 클래스 객체만의 기능도 호출 가능하다.

`= 0`

• 가상 함수 선언 뒤에 `=0`을 붙이면 순수 가상 함수(pure virtual function)가 된다.

• 순수 가상 함수는 함수의 구현체가 없고, 선언만 되어있는 함수이다.

• 순수 가상 함수를 하나 이상 포함하는 클래스는 추상 클래스가 된다.

• 추상 클래스는 객체를 직접 생성할 수 없으며, 자식 클래스에서 모든 순수 가상 함수를 구현해야만 객체를 생성할 수 있다.


▷ 예시 1 (virtual)

#include <iostream>

using std::cout;
using std::endl;

class CParent
{
protected:
	int m_i;

public:
	void SetInt(int _a)
	{
		m_i = _a;
	}

	// 가상 함수
	virtual void Output()
	{
		cout << "Parent" << endl;
        cout << "==========" << endl;
	}

public:
	CParent()
		: m_i(0)
	{
		cout << "부모 생성자" << endl;
        cout << "==========" << endl;
	}

	// 생성자 오버로딩
	CParent(int _a)
		: m_i(_a)
	{
		cout << "부모 생성자" << endl;
        cout << "==========" << endl;
	}

	~CParent()
	{
		cout << "부모 소멸자" << endl;
        cout << "==========" << endl;
	}
};

// 클래스 CChild가 클래스 CParent를 상속받는다.
class CChild : public CParent
{
private:
	float m_f;

public:
	// 상속받은 부모 클래스의 함수를 재정의(오버라이딩)했다.
	//void Output()
	//{
	//	cout << "Child" << endl;
    //	cout << "==========" << endl;
	//}

	// 가상 함수
	virtual void Output()
	{
		cout << "Child" << endl;
        cout << "==========" << endl;
	}

public:
	CChild()
		: m_f(0.f)
	{
		m_i = 0;
		cout << "자식 생성자" << endl;
        cout << "==========" << endl;
	}

	~CChild()
	{
		cout << "자식 소멸자" << endl;
        cout << "==========" << endl;
        // 상속받은 부모의 소멸자를 호출하는 코드가 생략되어있다.
	}
};

int main()
{
	CParent parent;  // 부모 생성자
	CChild child;  // 부모 생성자
    			   // 자식 생성자

	CParent* pParent = nullptr;	
	parent.Output();  // Parent
    
	pParent = &parent;
	pParent->Output();  // Parent

	child.Output();  // Child
	pParent = &child;
    // ===== virtual 키워드 추가하기 전 ===== //
	//pParent->Output();  //Parent
    	// 오류는 아니지만 문제 발생 => CChild쪽이 아닌 CParent쪽의 Output()이 호출된다.
		// 실제 객체가 Child여도 CParent*이기 때문에 parent 부분밖에 모른다.
		// 포인터에게 진짜 객체는 중요하지 않다.
	
    // ===== virtual 키워드 추가한 후 ===== //
	// 이런 문제 때문에 c++은 virtual 키워드를 붙인 가상 함수를 지원한다.
	// 부모 쪽 Output() 앞에 virtual을 붙이면 Child 쪽 Output()이 호출된다.
	pParent->Output();  // Child
	// CParent*로 child를 가리키고 있는데 child로 Output()을 호출했다.
	// Parent쪽이 아니라 Child쪽의 Output()을 구별해서 호출했다.

	return 0;
    // 자식 소멸자
    // 부모 소멸자
    // 부모 소멸자
}

▽ 출력 결과

부모 생성자
==========
부모 생성자
==========
자식 생성자
==========
Parent
==========
Parent
==========
Child
==========
Child
==========
자식 소멸자
==========
부모 소멸자
==========
부모 소멸자
==========
위 코드에서 부모 소멸자가 2번 호출된 이유
1. `parent` 객체 소멸 시 호출
2. `child` 객체 내부의 부모 클래스 부분이 소멸될 때 호출

Parent 쪽 `Output()` 앞에 `virtual`을 붙이면 Child 쪽 `Output()`이 호출된다.

▷ 길바닥에 떨어져있는 2개의 신용카드 [Parent], [Parent | Child]

[Parent ★| Child] ➜ 카드 일련번호처럼 ★(가상 함수 테이블)는 Child 클래스의 정보를 알고 있다. '나의 정체는 Child 클래스이다.'

[Parent ★] ➜ 만들어질 때 실제 정체가 Parent이기 때문에 ★은 Parent 클래스의 정보를 가리키고 있다.

정보를 확인함으로써 카드의 실제 주인을 알 수 있는 것처럼 [Parent ★| Child]의 실제 정체를 알 수 있는 것이다.

제품은 같지만 만들어질 때 누구의 카드인지 결정되어 있다.

가상 함수 테이블의 위치가 가장 앞쪽에 배치되어야 하는 이유: 부모 포인터로 접근했을 때 부모 부분까지만 알 수 있기 때문에 주소로 갔을 때 실제 객체에 대한 알맞은 정보가 있어야 한다.

그래야 실제 정체가 누군지 구별할 수 있다.

 

가상 함수 테이블에 들어있는 데이터: 타입에 대한 정보, 호출할 때 적절한 실제 가상 함수

멤버 함수 중 가상 함수 키워드가 붙은 함수들은 테이블에 등록되어 있다.

실제 생성된 객체의 정체가 Parent인 경우에 테이블을 열어보면('로컬' - parent 옆 화살표 열기) 구현되어 있는 `Output()`의 주소

가 기재되어 있다.

CChild 쪽의 CParent를 열어보면 chlid 쪽 `Output()`의 주소가 기재되어 있다.

가상 함수 호출 원리 (예시 1 코드)

1. 접근한 객체의 테이블로 간다.

2. 테이블에 등록되어 있는 `Output()`를 호출한다.

[Parent] ➜ Parent 쪽의 Output()이 등록되어 있기 때문에 부모 쪽에 구현되어 있는 Output()이 호출된다.

 

`CParent*`가 가리키고 있던 주소가 [Parent | Child]라면?

실제 정체가 Child인지는 알 필요가 없고, Parent 부분까지만 접근해서 가상 함수 쪽으로 간다.

그쪽에 등록되어 있는 `Output()`을 호출하는데, 이 `Output()`은 Child 쪽에 등록되어 있기 때문에 Child 쪽의 `Output()`이 호출된다.

※ 정리
부모 및 자식 클래스에 구현되어 있는 멤버 함수가 있다.
두 클래스를 가장 최상위인 부모 포인터로 가리켰을 때 실제 각자 구현한 쪽의 함수를 호출하려면 `virtual` 키워드를 붙여야 한다.
해당 함수들을 가상 함수로 취급되고, 각자 클래스 정보에 해당 함수를 등록시킨다.
해당 객체들은 객체가 만들어질 때의 가상 함수 테이블 포인터가 각자 자기 쪽 정보들을 미리 세팅해 놓으면서 만들어졌다.
때문에 함수를 호출하면 해당 테이블을 참조해서 거기 등록되어 있는 함수를 호출하라는 식으로 동작한다.
그래서 자기 쪽에 실제 구현되어 있는 함수가 호출된다.

부모 포인터 타입으로 부모 클래스로부터 파생되는 자식 클래스 객체들의 주소를 가리킬 수 있다.

모든 객체를 부모 클래스 타입으로 인식하게 되기 때문에 실제 객체가 무엇인지는 알 수 없다.

`virtual` 키워드를 통해서 각 클래스는 자신만의 고유한 가상 함수 테이블을 가지게 되는데, 각 클래스의 객체들은 가상 함수 테이블 포인터에서 해당 클래스에 맞는 테이블을 가리키게 된다.

그 테이블에는 해당 클래스의 가상 함수들이 등록되어 있다.

다운 캐스팅

• 부모 클래스에서 선언되지 않은, 오직 자식 쪽에서만 추가된 함수를 호출하고 싶을 때 사용한다.

즉, 부모 클래스를 자식 클래스로 변환하는 것이다.

다운 캐스팅 사용하는 이유

• 자식 클래스의 고유한 기능 사용: 부모 클래스에서는 제공되지 않는 자식 클래스만의 특별한 메서드나 멤버 변수에 접근하기 위해 사용한다.

• 다형성 활용: 다형성을 통해 다양한 객체를 하나의 타입으로 다루면서, 필요에 따라 특정 자식 클래스의 기능을  사용해야 할 때 유용하다.

다운 캐스팅 사용할 때 주의할 점

• 잘못된 캐스팅: 실제 객체의 타입이 예상과 다를 경우, 프로그램이 예기치 않게 종료되거나 잘못된 결과를 초래할 수 있다.

• 안전하지 않은 형 변환: 다운 캐스팅은 안전하지 않은 형 변환으로 간주되며, 신중하게 사용해야 한다.

class Animal {
public:
    virtual void makeSound() = 0;
};

class Dog : public Animal {
public:
    void bark() {
        cout << "Woof!" << endl;
    }
};

int main() {
    Animal* animal = new Dog();
    // 다운 캐스팅
    Dog* dog = dynamic_cast<Dog*>(animal);
    if (dog != nullptr) {
        dog->bark(); // Dog 클래스의 bark() 함수 호출
    }
}

 

자식 포인터 타입으로 일시적으로 캐스팅해서 호출한다.

문제가 발생할 수 있기 때문에 `dynamic_cast`로 안전하게 확인해 볼 수 있다.


RTTI(RunTime Type Identification or Information)

• 런타임에 객체의 정확한 타입을 확인하는 기능

• `typeid` 연산자와 `dynamic_cast` 연산자로 RTTI를 활용할 수 있다.

1) `typeid` 연산자: 객체의 타입을 나타내는 `type_info` 객체를 반환한다.

2) `dynamic_cast` 연산자: 안전한 다운 캐스팅을 수행하며, 성공하면 캐스팅된 포인터를 반환하고, 실패하면 `nullptr`을 반환한다.

RTTI 용도

• 다운 캐스팅 안전성 확보: `dynamic_cast`를 사용하여 객체의 실제 타입을 확인하고, 안전하게 다운 캐스팅을 수행할 수 있다.

• 타입 비교: `typeid` 연산자로 두 객체의 타입을 비교할 수 있다.

• 복잡한 다형성 시스템 관리: 다양한 클래스 간의 관계가 복잡할 때, RTTI를 사용하여 객체의 타입을 판별하고 적절한 처리를 할 수 있다.

RTTI 단점

• 성능 저하: RTTI를 사용하면 실행 속도가 느려질 수 있다.

• 코드 가독성 저하: RTTI를 과도하게 사용하면 코드가 복잡해지고 가독성이 떨어질 수 있다.

• 컴파일러 의존성: RTTI의 구현은 컴파일러에 따라 다를 수 있다.

RTTI 사용 시 주의할 점

• 가급적 자제하기: RTTI는 필요한 경우에만 사용하고, 가능한 한 다형성을 활용하여 코드를 작성하는 것이 좋습니다

• 안전한 다운 캐스팅: dynamic_cast를 사용하여 안전하게 다운 캐스팅을 수행합니다

• 명확한 목적: RTTI를 사용하는 이유를 명확하게 정의하고, 불필요한 사용은 피합니다.


▷ 예시 2

#include <iostream>

using std::cout;
using std::endl;

class CParent
{
protected:
	int m_i;

public:
	// 가상 함수
	virtual void Output()
	{
		cout << "Parent" << endl;
		cout << "==========" << endl;
	}

	virtual void A()
	{
		cout << "A" << endl;
		cout << "==========" << endl;
	}

	virtual void B()
	{
		cout << "B" << endl;
		cout << "==========" << endl;
	}

public:
	CParent()
		:m_i(0)
	{
		cout << "부모 생성자" << endl;
		cout << "==========" << endl;
	}

	// 생성자 오버로딩
	CParent(int _a)
		: m_i(_a)
	{
		cout << "부모 생성자" << endl;
		cout << "==========" << endl;
	}

	~CParent()
	{
		cout << "부모 소멸자" << endl;
		cout << "==========" << endl;
	}
};

// 클래스 CChild가 클래스 CParent를 상속받는다.
class CChild : public CParent
{
private:
	float m_f;

public:
	// 가상 함수
	virtual void Output()
	{
		cout << "Child" << endl;
		cout << "==========" << endl;
	}

	void NewFunc()
	{
		this->m_f;
	}

public:
	CChild()
		: m_f(0.f)
	{
		m_i = 0;
		cout << "자식 생성자" << endl;
		cout << "==========" << endl;
	}

	~CChild()
	{
		cout << "자식 소멸자" << endl;
		cout << "==========" << endl;
		// 상속받은 부모의 소멸자를 호출하는 코드가 생략되어있다.
	}
};

void FuncA()
{
	cout << "Function A" << endl;
	cout << "==========" << endl;
}

void FuncB()
{
	FuncA();
	cout << "Function B" << endl;
	cout << "==========" << endl;
}

int main()
{
	CParent parent;  // 부모 생성자
	CChild child;  // 부모 생성자
				   // 자식 생성자
	child.Output();  // Child
	child.B();  // B

	CParent* pParent = nullptr;
	pParent = &parent;
	// CChild로 확신했는데 실제 정체는 parent 객체이기 때문에 문제가 발생한다.
	//((CChild*)pParent)->NewFunc();

	// dynamic_cast를 통해서 parent 포인터가 가리키고 있는 실제 정체가 누구인지 확인한다.
	// dynamic_cast => 다운캐스팅 실패 시 nullptr를 반환한다.
	CChild* pChild = dynamic_cast<CChild*>(pParent);
	if (nullptr != pChild)
	{
		pChild->NewFunc();
		cout << pChild << endl;
	}
	else
	{
		cout << "pChild는 nullptr" << endl;
		cout << "==========" << endl;
	}
	// RTTI
		// 런타임 중에 포인터 *변수*에 담길 주소의 객체가 누구인지 알 수 없다.
		// 가리키고 있는 parent까지만 접근 가능한데, 테이블 정보에 접근해보니 parent 정보가 들어있다.
		// 실제 정체는 parent이고, 만약 child였다면 실제 정체는 child이다.

	return 0;
}

▽ 출력 결과

부모 생성자
==========
부모 생성자
==========
자식 생성자
==========
Child
==========
B
==========
pChild는 nullptr
==========
자식 소멸자
==========
부모 소멸자
==========
부모 소멸자
==========