
c++을 사용해 코딩을 하다보면 캐스팅을 참 다양한 방법으로 한다는 사실을 알 수 있다. 그동안 c#을 사용할때는 타입 안정성을 알아서 해줘서 몰랐는데 c++은 유연하기 때문에 어떤 캐스팅을 쓰는지 중요한 것이다.
캐스팅을 특정 타입을 다른 타입으로 변환하는 과정이라고 이해해도 좋지만 좀 더 본질적으로는 컴파일러에게 "이 데이터가 특정 타입으로 변환되어도 괜찮다.(안전하다)"라고 알리는 것이라고 생각하면 좋다.
자식->부모 클래스로 변환하는 것을 의미한다. 좀 더 풀어서 설명하면 자식 클래스를 부모 클래스 타입으로 바라보게 만드는 것이다.
주로 다형성을 사용하기 위해서 자주 사용하게 된다.
⚠️ 자식을 부모로 바라보는 것이기 때문에 자식 멤버 함수나 변수는 접근을 할 수 없다. 사용하기 위해서는 나중에 "다운 캐스팅"을 해서 사용하면 된다.
부모->자식 클래스로 변환하는 것을 의미한다. 그래서 다형성을 사용하다가 특정 파생 클래스의 기능을 사용하기 위해서는 무조건 다운캐스팅을 하게 되는데, 이때 명시적으로 작성해주는 것이다.
class Base
{
public:
Base(int id) : id(id) {}
virtual void print()
{
cout << "Base ID: " << id << endl;
}
void displayBase()
{
cout << "Base Display ID: " << id << endl;
}
private:
int id;
};
class Derived : public Base
{
public:
Derived(int id, const string& name) : Base(id), name(name) {}
void print() override
{
Base::print();
cout << "Derived Name: " << name << endl;
}
void displayDerived()
{
cout << "Derived Display Name: " << name << endl;
}
private:
string name;
};
int main()
{
Base* basePtr = new Derived(1, "Derived Object");
basePtr->print(); // Base의 print() 호출
//basePtr->displayDerived(); // 컴파일 에러: Base 클래스에는 displayDerived()가 없음
Derived* derivedPtr = (Derived*)basePtr;
if (derivedPtr)
{
derivedPtr->displayDerived(); // Derived 클래스의 displayDerived() 호출
}
else
{
cout << "Downcasting failed." << endl;
}
}
이제 본격적으로 c++ 캐스팅에 대해서 알아보도록 하겠다.
c++에서는 핵심적인 4가지 캐스팅이 존재한다.
<T>()const 캐스트 같은 경우는 포인터나 레퍼런스의 상수성(const)를 잠시 제거해 주는 캐스팅이다.
const int x = 10;
int* ptr = const_cast<int*>(&x);
*ptr = 20; // 컴파일 OK
cout << x;
🤔 그렇다면 const_cast는 왜 사용하는 걸까? 어차피 UB문제가 생기는거 아닌가?
✅ 진짜 const를 바꿀때는 위험하지만 가짜 const는 바꿔도 되기 때문이다.
int main()
{
const int x = 10;
int* ptr = const_cast<int*>(&x);
*ptr = 20;
cout << "Value of x: " << x << endl; // Undefined behavior
}
const_cast를 하면 결과가 이상하게 나올 것이다.// 가짜 const: 실제 객체는 mutable
int realData = 10; // 수정 가능한 객체
const int* constPtr = &realData; // 포인터만 const로 제한
int* mutablePtr = const_cast<int*>(constPtr);
*mutablePtr = 20; // 안전! realData도 20으로 변경
cout << realData << endl; // 20 출력
const_cast를 사용하면 const의 개념은 사용하면서 UB문제는 해결 할 수 있는 것이다.🤔그럼 이러한 const_cast는 어느 상황에서 주로 사용될까? 정답은 바로 레거시 C 라이브러리들을 연동할때 많이 사용된다. c스타일은 기본적으로 실제 데이터에는 mutuable하게 하고 포인터에 const를 하는것을 설계원칙으로 했기 때문에 필요한 순간에 const_cast를 이용해 연동을 쉽게 할 수 있는 것이다.
T>()down casting을 하는 과정에서 안전하게 처리할 수 있도록 하기 위해서 사용된다. 런타임에 실제 객체 타입을 확인한다.
🤔 어떻게 이게 가능하지?
✅ 정답은 바로 vtable에 있다. 클래스가 vtable이 생기게 되면 RTTI라는 것도 같이 가지고 있게 된다. 이는 런타임에 실제 타입을 알려주는 메타 데이터이고 무조건 vtable이 있어야 여기에 저장이 된다. 따라서 클래스가 vtable이 없다면 RTTI도 없는 것이고 결국 dynamic_cast도 오류가 나는 것이다.
그래서 핵심은 부모 클래스에 virtual키워드의 함수가 존재해야 한다는 것이다. 그래야지만 vtable이 생겨서 여기서 RTTI가 생기고 이를 통해 타입을 확인할 수 있기 때문이다.
class Base {
virtual ~Base() {} // RTTI 활성화!
};
class Player : public Base {};
class Monster : public Base {};
Base* obj = new Player(); // 실제 Player 객체
int main()
{
Player* player = dynamic_cast<Player*>(obj);
if (player)
{
player->playerOnlyFunc(); // 안전!
}
else
{
cout << "Not a Player!" << endl;
}
}
❌ 하지만 dynamic_cast는 vtable을 런타임에 봐야하는 성질 때문에 성능이 오버헤드 된다는 큰 단점이 존재한다. 따라서 4가지 casting중에서 가장 인기가 없다고 할 수 있다.
T>()static_cast는 컴파일 타임에 안전성을 검사하는 명시적 변환이다. 형 변환 시점이 컴파일이라 static이라는 키워드가 붙는다고 생각하면 된다.
한번 정리를 해보자
<허용하는 포인터 변환>
1. void* → T* (C API 연동)
2. 배열 → 포인터 (arr → arr[0])
3. Derived* → Base* (업캐스트)
4. Base* → Derived* (다운캐스트 - 위험!)
5. 0 → T* (nullptr 변환)
<차단하는 변환>
1. char* → int*
2. float* → int*
3. 무관련 클래스*
1. void* -> T*
// Win32 AcceptEx 콜백에서!
void WINAPI AcceptExProc(
void* buffer, // void* 출력버퍼
DWORD bytesReceived
) {
PacketHeader* pkt = static_cast<PacketHeader*>(buffer);
processPacket(pkt);
}
2. arr -> arr
char recvBuf[4096]; // 패킷 버퍼
int32_t* coords = static_cast<int32_t*>(recvBuf + 8);
coords[0] = 100; // recvBuf[8] 쓰기
coords[1] = 200; // recvBuf[12] 쓰기
3. 자식* -> 부모*
class Packet { virtual ~Packet() {} };
class MovePacket : public Packet {
int x, y;
};
MovePacket mp{100, 200};
Packet* base = static_cast<Packet*>(&mp); // 완벽 안전!
packetQueue.push(base); // 기반 클래스 큐
4. 부모* -> 자식*
Packet* pkt = packetQueue.front();
MovePacket* move = static_cast<MovePacket*>(pkt); // 컴파일 OK!
// 하지만 "확신할 때만" 사용 (대부분 dynamic_cast를 사용해 안전하게 처리함.)
move->x = 300; // pkt가 실제 MovePacket 아니면 UB!
5. 0 → T*
class IOCPSession {
Packet* currentPacket = static_cast<Packet*>(nullptr); // 명시적.
void reset() {
currentPacket = static_cast<Packet*>(0); // 같은 의미!
}
};
이런식으로 static_cast를 사용할 수 있다.
반면에 차단하는 방식도 존재한다.
1. char* → int*
char recvBuf[64]; // 패킷 버퍼
int* bad_coords = static_cast<int*>(recvBuf); // 컴파일 에러!
2. float* -> int*
float position[4] = {1.0f, 2.0f, 3.0f, 4.0f};
int* bad_ints = static_cast<int*>(position);
3. 무관한 클래스*
class Packet {};
class Player {};
Packet* pkt = new Packet();
Player* bad_player = static_cast<Player*>(pkt);
T>()reinterpret는 일단 "재해석 하다." 라는 뜻을 가지고 있다.
바로 비트 패턴을 재해석하고 실제 변환은 하지 않는 캐스팅을 의미한다.
참고로 cast 4대장 중 가장 위험한 놈이다.
조금 더 쉽게 말해보자면 해석 방식만 바꾸기 때문에 전혀 상관없는 타입끼리도 강제로 변환이 가능해서 해석만 다르게 한다는 것이다.
🤔 그렇다면 이러한 비트 재해석은 어디에서 사용이 될까?
✅ 주로 메모리 구조를 직접 다뤄야 하는 하드웨어 접근, 네트워크 패킷 해석, 파일 포맷 처리 등등에서 사용이 된다. 타입이 다른데 메모리 구조가 동일한 경우, 혹은 비트 단위 해석이 필요한 경우에 사용이 된다.
☝️참고로 여기서 말하고 있는 비트 해석이란 메모리에 저장된 실제 0과 1의 나열을 의미하는 것이다.
int a = 65;
char* c = reinterpret_cast<char*>(&a);
01000001로 저장이 된다. 이 주소를 재해석 해서 char로 바꿔서 읽겠다는 의미이다. 따라서 결과는 a가 될것이다.struct Header
{
uint16_t type;
uint16_t length;
uint32_t id;
};
int main()
{
char buffer[2];
Header* hdr = reinterpret_cast<Header*>(buffer);
hdr->id = 123;
cout << "Header ID: " << hdr->id << endl;
}
reinterpret_cast의 매우 위험한 상황에 대해서 알아보자.Header로 변환을 해서 멤버변수 id에 접근하고 있는 상태다.⚠️ 하지만 위에 있는 코드를 실행해보면 정상적으로 Header ID: 123이라고 출력이 되게 된다. 여기서 매우 위험한 동작이 일어나게 된다.
struct Header
{
uint16_t type;
uint32_t id;
};
struct LayoutTest {
char buffer[2];
volatile int value;
};
int main()
{
LayoutTest test = {};
test.buffer[0] = 0;
Header* hdr = reinterpret_cast<Header*>(test.buffer);
hdr->id = 123;
cout << "Header ID: " << hdr->id << endl; // 123
cout << "Value: " << test.value << endl; // 123
}
🤔 그렇다면 왜 이러한 문제가 발생하는 것일까? 애초에 접근하는 범위가 처음 할당된 것을 넘게 되면 오버플로우 문제가 발생해야 하는 것 아닌가?
✅ 여기서는 페이징이라는 개념이 추가적으로 등장하게 된다. 페이징이라는 것은 쉽게 말해서 어떤 함수가 호출이 되면 4KB의 메모리 공간을 할당해준다. 정해진 크기를 할당해주는 이유는 외부 파편화를 해결하기 위해서 그런것이다.
페이지를 할당받으면 컴파일러가 변수들을 이 페이지 내에 배치 시키게 된다. 그래서 이 범위 안에서는 자유롭게 읽고 쓰고가 가능한데, 특정 변수의 크기를 벗어나서 수정을 해도 OS는 페이지 경계를 넘지 않았기 때문에 문제를 식별하지 못한다. 따라서 다른 변수의 일정 부분의 비트를 수정할 수 있는 것이다. 그리고 이것은 의도치 않은 값의 변경으로 파생되는 문제가 발생하게 되는 것이다.
이걸 해결하기 위한 방법으로 미리 버퍼의 사이즈와 캐스팅하려고 하는 Header의 사이즈를 비교하는 방법이나, 경계를 넣어서 검사하는 방법등이 있을 것이다.
사실 bit_cast는 c++언어에 일부인 다른 캐스들과는 달리
<bit>헤더에 정의되어 있다. reinterpret_cast와 비슷하지만 주어진 타겟 타입의 새로운 객체를 생성하고 원본 객체를 비트로 복사하는 것이다.
따라서 bit_cast를 사용하기 위해서는 둘 다 복사 가능이여야 하고, 크기도 같아야 한다.
struct Header
{
uint16_t type;
uint32_t id;
};
struct LayoutTest {
char buffer[2];
volatile int value;
};
int main()
{
LayoutTest test = {};
test.buffer[0] = 0;
Header* hdr = bit_cast<Header*>(test.buffer);
hdr->id = 123;
cout << "Header ID: " << hdr->id << endl; // 123
}
uint16_t type = std::bit_cast<uint16_t>(test.buffer); 이런식으로 type에만 접근하는 방식으로 사용할 수 도 있다.
좋은 글 감사합니다