Modern C++ 강의 정리 – OOP

Inheritance & Composition

상속(Inheritance)은 자식 객체가 부모 객체의 속성을 그대로 이어받는 것 (is-A)를 의미한다. 예를 들어 부모 객체가 Car이고 그 자식이 SUV라고 할 때 SUV는 Car의 모든 속성을 그대로 물려받는다.

이양(Composition)은 부모-자식의 관계가 안이라 이양되는 객체가 이양되는 객체가 피이양 객체를 멤버로서 가지게 되는 것을 의미한다. 예를 들어서 Car라는 객체는 Engine, Seat, Battery 등의 객체들을 상속받는 것이 아니라 멤버로 가지게 된다(has-A).

클래스 상속 문법은
class <child class> : <access modifier> <base class> 이다.

#include <iostream>
using namespace std;

class Animal {
public:
    void Eat() {
        cout << "Aniimal Eating" << endl;
    }
    void Speak() {
        cout << "Aniimal Speaking" << endl;
    }
};

class Dog : public Animal {
public:
    void Speak() {
        cout << "Dog Speaking" << endl;
    }
};

int main()
{
    Dog dog;
    dog.Eat();
    dog.Speak();
    return 0;
}

다음과 같이 Animal을 상속받은 자식 Dog는 Animal의 멤버 함수인 Eat에 접근할 수 있다. Speak은 Animal에도 정의되어 있지만 Dog에도 정의되어 있기 때문에 dog의 멤버함수 Speak이 호출된다.


Access Modifier

Access Modifier로는 private / public / protected 가 있다.

private 접근제어자를 가진 멤버는 클래스 외부에서 직접적으로 접근할 수 없다. 외부에서 이 값을 읽거나 쓰려면 그 기능을 하는 멤버함수를 구현해서 외부에서 접근 가능하게 해야한다.

public 접근제어자를 가진 멤버는 클래스 외부에서 바로 접근할 수 있다.

protected 접근제어자를 가진 멤버는 클래스 외부에서는 접근할 수 없고, 자식 클래스에서는 접근할 수 있다.

파생클래스가 부모클래스를 상속할 때 사용한 접근제어자에 따라 부모로부터 상속받은 멤버들의 외부에 대한 접근 제어자가 변할 수 있다.

자식이 부모를 public으로 상속한 경우는 변함이 없다. private / public /protected -> private / public / protected

자식이 부모를 private로 상속한 경우는 모든 멤버가 private가 된다.

자식이 부모를 protected로 상속한 경우는 private / public / protected -> private / protected / protected 가 된다.


Object construction

객체 생성 순서는 부모 -> 자식 순서로 생성자를 호출한다.

객체 소멸 순서는 자식 -> 부모 순서로 소멸자를 호출한다.


Inheriting Constructors

파생클래스에서는 부모클래스의 함수를 재정의 (Overriding) 할 수 있다. 이 때 부모클래스의 함수를 재사용 할 필요가 있을 경우가 있다. 이 경우에는 아래와 같이 부모클래스의 Scope를 붙여주면, 부모클래스의 멤버 함수를 호출할 수 있다. 물론 호출을 위해서는 해당 함수가 private가 아니어야한다.

class Dog : public Animal {
public:
    void Speak() {
        Animal::Speak();
        cout << "Dog Speaking" << endl;
    }
};

파생 클래스의 생성자가 하는 일이 부모클래스와 완전히 같은 경우가 있을 수 있다. 이 경우에 파생클래스의 생성자가 하는 일은 단지 부모클래스의 생성자를 호출하는 것 뿐이다. C++11에서는 이 구현을 생략할 수 있도록 하는 기능을 지원한다.

#include <iostream>
#include <string>
using namespace std;

class Animal {
public:
    string name;
    Animal(const string& name): name(name) {
        cout << "Animal" << endl;
    }
};

class Dog : public Animal {
public:
    using Animal::Animal;
};

int main()
{
    Dog dog("dog");
    cout << dog.name << endl;
    return 0;
}

위 코드에서 Animal의 생성자는 name을 초기화시킨다. 그것을 파생클래스인 Dog도 똑같이 적용하려는데 생성자를 명시적으로 정의하지 않고 using Animal::Animal을 추가하여 Animal의 생성자를 호출할 수 있도록 한다.


Virtual Keyword

부모클래스의 포인터를 가지고 자식클래스의 인스턴스를 가리킬 수 있다(업 캐스팅). 예를 들어서 Car에 대해 SUV와 Sedan이 파생클래스로 존재한다고 하자. 이 두 객체는 연비가 달라 Accelerate 함수의 구현부가 다르다. Car 객체는 모두 공통적으로 Accelerate 멤버 함수를 가지기 때문에 이 Accelerate를 사용하는 함수를 파생클래스마다 각각 만들 수는 없는 일이다.

이 때 Car 클래스의 포인터를 가지고 파생 클래스들의 인스턴스를 가리킨 후에 함수 인자로 넣어주면 하나의 함수가 여러 파생클래스의 인스턴스를 받을 수 있다. 단 여기서 해당 함수는 부모 클래스에서 virtual로 선언되어 있어야 한다. 컴파일러는 컴파일 타임에 해당 함수가 뭔지 알 수 없으며, 런타임에만 알 수 있다.

#include <iostream>
#include <string>
using namespace std;

class Car {
public:
    virtual void showFuelEfficiency() {
        cout << "1.0" << endl;
    }
};

class SUV: public Car {
public:
    void showFuelEfficiency() {
        cout << "2.0" << endl;
    }
};

class Sedan: public Car {
public:
    void showFuelEfficiency() {
        cout << "3.0" << endl;
    }
};

void displayFuelEfficiency(Car* car) {
    car->showFuelEfficiency();
}

int main()
{
    Car car;
    SUV suv;
    Sedan sedan;
    displayFuelEfficiency(&car);
    displayFuelEfficiency(&suv);
    displayFuelEfficiency(&sedan);
    return 0;
}

위와 같이 showFuelEfficiency는 클래스에 따라 총 3개의 다른 구현이 존재한다. 함수는 동일한 인자로 Car의 포인터를 받지만 포인터가 가리키는 대상은 모두 다르다. 이 때 어떤 함수가 호출되는지는 런타임에 결정된다. virtual 함수가 존재하는 클래스는 반드시 vtable의 포인터를 추가적으로 컴파일러가 자동 생성하는데 이 vtable 내에는 각 virtual 함수들의 실제 주소가 있다.

#include <iostream>
#include <string>
using namespace std;

class Car {
public:
    virtual void showFuelEfficiency() {
        cout << "1.0" << endl;
    }
    virtual ~Car() {
        cout << "~Car" << endl;
    }
};

class SUV: public Car {
public:
    void showFuelEfficiency() {
        cout << "2.0" << endl;
    }
    ~SUV() {
        cout << "~SUV" << endl;
    }
};

void displayFuelEfficiency(Car* car) {
    car->showFuelEfficiency();
}

int main()
{
    Car* car = new SUV();
    delete car;
    return 0;
}

부모 클래스의 포인터로 파생클래스의 포인터를 초기화 한 경우에는 반드시 부모 클래스의 소멸자를 virtual (가상함수)로 만들어야 파생클래스의 소멸자가 호출된다. 파생클래스의 소멸자는 부모클래스를 호출하기 때문에 ~Car와 ~SUV가 모두 호출될 수 있다. 만일 부모 클래스의 소멸자가 가상함수가 아니라면 ~Car만 호출되어 memory leak이 발생할 수 있다.


Final & Override

클래스에 Final 키워드를 붙이면 이 클래스로부터 파생되는 클래스를 생성하는 것을 명시적으로 금지하게 된다.

파생클래스에서 부모클래스의 멤버 함수를 오버라이드 하기 위해서는 함수 시그니처 (이름, 매개변수의 이름과 타입)가 동일해야한다. 리턴 타입은 시그니처에 포함되지 않는다. 예를 들어 함수 int Divide (int n, int m)의 시그니처는 Divide(int, int)이다. int Divide (int n, int m)과 double Divide (int n, int m)은 동일한 시그니처를 갖는다.

시그니처가 같고 리턴 타입이 다른 두 함수가 존재할 경우 컴파일러는 에러를 발생시킨다. 함수 오버로딩은 이름만 같고 매개변수가 다른 함수를 여러 개 정의하는 것으로 호출부의 매개변수 타입에 따라 어떤 함수가 호출될 지 결정된다.

#include <iostream>
#include <string>
using namespace std;

class Document {
public:
    virtual void Print(int i) {
        cout << "Document";
    }
    virtual ~Document() {
        
    }
};

class Text: public Document {
public:
    void Print(float f) {
        cout << "Text";
    }
};

int main()
{
    Document* d = new Text();
    d->Print(1);
    delete d;
    return 0;
}

위 코드의 실행 결과는 “Text”가 아닌 “Document”를 출력한다. 각 클래스에 정의된 Print 함수는 서로 다른 시그니처를 가지고 있기 때문에, 인자 타입에 따라 Document의 멤버 함수가 호출된 것으로 보인다.

만약 사용자가 명시적으로 파생클래스에서 부모클래스의 멤버 함수를 오버라이드 하고 위와 같은 착오를 없애기 위해서는 override 키워드를 새로 정의될 함수에 붙여 void Print(float f) override 로 만들어줘야한다. 이 때 시그니처가 같은 함수를 부모 클래스에서 찾을 수 없기 때문에 컴파일 에러가 발생한다.

명시적으로 오버라이드를 하기 위해서는 반드시 부모클래스의 멤버함수는 반드시 virtual로 선언되어야 한다.

class Text: public Document {
public:
    void Print(float f) override final {
        cout << "Text";
    }
};

오버라이드 한 함수를 자신의 파생클래스에서는 오버라이드 하지 못하게 금지하고 싶을 때는 final 키워드를 붙여주면 된다.

Upcasting & Downcasting

파생클래스의 객체가 부모클래스의 객체에 대입될 때 컴파일러는 Object Slicing을 수행하여 복사를 수행한다.

#include <iostream>
#include <string>
using namespace std;

class Document {
public:
};

class Text: public Document {
public:
    string name;
};

int main()
{
    Text t;
    Document d = t;
    return 0;
}

위와 같이 Text 객체는 Document를 상속받아 자신만의 멤버로 name을 가진다. 위 코드에서 Text 객체는 Document 객체에 대입되는데 이 때 컴파일러는 Text의 name 객체는 날려버리고 Document에 속하는 멤버들만을 가지고 복사를 수행한다.

이와 다르게 부모 클래스의 포인터에서 파생클래스의 포인터를 가리키는 것을 upcasting 이라고 한다. 논리적으로 볼 때 모든 자식 클래스는 부모 클래스의 속성을 가지기 때문에 자연스럽게 가능하다. 이 때 이 포인터로는 당연히 부모 클래스의 멤버에만 접근할 수 있다.

반대로 자식클래스의 포인터로 부모객체를 가리키는 것을 downcasting이라고 한다. 모든 부모클래스가 파생클래스일 수는 없기 때문에 이 방식은 반드시 static_cast를 사용해서만 가능하다. 이 때 부모에 정의되지 않은 파생클래스의 멤버에 접근하는 행위는 예상하지 못한 결과를 발생시킬 수 있다.


RTTI (Run-time Type Information)
#include <iostream>
#include <string>
#include <typeinfo>
using namespace std;

class Document {
public:
    virtual ~Document() {}
};

class Text: public Document {
public:
    string name;
    Text(string name): name(name) {
        
    }
};

int main()
{
    Text t("text");
    Document d1 = t;
    Document *d2 = &t;
    
    const type_info &t1 = typeid(t);
    cout << t1.name() << endl;
    
    const type_info &t2 = typeid(d1);
    cout << t2.name() << endl;
  
    const type_info &t3 = typeid(d2);
    cout << t3.name() << endl;
    
    const type_info &t4 = typeid(*d2);
    cout << t4.name() << endl;
    
    return 0;
}

type_info를 사용하면 객체의 타입을 알 수 있다. 위의 경우를 보자.

t1은 Text의 인스턴스를 인자로 받아 그 타입을 리턴한다. (Class Text)
t2는 Text를 Object Slice해서 생성한 Document의 인스턴스를 인자로 받아 그 타입을 리턴한다. (Class Document)
t3는 Text 인스턴스의 주소를 가리키는 Document 포인터를 인자로 받아 그 타입을 리턴한다. (Document *)
t4는 Document 포인터가 실제로 가리키는 대상을 인자로 받아 그 타입을 리턴한다. (Class Text)

type_info를 쓰지 않고 런타임에 안전한 변화를 하기 위해서는 dynamic_cast를 사용하면 된다. 타입 변환이 제대로 이루어진 경우 그 결과는 인스턴스를 가리키며 그렇지 않은 경우 nullptr를 가진다.


Abstract Class

부모클래스의 어떤 함수를 완전 가상함수 (pure virtual function)으로 만들고 그 구현을 파생클래스에서 강제하고 싶을 때 다음과 같이 할 수 있다.

virtual <return type> <name> (<parameters>) = 0;

완전 가상함수가 하나라도 존재하는 클래스는 추상클래스 (abstract class)라고 하며 인스턴스화 될 수 없으며, 오로지 파생클래스의 인스턴스를 포인터나 레퍼런스를 통해 연결해 사용될 수 있다. 가상함수만으로만 이루어져있는 클래스는 인터페이스라고 부른다.


Diamond Inheritance

예시를 들어 살펴보자. Stream이라는 부모 클래스를 상속받은 InputStream, OutputStream이 있다. 이 두 클래스 모두를 상속받은 IOStream이 있다. 앞서 배운 생성자 호출 규칙에 따르면 Stream의 생성자는 두 번 호출되게 되며, 메모리 상으로도 서로 다른 두 개의 Stream이 존재하게 될 것이다.

#include <iostream>
#include <string>
#include <typeinfo>
using namespace std;

class A {
public:
    A() {
        cout << "A";
    }
};

class B: public A {
public:
    B() {
        cout << "B";
    }
};

class C: public A {
public:
    C() {
        cout << "C";
    }
};

class D: public B, C {
public:
    D() {
        cout << "D";
    }
};

int main()
{
    D d;
    return 0;
}

위 코드는 ABACD를 출력한다. 즉, A의 생성자가 두 번 호출된다. 만약 파생 클래스 D에서 부모 클래스 A의 함수를 호출한다고 할 때, 과연 두 개의 인스턴스 중에서 어떤 것을 호출할 것인가? 컴파일러는 이것을 알 수 없으므로 에러를 발생시킬 것이다.

이 상황을 방지하기 위해 부모클래스 A에 대해 단 하나의 인스턴스만을 가지고 싶을 수 있다. 이 때 virtual 키워드를 접근제어자 앞에 붙여 이를 실현할 수 있다.

#include <iostream>
#include <string>
#include <typeinfo>
using namespace std;

class A {
public:
    A() {
        cout << "A";
    }
};

class B: virtual public A {
public:
    B() {
        cout << "B";
    }
};

class C: virtual public A {
public:
    C() {
        cout << "C";
    }
};

class D: public B, C {
public:
    D() {
        cout << "D";
    }
};

int main()
{
    D d;
    return 0;
}