12. 클래스의 상속(2) - public 다형 상속(가상함수, override, final)

WanJu Kim·2022년 12월 14일
0

C++

목록 보기
50/81

가상함수

이전 클래스에서는 기초 클래스의 메서드를 파생 클래스에서 재정의 하는 경우가 없었다. 하지만 실제로는 그런 경우도 생길 수 있다. 그럴 경우는 어떻게 하면 될까? 그리고 왜 그래야 할까?

예를 들어 다음과 같은 메서드가 기초 클래스, 파생 클래스에 모두 정의되어있다고 하자.

void ViewAcct() const
{
	...	// 내용은 기초 클래스, 파생 클래스 각각 다르다.
}

이러면 클래스 객체에 따라서 호출하는 함수가 달라진다.

TableTennisPlayer dom("Dominic Banker", 11224, 4183.45);
RatedPlayer dot("Dorothy Banker", 12118, 2592.00);
dom.ViewAcct();	// TableTennisPlayer::ViewAcct()를 사용한다.
dot.ViewAcct();	// RatedPlayer::ViewAcct()를 사용한다.

만약 메서드가 객체가 아니라 참조나 포인터에 의해 호출되면 어떻게 될까?

TableTennisPlayer dom("Dominic Banker", 11224, 4183.45);
RatedPlayer dot("Dorothy Banker", 12118, 2592.00);
TableTennisPlayer & b1_ref = dom;
TableTennisPlayer & b2_ref = dot;
b1_ref.ViewAcct();	// TableTennisPlayer::ViewAcct()를 사용한다.
b2_ref.ViewAcct();	// TableTennisPlayer::ViewAcct()를 사용한다.

둘 다 참조 변수를 따른다. 여기서 만약 가상 함수를 사용하면 어떻게 될까? 가상 함수는 메서드 선언 앞에 virtual을 붙이면 된다. (기반 클래스 메서드에 사용한다.)

virtual void ViewAcct() const;	// 이런 식으로 virtual을 붙이면 된다.
TableTennisPlayer dom("Dominic Banker", 11224, 4183.45);
RatedPlayer dot("Dorothy Banker", 12118, 2592.00);
TableTennisPlayer & b1_ref = dom;
TableTennisPlayer & b2_ref = dot;
b1_ref.ViewAcct();	// TableTennisPlayer::ViewAcct()를 사용한다.
b2_ref.ViewAcct();	// RatedPlayer::ViewAcct()를 사용한다.

참조나 포인터가 아니라, 다시 객체가 중심이 되는 함수 호출로 바뀌었다.

그래서 그게 뭐 어쨌다는 건가? virtual을 쓰고 안 쓰고의 차이는 객체 중심 메서드 호출이냐 참조 중심 메서드 호출이냐인 것 같은데, 그게 중요하냐는 말이다.

기초 클래스와 파생 클래스가 섞여있는 경우를 보자. 예를 들어

CLIENTS = 50;
TableTennisPlayer * p_clients[CLIENTS];
for (int i = 0; i < CLIENTS; i++)
{
	int kind;
    cout << "1 or 2 입력" << endl;
	cin >> kind
    if (kind == 1)
		p_clients[i] = new TableTennisPlayer(temp, tempnum, tempbal);	// 매개 변수는 임의 사용.
    else
    	p_clients[i] = new RatedPlayer(temp, tempnum, tempbal, tmax, trate);	// 매개 변수는 임의 사용.
}

for (int i = 0; i < CLIENTS; i++)
{
	p_clients[i]->ViewAcct();	// 무엇을 실행할 것인가?
    cout <<endl;
}

이런 코드를 보자. TableTennisPlayer이 RatedPlayer의 기초 클래스이기 때문에 이런 동적 할당이 가능하다. 그러면 p_clinets 배열의 데이터형들은 같다. 왜? TableTennisPlayer을 지시하는 포인터가 TableTennisPlayer 객체와 RatedPlayer 객체 둘 다 지시할 수 있기 때문이다. 결과적으로 객체형이 하나 이상인 객체들의 집합을 하나의 배열로 나타낸 것이다. 이걸 다형(polymorhphic)이라고 부른다. 다형 상속을 구현하는 두 가지 방법은 다음과 같다.
① 기초 클래스 메서드를 파생 클래스에 다시 정의한다.
② 가상 메서드를 사용한다.

밑의 구문을 보면, 만약 가상 함수를 쓰지 않았더라면, 모두 TableTennisPlayer 메서드를 썼을 것이다. 하지만 가상 함수를 쓰면 객체에 따른 메서드를 쓸 수 있을 것이다. 이게 바로 가상 함수가 필요한 이유다.(솔직히... 필요한가? 저렇게 복잡한 코드가 나올지 모르겠다.)

기초 클래스 메서드에서 기능을 조금만 추가 하고 싶을 때도 있을 것이다. 그럴 땐 파생 클래스에 재정의된 함수 안에서 기초 클래스 함수 호출을 하면 된다. 여기서 주의할 점이 있는데, 메서드 앞에 기초 클래스명, 범위 연산자를 붙여야 한다. 안 붙이면 재귀 함수 된다.

	void RatedPlayer::Show() const
    {
    	TableTennisPlayer::Show();	// 기초 클래스의 Show() 메서드.
        Show();	// 이렇게 쓰면 재귀 함수 된다.
    }

재정의가 아닌 경우는 범위 연산자를 쓸 필요는 없다.

가상 파괴자

생성자가 사용됐으면 파괴자도 무조건 호출이 되어야 한다. 특히 동적 할당을 했을 때는 더 그렇다. 파생 클래스를 참조하는 기반 클래스가 있는데 가상 파괴자를 사용하지 않았다면, 기반 클래스의 파괴자만 호출이 될 것이다. 그러므로 파생 클래스의 파괴자도 호출하기 위해서 기반 클래스의 파괴자 앞에 virtual 키워드를 붙여야 한다.

override, final

재정의를 하다가 간혹 실수하는 경우가 있다. 예를 들어 다음과 같다.

class Action
{
	int a;
public:
	Action(int i = 0)
		: a(i)
	{}
	int val() const { return a; };
	virtual void f(char ch) const { std::cout << val() << ch << "\n"; }
};
class Bingo : public Action
{
public:
	Bingo(int i = 0)
		: Action(i)
	{}
	virtual void f(char* ch) const { std::cout << val() << ch << "!\n"; }	// 기초 클래스 메서드의 매개변수가 다름.
};
int main()
{
	Bingo b(10);
	b.f('@');
}

이는 명백한 범죄 행위다. 이런 일을 어떻게 방지할까? override 키워드를 쓰면 컴파일러가 확실히 알려준다. override는 재정의하는 함수명의 끝에 붙이면 된다. 에러 방지뿐만 아니라 가독성도 높여주므로, 재정의시에는 꼭쓰는 게 좋다

virtual void f(char* ch) const overrride { std::cout << val() << ch << "!\n"; }

final 키워드는 좀 다르다. 더 이상 파생 클래스에 재정의를 안하겠다고 명시적으로 밝히고자 할 때 쓰면 된다. override와 같은 위치에 쓰면 된다.

virtual void f(char* ch) const final { std::cout << val() << ch << "!\n"; }
profile
Question, Think, Select

0개의 댓글