1. 다형성과 객체의 자료형
1.1 다형성의 의미
다형성(Polymorphism)
같은 이름이나 인터페이스를 통해 서로 다른 자료형에 맞는 동작을 수행할 수 있는 성질이다. 상속 기반 다형성에서는 기본 클래스의 공통 인터페이스로 여러 파생 객체를 다룬다
예를 들어 Animal의 talk()라는 공통 기능을 사용하더라도, 실제 객체가 Cat인지 Dog인지에 따라 서로 다른 동작을 수행하게 할 수 있다
상속만 작성했다고 모든 멤버 함수에 실행 중 다형성이 적용되는 것은 아니다. 기본 클래스의 포인터·참조와 가상 함수의 관계를 함께 이해해야 한다
1.2 다형성의 여러 형태
| 종류 | 관련 기능 | 핵심 |
|---|---|---|
| 애드혹 다형성(Ad-hoc) | 함수·연산자 오버로딩 | 같은 이름으로 자료형에 맞는 여러 구현 제공 |
| 매개변수 다형성(Parametric) | 템플릿 | 자료형을 매개변수로 일반화 |
| 강제 변환 다형성(Coercion) | 자료형 변환 | 변환을 통해 연산이나 함수에 맞는 형식 사용 |
| 서브타입 다형성(Subtype) | 상속과 가상 함수 | 기본 클래스 인터페이스로 파생 객체의 동작 실행 |
C++에서는 상속, 기본 클래스 포인터·참조, 가상 함수를 함께 사용해 서브타입 다형성을 구현한다
1.3 정적 자료형과 동적 자료형
Dog dog;
Animal* ptr = &dog;Animal과 Dog가 public 상속 관계라고 할 때 다음과 같이 구분한다
| 구분 | 위 코드의 의미 |
|---|---|
| 정적 자료형(Static Type) | 선언으로 정해진 ptr의 형식은 Animal* |
| 동적 자료형(Dynamic Type) | ptr이 가리키는 실제 객체의 형식은 Dog |
어떤 멤버 이름에 접근할 수 있는지와 가상 함수를 호출할 때 어느 구현이 실행되는지는 구분해야 한다
2. 객체 포인터·참조와 상속 변환
2.1 객체를 가리키는 포인터
class Animal
{
public:
void eat() {}
};
class Dog : public Animal
{
public:
void fetch() {}
};Dog dog;
Dog* dogPtr = &dog;
dogPtr->eat();
dogPtr->fetch();포인터를 통한 멤버 접근에는 ->를 사용한다. dogPtr->eat()와 (*dogPtr).eat()는 같은 객체의 멤버를 사용한다
객체를 가리키는 포인터 자체를 선언했다고 새 객체가 생성되는 것은 아니다
Dog* ptr; // 포인터만 선언, 사용할 객체를 지정하지 않음
// ptr->fetch(); // 유효한 객체를 가리키지 않아 잘못된 사용2.2 파생 클래스에서 기본 클래스로의 변환
Dog dog;
Animal* ptr = &dog;
Animal& ref = dog;접근 가능하고 모호하지 않은 public 상속 관계에서는 파생 객체의 주소를 기본 클래스 포인터에 저장하거나 기본 클래스 참조로 연결할 수 있다
이를 보통 **업캐스팅(Upcasting)**이라고 한다
| 표현 | 결과 |
|---|---|
Dog* → Animal* | public 상속에서 암시적 변환 가능 |
Dog& → Animal& | public 상속에서 암시적 변환 가능 |
Animal* → Dog* | 일반적으로 암시적 변환 불가능 |
Animal& → Dog& | 일반적으로 암시적 변환 불가능 |
포인터·참조를 변환해도 원래 Dog 객체가 Animal 객체로 복사되거나 바뀌지는 않는다. 기본 클래스 부분을 통해 같은 객체에 접근한다
2.3 기본 클래스 포인터의 접근 범위
Dog dog;
Animal* ptr = &dog;
ptr->eat();
// ptr->fetch(); // Animal에는 fetch가 선언되어 있지 않음실제 객체가 Dog여도, Animal 포인터를 통해서는 Animal 인터페이스에 포함된 접근 가능한 멤버를 사용한다
virtual을 사용해도 기본 클래스에 없는 파생 클래스 전용 함수를 기본 클래스 포인터로 호출할 수는 없다
2.4 반대 방향의 변환
Animal animal;
// Dog* ptr = &animal; // 암시적 변환 불가능모든 Dog는 Animal이지만 모든 Animal이 Dog인 것은 아니다. 기본 클래스 포인터의 실제 대상이 특정 파생 객체인지 확인하지 않고 변환하면 올바르게 사용할 수 없다
C 스타일 캐스트를 붙여 컴파일된다고 객체의 실제 형식이 바뀌는 것은 아니다. 일반적으로 공통 기능을 호출하려는 목적이라면, 기본 클래스에 필요한 인터페이스를 선언하고 가상 함수를 통해 사용하는 구조를 우선 고려한다
2.5 참조를 통한 접근
Dog dog;
Animal& ref = dog;
ref.eat();참조도 원래 객체를 대상으로 한다. 포인터·참조가 원래 객체를 유지하는 점은 값 복사에 의한 객체 슬라이싱과 구분된다
3. 객체 배열과 객체 포인터 배열
3.1 객체 배열의 순회
class Counter
{
private:
int count;
public:
Counter() : count(0) {}
void increment() { ++count; }
int get() const { return count; }
};Counter counters[3];
Counter* ptr = counters;
for (int i = 0; i < 3; ++i)
{
ptr->increment();
++ptr;
}객체 배열에서는 실제 객체들이 배열 원소로 연속해서 존재한다. 같은 배열 안에서 포인터를 증가시키면 다음 원소를 가리킨다
마지막 증가 후에는 배열 끝 바로 다음 위치를 가리킬 수 있지만, 그 위치를 역참조하거나 멤버를 호출할 수는 없다
객체를 대상으로 포인터 연산을 하더라도, 같은 배열의 원소 사이에서만 이동할 수 있다는 기본 규칙은 같다
3.2 객체 배열과 포인터 배열의 차이
| 선언 | 배열 원소 | 객체 생성 여부 |
|---|---|---|
Dog dogs[3]; | Dog 객체 | Dog 객체 3개 생성 |
Dog* ptrs[3]; | Dog를 가리키는 포인터 | Dog 객체를 생성하지 않음 |
Dog* ptrs[3] = {}; | 초기값이 널 포인터인 포인터 | Dog 객체를 생성하지 않음 |
포인터 배열을 사용하려면 각 원소에 유효한 객체의 주소를 저장해야 한다
Dog a;
Dog b;
Dog* ptrs[2] = { &a, &b };
ptrs[0]->fetch();
ptrs[1]->fetch();3.3 서로 다른 파생 객체를 담는 포인터 배열
class Cat : public Animal {};
Dog dog;
Cat cat;
Animal* animals[2] = { &dog, &cat };배열 원소의 자료형은 모두 Animal*이지만, 각각 다른 종류의 파생 객체를 가리킬 수 있다
이 배열은 객체를 소유하는 배열이 아니다. 위 코드처럼 지역 객체의 주소를 저장했다면 해당 포인터를 delete하지 않는다
3.4 파생 객체 배열을 기본 클래스 포인터로 순회할 수 없는 이유
Dog dogs[2];
Animal* ptr = &dogs[0];
ptr->eat();첫 객체의 기본 클래스 부분에 접근하는 것은 가능하다. 하지만 ptr을 증가시켜 Dog 배열의 다음 원소를 순회하는 방식으로 사용하면 안 된다
포인터 연산은 포인터의 자료형에 따른 배열 관계를 전제로 한다. Dog 배열은 Animal 배열이 아니며, 기본 클래스 부분을 Animal 배열의 원소처럼 취급할 수 없다
여러 파생 객체를 기본 클래스 인터페이스로 순회하려면 Animal* 배열처럼 포인터들을 따로 저장한다
4. 객체 슬라이싱
4.1 기본 클래스 객체로 값 복사
객체 슬라이싱(Object Slicing)
파생 객체를 기본 클래스 객체로 값 복사할 때 기본 클래스 부분만 복사되고 파생 클래스 고유 부분은 복사 대상에 포함되지 않는 현상이다
class Animal
{
public:
int age;
Animal(int a = 0) : age(a) {}
};
class Dog : public Animal
{
public:
int tricks;
Dog(int a, int t) : Animal(a), tricks(t) {}
};Dog dog(3, 5);
Animal animal = dog;| 객체 | 실제 형식 | 보유하는 상태 |
|---|---|---|
| dog | Dog | Animal 부분의 age와 Dog의 tricks |
| animal | Animal | 복사된 age |
animal은 원래 Dog를 가리키는 별명이 아니라 별도로 생성된 Animal 객체이다. 원래 dog의 tricks가 삭제되는 것은 아니다
4.2 값 전달에서의 슬라이싱
void inspectByValue(Animal animal)
{
// Animal 값 매개변수로 복사
}Dog dog(3, 5);
inspectByValue(dog);함수 안의 매개변수는 Animal 객체이므로 파생 클래스 고유 부분을 유지하지 않는다
원래 객체를 복사하지 않고 기본 클래스 인터페이스로 사용하려면 포인터나 참조를 전달한다
void inspectByReference(const Animal& animal)
{
// 원래 객체를 const 참조로 사용
}inspectByValue는 별도의 Animal 객체를 만들고, inspectByReference는 원래 객체를 참조한다. const 참조를 통해서는 객체를 변경할 수 없지만, 원래 파생 객체의 형식은 유지된다
4.3 가상 함수와 슬라이싱의 관계
가상 함수가 있어도 값 복사를 통해 기본 클래스 객체가 만들어지면 파생 클래스 부분이 보존되지 않는다
| 표현 | 원래 파생 객체 유지 | 슬라이싱 |
|---|---|---|
Animal a = dog; | 별도의 Animal 객체 생성 | 발생 |
Animal& ref = dog; | 원래 객체 참조 | 발생하지 않음 |
Animal* ptr = &dog; | 원래 객체를 가리킴 | 발생하지 않음 |
동적 바인딩은 현재 실제로 존재하는 객체의 형식을 기준으로 한다. 원래 복사 대상이 Dog였다는 과거 정보로 Dog의 함수를 선택하지 않는다
5. 가상 함수와 동적 바인딩
5.1 비가상 함수의 호출
#include <iostream>
class Animal
{
public:
void talk() const { std::cout << "?\n"; }
};
class Dog : public Animal
{
public:
void talk() const { std::cout << "Woof\n"; }
};Dog dog;
Animal& ref = dog;
dog.talk(); // Woof
ref.talk(); // ?비가상 함수 호출은 이 경우 사용한 표현식의 정적 자료형에 따라 결정된다. Animal 참조를 통한 호출에서는 Animal::talk()가 선택된다
5.2 virtual을 통한 호출
#include <iostream>
class Animal
{
public:
virtual void talk() const { std::cout << "?\n"; }
};
class Cat : public Animal
{
public:
void talk() const override { std::cout << "Meow\n"; }
};
class Dog : public Animal
{
public:
void talk() const override { std::cout << "Woof\n"; }
};Cat cat;
Dog dog;
Animal* animals[2] = { &cat, &dog };
for (int i = 0; i < 2; ++i)
{
animals[i]->talk();
}Meow
Woof기본 클래스 포인터로 같은 talk()를 호출하지만 실제 대상 객체에 맞는 구현이 실행된다
이 예제는 지역 객체의 주소를 사용하므로 포인터에 대한 delete는 하지 않는다. 동적 할당한 객체를 기본 클래스 포인터로 삭제하는 규칙은 7절의 가상 소멸자에서 다룬다
5.3 정적 바인딩과 동적 바인딩
바인딩(Binding)
함수 호출을 실제 실행할 함수의 구현과 연결하는 것이다. 정적 바인딩은 컴파일 시점의 정보로, 동적 바인딩은 실행 중 실제 객체의 형식을 반영해서 연결한다
| 구분 | 정적 바인딩 | 동적 바인딩 |
|---|---|---|
| 대표적인 경우 | 비가상 멤버 함수 호출 | 기본 클래스 포인터·참조를 통한 가상 함수 호출 |
| 선택의 기준 | 표현식의 정적 자료형 등 | 실제 객체의 최종 재정의 함수 |
| 필요한 기능 | 일반 함수 선언 | virtual 함수와 재정의 |
컴파일러가 최적화를 통해 실제 호출을 미리 확정할 수 있어도, 언어 차원의 가상 함수 호출 규칙은 유지된다
5.4 override와 함수 선언의 일치
class Base
{
public:
virtual int get() const { return 0; }
};
class Derived : public Base
{
public:
int get() const override { return 1; }
};재정의에서는 이름뿐 아니라 매개변수, 멤버 함수의 const 등도 맞춰야 한다. 반환형 역시 일치하거나 허용되는 공변 반환형 조건을 만족해야 한다
// int get() override; // Base::get() const를 재정의하지 못하므로 오류기본 클래스에서 이미 virtual인 함수는 파생 클래스에서도 가상 함수이다. 파생 클래스에서 virtual을 반복해서 적을 필요는 없지만, override를 적으면 의도와 일치하는지 컴파일러가 검사한다
5.5 여러 단계의 상속과 가상 함수
class Animal
{
public:
virtual int sound() const { return 0; }
};
class Dog : public Animal
{
public:
int sound() const override { return 1; }
};
class Puppy : public Dog {};Puppy puppy;
Animal& ref = puppy;
int n = ref.sound(); // 1Puppy가 sound를 다시 정의하지 않으면 Dog의 재정의가 사용된다. Puppy가 재정의하면 그 구현이 선택된다
기본 클래스 구현을 명시적으로 호출할 때는 객체.Base::함수()처럼 범위를 지정할 수 있다. 이러한 명시적 범위 지정 호출은 가상 호출을 억제한다
5.6 가상 함수와 값 복사의 결과
5.2절의 Animal과 Dog를 사용하면, 값 복사와 참조의 차이가 호출 결과에도 드러난다
Dog dog;
Animal copied = dog;
Animal& referred = dog;
copied.talk(); // ?
referred.talk(); // Woofcopied는 값 복사로 생성된 Animal 객체이므로 Animal의 구현을 사용한다. referred는 원래 Dog 객체를 참조하므로 Dog의 재정의가 선택된다
가상 함수는 실제 객체의 형식을 반영하지만, 값 복사에서 빠진 파생 부분을 복원하지는 않는다
5.7 가상 함수가 보장하지 않는 것
- 기본 클래스에 없는 파생 클래스 전용 멤버의 접근을 허용하지 않는다
- 값 복사로 발생한 슬라이싱을 막지 않는다
- 객체의 수명을 연장하거나 포인터의 유효성을 보장하지 않는다
다형적 호출을 사용하려면 유효하게 살아 있는 실제 객체와 올바른 포인터·참조가 필요하다
6. 순수 가상 함수와 추상 클래스
6.1 순수 가상 함수
순수 가상 함수(Pure Virtual Function)
가상 함수 선언에
= 0을 붙여, 구체적인 파생 클래스가 제공해야 할 공통 인터페이스를 나타내는 함수이다
class Shape
{
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};= 0은 함수 호출 결과가 0이라는 의미가 아니다. 또한 일반적인 데이터 멤버의 초기화 문법도 아니다
순수 가상 함수도 별도의 정의를 가질 수 있지만, 해당 함수가 순수 가상이라는 성질은 유지된다
6.2 추상 클래스
추상 클래스(Abstract Class)
최종 재정의 함수가 순수 가상인 가상 함수를 하나 이상 가진 클래스이다. 직접 객체를 생성할 수 없으며, 파생 클래스의 공통 기반과 인터페이스로 사용한다
// Shape shape; // 추상 클래스 객체 생성 불가
Shape* ptr = nullptr; // 포인터 선언 가능추상 클래스도 생성자, 멤버 변수, 일반 멤버 함수, 구현이 있는 가상 함수를 가질 수 있다
6.3 구체적인 파생 클래스
class Rectangle : public Shape
{
private:
double width;
double height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double area() const override
{
return width * height;
}
};Rectangle rect(3.0, 4.0);
const Shape& shape = rect;
double result = shape.area(); // 12.0파생 클래스가 필요한 순수 가상 함수를 모두 구현하지 않으면 그 파생 클래스 역시 추상 클래스이다
6.4 공통 인터페이스를 사용하는 함수
double getArea(const Shape& shape)
{
return shape.area();
}이 함수는 구체적인 도형의 종류를 직접 분기하지 않고 Shape의 공통 인터페이스를 사용한다
새로운 파생 도형이 Shape의 인터페이스를 구현하면 같은 함수에 전달할 수 있다. 참조로 받으므로 파생 객체의 슬라이싱도 발생하지 않는다
7. 가상 소멸자와 다형적 객체 관리
7.1 기본 클래스 포인터로 객체 삭제
class Base
{
public:
virtual ~Base() = default;
};
class Derived : public Base
{
};Base* ptr = new Derived;
delete ptr;기본 클래스의 소멸자가 virtual이면 delete를 통해 파생 클래스의 소멸 과정부터 시작한 뒤 기본 클래스 부분까지 소멸한다
| 순서 | 동작 |
|---|---|
| 1 | Derived 소멸자 본문 실행 |
| 2 | Derived의 멤버 객체 소멸 |
| 3 | Base 부분 소멸 |
| 4 | 객체에 사용된 동적 저장 공간 해제 |
기본 클래스 포인터를 통한 삭제
일반적인
delete Base*로 파생 객체를 삭제하려면 기본 클래스 소멸자가 virtual이어야 한다. 그렇지 않으면 정의되지 않은 동작이며, 단순히 “기본 소멸자만 실행된다”는 결과를 보장할 수 없다
7.2 다른 가상 함수와 소멸자
일반 멤버 함수가 virtual이라고 소멸자까지 자동으로 virtual이 되는 것은 아니다
class Animal
{
public:
virtual void talk() const = 0;
virtual ~Animal() = default;
};기본 소멸자가 virtual이면 파생 소멸자는 virtual을 반복해서 적지 않아도 가상 소멸자이다. ~Dog() override = default;처럼 의도를 표시할 수도 있다
생성자는 virtual로 선언할 수 없다. 생성은 만들 객체의 형식을 정하는 과정이며, 소멸 시에는 기본 클래스 인터페이스를 통한 삭제가 가능하다는 차이가 있다
7.3 자원을 가진 파생 객체
class Buffer : public Base
{
private:
int* data;
public:
Buffer() : data(new int[10]{}) {}
~Buffer() override { delete[] data; }
Buffer(const Buffer&) = delete;
Buffer& operator=(const Buffer&) = delete;
};Base* ptr = new Buffer;
delete ptr;Base의 가상 소멸자로 인해 Buffer의 소멸자가 실행되고, Buffer가 소유한 배열도 해제된다
복사 금지 선언은 포인터를 얕게 복사해 동일 배열을 중복 해제하는 일을 막는다. 위 Buffer는 별도의 복사 정책을 구현하지 않았으므로 복사를 허용하지 않는다
7.4 포인터 컬렉션으로 객체 관리
#include <iostream>
#include <vector>
class Animal
{
public:
virtual void talk() const = 0;
virtual ~Animal() = default;
};
class Cat : public Animal
{
public:
void talk() const override { std::cout << "Meow\n"; }
};
class Dog : public Animal
{
public:
void talk() const override { std::cout << "Woof\n"; }
};std::vector<Animal*> animals;
animals.push_back(new Cat);
animals.push_back(new Dog);
for (Animal* animal : animals)
{
animal->talk();
}
for (Animal* animal : animals)
{
delete animal;
}
animals.clear();이 코드는 정상 실행 경로에서 각 객체를 한 번씩 해제한다. std::vector<Animal*>는 포인터 원소를 관리할 뿐, 가리키는 객체를 자동으로 delete하지 않는다
예외까지 고려한 소유권 관리는 스마트 포인터로 개선할 수 있다. 직접 포인터를 관리하는 경우에는 예외나 중간 반환이 발생하더라도 필요한 객체를 해제할 수 있는지 확인해야 한다
7.5 배열의 크기와 객체 수명
| 저장 방식 | 원소 수 확인 | 대상 객체의 해제 |
|---|---|---|
Animal* animals[2] | 고정 크기 2 등을 사용 | 포인터가 가리키는 대상의 소유권에 따라 결정 |
std::vector<Animal*> animals | animals.size() | vector만 소멸한다고 대상 객체가 해제되지는 않음 |
Dog* dogs = new Dog[2] | 별도로 크기를 관리 | 원래 Dog 포인터로 delete[] dogs |
일반 C++ 배열에는 .size() 멤버 함수가 없다
또한 가상 소멸자가 있어도 파생 객체 배열을 기본 클래스 포인터로 변환해 delete[]하는 것은 올바르지 않다. 단일 객체의 다형적 삭제와 배열 삭제를 구분한다