CPP Module 04 (1)

“CPP Module 04에 대하여”

Subtype polymorphism, abstract classes, interfaces

런타임 다형성의 모든 것

ex00: Polymorphism

가상 메소드 테이블(virtual method table)

혹은 가상 함수 테이블(virtual function table)

  • virtual 키워드를 통해, 런타임에 컴파일러가 클래스 포인터가 실제로 가리키는 객체의 오버라이딩된 함수를 실행할 수 있다고 말한 바 있다. 그것이 가능한 이유가 바로 vtable에 있다.

Vtable 참조, Understanding Virtual Tables in C++

  • 가상 함수(virtual function)를 포함한 클래스를 컴파일할 경우, 컴파일러는 가상 메소드 테이블(약칭 vtable)을 만든다. vtable는 클래스에서 접근 가능한 가상 함수에 대한 목록과 그 함수에 접근할 수 있는 포인터가 포함되어 있다.
#include <iostream>

class	Base
{
    public:
        virtual void	print(void) // virtual 키워드의 사용
        {
            std::cout << "I am base" << std::endl;
        }
        virtual void    whoamI(void)
        {
            std::cout << "who cares" << std::endl;
        }
};

class	Derived : public Base
{
    public:
        void	print(void) // override
        {
            std::cout << "I am derived" << std::endl;
        }
};

int main(void)
{
    Base*	base = new Derived(); // OK!
    base->print();
    delete base;
}
  • 지난 모듈에서 썼던 Base 클래스에 새로 int 변수 하나와 void whoamI()라는 함수를 추가했다. 그거 말고는 그대로다. 위 코드의 vtable은 대략 아래처럼 생길 것이다.


  • 기반 클래스에 virtual로 선언되었더라도, 파생 클래스에서 구현되지 않았으면 기반 클래스의 vtable 항목이 사용된다.

  • 컴파일러는 각 클래스의 vtable로 향하는 Vpointer를 클래스 매개 변수에 추가한다. 아래를 보자.

(1) virtual 함수를 사용하지 않은 class의 size

#include <iostream>

class	Base
{
    public:
        int num;
        void	print(void) // 이 함수는 Derived 클래스에서는 은닉(Hiding)된다
        {
            std::cout << "I am base" << std::endl;
        }
};

class	Derived : public Base
{
    public:
        void	print(void)
        {
            std::cout << "I am derived" << std::endl;
        }
};

int main(void)
{
    Base    b;
    Derived d;
    std::cout << sizeof(b) << ' ' << sizeof(d) << '\n';
}
❯ ./a.out
4 4
  • virtual이 선언되어 있지 않다면, Base의 size는 int(int num)를 포함한 4이다. Base를 상속한 Derived도 마찬가지이다.

(2) virtual 함수를 사용한 class의 size

#include <iostream>

class	Base
{
    public:
        int num;
        virtual void	print(void) // virtual 키워드의 사용
        {
            std::cout << "I am base" << std::endl;
        }
        // POINTER*    Vptr; 컴파일러에서 생성되는 Vpointer, 일반적으로 숨겨져 있다.
};

class	Derived : public Base
{
    public:
        void	print(void) // override
        {
            std::cout << "I am derived" << std::endl;
        }
        // POINTER*    Vptr; 컴파일러에서 생성되는 Vpointer, 일반적으로 숨겨져 있다.
};

int main(void)
{
    Base    b;
    Derived d;
    std::cout << sizeof(b) << ' ' << sizeof(d) << '\n';
}
❯ ./a.out
16 16
  • virtual이 선언되어 있다면 class에 각자의 vtable로 향하는 vptr(size 8)이 선언되고, int는 바이트 패딩되어(size 4 -> size 8) Base와 Derived는 16의 사이즈를 가지게 된다. 아래와 같은 구조를 가질 것이다.


  • C++ 클래스 내에서 vtable을 가리키는 vptr(가상 포인터)는 컴파일러 및 플랫폼에 따라 다를 수 있지만, 일반적으로 클래스 객체 내부의 숨겨진 멤버 변수로 존재한다. 이러한 vptr은 실제로 C++ 표준에 정확한 규격이 없으며, 컴파일러 구현에 따라 다를 수 있다고 한다.

  • vptr과 vtable은 C++의 내부 메커니즘으로, 일반적으로 직접 접근하거나 조작하지 않도록 권장된다. vptr과 vtable을 눈으로 확인해 보려고 부단한 노력을 해봤는데, 본인이 어셈블리를 읽을 수 있는 것도 아니라서 포기했다… 굳이 눈으로 봐야할 필요가 없기도 하다.

virtual 소멸자

(1) virtual 소멸자를 사용하지 않은 경우

  • 지난 모듈에서부터 암묵적으로 소멸자에 virtual을 붙여왔다. virtual의 원리를 알고 나니 왜 소멸자에 virtual을 붙여야하는지가 명확해졌다. 소멸자에 virtual을 붙이지 않으면 소멸자 함수가 정적 바인딩되어 적당한 순서로 소멸자를 호출할 수 없게 되고, 호출되지 못한 소멸자에 동적 할당했던 메모리를 해제하는 부분이 포함되어 있는 경우, 그 부분을 생략하게 되어 메모리 누수가 발생하게 된다.
#include <iostream>

void    leakcheck()
{
    system("leaks a.out | grep \"ROOT LEAK\"");
}
class	Base
{
    public:
        Base() {}
        ~Base() { std::cout << "Bye Bye" << std::endl; }
};

class	Derived : public Base
{
    private:
        int* num = new int(1);
    public:
        Derived() {}
        ~Derived() {
            delete  num; 
            std::cout << "See Ya" << std::endl; 
        }
};

int main(void)
{
    atexit(leakcheck);
    Base*   b = new Derived();
    delete  b;
}
❯ ./a.out
Bye Bye
    1 (16 bytes) ROOT LEAK: 0x7ffdef405970 [16]
  • Derived 클래스의 소멸자가 호출되지 않았다. Derived 소멸자가 호출되지 않았으므로, int* num에 대한 메모리 누수가 발생하게 된다.

(2) virtual 소멸자를 사용한 경우

  • 지난 모듈에서부터 암묵적으로 소멸자에 virtual을 붙여왔다. virtual의 원리를 알고 나니 왜 소멸자에 virtual을 붙여야하는지가 명확해졌다. 소멸자에 virtual을 붙이지 않으면 소멸자 함수가 정적 바인딩되어 적당한 순서로 소멸자를 호출할 수 없게 되고, 하이딩(Hiding)되어 숨겨진 소멸자에 동적 할당했던 메모리를 해제하는 부분이 포함되어 있는 경우, 그 부분을 생략하게 되어 메모리 누수가 발생하게 된다.
#include <iostream>

void    leakcheck()
{
    system("leaks a.out | grep \"ROOT LEAK\"");
}
class	Base
{
    public:
        Base() {}
        virtual ~Base() { std::cout << "Bye Bye" << std::endl; }
};

class	Derived : public Base
{
    private:
        int* num = new int(1);
    public:
        Derived() {}
        ~Derived() {
            delete  num; 
            std::cout << "See Ya" << std::endl; 
        }
};

int main(void)
{
    atexit(leakcheck);
    Base*   b = new Derived();
    delete  b;
}
❯ ./a.out
See Ya
Bye Bye
  • Derived 클래스의 소멸자가 호출되어, 메모리가 적절히 해제된다.

virtual 키워드를 적절히 활용하여, Animal과 Cat, dog의 makesound 함수와 getType, 소멸자를 구현해보자…

ex01: I don’t want to set the world on fire

  • 지난 Exercise의 Cat과 Dog 클래스에 이어 Brain이라는 새로운 클래스가 등장한다. Brain 클래스는 멤버 변수로 100개의 string을 가진 idea라는 문자열 배열을 가지고 있다. Cat과 Dog는 이제 멤버 변수로 Brain 클래스의 원시 포인터를 가지게 된다. Cat과 Dog는 생성될 때, 자신만의 Brain을 생성해야 한다. 또한 소멸할 때 자신의 Brain을 해제해야 한다. Cat과 Dog의 복사본이 생성될 때, 얕은 복사가 일어나서는 안된다. 즉 Cat, Dog의 Brain이 각자의 메모리 주소를 가져야 한다. 물론 메모리 누수가 일어나서는 안된다.

  • main 문에서는 Animal 객체의 배열을 만들어야 한다. 절반은 Dog이고 나머지는 Cat이어야 한다. 반복문을 돌면서 동적 할당하고, 마찬가지로 반복문을 돌면서 해제한다. 생성자와 소멸자가 적절한 순서에 따라 호출되어야 한다. 이 부분은 Animal의 소멸자에 virtual 키워드를 붙였다면 자연스럽게 해결되는 문제이다.

Brain의 주소 호출

  • main에서 구현하도록 지시된 객체 배열을 만들기 전에 먼저 확인해야하는 부분이다. 아래 코드를 보자.
class Cat : public Animal
{
    private:
        Brain	*head;
    public:
        // OCCF
        Cat();
        Cat(const Cat &ref);
        Cat &operator=(const Cat &ref);
        ~Cat();
        void    showHead(); // head의 주소를 출력하는 void showHead()
        // ... 그 외의 getter, setter
}

void    Cat::showHead() // head의 주소를 출력
{
    std::cout << this->head << std::endl;
}

int main()
{
    Cat* c1 = new Cat();
    Cat  c2(*c1);

    std::cout << "c1 address : " << c1 << std::endl;
    std::cout << "c2 address : " << &c2 << std::endl;

    std::cout << "c1 brain address : "; 
    c1->showHead();
    std::cout << "c2 brain address : "; 
    c2.showHead();

    delete c1;
    return 0;
}
  • Cat c1을 매개 변수로 Cat c2를 복사 생성했다. 각자의 주소가 어떻게 출력될까?

복사 생성 시 얕은 복사가 일어났을 때

c1 address : 0x7fe6db405960
c2 address : 0x7ffeeec4b4b8
c1 brain address : 0x7fe6db809800
c2 brain address : 0x7fe6db809800
  • c1과 c2의 brain이 동일한 주소를 가리킨다. c1의 brain이 가지고 있는 string 배열에 수정을 가하면, c2의 brain이 가지고 있는 string 배열에도 똑같은 변화가 적용될 것이다.
int main()
{
    Cat* c1 = new Cat();
    Cat  c2(*c1);

    // ...
    std::cout << c1->getThink(50) << std::endl;
    std::cout << c2.getThink(50) << std::endl;
    c1->setThink(50, "HEY");
    std::cout << c1->getThink(50) << std::endl;
    std::cout << c2.getThink(50) << std::endl;
}
  • c1->getThink(50)는 c1의 brain이 가진 std::string arr[100]의 arr[50]을 반환하는 함수다. c1->setThink(50, std::string)은 c1의 brain이 가진 std::string arr[100]의 arr[50]을 두 번째로 들어온 매개 변수 string으로 변경하는 함수다.

  • 나는 과제를 진행할 때, 문자열 배열의 모든 문자열들을 “LOVE”로 초기화했다. setThink()로 바꾸지 않는 한 getThink()로 어떤 인덱스를 호출하든 전부 LOVE를 반환할 것이다.

LOVE
LOVE
HEY
HEY
  • c2가 복사 생성될 때, brain이 얕은 복사되었다면, c1->setThink()가 실행되었을 때 c2의 brain에도 변경 사항이 적용된다. c1의 brain의 string arr[50]을 HEY로 변경하자, c2 brain의 string arr[50]도 HEY로 변경되었다.

복사 생성 시 깊은 복사가 일어났을 때

c1 address : 0x7ffdea405960
c2 address : 0x7ffeeb3ae4b8
c1 brain address : 0x7ffdea809800
c2 brain address : 0x7ffdea80a200
  • c1과 c2의 brain이 다른 주소를 가리킨다. c1의 brain이 가지고 있는 string 배열에 수정을 가해도, c2의 brain이 가지고 있는 string 배열에는 어떠한 변화도 없을 것이다.
int main()
{
    Cat* c1 = new Cat();
    Cat  c2(*c1);

    // ...
    std::cout << c1->getThink(50) << std::endl;
    std::cout << c2.getThink(50) << std::endl;
    c1->setThink(50, "HEY");
    std::cout << c1->getThink(50) << std::endl;
    std::cout << c2.getThink(50) << std::endl;
}
LOVE
LOVE
HEY
LOVE
  • c2가 복사 생성될 때, brain이 깊은 복사되었다면, c1->setThink()가 실행되었을 때 c2의 brain에는 변경 사항이 적용되지 않는다. c1의 brain의 string arr[50]을 HEY로 변경했지만, c2 brain의 string arr[50]은 여전히 LOVE다.

대입 연산자 오버로딩

Cat&	Cat::operator=(const Cat &ref)
{
	std::cout << "Assigning operator called" << std::endl;
	if (this != &ref)
	{
		this->type = ref.type;
        // ... something missing
	}
	return (*this);
}

int main()
{
    Cat* c1 = new Cat();
    Cat c2(*c1);

    std::cout << "c1 brain address : "; c1->showHead();
    std::cout << "c2 brain address : "; c2.showHead();
    return 0;
}
c1 brain address : 0x7ffa6f009800
c2 brain address : 0x0  <-- 복사 생성 시 초기화되지 않은 c2의 brain
  • 위의 코드는 내가 만들었던 Cat의 대입 연산자 오버로딩이다. 이 대입 연산자 오버로딩에는 문제가 있다. 어떤 문제가 있을까?

  • 바로 Brain에 대한 초기화 라인이 없다는 거다. 저기서 생성된 *this의 Brain에 대한 주소를 출력하면 NULL이 나온다. 대입 연산자 오버로딩을 명시했으면서, 거기에 Brain을 초기화시키지 않았으니 NULL이 나오는게 이상하지 않다. 통과한지 꽤 시간이 지났는데, 이제야 이 사실을 알게 되었다. 이건 얕은 복사도 깊은 복사도 아니고 그냥 복사를 안 한거기 때문에 실격 사유다… 운빨로 평가 통과하는 사람

  • 제대로 하려면 아래처럼 Brain에 대한 초기화 라인을 작성해야 할 것이다.

Cat&	Cat::operator=(const Cat &ref)
{
    std::cout << BOLDMAGENTA;
    std::cout << "assigning" << std::endl;
    if (this != &ref)
    {
        this->type = ref.type;
        this->head = new Brain(); // Brain에 대한 초기화
    }	
    return (*this);
}

ex02: Abstract class

Creating Animal objects doesn’t make sense after all. It’s true, they make no sound!

순수 가상 함수와 추상 클래스(abstract class)

  • 언더테일을 흉내낸 프로그램을 하나 만들어본다고 해보자. 필드에서 엔카운터하는 적들이 필요할 것이다. 클래스의 상속에 대해 알고 있다면, 그 모든 적들을 개개의 클래스로 구현하지는 않을 것이다. 아래와 같은 기반 클래스를 만들 수 있을 것이다.
//undertale.cpp
class   Enemy
{
    protected:
        size_t  hp;
        size_t  atk;
        size_t  def;
        size_t  exp;
        size_t  gold;
        std::string word;
    public:
        Enemy(size_t _hp, size_t _atk, size_t _def, \
        size_t _exp, size_t _gold, std::string _word) 
        {
            hp = _hp; atk = _atk; def = _def; 
            exp = _exp; gold = _gold; word = _word;
        }
        virtual ~Enemy() { 
            std::cout << BOLDCYAN << "It disappeared beyond the small wilderness..." 
            << RESET << std::endl; 
        };       
        virtual void    speak() = 0; // pure virutal function
};
  • 적으로써 갖추어야 할 status, 생성자와 가상 소멸자 그리고 가상 함수로 void speak()라는 함수가 선언되어 있다. 이 함수의 정체가 바로 순수 가상 함수(pure virtual function)이다. 이 함수는 그야말로 아무 것도 하지 않는다(‘= 0;’). 이 함수는 반드시 파생 클래스에서 오버라이딩 되어야만 한다. 기반 클래스에서 순수 가상 함수가 정의되고, 파생 클래스에서 정의되지 않는다면 아래처럼 컴파일 오류가 발생하게 된다.
undertale.cpp:18:25: note: unimplemented pure virtual method 'speak' in 'Froggit'
        virtual void    speak() = 0; 
  • 또한 순수 가상 함수를 호출하는 것 자체도 불가능하다. C++은 순수 가상 함수의 호출을 막기 위해 추상 클래스 자체를 선언하지 못하도록 만들었다. 이것은 Polymorphism의 무결성을 지키기 위한 C++의 설계로 볼 수 있다. 추상 클래스를 선언하면 아래와 같은 컴파일 오류가 발생한다.
Undertale.cpp:38:13: error: variable type 'Enemy' is an abstract class
    Enemy   e(20, 4, 5, 10, 0, "Here's new Enemy!");
            ^
  • 다만 파생 클래스에서 추상 클래스인 기반 클래스의 모든 순수 가상 함수를 오버라이딩 해주었을 때, 그 파생 클래스를 호출할 수 있게 된다. 이렇게 순수 가상 함수를 최소 하나 포함하고, 반드시 상속되어야 하는 클래스를 추상 클래스(abstract class)라고 한다. Enemy의 파생 클래스를 하나 만들어보자. 아래는 가장 처음 조우하게 되는 적인 Froggit 클래스다.


Froggit Froggit


class   Froggit : public Enemy
{
    public:
        Froggit() : Enemy(20, 4, 5, 10, 20, "Pondering Life") {
            std::cout << "Froggit attacks you!" << std::endl;
            std::cout << BOLDYELLOW << this->word << RESET << std::endl;
        };
        void    speak() // override 
        {
            std::cout << BOLDGREEN << "Croaking Sound" << RESET << std::endl;
        }
        ~Froggit() {
            std::cout << "Life is difficult for this enemy..." << std::endl;;
        }
};
  • 파생 클래스 Froggit에서 기반 클래스 Enemy에 정의된 순수 가상 함수 virtual void speak()를 override했다. 마침내 main 문에 Froggit을 정의할 수 있게 되었다.
int main()
{
    Enemy*   f = new Froggit();
    f->speak();

    delete f;
}


  • 의도한 바와 같이 잘 작동하는 것을 확인할 수 있다.

  • 흔히 추상 클래스를 “설계도”라고 한다. 위의 코드에서 모든 Enemy들은 반드시 void speak()하고, 각각의 Enemy들은 다른 내용을 speak 한다는 것을 전달하는 것이다. 따라서 사용자는 Enemy의 파생 클래스들을 만들 때 speak()의 세부 사항을 사양에 맞게 구현하면 된다.

  • 추상 클래스의 또 한가지 특징은 비록 객체는 생성할 수 없지만, 추상 클래스를 가리키는 포인터는 문제 없이 만들 수 있다는 점이 있다. 따라서

Enemy   *e

라고 선언하는 것 자체는 불가능하지 않다.

  • Animal에 순수 가상 함수 makesound()를 포함시키고, 파생 클래스 Cat과 Dog에서 적절히 override 하자.