본문 바로가기
GD's IT Lectures : 기초부터 시리즈/C, C++ 기초부터 ~

[C/C++ 프로그래밍 : 중급] 5. 상속

by GDNGY 2023. 5. 26.

Chapter 5. 상속

상속은 프로그래밍에서 굉장히 중요한 개념입니다. C++에서는 클래스를 기반으로 상속을 통해 코드를 재사용하고, 더 복잡한 시스템을 구축할 수 있습니다. 이번 장에서는 상속의 기본 개념부터, 다양한 상속 방식, 그리고 상속이 가져오는 다형성에 이르기까지, 다양한 주제를 다루게 됩니다. 하나하나 차근차근 이해해나가다 보면, 상속이 가져다주는 막대한 이점과 효율성을 깨닫게 될 것입니다. 

 

반응형

 


[Chapter 5. 상속]


5.1. 상속의 개념
5.1.1. 상속이란 무엇인가
5.1.2. 상속의 필요성
5.1.3. 클래스와 객체, 그리고 상속

5.2. 기본 상속
5.2.1. 기본 상속의 정의와 사용법
5.2.2. 기본 상속에서의 접근 제어 지시자
5.2.3. 기본 상속의 실제 적용 예시

5.3. 다중 상속
5.3.1. 다중 상속의 정의와 사용법
5.3.2. 다중 상속에서의 충돌과 해결 방법
5.3.3. 다중 상속의 실제 적용 예시

5.4. 가상 상속
5.4.1. 가상 상속의 정의와 사용법
5.4.2. 다이아몬드 문제와 가상 상속
5.4.3. 가상 상속의 실제 적용 예시

5.5. 상속과 생성자, 소멸자
5.5.1. 상속에서 생성자와 소멸자의 동작 방식
5.5.2. 상속에서의 초기화 리스트 활용
5.5.3. 생성자와 소멸자의 실제 적용 예시

5.6. 상속과 다형성
5.6.1. 상속을 통한 다형성의 이해
5.6.2. 상속과 다형성을 활용한 프로그래밍
5.6.3. 다형성의 실제 적용 예시

5.7. 상속의 장단점
5.7.1. 상속의 장점과 사용 시 주의점
5.7.2. 상속 대신 사용할 수 있는 대안
5.7.3. 상속의 실전 활용 팁

 


5.1. 상속의 개념

상속은 OOP(Object Oriented Programming)의 핵심 개념 중 하나로, 클래스 간에 코드를 재사용하고 조직화하는 방법입니다. 상속은 '부모' 클래스에서 '자식' 클래스로 속성과 메서드를 전달하는 과정입니다. 이를 통해 중복 코드를 최소화하고, 소프트웨어의 유지 관리를 용이하게 합니다. 이는 마치 생물학적 상속에서 특성이 부모에서 자식으로 전달되는 것과 유사한 원리입니다. 이번 섹션에서는 상속의 기본 개념을 다루며, 그 필요성과 활용 방안에 대해 설명합니다. 

5.1.1. 상속이란 무엇인가

상속은 객체 지향 프로그래밍의 핵심 개념 중 하나로, 클래스 간에 코드를 재사용할 수 있도록 합니다. 쉽게 말하자면, 한 클래스가 다른 클래스의 속성과 메서드를 물려받는 것을 말합니다. 

 

상속의 주요한 장점 중 하나는 코드 재사용성입니다. 예를 들어, 'Vehicle'라는 클래스가 'engine', 'wheels', 'doors' 등의 속성을 가지고 있다고 가정해봅시다. 이제 'Car'라는 새로운 클래스를 만들려고 하는데, 'Vehicle' 클래스의 모든 속성이 필요하다면, 'Car' 클래스에서 'Vehicle' 클래스를 상속받을 수 있습니다. 

상속을 사용하면 'Car' 클래스는 'Vehicle' 클래스의 모든 속성과 메서드를 자동으로 물려받게 되므로, 동일한 코드를 다시 작성할 필요가 없습니다. 또한, 추가적으로 'Car' 클래스만의 특징을 추가하거나 변경(오버라이딩)할 수 있습니다. 

 

다음은 C++에서 상속을 사용하는 간단한 예제입니다:

 

[예제]

// 부모 클래스 (Base class)
class Vehicle {
public:
    string brand = "Unknown";
    void honk() {
        cout << "Beep, beep!\n";
    }
};

// 자식 클래스 (Derived class)
class Car : public Vehicle {
public:
    string model = "Unknown";
};

int main() {
    Car myCar;
    myCar.brand = "Ford";
    myCar.model = "Mustang";
    myCar.honk(); // Vehicle 클래스의 메서드 사용
    cout << myCar.brand + " " + myCar.model; // Output: Ford Mustang

    return 0;
}

 

이 예제에서 'Car' 클래스는 'Vehicle' 클래스를 상속받아 'brand' 속성과 'honk' 메서드를 사용할 수 있습니다. 또한, 'Car' 클래스는 자체 속성인 'model'을 추가로 가지고 있습니다.

 

다만 주의할 점은, C++에서는 상속을 사용할 때 접근 지정자(public, protected, private)를 명시해야 한다는 점입니다. 이 접근 지정자는 부모 클래스의 멤버가 자식 클래스에서 어떻게 접근될 수 있는지를 결정합니다.

 

상속은 코드의 재사용성을 향상시키고, 클래스 간의 관계를 논리적으로 표현하는 데 매우 유용한 도구입니다. 하지만 항상 적절하게 사용해야 합니다. 상속 계층이 너무 복잡해지면 코드의 유지보수가 어려워질 수 있으므로, 상속을 사용하는 것이 적절한지, 그리고 어떤 클래스를 상속해야 할지 신중하게 고려해야 합니다.

 

5.1.2. 상속의 필요성

상속의 필요성을 이해하기 위해서는 코드의 재사용성, 확장성, 그리고 유지보수성에 대해 생각해 보는 것이 중요합니다. 상속이란 개념은 이 세 가지 핵심적인 이점을 제공합니다.

  1. 코드의 재사용성을 높여줍니다. 이미 작성된 클래스의 모든 속성과 메서드를 새로운 클래스에서 재사용할 수 있게 만듭니다. 이는 코딩 시간을 절약하고 코드의 일관성을 유지하는데 큰 도움이 됩니다.
  2. 클래스의 확장이 용이해집니다. 기존의 클래스를 수정하지 않고도 새로운 기능을 추가하는 것이 가능해집니다. 새로운 클래스를 생성하고 기존 클래스를 상속받아 필요한 기능만을 추가하면 됩니다.
  3. 유지보수성이 향상됩니다. 코드의 수정이 필요할 경우, 상위 클래스만 수정하면 그 클래스를 상속받은 모든 하위 클래스에 변경이 반영됩니다.

이제 이러한 이론적인 부분을 넘어 실제 코드로 이해해봅시다. 위에서 '동물' 클래스와 '고양이' 클래스를 만드는 예를 보았습니다. 만약 동물이 '자는' 행동을 추가해야 한다면 어떻게 할까요?

 

[예제]

// C++ 코드
class Animal {
public:
    void eat() {
        cout << "Eating...\n";
    }
    
    void move() {
        cout << "Moving...\n";
    }
    
    void sleep() {
        cout << "Sleeping...\n";
    }
};

class Cat : public Animal {
public:
    void meow() {
        cout << "Meowing...\n";
    }
};


Animal 클래스에 새로운 메서드인 'sleep'을 추가하였습니다. 이제 Cat 클래스의 객체는 '먹는다', '움직인다', '자는다', 그리고 '야옹'하는 행동을 할 수 있습니다. 이는 '상속'의 강력한 특징을 보여주는 예시입니다. 상속을 통해 코드의 수정 및 추가가 필요한 경우, 각각의 하위 클래스를 일일이 수정하지 않고도 상위 클래스에서 변경하면 됩니다. 이로 인해 프로그램의 유지보수가 용이해지며, 코드의 효율성이 향상됩니다.

 

따라서 상속은 프로그래밍에서 매우 중요한 개념이며, 특히 객체 지향 프로그래밍에서는 더욱 그렇습니다. 코드의 재사용성, 확장성, 그리고 유지보수성을 향상시키는 이점을 제공하므로, 상속을 적절히 활용하는 것은 효율적인 프로그램을 만드는데 매우 중요합니다. 이번 장에서는 이러한 상속의 필요성과 그 구현 방법에 대해 더욱 자세히 알아보겠습니다.

 

5.1.3. 클래스와 객체, 그리고 상속

객체 지향 프로그래밍(OOP)의 핵심은 클래스와 객체에 있습니다. 클래스는 '설계도'나 '틀'로 생각할 수 있으며, 이를 바탕으로 실제로 메모리에 할당된 인스턴스를 객체라고 합니다. 여기서 상속은 이런 클래스 간의 특별한 관계를 말합니다.

 

클래스는 변수(속성)와 함수(메서드)를 포함합니다. 이런 속성과 메서드들은 해당 클래스의 객체가 어떤 상태를 가질 수 있고, 어떤 행동을 할 수 있는지를 정의합니다.

 

[예제]

// C++ 코드
class Animal {
public:
    void eat() {
        cout << "Eating...\n";
    }
    
    void move() {
        cout << "Moving...\n";
    }
};

 

위의 코드는 Animal이라는 클래스를 정의하고 있습니다. 이 클래스는 eat와 move라는 두 개의 메서드를 가지고 있습니다. 이제 우리는 Animal 클래스를 기반으로 객체를 생성할 수 있습니다.

 

[예제]

Animal cat;
cat.eat();
cat.move();


위 코드는 Animal 클래스의 인스턴스(객체)인 'cat'을 생성하고, 이 'cat' 객체를 통해 eat와 move 메서드를 호출하는 예시입니다.

 

상속은 기존 클래스(상위 클래스 또는 부모 클래스라고 함)의 속성과 메서드를 새로운 클래스(하위 클래스 또는 자식 클래스라고 함)가 물려받는 것입니다. 상속을 사용하면 코드의 재사용성이 향상되고, 중복 코드를 줄일 수 있습니다.

 

[예제]

// C++ 코드
class Cat : public Animal {
public:
    void meow() {
        cout << "Meowing...\n";
    }
};


위 코드는 Animal 클래스를 상속받는 Cat 클래스를 정의하는 것입니다. 이 Cat 클래스는 Animal 클래스의 모든 속성과 메서드를 물려받아 사용할 수 있습니다. 따라서 Cat 클래스의 인스턴스는 eat, move, 그리고 meow 메서드를 모두 호출할 수 있습니다.

 

[예제]

Cat kitty;
kitty.eat();
kitty.move();
kitty.meow();

 

이처럼 상속은 기존 클래스의 속성과 메서드를 새로운 클래스에서 재사용할 수 있게 해주며, 이를 통해 코드의 구조를 개선하고 재사용성을 높일 수 있습니다. 상속을 이해하고 사용하는 것은 객체 지향 프로그래밍의 중요한 요소입니다. 이번 장에서는 상속에 대해 더욱 깊이 있는 이해를 위해 여러 가지 상황과 예제를 살펴볼 것입니다.

 

5.2. 기본 상속

기본 상속이란, 한 클래스의 속성과 메서드를 다른 클래스가 물려받는 객체 지향 프로그래밍의 중요한 기능입니다. 이를 통해 코드 재사용성이 높아지고, 프로그램의 구조와 설계가 개선되며, 이를 바탕으로 확장성 있는 프로그램을 만드는 데 도움이 됩니다. 이번 섹션에서는 이러한 기본 상속의 원리와 그 구현 방법에 대해 알아보겠습니다.

5.2.1. 기본 상속의 정의와 사용법

기본 상속이란, 하나의 클래스가 다른 클래스의 속성과 메서드를 물려받는 것을 의미합니다. 이를 통해 클래스 간에 계층적 관계를 형성할 수 있습니다. 기본적으로, 상속 관계에서 물려주는 클래스를 '상위 클래스' 또는 '부모 클래스'라 하고, 물려받는 클래스를 '하위 클래스' 또는 '자식 클래스'라고 합니다.

 

C++에서는 기본 상속을 적용할 때 '상속' 키워드를 사용합니다. 이어서 상속 받을 클래스명을 적고, 그 뒤에 상속 방식을 명시합니다. 기본적인 형식은 다음과 같습니다.

 

[예제]

class DerivedClass : access-specifier BaseClass

 

여기서 'DerivedClass'는 하위 클래스, 'BaseClass'는 상위 클래스를 나타냅니다. 'access-specifier'는 접근 제어자로서 public, protected, private 중 하나를 사용할 수 있습니다. 접근 제어자에 대한 자세한 내용은 이어지는 섹션에서 논의하겠습니다.

 

아래에는 C++의 기본 상속을 보여주는 간단한 예제를 준비했습니다.

 

[예제]

// C++ 코드
class BaseClass { // Base (parent) class
  public:
    void baseFunction() {
        cout << "This function is in the base class." << endl;
    }
};

class DerivedClass: public BaseClass { // Derived (child) class
};


이 예제에서 'DerivedClass'는 'BaseClass'의 속성과 메서드를 상속 받았습니다. 따라서, 'DerivedClass'의 인스턴스는 'baseFunction' 메서드를 호출할 수 있습니다.

 

[예제]

DerivedClass derivedObj;
derivedObj.baseFunction(); // This function is in the base class.

 

기본 상속의 개념을 이해하는 것은 객체 지향 프로그래밍을 잘 이해하는 데 필수적입니다. 특히, 상속을 통해 재사용성이 높아지고 코드의 중복을 줄일 수 있어, 프로그램의 유지 관리가 용이해집니다. 

 

5.2.2. 기본 상속에서의 접근 제어 지시자

C++에서 상속을 사용할 때, 접근 제어 지시자(access specifier)를 사용하게 됩니다. 이 접근 제어 지시자는 클래스 내부의 멤버(변수나 함수)가 외부에서 어떻게 접근될 수 있는지를 정의합니다. C++에서는 크게 세 가지 접근 제어 지시자를 제공합니다. public, protected, 그리고 private입니다.

  • public: public으로 선언된 멤버는 클래스의 어디에서든, 클래스 외부에서도 접근 가능합니다.
  • protected: protected로 선언된 멤버는 같은 클래스와 하위 클래스에서만 접근 가능합니다.
  • private: private로 선언된 멤버는 같은 클래스에서만 접근 가능합니다.

 

상속을 할 때 접근 제어 지시자를 선택하는 것은 중요한 결정입니다. 이는 상위 클래스의 멤버가 하위 클래스에서 어떻게 접근될 수 있는지를 결정하기 때문입니다. 예를 들어, public으로 상속을 하게 되면 상위 클래스의 public 멤버는 하위 클래스에서도 public으로 유지됩니다. 반면에, protected나 private으로 상속을 하게 되면 상위 클래스의 public 멤버는 하위 클래스에서는 protected나 private으로 변경되어 접근이 제한됩니다.

 

아래에는 접근 제어 지시자와 상속이 어떻게 작동하는지를 보여주는 예제를 제시하였습니다.

 

[예제]

// C++ 코드
class Base {
  public:
    int publicMember;
  protected:
    int protectedMember;
  private:
    int privateMember;
};

class DerivedPublic : public Base {
    // publicMember is accessible
    // protectedMember is accessible
    // privateMember is not accessible from DerivedPublic
};

class DerivedProtected : protected Base {
    // publicMember is protected
    // protectedMember is protected
    // privateMember is not accessible from DerivedProtected
};

class DerivedPrivate : private Base {
    // publicMember is private
    // protectedMember is private
    // privateMember is not accessible from DerivedPrivate
};


이 예제에서는 Base라는 상위 클래스가 세 가지 멤버를 갖고 있습니다: publicMember, protectedMember, privateMember. 이 중 publicMember는 public, protectedMember는 protected, privateMember는 private으로 선언되었습니다. 그리고 이 Base 클래스는 세 가지 방식(public, protected, private)으로 상속되었습니다. 

 

이러한 접근 제어 지시자의 이해는 클래스와 상속의 핵심적인 요소이며, 이를 통해 데이터를 안전하게 보호하고 코드의 유지 관리를 용이하게 할 수 있습니다. 따라서 접근 제어 지시자의 적절한 사용법을 숙지하는 것은 매우 중요합니다.

 

5.2.3. 기본 상속의 실제 적용 예시

이제 C++의 기본 상속에 대한 실제 적용 예시를 보도록 하겠습니다. 아래에 예제 코드가 있습니다. 이 예제는 간단한 동물(Animal) 클래스와 그 클래스를 상속받는 개(Dog) 클래스, 그리고 고양이(Cat) 클래스를 만드는 것입니다.

 

[예제]

// C++ 코드
class Animal {
public:
    Animal() {
        cout << "Animal 생성자 호출!" << endl;
    }
    void eat() {
        cout << "Animal이 먹는 중..." << endl;
    }
};

class Dog : public Animal {
public:
    Dog() {
        cout << "Dog 생성자 호출!" << endl;
    }
    void bark() {
        cout << "Dog이 짖는 중..." << endl;
    }
};

class Cat : public Animal {
public:
    Cat() {
        cout << "Cat 생성자 호출!" << endl;
    }
    void meow() {
        cout << "Cat이 야옹하는 중..." << endl;
    }
};


이 예제에서, 'Animal'이라는 기본 클래스(base class)를 만들고, 이를 상속받아 'Dog'와 'Cat' 두 개의 파생 클래스(derived classes)를 만들었습니다.

 

기본 클래스 'Animal'에는 eat라는 메서드가 있으며, 이 메서드는 모든 동물이 공유하는 행동을 나타냅니다.

 

파생 클래스인 'Dog'와 'Cat'은 각각 'Animal'에서 상속받아서 만들어진 클래스이므로, eat 메서드를 모두 가지고 있습니다. 또한 'Dog'와 'Cat' 클래스는 각각 개와 고양이의 고유한 행동을 나타내는 bark와 meow 메서드를 가지고 있습니다.

 

이와 같이 상속을 사용하면, 기본 클래스에 있는 메서드를 파생 클래스에서 재사용할 수 있으므로 코드의 중복을 줄이고, 프로그램의 구조를 더욱 명확하게 만들 수 있습니다. 또한 상속을 통해 기본 클래스의 특성을 공유하면서도 각 파생 클래스에서 고유한 기능을 추가하거나 수정할 수 있으므로, 프로그램의 확장성이 뛰어나게 됩니다.

 

이를 통해, 상속은 객체 지향 프로그래밍의 중요한 특성 중 하나인 코드 재사용성과 확장성을 실현할 수 있는 강력한 도구라고 할 수 있습니다.

 

5.3. 다중 상속

C++에서는 다중 상속이라는 개념을 지원합니다. 이는 하나의 클래스가 둘 이상의 부모 클래스로부터 상속을 받을 수 있음을 의미합니다. 다중 상속을 사용하면 여러 부모 클래스의 기능들을 모두 하나의 클래스에서 이용할 수 있게 되어 유연성과 확장성을 증가시킬 수 있습니다. 그러나, 복잡도가 증가하고, 다이아몬드 상속 문제와 같은 특정 문제를 야기할 수 있으므로 주의해서 사용해야 합니다.

5.3.1. 다중 상속의 정의와 사용법

다중 상속은 C++에서 제공하는 특별한 형태의 상속이며, 하나의 클래스가 여러 개의 부모 클래스로부터 상속을 받을 수 있게 해주는 기능입니다. 즉, 하나의 자식 클래스가 여러 부모 클래스의 속성과 메서드를 모두 물려받을 수 있습니다.

 

다중 상속의 기본 문법은 다음과 같습니다.

 

[예제]

class DerivedClass: access-specifier BaseClass1, access-specifier BaseClass2, ... {
    // 클래스의 본문
};


여기서 access-specifier는 public, private, protected 중 하나를 사용할 수 있습니다. 이는 부모 클래스의 멤버가 파생 클래스에서 어떻게 접근될 수 있는지를 결정합니다.

 

다중 상속을 이용하는 간단한 예제를 살펴봅시다.

 

[예제]

class Base1 {
public:
    void function1() {
        std::cout << "Base1 function1" << std::endl;
    }
};

class Base2 {
public:
    void function2() {
        std::cout << "Base2 function2" << std::endl;
    }
};

class Derived: public Base1, public Base2 {
};

int main() {
    Derived d;
    d.function1();  // Base1의 function1 호출
    d.function2();  // Base2의 function2 호출

    return 0;
}

 

이 예제에서, Derived 클래스는 Base1과 Base2 두 클래스를 상속받고 있습니다. Derived 클래스의 객체인 d는 Base1의 function1과 Base2의 function2 메서드를 모두 호출할 수 있습니다. 이처럼 다중 상속은 한 클래스가 둘 이상의 클래스의 특성과 기능을 동시에 상속받을 수 있게 해 주며, 이를 통해 코드의 재사용성과 효율성을 크게 향상할 수 있습니다.

 

그러나 다중 상속이 잘못 사용되면 프로그램의 복잡성을 높이고, 이해하기 어려운 코드를 생성할 수 있으므로 주의가 필요합니다. 특히 같은 이름의 메서드나 속성을 가진 부모 클래스를 상속받는 경우, 충돌 문제가 발생할 수 있으므로 이에 대한 처리가 필요합니다.

 

5.3.2. 다중 상속에서의 충돌과 해결 방법

다중 상속을 이용할 때, 주의해야 할 문제 중 하나는 '이름의 충돌'입니다. 이는 두 개 이상의 부모 클래스가 같은 이름의 메서드나 속성을 가지고 있을 때 발생합니다. 이 경우, 자식 클래스에서 해당 이름을 사용하려고 하면 컴파일러는 어느 부모 클래스의 멤버를 참조해야 할지 혼동하게 됩니다. 이를 '다이아몬드 문제'라고도 합니다.

 

이 문제를 해결하는 방법 중 하나는 '명시적인 범위 지정'을 사용하는 것입니다. 즉, 자식 클래스에서 부모 클래스의 멤버를 참조할 때, 부모 클래스의 이름을 명시하여 어떤 부모 클래스의 멤버를 참조하는지 명확하게 해줍니다.

 

예를 들어, 아래의 코드를 살펴봅시다:


[예제]

class Base1 {
public:
    void commonFunc() {
        std::cout << "Base1 commonFunc" << std::endl;
    }
};

class Base2 {
public:
    void commonFunc() {
        std::cout << "Base2 commonFunc" << std::endl;
    }
};

class Derived: public Base1, public Base2 {
public:
    void callFunc() {
        Base1::commonFunc();  // Base1의 commonFunc 호출
        Base2::commonFunc();  // Base2의 commonFunc 호출
    }
};

int main() {
    Derived d;
    d.callFunc();

    return 0;
}


여기서 Derived 클래스는 Base1과 Base2 두 클래스를 상속받고 있으며, 두 부모 클래스 모두 commonFunc라는 같은 이름의 함수를 가지고 있습니다. 이 경우 Derived 클래스에서 commonFunc 함수를 호출하려면, Base1::commonFunc() 또는 Base2::commonFunc()와 같이 부모 클래스의 이름을 명시해야 합니다.

 

이러한 방법은 다중 상속을 사용할 때 발생할 수 있는 이름의 충돌 문제를 해결하는 효과적인 방법입니다. 그러나 다중 상속은 복잡성을 증가시키며, 여러 가지 문제를 야기할 수 있으므로, 신중하게 사용해야 합니다. 특히 다중 상속을 사용할 때는 클래스 설계에 더욱 주의를 기울여야 합니다.

 

5.3.2. 다중 상속에서의 충돌과 해결 방법

다중 상속에서 충돌이 발생하는 또 다른 상황은 두 부모 클래스가 같은 이름의 데이터 멤버를 가지고 있을 때입니다. 이런 경우에도 앞서 언급한 '명시적 범위 지정' 방식을 사용해 충돌을 해결할 수 있습니다.

 

예를 들어 다음과 같은 C++ 코드를 보겠습니다.

 

[예제]

class Base1 {
public:
    int x;
};

class Base2 {
public:
    int x;
};

class Derived: public Base1, public Base2 {
public:
    void setValues(int a, int b) {
        Base1::x = a;  // Base1의 x 멤버에 접근
        Base2::x = b;  // Base2의 x 멤버에 접근
    }

    void printValues() {
        std::cout << "Base1::x = " << Base1::x << std::endl;
        std::cout << "Base2::x = " << Base2::x << std::endl;
    }
};

int main() {
    Derived d;
    d.setValues(10, 20);
    d.printValues();  // Base1::x = 10, Base2::x = 20 출력

    return 0;
}


위의 코드에서 Derived 클래스는 Base1과 Base2 두 클래스를 상속받습니다. 두 부모 클래스 모두 x라는 이름의 데이터 멤버를 가지고 있습니다. 그러나 Derived 클래스에서 Base1::x나 Base2::x와 같이 부모 클래스의 이름을 명시하면, 어느 부모 클래스의 x 멤버를 참조하는지 명확해집니다.

 

여기서 주의할 점은 C++에서만 다중 상속이 지원되며, C에서는 지원되지 않는다는 것입니다. C++에서 다중 상속을 사용하면 코드의 복잡도가 증가하므로 주의가 필요합니다. 특히, 다중 상속으로 인해 발생할 수 있는 이름의 충돌 문제는 주의 깊게 처리해야 합니다.

 

5.3.3. 다중 상속의 실제 적용 예시

C++에서는 클래스가 두 개 이상의 클래스를 상속받을 수 있는데, 이를 '다중 상속(Multiple Inheritance)'라 합니다. 이 기능은 매우 강력하지만, 잘못 사용하면 복잡한 문제를 초래할 수 있습니다.

 

다중 상속의 활용 예시로, 새로운 클래스가 '인쇄 가능(Printable)' 클래스와 '스캔 가능(Scannable)' 클래스 두 클래스를 모두 상속받아 '멀티펑션 프린터(MultiFunctionPrinter)' 클래스를 만드는 경우를 생각해 봅시다.

 

[예제]

class Printable {
public:
    void print() {
        std::cout << "Printing..." << std::endl;
    }
};

class Scannable {
public:
    void scan() {
        std::cout << "Scanning..." << std::endl;
    }
};

class MultiFunctionPrinter : public Printable, public Scannable {
};

int main() {
    MultiFunctionPrinter mfp;
    mfp.print(); // "Printing..." 출력
    mfp.scan();  // "Scanning..." 출력
    return 0;
}


여기서 'MultiFunctionPrinter' 클래스는 'Printable' 클래스와 'Scannable' 클래스 둘 다를 상속받아 두 클래스의 기능을 모두 사용할 수 있습니다. 이처럼 다중 상속은 여러 클래스의 기능을 합쳐 한 클래스에서 사용할 수 있도록 해주는 강력한 도구입니다.

 

다만, 주의할 점은 다중 상속이 코드의 복잡성을 높이고 예기치 않은 문제를 발생시킬 수 있다는 것입니다. 예를 들어, 두 부모 클래스가 같은 이름의 메서드를 가지고 있을 경우, 어떤 메서드를 호출해야 할지 명확하지 않아 충돌이 발생할 수 있습니다. 이런 문제는 명시적인 범위 지정을 통해 해결할 수 있습니다. 그러므로 다중 상속을 사용할 때에는 항상 주의를 기울여야 합니다.

 

여기까지 '다중 상속의 실제 적용 예시'에 대한 설명이었습니다.

 

5.4. 가상 상속

가상 상속(Virtual Inheritance)은 C++에서 다중 상속이 가져올 수 있는 문제인 '다이아몬드 문제'를 해결하는 방법입니다. 다이아몬드 문제는 두 개의 클래스가 같은 기본 클래스를 상속하고, 다시 이 두 클래스를 상속받는 하위 클래스가 생겼을 때 발생합니다. 이런 경우 하위 클래스는 두 경로를 통해 같은 기본 클래스를 상속받게 되는데, 이를 가상 상속을 통해 해결할 수 있습니다. 가상 상속은 'virtual' 키워드를 사용하여 수행되며, 이를 통해 하위 클래스는 한 개의 공유된 기본 클래스 인스턴스만을 가지게 됩니다.

5.4.1. 가상 상속의 정의와 사용법

가상 상속(Virtual Inheritance)은 C++에서 다이아몬드 문제를 해결하기 위한 특별한 형태의 상속입니다. 다이아몬드 문제는 두 개 이상의 클래스가 같은 기본 클래스를 상속받고, 이들을 다시 상속받는 클래스가 있을 때 발생하는 문제입니다. 이 경우, 하위 클래스는 두 개의 복사본을 가지게 되어 불필요한 메모리를 사용하거나, 두 개의 동일한 기본 클래스 인스턴스 사이에서 충돌이 발생할 수 있습니다.

 

가상 상속은 이러한 문제를 해결합니다. C++에서 'virtual' 키워드를 사용하여 상속을 선언하면, 기본 클래스는 단 한 번만 상속됩니다. 이로 인해 기본 클래스의 인스턴스는 하위 클래스에서 공유되며, 이를 통해 다이아몬드 문제가 해결됩니다.

 

[예제]

// 가상 상속의 예
class Base {
public:
    void baseFunc() {
        cout << "Base Function" << endl;
    }
};

class Derived1 : virtual public Base {
};

class Derived2 : virtual public Base {
};

class MostDerived : public Derived1, public Derived2 {
};

int main() {
    MostDerived md;
    md.baseFunc();  // No error, despite multiple inheritance paths to Base
    return 0;
}


위의 코드는 가상 상속을 사용하는 예입니다. 여기서 'Derived1'과 'Derived2' 클래스는 모두 'Base' 클래스를 가상으로 상속합니다. 따라서 'MostDerived' 클래스는 'Base' 클래스의 단일 인스턴스만 상속받습니다. 그 결과, 'baseFunc' 함수를 호출할 때 충돌이 발생하지 않습니다.

 

주의할 점은 가상 상속이 모든 상황에서 이상적인 해결책이라고는 할 수 없다는 것입니다. 가상 상속은 메모리와 성능에 추가적인 오버헤드를 발생시킬 수 있습니다. 따라서 가상 상속을 사용하기 전에 코드 구조를 신중하게 검토하고 설계하는 것이 중요합니다.

 

5.4.2. 다이아몬드 문제와 가상 상속

다이아몬드 문제는 다중 상속이 있는 프로그래밍 언어에서 발생하는 특정한 종류의 문제입니다. 이 문제는 하나의 클래스가 두 개 이상의 클래스를 상속하고, 이 두 클래스가 동일한 상위 클래스를 가지고 있을 때 발생합니다. 이런 경우, 가장 하위의 클래스는 상위 클래스의 멤버를 두 번 상속받게 되어 문제가 발생하게 됩니다. 이것이 바로 다이아몬드 문제입니다.

 

C++에서는 이 문제를 해결하기 위해 가상 상속이라는 개념을 도입하였습니다. 가상 상속을 사용하면, 다중 상속을 받는 클래스에서 상위 클래스의 멤버를 한 번만 상속받게 됩니다. 이는 상속받는 모든 경로에 걸쳐 상위 클래스의 단일 인스턴스만이 존재하도록 보장합니다.

 

이를 통한 다이아몬드 문제의 해결을 보여주는 예제 코드는 아래와 같습니다.

 

[예제]

class GrandParent {
public:
    void sayHello() { std::cout << "Hello from GrandParent!" << std::endl; }
};

class ParentA : public virtual GrandParent {
};

class ParentB : public virtual GrandParent {
};

class Child : public ParentA, public ParentB {
};

int main() {
    Child c;
    c.sayHello();  // No ambiguity, prints: "Hello from GrandParent!"
    return 0;
}


위 예제에서, 'Child' 클래스는 'ParentA'와 'ParentB'를 모두 상속받습니다. 두 부모 클래스는 각각 'GrandParent'를 상속받습니다. 만약 가상 상속이 없다면, 'Child'는 'GrandParent'의 두 인스턴스를 갖게 되어 'sayHello()' 함수를 호출할 때 어느 'GrandParent'의 함수를 호출해야 할지 모호해집니다.

 

하지만 이 예제에서는 'ParentA'와 'ParentB'가 'GrandParent'를 가상으로 상속하므로, 'Child'는 'GrandParent'의 단일 인스턴스만을 상속받게 됩니다. 따라서 'sayHello()' 함수를 호출할 때 모호함이 없습니다.

 

다이아몬드 문제와 가상 상속에 대한 이해는 복잡한 클래스 구조를 가진 대규모 프로젝트에서 중요합니다. 가상 상속을 잘 이해하고 사용하면, 클래스 간의 관계를 단순화하고, 오류를 예방하며, 코드를 더욱 효율적으로 만들 수 있습니다.

 

5.4.3. 가상 상속의 실제 적용 예시

지금까지 가상 상속의 개념과 이를 통한 다이아몬드 문제의 해결 방법을 살펴보았습니다. 이제 이러한 가상 상속이 실제 프로그래밍 상황에서 어떻게 적용될 수 있는지 살펴볼 것입니다.

 

하나의 실제 적용 예시로, 우리는 다양한 유형의 동물들을 나타내는 클래스 구조를 만들 수 있습니다. 각 동물은 먹이를 먹고, 소리를 내며, 행동하는 등 공통적인 특성을 가지지만, 이러한 특성들이 구체적으로 어떻게 나타나는지는 동물의 유형에 따라 다릅니다.

 

[예제]

class Animal {
public:
    virtual void eat() = 0;  // Pure virtual function
    virtual void makeSound() = 0;  // Pure virtual function
};

class Flying {
public:
    virtual void fly() = 0;  // Pure virtual function
};

class Bird : public Animal, public Flying {
public:
    void eat() override {
        std::cout << "Bird eats worms." << std::endl;
    }

    void makeSound() override {
        std::cout << "Bird chirps." << std::endl;
    }

    void fly() override {
        std::cout << "Bird flies in the sky." << std::endl;
    }
};

 

위의 코드에서, Bird 클래스는 Animal 클래스와 Flying 클래스 모두를 상속합니다. Animal 클래스는 모든 동물이 가져야 하는 기본적인 특성을 나타내며, Flying 클래스는 날 수 있는 능력을 가진 동물을 나타냅니다. Bird 클래스는 이 두 클래스의 특성을 모두 상속받아 구체적인 행동을 정의합니다. 

 

이런 방식으로 가상 상속은 여러 클래스의 공통된 특성을 추상화하여 코드를 더욱 모듈화하고 재사용 가능하게 만듭니다. 따라서 프로그램의 유지 보수가 쉬워지며, 코드의 가독성도 높아집니다. 

 

다만, 가상 상속이나 다중 상속을 사용할 때에는 주의가 필요합니다. 상속 구조가 너무 복잡해지면, 코드를 이해하고 디버그 하기 어려워질 수 있습니다. 따라서 이런 기능을 사용할 때에는 상속 구조를 간결하고 명확하게 유지하는 것이 중요합니다. 

 

5.5. 상속과 생성자, 소멸자

C++에서, 부모 클래스의 생성자와 소멸자는 자식 클래스에서 자동으로 호출됩니다. 생성자는 부모 클래스부터 호출되며, 소멸자는 자식 클래스부터 호출됩니다. 이는 부모 클래스의 자원을 먼저 초기화하고, 마지막에 해제해야 하기 때문입니다. 이 구조를 이해하면, 클래스의 메모리 관리에 중요한 역할을 하는 생성자와 소멸자의 작동을 이해하는 데 도움이 됩니다. 

5.5.1. 상속에서 생성자와 소멸자의 동작 방식

생성자와 소멸자는 클래스의 객체가 생성될 때와 소멸될 때 자동으로 호출되는 특별한 함수입니다. 생성자는 객체 초기화를 담당하며, 소멸자는 객체를 메모리에서 정리하는 역할을 합니다. 그런데 이 함수들이 상속 구조에서는 어떻게 동작하는지 궁금할 수 있습니다. 

 

생성자의 동작 방식

상속에서, 객체가 생성될 때 생성자의 호출 순서는 부모 클래스에서 자식 클래스로 입니다. 이것이 의미하는 바는, 객체를 초기화할 때는 상속 계층의 상위 클래스부터 초기화해야 한다는 것입니다. 이는 상위 클래스의 속성과 메서드가 하위 클래스에 올바르게 상속될 수 있도록 하기 위한 것입니다.

 

C++ 코드 예시는 다음과 같습니다:

 

[예제]

class Parent {
public:
    Parent() { cout << "Parent's constructor called" << endl; }
};

class Child : public Parent {
public:
    Child() { cout << "Child's constructor called" << endl; }
};

int main() {
    Child c;  // Output: Parent's constructor called
              //         Child's constructor called
    return 0;
}

 

소멸자의 동작 방식

소멸자의 경우, 호출 순서는 정확히 반대입니다. 즉, 자식 클래스에서 부모 클래스로입니다. 이는 객체가 메모리에서 제거될 때는 하위 클래스부터 정리해야 하기 때문입니다.

 

다음은 C++ 코드 예시입니다:

 

[예제]

class Parent {
public:
    ~Parent() { cout << "Parent's destructor called" << endl; }
};

class Child : public Parent {
public:
    ~Child() { cout << "Child's destructor called" << endl; }
};

int main() {
    Child c;  // Output: Child's destructor called
              //         Parent's destructor called
    return 0;
}


위 예제에서 볼 수 있듯이, Child 객체 c가 소멸될 때 Child의 소멸자가 먼저 호출되고, 그다음으로 Parent의 소멸자가 호출됩니다. 이것이 상속 구조에서 생성자와 소멸자가 작동하는 기본 원리입니다. 이 원리를 이해하고 코드에 적용하면, 더 안정적이고 효율적인 클래스 구조를 설계할 수 있습니다.

 

5.5.2. 상속에서의 초기화 리스트 활용

C++에서 생성자의 초기화 리스트는 객체의 속성을 초기화하는 데 사용됩니다. 초기화 리스트는 생성자의 본문이 실행되기 전에 실행되며, 해당 객체의 데이터 멤버를 초기화하는 데 사용됩니다. 상속 관계에서는 초기화 리스트가 부모 클래스의 생성자를 호출하는 데에도 사용됩니다.

 

이를 보다 구체적으로 이해하기 위해 코드 예제를 살펴봅시다.

 

[예제]

class Parent {
protected:
    int id;
public:
    Parent(int _id) : id(_id) { cout << "Parent's constructor called with id: " << id << endl; }
};

class Child : public Parent {
public:
    Child(int _id) : Parent(_id) { cout << "Child's constructor called with id: " << id << endl; }
};

int main() {
    Child c(42);  // Output: Parent's constructor called with id: 42
                  //         Child's constructor called with id: 42
    return 0;
}


위 코드에서 볼 수 있듯이, Child 클래스의 생성자는 초기화 리스트를 사용하여 Parent 클래스의 생성자를 호출하며, 이는 부모 클래스의 id 멤버를 초기화합니다. 

 

이렇게 초기화 리스트를 사용하면 몇 가지 장점이 있습니다. 첫째, 초기화 리스트를 사용하면 코드가 더 간결하고 명확해집니다. 둘째, 객체의 데이터 멤버를 초기화하는 데 더 효과적입니다. 즉, 더 적은 시간과 노력으로 멤버를 초기화할 수 있습니다. 

 

초기화 리스트는 상속 구조에서 중요한 역할을 하며, 이를 활용하면 클래스의 설계와 구현이 더욱 효과적이고 명확해질 것입니다. 이 원리를 이해하고 코드에 적용하면, 더 안정적이고 효율적인 클래스 구조를 설계할 수 있습니다.

 

5.5.3. 생성자와 소멸자의 실제 적용 예시

상속과 생성자 및 소멸자가 어떻게 함께 동작하는지 확인할 수 있는 실제 예시입니다.

 

생성자와 소멸자는 클래스의 인스턴스가 생성될 때와 소멸될 때 실행되는 특별한 함수입니다. 상속 관계에서 이들 함수는 특별한 방식으로 작동합니다. 특히, 부모 클래스와 자식 클래스 모두 생성자와 소멸자를 가질 수 있으며, 이들은 상속 체인에 따라 순서대로 호출됩니다.

 

먼저 생성자의 동작을 살펴봅시다. 자식 클래스의 객체가 생성될 때, 먼저 부모 클래스의 생성자가 호출되고 그 다음에 자식 클래스의 생성자가 호출됩니다. 이는 부모 클래스의 멤버가 먼저 초기화되어야 하기 때문입니다.

 

다음 코드 예제를 통해 확인해보겠습니다:

 

[예제]

class Base {
public:
    Base() { cout << "Base constructor" << endl; }
    ~Base() { cout << "Base destructor" << endl; }
};

class Derived : public Base {
public:
    Derived() { cout << "Derived constructor" << endl; }
    ~Derived() { cout << "Derived destructor" << endl; }
};

int main() {
    Derived d;  // Output: Base constructor
                //         Derived constructor
    return 0;   // Output: Derived destructor
                //         Base destructor
}


이 코드에서 Derived 클래스의 객체 d가 생성될 때, 먼저 Base 클래스의 생성자가 호출되고 그다음에 Derived 클래스의 생성자가 호출됩니다. 이후, 프로그램이 종료되면서 d가 소멸될 때, 반대 순서로 소멸자가 호출됩니다. 먼저 Derived 클래스의 소멸자가 호출되고 그다음에 Base 클래스의 소멸자가 호출됩니다. 이는 소멸 과정이 생성 과정의 역순으로 진행되기 때문입니다.

 

이 예제를 통해 보았듯이, 생성자와 소멸자는 C++에서 객체의 생명 주기를 관리하는 중요한 도구입니다. 이들을 잘 이해하고 사용하면, 객체 지향 프로그래밍에서 효율적이고 안전한 코드를 작성할 수 있습니다.

 

5.6. 상속과 다형성

다형성은 객체 지향 프로그래밍의 핵심 특징으로, 상속 관계 내의 객체가 동일한 인터페이스를 통해 서로 다른 동작을 수행하게 하는 것입니다. C++에서는 포인터와 참조를 통해 다형성을 구현하며, 특히 가상 함수를 사용하여 런타임에 객체의 실제 타입에 따라 다른 함수를 호출할 수 있습니다. 이는 코드의 재사용성과 확장성을 향상합니다.

5.6.1. 상속을 통한 다형성의 이해

다형성(polymorphism)은 객체지향 프로그래밍에서 가장 중요한 개념 중 하나입니다. 이름에서 알 수 있듯이, '다형성'은 '많은 형태'라는 뜻이며, 이것은 하나의 인터페이스나 레퍼런스가 여러 타입의 객체를 참조하거나, 하나의 메서드가 여러 방식으로 동작하는 것을 말합니다.

 

다형성을 이해하기 위해서는 먼저 '상속'과 '가상 함수'에 대한 이해가 필요합니다. 이 두 개념이 결합되면, 우리는 부모 클래스의 포인터나 레퍼런스를 사용하여 자식 클래스의 객체를 조작할 수 있게 됩니다.

 

예를 들어, 아래의 C++ 코드를 보겠습니다.

 

[예제]

class Animal {
public:
    virtual void makeSound() {
        cout << "The animal makes a sound \n";
    }
};

class Dog : public Animal {
public:
    void makeSound() override {
        cout << "The dog barks \n";
    }
};

int main() {
    Animal* animal1 = new Animal();
    Animal* animal2 = new Dog();

    animal1->makeSound();  // The animal makes a sound
    animal2->makeSound();  // The dog barks

    delete animal1;
    delete animal2;

    return 0;
}

 

위 코드에서 Animal은 makeSound()라는 가상 함수를 가지고 있습니다. Dog 클래스는 Animal을 상속받아 makeSound() 함수를 오버라이딩합니다. 메인 함수에서는 Animal의 포인터를 이용해 Dog 클래스의 makeSound() 함수를 호출할 수 있습니다. 이것이 바로 다형성의 핵심입니다.

 

위 예제에서는 Animal의 포인터를 사용하여 Animal 객체와 Dog 객체의 makeSound() 메서드를 호출하고 있습니다. 이러한 다형성을 통해 코드의 유연성과 확장성을 높일 수 있습니다. 이것은 코드의 재사용성을 향상시키고, 복잡한 조건문이나 타입 체킹을 줄일 수 있으므로 코드가 더 간결하고 명확해집니다.

 

그러나 다형성을 사용할 때는 주의해야 할 몇 가지 사항이 있습니다. 가장 중요한 것은 메모리 관리입니다. 다형성을 사용하면 다른 클래스의 객체를 가리키는 포인터를 만들 수 있지만, 이 포인터가 가리키는 객체를 제대로 삭제하는 것이 중요합니다. 이를 위해 C++에서는 가상 소멸자를 사용합니다. 가상 소멸자는 부모 클래스에서 선언되며, 이를 통해 부모 클래스의 포인터를 사용하여 자식 클래스의 객체를 올바르게 삭제할 수 있습니다.

 

예를 들어, 다음과 같은 코드를 생각해보세요:

 

[예제]

class Animal {
public:
    Animal() { cout << "Animal constructor\n"; }
    virtual ~Animal() { cout << "Animal destructor\n"; }
    virtual void makeSound() {
        cout << "The animal makes a sound \n";
    }
};

class Dog : public Animal {
public:
    Dog() { cout << "Dog constructor\n"; }
    ~Dog() override { cout << "Dog destructor\n"; }
    void makeSound() override {
        cout << "The dog barks \n";
    }
};

int main() {
    Animal* animal = new Dog();

    animal->makeSound();  // The dog barks

    delete animal;  // Dog destructor -> Animal destructor

    return 0;
}

 

이 경우, Animal 클래스의 소멸자가 가상 소멸자로 선언되어 있으므로, Animal 포인터를 사용하여 Dog 객체를 제거할 때 Dog 클래스의 소멸자가 먼저 호출되고, 그다음으로 Animal 클래스의 소멸자가 호출됩니다. 이렇게 가상 소멸자를 사용하면 메모리 누수를 방지할 수 있습니다.

 

또한, 다형성을 사용하려면 부모 클래스의 함수를 가상 함수로 선언해야 합니다. 가상 함수는 자식 클래스에서 오버라이드할 수 있으며, 부모 클래스의 포인터를 통해 호출될 때 동적 바인딩을 통해 적절한 함수가 실행됩니다.

 

다형성은 객체지향 프로그래밍의 핵심 개념이며, 코드의 유연성과 확장성을 높여줍니다. 하지만 이를 사용할 때는 가상 함수와 가상 소멸자의 개념을 이해하고, 메모리 관리에 주의해야 합니다. 

 

5.6.2. 상속과 다형성을 활용한 프로그래밍

이 부분에서는 다형성의 실제적인 활용 예를 통해 C++ 프로그래밍에서 다형성이 어떻게 활용되는지를 이해하는 것을 목표로 하겠습니다.

 

다형성은 상속과 밀접하게 연관된 객체지향의 핵심 개념입니다. 그럼 본격적으로 예제를 통해 상속과 다형성을 활용한 프로그래밍을 살펴봅시다.

 

[예제]

class Shape {
public:
    virtual void draw() const = 0; // 순수 가상 함수
};

class Circle : public Shape {
public:
    void draw() const override {
        cout << "Draw a Circle" << endl;
    }
};

class Rectangle : public Shape {
public:
    void draw() const override {
        cout << "Draw a Rectangle" << endl;
    }
};

int main() {
    vector<Shape*> shapes;
    shapes.push_back(new Circle());
    shapes.push_back(new Rectangle());

    for(const auto& shape : shapes) {
        shape->draw();
    }

    // 메모리 해제
    for(const auto& shape : shapes) {
        delete shape;
    }

    return 0;
}

 

위 코드는 간단한 그래픽 시스템을 예로 들었습니다. Shape라는 부모 클래스를 만들고, Circle과 Rectangle이라는 자식 클래스가 이를 상속받습니다. 부모 클래스에는 순수 가상 함수 draw()가 있으며, 자식 클래스들은 이를 오버라이드하여 각자의 그리기 방법을 구현합니다.

 

이 코드에서 중요한 부분은 메인 함수입니다. 여기서 Shape 포인터의 벡터에 Circle과 Rectangle 객체를 추가합니다. 그리고 이후에 Shape 포인터를 사용하여 각 객체의 draw() 메서드를 호출합니다. 이것이 바로 다형성의 핵심입니다: 부모 클래스의 포인터를 사용하여 자식 클래스의 메서드를 호출하는 것입니다.

 

또한, 이 코드는 객체지향 프로그래밍의 장점을 보여주고 있습니다. 즉, 코드의 유연성과 확장성을 높여줍니다. 나중에 새로운 도형이 추가되더라도, Shape을 상속받고 draw() 메서드를 구현하기만 하면 됩니다. 그리고 메인 함수는 수정 없이 그대로 동작합니다.

 

그러나 다형성을 사용할 때에는 주의해야 할 점도 있습니다. 가장 중요한 것은 메모리 관리입니다. 다형성을 사용하면 다른 클래스의 객체를 가리키는 포인터를 만들 수 있지만, 이 포인터가 가리키는 객체를 제대로 삭제하는 것이 중요합니다. 이를 위해 C++에서는 가상 소멸자를 제공합니다. 만약 소멸자를 가상 함수로 선언하지 않으면, 부모 클래스 포인터를 통해 자식 클래스 객체를 삭제할 때 문제가 발생할 수 있습니다.

 

그 이유는 C++이 부모 클래스의 소멸자만 호출하기 때문입니다. 따라서 자식 클래스의 리소스가 제대로 해제되지 않을 수 있습니다. 이는 메모리 누수로 이어질 수 있습니다. 가상 소멸자를 사용하면, 부모 클래스 포인터를 통해 객체를 삭제할 때 자식 클래스의 소멸자도 호출됩니다. 이로써 메모리 누수를 방지할 수 있습니다.

 

다음은 가상 소멸자를 사용한 예입니다.

 

[예제]

class Shape {
public:
    virtual ~Shape() {
        cout << "Shape destructor called" << endl;
    }
    virtual void draw() const = 0; 
};

class Circle : public Shape {
public:
    ~Circle() {
        cout << "Circle destructor called" << endl;
    }
    void draw() const override {
        cout << "Draw a Circle" << endl;
    }
};

int main() {
    Shape* shape = new Circle();
    delete shape;  // Circle destructor called
                   // Shape destructor called

    return 0;
}

 

이 예제에서는 Shape 클래스에 가상 소멸자를 추가했습니다. 따라서 Shape 포인터를 통해 Circle 객체를 삭제하면, Circle의 소멸자와 Shape의 소멸자 모두 호출됩니다. 이를 통해 Circle의 자원을 제대로 해제할 수 있습니다.

 

상속과 다형성은 객체지향 프로그래밍에서 중요한 개념입니다. 이를 통해 코드의 재사용성을 높이고, 유연하고 확장 가능한 설계를 할 수 있습니다. 그러나 다형성을 사용할 때는 메모리 관리에 주의해야 합니다. 가상 소멸자를 사용하여 메모리 누수를 방지하는 것이 중요합니다.

 

5.6.3. 다형성의 실제 적용 예시

아래의 예제는 동물원에서 다양한 동물들이 어떻게 다른 행동을 하는지를 시뮬레이션합니다.

 

[예제]

class Animal {
public:
    virtual void speak() = 0; // 순수 가상 함수
};

class Lion : public Animal {
public:
    void speak() override {
        cout << "Roar!" << endl;
    }
};

class Monkey : public Animal {
public:
    void speak() override {
        cout << "Ooh ooh aah aah!" << endl;
    }
};

int main() {
    vector<Animal*> animals;
    animals.push_back(new Lion());
    animals.push_back(new Monkey());

    for(const auto& animal : animals) {
        animal->speak();
    }

    // 메모리 해제
    for(const auto& animal : animals) {
        delete animal;
    }

    return 0;
}

 

여기서, Animal은 순수 가상 함수 speak()를 가지는 추상 클래스이며, Lion과 Monkey 클래스는 이를 상속받아 speak() 함수를 오버라이드합니다. 이러한 방식으로 각 동물이 서로 다른 소리를 낼 수 있게 합니다. 메인 함수에서는 Animal의 포인터를 이용해 Lion 클래스와 Monkey 클래스의 speak() 함수를 호출할 수 있습니다. 이것이 바로 다형성의 핵심입니다.

 

이 코드의 핵심은 다형성을 통해 코드의 유연성과 확장성을 높이는 것입니다. 새로운 동물이 동물원에 추가되더라도, 그 동물 클래스를 만들고 Animal 클래스를 상속받아 speak() 함수를 구현하면, 별도의 코드 수정 없이 해당 동물의 speak() 함수를 호출할 수 있습니다.

 

하지만, 다형성을 사용할 때는 메모리 관리에 주의해야 합니다. 이 코드에서는 new를 사용해 동적으로 동물 객체를 생성했으므로, 이들 객체를 사용한 후에는 반드시 delete를 사용해 메모리를 해제해야 합니다.

 

다형성은 상속과 결합하여 코드의 재사용성을 높이고, 코드의 가독성을 향상하며, 새로운 기능을 쉽게 추가할 수 있도록 도와줍니다. 따라서 다형성은 객체 지향 프로그래밍의 핵심 개념 중 하나이며, 이를 잘 이해하고 활용하는 것은 프로그래밍 실력을 향상하는 데 큰 도움이 됩니다.

 

5.7. 상속의 장단점

상속은 코드 재사용성을 증가시키며, 소프트웨어의 구조를 개선하고, 신규 기능을 쉽게 추가할 수 있게 해주는 OOP의 중요한 개념입니다. 하지만 잘못 사용하면 오버라이딩 오류, 다중 상속 문제, 불필요한 인터페이스 상속 등의 문제를 초래할 수 있습니다. 따라서 상속을 활용할 때는 적절한 설계 원칙과 상속 계층의 관리가 필요합니다.

5.7.1. 상속의 장점과 사용 시 주의점

상속의 가장 큰 장점은 바로 코드 재사용이 가능하다는 점입니다. 상속을 통해 기존 클래스의 모든 멤버 변수와 멤버 함수를 새로운 클래스에 그대로 가져올 수 있으므로, 비슷한 기능을 하는 새로운 클래스를 만들 때마다 모든 코드를 처음부터 다시 작성할 필요가 없습니다.

 

다음은 C++에서 클래스 상속의 간단한 예입니다.

 

[예제]

class Animal {
public:
    void eat() {
        cout << "Eating..." << endl;
    }
};

class Dog : public Animal {
public:
    void bark() {
        cout << "Barking..." << endl;
    }
};

int main() {
    Dog d;
    d.eat();  // from base class
    d.bark(); // from derived class
    return 0;
}


여기서 'Dog' 클래스는 'Animal' 클래스를 상속받아 'eat' 메소드를 재사용하며, 추가로 'bark' 메서드를 구현하였습니다. 이처럼 상속을 통해 코드 재사용성을 높일 수 있습니다.

 

하지만 상속을 사용할 때는 몇 가지 주의점이 있습니다. 첫째, 상속의 오용은 코드의 복잡성을 증가시킬 수 있습니다. 무분별한 상속 계층은 코드를 이해하고 디버깅하는 데 어려움을 줄 수 있으므로, 상속은 신중하게 사용해야 합니다. 둘째, 상속받은 클래스는 기본 클래스의 구현에 대해 알아야 하는데, 이는 캡슐화를 깨트릴 수 있습니다. 마지막으로, 클래스 간의 강력한 결합을 초래하므로, 하나의 클래스 변경이 다른 클래스에도 영향을 미칠 수 있습니다. 이런 이유로 상속보다는 컴포지션이나 인터페이스를 활용하는 것을 권장하는 경우도 있습니다.

 

상속은 강력한 도구일 수 있지만, 그만큼 신중하게 사용해야 하는 도구이기도 합니다. 프로그램의 복잡성을 관리하려면 상속을 적절히 사용하는 방법을 잘 이해해야 합니다.

 

5.7.2. 상속 대신 사용할 수 있는 대안

상속이 많은 장점을 가지고 있지만, 늘상 언급했듯이 신중하게 사용해야 합니다. 상속의 오용은 코드의 복잡성을 증가시키고, 예기치 못한 버그를 초래할 수 있습니다. 따라서, 상속의 단점을 해결하기 위한 다른 대안들을 알아볼 필요가 있습니다. 대표적인 대안으로는 컴포지션(composition)과 인터페이스(interface)가 있습니다. 

 

컴포지션은 클래스를 조합하여 복잡한 기능을 구현하는 방법입니다. 각 클래스는 독립적으로 동작하며, 다른 클래스 내에 포함되어 새로운 기능을 만들어냅니다. 이렇게 하면 각 클래스의 역할이 명확해지고, 유지보수가 용이해집니다. 예를 들어, 'Dog' 클래스가 'Bark' 클래스와 'Eat' 클래스를 가지고 있는 경우, 'Dog'는 'Bark'와 'Eat'의 기능을 활용할 수 있습니다.

 

다음은 C++에서 컴포지션을 사용하는 예시입니다:

 

[예제]

class Bark {
public:
    void doBark() {
        cout << "Barking..." << endl;
    }
};

class Eat {
public:
    void doEat() {
        cout << "Eating..." << endl;
    }
};

class Dog {
private:
    Bark barkBehaviour;
    Eat eatBehaviour;

public:
    void performBark() {
        barkBehaviour.doBark();
    }
    
    void performEat() {
        eatBehaviour.doEat();
    }
};

int main() {
    Dog d;
    d.performBark();  // Bark class method
    d.performEat();   // Eat class method
    return 0;
}


이 예제에서는 'Dog' 클래스가 'Bark'와 'Eat' 클래스의 인스턴스를 멤버 변수로 가지고 있습니다. 이렇게 하면 'Dog' 클래스는 'Bark'와 'Eat' 클래스의 기능을 재사용할 수 있으며, 상속의 복잡성을 피할 수 있습니다.

 

다음으로, 인터페이스는 특정 동작을 수행하기 위한 메서드 집합을 정의합니다. 클래스는 인터페이스를 구현하여 해당 인터페이스의 모든 메서드를 제공해야 합니다. 이 방식은 클래스 간의 계약을 정의함으로써 안정적인 프로그래밍을 가능하게 합니다. 하지만 C++ 에는 자바나 C#처럼 명확한 인터페이스 개념이 없으므로, 대신 추상 클래스를 사용하여 인터페이스를 구현할 수 있습니다.

 

결론적으로, 상속은 강력한 도구이지만, 항상 신중하게 사용해야 합니다. 때로는 상속보다 컴포지션이나 인터페이스를 사용하는 것이 더 효과적인 설계를 가능하게 합니다. 이렇게 다양한 도구와 전략을 사용하여 프로그램의 유연성과 확장성을 향상시키는 것이 중요합니다.

 

5.7.3. 상속의 실전 활용 팁

상속은 코드의 재사용성을 향상시키고, 객체 지향 설계의 복잡성을 관리하는 강력한 도구입니다. 그러나 이를 올바르게 사용하기 위해서는 몇 가지 주요한 가이드라인과 팁을 숙지하는 것이 중요합니다.

  1. "is-a" 관계를 고려해야 합니다. 상속은 기본적으로 "is-a" 관계를 나타내는 데 사용됩니다. 예를 들어, 'Dog' 클래스가 'Animal' 클래스를 상속받는다면, 이는 'Dog'이 'Animal'의 한 종류라는 "is-a" 관계를 나타냅니다. 이 관계가 성립하지 않는다면, 상속을 사용하지 않는 것이 좋습니다.
  2. 상속의 깊이는 가능한 한 낮게 유지해야 합니다. 깊은 상속 트리는 코드를 복잡하게 만들고, 오류를 찾기 어렵게 합니다. 상속 계층이 3개 이상의 레벨로 깊어진다면, 그 설계를 재검토해야 합니다.
  3. 상속을 사용할 때는 반드시 부모 클래스의 메서드를 오버라이드해야 하는지, 아니면 새로운 메서드를 추가해야 하는지 신중히 판단해야 합니다.

다음은 C++에서 상속을 사용하는 예시입니다:

 

[예제]

// 부모 클래스
class Animal {
public:
    virtual void makeSound() {
        cout << "The animal makes a sound" << endl;
    }
};

// 자식 클래스
class Dog : public Animal {
public:
    void makeSound() override {
        cout << "The dog barks" << endl;
    }
};

int main() {
    Animal* animal = new Animal();
    Animal* dog = new Dog();

    animal->makeSound(); // Output: The animal makes a sound
    dog->makeSound();    // Output: The dog barks

    delete animal;
    delete dog;

    return 0;
}

 

이 예제에서 'Dog' 클래스는 'Animal' 클래스를 상속받아 'makeSound' 함수를 오버라이드합니다. 이를 통해 'Dog' 객체에서 'makeSound' 함수를 호출하면 "The dog barks"라는 메시지가 출력됩니다.

 

마지막으로, 상속보다는 컴포지션을 먼저 고려하세요. 상속은 강력하지만 오용될 경우 코드를 복잡하게 만들 수 있습니다. 특히 상속 계층이 복잡해질수록 유지 관리가 어려워집니다. 반면에 컴포지션은 코드의 유연성을 높여주며, 더 직관적인 설계를 가능하게 합니다. 때문에 가능하면 컴포지션을 먼저 고려해 보세요.

 

 

 

2023.05.25 - [GD's IT Lectures : 기초부터 시리즈/C, C++ 기초부터 ~] - [C/C++ 프로그래밍 : 중급] 4. 접근 제어 지시자

 

[C/C++ 프로그래밍 : 중급] 4. 접근 제어 지시자

Chapter 4. 접근 제어 지시자 접근 제어 지시자는 클래스 내의 멤버(변수, 함수)가 외부에서 접근할 수 있는 범위를 제한하는 방법을 제공합니다. 이를 통해 객체 지향 프로그래밍의 핵심 원칙 중

gdngy.tistory.com



반응형

댓글