본문 바로가기
PROGRAMMING/Effective Modern C++

12. 재정의 함수들을 override로 선언하라.

by La-KanTo 2020. 12. 26.

- 재정의 함수는 override로 선언하라.

재정의가 일어나려면 다음과 같은 여러 필수 조건을 만족해야 한다.

1. 기반 클래스 함수가 반드시 가상 함수이어야 한다.

2. 기반 함수와 파생 함수의 이름이 반드시 동일해야 한다.(소멸자 제외)

3. 기반 함수와 파생 함수의 매개 변수 타입들이 반드시 동일해야 한다.
4. 기반 함수와 파생 함수의 const 성이 반드시 동일해야 한다.

5. 기반 함수와 파생 함수의 반환 타입과 예외 지정이 반드시 호환되어야 한다.

6. 멤버 함수들의 참조 한정자들이 반드시 동일해야 한다.

 

이러한 모든 재정의 요구조건들이 뜻하는 것은 작은 실수가 큰 차이를 빚을 수 있다는 것이다.

재정의 실수가 포함된 코드는 프로그래머가 의도한 것과 다르게 행동하기 마련이지만, 그래도 여전히 유효한 경우가 많다.

 

그래서 C++11은 파생 클래스 함수가 기반 클래스의 버전을 재정의 하려 한다는 의도를 명시적을 표현하는 방법을 제공한다. 바로 그런 파생 클래스 함수를 override로 선언하는 것이다.

 

파생 함수를 override로 선언하면 컴파일러는 재정의 문제점들을 모두 지적해준다.

 

재정의 함수를 override로 선언하지 않으면 다음과 같은 재정의 실수를 해도 문제 없이 컴파일 되는 경우가 많다.

class Base {
public:
    virtual void mf1(void) const;
    virtual void mf2(int x);
    virtual void mf3(void)&;
    void mf4(void) const;
};

class Derived : public Base {
public:
    virtual void mf1(void);
    virtual void mf2(unsigned int x);
    virtual void mf3(void)&&;
    void mf4(void) const;
};

다음은 위의 코드에서 발견된 네 가지 재정의 실수들이다.
1. Base에서는 mf1이 const로 선언되었지만, Derived에서는 그렇지 않다.
2. Base에서는 mf2가 int를 받지만, Derived에서는 unsigned int를 받는다.
3. Base에서는 mf3이 좌측값으로 한정되지만, Derived에서는 우측값으로 한정된다.
4. Base에서 mf4는 virtual로 선언되지 않았다.

이런 실수들이 있음에도 불구하고, 컴파일러는 불만 없이 이 코드들을 컴파일 하는 경우가 많다.
문법적으로 문제가 없기 때문이며, 이런 경우, 재정의가 일어나지 않는다. 그냥 Base 클래스의 멤버 함수들이 가려질 뿐이다.


컴파일러로부터 이러한 재정의 실수들을 지적받고 싶다면 재정의 함수들을 override로 선언해야 한다.

class Derived : public Base {
public:
    virtual void mf1(void) override;
    virtual void mf2(unsigned int x) override;
    virtual void mf3(void) && override;
    virtual void mf4(void) const override;
};

이제 이 코드는 컴파일되지 않는다.
다음은 override를 사용하는, 그리고 제대로 컴파일되는 코드이다.

class Base {
public:
    virtual void mf1(void) const;
    virtual void mf2(int x);
    virtual void mf3(void)&;
    virtual void mf4(void) const;
};

class Derived : public Base {
public:
    virtual void mf1(void) const override;
    virtual void mf2(int x) override;
    virtual void mf3(void) & override;
    void mf4(void) const override; // virtual을 붙여도 되지만, 꼭 그럴 필요는 없다.
};

Base의 mf4를 가상 함수로 선언하는 것도 코드가 제대로 컴파일되게 하는 데 필요한 일임을 주목하기 바란다.
재정의 관련 오류는 대부분 파생 클래스에서 일어나지만, 기반 클래스에서 뭔가 잘못되었을 가능성도 있다.

 

파생 클래스를 override로 선언한다는 방침은, 재정의를 의도한 함수가 실제로는 아무것도 재정의 하지 않는다는 점을 컴파일러가 지적해 주는 것 이상의 장점을 제공한다.

 

그러한 방침은 기반 클래스의 한 가상 함수의 시그니처를 변경했을 때 그 영향이 어느정도 인지 가늠하려 할때에도 도움이 된다.

 

파생 클래스들에서 override를 일관되게 적용했다면, 그냥 기반 함수의 시그니처를 변경하고, 시스템을 다시 컴파일하고, 컴파일에 실패한 파생 클래스가 몇 개나 되는지 파악하고, 그에 따라 해당 시그니처를 그렇게 변경하는 것이 가치가 있는 일인지 판단하면 된다.

 

그러나 override를 사용하지 않았다면, 상세한 단위 테스트들을 마련해 두길 잘했다는 생각이 들것이다.

 

왜냐하면, 앞에서 보았듯이 기반 클래스의 함수들을 재정의하려 했지만 실제로는 그렇지 않은 파생 클래스 가상 함수들에 대해 컴파일러가 아무런 경고도 하지 않기 때문이다.

 

참고로 C++11에는 두 개의 문맵 의존 키워드 override, final가 추가되었다. 이 키워드들은 오직 특정한 문맥에서만 예약어로 작용하는 특성이 있어서, override 나 final이라는 이름을 사용하는 구식 코드가 남아 있다고 해도, C++11을 위해 그 이름을 변경할 필요는 없다.

 

- 멤버 함수 참조 한정사를 이용하면 멤버 함수가 호출되는 객체(*this)의 좌측값 버전과 우측값 버전을 다른 방식으로 처리할 수 있다.

멤버 함수 참조 한정사를 사용하면 멤버 함수를 좌측값에만 또는 우측값에만 사용할 수 있게 제한할 수 있다.
가상 함수가 아닌 멤버 함수에도 멤버 함수 참조 한정사를 적용할 수 있다.

class Widget {
public:
    void doWork(void)&; // doWork의 이 버전은 *this가 좌측값일 때에만 적용된다.
    void doWork(void)&&; // doWork의 이 버전은 *this가 우측값일 때에만 적용된다.

    Widget makeWidget(void); // 팩터리 함수(우측값을 돌려줌)
    Widget w; // 보통 객체(좌측값을 돌려줌)

    w.doWork(); // 좌측값용 Widget::doWork(즉, Widget::doWork &)를 호출한다.
    makeWidget().doWork(); // 우측값용 Widget::doWork(즉, Widget::doWork &&)를 호출한다.
}

좌측값 인수만 받는 함수를 작성하고 싶다면, 비 const 좌측값 참조 매개변수를 선언하면 된다.

void doSomething(Widget& w); // 좌측값 Widget만 받는 함수

우측값 인수만 받는 함수를 작성하고 싶다면, 우측값 참조 매개변수를 선언하면 된다.

void doSomething(Widget&& w); // 우측값 Widget만 받는 함수

멤버 함수 참조 한정사는 멤버 함수가 호출되는 객체, 즉 *this에 대해 이러한 구분이 가능하게 만드는 것일 뿐이다.
멤버 함수 참조 한정사는 주어진 멤버 함수가 호출되는 대상(*this)이 const임을 명시하기 위해 멤버 함수 선언 끝에 붙이는 const와 딱 비슷하다.

 

이런 문법이 필요한 예를 보자. 예를 들어 Widget 클래스에 std::vector 데이터 멤버가 있으며, 그것에 직접 접근할 수 있는 접근용 멤버 함수를 클라이언트에게 제공한다고 하자.

class Widget {
public:
    using DataType = std::vector<double>;
    DataType& data(void) { return values; }
private:
    DataType values;
};

이 클래스의 설계가 아주 잘 캡슐화되어 있다고는 할 수 없겠지만, 그 점은 제쳐 놓고 다음과 같은 클라이언트 코드에서 어떤 일이 일어나는지 생각해 보자.

Widget w;
auto vals1 = w.data(); // w.values를 vals1에 복사

Widget::data의 반환 타입은 좌측값 참조이고 좌측값 참조는 정의상 좌측값으로 취급되므로, 이 코드는 하나의 좌측값으로 vals1을 초기화한다.
따라서, 주석에도 나와 있듯이 vals1은 w.values 로부터 복사 생성된다.

 

다음으로, Widget을 생성하는 팩터리 함수가 있다고 하자.

Widget makeWidget(void);

그리고 이 makeWidget이 돌려준 Widget 객체 안의 std::vector를 이용해서 변수를 초기화한다고 하자.

auto vals2 = makeWidget(void).data(); 

이번에도 Widget::data는 좌측값 참조를 돌려주며, 마찬가지로 vals2는 Widget안의 values로부터 복사 생성된다.
그러나 이번에는 Widget이 makeWidget이 돌려준 임시 객체(즉, 우측값)이다.

 

따라서 그 임시 객체 안의 std::vector를 복사하는 것은 시간 낭비이다.
이를 해소하기 위해 정말로 필요한 것은 data가 우측값 Widget에 대해 호출된 경우에는 반드시 우측값을 돌려주게 하는 것이다.
data를 좌측값 Widget과 우측값 Widget에 대해 개별적으로 오버로딩하면 그런 일이 가능하다.

 

class Widget {
public:
    using DataType = std::vector<double>;

    DataType& data(void)&
    {
        return values; // 좌측값을 반환
    }                

    DataType&& data(void)&&
    {
        return std::move(values); // 우측값을 반환
    }    
    
private:
    DataType values;
};

이제 클라이언트 코드는 우리가 원했던 방식으로 작동한다.

auto vals1 = w.data(); // vals1은 복사 생성됨
auto vals2 = makeWidget().data(); // vals2는 이동 생성됨