[C++] 08. 상속과 다형성

kkado·2023년 10월 14일
0

열혈 C++

목록 보기
8/16
post-thumbnail

💬 윤성우 님의 <열혈 C++ 프로그래밍> 책을 혼자 공부하며 배운 내용을 정리합니다. 글의 모든 내용은 책에서 발췌하였습니다.


다형성은 매우매우 중요한 개념이니까 잘 공부해야겠다...

객체 포인터 변수

앞서서 클래스 포인터 변수를 많이 살펴보았다. 가령 다음과 같은 형태이다.

Person* ptr;
ptr = new Person();

여기서 ptrPerson 객체를 가리킬 수 있다. 또한,

Person형 포인터는 Person 객체 뿐만 아니라 Person을 상속하는 유도 클래스의 객체도 가리킬 수 있다.

만약 Person 클래스를 상속하는 Student 클래스가 있고 이를 다시 상속하는 PartStudent 클래스가 있다고 하면 Person 포인터는 이 클래스들 모두를 가리킬 수 있다.

Person* ptr = new Student();
Person* ptr = new PartStudent();

C++에서 AAA형 포인터 변수는 AAA 객체는 물론, 이를 직/간접적으로 상속하는 모든 객체를 가리킬 수 있다. (객체 주소값을 저장할 수 있다.)

class Person
{
public:
    void sleep()
    {}
};

class Student : public Person
{
public:
    void study()
    {}
};

class PartStudent : public Student
{
public:
    void work()
    {}
};

int main()
{ 
    Person* ptr1 = new Student();
    Person* ptr2 = new PartStudent();
    Student* ptr3 = new PartStudent();

    ptr1->sleep();
    ptr2->sleep();
    ptr3->study();
}

유도 클래스들은 기초 클래스의 멤버 모두를 포함하고 있기 때문에, 기초 클래스 포인터 변수를 통해 기초 클래스의 멤버를 호출하는 데는 아무 문제가 없다.

논리적이지 않다고 생각될 수도 있으나. IS-A 관계를 생각해보면 적절하다고 생각될 것이다.

"학생은 사람이다." 라는 IS-A 구조는 조금 다르게 말하면 "학생은 사람의 일종이다." 라고도 표할 수 있고 따라서 Student 클래스는 Person 클래스의 일종이라고 할 수 있다. 그렇기 때문에 Person형 포인터로 가리킬 수 있다.


함수 오버라이딩

상속의 필요성을 알기 위해, 회사 직원 관리 프로그램을 예시로 든 적이 있다.

만약 프로그램 요구사항이 확장되어, 다음과 같은 구조를 띠어야 한다고 생각해보자.

- '직원' 의 종류에는 '정직원' 이 있고 '계약직' 이 있다.
- '영업직'은 '정직원' 의 일종이다.

만약 EmployeeHandler 클래스에서 저장 및 관리하는 대상이 '직원' 클래스가 되게 하면, 정직원, 계약직, 영업직 모두 '직원' 의 일종이므로 이들을 한꺼번에 관리할 수 있게 된다.

그럼 한번 구현해보자.

class Employee
{
private:
    char name[100];
public:
    Employee(char *name)
    {
        strcpy(this->name, name);
    }

    void showName()
    {
        cout << "Name : " << name << "\n";
    }
};

class PermanentWorker : public Employee
{
private:
    int salary;
public:
    PermanentWorker(char *name, int money) : Employee(name), salary(money)
    {}
    
    int getSalary()
    {
        return salary;
    }

    void showSalaryInfo()
    {
    	showName();
        cout << "Salary : " << salary << "\n";
    }
};

class EmployeeHandler
{
private:
    Employee* worker[50];
    int empNum;
public:
    EmployeeHandler() : empNum(0)
    {}
    
    void addEmployee(Employee* newWorker)
    {
        worker[empNum++] = newWorker;
    }

    void showSalarySum()
    {
        int sum = 0;
        for(int i=0;i<empNum;i++)
        {/*
            sum += worker[i]->getSalary();
        */
        }

    }

    void showAllSalaryInfo()
    {
        for(int i=0;i<empNum;i++)
        {/*
            worker[i]->showSalaryInfo();
        */
        }
    }

    ~EmployeeHandler()
    {
        for(int i=0;i<empNum;i++)
        {
            delete worker[i];
        }
    }
};

int main()
{
    EmployeeHandler handler;

    handler.addEmployee(new PermanentWorker("Lee", 5000));
}

먼저 직원을 나타내는 Employee 클래스, 이를 상속받는 PermanentWorker 클래스, 그리고 컨트롤 클래스인 EmployeeHandler를 구현했다.

EmployeeHandler의 함수들의 경우 아직은 컴파일 에러가 발생하므로 주석처리한다.

이어서 계약직과 영업직도 구현을 해봅시다.
영업직의 급여는 '기본급 + 인센티브(판매실적 x 비율)' 라고 가정하고
계약직의 급여는 '시급 x 일한 시간' 으로 가정하자.

그렇다면 영업직 클래스는 추가적으로 인센티브 변수가 필요할 것이며
계약직 클래스는 시급, 일한 시간 변수가 필요하다.

class TemporaryWorker : public Employee
{
private:
    int workHour;
    int payPerHour;

public:
    TemporaryWorker(char* name, int pay) : Employee(name), workHour(0), payPerHour(pay)
    {}

    void addWorkHour(int hour)
    {
        workHour += hour;
    }

    int getSalary()
    {
        return workHour * payPerHour;
    }

    void showSalaryInfo()
    {
        showName();
        cout << "Salary : " << getSalary() << "\n";
    }
};
class SalesWorker : public PermanentWorker
{
private:
    int result;
    double bonusRatio;
public:
    SalesWorker(char *name, int salary, double ratio) : PermanentWorker(name, salary), result(0), bonusRatio(ratio)
    {}

    void addResult(int result)
    {
        this->result += result;
    }

    int getSalary()
    {
        return PermanentWorker::getSalary() + int(result * bonusRatio);
    }

    void showSalaryInfo()
    {
        showName();
        cout << "Salary sale: " << getSalary() << "\n";
    }
};

코드를 치기 귀찮을 뿐 어려운 것은 하나도 없다.

여기서 중요한 점이 하나 있다. 정직원 클래스에도 showSalaryInfo 함수가 있고, 이를 상속받는 영업직 클래스에도 showSalaryInfo 함수가 있다.

이를 가리켜 함수 오버라이딩 이라고 한다.

오버라이딩 된 기초 클래스의 함수는 오버라이딩한 유도 클래스의 함수에 덮어씌워진다.

SalesWorker 객체에 showSalaryInfo 함수를 호출하면 PermanentWorker 클래스의 함수가 아닌 SalesWorker 함수가 호출된다.

기초 클래스의 함수를 호출하기 위해서는 앞에 클래스명을 명시해주어야 한다.


핸들러 부분의 주석을 제거하러 가기 전에 SalesWorker 클래스와 PermanentWorker 클래스의 showSalaryInfo 를 보면 완전히 동일한 것을 알 수 있다. 그런데 굳이 오버라이딩 한 이유는 뭘까??

PermanentWorker 클래스의 showSalaryInfo에서 getSalary() 함수를 호출하면 PermanentWorker 클래스의 함수를 호출해버린다.

그러므로 SalesWorker 클래스의 getSalary() 함수를 잘 호출하기 위해서는 오버라이딩을 해야 한다.

비록 함수의 몸체(구현부분)가 동일하더라도 유도 클래스의 멤버함수에 접근하기 위해서는 오버라이딩을 해야 한다.


가상 함수

int main()
{
    Simple* sim1 = new ~~~;
    Simple* sim2 = new ~~~;
}

이 코드가 정상적인 코드라고 가정하면 두 포인터 변수가 가리키는 객체의 자료형은 무엇일까?

모르긴 몰라도 Simple 클래스 객체거나 Simple 클래스를 직/간접적으로 상속받는 클래스 객체임은 분명하다.

그리고 실제로 컴파일러도 이런 식으로 포인터 변수를 취급한다.

그럼 다음과 같은 구조에서, main 함수가 이런 식으로 구현돼있다면?

class Base
{
public:
    void baseFunc()
    {
        cout << "Base Func\n";
    }
};

class Derived : public Base
{
public:
    void derivedFunc()
    {
        cout << "Derived Func\n";
    }
};

int main()
{
    Base* ptr = new Derived();
    ptr->derivedFunc();
}

컴파일 에러가 발생한다.
여기서 한 가지 의문점을 제기할 수 있다.

"그래도 실제 가리키는 대상은 Derived 클래스 객체인데, 오류가 없어야 정상 아닌가."

C++ 컴파일러는 포인터 연산의 가능성 여부를 실제 가리키는 객체의 자료형 기준이 아닌, 포인터의 자료형을 기준으로 판단한다.

Base* ptr = new Derived(); 라는 문장을 보고, 컴파일러는 다음과 같이 판단한다.

이 포인터 변수가 실제로 가리키고 있는 클래스는 Derived 클래스이며,
Derived 클래스는 Base 클래스의 유도 클래스이니까, Base형 포인터 변수로 참조가 가능하다!

그러나 다음 줄에서는 실제로 가리키고 있는 클래스가 Derived 클래스라는 사실을 잊고 다음과 같이 판단한다.

ptr은 Base형 포인터 변수이니까, 이 포인터 변수가 가리키는 대상은 Base형일 가능성이 있는데, 
Derived 클래스에서 정의된 함수를 실행하는 것은 성립하지 않으니까, 오류를 발생시키자.

반면 다음과 같은 구조에서는 문제 없이 컴파일이 된다.

int main()
{
    Derived* ptr = new Derived();
    Base* bptr = ptr;
}

ptr이 가리키는 대상은 Derived 클래스 또는 그 하위의 클래스일 것이므로, 마찬가지로 Derived 의 기초 클래스인 Base 클래스를 직/간접적으로 상속하는 객체일 것이다. 즉 참조가 가능하다.

요약하자면 상속 단계 상에서 더 상위에 해당하는 클래스 포인터 변수로는 하위 클래스 객체들을 가리킬 수는 있으나, 하위 클래스의 멤버에 접근하려고 하면 오류가 발생한다.

다시!

C++ 컴파일러는 포인터 연산의 가능성 여부를 실제 가리키는 객체의 자료형 기준이 아닌, 포인터의 자료형을 기준으로 판단한다.


class First
{
public:
    void myFunc()
    {
        cout << "First Func\n";
    }
};

class Second : public First
{
public:
    void myFunc()
    {
        cout << "Second Func\n";
    }
};

class Third : public Second
{
public:
    void myFunc()
    {
        cout << "Third Func\n";
    }
};

int main()
{
    Third* thirdPtr = new Third();
    Second* secondPtr = thirdPtr;
    First* firstPtr = thirdPtr;

    firstPtr->myFunc();
    secondPtr->myFunc();
    thirdPtr->myFunc();  
}

First -> Second -> Third 순으로 상속하고, Third 객체를 각기 다른 포인터 변수가 가리키고 있다. 그리고 각자 myFunc() 함수를 실행한다.

실행 결과는?

First Func
Second Func
Third Func

서로 다르게 나오는 걸로 보아, 서로 다른 함수를 호출했다는 것을 알 수 있다.

그러나 함수를 오버라이딩 했다고 함은, 해당 객체에서 호출되어야 하는 함수를 바꾼다는 의미인데, 포인터 변수의 자료형에 따라서 호출되는 함수가 결정되는 것은 분명 이상하다는 생각이 든다.

그래서 객체지향에서는 '가상 함수' 라는 개념이 존재한다.

가상 함수의 선언은 virtual 키워드를 통해 이뤄진다. 그리고 이렇게 만들어진 가상함수를 오버라이딩하는 함수도 모두 가상함수가 된다.

이제 위에서 제시한 코드의 모든 함수를 가상 함수로 바꿔보자.

class First
{
public:
    virtual void myFunc()
    {
        cout << "First Func\n";
    }
};

class Second : public First
{
public:
    virtual void myFunc()
    {
        cout << "Second Func\n";
    }
};

class Third : public Second
{
public:
    virtual void myFunc()
    {
        cout << "Third Func\n";
    }
};

실행하면, 전과 다른 결과가 나온다.

Third Func
Third Func
Third Func

이로써 우리는,

함수가 가상 함수로 선언되면, 해당 함수를 호출할 때 포인터의 자료형이 아닌 포인터가 실제로 가리키는 객체의 자료형을 기준으로 호출할 함수를 결정한다.

라는 사실을 알 수 있다.


급여관리 문제의 해결

다시 EmployeeHandler 클래스를 가져와보자.

class EmployeeHandler
{
private:
    Employee* worker[50];
    int empNum;
public:
    EmployeeHandler() : empNum(0)
    {}
    
    void addEmployee(Employee* newWorker)
    {
        worker[empNum++] = newWorker;
    }

    void showSalarySum()
    {
        int sum = 0;
        for(int i=0;i<empNum;i++)
        {/*
            sum += worker[i]->getSalary();
        */
        }

    }

    void showAllSalaryInfo()
    {
        for(int i=0;i<empNum;i++)
        {/*
            worker[i]->showSalaryInfo();
        */
        }
    }

    ~EmployeeHandler()
    {
        for(int i=0;i<empNum;i++)
        {
            delete worker[i];
        }
    }
};

여기서 문제가 발생한 이유는, 모든 직원 종류를 포괄하려고 Employee 자료형으로 관리하고 있는 worker 배열은 Employee 클래스이기 때문에 showSalaryInfo 함수가 없기 때문이었다.

이제, Employee 클래스에 showSalaryInfo 함수를 만들고, 이를 가상 함수로 선언하면 이 클래스를 상속받아 오버라이딩하는 모든 클래스들 각각의 showSalaryInfo 함수를 호출할 수 있다.

class Employee
{
private:
    char name[100];
public:
    Employee(char *name)
    {
        strcpy(this->name, name);
    }

    void showName()
    {
        cout << "Name : " << name << "\n";
    }
    
    virtual void showSalaryInfo()
    {}
};

즉 이제 Employee 배열만으로 정직원, 영업직, 계약직 직원들을 모두 관리할 수 있게 된 것이다.

상속을 통해 연관된 여러 클래스들에 대해 공통적인 규약을 정의할 수 있다는 것이 상속을 하는 이유이다.


순수 가상함수

급여 관리 프로그램에서 조금 더 개선할 수 있는 점이 있다.

Employee 클래스의 showSalaryInfo() 함수는 이 클래스를 상속받는 유도 클래스들이 자신들의 입맛대로 오버라이딩하여 사용할 수 있게끔 기본 기틀을 만들어 준 것이지, 함수 그 자체로는 아무 의미가 없다.

따라서 Employee 객체에서는 showSalaryInfo를 호출할 수 없게 하면 좋을 것이다. 가상 함수를 '순수 가상함수' 로 만들어줌으로써 가능하다.

virtual void showSalaryInfo() const = 0;

순수 가상함수는 0을 대입함으로써 만들 수 있다.

하나 이상의 멤버 함수가 순수 가상함수인 클래스는, 그 자체만으로는 온전히 기능할 수 없는 불완전한 클래스이다. 따라서 객체를 생성할 수 없다.

하나 이상의 멤버 함수가 순수 가상함수인 클래스는 객체를 생성하려고 하면 컴파일 에러를 발생시킨다.

Employee* emp = new Employee("Lee");

이러한 클래스를 가리켜 추상 클래스(abstract class) 라고 한다.

실질적 형태가 없이 추상적인 개념만 존재한다고 하여 추상 클래스라는 이름이 붙었다.


다형성 (Polymorphism)

지금까지 배운 개념들이 다형성이다. 다형성이란 프로그래밍적으로 얘기하자면 '문장은 같은데, 결과는 다르다' 라는 뜻이다.

다형성이란, 어떤 객체의 속성이나 기능이 상황에 따라 여러 가지 형태를 가질 수 있는 성질을 뜻한다.

예를 들어, 동일한 포인터 변수로 다른 자료형의 객체를 참조하면, 같은 함수를 실행해도 다른 결과가 나온다. 이것이 C++에서의 다형성이다.


가상 소멸자(virtual destructor)

이름에서도 알 수 있듯 virtual 키워드가 붙은 소멸자를 가상 소멸자라고 한다. 이것의 필요성을 알기 위해 먼저 제시된 예제를 보고, 문제점을 생각해 보자.

class First
{
private:
    char* strOne;
public:
    First(char* str)
    {
        strOne = new char[strlen(str) + 1];
    }
    
    ~First()
    {
        cout << "~First()\n";
        delete []strOne;
    }
};

class Second : public First
{
private:
    char* strTwo;
public:
    Second(char* str1, char* str2) : First(str1)
    {
        strTwo = new char[strlen(str2) + 1];
    }
    
    ~Second()
    {
        cout << "~Second()\n";
        delete []strTwo;
    }
};

int main()
{
    First* ptr = new Second("simple", "complex");
    delete ptr;
}

아래는 실행 결과이다.

~First()

ptr 포인터가 가리키고 있는 객체는 Second 자료형임에도 불구하고, 가리키고 있는 포인터의 자료형이 First 이어서 First 클래스의 소멸자만 호출되었다.

이렇게 소멸되지 않은 메모리 공간은 곧 누수이다.

객체의 소멸과정에는 delete 연산자에 사용된 포인터 변수의 자료형과 무관하게 모든 소멸자가 호출되어야 한다.

이를 위해서는 소멸자에 virtual 키워드를 붙이면 된다.

가상함수와 마찬가지로, 가상 소멸자 역시 상속의 계층 구조의 맨 위에 존재하는 기초 클래스의 소멸자만 가상 소멸자로 지정해 주면 클래스를 상속받는 모든 클래스의 소멸자가 가상 소멸자가 된다.

그리고 가상 소멸자가 호출되면 상속의 계층 구조의 맨 아래에 존재하는 유도 클래스의 소멸자가 호출되어, 기초 클래스의 소멸자가 차례로 호출된다.

호출 순서는 계층의 아래쪽부터 위로 호출된다.


참조자의 참조 가능성

앞서 포인터를 가지고 설명한 많은 성질에 대해 참조자의 경우에도 마찬가지로 성립한다.

C++에서, AAA형 참조자는 AAA 객체 또는 이를 직/간접적으로 상속받는 모든 객체를 참조할 수 있다.

(앞서 본 First->Second->Third 구조에서) First형 참조자를 이용하면 First 클래스에 정의된 함수가, Second형 참조자를 이용하면 Second 클래스에 정의된 함수가, Third형 참조자를 이용하면 Thrid 클래스에 정의된 함수가, 호출된다.

즉 우리는 이 문장을 보면,

void myFunction(const First &ref) {}

다음과 같이 생각할 수 있어야 한다.

  • First 객체 또는 First 객체를 직/간접적으로 상속받는 모든 객체가 인자로 올 수 있겠구나
  • 전달된 인자의 실제 자료형과 무관하게, 참조자가 First형이므로 First에 정의된 함수만 호출되겠구나

profile
울면안돼 쫄면안돼 냉면됩니다

0개의 댓글