C 스타일 캐스트 (T)x는 무엇이든 통과시킨다. const를 떼는 것도, 전혀 관계없는 타입의 포인터로 바꾸는 것도 경고 없이 한 줄에 끝난다. 다중 상속이나 불완전 타입이 섞이면 같은 문법이 상황에 따라 다른 변환으로 해석되어 주소가 어긋나기도 한다. C++은 이 변환을 목적별로 나눈 네 가지 캐스트 연산자(static_cast, const_cast, reinterpret_cast, dynamic_cast)를 제공한다. 이 글에서는 g++ 13.3으로 각 캐스트가 무엇을 허용하고 거부하는지, C 스타일 캐스트가 어디서 조용히 틀리는지 확인한다.
이 시리즈의 다른 글
- C++ 고급 문법 (4) — 함수 객체(Function Object)
- C++ 고급 문법 (5) — 임시 객체(Temporary Object)
- C++ 고급 문법 (7) — 생성자(Constructor)
- C++ 고급 문법 (8) — const 멤버 함수(const Member Function)
네 가지 캐스트 연산자
| 연산자 | 용도 | 검사 시점 |
|---|---|---|
static_cast | 수치 변환, enum ↔ 정수, void* → T*, 상속 관계 포인터 변환 | 컴파일 타임 |
const_cast | const/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를 쓴다.