본문으로 건너뛰기

안동민 개발노트

본문 시작

형변환

암시적 변환과 명시적 캐스팅의 동작을 비교하며 데이터 손실과 의도하지 않은 타입 변환을 피하는 기준을 익힙니다.

프로그램은 단순히 데이터를 저장하는 것을 넘어, 다양한 타입의 데이터 간에 연산을 수행해야 할 때가 많습니다.

예를 들어 정수와 실수를 함께 계산하거나, 문자를 숫자로 변환하는 등의 작업이 필요할 수 있습니다.

이 과정에서 필연적으로 발생하는 것이 바로 형 변환(Type Conversion)입니다.

형 변환은 한 데이터 타입을 다른 데이터 타입으로 변경하는 과정을 의미합니다.

이 장에서는 C++에서 형 변환이 어떻게 이루어지는지, 특히 자동으로 발생하는 암시적 형 변환(Implicit Conversion)과 프로그래머가 명시적으로 지시하는 명시적 형 변환(Explicit Conversion)을 다룹니다.

먼저 컴파일러가 자동으로 맞춰도 되는 변환과 개발자가 의도를 드러내야 하는 변환을 구분해 봅니다.

C++ 암시적 변환을 초기화와 호출, 일반 산술 변환, 승격, 값이 달라질 수 있는 변환으로 나누고 대상 타입과 값 보존 여부를 확인하는 표
암시적으로 허용되는지와 값을 보존하는지는 별개의 질문이므로, 변환 문맥이 정한 대상 타입과 실제 값의 범위를 함께 확인한다.
변환 문맥 대상 타입을 정하는 것 대표 예 수락 전 확인
초기화 · 대입 · 호출 · 반환 객체·매개변수·반환 타입 double d = 10; 값이 대상 범위와 정밀도에서 보존되는가? 경고를 표준 보장으로 오해하지 않는가?
일반 산술 변환 연산자가 만드는 공통 타입 10 + 3.5 → double signed/unsigned 조합과 정수·부동 소수점 정밀도 변화가 의도와 맞는가?
정수 · 부동 소수점 승격 정수 변환 rank와 부동 소수점 승격 규칙 bool → int, float → double 승격과 임의의 산술 변환을 같은 말로 부르지 않았는가?
값이 달라질 수 있는 변환 문맥의 대상 타입 3.14 → int(3), 큰 정수 → double 소수부 폐기, 정밀도 손실, 범위 밖 값의 동작을 검토했는가?
초기화 · 대입 · 호출 · 반환
대상 객체·매개변수·반환 타입이 정합니다.
double d = 10;에서 값 10은 정확히 보존됩니다.
확인 다른 값도 범위와 정밀도에서 보존되는지 따로 봅니다.
일반 산술 변환
대상 연산자가 피연산자의 공통 타입을 만듭니다.
10 + 3.5의 공통 타입과 결과 타입은 double입니다.
확인 signed/unsigned와 정밀도 변화가 의도와 맞는지 봅니다.
정수 · 부동 소수점 승격
대상 정수 rank와 부동 소수점 승격 규칙이 정합니다.
bool → int, float → double
확인 모든 산술 변환을 승격이라고 부르지 않습니다.
값이 달라질 수 있는 변환
3.14 → int(3), 큰 정수 → double
확인 소수부 폐기, 정밀도 손실, 범위 밖 값의 동작을 검토합니다.
리스트 초기화는 표준이 정한 narrowing을 오류로 만들지만 모든 의미 손실을 증명하지는 않습니다. 명시적 캐스트도 값 검증을 대신하지 않습니다.

형 변환이란 무엇인가?

컴퓨터 내부적으로 각 데이터 타입은 다른 방식으로 데이터를 저장하고 처리합니다.

예를 들어, 정수 5와 실수 5.0은 수학적으로는 같은 값처럼 보이지만, 컴퓨터 메모리에서는 전혀 다른 형태로 저장됩니다.

메모리에 저장되는 예시
int integer_five = 5;       // 메모리에 정수 5로 저장
double double_five = 5.0;   // 메모리에 실수 5.0으로 저장 (부동 소수점 방식)

만약 integer_fivedouble_five를 함께 연산해야 한다면 두 값을 동일한 형식으로 맞추는 과정이 필요합니다.

이때 형 변환이 발생합니다.

형 변환은 주로 다음과 같은 상황에서 일어납니다.

  • 서로 다른 데이터 타입의 값을 함께 연산할 때.
  • 함수에 전달되는 인자의 타입이 함수의 매개변수 타입과 다를 때.
  • 함수가 반환하는 값의 타입이 함수의 반환 타입과 다를 때.
  • 변수에 값을 할당할 때 할당하려는 값의 타입이 변수의 타입과 다를 때.

암시적 형 변환 (Implicit Conversion)

암시적 형 변환은 컴파일러가 자동으로 수행하는 형 변환입니다.

프로그래머가 특별한 지시를 하지 않아도, 연산자·초기화·함수 호출·반환처럼 문맥이 대상 타입을 요구하고 허용되는 변환 순서가 있으면 컴파일러가 값을 자동으로 변환합니다.

암시적으로 허용된다는 사실이 곧 무손실이나 안전을 뜻하지는 않습니다.

정수 승격처럼 원래 타입의 모든 값을 보존하는 변환도 있지만, 정수에서 부동 소수점으로 갈 때 정밀도를 잃거나 signed와 unsigned가 섞여 예상과 다른 공통 타입이 선택될 수도 있습니다.

암시적 형 변환이 발생하는 경우

대입 연산 시: 오른쪽 식의 타입이 왼쪽 객체의 타입과 다르면, 그 객체를 초기화하거나 대입하는 데 필요한 변환이 적용됩니다.

대입 연산 예시
int num_int = 10;
double num_double = num_int; // 값 10은 double로 정확히 표현된다.
std::cout << "num_double: " << num_double << std::endl; // 기본 서식 출력: 10

이 예시의 값 10은 보존되지만, 모든 int 값이 모든 부동 소수점 형식에서 정확히 표현된다고 일반화할 수는 없습니다.

반대로 부동 소수점 값을 정수 객체에 넣으면 소수부를 버립니다. 잘린 값이 대상 정수 타입으로 표현되지 않으면 동작이 정의되지 않으며, 일반 대입에서 진단을 내보낼지는 컴파일러 옵션에 달려 있습니다.

정보 손실 예시
double pi = 3.141592;
int integer_pi = pi; // pi (double)가 integer_pi (int)로 암시적 형 변환됨
                      // 표현 가능한 값이므로 소수부를 버려 3이 된다.
std::cout << "integer_pi: " << integer_pi << std::endl; // 출력: 3

산술 연산 시: 많은 이항 산술 연산자는 피연산자에 일반 산술 변환(usual arithmetic conversions)을 적용해 하나의 공통 타입을 만들고, 그 타입으로 연산합니다.

작은 정수 타입이나 boolint 또는 unsigned int로, floatdouble로 바뀌는 일부 규칙만 승격(Promotion)이라고 부릅니다. 서로 다른 임의의 산술 타입을 공통 타입으로 맞추는 전체 과정을 모두 승격이라고 하지는 않습니다.

산술 연산 예시
int a = 10;
double b = 3.5;
double result = a + b; // a (int)가 double로 암시적 형 변환된 후 b와 더해짐
                       // 공통 타입과 결과 타입은 double
std::cout << "result: " << result << std::endl; // 출력: 13.5

함수 호출 시: 함수의 매개변수 타입과 전달하는 인자의 타입이 다를 때, 매개변수 타입으로 자동 변환됩니다.

이 변환도 값의 범위와 정밀도를 자동으로 보장하지는 않으므로 API 경계에서 대상 타입의 계약을 확인해야 합니다.

함수 호출 예시
void printNumber(double num) {
    std::cout << "받은 숫자: " << num << std::endl;
}

int main() {
    int my_int = 123;
    printNumber(my_int); // my_int (int)가 num (double)로 암시적 형 변환됨
    return 0;
}

charbool의 정수형 변환: char는 정수 타입이며 sizeof(char)는 항상 1이지만, 한 바이트의 비트 수는 CHAR_BIT가 정하므로 반드시 8비트인 것은 아닙니다. 작은 정수 타입은 정수 승격 규칙에 따라 int가 모든 값을 표현할 수 있으면 int로, 그렇지 않으면 unsigned int로 승격됩니다. bool은 승격될 때 false가 0, true가 1이 됩니다.

char와 bool의 정수형 변환 예시
char ch = 'A';
int code_unit = ch; // 현재 구현의 ordinary literal encoding 값으로 승격
std::cout << "code unit of 'A': " << code_unit << std::endl; // 값은 실행 문자 인코딩에 달려 있다.

bool flag = true;
int boolean_int = flag; // flag (bool)가 int로 변환 (true -> 1)
std::cout << "boolean_int: " << boolean_int << std::endl; // 출력: 1

암시적 형 변환은 코드를 간결하게 만들지만, 허용 여부와 값 보존 여부는 별개의 질문입니다. 공통 타입, 대상 타입의 표현 범위, signed/unsigned 혼합, 정수 나눗셈처럼 변환이 일어나는 시점을 함께 살펴봐야 합니다.


명시적 형 변환 (Explicit Conversion)

명시적 형 변환은 프로그래머가 특정 데이터 타입을 다른 타입으로 변환하도록 의도적으로 지시하는 것입니다.

이는 암시적 형 변환만으로는 충분하지 않거나, 정보 손실이 예상될 때 프로그래머의 의도를 명확히 전달하기 위해 사용됩니다.

명시적 형 변환을 캐스팅(Casting)이라고도 부릅니다.

C++에서는 여러 가지 캐스팅 방법이 있습니다.

C-스타일 캐스팅 (C-style Casting): 가장 오래되고 단순한 형태의 캐스팅입니다.

괄호 안에 변환하고자 하는 타입을 명시합니다.

C-스타일 캐스팅
int numerator = 10;
int denominator = 3;

// (double) numerator를 사용하여 정수 나눗셈이 아닌 실수 나눗셈을 유도
double result = (double)numerator / denominator;
std::cout << "result (C-style cast): " << result << std::endl; // 실수 나눗셈 결과

이 표기는 문맥에 따라 const_cast, static_cast 또는 reinterpret_cast에 해당하는 변환이나 그 조합을 시도할 수 있어, 어떤 종류의 위험을 받아들이는지 이름만으로 드러나지 않습니다.

C++ 코드에서는 변환 의도를 더 좁게 드러내는 이름 있는 캐스팅 연산자를 우선 검토합니다.

C++ 스타일 캐스팅 (C++-style Casting): C++은 허용하는 변환의 종류를 용도별로 나눈 네 가지 캐스팅 연산자를 제공합니다.

이름이 의도를 좁혀 검색과 검토를 돕지만, 캐스트 자체가 값의 범위·객체 수명·실제 동적 타입을 자동으로 안전하게 만들어 주지는 않습니다.

  • static_cast<new_type>(expression): 가장 흔히 사용되는 캐스팅 연산자입니다.

    숫자·열거형 변환, 명시적으로 허용된 생성·변환, 일부 상속 관계처럼 표준이 정한 static_cast 규칙에 맞는 변환에 사용됩니다.

    변환 형식이 허용되는지는 컴파일 시점에 검사하지만, 숫자의 범위·정밀도나 수동 다운캐스트가 실제 객체와 맞는지는 별도로 확인해야 합니다.

    정보 손실이 발생할 수 있는 변환(예: double에서 int)도 허용하지만, 명시적이기 때문에 프로그래머의 의도를 명확히 합니다.

    static_cast 예시
    int i_val = 10;
    double d_val = 3.14;
    
    int cast_to_int = static_cast<int>(d_val); // double -> int (정보 손실)
    double cast_to_double = static_cast<double>(i_val); // int -> double
    char char_from_int = static_cast<char>(65); // int -> char, 표시 문자는 실행 문자 인코딩에 따름
    
    std::cout << "static_cast<int>(3.14): " << cast_to_int << std::endl; // 출력: 3
    std::cout << "static_cast<double>(10): " << cast_to_double << std::endl; // 기본 서식 출력: 10
    std::cout << "static_cast<char>(65): " << char_from_int << std::endl;
  • dynamic_cast<new_type>(expression): 주로 다형성(Polymorphism)을 사용하는 클래스 계층 구조에서 런타임에 다운캐스팅이나 교차 캐스팅을 검사할 때 사용됩니다.

    검사에 실패하면 포인터 대상은 nullptr이 되고 참조 대상은 std::bad_cast를 던집니다.

    나중에 객체 지향 프로그래밍을 배울 때 중요하게 다룰 것입니다.

  • const_cast<new_type>(expression): 비슷한 포인터·참조 타입 사이에서 cv 한정자를 조정할 때 사용됩니다. const를 추가하는 일반적인 변환은 보통 암시적으로 가능하므로, 실제로는 오래된 API 경계처럼 한정자를 제거해야 하는 제한된 상황에서 주로 보입니다.

    캐스트 결과가 같은 객체를 가리키더라도, 원래부터 const인 객체를 그 결과를 통해 수정하면 동작이 정의되지 않습니다.

  • reinterpret_cast<new_type>(expression): 표준이 열거한 포인터·정수·함수 포인터 등의 저수준 매핑에 사용됩니다.

    이름과 달리 언제나 같은 비트 패턴을 다른 값 타입으로 복사하는 연산은 아닙니다. 매핑은 구현 정의이거나 명시되지 않을 수 있고, 변환된 포인터로 객체에 접근하거나 함수를 호출할 수 있는지는 정렬·수명·type-accessibility·call-compatibility 같은 별도 전제에 달려 있습니다.

    ABI나 장치 경계처럼 외부 계약이 이 매핑을 요구하고 모든 전제를 문서화할 수 있을 때만 제한적으로 사용합니다.

중괄호 초기화 {}와 형 변환: C++11에서 도입된 리스트 초기화({})는 표준이 narrowing conversion으로 분류한 암시적 변환이 필요하면 프로그램을 ill-formed로 만듭니다.

모든 잠재적 정보 손실을 탐지하는 일반 검증기는 아니며, 클래스에서는 생성자 후보와 std::initializer_list 우선순위에도 영향을 줍니다.

int main() {
    int x {3.14}; // 컴파일 오류! double에서 int로 변환 시 정보 손실 발생.
                  // 명시적 형 변환이 필요함을 알림
    int y = 3.14; // 표준상 허용되고 3으로 잘리지만, 경고 여부는 도구 설정에 달려 있음

    int z = static_cast<int>(3.14); // 표현 가능한 잘린 값 3을 의도적으로 선택

    return 0;
}

따라서 중괄호 초기화는 표준이 정한 narrowing을 컴파일 오류로 드러내는 유용한 경계입니다. 다만 변환 후 값이 의도와 맞는지, 어떤 생성자가 선택되는지는 여전히 검토해야 합니다.

static_cast, dynamic_cast, const_cast, reinterpret_cast가 허용하는 변환 경계와 실패 신호, 프로그래머가 검증해야 할 전제를 비교하는 결정 그리드
static_cast
경계 숫자·열거형·명시적으로 허용된 변환과 일부 상속 관계
신호 허용 형식은 컴파일 시점에 결정됩니다.
검증 숫자 범위·정밀도와 수동 다운캐스트의 실제 객체 타입
dynamic_cast
경계 다형적 계층의 런타임 다운·교차 캐스트
신호 실패한 포인터는 nullptr, 참조는 std::bad_cast
검증 객체 수명, public·유일한 관계, 실패 처리
const_cast
경계 비슷한 포인터·참조의 cv 한정 조정
신호 결과는 같은 객체를 가리킵니다.
검증 원래부터 const인 객체를 결과를 통해 쓰지 않습니다.
reinterpret_cast
경계 포인터·정수·함수 포인터의 저수준 매핑
신호 결과가 구현 정의·미지정·조건부 지원일 수 있습니다.
검증 정렬·수명·접근·호출 호환성과 외부 ABI
C-스타일 캐스트는 여러 변환 종류나 조합을 한 표기로 숨길 수 있습니다. 이름 있는 캐스트를 선택하고도 각 행의 전제를 증명할 수 없다면 설계를 다시 검토합니다.

형 변환의 중요성과 주의사항

  • 정확한 연산 결과: 특히 정수와 실수가 섞인 연산에서 형 변환은 결과에 큰 영향을 미칩니다. int / int는 정수 나눗셈을 수행하지만, double / int (또는 int / double)는 실수 나눗셈을 수행합니다.
    정수와 실수 나눗셈 형변환 예시
    int total_score = 100;
    int num_students = 3;
    
    double average_int = total_score / num_students; // 100 / 3 = 33 (정수 나눗셈)
    double average_double = static_cast<double>(total_score) / num_students; // 100.0 / 3 = 33.33... (실수 나눗셈)
    
    std::cout << "정수 나눗셈 평균: " << average_int << std::endl;    // 출력: 33
    std::cout << "실수 나눗셈 평균: " << average_double << std::endl; // 33보다 큰 실수 몫
  • 정보 손실 진단: 일반 대입의 경고는 구현과 옵션에 달려 있지만, 리스트 초기화가 표준의 narrowing 변환을 요구하면 프로그램은 ill-formed입니다.
  • 가독성: 명시적 형 변환은 프로그래머의 의도를 명확하게 보여주므로 코드의 가독성을 높입니다.
  • 검토 가능성: static_cast, dynamic_cast, const_cast, reinterpret_cast는 허용되는 변환과 검증 책임을 이름으로 분리합니다. C-스타일 캐스트보다 검색과 리뷰가 쉽지만, 각 연산자의 전제를 지켜야 합니다.

아래 다이어그램은 산술 표현식 안에서 형 변환을 어느 시점에 적용해야 결과 타입이 바뀌는지 보여줍니다.

정수 total과 count를 먼저 나눈 뒤 double에 저장하는 흐름은 33.0을 만들고, 나눗셈 전에 total을 double로 변환하는 흐름은 실수 나눗셈으로 약 33.333을 만드는 비교
나눗셈 전후 변환 시점 비교 total과 count가 모두 int인 상태에서 나눗셈을 먼저 하면 33이 생긴 뒤 double 33.0으로 저장됩니다. total을 먼저 double로 바꾸면 count도 공통 타입 double로 변환되어 약 33.333의 실수 결과가 생깁니다. 같은 정수 입력 total = 100 · count = 3 연산 뒤에 변환 연산 전에 변환 표현식을 먼저 평가 int / int → 33 피연산자를 먼저 변환 double / int → double double 객체를 초기화 33 → 33.0 실수 나눗셈을 실행 100.0 / 3.0 → 약 33.333… 손실은 대입 전에 이미 발생 공통 타입을 연산 전에 결정
same input total = 100 · count = 3
연산 뒤에 변환
표현식 double avg = total / count;
연산 int / int → 33
저장 이미 생긴 3333.0으로 바뀝니다.
연산 전에 변환
표현식 static_cast<double>(total) / count
연산 일반 산술 변환 뒤 double / double
결과 100.0 / 3.0 → 약 33.333…
결과 객체의 타입은 이미 끝난 연산의 규칙을 되돌리지 못합니다. 원하는 공통 타입은 연산이 시작되기 전에 피연산자에서 만들어야 합니다.

핵심은 변환을 숨기지 않고, 연산이 시작되기 전에 필요한 타입을 명확히 만드는 것입니다.