본문 바로가기
Object Oriented Programming(C++)/전문가를 위한 C++

전문가를 위한 C++ | Chapter 10 상속 활용하기(중)

by continue96 2022. 2. 7.

10.3 부모를 공경하라

10.3.1 부모 클래스의 생성자

 C++는 객체 생성 과정을 다음과 같이 정의하고 있다. 이 규칙은 재귀적으로 적용되어 클래스에 부모 클래스가 있다면 현재 클래스보다 부모 클래스를 먼저 초기화한다.

  1. 부모 클래스라면 디폴트 생성자를 실행하거나 생성자 이니셜라이저를 호출한다.
  2. non-static 데이터 멤버를 코드에 나타난 순서대로 생성한다.
  3. 클래스 생성자의 본문을 실행한다.

 부모 클래스에 디폴트 생성자가 있으면 자동으로 호출해주고 디폴트 생성자가 없거나 있더라도 다른 생성자를 사용할 때는 생성자 이니셜라이저로 생성자를 체인으로 엮을 수 있다.

CAUTION 자식 클래스에서 부모 클래스의 virtual 메서드를 오버라이드하고 그 메서드를 부모 클래스 생성자에서 호출하면 안 된다. 자식 클래스에서 오버라이드한 버전이 아닌 부모 클래스에 구현된 virtual 메서드가 호출되기 때문이다.

 

10.3.2 부모 클래스의 소멸자

 소멸자의 호출 과정은 다음과 같이 생성자의 반대다. 이 규칙은 생성자와 마찬가지로 재귀적으로 적용되어 상속 체인의 가장 하위 멤버를 먼저 삭제한다.

  1. 현재 클래스의 소멸자를 호출한다.
  2. 현재 클래스의 데이터 멤버를 생성할 때와 반대되는 순서로 삭제한다.
  3. 부모 클래스가 있다면 부모 클래스의 소멸자를 호출한다.

 여기서 소멸자를 virtual로 선언하지 않고 자식 클래스 객체를 가리키는 부모 클래스 타입 포인터에 대해 delete를 호출하면 Parent 소멸자만 호출되고 Child 소멸자가 호출되지 않는다. 이런 문제를 만들지 않도록 항상 모든 소멸자를 virtual로 선언하는 것이 바람직하다.

CAUTION 소멸자 앞에는 항상 virtual 키워드를 붙인다. 최소한 부모 클래스만이라도 virtual 소멸자를 정의하거나 명시적으로 default로 지정한다.
CAUTION 자식 클래스에서 부모 클래스의 virtual 메서드를 오버라이드하고 그 메서드를 부모 클래스 소멸자에서 호출하면 안 된다. 생성자와 마찬가지로 자식 클래스에 구현한 버전이 아닌 부모 클래스에 구현한 virtual 메서드가 호출되기 때문이다.

 

10.3.3 부모 클래스 참조하기

 파생 클래스에서 메서드를 오버라이드해도 그 메서드의 부모 버전은 그대로 남아 있기 때문에 얼마든지 실행할 수 있다. 예를 들어, 오버라이드한 메서드에서 부모 클래스의 구현 코드를 그대로 유지한 채 다른 작업을 추가할 수 있다.

class WeatherPrediction {
public:
	virtual std::string getTemperature() const;
};

class MyWeatherPrediction : public WeatherPrediction {
public:
	virtual std::string getTemperature() const override;
};

 WeatherPrediction 클래스의 getTemperature 메서드를 MyWeatherPrediction 클래스에서 오버라이드하고 결괏값에 화씨 기호를 추가해보자. C++는 이름을 처리할 때 로컬 스코프부터 살펴보고 클래스 스코프를 검색한다. 따라서 제대로 작성하려면 다음과 같이 스코프 지정 연산자를 추가한다.

string MyWeatherPrediction::getTemperature() const {
	return WeatherPrediction::getTemperature() + "\u00B0F";
	혹은
	return __super::getTemperature() + "\u00B0F";
}

 

10.3.4 업캐스팅과 다운캐스팅

 자식 클래스 타입의 객체를 부모 클래스 타입으로 캐스팅하거나 대입하면 자식 클래스의 특성이 사라지는 슬라이싱(slicing)이 발생한다. 하지만 자식 클래스 타입의 객체를 부모 클래스 타입의 포인터나 레퍼런스에 대입할 때는 슬라이싱이 발생하지 않는다. 이렇게 부모 클래스 타입으로 자식 클래스를 참조하는 것을 업캐스팅(upcasting)이라고 한다.

Parent myParent = myChild; /* 슬라이싱이 발생합니다. */
Parent& myParent = myChild; /* 슬라이싱이 발생하지 않는다. */

 반면, 부모 클래스를 자식 클래스로 캐스팅하는 것을 다운캐스팅(downcasting)이라 부르는데, 다운캐스팅이 있다는 것은 디자인이 잘못되었다는 것을 의미한다. 간혹 다운캐스팅이 필요할 때는 완벽히 통제할 수 있는 상황에서 dynamic_cast를 호출한다.

CAUTION 다운캐스팅은 꼭 필요할 때만 dynamic_cast를 사용하여 구현한다.

 

10.4 다형성을 위한 상속

10.4.1 스프레드시트 예제

 8장과 9장에서 만든 SpreadsheetCell 클래스는 데이터를 구성하는 원소를 표현한다. 여기서는 셀을 내부적으로 항상 double로 저장하고 셀의 값을 리턴할 때는 string으로 표현한다. 실제로 쓸만한 스프레드시트 애플리케이션을 만들려면 셀에 다양한 종류의 값을 저장할 수 있어야 한다.

 

10.4.2 다형성을 지원하는 스프레드시트 셀

 다음 그림은 SpreadsheetCell이라는 공통의 부모 클래스를 가진 동기 관계로 계층 구조를 만들어 다형성을 활용하도록 디자인한 것이다. 이 디자인의 특성을 구체적으로 살펴보면 다음과 같다.

그림 10-2 다형성을 지원하는 SpreadsheetCell

  • 두 자식 클래스 모두 부모 클래스에서 정의한 인터페이스를 똑같이 제공한다.
  • SpreadsheetCell 객체를 사용하는 코드는 현재 셀 객체의 타입을 신경 쓸 필요 없이 SpreadsheetCell에 정의된 인터페이스를 마음껏 호출할 수 있다.
  • virtual 메커니즘을 통해 인터페이스의 메서드 중에서 가장 적합한 버전을 호출해준다.
  • 부모 클래스 타입을 참조하는 방식으로 여러 타입으로 된 셀을 하나로 묶을 수 있다.

 

10.4.3 SpreadsheetCell 베이스 클래스

 10.4.3.1 순수 가상 메서드와 추상 베이스 클래스

 순수 가상 메서드(pure virtual method)란 클래스 정의 코드에서 명시적으로 정의하지 않은 메서드이다. 순수 가상 메서드가 정의된 클래스를 추상 클래스(abstract class)라 부르고 추상 클래스에 대해서는 인스턴스를 만들 수 없다.

 순수 가상 메서드를 지정하려면 메서드 선언 뒤에 '= 0'을 붙인다. 그리고 구현 코드는 작성하지 않는다.

class SpreadsheetCell {
public:
	virtual ~SpreadsheetCell() = default;
	virtual void set(std::string_view inString) = 0; /* 순수 가상 메서드 */
	virtual std::string getString() const = 0; /* 순수 가상 메서드 */
};

 이렇게 하면 부모 클래스인 SpreadsheetCell은 추상 클래스가 되기 때문에 SpreadsheetCell 객체를 생성할 수 없다.

 

10.4.4 SpreadsheetCell 파생 클래스

 StringSpreadsheetCell과 DoubleSpreadsheetCell 클래스를 정의할 때는 부모 클래스에 정의된 기능을 그대로 구현하면 된다. 이때 부모 클래스로부터 받은 순수 가상 메서드를 하나도 빠짐없이 구현해야 한다.

 

 10.4.4.1 StringSpreadsheetCell 클래스 정의와 구현

 StringSpreadsheetCell 클래스를 정의할 때는 가장 먼저 SpreadsheetCell 클래스를 상속한다. 그러고 나서 상속한 순수 가상 메서드를 오버라이드한다. 이번에는 이 메서드를 '=0'으로 지정하지 않는다.

class StringSpreadsheetCell : public SpreadsheetCell {
public:
	virtual void set(std::string_view inString) override;
	virtual std::string getString() const override;
private:
	std::optional<std::string> mValue;
};

 getString 메서드를 구현할 때는 mValue에 실제값이 없다면 공백 스트링을 반환하게 만든다. 이 동작은 std::optional의 value_or 메서드로 mValue.value_or("")과 같이 작성하면 쉽게 구현할 수 있다.

void StringSpreadsheetCell::set(string_view inString) {
	mValue = inString;
}

string StringSpreadsheetCell::getString() const {
	return mValue.value_or("");
}

 

 10.4.4.2 DoubleSpreadsheetCell 클래스 정의와 구현

 부모 클래스에서 정의된 버전에 double 타입의 인수를 받는 set 메서드를 추가한다. 그러고 나서 string과 double 타입의 값을 상호 변환하기 위해 doubleToString 메서드와 stringToDouble 메서드를 추가한다. 이 메서드는 private static으로 선언한다.

class DoubleSpreadsheetCell : public SpreadsheetCell {
public:
	virtual void set(double inDouble);
	virtual void set(std::string_view inString) override;
	virtual std::string getString() const override;
private:
	static std::string doubleToString(double inValue);
	static double stringToDouble(std::string_view inValue);
	std::optional<double> mValue;
};

 getString 메서드는 double 타입으로 저장된 값을 string으로 반환하거나 호출 시점에 저장된 값이 없으면 공백 스트링을 반환한다. 이때 std::optional에서 제공하는 has_value 메서드로 실제값이 있는지 알아내고, 값이 있으면 value 메서드로 그 값을 가져온다.

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

void DoubleSpreadsheetCell::set(string_view inString) {
	mValue = stringToDouble(inString);
}

string DoubleSpreadsheetCell::getString() const {
	return (mValue.has_value() ? doubleToString(mValue.value()) : "");
}

 

10.4.5 다형성 활용하기

 지금까지 다형성을 지원하도록 SpreadsheetCell 클래스를 계층화했다. 이렇게 하면 사용자 코드는 다형성의 장점을 충분히 활용할 수 있다.

 먼저 SpreadsheetCell 포인터 타입 원소 세 개를 담을 수 있는 vector를 선언한다. vector의 첫 번째 원소와 두 번째 원소는 StringSpreadsheetCell 객체로 설정하고, 세 번째 원소는 DoubleSpreadsheetCell 객체로 설정한다.

vector<unique_ptr<SpreadsheetCell>> cellArray;
cellArray.push_back(make_unique<StringSpreadsheetCell>());
cellArray.push_back(make_unique<StringSpreadsheetCell>());
cellArray.push_back(make_unique<DoubleSpreadsheetCell>());

 이렇게 하면 부모 클래스에 선언된 메서드라면 어떤 것으로도 이 vector의 원소에 대해 호출할 수 있다. getString 메서드를 호출하면 객체마다 나름대로의 타입으로 저장된 값을 적절히 string으로 변환해 반환한다.

cellArray[0]->set("ten");
cellArray[1]->set("20");
cellArray[2]->set("30");
cout << cellArray[0]->getString() << endl;
cout << cellArray[1]->getString() << endl;
cout << cellArray[2]->getString() << endl;

 객체마다 string으로 변환하는 방식은 서로 다르지만, 구체적으로 어떻게 처리하는지 몰라도 된다. 그저 모든 객체의 타입이 SpreadsheetCell 타입이기 때문에 SpreadsheetCell에 정의된 동작을 수행할 수 있다는 사실만 알면 된다.

 

10.4.6 나중을 대비하기

 아직 실전에서 사용하기에는 부족한 기능이 있다. 몇 가지를 소개하면 다음과 같다.

 

 10.4.6.1 셀 타입 상호 변환

 첫째, 셀 타입끼리 상호 변환하는 기능을 넣어야 한다. DoubleSpreadsheetCell을 StringSpreadsheetCell 타입으로 변환하는 기능은 변환 생성자(converting constructor)를 추가하는 방식으로 구현할 수 있다. 이는 형제 클래스 객체에 대한 레퍼런스를 인수로 받는다.

class StringSpreadsheetCell : public SpreadsheetCell {
public:
	StringSpreadsheetCell() = default; /* 명시적 디폴트 생성자 */
	StringSpreadsheetCell(const DoubleSpreadsheetCell& inDoubleCell);/* 변환 생성자 */
};

StringSpreadsheetCell::StringSpreadsheetCell(const DoubleSpreadsheetCell& inDoubleCell) {
	mValue = inDoubleCell.getString();
}

 

 10.4.6.2 연산자 오버로딩

 둘째, 셀마다 연산자를 오버로드해야 한다. 구현 방식은 공통 표현 방식을 정해두는 것이다. 앞에서 본 예제는 string 타입을 공통 표현 방식으로 사용하고 있다. 이렇게 공통 표현에 대해 정의한 operator+ 연산자로 모든 조합의 연산을 구할 수 있다. 예를 들면 다음과 같다.

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

DoubleSpreadsheetCell myDouble;
myDouble = 8.5;
StringSpreadsheetCell myResult = myDouble + myDouble; /*8.5000008.500000 */

 

10.5 다중 상속

 이 절에서는 C++의 다중 상속 메커니즘을 자세히 소개한다.

 

10.5.1 여러 클래스 상속하기

 다중 상속은 클래스 이름 옆에 상속할 부모 클래스를 나열하여 정의한다.

class Widget : public Foo, public Bar { ... };

 이렇게 Widget이 여러 클래스를 동시에 상속하면 다음과 같은 속성을 갖게 된다.

  • Widget 객체는 Foo와 Bar에 있는 데이터 멤버와 메서드를 가진다.
  • Widget 객체를 Foo나 Bar로 업캐스팅할 수 있다.
  • Widget 객체를 생성하면 Foo와 Bar의 디폴트 생성자가 호출된다. 이때 클래스 정의에 나열된 순서대로 생성자가 호출된다.
  • Widget 객체를 삭제하면 Foo와 Bar이 소멸자가 호출된다. 이때 클래스 정의에 나열된 반대 순서대로 소멸자가 호출된다.

 다음 코드에 나온 DogBird 클래스는 Dog 클래스와 Bird 클래스를 동시에 상속한다. 이 예제에서 DogBird 객체는 Dog와 Bird에 있는 public 메서드를 모두 제공한다.

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

class Bird {
public:
	virtual void chirp() { cout << "짹짹!" << endl; }
};

class DogBird : public Dog, public Bird { };

DogBird myAnimal;
myAnimal.bark(); /* 멍멍! */
myAnimal.chirp(); /* 짹짹! */

그림 10-3 여러 클래스 상속하기

 

10.5.2 모호한 메서드와 모호한 부모 클래스

 10.5.2.1 모호한 메서드

 Dog 클래스와 Bird 클래스 모두 eat 메서드를 가지고 있다고 하자. 사용자 코드에서 DogBird의 eat 메서드를 호출하면 컴파일러는 eat을 호출하는 부분이 모호하다는 에러를 발생시킨다. 어느 버전의 eat을 호출해야 하는지 알 수 없기 때문이다.

class Dog {
public:
	virtual void bark() { cout << "멍멍!" << endl; }
	virtual void eat() { cout << "강아지가 먹이를 먹습니다." << endl; }
};

class Bird {
public:
	virtual void chirp() { cout << "짹짹!" << endl; }
	virtual void eat() { cout << "새가 먹이를 먹습니다." << endl; }
};

class DogBird : public Dog, public Bird { };

DogBird myAnimal;
myAnimal.eat(); /* 컴파일 에러 */

 

① 모호한 부모 클래스 메서드 호출하기

 이러한 상황이 발생하지 않게 하려면 dynamic_cast로 객체를 명시적으로 업캐스팅하거나 스코프 지정 연산자로 원하는 버전을 구체적으로 지정한다. 예를 들면 다음과 같다.

dynamic_cast<Dog&>(myAnimal).eat(); /* Dog::eat()을 호출합니다. */
myAnimal.Bird::eat(); /* Bird::eat()을 호출합니다. */

 

② 모호한 부모 클래스 메서드 오버라이드하기

 파생 클래스에 이름이 같은 메서드가 있을 때는 스코프 지정 연산자로 원하는 메서드를 명확히 지정한다.

class DogBird : public Dog, public Bird {
public:
	void eat() override;
};

void DogBird::eat() {
	Dog::eat(); /* Dog 버전의 eat()을 호출합니다. */
}

 아니면 다음과 같이 using으로 DogBird가 상속한 eat 버전을 구체적으로 지정한다.

class DogBird : public Dog, public Bird {
public:
	using Dog::eat; /* Dog 버전의 eat을 상속합니다. */
};

 

 10.5.2.2 모호한 부모 클래스

 같은 클래스를 두 번 상속할 때도 모호한 상황이 발생한다. 예를 들어 Bird 클래스가 Dog를 상속하면 DogBird 코드에서 컴파일 에러가 발생한다. 마찬가지로 데이터 멤버에 대해서도 모호함이 발생할 수 있다. Dog와 Bird의 데이터 멤버 중 이름이 같은 것이 있을 때 사용자 코드가 이 멤버에 접근하면 어디에 속한 것인지 알 수 없어 에러가 발생한다.

그림 10-4 모호한 부모 클래스

 

 가장 흔한 사례는 부모가 겹칠 때다. 클래스 계층이 이렇게 다이아몬드 형태로 구성됐을 때는 최상단의 클래스를 순수 가상 메서드로만 구성된 추상 클래스로 만들면 된다. 물론, 이럴 때는 최상단에 있는 부모 클래스를 가상 베이스 클래스(virtual base class)로 만드는 것이 가장 좋다.

그림 10-5 다이아몬드 클래스

 

10.5.3 다중 상속 활용 방법

 다중 상속은 is-a 관계를 맺는 대상이 하나 이상인 객체에 대한 클래스를 정의하기 위해서 활용한다. 또한, 믹스인 클래스를 구현하거나 컴포넌트 기반으로 클래스를 모델링할 때도 다중 상속을 사용한다.

 

10.5.4 가상 베이스 클래스

 C++는 중복되는 부모 클래스가 자체 기능을 가질 수 있도록 가상 베이스 클래스라는 기능을 제공한다. 중복되는 부모가 가상 베이스 클래스라면 모호한 상황이 발생하지 않는다. 다음 코드는 Animal을 가상으로 상속해 Animal의 하위 타입이 하나만 생성되기 때문에 sleep을 호출할 때 모호한 상황이 발생하지 않는다.

#include <iostream>
using namespace std;

class Animal {
public:
	virtual void eat() = 0;
	virtual void sleep() { cout << "잠을 잡니다." << endl; }
};

class Dog : public virtual Animal {
public:
	virtual void bark() { cout << "멍멍!" << endl; }
	virtual void eat() { cout << "강아지가 먹이를 먹습니다." << endl; }
};

class Bird : public virtual Animal {
public:
	virtual void chirp() { cout << "짹짹!" << endl; }
	virtual void eat() { cout << "새가 먹이를 먹습니다." << endl; }
};

class DogBird : public Dog, public Bird {
public:
	virtual void eat() override { Dog::eat(); }
};

int main(void) {
	DogBird myAnimal;
	myAnimal.sleep(); /* 조부모 클래스를 virtual로 선언해서 모호하지 않습니다. */
	return 0;
}

댓글