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

[C/C++ 프로그래밍 : 중급] 7.가상 함수와 추상 클래스

by GDNGY 2023. 5. 30.

Chapter 7. 가상 함수와 추상 클래스

가상 함수는 C++의 객체 지향 프로그래밍의 중요한 개념 중 하나입니다. 이는 기반 클래스에서 선언되고 파생 클래스에서 재정의 될 수 있는 함수를 가리킵니다. 가상 함수를 이해하는 것은 다형성 구현에 있어 핵심이며, 이를 통해 여러분의 코드는 유연성과 확장성을 가질 수 있습니다.

 

반응형

 


[Chapter 7. 가상 함수와 추상 클래스]

 

7.1. 가상 함수 이해하기

7.1.1. 가상 함수란 무엇인가

7.1.2. 가상 함수 선언과 구현

7.1.3. 가상 함수의 호출과 오버라이딩

7.1.4. 가상 함수의 동작 원리

 

7.2. 가상 함수의 활용

7.2.1. 다형성과 가상 함수

7.2.2. 가상 함수와 메모리 관리

7.2.3. 가상 함수를 이용한 코드 재사용성

7.2.4. 가상 함수를 활용한 프로그래밍 예제

 

7.3. 추상 클래스와 순수 가상 함수

7.3.1. 추상 클래스란 무엇인가

7.3.2. 추상 클래스의 생성과 특성

7.3.3. 순수 가상 함수의 정의와 사용 방법

7.3.4. 추상 클래스의 활용 및 제한 사항

7.3.5. 추상 클래스의 사용 예제

 

7.4. 인터페이스 개념과 추상 클래스

7.4.1. 인터페이스란 무엇인가

7.4.2. 인터페이스와 추상 클래스의 차이

7.4.3. 추상 클래스를 이용한 인터페이스 구현

7.4.4. 인터페이스를 통한 코드 재사용성 증진

7.4.5. 인터페이스 활용 예제

 

7.5. 다중 상속과 가상 상속

7.5.1. 다중 상속의 정의와 문제점

7.5.2. 다중 상속의 활용

7.5.3. 다중 상속에서의 이름 충돌 문제

7.5.4. 가상 상속을 통한 다이아몬드 상속 문제 해결

7.5.5. 가상 상속의 활용 및 주의 사항


7.1. 가상 함수 이해하기

가상 함수는 C++의 객체 지향 프로그래밍의 중요한 개념 중 하나입니다. 이는 기반 클래스에서 선언되고 파생 클래스에서 재정의 될 수 있는 함수를 가리킵니다. 가상 함수를 이해하는 것은 다형성 구현에 있어 핵심이며, 이를 통해 여러분의 코드는 유연성과 확장성을 가질 수 있습니다. 

7.1.1 가상 함수란 무엇인가

C++에서 가상 함수는 기반 클래스(부모 클래스)에서 선언되고 파생 클래스(자식 클래스)에서 재정의 될 수 있는 함수를 의미합니다. 이런 방식은 프로그램의 다형성을 활용하는 데 매우 중요합니다.

 

다형성은 객체 지향 프로그래밍의 핵심 원칙 중 하나로, 이는 한 객체가 여러 가지 형태를 가질 수 있다는 의미입니다. 가상 함수는 이런 다형성을 지원합니다.

 

예를 들어, 고양이와 개는 모두 동물이라는 기반 클래스를 가질 수 있습니다. 이때 "발걸음 소리를 내다"라는 동작은 개와 고양이 모두 할 수 있지만, 그 소리는 서로 다르겠죠. 가상 함수를 사용하면 기반 클래스인 '동물'에 이 동작을 선언하고, 각 파생 클래스인 '고양이'와 '개'에서 이를 재정의하여 고양이와 개가 각자의 발걸음 소리를 낼 수 있게 만들 수 있습니다.

 

예제 코드는 다음과 같습니다.

 

[예제]

#include <iostream>

// 기반 클래스
class Animal {
public:
    // 가상 함수 선언
    virtual void makeFootstepSound() {
        std::cout << "This animal makes a sound...\n";
    }
};

// 파생 클래스: Cat
class Cat : public Animal {
public:
    // 가상 함수 재정의
    void makeFootstepSound() override {
        std::cout << "Cat: Purr Purr...\n";
    }
};

// 파생 클래스: Dog
class Dog : public Animal {
public:
    // 가상 함수 재정의
    void makeFootstepSound() override {
        std::cout << "Dog: Woof Woof...\n";
    }
};

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

    animal1->makeFootstepSound();  // 출력: Cat: Purr Purr...
    animal2->makeFootstepSound();  // 출력: Dog: Woof Woof...

    delete animal1;
    delete animal2;

    return 0;
}

 

위의 코드에서 Animal 클래스는 가상 함수 makeFootstepSound를 정의하고 있습니다. 이 함수는 Cat과 Dog 클래스에서 재정의되고 있습니다. 이처럼, 가상 함수를 이용하면 같은 이름의 함수를 호출하더라도, 객체의 타입에 따라 다른 동작을 수행하도록 할 수 있습니다. 이것이 바로 다형성입니다.

 

C 언어에는 가상 함수의 개념이 없지만, 함수 포인터를 사용해서 비슷한 동작을 구현할 수 있습니다. 하지만 C++의 가상 함수를 이용하면 더욱 간결하고 직관적인 코드 작성이 가능하며, 객체 지향 프로그래밍의 장점을 살릴 수 있습니다.

 

C++에서 가상 함수는 vtable이라는 특별한 메커니즘을 통해 작동합니다. vtable은 가상 함수에 대한 포인터를 저장하는 테이블로, 컴파일러가 각 클래스에 대해 하나씩 생성합니다. 객체가 생성될 때, vptr(vtable을 가리키는 포인터)이 생성되며, 해당 객체의 클래스에 대한 vtable을 가리킵니다. 이후 가상 함수가 호출될 때, vptr을 통해 적절한 함수를 찾아 호출합니다.

 

예를 들어, 위의 Animal 예제에서 Animal 객체를 생성하면, 해당 객체의 vptr은 Animal의 vtable을 가리킵니다. Cat 객체를 생성하면, 해당 객체의 vptr은 Cat의 vtable을 가리키게 됩니다. 따라서 makeFootstepSound 함수를 호출하면, vptr이 가리키는 vtable을 통해 적절한 함수(Animal::makeFootstepSound 혹은 Cat::makeFootstepSound)를 찾아 호출하게 됩니다.

 

C++에서 가상 함수를 사용하면, 파생 클래스에서 기반 클래스의 함수를 재정의 할 수 있습니다. 이렇게 재정의된 함수는 원래 함수를 '가리며', 파생 클래스의 객체를 통해 호출될 때 실행됩니다. 이것이 바로 다형성이 적용되는 방식입니다.

 

이제 가상 함수를 활용한 프로그래밍에 대한 이해를 바탕으로, 다음 섹션에서는 가상 함수를 어떻게 활용할 수 있는지에 대해 알아보겠습니다. 가상 함수를 이해하고 사용하는 것은 객체 지향 프로그래밍의 기본적인 능력 중 하나입니다. 이 섹션을 통해 C++ 프로그래밍의 또 다른 장점을 알게 되셨길 바랍니다.

 

기억하셔야 할 중요한 점은 가상 함수는 기반 클래스에서 선언되고, 파생 클래스에서 재정의 될 수 있는 함수라는 것입니다. 이를 통해 코드의 재사용성과 유연성이 향상되며, 다형성을 실현할 수 있습니다. 

 

7.1.2 가상 함수 선언과 구현

가상 함수를 사용하기 위해서는 먼저 기반 클래스에서 가상 함수를 선언해야 합니다. 가상 함수는 virtual 키워드를 사용하여 선언하며, 기반 클래스에서 선언된 가상 함수는 파생 클래스에서 재정의 될 수 있습니다.

 

가상 함수를 선언하는 방법은 아래 C++ 코드 예제를 통해 살펴보겠습니다.

 

[예제]

// 기반 클래스
class Animal {
public:
    // 가상 함수 선언
    virtual void makeSound() {
        std::cout << "The animal makes a sound.\n";
    }
};


위의 코드에서 makeSound 함수는 가상 함수로 선언되었으며, Animal 클래스에서 이를 구현하였습니다. virtual 키워드가 함수 선언 앞에 오며, 이 키워드가 가상 함수임을 나타냅니다.

 

기반 클래스에서 선언된 가상 함수는 파생 클래스에서 재정의 될 수 있습니다. 파생 클래스에서 가상 함수를 재정의하는 방법은 아래 코드를 참조해 주세요.

 

[예제]

// 파생 클래스
class Dog : public Animal {
public:
    // 가상 함수 재정의
    void makeSound() override {
        std::cout << "The dog barks.\n";
    }
};

 

위의 코드에서, Dog 클래스는 Animal 클래스로부터 상속받아, makeSound 가상 함수를 재정의 하였습니다. override 키워드는 선택적으로 사용할 수 있지만, 이 키워드를 사용하면 컴파일러가 재정의된 함수를 검사하고, 오류를 미리 잡아낼 수 있으므로 사용하는 것이 좋습니다.

 

C++의 가상 함수를 통해, 우리는 동일한 인터페이스에 대해 서로 다른 구현을 제공할 수 있습니다. 이렇게 함으로써, 코드의 유연성과 재사용성이 증가하며, 프로그램의 다형성을 실현할 수 있습니다.

 

가상 함수는 실제 객체의 타입에 따라 다르게 동작합니다. 이를 런타임 다형성이라고 부르며, 이것이 가능한 이유는 가상 함수가 vtable을 통해 늦게 바인딩되기 때문입니다.

 

가상 함수를 이해하는 또 다른 중요한 개념은 '기본 구현'입니다. 기반 클래스에 가상 함수를 선언할 때, 해당 함수에 대한 기본 구현을 제공할 수 있습니다. 이 기본 구현은 파생 클래스에서 재정의하지 않는 경우에 사용됩니다. 하지만 기반 클래스에서 순수 가상 함수를 선언하면, 해당 함수에 대한 기본 구현을 제공하지 않아야 합니다.

 

C++에서 가상 함수를 사용하면, 코드의 유연성과 재사용성이 향상됩니다. 가상 함수를 사용하면, 기반 클래스의 인터페이스를 그대로 두면서도 파생 클래스에서 해당 인터페이스의 동작을 변경할 수 있습니다. 또한, 가상 함수를 사용하면 동일한 인터페이스를 가지는 여러 타입의 객체를 동일한 방식으로 처리할 수 있습니다. 이런 유연성은 코드의 복잡성을 줄이고, 오류를 줄여줍니다.

 

7.1.4 가상 함수의 동작 원리

가상 함수의 동작 원리를 이해하려면, 먼저 C++의 런타임 다형성 개념을 이해해야 합니다. 런타임 다형성은 실행 시간에 어떤 함수가 호출될지 결정하는 능력을 말합니다. 이것은 C++에서 가상 함수가 작동하는 핵심 메커니즘입니다.

 

이를 설명하기 위해, 앞서 우리가 보았던 Animal과 Dog 클래스의 예제를 확장해 봅시다.

 

[예제]

// 기반 클래스
class Animal {
public:
    virtual void makeSound() {
        std::cout << "The animal makes a sound.\n";
    }
};

// 파생 클래스
class Dog : public Animal {
public:
    void makeSound() override {
        std::cout << "The dog barks.\n";
    }
};

// 메인 함수
int main() {
    Animal* animal = new Dog();
    animal->makeSound();  // 출력: "The dog barks."
    delete animal;
    return 0;
}

 

위 코드에서 animal 포인터는 Animal 타입입니다. 하지만 실제로 이 포인터가 가리키는 객체는 Dog 타입입니다. makeSound 함수는 Animal 클래스에서 가상 함수로 선언되었으므로, 이 함수를 호출할 때는 실행 시간에 animal 포인터가 가리키는 실제 객체의 타입을 기반으로 어떤 함수를 호출할지 결정됩니다. 이것이 바로 런타임 다형성의 원리입니다.

 

이러한 메커니즘은 '가상 테이블(vtable)'이라는 특별한 메커니즘을 통해 가능합니다. 각 클래스에는 가상 함수들의 주소를 저장하는 가상 테이블이 존재하며, 가상 함수를 호출할 때는 이 가상 테이블을 참조하여 어떤 함수를 호출할지 결정합니다.

 

가상 테이블은 컴파일러에 의해 자동으로 생성되며, 모든 객체가 가상 테이블 포인터를 가집니다. 이 포인터는 객체의 생성 시점에 가상 테이블 주소로 초기화됩니다. 그런 다음, 가상 함수를 호출할 때마다 이 가상 테이블 포인터를 통해 가상 테이블에 접근하게 됩니다.

 

가상 함수를 호출하는 방법은 간단합니다. 먼저, 가상 테이블 포인터를 통해 해당 객체의 가상 테이블에 접근합니다. 그런 다음, 가상 테이블에서 해당 가상 함수의 주소를 찾습니다. 마지막으로, 이 주소를 통해 실제 함수를 호출합니다.

 

이러한 과정은 다음의 간략화된 코드 예제를 통해 설명될 수 있습니다:

 

[예제]

// 가상 함수 호출 메커니즘
animal->vptr[0](); // 가상 테이블 포인터를 통해 첫 번째 가상 함수 호출

위 코드에서 'vptr'은 가상 테이블 포인터를 나타내며, 실제 C++ 코드에서는 이런 식으로 직접 가상 테이블 포인터에 접근할 수 없습니다. 이 코드는 가상 함수 호출 메커니즘을 설명하기 위한 것일 뿐입니다.

 

실제로는 컴파일러가 이 모든 과정을 자동으로 처리해줍니다. 가상 함수를 호출하려면 단순히 'animal->makeSound()'와 같이 함수를 호출하기만 하면 됩니다. 그러면 컴파일러가 가상 테이블 포인터를 통해 적절한 가상 함수를 호출해 줍니다.


7.2. 가상 함수의 활용

가상 함수가 프로그래밍에서 어떻게 사용되는지 실제 예제와 함께 자세히 알아보겠습니다. 가상 함수는 C++에서 다형성을 구현하는 주요 도구로, 이를 통해 코드의 유연성과 재사용성을 높일 수 있습니다. 다형성과 가상 함수의 관계, 가상 함수를 통한 코드 재사용, 그리고 실제 프로그래밍 예제를 통한 가상 함수의 활용에 대해 집중적으로 살펴보겠습니다.

7.2.1. 다형성과 가상 함수

다형성은 그리스어에서 유래한 말로 '많은 형태'라는 의미를 가지고 있습니다. 프로그래밍 언어에서 다형성은 주로 클래스 간에 관련성을 나타내며, 클래스 간의 관계를 맺어줍니다. 다형성은 프로그램을 유연하고 확장 가능하게 만들며, 이를 통해 재사용성이 높은 코드를 작성할 수 있습니다.

 

다형성을 이해하는 데 가장 중요한 요소 중 하나가 바로 가상 함수입니다. 가상 함수는 베이스 클래스에서 선언되고 파생 클래스에서 재정의됩니다. 베이스 클래스의 포인터나 참조를 통해 가상 함수를 호출하면, 실제 객체의 타입에 따라 적절한 함수가 실행됩니다. 이를 '런타임 다형성'이라고 합니다.

 

간단한 예를 들어 보겠습니다. "Animal"이라는 베이스 클래스가 있고, 이 클래스에는 "makeSound"라는 가상 함수가 있습니다. "Dog"와 "Cat"이라는 파생 클래스에서 "makeSound" 함수를 재정의하면, "Animal" 포인터를 통해 "makeSound" 함수를 호출하더라도 실제 객체의 타입에 따라 "Dog"의 "makeSound" 혹은 "Cat"의 "makeSound"가 호출됩니다.

 

[예제]

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;
    }
};

class Cat: public Animal {
public:
    void makeSound() override {
        cout << "The cat meows" << endl;
    }
};

// 베이스 클래스의 포인터로 가상 함수 호출
Animal* animal1 = new Dog();
Animal* animal2 = new Cat();
animal1->makeSound(); // 출력: "The dog barks"
animal2->makeSound(); // 출력: "The cat meows"

 

이러한 방식을 통해 C++에서는 다형성을 지원하고, 가상 함수를 사용하여 다양한 상황에 대응하는 유연한 코드를 작성할 수 있습니다. 이는 코드의 재사용성을 높이고, 확장성을 갖추는 데 도움이 됩니다.

 

7.2.2. 가상 함수와 메모리 관리

가상 함수는 클래스의 메소드를 가리키는 가상 함수 테이블(virtual function table, vtable)을 이용하여 작동합니다. 객체가 생성될 때마다 각 객체는 해당 클래스의 vtable에 대한 포인터를 가지게 됩니다. 이 vtable은 해당 클래스에서 정의한 가상 함수에 대한 포인터를 가지고 있습니다. 

 

이런 구조를 통해, 프로그램은 런타임에 vtable을 조회하여 적절한 메서드를 호출할 수 있습니다. 이렇게 해서 런타임 다형성이 가능해집니다. 그러나 이렇게 런타임 다형성을 지원하는 구조가 메모리 관리에 어떤 영향을 미치는지 살펴보겠습니다.

 

[예제]

class Base {
public:
    virtual void func() { cout << "Base function\n"; }
};

class Derived : public Base {
public:
    void func() override { cout << "Derived function\n"; }
};

Base* obj = new Derived;
delete obj; // 어떤 소멸자가 호출될까요?

 

위의 코드에서 마지막 줄에서 delete obj;를 실행하면 어떤 클래스의 소멸자가 호출될까요? Base 클래스의 소멸자일까요, 아니면 Derived 클래스의 소멸자일까요?

 

사실 이것은 C++에서 소멸자가 가상 함수인지 아닌지에 따라 다릅니다. 만약 Base 클래스의 소멸자가 가상 함수가 아니라면, Base 클래스의 소멸자만 호출됩니다. 이는 메모리 누수를 발생시킬 수 있습니다. 왜냐하면 Derived 클래스에 추가로 할당된 메모리는 정리되지 않기 때문입니다.

 

이 문제를 해결하기 위해, C++에서는 베이스 클래스의 소멸자를 가상 함수로 선언하는 것을 권장합니다. 이렇게 하면 파생 클래스의 소멸자가 올바르게 호출되고, 메모리 누수를 방지할 수 있습니다.

 

[예제]

class Base {
public:
    virtual void func() { cout << "Base function\n"; }
    virtual ~Base() { cout << "Base destructor\n"; } // 베이스 클래스의 소멸자를 가상 함수로 선언
};

class Derived : public Base {
public:
    void func() override { cout << "Derived function\n"; }
    ~Derived() override { cout << "Derived destructor\n"; } // 파생 클래스의 소멸자
};

Base* obj = new Derived;
delete obj; // 이제 파생 클래스의 소멸자가 올바르게 호출됩니다.

 

결과적으로, 가상 함수는 메모리 관리와 깊은 관련이 있으며, C++ 프로그래밍에서 메모리 누수를 방지하는 데 중요한 역할을 합니다. 가상 함수와 가상 소멸자를 이해하고 올바르게 사용하는 것이 좋은 C++ 코드를 작성하는 데 매우 중요합니다.

 

7.2.3. 가상 함수를 이용한 코드 재사용성

가상 함수는 코드 재사용성을 크게 향상시키는 방법 중 하나입니다. 객체 지향 프로그래밍에서는 종종 다른 클래스들이 공통의 특성을 공유하는 경우가 있습니다. 이러한 경우에는 부모 클래스에서 공통 기능을 정의하고, 자식 클래스에서 이를 상속받아 사용하거나 필요에 따라 재정의하는 것이 일반적입니다. 

 

가상 함수는 부모 클래스에서 선언되고, 필요에 따라 자식 클래스에서 재정의 될 수 있는 함수입니다. 이 기능을 이용하면, 부모 클래스의 함수를 재사용하면서 필요한 부분만 자식 클래스에서 수정하여 코드의 재사용성을 높일 수 있습니다.

 

가상 함수를 통한 코드 재사용성을 더 잘 이해하기 위해, 간단한 예제를 살펴보겠습니다.

 

[예제]

#include <iostream>
using namespace std;

// 부모 클래스
class Animal {
public:
    // 가상 함수 선언
    virtual void sound() {
        cout << "This is a sound of an animal.\n";
    }
};

// 자식 클래스
class Dog : public Animal {
public:
    // 가상 함수 재정의
    void sound() override {
        cout << "Bark! Bark!\n";
    }
};

class Cat : public Animal {
public:
    // 가상 함수 재정의
    void sound() override {
        cout << "Meow~ Meow~\n";
    }
};

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

    animal->sound();  // 출력: This is a sound of an animal.
    dog->sound();     // 출력: Bark! Bark!
    cat->sound();     // 출력: Meow~ Meow~

    delete animal;
    delete dog;
    delete cat;

    return 0;
}

 

이 예제에서는 Animal 클래스의 sound() 함수를 Dog 클래스와 Cat 클래스에서 재정의하여 사용하였습니다. 이렇게 하면, 동일한 함수 이름을 이용해 다양한 기능을 수행할 수 있으며, 이는 코드의 재사용성을 향상합니다.

 

특히, 가상 함수를 사용하면 함수 호출이 실행 시점에 결정되므로, 다형성을 구현할 수 있습니다. 이는 여러 종류의 객체를 동일한 인터페이스로 처리할 수 있게 해주므로, 코드의 복잡성을 줄이고 가독성을 높여주는데 큰 장점을 제공합니다.

 

따라서 가상 함수는 객체 지향 프로그래밍에서 코드 재사용성을 향상시키는 중요한 도구입니다. 이를 이해하고 적절히 활용하면, 효율적이고 관리하기 쉬운 코드를 작성할 수 있습니다.

 

7.2.4. 가상 함수를 활용한 프로그래밍 예제

가상 함수를 활용한 프로그래밍에 대한 예제를 통해 실제로 어떻게 작동하는지 알아보겠습니다.

 

예제는 도형 클래스를 선언하고 각 도형의 넓이를 구하는 것입니다. 이를 통해 다형성과 가상 함수의 활용 방법을 이해하게 될 것입니다.

 

[예제]

#include <iostream>
using namespace std;

// 추상 클래스 선언
class Shape {
public:
    // 가상 함수 선언 (0으로 초기화하여 순수 가상 함수로 만듦)
    virtual float area() = 0; 
};

// 원 클래스 선언
class Circle : public Shape {
private:
    float radius;
public:
    Circle(float r) : radius(r) { }
    float area() {
        return 3.14 * radius * radius;
    }
};

// 사각형 클래스 선언
class Rectangle : public Shape {
private:
    float width, height;
public:
    Rectangle(float w, float h) : width(w), height(h) { }
    float area() {
        return width * height;
    }
};

int main() {
    Circle circle(10.0); // 반지름 10인 원 생성
    Rectangle rectangle(10.0, 20.0); // 가로 10, 세로 20인 사각형 생성

    cout << "Circle area: " << circle.area() << endl; // 출력: Circle area: 314
    cout << "Rectangle area: " << rectangle.area() << endl; // 출력: Rectangle area: 200

    return 0;
}

 

이 코드에서는 Shape이라는 추상 클래스를 선언하고, 이를 상속받는 Circle과 Rectangle 두 개의 클래스를 선언했습니다. Shape 클래스는 순수 가상 함수인 area()를 가지고 있습니다. 이를 Circle과 Rectangle 클래스에서 구체화(재정의)하여 각 도형의 넓이를 계산하는 함수를 작성했습니다.

 

가상 함수를 이용하면 다형성을 활용할 수 있습니다. 예를 들어, Shape 포인터가 다양한 자식 클래스 객체를 가리키게 하여 각 객체의 area() 함수를 호출할 수 있습니다. 이는 코드의 유연성을 높여줍니다.

 

또한, 이 예제에서 볼 수 있듯, 가상 함수는 자식 클래스에서 함수를 재정의해야 하는 경우에 유용합니다. 이를 통해 동일한 인터페이스(여기서는 area() 함수)를 유지하면서 다양한 동작을 구현할 수 있습니다. 이런 방식은 코드의 재사용성과 유지 보수성을 향상하는 데 큰 도움이 됩니다.


7.3. 추상 클래스와 순수 가상 함수

'추상 클래스와 순수 가상 함수'는 객체 지향 프로그래밍의 핵심 원칙 중 하나입니다. 추상 클래스는 직접 인스턴스화할 수 없는 클래스로, 하나 이상의 순수 가상 함수를 가집니다. 순수 가상 함수는 몸체가 없는 함수로, 이를 포함하는 클래스를 상속받는 모든 클래스가 이 함수를 반드시 구현해야 합니다. 이를 통해 공통 인터페이스를 제공하면서 다양한 동작을 구현할 수 있습니다. 


7.3.1. 추상 클래스란 무엇인가

추상 클래스는 직접적인 인스턴스화가 불가능한 클래스입니다. 다시 말해, 우리는 추상 클래스 자체의 객체를 만들 수 없습니다. 그렇다면 이 추상 클래스는 어떻게 사용할까요? 이는 하나 이상의 순수 가상 함수(pure virtual function)를 포함하고 있기 때문입니다. 

 

[예제]

class AbstractClass {
public:
    virtual void PureVirtualFunction() = 0;
};

 

위의 코드에서 볼 수 있듯이, 순수 가상 함수는 함수 몸체가 없고 = 0으로 선언된 함수입니다. 이 함수를 포함하는 클래스를 추상 클래스라고 합니다.

 

이러한 추상 클래스는 상속을 통해 사용되며, 이 클래스를 상속받는 자식 클래스는 이 순수 가상 함수를 반드시 구현해야 합니다. 이렇게 되면, 추상 클래스는 공통적인 인터페이스를 제공하는 틀이 되며, 이를 상속받는 클래스는 이 인터페이스에 따라서 고유한 기능을 구현하게 됩니다.

 

[예제]

class ConcreteClass : public AbstractClass {
public:
    void PureVirtualFunction() override {
        // specific implementation
    }
};

 

이런 방식으로, 추상 클래스와 순수 가상 함수는 코드의 재사용성을 높이고, 유연하고 확장 가능한 프로그램 구조를 만들 수 있도록 도와줍니다. 같은 기능을 하는 함수를 각각의 클래스에서 다르게 구현할 수 있어 다형성(polymorphism)을 구현하는 데에도 매우 중요한 역할을 합니다.

 

7.3.2. 추상 클래스의 생성과 특성

추상 클래스를 만드는 것은 간단합니다. 다음은 간단한 추상 클래스를 만드는 방법입니다.

 

[예제]

class AbstractClass { 
public: 
    virtual void PureVirtualFunction() = 0; 
};

 

여기서 = 0을 사용해서 함수가 순수 가상 함수임을 명시했습니다. 그렇기 때문에 이 클래스는 추상 클래스가 됩니다.

 

추상 클래스의 주요 특징은 다음과 같습니다.

 

추상 클래스는 인스턴스화할 수 없습니다. 즉, 추상 클래스로부터 객체를 직접 생성할 수 없습니다. 이는 추상 클래스가 불완전한 구조로서 순수 가상 함수를 포함하고 있기 때문입니다.

 

추상 클래스는 기반 클래스로 사용될 수 있습니다. 즉, 다른 클래스가 추상 클래스를 상속받을 수 있습니다.

 

[예제]

class ConcreteClass : public AbstractClass { 
public: 
    void PureVirtualFunction() override {
        // specific implementation
    }
};

 

추상 클래스를 상속받은 자식 클래스는 모든 순수 가상 함수를 반드시 구현해야 합니다. 만약 모든 순수 가상 함수를 구현하지 않은 클래스가 있다면, 그 클래스 또한 추상 클래스가 됩니다.

 

[예제]

class AnotherAbstractClass : public AbstractClass { 
    // This class is also abstract because
    // it doesn't provide an implementation
    // for PureVirtualFunction()
};

 

추상 클래스의 포인터나 참조를 통해 상속받은 클래스의 인스턴스를 관리할 수 있습니다. 이는 다형성을 가능하게 하는 중요한 특징입니다.

 

[예제]

ConcreteClass obj;
AbstractClass* ptr = &obj;
ptr->PureVirtualFunction();  // Calls ConcreteClass's implementation

 

결론적으로, 추상 클래스와 순수 가상 함수는 객체지향 프로그래밍에서 중요한 요소입니다. 이를 활용하면 코드의 유연성과 확장성을 높일 수 있으며, 재사용성을 높이고 코드의 유지 관리를 용이하게 합니다.

 

7.3.3. 순수 가상 함수의 정의와 사용 방법

순수 가상 함수는 추상 클래스를 만드는 핵심적인 부분입니다. 순수 가상 함수는 함수의 프로토타입과 함께 = 0이라는 표현식이 붙어있는 가상 함수를 말합니다.

 

[예제]

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

 

이렇게 정의된 순수 가상 함수는 바로 구현되지 않습니다. 대신 이를 상속받은 자식 클래스에서 구현해야 합니다.

 

[예제]

class DerivedClass : public AbstractClass {
public:
    void PureVirtualFunction() override {
        // 구현 내용
    }
};

 

순수 가상 함수의 목적은 인터페이스를 제공하는 것입니다. 추상 클래스를 상속받은 클래스가 특정 기능을 반드시 구현하도록 강제하는 것이죠. 이를 통해 프로그래머는 일관된 인터페이스를 가진 클래스를 설계할 수 있습니다.

 

순수 가상 함수는 다음과 같이 사용됩니다:

 

[예제]

DerivedClass d;
d.PureVirtualFunction(); // DerivedClass에서 구현된 메서드가 호출됩니다.

AbstractClass* ptr = &d;
ptr->PureVirtualFunction(); // 역시 DerivedClass에서 구현된 메서드가 호출됩니다.

 

이처럼 추상 클래스의 포인터로 자식 클래스의 인스턴스를 가리킬 수 있고, 이 때 가상 함수는 자식 클래스에서 구현된 버전이 호출됩니다. 이는 다형성의 한 예입니다.

 

그러나 주의할 점은 추상 클래스는 인스턴스를 만들 수 없다는 것입니다. 따라서 다음과 같은 코드는 오류를 발생시킵니다:

 

[예제]

AbstractClass a; // 오류: 추상 클래스의 인스턴스를 만들 수 없습니다.

 

따라서 추상 클래스와 순수 가상 함수는 주로 다른 클래스가 상속받아 사용하도록 설계된 클래스에서 사용됩니다. 이는 코드의 재사용성을 높이고 유지 관리를 용이하게 합니다.

 

7.3.4. 추상 클래스의 활용 및 제한 사항

추상 클래스는 클래스 계층구조에서 기본적인 틀을 제공하며, 자식 클래스에서 공통적인 부분을 재사용하면서도 각 클래스에 맞게 기능을 추가할 수 있도록 도와줍니다.

 

[예제]

class AbstractClass {
public:
    void commonMethod() { // 공통적인 기능
        // 코드...
    }
    virtual void uniqueMethod() = 0; // 각 클래스마다 다른 기능
};

 

이처럼 추상 클래스를 활용하면 코드의 중복을 줄이고 유지 보수를 용이하게 만들 수 있습니다. 이런 성질은 특히 같은 종류의 객체가 다양한 행동을 하는 상황, 즉 다형성이 필요한 상황에서 유용합니다. 예를 들어 게임에서 여러 종류의 적이 같은 기본 행동을 가지면서도 각각 다른 공격 방식을 가질 때 추상 클래스를 활용할 수 있습니다.

 

그러나 추상 클래스의 사용에는 제한사항이 있습니다. 가장 중요한 것은, 추상 클래스는 자체적으로 인스턴스를 만들 수 없다는 점입니다. 즉, 다음과 같은 코드는 오류를 발생시킵니다.

 

[예제]

AbstractClass a; // 오류! 추상 클래스는 인스턴스를 만들 수 없습니다.

 

대신 추상 클래스는 자식 클래스를 통해 인스턴스를 만들 수 있습니다.

 

[예제]

class ConcreteClass : public AbstractClass {
public:
    void uniqueMethod() override {
        // 코드...
    }
};

ConcreteClass c; // 추상 클래스를 상속받은 클래스의 인스턴스를 만들 수 있습니다.

 

이처럼 추상 클래스는 다른 클래스가 상속받아 사용하도록 설계된 클래스입니다. 이를 통해 클래스 간의 관계를 명확히 하고, 코드의 재사용성을 높일 수 있습니다.

 

7.3.5. 추상 클래스의 사용 예제

추상 클래스를 활용하는 간단한 예제를 통해 이 개념을 이해해봅시다. 우리는 동물원을 관리하는 간단한 시스템을 구현해 보겠습니다.

 

[예제]

// Animal 추상 클래스 정의
class Animal {
public:
    virtual void makeSound() = 0; // 순수 가상 함수
};

// Cat 클래스 정의
class Cat : public Animal {
public:
    void makeSound() override { // 순수 가상 함수를 오버라이드
        cout << "Meow~" << endl;
    }
};

// Dog 클래스 정의
class Dog : public Animal {
public:
    void makeSound() override { // 순수 가상 함수를 오버라이드
        cout << "Woof!" << endl;
    }
};

 

위의 코드에서 Animal은 추상 클래스이며, 이를 상속받는 Cat과 Dog는 구체 클래스입니다. Animal 클래스는 순수 가상 함수인 makeSound()를 가지며, Cat과 Dog는 이 함수를 오버라이드하여 각각의 특성에 맞는 소리를 출력하도록 구현되었습니다.

 

이제 이 클래스들을 사용하는 방법을 살펴봅시다.

 

[예제]

int main() {
    Animal* animals[2];
    animals[0] = new Cat();
    animals[1] = new Dog();

    for (int i = 0; i < 2; ++i) {
        animals[i]->makeSound();
    }

    delete animals[0];
    delete animals[1];

    return 0;
}

 

Cat과 Dog의 인스턴스를 생성하여 Animal 포인터 배열에 저장합니다. 이후 배열을 순회하며 각 동물의 makeSound() 함수를 호출합니다. 출력 결과는 다음과 같습니다.

 

Meow~
Woof!

 

각 클래스는 Animal의 makeSound() 함수를 오버라이드하여 자신만의 구현을 제공합니다. 따라서 Animal 포인터를 통해 해당 함수를 호출하면, 실제 객체의 타입에 따라 적절한 함수가 실행됩니다. 이처럼 추상 클래스와 가상 함수를 활용하면 같은 인터페이스를 가지면서도 다른 동작을 하는 객체를 손쉽게 관리할 수 있습니다. 이를 통해 코드의 유연성과 재사용성을 크게 향상할 수 있습니다. 


7.4. 인터페이스 개념과 추상 클래스

'인터페이스'는 프로그래밍에서 객체나 컴포넌트 간의 통신 방식을 정의하는 것입니다. C++에서는 '추상 클래스'를 통해 인터페이스를 구현합니다. 추상 클래스는 하나 이상의 순수 가상 함수를 포함하는 클래스로, 이를 상속받는 모든 클래스는 이 순수 가상 함수를 반드시 구현해야 합니다. 이렇게 하면 서로 다른 객체들이 동일한 인터페이스를 공유할 수 있으므로, 코드의 유연성과 재사용성이 향상됩니다. 인터페이스는 구현과 분리되어 있으므로, 사용자는 인터페이스에 명시된 함수의 동작만 알면 됩니다. 

7.4.1. 인터페이스란 무엇인가

프로그래밍에서 '인터페이스'는 매우 중요한 개념입니다. 인터페이스는 컴포넌트, 모듈 또는 객체 간의 상호 작용을 정의하는 계약입니다. 사실, 인터페이스는 어떤 일을 할 것인지를 정의하고, 어떻게 수행할 것인지는 구현하는 객체에게 맡깁니다.

 

예를 들어, 자동차의 스티어링 휠은 인터페이스의 좋은 예입니다. 운전자는 스티어링 휠을 어떻게 돌려야 할지 알고 있지만, 실제로 어떻게 동작하는지는 알 필요가 없습니다. 그것은 자동차가 알아서 처리합니다.

 

C++에서는 '추상 클래스'를 사용하여 인터페이스를 정의합니다. 이것은 하나 이상의 순수 가상 함수를 포함하는 클래스를 말합니다. 순수 가상 함수는 선언만 있고 구현이 없는 함수입니다. 따라서 이를 상속받는 클래스는 이 순수 가상 함수를 반드시 구현해야 합니다.

 

이러한 방식은 여러 가지 장점이 있습니다. 첫째, 인터페이스를 통해 객체 간의 상호작용을 표준화할 수 있습니다. 둘째, 인터페이스는 코드의 유연성을 높여줍니다. 이는 다른 구현이 필요할 때 인터페이스를 상속받아 새로운 클래스를 만들 수 있기 때문입니다.

 

아래의 C++ 코드는 '동물' 인터페이스를 정의하는 간단한 예제입니다.

 

[예제]

// 동물 클래스를 정의합니다.
class Animal {
public:
    // 순수 가상 함수를 선언합니다.
    virtual void makeSound() = 0;  // 순수 가상 함수
};

// '개' 클래스를 정의하고 '동물' 클래스를 상속받습니다.
class Dog : public Animal {
public:
    // '동물'의 순수 가상 함수를 구현합니다.
    void makeSound() override {
        std::cout << "Woof!\n";
    }
};

 

이 예제에서 'Animal' 클래스는 인터페이스 역할을 하며, 'Dog' 클래스는 이를 구현합니다. 다른 동물 클래스도 이 'Animal' 인터페이스를 구현할 수 있습니다. 이런 방식으로, 여러 동물이 'makeSound'라는 동일한 인터페이스를 공유하게 되는 것입니다. 이는 객체 지향 프로그래밍의 핵심 원칙 중 하나인 '다형성'을 실현하는 방법입니다.

 

참고로, C 언어는 클래스와 가상 함수라는 개념이 없으므로 인터페이스를 구현하는 방식이 C++과는 다릅니다. C에서는 일반적으로 함수 포인터와 구조체를 사용하여 비슷한 효과를 얻습니다.

 

C에서 인터페이스를 구현하는 간단한 예제는 다음과 같습니다.

 

[예제]

// 동물 구조체를 정의합니다.
typedef struct {
    void (*makeSound)();
} Animal;

// 개의 소리를 출력하는 함수를 정의합니다.
void dogSound() {
    printf("Woof!\n");
}

int main() {
    // 동물 구조체를 생성하고 함수 포인터를 할당합니다.
    Animal dog;
    dog.makeSound = dogSound;
    
    // 함수 포인터를 사용하여 개의 소리를 출력합니다.
    dog.makeSound();
    
    return 0;
}

이 C 코드 예제에서는 Animal이라는 구조체를 정의하고 이 안에 함수 포인터 makeSound를 선언했습니다. 그런 다음 dogSound 함수를 정의하고 dog라는 Animal 구조체의 makeSound 함수 포인터에 연결했습니다. 이로써 C에서도 '인터페이스'와 같은 역할을 수행하는 구조를 만들어낼 수 있습니다. 

 

다시 C++로 돌아가서, 다음으로 우리는 인터페이스를 사용하면 어떤 이점이 있는지 알아보겠습니다. 다형성의 힘을 이용하면 우리는 코드를 더욱 유연하고 확장 가능하게 만들 수 있습니다.

 

예를 들어, 우리는 'Animal' 인터페이스를 사용하여 다양한 동물을 모두 처리할 수 있는 함수를 만들 수 있습니다. 이 함수는 'Animal' 인터페이스를 따르는 모든 객체에 대해 동일하게 작동합니다.

 

[예제]

// 'Animal' 인터페이스를 따르는 모든 객체에 대해 동작하는 함수를 정의합니다.
void playSound(Animal* animal) {
    animal->makeSound();  // 동물의 소리를 출력합니다.
}

int main() {
    Dog dog;  // '개' 객체를 생성합니다.

    playSound(&dog);  // '개'의 소리를 출력합니다.

    return 0;
}

 

이 함수는 'Animal' 인터페이스를 따르는 모든 객체에 대해 동일하게 작동하므로, 여기에 'Dog' 뿐만 아니라 'Cat', 'Bird' 등 다른 동물 클래스의 객체도 넘길 수 있습니다.

 

이것이 바로 '인터페이스'와 '추상 클래스'가 C++ 프로그래밍에서 중요한 이유입니다. 이를 활용하면 코드의 재사용성과 확장성을 크게 높일 수 있습니다.

 

이렇게 각 섹션별로 C/C++의 중요한 개념을 살펴보았습니다. 이를 통해 가상 함수, 추상 클래스, 그리고 인터페이스에 대한 이해를 높이고 이를 활용한 프로그래밍 스킬을 향상시킬 수 있을 것입니다. 그리고 이 모든 것이 객체 지향 프로그래밍의 핵심 요소인 다형성을 이해하는 데 큰 도움이 될 것입니다.

 

7.4.2. 인터페이스와 추상 클래스의 차이

인터페이스와 추상 클래스는 모두 객체 지향 프로그래밍에서 사용되는 중요한 개념이지만, 그들 간에는 몇 가지 중요한 차이점이 있습니다. 이 차이점을 이해하면 프로그래밍 설계와 구현에서 더 나은 결정을 내릴 수 있게 됩니다. 

 

첫 번째로, 추상 클래스는 일반 메서드(구현이 있는 메서드)와 추상 메서드(구현이 없고, 하위 클래스에서 반드시 구현해야 하는 메서드)를 모두 포함할 수 있습니다. 반면 인터페이스는 구현이 없는 메서드만 포함합니다. C++에서는 순수 가상 함수를 사용하여 인터페이스를 흉내 낼 수 있습니다.

 

[예제]

// 추상 클래스
class AbstractClass {
public:
    virtual void abstractMethod() = 0;  // 추상 메서드
    
    void regularMethod() {  // 일반 메서드
        // ... 구현 코드
    }
};

// 인터페이스(추상 클래스를 사용)
class Interface {
public:
    virtual void method1() = 0;  // 순수 가상 함수
    virtual void method2() = 0;  // 순수 가상 함수
};

 

두 번째로, 클래스는 한 개의 추상 클래스만 상속받을 수 있지만, 여러 개의 인터페이스를 구현할 수 있습니다. 이는 C++에서는 다중 상속을 허용하지만, 이는 프로그램의 복잡성을 높일 수 있으므로 신중하게 사용해야 합니다.

 

[예제]

// 추상 클래스
class AbstractClass {
public:
    virtual void abstractMethod() = 0;
};

// 인터페이스
class Interface1 {
public:
    virtual void method1() = 0;
};

class Interface2 {
public:
    virtual void method2() = 0;
};

// 'ConcreteClass'는 'AbstractClass'를 상속하고, 'Interface1'과 'Interface2'를 모두 구현합니다.
class ConcreteClass : public AbstractClass, public Interface1, public Interface2 {
public:
    void abstractMethod() override {
        // ... 구현 코드
    }

    void method1() override {
        // ... 구현 코드
    }

    void method2() override {
        // ... 구현 코드
    }
};

 

세 번째로, 인터페이스와 추상 클래스는 설계 의도가 다릅니다. 추상 클래스는 'is-a' 관계를 정의합니다. 예를 들어 'Dog' 클래스가 'Animal' 추상 클래스를 상속받는다면, 'Dog'은 'Animal'입니다. 반면 인터페이스는 'can-do' 관계를 정의합니다. 예를 들어 'Printer' 클래스가 'Printable' 인터페이스를 구현한다면, 'Printer'는 출력할 수 있습니다.

 

이러한 차이점을 이해하면, 클래스 계층구조와 인터페이스를 설계하고 구현하는 데 도움이 될 것입니다. 다음 섹션에서는 이러한 개념을 실제 코드에 적용하는 방법을 배우겠습니다.

 

7.4.3. 추상 클래스를 이용한 인터페이스 구현

C++에서는 '인터페이스'라는 특별한 키워드나 문법이 없습니다. 그러나 '추상 클래스'를 이용해서 인터페이스를 구현할 수 있습니다. 이는 클래스가 여러 인터페이스를 동시에 구현할 수 있도록 하며, 이를 통해 다양한 기능을 모듈화하고 재사용할 수 있습니다. 

 

추상 클래스를 이용한 인터페이스 구현은 모든 메서드가 순수 가상 함수인 추상 클래스를 만드는 것으로 이루어집니다. 순수 가상 함수는 구현이 없고, 반드시 하위 클래스에서 구현해야 하는 메서드입니다.

 

[예제]

// 'Printable' 인터페이스를 정의합니다.
class Printable {
public:
    virtual void print() = 0;  // 순수 가상 함수
};

 

'Printable' 인터페이스는 'print'라는 순수 가상 함수를 정의합니다. 이 인터페이스를 구현하려면 클래스에서 'print' 함수를 반드시 구현해야 합니다.

 

[예제]

// 'Printable' 인터페이스를 구현하는 'Document' 클래스를 정의합니다.
class Document : public Printable {
public:
    void print() override {
        // ... 'print' 함수를 구현합니다.
    }
};

 

'Document' 클래스는 'Printable' 인터페이스를 상속받아 'print' 함수를 구현합니다. 이제 'Document' 객체는 'print' 함수를 호출할 수 있습니다.

 

[예제]

Document doc;
doc.print();  // 'print' 함수를 호출합니다.

 

이처럼 추상 클래스를 이용하면 인터페이스를 효과적으로 구현할 수 있습니다. 각 인터페이스는 특정한 기능을 정의하므로, 클래스는 필요한 기능을 선택하여 구현할 수 있습니다. 이는 코드의 모듈성과 재사용성을 높이며, 다양한 상황에 대응할 수 있게 합니다.

 

7.4.4. 인터페이스를 통한 코드 재사용성 증진

인터페이스는 클래스에 특정한 행동을 강제하는 역할을 합니다. 이를 통해 다양한 클래스가 동일한 방식으로 동작하도록 할 수 있고, 이로 인해 코드의 재사용성이 향상됩니다. 

 

인터페이스를 구현하는 클래스는 인터페이스가 정의한 모든 함수를 구현해야 합니다. 따라서 해당 인터페이스를 구현하는 모든 클래스는 동일한 함수를 가지고 있으므로, 이들 클래스의 객체는 동일한 방식으로 사용할 수 있습니다. 이는 다형성의 원리를 따르는 것으로, 이를 통해 유연하고 재사용 가능한 코드를 작성할 수 있습니다.

 

예를 들어, 이전에 정의했던 Printable 인터페이스를 기억하시나요? 이 인터페이스를 구현하는 여러 클래스가 있다고 상상해봅시다.

 

[예제]

class Document : public Printable {
public:
    void print() override {
        // Document에 대한 print() 구현
    }
};

class Picture : public Printable {
public:
    void print() override {
        // Picture에 대한 print() 구현
    }
};

 

여기서 'Document'와 'Picture' 클래스는 모두 'Printable' 인터페이스를 구현하므로, 이들 클래스의 객체는 동일한 방식으로 사용할 수 있습니다.

 

[예제]

void printDocuments(const std::vector<Printable*>& docs) {
    for (Printable* doc : docs) {
        doc->print();
    }
}

 

이 예제에서 printDocuments 함수는 Printable 인터페이스를 구현하는 객체의 배열을 받아, 배열의 모든 객체에 대해 print 함수를 호출합니다. 이 함수는 Document 객체와 Picture 객체 모두에서 동작하며, 이를 통해 코드의 재사용성이 향상됩니다.

 

따라서, 인터페이스는 공통된 행동을 강제하며 다양한 타입에 대한 일관성을 제공합니다. 이를 통해 소프트웨어의 유연성과 재사용성이 향상되며, 유지 보수성과 확장성 또한 증진됩니다. 다음 섹션에서는 이러한 이점을 더욱 활용할 수 있는 방법에 대해 자세히 알아보겠습니다.

 

7.4.5. 인터페이스 활용 예제

인터페이스는 우리가 작성하는 코드의 유연성을 크게 증가시켜주며, 코드의 재사용성도 높이게 됩니다. 여러분이 어떤 프로그램을 작성하더라도, 인터페이스는 그 프로그램의 코드 구조를 개선하는 데 큰 도움이 됩니다.

 

예를 들어, 공통적인 동작을 하는 다양한 클래스들을 처리하는데 인터페이스를 활용할 수 있습니다. 여기서는 간단한 도형을 그리는 예제를 살펴보겠습니다. 먼저 인터페이스를 선언해 봅시다.

 

[예제]

class Shape {
public:
    virtual void draw() = 0;  // 순수 가상 함수로 'draw' 함수를 선언
};

 

그리고 나서, Shape 인터페이스를 구현하는 Circle, Square, Triangle 클래스를 만들어 봅시다.

[예제]

class Circle : public Shape {
public:
    void draw() override {
        // 원을 그리는 코드
        cout << "Drawing a circle..." << endl;
    }
};

class Square : public Shape {
public:
    void draw() override {
        // 사각형을 그리는 코드
        cout << "Drawing a square..." << endl;
    }
};

class Triangle : public Shape {
public:
    void draw() override {
        // 삼각형을 그리는 코드
        cout << "Drawing a triangle..." << endl;
    }
};

 

이제 우리는 Shape 인터페이스를 구현하는 어떤 클래스의 객체든 동일한 방식으로 다룰 수 있습니다. 예를 들어, 아래의 코드는 Shape 인터페이스를 구현하는 객체들의 목록을 그릴 수 있습니다.

 

[예제]

void drawShapes(const std::vector<Shape*>& shapes) {
    for (Shape* shape : shapes) {
        shape->draw();
    }
}

int main() {
    Circle circle;
    Square square;
    Triangle triangle;

    std::vector<Shape*> shapes = {&circle, &square, &triangle};
    drawShapes(shapes);

    return 0;
}

 

이 예제에서는 Circle, Square, Triangle 클래스가 모두 Shape 인터페이스를 구현하므로, 각각의 객체를 Shape 포인터로 참조하여 동일한 방식으로 다룰 수 있습니다. 이러한 유연성은 인터페이스를 활용한 강력한 장점 중 하나입니다.


7.5. 다중 상속과 가상 상속

C++에서 다중 상속은 한 클래스가 두 개 이상의 부모 클래스를 갖는 것을 의미합니다. 이는 여러 부모 클래스의 멤버를 한 번에 상속받을 수 있다는 장점이 있지만, 이름 충돌이나 다이아몬드 문제와 같은 복잡한 문제를 일으킬 수 있습니다. 가상 상속은 이러한 문제를 해결하기 위해 도입되었습니다. 가상 상속을 사용하면, 중복된 부모 클래스의 멤버가 하나의 인스턴스만을 갖게 되어 다중 상속으로 인한 문제를 최소화할 수 있습니다. 이런 특징은 C++에서만 제공되는 기능으로, C 언어에서는 지원되지 않습니다. 

7.5.1. 다중 상속의 정의와 문제점

다중 상속은 C++에서 한 클래스가 둘 이상의 클래스를 상속받는 것을 의미합니다. C++에서 다중 상속은 아래와 같은 방식으로 선언할 수 있습니다.

 

[예제]

class Parent1 {};
class Parent2 {};
class Child : public Parent1, public Parent2 {};

 

위의 코드에서, Child 클래스는 Parent1과 Parent2 두 클래스를 모두 상속받고 있습니다.

 

이는 클래스가 다양한 속성과 기능을 둘 이상의 부모 클래스로부터 상속받을 수 있다는 매우 강력한 특징을 제공합니다. 하지만 이로 인해 몇 가지 복잡한 문제가 발생할 수 있습니다.

 

문제점 1: 이름 충돌

이름 충돌은 두 개 이상의 부모 클래스에서 동일한 이름의 메서드나 속성을 갖고 있을 때 발생합니다. 예를 들어, 다음과 같은 상황을 생각해봅시다.

 

[예제]

class Parent1 {
public:
    void foo() {}
};

class Parent2 {
public:
    void foo() {}
};

class Child : public Parent1, public Parent2 {};

 

Child 클래스는 foo() 메서드를 호출할 경우, Parent1의 foo()를 호출해야 하는지, Parent2의 foo()를 호출해야 하는지 알 수 없게 됩니다. 이를 해결하기 위해서는 Child 클래스에서 foo() 메서드를 오버라이드하여 명확히 해야 합니다.

 

문제점 2: 다이아몬드 문제

다이아몬드 문제는 두 부모 클래스가 동일한 클래스를 상속받았을 때 발생합니다. 이 문제는 두 부모 클래스가 동일한 클래스의 메서드나 속성을 상속받아 Child 클래스에서 중복으로 상속받게 되는 문제를 일으킵니다.

 

다음 코드를 보겠습니다.

 

[예제]

class Grandparent {
public:
    void foo() {}
};

class Parent1 : public Grandparent {};
class Parent2 : public Grandparent {};

class Child : public Parent1, public Parent2 {};

 

여기서 Child 클래스는 Grandparent 클래스의 foo() 메서드를 두 번 상속받게 됩니다. 이런 문제를 해결하기 위해 C++에서는 가상 상속이라는 기능을 제공합니다.

 

이러한 복잡한 문제들로 인해, 다중 상속은 신중하게 사용해야 합니다.

 

7.5.2. 다중 상속의 활용

C++에서 다중 상속은 특정 클래스가 두 개 이상의 부모 클래스로부터 속성과 기능을 상속받는 것을 가능하게 합니다. 이를 통해 매우 복잡한 클래스 계층구조를 생성하고, 다양한 문제를 효과적으로 해결할 수 있습니다.

 

예를 들어, 그래픽 사용자 인터페이스(GUI)에서 라이브러리가 종종 다중 상속을 사용합니다. 특정 GUI 요소는 시각적 요소(예: Button, CheckBox)와 동시에 특정 동작을 정의하는 클래스(예: Clickable, Draggable)를 상속받을 수 있습니다. 이렇게 함으로써, 각 클래스는 그것이 나타내는 도메인의 개념을 더욱 잘 표현할 수 있습니다.

 

다음은 그런 예시입니다.

 

[예제]

class Button {};
class Clickable {};

class ClickableButton : public Button, public Clickable {};

 

이 예제에서 ClickableButton은 Button과 Clickable 두 클래스를 상속받습니다. Button 클래스는 버튼의 시각적 요소를, Clickable 클래스는 클릭 가능한 요소의 동작을 정의합니다. ClickableButton 클래스는 이 두 클래스의 특성을 모두 상속받아 클릭 가능한 버튼을 표현합니다. 

 

또 다른 예시로는, 동물의 행동을 표현하는 클래스를 생각해보겠습니다.

 

[예제]

class Flyable {
public:
    void fly() {
        cout << "I can fly!" << endl;
    }
};

class Walkable {
public:
    void walk() {
        cout << "I can walk!" << endl;
    }
};

class Bird : public Flyable, public Walkable {};

 

위 예제에서, Bird 클래스는 Flyable과 Walkable 두 클래스를 상속받아 날고 걷는 기능을 모두 가집니다. 이처럼 다중 상속을 이용하면 클래스의 속성과 기능을 더욱 명확하고 정확하게 모델링할 수 있습니다.

 

그러나 다중 상속은 복잡성을 증가시키고, 이름 충돌, 다이아몬드 문제 등의 문제를 일으킬 수 있으므로, 반드시 필요한 경우에만 신중하게 사용해야 합니다.

 

7.5.3. 다중 상속에서의 이름 충돌 문제

다중 상속은 클래스가 여러 부모 클래스로부터 기능을 상속받을 수 있는 강력한 도구이지만, 이로 인해 이름 충돌 문제가 발생할 수 있습니다. 이름 충돌이란 두 개 이상의 부모 클래스가 동일한 이름의 멤버(함수 또는 변수)를 가지고 있을 때, 자식 클래스가 이 멤버를 참조하려 할 때 어떤 부모 클래스의 멤버를 참조해야 할지 결정할 수 없는 상황을 말합니다. 

 

예를 들어, ClassA와 ClassB 모두 print() 함수를 가지고 있고, ClassC가 ClassA와 ClassB를 상속받았다면, ClassC의 객체가 print() 함수를 호출하려 할 때 어떤 print()를 호출해야 할지 애매해집니다.

 

[예제]

class ClassA {
public:
    void print() {
        cout << "ClassA's print" << endl;
    }
};

class ClassB {
public:
    void print() {
        cout << "ClassB's print" << endl;
    }
};

class ClassC : public ClassA, public ClassB {};

 

이런 경우 이름을 명확하게 지정해주는 것이 필요합니다. ClassC에서 ClassA의 print()를 호출하려면 ClassA::print()라고 명시해야 하고, ClassB의 print()를 호출하려면 ClassB::print()라고 명시해야 합니다.

 

[예제]

int main() {
    ClassC c;
    c.ClassA::print(); // prints "ClassA's print"
    c.ClassB::print(); // prints "ClassB's print"
    return 0;
}

 

이처럼 C++에서는 다중 상속으로 인한 이름 충돌 문제를 해결하기 위해 :: 연산자를 사용하여 특정 부모 클래스의 멤버를 명확하게 지정할 수 있습니다. 그러나 이런 문제를 최대한 피하기 위해, 상속받는 클래스들 간에 중복되는 이름의 멤버가 없도록 하는 것이 좋습니다. 다음 섹션에서는 다중 상속에서 발생할 수 있는 또 다른 문제인 다이아몬드 문제에 대해 알아보겠습니다.

 

7.5.4. 가상 상속을 통한 다이아몬드 상속 문제 해결

다중 상속에서 복잡한 문제 중 하나가 바로 '다이아몬드 상속' 문제입니다. 이 문제는 한 클래스가 두 개 이상의 클래스를 상속하고, 그 상속된 클래스들이 동일한 베이스 클래스를 공유할 때 발생합니다. 이러한 상황에서는 상속 구조가 다이아몬드 형태로 되기 때문에 '다이아몬드 상속'이라는 이름이 붙었습니다. 

 

[예제]

class GrandParent {
public:
    void print() {
        cout << "I'm grandparent." << endl;
    }
};

class ParentA : public GrandParent {};
class ParentB : public GrandParent {};
class Child : public ParentA, public ParentB {};

 

이런 Child 클래스는 GrandParent 클래스를 두 번 상속받게 됩니다. Child 객체가 print() 함수를 호출하면, ParentA를 통해 상속받은 print()와 ParentB를 통해 상속받은 print() 중 어떤 것을 호출해야 할지 모호해지는 문제가 생깁니다. 

 

이 문제를 해결하기 위해 C++에서는 '가상 상속(virtual inheritance)'이라는 개념을 도입하였습니다. 가상 상속은 다이아몬드 상속 구조에서 베이스 클래스의 멤버가 중복되는 것을 방지하며, 상속 구조에서 하나의 베이스 클래스만 존재하도록 보장합니다.

 

virtual 키워드를 사용하여 베이스 클래스를 상속받을 때 가상 상속을 사용한다는 것을 나타낼 수 있습니다.

 

[예제]

class ParentA : virtual public GrandParent {};
class ParentB : virtual public GrandParent {};

 

이제 Child 객체가 print() 함수를 호출하면, GrandParent 클래스를 한 번만 상속받았으므로 print() 호출이 모호하지 않습니다.

 

[예제]

int main() {
    Child c;
    c.print(); // prints "I'm grandparent."
    return 0;
}

 

가상 상속은 상속 구조를 단순화하고 코드의 가독성을 높여줍니다. 그러나, 가상 상속은 추가적인 메모리와 성능 오버헤드를 가질 수 있으므로 필요할 때만 사용해야 합니다. 이제 다중 상속과 가상 상속에 대해 알았으니, 이러한 개념들을 활용하여 보다 강력하고 유연한 코드를 작성할 수 있습니다.

 

7.5.5. 가상 상속의 활용 및 주의 사항

가상 상속은 다이아몬드 상속 문제를 해결하는 강력한 도구이지만, 그 사용은 항상 주의가 필요합니다. 잘못 사용될 경우, 예상치 못한 결과를 초래하거나 코드의 복잡성을 높일 수 있습니다. 그래서 가상 상속을 활용하면서 반드시 고려해야 하는 몇 가지 사항들을 살펴보겠습니다. 

 

  • 성능 오버헤드 : 가상 상속은 상속 구조를 단순화하지만, 추가적인 메모리 사용과 성능 저하를 야기할 수 있습니다. 가상 상속은 각각의 객체에 대해 추가적인 가상 포인터를 필요로 하며, 이는 메모리 사용량을 증가시키며, 상속 관계를 파악하는데 추가적인 시간이 필요하게 됩니다.
  • 생성자 호출 순서 : 가상 상속에서는 베이스 클래스의 생성자가 파생 클래스의 생성자보다 먼저 호출됩니다. 이는 비-가상 상속과는 다르며, 이해하고 있어야 합니다. 가상 상속을 사용할 때는 생성자에서 초기화 순서를 주의 깊게 고려해야 합니다.
  • 가상 상속의 한계 : 가상 상속은 다이아몬드 상속 문제를 해결할 수 있지만, 모든 상황에 대한 완벽한 해결책은 아닙니다. 예를 들어, 다중 상속을 통해 여러 인터페이스를 구현하는 경우, 서로 다른 인터페이스에서 동일한 이름의 메서드가 존재할 수 있습니다. 이런 경우에는 가상 상속만으로는 이름 충돌 문제를 해결할 수 없습니다.

예제를 통해 가상 상속을 활용하는 방법을 살펴봅시다.

 

[예제]

class GrandParent {
public:
    virtual void print() {
        cout << "I'm grandparent." << endl;
    }
};

class ParentA : virtual public GrandParent {
public:
    void print() override {
        cout << "I'm parent A." << endl;
    }
};

class ParentB : virtual public GrandParent {
public:
    void print() override {
        cout << "I'm parent B." << endl;
    }
};

class Child : public ParentA, public ParentB {
public:
    void print() override {
        cout << "I'm child." << endl;
    }
};

 

이 경우 Child 클래스는 print() 메소드를 오버라이드 하여 자신만의 구현을 제공합니다. 이러한 방식으로 다중 상속과 가상 상속을 활용하여 클래스의 동작을 보다 유연하게 조정할 수 있습니다. 다만, 코드의 복잡성을 증가시킬 수 있으므로 주의 깊게 활용해야 합니다.

 

C++에서의 다중 상속과 가상 상속은 매우 복잡한 주제이며, 이를 완벽하게 이해하려면 실제로 코드를 작성하고 실험해 보는 것이 중요합니다.

 

 

 

2023.05.29 - [GD's IT Lectures : 기초부터 시리즈/C, C++ 기초부터 ~] - [C/C++ 프로그래밍 : 중급] 6. 다형성

 

[C/C++ 프로그래밍 : 중급] 6. 다형성

Chapter 6. 다형성 다형성(Polymorphism)은 동일한 인터페이스에서 다양한 동작을 할 수 있는 객체 지향 프로그래밍의 핵심 특성 중 하나입니다. 이 챕터에서는 C++에서의 다형성 개념을 이해하고 이를

gdngy.tistory.com

 

반응형

댓글