C++는 광범위한 아키텍처에서 이식 가능하고 성능이 뛰어나도록 설계되었기 때문에
언어 설계자는 특정 CPU가 우리가 사용하는 CPU의 자연 데이터 크기보다 좁은(작은 크기. 앞으로는 비트의 수, 너비로 표현) 값을 효율적으로 조작할 수 있다고 생각하지 않았다.
그래서 C++은 numeric promotion (숫자 승격)을 지원한다.
이것은 특정 좁은 숫자 타입을 효율적으로 처리할 수 있고, 오버플로 가능성이 적은 더 넓은 숫자 타입으로 변환하는 것이다.
승격이 된다고 해서 값이 변하는 것은 아니다. 변환된 값은 항상 source값과 동일하여 보존된다. 그냥 타입만 달라질 뿐이다.
이러한 숫자 승격 규칙은 integral promotions및 floating point promotions 의 두 가지 하위 범주로 나뉜다.
floating point promotions
이 규칙은 float타입의 값을 double 타입의 값으로 변환할 수 있다.
double이나 float값 둘 중 하나를 사용해서 double값으로 함수를 쓸 수 있다는 것이다.
void printDouble(double d)
{
std::cout << d << '\n';
}
int main()
{
printDouble(5.0); // no conversion necessary
printDouble(4.0f); // numeric promotion of float to double
return 0;
}
이러면 4.0f도 double로 승격되어 double타입을 갖는다.
integral promotions
integral은 살짝 복잡하다.
integral 규칙을 사용하면 여러 변환들이 이루어지는데
- signed char 또는 signed short는 int로 변환될 수 있다.
- unsigned char, char8_t 및 unsigned short는
int가 유형의 전체 범위를 보유할 수 있는 경우 int로 변환될 수 있고, 그렇지 않으면 unsigned int로 변환될 수 있다. - char에 부호가 있으면 signed char 변환 규칙을 따르고, 부호가 없으면 위에서 말한것 그대로 따른다.
- bool은 int로 변환될 수 있으며 false는 0이 되고 true는 1이 된다.
요약하자면 부호 없으면 0~255니까 -128~127 범위 포함되는 경우만 부호에 맞게 int로 변환한다는 것이다.
이경우는 값이 보존되는데 저 범위를 넘어가면 값을 보존하지 않는다. (오버플로)
간단하게 bool, signed unsigned char, short가 모두 int로 승격된다는 말.
#include <iostream>
void printInt(int x)
{
std::cout << x << '\n';
}
int main()
{
printInt(2);
short s{ 3 }; // there is no short literal suffix, so we'll use a variable for this one
printInt(s); // numeric promotion of short to int
printInt('a'); // numeric promotion of char to int
printInt(true); // numeric promotion of bool to int
return 0;
}
조금 덧붙이자면, 일부 더 좁은 부호 없는 유형은 더 큰 부호 있는 유형으로 승격될 수 있다.
따라서 integral promotion은 가치를 보존하지만 타입의 부호까지 보존하진 않는다.
묵시적 변환의 종류들을 다시 정리해보자. 숫자 변환에는 다섯 가지 기본 유형이 있다.
1. 정수 타입을 다른 정수 타입으로 변환
short s = 3; // convert int to short
long l = 3; // convert int to long
char ch = s; // convert short to char
unsigned int u = 3; // convert int to unsigned int
2. 부동 소수점 타입을 다른 부동 소수점 타입으로 변환
float f = 3.0; // convert double to float
long double ld = 3.0; // convert double to long double
3. 부동 소수점 타입을 정수 타입으로 변환
int i = 3.5; // convert double to int
4. 정수 계열 타입을 부동 소수점 타입으로 변환
double d = 3; // convert int to double
5. 정수 타입 또는 부동 소수점 타입을 bool로 변환
bool b1 = 3; // convert int to bool
bool b2 = 3.0; // convert double to bool
축소 변환, constexpr 초기화
축소 변환은 대상 타입이 소스 타입의 모든 값을 보유하지 못할 수 있는 변환으로, 안전하지 않다.
아까 설명했던 변환과는 반대로 범위를 좁히는 것으로 정의된다.
대부분의 경우 컴파일러 오류를 일으키기 때문에 피해야 하지만, 의도적으로 변환해야 할 경우 static_cast<type>을 사용해서 명시적으로 표현해준다.
축소 변환의 소스 값을 런타임까지 알 수 없는 경우, 변환 결과도 런타임까지 확인할 수 없다.
이러한 경우 축소 변환이 값을 유지하는지 여부도 런타임까지 확인할 수 없다.
void print(unsigned int u) // note: unsigned
{
std::cout << u << '\n';
}
int main()
{
std::cout << "Enter an integral value: ";
int n{};
std::cin >> n; // enter 5 or -5
print(n); // conversion to unsigned may or may not preserve value
return 0;
}
여기서 컴파일러는 어떤 값이 입력될지 알 수 없기 때문에 에러를 반환한다.
그러나 대부분의 축소 변환 정의에는 "변환되는 값이 constexpr 및 ...이 아닌 경우"로 시작하는 예외 절이 있다!
축소 변환의 소스 값이 constexpr인 경우 변환할 특정 값을 컴파일러에 알려야 한다.
이러한 경우 컴파일러는 변환 자체를 수행한 다음 값이 유지되었는지 확인한다.
이 때 값이 유지되지 않으면 컴파일러는 오류로 인해 컴파일을 중단하고(unsigned에 -부호 넣는 경우), 값이 유지되면 축소변환된 것으로 간주되지 않는다.
이렇게 하는 것이 안전하기 때문에 컴파일러는 전체 변환을 변환된 결과로 바꿀 수 있다.
간단하게, constexpr을 사용하면 축소 변환으로 인식하지 않는다.
그리고 일반적으로 축소변환은 컴파일 에러를 발생한다.
int n { 5.0 }; // compile error: narrowing conversion
constexpr double d { 0.1 };
float f { d }; // not narrowing, even though loss of precision results
명시적 타입 캐스팅
C++에선 C-style casts 및 static casts, const casts, dynamic casts, reinterpret casts 이렇게 여러가지 타입 캐스팅을 지원한다. C-style casts을 제외한 나머지 4개는 명시적 캐스팅이라 부른다.
그 중 Const casts 이랑 reinterpret casts 는 드문 경우에만 유용하고 대부분 코드에 해롭기 때문에 일반적으로 사용하지 않는다.
그럼 C-style casts 이랑 static casts, dynamic casts 만 남았다.
static cast는 이미 자주 쓰고있으니 C-style과 dynamic만 더 알아보면 되겠다.
C-style casts
double d { (double)x / y }; // convert x to a double so we get floating point division
double d { double(x) / y };
괄호 ()를 사용하여 타입이나 변수를 괄호 안에 넣어서 변환한다.
하지만 C-style casts 는 단일 캐스트처럼 보여도 static cast, const cast, reinterpret cast 가 포함되어 있기 때문에 상황에 따라 다양하게 변환을 수행할 수 있다. (여기서 아까 const랑 reinterpret은 피해야함)
결과적으로 C 스타일의 캐스트는 실수로 잘못 사용되어 예상되지 않은 행동을 나타낼 위험이 있다.
그냥 사용하지 말자.
static_cast
static_cast<type>(expr)
이렇게 표현식을 입력으로 사용하고 꺾쇠 괄호 안에 지정된 타입으로 변환된 평가 값을 반환한다.
우리가 아는 타입 캐스팅이다.
이 캐스팅의 가장 큰 장점은 컴파일 타임 타입 검사를 제공하여 부주의한 오류를 발생시키기 어렵게 한다는 것이다.
또한 의도적으로 C style보다 약하게 하여 의도하지 않은 작업을 수행할 수 없게 만들었다.(상수 제거 등)
Type aliases
C++에서 using은 기존 데이터 타입에 대한 별칭을 만드는 키워드이다.
using Distance = double; // define Distance as an alias for type double
이렇게 별명을 갖게 된 타입은 해당 블록 내에서만 사용할 수 있거나 전역 네임스페이스에 선언할 시 전역범위를 갖는다.
여러 파일에서 하나 이상의 타입 별칭을 사용해야 하는 경우, 헤더 파일에 정의하고 필요 시 #include한다.
#include로 가져오면 전역 네임스페이스로 가져와지므로 전역 범위를 갖는다.
이 외에 Typedef를 사용하여 별칭을 만들수도 있다.
// The following aliases are identical
typedef long Miles; // 특이하게 using과 달리 type 이후 별칭;
using Miles = long; // using 별칭 = 타입;
Typedef는 이전 버전과의 호환성 때문에 여전히 C++에 있지만, 최신 C++에서는 대부분 type aliases으로 대체됐다.
구식 답게 복잡해서 읽기도 힘들고 선언도 헷갈린다. 그냥 다 잊고 using으로 선언하자.
그래서 이런 별명을 왜 사용하는걸까?
주로 플랫폼별 세부 정보를 숨기고 싶을 때 사용한다.
예를들어 어떤 플랫폼에선 int가 2byte인데 다른데선 4byte인 경우가 있다.
그래서 type aliases를 사용하면 타입 크기를 사용하는 데에 실수를 방지할 수 있다.
#ifdef INT_2_BYTES
using int8_t = char;
using int16_t = int;
using int32_t = long;
#else
using int8_t = char;
using int16_t = short;
using int32_t = int;
#endif
이것만이 아니다. type aliases를 사용하면 복잡한 타입을 더 쉽게 읽을 수 있다.
타입이 뭐 어렵다고 얼마나 복잡하겠나 싶지만, 고급 C++에서 수동으로 입력하기엔 좀 복잡한 경우가 있다.
복잡한 코드 예시
#include <string> // for std::string
#include <vector> // for std::vector
#include <utility> // for std::pair
bool hasDuplicates(std::vector<std::pair<std::string, int>> pairlist)
{
// some code here
return false;
}
int main()
{
std::vector<std::pair<std::string, int>> pairlist;
return 0;
}
추가로 일일이 std::어쩌구 쭈욱 쓰기엔 많이 번거롭다.
그래서 이런 경우 타입에 별명을 붙여주면 아주 유익하다.
#include <string> // for std::string
#include <vector> // for std::vector
#include <utility> // for std::pair
using VectPairSI = std::vector<std::pair<std::string, int>>; // make VectPairSI an alias for this crazy type
bool hasDuplicates(VectPairSI pairlist) // use VectPairSI in a function parameter
{
// some code here
return false;
}
int main()
{
VectPairSI pairlist; // instantiate a VectPairSI variable
return 0;
}
이외에도 별명을 붙이면 특정 변수의 타입을 전부 다른 타입으로 바꿔야 할 때 관리하기 쉽다.
타입들을 한번에 업데이트하거나, 예를들어 int가 어떤 의미를 갖는지 명확하게 하기 위해
using TestScore = int;
TestScore gradeTest();
이런 형식으로 표현할 수 있다.
그런데 사실 쓰이는 경우는 못봤다. 이건 그냥 알아만 두자..
Auto 키워드를 사용하여 객체의 type 추론해보기
타입 추론은 컴파일러가 객체의 초기화 부분에서 객체 타입을 추론할 수 있는 기능이다.
변수와 함께 사용하려면 변수의 타입 대신 auto키워드가 대신 사용된다.
int main()
{
auto d{ 5.0 }; // 5.0 is a double literal, so d will be type double
auto i{ 1 + 2 }; // 1 + 2 evaluates to an int, so i will be type int
auto x { i }; // i is an int, so x will be type int too
auto a { 1.23f }; // f suffix causes a to be deduced to float
auto b { 5u }; // u suffix causes b to be deduced to unsigned int
return 0;
}
여기서 d는 5.0으로 초기화됐기 때문에 컴파일러는 d의 타입이 double이어야 한다고 추론한다.
하지만 만약 초기화가 없는 개체에는 타입 추론이 작동하지 않는다. 물론 void타입 함수도 포함이다.
그런데 아직 auto의 필요성이 와닿진 않는다.
지금 당장에 필요한건 아니고, 향후 타입이 복잡하고 길어지는 경우 auto를 사용하면 많은 타이핑이나 오타를 줄일 수 있다. (포인터나 참조의 경우 등?)
예를들면 타입 추론은 초기화가 된 변수에서만 작동하므로, auto를 사용하면 의도치 않게 초기화되지 않은 변수를 피하는데 도움이 될 수 있다.
int x; // oops, we forgot to initialize x, but the compiler may not complain
auto y; // the compiler will error out because it can't deduce a type for y
주로객체의 타입이 중요하지 않은 경우 변수에 auto를 사용한다고 한다.
추가로 auto는 const / constexpr 한정자를 삭제한다.
예를들어 const int 타입의 변수 x를 auto y에 대입하여 불러보면 const가 삭제되고 int만 추론한다.
만약 추론된 유형이 constexpr을 갖게하고 싶다면 constexpr auto x {} 로 직접 써야한다.