15장 다형성과 가상함수

2023. 11. 30. 00:20프로그래밍 공부/OOP

15.1 가상 함수 기본 사항

 

늦은 바인딩

가상 함수는 예를 들어 가장 잘 설명된다. 

직사각형, 원, 타원 등과 같은 여러 종류의 도형에 대한 클래스가 있는 그래픽 패키지의 소프트웨어를 설계한다고 가정해 보자. 

각 도형은 다른 클래스의 객체일 수도 있다. 

 

예를 들어, Rectangle 클래스는 높이, 너비, 중심점에 대한 멤버 변수가 있는 반면,

Circle 클래스는 중심점과 반지름에 대한 멤버 변수가 있을 수 있다. 

이러한 클래스는 모두 Figure과 같은 단일 부모 클래스의 후손이다. 

 

이제 화면에 도형을 그리기 위한 함수를 원한다고 가정해 보자. 

원을 그리기 위해서는 직사각형을 그리기 위한 명령어와 다른 명령어가 필요하다. 

따라서 각 클래스는 도형을 그리기 위한 다른 함수를 가져야 한다. 

그러나 함수는 클래스에 속하므로 모두 draw라고 할 수 있다. 

만약 r이 Rectangle 객체이고 c가 Circle 객체라면 r.draw( )와 c.draw( )는 서로 다른 코드로 구현된 함수일 수 있다.

이 모든 것이 새로운 것은 아니다.

하지만 이제 새로운 개념인 부모 클래스 Figure에 정의된 가상 함수로 넘어간다.

 

부모 클래스 Figure은 모든 그림에 적용되는 함수들을 가질 수 있다. 

ex) 함수 center - 그림을 지우고 화면 중앙에 다시 그려서 화면 중앙으로 이동시킨다.

함수 Figure::center - 함수 draw를 사용하여 화면 중앙에 있는 그림을 다시 그릴 수 있다. 

클래스 Rectangle과 Circle의 도형이 상속된 함수 center을 사용하는 것을 생각하면 여기에 복잡성이 있다는 것을 알 수 있다.


요점을 분명히 하기 위해, 클래스 Figure이 이미 작성되어 사용되고 있고, 

나중에 당신이 완전히 새로운 종류의 그림에 대한 클래스를 추가한다고 가정해보자. 

이 클래스를 Triangle이라고 하자.

이제 Triangle은 클래스 Figure에서 파생된 클래스가 될 수 있으므로, 

함수 center은 클래스 Figure에서 상속될 것이다. 

따라서 함수 center은 모든 Triangle들에 적용되어야 하며, 이에 대해 올바르게 수행되어야 한다. 

하지만 복잡한 문제가 있다. 

함수 center은 draw를 사용하고, 함수 draw는 그림의 종류마다 다르다. 

상속된 함수 center은 (특별하게 수행된 것이 없다면)

클래스 Figure에 주어진 함수 draw의 정의를 사용할 것이고, 

그 함수 draw는 Triangle들에 대해 올바르게 작동하지 않는다. 

우리는 상속된 멤버 함수 center이 함수 Figure::draw 대신에 Triangle::draw를 사용하기를 원한다. 

그러나 클래스 Triangle, 그리고 따라서 함수 Triangle::draw는

함수 center이 (클래스 Figure에 정의된) 작성되고 컴파일될 때조차 작성되지 않았다!

어떻게 함수 center이 Triangle들에 대해 올바르게 작동할 수 있을까? 

컴파일러는 그 center가 컴파일될 당시에는 Triangle::draw에 대해 아무것도 알지 못했다.

답은 draw가 가상 함수라면 적용할 수 있다는 것이다.

 

함수를 가상(virtual)으로 만들면 컴파일러에게

"나는 이 함수가 어떻게 구현되는지 모른다.

프로그램에서 사용될 때까지 기다렸다가 객체 인스턴스에서 구현을 가져오라."

라고 말한다.

늦은 바인딩(Late binding) 또는 동적 바인딩(dynamic binding): 절차의 구현을 결정하기 위해 런타임까지 기다리는 기법

가상 함수(virtual function): C ++가 동적 바인딩을 제공하는 방법

C ++의 가상 함수에 대한 자세한 내용을 설명하기 위해 그림 그리기 이외의 응용 영역에서 단순화된 예제를 사용할 것이다.

 

C++의 가상 함수

자동차 부품 판매점을 위한 기록 보관 프로그램을 설계한다고 가정해 보자. 

프로그램을 다용도로 사용할 수 있도록 만들고 싶지만 모든 가능한 상황을 고려할 수 있을지 확신할 수 없다.

 예를 들어, 판매를 계속 추적하고 싶지만 모든 종류의 판매를 예상할 수는 없다. 

처음에는 특정 부품 한 개를 사기 위해 판매점에 가는 소매 고객들에게는 정기적인 판매만 있을 것이다. 

그러나 나중에 할인이 포함된 판매를 추가하거나 배송비가 포함된 통신 판매를 추가할 수도 있다. 

이 모든 판매는 기본 가격을 가진 품목에 대한 것이고 궁극적으로 약간의 청구서를 생성할 것이다. 

간단한 판매의 경우 청구서는 기본 가격이지만 

나중에 할인을 추가하면 일부 종류의 청구서도 할인의 크기에 따라 달라진다. 

프로그램은 일일 총 판매를 계산해야 하며, 

이는 직관적으로 모든 개별 판매 청구서의 합이어야 한다. 

또한 하루의 가장 큰 판매와 가장 작은 판매 또는 하루의 평균 판매를 계산할 수도 있다. 

이 모든 것은 개별 청구서에서 계산할 수 있지만 

청구서 계산을 위한 많은 함수는 나중에 어떤 종류의 판매를 처리할지 결정할 때까지 추가되지 않을 것이다. 

이를 수용하기 위해 청구서 계산을 위한 함수를 가상 함수로 만든다. 


디스플레이 15.1과 15.2는 클래스 Sale에 대한 인터페이스와 구현을 포함한다. 

모든 유형의 판매는 클래스 Sale의 파생 클래스가 될 것이다.

클래스 Sale은 할인이나 요금이 추가되지 않은 단일 품목의 단순 판매에 해당한다.

멤버 함수 bill(15.1 표시)에 대한 설명에서 예약된 단어 virtual을 주목하자.

멤버 함수 savings과 오버로딩된 연산자,<,은 각각 함수 bill를 사용한다.

bill이 가상 함수라고 선언되므로

나중에 클래스 Sale의 파생 클래스를 정의하고 멤버 함수 bill의 버전을 정의할 수 있다.

클래스 Sale과 함께 제공한 멤버 함수 savings과 오버로딩된 연산자,<,의 정의는

파생 클래스의 객체에 해당하는 멤버 함수 bill 버전을 사용한다.

 

디스플레이 15.1 기본 클래스 Sale 인터페이스

//This is the header file sale.h.
//This is the interface for the class Sale.
//Sale is a class for simple sales.

#ifndef SALE_H
#define SALE_H

namespace SavitchSale
{

	class Sale
	{
	public:
		Sale( );
		Sale( double thePrice);
		double getPrice( ) const;
		void setPrice( double newPrice);
		virtual double bill( ) const;
		double savings(const Sale& other) const;
		//Returns the savings if you buy other instead of the calling object.
	private:
		double price;
	};
    
	bool operator < ( const Sale& first, const Sale& second);
	//Compares two sales to see which is larger.
} //SavitchSale

#endif // SALE_H

 

디스플레이 15.2 기본 클래스 Sale 구현

//This is the file sale.cpp.
//This is the implementation for the class Sale.
//The interface for the class Sale is in the file sale.h.

#include <iostream>
#include "sale.h"
using std::cout;

namespace SavitchSale
{

	Sale::Sale( ) : price(0)
	{
		//Intentionally empty
	}
    
	Sale::Sale( double thePrice)
	{
		if (thePrice >= 0)
			price = thePrice;
		else
		{
			cout << "Error: Cannot have a negative price!\n";
			exit(1);
		}
	}
    
	double Sale::bill( ) const
	{
		return price;
	}
    
	double Sale::getPrice( ) const
	{
		return price;
	}

	void Sale::setPrice( double newPrice)
	{
		if (newPrice >= 0)
			price = newPrice;
		else
		{
			cout << "Error: Cannot have a negative price!\n";
			exit(1);
		}
	}
    
	double Sale::savings( const Sale&other) const
	{
		return (bill( ) - other.bill( ));
	}
    
	bool operator < ( const Sale& first, const Sale& second)
	{
		return (first.bill( ) < second.bill( ));
	}    
} //SavitchSale

 

예를 들어, 15.3과 15.4는 파생된 클래스 DiscountSale를 보여준다.

클래스 DiscountSale는 멤버 함수 bill의 버전에 대해 다른 정의를 요구한다.

그럼에도 불구하고 멤버 함수 savings과 오버로딩 연산자,<,을 클래스 DiscountSale의 객체와 함께 사용할 때,

그들은 클래스 DiscountSale와 함께 제공된 bill에 대한 함수 정의의 버전을 사용한다.

이것은 C++가 성공하기에는 정말 멋진 트릭이다.

클래스 DiscountSale의 객체 d1과 d2에 대한 함수 d1.savings(d2)라고 부르는 것을 생각해 보자.

함수 savings의 정의(클래스 DiscountSale의 객체에 대해서도)는

클래스 DiscountSale를 생각하기도 전에 작성된 기본 클래스 DiscountSale 구현 파일에 나와 있다.

그러나 함수 d1.savings(d2)라고 부르는 함수에서 함수 bill을 호출하는 행은

클래스 DiscountSale에 대해 제공된 함수 bill의 정의를 사용할 만큼 충분히 알고 있다.
이것은 어떻게 작동할까? 

C++ 프로그램을 작성하기 위해서는 마법에 의해 일어난다고 가정할 수 있지만,

실제 설명은 이 섹션의 소개에서 제공되었다. 

함수에 virtual 라벨을 지정하면

C++ 환경에 "이 함수가 프로그램에서 사용될 때까지 기다렸다가 호출 객체에 해당하는 구현을 가져오라."라고 말한다.
디스플레이 15.5는 가상 함수 bill과 bill을 사용하는 함수가 전체 프로그램에서 어떻게 작동하는지를 보여주는 샘플 프로그램을 제공한다.

 

디스플레이 15.3 파생 클래스 DiscountSale 인터페이스

//This is the file discountsale.h.
//This is the interface for the class DiscountSale.

#ifndef DISCOUNTSALE_H
#define DISCOUNTSALE_H
#include "sale.h"

namespace SavitchSale
{

	class DiscountSale : public Sale
	{
    
	public:
		DiscountSale( );
		DiscountSale( double thePrice, double theDiscount);
		//Discount is expressed as a percentage of the price.
		//A negative discount is a price increase.        
		double getDiscount( ) const;
		void setDiscount( double newDiscount);
		double bill( ) const;
	private:
		double discount;
        
	};
    
}//SavitchSale

#endif //DISCOUNTSALE_H

bill이 기본 클래스에서 virtual로 선언되었기 때문에 파생 클래스 DiscountSale에서 자동으로 virtual이 된다.

bill이라는 수식어를 bill 선언에 추가하거나 생략할 수 있다.

어느 경우든 클래스 discountSale에서 virtual이 된다.
(필요하지 않더라도 모든 가상 함수 선언에 virtual이라는 단어를 포함시키는 것이 좋다.

필요하지 않다는 것을 설명하기 위해 생략했다.)

 

디스플레이 15.4 파생 클래스 DiscountSale 구현

//This is the implementation for the class DiscountSale.
//This is the file discountsale.cpp.
//The interface for the class DiscountSale is in the header file
//discountsale.h.
#include "discountsale.h"

namespace SavitchSale
{
	DiscountSale::DiscountSale( ) : Sale( ), discount(0)
	{
		//Intentionally empty
	}
    
	DiscountSale::DiscountSale( double thePrice, double theDiscount)
	          : Sale(thePrice), discount(theDiscount)
	{
		//Intentionally empty
	}
    
	double DiscountSale::getDiscount( ) const
	{
		return discount;
	}
    
	void DiscountSale::setDiscount( double newDiscount)
	{
		discount = newDiscount;
	}
    
	double DiscountSale::bill( ) const
	{
		double fraction = discount / 100;
		return (1 - fraction) * getPrice( );
	}

} //SavitchSale

double DiscountSale::bill( ) const의 함수 정의에서 한정자 virtual을 반복하지 않는다.

 

디스플레이 15.5 가상 함수의 사용

//Demonstrates the performance of the virtual function bill.
#include <iostream>
#include "sale.h" //Not really needed, but safe due to ifndef.
#include "discountsale.h"
using std::cout;
using std::endl;
using std::ios;
using namespace SavitchSale;

int main( )
{
	Sale simple(10.00); //One item at $10.00.
	DiscountSale discount(11.00, 10); 
	//One item at $11.00 with a 10% discount

	cout.setf(ios::fixed);
	cout.setf(ios::showpoint);
	cout.precision(2);
    
	if (discount < simple)
	{
		cout << "Discounted item is cheaper.\n";
		cout << "Savings is $" << simple.savings(discount) << endl;
	}
	else
		cout << "Discounted item is not cheaper.\n";
        
	return 0;
}

객체 discount와 simple은 less-than(<) 비교 시 멤버 함수 bill에서 다른 코드를 사용하고 있으며,

유사한 내용은 savings에도 적용된다.

 

샘플 대화 상자

Discounted item is cheaper.
Savings is $0.10

 

가상 함수
가상 함수(virtual function): 멤버 함수 선언에 virtual이라는 수식어를 포함하여 표시된다(클래스의 정의에 나와 있음).
어떤 함수가 가상이고 그 함수에 대한 새로운 정의가 파생 클래스에 주어지면, 

그 객체는 가상 함수가 상속된 함수의 정의에 호출되어 간접적으로 사용되더라도 

항상 파생 클래스에 주어진 가상 함수의 정의를 사용할 것이다. 

늦은 바인딩(late binding): 가상 함수의 어떤 정의를 사용할지를 결정하는 방법

다형성
다형성(Polymorphism): 하나의 함수 이름에 여러 가지 의미를 연결하는 기능을 late-binding 메커니즘으로 말한다.

따라서 다형성, late binding, 가상 함수는 모두 같은 주제이다.

덮어쓰기(Overriding)
파생 클래스에서 가상 함수 정의가 변경되면 프로그래머들은 종종 함수 정의가 재정의되었다(overridden)고 말한다.

C++ 문헌에서는 일반적으로 재정의된(redefined) 용어와 재정의된(overridden) 용어를 구분한다.

두 용어 모두 파생 클래스에서 함수의 정의를 변경하는 것을 말한다.

함수가 가상 함수인 경우 - 재정의(overriding)
함수가 가상 함수가 아닌 경우 - 재정의(redefinding)

두 경우 모두 동일한 작업을 수행하지만 컴파일러는 두 경우를 다르게 취급하기 때문에 

프로그래머에게는 어리석은 구분처럼 보일 수 있다.


팁: 가상 속성이 상속됨

가상 함수라는 속성은 상속된다.

예를 들어, 기본 클래스 Sale(디스플레이 15.1)에서 bill이 가상으로 선언되었으므로(디스플레이 15.3),

파생 클래스 DiscountSale(Display 15.3)에서 함수 bill은 자동으로 가상으로 선언된다.

따라서, 파생 클래스 DiscountSale의 정의에서 멤버 함수 bill의 다음 두 선언은 동등할 것이다:
double bill( ) const;
virtual double bill( ) const;
따라서 SuperDiscountSale이 함수 savings를 상속하는 클래스 DiscountSale의 파생 클래스이고 

함수 bill에 클래스 SuperDiscountSale에 대한 새로운 정의가 부여된 경우 

클래스 SuperDiscountSale의 모든 객체는 클래스 SuperDiscountSale의 정의에 주어진 함수 bill의 정의를 사용한다.
상속된 함수 savings(함수 bill에 대한 호출 포함)도 호출 객체가 클래스 SuperDiscountSale에 있을 때마다 

SuperDiscountSale에 주어진 bill의 정의를 사용한다.


팁: 가상 함수를 언제 사용해야하는지

가상 함수를 사용하면 얻을 수 있는 분명한 이점들이 있지만 지금까지 우리는 뚜렷한 단점을 보지 못했다.

그렇다면 왜 모든 멤버 함수를 가상으로 만들지 않는 것일까?

사실, 왜 모든 멤버 함수가 자동으로 가상이 되도록 C++ 컴파일러를 정의하지 않는 것일까?

답은 함수를 가상으로 만드는 데 드는 오버헤드가 있다는 것이다.

이렇게 하면 더 많은 스토리지가 필요하고, 함수가 가상이 아닌 경우보다 프로그램이 더 느리게 실행된다.

그래서 C++의 설계자들은 프로그래머에게 어떤 멤버 함수가 가상이고 어떤 함수가 가상이 아닌지를 통제할 수 있게 되었다.

만약 가상 멤버 함수의 장점이 필요할 것으로 예상한다면, 그 멤버 함수를 가상으로 만들자.

만약 당신이 가상 함수의 장점이 필요하지 않을 것으로 예상한다면,

만약 당신이 멤버 함수를 가상으로 만들지 않는다면 당신의 프로그램은 더 효율적으로 실행될 것이다.


함정: 가상 멤버 함수의 정의 생략

점진적으로 발전시켜 나가는 것이 현명하다.

이것은 코드를 조금만, 그다음에 조금만 테스트하고, 그다음에 조금만 더 코딩하고, 조금만 더 테스트하는 등의 의미이다.

그러나 virtual 멤버 함수로 클래스를 컴파일하려고 하지만 각 멤버를 구현하지 않으면,

정의되지 않은 멤버 함수를 호출하지 않더라도

이해하기 매우 어려운 오류 메시지에 부딪힐 수 있다!


컴파일 전에 가상 멤버 함수가 구현되지 않으면

컴파일이 실패하고 다음과 같은 오류 메시지가 표시된다.

Undefined reference to Class_Name virtual table.
파생 클래스가 없고 가상 멤버 함수가 하나일 뿐인데

그 함수에 정의가 없다고 해도 이런 종류의 메시지는 계속 발생한다.


오류 메시지를 해독하기 매우 어렵게 만드는 것은

virtual로 선언된 함수에 대한 정의가 없으면 

이러한 생성자가 실제로 이미 정의되어 있더라도

기본 생성자에 대한 정의되지 않은 참조에 대해 불평하는 오류 메시지가 추가로 존재한다는 것이다.


물론 당신은 함수의 "진짜" 버전을 정의할 준비가 될 때까지 가상 함수에 대해 사소한 정의를 사용할 수도 있다.

이 주의는 다음 절에서 논의하는 순수 가상 함수에는 적용되지 않는다.

앞으로 보게 되겠지만 순수 가상 함수는 정의가 없어야 한다.


추상 클래스와 순수 가상 함수

다른 여러 클래스의 기본 클래스로 사용할 클래스를 가지고 싶지만 

하나 이상의 멤버 함수에 부여할 의미 있는 정의가 없는 상황에 직면할 수 있다.

가상 함수를 소개할 때 그러한 시나리오 하나를 논의했다. 이제 검토해 보겠다.


직사각형, 원, 타원 등과 같은 여러 종류의 도형에 대한 클래스가 있는 그래픽 패키지의 소프트웨어를 설계한다고 가정해 보자. 

각 도형은 다른 클래스의 객체일 수도 있는데, 예를 들어 Rectangle 클래스나 Circle 클래스가 그것이다.

잘 설계된 프로그래밍 프로젝트에서 이 클래스들은 아마도 Figure과 같은 단일 부모 클래스의 후손일 것이다.

이제 화면에 도형을 그리기 위한 함수를 원한다고 가정해 보자.

원을 그리기 위해서는 직사각형을 그리기 위해 필요한 것과는 다른 명령어가 필요하다.

따라서 각 클래스는 도형을 그리기 위해 다른 함수를 가져야 한다.

r이 Rectangle 객체이고 c가 Circle 객체라면 r.draw()와 c.draw()는 다른 코드로 구현된 함수일 수 있다.


상위 클래스 Figure은 center 함수를 가질 수 있다.

center 함수 - 화면 중앙에 그림을 지운 후 화면 중앙에 다시 그려 넣어 화면 중앙으로 이동시킨다.

함수 Figure::center - 함수 draw를 사용하여 화면 중앙에 있는 도형을 다시 그릴 수 있다.

멤버 함수 draw를 가상 함수로 만들어서,

여러분은 클래스 Figure에서 멤버 함수 Figure::center에 대한 코드를 작성할 수 있고,

그것이 파생 클래스(예를 들어, Circle)에 사용될 때 클래스 Circle에서 draw의 정의가 사용되는 정의임을 알 수 있다.

여러분은 결코 Figure 유형의 객체를 만들 계획이 없다.

여러분은 Circle과 Rectangle과 같은 파생 클래스의 객체만 만들 생각이다.

따라서 Figure::draw에 부여하는 정의는 절대 사용되지 않는다.

그러나 지금까지 다룬 내용만을 바탕으로 Figure::draw에 대한 정의를 제공해야 한다.

 

순수 가상 함수(pure virtual function)

멤버 함수 Figure::draw를 순수 가상 함수으로 만들면 멤버 함수에 정의를 내릴 필요가 없다.

멤버 함수를 순수 가상 함수로 만드는 방법은

다음 예제와 같이 virtual로 표시하고 멤버 함수 선언에 주석 '= 0'을 추가하는 것이다:
virtual void draw( ) = 0;
어떤 종류의 멤버도 순수 가상 함수로 만들 수 있다.

우리의 예처럼 매개변수가 없는 void 함수일 필요는 없다.

 

추상 클래스(abstract class)
추상 클래스: 하나 이상의 순수 가상 함수를 가진 클래스

추상 클래스는 다른 클래스를 유도하는 기본 클래스로만 사용될 수 있다.

추상 클래스는 완전한 클래스 정의가 아니므로 추상 클래스의 개체를 만들 수 없다.

추상 클래스는 순수 가상 함수가 아닌 다른 멤버 함수를 포함할 수 있으므로 부분 클래스 정의이다.

추상 클래스도 유형이므로 추상 클래스 유형의 매개 변수로 코드를 작성할 수 있으며 

추상 클래스의 하위 항목인 클래스의 모든 개체에 적용된다.


추상 클래스에서 클래스를 파생하면 상속된 모든 순수 가상 함수에 대한 정의를 제공하지 않고 

새로운 순수 가상 함수를 도입하지 않는 한 파생 클래스는 그 자체로 추상 클래스가 된다.

상속된 모든 순수 가상 함수에 대한 정의를 제공하고 새로운 순수 가상 함수를 도입하지 않는 경우

결과 클래스는 추상 클래스가 아니므로 클래스의 객체를 만들 수 있다.


예: 추상 클래스

디스플레이 15.6에서는 디스플레이 14.1에서 Employee 클래스를 약간 다시 작성했다.

이번에는 Employee을 추상 클래스로 만들었다.

다음 행(디스플레이 15.6에서 강조 표시)은 Employee에 대한 이전 정의(디스플레이 14.1)와 다른 유일한 점이다:
virtual void printCheck( ) const = 0;
멤버 함수 표제의 virtual 단어와 = 0은 컴파일러에게 이것이 순수 가상 함수이며 

따라서 Employee 클래스가 이제 추상 클래스가 되었음을 알려준다.

Employee 클래스에 대한 구현에는 Employee::printCheck 클래스에 대한 정의가 없지만

그렇지 않으면 Employee 클래스의 구현은 이전과 동일하다(즉, 디스플레이 14.2와 동일).


멤버 함수에 대한 정의가 없는 것은 일리가 있다.

어떤 종류의 직원을 상대하는지 알기 전까지는 어떤 종류의 체크를 작성해야 하는지 모르기 때문이다.

클래스 Employee에 대한 첫 번째 정의(표시 14.1 및 14.2)에서

Employee::printCheck에 대한 정의를 강제로 지정하여

함수를 호출해서는 안 된다는 오류 메시지를 출력했다.

이제 좀 더 우아한 해결책이 나왔다.

Employee::printCheck을 순수 가상 함수로 만들어

컴파일러가 Employee::printCheck 호출을 금지하도록 설정했다.

 

디스플레이 15.6 추상 클래스 Employee 인터페이스

//This is the header file employee.h.
//This is the interface for the abstract class Employee.

#ifndef EMPLOYEE_H
#define EMPLOYEE_H

#include <string>
using std::string;

namespace SavitchEmployees
{

	class Employee
	{
	public:
		Employee( );
		Employee( const string& theName, const string& theSsn);
		string getName( ) const;
		string getSsn( ) const;
		double getNetPay( ) const;
		void setName( const string& newName);
		void setSsn( const string& newSsn);
		void setNetPay( double newNetPay);
		virtual void printCheck( ) const = 0;
	private:
		string name;
		string ssn;
		double netPay;
        
	};
    
} //SavitchEmployees

#endif //EMPLOYEE_H

이것은 디스플레이 14.1에서 제공된 직원 클래스의 개선된 버전이다.

이 클래스의 구현은 구성원 함수 printCheck()에 대한 정의가 제공되지 않은 것을 제외하고는 디스플레이 14.2와 동일하다.

 

virtual void printCheck( ) const = 0;

순수 가상 함수

 

15.2 포인터 및 가상 함수


가상 함수와 확장형 호환성

Derived가 기본 클래스 Base의 파생 클래스인 경우 

Derived 유형의 개체를 Base 유형의 변수(또는 매개 변수)에 할당할 수 있지만 

그 반대는 할당할 수 없다.


구체적인 예를 생각해 보면 이를 이해할 수 있다.

예를 들어, DiscountSale는 파생된 Sale 클래스이다(표시 15.1 및 15.3).

DiscountSale는 Sale이므로 DiscountSale 클래스의 객체를 DiscountSale 유형 변수에 할당할 수 있다.

그러나 Sale은 반드시 DiscountSale이 아니기 때문에 반대 할당을 수행할 수는 없다.


파생 클래스의 개체를 해당 기본 클래스의 변수(또는 매개 변수)에 할당할 수 있다는 사실은 

상속을 통한 코드 재사용에 매우 중요하다. 그러나 문제가 있다.
예를 들어 프로그램이나 장치에 다음과 같은 클래스 정의가 포함되어 있다고 가정한다:

class Pet
{
public:
    string name;
    virtual void print( ) const;
};
class Dog : public Pet
{
public:
    string breed;
    virtual void print( ) const; // keyword virtual not needed,
                                                //but put here for clarity.

};
Dog vdog;
Pet vpet;

 

이제 데이터 구성원의 이름과 품종에 초점을 맞춰보자.

(이 예제를 간단히 유지하기 위해 멤버 변수를 public으로 만들었다.

실제 응용프로그램에서 멤버는 private이어야 하며 멤버를 조작할 수 있는 함수가 있어야 한다.)

Dog라는 것은 무엇이든 Pet이다.

프로그램이 Dog 유형의 값을 Pet 유형의 값으로 간주하도록 허용하는 것이 타당할 것이므로 다음을 허용해야 한다:

vdog.name = "Tiny";
vdog.breed = "Great Dane";
vpet = vdog;

 

C ++는 이러한 종류의 할당을 허용한다.

vdog 값과 같은 값을 vpet과 같은 부모 유형의 변수에 할당할 수 있지만 반대 할당은 수행할 수 없다.

이전 할당은 허용되지만 변수 vpet에 할당된 값은 해당 breed 영역을 잃게 된다.

이를 슬라이싱 문제(slicing problem)라고 한다.

다음 접근 시도 시 오류 메시지가 표시된다:

cout << vpet.breed;
// Illegal: class Pet has no member named breed

 

일단 Dog를 Pet 유형의 변수로 옮겼기 때문에,

Dog는 다른 Pet처럼 취급되어야 하고 Dog 특유의 속성을 가져서는 안 된다는 점에서 일리가 있다고 주장할 수 있다.

이것은 활발한 철학적 논쟁을 야기하지만, 프로그래밍을 할 때는 대개 성가신 일에 불과하다.

Tiny라는 이름의 Dog는 여전히 Great Dane이고,

비록 우리가 그것을 어딘가에서 Pet으로 취급했더라도 그 품종을 언급하고 싶다.

다행히도, C++는 우리에게 Dog를 Pet으로 취급할 수 있는 방법을 제공한다.

이를 위해, 우리는 동적 변수에 대한 포인터를 사용한다.

다음 선언을 추가한다고 가정해자:

Pet *ppet;
Dog *pdog;

 

만약 우리가 포인터와 동적 변수를 사용한다면, 우리는 Tiny를 그의 품종을 잃지 않고 Pet으로 대할 수 있다.

다음은 허용된다.

pdog = new Dog;
pdog->name = "Tiny";
pdog->breed = "Great Dane";
ppet = pdog;

 

게다가, 우리는 여전히 ppet가 가리키는 노드의 breed 필드에 접근할 수 있습니다

Dog::print( ) const;

가 다음과 같이 정의되었다고 가정한다:

void Dog::print( ) const
{
    cout << "name:" << name << endl;
    cout << "breed:" << breed << endl;
}

 

ppet->print( );

라는 문구를 사용하면 화면에 다음과 같은 내용이 인쇄된다:

name: Tiny
breed: Great Dane

이렇게 좋은 출력은 print( )가 가상 멤버 함수이기 때문에 발생한다.

우리는 Display 15.7에 테스트 코드를 포함시켰다.

 

디스플레이 15.7 슬라이싱 문제 해결

//Program to illustrate use of a virtual function to defeat the slicing
//problem.
#include <string>
#include <iostream>
using std::string;
using std::cout;
using std::endl;

class Pet
{
public:
	string name;
	virtual void print( ) const;
};

class Dog : public Pet
{
public:
	string breed;
	virtual void print( ) const;
};

int main( )
{
	Dog vdog;
	Pet vpet;
	vdog.name = "Tiny";
	vdog.breed = "Great Dane";
	vpet = vdog;
	cout << "The slicing problem:\n";
	//vpet.breed; is illegal since class Pet has no member named breed.
	vpet.print( );
	cout << "Note that it was print from Pet that was invoked.\n";
	cout << "The slicing problem defeated:\n";    
	Pet *ppet;
	Dog *pdog;
	pdog = new Dog;
	pdog->name = "Tiny";
	pdog->breed = "Great Dane";
	ppet = pdog;
	ppet->print( );
	pdog->print( );
    
	//The following, which accesses member variables directly
	//rather than via virtual functions, would produce an error:
	//cout << "name: " << ppet->name << " breed: "
	// << ppet->breed << endl;
	//It generates an error message saying
	//class Pet has no member named breed.
    
	return 0;
}

void Dog::print( ) const
{
	cout << "name: " << name << endl;
	cout << "breed: " << breed << endl;
}

void Pet::print( ) const
{
	cout << "name: " << name << endl;
}

예제를 간단하게 유지하기 위해 멤버 변수를 공개했다.

실제 응용 프로그램에서는 멤버 함수를 통해 액세스하고 private으로 해야 한다.

 

class Dog : public Pet
{
public:
    string breed;
    virtual void print( ) const;
};

키워드 virtual은 여기서 필요하지 않지만 명확하게 하기 위해 삽입했다.

 

ppet->print( );

pdog->print( );

이 두 가지는 동일한 출력을 인쇄한다:

name: Tiny
breed: Great Dane

 

void Pet::print( ) const
{
    cout << "name: " << name << endl;
}

breed에 대해서는 언급이 없다.

 

샘플 대화 상자

The slicing problem:
name: Tiny
Note that it was print from Pet that was invoked.
The slicing problem defeated:
name: Tiny
breed: Great Dane
name: Tiny
breed: Great Dane

 

동적 변수가 있는 객체 지향 프로그래밍은 프로그래밍을 보는 매우 다른 방식이다.

처음에는 이 모든 것이 당황스러울 수 있다.

두 가지 간단한 규칙을 염두에 둔다면 도움이 될 것이다:
1. 포인터 pAncestor의 도메인 유형이 포인터 pDescendant의 도메인 유형에 대한 상위 클래스인 경우

 다음과 같은 포인터 할당이 허용된다:
pAncestor = pDescendant;
또한 pDescendent가 가리키는 동적 변수의 데이터 멤버 또는 멤버 함수는 손실되지 않는다.
2. 동적 변수의 모든 추가 필드가 있지만 접근하려면 가상 멤버 함수가 필요하다.

 

함정: 슬라이싱 문제

기본 클래스 변수에 파생 클래스 객체를 할당하는 것은 허용 가능하지만

기본 클래스 객체에 파생 클래스 객체를 할당하면 데이터가 조각난다. 

기본 클래스에 속하지 않는 파생 클래스 객체의 데이터 멤버는 할당에서 손실되고

기본 클래스에 정의되지 않은 멤버 함수는 기본 클래스 객체에 대해 마찬가지로 사용할 수 없다.


예시)

Dog가 Pet의 파생 클래스인 경우 다음은 허용 가능하다:
Dog vdog;
Pet vpet;
vpet = vdog;

그러나 vpet은 해당 함수 역시 

Pet의 멤버 함수가 아닌 이상 Dog에서 멤버 함수의 호출 객체가 될 수 없으며, 

클래스 Pet에서 상속되지 않는 vdog의 멤버 변수는 모두 손실된다.

 

이것이 슬라이싱 문제이다.
단순히 멤버 함수를 가상으로 만드는 것만으로는 슬라이싱 문제를 해결할 수 없다. 

디스플레이 15.7의 다음 코드를 참고하자:
Dog vdog;
Pet vpet;
vdog.name = "Tiny";
vdog.breed = "Great Dane";
vpet = vdog;
. . .
vpet.print( );
vdog의 개체는 Dog 유형이지만 vdog가 (Pet 유형의) 변수 vpet에 할당되면 Pet 유형의 개체가 된다.

따라서 vpet.print( )는 Dog에 정의된 버전이 아닌 Pet에 정의된 print( ) 버전을 호출한다.

print( )가 가상임에도 불구하고 발생한다.

슬라이싱 문제를 해결하려면 함수가 가상이어야 하며 포인터와 동적 변수를 사용해야 한다.

 

팁: 소멸자를 가상화

소멸자를 항상 가상으로 만드는 것은 좋은 정책이지만, 

이것이 왜 좋은 정책인지 설명하기 전에 소멸자와 포인터가 어떻게 상호 작용하는지, 

소멸자가 가상이 되는 것이 무엇을 의미하는지에 대해 한두 마디 말해야 한다.


다음 코드를 고려해 보겠다. 

여기서 SomeClass는 가상이 아닌 소멸자가 있는 클래스이다:
SomeClass *p = new SomeClass;
. . .
delete p;
p로 delete를 호출하면 SomeClass 클래스의 소멸자가 자동으로 호출된다.

이제 소멸자를 virtual로 표시했을 때 어떤 일이 일어나는지 알아보겠다.


가상 함수 메커니즘과 어떻게 소멸자들이 상호작용하는지를 기술하는 가장 쉬운 방법은

소멸자들이 마치 모든 소멸자들이 같은 이름을 가진 것처럼 취급되는 것이다.

예를 들어, Derived가 Base 클래스의 파생 클래스이고

Base 클래스의 소멸자가 virtual로 표시되었다고 가정해 보자.

이제 다음 코드를 생각해 보자:
Base *pBase = new Derived;
. . .
delete pBase;
pBase를 사용하여 delete를 호출하면 소멸자가 호출된다.

Base 클래스의 소멸자가 virtual로 표시되고

개체가 Derived 타입이므로 Derived 클래스의 소멸자가 호출된다

(그리고 Base 클래스의 소멸자를 차례로 호출한다).

Base 클래스의 소멸자가 virtual로 선언되지 않았다면 Base 클래스의 소멸자만 호출된다.
염두에 두어야 할 또 다른 점은 소멸자가 가상으로 표시되면

파생 클래스의 모든 소멸자는 자동으로 가상으로 표시된다는 것이다. 

다시 말하지만, 이 동작은 모든 소멸자가 동일한 이름을 가진 것과 같다.

 

이제 우리는 왜 모든 소멸자가 가상이어야 하는지 설명할 준비가 되었다.

기본 클래스에서 소멸자가 가상으로 선언되지 않았을 때 어떤 일이 일어나는지 생각해 보자.

특히 기본 클래스 PFArrayD (double형의 부분적으로 채워진 배열)와

그것의 파생 클래스 PFArrayDBak (double형의 백업을 가진 부분적으로 채워진 배열)을 생각해 보자.

가상 함수에 대해 알기 전인 14장에서 이러한 클래스에 대해 논의했으므로

기본 클래스 PFArrayD의 소멸자는 virtual로 표시되지 않았다.

디스플레이 15.8에서 PFArrayD와 PFArrayDBak 클래스에 대해 필요한 모든 사실을 요약하여 14장으로 되돌아갈 필요가 없다.


다음 코드를 고려해 보자:
PFArrayD *p = new PFArrayDBak;
. . .
delete p;
기본 클래스의 소멸자는 virtual로 표시되지 않으므로 기본 클래스(PFArrayD)에 대한 소멸자만 호출된다.

그러면 멤버 배열 a(PFArrayD에 선언됨)의 메모리가 프리스토어로 반환되지만

멤버 배열 b(PFArrayDBak에 선언됨)의 메모리는 프로그램이 종료될 때까지 프리스토어로 반환되지 않는다.


반면에 (Display 15.8과 달리) 기본 클래스 PFArrayD의 소멸자가 virtual로 표시된 경우 

delete가 p에 적용되면 클래스 PFArrayDBak의 소멸자가 호출된다. 

(객체가 가리키는 것은 PFArrayDBak 유형이므로)

클래스 PFArrayDBak의 소멸자는 배열 b를 삭제한 다음 

기본 클래스 PFArrayD의 소멸자를 자동으로 호출하여 멤버 배열 a를 삭제한다.

따라서 기본 클래스의 생성자를 virtual로 표시하면 모든 메모리가 freestore로 반환된다.

이러한 경우에 대비하기 위해서는 항상 생성자를 가상으로 표시하는 것이 가장 좋다.


다운캐스팅 및 업캐스팅

여러분은 일종의 타입 캐스팅을 통해 슬라이싱 문제를 쉽게 해결할 수 있을 것이라고 생각할 수도 있다.

(타입 캐스팅: 인스턴트의 타입을 확인하거나, 해당 인스턴스를 슈퍼 클래스나 하위 클래스로 취급하는 방법)

하지만 일이 그렇게 간단하지는 않다.

다음은 허용되지 않다:

Pet vpet;
Dog vdog; //Dog is a derived class with base class Pet.
...
vdog = static_cast<Dog>(vpet); //ILLEGAL!

 

그러나 다른 방향으로의 캐스팅은 완벽하게 합법적이며 캐스팅 연산자도 필요하지 않다:

vpet = vdog; //Legal (하지만 슬라이싱 문제가 발생한다.)

 

업캐스팅(upcasting)

업캐스팅: 클래스 계층을 상향 조정하기 때문에 후손 유형에서 조상 유형으로 캐스팅하는 것

업캐스팅은 일부 정보(멤버 변수 및 함수를 무시함)를 무시하는 것이기 때문에 안전하다. 

따라서 다음은 완벽하게 안전하다:

vpet = vdog;

 

다운캐스팅(downcasting)

다운캐스팅: 상위 유형에서 하위 유형으로 캐스팅하는 것

정보가 추가되는 것(멤버 변수 및 함수가 추가됨)을 가정하기 때문에 매우 위험하다.

1장에서 간단히 설명한 dynamic_cast는 다운캐스팅에 사용된다.

슬라이싱 문제를 해결하는 데 일부 유용할 수 있지만 위험할 정도로 신뢰할 수 없고 함정이 있다.

dynamic_cast를 사용하면 다운캐스팅할 수 있지만 다음과 같이 포인터 유형에서만 작동한다:

Pet *ppet;
ppet = new Dog;
Dog *pdog = dynamic_cast<Dog*>(ppet); //Dangerous!

이렇게 간단한 상황에서도 다운캐스팅 실패가 있었기 때문에 권장하지 않다.

dynamic_cast는 실패할 경우 알려주기로 되어 있다.

캐스트가 실패할 경우 dynamic_cast는 NULL(실제로는 정수 0)을 반환해야 한다.

 

다운캐스팅을 시도하려면 다음 사항을 염두에 두어야 한다:
1. 추가할 정보가 실제로 있는지 알 수 있도록 상황을 파악해야 한다.
2. dynamic_cast가 가상 함수 정보를 사용하여 캐스팅을 수행하므로 멤버 함수는 가상이어야 한다.

 

C++가 가상 함수를 구현하는 방법

컴파일러를 사용하기 위해서는 어떤 식으로 작동하는지 알 필요가 없다.

그것이 모든 좋은 프로그램 설계 철학의 기본인 정보 숨기기(information hiding)의 원리이다.

특히 가상 함수를 사용하기 위해서는 가상 함수가 어떻게 구현되는지 알 필요가 없다.

하지만 많은 사람들이 구현의 구체적인 모델이 이해에 도움이 된다는 것을 알게 되고,

다른 책에서 가상 함수에 대해 읽을 때 가상 함수의 구현에 대한 언급을 접하게 될 가능성이 높다.

그래서 우리는 그것들이 어떻게 구현되는지 간략하게 설명할 것이다.

가상 함수를 가진 모든 언어(C++ 포함)의 모든 컴파일러는 기본적으로 동일한 방식으로 가상 함수를 구현한다.

 

디스플레이 15.8 클래스 PFArrayD와 PFArrayDBak의 리뷰

class PFArrayD
{
public:
	PFArrayD( );
	...
	~PFArrayD( );
protected:
	double *a; //for an array of doubles .
	int capacity; //for the size of the array .
	int used; //for the number of array positions currently in use .
};

PFArrayD::PFArrayD( ) : capacity(50), used(0)
{
	a = new double[capacity];
}

PFArrayD::~PFArrayD( )
{
	delete [] a;
}

기본 클래스 PFArrayD에 대한 몇 가지 세부 정보.
PFArrayD에 대한 보다 완벽한 정의는 디스플레이 14.8과 14.9에 나와 있지만 이 디스플레이에는 필요한 모든 세부 정보가 있다.

 

class PFArrayDBak : public PFArrayD
{
public:
	PFArrayDBak( );
	...
	~PFArrayDBak( );
private:
	double *b; //for a backup of main array .
	int usedB; //backup for inherited member variable used .
};

PFArrayDBak::PFArrayDBak( ) : PFArrayD( ), usedB(0)
{
	b = new double[capacity];
}

PFArrayDBak::~PFArrayDBak( )
{
	delete [] b;
}

소멸자는 virtual이어야 하지만 이러한 클래스를 작성할 때는 아직 가상 함수를 다루지 않았다.

 

파생 클래스 PFArrayDBak에 대한 몇 가지 세부 정보.
디스플레이 14.10 및 14.11에 PFArrayDBak에 대한 완전한 정의가 나와 있지만,

이 디스플레이에는 이 장에 필요한 모든 세부 정보가 나와 있다.

 

가상 함수 테이블(virtual function table)

클래스에 가상인 멤버 함수가 하나 이상 있는 경우

컴파일러는 해당 클래스에 대한 가상 함수 테이블이라고 하는 것을 만든다.

가상 함수 테이블: 해당 클래스에 대한 각각의 가상 멤버 함수에 대한 포인터(메모리 주소)가 있다.

포인터는 해당 멤버 함수에 대한 올바른 코드의 위치를 가리킨다.

하나의 가상 함수가 상속되었지만 변경되지 않은 경우

해당 테이블 항목은 상위 클래스(또는 필요한 경우 다른 상위 클래스)에 주어진 함수에 대한 정의를 가리킨다.

다른 가상 함수에 클래스에 새로운 정의가 있는 경우 해당 멤버 함수에 대한 테이블의 포인터는 해당 정의를 가리킨다.

(가상 함수라는 속성이 상속되므로 클래스에 가상 함수 테이블이 있으면

해당 클래스의 모든 하위 클래스에 가상 함수 테이블이 있음을 기억하자.)


하나 이상의 가상 함수를 가진 클래스의 오브젝트가 생성될 때마다

메모리에 저장된 오브젝트의 설명에 다른 포인터가 추가된다.

이 포인터는 클래스의 가상 함수 테이블을 가리킨다.

오브젝트에 대한 포인터(yep)를 사용하여 멤버 함수를 호출하면

런타임 시스템은 가상 함수 테이블을 사용하여 포인터의 종류를 사용하지 않고 멤버 함수의 정의를 결정한다.


물론 이 모든 일은 자동으로 일어나므로 걱정할 필요가 없다.

컴파일러 작성자는 가상 함수가 제대로 작동하는 한 다른 방식으로 자유롭게 구현할 수도 있다.

'프로그래밍 공부 > OOP' 카테고리의 다른 글

17 연계된 자료 구조(1)  (1) 2023.12.01
16장 탬플릿  (1) 2023.11.30
14장 상속  (0) 2023.11.29
13장 재귀  (1) 2023.11.28
12장 스트림 및 파일 입출력  (1) 2023.11.27