C++ 고급 문법 (6) — 명시적 캐스팅(Explicit Casting)

C 스타일 캐스트 (T)x는 무엇이든 통과시킨다. const를 떼는 것도, 전혀 관계없는 타입의 포인터로 바꾸는 것도 경고 없이 한 줄에 끝난다. 다중 상속이나 불완전 타입이 섞이면 같은 문법이 상황에 따라 다른 변환으로 해석되어 주소가 어긋나기도 한다. C++은 이 변환을 목적별로 나눈 네 가지 캐스트 연산자(static_cast, const_cast, reinterpret_cast, dynamic_cast)를 제공한다. 이 글에서는 g++ 13.3으로 각 캐스트가 무엇을 허용하고 거부하는지, C 스타일 캐스트가 어디서 조용히 틀리는지 확인한다.

이 시리즈의 다른 글

네 가지 캐스트 연산자

연산자용도검사 시점
static_cast수치 변환, enum ↔ 정수, void* → T*, 상속 관계 포인터 변환컴파일 타임
const_castconst/volatile 추가·제거만컴파일 타임
reinterpret_cast무관한 포인터 타입, 포인터 ↔ 정수없음 (비트 그대로)
dynamic_cast다형 클래스의 안전한 다운캐스트실행 시점 (RTTI)
std::bit_cast (C++20)같은 크기 타입 간 비트 패턴 복사컴파일 타임 (크기·복사 가능성)

C 스타일 캐스트의 문제

#include <cstdio>

struct Sensor { int id; };
struct Motor  { double rpm; };

int main()
{
    const Sensor s{7};
    Motor* m = (Motor*)&s;      // const 제거 + 무관한 타입 변환이 한 번에 통과
    std::printf("%d\n", m != nullptr);
}
$ g++ -std=c++20 -Wall -o cstyle cstyle.cpp
$ g++ -std=c++20 -Wall -Wold-style-cast -o cstyle cstyle.cpp
cstyle.cpp: In function ‘int main()’:
cstyle.cpp:9:25: warning: use of old-style cast to ‘struct Motor*’ [-Wold-style-cast]
    9 |     Motor* m = (Motor*)&s;      // const 제거 + 무관한 타입 변환이 한 번에 통과
      |                         ^

기본 경고 옵션으로는 아무 말이 없고, -Wold-style-cast를 켜야 위치만 알려준다. 같은 변환을 static_cast로 쓰면 두 가지 문제가 각각 에러로 드러난다.

struct Sensor { int id; };
struct Motor  { double rpm; };

int main()
{
    const Sensor s{7};
    Motor*  m1 = static_cast<Motor*>(&s);    // 무관한 타입
    Sensor* m2 = static_cast<Sensor*>(&s);   // const 제거
    return m1 == nullptr && m2 == nullptr;
}
$ g++ -std=c++20 -Wall -c cstyle_err.cpp
cstyle_err.cpp: In function ‘int main()’:
cstyle_err.cpp:7:18: error: invalid ‘static_cast’ from type ‘const Sensor*’ to type ‘Motor*’
    7 |     Motor*  m1 = static_cast<Motor*>(&s);    // 무관한 타입
      |                  ^~~~~~~~~~~~~~~~~~~~~~~
cstyle_err.cpp:8:18: error: ‘static_cast’ from type ‘const Sensor*’ to type ‘Sensor*’ casts away qualifiers
    8 |     Sensor* m2 = static_cast<Sensor*>(&s);   // const 제거
      |                  ^~~~~~~~~~~~~~~~~~~~~~~~

static_cast

#include <cstdint>
#include <cstdio>

enum class Level : std::uint8_t { Debug = 1, Info, Warn };

int main()
{
    double d = 3.99;
    int i = static_cast<int>(d);                   // 0 방향으로 버림
    int r = static_cast<int>(d + 0.5);

    Level lv = static_cast<Level>(3);              // 정수 -> enum class
    int raw = static_cast<int>(Level::Info);       // enum class -> 정수

    int value = 42;
    void* vp = &value;                             // T* -> void*는 암시적
    int* ip = static_cast<int*>(vp);               // void* -> T*는 명시 필요

    std::printf("i=%d r=%d lv=%d raw=%d *ip=%d\n", i, r, static_cast<int>(lv), raw, *ip);
}
$ g++ -std=c++20 -Wall -o static static.cpp
$ ./static
i=3 r=4 lv=3 raw=2 *ip=42

실수 → 정수 변환은 반올림이 아니라 0 방향 버림이다. enum class는 정수와 암시적으로 섞이지 않으므로 양방향 모두 static_cast가 필요하다.

const_cast

const를 빠뜨린 레거시 API에 넘길 때가 거의 유일한 정당한 용도다. 원래 const로 선언된 객체를 실제로 수정하면 미정의 동작이다.

#include <cstdio>
#include <cstring>

// const를 빠뜨린 레거시 C API (실제로는 수정하지 않는다)
std::size_t legacy_len(char* s) { return std::strlen(s); }

int main()
{
    const char* name = "sensor0";
    std::printf("len=%zu\n", legacy_len(const_cast<char*>(name)));   // 정당한 사용

    const int limit = 10;                        // 원래부터 const인 객체
    int* p = const_cast<int*>(&limit);
    *p = 99;                                     // 미정의 동작
    std::printf("limit=%d *p=%d\n", limit, *p);
}
$ g++ -std=c++20 -Wall -O0 -o cc constcast.cpp
$ ./cc
len=7
limit=10 *p=99
$ g++ -std=c++20 -Wall -O2 -o cc constcast.cpp
$ ./cc
len=7
limit=10 *p=99

같은 주소인데 limit은 10, *p는 99다. 컴파일러가 -O0에서도 const int를 상수 10으로 치환했기 때문이다.

다중 상속에서 주소가 달라지는 경우

두 번째 기반 클래스는 파생 객체 안에서 시작 위치가 다르다. static_cast는 이 오프셋을 보정하지만 reinterpret_cast는 주소를 그대로 둔다.

#include <cstdio>

struct Readable { virtual ~Readable() = default; int r = 1; };
struct Writable { virtual ~Writable() = default; int w = 2; };
struct File : Readable, Writable { int fd = 3; };

int main()
{
    File f;
    Writable* ws = static_cast<Writable*>(&f);        // 오프셋 보정
    Writable* wr = reinterpret_cast<Writable*>(&f);   // 주소 그대로
    Writable* wc = (Writable*)&f;                     // C 스타일: 여기서는 static_cast로 해석

    std::printf("&f = %p\n", static_cast<void*>(&f));
    std::printf("static_cast      = %p  w=%d\n", static_cast<void*>(ws), ws->w);
    std::printf("reinterpret_cast = %p  w=%d\n", static_cast<void*>(wr), wr->w);
    std::printf("C-style          = %p  w=%d\n", static_cast<void*>(wc), wc->w);
}
$ g++ -std=c++20 -Wall -o mi mi.cpp
$ ./mi
&f = 0x7ffd9e7f7fa0
static_cast      = 0x7ffd9e7f7fb0  w=2
reinterpret_cast = 0x7ffd9e7f7fa0  w=1
C-style          = 0x7ffd9e7f7fb0  w=2

reinterpret_cast 결과는 Readable 부분을 가리켜 w 자리에서 r 값 1을 읽었다. 여기서는 C 스타일 캐스트가 static_cast로 해석되어 맞는 값이 나왔다.

불완전 타입에서는 C 스타일 캐스트가 reinterpret_cast가 된다

C 스타일 캐스트는 const_cast → static_cast → reinterpret_cast 순으로 되는 것을 고른다. 전방 선언만 보이는 곳에서는 상속 관계를 알 수 없어 마지막 단계로 넘어간다.

#include <cstdio>

struct Writable;                 // 불완전 타입: 상속 관계를 컴파일러가 모른다
struct File;

Writable* as_writable(File* f)
{
    return (Writable*)f;         // C 스타일: static_cast 불가 -> reinterpret_cast로 조용히 전환
}

struct Readable { virtual ~Readable() = default; int r = 1; };
struct Writable { virtual ~Writable() = default; int w = 2; };
struct File : Readable, Writable { int fd = 3; };

int main()
{
    File f;
    std::printf("w=%d (expected 2)\n", as_writable(&f)->w);
}
$ g++ -std=c++20 -Wall -o inc incomplete.cpp
$ ./inc
w=1 (expected 2)

같은 코드를 static_cast로 쓰면 컴파일러가 불완전 타입이라 판단할 수 없다고 거부한다.

struct Writable;
struct File;

Writable* as_writable(File* f)
{
    return static_cast<Writable*>(f);
}
$ g++ -std=c++20 -Wall -c incomplete_err.cpp
incomplete_err.cpp: In function ‘Writable* as_writable(File*)’:
incomplete_err.cpp:6:12: error: invalid ‘static_cast’ from type ‘File*’ to type ‘Writable*’
    6 |     return static_cast<Writable*>(f);
      |            ^~~~~~~~~~~~~~~~~~~~~~~~~
incomplete_err.cpp:1:8: note: class type ‘Writable’ is incomplete
    1 | struct Writable;
      |        ^~~~~~~~
incomplete_err.cpp:2:8: note: class type ‘File’ is incomplete
    2 | struct File;
      |        ^~~~

reinterpret_cast와 std::bit_cast

#include <bit>
#include <cstdint>
#include <cstdio>

struct Header { std::uint16_t type; std::uint16_t len; };

int main()
{
    Header h{0x0102, 0x0304};

    // 1) 객체 표현을 바이트로 보기: unsigned char*는 aliasing 예외라 합법
    auto* bytes = reinterpret_cast<const unsigned char*>(&h);
    for (std::size_t i = 0; i < sizeof h; ++i) std::printf("%02x ", bytes[i]);
    std::printf("\n");

    // 2) 포인터 <-> 정수 (정렬 검사 등)
    auto addr = reinterpret_cast<std::uintptr_t>(&h);
    std::printf("aligned to 4: %s\n", addr % 4 == 0 ? "yes" : "no");

    // 3) 비트 패턴 재해석은 reinterpret_cast 대신 C++20 std::bit_cast
    float f = 1.0f;
    std::printf("bits of 1.0f = 0x%08x\n", std::bit_cast<std::uint32_t>(f));
}
$ g++ -std=c++20 -Wall -o reinterp reinterp.cpp
$ ./reinterp
02 01 04 03 
aligned to 4: yes
bits of 1.0f = 0x3f800000

리틀 엔디언이라 0x0102가 02 01로 저장된다. float를 uint32_t*로 reinterpret_cast해서 읽으면 strict aliasing 위반이지만, std::bit_cast는 값을 복사하므로 합법이다.

다운캐스트: static_cast와 dynamic_cast

#include <cstdio>

struct Shape  { virtual ~Shape() = default; };
struct Circle : Shape { double r = 1.5; };
struct Square : Shape { double side = 2.0; };

void inspect(Shape* s)
{
    Circle* bad = static_cast<Circle*>(s);           // 검사 없음
    Circle* chk = dynamic_cast<Circle*>(s);          // 실행 시점 검사, 실패하면 nullptr
    std::printf("static_cast r=%.1f, dynamic_cast %s\n",
                bad->r, chk ? "ok" : "nullptr");
}

int main()
{
    Circle c; Square q;
    inspect(&c);
    inspect(&q);       // Square를 Circle로 읽는다
}
$ g++ -std=c++20 -Wall -o dyncast dyncast.cpp
$ ./dyncast
static_cast r=1.5, dynamic_cast ok
static_cast r=2.0, dynamic_cast nullptr

static_cast 다운캐스트는 실제 타입을 검사하지 않아 Square의 side를 r로 읽었다. UBSan(-fsanitize=undefined)은 이 잘못된 다운캐스트를 실행 시점에 잡는다.

$ g++ -std=c++20 -Wall -g -fsanitize=undefined -o dyncast dyncast.cpp
$ ./dyncast 2>&1 | grep -E 'runtime error|object is of type' | head -2
dyncast.cpp:9:19: runtime error: downcast of address 0x7ffed875eed0 which does not point to an object of type 'Circle'
0x7ffed875eed0: note: object is of type 'Square'

dynamic_cast의 비용과 동작 원리는 이 시리즈의 업캐스팅, RTTI 편에서 다룬다.

주의사항

상황문제대응
C 스타일 캐스트의도와 다른 캐스트로 해석돼도 경고 없음-Wold-style-cast로 찾아 C++ 캐스트로 교체
원래 const인 객체를 const_cast 후 수정미정의 동작, 상수 치환으로 값 불일치수정이 필요하면 처음부터 const를 붙이지 않는다
다중 상속 포인터에 reinterpret_cast오프셋 보정 없음상속 관계 변환은 static_cast/dynamic_cast
전방 선언만 보이는 곳에서 캐스트C 스타일 캐스트가 reinterpret_cast로 전환정의를 include하고 static_cast 사용
근거 없는 static_cast 다운캐스트잘못된 타입으로 읽어도 동작타입이 불확실하면 dynamic_cast, 테스트는 UBSan
reinterpret_cast로 타입 퍼닝strict aliasing 위반std::bit_cast 또는 memcpy

마무리

  • C++ 캐스트는 목적별로 나뉘어 있어, 의도하지 않은 변환을 컴파일러가 거부한다.
  • C 스타일 캐스트는 상황에 따라 static_cast나 reinterpret_cast로 해석이 바뀌어 주소가 조용히 어긋날 수 있다.
  • const_cast는 const가 빠진 API 호환용으로만 쓰고, 비트 재해석은 std::bit_cast를 쓴다.

참고

답글 남기기