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

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

by GDNGY 2023. 5. 29.

Chapter 6. 다형성

다형성(Polymorphism)은 동일한 인터페이스에서 다양한 동작을 할 수 있는 객체 지향 프로그래밍의 핵심 특성 중 하나입니다. 이 챕터에서는 C++에서의 다형성 개념을 이해하고 이를 실제 코드에서 어떻게 활용하는지에 대해 살펴봅니다. 함수 오버로딩, 연산자 오버로딩부터 가상 함수와 순수 가상 함수를 통한 다형성 구현, 그리고 RTTI를 통한 런타임 다형성까지 상세히 다룹니다. 이를 통해 프로그래밍의 유연성과 코드 재사용성을 높일 수 있는 다형성의 필요성과 활용 방법을 배울 수 있습니다.

 

반응형

 


[Chapter 6. 다형성]

 

6.1. 다형성의 개념

6.1.1. 다형성이란 무엇인가

6.1.2. 다형성의 필요성

 

6.2. 함수 오버로딩

6.2.1. 함수 오버로딩의 정의와 사용법

6.2.2. 함수 오버로딩의 주의점

 

6.3. 연산자 오버로딩

6.3.1. 연산자 오버로딩의 정의와 사용법

6.3.2. 연산자 오버로딩의 주의점

 

6.4. 가상 함수와 순수 가상 함수

6.4.1. 가상 함수의 정의와 사용법

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

 

6.5. 다형성의 활용

6.5.1. 다형성을 활용한 프로그래밍 예제

6.5.2. 다형성의 장점과 한계

 

6.6. RTTI (Run-Time Type Identification)

6.6.1. RTTI의 정의와 필요성

6.6.2. RTTI의 사용법


6.1. 다형성의 개념

다형성(Polymorphism)은 객체 지향 프로그래밍에서 중요한 개념 중 하나입니다. 그리스어에서 유래한 단어로, '많은'을 의미하는 'poly'와 '형태'를 의미하는 'morph'의 조합입니다. 즉, 다양한 형태를 가질 수 있다는 의미를 내포하고 있습니다. 프로그래밍에서 다형성이란 같은 이름의 메서드나 함수가 다른 동작을 하는 것을 말합니다. 이를 통해 코드의 재사용성과 유지 보수성이 향상되며, 더 나아가 코드의 유연성을 높일 수 있습니다. 이번 섹션에서는 다형성의 개념에 대해 자세히 알아보고, 이것이 왜 필요한지에 대해 이야기해 보겠습니다.

6.1.1. 다형성이란 무엇인가

다형성(Polymorphism)이란 동일한 이름의 함수나 메소드가 서로 다른 방식으로 동작하는 객체 지향 프로그래밍의 특징입니다. 다형성은 '여러 가지 형태'라는 의미를 가지며, 프로그래밍에서는 같은 이름의 메서드가 서로 다른 객체에 따라 다르게 동작할 수 있다는 개념을 포함합니다.

 

다형성의 가장 큰 장점은 코드의 재사용성을 높이는 것입니다. 같은 이름의 함수가 다른 동작을 수행하면서도 그 기능을 일관되게 사용할 수 있게 해주기 때문입니다. 이는 코드의 간결성과 유지 관리성을 크게 향상하며, 더 나아가 객체 지향 프로그래밍의 핵심 원칙 중 하나인 '추상화'를 더욱 강화합니다.

 

C++에서의 다형성은 주로 가상 함수를 통해 구현됩니다. 가상 함수는 기본 클래스에서 선언되고 파생 클래스에서 재정의되는 함수입니다. 이를 통해 기본 클래스의 포인터나 참조를 통해 파생 클래스의 객체를 다룰 수 있게 됩니다.

 

다음은 C++에서 다형성을 이용한 간단한 예제입니다.

 

[예제]

#include <iostream>

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

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

int main() {
    Animal* animal1 = new Dog();
    Animal* animal2 = new Cat();
    animal1->makeSound(); // Outputs: "The dog barks"
    animal2->makeSound(); // Outputs: "The cat meows"
    delete animal1;
    delete animal2;
    return 0;
}

 

위의 예제에서는 Animal이라는 기본 클래스가 있고, Dog와 Cat이라는 두 개의 파생 클래스가 있습니다. 이들 각각은 makeSound라는 가상 함수를 재정의하여 자신만의 특징을 가진 소리를 출력합니다. 이렇게 하면 Animal 클래스의 포인터를 통해 Dog와 Cat 클래스의 makeSound 메서드를 호출할 수 있습니다. 이것이 바로 다형성의 가장 대표적인 예입니다.

 

이렇게 다형성을 이용하면, 코드는 간결해지고 가독성이 향상되며, 무엇보다 중요한 것은 프로그램의 유연성이 높아집니다. 함수나 메소드의 이름만 알면 어떤 객체에서든 동일하게 사용할 수 있으며, 그 결과에 대한 변동성을 최소화할 수 있습니다. 그래서 다형성은 객체 지향 프로그래밍에서 매우 중요한 요소로 간주됩니다.

 

하지만 다형성을 잘 사용하기 위해서는 클래스와 객체, 상속, 가상 함수 등의 개념에 대한 이해가 필요합니다. 이러한 기본 개념을 이해하고 나면, 다형성은 코드의 재사용성을 높이고 유지 관리를 용이하게 하는 매우 강력한 도구가 될 것입니다. 다음 섹션에서는 다형성이 왜 필요한지에 대해 더 자세히 알아보겠습니다.

 

6.1.2. 다형성의 필요성

다형성이 왜 필요한지 이해하기 위해서는 객체 지향 프로그래밍의 핵심 원칙인 '추상화', '캡슐화', '상속' 등에 대한 이해가 필요합니다. 이러한 원칙들은 코드의 재사용성을 높이고 유지 보수를 용이하게 하는 데 중요한 역할을 합니다. 이 중 다형성은 코드의 유연성을 높이는데 큰 역할을 합니다.

 

다형성의 필요성은 크게 세 가지 측면에서 이해할 수 있습니다.

  1. 코드의 재사용성을 높입니다. 함수 오버로딩이나 가상 함수와 같은 다형성의 특징을 이용하면, 같은 이름의 함수나 메소드를 여러 방식으로 사용할 수 있습니다. 이를 통해 이미 작성된 코드를 다양한 상황에서 재사용할 수 있게 되어 코드의 생산성을 높일 수 있습니다.
  2. 코드의 유연성을 높입니다. 다형성을 이용하면 하나의 변수나 포인터가 여러 타입의 객체를 참조할 수 있게 되어 코드의 유연성이 향상됩니다. 이는 프로그램의 수정이나 확장을 쉽게 할 수 있도록 돕습니다.
  3. 코드의 가독성을 높입니다. 다형성은 코드를 더욱 간결하고 이해하기 쉽게 만듭니다. 같은 이름의 함수가 다양한 동작을 하는 것을 이해하기만 하면 코드의 동작을 쉽게 예측할 수 있습니다.

이러한 장점들을 실제 코드 예제를 통해 확인해봅시다.

 

[예제]

#include <iostream>

class Shape {
public:
    virtual double getArea() = 0; // 순수 가상 함수
};

class Circle : public Shape {
private:
    double radius;
public:
    Circle(double r) : radius(r) {}
    double getArea() override {
        return 3.14 * radius * radius;
    }
};

class Rectangle : public Shape {
private:
    double width, height;
public:
    Rectangle(double w, double h) : width(w), height(h) {}
    double getArea() override {
        return width * height;
    }
};

int main() {
    Shape* shapes[2];
    shapes[0] = new Circle(3.0);
    shapes[1] = new Rectangle(3.0, 4.0);

    for (int i = 0; i < 2; ++i) {
        std::cout << "Area of shape " << i+1 << " = " << shapes[i]->getArea() << "\n";
    }

    delete shapes[0];
    delete shapes[1];

    return 0;
}

 

위 코드에서는 Shape이라는 추상 클래스를 만들고 Circle과 Rectangle 두 개의 클래스가 이를 상속하도록 했습니다. Shape 클래스의 getArea() 함수는 순수 가상 함수로 선언되어 각 하위 클래스에서 그에 맞게 구현되어야 합니다. 이를 통해 다형성을 활용해 Shape 타입의 포인터로 Circle과 Rectangle 객체의 메서드를 호출하는 것을 볼 수 있습니다.

 

이렇게 다형성을 활용하면, Shape 클래스를 상속받는 새로운 도형 클래스를 만들었을 때 메인 함수를 전혀 수정하지 않고도 새로운 클래스의 객체를 배열에 추가하고 메소드를 호출할 수 있습니다. 이는 프로그램의 확장성을 크게 높여줍니다.

 

다형성의 이런 필요성을 잘 이해하고 활용하면, 프로그래밍 생산성과 코드의 유연성, 가독성을 높일 수 있습니다. 이를 통해 소프트웨어의 유지 보수와 확장이 용이하게 됩니다.


6.2. 함수 오버로딩

함수 오버로딩은 프로그래밍에서 흔히 볼 수 있는 다형성의 한 형태입니다. 오버로딩은 같은 이름의 함수를 여러 개 정의하는 것을 의미합니다. 각 함수는 매개변수의 타입이나 개수가 다르게 설정됩니다. 컴파일러는 함수를 호출할 때 주어진 인자를 바탕으로 가장 적절한 함수를 선택합니다. 이렇게 함수 오버로딩을 통해 같은 목적을 가진 연산이나 기능을 동일한 이름으로 캡슐화할 수 있으며, 이는 코드의 가독성을 향상합니다. 함수 오버로딩은 많은 객체 지향 언어, 특히 C++에서 자주 사용됩니다.

6.2.1. 함수 오버로딩의 정의와 사용법

'함수 오버로딩'이란, 같은 이름을 가진 함수를 여러 개 생성하는 것을 말합니다. 이러한 함수는 매개변수의 개수 또는 타입이 서로 달라야 합니다. 이 기능은 코드의 가독성을 높이고 유지보수를 용이하게 하며, 같은 이름의 함수라도 그 성격이 다르면 다른 행동을 할 수 있게 합니다. 이런 점에서 C++ 프로그래밍에서는 중요한 개념입니다. (C언어에서는 지원하지 않습니다.)

 

다음은 함수 오버로딩의 간단한 예시입니다.

 

[예제]

#include <iostream>

void print(int i) {
  std::cout << "Here is int: " << i << std::endl;
}

void print(double  f) {
  std::cout << "Here is float: " << f << std::endl;
}

void print(char* c) {
  std::cout << "Here is char*: " << c << std::endl;
}

int main() {
  print(10);
  print(10.10);
  print("ten");
  return 0;
}

 

위의 코드는 print라는 함수를 세 번 오버로딩한 예시입니다. 각 함수는 int, double, char* 타입의 매개변수를 각각 받습니다. main() 함수에서는 이 세 함수를 모두 호출하는데, 컴파일러는 전달된 인자의 타입에 따라 적절한 함수를 선택하여 호출합니다.

 

다른 예를 들면, 두 숫자의 합을 계산하는 함수를 생각해봅시다. 두 정수의 합과 두 실수의 합은 동일한 연산을 수행하지만, 데이터 타입이 다르므로 다른 함수를 사용해야 합니다. 이럴 때 함수 오버로딩을 사용하면, 동일한 이름(add)을 가진 함수를 생성하고 매개변수의 타입에 따라 적절한 함수가 호출될 수 있도록 합니다.

 

[예제]

#include <iostream>

int add(int x, int y) {
  return x + y;
}

double add(double x, double y) {
  return x + y;
}

int main() {
  std::cout << add(1, 2) << std::endl;
  std::cout << add(1.0, 2.3) << std::endl;
  return 0;
}

 

이와 같이 함수 오버로딩은 비슷한 동작을 하는 함수들을 그룹화하고, 그들을 하나의 함수처럼 다룰 수 있게 해 줍니다. 이는 코드의 가독성을 높이고, 개발자가 에러를 만드는 것을 방지하는 데 도움이 됩니다.

 

6.2.2. 함수 오버로딩의 주의점

함수 오버로딩이 프로그래밍에 편의성을 제공하지만, 주의해야 할 점들도 있습니다. 오버로딩된 함수는 그 함수의 이름은 같지만, 매개변수의 개수나 타입이 서로 달라야 합니다. 그렇지 않으면 컴파일러는 어떤 함수를 호출해야 할지 결정할 수 없으며, 이는 컴파일 에러를 유발합니다.

 

먼저, 함수의 반환 타입만 다르고 그 외에 모든 것이 같은 경우에는 오버로딩을 할 수 없습니다. 컴파일러는 함수를 호출할 때 반환 타입을 고려하지 않기 때문입니다. 예를 들어, 다음과 같은 코드는 컴파일 에러를 발생시킵니다.

 

[예제]

#include <iostream>

int foo() {
  return 10;
}

double foo() {
  return 10.10;
}

int main() {
  foo();  // 컴파일 에러: foo() 함수가 두 번 정의됨
  return 0;
}

 

또한, 함수 오버로딩을 할 때, 매개변수의 타입이나 개수가 충분히 다르지 않으면 컴파일러는 어떤 함수를 호출해야 할지 모를 수 있습니다. 이런 경우에도 컴파일 에러가 발생합니다. 예를 들어, 다음과 같은 코드에서는 두 번째 함수의 매개변수가 첫 번째 함수의 매개변수에 대한 상위 타입이므로, 컴파일러는 두 함수 중 어떤 것을 호출해야 할지 결정할 수 없습니다.

 

[예제]

#include <iostream>

void bar(int i) {
  std::cout << "bar(int): " << i << std::endl;
}

void bar(double d) {
  std::cout << "bar(double): " << d << std::endl;
}

int main() {
  bar(10);  // 컴파일 에러: 호출이 모호함
  return 0;
}

 

이런 문제를 해결하기 위해서는 함수를 오버로딩 할 때, 매개변수의 타입이나 개수가 충분히 다르도록 해야 합니다. 이렇게 하면 컴파일러가 함수 호출을 올바르게 분석할 수 있습니다.

 

함수 오버로딩은 매우 강력한 도구이지만, 제대로 사용하지 않으면 혼란을 초래할 수 있습니다. 그래서 함수를 오버로딩할 때는 항상 주의해야 합니다.


6.3. 연산자 오버로딩

'연산자 오버로딩'은 특정 클래스 또는 구조체에서 연산자의 동작을 사용자가 원하는 대로 재정의하는 기능입니다. 이는 C++에서 제공하는 기능으로, C 언어에서는 지원되지 않습니다. 연산자 오버로딩은 코드의 가독성을 높이며, 사용자 정의 데이터 타입에서 기본 데이터 타입처럼 동작하도록 만들어 줍니다. 하지만 적절히 사용되지 않으면 코드의 복잡성을 증가시킬 수 있으므로 주의해야 합니다. 이 장에서는 연산자 오버로딩의 기본 개념과 사용법, 그리고 이를 사용할 때 주의해야 할 사항들에 대해 알아보겠습니다. 

6.3.1. 연산자 오버로딩의 정의와 사용법

'연산자 오버로딩'은 프로그래머가 C++의 표준 연산자들(덧셈, 뺄셈, 곱셈 등)을 사용자 정의 데이터 타입에 대해 작동하도록 재정의하는 C++의 고유한 기능입니다. 이를 통해, 클래스와 구조체의 객체에서 일반 변수처럼 연산자를 사용할 수 있습니다. 이 기능은 코드의 읽기 쉬움을 증가시키는데 도움을 줍니다. 

 

하지만, 연산자 오버로딩이 C++에서만 가능하다는 점을 기억해야 합니다. C에서는 이러한 기능을 지원하지 않습니다.

[예제]

#include<iostream>
using namespace std;

class Complex {
public:
    int real, imag;
    Complex(int r = 0, int i =0)  {real = r;   imag = i;}
    
    // 연산자 오버로딩
    Complex operator + (Complex const &obj) {
         Complex res;
         res.real = real + obj.real;
         res.imag = imag + obj.imag;
         return res;
    }

    void print() { cout << real << " + i" << imag << endl; }
};
 
int main()
{
    Complex c1(10, 5), c2(2, 4);
    Complex c3 = c1 + c2; // c1 + c2 호출
    c3.print();
}

 

이 코드에서, + 연산자는 Complex 클래스의 객체에 대해 재정의되었습니다. Complex c3 = c1 + c2; 이라는 코드는 정상적으로 작동하며, c1과 c2의 실수와 허수 부분을 각각 더한 결과를 c3에 저장합니다.

 

이렇게 연산자 오버로딩을 통해 코드의 가독성을 높이고, 사용자 정의 데이터 타입에 대한 연산자의 동작을 원하는 대로 변경할 수 있습니다.

 

6.3.2. 연산자 오버로딩의 주의점

연산자 오버로딩은 코드의 가독성을 향상시키지만, 잘못 사용될 경우에는 예기치 못한 결과를 초래할 수 있으며 코드가 혼란스러울 수 있습니다. 그래서 연산자 오버로딩을 사용할 때는 다음과 같은 몇 가지 주의사항을 지켜야 합니다.

 

자연스러운 사용: 연산자는 그들의 기본적인 의미를 유지하도록 오버로드해야 합니다. 예를 들어, '+' 연산자는 일반적으로 두 객체를 합치는 작업에 사용되므로, 이를 '뺄셈'이나 '곱셈'에 사용하는 것은 좋지 않습니다. 이렇게 하면 코드를 읽는 사람들이 혼란스러울 수 있습니다.

 

코드의 가독성 유지: 연산자 오버로딩은 코드를 더 읽기 쉽게 만드는 것이 목표입니다. 그러나 너무 많은 연산자를 오버로딩하면 코드가 복잡해질 수 있습니다.

 

오버로딩 제한: C++에서는 모든 연산자를 오버로딩할 수 있는 것은 아닙니다. 예를 들어, 범위 결정 연산자(::), 크기 연산자(sizeof), 조건 연산자(?:) 등은 오버로딩할 수 없습니다.

 

이런 주의사항들을 기억하면서 연산자 오버로딩을 사용하면, 효과적으로 코드의 가독성을 높이고 자신만의 커스텀 동작을 정의할 수 있습니다.

 

[예제]

// 잘못된 연산자 오버로딩 예
class MyString {
public:
    // ... 이전의 코드 생략 ...
    
    // '+' 연산자를 '문자열 제거'에 사용하는 것은 잘못된 접근입니다.
    MyString operator+(const MyString& rhs) {
        MyString temp(*this);
        temp.str.erase(temp.str.find(rhs.str));
        return temp;
    }
};

 

이 코드에서는 '+' 연산자가 일반적으로 가진 '추가'라는 의미와 다르게, '제거'라는 동작을 수행하도록 오버로딩되었습니다. 이러한 접근은 코드를 읽는 사람에게 혼란을 줄 수 있으며, 이로 인해 오류를 유발할 가능성이 있습니다.

 

따라서, 연산자 오버로딩은 신중하게 사용해야 합니다. 연산자의 기본적인 의미를 고려하고, 코드의 가독성을 유지하며, 오버로딩 가능한 연산자에 대한 이해를 바탕으로 사용해야 합니다.


6.4. 가상 함수와 순수 가상 함수

가상 함수(virtual function)는 C++에서 상속 관계에 있는 클래스들에서 동일한 함수의 이름을 가지지만, 클래스에 따라 다르게 동작하도록 구현할 수 있게 해주는 기능입니다. 이를 통해 다형성을 실현할 수 있습니다.

순수 가상 함수(pure virtual function)는 가상 함수와 비슷하지만, 기본 클래스에서는 함수의 구현을 제공하지 않으며 파생 클래스에서 반드시 오버라이딩해야 하는 함수를 정의합니다. 이는 C++에서 추상 클래스를 구현하는 데 사용되며, 상속받은 클래스가 반드시 특정 함수를 구현하도록 강제하는 역할을 합니다.

이 두 기능은 C++의 다형성을 지원하며, 프로그램의 유연성과 재사용성을 높이는 데 크게 기여합니다.

6.4.1. 가상 함수의 정의와 사용법

가상 함수(virtual function)는 C++의 다형성을 지원하는 핵심 기능 중 하나입니다. 가상 함수를 이해하기 위해선 먼저 '상속'과 '오버라이딩'에 대한 이해가 필요합니다. 상속은 한 클래스의 특성을 다른 클래스가 물려받는 것이며, 오버라이딩은 상속받은 클래스가 부모 클래스의 메서드를 자신의 상황에 맞게 재정의하는 것을 말합니다.

 

가상 함수는 부모 클래스에서 선언하고, 자식 클래스에서 재정의(오버라이드)하는 함수입니다. C++에서는 이러한 가상 함수를 'virtual' 키워드를 사용해 선언합니다.

 

[예제]

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

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

 

이렇게 선언한 후에는, Animal 클래스의 포인터 혹은 참조를 사용하여 Dog나 Cat의 makeSound() 함수를 호출할 수 있습니다. 이를 통해 런타임에 객체의 실제 타입에 따라 적절한 함수가 호출됩니다. 이것이 바로 '다형성'입니다.

 

[예제]

Animal* animal1 = new Dog();
Animal* animal2 = new Cat();

animal1->makeSound(); // Outputs: "The dog barks"
animal2->makeSound(); // Outputs: "The cat meows"

 

이처럼 가상 함수는 동일한 인터페이스 아래에서 다른 동작을 하는 객체들을 쉽게 관리할 수 있게 해줍니다.

 

가상 함수는 런타임 다형성을 지원하며, 이는 프로그램이 실행 중인 시점에서 어떤 메서드를 호출할지 결정하는 것을 의미합니다. 이 기능은 프로그램의 유연성을 크게 증가시키지만, 잘못 사용하면 프로그램의 복잡성을 증가시키고 버그를 유발할 수 있습니다.

 

그러나 몇 가지 규칙을 따르면 이러한 위험을 피할 수 있습니다. 첫째, 가상 함수는 일반적으로 public 또는 protected 접근 지정자를 갖는 메서드에만 사용되어야 합니다. 그 이유는 가상 함수가 자식 클래스에서 재정의될 것이기 때문입니다. 둘째, 가상 함수를 사용할 때는 반드시 'override' 키워드를 사용해야 합니다. 이 키워드는 컴파일러에게 함수가 부모 클래스의 가상 함수를 재정의하는 것임을 명확히 알려주고, 재정의가 제대로 이루어지지 않았을 때 오류 메시지를 표시합니다.

 

다음은 이 규칙들을 적용한 예제입니다.

 

[예제]

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

class Dog : public Animal {
public:
    void makeSound() override { // 'override' 키워드를 사용합니다.
        cout << "The dog barks \n";
    }
};

class Cat : public Animal {
public:
    void makeSound() override { // 'override' 키워드를 사용합니다.
        cout << "The cat meows \n";
    }
};

 

가상 함수는 C++의 강력한 기능 중 하나이며, 올바르게 사용하면 코드의 재사용성을 높이고 유연성을 증가시킬 수 있습니다. 하지만 주의사항을 염두에 두고 사용하여 프로그램의 복잡성을 증가시키지 않도록 주의해야 합니다. 

 

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

순수 가상 함수는 C++에서 인터페이스를 만드는 데 사용되는 매우 중요한 기능입니다. 이는 빈 함수 즉, 함수 몸체가 없는 가상 함수를 의미합니다. 순수 가상 함수는 선언부에 '= 0'을 추가함으로써 표시됩니다.

 

이렇게 하면 해당 함수는 부모 클래스에서 구현되지 않고, 자식 클래스에서 반드시 재정의되어야 한다는 것을 명시하게 됩니다. 이런 성질 덕분에, 우리는 특정한 행동을 강제하는 인터페이스를 정의하는 데 순수 가상 함수를 사용할 수 있습니다.

 

다음은 순수 가상 함수를 사용하는 예입니다.

 

[예제]

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

class Dog : public Animal {
public:
    void makeSound() override { // 부모 클래스의 순수 가상 함수를 재정의합니다.
        cout << "The dog barks \n";
    }
};

class Cat : public Animal {
public:
    void makeSound() override { // 부모 클래스의 순수 가상 함수를 재정의합니다.
        cout << "The cat meows \n";
    }
};

 

이 코드에서 'Animal' 클래스는 'makeSound'라는 순수 가상 함수를 가지고 있습니다. 따라서 'Animal' 클래스는 인스턴스화될 수 없습니다. 이 클래스를 상속하는 'Dog' 클래스와 'Cat' 클래스는 'makeSound' 함수를 반드시 재정의해야 합니다. 이를 통해 각 동물이 자신만의 소리를 내도록 강제할 수 있습니다.

 

순수 가상 함수는 코드의 재사용성과 유연성을 높이는 강력한 도구입니다. 하지만 이들을 사용할 때는 몇 가지 주의사항이 있습니다.

  1. 순수 가상 함수를 가진 클래스는 '추상 클래스'로 간주되며, 이는 객체를 생성할 수 없는 클래스를 의미합니다.
  2. 순수 가상 함수는 자식 클래스에서 반드시 재정의해야 합니다. 이를 어기면 컴파일 에러가 발생합니다.

이로써 순수 가상 함수의 개념과 사용법에 대한 기본적인 이해를 얻었을 것입니다. 다음 섹션에서는 다형성과 관련하여 더 심화된 주제를 다루겠습니다. 다음 섹션에서 뵙겠습니다!


6.5. 다형성의 활용

다형성의 활용은 C++ 프로그래밍에서 매우 중요합니다. 다형성은 코드의 유연성과 재사용성을 향상시키는 데 큰 도움이 됩니다. 함수 오버로딩, 연산자 오버로딩, 가상 함수 등을 이용해 다형성을 구현할 수 있습니다. 이를 통해 동일한 함수나 연산자를 다양한 타입에 대해 다르게 작동하도록 만들 수 있습니다. 또한, 상속 관계에 있는 클래스들 간에 동일한 인터페이스를 제공하면서도 각 클래스마다 다른 기능을 수행하게 할 수 있습니다. 이런 방식은 코드의 가독성을 향상하고, 오류를 줄이며, 유지 관리를 용이하게 합니다. 

6.5.1. 다형성을 활용한 프로그래밍 예제

다형성은 C++에서 특히 강력한 도구입니다. 이를 활용하면 동일한 코드 구조에 대해 다양한 방식으로 동작하도록 만들 수 있습니다. 이 섹션에서는 간단한 동물 움직임 시뮬레이션을 통해 다형성을 어떻게 활용하는지 보여드리겠습니다.

 

먼저, 다양한 동물들이 공통적으로 가지는 특성을 추상화하기 위해 Animal이라는 베이스 클래스를 만들어 봅시다.

 

[예제]

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

 

move 메소드는 순수 가상 함수로 선언되었습니다. 이는 Animal 클래스를 상속받는 모든 클래스가 move 메서드를 구현해야 함을 의미합니다.

 

이제 Dog와 Fish라는 두 개의 서브클래스를 정의해 봅시다. 각각은 move 함수를 서로 다르게 구현하게 됩니다.

 

[예제]

class Dog : public Animal {
public:
    void move() override {
        std::cout << "The dog walks." << std::endl;
    }
};

class Fish : public Animal {
public:
    void move() override {
        std::cout << "The fish swims." << std::endl;
    }
};

 

이렇게 정의된 클래스를 통해 다음과 같은 메인 함수를 작성할 수 있습니다.

 

[예제]

int main() {
    Dog myDog;
    Fish myFish;
    
    Animal* myAnimals[2];
    myAnimals[0] = &myDog;
    myAnimals[1] = &myFish;
    
    for(int i = 0; i < 2; ++i) {
        myAnimals[i]->move();
    }
    
    return 0;
}

 

위의 코드를 실행하면 다음과 같은 출력을 볼 수 있습니다:

 

The dog walks.
The fish swims.

 

코드의 핵심은 Animal 포인터 배열에 Dog와 Fish 객체를 담은 후, 배열의 각 요소에 대해 move 메소드를 호출하는 것입니다. 이때, 메서드의 호출은 객체의 실제 타입(Dog 혹은 Fish)에 따라 달라집니다. 이처럼 하나의 인터페이스(move 메서드)를 통해 다양한 행동을 구현하는 것이 바로 다형성입니다. 이런 특성 덕분에 우리는 보다 유연하고 재사용 가능한 코드를 작성할 수 있습니다.

 

6.5.2. 다형성의 장점과 한계

다형성은 객체 지향 프로그래밍의 핵심적인 개념 중 하나로, 많은 장점을 가지고 있지만, 동시에 몇 가지 한계도 존재합니다. 이제 다형성의 장점과 한계에 대해 알아봅시다.

 

장점
  1. 코드 재사용 : 다형성은 코드의 재사용성을 증가시킵니다. 예를 들어, 같은 메서드 이름(move)을 갖고 있는 여러 클래스(Dog, Fish 등)가 있을 때, 해당 메서드를 호출하는 함수는 각 클래스의 구현에 대해 전혀 알 필요가 없습니다. 이를 통해 함수는 범용적으로 사용할 수 있게 되며, 이는 코드의 재사용성을 증가시킵니다.
  2. 확장성 : 다형성은 코드의 확장성을 증가시킵니다. 새로운 클래스(Cat 등)를 추가하더라도 기존 코드를 변경할 필요 없이 해당 클래스의 메서드만 구현하면 됩니다. 이를 통해 새로운 기능을 쉽게 추가할 수 있습니다.

 

단점
  1. 성능 이슈 : 다형성을 이용하면 실행 시점에서 적절한 메서드를 결정해야 하므로, 이 과정에서 약간의 성능 저하가 발생할 수 있습니다. 다만, 이런 성능 저하는 대부분의 경우에서 무시할 수 있는 수준입니다.
  2. 설계 복잡성 : 다형성을 제대로 활용하기 위해서는 신중한 설계가 필요합니다. 클래스 계층을 잘못 설계하면 오히려 코드가 복잡해지고 유지 보수가 어려워질 수 있습니다.

 

이런 장단점을 이해하고 다형성을 적절히 활용하는 것은 객체 지향 프로그래밍에 있어서 중요한 능력입니다. 다형성은 코드의 재사용성, 읽기 쉬움, 확장성 등 많은 장점을 제공하지만, 항상 설계 단계에서 신중하게 고려해야 합니다. 이해를 돕기 위해 위에서 작성했던 코드의 확장 예를 들어보겠습니다.

 

[예제]

class Cat : public Animal {
public:
    void move() override {
        std::cout << "The cat walks." << std::endl;
    }
};

int main() {
    Dog myDog;
    Fish myFish;
    Cat myCat;
    
    Animal* myAnimals[3];
    myAnimals[0] = &myDog;
    myAnimals[1] = &myFish;
    myAnimals[2] = &myCat;
    
    for(int i = 0; i < 3; ++i) {
        myAnimals[i]->move();
    }
    
    return 0;
}

 

이 코드는 'Cat'이라는 새로운 클래스를 추가하였습니다. 이 클래스는 'Animal'을 상속받고 'move' 메소드를메서드를 구현하였습니다. 이렇게 하면 기존에 작성한 'main' 함수에서 'Animal' 배열에 'Cat' 인스턴스를 추가하면, 나머지 코드는 그대로 두고도 'Cat'의 'move' 메서드를 호출할 수 있습니다. 이처럼 다형성은 코드의 확장성을 증가시키며, 코드의 재사용성을 향상합니다.


6.6. RTTI (Run-Time Type Identification)

RTTI (Run-Time Type Identification)은 C++에서 실행 시 객체의 타입을 식별하는 기능입니다. 'typeid' 연산자와 'dynamic_cast' 연산자를 이용하여 객체의 실제 타입을 알아내거나, 부모 클래스의 포인터를 자식 클래스의 포인터로 안전하게 변환할 수 있습니다. 이러한 기능은 다형성을 이용한 프로그래밍에서 매우 중요하며, 객체 지향 설계를 더욱 풍부하게 만들어 줍니다. 그러나 남용하면 프로그램의 복잡성을 증가시킬 수 있으므로, 적절한 사용이 필요합니다.

6.6.1. RTTI의 정의와 필요성

RTTI는 Run-Time Type Identification의 약자로, C++에서 동적으로 객체의 데이터 타입을 식별하는 메커니즘이다. RTTI를 사용하면 실행 시점에 객체의 실제 타입을 알아낼 수 있다. C++의 다형성에서 RTTI는 매우 중요한 역할을 한다.

 

RTTI를 사용하는 주된 이유는 다형성을 효과적으로 사용하기 위해서다. 다형성을 사용하면 상위 클래스의 포인터나 참조를 통해 하위 클래스의 인스턴스를 조작할 수 있다. 그러나, 특정 상황에서는 객체의 실제 타입을 알아내야 할 필요가 있을 수 있다. 이럴 때 RTTI를 사용하면 객체의 실제 타입을 얻을 수 있다.

 

예를 들어, 아래의 코드를 보자.

 

[예제]

class Base { virtual void dummy() {} };
class Derived: public Base { int a; };

void function(Base * basePtr) {
    Derived * derivedPtr = dynamic_cast<Derived*>(basePtr);
    if(derivedPtr) {
        // 실제 타입이 Derived인 경우만, 멤버 'a'에 접근 가능하다.
        derivedPtr->a = 10;
    }
}

 

위의 예제에서 dynamic_cast 연산자는 RTTI를 사용하여 basePtr이 실제로 가리키는 객체가 Derived 타입인지 확인한다. 만약 그렇다면 Derived 타입으로 안전하게 형변환된 포인터를 반환한다. 따라서, 이 코드는 객체의 실제 타입이 Derived일 때만, Derived 클래스의 멤버 변수 a에 접근하게 한다.

 

RTTI는 프로그램의 안전성과 유연성을 높여준다. 즉, 객체의 실제 타입을 확인함으로써 프로그램이 예상치 못한 동작을 하는 것을 막을 수 있다. 그러나 RTTI는 실행 시간이 추가로 소요되므로, 성능이 중요한 코드에서는 신중하게 사용해야 한다.

 

6.6.2. RTTI의 사용법

RTTI는 C++에서 제공하는 기능으로 주로 다음 두 가지 메커니즘을 통해 사용된다: dynamic_cast와 typeid.

 

dynamic_cast : 주로 다운캐스팅 시 사용된다. 다운캐스팅이란 상위 클래스(부모 클래스)의 포인터나 참조에서 하위 클래스(자식 클래스)의 포인터나 참조로 형 변환하는 것을 말한다. 이때, dynamic_cast는 형 변환하려는 객체가 실제로 형 변환 대상 타입인지를 실행 시간에 체크한다.


다음은 dynamic_cast의 사용 예이다.

 

[예제]

class Base { public: virtual ~Base(){} };
class Derived : public Base { public: void print() { std::cout << "Derived\n"; } };

int main() {
    Base* basePtr = new Derived();
    Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);
    if (derivedPtr) { // 성공적으로 형 변환이 되면, derivedPtr은 nullptr이 아니다.
        derivedPtr->print(); // 출력: "Derived"
    }
    delete basePtr;
    return 0;
}

 

typeid : 연산자는 객체의 타입 정보를 제공하는 std::type_info 객체를 반환한다. 이를 통해 객체의 실제 타입을 알아낼 수 있다. typeid는 주로 디버깅이나 객체의 타입을 문자열로 출력할 필요가 있을 때 사용된다.

 

아래는 typeid 사용 예이다.

 

[예제]

class Base { public: virtual ~Base(){} };
class Derived : public Base {};

int main() {
    Base* basePtr = new Derived();
    std::cout << typeid(*basePtr).name() << std::endl; // 출력: "Derived"
    delete basePtr;
    return 0;
}

 

위의 코드에서 typeid 연산자는 포인터가 가리키는 객체의 실제 타입을 출력한다. 여기서 주의할 점은 typeid 연산자를 사용할 때 반드시 객체에 대해 사용해야 한다는 것이다. 만약 typeid(basePtr)와 같이 포인터에 대해 typeid를 사용하면, 출력되는 타입은 포인터 타입이 된다.

 

RTTI는 실행 시간에 타입 정보를 얻을 수 있게 해주지만, 그만큼 프로그램의 실행 속도를 느리게 만들 수 있으므로, 필요한 경우에만 사용하는 것이 좋다. 또한, 가상 함수 테이블(vtable)을 사용하는 클래스에서만 동작한다는 점도 기억해야 한다. 가상 함수가 없는 클래스에 대해서는 dynamic_cast나 typeid를 사용할 수 없다. 이 점이 RTTI의 한계라고 할 수 있다.

 

이상으로 RTTI의 사용법에 대해 알아보았다. 이를 통해 C++에서 다형성을 활용할 때 더 유연하고 안전하게 프로그래밍할 수 있다. 다음 장에서는 이어서 C++의 더 고급적인 주제를 다루도록 하겠다. 이제부터는 이해도를 높이기 위해 실제 코드를 작성하며 연습하는 것이 중요하다.

 

 

 

2023.05.26 - [GD's IT Lectures : 기초부터 시리즈/C, C++ 기초부터 ~] - [C/C++ 프로그래밍 : 중급] 5. 상속

 

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

Chapter 5. 상속 상속은 프로그래밍에서 굉장히 중요한 개념입니다. C++에서는 클래스를 기반으로 상속을 통해 코드를 재사용하고, 더 복잡한 시스템을 구축할 수 있습니다. 이번 장에서는 상속의

gdngy.tistory.com

 

반응형

댓글