ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [C++] 상속 기본
    C++/이것이 C++이다 2021. 8. 13. 14:18

    상속이란 다음과 같이 정의됩니다.

    상속 [명사]1. 뒤를 이음.
    2. 일정한 친족 관계가 있는 사람 사이에서, 한 사람이 사망한 후에 다른 사람에게 재산에 대한 권리와 의무의 일체를 이어 주거나, 다른 사람이 사망한 사람으로부터 그 권리와 의무의 일체를 이어받는 일.
    출처 : 표준국어대사전

    객체지향 프로그래밍에서의 상속 또한 우리가 익히 알고 있는 상속과 의미가 일치합니다.

    상위 객체의 뒤를 이어, 그 객체를 계승하고, 발전된 형태의 객체를 만드는 것을 상속이라 합니다.

    얕게 보면 코드 재사용성을 문법 레벨에서 지원하는 것이 될 수 있으며

    깊게 보면 객체지향적 설계에서, 객체 간 관계규정에 도움을 주는 요소로 볼 수 있겠습니다.

    이번 글에서는, 이러한 상속의 기본적인 사용법에 대해 살펴 볼 것입니다.

     


     

    1. 상속

     

    더보기

    상속은 객체 단위 코드를 재사용 하는 방법입니다.

    여기서 재사용이란 기존 코드를 그대로 사용하는 것에 그치지 않고 추가적인 기능의 확장과 개선을 의미합니다.

     

    상속에 있어서 가장 중요한 것은 관계입니다.

    두 클래스간의 어떤 관계가 있는지, 그리고 이후에 생길 다른 클래스와는 어떤 관계가 될 지, 현재 뿐만 아니라 미래 또한 생각하며 객체 간의 관계를 규정해야 올바른 상속 구조를 사용할 수 있습니다.

     

    특정 클래스를 상속받는 새 클래스는 아래와 같이 정의합니다.

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

    class Ancestor {
    
    };
    
    class Descendant : public Ancestor {
    
    };

    위와 같은 코드를 통해, Descendant는 Ancestor를 상속받은 파생 클래스가 됩니다.

    상속은 그 이름에서 알 수 있듯이 상하 관계가 성립합니다.

    이것에 대해 'is - a', 'has - a' 라는 표현을 종종 사용하는데, 해당 내용은 다른 글에서 자세히 서술하도록 하겠습니다.

     

    이제 아래 예제를 통해 상속에 대해 조금 더 자세히 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    class MyClassAnc {
    public:
        MyClassAnc() { cout << "Ancestor\n"; }
        int get_data() { return anc_data; }
        void set_data(int val) { anc_data = val; }
    
    protected:
        void print_data() { cout << "MyClassAnc::print_data()\n"; }
    
    private:
        int anc_data = 0;
    };
    
    class MyClassDes : public MyClassAnc {
    public:
        MyClassDes() { cout << "Descendant\n"; }
        void function() {
            print_data();
            set_data(5);
            cout << MyClassAnc::get_data() << "\n"; // Line 23
        }
    };
    
    int main() {
        MyClassDes cls; // Line 28
    
        cls.set_data(10); // Line 30
        cout << cls.get_data() << "\n";
    
        cls.function();
    
        return 0;
    }
    Ancestor
    Descendant
    10
    MyClassAnc::print_data()
    5

     MyClassAnc와 MyClassDes를 정의했고, MyClassDes는 MyClassAnc를 상속받음을 볼 수 있습니다.

    그리고, 28번 줄에서 MyClassDes를 선언했습니다.

    여기까지만 보면 MyClassDes가 선언되었으니 Des의 생성자가 실행되고, 상위 클래스인 Anc의 생성자가 실행 될 것이라는 추측을 할 수 있습니다.

    그런데 출력을 보면 Anc의 생성자가 먼저 실행된 후, Des의 생성자가 실행되는 것을 볼 수 있습니다.

    이것을 보고 다음과 같이 유추할 수 있습니다.

    "상속 관계의 클래스를 생성하면, 상위 클래스부터 생성자가 호출된다"

    하지만, 이것은 정답과는 조금 다릅니다.

    상속 관계의 클래스를 생성 시, 가장 먼저 생성되는 것은 호출된 클래스의 생성자입니다.

    호출된 생성자는 실행 전, 상위 클래스의 생성자를 호출합니다.

    최상위 클래스까지 생성자가 호출만 된 후, 호출 된 역순으로 생성자가 실행됩니다.

    이러한 과정 때문에 상위 클래스의 생성자가 먼저 호출되는 것으로 보이는 것 입니다.

     

    추가적으로 볼 수 있는 것은 메소드 호출에 관한 부분입니다.

    30번 줄에서 파생 클래스의 인스턴스를 통해 상위 클래스의 메소드를 호출하는 것을 볼 수 있고

    23번 줄에서는 파생 클래스의 메소드에서 명시적으로 상위 클래스의 메소드를 호출하는 것을 볼 수 있습니다.

    또한 이번 예제에는 private, protected, public 접근 지정자가 골고루 사용되었는데

    private접근 지정자로 선언된 멤버는 파생 클래스에서도 접근이 불가능한 것을 보여드리기 위함이었습니다.

     

    2. 메소드 오버라이드

     

    더보기

    메소드 오버라이드 (Method Override)는 우리말로 "재정의"로 의역됩니다.

    재정의의 사전적 의미에는 "무시하다"라는 의미가 포함되어 있는데

    메소드 오버라이드를 통해 재정의 된 메소드는 기존의 메소드가 무시되므로, 적절한 의역이라고 생각합니다.

     

    메소드 오버라이드는 파생 클래스에서 상위 클래스의 메소드를 재정의 하는 것을 의미합니다.

    이 때 상위 클래스의 메소드는 삭제되지 않고 단지 대체되기 때문에 무시된다는 표현을 사용 한 것입니다.

     

    예제를 살펴보도록 하겠습니다. (Method Override)

    #include <iostream>
    using namespace std;
    
    class MyClassAnc {
    public:
        int get_data() { return anc_data; }
        void set_data(int val) { anc_data = val; }
    
    private:
        int anc_data = 0;
    };
    
    class MyClassDes : public MyClassAnc {
    public:
        void set_data(int val) { // Line 15
            if(val < 0)
                MyClassAnc::set_data(0);
            else
                MyClassAnc::set_data(val);
        }
    };
    
    int main() {
        MyClassAnc anc;
        MyClassDes des;
        
        anc.set_data(-5);
        des.set_data(-5);
        
        cout << "Anc : " << anc.get_data() << "\n";
        cout << "Des : " << des.get_data() << "\n";
        
        return 0;
    }
    Anc : -5
    Des : 0

    15번 줄을 통해 이미 상위 클래스에서 정의된 set_data 함수를 오버라이드 하는 것을 볼 수 있습니다.

    새로 재정의된 함수는 음수값을 보정해주는 간단한 기능이 추가되었습니다.

    메소드를 오버라이드 한 것 외에도, 오버라이드 된 메소드에서 상위 클래스의 메소드를 호출할 때 식별자 (MyClassAnc::)를 명시적으로 선언한 것을 볼 수 있습니다.

    여기에 식별자를 선언하지 않고 set_data함수를 호출할 경우, 재귀호출이 발생할 수 있으니 유의합시다.

     

    메소드 오버라이드는 기존 메소드를 보완하는 데 그 의의가 있습니다.

    기존 메소드와 완전히 다른 새 기능을 하는 메소드를 만드는 것이 목적이라면 오버라이드는 적절하지 않습니다.

    두 메소드를 하나로 묶어 더 보완된 기능을 하도록 하는 것이 오버라이드의 주 목적임을 기억하고, 사용해야 합니다.

     

    3. 참조 형식과 실제 형식

     

    더보기

    상위 문단의 Method Override 예제의 main메소드에 대하여, 다음과 같이 수정하도록 하겠습니다.

    int main(){
        MyClassDes des;
        MyClassAnc& anc = des;
        anc.set_data(-5); // Line 4
        
        cout << anc.get_data() << "\n";
        
        return 0;
    }

    코드를 보면 MyClassAnc&형식의 클래스가 MyClassDes를 참조하는 것을 볼 수 있습니다.

    이 코드는 문법적인 오류가 없고, 매우 정상적인 표현입니다.

    예를 들면, 상위 클래스인 "사람"과 파생 클래스인 "학생"에 대하여, 학생을 사람이라고 부르는 것 입니다.

    문법적인 의문이 해결되었으면 이제 중요한 것은, 4번 줄의 코드입니다.

    참조 형식이 상위 클래스이고, 실제 형식이 파생 클래스인 인스턴스에 대하여

    오버라이드 된 메소드를 묵시적으로 호출했을 때 어떤 메소드가 호출되는가?

    이 질문을 기억하고 다음 예제를 살펴보도록 하겠습니다.

    int main(){
        MyClassAnc *anc = new MyClassDes;
        anc->set_data(-5);
        
        cout << anc->get_data() << "\n";
        
        delete anc;
        
        return 0;
    }

    참조자를 사용 한 예제를 포인터로 바꾼 것입니다.

    질문은 위와 같습니다.

    실제 형식이 파생 클래스일 때

    묵시적으로 오버라이드 된 메소드를 호출하면 어떤 메소드가 호출되는가?

     

    정답은 참조 형식의 메소드입니다.

    위 두 예제를 실행시키면, 파생 클래스의 보정 기능이 작동하지 않은 상위 클래스의 메소드가 호출되고

    결과값이 모두 -5가 출력되는 것을 볼 수 있습니다.

     

    번외로, 아래 포인터를 사용한 예제는 잠재적인 메모리 누수 가능성이 존재합니다.

    delete연산을 실행할 때, 파생 클래스의 소멸자가 호출되지 않기 때문입니다.

    자세한 내용은 차후 다른 글에서 다룰 수 있도록 하겠습니다.

     

    4. 생성자와 소멸자

     

    더보기

    위 문단 [1. 상속]에서 상속 관계에 있는 클래스에 대한 생성자의 호출 및 실행 순서에 대해 알아보았습니다.

    다시 정리하면 아래와 같습니다.

    1. 선언한 클래스의 생성자가 호출된다.
    2. 생성자는 실행 전 상위 클래스의 생성자를 호출한다.
    3. 2를 반복하여, 최상위 클래스의 생성자가 실행되고, 그 파생 클래스의 생성자가 실행된다.
    4. 3이 반복되어 선언한 클래스의 생성자가 실행된다.

    요약하면 최상위 클래스까지 호출, 이후 최하위 클래스까지 실행 입니다.

    다소 복잡하게 보일 수 있는 생성자에 비해, 소멸자는 굉장히 간단합니다.

    1. 인스턴스의 소멸시 파생 클래스에서 상위 클래스의 순서로 소멸자가 호출되고 실행된다.

     

    예제를 살펴보도록 하겠습니다. (Constructor & Destructor in Inheritance)

    #include <iostream>
    using namespace std;
    
    class MyClassOne {
    public:
        MyClassOne() {
            cout << "MyClassOne()\n";
        }
        ~MyClassOne() {
            cout << "~MyClassOne()\n";
        }
    };
    
    class MyClassTwo : public MyClassOne {
    public:
        MyClassTwo() {
            cout << "MyClassTwo()\n";
        }
        ~MyClassTwo() {
            cout << "~MyClassTwo()\n";
        }
    };
    
    class MyClassThr :public MyClassTwo {
    public:
        MyClassThr() {
            cout << "MyClassThr()\n";
        }
        ~MyClassThr() {
            cout << "~MyClassThr()\n";
        }
    };
    
    int main(){
        cout << "Before\n";
        MyClassThr cls;
        cout << "After\n";
        
        return 0;
    }
    Before
    MyClassOne()
    MyClassTwo()
    MyClassThr()
    After
    ~MyClassThr()
    ~MyClassTwo()
    ~MyClassOne()

    이해를 돕기 위해, Visual Studio에서 위 소스코드를 실행했을 때의 호출 스택을 살펴보았습니다.

    호출순서와 실행순서가 반대임을 볼 수 있다.

    생성자는 [최상위 클래스까지 생성자 호출] -> [파생 클래스로 내려가면서 실행]의 구조를 띠고 있는것을 볼 수 있습니다.

    소멸자를 살펴보도록 하겠습니다.

    소멸자도 호출 스택은 크게 다르지 않다.

    소멸자는 [소멸자 호출] -> [소멸자 실행] -> [상위 클래스 소멸자 호출] -> [상위 클래스 소멸자 실행] -> [반복]

    의 순서를 따라가고 있음을 볼 수 있습니다.

     

    5. 상속 관계에서의 논리 오류

     

    더보기

    이야기를 시작하기 전에, 예제를 살펴보도록 하겠습니다.

    예제는 상속 관계에서 우연히 발생할 수 있는, 문법적으로 오류가 없지만 문제가 되는 코드입니다.

    #include <iostream>
    using namespace std;
    
    class MyClassOne {
    public:
        MyClassOne() {
            cout << "MyClassOne()\n";
    
            arr = new char[16];
        }
        ~MyClassOne() {
            cout << "~MyClassOne()\n";
    
            delete arr;
        }
    
    protected:
        char* arr;
    };
    
    class MyClassTwo : public MyClassOne {
    public:
        MyClassTwo() {
            cout << "MyClassTwo()\n";
        }
        ~MyClassTwo() {
            cout << "~MyClassTwo()\n";
    
            delete arr; //Line 29
        }
    };

    Line 29가 이 예제의 핵심 코드입니다.

    위 클래스는 인스턴스 소멸 시기에 오류가 발생 할 것입니다.

    이미 MyClassTwo에서 해제한 arr에 대하여, MyClassOne이 arr을 해제하려고 시도하기 때문입니다.

    문법적으로도 접근 제어 지시자가 protected이기 때문에 문제가 없습니다.

     

    이런 문제를 만들지 않기 위해, 다음의 사항을 숙지하는 것이 좋겠습니다.

    • 파생 클래스는 상위 클래스의 멤버 변수에 직접적인 쓰기 연산을 하지 않는다.
    • 파생 클래스 생성자에서 부모 클래스의 멤버 변수를 초기화 하지 않는다.

    생성자와 소멸자는 자기 자신에 대한 초기화, 해제만 하는 것이 좋겠습니다.

    협업이 아니라, 모든 설계를 혼자서 하는 1인 개발이라도 시간이 지나면 헷갈리기 쉽기 때문에, 적절한 규칙을 만들어 그것을 지키는 것이 유지 보수에 도움이 될 것입니다.

     

    6. 생성자 선택

     

    더보기

    생성자는 오버로딩이 가능합니다.

    그러면 다음과 같은 질문이 나올 수 있습니다.

    "상속 관계에서 인스턴스 생성 시기에 상위 클래스의 생성자는 어떻게 선택하는가?"

    정답은 아래와 같습니다.

    "상위 클래스의 생성자를 선택할 수 있다."

     

    예제를 살펴보도록 하겠습니다. (Constructor Select)

    #include <iostream>
    using namespace std;
    
    class MyClassOne {
    public:
        MyClassOne() {
            cout << "MyClassOne()\n";
        }
        MyClassOne(int val) {
            cout << "MyClassOne(int)\n";
        }
        MyClassOne(double val) {
            cout << "MyClassOne(double)\n";
        }
    };
    
    class MyClassTwo : public MyClassOne {
    public:
        MyClassTwo() {
            cout << "MyClassTwo()\n";
        }
        MyClassTwo(int val) : MyClassOne(val) {
            cout << "MyClassTwo(int)\n";
        }
        MyClassTwo(double val) : MyClassOne() {
            cout << "MyClassTwo(double)\n";
        }
    };
    
    int main() {
    
        MyClassTwo();
        cout << "********************\n";
        MyClassTwo(5);
        cout << "********************\n";
        MyClassTwo(1.2);
    
        return 0;
    }
    MyClassOne()
    MyClassTwo()
    ********************
    MyClassOne(int)
    MyClassTwo(int)
    ********************
    MyClassOne()
    MyClassTwo(double)

    MyClassOne을 상속받은 MyClassTwo에서 상위 클래스의 생성자를 선택한 코드입니다.

    각각 초기화 목록을 사용하듯이 MyClassOne(), MyClassOne(int), MyClassOne()의 생성자를 선택한 것을 볼 수 있습니다.

     

    그리고 C++11 표준에서 "생성자 상속" 이라는 개념이 등장했습니다.

    위와 같은 경우 (double 생성자에서 파라미터가 없는 생성자를 선택)에는 사용하기 어렵지만

    파라미터의 형식이 비슷한 경우에는 아래와 같이 간단하게 상위 클래스의 생성자를 선택할 수 있습니다.

     

    예제를 살펴보도록 하겠습니다. (Construct Select in C++11)

    #include <iostream>
    using namespace std;
    
    class MyClassOne {
    public:
        MyClassOne() {
            cout << "MyClassOne()\n";
        }
        MyClassOne(int val) {
            cout << "MyClassOne(int)\n";
        }
        MyClassOne(double val) {
            cout << "MyClassOne(double)\n";
        }
    };
    
    class MyClassTwo : public MyClassOne {
    public:
        using MyClassOne::MyClassOne;
    };
    
    int main() {
    
        MyClassTwo();
        cout << "********************\n";
        MyClassTwo(5);
        cout << "********************\n";
        MyClassTwo(1.2);
    
        return 0;
    }
    MyClassOne()
    ********************
    MyClassOne(int)
    ********************
    MyClassOne(double)

    MyClassTwo의 생성자를 추가로 정의하지 않아 메시지는 나오지 않았지만 각각의 파라미터에 맞는 상위 클래스의 생성자를 호출하는 모습을 볼 수 있습니다.

    이러한 문법은 파생 클래스에서 상위 클래스의 생성자를 제공해야 할 때 유용하게 사용할 수 있을 것 입니다.

     


     

    다시한번 강조하지만, 상속이란 객체 단위로 코드를 재사용 하는 방법입니다.

    여기서 재사용이라는 단어에는 기능적 확장, 개선이라는 의미 또한 포함되어 있습니다.

    하지만 이러한 과정에서 논리적인 오류, 설계적인 결함은 최소화 해야 할 것입니다.

     

    이번 글에서는 상속이 가지는 의미, 상속과 관련된 문법

    그리고 상속 관계의 인스턴스가 생성되는 과정, 생성 및 소멸에서 발생할 수 있는 문제 등을 살펴보았습니다.

    상속에 관한 내용이 적지 않은 관계로, 상속과 관련된 글은 다음 글에서도 이어질 예정입니다.

    다음 글은 상속의 조금 더 깊은 내용으로 찾아뵙겠습니다.

     

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

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

    [C++] 형 변환 연산자  (0) 2021.09.19
    [C++] 가상 함수  (0) 2021.09.05
    [C++] 연산자  (0) 2021.07.16
    [C++] 깊은 복사와 얕은 복사  (0) 2021.03.30
    [C++] 클래스 기본 문법 2  (0) 2021.03.12

    댓글

Designed by Tistory.