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

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

by continue96 2022. 2. 10.

10.6 상속에 관련된 미묘하고 흥미로운 문제

10.6.1 오버라이드한 메서드 속성 변경하기

 대부분 메서드의 구현을 번경하기 위해 메서드를 오버라이드하지만, 간혹 이 과정에서 원래 메서드의 속성을 변경할 수도 있다.

 

 10.6.1.1 메서드 리턴 타입 변경하기

 오버라이드할 메서드는 부모 클래스의 메서드 프로토타입(method prototype) 혹은 메서드 선언과 똑같이 작성하는 것이 원칙이다. 하지만 C++는 부모 클래스의 리턴 타입이 다른 클래스에 대한 포인터나 레퍼런스 타입이면, 메서드를 오버라이드할 때 리턴 타입을 다른 클래스의 자식 클래스에 대한 포인터나 레퍼런스 타입으로 바꿀 수 있다. 이런 타입을 공변 리턴 타입(covariant return type)이라 부른다. 예를 들어, 사과 과수원 시뮬레이터가 있다고 하자.

그림 10-6 메서드 리턴 타입 변경하기

class Apple { ... };
class GreenApple : public Apple { ... };

class AppleTree {
public:
	virtual Apple* pick() { /* pick 메서드입니다. */
		return new Apple();
	}
};

class GreenAppleTree {
public:
	virtual Apple* pick() override { /* virtual GreenApple* pick() override로 리턴 타입을 변경할 수 있습니다. */
		auto theApple = std::make_unique<GreenApple>();
		theApple.polish();
		return theApple.release(); /* 항상 GreenApple 객체를 리턴합니다. */
	}
};

 오버라이드하는 과정에서 기존 코드가 제대로 작동하는지 확인하는 리스코프 치환 원칙(liskov substitution principle, LSP)으로 메서드의 리턴 타입을 변경해도 되는지 알아낸다. 이 원칙에 따라 리턴 타입을 void*와 같이 전혀 관련 없는 타입으로는 변경할 수 없다.

 

 10.6.1.2 메서드 매개 변수 변경하기

 자식 클래스를 정의하는 코드에서 virtual 메서드를 선언할 때 이름은 부모 클래스에 있는 것과 똑같이 쓰고 매개 변수를 다르게 지정하면 부모 클래스의 메서드가 오버라이드 되지 않고 새로운 메서드가 정의된다.

class Parent {
public:
	virtual void parentMethod();
};

class Child : public Parent {
public:
	virtual void parentMethod(int i); /* 부모 클래스의 parentMethod가 오버라이드되지 않습니다. */
};

Child myChild;
myChild.parentMethod(); /* 컴파일 에러 */

 C++ 표준에 따르면 Child 클래스에서 메서드를 이렇게 정의하면 부모 클래스에 있던 원래 메서드를 숨긴다. 따라서 Parent에 있는 parentMethod를 오버라이드하려 했다면 override 키워드를 붙이고 코드에 잘못된 부분이 있을 때 컴파일러가 에러를 발생시키도록 해야 한다.

 

10.6.2 생성자 상속하기

 using 키워드를 사용하여 부모 클래스의 생성자를 상속할 수 있다. 다음과 같이 정의된 Parent와 Child 클래스를 살펴보자.

class Parent {
public:
	virtual ~Parent() = default;
	Parent() = default;
	Parent(std::string_view strView);
	Parent(float f);
};

class Child : public Parent {
public:
	using Parent::Parent; /* Parent 생성자를 명시적으로 상속합니다. */
	Child(int i);
	Child(float f); /* float 버전 Parent 생성자를 오버라이드합니다. */ 
};

Child firstChild(1); /* int를 인수로 받는 Child 생성자를 호출합니다. */
Child secondChild("Hello"); /* string_view를 인수로 받는 Parent 생성자를 호출합니다. */
Child thirdChild("10.10f"); /* float를 인수로 받는 Child 생성자를 호출합니다. */

 일반드로 Parent 클래스에 정의된 string_view 인수를 받는 생성자로는 Child 객체를 만들 수 없다. string_view 인수를 받는 Parent 생성자로 Child 객체를 만들고 싶다면 위와 같이 using 키워드로 Child 클래스에서 Parent 생성자를 명시적으로 상속해야 한다.

 using 구문으로 부모 클래스의 생성자를 상속할 때는 부모 클래스의 디폴트 생성자를 제외한 모든 생성자를 상속한다. 또한, 다중 상속과 관련해서 여러 부모 클래스에서 매개 변수 목록이 같은 생성자를 상속할 수 없다. 어느 부모에 있는 생성자를 호출해야 할지 알 수 없기 때문이다. 이럴 때는 충돌이 발생한 생성자를 명시적으로 선언해야 한다.

 

10.6.3 특수한 메서드 오버라이딩

 10.6.3.1 static 메서드 오버라이딩

 C++는 static 메서드를 오버라이드할 수 없다. 먼저 메서드에 static과 virtual을 동시에 지정할 수 없다. 또한, 자식 클래스에 있는 static 메서드의 이름이 부모 클래스의 static 메서드와 같으면 서로 다른 메서드가 각각 생성된다.

class Parent {
public:
	static void beStatic() {
		cout << "부모 클래스의 정적 메서드입니다." << endl;
	}
};

class Child : public Parent {
public:
	static void beStatic() {
		cout << "자식 클래스의 정적 메서드입니다." << endl;
	}
};

Parent::beStatic(); /* 부모 클래스의 정적 메서드입니다. */
Child::beStatic(); /* 자식 클래스의 정적 메서드입니다. */
NOTE static 메서드의 유효 범위는 클래스 이름으로 결정된다. static 메서드를 호출할 때는 C++의 이름 결정(name resolution)에 따라 결정된 버전이 실행된다.

 

 10.6.3.2 오버로드된 메서드 오버라이딩

 부모 클래스에 다양한 버전으로 오버로드된 메서드가 있는데 그중 한 버전을 오버라이드하면 컴파일러는 부모 클래스에 있는 다른 버전의 메서드를 숨겨버린다. 예를 들어 다음 코드를 보자.

class Parent {
public:
	virtual ~Parent() = default;
	virtual void overload() {
		cout << "부모 클래스의 overload 메서드입니다." << endl;
	}
	virtual void overload(int i) {
		cout << "부모 클래스의 overload(int i) 메서드입니다." << endl;
	}
	virtual void overload(double d) {
		cout << "부모 클래스의 overload(double d) 메서드입니다." << endl;
	}
};

class Child : public Parent {
public:
	using Parent::overload;
	virtual void overload(int i) override {
		cout << "자식 클래스의 overload(int i) 메서드입니다." << endl;
	}
};

int main(void) {
	Child myChild;
	myChild.overload(); /* 부모 클래스의 overload 메서드입니다. */
	myChild.overload(5); /* 자식 클래스의 overload(int i) 메서드입니다. */
	myChild.overload(5.0); /* 부모 클래스의 overload(double d) 메서드입니다. */
	return 0;
}

 실제로 오버라이드하고 싶은 버전은 하나뿐인데 그것만 오버라이드하면 부모 클래스에 있는 나머지 메서드가 모두 가려지는 문제 때문에 자식 클래스에서 다시 모든 버전을 오버로드하는 것은 너무 번거롭니다. 이럴 때는 using 키워드를 사용하여 나머지는 Base에 있는 것을 명시적으로 오버로드한다.

NOTE 오버로드된 메서드를 오버라이드하려면 메서드의 모든 버전을 명시적으로 오버라이드하거나 using 키워드를 적절히 활용한다.

 

 10.6.3.3 private·protected 메서드 오버라이딩

 private나 protected 메서드도 얼마든지 오버라이드할 수 있다. 메서드에 대한 접근 지정자는 그 메서드를 호출할 수 있는 대상만 제한할 뿐, 자식 클래스에서 부모 클래스의 private 메서드를 호출할 수 없다고 해서 오버라이드할 수 없는 것은 아니다. 예를 들어 다음과 같이 정의한 클래스를 살펴보자.

class MilesEstimator {
public:
	MilesEstimator() : mGallonsLeft(0) { };
	virtual ~MilesEstimator() = default;
	virtual int getMilesLeft() const {
		return getMilesPerGallon() * getGallonsLeft();
	}

	virtual void setGallonsLeft(int gallons) {
		mGallonsLeft = gallons;
	}

	virtual int getGallonsLeft() const {
		return mGallonsLeft;
	}
private:
	int mGallonsLeft;
	virtual int getMilesPerGallon() const {
		return 20;
	}
};

class EfficientMilesEstimator : public MilesEstimator {
private:
	virtual int getMilesPerGallon() const override { /* private 메서드를 오버라이드합니다. */
		return 40;
	}
};

int main(void) {
	MilesEstimator myEstimator;
	EfficientMilesEstimator myEfficientEstimator;
	myEstimator.setGallonsLeft(5);
	myEfficientEstimator.setGallonsLeft(5);
	cout << myEstimator.getMilesLeft() << endl; /* 100 */
	cout << myEfficientEstimator.getMilesLeft() << endl; /* 200 */
}

 이렇게 기존 클래스의 public 메서드를 건드리지 않고 새로 정의한 클래스에서 private 메서드 하나만 오버라이드해도 동작을 이전과 완전히 다르게 변경할 수 있다.

NOTE 기존 클래스의 전체적인 골격은 그대로 유지한 채 특정한 기능을 변경할 때는 private나 protected 메서드를 오버라이드하는 것이 좋다.

 

 10.6.3.4 디폴트 인수가 있는 메서드 오버라이딩

 자식 클래스와 부모 클래스에서 메서드에 지정한 디폴트 인수가 서로 다를 수 있다. 이때 적용되는 인수는 변수에 선언된 타입에 따라 결정된다. 다음과 같이 자식 클래스에서 메서드를 오버라이드할 때 디폴트 인수를 다르게 지정한 경우를 보자.

#include <iostream>
using namespace std;

class Parent {
public:
	virtual ~Parent() = default;
	virtual void go(int i = 5) {
		cout << "부모 클래스의 go(int i = " << i << ") 메서드입니다." << endl;
	}
};

class Child : public Parent {
public:
	virtual void go(int i = 10) override {
		cout << "자식 클래스의 go(int i = " << i << ") 메서드입니다." << endl;
	}
};

 특이하게도 Child 객체를 가리키는 Parent 타입이나 Parent 레퍼런스로 선언된 변수로 go를 호출하면 Child 버전의 go 코드가 실행되지만 디폴트 인수는 Parent에 지정된 2가 적용된다. 예를 들면 다음과 같다.

int main(void) {
	Parent myParent;
	Child myChild;
	Parent& myParentRef = myChild;

	myParent.go(); /* 부모 클래스의 go(int i = 2) 메서드입니다. */
	myChild.go(); /* 자식 클래스의 go(int i = 7) 메서드입니다. */
	myParentRef.go(); /* 자식 클래스의 go(int i = 2) 메서드입니다. */
	return 0;
}

 이렇게 실행되는 이유는 C++에서 디폴트 인수는 상속되지 않고, C++에서 실행 시간의 타입이 아닌 컴파일 시간의 타입을 보고 디폴트 인수를 결정하기 때문이다.

NOTE 디폴트 인수가 지정된 메서드를 오버라이드할 때는 자식 클래스에서도 반드시 디폴트 인수를 똑같은 값으로, 특히 기호 상수(symbolic constant)로 지정하는 것이 좋다.

 

 10.6.3.5 접근 범위가 다른 메서드 오버라이딩

① 접근 권한을 좁히는 경우

 메서드나 데이터 멤버에 대해 접근 권한을 좁히는 방법은 자식 클래스에서 접근 지정자를 다르게 지정하는 것이다.

class Gregarious {
public:
	virtual void talk() { cout << "안녕." << endl; }
};

class Shy : public Gregarious {
protected:
	virtual void talk() override { cout << "안녕하세요." << endl; }
};

Shy myShy;
Gregarious& myShyRef = myShy;
myShy.talk(); /* protected 메서드이므로 컴파일 에러가 발생합니다. */
myShyRef.talk(); /* 안녕하세요. */

 외부에서 Shy 객체의 talk을 호출하면 컴파일 에러가 올바르게 발생한다. 그런데 Gregarious 타입의 레퍼런스나 포인터를 사용하면 이 talk에 접근할 수 있다. 이처럼 이 메서드의 접근 범위를 protected로 완벽하게 제한할 수는 없다.

NOTE 부모 클래스에서 public으로 선언한 메서드의 접근 범위는 완벽히 좁힐 수 없고, 또한 굳이 좁힐 일도 거의 없다.

 

② 접근 권한을 넓히는 경우

 메서드나 데이터 멤버에 대해 접근 권한을 넓히는 방법은 자식 클래스에서 접근 범위를 public으로 변경하는 것이다.

class Secret {
protected:
	virtual void dontTell() { cout << "절대 말하지 마세요." << endl; }
};

class Blabber : public Secret {
public:
	virtual void dontTell() { cout << "너만 알고 있어." << endl; }
};

Blabber myBlabber;
Secret& myBlabberRef = myBlabber;
myBlabber.dontTell(); /* 너만 알고 있어. */
myBlabberRef.dontTell(); /* protected 메서드이므로 컴파일 에러가 발생합니다. */

 부모 클래스 메서드의 접근 범위는 여전히 protected다. 그러므로 Secret 타입의 레퍼런스나 포인터로 Secret 버전의 dontTell을 외부에서 호출하면 컴파일 에러가 발생한다.

 

10.6.4 자식 클래스의 복사 생성자와 복사 대입 연산자

 자식 클래스에서 복사 생성자를 명시적으로 정의하면 다음 코드처럼 반드시 부모 클래스의 복사 생성자를 호출해야 한다. 그렇지 않으면 객체에서 부모 부분에 대해 복사 생성자가 아닌 디폴트 생성자가 사용된다.

class Parent {
public:
	virtual ~Parent() = default;
	Parent() = default;
	Parent(const Parent& rhs);
};

Parent::Parent(const Parent& rhs) { }

class Child : public Parent {
public:
	Child() = default;
	Child(const Child& rhs);
};

Child::Child(const Child& rhs) : Parent(rhs) { }

 마찬가지로 자식 클래스에서 복사 대입 연산자를 오버라이드하면 부모 버전의 복사 대입 연산자도 함께 호출해야 한다. 다음 코드는 자식 클래스에서 부모 클래스의 복사 대입 연산자를 호출하는 방법을 보여준다.

Child& Child::operator=(const Child& rhs) {
	if (&rhs == this) {
		return *this;
	}
	Parent::operator=(rhs); /* 부모 클래스의 복사 대입 연산자를 호출합니다. */
	/* 자식 클래스의 복사 대입 연산을 수행합니다. */
	return *this;
}
CAUTION 자식 클래스에서 복사 생성자나 복사 대입 연산자를 정의했다면 부모 클래스의 것도 반드시 명시적으로 호출한다.

 

10.6.5 실행 시간 타입 정보

 C++는 실행 시간에 객체를 들여다보는 실행 시간 타입 정보(run-time type information, RTTI)를 제공한다. 예를 들어 dynamic_cast는 객체지향 계층 사이에서 타입을 안전하게 변환해준다. 또한, typeid 연산자는 실행 시간에 객체의 타입 정보를 조회할 수 있다. typeid 연산자는 보통 로깅 및 디버깅 용도로 활용한다.

NOTE typeid 연산자는 클래스에 가상 메서드가 있을 때, 즉 vtable이 있을 때 올바르게 작동한다.

 

10.6.6 private·protected 클래스 상속

 부모 클래스를 상속할 때 private나 protected 키워드로 지정할 수 있다. 부모 클래스에 접근 지정자를 붙이지 않으면 상속할 때 class에 대해서 private, struct에 대해서 public이 적용된다.

 부모 클래스를 protected로 지정하면 부모 클래스에 있던 public 메서드와 데이터 멤버가 자식 클래스에서 protected로 취급된다. 마찬가지로 private로 지정하면 부모 클래스의 public 및 protected 메서드와 데이터 멤버가 자식 클래스에서 private로 취급된다.

부모 클래스 public 상속 protected 상속 private 상속
public public protected private
protected protected protected private
private private private private

 

댓글