프로그래밍 언어/C & C++ 정리

Casting - 왜 C스타일 캐스팅은 4개로 나뉘었을까?

뽀또치즈맛 2024. 11. 24. 09:05

캐스팅(형 변환, Casting)

캐스팅은 형변환 하는 것을 말한다.

 

암시적(Implicit)캐스팅

프로그래머가 명시적으로 형 변환을 안할 경우에,

알아서 컴파일러가 형변환을 해준다.

단, 형변환이 허용되는 변환일 때에만 작동한다.

 

당연히, 프로그래머가 명시적 형변환을 할 때에는

컴파일러가 개입하지 않으므로 암시적 캐스팅은 일어나지 않는다.

 

예시 코드

int number1 = 3;
long number2 = number1;

 

 

명시적(Explicit) 캐스팅

 

C++ 캐스팅의 종류

  • static_cast
  • const_cast
  • dynamic_cast
  • reinterpret_cast

 

C 스타일 캐스팅이 C++에서 4가지로 나뉜 이유

int score = (int)someVariable;

 

위 코드는 대체 무얼 할까? 

위 코드는 C++ 스타일 4개의 캐스팅 중 하나를 의미한다.

(정확히 말하면 dynamic_cast 을 제외한 3개이다.)

하지만 코드만 봐서는 뭔가 명확하지 않다.

 

또한 컴파일러도 의도대로 돌아가지 않을 수 있다.

C스타일 캐스팅은 컴파일러가 4개 중 어느 것을 원하는지 모른다.

프로그래머가 이런 위험한 접근을 하려 하는 건지,

아니면 실수 한건지 모른다는 것이다.

 

그렇게 용도에 따라 나뉘어 컴파일러가 인간을 더 잘 돕게끔 만든 것이다.

그러나 어셈블리어 단에 가면, 기계는 신경 안쓴다.

C나 C++이나 똑같다.

전혀 캐스팅의 종류의 차이를 모른다.

늘 기계의 입장을 생각하고

그나마 기계어와 가까운 C언어에 없는 기능이라면,

아 원래 없는 건데 좀 더 인간 친화적으로 쓰기 위해서 만들었구나 하자.

 

그래서 C++에서 4개로 나뉘어 명확성을 띄게 만들었다.

각 캐스팅 별로 역할을 정해두어 C스타일 캐스팅을

4가지 역할로 나눈 것이다.

 

정적 캐스팅 (Static Casting)

C스타일과 C++의 용법 차이는 아래와 같다.

정적 캐스팅으로, 정적인 시간, 즉 컴파일 타임에 캐스팅 된다.

 

  • 1. 스태틱 케스트가 값에 쓰일 때,
    • 두 숫자 형 간의 변환인 예시)
      • 값을 유지하려 한다 (단, 반올림 오차는 제외한다)
      • 이진수 표기는 달라질 수 있음
int number1 = 3;
int number2 = static_cast<short>(number1);

int: 3 
0000 0000 0000 0011

static_cast<short> 이후

short : 3
          0000 0011

 

위 코드 예제를 보아.

int를 short를 줄였을 때,

비트가 뜻하는 의미는 바뀌지 않는다.

즉, 비트 값은 바뀌지 않지만,

전체 비트의 수가 줄었을 뿐이다.

 

그럼 float형을 int형으로 바꾸면 어떻게 될까?

비트가 표현하는 숫자가 바뀔까?

아니다. 비트가 표현하는 숫자는 바뀌지 않는다.

다만 flaot이 3.f를 표현하는 비트와,

int 3을 표현하는 비트가 다르니

float형의 3.f 표현하던 메모리 비트는,

스태틱 캐스팅 이후

int형에서 3을 표현할 수 있는 비트로 바뀐다.

float number1 = 3.f;
int number2 = static_cast<int>(number1);

float : 3 
0100 0000 0100 0000

static_cast<short> 이후

float: 3 
0000 0000 0000 0011

 

 

  • 2. 개체 포인터
    • 변수형 체크 후 베이스 클래스를 파생 클래스로 변환한다.
    • 컴파일 시에만 형 체크 가능하다.
    • 실행 도중 여전히 크래스가 날 수 있다.

즉, 너네 둘이 진짜 개체끼리 상속 관계가 있는가? 를 체크해주는 것이다.

Animal과 Cat 사이에 부모 자식 관계인지 확인하고

아니라면 컴파일 오류를 띄운다.

 

컴파일 시간에 오류를 잡아주므로,

정적 캐스팅, 스태틱 캐스팅임을 잊지말자.

 

그럼 업캐스팅(자식->부모)과 다운 캐스팅(부모->자식)의 위험성을 알아보자.

위에 실행 도중에 크래시가 날 수 있는 경우가 여기에 있다.

 

Animal* myPet = new Cat(2, "Coco");

Cat* myCat = static_cast<Cat*>(myPet);	// 자식은 부모가 가지는 변수 밑 함수를 가지고 있다.
					// 따라서 안전하다.

Dog* myDog = static_cast<Dog*>(myPet);	// 부모가 자식이 될 경우 컴파일은 된다.
myDog->GetDogHouseName();		// 그러나 위험하다.
					// Dog 클래스의 멤버를 가지고 있지 않기 때문에 
                                        // 크래시 위험이 있다.

 

컴파일 타임에 확인해주는 것은 상속관계인가? 를 잡아주는 것이지,

해당 자료형을 구성하기 위한 필요한 메모리가 할당되어 있는지의 여부는

확인해주지 않는다.

 

Animal* myPet = new Cat(2, "Coco");

House* myHouse = static_cast<House*>(myPet);	// 상속관계x 컴파일 에러
myHouse->GetAddress();

C스타일로 위 코드를 짜면 컴파일 에러가 안뜬다.

왜 그럴까? 스태틱 캐스트가 아닌

다른 캐스트로의 컴파일이 가능하 때문이다.

 

그럼 Cat을 Cat으로 캐스팅 하는 건? 된다.

같은 자료형끼리 캐스팅 된다.

Cat를 Dog로 캐스팅 하는 건? 된다.

형제 관계도 허용이 된다.

 

 

 

 

리인터프리트(Reinterpret) 캐스팅

 

 

reinterpret는 재해석이라는 의미이다.

C++/C에서 가장 위험한 캐스트 중 하나일 것이다.

가장 위험한 캐스팅이라 라고 생각하자.

 

그걸 방지하기 위해서 스태틱 캐스트가 나왔다고 이해하자.

안전하게 쓰기 위해서 스태틱 캐스트를 분류했다고 생각하자.

 

왜 리인터프리트(reinterpret) 캐스팅이 위험할까?

 

  • 1. 연관 없는 두 포인터 형 사이의 변환을 허용하기 때문이다.

위 그림처럼 연관 없는 두 포인터는

상속 관계나 어떠한 공통점도 없음에도 불구하고,

변환이 가능하다.

이러한 것은 해당 값의 안전성을 보장하기 힘들다.

 

  • 2. 포인터와 포인터 아닌 변수 사이의 형 변환을 허용

위 그림과 같이 포인터인 변수와 포인터가 아닌 변수 사이에

어떠한 유사 관계를 찾아볼 수 없다.

즉 성립 규칙이 제대로 된 것이 없다는 것이다.

이는 자유도가 높다는 뜻이고, 자유도가 높아지면

해당 기능에 대해서 안전성이 떨어진다는 것이다.

 

  • 3. 이진수 표기는 달라지지 않음
    • A형의 이진수 표기를 그냥 B형인 것처럼 해석한다.

위에서 float의 이진수 표기와, int의 이진수 표기는 다르다고 했다.

컴퓨터는 메모리에 값을 저장해둘 뿐이다.

그 값을 어떻게 해석할지는 결과적으로

코딩을 짤 때, 이 값은 이렇게 해석하라고 짜는 것이다.

어떠한 데이터 개체를 넣든, int를 넣든, flaot을 넣든,

모두 이진수로 뭔가 저장이 되는 것이다.

그 이진수를 어떻게 해석할지에 대한 것은 

전적으로 컴파일러가 해석할 뿐이지만,

그에 맞는 명령어를 우리가 작성해야 한다.

 

근데, 이러한 자료형에 따른 이진수 규칙을 무시하고

변환되지 않는 규칙을 이용한 캐스팅을 쓴다는 것은

안전성이 떨어지는 캐스팅이다.

 

쉽게 이해하기 위해서 그림으로 표현한 메모리를 보자.

이러면 되게 재밌는 일이 일어난다.

-10를 정수로 해석하면 되게 이상한 일이 일어난다.

마이너스인 숫자는 특별한 비트 표현이 있다.

가장 첫 번째 비트, 가장 큰 비트가 1로 설정이 되어있으면 마이너스다.

그 얘기는, unsigned 비트에서 마이너스 부호가 없는 것으로 처리하기 때문에

비트 그대로를 -10으로 표현하게 되면 굉장히 큰 숫자로 표현이 된다.

 

 

unsigned int 최댓값 -9인 값이 나오게 된다.

 

그럼 아래 코드를 통해 왜 static_cast가 나오게 된 건지 생각해보자.

int* signedNumber = new int(-10);

// 컴파일 에러. 유효하지 않은 형 변환
unsigned int* unsignedNumber1 = static_cast<unsigned int*>(signedNumber);

// 컴파일 됨, 허나 값은 더 이상 -10이 아니다.
usigned int* unsignedNumber2 = reinterpret_cast<unsigned int*>(signedNumber);

 

staitc_cast는 이러한 비트값이 변환이 안되서,

-10이 엄청 큰 값으로 변환되는 걸 원하지 않을 때 쓰는 것이다.

만약 그걸 원하면 static_cast 쓰면 안돼 를 잡아주기 위해서이다.

 

하지만 위험하다고 못쓰는 건아니다.

이러한 특징을 유용하게 쓸 곳은 있다.

그에 대한 예시로

포인터들을 직렬화(serialize)해야 되는 경우이다.

 

오프셋을 저장하기 위한 방법

 

예를 들어 포인터가 있다. 

이 포인터들을 하드에 저장해야 해야 될 때가 있다.

엄밀히 말하자면 포인터를 저장하는 것은 아니다.

 

왜냐하면 메모리에 예를 들어 4096에 어떤 오브젝트가 있다.

그리고 그 어떤 다른 오브젝트가 이 오브젝트를 참조하고 있는 경우는

다른 오브젝트 안에 이 오브젝트의 주소를 가지고 있는 것이다.

 

그러면 데이터를 기억해두기 위해서

그 주소를 읽어와 본들,

컴퓨터를 껐다 키면 그 주소지에 아무것도 없다.

근데 데이터를 예쁘게 저장하면,

즉 구조체 같은 걸로 만들어서,

이 구조체를 하드에 저장하게 되고,

그 구조체 중간에 어떤 포인터가 있었고

그 포인터가 이제 가리키는 오브젝트를 다시 또 하드에 저장한다.

 

그러면 순서가 원래 처음에 있던 오브젝트,

그리고 그 오브젝트 안에서 포인트 했던

오브젝트가 이제 순서대로 저장이 되어있다.

 

그러면 아까 있던 그 포인터 변수는

의미가 없지만,

이 포인터 변수에 지금 내 위치로부터 몇 바이트 뒤에

이 오브젝트가 있어다는 그런 오프셋 개념을 저장할 수가 있다.

 

그런 예를 간단히 하기 위해서

포인터를 저장하겠다는 예시를 코드로 써보자.

 

이 포인터를 저장하려면 

이 포인터를 저장할 수 있는 데이터 타입으로 바꿔야 한다.

따라서 int로 바꿔서 저장해야한다.

 

그렇게 할 때는 재해석을 해야만 하고,

그럴 때는 reinterpret_cast를 사용해야 한다.

 

실제 저장하는 코드는 생략하고

전반적으로 이런 예시가 있다는 것만 이해하고 넘어가보자.

void ObjectAddressSavingExample()
{
	Tiger* tiger = new Tiger(5);
    unsigned int intAddress = reinterpret_cast<unsigned int>(tiger);
    
    cout << "saving address as int : " << intAddress << endl;
    cout << "read int address to pointer" << endl;
    
    tiger = reinterpret_cast<Tiger*>(intAdress);
    tiger->PretendIAmAZebra();
    
    delete tiger;
}

 

나중에 C++를 이용해서

데이터를 순서대로 저장하고 오프셋만 저장하는 방법,

그리고 로딩한 다음에 그 포인터 주소 오프셋에기반해서,

내 현재 오브젝트에 현재 주소를 기반해서

오프셋 연산만 하는 것,

이러한 연산으로 포인터를 다시 복구할 수 있다는 것만 알아도

근무할 때 이러한 예시를 응용할 일이 있으면

이러한 경우를 알고 있다는 것만으로도 도움이 될 것이다.

 

어떻게 해야하는 지는 일하면서 필요할 때 배우자.

 

컨스트(Const) 캐스팅

 

const를 const가 아닌 변수로 변환시켜 주는 것이다.

함수의 시그네처를 해치기 때문에 굉장히 나쁜 코드이다.

  • const_cast로는 형을 바꿀 수 없다.
  • const 또는 volatile 애트리뷰트를 제거할 때 사용한다.
  • 포인터 형에 사용할 때만 가능하다.
    • 값 형은 언제나 복사되니까
  • 코딩 스타일 측면
    • const_cast를 코드에 쓰려고 한다면,
      무언가 잘못하고 있는 것이다.

 

위험하기 보단

규약으로 정해놓은 걸 굳이 다시 규약을 깨는 것은

하지 말아야 할 캐스트라고 생각하면 좋다.

 

누군가 500줄을 규약에 맞게 짜놨는데

다른 누군가가 2줄을 규약을 깨놓아서 짠다면

좀 복잡해질 수 있기 때문이다.

 

왜 const_cast가 쓰면 안좋은가?

const_cast는 형을 바꿀 수 없다.

즉 이 친구가 하는 일은 순수하게

cosnt를 빼는 것만 가능하다는 것이다.

정확히 얘기하면

const 또는 volatile 애트리뷰트를 제거할 때 사용한다. 

근데 volatile 은 컴파일러의 재량을 제한하는 역할을 한다.

즉 const와 volatile 둘 다,

특정 조건을 달아놓고 쓰는 애들이다.

 

그런 규약을 깨는 것만을 하는 역할의 cast이니,

굳이굳이 쓸 필요는 없는 것이다.

Animal* myPet = new Cat(2, "Coco");
const Animal* petPtr = myPet;

//C-style
Animal* myAnimal1 = (Animal*)petPtr			// OK
Cat* myCat1 = (Cat*)petPtr;				// OK

Animal* myAnimal2 = const_cast<Animal*>(petPtr);	//OK
Cat* myCat2 = const_cast<Cat*>(petPtr);		// 컴파일 애러
						// 형변환 지원 안해준다.

 

 

형변환을 할 거면 static_cast 하는 게 맞지 왜 const_cast쓰냐고

컴파일러가 걸러주는 것이다.

형변환을 하도록 쓰면,

사람한테 어 이거 우리가 이렇게 쓰자고 약속한 거 아닌데,

너 틀리게 썼어 하고 컴파일러가 사람에게 알려주게 된다.

 

근데 const_cast 할 수 있다는 건 알겠는데,

하지마세요.

 

나는 이거 진짜 헤더에서 안바꾸겠다고 약속했는데,

바꾸고 있는 것이다.

가장 중요한 약속은 헤더에서 시작한다.

근데 그 헤더에서 약속한 const를 바꾸고 있으면 되게 잘못하고 있는 것이다.

 

근데 이런 const cast를 한번 쯤은 쓸 때가 있다.

const_cast를 사용할 때는?

  • 서드파트 라이브러리가 const를 제대로 사용하지 않을 때
void WriteLine(char* ptr); //뭔가 별로인 외부 라이브러리
void MyWriteLine(const char* ptr) { // 우리 프로그램에 있는 함수
	WirteLine(const_cast<char*>(ptr));
}

코드를 볼 수 없는 라이브러리가

코드를 잘못 짜 놓은 경우, const로 넣으면 에러가 나게 된다.

그러면 const 벗겨주는 cast를 사용해서 쓸 때 쓴다.

 

근데, 이 외에는 진짜 안된다.

근데 애초에 저런 식으로 const를 빼는 것은 정말 잘못쓰고 있는 것이다.

 

 

 

다이나믹(Dynamic) 캐스팅

 

다이나믹 캐스팅은 말 그대로 동적인 시간에 캐스팅 되는 것이다.

즉 런타임에 캐스팅을 하는 것이다.

dynamic_cast를 쓰려면 컴파일 중에 RTTI(실시간 타입정보)를 켜야한다.

RTTI를 키지 않으면 static_cast와 똑같이 작동한다.

 

C++ 프로젝트에서는 RTTI를 끄는 것이 보통이다.

 

C++는 성능 중요시하는 업계에서 쓰는 언어이다.

RTTI를 켜서 오는 부하를 허용하지 않는 프로젝트가 많다.

따라서 dynamic_cast는 무용지물인 경우가 많다.

 

실제로는 사실상 쓸모 없는 개념이 dynamic_cast이다.

 

  • 실행 중에 형을 판단한다.
  • 포인터 또는 참조 형을 캐스팅할 때만 쓸 수 있다.
  • 호환되지 않는 자식형으로 캐스팅하려 하면 NULL을 반환한다.
    • 따라서, dynamic_cast가 static_cast보다 안전하다.

 

 

static_cast vs dynamic_cast

즉, 호환되지 않는 자식형으로 캐스팅하려하면 안된다.

너 부모 자식 관계 아닌데 왜 형제끼리 옮겨갈라해? 위험해 안돼 하는 것이다.

그러나 dynamic_cast를 쓰려면 컴파일 중에 RTTI(실시간 타입정보)를 켜야한다.

RTTI를 키지 않으면 static_cast와 똑같이 작동한다.

 

RTTI를 키면,

각 오브젝트마다 typeid를 가져올 수 있다.

 

C++ 프로젝트에서는 RTTI를 끄는 것이 보통이다.

 

왜? C++는 성능 중요시하는 업계에서 쓰는 언어이다.

RTTI를 켜서 오는 부하를 허용하지 않는 프로젝트가 많다.

따라서 dynamic_cast는 무용지물인 경우가 많다.

 

하지만 RTTI를 직접 넣는 경우도 있다.

GetType()같은 함수를 부모 클래스에 미리 만들어 두는 경우가 있다.

그럼 RTTI돌듯이 내 타입을 반환하는 것을 구현 할 수는 있다.

 

모든 오브젝트의 타입 정보를 반환할 필요는 없으니까 말이다.

실제로는 사실상 쓸모 없는 개념이 dynamic_cast이다.

 

 

캐스팅 규칙

 

규칙 (제일 안전한 것 -> 가장 위험한 것)

 

 

  • 1. 기본적으로 static_cast를 쓸 것
  • 2. 그게 안될 때 reinterpret_cast를 쓸 것
    • 포인터와 비포인터 사이의 반환
      • 이걸 정말 해야 할 때가 있긴 하다.
    • 서로 연관이 없는 포인터 사이의 반환은
      그 데이터형이 맞다 정말 확신할 때만 할 것
  • 3. 내가 변경권한이 없는 외부 라이브러리를 호출할 때만 const_cast
    (정말 쓸일이 없어야 한다.)

  • 4.성능이 떨어져서 쓸 일 없어서 안넣음

 

이러한 규칙을 따르는 것이 좋은 이유는

사람은 실수할 수 밖에 없다.

모든 코드를 다 집중해서 조심하여 디버깅 하기 보단,

코딩 규칙을 잘 따르면서

안전한 것은 느긋하게 보다가

위험한 요소에 집중해서 잘 보는 게 더 효율적이기 때문이다.

'프로그래밍 언어 > C & C++ 정리' 카테고리의 다른 글

그래서 다형성이 뭐라고요?  (0) 2024.11.26
인라인 함수  (0) 2024.11.25
인터페이스  (0) 2024.11.22
가상 소멸자와 비 가상 소멸자  (0) 2024.11.21
다형성(Polymorphism)  (2) 2024.11.20