LearnCPP - 10

Justin·2026년 2월 17일

LearnCPP.com

목록 보기
10/22

10.1 — 암시적 형 변환 (Implicit type conversion)

우리는 챕터 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)작은 정수 타입을 intunsigned int로, floatdouble로 변환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)
작은 타입더 큰 타입으로 변환합니다.

charint
floatdouble

숫자 변환 (Numeric conversions)
숫자 승격이 아닌 다른 타입으로 변환합니다.

intfloat
doubleint

한정자 변환 값 변환 포인터 변환 은 해당 개념을 배운 후 후술합니다.

예를 들어, int 값을 float 값으로 변환하는 것은 숫자 변환 범주에 속하므로,
컴파일러는 단순히 int에서 float으로의 숫자 변환 규칙을 적용하여 작업을 수행합니다.
이 중에서 숫자 변환숫자 승격가장 중요하며, 이후 강의에서 자세히 다룹니다.


심화 학습자를 위한 전체 표준 변환 목록

전체 표준 변환 규칙의 목록은 다음과 같습니다. 가볍게 참고만 하셔도 좋습니다.

범주표준 변환설명참고
값 변환Lvalue-to-rvalue좌측값(lvalue) 표현식을 우측값(rvalue) 표현식으로 변환12.2 -- 값 카테고리
값 변환Array-to-pointerC 스타일 배열을 첫 번째 배열 요소에 대한 포인터로 변환 (배열 붕괴, array decay)17.8 -- C 스타일 배열 붕괴
값 변환Function-to-pointer함수를 함수 포인터로 변환20.1 -- 함수 포인터
값 변환Temporary materialization값을 임시 객체로 변환
한정자 변환Qualification conversion타입에서 constvolatile을 추가하거나 제거
숫자 승격Integral promotions더 작은 정수 타입을 intunsigned int로 변환10.2 -- 부동 소수점 및 정수 승격
숫자 승격Floating point promotionsfloatdouble로 변환10.2 -- 부동 소수점 및 정수 승격
숫자 변환Integral conversions정수 승격이 아닌 정수 변환10.3 -- 숫자 변환
숫자 변환Floating point conversions부동 소수점 승격이 아닌 부동 소수점 변환10.3 -- 숫자 변환
숫자 변환Integral-floating conversions정수와 부동 소수점 타입 간의 변환10.3 -- 숫자 변환
숫자 변환Boolean conversions정수, 범위 없는 열거형, 포인터, 또는 멤버 포인터를 bool로 변환4.10 -- if 문 소개
포인터 변환Pointer conversionsstd::nullptr을 포인터로, 또는 포인터를 void 포인터나 기본(base) 클래스 포인터로 변환
포인터 변환Pointer-to-member conversionsstd::nullptr을 멤버 포인터로 변환하거나, 기본 클래스의 멤버 포인터를 파생 클래스의 멤버 포인터로 변환
포인터 변환Function pointer conversionsnoexcept-함수 포인터를 일반 함수 포인터로 변환

형 변환은 실패할 수도 있습니다

암시적이든 명시적이든 형 변환이 호출되면, 컴파일러는 변환이 가능한지 먼저 확인합니다.
유효한 변환 규칙을 찾으면 변환된 새로운 값을 생성합니다. 유효한 변환 규칙을 찾지 못하면 컴파일 오류가 발생합니다.

예를 들어

int main(){
    int x { "14" };
    return 0;
}

문자열 "14"int로 변환하는 표준 규칙이 없기 때문에 컴파일 오류가 발생합니다.


어떤 경우에는 특정 문법이 일부 형 변환을 아예 차단하기도 합니다.

int x { 3.5 }; // 중괄호 초기화는 데이터 손실이 발생하는 변환을 허용하지 않습니다

컴파일러는 double 값을 int 값으로 변환하는 방법 자체는 알고 있습니다.
하지만 중괄호 초기화를 사용할 때는 데이터가 잘려나가는 축소 변환이 언어 규칙상 엄격히 금지되어 있어 컴파일이 거부됩니다.

또한, 가능한 형 변환 후보가 여러 개 있어서 컴파일러가 도대체 어떤 것을 선택해야 할지 모호해하는 경우도 있습니다.
이 부분에 대해서는 11챕터에서 자세히 다룰 것입니다.


10.2 — 부동소수점 승격과 정수 승격

이전 강의 4챕터 — 객체 크기와 sizeof 연산자 에서, C++의 기본 자료형들이 최소한 어느 정도의 크기를 가져야 하는지 보장한다고 배웠습니다.
하지만 실제 자료형의 크기는 컴파일러와 컴퓨터 구조에 따라 달라질 수 있습니다.

이렇게 크기가 달라질 수 있도록 허용한 이유는,
intdouble 같은 자료형을 각 컴퓨터에서 가장 빠르게 처리할 수 있는 크기로 설정할 수 있게 하기 위해서입니다.

예를 들어, 32비트 컴퓨터는 보통 한 번에 32비트 데이터를 처리할 수 있습니다.
이 경우 int는 보통 32비트 크기로 설정됩니다. 왜냐하면 이것이 CPU가 자연스럽게 처리하는 크기이고, 성능도 가장 좋기 때문입니다.

작은 크기 데이터를 처리할 때 무슨 일이 일어날까?

그렇다면 32비트 CPU에서 8비트 값 char 혹은 16비트 값 short을 처리해야 한다면 어떻게 될까요?

CPU마다 다릅니다.

  • 어떤 CPU(예: x86)는 8비트16비트 값을 직접 처리할 수 있음
    → 하지만 32비트 처리보다 느릴 수 있음
  • 어떤 CPU(예: PowerPC)는 오직 32비트 값만 처리 가능
    → 작은 값은 복잡한 추가 작업이 필요함

즉, 작은 자료형을 그대로 처리하는 것이 항상 효율적인 것은 아닙니다.


숫자 승격 (Numeric promotion)

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 로 변환됩니다.
그래서 하나의 함수만 있어도 됩니다.


숫자 승격의 종류

숫자 승격은 크게 두 종류로 나뉩니다.

  • 정수 승격 (integral promotion)
  • 부동소수점 승격 (floating point promotion)

부동 소수점 승격 (Floating point promotions)

더 쉬운 것부터 시작해 봅시다. 이 규칙은 매우 간단합니다.
부동 소수점 승격 규칙에 따라, 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알아서 승격됩니다.


정수 승격 (Integral promotions)

정수 승격 규칙은 조금 더 복잡합니다. 규칙에 따르면 다음과 같은 변환들이 일어납니다.

char short
int로 변환될 수 있습니다.

unsigned char unsigned short char8_t
→ 그 값의 전체 범위를 int가 담을 수 있다면 int로 변환되고, 담을 수 없다면 unsigned int로 변환됩니다.

bool
bool 타입은 int로 변환될 수 있으며, false0으로, true1이 됩니다.

만약 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;
}

주의사항 1가지

intsigned입니다. 즉, 값은 유지되지만 signed/unsigned 속성은 바뀔 수 있습니다.

unsigned charint

모든 확장 변환이 숫자 승격은 아닙니다.

charshort로 바꾸거나, intlong으로 바꾸는 것처럼 단순히 크기가 더 커진다고 해서 모두 C++의 숫자 승격이라고 부르지는 않습니다.

이런 것들은 숫자 변환입니다. 왜 구분할까요?
이런 변환들은 앞서 말한 "CPU가 가장 효율적으로 처리할 수 있는 넉넉한 크기"로 곧바로 변환해 주는 목적과는 다르기 때문입니다.

승격이냐 변환이냐를 구분하는 것이 너무 학술적으로 들릴 수도 있습니다. 하지만 특정 상황에서 컴파일러는 숫자 변환보다 숫자 승격을 더 선호합니다. 이 차이가 결과를 어떻게 바꾸는지에 대해서는 나중에 함수 오버로딩 해결을 배우는 챕터 11에서 명확한 예제와 함께 다시 살펴볼 것입니다.


10.3 — 숫자 변환 (Numeric conversions)

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) — 위험하지만 데이터는 유지됨

이 변환은 불안전합니다. 변환된 값이 원래 값과 달라질 수 있기 때문입니다.
하지만 데이터 자체가 날아가는 것은 아닙니다. SignedUnsigned 사이의 변환이 여기에 속합니다.

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.5int로 바꿔서 3이 되었다면, 이걸 다시 double로 바꿔봤자 3.5가 아닌 3.0이 될 뿐입니다.
컴파일러는 보통 이런 변환을 시도할 때 경고나 에러를 띄웁니다.


숫자 변환 시 꼭 기억해야 할 4가지 원칙

규칙이 너무 많아서 복잡해 보인다면, 초보자 분들은 아래 내용만 확실히 기억해 두세요.

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. intfloat 변환은 보통 안전합니다

int i{ 10 };
float f = i;
std::cout << f << '\n'; // 10 출력

4. floatint 변환은 소수점이 사라집니다

int i = 3.5;
std::cout << i << '\n'; // 0.5가 버려지고 3 출력

10.4 — 축소 변환, 리스트 초기화, 그리고 constexpr 초기화

축소 변환 (Narrowing conversions)

C++에서 축소 변환이란 잠재적으로 위험할 수 있는 숫자 변환을 뜻합니다.
변환하려는 원래 데이터가 변환될 자료형의 그릇보다 커서, 데이터의 일부를 잃어버릴 수 있기 때문입니다.
즉, 목적지 타입이 원본 타입의 모든 값을 저장할 수 없는 경우를 말합니다.

다음과 같은 변환들이 축소 변환으로 정의됩니다.

1. 실수형 → 정수형 변환  doubleint
실수는 소수점을 포함할 수 있지만, 정수는 소수점을 저장할 수 없기 때문에 값이 손실될 수 있습니다.

2. 실수형 → 더 작은 실수형 변환 doublefloat
실수형에서 더 작거나 단계가 낮은 실수형으로 변환할 때 값이 손실될 수 있습니다.

  • 단, 변환하려는 값이 constexpr 이고, 목적지 타입 범위 안에 있으면 축소 변환으로 간주되지 않습니다.
    (정밀도가 줄어들더라도 범위 안에 있으면 예외가 적용됩니다.)

3. 정수형 → 실수형 변환

  • 단, 값이 constexpr이고 목적지 타입에서 정확하게 표현 가능하면 축소 변환이 아닙니다.

4. 정수형 → 더 작은 정수형 변환 long → int int → short int → unsigned int unsigned 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;
}

일부 constexpr 변환은 축소 변환으로 간주되지 않습니다

값이 런타임에 결정되는 경우, 변환 결과도 런타임에만 알 수 있습니다.

#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 값은 컴파일 시점에 이미 값이 결정되어 있습니다.

따라서 컴파일러는

  1. 직접 변환을 수행하고
  2. 값이 유지되는지 확인할 수 있습니다

값이 유지되면 → 축소 변환이 아님
값이 바뀌면 → 컴파일 오류 발생

#include <iostream>

int main(){
    constexpr int n1{ 5 };   // 참고: constexpr
    unsigned int u1 { n1 };  // 문제없음: 예외 조항 덕분에 축소 변환으로 간주되지 않습니다.

    constexpr int n2 { -5 }; // 참고: constexpr
    unsigned int u2 { n2 };  // 컴파일 오류: 값이 변하기 때문에 축소 변환으로 간주됩니다.

    return 0;
}

10.5 — 산술 변환 (Arithmetic conversions)

다음 표현식을 살펴봅시다.

int x { 2 + 3 };

이 코드에서 이항 연산자 + 는 두 개의 피연산자 2 3를 받습니다. 그리고 두 피연산자는 모두 int 타입입니다.

피연산자의 타입이 동일하기 때문에

  • 계산도 int 타입으로 수행되고
  • 결과도 int 타입으로 반환됩니다.

따라서 2 + 3 의 결과는 int 타입 값 5가 됩니다.
그렇다면 피연산자의 타입이 서로 다르면 어떻게 될까요?

??? y { 2 + 3.5 };

이 경우 2int , 3.5double 입니다. 이때 결과는 어떤 타입이 될까요? 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 이 더 높은 우선순위를 가집니다.
따라서 int2double2.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 문제

signedunsigned 를 섞어서 사용하면 예상하지 못한 결과가 나올 수 있습니다.

#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 는 음수를 표현할 수 없기 때문에, 이상한 결과가 나옵니다.


std::common_type 와 std::common_type_t

두 타입의 공통 타입을 알고 싶을 때 사용할 수 있는 도구입니다.
이 기능은 나중 강의에서 더 자세히 다룹니다.

  • 헤더 #include <type_traits>
  • std::common_type_t<int, double> → double
  • std::common_type_t<unsigned int, long> → 공통 타입 반환

10.6 — 명시적 타입 변환(casting)과 static_cast

정수 나눗셈 때문에 발생하는 흔한 실수

많은 초보 C++ 프로그래머들은 종종 아래와 같은 실수를 하곤 합니다.

double d = 10 / 4; // 정수 나눗셈을 수행하여, d를 2.0으로 초기화합니다.

대부분의 경우, 우리는 2.5 를 기대했을 것입니다.
하지만 정수 나눗셈을 실행하여 결과값으로 2.0 을 만들어냅니다.
왜 이런 일이 발생할까요?

  • 104는 둘 다 int 타입입니다. 따라서 정수 나눗셈이 수행됩니다.
  • 정수 나눗셈의 결과는 2.5가 아니라, 2입니다.
  • 그 다음에 2double로 변환되어 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++는 캐스트라는 기능을 제공합니다.


타입 캐스팅(Type casting)

캐스트란 프로그래머가 직접 컴파일러에게 타입 변환을 요청하는 것 입니다. 이것을 명시적 타입 변환이라고 합니다.
반대로, 컴파일러가 자동으로 수행하는 것은 암시적 타입 변환이라고 합니다.


C++에서 지원하는 캐스트 종류

C++는 5가지 종류의 캐스트를 지원합니다.
static_cast dynamic_cast const_cast reinterpret_cast C스타일 캐스트 입니다.
앞의 네 가지는 이름 있는 캐스트(named casts) 라고도 부릅니다.

캐스트설명안전성
static_cast관련된 타입 간 변환안전
dynamic_cast상속 구조에서 런타임 변환안전
const_castconst 추가 또는 제거const 추가만 안전
reinterpret_cast비트 단위 재해석위험
C-style cast여러 캐스트 조합위험

모든 캐스트의 기본 작동 방식은 동일합니다. 변환할 값목표 타입 을 입력으로 받고, 변환이 완료된 결과를 출력해 줍니다.
이 강의에서는 가장 흔하게 쓰이는 C스타일 캐스트static_cast에 집중하겠습니다.

관련 내용 및 주의사항

  • dynamic_cast는 필요한 기본 지식을 배운 후 나중에 다룹니다.
  • const_castreinterpret_cast는 아주 드문 상황에서만 유용하고 잘못 쓰면 위험하므로 아주 타당한 이유가 없다면 사용을 피하세요.

C 스타일 캐스트

과거 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;
}
  • xdouble로 변환됨
  • 실수 나눗셈 수행
  • 결과 2.5

함수 스타일 캐스트

C++에서는 다음과 같은 형태도 가능합니다. 이 방식은 함수 호출처럼 보여서 조금 더 읽기 쉽습니다.

std::cout << double(x) / y << '\n'; // x를 double로 변환하는 함수 스타일 캐스트

하지만 현대 C++에서는 C스타일 캐스트를 일반적으로 피해야 합니다. 그 이유는 크게 두 가지입니다.

이유 1: 어떤 변환이 일어나는지 명확하지 않음

C스타일 캐스트는 겉보기엔 단순해 보여도, 사용되는 상황에 따라 static_cast const_cast reinterpret_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바이트 크기인 int1바이트인 char에 욱여넣는 것은 잠재적으로 안전하지 않습니다.
숫자가 char허용 범위를 넘쳐버릴(오버플로우) 수 있기 때문이죠. 그래서 컴파일러가 경고를 주는 것입니다.

이때 static_cast를 사용하면 컴파일러에게 "내가 이 변환의 위험성을 알고 있고 의도한 거야!"라고 명확히 알려줄 수 있습니다.

int i { 48 };
// int에서 char로 명시적 변환을 수행하여, 변수 ch에 char 타입으로 대입합니다.
char ch { static_cast<char>(i) };

이렇게 하면 결과값이 확실한 char 타입이 되기 때문에, 대입할 때 타입이 어긋나지 않아 컴파일러가 더 이상 경고를 보내지 않습니다.
물론 범위를 벗어나 넘쳐흐르는 결과에 대한 책임은 프로그래머의 몫이 됩니다.

또 다른 예로, doubleint로 변환할 때 데이터 손실(소수점 아래 잘림) 경고가 뜨는 것을 막고 싶다면 아래처럼 명확히 의도를 밝히면 됩니다.

int i { 100 };
i = static_cast<int>(i / 2.5); // "소수점이 잘려도 괜찮아, 내가 의도한 거야"

10.7 — Typedef와 타입 별칭 (Typedefs and type aliases)

타입 별칭 (Type aliases)

C++에서 using 은 기존 데이터 타입에 새로운 이름(별칭)을 붙일 수 있게 해주는 키워드입니다.

using Distance = double; // Distance를 double 타입의 별칭으로 정의

이제 Distancedouble 과 같은 의미로 사용할 수 있습니다.

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

타입 별칭은 새로운 타입이 아닙니다

타입 별칭은 새로운 타입을 만드는 것이 아닙니다. 단지 기존 타입에 다른 이름을 붙이는 것뿐입니다.


타입 별칭의 범위(scope)

타입 별칭의 이름도 일반 변수처럼 '사용 가능한 범위(스코프)' 규칙을 따릅니다.
즉, 중괄호 {} 블록 안에서 별칭을 만들면 그 블록 안에서만 쓸 수 있고,
파일의 맨 위(전역 네임스페이스)에 만들면 그 파일 끝까지 어디서든 쓸 수 있습니다.

여러 파일에서 똑같은 타입 별칭을 쓰고 싶다면, 헤더 파일에 정의해 두고 필요한 코드 파일에서 #include로 불러와 사용하면 됩니다.

// mytypes.h:
#ifndef MYTYPES_H
#define MYTYPES_H

    using Miles = long;
    using Speed = long;

#endif

typedef (옛 방식)

typedef타입 별칭을 만드는 옛 방식입니다.

typedef long Miles;
using Miles = long;

둘은 같은 의미입니다.
하지만 modern C++에서는 using 이 더 권장됩니다.

용어 정리
C++ 공식 표준 문서나 일반적인 프로그래밍 대화에서는 typedefusing이나 결국 하는 일이 같기 때문에, 이 둘을 구분 없이 뭉뚱그려 typedef라고 부르기도 합니다.


타입 별칭은 언제 사용해야 할까?

1. 플랫폼 독립 코드 작성
int는 시스템마다 크기가 다를 수 있습니다. 어떤 시스템에선 2 bytes 어떤 시스템에선 4 bytes 일수도 있습니다.
이 문제를 해결하기 위해 int8_t int16_t int32_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;

10.8 — auto 키워드를 사용한 객체의 타입 추론(Type Deduction)

다음과 같이 변수를 정의할 때, 사실 우리는 타입 정보를 약간 불필요하게 중복해서 적고 있습니다.

double d{ 5.0 };

C++에서는 모든 객체를 선언할 때 반드시 타입을 명시해야 합니다. 그래서 우리는 변수 ddouble 타입이라고 직접 써 주었습니다.
하지만 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;
}

물론 constconstexpr 같은 한정자도 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;
}

auto의 장점

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() };        // 좋음: 변환 없음

auto의 단점

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 로 추론하게 만들고 싶다면, ssv 접미사를 꼭 붙여주어야 합니다.

#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;
}

타입 추론은 const를 제거한다

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과 타입 추론

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;
}

10.9 — 함수의 타입 추론 (Type deduction for functions)

auto로 반환 타입 자동 추론하기

컴파일러는 어차피 return 문을 보고 “이 값이 반환 타입으로 변환 가능한가?”를 검사해야 합니다.
그래서 C++14부터는 함수의 반환 타입 자리에도 auto를 쓸 수 있게 확장되었습니다.
즉, 반환 타입을 직접 쓰는 대신 auto라고 적으면 컴파일러가 알아서 반환 타입을 추론합니다.

auto add(int x, int y){
    return x + y;
}

여기서는 return x + y;int 값을 반환하므로, 컴파일러가 이 함수의 반환 타입을 int로 추론합니다.


auto 반환 타입을 쓰면 모든 return의 타입이 같아야 함

auto 반환 타입을 사용할 때 주의할 점은, 함수 안의 모든 return 문이 반드시 같은 타입의 값을 반환해야 한다는 것입니다.
그렇지 않으면 에러가 발생합니다.

auto someFcn(bool b){
    if (b)
        return 5;   // 반환 타입: int
    else
        return 6.7; // 반환 타입: double
}

위 함수는 returnintdouble서로 다르기 때문에 컴파일러가 오류를 냅니다.
만약 “상황에 따라 다른 타입을 반환” 같은 동작을 원한다면, 보통은 다음 중 하나로 해결합니다.

반환 타입을 명시적으로 적기
그러면 컴파일러는 반환 타입에 맞게(가능하면) 암시적 변환을 시도합니다.

모든 return 값을 같은 타입으로 맞추기
예를 들어 위 코드에서는 55.0으로 바꾸면 둘 다 double이 됩니다.
리터럴이 아닌 타입이라면 static_cast 같은 명시적 변환을 사용할 수도 있습니다.


반환 타입 자동 추론의 장점

가장 큰 장점은 반환 타입 불일치로 인한 실수를 줄여준다는 점입니다.
즉, “내가 반환 타입을 잘못 적어서 원하지 않는 변환이 일어나는 상황”을 막는 데 도움이 됩니다.

또 다른 경우로, 함수의 반환 타입이 너무 길고 복잡하거나 한눈에 파악하기 힘들 때 auto를 사용해 코드를 단순하게 만들 수 있습니다.

// 컴파일러가 unsigned short와 char를 더한 결과의 타입을 스스로 결정하도록 합니다.
auto add(unsigned short x, char y){
    return x + y;
}

반환 타입 자동 추론의 단점

편리해 보이지만 두 가지 큰 단점도 존재합니다.

  1. auto 반환 타입을 사용하는 함수는 사용되기 전'완전히' 정의되어 있어야 합니다.
    함수의 껍데기만 보여주는 전방 선언만으로는 부족합니다.
#include <iostream>

auto foo();

int main(){
    std::cout << foo() << '\n'; // 이 시점에서 컴파일러는 함수의 전방 선언만 본 상태입니다.

    return 0;
}

auto foo(){
    return 5;
}
  1. 객체에서 타입 추론을 할 때는 보통 같은 문장 안에 초기값이 같이 있어서, “어떤 타입이 추론될지” 비교적 알기 쉽습니다.

하지만 함수는 다릅니다. 함수의 선언(프로토타입)만 보면 실제로 어떤 타입을 반환하는지 알 수 없습니다. 좋은 IDE는 “추론된 반환 타입”을 보여주겠지만, 그런 도움 없이 코드를 읽는 사람은 함수 본문을 직접 열어봐야 반환 타입을 알 수 있습니다. 그만큼 실수할 가능성도 올라갑니다.

일반적으로 우리는 인터페이스(함수 선언은 인터페이스입니다)에 포함되는 타입은 명시적으로 적는 쪽을 더 선호합니다.

그래서 함수 반환 타입 추론은 객체 타입 추론만큼 “이게 정답”이라는 합의가 강하지 않습니다. 이
강의에서는 대체로 함수 반환 타입 추론을 피하는 것을 권장합니다.


후행 반환 타입 (Trailing return type) 문법

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) 기능을 사용하게 만드는 역할을 합니다.

profile
안녕하세요.

0개의 댓글