본문 바로가기

Programming/C++

[C++] 상속(Inheritance)

이 포스팅은 윤성우님의 열혈 C++ 프로그래밍 책을 기반으로 작성되었습니다.

이번에는 포스팅을 시작하기 전에 하나의 문제점을 제안하고 그에 대한 답안을 찾아가보고자 한다.
Class Student가 있다. 이 Student 클래스의 멤버변수로는 이름, 나이, 학번, 전화번호 등 다양한 정보가 저장되어 있다.
그런데 문제가 생겼다. 교육과정의 문제로 이 Student 클래스를 학부생과 대학원생으로 나눠서 정의를 해야하는 것이다.
그리고 그 학부생과 대학원생에 따라 추가해야하는 멤버변수들이 꽤 생겨난 것이다.
그러면 우리는 여기서 학부생, 즉  Undergraduate 클래스와 대학원생, Graduate 클래스를 재정의해야할 것이다.
하지만 이 두 클래스는 분명 Student라는 공통분모의 멤버변수들이 많이 존재한다.
또한, 만약 Student 클래스를 관리하는 School라는 class가 있다면 이러한 추가적인 클래스의 생성은 대대적인 School 클래스의 개편으로 이어질 것이다.
이럴 경우, 우리는 어떻게 변경 및 기능의 추가에 따른 변경이 최소화할 수 있을까?

그 해결책으로 상속이 사용된다.
상속은 다음과 같이 이니셜라이져를 통해 정의할 수 있다.

class Undergraduate: public Student // 상속의 정의 (접근범위는 public/protected/private 중 1)
{
private:
    ....
public:
    Undergraduate(char *myname, int ID): Student(myname,ID){
        cout << "I'm undergraduate student!";
    }
    // 위와 같이 유도클래스의 생성자의 이니셜라이져를 통해 기초클래스의 생성자를 초기화해주어야 한다.
    // 따라서 유도클래스의 생성자는 기초클래스의 생성자 호출을 위한 인자도 함께 받는다.
    ....
    getName(); // 상속받는 클래스의 멤버함수 이용 가능, 단 private한 멤버변수는 접근이 불가하다.
    getID();
}

이 때, 객체 생성과정은 먼저 메모리 공간이 할당되며, 유도클래스의 생성자가 호출되지만 실행되기 이전에 이니셜라이져를 통해 기초클래스의 생성자 호출 및 실행 이후 유도클래스의 생성자가 실행된다. 만약 여기서 기초클래스의 생성자가 명시적으로 정의되어있지 않다면, 아무것도 하지 않는 void 생성자를 호출한다.
그 후, 소멸할 때는 반대로 유도클래스의 소멸자가 실행된 이후에 기초클래스의 소멸자가 실행된다.

상속의 접근 범위

또한 상속을 정의할 때 접근 범위를 정해주어야 한다.
이는 상속하는 기초클래스의 멤버변수 및 멤버함수들을 유도클래스 내에서 어떻게 접근범위를 설정해주느냐에 대한 것이다.

기본적으로 알아둬야할 각 접근 범위는 다음과 같다.
public은 외부 모두에서 접근이 가능하다.
protected는 클래스 내에서와 상속한 클래스에서 접근이 가능하다.
private은 클래스 내부에서만 접근이 가능하다는 것이다.

그리고 상속시 어떻게 접근하느냐에 따라.
1. Public으로 설정 시, 기초 클래스의 기존 접근 범위를 그대로 가져온다. 
2. Protected로 설정 시, 기초 클래스의 public으로 선언되었던 내용들은 protected로 가져온다.
3. Private으로 설정 시, 기초클래스의 모든 내용을 private으로 가져온다.
이 때, 헷갈리지 말아야 할 것은 기초클래스에서 private으로 정의된 멤버들을 public이나 protected로 상속한다고 해서 접근하지 못한다는 것이다.
상속하는 유도클래스는 기초클래스의 protected 범위까지 접근이 가능하다.

상속의 기본 조건

IS-A관계
상속의 가장 기본적인 관계가 바로 IS-A 관계이다.
유도클래스는 유도클래스 is a 기초클래스 관계로 정의가 가능할 때 상속을 할 수 있다.
예를 들어 학부생은 학생이다는 성립하는 명제이다.
하지만 학생은 학부생이다는 성립하지 않는다. 학부생이 아닌 대학원생, 유치원생일수도 있기 때문이다.
따라서 이 경우에는 IS-A 뒤에 오는 것이 기초 클래스가 될 것이다.

HAS-A 관계
HAS-A 관계는 포함관계로 볼 수 있다.
코드레벨에서 보았을 때, 유도클래스는 모두 기본적으로 기초클래스의 내용을 품고 있다.
따라서 유도클래스 has a 기초클래스 관계가 성립한다고 볼 수 있다.
이는 주로 경찰과 총의 관계로 자주 표현되는데 경찰은 총을 가지고 있다. 라고 하면 경찰의 멤버변수로 총이라는 클래스를 가질 수 있는 것이다.
당연하게 그 역은 성립하지 않는다.
또한 위와 같은 표현은 총을 가진 경찰과 그렇지 않은 경찰로 표현이 쉬워지며, 굳이 총을 가진 경찰이라는 클래스를 생성하는 소모적인 일을 막을 수 있다.

객체 포인터의 참조관계

C++에서 어떠한 클래스의 객체 포인터 변수는 그 클래스 객체 뿐만 아니라 그 클래스를 상속하는 유도클래스의 객체 역시 가리킬 수 있다.
다시 말해 Student 클래스의 객체 포인터 변수는 Undergraduate 클래스의 객체를 가리킬 수 있다, 즉 객체의 주소값을 저장할 수 있다는 말이다.

Student *ptr = new Student // Student 객체를 가리키는 Student 객체 포인터
Student *ptr = new Undergraduate // Student를 상속하는 Undergraduate 객체를 가리키는 Student 객체 포인터
Undergraduate *ptr = new Undergraduate

위와 같은 특징 덕분에, 앞서 도입부에서 말했던 Student를 관리하는 School 클래스 입장에서는 굳이 새로 추가되는 클래스에 대한 정보를 모두 변경할 필요 없이 그대로 Student 객체의 형태로 관리할 수 있게 된 것이다!

하지만 한 가지 문제가 있다. C++에서는 포인터의 자료형을 기준으로 판단한다는 것이다. 즉  Student 객체 포인터가 실제로 가리키는 것은 Undergraduate 객체라고 하여도, 컴파일러는 이를 Student 객체로 판단한다는 것이다.
이는 꽤나 고민할만한 문제거리를 가져온다. Student 객체 포인터가 Undergraduate 객체를 가리킨다고 할지라도, 실제로 이 객체포인터가 접근할 수 있는 범위는 Undegraduate 객체 내에 Student 객체의 멤버들로 제한되기 때문이다. 이 때 필요한 것이 바로 vitrual 가상함수이다.
아래는 실제로 컴파일러가 객체 포인터의 자료형을 기준으로 포인터 연산을 함을 보여주고 있다.

Class First {public: void MyFunc(){ cout<< "FIRST" << endl;};
Class Second {public: void MyFunc(){ cout<< "SECOND" << endl;};
Class Third {public: void MyFunc(){ cout<< "THIRD" << endl;};

int main(void){
    Third *ptr3 = new Third;
    Second *ptr2 = ptr3;
    First *ptr1 = ptr2;
    
    ptr3->Myfunc();
    ptr2->Myfunc();
    ptr1->Myfunc();
}

>>>
THIRD
SECOND
FIRST

우리는 이를 virtual을 이용하여 어떻게 실제 가리키는 객체포인터의 멤버함수를 호출하도록 할 수 있을까?

virtual 가상함수

이는 매우 간단하게도 위의 함수 정의 앞에 virtual만 붙여주면 끝난다.
아래와 같이 말이다.

Class First {public: virtual void MyFunc(){ cout<< "FIRST" << endl;}};
Class Second : public First {public: virtual void MyFunc(){ cout<< "SECOND" << endl;}};
Class Third : public Second {public: virtual void MyFunc(){ cout<< "THIRD" << endl;}};

int main(void){
    Third *ptr3 = new Third;
    Second *ptr2 = ptr3;
    First *ptr1 = ptr2;
    
    ptr3->Myfunc();
    ptr2->Myfunc();
    ptr1->Myfunc();
}

>>>
THIRD
THIRD
THIRD

이는 실제로 모든 함수에 virtual을 붙일 필요는 없고, 오버라이딩한 함수도 자동으로 virtual 선언이 되지만 좀 더 직관적으로 알기 위해서 다 붙여주는 게 좋다.
기능적으로는 차이가 없다. 이렇게 상속은 일종의 클래스들 사이의 공통된 규약을 부여해주고 있는 것이다.

그런데 위의 경우에 실제로 First와 Second의 MyFunc()는 사용되지 않고 있는데, 그러면 이 안에 그냥 정의하지 않고 비워둬도 되는 게 아닐까?
그렇다! 이 경우는 우리가 순수 가상함수라고 부르게 되며, 대신 이 순수 가상함수를 하나 이상 포함한 클래스는 몸체가 정의가 될 수가 없으므로 객체 생성이 불가능한 클래스라고 하여 추상 클래스라고 부르게 된다.
이 경우는 다음과 같이 정의할 수 있다.

Class First {public: virtual void MyFunc() const = 0;};
Class Second {public: virtual void MyFunc() const = 0;};

다형성 (Polymorphism)

이렇게 우리는 가상함수에 대해서 알게 되었는데, 이와 같은 내용들을 다형성이라고 부른다.
가상함수를 다형성이라고 부른다는 것이 아니라, 실제로 모습은 같으나 그 내용은 다른 것과 같은 것을 일컫는다.
예로 First 클래스로 정의된 포인터에서 First 객체를 가리켜서 이 전의 virtual 선언된 함수를 호출한다면 "First"가 출력되겠지만,
Firts 클래스로 정의된 포인터에서 Second 객체를 가리켜 함수를 호출한다면 "Second"가 호출될 것이다.

가상 소멸자

우리는 상속을 통해서 유도클래스의 객체를 생성하면 유도클래스 객체 내에 기초클래스의 객체가 있는 것과 같다는 것을 알게 되었다.
그렇다면 만약, 기초클래스에서 멤버변수에 동적할당을 하고 있다면 이를 소멸시켜주는 소멸자는 어디에서 정의를 해주어야할까?
또 만약 그 멤버변수의 정의가 오버라이딩되는 멤버함수에서 정의된다면 이를 소멸시켜주는 것은 더욱 복잡해질 것 같은데...

방법은 정말 간단하다. 각 객체내에서 생성한 동적메모리공간은 각 객체의 소멸자에서만 관리하는 것이다.
그리고 소멸자를 가상함수로 선언함으로써, 각각의 생성자 내에서 할당한 메모리 공간만큼만 효율적으로 관리하게 된다.
소멸은 유도클래스에서 기초클래스의 순서대로 이루어진다.