[C++] 07. 상속(Inheritance)의 이해

kkado·2023년 10월 14일
0

열혈 C++

목록 보기
7/16
post-thumbnail

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


드디어 큰 게 온다... 객체지향의 정수 상속...

문제의 제시

간단한 회사 직원 관리 프로그램을 만든다고 하자. 직원의 고용형태는 정직원 1가지 뿐이다. 구조체를 이용하여 각 직원들의 정보를 관리하고자 다음과 같은 직원 클래스를 정의하였다.

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

    int getSalary()
    {
        return salary;
    }

    int showEmployeeInfo()
    {
        cout << "Name : " << name << "\n";
        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() const
    {
        int sum = 0;
        for(int i=0; i<empNum; i++)
        {
            sum += worker[i]->getSalary();
        }
        cout << "Salary Sum : " << sum << "\n";
    }

    void showAllEmployeeInfo() const
    {
        for(int i=0; i<empNum; i++)
        {
            worker[i]->showEmployeeInfo();
        }
    }

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

두 클래스는 서로 성격이 다르다. Employee 클래스는 정보로서의 성격이 강하고, EmployeeHandler 클래스는 그 이름에서도 알 수 있듯 기능적 성격이 강하다.

이렇게, 기능적 처리를 실제로 담당하는 클래스를 '컨트롤 클래스' 혹은 '핸들러 클래스' 라고 한다.

그리고 main 함수에서는 이 두 클래스를 활용하여 다음과 같은 기능을 수행할 수 있다.

int main()
{  
    EmployeeHandler eHandler;

    eHandler.addEmployee(new Employee("Lee", 1000));
    eHandler.addEmployee(new Employee("Park", 3000));
    eHandler.addEmployee(new Employee("Kim", 5000));

    eHandler.showAllEmployeeInfo();
    eHandler.showSalarySum();
}

기능의 추가에 따른 프로그램의 확장성에 대한 고려

만약 회사의 고용형태가 다양해지고, 그에 따라 급여의 계산식 또는 추가적인 정보가 필요할 수도 있다. 예컨대 '영업직' 의 경우 인센티브의 개념이 추가될 수 있을 것이며 '아르바이트' 의 경우 시급, 일한 시간 등의 추가적인 정보가 필요할 것이다.

그러면 각 고용형태가 새롭게 추가됨에 따라 각각의 클래스를 새롭게 만들어야 할 것인데, 그렇다면 EmployeeHandler 에서는 어떻게 바뀌어야 할까.

먼저 각 고용형태별 직원 수를 각각 다른 변수로 관리해야 하며, 직원 클래스 배열의 경우 각 고용형태별로 따로 관리를 해야 할 것이며 따라서 코드가 지저분해질 것이다.

만약 고용형태 등 주어진 정보가 추가되어도 기능적 구현에 대한 변경을 줄일 수 있다면 좋을 것이다.

이제부터 상속의 개념이 도입된다.


상속

흔히 일상생활에서 사용되는 '상속' 이라 함은 손윗사람으로부터 재능이나 재물 등 어떤 것을 물려받는 뜻으로 사용된다.

객체지향에서도 이와 마찬가지로, 어떤 클래스가 가지고 있는 속성이나 성질 등을 다른 클래스에서도 똑같이 가질 수 있게 하는 것이 상속이다.

만약 Student 클래스가 Person 클래스를 상속받는다고 가정하면, Student 클래스는 Person 클래스의 모든 멤버를 물려받는다. 즉 Student 객체에는 Student 객체 자신에게 선언된 멤버뿐 아니라 Person 클래스의 멤버도 가지고 있는 셈이 된다.

직접 구현해보면, 먼저 Person 클래스이다.

class Person
{
private:
    int age;
    char name[20];
public:
    Person(int n, char* name) : age(n)
    {
        strcpy(this->name, name);
    }

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

    void printAge()
    {
        cout << "Age : " << age << "\n";
    }
};

간단하게 만들었고 추가적 설명은 필요없어 보인다.
다음으로 Person 클래스를 상속받는 Student 클래스이다.

class Student : public Person // 상속받음을 의미함
{
private:
    char major[20];

public:
    Student(char* name, int age, char* major) : Person(age, name)
    {
        strcpy(this->major, major);
    }

    void printMajor()
    {
        cout << "Major : " << major << "\n";
    }
};

클래스의 이름 뒤에 : public Person 을 작성함으로써 Person 클래스를 상속받을 수 있다.
Student 클래스 안에 printName(), printAge() 함수를 선언하지 않았지만 호출이 가능하다.


상속 받은 클래스의 생성자 정의

상속 받는 클래스는 상위 클래스의 멤버까지 초기화해야 한다.

    Student(char* name, int age, char* major) : Person(age, name)
    {
        strcpy(this->major, major);
    }

위 코드에서 Student 클래스는 생성자에서 Person 클래스의 생성자를 호출함으로써 Person 클래스의 멤버들을 초기화하고 있다.

이처럼 상속받는 클래스는 이니셜라이저를 사용해서 상속하는 클래스의 생성자 호출을 명시할 수 있다.

int main()
{ 
    Student me("Lee", 25, "Computer");
    me.printAge();
    me.printMajor();
}

이 때 주의해야 할 것은 Person 클래스 안의 멤버 변수들은 private으로 정의되어 있다는 것. private으로 정의된 멤버 변수는 그 클래스를 상속받는 클래스에서 직접 접근할 수 없고, 별도로 public으로 정의된 함수 등을 이용해야 한다.


상속을 해주는 클래스를 상위 클래스, 기초 클래스, 슈퍼 클래스, 부모 클래스 등으로 지칭하고,
상속을 받는 클래스를 하위 클래스, 유도 클래스, 서브 클래스, 자식 클래스 등으로 지칭한다.

이 시리즈에서는 책을 따라 기초/유도 클래스라는 어휘를 사용하겠다 !


유도 클래스의 정의는 기초 클래스까지 고려해야 하므로 중요하다.
다음의 예제를 보면 잘 알 수 있다.

class Base
{
private:
    int baseNum;
public:
    Base() : baseNum(10)
    {
        cout << "Base()\n";
    }

    Base(int n) : baseNum(n)
    {
        cout << "Base(int n)\n";
    }

    void showBaseData()
    {
        cout << baseNum << "\n";
    }
};

class Derived : public Base
{
private:
    int derivNum;

public:
    Derived() : derivNum(20)
    {
        cout << "Derived()\n";
    }

    Derived(int n) : derivNum(n)
    {
        cout << "Derived(int n)\n";
    }

    Derived(int n1, int n2) : Base(n1), derivNum(n2)
    {
        cout << "Derived(int n1, int n2)\n";
    }
};

int main()
{ 
    Derived dr1;
    dr1.showBaseData();
    cout << "------------------------\n";

    Derived dr2(50);
    dr2.showBaseData();
    cout << "------------------------\n";

    Derived dr3(30, 40);
    dr3.showBaseData();
}

기초 클래스와 유도 클래스 모두 다양한 생성자를 만들었고, main 함수에서 인자를 다양하게 명시하여 생성했다. 출력 결과는 다음과 같다.

Base()
Derived()
10
------------------------
Base()
Derived(int n)
10
------------------------
Base(int n)
Derived(int n1, int n2)
30

결과로부터 우리는 두 가지를 추론할 수 있다.

  1. 유도 클래스의 객체 생성 과정에서 기초 클래스의 생성자는 항상, 먼저 호출된다.
  2. 유도 클래스의 생성자에서 기초 클래스의 생성자 호출을 명시하지 않으면 기초 클래스의 인자가 없는 생성자를 호출한다.

유도 클래스의 소멸

유도 클래스를 생성할 때 생성자가 두 번 호출된다는 것을 알았으니, 소멸될 때도 소멸자가 두 번 호출될 것이라는 예상을 할 수 있는데, 진짜 그런지 확인해보자.

class Base
{
private:
    int baseNum;
public:
    Base(int n) : baseNum(n)
    {
        cout << "Base " << baseNum << "\n";
    }

    ~Base()
    {
        cout << "Base destroy " << baseNum << "\n";
    }

};

class Derived : public Base
{
private:
    int derivNum;

public:
    Derived(int n) : derivNum(n), Base(n)
    {
        cout << "Derived() " << derivNum << "\n";
    }

    ~Derived()
    {
        cout << "Derived destroy " << derivNum << "\n";
    }
};

int main()
{ 
    Derived dr1(15);
    Derived dr2(30);
}

실행 결과는 아래와 같다.

Base 15
Derived() 15
Base 30
Derived() 30
Derived destroy 30
Base destroy 30
Derived destroy 15
Base destroy 15

세 가지 특징을 발견했다.
1. 같은 Derived 클래스 내에서는 기초 클래스의 생성자인 Base가 먼저 호출됨을 볼 수 있다.
2. 두 객체 사이에서 먼저 소멸하는 것은 후에 생성한 dr2임을 알 수 있다.
3. 또한, 그 중에서도 나중에 호출된 Derived 소멸자가 먼저 호출되고 Base 소멸자가 호출됨을 볼 수 있다.


상속은 연쇄적으로도 가능하다.
어떤 클래스의 유도 클래스도 다른 클래스를 상속해줄 수 있다.


protected

이전에 접근 제어 지시자를 다룰 때 public, protected, private 세 가지가 있다고 배웠는데 여지껏 public과 private만 사용하였다.

protected 접근제어 지시자는 이를 상속하는 유도 클래스에서 접근이 가능하다.

즉 접근 범위가 넓은 순서대로 public이 가장 넓고 그 다음으로 protected, 가장 좁은 것이 private이다.

protected 지시자는 유도 클래스에게 제한적으로 접근을 허용한다는 측면을 잘 활용하면 유용하게 사용할 수 있으나, 기본적으로 기초 클래스 - 유도 클래스 간에도 정보 은닉이 지켜지는 것이 좋으므로 그리 많이 사용되지는 않는다.


세 가지 형태의 상속

앞서 클래스를 상속할 때 다음과 같은 문법을 사용했다.

class Derived : public Base
{
	...
}

여기서 public 이라는 접근제어 지시자를 다루지 않았는데 어떤 뜻일까?
기초 클래스는 이렇게 생겼다고 가정한다.

class Base
{
private:
	int num1;
protected:
	int num2;
public:
	int num3;
}

(접근제어 지시자) 로 상속한다는 것은 기초 클래스에서 (접근제어 지시자)보다 넓은 범위의 접근제어 지시자들을 (접근제어 지시자)로 변경하겠다는 뜻이다.

  • public으로 상속한 경우에는 그냥 그대로 상속된다.
  • protected로 상속한 경우에는 기초 클래스의 public 멤버변수가 protected로 상속된다.
  • private로 상속한 경우에는 기초 클래스의 public, protected 멤버변수가 private로 상속된다.

세 가지 모두 공통된 점은, 기초 클래스의 private 멤버에는 접근할 수 없다는 것이다.

실제로 "C++의 상속은 public만 있다고 생각해라"고 강의하는 교수님이 있다고 할 정도로 나머지 두 개의 상속 형태는 잘 사용하지 않는다고 한다.


상속을 위한 조건

상속 관계를 구성하기 위해서는 조건이 필요하다. 그 조건과 필요가 만족되지 않은 상속은 안하느니만 못하다는 전문가들도 있다고 한다.

IS-A 구조

상속을 받는 유도 클래스는 기초 클래스의 모든 속성을 가지고 있음과 동시에 자신만의 속성을 추가로 가진다.

앞서 살펴보았던 Person-Student의 구조 역시 이름, 나이의 Person 멤버와 함께 전공이라는 자신만의 속성을 가지고 있음을 기억하자.

그리고, 이러한 상속 관계가 성립하면 다음과 같은 문장도 성립한다.

Student는 Person입니다.

맞는 말이다. Student이기 이전에 Person으로서 이름, 나이 등의 정보를 100% 가지고 있기 때문이다.

또다른 예시를 들어 전화기 -> 스마트폰의 상속이 가능할 것이다.

전화기가 가지고 있는 전화통화의 기능을 가지고 있으면서, 스마트폰은 자신만의 속성, 예컨대 인터넷 검색 등을 가진다. 그리고 이 문장도 성립한다.

스마트폰은 전화기이다. (smartphone is a telephone.)

따라서 이 구조는 합리적인 상속 구조이다. 'OO은 OO이다' 라는 뜻의 'OO is a OO' 라는 문장에서 기인하여 IS-A 구조 라고 한다.

만약 상속하려는 대상이 IS-A 구조가 아니라면, 적절한 상속 관계가 아닐 가능성이 높다.


HAS-A 구조

유도 클래스는 기초 클래스가 지닌 모든 것을 '소유' 한다. 따라서 소유 관계에 있는 클래스들도 상속 관계로 나타낼 수 있다. 다음의 예시를 보자.

class Gun
{
private:
    int bullet;

public:
    Gun(int bNum) : bullet(bNum)
    {}

    void shoot()
    {
        bullet--;
        cout << "총 사용! \n";
    }
};

class Police : public Gun
{
private:
    int handcuffs;
public:
    Police(int bNum, int cNum) : handcuffs(cNum), Gun(bNum)
    {}

    void snap()
    {
        handcuffs--;
        cout << "수갑 사용 ! \n";
    }
};

int main()
{ 
    Police p(5, 3);
    p.shoot();
    p.snap();
}

Gun 클래스가 있고, 이를 소유하고 있는 Police 클래스가 있다.
이러한 구조를 HAS-A 구조라고 한다.

그러나 이러한 소유 관계는 상속을 사용하지 않고 다른 방법으로도 구현할 수 있다.

class Police
{
private:
    int handcuffs;
    Gun* pistol;
public:
    Police(int bNum, int cNum) : handcuffs(cNum)
    {
        if (bNum > 0)
            pistol = new Gun(bNum);
        else
            pistol = NULL;
    }

    void shoot()
    {
        if (pistol)
            pistol->shoot();
    }

    void snap()
    {
        handcuffs--;
        cout << "수갑 사용 ! \n";
    }
};

소유 관계는 차라리 상속하지 않는 편이 낫다. 왜냐하면 확장성이 낮기 때문이다.

만약 총과 수갑, 경찰봉까지 소유한 경찰이 있거나 권총을 소유하지 않은 경찰이 있을 수도 있는데 상속 관계에서는 정리하기가 골치를 앓을 수 있다.


상속으로 묶인 두 클래스는 강한 연관성을 띤다.

상속의 개념은 IS-A 관계에서는 찰떡같이 사용할 수 있으나, HAS-A 관계에서는 오히려 득보단 실이 많을 수 있다.

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

0개의 댓글