[전문가를 위한 C++] 상속 활용하기
‘전문가를 위한 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 메서드의 내부 작동 방식
- 클래스를 컴파일하면 클래스 내부의 메서드를 모두 담은 바이너리 객체가 생성된다.
- virtual로 선언하지 않은 메서드들은 컴파일 시간에 결정된 타입의 코드로 교체한다.
- 이를 정적 바인딩(static binding) 또는 이른 바인딩(early binding)이라고 부른다.
- virtual로 선언한 메서드가 있는 경우 vtable(가상 테이블)이라는 특수 메모리 영역을 활용해 가장 적합한 구현 코드를 호출한다.
- 이 클래스로 생성한 객체마다 vtable에 대한 포인터를 갖고 있으며, vtable에 virtual 메서드의 구현 코드에 대한 포인터가 담겨 있다.
- 객체에 대한 메서드 호출 시 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;
코드 재사용을 위한 상속
- 상속이 중요한 이유 중 하나는 코드 재사용이다.
부모를 공경하라
- 생성 순서, 생성자 체이닝, 캐스트 등과 관련하여 생기는 버그들을 주의해야 한다.
부모 클래스의 생성자
- 자식 객체의 생성 순서는 다음과 같다
- 베이스 클래스의 생성자 초기자가 없다면 디폴트 생성자를 실행한다.
- 현재 클래스의 비 static으로 선언한 데이터 멤버를 생성한다. 코드에 선언된 순서에 따른다.
- 현재 클래스에 있는 생성자의 본문을 실행한다.
- 부모 클래스에 디폴트 생성자가 없거나, 다른 생성자를 사용하고 싶다면 생성자 초기자로 데이터 멤버 초기화하듯이 생성자를 체인으로 엮을 수 있다.
- 데이터 멤버는 클래스 생성자가 실행된 후에야 초기화 되므로, 부모 생성자에 데이터 멤버를 인수로 전달할 수 없다.
class Base
{
public:
Base(int i) {};
};
class Derived : public Base
{
public:
Derived() : Base { 7 } {}
};
// 이렇게 변수 전달도 가능
Derived::Derived(int i) : Base { i } {}
부모 클래스의 소멸자
- 소멸자는 인수를 받지 않으므로 부모 클래스의 소멸자는 언제나 자동으로 호출 가능하다.
- 현재 클래스에 있는 소멸자의 본문을 실행한다
- 현재 클래스의 데이터 멤버를 생성할 때와 반대 순서로 제거한다
- 부모 클래스가 있다면 제거한다
- 소멸자는 항상 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