6 minute read

‘전문가를 위한 C++ - Marc Gregoire 지음, 남기혁 옮김’ 책을 참고하여 작성한 포스트입니다.


상속을 이용한 클래스 구현

클래스 확장하기

  • 클래스를 정의할 때 컴파일러에 기존 클래스를 상속(inherit), 파생(derive), 확장(extend)한다고 선언할 수 있다.
  • 원본 클래스를 부모 클래스(parent class, base class, super class) 라고 부르고,
  • 기존 클래스를 확장한 클래스를 **자식 클래스(child class, derived class, subclass) 라고 부른다.

클라이언트 입장에서 본 상속

  • 부모 입장에서는 자식 클래스의 존재를 알 수 없으므로, 자식 객체의 메서드나 데이터 멤버를 사용할 수 없다.
  • 객체에 대한 포인터나 레퍼런스 타입은 해당 클래스의 파생 클래스 타입으로도 지정 가능하다.
  • 즉 부모 클래스 타입의 포인터로 자식 클래스 객체를 가리킬 수 있다.

파생 클래스 입장에서 본 상속

  • 베이스 클래스에 private으로 선언된 메서드와 데이터 멤버는 파생 클래스서 접근할 수 없다.

상속 방지

  • 클래스 정의할 때 final 키워드를 붙이면 다른 클래스가 상속할 수 없게 된다.
class Base final  {...};

메서드 오버라이딩(method overriding)

  • 베이스 클래스에 정의된 메서드의 동작을 변경하는 것이다.

virtual 키워드

  • 베이스 클래스에서 virtual 키워드로 선언된 메서드만 오버라이딩 가능하다.
  • 한번 virtual로 지정하고 나면 그 후 정의하는 모든 파생 클래스에서도 virtual 상태를 유지한다. (키워드를 없애도!)
public:
	virtual void SomeMethod() {}

메서드 오버라이딩 문법

  • 맨 뒤에 override 키워드를 붙여주면 된다.
public:
	void SomeMethod() override;

클라이언트 관점에서 본 오버라이드한 메서드

  • 객체에 속한 클래스에 따라 메서드가 달리 호출된다.
  • 주의할 점은 파생 클래스에 속한 객체를 가리키는 레퍼런스를 베이스 클래스 타입으로 선언한 경우,
  • 메서드를 호출하면 파생 클래스 버전의 메서드가 호출되는 점이다.
  • 이 경우도 베이스 클래스에 정의되지 않은 파생 클래스의 데이터 멤버나 메서드는 접근이 불가능하다!
  • 이것도 포인터나 레퍼런스 객체에만 적용되지,
  • 파생 클래스에 속한 객체를 베이스 클래스에 속한 객체로 캐스트하거나 대입하면 파생 클래스 정보가 사라진다.
  • 이렇게 파생 클래스의 데이터 멤버나 오버라이드된 메서드가 삭제되는 것을 슬라이싱(slicing)이라고 한다.

override 키워드

  • override 키워드는 생략해도 된다. 하지만 작성해주자.
  • override 안하고 virtual 메서드를 새로 정의하는 실수를 저지를 위험이 있다.
public:
	virtual void SomeMethod(int i) override;

virtual 메서드

오버라이드하지 않고 숨기기

  • virtual로 선언하지 않은 메서드를 재선언하면 베이스 클래스에 있는 메서드를 가려버려 파생 클래스에서만 사용 가능해진다.

    virtual 메서드의 내부 작동 방식

    1. 클래스를 컴파일하면 클래스 내부의 메서드를 모두 담은 바이너리 객체가 생성된다.
    2. virtual로 선언하지 않은 메서드들은 컴파일 시간에 결정된 타입의 코드로 교체한다.
    • 이를 정적 바인딩(static binding) 또는 이른 바인딩(early binding)이라고 부른다.
      1. virtual로 선언한 메서드가 있는 경우 vtable(가상 테이블)이라는 특수 메모리 영역을 활용해 가장 적합한 구현 코드를 호출한다.
      2. 이 클래스로 생성한 객체마다 vtable에 대한 포인터를 갖고 있으며, vtable에 virtual 메서드의 구현 코드에 대한 포인터가 담겨 있다.
      3. 객체에 대한 메서드 호출 시 vtable을 통해 그 시점에 적합한 버전의 메서드를 실행한다.
    • 이를 동적 바인딩(dynamic binding) 또는 늦은 바인딩(late binding)이라고 부른다.

virtual 키워드가 필요한 이유

  • vtable의 오버헤드를 줄이기 위해서.

virtual 소멸자의 필요성

  • 소멸자를 virtual로 선언하지 않으면 객체가 소멸할 때 메모리가 해제되지 않을 수 있다.
  • 대부분의 컴파일러에서는 아래 코드에서 파생 클래스의 소멸자가 아닌 베이스 클래스의 소멸자를 호출하도록 처리한다.
  • virtual ~Base() = default 로 해주어야 한다.
class Base
{
public:
	Base() {}
	~Base() {}
};

class Derived()
{
public:
	Derived()
	{
		MString = new char[10];
	}
	~Derived()
	{
		delete[] MString;
	}
private:
	char* MString;
};

int main()
{
	Base* Ptr { new Derived() };
	delete Ptr;
}

오버라이드 방지하기

  • 다음과 같이 개별 메서드 단위로 final 선언이 가능하다.
void SomeMethod() override final;



코드 재사용을 위한 상속

  • 상속이 중요한 이유 중 하나는 코드 재사용이다.

부모를 공경하라

  • 생성 순서, 생성자 체이닝, 캐스트 등과 관련하여 생기는 버그들을 주의해야 한다.

    부모 클래스의 생성자

  • 자식 객체의 생성 순서는 다음과 같다
    1. 베이스 클래스의 생성자 초기자가 없다면 디폴트 생성자를 실행한다.
    2. 현재 클래스의 비 static으로 선언한 데이터 멤버를 생성한다. 코드에 선언된 순서에 따른다.
    3. 현재 클래스에 있는 생성자의 본문을 실행한다.
  • 부모 클래스에 디폴트 생성자가 없거나, 다른 생성자를 사용하고 싶다면 생성자 초기자로 데이터 멤버 초기화하듯이 생성자를 체인으로 엮을 수 있다.
  • 데이터 멤버는 클래스 생성자가 실행된 후에야 초기화 되므로, 부모 생성자에 데이터 멤버를 인수로 전달할 수 없다.
class Base
{
public:
	Base(int i) {};
};
class Derived : public Base
{
public:
	Derived() : Base { 7 } {}
};

// 이렇게 변수 전달도 가능
Derived::Derived(int i) : Base { i } {}

부모 클래스의 소멸자

  • 소멸자는 인수를 받지 않으므로 부모 클래스의 소멸자는 언제나 자동으로 호출 가능하다.
    1. 현재 클래스에 있는 소멸자의 본문을 실행한다
    2. 현재 클래스의 데이터 멤버를 생성할 때와 반대 순서로 제거한다
    3. 부모 클래스가 있다면 제거한다
  • 소멸자는 항상 virtual 선언 해주자.

부모 클래스 참조하기

  • 스코프 지정 연산자를 이용하면 된다.

업캐스트와 다운캐스트

  • 파생 클래스 타입의 객체를 베이스 클래스 타입의 포인터나 레퍼런스에 대입해야 슬라이싱이 발생하지 않는다.
Base MyBase { MyDerived }; // 슬라이싱 발생
Base& MyBaseRef { MyDerived }; // 슬라이싱 발생 안함
  • 베이스 클래스 타입으로 파생 클래스를 참조하는 것을 업캐스트(upcast)라 부르고,
  • 대체로 이 방식으로 참조해야 한다.
  • 베이스 클래스를 파생 클래스로 캐스트하는 것을 다운캐스트(downcast)라 부른다.
  • 하지만 이 경우 해당 객체가 반드시 파생 클래스에 속한다고 보장할 수 없다..
  • 다운캐스트 할 때는 반드시 dynamic_cast()를 호출하여 통제해야 한다.



다형성을 위한 상속

SpreadsheetCell 베이스 클래스

  • 사용자로 부터 데이터를 입력받아 string 타입으로 cell에 저장하고 불러오게 구현해 본다.
  • 여러 타입들이 공통적으로 제공할 동작을 부모 클래스 하나로 정의해 본다.
  • 이때 부모 클래스의 인스턴스를 만들 수 없게 추상 클래스(abstract class)로 만들어 주어야 한다.
  • 순수 가상 메서드(pure virtual method)를 하나라도 정의한 클래스를 추상 클래스라고 하는데,
  • 순수 가상 메서드란 클래스 정의 코드에서 명시적으로 정의하지 않은 메서드다.
  • 순수 가상 메서드를 지정하려면 메서드 맨 뒤에 =0을 붙이면 된다.
  • 추상 클래스의 경우 소스파일을 따로 작성하지 않아도 된다.
class SpreadsheetCell
{
public:
	virtual ~SpreadsheetCell() = default;
	virtual void Set(std::string_view Value) = 0;
	virtual std::string GetString() const = 0;
};

// 아래 코드는 에러를 발생시키지 않는다.
// 객체를 파생 클래스 타입으로 생성하기 때문
unique_ptr<SpreadsheetCell> CellPtr { new SpreadsheetCell{} };

파생클래스 구현하기

  • 추상 클래스를 상속받는 파생 클래스는 베이스 클래스의 순수 가상 메서드를 하나도 빠짐없이 모두 구현해야 한다.
  • 그러지 않으면 파생클래스도 추상 클래스가 된다.
// SpreadsheetCell  import / include

class StringSpreadsheetCell : public SpreadsheetCell
{
public:
	virtual void Set(std::string_view Value) override {StringValue = Value;}
	// 공백이면 "" 리턴
	virtual std::string GetString() override {return StringValue.value_or(""); }
private:
	std::optional<std::string> StringValue;
};

class DoubleSpreadsheetCell : public SpreadsheetCell
{
public:
	virtual void Set(double Value) { DoubleValue = Value; }
	void Set(std::string_view Value) override {DoubleValue = StringToDouble(Value);}
	std::string GetString() const override
	{
		return (DoubleValue.has_value() ? DoubleToString(DoubleValue.value()) : "");
	}
private:
	static std::string DoubleToString(double Value); //	구현 생략
	static double StringToDouble(std::string_view Value); // 구현 생략
	std::optional<double> DoubleValue;
};

다형성 최대로 활용하기

  • 위 코드처럼 작성할 경우, vector를 부모 클래스인 SpreadsheetCell 타입으로 선언하면,
  • 두 파생 클래스 타입 모두 원소로 담을 수 있다.
  • 모든 객체의 타입이 SpreadsheetCell 타입이므로 SpreadsheetCell에 정의된 동작을 실행할 수 있다는 사실이 중요하다!

나중을 대비하기

  • 위의 경우 두 클래스를 별도로 정의해서 셀을 표현하는 타입이 두 가지로 갈라졌다.
  • 그래서 타입을 변환하는 기능을 추가해 주어야 하는데, 변환 생성자(converting constructor, typed constructor) 를 추가하는 방식을 쓸 수 있다.
class StringSpreadsheetCell : public SpreadsheetCell
{
public:
	StringSpreadsheetCell() = default;
	StringSpreadsheetCell(const DoubleSpreadsheetCell& Cell)
		: StringValue { Cell.GetString() }
	{}
	...
}
  • 셀 타입 조합에 대한 연산자를 구현도 해야 할텐데, 공통 타입으로 string을 두고, 글로벌 함수를 이용하면 된다.
export StringSpreadsheetCell opertar+(const StringSpreadsheetCell& lhs,
									  const StringSpreadsheetCell& rhs)
{
	StringSpreadsheetCell NewCell;
	NewCell.Set(lhs.GetString() + rhs.GetString());
	return NewCell;
}



다중 상속

여러 클래스 상속하기

  • 클래스를 선언할 때 이름 뒤에 상속할 베이스 클래스를 나열하면 된다.

    이름 충돌과 모호한 베이스 클래스

    모호한 이름

  • 두 베이스 클래스가 동일한 이름 및 매개변수의 메서드를 가지고 있다고 하면,
  • 두 베이스 클래스는 서로 관련이 없어 서로 오버라이드 하지 않는다.
  • 그러면 파생 클래스에서는 두 메서드가 그대로 유지되는 상황이라 해당 메서드 호출 시 어떤 베이스 클래스의 메서드를 호출할 지 모호하다.
  • 이런 경우 dynamic_cast()로 객체를 명시적으로 업캐스트해 원하지 않는 버전을 컴파일러가 볼 수 없게 가리거나,
  • 스코프 지정 연산자를 이용해 구체적으로 지정하면 된다.

모호한 베이스 클래스

  • 같은 클래스를 두 번 상속하게 되는 경우 에러가 발생한다.
  • 다이아몬드 형태로 부모가 겹치는 경우가 있는데 이 경우는 최상단 베이스 클래스를 가상 베이스 클래스(virtual base class)로 만들어 주는 것이 좋다.

다중 상속 활용 예

  • 다중 상속을 활용하는 가장 간단한 예는 is-a 관계를 맺는 대상이 여럿인 객체를 정의하기 위해서다.
  • 믹스인 클래스를 구현할 때도 사용한다(5장, 32장)
  • 컴포넌트 기반으로 클래스를 모델링할 때도 사용 가능하다.



상속에 관련된 미묘하면서 흥미로운 문제들(나중에 추가)

Leave a comment