프로그래밍 공부
작성일
2024. 2. 27. 17:54
작성자
WDmil
728x90

9.4 다형성을 위한 상속

지금까지 파생 클래스와 그 부모간의 관계에 대해 배웠다. 이제는 가장 강력한 시나리오인 다형성 구현에 상속을 어떻게 이용하는지 알아볼 차례다. 전에 사용했던 다형성의 활용은, 공통의 부모를 가지는 객체를 부모타입의 객체가 필요한 자리에 마음대로 바꿔 넣어 사용한 예 이다.


9.4.1 스프레드 시트 다시보기

7장과 8장에서는 객체지향 프로그래밍을 설명하기 위해 스프레드 시트 예제를 활용했다.

SpreadSheetCell은 데이터의 한 항목을 나타내고, 각 항목의 값은 double또는 string타입 이다. 다음은 SpreadSheetCell클래스의 정의로, 셀의 값을 세팅할 때 double이나 string중 어떤 값이든 이용할 수 있다. 단, 현재 셀의 데이터 값을 읽어올 때는 항상 string 타입 값만 리턴 받을 수 있다.

class SpreadSheetCell
{
public:
	SpreadSheetCell();
	virtual	void set(double inValue);
	virtual	void set(const std::string& inString);
	virtual	std::string& getString() const;
protected:
	static std::string doubleToStyring(double inValue);
	static double stringToDouble(const std::string& inString);
	double		mValue;
	std::string mString;
};

위 클래스는 정체성 혼란을 겪고있다. 데이터 셀이 double값을 대표하기도 하고, string값을 대표하기도 한다. 어떤 경우에는 두 포멧 사이에 변환하기도 한다. 셀이 하나의 데이터만 대표하더라도 이러한 이중성을 구현하기 위해 클래스에서는 두 종류의 데이터 값을 모두 저장하고 있다.

 

그런데 만약 날짜나 수식 같은 새로운 데이터 타입이 추가되면 어떻게 될까? SpreadSheetCell클래스는 각 데이터 타입을 저장하는 것은 물론 각 데이터 타입 간 변환까지 지원해야 해서 과도하게 커질 수 있다.


9.4.2 다형성에 기반을 둔 SpreadSheetCell디자인

SpreadSheetCell은 계층적인 속성을 클래스 하나에 몰아넣었기 떄문에 비대해졌다. 이에 대한 대안 중 하나는 SpreadSheetCell의 범위를 좁혀서 string만 취급되도록 하는 것이다. 클래스 이름도 이에 맞추어 StringSpreadSheetCell로 바꾼다. double타입 데이터를 위해서는 DOubleSpreadSheetCell 클래스를 새로 만들면서 StringSPreadSheetCell을 상속받도록 하여, string타입까지 같이 처리할 수 있게 한다. 다음 그림은 이러한 접근방법을 표현하고 있다.

이 접근 방법은 클래스 상속을 통한 코드 재활용에 중점을 둔 것으로 DoubleSpraedSHeetCell이 StringSpreadSHeetCell을 상속받는 이유는 StringSpreadSheetCell에 내장된 일부 기능을 이용하기 위해서다.

 

그런데 위와같은 디자인을 구현하다보면, 파생 클래스가 베이스 클래스가 가진 대부분의 메서드를 오버라이딩하게 된다는 사실을 발견하게 된다. 이렇게 되면 기존의 double 과 string이 가졌던 관계와 멀어지게 된다. 물론 셀 하나가 double과 string타입 데이터를 모두 가진다는 점은 그대로 남는다.

 

이렇게 double타입 셀이 string타입 셀과 다소 이상한 is-a관계를 가지는 대신, 다음과 같이 두 셀이 공통의 부모를 가지는 방식으로 디자인을 개선할 수 있다.

위 그림은 SPreadSheetCell이 다형성을 갖게 한 디자인이다. DoubleSpreadSheetCell과 StringSPreadSHeet 모두 공통의 부모인 SpreadSheetCell을 상속받기 떄문에 코드의 관점에 따라 두 셀은 자유롭게 대체되어 사용될 수 있다. 실질적인 의미를 따져보면 다음과 같다.

  • 두 파생 클래스는 베이스 클래스에 정의된 공통의 인터페이스 를 가진다.
  • SpreadSheetCell을 이용하는 코드는 실제 객체가 DoubleSpreadSheetCell인지 StringSpreadSheetCell인지 전혀 몰라도 인터페이스를 이용하는데 아무런 문제가 없다.
  • virtual 메커니즘에 의해 각 인스턴스의 인터페이스가 제공하는 모든 메서드는 실제 객체의 클래스 타입에 따른 메서드가 자동으로 호출된다.
  • 전에 설명한 SpreadSheet같은 다른 데이터 구조에서는 부모 클래스 타입의 셀을 참조하여 여러 계층의 셀 타입을 한꺼번에 보관할 수 있다.

9.4.3 SpreadSheetCell 베이스 클래스

스프레드시트의 모든 셀이 SpreadSheetCell을 베이스 클래스로 두고 있기 때문에 그 클래스를 먼저 정의하는 것이 바람직하다. 베이스 클래스를 디자인할 떄는 파생 클래스가 서로 어떤 관계를 가질지 고려해야 한다. 이러한 정보로부터 공통적인 부분을 판별하여 부모 클래스에 내장시킬 수 있다. 예를 들어 String셀과 double셀은 모두 데이터 저장을 위한 변수를 가진다.

 

그런데 데이터는 기본적으로 사용자로부터 입력받거나 사용자에게 출력해야 하기 떄문에 string으로 값을 세팅하고 string으로 값을 얻어내는 기능이 필요하다. 이러한 동작은 공유되는 기능으로서 베이스 클래스의 작성에 포함된다.


9.4.3.1 첫 번째 시도

베이스 클래스인 SpreadSheetCell은 모든 스프레드셀이 가져야 하는 공통적인 동작을 정의해야 한다. 이 예제에서는 모든 셀이 string으로부터 값을 세팅할 수 있어야 한다. 모든 셀은 또한 현재 가진 값을 string으로 리턴할 수 있어야 한다. 베이스 클래스는 이러한 메서드들을 선언한다. 단, 데이터 멤버는 선언하지 않는다. 데이터 멤버를 선언하지 않는 이유는 후에 서술한다.

 

class SpreadSheetCell
{
public:
	SpreadSheetCell();
	virtual ~SpreadSheetCell();
	virtual void set(const std::string& inString);
	virtual std::string getString() const;
};

이 클래스 구현을 위해 .cpp파일을 작성하기 시작하면 바로 문제에 봉착하게 된다. 베이스 클래스는 double이든 string이든 데이터 멤버를 가지고 있지 않는데, 어떻게 구현할 수 있을까? 이 문제를 더 일반적으로 표현하면, 파생 클래스에 의해 지원되는 행동을 실제 행동의 구현 없이 어떻게 베이스 클래스에서 선언할 수 있을까?

 

한 가지 가능한 접근 방법은 그러한 행동에 대해 아무것도 구현하지 않는 것이다. 예를 들어 SpreadSheetCell의 set()메서드는 베이스 클래스에서 세팅할 것이 없으므로 아무런 부가 효과가 없다. 그런데 이러한 접근방법은 뭔가 좀 이상하다.

 

하지만 이상적으로 볼 때 베이스 클레스 타입의 인스턴스는 존재해서는 안 된다. set()메서드의 호출은 DoubleSpreadSheetCell이나, StringSpreadSheetCell에 대해서만 발생해야 한다. 이러한 제약 조건을 강제하는 것은 좋은 방법이다.

 

위 코드에서는SpreadSheetCell클래스의 소멸자를 virtual로 선언하고 있다. 그렇게 하지 않으면 컴파일러가 virtual이 아닌 다른 디폴트 소멸자를 생성하게 된다. 소멸자가 virtual로 선언되지 않으면 파생 클래스를 부모 타입의 포인터나 참조로 사용하면서 삭제할 때 이 장의 앞부분에서 설명했던 잘못된 소멸 순서 문제가 발생한다.


9.4.3.2 퓨어 버추얼 메서드와 추상 베이스 클래스

퓨어 버추얼 메서드(Pure virtual Method[순수 가상 메서드]) 는 클래스를 정의할 떄 선언만 하고 구현부 정의는 하지 않는 메서드를 말한다. 메서드를 퓨어 버추얼로 선언하면 컴파일러가 그 메서드가 선언된 클래스에서 메서드 구현부를 찾지 않는다. ( 인터페이스 방식의 구조를 이야기하는듯 )

 

이 때문에 퓨어 버추얼 함수를 가진 클래스는 인스턴스화 할 수 없으므로 추상 클래스라고 부른다. 클래스가 퓨어 버추얼 메서드를 하나라도 가지고 있으면 컴파일러에 의해 해당 클래스의 객체 생성이 금지된다.

 

퓨어 버추얼 메서드로 선언하기 위해서는 메서드 선언 뒤에 =0을 붙이는 특별한 문법을 사용해야 한다. 그렇게 하면 메서드 정의를 구현할 필요가 없다.

class SpreadSheetCell
{
public:
	SpreadSheetCell();
	virtual ~SpreadSheetCell();
	virtual void set(const std::string& inString) = 0;
	virtual std::string getString() const = 0;
};

이제 SpreadSheetCell클래스는 추상 클래스가 되었기 떄문에 SpreadSheetCell 객체를 직접 만드는 것은 불가능하다. 즉, 다음 코드는 컴파일 에러가 발생한다.

SpreadSheetCell cell; // 에러! 추상 클래스의 인스턴스를 생성하려 했다.

이때 에러 메세지는 보통 '클래스의 메서드 중 하나 이상이 버추얼 함수이기 때문에 객체를 선언할 수 없다' 라고 출력된다.

 

하지만 다음의 코드는 컴파일된다.

SpreadSheetCell* ptr = nullptr;

위 코드는 다음처럼 나중에 파생 클래스를 인스턴스화하면서 초기화되기 때문에 아무런 문제가 없다.

ptr = new StringSpreadSheetCell();

9.4.3.3 베이스 클래스의 소스 코드

SpreadSheetCell 클래스는 추상 클래스이기 때문에 대부분의 메서드가 퓨어 버추얼이라 ,cpp에 들어갈 소스코드가 많지 않다. 코드가 필요한 부분은 생성자와 소멸자 뿐이다. 그런데 그 조차도 미래에 있을 수 있는 초기화 작업을 위해 껍데기를 만들어 놓는 수준에 그칠 수 있다.

 

소멸자의 경우도 앞서 설명했던 소멸 순서 문제 떄문에 virtual화 하는 것 말고는 딱히 할 일이 없다.

SpreadShsetCell::SpreadSheetCell() {}
SpreadSheetCell::~SpreadSheetCell() {}

9.4.4 각 파생 클래스의 구현

StringSpreadSheetCell과 DoubleSpreadSheetCell클래스 작성의 대부분은 부모 클래스에 정의된 기능을 구현하는 것이다. 사용자가 double타입 ,string 타입 셀 객체를 만들 수 있어야 하므로 이들 클래스에 퓨어 버추얼 메서드를 남겨둘 수는 없다. 이들 클래스에서는 부모 클래스에서 정의한 퓨어 버추얼 메서드를 빠짐없이 모두 구현해야 한다.

 

만약 파생 클래스에서 베이스 클래스의 모든 퓨어 버추얼 메서드를 빠짐없이 구현하지 않는다면, 그 파생 클래스도 여전히 추상클래스가 되어버린다. 그러한 파생 클래스는 사용자가 인스턴스화 할 수 없다.


9.4.4.1 StringSPreadSheetCell 클래스의 정의

StringSpreadSheetCell클래스의 정의에서 가장 먼저 할 일은 SpreadSheetCell을 상속받는 파생 클래스로 정의하는 것이다.

class StringSpreadSheetCell : pulbic SpreadSheetCell
{

StringSpreadSheetCell이 초기화될 떄의 데이터 값으로 초깃값이 없다는 의미의 #NOVALUE 문자열이 세팅되도록 하자. 컴파일러가 자동으로 생성하는 디폴트 생성자는 string타입 데이터 멤버 mValue를 공백 문자 "" 로 초기화하기 떄문에 의도에 맞지 않다. 이 때문에 직접 디폴트 생성자를 선언해야 한다.

public:
	StringSPreadSheetCell();

다음으로 오버라이딩할 부모 클래스의 퓨어 버추얼 메서드를 선언한다. 이때는 구현부를 정의할 것이므로 =0을 붙이지 않는다.

	virtual void set(const std::string& inSltring) override;
	virtual std::string getString() const override;

마지막으로 셀의 값을 저장할 string타입의 데이터 멤버 mValue를 private로 선언한다.

private:
	std::string mValue;
};

9.4.4.2 StringSpreadSheetCell 클래스의 구현

StringSpreadSheetCell의 .cpp파일은 베이스 클래스의 것보다 할 일이 많다. 생성자에서는 mValue를 세팅된 적이 없다는 의미로 #NOVALUE로 초기화한다.

StringSpreadSheetCell::StringSpreadSheetCell()
	: mValue("#NOVALUE")
{
}

set()메서드는 입력 타입도 string이고 내부 데이터 타입도 string이기 때문에 쉽게 구현할 수 있다. getString()메서드도 리턴 타입과 내부 데이터 타입이 동일하기 때문에 역시 간단하게 구현된다.

void StringSpreadSheetCell::set(const std::string& inString)
{
	mValue = inString;
}

std::string StringSpreadSheetCell::getString() const
{
	return mValue;
}

9.4.4.3 DoubleSpreadSheetCell 클래스의 정의와 구현

double 타입 셀 클래스도 로직만 다를 뿐 비슷한 패턴으로 구현된다. string타입 인자를 갖는 set()메서드 외에 double타입 인자를 갖는 set()메서드가 새로 추가된다. 그리고 string과 double데이터 타입 간 변환을 위해 두 개의 private메서드도 새롭게 필요하다.

 

StringSPreadSHeetCell에서 string타입이었던 mValue를 이번에는 double로 선언한다. 이렇게 데이터 멤버 이름을 같게 하더라도, StringSPreadSheetCell과 DoubleSpreadSheetCell은 형제 관계임으로 이름이 가려지거나 충돌하지 않는다.

#pragma once
#include "SpreadSheet.h"

class DoubleSpreadSheetCell : public SpreadSheetCell
{
public:
	DoubleSpreadSheetCell();
	virtual void set(double inDouble);
	virtual void set(const std::string& inString) override;
	virtual std::string getString() const override;

private:
	static std::string doubleToString(double inValue);
	static double strignTodouble(const std::string& inValue);
	double mValue;
};

DoubleSpreadSheetCell의 생성자를 다음과 같이 구현한다.

DoubleSpreadSheetCell::DoubleSpreadSheetCell()
	: mValue(std::numeric_limits<double>::quiet_NaN())
{
}

mValue의 초깃값으로 사용된 std::numeric_limits<double>::quite_NaN()에서 NaN은 'Not a Number'의 약자로, 숫자가 아니라는 뜻이다. std::numeric_limits<>를 사용하려면 #include <limits>가 필요하다.

 

double타입 인자를 갖는 set()메서드는 인자를 그대로 멤버 변수에 대입하는 것으로 구현이 완료된다. string 타입 인자를 갖는 set()메서드는 값 변환을 위해 private static메서드인 stringToDouble()의 호출이 추가되는 것이 다르다. getString()메서드는 저장된 double값을 string타입 값으로 변환한다.

 

void DoubleSpreadSheetCell::set(double inDouble)
{
	mValue = inDouble;
}

void DoubleSpreadSheetCell::set(const std::string& inString)
{
	mValue = stringTodouble(inString);
}

std::string DoubleSpreadSheetCell::getString() const
{
	return doubleToString(mValue);
}

스프레드 시트 셀의 클래스 계층 디자인을 바꾸고 나니 코드 구현이 훨씬 간단해졌다. 이제는 동시에 두 개의 데이터 멤버를 관리하느라 성가신 작업을 하지 않아도 된다. 각 객체는 자기만의 데이터 타입과 기능에 집중하면 된다.

 

doubleToString()과 stringToDouble()메서드의 구현은 7장에서와 동일함으로 이 예제에서는 생략했다.


9.4.5 디형성 활용의 극대화

이제 SpreadSheetCell은 다형성을 가지게 되었다. 사용자는 다형성에 기반해서 여러 가지 이점을 얻을 수 있다. 다음 테스트 프로그램을 통해 어떤 이점이 있는지 살펴보자.

int main()
{

다형성을 시연하기 위해 이 테스트 프로그램에서는 SpreadSheetCell의 포인터 3개를 담을 수 있는 vector를 선언하고 있다. SpreadSheetCell이 추상 클래스기 때문에 이 타입으로는 객체를 생성할 수 없다. 하지만 SpreadSheetCell 타입의 포인터나 참조형 변수는 사용할 수 있다. 포인터나 참조형 변수는 파생 클래스에서 생성된 객체를 가리킬 수 있기 때문이다.

 

이 vector의 항목은 부모 클래스 타입 포인터이기 때문에, 파생 클래스의 셀 종류와 관계없이 혼합하여 저장할 수 있다.

 

즉, 각 배열의 항목은 StringSpreadSheetCell 객체를 가리킬 수도 있고 DoubleSpreadSheetCell객체를 가리킬 수도 있다.

vector<unique_ptr<SpreadSheetrCell>> cellArray;

배열의 첫 번째 항목은 StringSpreadSheetCell을 new하여 만들어진 객체를 가리킨다. 두 번째 와 세 번째 항목은 각각 StringSpreadSheetCell과 DoubleSpreadSheetCell로 만들어진 객체를 가리킨다.

cellArray.push_back(make_unique<StringSpreadSheetCell>());
cellArray.push_back(make_unique<StringSpreadSheetCell>());
cellArray.push_back(make_unique<DoubleSpreadSheetCell>());

이제 배열이 복수 타입의 데이터를 저장하고 있지만, 베이스 클래스에 정의된 메서드라면 데이터 타입과 관계없이 얼마든지 사용할 수 있다. 이 코드에서는 SpreadSheetCell탕비의 포인터를 이용하기 때문에 컴파일러는 실제 객체가 어느 타입인지 전혀 알지 못한다. 하지만 이들 객체는 SpreadSheetCell의 파생 클래스이기 떄문에 SpreadSheetCell에 정의된 메서드를 반드시 지원해야 한다.

cellArray[0]->set("hello");
cellArray[0]->set("10");
cellArray[0]->set("18");

getString()메서드가 호출되면 각 셀 객체에서 스스로에 설정된 값을 적절한 문자열 표현으로 리턴한다. 여기서 특기할 부분은 서로 다른 객체에서 서로 다른 방법으로 실행된다는 것이다. StringSpreadSheetCell 객체에서는 단순히 저장된 멤버 값을 리턴하고, DoubleSpreadSheetCell 객체에서는 double를 string으로 변환하여 리턴한다.

 

사용자로서는 각 객체가 어떻게 동작하는지 알 필요가 없다. 단지 SpreadSheetCell 종류라면 이러한 행동을 할 수 있다 는 것만 알고 있으면 된다.

	cout << "Vectorvalues ar [" << cellArray[0]->getString() <<"," <<
    							cellArray[1]->getString() << "," <<
                                cellArray[2]->getString() << "]" <<
                                endl;
	return 0;
}

9.4.6 미래에 대한 고려

SpreadSheetCell의 새로운 계층 디자인은 객체지향 디자인 관점에서도 분명 개선되었다. 하지만 실제 스프레드 시트 프로그램에 적용하기에는 아직도 부족한 부분이 있다.

 

먼저 디자인이 개선되었지만 원래 디자인에 있던 셀 간 변환 기능이 빠졌다. 셀을 두 클래스로 나눔으로써 둘의 통합성이 떨어졌다. double타입 셀과 string타입 셀을 상호 변환할 수 있게 하려면 각각의 셀 타입을 인자로 가지는 생성자를 추가해야 한다.

 

이 생성자는 복제 생성자와 비슷해보이지만, 같은 클래스의 참조 인자 대신 형제 클래스의 참조 인자를 받는다.

#pragma once
#include "SpreadSheet.h"

class DoubleSpreadSheetCell;
class StringSpreadSheetCell : public SpreadSheetCell
{
public:
	StringSpreadSheetCell();
	StringSpreadSheetCell(const DoubleSpreadSheetCell& inDoubleCell);

이러한 생성자를 이용하면 DoubleSpreadSheetCell 객체로부터 StringSpreadSheetCell객체를 쉽게 생성할 수 있다. 이것을 캐스팅과 혼동하면 안된다. 어느 한 형제 클래스에서 다른 형제 클래스로의 캐스팅은 캐스팅 오퍼레이터를 오버로딩하여 구현하지 않는 한 작동하지 않는다.

 

부모 방향으로 업 케스팅하는 것은 언제든지 유효하다. 그리고 드물겠지만 자식 방향으로 다운 캐스팅 할 수도 있다. case연산자 또는 reinterpret_cast<>를 이용하여

클래스 계층을 위 아래로 움직이면서 동작방식을 변경할 수는 있지만 권장되지는 않는다.

 

여러 셀 타입에 대한 연산자 오버로딩은 어떻게 구현할 수 있을까? 한 가지 방법은 셀 타입의 가능한 모든 조합 케이스마다 오퍼레이터를 오버로딩 하는 것이다. 아직은 파생 클래스가 두 개 뿐이므로 조합의 경우의 수가 감당할만하다. operator+에 대해서 double + double케이스, double + string 케이스, string + double 케이스, string + string 케이스 등 총 네경우만 구현하면 된다. 또 다른 방법은 공통 표현 방식을 도입하는 것이다.

 

앞서 설명한 예에서는 이미 string을 일종의 공통 표현 도구로 활용하고 있다. 따라서 string간 덧셈에 대해서만 operator+를 오버로딩하면 모든 종류의 셀 간 덧셈을 처리할 수 있다. 이러한 방법에 따라 operator+를 구현하면 다음과 같이 된다.

	StringSpreadSheetCell operator+(const StringSpreadSheetCell& lhs,
		const StringSpreadSheetCell& rhs)
	{
		StringSpreadSheetCell newCell;
		newCell.set(lhs.getString() + rhs.getString());
		return newCell;
	}

컴파일러가 특정 셀을 StringSpreadSheetCell로 변환할 수만 있으면 위와 같은 연산자 오버로딩이 제 역할을 할 것이다. 이전 예제에서 StringSpreadSheetCell의 생성자 중에 DoubleSpreadSheetCell을 인자로 받는 것이 정의되었기 때문에 컴파일러가 자동으로 변환할 수 있는 통로가 이미 준비되었다.

 

컴파일러 입장에서 볼 때 operator+가 작동하도록 하기 위해 다른 방안이 없다면 이 생성자를 이용해서 임시 객체를 생성해서 셀 간 덧셈이 작동하도록 해준다. 즉, operator+가 string타입 셀을 대상으로 작성되었지만, 다음과 같은 코드도 정상적으로 컴파일되고 작동한다.

DoubleSpreadSheetCell myDbl;
myDbl.set(8.4);
StringSpreadSheetCell result = myDbl + myDbl;

위 코드의 결과는 숫자의 합이 아니라 문자열의 연결이 된다. double 타입 셀을 string타입 셀로 변환한 다음 두 string을 더하기 때문에 result 셀은 8.48.4를 값으로 가지게 된다.

728x90