본문 바로가기
프로그래밍/C++

C++] virtual function(가상 함수)에 대하여...(햇갈리는 분들~ 어여 드루와)

by Hwan2 2020. 6. 21.
728x90
반응형

예전에 작성한 가상 함수글이 있습니다. 하지만 조금 미흡한것 같아 다시 작성해봅니다.



1. 일반적인 상속관계에서의 생성자, 소멸자 호출

일반적으로 클래스를 정의하고 객체를 선언하면 다음과 같은 생성자, 소멸자 호출이 됩니다.


1) 생성자, 소멸자 호출 확인 코드.

#include <iostream>

class A {
private:
public:
    A() { printf("A 생성자\n"); }
    ~A() { printf("A 소멸자\n"); }
};

class B : public A{
private:
public:
    B() { printf("B 생성자\n"); }
    ~B() { printf("B 소멸자\n"); }
};
int main(void) {
    B b;

    return 0;
}


B객체를 선언했는데 상속된 부모 클래스의 A생성자와 소멸자가 호출되었습니다.

즉, 자식클래스를 선언하면 부모의 생성자를 자동으로 호출하게되며, 소멸자 또한 자동으로 부모 소멸자까지 호출되게 됩니다.

이는 상속에 특성인데, 자식은 부모의 모든것을 물려받기 때문입니다.



2) 자식은 과연 부모의 모든 것을 물려 받는가?

#include <iostream>

class A {
private:
    int A_num;
    int A_sum;
public:
    A() { printf("A 생성자\n"); }
    ~A() { printf("A 소멸자\n"); }
    void fun1() { printf("A의 fun1\n"); }
};

class B : public A {
private:
    int B_num;
    int B_sum;
public:
    B() { printf("B 생성자\n"); }
    ~B() { printf("B 소멸자\n"); }
    void fun2() { printf("B의 fun1\n"); }
};
int main(void) {
    B b;
    printf("%d\n"sizeof(b));
    return 0;
}


객체 B의 크기는 16바이트 입니다. 즉, 부모 클래스의 맴버 변수들도 가지고 있다는 뜻입니다.

메모리 구조를 그려보면 다음과 같습니다.


스택에 자식 클래스가 객체가 되어 올라가게 되면 부모 클래스의 맴버변수부터 차례대로 입력이 됩니다.

때문에 16바이트의 크기가 나오게 됩니다.

이건 new를 통해 heap에 할당해도 마찬가지 입니다.


그럼 부모 클래스와 자식 클래스의 함수들은 어디로 간걸까???

함수의 정보는 없는것인가?? 

라고 생각할 수 있습니다.


해당 코드에서 따로 함수만 추가해 보겠습니다.


3) 클래스 안에 맴버 함수는 어디에 할당되는가?

#include <iostream>

class A {
private:
    int A_num;
    int A_sum;
public:
 //   A() { printf("A 생성자\n"); }
 //   ~A() { printf("A 소멸자\n"); }
    void fun1() { printf("A의 fun1\n"); }
};

class B : public A {
private:
    int B_num;
    int B_sum;
public:
//    B() { printf("B 생성자\n"); }
//    ~B() { printf("B 소멸자\n"); }
    void fun2() { printf("B의 fun1\n"); }
};

void fun3() {
    printf("일반 함수 fun3\n");
}

int main(void) {
    B b;
    b.fun1();
    b.fun2();
    fun3();

    return 0;
}


자식 클래스 b는 부모 클래스의 함수를 호출할 수 있으며, 자신의 함수도 호출을 할 수 있습니다.

그럼 이 정보는 어디서 나오는걸까?? 어떤 정보를 근거로 호출이 되는걸까?


이 예기는 잠시 접어두고 fun3()을 보겠습니다.

fun3() 함수는 일반적인 함수입니다.

그리고 우리는 main함수를 통해 아무렇지 않게 fun3()함수를 호출하고 있지요.

그럼 이 fun3()함수는 어떤 근거로 호출되는걸까요??


결론을 말하자면 호출방식은 둘다 같습니다.

일반적인 함수 fun3()의 함수 주소 위치의 정보는 fun3() 이름 자체에 있습니다.


해당 코드를 디스어셈블리로 확인해보면 다음과 같이 나오게 됩니다.


01F146Ah부분 위치에서 본 결과 ↓


즉, b.fun1(), b.fun2()

B::fun1(), B::fun2() 와 같다는 말이 됩니다.(물론 해당 코드는 static을 선언해야 호출되지만 이해를 돕기위해서...)

즉, 함수 자체에 함수 내용이 있는 곳을 가르키게 되니 객체엔 해당 메모리 주소 정보가 필요없습니다.

("__empty_global_delete"에 대한 레퍼런스를 찾아보려고 했는데 구글링해도 나오질 않네요..... 함수 호출 시 호출되는걸 보면

저기에 담겨진 후 호출되는 것 같습니다. delete 키워드가 있는걸 봐선... 코드 최적화? 메모리 최적화? 기능을 해주는 것 같고....)



또한B b[100]을 만든다 쳐도 100개의 B객체는 맴버 함수로 선언된 함수를 모두 공유하게 됩니다.

그림으로 그리자면 다음과 같습니다.


그리고 이 함수들은 코드섹션이라는 메모리에 존재하게 됩니다.




2. 가상 함수

앞써 제시된 글들은 모두 stack에 할당된 객체들이었습니다.
그리고 지금까지의 설명들은 이 가상 함수를 이해하기 위한 발판이었지요.

객체를 heap에 생성할 때는 가상 함수가 필수입니다.
"왜 객체를 굳이 heap에 할당하지? 객체를 그냥 선언해서 쓰면 되잖아??" 라고 생각하신 분들은
https://hwan-shell.tistory.com/213를 보고 오시는걸 추천드리겠습니다.


이번엔 객체를 heap에 만들어 보겠습니다.


1) heap에 할당된 객체

#include <iostream>

class A {
private:
    int A_num;
    int A_sum;
public:
    A() { printf("A 생성자\n"); }
    ~A() { printf("A 소멸자\n"); }
    void fun1() { printf("A의 fun1\n"); }
};

class B : public A {
private:
    int B_num;
    int B_sum;
public:
    B() { printf("B 생성자\n"); }
    ~B() { printf("B 소멸자\n"); }
    void fun2() { printf("B의 fun1\n"); }
};

int main(void) {
    A* a = new A();
    delete a;
    printf("------\n");
    B* b = new B();
    delete b;
    printf("------\n");

    A* c = new B(); //여기에 집중!!
    delete c;

    return 0;
}


결과를 보면 다소 이상한점을 확인할 수 있습니다.

바로 A* c = new B(); 부분 입니다.


생성자는 정상 호출됬지만 소멸자는 호출되지 않았습니다.

왜그럴까요??


new B()를 통해 B객체를 선언 했지만 포인터 타입을 A* 로 받았기 때문에 

해당 포인터로 선언된 객체 c는 A클래스의 정보밖엔 볼 수 없습니다.


이해를 돕기위해 그림을 그려보겠습니다.

위 그림을 보면 이렇게 생각할 수 있습니다.

"포인터 자료형은 A라면서?? 근데 왜 heap에는 B객체의 맴버변수가 들어있냐?"


new B()로 할당받았기 때문입니다......

이런게 가능하느냐?? 가능합니다.

그리고 이런걸 upcasting 이라고 합니다. downcasting도 있지요.

또한 heap에 사용할 수 없는 자식의 맴버변수가 잡히는 이유도 downcasting으로 인해

접근이 가능해 지기 때문입니다.



정리하자면.

1. A* c = new B(); 에서 new로 생성된 자료형에 따라 해당 자료형에 맞게 메모리가 할당된다.

2. 할당은 되지만 포인터 자료형이 A 이므로 A클래스의 맴버 변수와 맴버 함수만 볼 수 있다.

3. 사용할 수 없는 자식클래스의 맴버 변수들까지 할당되는 이유는 downcasting을 위해서다.

(사실 new를 통해 B객체를 생성했으니 당연히 B클래스의 내용이 들어가지만, 이 부분을 햇갈려 하는 분들이

많기에 위 설명처럼 설명했습니다. 사실 맞는 말이기도 하구요.)




다시 본론으로 들어와서 생성자와 소멸자 호출을 보겠습니다.

A생성자 -> B 생성자 -> A 소멸자 순으로 호출이 됐습니다.


생성자는 A와 B가 호출됐는데, 왜 소멸자는 A만 호출이 되었을까??

이는 코드가 실행되면서 호출되는 관계를 살펴보면 됩니다.


코드에 new B(); 만 실행시켜 보세요. 출력결과는 다음과 같이 나올 것입니다.

A생성자

B생성자

이렇게 말이죠.


왜그런지 이해를 돕기위해 그림을 다시한번 가져와 보겠습니다.




때문에 upcast를 통한 객체 선언은 이런 문제점이 있습니다.

이를 해결하기 위해 virtual이라는 키워드가 등장합니다.

(드디어 나왔네요.... virtual...)


virtual 키워드를 함수에 붙여주면 해당 클래스에는 v-table이란 것이 생성되게 됩니다.


우선 코드를 살펴보겠습니다.


1) virtual 키워드가 붙은 소멸자

#include <iostream>

class A {
private:
    int A_num;
    int A_sum;
public:
    A() { printf("A 생성자\n"); }
    virtual ~A() { printf("A 소멸자\n"); }
    void fun1() { printf("A의 fun1\n"); }
};

class B : public A {
private:
    int B_num;
    int B_sum;
public:
    B() { printf("B 생성자\n"); }
    ~B() { printf("B 소멸자\n"); }
    void fun2() { printf("B의 fun1\n"); }
};

int main(void) {
    A* c = new B();
    delete c;
    printf("A클래스의 크기 = %d, B클래스의 크기= %d\n"sizeof(A), sizeof(B));

    return 0;
}


생성자와 소멸자가 정상적으로 호출된 것을 확인할 수 있습니다.

하지만 클래스의 크기가 좀 이상해 졌습니다.

원래대로 라면 A 클래스의 크기는 8바이트가 나와야 하고,

B클래스의 크기는 16바이트가 나와야 하는데 말이죠...


이는 v-table을 가르키는 virtual table pointer가 추가가 되서 그렇습니다.

v-table이 선언된 객체의 매모리 구조를 보겠습니다.


이렇게 됩니다. 

"무슨 말이냐? B객체에는 virtual이라는 키워드가 없는데..."

부모 클래스에 virtual이 선언되면 자식 클래스는 자동으로 B클래스에 대한 

virtual pointer를 만들게 됩니다.



그럼 A* c = new B(); 에 대한 메모리 구조를 그려보겠습니다. 디테일 하게....

이런식으로 구성됩니다. new B() 를 사용해 메모리를 할당했기 때문에 B객체에 대한 v-table이 생성되게 됩니다.

또한 B객체의 v-table엔 B클래스의 소멸자에 대한 주소정보를 담고 있죠.



2) override된 가상함수

class A {
private:
    int A_num;
    int A_sum;
public:
    A() { printf("A 생성자\n"); }
    virtual ~A() { printf("A 소멸자\n"); }
    virtual void fun1() { printf("A의 fun1\n"); }
};

class B : public A {
private:
    int B_num;
    int B_sum;
public:
    B() { printf("B 생성자\n"); }
    ~B() { printf("B 소멸자\n"); }
    void fun1() override { printf("B의 fun1\n"); }
};


int main(void) {
    A* c = new B();
    c->fun1();
    delete c;

    return 0;
}


B 클래스에선 부모 클래스의 fun1() 함수를 override하고 있습니다.

부모 클래스의 함수를 자식클래스에서 그대로 가져와 재 정의하는 것을 override라고 합니다.



메모리 구조를 보면서 마무리하겠습니다.!!

이런식으로 호출되게 됩니다.


이러한 기능을 OOP의 dynamic polymorphism 이라고 합니다.

또한 https://hwan-shell.tistory.com/213 이 글과 같이 보시면 더 좋을 것 같습니다.!!




이걸 다 읽는 사람이 있을까?.....



반응형

댓글


스킨편집 -> html 편집에서