C++ 고급 문법 (18) — 업캐스팅(Upcasting)

파생 클래스 객체를 기반 클래스의 포인터나 참조로 다루는 것을 업캐스팅(upcasting)이라 한다. public 상속이면 캐스트 없이 암시적으로 일어나고, 다형성은 이 변환을 전제로 동작한다. 하지만 같은 업캐스팅이라도 포인터·참조가 아니라 값으로 일어나면 파생 부분이 잘려 나가고(slicing), 배열을 기반 포인터로 넘기면 포인터 연산이 어긋난다. 이 글에서는 g++ 13.3으로 업캐스팅이 안전한 경우와 아닌 경우, dynamic_cast 다운캐스트, 다이아몬드 상속에서의 모호성을 확인한다. 명시적 캐스트 연산자 자체는 명시적 캐스팅 편에서 다뤘다.

이 시리즈의 다른 글

포인터와 참조 업캐스팅

#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로 검사하고, 다이아몬드 상속은 가상 상속으로 기반을 하나로 만든다.

참고

답글 남기기