10.1 상속을 이용한 클래스 구현
현실에 존재하는 대상은 대체로 계층 구조에 속한다. 프로그래밍을 할 때도 클래스를 수정하거나 다른 클래스를 바탕으로 새 클래스를 정의할 때 이러한 관계를 분명히 볼 수 있다. C++는 진정한 is-a 관계를 정의하는 기능을 기본으로 제공한다.
10.1.1 클래스 상속하기
C++에서 클래스를 정의할 때 컴파일러에 클래스를 상속(inherit), 파생(derive), 확장(extend)한다고 선언할 수 있다. 이때 원본 클래스를 부모 클래스(베이스 클래스(base class) 또는 슈퍼 클래스(super-class))라고 부른다. 그러면 원본 클래스를 확장한 자식 클래스(파생 클래스(derived class) 또는 서브 클래스(sub-class))는 부모 클래스와 다른 부분만 구현하면 된다. 먼저 Parent 클래스를 정의하고 Child 클래스가 Parent 클래스를 상속하도록 만들어보자.
class Parent {
public:
void parentMethod();
protected:
int mProtectedInt;
private:
int mPrivateInt;
};
class Child : public Parent {
public:
void childMethod();
};
Child는 Parent 클래스가 가진 특성을 그대로 물려받은 완전한 형태의 클래스다.
■ 10.1.1.1 사용자 입장에서 본 상속
다른 코드에서 볼 때 Child 타입의 객체는 Parent 타입의 객체이기도 하다. Child는 Parent를 상속했기 때문이다. 따라서 Child 객체는 Parent에 있는 public 메서드나 데이터 멤버뿐만 아니라 Child의 public 메서드와 데이터 멤버도 사용할 수 있다.
Child myChild;
myChild.parentMethod();
myChild.childMethod();
단, 상속은 반드시 한 방향으로 진행된다는 점에 주의한다. Parent 타입 객체는 Child 객체의 메서드나 데이터 멤버를 사용할 수 없다. Child는 Parent 타입이지만 Parent는 Child 타입이 아니기 때문이다.
Parent myParent;
myParent.parentMethod();
myParent.childMethod(); /* 컴파일 에러 */
어떤 객체를 포인터나 레퍼런스로 가리킬 때 그 객체를 선언한 클래스의 객체뿐만 아니라 그 클래스의 파생 클래스 객체도 가리킬 수 있다. 즉, Parent에 대한 포인터나 레퍼런스로 Child 객체도 가리킬 수 있다. 예를 들어 다음과 같이 작성하면 정상적으로 컴파일된다.
Parent* myParent = new Child(); /* Child 객체를 생성해서 Parent 포인터에 저장합니다. */
myParent->childMethod(); /* 컴파일 에러 */
그런데 Parent 포인터로 Child 클래스의 메서드를 호출할 수는 없다. 객체의 실제 타입이 Child이지만 Parent 타입으로 선언했기 때문에 컴파일러는 여전히 이 객체의 타입이 childMethod가 없는 Parent로 보이기 때문이다.
■ 10.1.1.2 자식 클래스 입장에서 본 상속
자식 클래스는 부모 클래스에 선언된 public 및 protected 메서드나 데이터 멤버를 자식 클래스 안에서 정의한 것처럼 사용할 수 있다. 실제로 자식 클래스 안에 담겨 있기 때문이다.
void Child::childMethod() {
std::cout << mProtectedInt << std::endl;
std::cout << mPrivateInt << std::endl; /* 컴파일 에러 */
}
부모 클래스에서 데이터 멤버나 메서드를 protected로 선언하면 자식 클래스에서도 접근할 수 있다. 반면 private로 선언하면 자식 클래스에서 접근할 수 없다. 이렇게 private 접근 지정자는 나중에 정의될 자식 클래스에서 현재 클래스에 접근하는 수준을 제어하는 데 활용할 수 있다.
NOTE 자식 클래스의 입장에서 부모 클래스에 있는 public과 protected 데이터 멤버 및 메서드를 모두 마음껏 사용할 수 있다.
■ 10.1.1.3 클래스 상속 방지
C++에서 클래스를 정의할 때 final 키워드를 붙이면 다른 클래스가 이 클래스를 상속할 수 없다. final로 선언한 클래스를 상속하면 컴파일 에러가 발생한다. 예를 들어 Parent 클래스를 final로 선언하면 다음과 같다.
class Parent final {
public:
...
};
10.1.2 메서드 오버라이딩
클래스를 상속하는 주된 이유는 기능을 추가하거나 바꾸기 위해서다. 부모 클래스에서 정의된 메서드의 동작은 변경할 일이 많은데, 이는 메서드 오버라이딩(method overriding)을 이용해 가능하다.
■ 10.1.2.1 메서드 오버라이딩과 문법
C++에서는 부모 클래스에 virtual 키워드로 선언된 메서드만 자식 클래스에서 오버라이드할 수 있다. 이 키워드는 메서드를 선언할 때 제일 앞부분에 적는다. 예를 들어 Parent 클래스의 메서드를 virtual로 선언하려면 다음과 같이 수정한다.
class Parent {
public:
virtual void parentMethod(); /* virtual 키워드 */
protected:
int mProtectedInt;
private:
int mPrivateInt;
};
class Child : public Parent {
public:
virtual void childMethod();
};
일반적으로 모든 메서드를 virtual로 선언하는 것이 바람직하다. 그러면 오버라이드하는 과정에서 메서드가 제대로 작동할지 신경 쓸 필요가 없다.
NOTE 경험칙(rule of thumb)에 의하면 소멸자를 포함한 모든 메서드를 virtual로 선언하면 이 키워드를 깜박하고 적지 않았을 때 발생하는 문제를 방지할 수 있다.
자식 클래스에서 부모 클래스의 메서드를 오버라이드하려면 그 메서드를 부모 클래스에 나온 것과 똑같이 선언하고 override 키워드를 붙인다. 그러고 나서 메서드 본문에 자식 클래스에서 구현하려는 방식으로 코드를 작성한다.
class Child : public Parent {
public:
virtual void parentMethod() override; /* Parent의 parentMethod를 오버라이드합니다. */
virtual void childMethod();
};
void Parent::parentMethod() {
std::cout << "이것은 부모 클래스의 parentMethod입니다." << std::endl;
}
void Child::parentMethod() {
std::cout << "이것은 자식 클래스의 parentMethod입니다." << std::endl;
}
void Child::childMethod() {
std::cout << "이것은 자식 클래스의 childMethod입니다." << std::endl;
}
메서드나 소멸자를 virtual로 지정하면 모든 자식 클래스에서도 virtual 상태를 유지한다. 자식 클래스에서 virtual 키워드를 제거하더라도 마찬가지이다.
■ 10.1.2.2 사용자 입장에서 본 메서드 오버라이딩
Parent나 Child 객체에 대해 parentMethod를 호출하는 방법은 그대로다. 하지만 parentMethod의 실제 동작은 객체가 속한 클래스에 따라 달라진다.
Parent myParent;
myParent.parentMethod();/* 이것은 부모 클래스의 parentMethod입니다. */
Child myChild;
myChild.parentMethod(); /* 이것은 자식 클래스의 parentMethod입니다. */
자식 객체에 있는 다른 부분은 이전과 같다. Parent를 상속한 다른 메서드도 Child에서 따로 오버라이드하지 않았다면 Parent에 정의된 내용이 그대로 유지된다.
앞서 설명한 것처럼 포인터나 레퍼런스는 해당 클래스뿐만 아니라 파생 클래스 객체까지 가리킬 수 있다. 객체 자신은 멤버가 어느 클래스에 속해 있는지 알기 때문에 virtual로 선언됐다면 가장 적합한 메서드를 호출한다. 예를 들어 다음과 같이 Child 객체를 가리키는 레퍼런스를 Parent 타입으로 선언한 상태에서 parentMethod를 호출하면 자식 클래스 버전이 호출된다.
Child myChild;
Parent& myParent = myChild;
myParent.parentMethod(); /* 이것은 자식 클래스의 parentMethod입니다. */
myParent.childMethod(); /* 컴파일 에러 */
이때 부모 클래스 타입의 포인터나 레퍼런스가 실제로 자식 클래스 타입의 객체를 가리킨다 해도 부모 클래스에 정의되지 않은 자식 클래스의 데이터 멤버나 메서드는 접근할 수 없다.
NOTE 자식 클래스 객체를 부모 클래스 타입으로 캐스팅할 때 자식 클래스의 고유한 정보가 사라진다. 이렇게 자식 클래스의 데이터 멤버나 메서드가 사라지는 것을 슬라이스(slice)라 부른다.
■ 10.1.2.3 override 키워드
간혹 실수로 부모 클래스에 있는 메서드를 오버라이드하지 않고 자식 클래스에 virtual 메서드를 새로 정의할 때가 있다. 부모 클래스를 수정하다가 자식 클래스를 업데이트하는 것을 잊었을 때 이런 문제가 발생한다. 이런 문제를 방지하려면 다음과 같이 override 키워드를 붙인다.
class Parent {
public:
virtual void parentMethod(double d);
};
class Child : public Parent {
public:
virtual void parentMethod(int i) override; /* 컴파일 에러 */
};
그러면 Child를 이렇게 정의할 때 컴파일 에러가 발생한다. parentMethod가 부모 클래스의 메서드를 오버라이드하도록 override 키워드를 지정했는데 Parent 클래스에 있는 parentMethod는 int 값이 아닌 double 값을 매개 변수로 받기 때문이다.
NOTE 부모 클래스의 메서드를 오버라이드할 때는 항상 override 키워드를 붙인다.
■ 10.1.2.4 virtual 키워드
virtual로 선언하지 않은 메서드를 오버라이드하면 몇 가지 문제가 발생한다. 따라서 오버라이드할 메서드는 항상 virtual로 선언하는 것이 좋다.
NOTE virtual로 선언하지 않은 메서드를 오버라이드하면 부모 클래스 쪽 정의를 숨겨버린다. 그래서 오버라이드한 메서드를 자식 클래스 문맥에서만 사용할 수 있다.
① virtual 메서드의 내부 작동 방식
C++에서 클래스를 컴파일하면 그 클래스의 모든 메서드를 담은 바이너리 객체가 생성된다. 그런데 컴파일러는 virtual로 선언되지 않은 메서드를 호출하는 부분을 컴파일 시간에 결정된 타입의 코드로 교체한다. 이를 정적 바인딩(static binding) 또는 이른 바인딩(early binding)이라 부른다. 한편, 메서드를 virtual로 선언하면 가상 테이블(vtable)이라 부르는 특수한 메모리 영역을 활용해서 가장 적합한 구현 코드를 호출한다. virtual 메서드가 하나 이상 정의된 클래스마다 vtable이 하나씩 있는데, 이 클래스로 생성한 객체마다 이 vtable에 대한 포인터를 갖게 된다. 그래서 객체에 대해 메서드를 호출하면 vtable을 보고 그 시점에 적합한 버전의 메서드를 실행한다. 이를 동적 바인딩(dynamic binding) 또는 늦은 바인딩(late binding)이라 부른다.
다음과 같이 정의된 Parent와 Child 클래스를 통해 가상 테이블로 메서드 오버라이드를 처리하는 과정을 더 구체적으로 살펴보자.
class Parent {
public:
virtual void f1();
virtual void f2();
void f3();
};
class Child {
public:
virtual void f2();
void f3();
};
Parent myParent;
child myChild;
myParent 객체는 vtable에 대한 포인터를 갖고 있으며 이 가상 테이블에는 f1과 f2에 대한 항목이 있다. 각 항목은 Parent::f1과 Parent::f2의 구현 코드를 가리킨다. myChild 객체도 마찬가지로 vtable에 대한 포인터를 가지며 f1과 f2에 대한 항목으로 구성된다. 그런데 Child 클래스가 f1을 오버라이드하지 않았기 때문에 f1에 대한 항목은 Parent::f1을 가리킨다. 반면, f2에 대한 항목은 Child::f2를 가리킨다. 여기서 주목할 점은 두 가상 테이블 모두 f3 메서드에 대한 항목을 가지지 않는다는 점이다. 이 메서드는 virtual로 선언되지 않았기 때문이다.
② virtual 키워드가 필요한 이유
C++에서 모든 메서드를 virtual로 처리하면 안 된다고 주장하는 사람도 있다. 그 이유는 애초에 vtable에 대한 오버헤드를 줄이기 위해 virtual 키워드를 만들었기 때문이다. 그 당시 C++를 디자인한 사람은 성능을 최대한 높일 수 있도록 이러한 결정을 프로그래머가 내리는 것이 낫다고 판단했다. 오버라이드할 일이 없는 메서드라면 굳이 virtual로 만들어서 오버헤드를 발생시킬 필요가 없기 때문이다. 하지만 최신 CPU를 사용할 때는 이 과정에서 발생하는 오버헤드가 나노초 단위로 미미하다. 따라서 모든 메서드를, 그중에서도 특히 소멸자는 virtual로 선언하는 관례를 따르는 것이 바람직하다.
③ virtual 소멸자의 필요성
소멸자를 virtual로 선언하지 않으면 객체가 소멸할 때 메모리가 해제되지 않을 수 있다. 클래스를 final로 선언할 때를 제외한 나머지 경우는 항상 소멸자를 virtual로 선언하는 것이 좋다.
CAUTION 특별한 이유가 없거나 클래스를 final로 선언하지 않았다면 소멸자를 포함한 모든 메서드를 virtual로 선언한다.
■ 10.1.2.5 메서드 오버라이딩 방지
메서드도 final로 지정할 수 있다. 메서드를 final로 지정하면 자식 클래스에서 오버라이드할 수 없다. 예를 들면 다음과 같다.
class Parent {
public:
virtual void parentMethod() final; /* 이 메서드를 오버라이드하지 못합니다. */
virtual ~Parent() = default;
};
class Child {
public:
virtual void parentMethod() override; /* 컴파일 에러 */
};
이 상태에서 다음과 같이 Child 클래스에서 parentMethod를 오버라이드하면 컴파일 에러가 발생한다. Parent 클래스에서 이 메서드를 final로 선언했기 때문이다.
10.2 코드 재사용을 위한 상속
C++에서 상속을 이용하면 기존에 작성된 코드를 그대로 활용하여 자신이 원하는 요구사항에 딱 맞는 클래스를 만들 수 있다. 게다가 부모 클래스에 있던 기능을 재사용하기 때문에 새로 작성할 코드도 많지 않다.
10.2.1 WeatherPrediction 클래스
간단한 일기예보 프로그램을 작성한다고 하자. WeatherPrediction 클래스의 정의는 다음과 같다.
class WeatherPrediction {
public:
virtual ~WeatherPrediction();
virtual void setCurrentTempFahrenheit(int temp); /* 현재 온도를 화씨로 설정합니다. */
virtual void setPositionOfJupiter(int distanceFromMars);/* 목성과 화성 사이의 거리를 설정합니다. */
virtual int getTomorrowTempFahrenheit() const; /* 내일 온도를 불러옵니다. */
virtual double getChanceOfRain() const; /* 내일 비가 올 확률을 불러옵니다. */
virtual void showResult() const; /* 사용자에게 결과를 출력합니다. */
virtual std::string getTemperature() const; /* 오늘 온도를 문자열로 반환합니다. */
private:
int mCurrentTempFahrenheit;
int mDistanceFromMars;
};
이 클래스는 우리가 만들 프로그램의 요구사항에 딱 맞지 않다. 첫째, 온도가 모두 화씨 단위다. 우리 프로그램은 섭씨로도 표현해야 한다. 둘째, showResult 메서드에 출력하는 결과의 형식을 바꾸고 싶다.
10.2.2 자식 클래스에서 기능 추가하기
상속의 주목적은 기능 추가에 있다. 여기서 작성할 프로그램은 WeatherPrediction 클래스에 몇 가지 기능을 더 추가해야 한다. 먼저 WeatherPrediction 클래스를 상속하는 MyWeatherPrediction 클래스를 다음과 같이 새로 정의한다.
class MyWeatherPrediction : public WeatherPrediction {
public:
virtual void setCurrentTempCelsius(int temp);
virtual int getTomorrowTempCelsius() const;
private:
static int convertCelsiusToFahrenheit(int Celsius);
static int convertFahrenheitToCelsius(int Fahrenheit);
};
섭씨 단위를 지원하기 위한 첫 단계는 사용자가 현재 온도를 섭씨 단위로 설정하는 메서드와 내일 예상 온도를 섭씨 단위로 받는 메서드를 추가하는 것이다. 또한, 섭씨와 화씨를 양방향으로 변환하는 private 헬퍼 메서드도 정의한다.
void MyWeatherPrediction::setCurrentTempCelsius(int temp) {
int fahrenheitTemp = convertCelsiusToFahrenheit(temp);
setCurrentTempFahrenheit(fahrenheitTemp);
}
마찬가지로 getTomorrowTempCelsius의 구현 코드에서도 부모 클래스의 기능을 이용하여 현재 온도를 화씨 단위로 가져온 다음 이를 섭씨로 변환해서 반환한다.
int MyWeatherPrediction::getTomorrowTempCelsius() const {
int fahrenheitTemp = getTomorrowTempFahrenheit();
return convertFahrenheitToCelsius(fahrenheitTemp);
}
10.2.3 자식 클래스에서 기능 변경하기
상속의 또 다른 목적은 기존 기능을 변경하는 데 있다. 여기서는 WeatherPrediction 클래스에 있는 showResult 메서드가 결과를 조금 다르게 출력하도록 수정할 필요가 있다. 새로 정의한 MyWeatherPredictoin 클래스 코드는 다음과 같다.
class MyWeatherPrediction : public WeatherPrediction {
public:
virtual void setCurrentTempCelsius(int temp);
virtual int getTomorrowTempCelsius() const;
virtual void showResult() const override; /* showResult 메서드를 오버라이드합니다. */
private:
static int convertCelsiusToFahrenheit(int Celsius);
static int convertFahrenheitToCelsius(int Fahrenheit);
};
showResult에서 결과를 구체적으로 출력하도록 다음과 같이 구현했다.
void MyWeatherPrediction::showResult() const {
cout << "내일 예상 온도는 섭씨 " << getTomorrowTempCelsius() << "도(화씨 "
<< getTomorrowTempFahrenheit() << "도)입니다." << endl;
cout << "내일 비가 올 확률은 "<< (getChanceOfRain() * 100) << "퍼센트입니다." << endl;
}
이렇게 객체를 MyWeatherPrediction 타입으로 생성하면 새로 구현한 메서드가 호출된다.
'Object Oriented Programming(C++) > 전문가를 위한 C++' 카테고리의 다른 글
전문가를 위한 C++ | Chapter 10 상속 활용하기(하) (0) | 2022.02.10 |
---|---|
전문가를 위한 C++ | Chapter 10 상속 활용하기(중) (0) | 2022.02.07 |
전문가를 위한 C++ | Chapter 07 메모리 관리(하) (0) | 2022.01.29 |
전문가를 위한 C++ | Chapter 07 메모리 관리(상) (0) | 2022.01.20 |
전문가를 위한 C++ | Chapter 09 클래스와 객체 마스터하기(하) (0) | 2022.01.16 |
댓글