ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [C++] 가상 함수
    C++/이것이 C++이다 2021. 9. 5. 15:38

    가상 함수는 상속과 더불어 객체지향적 설계의 핵심적인 역할을 담당하는 문법입니다.

    관점을 다르게 하면, 가상 함수를 사용해야 해결되는 문제도 있을 만큼 매우 중요한 문법입니다.

    이번 글에서는, 가상 함수에 대해 다룹니다.

     


     

    1. 개요

     

    더보기

    가상 함수 (Virtual Function)는 virtual 예약어를 붙인 메소드를 의미합니다.

     

    가상 함수는 다음과 같이 선언됩니다.

    virtual [return type] [function]

     

    예제를 통해 가상함수가 일반 함수와 어떻게 다른지 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    class MyClassAnc {
    public:
        virtual void print_virtual() {
            cout << "Virtual Anc : " << data << "\n";
        }
    
        void print_normal() {
            cout << "Normal Anc : " << data << "\n";
        }
    
        void function() {
            cout << "---Virtual---\n";
            print_virtual();
            cout << "-------------\n\n";
    
            cout << "---Normal---\n";
            print_normal();
            cout << "------------\n";
        }
    
    protected:
        int data = 10;
    };
    
    class MyClassDes : public MyClassAnc {
    public:
        virtual void print_virtual() {
            cout << "Virtual Des : " << data * 2 << "\n";
        }
    
        void print_normal() {
            cout << "Normal Des : " << data * 2 << "\n";
        }
    };
    
    
    int main() {
        MyClassDes a;
        MyClassAnc& b = a; // Line 42
    
        a.print_virtual();
        b.print_virtual();
        cout << "\n";
        a.print_normal();
        b.print_normal();
        cout << "\n";
        a.function(); // Line 50
    
        return 0;
    }
    Virtual Des : 20
    Virtual Des : 20
    
    Normal Des : 20
    Normal Anc : 10
    
    ---Virtual---
    Virtual Des : 20
    -------------
    
    ---Normal---
    Normal Anc : 10
    ------------

    가상 함수와 일반 함수를 비교할 수 있는 예제입니다.

    표시된 Line 42와 함수 호출, 그리고 출력 결과를 보면 가상 함수와 일반 함수의 차이를 알 수 있을 것 입니다.

    가상 함수는 실 형식을 따르고, 일반 함수는 참조 형식(추상 형식)을 따르게 됩니다.

     

    가장 중요한 부분은 Line 50 입니다.

    Line 50에서 호출하는 function함수는 내부에서 가상 함수와 일반 함수를 호출합니다.

    중요한 것은, function함수가 호출하는 가상 함수는 그 클래스에 있는 함수가 아니라 이후에 재정의 된 함수가 될 수 있다는 것 입니다.

    조금 추상적으로 말하면, 미래에 재정의 된 함수를 호출한다 고 볼 수 있을 것 입니다.

     

    가상 함수는 호출하는 것이 아니라 호출되는 것이라고 합니다.

    미래의 유지보수를 고려해서 설계를 한다면, 가상 함수를 사용하는 것이 도움이 될 수 있습니다.

     

    추가로, 가상 함수의 오버라이드를 특정 이유로 막아야 할 상황이 올 수 있습니다.

    이럴 때에는 아래와 같이

    virtual [return type] [function] final

    final 키워드를 이용할 수 있습니다.

     

    2. 소멸자 가상화

     

    더보기

    우선 예제를 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    class MyClassAnc {
    public:
        MyClassAnc() {
            arr = new char[4];
        }
        ~MyClassAnc() { // Line 9
            cout << "~Anc()\n";
        delete arr;
        }
    
    private:
        char* arr;
    };
    
    class MyClassDes : public MyClassAnc {
    public:
        MyClassDes() {
            data = new int;
        }
        ~MyClassDes() {
            cout << "~Des()\n";
        delete data;
        }
    
    private:
        int* data;
    };
    
    
    int main() {
        MyClassAnc* a = new MyClassDes;
    
        delete a;
    
        return 0;
    }
    ~Anc()

    이전 글(상속 기본)에서, 잠재적 메모리 누수에 대한 언급이 있었습니다.

    추상 형식과 실 형식이 다를 경우, 파생 클래스의 소멸자가 호출되지 않아 메모리 누수가 생길 수 있다는 내용이었고, 이 예제는 바로 그것을 의미하고 있습니다.

     

    이것을 해결하기 위해 Line 9의 MyClassAnc의 소멸자를 가상화 할 수 있습니다.

    MyClassAnc의 소멸자에 virtual 키워드를 붙이는 것 외에 바뀌는 것이 없으므로, 예제를 따로 추가하지는 않겠습니다.

    이것만으로도 파생 클래스의 소멸자가 호출이 되어 메모리 누수를 막을 수 있을 것 입니다.

     

    MyClassDes의 소멸자에는 virtual 키워드를 붙이지 않더라도 가상화됩니다.

    상위 클래스의 소멸자가 이미 가상화 되어있기 때문에, 키워드가 없더라도 가상화 되기 때문입니다.

    한번 가상 함수는 영원히 가상 함수입니다.

     

    3. 가상 함수 테이블 (vtable)

     

    더보기

    이번 문단에서는 가상 함수가 내부적으로 어떻게 구현되어 있는지 살펴보도록 하겠습니다.

    우선 예제를 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    class MyClassAnc {
    public:
        MyClassAnc() {
            cout << "MyClassAnc()\n"; // Line 7
        }
    
        virtual ~MyClassAnc() {}
        virtual void function1() {}
        virtual void function2() {}
    };
    
    class MyClassDes : public MyClassAnc {
    public:
        MyClassDes() {
            cout << "MyClassDes()\n"; // Line 18
        }
    
        virtual ~MyClassDes() {}
        virtual void function1() {}
        virtual void function2() {
            cout << "function2()\n";
        }
    };
    
    
    int main() {
        MyClassAnc* a = new MyClassDes;
        a->function2();
        delete a;
    
        return 0;
    }
    MyClassAnc()
    MyClassDes()
    function2()

    소스코드 및 실행결과는 지금까지 보던 것과 크게 다르지 않습니다.

    이제 가상 함수 테이블을 살펴보기 위해 디버깅을 할텐데, Visual Studio 2019 를 기준으로 작성하도록 하겠습니다.

    프로젝트 속성 -> 구성 속성 -> 링커 -> 고급 탭의 임의 기준 주소 항목을 [아니요]로 설정하자.

    이 설정을 통해 ASLR (Address Space Layout Randomization)을 해제하도록 하겠습니다.

    ASLR은 메모리 영역의 시작 주소를 랜덤화해서, 메모리 해킹을 방지하는 기능이라고 합니다.

     

    이후 소스코드에 표시된 Line 7, Line 18의 생성자 부분에 브레이크 포인트를 설정하고 디버깅을 시작하겠습니다.

    브레이크 포인트가 적중하면 메모리를 확인해야 합니다.

    디버그 -> 창 -> 지역 을 선택하여 창을 활성화하자.

    브레이크 포인트가 MyClassAnc의 생성자에서 적중해서, 메모리를 확인해보도록 하겠습니다.

    this 포인터 아래 __vfptr을 확인할 수 있다.

    __vfptr이 가상 함수 테이블 입니다.

    아래 배열의 값을 확인하면 가상 함수로 선언한 소멸자와 function1, function2를 볼 수 있습니다.

    현재 this포인터의 주소는 0x0074df80이고, 나머지는 0x00419b34, 10aa, 116d, 1393임을 확인하고, 다음으로 넘어가겠습니다.

     

    이후 MyClassDes의 생성자로 넘어갈 수 있도록 한번 실행하고, 메모리를 다시 확인하도록 하겠습니다.

    MyClassAnc가 MyClassDes로 래핑된 것을 볼 수 있다.

    this 포인터의 주소는 변하지 않은 것을 볼 수 있습니다.

    하지만 __vfptr의 주소값은 변경된 것을 볼 수 있습니다.

    __vfptr이 인스턴스가 보유하고 있는 가상 함수들의 함수 포인터입니다.

     

    위 예제의 함수를 호출하는 다음과 같은 코드를 작성하도록 하겠습니다.

    int main() {
        MyClassAnc* a = new MyClassDes;
        a->function2();
        delete a;
    
        return 0;
    }

    a->function2(); 부분을 구조적으로 이해하기 쉽게 풀어보면

    int main() {
        MyClassAnc* a = new MyClassDes;
        a->__vfptr->function2();
        delete a;
    
        return 0;
    }

    이라고 볼 수 있을 것 입니다.

    가상 함수를 호출하기 전에, 그 가상 함수 리스트를 가지고 있는 __vfptr을 통해 함수를 호출하는 것 입니다.

    또한 __vfptr이 파생 클래스의 생성자마다 계속 갱신되기 때문에 가상 함수는 호출 시 마지막에 파생된 클래스의 함수를 호출할 수 있는 것입니다.

     

    4. 바인딩

     

    더보기

    바인딩이란 함수나 변수의 주소가 결정되는 것을 의미합니다.

    이 주소가 컴파일 타임에 결정되면 이른 바인딩 (Early Binding), 정적 바인딩 (Static Binding)이라 부르고 런타임 중에 결정되면 늦은 바인딩 (Late Binding), 동적 바인딩 (Dynamic Binding)이라고 부릅니다.

     

    정적 바인딩에 관한 예제를 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    void function(int param) {  }
    
    int main() {
        function(10); // Line 7
    
        return 0;
    }

    선언된 function함수는 상수이고, 함수 호출 연산자의 피연산자입니다.

    조금 쉽게 말해서, 우리가 자주 사용하는 함수가 정적 바인딩 된 함수입니다.

     

    조금 더 깊게 살펴보겠습니다.

    함수를 호출하는 Line 7에 브레이크 포인트를 설정한 뒤, 디버그를 수행하도록 하겠습니다.

    이후 디버그 -> 창 -> 디스어셈블리 를 선택해 어셈블리를 확인한다.
    call function 을 확인할 수 있다.

    간단하게 설명하면, 0FD1091h라는 메모리에 function함수가 결정되어 있는 상태로 함수를 호출(call)한 것입니다.

    정적 바인딩은 위와 같이 컴파일 시 주소가 결정되는 것을 의미합니다.

     

    정적 바인딩의 특징 (컴파일 시 주소가 결정됨)을 기억하고, 동적 바인딩의 예제를 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    void function1(int param) {  }
    void function2(int param) {  }
    
    int main() {
        int input = 0;
        cin >> input;
        void (*dynamic_binding)(int) = NULL;
    
        if (input > 10)
            dynamic_binding = function1;
        else
            dynamic_binding = function2;
    
        dynamic_binding(10); // Line 17
    
        return 0;
    }

    input의 값에 따라 함수 포인터인 dynamic_binding에 포인팅 되는 함수가 달라지는 소스코드입니다.

    Line 17에 브레이크 포인트를 설정하고, 정적 바인딩의 예제와 같이 어셈블리를 확인하도록 하겠습니다.

     

    call dword ptr [dynamic_binding]을 확인할 수 있다.

    피 연산자가 상수가 아니라 포인터 변수임을 확인할 수 있습니다.

    이를 통해, 변수에 저장된 함수가 호출이 될 것이며 이 값이 확정되어 있지 않음을 볼 수 있습니다.

     

    5. 순수 가상 클래스

     

    더보기

    순수 가상 클래스 (Pure Virtual Class)는 순수 가상 함수 (Pure Virtual Function)을 멤버로 가진 클래스입니다.

    순수 가상 함수란 선언은 하되, 정의는 하지 않은 가상 함수입니다.

    다음과 같이 선언할 수 있습니다.

    virtual [return type] [function] = 0;

    가상 함수의 정의를 하지 않고, = 0이라는 표시를 하는 것으로 가상 함수를 순수 가상 함수로 만들 수 있습니다.

    순수 가상 함수의 가장 큰 특징은 인스턴스를 직접 선언할 수 없다는 것과 파생 클래스에서 반드시 재정의 해야한다는 것 입니다.

     

    예제를 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    class MyClassAnc {
    public:
        virtual void function() = 0;
    };
    
    class MyClassDes : public MyClassAnc {
    public:
        virtual void function() {
            cout << "Function\n";
        }
    };
    
    int main() {
        MyClassDes a;
        a.function();
    
        return 0;
    }
    Function

    위의 소스코드에서 MyClassAnc를 인스턴스 선언하거나 MyClassDes에서 function을 재정의 하지 않을 경우 컴파일 에러가 발생할 것 입니다.

     

    이런 순수 가상 클래스들은, 인터페이스를 정의할 때 유용하게 사용할 수 있습니다.

    예제를 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    class MyObject {
    public:
        virtual int get_serial() = 0;
    protected:
        int serial;
    };
    
    void print_serial(MyObject &obj) {
        cout << "Serial Number : " << obj.get_serial() << "\n";
    }
    
    class MyPhone : public MyObject {
    public:
        MyPhone(int id) { serial = id; }
        virtual int get_serial() {
            cout << "Phone ";
            return serial;
        }
    };
    
    class MyWatch : public MyObject {
    public:
        MyWatch(int id) { serial = id; }
        virtual int get_serial() {
            cout << "Watch ";
            return serial;
        }
    };
    
    int main() {
        MyPhone phone(123);
        MyWatch watch(321);
    
        print_serial(phone);
        print_serial(watch);
    
        return 0;
    }
    Phone Serial Number : 123
    Watch Serial Number : 321

    USB를 생각해보겠습니다.

    공통점은 USB 포트를 이용한다는 것 외에는 없을 정도로 여러 종류의 장치가 존재합니다.

    순수 가상 클래스를 USB에 빗대어 생각한다면 이해하기 용이할 것이라 생각합니다.

    위 소스코드의 print_serial 함수는 MyObject를 추상 자료형으로 받기 때문에 MyObject를 상속받는 MyPhone과 MyWatch또한 실인수가 될 수 있는 것 입니다.

    이후에 다른 클래스로 확장되더라도 이러한 특징이 유지될 것이고, get_serial 메소드가 가상 함수이기 때문에

    실 형식의 메소드가 호출되는 것도 보장됩니다.

     

    이처럼 다른 객체 (장치)와 상호작용 할 수 있도록 하는 방법, 혹은 통로와 같은 것을 인터페이스라고 합니다.

    순수 가상 클래스는 이러한 인터페이스의 용도로 사용될 수 있습니다.

     

    6. 추상 자료형

     

    더보기

    이전 문단에서

    MyClassAnc *a = new MyClassDes;

    와 같은 식으로 참조 형식이 상위 클래스, 실 형식이 파생 클래스일 경우 참조 형식을 추상 자료형 (추상 형식)이라고 표현하였습니다.

     

    이 추상 형식과 이전 문단의 인터페이스를 응용할 경우 효율적인 설계를 할 수 있습니다.

    예제를 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    class MyShape {
    public:
        virtual void draw() = 0;
    };
    
    class MyRectangle : public MyShape {
    public:
        virtual void draw() {
            cout << "Rectangle\n";
        }
    };
    
    class MyTriangle : public MyShape {
    public:
        virtual void draw() {
            cout << "Triangle\n";
        }
    };
    
    class MyCircle : public MyShape {
    public:
        virtual void draw() {
            cout << "Circle\n";
        }
    };
    
    int main() {
        MyShape* list[5] = { 0, };
    
        for (auto& shape : list) {
            int input;
            cout << "1 : Circle, 2 : Triangle, 3 : Rectangle\n";
            cin >> input;
            switch (input) {
            case 1:
                shape = new MyCircle;
                break;
            case 2:
                shape = new MyTriangle;
                break;
            case 3:
                shape = new MyRectangle;
                break;
            }
        }
    
        for (auto shape : list) {
            shape->draw();
        }
    
        for (auto shape : list) {
            delete shape;
        }
    
        return 0;
    }
    1 : Circle, 2 : Triangle, 3 : Rectangle
    1
    1 : Circle, 2 : Triangle, 3 : Rectangle
    3
    1 : Circle, 2 : Triangle, 3 : Rectangle
    2
    1 : Circle, 2 : Triangle, 3 : Rectangle
    3
    1 : Circle, 2 : Triangle, 3 : Rectangle
    2
    Circle
    Rectangle
    Triangle
    Rectangle
    Triangle

    MyCircle, MyTriangle, MyRectangle은 모두 순수 가상 클래스 MyShape를 상속받은 클래스입니다.

    사용자의 입력에 따라 추상 자료형이 MyShape인 list를 각각의 도형으로 인스턴스화 하는 것입니다.

    순수 가상 클래스를 이용하지 않고, 다중 if문, 혹은 switch-case문을 이용해 각각의 객체를 만드는 방법도 존재합니다.

    지금과 같은 코드(경우의 수가 적음)에서는 그것이 더 나을 수도 있습니다.

    하지만 만약 MyShape를 상속받은 도형이 더 많아진다면 코드 가독성이 떨어질 것이고 결정적으로 퍼포먼스가 희생될 것 입니다.

    연산 속도에서 위와 같은 방식의 가상 함수 호출이 조건문보다 더 빠른 성능을 보여줍니다.

     


     

    글 내용에 전체적으로 가상 함수의 장점에 대해 늘어놓았지만

    가상 함수는 인라인 함수, 일반 함수보다 속도가 느리다는 단점이 있긴 합니다.

    관련 글(Stack overflow)에 의하면, 일반 함수와 약 두배, 인라인 함수에 비하면 약 20배의 차이가 났습니다.

    하지만 글에도 있듯이, 초당 천만 회 이상의 호출이 아닌 이상 큰 차이가 없는 정도라고 합니다.

    그만한 속도조차 민감하게 반응해야 한다면 지양할 필요는 있겠지만

    가상 함수가 주는 설계적인 이점은 아주 조금의 성능 저하를 충분히 감수할 만한 가치가 있다고 생각합니다.

     

    이번 글에서는

    가상함수의 특징, 내부 구조, 아주 약간의 응용법에 대하여 살펴보았습니다.

    다음 글에서는 상속과 관련된 다른 내용을 다루어 볼 예정입니다.

    방문해주셔서 감사합니다.

    'C++ > 이것이 C++이다' 카테고리의 다른 글

    [C++] 다중 상속  (0) 2021.09.24
    [C++] 형 변환 연산자  (0) 2021.09.19
    [C++] 상속 기본  (0) 2021.08.13
    [C++] 연산자  (0) 2021.07.16
    [C++] 깊은 복사와 얕은 복사  (0) 2021.03.30

    댓글

Designed by Tistory.