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

9.3 부모를 존중하라

파생 클래스를 작성할 때는 베이스 클래스와의 연동을 고려해야 한다. 생성 순서, 생성자 체이닝, 캐스팅 같은 부분은 모두 잠재적인 버그의 온상이다.


9.3.1 부모의 생성자

객체는 그냥 생성될 수 없다. 베이스 클래스와 그에 속한 객체들이 함께 생성되어야 한다. C++는 생성 순서를 다음과 같이 정하고 있다.

  1. 클래스가 베이스 클래스를 가졌으면 베이스 클래스의 디폴트 생성자가 실행된다. 단, 파생 클래스의 생성자 초기화 리스트에서 명시적으로 베이스 클래스의 특정 생성자를 호출하고 있다면, 그 생성자가 호출된다.
  2. static이 아닌 클래스 맴버들이 선언 순서에 맞춰 생성된다.
  3. 클래스의 생성자 바디가 실행된다.

이러한 생성 순서는 재귀적으로 적용된다. 만약 클래스가 조부모를 가졌으면, 조부모는 부모에 앞서 생성된다. 더 앞선 부모가 있을 때도 마찬가지로 적용된다. 다음 코드는 이러한 생성 순서를 보여준다.

 

다음 코드에서는 앞서 권장했던 방식과 달리 클래스 정의에 함수 구현부를 바로 포함하고 있다. 이것은 순전히 지면 관계상 예제를 보여주기 편리한 방법을 선택한 것으로 구현부를 별도 파일에 위치시키라는 권고를 저버린 것은 아니다.

밑 코드의 실행결과는 '123'이 된다.

class Something
{
	public:
    	sumthing() { cout << "2"; }
};
class parent
{
	public:
    	Parent() {cout << "1"; }
};
class Child : pulbic Parent
{
	public:
    	Child() { cout << "3"; }
    private:
		Somthing mDataMember;
};
int main()
{
	Child myChild;
    return 0;
}

 

myChild객체가 생성되면 Parent의 생성자가 가장 먼저 호출되면서 '1'을 출력한다. 다음으로 mDataMember가 초기화되면서 Something의 생성자가 호출되어 '2'가 출력된다. 마지막으로 Child의 생성자가 호출되면서 '3'을 출력한다.

 

Parent의 생성자가 자동으로 호출되었다는 점에 주목하자. C++는 부모 클래스의 디폴트 생성자가 있을 때 자동으로 호출해준다. 만약 부모 클래스의 디폴트 생성자 호출을 원치 않거나, 디폴트 생성자가 정의되어 있지 않는 상황이라면 생성자 초기화 리스트에서 데이터 멤버를 초기화할 때 직접 부모 클래스의 생성자를 지정할 수 있다.

 

다음 코드에서 Super는 디폴트 생성자가 없다. Super를 상속받는 Sub에서는 명시적으로 Super의 생성자를 지정하고 있다.

만약, Sub의 생성자에서 Super의 생성자를 명시적으로 지정하지 않으면, 컴파일러가 Super의 어떤 생성자를 호출해야 할지 알 수 없기 때문에 컴파일 에러가 발생한다.

 

Class Super
{
	pulbic:
    	Super(int i);
};
class Sub : public Super
{
	public:
    	Sub();
};
Sub::Sub() : Super(7)
{
	// 여기서 Suba의 다른 초기화 작업을 수행
}

 위 코드에서 Sub의 생성자는 Super의 생성자 파라미터로 상수값 7을 넘겨받 고 있다. 만약 사용자로부터의 동적인 파라미터 전달이 필요하다면, 다음처럼 Sub의 생성자 파람미터를 Super의 생성자에 넘겨줄 수도 있다.

Sub::Sub(int i) : Super(i) {}

파생 클래스의 생성자 파라미터를 베이스 클래스의 생성자에 넘겨주는 것은 아주 일반적인 사용 예로 문제될 것이 전혀 없다.

하지만 파생 클래스의 데이터 멤버를 베이스 클래스의 생성자 파라미터로 넘기는 것은 제대로 작동하지 않는다.

코드는 컴파일되겠지만 멤버의 초기화는 베이스 클래스가 초기화된 다음에 일어나기 때문에 실제 어떤 값이 넘겨질지 보증하기 어렵다.

 

생성자 안에서는 virtual 메서드의 동작이 달라진다. 파생 클래스에서 베이스 클래스의 virtual메서드를 오버라이딩하고 있더라도 베이스 클래스의 생성자 안에서는 베이스 클래스의 메서드가 호출된다.


9.3.2 부모의 소멸자

소멸자는 인자를 가질 수 없음으로 언어 차원에서 자동으로 베이스 클래스의 생성자를 호출할 수 있다. 소멸자의 호출 순서는 다음과 같이 생성자의 호출 순서와 정반대 이다.

  1. 클래스의 소멸자 바디 호출
  2. 클래스의 데이터 멤버를 생성 순서와 반대순으로 삭제
  3. 부모 클래스가 있다면 소멸자를 호출

다시 말하지만, 이러한 룰은 재귀적으로 적용된다. 가장 마지막에 성너된 멤버, 즉 가장 마지막에 생성된 멤버가 가장 먼저 삭제된다. 다음 코드는 이전 예제에 소멸자를 추가한 것이다. 

 

소멸자는 모두 virtual로 선언된다. 소멸자를 virtual로 선언하는 것은 매우 중요한데, 그 이유에 대해서는 나중에 다시 설명한다. 다음 코드가 실행되면 '123321'을 출력한다.

class Somethig
{
	public:
    	Something() { cout << "2"; }
        virtual ~Something() { cout << "2"; }
};
class Parent
{
	public:
    	Parent() { cout << "1"; }
        virtual ~Parent() { cout << "1"; }
};
class Child : pulbic Parent
{
	pulbic:
		Child() { cout << "3"; }
        virtual ~Child() { cout << "3"; }
    private:
        	Something mDataMember;
};
int main()
{
	Child myCHild;
    return 0;
}

소멸자는 무조건 virtual로 선언해두는 것이 안전하다. 위 코드에서는 소멸자를 virtual로 선언하지 않아도 실행은 잘 된다. 하지만 이 객체가 Super타입의 포인터로 delete된다면 Sub의 소멸자가 아닌 Super의 소멸자가 불리면서 소멸 순서가 엉망이 되어버린다.

 

예를 들어 다음 코드는 위 예제와 비슷하지만 소멸자가 virtual로 선언되지 않은 경우다. 이러한 경우자식 객체가 부모 클래스 타입의 포인터로 delete될 때 문제가 발생한다.

class Somethig
{
	public:
    	Something() { cout << "2"; }
        ~Something() { cout << "2"; } // 작동은 하지만 virtual로 선언하는것이 좋다.
};
class Parent
{
	public:
    	Parent() { cout << "1"; }
        ~Parent() { cout << "1"; } // 버그! 반드시 vritual이어야 한다!
};
class Child : pulbic Parent
{
	pulbic:
		Child() { cout << "3"; }
        ~Child() { cout << "3"; } // 작동은 하지만 virtual로 선언하는 것이 좋다.
    private:
        	Something mDataMember;
};
int main()
{
	Parent* ptr = new Child ();
    return 0;
}

위 코드의 실행 결과는 엉뚱하게도 '1231'이다. prt변수가 delete될 때 Parent의 소멸자만 호출된다. 소멸자가 virtual로 선언되지 않았기 때문이다. 결과적으로 child의 소멸자는 호출되지 않았고 그 데이터 멤버도 삭제되지 않았다.

 

이 문제는 Parent의 소멸자만 virtual로 만들면 해결된다. 부모 클래스에서 virtual로 선언했기 때문에, 파생 클래스에서도 자동으로 virtual로 적용된다. 하지만 불필요한 불안 요소를 없애려면 모든 소멸자를 virtual로 선언하는것이 바람직하다.

 

컴파일러가 생성하는 디폴트 소멸자는 vritual이 아니다! 그러므로 항상 소멸자를 명시적으로 정의하여 virtual로 선언해주어야 한다! 전부 할 수 없다면 부모 클래스만이라도 꼭 해주어야 한다.

 

생성자에서 그랬듯이 소멸자에서도 virtual메서드의 동작 방식이 달라진다. 파생 클래스에서 오버라이딩 하고 있는 vritual 메서드라 하더라도 베이스 클래스의 소멸자에서 호출할 때는 오버라이딩 된 파생 클래스의 메서드가 아니라 베이스 클래스의 메서드를 호출한다.


9.3.3 부모 클래스의 참조

파생 클래스에서 메서드를 오버라이딩하면 원래 메서드가 완전히 교체된다. 하지만 오버라이딩을 했더라도 베이스 클래스의 원래 메서드가 필요한 경우가 있다. 예를 들어 오버라이딩 자체가 원본 메서드의 작업을 그대로 수행한 후 다른 작업을 더하는것일 수 있다. WeatherPerdiction클래스의 getTemperature()메서드를 살펴보자. 이 메서드는 현재 온도를 string타입으로 리턴한다.

 

class WeatherPrediction
{
	public:
    	virtual std::string getTemperature() const;
        // 코드 생략
};

이 메서드를 MyWeatherPrediction에서 다음처럼 오버라이딩 할 수 있다.

class MyWeatherPrediction : public WeatherPrediction
{
	public:
		virtual std::string getTemperature() cosnt override;
        // 코드 생략
};

파생 클래스에서 화씨 기호 F를 추가하고 싶다면, 베이스 클래스의 getTempreature() 메서드의 결과 문자열에 F 문자를 더하면 된다.

string MyWeatherPrediction::getTemperature() const
{
	// \u00B0는 ISO/IEC 10646 표준에 따라 온도를 나타내는 기호를 의미한다.
    return getTemperature() + "\u00B0F"; // 버그!
}

하지만 위 코드는 의도한 대로 작동하지 않는다. C++에서는 먼저 현재의 스코프, 그 다음으로 클래스 스코프에서 메서드 이름을 찾기 때문에 결국 getTemperature() 호출은 MyWeatherPrediction::getTemperature() 호출과 같다. 이 때문에 이 코드는 스택 메모리가 부족해져서 강제 종료될때까지 재귀적으로 무한히 반복된다

(어떤 컴파일러는 컴파일 타임에 문제 가능성을 발견하고 경고나 에러를 발생시킨다.)

 

의도대로 작동하는 코드를 만들려면 다음과 같이 스코프 지정 연산자를 사용해야 한다.

string MyWeatherPrediction::getTemperature() const
{
	// \u00B0는 ISO/IEC 10646 표준에 따라 온도를 나태는 기호를 의미한다.
    return WeatherPrediction::getTemperature() + "\u00B0F";
}

오버라이딩하는 메서드 안에서 베이스 클래스의 같은 메서드를 부르는 것은 C++에서 아주 흔한 패턴이다. 만약 파생 클래스가 줄줄이 연결되어 있다면 파생 클래스마다 이미 존재하는 베이스 클래스의 기능을 재활용하여 기능을 추가하고 싶을 것이다.

 

이러한 예로 다음과 같은 도서의 분류 계층을 생각해보자.

각 클래스 계층이 책의 부분적인 속성 종류별로 나뉘어 있기 때문에 책 하나에 대한 설명을 얻기 위해서는 베이스 클래스에서 파생 클래스까지의 모든 정보가 있어야 한다. 이렇게 정보를 모으기 위해서는 베이스 클래스에서 파생 클래스까지 메서드를 차례로 모두 호출해서 그 결과를 누적해야 한다.

 

다음 코드는 이러한 패턴의 예 이다.

class Book
{
	public:
	virtual ~Book() {}
    virtual string getDescription() const { return "Book"; }
    virtual int geHeignt() const { return 120; }
};
class Paperback : public Book
{
	public:
    	virtual string getDescription() const override {
        	return "Paperback " + Book::getDescription();
        }
};
class Romance : pulbic paperback
{
	pulbic:
    	virtual string getDescription() const override {
        	return "Romance " + Paperback::getDescription();
        }
        virtual int getHeignt() const override {
        	return Paperback::getHeight() / 2;
        }
};
class Technical : pulbic Book
{
	public:
		virtual string getDescription() const override {
        	return "Technical " + Book::getDescription();
        }
};
int main()
{
	Romance novel;
    Book book;
    cout << novel.getDescription() << endl; // ROmance Paperback Book을 출력
    cout << book.getDescription() << endl; // Book을 출력
    cout << novel.getHeight() << endl; 		// '60'을 출력
    cout << book.getHeight() << endl;		// '120'을 출력
    return 0;
}

Book 클래스에서 정의하고 있는 virtual getHeight() 메서드는 120을 리턴한다. 그런데 Romance클래스만이 이 메서드를 베이스 클래스인 PaperBack으로부터 오버라이딩하여 다음처럼 결과의 1/2값을 리턴하도록 바꾸고 있다.

virtual int getHeight() const override { return { Paperback::getHeight() / 2; }

그런데 스코프 지정 연산자로Paperback을 지정하더라도 Paperback은 getHeight()를 오버라이딩하고 있지 않기 떄문에 상위 계층을 뒤져서 처음 만나는 getHeight()메서드를 호출한다.

 

이 예제에서는 Paperback::getHeight대신 Book::getHeight()를 호출한다.

 

9.3.4 업캐스팅과 다운케스팅

상위(부모) 클래스로 타입 캐스팅하는 것을 업캐스팅(up-casting)이라고 한다. 앞서 보았듯이 객체는 부모 클래스로 업캐스팅되거나 부모 클래스의 객체에 대입할 수 있다. 만약 다음처럼 객체를 대상으로 캐스팅이나 대입이 일어나면 자식 클래스의 특징이 사라지는 슬라이싱이 발생한다.

Super mySuper = mySub; // 슬라이싱 발생!

위와 같이 객체 간 대입으로서 SUb객체를 Super객체로 업캐스팅하면 Sub의 자식 클래스로서의 특징들이 빠진 채 SUper객체의 값이 Sub객체의 값으로 업데이트된다. 하지만 다음처럼 참조형 변수의 초기화 방식으로 업캐스팅을 하면 슬라이싱이 발생하지 않는다.

Super& mySuper = mySub; // 슬라이싱이 발생하지 않는다!

파생 클래스의 객체를 베이스 클래스 타입으로 이용할 때, 즉 업캐스팅을 할 때는 이렇게 참조형을 사용하는 것이 올바른 방법이다. 메서드나 함수에서 객체를 이용해야 할 떄는 사용할 클래스 타입으로 직접 접근해서는 안 되고 항상 참조형으로 접근해야 한다. 그렇게 해야 슬라이싱을 피하고 파생 클래스의 객체를 그대로 사용할 수 있다.

 

베이스 클래스로 업캐스팅을 할 떄는 포인터 또는 참조를 이용하여 슬라이싱이 발생하지 않도록 한다.

 

하위(자식) 클래스로 타입 캐스팅하는것을 다운 캐스팅 이라고 한다. 다운 캐스팅은 일반적으로 정삭동작이 보증되지 않기 때문에, 전문 개발자는 아예 사용하지 않는다. 예를 들어 다음 코드를 보자.

void presumptuous(Super* inSuper)
{
	Sub* mySub = static_cast<Sub*>(inSuper);
    // mySUb를 통해 Sub의 멤버와 메서드에 접근
}

만약 presumptuous()를 호출해서 사용하는 사람이 presumptuous()의 작성자와 같다면 위 코드가 문제없이 구동될 수도 있다.

 

함수 안에서 다운 캐스팅이 일어난다는 사실을 알기 때문에 presumptuous()의 파라미터를 신중하게 선택할 것이기 때문이다 그게 아니라면 위 코드는 큰 문제가 된다. 다운 캐스팅은 컴파일 타임에 검증이 안 되기 때문에 이 코드는 아무런 근거도 없이 inSuper가 가리키는 포인터가 Sub의 객체일 거라고 가정해버린다.

 

다운 캐스팅이 필요한 경우도 있다. 단, 전체 이용 환경을 모두 통제할 수 있어야 한다. 다운 케스팅을 꼭 써야 한다면 dynamic_cast를 이용해야 한다. static_cast와 달리 dynamic_cast는 객체에 저장된 정보를 이용하여 해당 캐스팅이 적합한지 런타임에검사하여 문제가 있으면 캐스팅을 거부하고 에러를 발생시킨다.

 

캐스팅 실패가 포인터 변수에 대해 발생하면 무의미한 객체를 가리키는 대신 포인터 값이 nullptr이 된다. 만약 참조형 변수에 대해 캐스팅이 실패했다면 std::bac_cast 익셉션이 발생한다.

 

위 코드를 올바르게 고치면 다음과 같아진다.

void lessPresumptuous(Super* inSuper)
{
	Sub* mySub = dynamic_cast<Sub*>(inSuper);
    if (mySub != nullptr) {
    	// mySub를 통해 Sub의 멤버와 메서드에 접근
    }
}

 

다운 케스팅은 필요한 경우에만 사용하고, 이 때 dynamic_cast를 사용하라

728x90