우리는 챕터 4에서 형 변환에 대해 소개한 바 있습니다. 해당 레슨의 가장 중요한 점을 요약하면 다음과 같습니다.
static_cast와 같은 캐스트(cast) 연산자를 사용하여 개발자가 직접 요청하는 변환입니다.객체의 값은 메모리에 비트들의 연속으로 저장됩니다.
그리고 데이터 타입은 그 비트들을 어떤 값으로 해석해야 하는지 컴파일러에게 알려주는 역할을 합니다.
데이터 타입이 다르면 같은 숫자라도 메모리에 저장되는 비트 형태는 달라질 수 있습니다.
예를 들어, 정수 3은
0000 0000 0000 0000 0000 0000 0000 0011
과 같은 비트 형태로 저장될 수 있습니다.
반면, 실수 3.0은
0100 0000 0100 0000 0000 0000 0000 0000
처럼 전혀 다른 비트 형태로 저장됩니다.
즉, 값이 비슷해 보여도 데이터 타입이 다르면 메모리에 저장되는 방식이 달라집니다.
그렇다면 다음과 같은 코드를 작성하면 어떻게 될까요?
float f{ 3 }; // float 변수를 int 3으로 초기화
컴파일러는 int 타입의 값 3이 사용하는 비트들을 그대로 float 변수의 메모리에 복사할 수 없습니다.
만약 그렇게 한다면, 그 비트들은 원래 정수를 나타내는 형태인데, 나중에 float 변수로 읽을 때는 부동 소수점 방식으로 해석됩니다.
즉, 정수 기준으로 저장된 비트를 실수 기준으로 해석하게 되므로, 결과는 3.0이 아니라 전혀 다른 이상한 값이 됩니다.
암시적 형 변환은 다음과 같이 어떤 타입이 요구되는 상황에서 다른 타입이 제공될 때 컴파일러가 자동으로 수행합니다.
C++에서 일어나는 형 변환의 대다수는 이 암시적 형 변환입니다. 구체적으로 다음과 같은 상황에서 발생합니다.
다른 데이터 타입의 값으로 변수를 초기화하거나 값을 할당할 때
double d{ 3 }; // int 값 3이 double 타입으로 암시적 변환됨 d = 6; // int 값 6이 double 타입으로 암시적 변환됨
함수의 반환 타입과 반환 값 타입이 다를 때
float doSomething(){ return 3.0; // double 값 3.0이 float 타입으로 암시적 변환됨 }
서로 다른 타입의 피연산자를 사용하는 경우
double division{ 4.0 / 3 }; // int 값 3이 double 타입으로 암시적 변환됨
if 문에서 불리언(Boolean)이 아닌 값을 사용할 때
if (5) // int 값 5가 bool 타입으로 암시적 변환됨 {}
함수에 전달된 인자가 함수 매개변수와 다른 타입일 때
void doSomething(long l){} doSomething(3); // int 값 3이 long 타입으로 암시적 변환됨
C++ 표준에는 표준 변환이라는 규칙 모음이 정의되어 있습니다.
이 규칙은 다양한 기본 타입들이 어떻게 다른 타입으로 변환될 수 있는지를 명시합니다.
배열 참조 포인터 열거형 을 포함한 특정 복합 타입들의 변환도 포함이며 이는 나중에 배웁니다.
컴파일러는 이 규칙을 사용하여 자동으로 변환을 수행합니다.
C++23 기준으로 총 14가지의 서로 다른 표준 변환이 존재하며, 이를 크게 5가지 범주로 나눌 수 있습니다.
| 범주 (Category) | 의미 (Meaning) | 링크 (Link) |
|---|---|---|
| 숫자 승격 (Numeric promotions) | 작은 정수 타입을 int나 unsigned int로, float를 double로 변환 | 10.2 -- 부동 소수점 및 정수 승격 |
| 숫자 변환 (Numeric conversions) | 승격(promotion)이 아닌 다른 정수 및 부동 소수점 변환 | 10.3 -- 숫자 변환 |
| 한정자 변환 (Qualification conversions) | const 또는 volatile을 추가하거나 제거하는 변환 | |
| 값 변환 (Value transformations) | 표현식의 값 카테고리(value category)를 변경하는 변환 | 12.2 -- 값 카테고리 (lvalues 및 rvalues) |
| 포인터 변환 (Pointer conversions) | std::nullptr에서 포인터 타입으로, 또는 포인터 타입에서 다른 포인터 타입으로의 변환 |
숫자 승격 (Numeric promotions)
작은 타입을더 큰 타입으로 변환합니다.char → int float → double
숫자 변환 (Numeric conversions)
숫자 승격이 아닌다른 타입으로 변환합니다.int → float double → int
한정자 변환값 변환포인터 변환은 해당 개념을 배운 후 후술합니다.
예를 들어, int 값을 float 값으로 변환하는 것은 숫자 변환 범주에 속하므로,
컴파일러는 단순히 int에서 float으로의 숫자 변환 규칙을 적용하여 작업을 수행합니다.
이 중에서 숫자 변환과 숫자 승격이 가장 중요하며, 이후 강의에서 자세히 다룹니다.
전체 표준 변환 규칙의 목록은 다음과 같습니다. 가볍게 참고만 하셔도 좋습니다.
| 범주 | 표준 변환 | 설명 | 참고 |
|---|---|---|---|
| 값 변환 | Lvalue-to-rvalue | 좌측값(lvalue) 표현식을 우측값(rvalue) 표현식으로 변환 | 12.2 -- 값 카테고리 |
| 값 변환 | Array-to-pointer | C 스타일 배열을 첫 번째 배열 요소에 대한 포인터로 변환 (배열 붕괴, array decay) | 17.8 -- C 스타일 배열 붕괴 |
| 값 변환 | Function-to-pointer | 함수를 함수 포인터로 변환 | 20.1 -- 함수 포인터 |
| 값 변환 | Temporary materialization | 값을 임시 객체로 변환 | |
| 한정자 변환 | Qualification conversion | 타입에서 const나 volatile을 추가하거나 제거 | |
| 숫자 승격 | Integral promotions | 더 작은 정수 타입을 int나 unsigned int로 변환 | 10.2 -- 부동 소수점 및 정수 승격 |
| 숫자 승격 | Floating point promotions | float를 double로 변환 | 10.2 -- 부동 소수점 및 정수 승격 |
| 숫자 변환 | Integral conversions | 정수 승격이 아닌 정수 변환 | 10.3 -- 숫자 변환 |
| 숫자 변환 | Floating point conversions | 부동 소수점 승격이 아닌 부동 소수점 변환 | 10.3 -- 숫자 변환 |
| 숫자 변환 | Integral-floating conversions | 정수와 부동 소수점 타입 간의 변환 | 10.3 -- 숫자 변환 |
| 숫자 변환 | Boolean conversions | 정수, 범위 없는 열거형, 포인터, 또는 멤버 포인터를 bool로 변환 | 4.10 -- if 문 소개 |
| 포인터 변환 | Pointer conversions | std::nullptr을 포인터로, 또는 포인터를 void 포인터나 기본(base) 클래스 포인터로 변환 | |
| 포인터 변환 | Pointer-to-member conversions | std::nullptr을 멤버 포인터로 변환하거나, 기본 클래스의 멤버 포인터를 파생 클래스의 멤버 포인터로 변환 | |
| 포인터 변환 | Function pointer conversions | noexcept-함수 포인터를 일반 함수 포인터로 변환 |
암시적이든 명시적이든 형 변환이 호출되면, 컴파일러는 변환이 가능한지 먼저 확인합니다.
유효한 변환 규칙을 찾으면 변환된 새로운 값을 생성합니다. 유효한 변환 규칙을 찾지 못하면 컴파일 오류가 발생합니다.
예를 들어
int main(){
int x { "14" };
return 0;
}
문자열 "14"을 int로 변환하는 표준 규칙이 없기 때문에 컴파일 오류가 발생합니다.
어떤 경우에는 특정 문법이 일부 형 변환을 아예 차단하기도 합니다.
int x { 3.5 }; // 중괄호 초기화는 데이터 손실이 발생하는 변환을 허용하지 않습니다
컴파일러는 double 값을 int 값으로 변환하는 방법 자체는 알고 있습니다.
하지만 중괄호 초기화를 사용할 때는 데이터가 잘려나가는 축소 변환이 언어 규칙상 엄격히 금지되어 있어 컴파일이 거부됩니다.
또한, 가능한 형 변환 후보가 여러 개 있어서 컴파일러가 도대체 어떤 것을 선택해야 할지 모호해하는 경우도 있습니다.
이 부분에 대해서는 11챕터에서 자세히 다룰 것입니다.
이전 강의 4챕터 — 객체 크기와 sizeof 연산자 에서, C++의 기본 자료형들이 최소한 어느 정도의 크기를 가져야 하는지 보장한다고 배웠습니다.
하지만 실제 자료형의 크기는 컴파일러와 컴퓨터 구조에 따라 달라질 수 있습니다.
이렇게 크기가 달라질 수 있도록 허용한 이유는,
int와 double 같은 자료형을 각 컴퓨터에서 가장 빠르게 처리할 수 있는 크기로 설정할 수 있게 하기 위해서입니다.
예를 들어, 32비트 컴퓨터는 보통 한 번에 32비트 데이터를 처리할 수 있습니다.
이 경우 int는 보통 32비트 크기로 설정됩니다. 왜냐하면 이것이 CPU가 자연스럽게 처리하는 크기이고, 성능도 가장 좋기 때문입니다.
그렇다면 32비트 CPU에서 8비트 값 char 혹은 16비트 값 short을 처리해야 한다면 어떻게 될까요?
CPU마다 다릅니다.
8비트나 16비트 값을 직접 처리할 수 있음즉, 작은 자료형을 그대로 처리하는 것이 항상 효율적인 것은 아닙니다.
C++는 다양한 컴퓨터에서 잘 작동하고 빠르게 실행되도록 설계되었습니다. 그래서 언어를 만든 사람들은 특정 CPU가 자신에게 가장 잘 맞는 데이터 크기보다 더 작은 값들을 항상 알아서 효율적으로 다룰 수 있을 거라고 가정하고 싶지 않았습니다.
이 문제를 해결하기 위해, C++은 숫자 승격이라는 변환 규칙을 정의했습니다.
숫자 승격이란?
작은 숫자 자료형
char등을
더 큰 자료형int또는double등으로 자동 변환하는 것이렇게 하면 CPU가 더 효율적으로 처리할 수 있습니다.
숫자 승격은 항상 값을 그대로 유지합니다. 이를 값 보존 변환 또는 안전한 변환 이라고 합니다.
즉, 변환 전과 변환 후 값이 정확히 같습니다.
char x = 65;
int y = x;
→ y의 값은 정확히 65 입니다. 값 손실이 없습니다.
이 변환은 안전하기 때문에 경고도 발생하지 않으며, 컴파일러는 자동으로 사용합니다.
숫자 승격은 또 다른 골칫거리도 해결해 줍니다. int 타입의 값을 출력하는 함수를 만든다고 가정해 봅시다.
#include <iostream>
void printInt(int x)
{
std::cout << x << '\n';
}
이 함수는 int 출력에는 문제가 없습니다. 하지만 short char 타입의 값도 출력하고 싶다면 어떻게 될까요?
만약 형 변환이라는 기능이 없다면, short용 출력 함수 char용 출력 함수 를 따로따로 다 만들어야 할 겁니다.
void printShort(short x);
void printChar(char x);
void printUnsignedChar(unsigned char x);
이처럼 많은 함수를 만들어야 합니다.
하지만 숫자 승격 덕분에 char short 등이 자동으로 int 로 변환됩니다.
그래서 하나의 함수만 있어도 됩니다.
숫자 승격은 크게 두 종류로 나뉩니다.
더 쉬운 것부터 시작해 봅시다. 이 규칙은 매우 간단합니다.
부동 소수점 승격 규칙에 따라, float 타입의 값은 double 타입으로 변환될 수 있습니다.
#include <iostream>
void printDouble(double d)
{
std::cout << d << '\n';
}
int main()
{
printDouble(5.0); // 변환이 필요하지 않음
printDouble(4.0f); // float에서 double로 숫자 승격이 일어남
return 0;
}
printDouble()을 두 번째로 호출할 때, 4.0f라는 float 값은 함수가 요구하는 타입에 맞춰 double로 알아서 승격됩니다.
정수 승격 규칙은 조금 더 복잡합니다. 규칙에 따르면 다음과 같은 변환들이 일어납니다.
charshort
→int로 변환될 수 있습니다.
unsigned charunsigned shortchar8_t
→ 그 값의 전체 범위를int가 담을 수 있다면int로 변환되고, 담을 수 없다면unsigned int로 변환됩니다.
bool
→bool타입은int로 변환될 수 있으며,false는0으로,true는1이 됩니다.만약
char가 기본적으로 부호가 있는 타입이라면 위의signed char규칙을, 부호가 없는 타입이라면unsigned char규칙을 따릅니다.
요즘 우리가 흔히 쓰는 컴퓨터 환경을 기준으로 생각하면, 복잡하게 외울 것 없이
char signed char unsigned char signed short unsigned short bool
모두 기본적으로 int로 승격된다고 이해하시면 아주 편합니다.
(흔히 환경 = 1바이트가 8비트이고, int가 4바이트 이상인 환경)
#include <iostream>
void printInt(int x)
{
std::cout << x << '\n';
}
int main()
{
printInt(2);
short s{ 3 }; // short 타입은 뒤에 붙이는 리터럴 접미사가 없으므로, 변수를 사용해 테스트합니다.
printInt(s); // short에서 int로 수치 승격이 일어남
printInt('a'); // char에서 int로 수치 승격이 일어남
printInt(true); // bool에서 int로 수치 승격이 일어남
return 0;
}
int는 signed입니다. 즉, 값은 유지되지만 signed/unsigned 속성은 바뀔 수 있습니다.
unsigned char → int
char를 short로 바꾸거나, int를 long으로 바꾸는 것처럼 단순히 크기가 더 커진다고 해서 모두 C++의 숫자 승격이라고 부르지는 않습니다.
이런 것들은 숫자 변환입니다. 왜 구분할까요?
이런 변환들은 앞서 말한 "CPU가 가장 효율적으로 처리할 수 있는 넉넉한 크기"로 곧바로 변환해 주는 목적과는 다르기 때문입니다.
승격이냐 변환이냐를 구분하는 것이 너무 학술적으로 들릴 수도 있습니다. 하지만 특정 상황에서 컴파일러는 숫자 변환보다 숫자 승격을 더 선호합니다. 이 차이가 결과를 어떻게 바꾸는지에 대해서는 나중에 함수 오버로딩 해결을 배우는 챕터 11에서 명확한 예제와 함께 다시 살펴볼 것입니다.
C++에는 숫자 승격 외에도 또 다른 숫자 변환 종류가 있는데, 이를 숫자 변환이라고 합니다.
이는 기본 데이터 타입들 사이에서 일어나는 더 다양하고 포괄적인 변환들을 의미합니다.
숫자 형 변환에는 크게 5가지 기본 유형이 있습니다.
정수 타입을 다른 정수 타입으로 변환 (정수 승격 제외)
short s = 3; // int를 short로 변환 long l = 3; // int를 long으로 변환 char ch = s; // short를 char로 변환 unsigned int u = 3; // int를 unsigned int로 변환부동 소수점 타입을 다른 부동 소수점 타입으로 변환 (부동 소수점 승격 제외)
float f = 3.0; // double을 float로 변환 long double ld = 3.0; // double을 long double로 변환부동 소수점 타입을 정수 타입으로 변환
int i = 3.5; // double을 int로 변환정수 타입을 부동 소수점 타입으로 변환
double d = 3; // int를 double로 변환정수나 부동 소수점 타입을
bool타입으로 변환bool b1 = 3; // int를 bool로 변환 bool b2 = 3.0; // double을 bool로 변환
숫자 승격은 항상 원래의 값을 그대로 유지하기 때문에 안전하지만, 상당수의 숫자 변환은 불안전합니다.
불안전한 숫자 변환은 원래 값과 정확히 같은 값을 새로운 타입에서 표현할 수 없는 경우입니다.
숫자 변환은 안전성에 따라 3가지로 나눌 수 있습니다.
1. 값 보존 변환 (Value-preserving conversion) — 안전함
이 변환은 새로운 타입이 원래 타입의 모든 값을 정확히 표현할 수 있는 경우입니다.
- int → long
- short → double
컴파일러는 이런 안전한 변환에 대해 보통 경고를 하지 않습니다.
int main(){ int n { 5 }; long l = n; // 문제없음, long 타입의 5 생성 short s { 5 }; double d = s; // 문제없음, double 타입의 5.0 생성 return 0; }또한 다시 원래 타입으로 변환해도 값이 유지됩니다.
#include <iostream> int main(){ int n = static_cast<int>(static_cast<long>(3)); // int 3을 long으로 변환했다가 다시 int로 변환 std::cout << n << '\n'; // 3 출력 char c = static_cast<char>(static_cast<double>('c')); // 'c'를 double로 변환했다가 다시 char로 변환 std::cout << c << '\n'; // 'c' 출력 return 0; }
2. 재해석 변환 (Reinterpretive conversion) — 위험하지만 데이터는 유지됨
이 변환은 불안전합니다. 변환된 값이 원래 값과 달라질 수 있기 때문입니다.
하지만 데이터 자체가 날아가는 것은 아닙니다.Signed과Unsigned사이의 변환이 여기에 속합니다.int main(){ int n1 { 5 }; unsigned int u1 { n1 }; // 문제없음: unsigned int 5로 변환됨 (값 보존됨) int n2 { -5 }; unsigned int u2 { n2 }; // 나쁨: signed int 범위를 벗어난 아주 큰 정수가 됨 return 0; }
u1의 경우5라는 양수가 그대로 양수5로 넘어가기 때문에 값이 보존됩니다.
하지만u2의 경우,-5라는 음수를 음수 표현이 불가능한unsigned int에 억지로 넣게 됩니다.
그 결과 래핑 현상이 발생해 아주 엉뚱하고 큰 숫자가 되어버립니다. 값이 보존되지 않는 것이죠.이런 값의 변화는 대개 원치 않는 결과이며 프로그램 오류의 원인이 됩니다.
단, 재해석 변환 역시 데이터 자체가 삭제된 것은 아니기 때문에,
엉뚱하게 변한 값이라도 다시 원래 타입으로 캐스팅하면 원래의 값으로 돌아오긴 합니다.#include <iostream> int main(){ int u = static_cast<int>(static_cast<unsigned int>(-5)); // '-5'를 unsigned로 변환했다가 다시 되돌림 std::cout << u << '\n'; // -5 출력 return 0; }
3. 데이터 손실 변환 (Lossy conversion) — 위험함
이 변환은 데이터 일부가 사라집니다.
예를 들어, 소수점이 있는double을 정수형인int로 변환하면 데이터가 날아갑니다.int i = 3.0; // 문제 없음: int 값 3 int j = 3.5; // 데이터 손실: 소수점 0.5가 사라짐 → int 값 3더 큰 소수점 타입인
double을 더 작은float로 변환할 때도 정밀도 손실이 발생합니다.float f = 1.2; // 문제없음: float 값 1.2로 변환됨 (값 보존됨) float g = 1.23456789; // 데이터 손실: float 1.23457로 변환됨 (정밀도 손실)손실 변환은 데이터가 이미 지워진 것이기 때문에, 다시 원래 타입으로 되돌려도 처음 값으로 돌아오지 않습니다.
3.5를int로 바꿔서3이 되었다면, 이걸 다시double로 바꿔봤자3.5가 아닌3.0이 될 뿐입니다.
컴파일러는 보통 이런 변환을 시도할 때 경고나 에러를 띄웁니다.
규칙이 너무 많아서 복잡해 보인다면, 초보자 분들은 아래 내용만 확실히 기억해 두세요.
1. 타입 범위를 벗어나면 이상한 결과가 발생합니다
int main(){ int i{ 30000 }; char c = i; // char는 -128부터 127까지만 담을 수 있습니다. std::cout << static_cast<int>(c) << '\n'; return 0; } 결과: 48 (넘쳐서 엉뚱한 값이 나옴 - 오버플로우)2. 큰 타입 → 작은 타입 변환은 값이 범위 내에 있으면 안전합니다
int i{ 2 }; short s = i; // int에서 short로 변환 (2는 short 안에 들어감) std::cout << s << '\n'; // 2 출력 double d{ 0.1234 }; float f = d; std::cout << f << '\n'; // 0.1234 출력3.
int→float변환은 보통 안전합니다int i{ 10 }; float f = i; std::cout << f << '\n'; // 10 출력4.
float→int변환은 소수점이 사라집니다int i = 3.5; std::cout << i << '\n'; // 0.5가 버려지고 3 출력
C++에서 축소 변환이란 잠재적으로 위험할 수 있는 숫자 변환을 뜻합니다.
변환하려는 원래 데이터가 변환될 자료형의 그릇보다 커서, 데이터의 일부를 잃어버릴 수 있기 때문입니다.
즉, 목적지 타입이 원본 타입의 모든 값을 저장할 수 없는 경우를 말합니다.
다음과 같은 변환들이 축소 변환으로 정의됩니다.
1. 실수형 → 정수형 변환
double→int
실수는 소수점을 포함할 수 있지만, 정수는 소수점을 저장할 수 없기 때문에 값이 손실될 수 있습니다.
2. 실수형 → 더 작은 실수형 변환
double→float
실수형에서 더 작거나 단계가 낮은 실수형으로 변환할 때 값이 손실될 수 있습니다.
- 단, 변환하려는 값이
constexpr이고, 목적지 타입 범위 안에 있으면 축소 변환으로 간주되지 않습니다.
(정밀도가 줄어들더라도 범위 안에 있으면 예외가 적용됩니다.)
3. 정수형 → 실수형 변환
- 단, 값이
constexpr이고 목적지 타입에서 정확하게 표현 가능하면 축소 변환이 아닙니다.
4. 정수형 → 더 작은 정수형 변환
long → intint → shortint → unsigned intunsigned int → int
- 단, 값이
constexpr이고 목적지 타입에서 정확하게 표현 가능하면 축소 변환이 아닙니다.
대부분의 경우, 암시적 축소 변환은 컴파일러 경고를 발생시킵니다.
단, signed ↔ unsigned 변환은 컴파일러 설정에 따라 경고가 나오지 않을 수도 있습니다.
축소 변환은 항상 피할 수 있는 것은 아닙니다.
특히 함수 호출 시, 함수 매개변수 타입과 전달되는 값 타입이 다르면 축소 변환이 필요할 수 있습니다.
이런 경우에는 static_cast를 사용하여 명시적으로 변환하는 것이 좋습니다.
이렇게 하면 변환이 의도된 것임을 명확히 보여줄 수 있고 컴파일러 경고도 제거할 수 있습니다
void someFcn(int i){}
int main(){
double d{ 5.0 };
someFcn(d); // 나쁨: 암시적 축소 변환이 일어나 컴파일러 경고를 발생시킵니다.
// 좋음: 컴파일러에게 이 축소 변환이 의도적임을 명확하게 알려줍니다.
someFcn(static_cast<int>(d)); // 경고가 발생하지 않습니다.
return 0;
}
중괄호 {}를 사용해 변수를 초기화하는 리스트 초기화 방식은 축소 변환을 아예 허용하지 않습니다.
이것이 우리가 이 초기화 방식을 가장 선호하는 이유이기도 합니다.
축소 변환을 중괄호 초기화에서 사용하려면 static_cast 를 사용해야 합니다.
int main(){
double d { 3.5 };
// static_cast<int>는 double을 int로 변환하고, 그 int 결과값으로 i를 초기화합니다.
int i { static_cast<int>(d) };
return 0;
}
값이 런타임에 결정되는 경우, 변환 결과도 런타임에만 알 수 있습니다.
#include <iostream>
void print(unsigned int u) // 참고: unsigned (부호 없음)
{
std::cout << u << '\n';
}
int main(){
std::cout << "정수 값을 입력하세요: ";
int n{};
std::cin >> n; // 5 또는 -5를 입력해 보세요.
print(n); // unsigned로 변환할 때 값이 유지될 수도 있고 아닐 수도 있습니다.
return 0;
}
컴파일러는 n에 어떤 값이 들어올지 모르기 때문에, 이 변환이 안전한지 판단할 수 없습니다.
그래서 signed/unsigned 경고가 발생할 수 있습니다.
하지만 constexpr 값은 다릅니다.
constexpr 값은 컴파일 시점에 이미 값이 결정되어 있습니다.
따라서 컴파일러는
값이 유지되면 → 축소 변환이 아님
값이 바뀌면 → 컴파일 오류 발생
#include <iostream>
int main(){
constexpr int n1{ 5 }; // 참고: constexpr
unsigned int u1 { n1 }; // 문제없음: 예외 조항 덕분에 축소 변환으로 간주되지 않습니다.
constexpr int n2 { -5 }; // 참고: constexpr
unsigned int u2 { n2 }; // 컴파일 오류: 값이 변하기 때문에 축소 변환으로 간주됩니다.
return 0;
}
다음 표현식을 살펴봅시다.
int x { 2 + 3 };
이 코드에서 이항 연산자 + 는 두 개의 피연산자 2 3를 받습니다. 그리고 두 피연산자는 모두 int 타입입니다.
피연산자의 타입이 동일하기 때문에
int 타입으로 수행되고int 타입으로 반환됩니다.따라서 2 + 3 의 결과는 int 타입 값 5가 됩니다.
그렇다면 피연산자의 타입이 서로 다르면 어떻게 될까요?
??? y { 2 + 3.5 };
이 경우 2 는 int , 3.5 는 double 입니다. 이때 결과는 어떤 타입이 될까요? int? double? 아니면 다른 타입?
C++에서는 일부 연산자가 두 피연산자의 타입이 같아야만 합니다.
만약 피연산자의 타입이 서로 다르면, C++는 자동으로 피연산자의 타입을 변환합니다. 이를 일반적인 산술 변환 이라고 합니다.
이 규칙을 통해 두 피연산자는 동일한 타입으로 변환됩니다. 그리고 이렇게 변환된 타입을 공통 타입이라고 합니다.
다음 연산자들은 두 피연산자의 타입이 반드시 같아야 합니다.
1. 산술 연산자 + - * / %
2. 비교 연산자 < > <= >= == !=
3. 비트 연산자 & ^ |
4. 조건 연산자 ?: (단, bool 타입이어야 하는 조건식 부분은 제외)
이 규칙은 실제로 꽤 복잡하지만, 이해하기 쉽게 단순화해서 설명하겠습니다.
컴파일러는 타입마다 우선순위 목록을 가지고 있습니다.
long double (가장 높음)
double
float
long long
long
int (가장 낮음)
한쪽은 정수 타입이고 다른 한쪽은 실수 타입이라면, 정수 값이 실수 타입으로 변환됩니다.
단순히 순위가 낮은 타입이 순위가 높은 타입으로 변환됩니다.
예제 1: int 와 double 더하기
#include <iostream> #include <typeinfo> // typeid()를 사용하기 위해 int main() { int i{ 2 }; std::cout << typeid(i).name() << '\n'; // i의 타입 이름을 보여줌 double d{ 3.5 }; std::cout << typeid(d).name() << '\n'; // d의 타입 이름을 보여줌 std::cout << typeid(i + d).name() << ' ' << i + d << '\n'; // i + d의 타입 이름을 보여줌 return 0; } 출력: int double double 5.5이 경우
double이 더 높은 우선순위를 가집니다.
따라서int값2는double값2.0으로 변환됩니다.
그 후2.0 + 3.5 = 5.5결과 타입은double입니다.
예제 2: short + short
#include <iostream> #include <typeinfo> // typeid()를 사용하기 위해 int main() { short a{ 4 }; short b{ 5 }; std::cout << typeid(a + b).name() << ' ' << a + b << '\n'; // a + b의 타입을 보여줌 return 0; }
short는 우선순위 목록에 없기 때문에, 두 값 모두 먼저int로 승격됩니다.
따라서 결과는int 9가 됩니다.
signed 와 unsigned 를 섞어서 사용하면 예상하지 못한 결과가 나올 수 있습니다.
#include <iostream>
#include <typeinfo> // typeid()를 사용하기 위해
int main()
{
std::cout << typeid(5u-10).name() << ' ' << 5u - 10 << '\n'; // 5u는 5를 부호 없는 정수(unsigned)로 취급하라는 뜻입니다.
return 0;
}
보통 우리는 5 - 10 = -5 처럼 생각합니다.
하지만 실제 결과는 unsigned int 4294967291 입니다.
왜냐하면 변환 규칙에 의해 10 (int) → unsigned int 로 변환되기 때문입니다.
unsigned 는 음수를 표현할 수 없기 때문에, 이상한 결과가 나옵니다.
두 타입의 공통 타입을 알고 싶을 때 사용할 수 있는 도구입니다.
이 기능은 나중 강의에서 더 자세히 다룹니다.
#include <type_traits>std::common_type_t<int, double> → doublestd::common_type_t<unsigned int, long> → 공통 타입 반환많은 초보 C++ 프로그래머들은 종종 아래와 같은 실수를 하곤 합니다.
double d = 10 / 4; // 정수 나눗셈을 수행하여, d를 2.0으로 초기화합니다.
대부분의 경우, 우리는 2.5 를 기대했을 것입니다.
하지만 정수 나눗셈을 실행하여 결과값으로 2.0 을 만들어냅니다.
왜 이런 일이 발생할까요?
10과 4는 둘 다 int 타입입니다. 따라서 정수 나눗셈이 수행됩니다.2.5가 아니라, 2입니다.2가 double로 변환되어 2.0이 됩니다.숫자(리터럴)를 직접 사용할 때는, 둘 중 하나를 실수로 적어주면 간단히 실수 나눗셈을 할 수 있습니다.
double d = 10.0 / 4; // 실수 나눗셈을 수행하여, d를 2.5로 초기화합니다.
하지만 변수를 사용하면 어떻게 할까요? 여기서도 같은 문제가 발생합니다.
int x { 10 };
int y { 4 };
double d = x / y; // 정수 나눗셈을 수행하여, d를 2.0으로 초기화합니다.
변수에는 리터럴처럼 .0을 붙일 수 없습니다.
따라서 변수의 타입을 직접 실수 타입으로 변환해야 합니다.
이를 위해 C++는 캐스트라는 기능을 제공합니다.
캐스트란 프로그래머가 직접 컴파일러에게 타입 변환을 요청하는 것 입니다. 이것을 명시적 타입 변환이라고 합니다.
반대로, 컴파일러가 자동으로 수행하는 것은 암시적 타입 변환이라고 합니다.
C++는 5가지 종류의 캐스트를 지원합니다.
static_cast dynamic_cast const_cast reinterpret_cast C스타일 캐스트 입니다.
앞의 네 가지는 이름 있는 캐스트(named casts) 라고도 부릅니다.
| 캐스트 | 설명 | 안전성 |
|---|---|---|
| static_cast | 관련된 타입 간 변환 | 안전 |
| dynamic_cast | 상속 구조에서 런타임 변환 | 안전 |
| const_cast | const 추가 또는 제거 | const 추가만 안전 |
| reinterpret_cast | 비트 단위 재해석 | 위험 |
| C-style cast | 여러 캐스트 조합 | 위험 |
모든 캐스트의 기본 작동 방식은 동일합니다. 변환할 값 과 목표 타입 을 입력으로 받고, 변환이 완료된 결과를 출력해 줍니다.
이 강의에서는 가장 흔하게 쓰이는 C스타일 캐스트 와 static_cast에 집중하겠습니다.
dynamic_cast는 필요한 기본 지식을 배운 후 나중에 다룹니다.const_cast와 reinterpret_cast는 아주 드문 상황에서만 유용하고 잘못 쓰면 위험하므로 아주 타당한 이유가 없다면 사용을 피하세요.과거 C 언어 프로그래밍에서는 소괄호 () 연산자를 이용해 형 변환을 했습니다.
(type)value
괄호 안에 바꿀 타입을 적고, 그 바로 오른쪽에 변환할 값을 적는 방식이죠.
C++에서는 이를 C스타일 캐스트 라고 부릅니다. C 언어에서 넘어온 코드에서 종종 볼 수 있습니다.
#include <iostream>
int main()
{
int x { 10 };
int y { 4 };
std::cout << (double)x / y << '\n'; // x를 double 타입으로 C스타일 캐스트
return 0;
}
x가 double로 변환됨2.5C++에서는 다음과 같은 형태도 가능합니다. 이 방식은 함수 호출처럼 보여서 조금 더 읽기 쉽습니다.
std::cout << double(x) / y << '\n'; // x를 double로 변환하는 함수 스타일 캐스트
하지만 현대 C++에서는 C스타일 캐스트를 일반적으로 피해야 합니다. 그 이유는 크게 두 가지입니다.
이유 1: 어떤 변환이 일어나는지 명확하지 않음
C스타일 캐스트는 겉보기엔 단순해 보여도, 사용되는 상황에 따라
static_castconst_castreinterpret_cast중 하나를 제멋대로 수행해 버립니다. 어떤 캐스트가 실행될지 코드를 읽고 명확히 알기 어렵고, 단순한 변환을 의도했는데 위험한 변환이 일어날 수도 있습니다. 이런 문제는 보통 프로그램을 실행할 때(런타임)가 되어서야 오류로 나타납니다.
이유 2: 찾기 어렵고 읽기 어렵다
(double)x이 코드는 눈에 잘 띄지 않으며 검색하기도 어렵습니다. 반면
static_cast<double>(x)는 명확합니다.static_cast<double>(x)
static_cast를 사용하세요C++에서 가장 많이 사용하는 캐스트는 static_cast 입니다.
#include <iostream>
int main()
{
char c { 'a' };
std::cout << static_cast<int>(c) << '\n'; // a 대신 97 출력
return 0;
}
static_cast<변환할타입>(값)
static_cast의 특징특징 1: 컴파일 타임 검사
잘못된 변환은 컴파일 오류를 발생 시킵니다.int x { static_cast<int>("Hello") }; // 잘못된 변환 → 컴파일 오류 발생
특징 2: 위험한 변환을 제한
C 스타일 캐스트보다 안전합니다.
static_cast 활용하기데이터의 크기가 작아지거나 손실될 수 있는 변환(이를 축소 변환이라 합니다)이 암시적으로 일어날 때, 컴파일러는 종종 경고를 냅니다.
int i { 48 };
char ch = i; // 암시적 축소 변환 (데이터 손실 우려로 경고 발생 가능)
2바이트나 4바이트 크기인 int를 1바이트인 char에 욱여넣는 것은 잠재적으로 안전하지 않습니다.
숫자가 char의 허용 범위를 넘쳐버릴(오버플로우) 수 있기 때문이죠. 그래서 컴파일러가 경고를 주는 것입니다.
이때 static_cast를 사용하면 컴파일러에게 "내가 이 변환의 위험성을 알고 있고 의도한 거야!"라고 명확히 알려줄 수 있습니다.
int i { 48 };
// int에서 char로 명시적 변환을 수행하여, 변수 ch에 char 타입으로 대입합니다.
char ch { static_cast<char>(i) };
이렇게 하면 결과값이 확실한 char 타입이 되기 때문에, 대입할 때 타입이 어긋나지 않아 컴파일러가 더 이상 경고를 보내지 않습니다.
물론 범위를 벗어나 넘쳐흐르는 결과에 대한 책임은 프로그래머의 몫이 됩니다.
또 다른 예로, double을 int로 변환할 때 데이터 손실(소수점 아래 잘림) 경고가 뜨는 것을 막고 싶다면 아래처럼 명확히 의도를 밝히면 됩니다.
int i { 100 };
i = static_cast<int>(i / 2.5); // "소수점이 잘려도 괜찮아, 내가 의도한 거야"
C++에서 using 은 기존 데이터 타입에 새로운 이름(별칭)을 붙일 수 있게 해주는 키워드입니다.
using Distance = double; // Distance를 double 타입의 별칭으로 정의
이제 Distance 는 double 과 같은 의미로 사용할 수 있습니다.
Distance milesToDestination{ 3.4 }; // double 타입 변수를 정의
컴파일러는 Distance 를 보면 자동으로 double 로 바꿔서 처리합니다.
다음 예제를 봅시다.
#include <iostream>
int main()
{
using Distance = double; // Distance를 double 타입의 별칭으로 정의합니다.
Distance milesToDestination{ 3.4 }; // double 타입의 변수를 정의합니다.
std::cout << milesToDestination << '\n'; // double 값을 출력합니다.
return 0;
}
출력 결과:
3.4
타입 별칭은 새로운 타입을 만드는 것이 아닙니다. 단지 기존 타입에 다른 이름을 붙이는 것뿐입니다.
타입 별칭의 이름도 일반 변수처럼 '사용 가능한 범위(스코프)' 규칙을 따릅니다.
즉, 중괄호 {} 블록 안에서 별칭을 만들면 그 블록 안에서만 쓸 수 있고,
파일의 맨 위(전역 네임스페이스)에 만들면 그 파일 끝까지 어디서든 쓸 수 있습니다.
여러 파일에서 똑같은 타입 별칭을 쓰고 싶다면, 헤더 파일에 정의해 두고 필요한 코드 파일에서 #include로 불러와 사용하면 됩니다.
// mytypes.h:
#ifndef MYTYPES_H
#define MYTYPES_H
using Miles = long;
using Speed = long;
#endif
typedef는 타입 별칭을 만드는 옛 방식입니다.
typedef long Miles;
using Miles = long;
둘은 같은 의미입니다.
하지만 modern C++에서는 using 이 더 권장됩니다.
용어 정리
C++ 공식 표준 문서나 일반적인 프로그래밍 대화에서는typedef나using이나 결국 하는 일이 같기 때문에, 이 둘을 구분 없이 뭉뚱그려typedef라고 부르기도 합니다.
1. 플랫폼 독립 코드 작성
int는 시스템마다 크기가 다를 수 있습니다. 어떤 시스템에선2 bytes어떤 시스템에선4 bytes일수도 있습니다.
이 문제를 해결하기 위해int8_tint16_tint32_t같은 타입 별칭을 사용합니다.
이렇게 하면 플랫폼과 관계없이 올바른 크기를 보장할 수 있습니다.#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
2. 복잡한 타입을 간단하게 만들기
고급 C++로 넘어가면, 타입 이름이 타이핑하기 끔찍할 정도로 길어지는 경우가 생깁니다.
std::vector<std::pair<std::string, int>>같이 일일이 치려면 손도 아프고 오타도 나기 쉽습니다.
이때 타입 별칭을 쓰면 마법처럼 편해집니다.#include <string> #include <vector> #include <utility> using VectPairSI = std::vector<std::pair<std::string, int>>; // 이 복잡하고 긴 타입에 VectPairSI라는 별칭을 줍니다. bool hasDuplicates(VectPairSI pairlist) // 함수 매개변수에 훨씬 짧아진 별칭을 사용합니다. { // 코드가 들어갈 자리 return false; } int main() { VectPairSI pairlist; // 별칭으로 간단하게 변수를 생성합니다. return 0; }훨씬 읽기 쉽습니다. 이것이 타입 별칭의 가장 좋은 사용 방법입니다.
3. 값의 '의미'를 명확하게 설명하고 싶을 때
int gradeTest();이 함수가 반환하는
int의 의미가 불명확합니다.using TestScore = int; TestScore gradeTest();타입 별칭을 사용하면 이제 시험 점수라는 의미가 명확해집니다.
4. 유지보수 쉽게 하기
using StudentId = short;나중에 쉽게 변경 가능합니다. 코드 전체를 수정할 필요 없습니다.
using StudentId = long;
다음과 같이 변수를 정의할 때, 사실 우리는 타입 정보를 약간 불필요하게 중복해서 적고 있습니다.
double d{ 5.0 };
C++에서는 모든 객체를 선언할 때 반드시 타입을 명시해야 합니다. 그래서 우리는 변수 d가 double 타입이라고 직접 써 주었습니다.
하지만 d에 넣기 위해 적어둔 값 5.0 역시 (숫자 형태를 보면 알 수 있듯이) 이미 내부적으로 double 타입입니다.
즉, 우리는 같은 타입 정보를 두 번 써 준 셈입니다.
타입 추론이란, 컴파일러가 초기값을 보고 변수의 타입을 자동으로 결정하는 기능입니다.
변수를 정의할 때 auto 키워드를 사용하면 타입 추론이 적용됩니다.
int main()
{
auto d { 5.0 }; // 5.0은 double 리터럴이므로, d는 double 타입으로 추론됩니다.
auto i { 1 + 2 }; // 1 + 2의 계산 결과가 int이므로, i는 int 타입으로 추론됩니다.
auto x { i }; // i가 int이므로, x 역시 int 타입으로 추론됩니다.
return 0;
}
함수 호출도 표현식이므로 타입 추론이 가능합니다.
add() 함수가 int를 반환하므로 sum 은 자동으로 int 가 됩니다.
int add(int x, int y)
{
return x + y;
}
int main()
{
auto sum { add(5, 6) }; // add() 함수가 int를 반환하므로, sum의 타입은 int로 추론됩니다.
return 0;
}
리터럴 뒤에 붙는 접미사로 원하는 타입을 지정할수도 있습니다.
int main()
{
auto a { 1.23f }; // 'f' 접미사가 있으므로 a는 float으로 추론됩니다.
auto b { 5u }; // 'u' 접미사가 있으므로 b는 unsigned int로 추론됩니다.
return 0;
}
물론 const 나 constexpr 같은 한정자도 auto 와 함께 사용할 수 있습니다.
int main()
{
int a { 5 }; // a는 평범한 int입니다.
const auto b { 5 }; // b는 const int입니다.
constexpr auto c { 5 }; // c는 constexpr int입니다.
return 0;
}
auto 는 초기값을 보고 타입을 유추하는 것이므로, 초기값이 아예 없거나 비어 있으면 작동하지 않습니다.
또한, 반환값이 없는 void 함수를 초기값으로 넣어도 에러가 발생합니다.
#include <iostream>
void foo()
{
}
int main()
{
auto a; // 에러: 컴파일러가 a의 타입을 추론할 단서가 없습니다.
auto b { }; // 에러: 컴파일러가 b의 타입을 추론할 단서가 없습니다.
auto c { foo() }; // 에러: void 타입(불완전한 타입)으로는 추론할 수 없습니다.
return 0;
}
1. 코드 정렬이 깔끔해진다
변수 이름이 정렬되어 가독성이 좋아집니다.// 읽기 어려움 int a { 5 }; double b { 6.7 }; // 읽기 쉬움 auto c { 5 }; auto d { 6.7 };
2. 초기화 안 된 변수를 방지할 수 있다
auto는 반드시 초기값이 있어야 하므로 실수로 초기화하지 않는 상황을 줄여줍니다.int x; // 초기화 안 됨 (실수) auto y; // 오류 발생 → 타입 추론 불가
3. 불필요한 형 변환을 방지
auto를 사용하면 반환 타입 그대로 사용하므로 불필요한 변환이 발생하지 않습니다.std::string_view getString(); // std::string_view를 반환하는 함수 std::string s1 { getString() }; // 나쁨: string_view → string 변환 (비용 발생) auto s2 { getString() }; // 좋음: 변환 없음
1. 타입이 코드에 직접 보이지 않는다
코드를 눈으로 읽을 때 객체의 정확한 타입을 알기 어렵습니다.auto y { 5 }; // 앗, double 타입이 필요했는데 실수로 정수를 넣어버려서 int로 추론되었습니다.다음은 또다른 예시 입니다.
#include <iostream> int main(){ auto x { 3 }; auto y { 2 }; std::cout << x / y << '\n'; //소수점 나눗셈을 원했지만, 둘 다 int이므로 정수 나눗셈이 되어버립니다. return 0; }
2. 초기값 타입이 바뀌면 변수 타입도 바뀐다
만약 나중에 누군가가add함수의 반환값이나gravity변수의 타입을int에서double로 수정한다면, 위 코드의sum역시 예기치 않게double로 변해버립니다.auto sum { add(5, 6) + gravity };
다음 코드는 예상과 다르게 동작합니다. C++에서 문자열 리터럴은 역사적인 이유로 const char* 타입입니다.
auto s { "Hello, world" }; // s는 std::string이 아니라 const char*
만약 문자열을 진짜 C++ 방식인 std::string이나 std::string_view 로 추론하게 만들고 싶다면, s나 sv 접미사를 꼭 붙여주어야 합니다.
#include <string>
#include <string_view>
int main()
{
using namespace std::literals; // s와 sv 접미사를 쓰기 위한 가장 쉬운 방법입니다.
auto s1 { "goo"s }; // "goo"s는 std::string 리터럴이므로, s1은 std::string으로 추론됩니다.
auto s2 { "moo"sv }; // "moo"sv는 std::string_view 리터럴이므로, s2는 std::string_view로 추론됩니다.
return 0;
}
auto 는 기본적으로 const 를 제거합니다.
int main(){
const int a { 5 }; // a는 const int
auto b { a }; // b는 int (const 제거됨)
return 0;
}
const 를 유지하려면 직접 써줘야 합니다.
const auto b { a };
constexpr은 암묵적으로 const의 성질을 가지는데, 이 역시 auto 를 쓰면 떨어져 나갑니다.
유지하고 싶다면 직접 다시 적어주어야 합니다.
int main()
{
constexpr double a { 3.4 }; // a는 'const double' 성질을 가집니다. (constexpr은 타입이 아니며, const가 암묵적으로 적용됨)
auto b { a }; // b의 타입은 'double'입니다. (const가 떨어져 나감)
const auto c { a }; // c의 타입은 'const double'입니다. (떨어진 const를 직접 다시 붙여줌)
constexpr auto d { a }; // d의 타입은 'const double'입니다. (constexpr 키워드 때문에 const가 다시 적용됨)
return 0;
}
컴파일러는 어차피 return 문을 보고 “이 값이 반환 타입으로 변환 가능한가?”를 검사해야 합니다.
그래서 C++14부터는 함수의 반환 타입 자리에도 auto를 쓸 수 있게 확장되었습니다.
즉, 반환 타입을 직접 쓰는 대신 auto라고 적으면 컴파일러가 알아서 반환 타입을 추론합니다.
auto add(int x, int y){
return x + y;
}
여기서는 return x + y;가 int 값을 반환하므로, 컴파일러가 이 함수의 반환 타입을 int로 추론합니다.
auto 반환 타입을 사용할 때 주의할 점은, 함수 안의 모든 return 문이 반드시 같은 타입의 값을 반환해야 한다는 것입니다.
그렇지 않으면 에러가 발생합니다.
auto someFcn(bool b){
if (b)
return 5; // 반환 타입: int
else
return 6.7; // 반환 타입: double
}
위 함수는 return이 int와 double로 서로 다르기 때문에 컴파일러가 오류를 냅니다.
만약 “상황에 따라 다른 타입을 반환” 같은 동작을 원한다면, 보통은 다음 중 하나로 해결합니다.
반환 타입을 명시적으로 적기
그러면 컴파일러는 반환 타입에 맞게(가능하면) 암시적 변환을 시도합니다.
모든return값을 같은 타입으로 맞추기
예를 들어 위 코드에서는5를5.0으로 바꾸면 둘 다double이 됩니다.
리터럴이 아닌 타입이라면static_cast같은 명시적 변환을 사용할 수도 있습니다.
가장 큰 장점은 반환 타입 불일치로 인한 실수를 줄여준다는 점입니다.
즉, “내가 반환 타입을 잘못 적어서 원하지 않는 변환이 일어나는 상황”을 막는 데 도움이 됩니다.
또 다른 경우로, 함수의 반환 타입이 너무 길고 복잡하거나 한눈에 파악하기 힘들 때 auto를 사용해 코드를 단순하게 만들 수 있습니다.
// 컴파일러가 unsigned short와 char를 더한 결과의 타입을 스스로 결정하도록 합니다.
auto add(unsigned short x, char y){
return x + y;
}
편리해 보이지만 두 가지 큰 단점도 존재합니다.
auto반환 타입을 사용하는 함수는 사용되기 전에 '완전히' 정의되어 있어야 합니다.
함수의 껍데기만 보여주는 전방 선언만으로는 부족합니다.#include <iostream> auto foo(); int main(){ std::cout << foo() << '\n'; // 이 시점에서 컴파일러는 함수의 전방 선언만 본 상태입니다. return 0; } auto foo(){ return 5; }
- 객체에서 타입 추론을 할 때는 보통 같은 문장 안에 초기값이 같이 있어서, “어떤 타입이 추론될지” 비교적 알기 쉽습니다.
하지만 함수는 다릅니다. 함수의 선언(프로토타입)만 보면 실제로 어떤 타입을 반환하는지 알 수 없습니다. 좋은 IDE는 “추론된 반환 타입”을 보여주겠지만, 그런 도움 없이 코드를 읽는 사람은 함수 본문을 직접 열어봐야 반환 타입을 알 수 있습니다. 그만큼 실수할 가능성도 올라갑니다.
일반적으로 우리는 인터페이스(함수 선언은 인터페이스입니다)에 포함되는 타입은 명시적으로 적는 쪽을 더 선호합니다.
그래서 함수 반환 타입 추론은 객체 타입 추론만큼 “이게 정답”이라는 합의가 강하지 않습니다. 이
강의에서는 대체로 함수 반환 타입 추론을 피하는 것을 권장합니다.
auto 키워드는 반환 타입을 알아서 추론해 달라는 뜻 외에도, 반환 타입을 함수 선언의 제일 끝에 적는 '후행 반환 문법'을 사용할 때도 쓰입니다.
int add(int x, int y){
return (x + y);
}
후행 반환 문법을 사용하면 위 코드를 아래처럼 똑같이 작성할 수 있습니다.
auto add(int x, int y) -> int{
return (x + y);
}
여기서 auto는 타입 추론을 하는 게 아닙니다.
그냥 “뒤에 반환 타입을 쓰는 문법”을 만들기 위한 구성 요소일 뿐입니다.
1) 반환 타입이 복잡할 때 읽기 쉬움
#include <type_traits> // std::common_type를 사용하기 위해
std::common_type_t<int, double> compare(int, double); // 읽기 어려움(함수 이름이 어디에 있는지 한눈에 안 들어옴)
auto compare(int, double) -> std::common_type_t<int, double>; // 읽기 쉬움(반환 타입이 필요할 때만 보면 됨)
2) 여러 함수 선언을 깔끔하게 정렬할 수 있음
auto add(int x, int y) -> int;
auto divide(double x, double y) -> double;
auto printSomething() -> void;
auto generateSubstring(const std::string &s, int start, int len) -> std::string;
타입 추론을 배우면, 초보자들이 종종 이런 코드를 시도합니다.
#include <iostream>
void addAndPrint(auto x, auto y){
std::cout << x + y << '\n';
}
int main(){
addAndPrint(2, 3); // 경우 1: int 인자로 호출
addAndPrint(4.5, 6.7); // 경우 2: double 인자로 호출
return 0;
}
안타깝게도 C++20 이전에는, 함수 매개변수에 auto를 쓰는 “그런 의미의 타입 추론”이 동작하지 않아서 위 코드는 컴파일되지 않습니다.
(함수 매개변수는 auto 타입을 가질 수 없다는 오류가 납니다)
C++20에서는 위 프로그램이 컴파일되고 제대로 동작하긴 합니다. 하지만 이때의 auto는 “그냥 타입 추론”이 아니라, 이런 상황을 처리하기 위해 설계된 함수 템플릿(function templates) 기능을 사용하게 만드는 역할을 합니다.