본문 바로가기
프로그래밍/C & C++

[C++] 가상 함수 (virtual, override 키워드)

by 별준 2021. 7. 30.

References

Contents

  • 가상 함수 (virtual 키워드)
  • 오버라이드(override 키워드)
  • 다형성 (polymorphism)
  • 생성자/소멸자에서의 가상 함수 호출(주의사항)

2021.07.28 - [C & C++] - [C++] 가상 소멸자

 

[C++] 가상 소멸자

Reference Effective C++ (항목 7) Contents 다형성을 가진 기본(base) 클래스에서의 소멸자 가상 소멸자 가상 함수 테이블 아래와 같은 TimeKeeper라는 기본(base) 클래스가 있고, 이 클래스를 상속받는 AtomicCl..

junstar92.tistory.com

이전 글에서 가상 소멸자에 대해서 정리를 했는데, 이번 글에서 가상 함수(virtual 키워드)에 대해서 정리해보려고 합니다.


기본(base) 클래스와 파생(derived) 클래스

가상 함수에 대해서 설명하기 전에, 먼저 기본(Base) 클래스와 이를 상속받는 파생(Derived) 클래스를 아래와 같이 정의하고, 동작에 대해서 살펴보겠습니다.

class Base {
public:
	Base() : s("Base") {
		std::cout << "Contructor of Base Class\n";
	};
	~Base() {
		std::cout << "Destructor of Base Class\n";
	}

	void what() {
		std::cout << s << std::endl;
	}

private:
	std::string s;
};

class Derived : public Base {
public:
	Derived() : s("Derived"), Base() {
		std::cout << "Constructor of Derived Class\n";
	}
	~Derived() {
		std::cout << "Destroctor of Derived Class\n";
	}

	void what() {
		std::cout << s << std::endl;
	}

private:
	std::string s;
};

위와 같이 정의된 클래스를 사용하여, 아래처럼 main문을 작성하고 실행시켜 보겠습니다.

int main(void) {
	std::cout << "기본 클래스 생성\n";
	Base base;
	base.what();

	std::cout << "파생 클래스 생성\n";
	Derived derived;
	derived.what();

	return 0;
}

아무런 문제도 없는 결과입니다. Base 클래스에서 what을 호출하면 당연하게 Base의 what이 호출되어 'Base'를 출력하고, Base를 상속받는 Derived 클래스에서 what을 호출하면, Derived의 what이 Base의 what을 오버라이드(override)해서 Derived의 what이 호출됩니다.

 

이번에는 Derived 객체를 Base 포인터에 넣어서 실행시켜 보겠습니다.

Derived가 Base를 상속받기 있기 때문에, Base 객체 포인터에 Derived 주소를 대입할 수 있습니다.

즉, Derived 객체도 어떻게 보면 Base 객체이기 때문에 Base를 가리키는 포인터가 derived를 가리켜도 무방합니다.

(Derived is a Base 가 성립합니다.)

int main(void) {
	Base base;
	Derived derived;

	Base* p_base = &derived;
	p_base->what();

	return 0;
}

derived를 가리키는 Base 객체의 포인터의 what 함수를 실행시키면 Base를 출력합니다. 이는 Base 포인터가 비록 Derived 객체인 derived를 가리키지만, Base 포인터 입장에서는 Derived 객체의 Base에 해당되는 정보 밖에 알 수가 없습니다. (Derived 객체의 Base 이외의 정보는 Base 포인터가 알 수 없습니다.)

이러한 형태의 캐스팅(파생 클래스에서 기본 클래스로 캐스팅하는 것)을 업 캐스팅이라고 합니다.

참고로 반대의 경우, Derived 객체의 포인터로 Base 객체를 가리키는 것은 불가능합니다. Base 객체에서는 Derived 객체의 정보가 없기 때문에, Derived 객체의 포인터는 존재하지 않는 부분을 참조하게 되기 때문입니다.

 


virtual 키워드와 가상 함수(virtual function)

바로 위의 예시에서 derived 객체를 가리키는 Base 포인터의 what을 실행시키면, Base 클래스의 what이 호출되었습니다. 만약 다형성이 고려되지 않은 기본 클래스라면 이 동작이 올바르겠지만, 다형성을 가진 기본 클래스라면 기본 클래스 인터페이스를 통해서 파생 클래스를 조작할 수 있어야 하기 때문에 이는 올바르지 않게 설계되었다고 볼 수 있습니다.

 

이런 문제 상황에서 다형성을 만족시키려면, virtual 키워드만 있으면 됩니다. virtual 키워드를 통해서 what 함수를 가상 함수로 만드는 것이죠. (출력을 깔끔하게 하기 위해서 각 클래스의 소멸자는 지웠습니다.)

class Base {
public:
	Base() : s("Base") {
		std::cout << "Contructor of Base Class\n";
	};

	virtual void what() {
		std::cout << "--- Base Class의 what ---\n";
	}

private:
	std::string s;
};

class Derived : public Base {
public:
	Derived() : s("Derived"), Base() {
		std::cout << "Constructor of Derived Class\n";
	}

	void what() {
		std::cout << "--- Derived Class의 what ---\n";
	}

private:
	std::string s;
};

위와 같이 수정해주고 아래의 main문을 실행하면 다음의 출력 결과를 얻을 수 있습니다.

int main(void) {
	Base base;
	Derived derived;

	Base* p_base1 = &base;
	Base* p_base2 = &derived;

	p_base1->what();
	p_base2->what();

	return 0;
}

p_base1과 p_base2는 모두 Base 객체를 가리키는 포인터입니다. 하지만, p_base1->what()와 p_base2->what()을 하면 결과가 다릅니다. p_base2가 Base 객체를 가리키는 포인터지만, 실제로는 Derived 객체를 가리키고 있기 때문에 적절하게 함수를 호출해주게 됩니다.

 

이것이 가능하게 된 것이 바로 virtual 키워드 때문이고, virtual 키워드를 추가하면 컴퓨터는 런타임 시에 적절한 함수를 찾아서 실행하게 됩니다.

이렇게 컴파일 시에 어떤 함수가 실행될 지 결정되지 않고 런타임 시에 정해지는 것을 동적 바인딩(dynamic binding)이라고 합니다.
반대의 경우는 정적 바인딩(static binding)이라고 하며, 일반적으로 알고 있있었던 함수에 해당됩니다.

 

이렇게 virtual 키워드가 붙은 함수를 가상 함수(virtual function)이라고 합니다. 

그리고 파생 클래스에서의 함수가 기본 클래스의 가상 함수를 오버라이드(override) 하려면 두 함수의 꼴이 완전히 동일해야 합니다.

 


override 키워드

C++11 이후부터는 파생 클래스에서 기본 클래스의 가상 함수를 오버라이드하는 경우, override 키워드를 통해서 명시적으로 나타낼 수 있습니다. (21 line)

class Base {
public:
	Base() : s("Base") {
		std::cout << "Contructor of Base Class\n";
	};

	virtual void what() {
		std::cout << "--- Base Class의 what ---\n";
	}

private:
	std::string s;
};

class Derived : public Base {
public:
	Derived() : s("Derived"), Base() {
		std::cout << "Constructor of Derived Class\n";
	}

	void what() override {
		std::cout << "--- Derived Class의 what ---\n";
	}

private:
	std::string s;
};

위 경우에서 Derived 클래스의 what 함수는 Base 클래스의 what 함수를 오버라이드하기 때문에, override 키워드를 통해 이를 명시적으로 나타내고 있습니다. override 키워드를 사용하면, 실수로 오버라이드하지 않는 경우를 방지할 수 있습니다.

 

아래의 예시를 살펴보겠습니다. Derived 클래스의 what 함수를 실수로 const로 선언하고 override 키워드를 주었습니다.

class Base {
public:
	Base() : s("Base") {
		std::cout << "Contructor of Base Class\n";
	};

	virtual void what() {
		std::cout << "--- Base Class의 what ---\n";
	}

private:
	std::string s;
};

class Derived : public Base {
public:
	Derived() : s("Derived"), Base() {
		std::cout << "Constructor of Derived Class\n";
	}

	void what() const override {
		std::cout << "--- Derived Class의 what ---\n";
	}

private:
	std::string s;
};

이 경우에 컴파일 시에 에러가 발생하게 됩니다.

Derived의 what 함수가 오버라이드한다고 했지만, 실제로는 아무것도 오버라이드하지 않는다는 에러입니다. 위 예시에서 const를 지우게 되면 Base 클래스의 what 함수와 완전히 동일하고, 오버라이드되기 때문에 정상적으로 컴파일이 됩니다.


가상 함수 테이블

이전 가상 소멸자에서도 언급했지만, 위처럼 편리한 가상 함수를 디폴트로 '모든 함수에 virtual 키워드를 달아버리면 되지 않을까'하는 생각이 들 수 있습니다. 사실 모든 함수를 virtual로 만들어버린다고 해서 문제될 것은 전혀 없고, 가상 함수 또한 실제로 존재하는 함수이며 정상적으로 호출도 할 수 있습니다. 이렇게 모든 함수를 가상 함수로 만들면, 언제나 동적 바인딩이 정상적으로 제대로 동작하도록 할 수 있습니다.

(실제로 자바의 경우 모든 함수들이 디폴트로 virtual 함수로 선언된다고 합니다.)

 

C++에서는 가용자가 직접 virtual 키워드를 통해 가상 함수로 선언하도록 했는데, 이는 가상 함수를 사용하게 되면 약간의 오버 헤드(overhead)가 존재하기 때문입니다.

 

C++ 컴파일러는 클래스에 가상 함수가 하나라도 존재하면, 가상 함수 테이블(virtual function table)을 만들게 됩니다. 이 테이블에는 가상 함수들의 주소가 포인터의 형태로 저장되어 있고, 가상 함수가 호출될 때 이 테이블을 거쳐서 호출되는 함수가 결정됩니다. (클래스 내의 비가상 함수는 이 과정을 거치지 않고 정상적으로 호출됩니다.)

 

이처럼 두 단계에 걸쳐서 함수를 호출함으로써 동적 바인딩이 구현되고, 이러한 이유 때문에 일반적인 함수 호출보다 약간의 시간이 더 소요되게 됩니다. 차이가 미비할 수도 있으나, 최적화가 중요한 분야에서는 중요할 수 있는 부분입니다.


객체 생성 및 소멸 과정 중에는 가상 함수를 호출하면 안된다.

가상 함수를 사용할 때 주의해야할 점은 객체의 생성 및 소멸 과정 중에서는 가상 함수를 호출하면 안된다는 것입니다.

예시를 통해서 이유를 살펴보겠습니다.

class Transaction {
public:
	Transaction();
	virtual void logTransaction() const = 0; //타입에 따라 달라지는 로그기록
};

Transaction::Transaction() {
	// Do something ...
	logTransaction(); // 생성자 마지막에 로깅 시작
}

class BuyTransaction : public Transaction {
public:
	virtual void logTransaction() const {
		std::cout << "logging for transaction of buy\n";
	}
};

class SeelTransaction : public Transaction {
public:
	virtual void logTransaction() const {
		std::cout << "logging for transaction of sell\n";
	}
};

주식 거래를 위한 기본 클래스(Transaction)이 있고, 이를 상속받는 매도/매수 등의 클래스의 예시입니다.

그리고, 아래의 코드를 실행하면 어떻게 될까요 ?

int main(void) {
	BuyTransaction b;

	return 0;
}

BuyTransaction 클래스가 생성되면서 BuyTransaction 생성자가 호출되는 것은 맞지만, 우선 Transaction 생성자가 호출되어야 합니다. (파생 클래스가 생성될 때 그 객체의 기본 클래스 부분이 파생 클래스 부분보다 먼저 호출됩니다.)

Transaction 생성자의 마지막 부분에 가상 함수인 logTransaction을 호출하게 되는데, 이 부분에서 문제가 발생하게 됩니다. 여기서 호출되는 logTransaction은 BuyTransaction의 logTransaction이 아니라, Transaction의 logTransaction입니다.

이렇게 기본 클래스의 생성자가 호출되는 동안에는 가상 함수가 적용되지 않습니다.

 

단순히 생각해봐도 직관적이지 않는데, 기본 클래스 생성자는 파생 클래스 생성자보다 앞서서 실행되기 때문에 기본 클래스 생성자가 실행되고 있는 시점에 파생 클래스의 데이터 멤버는 아직 초기화된 상태가 아닙니다.

이런 상황에서 기본 클래스 생성자에서 호출된 가상 함수가 파생 클래스 쪽으로 내려가서 실행되면 어떻게 될까요 ? 파생 클래스 버전의 가상 함수가 파생 클래스만의 초기화되지 않은 데이터 멤버를 건들일 수도 있고, 예기치 못한 위험들이 발생될 것 입니다.

 

가장 중요한 핵심은, 파생 클래스 객체가 생성되면서 기본 클래스 부분이 생성되는 동안, 그 객체의 타입은 바로 기본 클래스라는 것입니다. 호출되는 가상 함수는 모두 기본 클래스의 것으로 결정될뿐만 아니라, 런타임 타입 정보를 사용하는 언어 요소(dynamic_cast, typeid)를 사용한다고 해도 기본 클래스 부분이 생성되는 동안은 모두 기본 클래스 타입의 객체로 취급합니다.

 

위 예제에서 BuyTransaction 객체의 기본 클래스 부분을 초기화하기 위해서 Transaction 생성자가 실행되는 동안에는 그 객체의 타입이 Transaction이라는 것입니다. 즉, 파생 클래스의 생성자가 실행이 시작되어야만 그 객체가 비로소 파생 클래스의 역할을 하게 됩니다.

 

객체가 소멸될 때(소멸자가 호출될 때)도 동일하게 생각하면 됩니다. 파생 클래스의 소멸자가 일단 호출되고 나면 파생 클래스만의 데이터 멤버는 정의되지 않은 값으로 가정하기 때문에, C++도 이들이 없는 것처럼 취급하고 진행합니다. 따라서, 기본 클래스의 소멸자에 진입할 당시의 객체는 기본 클래스 객체가 되며, 모든 C++ 기능들(가상함수, dynamic_cast 등)도 역시 기본 클래스 객체의 자격으로 처리됩니다.

 

그래서 위 예제 코드를 실행하면 아래의 에러를 출력하게 됩니다.


생성자 혹은 소멸자 안에서 가상 함수가 호출되는지 잡아내는 일이 항상 쉬운 것은 아닙니다.

방금 예제에서 Transaction 함수를 조금 변형한 코드를 살펴보겠습니다.

class Transaction {
public:
	Transaction() {
		init();
	}
	virtual void logTransaction() const = 0;

private:
	void init() {
		// Do something ...
		logTransaction();
	}
};

Transaction 생성자에서 비가상 함수인 init 함수를 호출하고 있지만, init 함수 내에서 가상 함수인 logTransaction 함수를 호출하고 있습니다.

 

잘못된 코드이지만, 앞의 코드와는 달리 컴파일도 잘 되고 링크도 정상적으로 됩니다. 

이 코드가 실행된다고 가정해보면, logTransaction은 Transaction 클래스 안에서 순수 가상 함수이기 때문에, 대부분의 시스템은 순수 가상 함수가 호출될 때 프로그램을 바로 종료시켜 버립니다. 이런 경우라면 매우 감사한 상황입니다.

하지만, 순수 가상 함수가 아니라 Transaction 함수가 보통의 가상 함수라면, BuyTranscation이 아닌 Transcation의 logTranscation이 호출됩니다. 프로그램은 에러를 뱉지 않고, 정상적인 것처럼 돌아갈 것입니다.

 

이런 문제를 피하는 방법은 다른 게 없으며, 단순히 생성 중이거나 소멸 중인 객체에 대해 생성자나 소멸자에서 가상 함수 호출을 제거하고, 생성자와 소멸자가 호출하는 모든 함수들이 똑같은 제약을 따르도록 만들어야 합니다.


생성자/소멸자에서 가상 함수 호출을 대처하는 방안

책에서 설명하고 있는 방법은 다음과 같습니다.

바로 logTransaction을 Transaction 클래스의 비가상 멤버 함수로 변경하는 것입니다. 그리고 나서 파생 클래스의 생성자들로 하여금 필요한 로그 정보를 Transaction의 생성자로 넘겨야 한다는 규칙을 만듭니다. logTransaction이 비가상 함수이기 때문에 Transaction의 생성자는 이 함수를 안전하게 호출할 수 있습니다.

class Transaction {
public:
	explicit Transaction(const std::string& logInfo) {
		// Do something ...
		logTransaction(logInfo);
	}
	void logTransaction(const std::string& logInfo) const {
		// Do something ...
	};
};

class BuyTransaction : public Transaction {
public:
	BuyTransaction() : Transaction(createLogString("buy")) {
		// Do something ...
	}

private:
	static std::string createLogString(std::string param) {
		return param;
	}
};

위 코드의 경우에는 Transaction 클래스가 비가상 멤버 함수를 호출하고 있으며, 필요한 초기화 정보는 파생 클래스 쪽으로 부터 전달받아서 부족한 부분을 채워주고 있습니다.

 

방금 예제 코드에서 createLogString이라는 멤버 함수는 정적(static) 함수로 사용되고 있는데, 이 함수는 기본 클래스 생성자 쪽으로 넘길 값을 생성하는 용도로 쓰이는 도우미 함수라고 볼 수 있습니다. 특히, 정적 멤버로 되어 있기 때문에 자칫 BuyTransaction 객체의 미초기화된 데이터 멤버를 실수로 건드릴 위험도 없어지게 됩니다. 이는 미초기화된 데이터 멤버는 정의 되지 않은 상태에 있기 때문입니다.

댓글