파생 클래스 객체를 기반 클래스의 포인터나 참조로 다루는 것을 업캐스팅(upcasting)이라 한다. public 상속이면 캐스트 없이 암시적으로 일어나고, 다형성은 이 변환을 전제로 동작한다. 하지만 같은 업캐스팅이라도 포인터·참조가 아니라 값으로 일어나면 파생 부분이 잘려 나가고(slicing), 배열을 기반 포인터로 넘기면 포인터 연산이 어긋난다. 이 글에서는 g++ 13.3으로 업캐스팅이 안전한 경우와 아닌 경우, dynamic_cast 다운캐스트, 다이아몬드 상속에서의 모호성을 확인한다. 명시적 캐스트 연산자 자체는 명시적 캐스팅 편에서 다뤘다.
이 시리즈의 다른 글
- C++ 고급 문법 (16) — protected 생성자(protected Constructor)
- C++ 고급 문법 (17) — private 상속(private Inheritance)
- C++ 고급 문법 (19) — 함수 바인딩(Function Binding)
- C++ 고급 문법 (20) — CRTP(Curiously Recurring Template Pattern)
포인터와 참조 업캐스팅
#include <cstdio>
#include <memory>
#include <vector>
struct Animal {
virtual ~Animal() = default;
virtual const char* sound() const { return "..."; }
const char* kind() const { return "Animal"; } // 비가상
};
struct Dog : Animal {
const char* sound() const override { return "woof"; }
const char* kind() const { return "Dog"; } // 이름만 같은 별개 함수
};
int main()
{
Dog d;
Animal& ref = d; // 업캐스팅: 암시적, 캐스트 불필요
Animal* ptr = &d;
std::printf("ref: %s %s\n", ref.kind(), ref.sound());
std::printf("ptr: %s %s\n", ptr->kind(), ptr->sound());
std::vector<std::unique_ptr<Animal>> zoo;
zoo.push_back(std::make_unique<Dog>()); // unique_ptr<Dog> -> unique_ptr<Animal>
std::printf("zoo[0]: %s\n", zoo[0]->sound());
}$ g++ -std=c++20 -Wall -o b basic.cpp
$ ./b
ref: Animal woof
ptr: Animal woof
zoo[0]: woof
업캐스팅 후에는 정적 타입(Animal)으로 호출할 함수가 정해지므로 비가상 kind()는 Animal 버전이, 가상 sound()는 실제 객체 버전이 호출된다. 이 차이는 함수 바인딩 편에서 자세히 다룬다.
값으로 업캐스팅하면 잘린다
#include <cstdio>
#include <vector>
struct Shape {
double x = 0;
virtual ~Shape() = default;
virtual double area() const { return 0; }
};
struct Circle : Shape {
double r = 2;
double area() const override { return 3.14159 * r * r; }
};
double by_value(Shape s) { return s.area(); } // 값으로 받기
double by_ref(const Shape& s) { return s.area(); }
int main()
{
Circle c;
std::printf("by_ref : %.2f\n", by_ref(c));
std::printf("by_value : %.2f\n", by_value(c)); // Circle 부분이 잘린다
std::vector<Shape> shapes;
shapes.push_back(c); // 컨테이너에 값으로 저장해도 잘린다
std::printf("vector : %.2f\n", shapes[0].area());
std::printf("sizeof(Shape)=%zu sizeof(Circle)=%zu\n", sizeof(Shape), sizeof(Circle));
}$ g++ -std=c++20 -Wall -o s slicing.cpp
$ ./s
by_ref : 12.57
by_value : 0.00
vector : 0.00
sizeof(Shape)=16 sizeof(Circle)=24
값으로 받으면 Shape 크기(16바이트)만큼만 복사되어 새 Shape 객체가 되고, 가상 함수도 Shape 버전이 불린다. std::vector<Shape>에 넣어도 마찬가지다.
참조를 통한 반쪽 대입
#include <cstdio>
struct Account {
int balance = 0;
virtual ~Account() = default;
};
struct Savings : Account {
double rate = 0;
};
int main()
{
Savings a, b;
a.balance = 100; a.rate = 0.03;
b.balance = 500; b.rate = 0.05;
Account& ra = a;
Account& rb = b;
ra = rb; // Account::operator= 만 호출된다
std::printf("a.balance=%d a.rate=%.2f\n", a.balance, a.rate); // 반쪽만 복사
}$ g++ -std=c++20 -Wall -o pa partial_assign.cpp
$ ./pa
a.balance=500 a.rate=0.03
operator=는 가상 함수가 아니므로 정적 타입인 Account의 대입 연산자가 불려 balance만 복사되고 rate는 그대로 남는다.
배열은 업캐스팅하지 않는다
#include <cstdio>
struct Base { int id; virtual ~Base() = default; };
struct Derived : Base { double extra[2]; };
void print_ids(const Base* arr, int n) // 배열을 기반 포인터로 받는다
{
for (int i = 0; i < n; ++i) std::printf(" arr[%d].id = %d\n", i, arr[i].id);
}
int main()
{
Derived ds[3];
for (int i = 0; i < 3; ++i) ds[i].id = 10 + i;
std::printf("sizeof(Base)=%zu sizeof(Derived)=%zu\n", sizeof(Base), sizeof(Derived));
print_ids(ds, 3); // Derived* -> Base* 변환은 허용된다
}$ g++ -std=c++20 -Wall -o ar array.cpp
$ ./ar
sizeof(Base)=16 sizeof(Derived)=32
arr[0].id = 10
arr[1].id = 679920536
arr[2].id = 11
arr[i]는 sizeof(Base) 단위로 이동하므로 arr[1]은 ds[0]의 중간을, arr[2]는 ds[1]을 가리킨다. 컴파일러는 경고하지 않는다.
다운캐스트는 dynamic_cast로
#include <cstdio>
#include <typeinfo>
struct Animal { virtual ~Animal() = default; };
struct Dog : Animal { void fetch() const { std::puts(" fetch!"); } };
struct Cat : Animal {};
void play(Animal& a)
{
if (auto* d = dynamic_cast<Dog*>(&a)) d->fetch(); // 포인터: 실패 시 nullptr
else std::puts(" not a dog (pointer)");
try {
Dog& d = dynamic_cast<Dog&>(a); // 참조: 실패 시 예외
d.fetch();
} catch (const std::bad_cast& e) {
std::printf(" bad_cast: %s\n", e.what());
}
}
int main()
{
Dog d; Cat c;
std::puts("Dog:"); play(d);
std::puts("Cat:"); play(c);
}$ g++ -std=c++20 -Wall -o dn down.cpp
$ ./dn
Dog:
fetch!
fetch!
Cat:
not a dog (pointer)
bad_cast: std::bad_cast
포인터 dynamic_cast는 실패하면 nullptr, 참조 dynamic_cast는 std::bad_cast 예외를 낸다. 동작 원리와 비용은 RTTI 편에서 다룬다.
다이아몬드 상속의 업캐스팅
struct Device { int id = 0; };
struct Input : Device {};
struct Output : Device {};
struct Terminal : Input, Output {}; // Device 부분 객체가 두 개
int main()
{
Terminal t;
Device& d = t; // 어느 Device로 갈지 모호하다
return d.id;
}$ g++ -std=c++20 -Wall -c diamond.cpp 2>&1 | grep error
diamond.cpp:9:17: error: ‘Device’ is an ambiguous base of ‘Terminal’
일반 다중 상속에서는 Device 부분 객체가 두 개라 어느 쪽으로 변환할지 정할 수 없다. 중간 클래스를 거쳐 경로를 지정하거나, 가상 상속(virtual inheritance)으로 공통 기반을 하나로 합친다.
#include <cstdio>
struct Device { int id = 0; };
struct Input : Device {};
struct Output : Device {};
struct Terminal : Input, Output {};
struct VInput : virtual Device {}; // 가상 상속: Device를 하나만 공유
struct VOutput : virtual Device {};
struct VTerminal : VInput, VOutput {};
int main()
{
Terminal t;
Device& viaIn = static_cast<Input&>(t); // 경로를 지정하면 업캐스트 가능
Device& viaOut = static_cast<Output&>(t);
std::printf("Terminal : two Device at %p, %p (sizeof=%zu)\n",
static_cast<void*>(&viaIn), static_cast<void*>(&viaOut), sizeof(Terminal));
VTerminal v;
Device& d = v; // 하나뿐이라 모호하지 않다
d.id = 7;
std::printf("VTerminal: one Device, id=%d (sizeof=%zu)\n", v.VInput::id, sizeof(VTerminal));
}$ g++ -std=c++20 -Wall -o df diamond_fix.cpp
$ ./df
Terminal : two Device at 0x7ffc6714e8e8, 0x7ffc6714e8ec (sizeof=8)
VTerminal: one Device, id=7 (sizeof=24)
가상 상속은 공통 기반의 위치를 실행 시점에 찾기 위한 정보가 추가되어 크기가 8바이트에서 24바이트로 늘었다.
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| 다형 객체를 값으로 전달·저장 | slicing: 파생 데이터와 동작 손실 | 참조·포인터로 전달, 컨테이너는 unique_ptr<Base> |
| 기반 참조로 대입 | 반쪽 대입 | 다형 기반 클래스는 복사·대입을 protected나 = delete로 |
| 파생 배열을 기반 포인터로 | 포인터 연산 오류, 쓰레기 값 | std::vector<std::unique_ptr<Base>> |
파생 배열을 기반 포인터로 delete[] | 미정의 동작 | 배열 대신 컨테이너 |
| 근거 없는 다운캐스트 | 잘못된 타입으로 접근 | dynamic_cast, 가능하면 가상 함수로 대체 |
| 다이아몬드 상속 | 업캐스트 모호성, 기반 중복 | 가상 상속 또는 설계 변경 |
마무리
- 업캐스팅은 포인터·참조로 할 때만 다형성이 유지되고, 값으로 하면 slicing이 일어난다.
- 파생 객체 배열을 기반 포인터로 넘기면 원소 크기가 달라 인덱싱이 깨진다.
- 다운캐스트는
dynamic_cast로 검사하고, 다이아몬드 상속은 가상 상속으로 기반을 하나로 만든다.