C++은 할건데, C랑 다른 것만 합니다. 10편 가상 소멸자와 가상 함수, 다중 상속

0

C++

목록 보기
10/10

클래스에 관하여

https://modoocode.com/210

본 포스팅은 위 포스트를 정리한 내용입니다.

1. virtual 소멸자

한가지 궁금한 것이있다. 이전 시간에 기반 클래스로 된 변수가 내부 객체로 파생 클래스를 가지고 있다면, 다운 캐스팅이나 virtual 키워드를 쓰지 않는 이상, 기반 클래스의 함수와 맴버 변수를 호출한다는 것을 알게되었다.

#include <iostream>

using namespace std;

class Soldier {
    string name;
    public:
        Soldier() : name("solider") {
            cout << "Soldier class initialization" << endl; 
        }

        void training(int i){
            cout << name << " " << "헛둘헛둘" << i << endl;
        }
};

class Sergeant : public Soldier {
    string name;
    public:
        Sergeant() : name("sergeant") , Soldier() {
            cout << "Sergeant class initialization" << endl;
        }

        void training(int i) {
            cout << name << " " << "훈련을 하나마나" << i * 10 << endl;
        }
};

int main(){
    Soldier *soldier;
    Sergeant sergeant;
    soldier = &sergeant;
    soldier->training(3);
}
Soldier class initialization
Sergeant class initialization
solider 헛둘헛둘3

친근한 위의 예제를 보면, Soldier 클래스인 변수는 내부적으로 객체 sergeant를 갖는다. training 함수를 호출하는데, virtual도 아니고, 다운 캐스팅을 한 것도 아니기 때문에 기반 클래스인 Soldier 변수의 training이 호출된다.

그렇다면, 소멸자는 어떻게 될까???

생성자는 어차피 Sergeant를 만들어 줄 때 호출되므로 문제가 없다. 그런데 자동으로 호출되는 소멸자는 Soldier안에서 호출되기 때문에 Sergeant가 호출이 안될 수 있다.

왜냐하면 Soldier 변수에서는 Sergeant의 소멸자 함수를 볼 수 없기 때문이다.

소멸자가 있는 상태로 보도록 하자

#include <iostream>

using namespace std;

class Soldier {
    string name;
    public:
        Soldier() : name("solider") {
            cout << "Soldier class initialization" << endl; 
        }
        ~Soldier(){
            cout << "Soldier died" << endl;
        }

        void training(int i){
            cout << name << " " << "헛둘헛둘" << i << endl;
        }
};

class Sergeant : public Soldier {
    string name;
    public:
        Sergeant() : name("sergeant") , Soldier() {
            cout << "Sergeant class initialization" << endl;
        }
        ~Sergeant(){
            cout << "Sergeant died" << endl;
        }

        void training(int i) {
            cout << name << " " << "훈련을 하나마나" << i * 10 << endl;
        }
};

int main(){
    Soldier *soldier = new Sergeant();
    delete soldier;
}
Soldier class initialization
Sergeant class initialization
Soldier died

순서는 이렇게 된다.

부모 생성자 -> 자식 생성자 -> 자식 소멸자 -> 부모 소멸자

집에 비유하자면, 먼저 집의 큰 틀(부모 클래스) 만들고, 세부 사항 인테리어(자식 클래스)을 맞춘다. 그리고 집을 철거할 때는 세부 사항 인테리어(자식 클래스)를 먼저 치우고, 집의 큰 틀(부모 클래스)를 부순다.

그런데 위의 예제의 경우에는 자식 소멸자(병장)이 불리지 않았다.

이는 Soldier *soldier = new Sergeant();에서 soldier 변수가 내부적으로 Sergeant 객체를 갖고있기 때문이다.

때문에 virtual 키워드로 선언되는 함수가 아닌한, soldier 변수의 함수를 실행하게 된다.

따라서, 힙메모리에 남아있는 Sergeant객체는 사라지지 않게된다.

이를 해결하기 위해서 virtual 소멸자를 만드는 것이다. 만드는 그냥 기반 클래스에 virtual 키워드만 붙이면 된다.

class Soldier {
    string name;
    public:
        Soldier() : name("solider") {
            cout << "Soldier class initialization" << endl; 
        }
        virtual ~Soldier(){
            cout << "Soldier died" << endl;
        }

        void training(int i){
            cout << name << " " << "헛둘헛둘" << i << endl;
        }
};
Soldier class initialization
Sergeant class initialization
Sergeant died
Soldier died

virtual로 기반 클래스에서 불러진 소멸자가 자식 클래스의 소멸자를 부르는 것은 이제 이해가 되었다.

그런데, 부모 클래스의 소멸자는 누가 불러주는 것일까?? 자식 클래스의 소멸자가 알아서 부모 클래스의 소멸자를 호출해준다.

왜냐하면, 자식 클래스(병장)의 입장에서는 자신이 부모 클래스(군인)에게 상속받은 지를 안다. 그래서 자식에서 부모의 소멸자를 호출할 수 있던 것이었다. 그러나, 부모 클래스의 입장에서는 누가 자신에게 상속 받았는 지를 모른다. 따라서 부모 클래스에서 자식 클래스의 소멸자를 호출하지 못하는 것이다.

2. 가상 함수의 구현 원리

그럼 모두 virtual 함수로 만들어버리면 문제가 없는게 아닐까??

사실 누구는 virtual 누구는 그냥 함수로 만들어주는 것은 꽤나 머리아프고 피곤한 일이다. 그래서 java에서는 모든 함수들이 default로 virtual 함수로 선언된다.

그래서 자바가 c++보다 쉬운 것이다.

그런데 사실 virtual 키워드를 사용하는 것은 약간의 오버헤드가 존재한다. 즉, 보통의 함수를 호출하는 것보다 virtual 함수를 호출하는 데 걸리는 시간이 조금 더 오래 걸린다.

다음의 예제를 보도록 하자

class Soldier {
    public:
        virtual void training();
        virtual void eatting();
};

class Sergeant : public Soldier {
    public:
        virtual void training();
        void eatPxFood();
};

C++ 컴파일러는 가상 함수가 하나라도 존재하는 클래스에 대해서, 가상 함수 테이블(virtual function table (vtable))을 만든다.

이전에 virtual 키워드를 사용한 가상 함수는 동적 바인딩이 된다고 했다. 이 테이블이 바로 동적으로 어떤 함수를 실행할 지에 대해서 바인딩 시켜주는 자료구조가 된다.

[사진1, 가상함수 테이블]

가상 함수인 경우를 보면 가상함수 테이블이 하나있어, 이들이 동적으로 바인딩한 함수로 실행이 된다. 그러나, 일반 함수는 그냥 직방으로 바로 실행되기 때문에 가상 함수 테이블을 사용한 가상 함수는 오버헤드가 있을 수 밖에 없다.

물론 이 오버헤드가 시스템 전체 성능에 커다란 영향을 미치냐는 상황에 따라 달라진다. 디바이스 환경이 좋아져 오늘날에는 크게 문제될 만한 것은 아니게 되었지만 예전에는 큰 오버헤드로 생각했을 것이다.

3. 순수 가상 함수(pure virtual function)과 추상 클래스(abstract class)

virtual 키워드는 다형성(polymorphism)을 위한 것이다. 자바에서의 abstract와 헷갈려서는 안된다.

그럼 abstract 클래스는 c++에서 어떻게 만들까?? 그걸 가능하게 해주는 것이 순수 가상 함수(pure virtual function)이다.

문법이 아주 재밌다.

#include <iostream>

using namespace std;

class Soldier {
    public:
        Soldier(){}
        virtual ~Soldier(){}
        virtual void training() = 0;
        virtual void eatting() = 0;
};

class Sergeant : public Soldier {
    public:
};

int main(){
    Soldier *soldier = new Sergeant();
}

다음의 코드는 컴파일 에러가 발생한다. 이유는 다음과 같다.

추상 클래스 형식 "Sergeant"의 개체를 사용할 수 없습니다. -- 순수 가상 함수 "Soldier::training"에 재정의자가 없습니다. -- 순수 가상 함수 "Soldier::eatting"에 재정의자가 없습니다

즉, Soldier의 training과 eatting 함수를 override하지 않았다는 것이다.

그래서 soldier 의 순수 가상 함수를 보면

virtual void training() = 0;
virtual void eatting() = 0;

아주 재밌게 생겼다. 선언부만 있고 구현부인 {} 가 없고 = 0을 채우고 있다. 이것이 바로 순수 가상 함수(pure virtual function)이라고 한다.

순수 가상 함수는 반드시 오버라이딩 되어야만 하는 함수이다.

순수 가상 함수는 몸체(구현부 {})가 없기 때문에 직접 호출이 불가능하다. 따라서, 직접 호출도 안되니 객체 생성도 불가능하다.

Soldier soldier;

를 만들어보면 다음과 같은 에러가 발생한다.

추상 클래스 형식 "Soldier"의 개체를 사용할 수 없습니다. -- 함수 "Soldier::training"은(는) 순수 가상 함수입니다. -- 함수 "Soldier::eatting"은(는) 순수 가상 함수입니다.

순수 가상함수가 있기 때문에 클래스의 객체를 만들 수 없다는 것이다.

순수 가상 함수만 호출이 안되도록 하면되지, 굳이 객체까지 못만들게 할 필요가 있을까 생각이 들지만 c++개발자들은 아예 객체를 생성하지 못하도록하여 에러가 발생하지 못하도록 하였다.

이렇게 순수 가상 함수는 하나의 설계도로서 사용된다. 이 설계도에 따라 메서드를 오버라이드해놓으면 다형성에 따라 유연한 프로그래밍이 가능하기 때문이다.

따라서 군인(soldier)를 상속하여 순수 가상 함수들을 모두 정의한 병장(sergeant)를 만들면 호출이 가능하다.

#include <iostream>

using namespace std;

class Soldier {
    public:
        Soldier(){}
        virtual ~Soldier(){}
        virtual void training() = 0;
        virtual void eatting() = 0;
};

class Sergeant : public Soldier {
    public:
        void training(){
            cout << " 병장은 논다 " << endl;
        }
        void eatting(){
            cout << " 병장은 짬밥 안먹는다" << endl;
        }
};

int main(){
    Soldier *soldier = new Sergeant();
    soldier->training();
    soldier->eatting();
}
 병장은 논다 
 병장은 짬밥 안먹는다

이처럼 변수 soldiersergeant객체가 들어가서 soldier가 가진 순수 가상 함수를 호출하면 가상 함수이기 때문에 내부의 sergeant 객체의 training, eatting이 실행된다.

4. 다중 상속(multiple inheritance)

c++의 특징적인 문법 중 하나이자, 자바에서는 막아버린 다중 상속이다.

이는 자식(기반) 클래스가 하나의 부모만이 아닌 여러 부모를 가질 수 있다는 의미로, 여러 부모를 상속할 수 있다.

병장 is a 군인, 이지만, 병장 is a 사람이기도 하다.( 군인도 사람이야!) 그렇기 때문에 다음과 같은 다중 상속이 가능하다.

class Soldier {
    public:
        int a;
};

class People {
    public:
        int b;
};

class Sergeant : public Soldier, public People {
    public:
        int c;
};

[사진2 병장이 사람과 군인을 모두 상속받는 사진]

따라서 다음과 같은 결과도 가능하다.

Sergeant sergeant;
sergeant.a = 10;
sergeant.b = 20;
sergeant.c = 30;

4.1 다중 상속 시, 생성자 소멸자 호출 순서

생성자는 부모 먼저, 그리고 자식 순서라고 했다. 그런데 다중 상속된 부모들은 누가 더 먼저 일까??

#include <iostream>

using namespace std;

class Soldier {
    public:
        Soldier(){
            cout << "군인 생성" <<endl;
        }
        int a;
};

class People {
    public:
        People(){
            cout << "사람 생성" << endl;
        }
        int b;
};

class Sergeant : public Soldier, public People {
    public:
        Sergeant(){
            cout << "병장 생성" << endl;
        }
        int c;
};

int main(){
    Sergeant sergeant;
    sergeant.a = 10;
    sergeant.b = 20;
    sergeant.c = 30;
}
군인 생성
사람 생성
병장 생성

정답은 상속 순서대로 먼저 생성된다. 지금은 Soldier가 먼저라서 군인이 먼저 생성되고, 다음은 People이다.

만약 People을 먼저 상속하면 Soldier 보다 먼저 생성자가 불린다.

class Sergeant : public People, public Soldier {
    public:
        Sergeant(){
            cout << "병장 생성" << endl;
        }
        int c;
};

위에 코드에서 다음과 같이 변경하여 호출하면

사람 생성
군인 생성
병장 생성

이렇게 나온다.

4.2 다중 상속 시 주의할 점

다중 상속 시 가장 주의해야할 경우는 다음과 같은 경우이다.

#include <iostream>

using namespace std;

class Soldier {
    public:
        Soldier(){
            cout << "군인 생성" <<endl;
        }
        int mind;
};

class People {
    public:
        People(){
            cout << "사람 생성" << endl;
        }
        int mind;
};

class Sergeant : public People, public Soldier {
    public:
        Sergeant(){
            cout << "병장 생성" << endl;
        }
        int pxFood;
};

int main(){
    Sergeant sergeant;
    sergeant.mind = 10;
    sergeant.mind = 20;
    sergeant.pxFood = 30;
}

다음의 경우는 상속받는 두 개의 부모 클래스들이 서로 같은 이름의 함수 또는 변수를 갖고 있을 때이다. 이때 컴파일 에러가 발생하는데

"Sergeant::mind"이(가) 모호합니다.

어떤 것인지 모호할만 하다. 이렇듯 부모 클래스 둘 이상이 같이 이름을 갖는 함수나 변수를 갖을 때 에러가 발생할 수 있다.

이 부분은 어느정도 눈에 띄는 문제이고 약속을 통해서 해결이 가능하다 그러나 가장 유명한 다음의 문제가 있다.

  • 다이아몬드 상속, 공포의 다이아몬드 상속

자바가 다중 상속을 포기한 이유 중 하나이자, 다중 상속을 조심히써야하는 이유 중 하나이다.

군인 is 생명체 이고 사람 is 생명체이다. 따라서 다음과 같은 상속 관계가 성립한다.

#include <iostream>

using namespace std;

class Life {
    public:
        Life(){
            cout << "생명 생성" << endl;
        }
        int mind;      
};

class Soldier : public Life{
    public:
        Soldier(){
            cout << "군인 생성" <<endl;
        }
};

class People : public Life{
    public:
        People(){
            cout << "사람 생성" << endl;
        }
};

class Sergeant : public People, public Soldier {
    public:
        Sergeant(){
            cout << "병장 생성" << endl;
        }
        int pxFood;
};

int main(){
    Sergeant sergeant;
    sergeant.mind = 20;
}

[사진 3 공포의 다이아몬드 상속]

그림으로보면 다음과 같다.

왜 이것이 문제가 될까?? 군인과 사람은 mind라는 변수를 명시적으로 갖고 있진 않다. 그러나 Life를 상속하면서 mind 변수를 각각이 갖게되고, 이 둘을 병장이 상속하면서 문제 맨 처음 위에서 봤던 문제가 생기는 것이다.

그래서 에러사항도 다음과 같다.

"Sergeant::mind"이(가) 모호합니다.

누구의 mind인지를 모른다. 죽음의 다이아몬드는 나도 모른새에 똑같은 변수를 상속받았다는 문제가 있어 심각한 것이다. 이는 디버깅할 때 굉장히 어려워지기 때문이다.

만약 mind를 호출하지 않고 실행해보도록 하자

int main(){
    Sergeant sergeant;
}

이렇게 두면 실행이 가능하다.

생명 생성
사람 생성
생명 생성
군인 생성
병장 생성

그런데 Life가 두 번 생성되었다는 것을 알 수 있다.

[사진 5 생명이 두 번 생성된 사진]

이처럼 불필요하게 생성자가 두 번 호출이 되는데, 이는 병장 클래스은 자신도 모르는 사이에 똑같은 생명 클래스을 두 번 상속받게 된다는 것이다.

이러한 문제를 해결할 수 있는 방법이 있다!! 죽음의 다이아몬드를 해결할 수 있는 방법은 가상 상속이다.

4.3 virtual inheritance(가상 상속)

가상 상속은 상속할 때 접근 지시자 다음에 virtual이라는 키워드를 써서 쓰는 것이다.

#include <iostream>

using namespace std;

class Life {
    public:
        Life(){
            cout << "생명 생성" << endl;
        }
        int mind;      
};

class Soldier : public virtual Life{
    public:
        Soldier(){
            cout << "군인 생성" <<endl;
        }
};

class People : public virtual Life{
    public:
        People(){
            cout << "사람 생성" << endl;
        }
};

class Sergeant : public People, public Soldier {
    public:
        Sergeant(){
            cout << "병장 생성" << endl;
        }
        int pxFood;
};

int main(){
    Sergeant sergeant;
}

다음과 같이, 중간 단계인 군인 클래스사람 클래스에 virtual 키워드를 넣고 생명 클래스를 상속 받는다.

생명 생성
사람 생성
군인 생성
병장 생성

결과는 다음과 같다. 생명 클래스의 생성자가 한 번만 실행된 것을 확인할 수 있다.

이는 가상 상속 키워드가 붙으면 먼저 호출되는 클래스에서 먼저 부모 클래스를 호출하고 상속받기 때문이다.

그리고, 나머지 클래스들은 마치 그 부모를 상속받은 것마냥 가상적인 테이블에 매핑되는 것이다.

즉, 그림으로 보면 다음과 같다.

[사진 - 사람에게는 선이 있고, 군인에게는 실선이 있는 사진]

이처럼, 사람 클래스병장 클래스에서 먼저 상속을 진행하기 때문에 사람 클래스내부에서 생명 클래스의 생성자를 호출하고 생성한다.

그리고 다음으로 군인 클래스가 상속을 진행하는데, 먼저 생성된 생명 클래스가 있으므로 생성자를 호출하지 않고 virtual 맵핑을 한다. 그것이 실선이다.

이렇게 가상 상속을 통해서 죽음의 다이아몬드를 해쳐나갈 수 있게 되는 것이다.

그러나, 다중 상속은 그 이점도 크지만 단점도 크기 때문에 언제나 조심히, 그리고 섬세히 다루도록 하자

가상 상속의 내부 구현에 대해서는 나중에 더 자세히 알아보도록 하자

0개의 댓글