References
- Professional C++
Contents
- 다형성을 위한 상속
- 순수 가상 메소드, 추상 클래스
- 다중 상속
[C++] 클래스(Class) 상속 (1) - 확장, 재사용
이전 포스팅에 이어 계속해서 클래스의 상속에 대해 알아보도록 하겠습니다!
4. 다형성을 위한 상속
이전 포스팅에서 파생 클래스와 부모 클래스의 관계에 대해서 알아봤고, 이번 포스팅에서는 상속의 가장 강력한 기능인 다형성을 살펴보도록 하겠습니다.
4.1 Spreadsheet 예제
지난 포스팅들을 통해 클래스에 대해 알아보면서 스프레드시트 어플리케이션 예제를 만들었습니다. SpreadsheetCell은 데이터를 구성하는 원소 하나를 표현하는데, 지금까지는 이 원소를 double 값 하나로만 저장했습니다. 이렇게 간단하게 정의한 SpreadsheetCell 코드는 다음과 같습니다. (기존에 작성한 SpreadsheetCell 클래스의 나머지 부분은 생략 및 축소된 버전입니다.)
class SpreadsheetCell
{
public:
virtual void set(double value);
virtual void set(std::string_view value);
virtual std::string getString() const;
private:
static std::string doubleToString(double value);
static double stringToDouble(std::string_view value);
double m_value;
};
실제로 쓸 만한 스프레드시트 어플리케이션을 만드려면 셀에 다양한 종류의 값을 저장할 수 있어야 합니다. double 값뿐만 아니라 문장도 저장하고, 수식이나 날짜와 같은 특수한 타입도 표현할 수 있어야 합니다.
4.2 다형성을 지원하는 스프레드시트 셀 디자인
지금 상태의 SpreadsheetCell 클래스는 계층화할 필요가 있습니다. 이상적인 방법은 SpreadsheetCell 클래스가 string만을 커버할 수 있도록 축소하는 것입니다. 아마도 이름을 다시 정하면 StringSpreadsheetCell이면 적절한 것 같습니다. 또한 double 타입을 저장하는셀을 위해서 DoubleSpreadsheetCell이란 클래스를 새로 정의합니다. 이 클래스는 StringSpreadsheetCell을 상속하면서 double 값을 다루는 데 필요한 기능을 추가합니다. 아래 그림은 이렇게 디자인한 계층을 보여줍니다.
위와 같은 디자인을 구현하다 보면 베이스 클래스의 기능 중 전부는 아니지만 대부분을 파생 클래스에서 오버라이드하게 됩니다. double과 string 타입의 관계는 예상보다 구현 코드의 공통점이 적기 때문입니다. 그래도 두 타입을 저장한 셀 사이에 어느 정도 관계는 있습니다.
이럴 때는 위의 구조처럼 DoubleSpreadsheetCell과 StringSpreadsheetCell을 is-a 관계로 보는 것이 아니라 SpreadsheetCell이란 공통의 부모를 가진 형제 관계로 보는 것이 바람직합니다. 이렇게 수정하면 다음 그림과 같습니다.
위 그림은 SpreadsheetCell의 계층 구조를 다형성을 활용하도록 디자인한 것입니다. DoubleSpreadsheetCell과 StringSpreadsheet은 모두 SpreadsheetCell이란 부모를 상속하기 때문에 다른 코드에서 얼마든지 교체해서 쓸 수 있습니다. 이 디자인의 특성을 구체적으로 살펴보면 다음과 같습니다.
- 두 파생 클래스는 모두 베이스 클래스에서 정의한 인터페이스(메소드 집합)를 똑같이 제공합니다.
- SpreadsheeetCell 객체를 사용하는 코드는 현재 셀 객체의 타입이 DoubleSpreadsheetCell인지 아니면 StringSpreadsheetCell인지 신경 쓸 필요 없이 SpreadsheetCell에 정의된 인터페이스를 마음껏 호출할 수 있습니다.
- virtual 메커니즘을 통해 공통 인터페이스의 메소드 중에서 가장 적합한 버전을 호출해줍니다.
- 다른 데이터 구조도 부모 클래스 타입을 참조하는 방식으로 여러 타입으로 된 셀을 하나로 묶을 수 있습니다.
4.3 SpreadsheetCell 베이스 클래스
스프레드시트를 구성하는 셀은 모두 SpreadsheetCell이라는 베이스 클래스를 상속하므로 이 클래스부터 정의하는 것이 바람직합니다. 베이스 클래스를 디자인할 때는 이를 상속하는 여러 파생 클래스 사이의 관계부터 분석합니다. 여기서 도출된 공통 정보를 토대로 부모 클래스의 구성 요소를 결정합니다. 예를 들어 string 타입의 셀과 double 타입의셀은 모두 데이터 항목 하나를 표현한다는 공통점이 있습니다. 이 데이터를 사용자로부터 받아서 다시 사용자에게 출력하ㅣㄱ 때문에 값을 string 타입으로 저장하고 불러오는 것이 적합합니다. 이 동작이 바로 베이스 클래스를 구성할 공통 기능입니다.
4.3.1 첫 번째 버전
베이스 클래스인 SpreadsheetCell은 이 클래스를 상속하는 모든 파생 클래스에 제공할 공통 동작을 정의해야 합니다.예제에서는 모든 셀이 값을 string으로 저장하고, 현재 저장된 값을 리턴할때도 string으로 표현합니다. 베이스 클래스를 정의하는 코드는 이와 관련된 메소드뿐만 아니라 명시적으로 디폴트로 선언한 virtual 소멸자도 선언해야 합니다. 여기서 데이터 멤버는 정의하지 않습니다.
class SpreadsheetCell
{
public:
virtual ~SpreadsheetCell() = default;
virtual void set(std::string_view inString);
virtual std::string getString() const;
};
이제 이 클래스에 대한 .cpp 파일을 작성하는 시점에 문제가 발생합니다. 베이스 클래스에서 double이나 string 타입의 데이터 멤버가 없는데 어떻게 구현해야 할지 막막할 수 있습니다. 좀 더 일반적으로 표현하면 파생 클래스에서 제공할 동작을 실제로 구현하지 않은 상태에서 이에 대한 동작을 부모 클래스에서 선언하는 방법을 찾아야 합니다.
한 가지 가능한 방법은 각 동작에 대해 아무 일도 하지 않도록 구현하는 것입니다. 예를 들어, SpreadsheetCell 베이스 클래스의 set() 메소드를 호출하더라도 실제로 구현된 코드가 없기 때문에 아무 일도 일어나지 않습니다. 가장 좋은 방법은 베이스 클래스의 인스턴스를 아예 만들 수 없게 하는 것입니다. set() 메소드는 항상 DoubleSpreadsheetCell이나 StringSpreadsheetCell 중 하나에 대해 호출될 때 실제 동작이 일어나야 합니다. 따라서 이러한 제약사항을 공식적으로 지원하면 좋습니다.
4.3.2 순수 가상 메소드와 추상 베이스 클래스
순수 가상 메소드(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 객체를 생성할 수 없습니다. 따라서 다음과 같이 작성하면 "error C2259: 'SpreadsheetCell': connot instaniate abstract class"라는 컴파일 에러가 발생합니다.
SpreadsheetCell cell; // Error!
하지만 StringSpreadsheetCell 클래스를 구현한 다음 다음과 같이 작성하면 컴파일 에러가 발생하지 않습니다. 객체를 추상 베이스 클래스가 아닌 파생 클래스 타입으로 생성하기 때문입니다.
std::unique_ptr<SpreadsheetCell> cell{ new StringSpreadsheetCell() };
여기서 SpreadsheetCell은 구현할 내용이 없기 때문에 SpreadsheetCell.cpp 소스 파일을 작성할 필요가 없습니다.
추상 클래스를 정의할 때 메소드는 대부분 순수 가상 메소드로 정의하고, 소멸자는 명시적으로 디폴트로 선언합니다.
4.4 파생 클래스 구현
StringSpreadsheetCell과 DoubleSpreadsheetCell 클래스를 정의할 때는 부모 클래스에 정의된 기능을 그대로 구현하면 됩니다. 클라이언트가 이 두 가지 클래스로 string이나 double 타입 셀을 객체로 생성할 수 있어야 하므로 두 타입을 추상 클래스로 정의하면 안됩니다. 즉, 부모 클래스로부터 받은 순수 가상 메소드를 하나도 빠짐없이 구현해야 합니다. 베이스 클래스에서 순수 가상으로 정의한 메소드 중 파생 클래스에서 하나라도 구현하지 않은 것이 있으면 파생 클래스도 추상 클래스가 됩니다.
4.4.1 StringSpreadsheetCell 클래스 정의
StringSpreadsheetCell 클래스를 정의할 때는 가장 먼저 SpreadsheetCell을 상속합니다.
그리고 나서 상속한 순수 가상 메소드를 오버라이드합니다. 이번에는 이 메소드를 =0으로 지정하지 않습니다.
마지막으로 string 타입의 셀을 정의하는 클래스에 m_value라는 데이터 멤버를 추가하고 private로 지정합니다. 실제 셀 값은 여기에 저장됩니다. 이 멤버의 타입을 C++17부터 추가된 std::optional(defined in <optional>)로 지정합니다. optional 타입은 클래스 템플릿인데, 셀에 값이 설정되어 있는지 쉽게 알아낼 수 있습니다. 여기서 optional에 대해 자세한 설명은 생략하도록 하겠습니다.
#include <optional>
#include "SpreadsheetCell.h"
class StringSpreadsheetCell : public SpreadsheetCell
{
public:
virtual void set(std::string_view inString) override;
virtual std::string getString() const override;
private:
std::optional<std::string> m_value;
};
4.4.2 StringSpreadsheetCell 구현
StringSpreadsheetCell 메소드를 정의하는 코드는 이 클래스의 소스 파일에 작성합니다. set() 메소드는 간단합니다. 내부적으로 이미 string으로 표현하기 때문입니다. m_value가 optional 타입이므로 실제값은 없을 수도 있습니다. 따라서 m_value에 실제값이 없다면 공백 문자열을 리턴하게 만듭니다.이 동작은 std::optional의 value_or() 메소드로 쉽게구현할 수 있습니다. m_value.value_or("")고 같이 작성하면 m_value에 실제값이 있으면 그 값을 리턴하고, 그렇지 않으면 공백 문자열을 리턴합니다.
void StringSpreadsheetCell::set(std::string_view inString)
{
m_value = inString;
}
std::string StringSpreadsheetCell::getString() const
{
return m_value.value_or("");
}
4.4.3 DoubleSpreadsheetCell 클래스 정의 및 구현
double 버전을 정의하는 방법도 이와 비슷하지만 세부 로직에서 약간 차이가 있습니다. 베이스 클래스에 정의된 버전뿐만 아니라 string_view타입의 인수를 받는 set() 메소드와 클라이언트가 double 인수로 값을 설정할 수 있도록 double 타입의 인수를 받는 set() 메소드도 추가합니다. 그리고 두 개의 새로운 private static 메소드는 string과 double 타입의 값을 상호 변환할 때 사용합니다. StringSpreadsheetCell과 마찬가지로 m_value 데이터 멤버는 optional<double>로 지정합니다.
#include <optional>
#include "SpreadsheetCell.h"
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> m_value;
};
double 타입의 인수를 받는 set() 메소드는 간단합니다. string_view 타입을 받는 set() 메소드는 stringToDouble() 메소드를 사용합니다. getString() 메소드는 저장된 double 타입의 값을 string으로 리턴하거나 만약 값이 저장되어 있지 않다면 빈 문자열을 리턴합니다. 이를 위해서 optional이 실제값을 가지고 있는지 확인하는 std::optional의 has_value() 메소드를 사용합니다. 만약 값을 가지고 있다면 value() 메소드를 사용하여 실제값을 얻습니다.
구현 코드는 다음과 같습니다.
void DoubleSpreadsheetCell::set(double inDouble)
{
m_value = inDouble;
}
void DoubleSpreadsheetCell::set(std::string_view inString)
{
m_value = stringToDouble(inString);
}
std::string DoubleSpreadsheetCell::getString() const
{
return (m_value.has_value() ? doubleToString(m_value.value()) : "");
}
std::string DoubleSpreadsheetCell::doubleToString(double inValue)
{
return std::to_string(inValue);
}
double DoubleSpreadsheetCell::stringToDouble(std::string_view inValue)
{
return strtod(inValue.data(), nullptr);
}
이렇게 스프레드시트 셀을 표현하는 클래스를 계층화하면 코드가 훨씬 간결해집니다. 이처럼 상속을 활용하면각 타입에 관련된 기능에만 집중할 수 있습니다.
4.5 다형성 활용
지금까지 다형성을 지원하도록 SpreadsheetCell 클래스를 계층화하였습니다. 이렇게 하면 클라이언트는 다형성의 장점을 충분히 활용할 수 있습니다. 구체적으로 어떤 장점이 있는지 테스트 프로그램을 작성하면서 소개해보겠습니다.
다형성의 효과를 코드로 표현하기위해 먼저 SpreadsheetCell 포인터 타입 원소 3개를 담을 vector를 선언합니다. SpreadsheetCell은 추상 클래스이므로 이클래스로 곧바로 객체를 생성할 수는 없지만 SpreadsheetCell에 대한 포인터나 레퍼런스는 파생 클래스 객체를 가리킬 수 있으므로 이렇게 원소 타입으로 선언할 수는 있습니다. 따라서 SpreadsheetCell의 파생 클래스인 StringSpreadsheetCell과 DoubleSpreadsheetCell 타입의 원소를 모두 담을 수 있습니다.
std::vector<std::unique_ptr<SpreadsheetCell>> cellArray;
그리고 vector의 첫 번째 원소와 두 번째 원소는 StringSpreadsheetCell 객체로 설정하고, 세 번째 원소는 DoubleSpreadsheetCell 객체로 설정합니다.
cellArray.push_back(std::make_unique<StringSpreadsheetCell>());
cellArray.push_back(std::make_unique<StringSpreadsheetCell>());
cellArray.push_back(std::make_unique<DoubleSpreadsheetCell>());
이렇게 하면 vector에는 타입이 서로 다른 데이터가 저장되며, 베이스 클래스에 선언된 메소드라면 어떤 것으로도 이 vector의 원소를 통해 호출할 수 있습니다. 코드에서는 단지 SpreadsheetCell 포인터만 사용했는데, 컴파일러는 이 포인터가 실제로 어떤 타입에 객체를 가리키는지 컴파일시간에 알 수 없습니다. 그러나 런타임에 포인터가 실제로 가리키는 객체는 모두 SpreadsheetCell을 상속한 것이기 때문에 SpreadsheetCell에 정의된 메소드를 확실하게 사용할 수 있습니다.
cellArray[0]->set("hello");
cellArray[1]->set("10");
cellArray[2]->set("18");
getString() 메소드를 호출하면 각 객체는 그들의 값을 적절하게 string으로 변환해서 리턴합니다. 중요한 것은 객체마다 변환하는 방식이 다르다는 것입니다. StringSpreadsheetCell은 저장된 값을 그대로 리턴하거나 공백 문자열을 리턴합니다. DoubleSpreadsheetCell은 저장된 값이 있으면 string으로 변환해서 리턴하고 그렇지 않으면 공백 문자열을 리턴합니다. 따라서 각 객체마다 구체적으로 어떻게 처리하는지는 몰라도 되며, 모든 객체의 타입이 SpreadsheetCell 타입이기 때문에 SpreadsheetCell에 정의된 동작을 수행할 수 있다는 사실만 알면 됩니다.
std::cout << "Vector values are [" << cellArray[0]->getString() << ", " <<
cellArray[1]->getString() << "," <<
cellArray[2]->getString() << "]" << std::endl;
4.6 고려사항
객체지향 디자인 관점에서 SpreadsheetCell을 이렇게 계층화해서 구현하는 것이 훨씬 좋습니다. 그래도 여전히 실전에서 사용하기에는 부족한 기능이 있습니다. 몇 가지만 소개하자면 다음과 같습니다.
첫째, 디자인은 좀 나아졌지만 아직 빠진 기능이 있습니다. 바로 셀 타입끼리 상호 변환하는 기능입니다. 앞에서 두 클래스를 별도로 정의했기 때문에 셀을 표현하는 타입이 두 가지로 갈라졌습니다. 그래서 타입을 변환하는 기능을 별도로 제공해야 합니다. DoubleSpreadsheetCell 타입을 StringSpreadsheetCell 타입으로 변환하는 기능은 변환 생성자(converting constructor) 또는 타입 생성자(typed constructor)를 추가하는 방식으로 구현할 수 있습니다. 이는 복사 생성자와 비슷하지만 동일한 클래스가 아니라 형제 클래스 객체에 대한 레퍼런스를 인수로 받습니다. 이렇게 하면 디폴트 생성자를 반드시 선언해주어야 합니다. 명시적으로 디폴트를 지정해도 됩니다. 이는 생성자를 직접 작성하면 컴파일러가 더 이상 자동으로 만들어주지 않기 때문입니다.
class StringSpreadsheetCell : public SpreadsheetCell
{
public:
StringSpreadsheetCell() = default;
StringSpreadsheetCell(const DoubleSpreadsheetCell& cell)
: m_value{ cell.getString() }
{}
virtual void set(std::string_view inString) override;
virtual std::string getString() const override;
private:
std::optional<std::string> m_value;
};
변환 생성자를 이용하면 DoubleSpreadsheetCell을 이용해서 쉽게 StringSpreadsheetCell 객체를 생성할 수 있습니다. 하지만 포인터나 레퍼런스를 캐스팅하는 것과 혼동하면 안됩니다. cast 연산자를 오버로딩하지 않으면 포인터나 레퍼런스의 타입을 형제 클래스끼리 캐스팅할 수 없습니다.
둘째, 셀마다 연산자를 오버로딩해야 합니다. 구현 방식은 여러 가지가 있습니다. 모든 셀 타입 조합에 대해 연산자를 일일히 구현하는 것입니다. 파생 클래스가 두 개뿐이라면 이렇게 해도 부담스럽지 않습니다. double 타입 셀끼리 더하는 연산, string 타입 셀끼리 더하는 연산, double 타입 셀과 string 타입 셀을 더하는 연산, 이렇게 3가지 경우에 대해 operator+ 함수를 일일히 정의하면 됩니다. 다른 방식은 공통 표현 방식을 정해두는 것입니다. 앞에서 본 예제는 이미 string 타입을 공통 표현 방식으로 사용하고 있습니다. 이렇게 정해둔 공동 표현에 대해 정의한 operator+ 연산자로 모든 조합의 연산을 구현할 수 있습니다.
예를 들면, 다음과 같습니다. 여기서는 두 셀을 더한 값이 항상 string 타입이라고 가정합니다.
StringSpreadsheetCell operator+(const StringSpreadsheetCell& lhs, const StringSpreadsheetCell& rhs)
{
StringSpreadsheetCell newCell;
newCell.set(lhs.getString() + rhs.getString());
return newCell;
}
(함수 프로토 타입은 헤더 파일에 선언해주고, 구현 코드는 구현 파일에서 작성합니다.)
컴파일러 입장에서 볼 때 주어진 셀을 StringSpreadsheetCell로 변환할 수만 있다면 제대로 작동합니다. 앞에서 정의한 것처럼 DoubleSpreadsheetCell을 인수로 받는 StringSpreadsheetCell 생성자가 있을 때 컴파일러 입장에서 operator+를 실행하기 위한 다른 방법을 찾지 못하면 이 생성자를 이용하여 타입을 변환해줍니다. 따라서 operator+를 StringSpreadsheetCell에 대해서만 구현했더라도 다음의 코드는 실행됩니다.
DoubleSpreadsheetCell myDbl;
myDbl.set(8.4);
StringSpreadsheetCell result{ myDbl + myDbl };
물론, 이 문장은 두 수를 더하는 것이 아닙니다. double 값 두 개를 모두 string 타입으로 변환해서 서로 이어붙입니다. 따라서 result는 8.4000008.400000이라는 값을 가집니다.
5. 다중 상속
사실 객체지향 프로그래밍에서 다중 상속은 쓸데없이 복잡하기만 한 개념이라고 여기는 사람들이 많습니다. 다중 상속이 좋고 나쁜지는 개개인의 판단에 맡기고, 이번 포스팅에서는 C++의 다중 상속 메커니즘에 대해 알아보도록 하겠습니다.
5.1 여러 클래스 상속하기
다중 상속을 정의하는 방법은 간단합니다. 클래스 이름 옆에 상속할 베이스 클래스를 나열하기만 하면 됩니다.
class Baz : public Foo, public Bar { /* Etc. */ };
이렇게 Baz가 여러 부모를 동시에 상속하면 다음과 같은 속성들을 갖습니다.
- Baz 객체는 Foo와 Bar에 있는 데이터 멤버와 public 메소드를 갖습니다.
- Baz 클래스의 메소드는 Foo와 Bar에 있는 protected 데이터 멤버와 메소드에 접근할 수 있습니다.
- Bar 객체는 Foo 또는 Bar로 업캐스팅할 수 있습니다.
- Bar 객체를 생성하면 Foo와 Bar의 디폴트 생성자가 자동으로 호출됩니다. 이때 호출 순서는 첫 줄에 정의한 순서를 따릅니다.
- Bar 객체를 삭제하면 Foo와 Bar의 소멸자가 자동으로 호출ㄹ됩니다. 이때 호출 순서는 정의에 나열한 클래스 순서의 반대입니다.
다음 예제 코드에서는 두 개의 부모 클래스(Dog, Bird)를 상속하는 DogBird 클래스를 보여줍니다.
class Dog
{
public:
virtual void bark() { std::cout << "Woof!" << std::endl; }
};
class Bird
{
public:
virtual void chirp() { std::cout << "Chirp!" << std::endl; }
};
class DogBird : public Dog, public Bird
{
};
다중 상속 클래스의 객체는 단일 상속 클래스의 객체와 크게 다르지 않습니다. 게다가 주어진 클래스의 부모가 둘이라는 사실을 클라이언트 코드에서 알 필요도 없습니다. 그보다는 이 클래스가 어떤 속성과 메소드를 제공하는지 아는 것이 중요합니다. 이 예제에서 DogBird 객체는 Dog와 Bird에 있는 public 메소드를 모두 제공합니다.
int main()
{
DogBird myConfusedAnimal;
myConfusedAnimal.bark();
myConfusedAnimal.chirp();
}
5.2 Naming Collisions and Ambiguous Base Classes
다중 상속에서 문제가 발생하는 사례를 만드는 방법은 어렵지 않습니다. 아래에 나오는 예제들은 반드시 주의해야 할 특수항 상황들을 보여줍니다.
5.2.1 모호한 이름
Dog 클래스와 Bird 클래스 둘 다 eat() 메소드를 가지고 있다고 가정해봅시다. Dog와 Bird는 서로 관련이 없기 때문에 어느 한쪽이 다른 쪽의 메소드를 오버라이딩할 수 없습니다. 따라서 DogBird라는 파생 클래스에서도 그대로 유지됩니다.
클라이언트가 eat() 메소드를 호출하지 않는다면 아무런 문제도 발생하지 않습니다. 이렇게 eat() 메소드의 버전이 두 개이더라도 DogBird 클래스를 컴파일할 때 아무런 에러가 발생하지 않습니다. 하지만 클라이언트 코드에서 DogBird의 eat() 메소드를 호출하도록 작성하면 컴파일 에러는 eat()를 호출하는 부분이 모호하다는 에러를 발생시킵니다. 컴파일러 입장에서는 어느 버전의 eat()를 호출해야 하는지 판단할 수 없기 때문입니다.
class Dog
{
public:
virtual void bark() { std::cout << "Woof!" << std::endl; }
virtual void eat() { std::cout << "The dog ate." << std::endl; }
};
class Bird
{
public:
virtual void chirp() { std::cout << "Chirp!" << std::endl; }
virtual void eat() { std::cout << "The bird ate." << std::endl; }
};
class DogBird : public Dog, public Bird
{
};
int main()
{
DogBird myConfusedAnimal;
myConfusedAnimal.eat(); // Error! Ambiguous call to method eat()
}
이러한 모호한 상황이 발생하지 않게 하려면 dynamic_cast()로 객체를 명시적으로 업캐스팅해서 원하지 않는 버전을 컴파일러가 볼 수 없게 가리거나 disambiguation syntax를 사용합니다.
int main()
{
DogBird myConfusedAnimal;
dynamic_cast<Dog&>(myConfusedAnimal).eat();
myConfusedAnimal.Bird::eat();
}
파생 클래스 사이에 이름이 같은 메소드가 있을 때도 알아서 본 부모 메소드 접근 방식처럼 스코프 지정 연산자(::, scope resolution operator)로 원하는 메소드를 정확히 지정합니다. 예를 들어, DogBird 클래스는 이렇게 모호한 상황이 발생하지 않도록 다음과 같이 eat() 메소드를 별도로 정의합니다. 구체적으로 어느 부모의 메소드를 호출할 지는 이 메소드 안에서 결정합니다.
class DogBird : public Dog, public Bird
{
public:
void eat() override
{
Dog::eat(); // Explicitly call Dog's version of eat()
}
};
다른 방법으로는 using 문으로 DogBird가 상속할 eat() 버전을 구체적으로 지정합니다.
class DogBird : public Dog, public Bird
{
public:
using Dog::eat; // Explicitly inherit Dog's version of eat()
};
5.2.2 Ambiguous Base Classes
같은 클래스를 두 번 상속할 때도 모호한 상황이 발생합니다. 예를 들어 Bird 클래스가 Dog를 상속하면 DogBird 코드에서 컴파일 에러가 발생합니다. 베이스 클래스가 모호하기 때문입니다.
class Dog {};
class Bird : public Dog {};
class DogBird : public Bird, public Dog {}; // Error!
베이스 클래스가 모호한 경우는 바로 위의 코드처럼 상속 관계가 이상하거나 클래스 계층이 정리되지 않았을 때 발생합니다. 앞서 본 예제 코드의 상속 관계를 클래스 다이어그램으로 그려보면 모호함이 발생하는 것을 볼 수 있습니다.
데이터 멤버에 대해서도 모호함이 발생할 수 있습니다. 만약 Dog와 Bird의 데이터 멤버 중 이름이 같은 것이 있을 때 클라이언트 코드가 이 멤버에 접근하면 어디에 속한 것인지 알 수 없어서 에러가 발생합니다.
가장 흔한 사례는 부모가 겹칠 때입니다. 예를 들어, 아래 그림처럼 Dog와 Bird가 모두 Animal 클래스를 상속한 경우를 살펴보겠습니다.
C++에서는 얼마든지 이렇게 클래스 계층을 구성해도 되지만 이름의 모호함은 발생할 수 있습니다. 예를 들어, Animal 클래스에서 sleep() 이라는 public 메소드가 있을 때, DogBird 객체로 이 메소드를 호출할 수 없습니다. 컴파일러는 Dog와 Bird 중 어디에 있는 sleep()을 호출할지 판단할 수가 없기 때문입니다.
위의 그림처럼 클래스 계층이 다이아몬드 형태로 구성됐을 때는 최상단의 클래스를 순수 가상 메소드로만 구성된 추상 클래스로 만들면 됩니다. 메소드를 선언만하고 정의하지는 않았기 때문에 베이스 클래스에서 호출할 메소드가 없습니다. 그러면 모호함이 발생하지 않습니다.
다음 예제 코드는 이 방법에 따라 eat()를 순수 가상 메소드로 선언해서 Animal 클래스를 추상 베이스 클래스로 만들었습니다. 따라서 이 클래스를 상속하는 모든 파생 클래스는 반드시 eat() 메소드를 구현해야 합니다. DogBird 클래스도 어느 부모의 eat() 메소드를 사용할지 명확히 밝혀야 합니다. 하지만 Dog와 Bird 사이에서 모호함이 발생하는 근본 원인은 같은 클래스를 상속하기 때문이 아니라 메소드 이름이 같이 때문입니다.
class Animal
{
public:
virtual void eat() = 0;
};
class Dog : public Animal
{
public:
virtual void bark() { std::cout << "Woof!" << std::endl; }
virtual void eat() override { std::cout << "The dog ate." << std::endl; }
};
class Bird : public Animal
{
public:
virtual void chirp() { std::cout << "Chirp!" << std::endl; }
virtual void eat() override { std::cout << "The bird ate." << std::endl; }
};
class DogBird : public Dog, public Bird
{
public:
using Dog::eat;
};
5.2.3 다중 상속 활용법
지금까지 설명한 내용을 보면 다중 상속을 왜 사용하는지 궁금할 수 있습니다. 다중 상속을 활용하는 가장 간단한 예는 is-a 관계를 맺는 대상이 하나 이상인 객체에 대한 클래스를 정의하기 위해서입니다. 하지만 현실에서 이런 관계를 가진 객체는 코드로 표현하기 쉽지 않습니다.
다중 상속의 가장 적합하면서 간단한 예는 믹스인 클래스를 구현할 때입니다.
컴포넌트 기반으로 클래스를 모델링할 때도 다중 상속을 활용합니다. 예를 들면, 비행기 시뮬레이션 예제가 여기에 속합니다. Airplane 클래스는 엔진, 동체, 제어장치 등의 컴포넌트로 구성됩니다. 이렇게 정의한 Airplane 클래스는 주로 각 컴포넌트를 데이터 멤버로 만드는 방식으로 구현하는데, 이를 다중 상속으로 구현해도 됩니다. 즉, Airplane 클래스가 엔진, 동체, 제어장치에 대한 클래스를 상속해서 각각의 동작과 속성을 물려받는 것입니다. 하지만 has-a 관계가 복잡해지기 때문에 이 방식으로 구현하지 않는 것이 좋습니다. 바람직한 방법은 Airplane 클래스에 Engine, Fuselage, Controls 라는 이름으로 데이터 멤버를 정의하는 것입니다.
다음 포스팅에서 이어서 상속과 관련된 이슈들에 대해서 알아보도록 하겠습니다 !
'프로그래밍 > C & C++' 카테고리의 다른 글
[C++] 캐스팅(Casting) (0) | 2022.02.17 |
---|---|
[C++] 클래스(Class) 상속 (3) - 다양한 이슈들 (0) | 2022.02.16 |
[C++] 클래스(Class) 상속 (1) - 확장, 재사용 (0) | 2022.02.14 |
[C++] 클래스(Class) 심화편 (3) (0) | 2022.02.13 |
[C++] 클래스(Class) 심화편 (2) (0) | 2022.02.12 |
댓글