class Animal
{
public:
	Animal& operator=(const Animal& animal);
};

class Lizard : public Animal
{
public:
	Lizard& operator=(const Lizard& lizard);
};

class Chicken : public Animal
{
public:
	Chicken& operator=(const Chicken& chicken);
};

Lizard liz1;
Lizard liz2;

Animal* pAnimal1 = &liz1;
Animal* pAnimal2 = &liz2;

*pAnimal1 = *pAnimal2;

마지막 호출되는 대입 연산자는 Animal::operator==이므로 Animal 클래스 부분만 바뀌는 부분 대입 현상 발생

 

class Animal
{
public:
	virtual Animal& operator=(const Animal& animal);
};

class Lizard : public Animal
{
public:
	virtual Animal& operator=(const Animal& animal);
};

class Chicken : public Animal
{
public:
	virtual Animal& operator=(const Animal& animal);
};

Lizard liz1;
Chicken chick;

Animal* pAnimal1 = &liz1;
Animal* pAnimal2 = &chick;

*pAnimal1 = *pAnimal2;

가상 함수로 구현할 경우 매개변수의 타입을 기본 클래스와 동일하게 해야 하기 때문에 Lizard에 Chicken을 대입하는 타입 불일치 대입 현상이 발생할 수 있음

 

class Animal
{
protected:
	Animal& operator=(const Animal& animal);
};

class Lizard : public Animal
{
public:
	Lizard& operator=(const Lizard& lizard);
};

class Chicken : public Animal
{
public:
	Chicken& operator=(const Chicken& chicken);
};

Lizard liz1;
Lizard liz2;

Animal* pAnimal1 = &liz1;
Animal* pAnimal2 = &liz2;

*pAnimal1 = *pAnimal2; // error

기본 클래스 타입으로 대입 연산을 막기 위해 operator=을 protected로 선언

타입 불일치 대입 현상도 막을 수 있음

 

아예 기본 클래스의 인스턴스화를 막기 위해 추상 클래스로 선언

  • 추상 클래스로 선언할 때는 한 개 이상의 순수 가상 함수를 선언해야 함
class Animal
{
public:
	virtual void ~Animal() = 0;

protected:
	Animal& operator=(const Animal& animal);
};

여러 타입에 대해 충돌 처리를 한다고 가정

 

가상 함수와 RTTI를 사용하여 구현하는 이중 디스패치(여러 개의 매개변수에 대해 가상 함수처럼 동작하는 함수)

가장 흔하면서 지저분한 방법

하나의 객체의 타입은 가상 함수 매커니즘에 의해 자동으로 결정되므로 나머지 객체의 타입만 알아내면 됨

class GameObject
{
public:
    virtual void Collide(GameObject& OtherObject) = 0;    
};

class SpaceShip : public GameObject
{
public:
    virtual void Collide(GameObject& OtherObject) override;
};

void SpaceShip::Collide(GameObject& OtherObject)
{
    const type_info& ObjectType = typeid(OtherObject);
    
    if(ObjectType == typeid(SpaceShip))
    {
        // 우주선-우주선 충돌 처리
    }
    else if(ObjectType == typeid(SpaceStation))
    {
        // 우주선-우주정거장 충돌 처리
    }
    else if(ObjectType == typeid(Asteroid))
    {   
        // 우주선-소행성 충돌 처리
    }
}

 

위 코드는 GameObject에 파생된 클래스를 모두 알고 있어야 하므로 캡슐화 위반

만약 새로운 형태의 객체를 게임에 추가한다면 추가된 객체 타입을 반영해야 하므로 작업 시간 증가, 타입을 빠뜨리는 실수 발생 가능으로 좋지 않음

 

가상 함수만 사용하여 구현하는 이중 디스패치

이중 디스패치를 단일 디스패치 두 개로 구현

  • 첫 번째 가상 함수는 충돌에 가담한 첫 번째 객체의 동적 타입을 결정하고, 두 번째 가상 함수는 두 번째 객체의 동적 타입을 결정
class SpaceShip;
class SpaceStation;
class Asteroid;

class GameObject
{
public:
    virtual void Collide(GameObject& OtherObject) = 0;    
    virtual void Collide(SpaceShip& OtherObject) = 0;    
    virtual void Collide(SpaceStation& OtherObject) = 0;    
    virtual void Collide(Asteroid& OtherObject) = 0;    
};

class SpaceShip : public GameObject
{
public:
    virtual void Collide(GameObject& OtherObject) = 0;    
    virtual void Collide(SpaceShip& OtherObject) = 0;    
    virtual void Collide(SpaceStation& OtherObject) = 0;    
    virtual void Collide(Asteroid& OtherObject) = 0;    
};

void SpaceShip::Collide(GameObject& OtherObject)
{
    OtherObject.Collide(*this);
}

컴파일러는 자신이 호출할 함수를 결정할 때 함수에 넘겨진 인자의 정적 타입을 보고 결정

즉, *this의 정적 타입을 기반으로 결정되므로 SpaceShip이 됨

이후 OtherObject::Collide가 호출되면 OtherObject의 타입도 결정되므로 함수 호출이 명확해짐

 

앞의 RTTI 방법보다 깔끔하고, 순수 함수로 구현되기 때문에 타입을 빠뜨릴 실수가 발생하지 않음

GameObject에 파생된 클래스를 모두 알고 있어야 하므로 캡슐화 위반

새 클래스가 추가되면 새 가상 함수를 정의해야 함

프록시 객체(대리자)는 다른 객체를 대신하여 객체를 사용하는 방법

프록시 객체는 프록시 객체를 만드는 클래스

 

읽기 연산과 쓰기 연산은 비용이 다르기 때문에 operator[]을 사용할 때 둘의 연산을 구분하기 위해 프록시 클래스를 사용

class String
{
public:
    class CharProxy
    {
    public:
        CharProxy(String& str, int index);
        CharProxy& operator=(const CharProxy& rhs); // lvalue 연산
        CharProxy& operator=(char c); // lvalue 연산
        operator char() const; // rvalue 연산

    private:
        String& theString;
        int charIndex;
    };

    const CharProxy operator[](int index) const;
    CharProxy operator[](int index);
};

 

string s1;
cout << s1[5];

s1[5]는 CharProxy 객체를 반환하는데, 이 객체는 operator<<가 정의되어 있지 않아 C++ 컴파일러는 operator++를 성사시키기 위해 암시적 타입 변환 함수인 char() 함수를 자동으로 호출

rvalue로 사용될 때에는 반드시 CharProxy에서 char로의 변환이 이루어짐

 

const String::CharProxy String::operator[](int index) const
{
    return CharProxy(const_cast<String&>(*this), index);
}

String::CharProxy String::operator[](int index)
{
    return CharProxy(*this, index);
}

String 클래스의 operator[]는 요구된 문자에 대한 프록시 객체를 생성해서 반환하는 것 외에 다른 일을 하지 않음

반환된 프록시에 쓰기 혹은 읽기 동작이 행해지는 것이 확실해질 때까지 동작을 지연시킴

const 버전의 operator[]은 상수 프록시 객체를 반환하는데, CharProxy::operator=는 const 멤버 함수가 아니기 때문에 대입 연상의 대상으로 쓸 수가 없으므로 lvalue로 쓰이지 못함

CharProxy의 생성자를 통과하기 위해 const_cast를 사용했지만 operator[]이 반환하는 CharProxy 객체는 상수이기 때문에 변경될 위험은 없음

 

String s1 = "Hello";
char *p = &s1[1]; // error

&s1[1]은 CharProxy* 이므로 CharProxy*에서 char*로 바꾸는 변환은 정의되지 않았기 때문에 컴파일 에러 발생

operator*

연산자를 오버로딩하여 해결할 수 있음

 

void swap(char& a, char& b);
String s = "+C+";
swap(s[0], s[1]); // error

CharProxy에는 char 타입으로 바꿀 수 있지만 char&에 대한 변환 함수가 없기 때문에 매개변수에 바인딩될 수 없음

스마트 포인터를 사용하는 이유

  • 스마트 포인터가 생성되고 소멸되는 시기를 결정할 수 있음
    스마트 포인터는 생성될 때 기본값 nullptr으로 초기화 되기 때문에 직접 초기화해야 하는 포인터와는 다름
    객체를 가리키는 최후의 스마트 포인터가 소멸될 때 자동으로 객체를 소멸하여 리소스 누수를 막음
  • 스마트 포인터가 복사되거나 대입될 때 깊은 복사를 수행하거나 얕은 복사를 수행하도록 결정할 수 있음
  • 역참조 동작을 조절할 수 있음

C++ 기본 제공 포인터처럼 가리킬 타입이 정확하게 지정되어야 하므로 스마트 포인터는 템플릿 기반으로 만들어짐

따라서 템플릿 매개변수를 통해 스마트 포인터로 가리킬 객체의 타입을 지정

 

복사와 대입을 모두 허용하여 복사 생성자와 대입 연산자 모두 public으로 선언됨

역참조 연산자가 const로 선언한 이유는 포인터를 역참조한다고 내부 데이터가 변경되지 않음

 

스마트 포인터가 자신이 가리키는 객체를 소유한 경우에는 스마트 포인터가 소멸될 때 객체도 삭제해야 하는데, 객체가 동적 할당된 경우를 가정한 것

 

unique_ptr은 자신이 소멸될 때까지 힙 기반 객체를 가리키는 스마트 포인터

unique_ptr은 자신이 소멸될 때 소멸자가 호출되면서 자신이 가리키는 객체를 삭제

unique_ptr은 복사 혹은 대입될 때 소유 관계를 옮겨 객체를 두 개 이상의 unique_ptr이 가리키지 못하도록 구현

  • 따라서 unique_ptr을 매개변수에 값으로 전달할 경우 매개변수에 소유권이 옮겨지고 함수가 종료되어 매개변수가 소멸되면서 객체를 삭제하고, 기존 unique_ptr은 아무것도 가리키지 않게 됨

 

void temp(unique_ptr<const int>& ci);

unique_ptr<int> i;
unique_ptr<const int> ci;

temp(i); // 에러
temp(ci);

temp(i)가 에러인 이유는 템플릿 매개변수가 다르기 때문에 unique_ptr<int>와 unique_ptr<const int>는 아예 다른 타입이고, 암시적 변환이 안되기 때문

객체를 한 개만 생성하는 방법

클래스의 정적 멤버로 선언된 객체는 그것이 사용되든 사용되지 않든 상관없이 생성됨

함수 안에서 정적 변수로 선언된 객체는 함수가 최소한 한 번 호출되어야 생성됨

 

클래스의 정적 멤버로 선언된 객체는 초기화 시점이 정확하게 정의되어 있지 않음

  • C++는 하나의 컴파일 단위(오브젝트 파일) 안에 선언된 정적 객체들의 초기화 순서는 보장하고 있지만 서로 다른 컴파일 단위에 있는 정적 객체의 초기화 순서는 정의하지 않음

함수 안에서 정적 변수로 선언된 객체는 초기화 시점이 함수가 처음 호출되는 시점으로 명확함

 

정적 객체를 선언한 비멤버 함수를 inline으로 선언하면 호출하는 곳마다 정적 객체가 선언되므로 inline으로 선언하면 안됨

Printer& thePrinter()
{
    static Printer p;
    return p;
}

 

인스턴스 카운팅 기능을 가진 기본 클래스

template <class BeingCounted>
class Counted
{
public:
    class TooManyObjects
    {};

    static size_t objectCount() { return numObjects; }

protected:
    Counted();
    Counted(const Counted&);
    virtual ~Counted() { --numObjects; }

private:
    void init();

    static size_t numObjects;
    static const size_t maxObjects;
};

template <class BeingCounted>
Counted<BeingCounted>::Counted()
{
    init();
}

template <class BeingCounted>
Counted<BeingCounted>::Counted(const Counted<BeingCounted>&)
{
    init();
}

template <class BeingCounted>
void Counted<BeingCounted>::init()
{
    if (numObjects >= maxObjects)
    {
        throw TooManyObjects();
    }

    ++numObjects;
}

각 클래스마다 별도의 카운터를 두어야하기 때문에 클래스 템플릿으로 구현

기본 클래스로만 사용되도록 설계됐기 때문에 생성자와 소멸자가 모두 protected로 선언됨

예외 대신에 다른 알림으로도 사용할 수 있음

가상 함수가 호출될 때 그 함수에 대해 실행되는 코드는 함수가 호출되는 객체의 동적 타입에 맞는 것이어야 함

컴파일러는 가상 테이블(vtbl)과 가상 테이블 포인터(vptr)를 사용하여 동적 타입을 판단

 

vtbl

vtbl의 각 요소는 클래스에 정의한 가상 함수 포인터

vtbl의 크기는 클래스에 선언된 가상 함수의 수에 비례

vtbl은 클래스에 한 개만 생성되기 때문에 크기는 미미하지만 소프트웨어 전체에 가상 클래스나 가상 함수가 많을 때에는 무시 못할 메모리 부담이 됨

vtbl은 일반적으로 컴파일러에 의해 가상 함수의 정의가 있는 오브젝트 파일에 위치

 

vptr

vptr은 가상 테이블을 가리키는 포인터

가상 함수를 선언한 클래스에는 숨겨진 데이터 멤버 vptr이 있어서 객체의 크기에 포인터의 크기만큼 추가됨

 

class C1;
class C2 : public C1;

void makeACall(C1* pC1)
{
    pC1->f1();
}

가상 함수가 호출되는 과정

pC1이 가리키는 객체의 vptr을 가리키는 vtbl을 참조하여 호출해야 하는 함수의 포인터를 찾아옴

찾아온 함수 포인터가 가리키는 함수를 호출

 

가상 함수 호출에 드는 비용은 함수 포인터를 통해 함수를 호출하는 비용과 같기 때문에 가상 함수만 놓고 볼 때는 수행 성능의 발목을 잡는 요인은 아님

인라인은 컴파일 도중에 호출 위치에 호출되는 함수의 본문을 끼워 넣는다는 의미인데, 가상 함수는 호출할 함수를 런타임까지 기다려 결정한다는 의미이므로 가상 함수는 인라인 효과를 포기해야 함

 

다중 상속 중 다이아몬드 상속일 경우 기본 클래스의 데이터 멤버가 중복 생성되므로 가상 상속을 통해 중복 생성을 방지할 수 있음

하지만 가상 상속도 비용이 발생하므로 아래 글을 참고

 

Effective C++ 항목 40 다중 상속은 심사숙고해서 사용하자

이것만은 잊지 말자!- 다중 상속은 단일 상속보다 확실히 복잡함 모호성 문제 뿐만 아니라 가상 상속이 필요해질 수 있음 - 가상 상속을 쓰면 크기 / 속도 비용이 늘어나며 초기화 및 대입 연산의

eovywjr1.tistory.com

 

RTTI(RunTime Type Identification)

런타임에 객체와 클래스의 정보를 알아낼 수 있게 하는 정보

type_info 타입의 객체에 저장하고, type_info 객체는 typeid 연산자를 써서 접근할 수 있음

클래스마다 RTTI 정보를 하나씩만 유지하고 vbtl의 0번째 인덱스에 type_info 객체에 대한 포인터가 위치

객체의 동적 타입을 정확히 알아내려면 클래스에 가상 함수가 최소한 하나는 있어야 함

+ Recent posts

목차