[C++] 클래스 상속 시, 소멸자를 가상 함수(Virtual)로 만들어야 하는 이유

2022. 7. 7. 16:28Languages/C++

 

 

 

정적 바인딩(Static binding)이 오버라이드 메소드에 미치는 영향

 

객체 지향 언어를 통해 프로그래밍을 하다 보면, 부모 클래스로 자식 클래스를 가리키는 업 캐스팅(Upcasting)을 사용할 때가 많습니다.

Parent* obj = new Child();

 

우리 입장에서는 부모 클래스 포인터로 자식 클래스 객체를 가리키고 있구나라고 알 수 있지만, 컴퓨터 입장에서는 부모 클래스의 포인터이므로 당연히 부모 클래스 객체를 가리키고 있다고 생각합니다.

 

따라서, 자식 클래스에 오버라이딩(Overriding)된 메소드가 있다면 여기에서 문제가 발생하게 됩니다.

Parent* p = new Parent();
Parent* c = new Child();

p->Function();   // Parent 클래스의 Function() 호출
c->Function();   // Parent 클래스의 Function() 호출

 

부모 클래스의 포인터이므로 부모 클래스의 메소드와 바인딩을 컴파일 타임에 결정해버리는 겁니다.

이것을 정적 바인딩(Static binding)이라고 합니다. 

 

이에 반해, 동적 할당런타임(Run time)에 일어나는 과정이므로 부모 클래스 포인터로 자식 클래스 객체를 가리키더라도 이미 컴파일 타임에 결정된 사항에 따르게 되는 것이죠. 이런 상황이라면 업 캐스팅을 하는 의미가 없습니다. 해결해 줘야겠죠.

 

 

해결책은 동적 바인딩(Dynamic binding), virtual 키워드

 

컴파일 타임에 바인딩이 일어나는 게 문제였으므로, 런타임에 원하는 메소드를 알아서 결정하여 사용할 수 있도록 하는 동적 바인딩이 필요해졌습니다. C++에서는 virtual 키워드로 이것을 지원하고 있습니다. virtual 키워드가 붙은 함수를 가상 함수(virtaul function)라고 하죠.

#include <iostream>

class Parent{
public:
    virtual void Function() {   // virtual
        std::cout << "부모 클래스 Function()" << std::endl;
    } 
};


class Child : public Parent {
public:
    void Function() override {  //override
        std:: cout << "자식 클래스 Function()" << std::endl;
    } 
};

 

부모 클래스의 메소드에는 virtual 키워드를, 자식 클래스의 메소드에는 override 키워드를 붙여주면 됩니다.

(C++ 11부터 override 키워드를 통해 명시적으로 나타낼 수 있습니다.)

 

사실 override 키워드는 생략해도 상관없지만, 프로그래머의 실수를 방지할 수 있게 해주므로 쓰는 걸 권장한다고 합니다. 만약 가상 함수를 오버라이드 하려 했던 메소드였는데, 하지 않았다면 컴파일 타임에 오류를 발생시켜주니까요.

 

Parent* p = new Parent();
Parent* c = new Child();

p->Function();   // Parent 클래스의 Function() 호출
c->Function();   // Child 클래스의 Function() 호출

 

 

클래시 상속 시, 소멸자도 가상 함수로 만들어줘야 한다.

 

클래스 상속이 있다면, 바로 소멸자도 가상함수로 만들어줘야 합니다. 설명을 위해 코드를 빌려왔습니다.

#include <iostream>

class Parent {
public:
    Parent() { std::cout << "Parent 생성자 호출" << std::endl; }
    ~Parent() { std::cout << "Parent 소멸자 호출" << std::endl; }
};

class Child : public Parent {
public:
    Child() { std::cout << "Child 생성자 호출" << std::endl; }
    ~Child() { std::cout << "Child 소멸자 호출" << std::endl; }
};

 

단순히 스택 영역에 객체를 생성할 때는 별 문제가 없습니다.

Child c;

 

스택 할당

 

동적 할당 역시 delete만 잘 해주면 문제가 될 게 없죠.

Child* c = new Child();
delete c;

동적(힙) 할당

 

다만 업 캐스팅을 했을 때는 문제가 생깁니다.

Parent* p = new Child();
delete p;

Child 소멸자가 호출되지 않는다.

 

이런 문제가 발생하는 이유는 위의 내용들을 읽었다면 아실거라 생각합니다. 따라서, 이 부분도 virtaul 키워드로 해결할 수 있습니다.

class Parent {
public:
	Parent() { std::cout << "Parent 생성자 호출" << std::endl; }
	virtual ~Parent() { std::cout << "Parent 소멸자 호출" << std::endl; }  // 가상 소멸자
};

 

이렇게 부모 클래스의 소멸자를 가상 소멸자로 만들어주면 됩니다. 다시 실행해보면,

 

가상 소멸자로 만든 후

 

Child 클래스는 자신이 Parent 클래스를 상속받는다는 사실을 알고 있기 때문에, 자신이 소멸될 때 Parent 클래스의 소멸자도 호출하게 됩니다. (소멸자는 생성자의 역순으로 호출된다는 사실을 생각하면 됩니다.)

 

하지만 Parent는 자신이 누구에게 상속을 해주는지 알 수 없기 때문에 먼저 소멸하게 되어 버리면, Child 클래스의 소멸자를 호출해줄 수 없게 됩니다.

 

따라서, 부모가 되는 기반 클래스들은 반드시 소멸자를 virtaul로 만들어주어야 추후에 메모리 누수 문제가 발생하지 않습니다.

 

 

참조(Reference) 또한 가능

 

부모(기반) 클래스의 참조(Reference)여도 문제없이 잘 작동합니다.

#include <iostream>
#include <string>

class Parent {
protected:
    std::string name;
public:
    Parent() { name = "부모"; }
    virtual std::string GetName() const { return name; }
};

class Child : public Parent {
public:
    Child() { name = "자식"; }
    std::string GetName() const override { return name; }
};

 

다음과 같이 참조를 받는 함수가 있다고 하면,

void Call(Parent& parent) {
    std::cout << "누가 날 불렀니? : " << parent.GetName() << std::endl;
}

 

호출해보면 잘 작동하는 것을 볼 수 있습니다.

Parent p;
Child c;

Call(p);
Call(c);

 

 

728x90
반응형