Item 12: 재정의(overriding) 함수들을 override로 선언해라

객체 지향 프로그래밍에서 부모 클래스가 정의한 함수를 상속받은 자식 클래스가 재정의(overriding)하는 모습을 쉽게 찾아볼 수 있다.

일반적으로 먼저 정의된 클래스를 기반(base) 클래스, 상속받는 클래스를 파생(derived) 클래스라 부르지만 개인적으로 익숙한 부모/자식 표현을 사용하겠다.

C++에서는 상속과 재정의 함수를 통해 런타임에 부모 클래스 포인터로 여러 자식 클래스 객체를 가리키는 동적 다형성(dynamic polymorphism)을 구현할 수 있다.

// 부모 클래스
class Base {
public:
  virtual void doWork();
};

// 자식 클래스
class Derived : public Base {
public:
  virtual void doWork(); // 자식 함수의 virtual 키워드는 생략 가능
};

// 자식 클래스 객체를 가리키는 부모 클래스 포인터 생성 
std::unique_ptr<Base> upb = std::make_unqiue<Derived>();

// 부모 클래스 포인터로 자식 클래스의 overriding 함수를 호출
upb->doWork();


함수 재정의(overriding) 조건

함수를 overriding 하기 위해서는 다음과 같은 조건을 필수적으로 만족해야한다.

1. 부모 클래스 함수가 반드시 가상 함수이어야 함

가상 함수는 함수의 선언 앞에 virtual 키워드를 붙인다.

class Base {
public:
  virtual void doWork();
};

virtual 키워드를 사용하면 클래스는 실제로 호출해야 할 함수의 주소를 가지고 있는 가상 테이블(virtual table)을 가리키는 포인터(vptr)를 생성한다.

virtual 키워드를 사용해야만 부모 클래스 포인터로 자식 클래스 객체를 가리키고 있을 때 문제없이 자식 클래스의 overriding 함수를 호출할 수 있다.

2. 부모 함수와 자식 함수의 이름이 반드시 동일해야 함 (소멸자는 예외)

당연히 부모 함수와 자식 함수의 이름이 같아야 함수 overriding이 가능하다.

단, 부모 클래스와 자식 클래스의 이름이 다르기 때문에 소멸자는 예외로 한다.

부모 클래스 소멸자에 virtual 키워드가 없으면 동적 다형성(dynamic polymorphism)을 구현할 때 부모 객체 포인터로 자식 객체를 삭제하면 부모 클래스의 소멸자만 호출되고 자식 클래스의 소멸자는 호출되지 않는다.

class A {
public:
  A() {
    std::cout << "A constructor" << std::endl;
  }
  virtual ~A() {
    std::cout << "A destructor" << std::endl;
  }
};

class B : public A {
public:
  B() {
    std::cout << "B constructor" << std::endl;
  }
  ~B() {
    std::cout << "B destructor" << std::endl;
  }
};

std::unique_ptr<A> a = std::make_unique<B>();

// A constructor
// B constructor
// B destructor (A 클래스 소멸자에 virtual 키워드가 없으면 호출되지 않음)
// A destructor

3. 부모 함수와 자식 함수의 매개변수 타입들이 반드시 동일해야 함

부모 함수와 자식 함수의 매개변수 타입이 동일해야 같은 함수로 취급하여 함수 overriding이 가능하다.

4. 부모 함수와 자식 함수의 상수성(constness)이 반드시 동일해야 함

부모 함수와 자식 함수의 상수성(constness)이 동일해야 같은 함수로 취급하여 함수 overriding이 가능하다.

함수 내에서 다른 변수의 값을 바꾸지 않는 함수의 상수성(constness)은 함수 이름 뒤에 const 키워드를 붙여서 표현한다.

void func() const {}

5. 부모 함수와 자식 함수의 반환 타입과 예외 지정이 반드시 호환되어야 함

부모 함수와 자식 함수의 반환 타입과 예외 지정(exception specification)이 동일해야 같은 함수로 취급하여 함수 overriding이 가능하다.

소멸자, 이동 생성자, 이동 대입 연산자처럼 수행 도중 예외가 발생하면 안되는 함수의 이름 뒤에는 예외 지정(exception specification)을 의미하는 noexcept 키워드를 붙인다.

void func() noexcept {}

6. 멤버 함수들의 참조 한정사(reference qualifier)들이 반드시 동일해야 한다.

C++11부터 멤버 함수들의 참조 한정사(reference qualifier)들이 반드시 같아야 한다는 조건이 추가되었다.

참조 한정사(reference qualifier)는 해당 객체(*this)가 lvalue 혹은 rvalue 일 때만 멤버 함수를 호출할 수 있도록 함수 이름 뒤에 &, && 키워드를 붙여준다.

class Widget {
public:
  void doWork() &;  // lvalue 참조 한정사
  void doWork() &&; // rvalue 참조 한정사
};

// Widget 타입의 객체를 반환하는 팩토리 함수
Widget makeWidget();

// Widget 타입의 w 변수
Widget w;

w.doWork();           // lvalue 참조 한정사를 사용한 함수 호출

makeWidget().doWork() // rvalue 참조 한정사를 사용한 함수 호출


위 조건들을 이해했다면 아래 예시에서 부모 클래스와 자식 클래스 간의 함수 overriding이 실패한 원인을 찾을 수 있을 것이다.

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);           // 부모 함수와 상수성(constness)가 다름
  virtual void mf2(unsigned int x); // 부모 함수와 매개변수가 다름
  virtual void mf3(void) &&;        // 부모 함수와 참조 한정사가 다름
  void mf4(void) const;             // 부모 함수에 virtual 키워드가 없음
};

정상적으로 함수 overriding이 발생하는 예시는 다음과 같다.

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;
  virtual void mf2(int x);
  virtual void mf3(void) &;
  void mf4(void) const; // virtual은 생략 가능
};

이처럼 함수 overriding 조건이 까다로워서 충분히 실수가 발생할 수 있다.

문제는 실수해도 함수 overriding이 발생하지 않을 뿐 코드는 정상적으로 실행된다는 점이다.

따라서 함수 overriding이 실패했을 경우 이를 경고해 줄 방법이 필요하다.


override, final

C++11에서는 컴파일러에게 함수 overriding을 알려줄 수 있는 override, final 키워드가 추가되었다.

override 키워드는 부모 클래스에서 있는 함수를 overriding 한다는 뜻이고 final 키워드는 부모 클래스에 있는 함수를 마지막으로 overriding 한다는 뜻이다.

이 키워드를 사용하면 컴파일러로부터 overriding 실수가 발생했을 때 에러 메시지를 받을 수 있다.

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

// 컴파일러로부터 overriding 실패 메시지를 받을 수 있음
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;
};

// overriding 목적을 명확하게 표현
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은 생략 가능
};


멤버 함수들의 참조 한정사(reference qualifier)

앞서 설명한 참조 한정사(reference qualifier)를 통해 멤버 함수가 호출되는 객체(*this)의 lvalue 버전과 rvalue 버전을 다른 방식으로 처리할 수 있다.

예를 들어 멤버 변수의 reference를 반환하는 멤버 함수를 구현해보자.

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

private:
  DataType values;
};

// Widget 타입의 객체를 반환하는 팩토리 함수
Widget makeWidget();

// Widget 타입의 w 변수
Widget w;

auto vals1 = w.data();             // w.values를 vals1에 복사
auto vals2 = makeWidget().data();  // 임시 객체 Widget의 values를 vals2에 복사

data() 멤버 함수를 호출하는 객체가 lvalue든 rvalue든 리턴 타입은 lvalue이며 이를 사용해 vals1, vals2 객체를 생성할 때 복사 생성자가 호출된다.

즉, 임시 객체(rvalue) 안의 멤버 변수값을 가져올 때도 비효율적인 복사 연산을 하고 있다.

이때 참조 한정사(reference qualifier)를 사용하면 더 효율적으로 구현할 수 있다.

class Widget {
public:
  using DataType = std::vector<double>;
  // lvalue 객체에 대해서는 lvalue 반환
  DataType& data(void) & { return values; }               
  // rvalue 객체에 대해서는 rvalue 반환
  DataType&& data(void) && { return std::move(values); }

private:
  DataType values;
};

// Widget 타입의 객체를 반환하는 팩토리 함수
Widget makeWidget();

// Widget 타입의 w 변수
Widget w;

auto vals1 = w.data();            // lvalue 참조 한정사 함수를 호출, vals1은 복사 생성됨
auto vals2 = makeWidget().data(); // rvalue 참조 한정사 함수를 호출, vals2은 이동 생성됨

멤버 함수를 호출하는 객체(*this)가 lvalue이면 lvalue를 반환하고 rvalue 이면 rvalue를 반환하는 data() 함수를 구현하여 vals1 객체를 생성할 때는 복사 생성자가 vals2 객체를 생성할 때는 이동 생성자가 호출된다.


카테고리:

업데이트: