[Advanced C++] 10. Numeral System, 최적화(optimization), 컴파일 타임 프로그래밍, constexpr

dev.kelvin·2025년 1월 7일
1

Advanced C++

목록 보기
10/74
post-thumbnail

1. Numeral System

Numeral System (숫자 체계)

C++에서 사용할 수 있는 Numeral System에는 2진법, 8진법, 10진법, 16진법이 존재한다

일반적으로 사람이 사용하는 진법은 10진법이다, 이는 사용할 수 있는 숫자가 10개이기 때문에 10진법이다
(0~9)

기본적으로 C++에서는 숫자는 10진수로 가정한다

	int foo {10};

2진법은 0과 1만 사용한다, 이때 2진법을 연속으로 부르면 10진수와 헷갈리기 때문에 -를 붙여 사용한다
(0, 1, 1-0, 1-1, 1-0-0...)

8진법은 숫자 8개만 사용한다 (0~7) 8은 10으로 표현한다

C++에서 8진법을 사용하기 위해서는 literal 앞에 0을 붙여야 한다

	int foo {012}; // 10진수 10을 의미함

하지만 8진법은 거의 사용하지 않는다

마지막으로 16진법은 0~9까지의 숫자와 A~F까지의 알파벳을 사용하여 총 16개의 숫자와 문자로 수를 표현한다
(0~9, 10부터 A~F, 16은 10이 된다)

C++에서 16진법을 사용하기 위해서는 literal 앞에 0x를 붙여야 한다

	int foo {0xF}; //10진수 15을 의미한다

16진수는 숫자 하나가 4bit를 포함한다, 따라서 1byte를 정확하게 표현할 수 있다
2진수의 경우에는 표현에 있어서 숫자가 너무 길어지고 0과 1의 반복으로 읽기가 힘들다, 따라서 16진수를 사용하여 메모리 주소, 메모리 원시 데이터를 표현하는데 사용한다

2진수는 C++14 이전에서는 표현이 불가능했다, 따라서 16진수 표현으로 2진수를 표현했다

	int foo {0x0001};

하지만 C++14 이후에는 2진수도 표현이 가능하다, literal앞에 0b를 붙이면 된다

	int foo {0b11};

또한 C++14 이후에는 긴 literal 값의 가독성을 위해 '로 숫자 구분이 가능하다 (숫자 맨 앞에는 사용할 수 없다)

    int bin{ 0b1011'1111 };
    long foo {1'234'567'890};
    long foo {'1'234'567'890}; //error

기능상 전혀 영향을 미치지 않는 오직 가독성을 위한 기능이다

C++은 기본적으로 값을 10진수로 출력한다, 이때 8진수, 16진수로 출력 타입을 변경할 수 있다

    int foo{ 12 };

    std::cout << std::hex << foo << '\n'; //16진수 c
    std::cout << std::dec << foo << '\n'; //10진수 12
    std::cout << std::oct << foo << '\n'; //8진수 14

2진법은 std::cout으로 쉽게 출력이 불가능하다, C++ Standard Library의 std::bitset으로 2진수를 출력한다

	#include <bitset>
    
    std::bitset<8> bin{ 0b1100'0100 }; //8bit로 표현
    
    std::cout << bin << '\n'; //1100 0100
    std::cout << std::bitset<4>{ 0b1010 } << '\n'; //4bit로 표현, 1010으로 나옴, 8로 지정하면 0000 1010이 나옴

C++20, C++23에는 bin을 표현하기 위한 더 좋은 기능이 존재한다 (std::format, println)


2. 최적화 (optimization)

최적화란

소프트웨어를 수정하여 더 빠르거나 더 적은 리소스를 사용하도록 변경하여 조금 더 효율적으로 작동시키게 하는걸 최적화라고 한다

최적화는 수동으로 진행하거나 자동으로 진행할 수 있다

수동 최적화는 일반적으로 Profiler를 이용하여 프로그램의 수행 시간, 성능에 영향을 미치는 부분이 어느 부분인지 확인 후 프로그래머가 수동으로 수정하는 방법이다
(더 빠른 알고리즘 선택, 데이터 save/load 최적화, 리소스 사용 감소, 작업 병렬화 등)

자동 최적화는 Optimizer를 사용하는 최적화이다, 보통은 저수준에서 작동하며 명령문, 표현식 자체에서의 개선을 한다

최신 C++ 컴파일러는 최적화 컴파일러로서 프로그램을 자동 최적화 시킬 수 있다, 또한 이는 Compile Process로서 소스 코드 수정을 하지 않고 진행한다 (기본적으로 켜져 있지 않기 때문에 따로 설정해줘야 함)

as-if rule

as-if rule은 컴파일러가 최적화를 진행할 때 프로그램의 결과가 동일한 한 자유롭게 최적화를 할 수 있는 rule이다
ex) 소스 코드 실행 순서, 구조 변경 가능 -> 이때 결과는 동일해야 함

	int x = 10;
    int y = 11;
    
    std::cout << x << std::endl;

여기서 y는 사용되지 않기 때문에 컴파일러는 최적화를 위해 코드 제거가 가능하다

또한 연산의 순서를 코드 라인 그대로 순차적으로 계산하지 않고 순서 변경도 가능하다, 이때 결과값은 항상 동일해야 한다

여러가지 최적화 가능 case

	constexpr int x { 3 + 4 };
	std::cout << x << '\n';
    
    std::cout << 3 + 4 << '\n';

위와 같은 코드가 프로그램에 수백만줄 있다면 항상 같은 결과인 7을 계산하는 3+4 연산을 수백만번 하게 된다

따라서 Compile-time evaluation으로 컴파일러는 runtime이 아닌 compile time에 계산을 하여 runtime에 불필요한 연산을 줄인다

위의 코드는 3+4가 7로 대체된다 (constant folding)

Compile-time evaluation은 2가지로 나뉘는데 Fully Evaluated (완전), Partially Evaluated (부분)으로 나뉜다

전체 표현식을 compile time에 전부 계산하고 runtime에는 결과만 이용하면 Fully, 일부 표현식만 compile time에 계산하고 나머지는 runtime에 계산하면 partially이다
(물론 약간의 compile 시간이 더 걸리게 됨)

이러한 코드도 최적화가 가능하다

	const int a {8};
    std::cout << a << '\n';

a가 상수 8을 갖는걸 알기 때문에 a 사용에서 8이라는 상수로 컴파일러가 최적화를 통해 대체한다, 이를 Constant Propagation이라 한다

더 이상 a변수 객체 메모리에 접근하여 값을 가져올 필요가 없기 때문에 성능 최적화가 가능하다

단 이때 a값이 절대 변하지 않는다는 보장이 있어야 하기 때문에 const 변수로 사용하는것이 더 효과적으로 최적화가 가능하다

또한 사용하지 않는 변수나 함수 제거 (Dead code eliminate) 등이 있다 (실제 코드를 제거하는것이 아닌 메모리에서 날림)

그렇다면 왜 컴파일러 최적화는 자동으로 켜져있지 않을까?

이렇게 좋은 컴파일러 최적화는 왜 auto로 켜져있지 않는걸까?

바로 디버깅에 문제가 발생할 수 있기 때문이다, runtime에 원래의 소스코드와 compiler 최적화를 통한 코드 사이의 차이로 디버깅이 힘들어진다 (ex) 사용하지 않는 변수, 함수를 메모리에서 날렸기 때문에 차이가 발생할 수 있다 디버깅 시)

따라서 debug mode에서는 이러한 최적화를 해제하여 원래의 소스코드와 컴파일된 코드를 일치시켜 빌드한다

Release로 하면 컴파일러 최적화를 활성화 할 수 있다

세부 설정도 가능하다

  • Disabled (/Od): 최적화 비활성화 (기본 디버그 모드).
  • Minimize Size (/O1): 코드 크기를 최소화하는 최적화.
  • Maximize Speed (/O2): 실행 속도를 최적화.
  • Full Optimization (/Ox): 가능한 모든 최적화 활성화

3. Compile Time Programming

컴파일 타임 프로그래밍

프로그래머는 원하는 부분만 명시적으로 지정하여 컴파일 타임에 실행시킬 수 있다, 이러한 프로그래밍을 컴파일 타임 프로그래밍이라 한다

컴파일 타임 프로그래밍은 더 높은 성능과 안정성 있는 (버그가 적은) 프로그램 개발에 도움을 준다

상수 표현식

상수 표현식은 다음과 같다

  • literal
    ex) 5, 11, 22
  • 상수를 피연산자로 갖는 연산
    ex) 3 + 4, 2 * sizeof(int)
  • const 정수 변수 초기화
    ex) const int foo {100};
  • constexpr 변수
  • constexpr 함수

상수 표현식은 컴파일 타임에 평가될 수 있다

반대로 런타임 표현식은 런타임에 평가되는 표현식이다

또한 아래와 같은 케이스들은 상수 표현식이 될 수 없다

  • 상수가 아닌 변수
  • 상수가 아닌 값으로의 정수 초기화 (int c = 100; const int a {c};)
  • 정수가 아닌 const 상수 초기화 (const double d = 1.2;)
  • 상수가 아닌 변수를 피연산자로 갖는 연산 (x + y)
  • std::cout, cin과 같은 런타임 객체
  • constexpr이 아닌 함수
  • constexpr함수지만 해당 함수의 파라미터는 상수 표현식이 아니다 (매개변수는 런타임에 초기화 되기 때문)
  • new, delete, throw, typeid, operator,와 같은 연산자는 런타임에만 사용될 수 있다

상수는 값이 절대 변경되지 않기 때문에 컴파일 타임 평가가 가능한 것이다

    int getNumber()
    {
        std::cout << "Enter a number: ";
        int y{};
        std::cin >> y; //runtime

        return y;      //runtime
    }

    int five()
    {
        return 5;      //compile time
    }

    int main()
    {
        5;                           // constant expression
        1.2;                         // constant expression
        "Hello world!";              // constant expression
        
        5 + 6;                       // constant expression
        1.2 * 3.4;                   // constant expression
        8 - 5.6;                     // constant expression
        sizeof(int) + 1;             // constant expression

        getNumber();                 // runtime expression (constexpr X)
        five();                      // runtime expression (constexpr X)

        std::cout << 5;              // runtime expression

        return 0;
    }

여기서 중요한 점은 상수 표현식이 반드시 컴파일 타임에만 평가 되는것은 아니라는 점이다

컴파일러는 상수 표현식이 필요한 context에서만 평가한다

	const int a { 3 + 4 }; //compile time
    
    int a { 3 + 4 }; //3 + 4 상수 표현식은 a가 const가 아니기 때문에 컴파일 타임에 꼭 평가될 필요가 없다

4. constexpr

constexpr (constant expression)

기존에 정리한 상수 표현법으로 const를 사용했는데, const는 여러 문제가 발생할 수 있다

우선 상수 표현식인지 아닌지를 추론해야 한다는 점이다

	const int b {10}; //상수 표현식
    
    const int c {d}; //d가 컴파일 타임에 결정되는 값인지 아닌지에 따라 상수 표현식인지 아닌지가 결정된다
    const int k { getValue() }; //getValue()가 constexpr 함수인지 아닌지에 따라 상수 표현식인지 아닌지가 결정된다

또한 const 상수는 정수이외의 타입은 상수 표현식으로 사용할 수 없다 (컴파일 타임 상수가 될 수 없다)

마지막으로 const 변수는 컴파일 타임 평가 가능 여부를 알려주지 않는다, 오직 상수로서 값을 변경할 수 없다는것만 알려준다 (컴파일 타임에 평가될 수 있다는 보장이 없다)

따라서 원하는 곳에 컴파일 타임 상수 변수를 만들기 위해서는 constexpr을 사용한다

constexpr변수는 무조건 컴파일 타임 상수이다, const와 마찬가지로 초기화 해야한다

	constexpr int foo {4 + 5};
    constexpr int foo2 {10};
    
    int age {20};
    constexpr int test {age}; //error, age가 const가 아님
    
    int foofunction()
    {
    	return 3;
    }
    
    constexpr int result {foofunction()}; //error, foofunction이 constexpr함수가 아님
	constexpr double d { 1.3 }; //정수형이 아닌 타입의 값도 가능

const와 constexpr은 다르다

const의 초기값은 컴파일 타임, 런타임 둘 다 결정될 수 있다 ,하지만 constexpr은 초기값이 반드시 컴파일 타임에 결정된다

기본적으로 constexpr은 const이다 (상수)

따라서 상수를 표현할 때 초기값이 컴파일 타임에 결정되는 상수 표현식이면 constexpr을 사용하고 초기값이 상수 표현식이 아닌 런타임에 결정되는 값이면 const를 사용한다

이때 constexpr은 동적 메모리 할당을 사용하는 타입인 std::string, std::vector등과 호환되지 않는 경우가 있다 (컴파일 타임에 평가될 수 없기 때문)

const는 함수의 매개변수로 사용이 가능하지만 const expr은 함수의 매개변수로 사용이 불가능하다, 함수의 매개변수 초기화는 runtime에 이루어지기 때문이다

constexpr은 C++17부터 암묵적으로 inline이다, 따라서 여러 파일에 include해도 ODR에 위배되지 않는다

(constexpr함수는 추후 정리할 예정임)

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

0개의 댓글