[Advanced C++] 22. Narrowing Conversion(축소 변환), Arithmetic Conversion(산술 변환), Casting, Type Aliases(타입 별칭 (using, typedef))

dev.kelvin·2025년 2월 25일
1

Advanced C++

목록 보기
22/74
post-thumbnail

1. Narrowing Conversion (축소 변환)

Narrowing Conversion

축소 변환은 대상 타입이 원본 타입의 모든 값을 담을 수 없을 가능성이 있을때, (데이터 손실이 발생할 수 있는) 잠재적으로 안전하지 않은 변환이다

  • 실수에서 정수로 변환
  • 더 큰 범위의 실수를 작은 범위의 실수로 변환 (이때 constexpr 값이라 컴파일 타임에 데이터 손실이 발생하지 않는다면 괜찮음, 이때 정밀도 손실까지는 괜찮음)
  • 아주 큰 정수에서 작은 실수로 변환 (이때 constexpr값이라 컴파일 타임에 데이터 손실이 발생하지 않는다면 괜찮음, longlong -> float)
  • 더 큰 범위의 정수를 작은 범위의 정수로 변환
  • signed <-> unsigned 사이의 변환

대부분의 암시적 축소 변환은 compile error를 발생시킨다

데이터 손실 위험이 있기 때문에 가능한 축소 변환은 피하는것이 좋다

  • 암시적인 축소 변환을 피하기 위해 static_cast를 사용하자 (코드의 의도가 명확해짐)
	double d{ 6.0 };
    int i = d; //Bad
    int i = static_cast<int>(d); //Good
  • 리스트 초기화를 사용하여 축소 변화를 막는 방법 (자동으로 차단)
	int i{ 5.5f }; //error
    int i{ static_cast<int>(5.5f) }; //Good

constexpr 값 변환

constexpr값을 변환할 때 값이 보존된다면 축소 변환이 아니다, 컴파일러가 변환을 미리 수행할 수 있다면 허용된다 (컴파일러가 런타임 전에 해당 값이 보존되는지 파악할 수 있기 때문이다)

	constexpr int i{ 10 };
    unsigned int ui{ i }; //축소 변환 아님, 허용
    
    constexpr int i{ -10 };
    unsigned int ui{ i }; //컴파일 에러 발생

실수에서 정수, 실수에서 실수 변환

실수에서 정수 변환은 항상 축소 변환이다 (constexpr을 사용해도 축소 변환임, 항상 compile error)

	int i{ 10.5 }; //10으로 데이터 손실 발생, 컴파일 에러

constexpr실수값 에서 더 작은 타입의 실수 변환은 허용되지만 정밀도 손실이 일어날 수 있다 (자릿수 손실이 발생해도 축소 변환으로 간주되지 않고 에러가 발생하지 않는다)

	double d{ 10.45645645 };
	float f{ d }; //Narrowing Conversion compile error
    
    constexpr double d{ 10.4565645 };
    float f{ d }; //허용

리스트 초기화와 constexpr

리스트 초기화를 사용하면 literal suffix를 생략할 수 있다

	float f{ 5.0 }; //.f 사용하지 않아도 됨
    unsigned int u{ 5 }; //u 사용하지 않아도 됨

constexpr 값을 중괄호 초기화에 사용한다면 static_cast로 명시적 변환하지 않아도 된다 (컴파일 타임에 데이터 손실이 방지하는지 체크하기 때문)

	constexpr int i{ 10 };
    double d{ i }; //pass

2. Arithmetic Conversion (산술 변환)

Arithmetic Conversion

산술 변환이란 피연산자들끼리 타입이 다를때 이를 일치시키기 위해 자동으로 변환하는걸 의미한다

산술 연산자를 사용할 때 피연산자들은 타입이 같을 필요가 있다, 이때 서로 다르다면 둘 중 하나 혹은 둘 다 자동으로 타입이 변환된다 (이러한 타입을 공통 타입 common type이라고 한다)

다음은 동일한 타입이 필요한 연산자이다

  • 이진 산술 연산자: +, -, *, /, %
  • 이진 관계 연산자: <, >, <=, >=, ==, !=
  • 이진 비트 연산자: &, ^, |
  • 조건 연산자 ?:

산술 변환 규칙

컴파일러는 타입 순위를 가지고 있는데

  1. long double
  2. double
  3. float
  4. long long
  5. long
  6. int
    순으로 타입 순위를 따진다

예를들어 정수와 실수의 연산이 있다고 가정하면 정수가 우선순위가 높은 실수로 변환된다
만약 정수끼리의 연산 char, short사이의 연산이 있다고 가정하면 char와 short를 둘 다 int로 변환하여 계산한다
(정수 승격)

정리하면 낮은 순위의 타입이 높은 순위의 타입으로 변환된다는 의미이다

그 후 부호가 있는 피연산자, 부호가 없는 피연산자끼리의 연산이라면 특별한 규칙이 적용된다

부호가 서로 다른 정수형 피연산자끼리의 연산에는 규칙이 존재한다

unsigned 피연산자의 타입 순위가 signed 피연산자의 타입 순위보다 같거나 높으면 signed 피연산자는 unsigned 타입으로 변환된다

이때 signed 타입이 unsigned 타입의 모든 값을 표현할 수 있다면 unsigned 타입은 signed 타입으로 변환된다

위 케이스에 적용되지 않는다면 각각 대응하는 unsigned 타입으로 변환된다

그렇기 때문에 다음 코드 결과가 나오게 되는것이다

	#include <typeinfo>
    
	int i{ 10 };
	std::cout << typeid(i).name() << '\n'; //int
	double d{ 5.7 };
	std::cout << typeid(d).name() << '\n'; //double

	std::cout << typeid(i + d).name() << ' ' << i + d << '\n'; //double

int와 double을 연산했기 때문에 int는 우선순위가 높은 double로 변환되어 계산된 것이다

여기서 typeid(객체);는 객체의 타입 정보를 확인할 수 있는 연산자이다 (type_info return), name()을 호출하여 해당 타입의 이름을 알 수 있다

마찬가지로 short + short를 typeid로 확인해보면 int가 나오게 된다 (정수 승격)

unsigned와 signed사이의 연산도 마찬가지다

	std::cout << typeid(5u - 10).name() << ' ' << 5u - 10 << '\n'; // 부호 없는 피연산자의 타입 순위가 부호 있는 피연산자의 타입 순위랑 같기 때문에 부호 있는 피연산자가 부호 없는 피연산자 타입으로 변환된다, unsigned int 출력

이러한 공통 타입 (commont type)을 쉽게 알 수 있는 함수가 존재한다

	std::common_type_t<int, double>;

이렇게 하면 두 타입의 공통 타입을 반환한다

    #include <typeinfo>
    #include <type_traits>

    int main()
    {
        using CommonType = std::common_type_t<int, double>;  // int와 double의 공통 타입은 double
        std::cout << typeid(CommonType).name() << std::endl;

        return 0;
    }

3. Casting

Casting

컴파일러는 한 데이터 타입의 값을 다른 데이터 타입의 값으로 암시적 변환을 시켜준다

더 넓은 데이터 타입으로 숫자적 승격시키고 싶을때는 암시적 변환을 사용하는 것이 좋다

다음과 같은 코드를 살펴보자

	double d = 10 / 3;

d에는 자동으로 3.33333...이 들어갈 것 같지만 3이 들어가게 된다, 이유는 double타입의 d가 초기화 되기 전에 이미 int / int 계산이 들어가서 3이 나왔기 때문이다

이렇게 literal 피연산자를 사용할 때 둘 중 하나 혹은 둘 다 double literal을 사용하면 부동 소수점 연산으로 진행된다

	double d = 10.0 / 3; // 3.33333

만약 literal 피연산자가 아닌 변수를 사용한다면 명시적으로 캐스팅이 필요하다

C++은 5가지의 캐스팅을 지원한다

  • static_cast
    컴파일 타임에 타입을 확인하여 논리적으로 변환 가능한 경우에 캐스팅된다 (비교적 안전한 캐스팅)

정수 <-> 실수로 변환 시 데이터 손실 가능성이 있다

상속 관계에서 파생 클래스 타입을 기본 클래스 타입으로 변환하는 Upcasting은 항상 안전하고 반대인 Downcasting은 런타임에 타입 체크 없이 캐스팅한다 (안전함을 보장받지 못한다)

전혀 관련 없는 타입간의 포인터 변환은 허용하지 않는다 ex) int -> Knight, Knihgt -> Archer

	static_cast<int>(double);
  • dynamic_cast
    상속 관계에서 Downcasting을 안전하게 수행하기 위해 사용된다, 런타임에 실제 객체의 타입을 확인해서 변환이 가능한지 검사한다 (추후 자세히 정리)

  • const_cast
    const나 volatile을 제거하거나 추가할 때 사용한다, 타입 자체를 변경하는게 아닌 한정자만 변경한다

const로 선언된 객체에 대해 const가 아닌 멤버 함수를 호출할 때 사용한다 (const객체의 const를 제거해서 const가 아닌 멤버함수를 호출하게 하는것)

    const Knight K1;
    const_cast<Knight*>(&K1)->TestKnight();
    
    const Knight tempKnight;
    const_cast<Knight&>(tempKnight).TestKnight();
    
    Knight tempKnight;
    const Knight& cKnight = const_cast<const Knight&>(tempKnight); //const가 아닌 객체를 const_cast를 통해 const로 만들기

const_cast<>의 타입은 포인터, 참조 타입이어야 하고 ()에는 결국 주소나 객체가 들어가야 한다

결국 const로 선언된 객체의 상수성을 제거할 수 있기 때문에 객체 수정이 일어날 수 있어 매우 조심해서 사용해야 한다

  • reinterpret_cast
    가장 위험하고 강력한 캐스팅이다, 한 타입의 비트 패턴을 완전히 다른 타입의 비트 패턴으로 재해석한다 (타입 시스템의 안전성을 무시한다)

아예 관련없는 논리적으로 말이 되지 않는 타입 변환에 사용한다, 단 위험성이 매우 높으니 왠만하면 사용하지 않는것이 좋다 (const, volatile제거는 불가능하다)

한 포인터 타입을 다른 포인터 타입으로 변환

포인터를 충분히 큰 정수 타입으로 변환 (포인터의 주소값을 담을 수 있는 정수 타입으로 변환할 수 있다)

충분히 큰 정수 타입을 포인터로 변환

함수 포인터를 다른 함수 포인터 타입으로 변환

nullptr_t를 임의의 정수 타입으로 변환

    Knight* tempKnight;
    reinterpret_cast<int*>(tempKnight);
  • C-style cast
    강력한 캐스팅으로 거의 모든 종류의 캐스팅을 시도한다 (static_cast, const_cast, reinterpret_cast 등을 조합해서 캐스팅한다)

매우 위험할 수 있다, 컴파일러가 최소한의 타입체크만 수행하고 런타임 타입 체크는 전혀 없다 (지양하는게 좋다)

	int a{ 10 };
	(double)a;

아주 특별하게 C-style cast, const_cast, reinterpret_cast를 사용해야 하는 이유가 없다면 피하는것이 좋다 (위험)

C-style cast는 operator()를 통해 캐스팅 되고 ( )안에 원하는 타입, 그 뒤에 변환할 값을 작성한다

    int x { 10 };
    int y { 4 };

    std::cout << (double)x / y << '\n'; //double과 int의 연산으로 컴파일러가 인식하여 부동소수점 결과를 출력한다

또한 다음과 같이 캐스팅도 가능하다

	double(x);

C-style 캐스팅은 우선 지양하는게 좋다

  1. 단일 캐스팅이 아니다
    C-style 캐스팅은 실제로 어떤 캐스팅이 수행될지 명확하게 나타내지 않는다 (에러 발생률이 굉장히 높다)
    (static_cast, const_cast, reinterpret_cast중 어떤것으로 캐스팅할지 모른다)

또한 문법 자체가 캐스팅임 식별하기 힘들다 (가독성 낮음)

C-style 캐스팅은 const_cast -> static_cast -> static_cast + const_cast -> reinterpert_cast -> reinterpret_cast + const_cast 순으로 캐스팅을 수행하려고 시도한다

C-style 캐스팅은 파생 클래스 객체를 접근할 수 없는 기본 클래스 타입으로 변환할 수 있다 (ex) private으로 상속된 클래스, 이는 reinterpret_cast로 시도하기 때문에 가능한것임)

대부분의 캐스팅은 특별한 이유가 없다면 static_cast로 사용하는걸 권장한다

	char c{ 'a' };
    static_cast<int>(c);

static_cast의 < >에 원하는 타입을 넣고 ( )에 값을 넣어주면 된다

static_cast는 컴파일 타임 타입 검사를 제공한다, 값을 어떤 타입으로 변환하려고 할 때 컴파일러가 해당 변환을 할 줄 모른다면 컴파일 에러를 발생시킨다

	static_cast<int>("Hello"); //error

static_cast는 C-style보다 덜 강력하다, 논리적으로 말이 안되는 변환을 방지하고 const 제거와 같은 위험한 변환을 방지한다

static_cast를 사용하여 어떤 값을 특정 클래스 타입으로 변환하여 초기화 할 때 이는 직접 초기화 처럼 동작한다, 따라서 생성자가 explicit이어도 호출될 수 있다

    class MyNumber 
    {
        public:
            int m_value;

            explicit MyNumber(int value) : m_value(value) 
            {
                std::cout << "explicit MyNumber(int) called. Value: " << m_value << std::endl;
            }
    }

	MyNumber num_from_int = static_cast<MyNumber>(int_val); //이게 가능
    MyNumber num = 100; //이건 불가능 (explicit)
    MyNumber num(100); //이건 가능, 직접 초기화기 때문에 explicit 생성자 호출이 가능함

static_cast는 축소 변환을 명시적으로 할 때도 사용한다

	 int i{ 48 };
 	 char ch = i

이렇게 암시적으로 narrowing conversion을 하게 되면 warning이 발생할 수 있는데 static_cast로 명시적으로 narrowing conversion을 하여 경고를 제거할 수 있다 (컴파일러에게 의도된 축소변환임을 알린다)

	int i{ 48 };
    char ch{ static_cast<char>(i) };
    int i { 100 };
    i = i / 2.5; //warning
    
    int i { 100 };
	i = static_cast<int>(i / 2.5);

static_cast<>()를 하면 변환된 타입의 값으로 직접 초기화 된 임시객체가 return된다

그렇다면 리스트 초기화로 임시객체가 생성되는것과 static_cast<>()로 임시객체가 생성되는것과 뭐가 다를까?

	int x{ 10 };
    
	static_cast<int>(x); //직접 초기화로 임시객체 반환
    double { x }; //리스트 초기화로 임시 객체 생성

위 코드에서 double { x }는 데이터 손실을 방지한다 (축소변환을 방지), 하지만 static_cast<>는 데이터 손실을 방지하지 않는다

double { x }는 타입 변환용인지 아니면 단지 임시 객체를 만든건지 의도가 애매할 수 있다, static_cast<>는 명확하게 캐스팅 의도를 전달할 수 있다

리스트 초기화로 임시객체를 생성하는 방법의 가장 치명적인 단점은 타입 이름이 2개 이상이면 { }를 통한 변환은 문제가 발생할 수 있다, 단일 타입만 허용한다 (컴파일러에 따라 다르고 버전에 따라 다를 수 있다)

	int x{ 10 };
    unsigned int{ x }; //error, 타입 이름이 2개임

static_cast는 이런 문제가 발생하지 않는다


4. Type Aliases (타입 별칭)

using

C++에서는 using 키워드를 이용하여 기존 데이터 타입에 대한 별칭을 만들 수 있다

	using TestType = double; //TestType을 double의 별칭으로 사용

한번 정의되면 범위 내에서 언제든 사용이 가능하다

	TestType d{ 10.5 }; //double타입의 d라는 변수 정의

컴파일러는 이러한 타입 별칭을 만나게 되면 실제 타입으로 바꿔서 처리한다 (타입 별칭은 원래의 타입과 완벽하게 호환된다, 새로운 타입을 정의하는게 아님)

타입 별칭 네이밍 룰

standard library에서는 suffix로 _t를 많이 붙여서 사용했다
ex) size_t, nullptr_t 등

이는 C에서 유래되었고 modern C++에서는 잘 사용되지 않는다, 다른 시스템에서 _t로 타입 명칭을 정의할 확률이 높기 때문에 충돌 발생 확률이 있다

또한 _type또한 자주 사용한다 ex)std::string::size_type 등

Modern C++에서는 대문자로 시작하고 suffix를 붙히지 않는 규칙을 따른다 (일반 변수명과 구분되고 명명 충돌을 방지한다)

	using TestType = float;

결국에 타입 별칭은 프로그래머가 이름을 붙히는것이기 때문에 다른 의미의 값과 혼동하지 않도록 주의해야 한다

타입 별칭의 범위

타입 별칭의 범위는 변수 식별자 범위와 동일하다

{ } 안에서 정의된 타입 별칭은 { }안에서만 사용가능하며 전역 namespace범위에서 정의되었다면 파일 전체에서 사용이 가능하다

.h에 타입 별칭을 정의하고 이를 #include하여 다른 파일에서 사용할 수 있다

	typealiases.h
    using TempType = double
    
    main.cpp
    #include "typealiases.h"
    
    TempType foo = 100.f;

typedef

using뿐 아니라 typedef 키워드로도 타입 별칭을 정의할 수 있다 (옛날 방식)

	typedef double TempType;
    using TempType = double;

그렇다면 왜 typedef말고 using을 많이 쓸까?

우선 별칭 이름과 타입의 순서가 헷갈린다 (타입이 먼저 와야한다)

	typedef TempType double //X
    typedef double TempType; //O

또한 복잡한 타입일 경우 가독성이 떨어진다

복잡한 타입일 경우 타입 별칭을 사용하여 깔끔하게 코드 작성이 가능하다

	using VectPairSI = std::vector<std::pair<std::string, int>>;

또한 함수의 반환값의 목적을 실질적으로 표현할 수 있다 (근데 이러한 방식은 별로 좋다고 생각하지 않음, 함수의 네이밍으로 파악이 가능해야 함)

profile
GameDeveloper🎮 Dev C++, DataStructure, Algorithm, UE5, Assembly🛠, Git/Perforce🌏

0개의 댓글