상수는 프로그램 실행 중에 변경될 수 없는 값을 말한다.
C++에는 두 가지 종류의 상수가 있다.
식별자와 연결된 상수 값인 명명된 상수와 식별자와 연결되지 않은 리터럴 상수가 있다.
우선 명명된 상수들에 대해 알아보자
상수
지금까지 알아본 모든 변수는 언제든지 값이 변경될 수 있는 일정하지 않은 값이었다.
하지만 변경할 수 없는 값으로 변수를 정의하는 것이 유용한 경우가 있다. (지구의 중력 9.8 )
Const 변수는 정의할 때 초기화되어야 하며, 그 값은 할당으로 변경될 수 없다.
하지만 아래와 같이 초기화를 할 때, 값에 다른 변수를 할당하는 것으로도 초기화가 가능하다.
std::cout << "Enter your age: ";
int age{};
std::cin >> age;
const int constAge { age };
Type qualifiers (한정자)
한정자는 타입의 작동 방식을 수정하는, 타입에 적용되는 키워드다.
상수 변수를 선언하는 데 사용되는 const 를 const 타입 한정자 라고 한다.
리터럴 literal
리터럴은 코드에 직접 삽입되는 값이다. 리터럴은 의미를 재정의할 수 없기 때문에 리터럴 상수 라고도 한다.
return 5; // 5 is an integer literal
bool myNameIsAlex { true }; // true is a boolean literal
double d { 3.4 }; // 3.4 is a double literal
std::cout << "Hello, world!"; // "Hello, world!" is a C-style string literal
객체에 타입이 있는것과 같이 리터럴에도 타입이 있다. 리터럴의 타입은 리터럴의 값에서 추론된다.
예를 들어 정수인 리터럴(예: 5)은 int type으로 추론된다.
여기서 우리가 원하는 리터럴의 타입이 다른 경우 접미사를 추가하여 리터럴의 타입을 변경할 수 있다.
Data type | 접미사 | 의미 |
integral | u or U | unsigned int |
integral | l or L | long |
integral | ul, uL, Ul, UL, lu, lU, Lu, LU | unsigned long |
integral | ll or LL | long long |
integral | ull, uLL, Ull, ULL, llu, llU, LLu, LLU | unsigned long long |
integral | z or Z | The signed version of std::size_t (C++23) |
integral | uz, uZ, Uz, UZ, zu, zU, Zu, ZU | std::size_t (C++23) |
floating point | f or F | float |
floating point | l or L | long double |
string | s | std::string |
string | sv | std::string_view |
이 표에서 알 수 있듯이, 정수형 리터럴 int의 경우는 접미사를 사용하지 않는다.
부동 소수점 리터럴
int main()
{
std::cout << 5.0 << '\n'; // 5.0 (no suffix) is type double (by default)
std::cout << 5.0f << '\n'; // 5.0f is type float
return 0;
}
부동 소수점 리터럴은 리터럴로 만들기 위해서 f 접미사를 사용해야 한다. (안하면 double취급)
float f { 4.1 }; // warning: 4.1 is a double literal, not a float literal
그래서 이와 같은 경우 float에 double을 넣었기 때문에 오류가 발생한다.
문자열 리터럴 string
문자열은 텍스트(이름, 단어, 문장)를 나타내는 데 사용되는 연속 문자 모음이다.
예를 들어 "Hello, world!"는 문자열 리터럴이다. 문자열 리터럴은 큰따옴표 사이에 배치되어 문자열로 식별된다
(작은따옴표 사이에 배치되는 char 리터럴과 반대).
이러한 C 스타일 문자열 리터럴 string에 대해 알아야 할 것이 있다.
- 모든 C 스타일 문자열 리터럴에는 암시적 null 끝맺음이 있다. "hello" 와 같은 문자열을 고려해볼 때, 이 C 스타일 문자열은 5개의 문자만 있는 것처럼 보이지만 실제로는 6개, 즉 , 'h', 'e'' 'l, 'l', 'o'및 '\0'(ASCII 코드 0의 문자)로 구성된다. null의미를 갖는 \0은 문자열의 끝을 나타내는 데 사용된다.
- 대부분의 다른 리터럴(객체가 아닌 값)과 달리 C 스타일 문자열 리터럴은 프로그램 시작 시 생성되고 프로그램 전체에 걸쳐 존재하도록 보장되는 const 객체이다.
매직넘버
매직 넘버는 의미가 불분명하거나 나중에 변경해야 될 수 있는 리터럴(일반적으로 숫자)이다.
const int maxStudentsPerSchool{ numClassrooms * 30 };
setMax(30);
여기에서 30이란 것은 맥락 상 무슨 의미인지 잘 파악할 수 없다.
따라서 매직 넘버를 사용하는 것은 일반적으로 나쁜 습관으로 간주된다.
참고) 출력 형식 변경
출력형식 변경
#include <iostream>
int main()
{
int x { 12 };
std::cout << x << '\n'; // decimal (by default)
std::cout << std::hex << x << '\n'; // hexadecimal
std::cout << x << '\n'; // now hexadecimal
std::cout << std::oct << x << '\n'; // octal
std::cout << std::dec << x << '\n'; // return to decimal
std::cout << x << '\n'; // decimal
return 0;
}
이진수로 값 출력하기
#include <bitset> // for std::bitset
#include <iostream>
int main()
{
// std::bitset<8> means we want to store 8 bits
std::bitset<8> bin1{ 0b1100'0101 }; // binary literal for binary 1100 0101
std::bitset<8> bin2{ 0xC5 }; // hexadecimal literal for binary 1100 0101
std::cout << bin1 << '\n' << bin2 << '\n';
std::cout << std::bitset<4>{ 0b1010 } << '\n'; // create a temporary std::bitset and print it
return 0;
}
컴파일 시간 최적화시키기
컴파일러는 프로그램을 최적화할 수 있는 여지가 많다.
코드를 수정한 사항이 프로그램의 "관찰 가능한 동작"에 영향을 미치지 않는 한, 컴파일러가 더 최적화된 코드를 생성하기 위해 원하는 대로 프로그램을 수정할 수 있는 C++의 as - if 규칙을 통해 어떻게 최적화시키는지 알아보자.
우선, 컴파일 시간과 런타임 시간의 차이다. 앞으로 이 두 가지 차이가 중요하다.
- 컴파일 시간(Compile Time):
- 컴파일 시간은 소스 코드를 기계어로 번역하는 과정에서 소요되는 시간을 의미
- 프로그래밍 언어에 따라 컴파일러의 효율성과 소스 코드의 복잡성에 따라 컴파일 시간은 달라짐
- 런타임 시간(Runtime Time):
- 런타임 시간은 컴파일된 프로그램이 실제로 실행되는 시간을 의미
- 프로그램의 실행 속도는 다양한 요인에 따라 결정된다. 이에는 하드웨어 성능, 프로그래밍 언어, 알고리즘의 효율성 등이 포함됨
- 런타임 시간은 프로그램의 성능을 측정하고 최적화하는 데 중요한 지표이다. 런타임 성능을 향상시키는 방법으로는 알고리즘의 최적화, 병렬 처리 등이 있다.
표현식의 컴파일 타임 평가
최신 C++ 컴파일러는 컴파일 타임에 일부 표현식을 평가할 수 있다.
int x { 3 + 4 };
std::cout << x << '\n';
컴파일러는 이와 같은 코드를 아래와 같이 임의로 바꿔서 최적화한다. (as-if 규칙)
int x { 7 };
std::cout << x << '\n';
상수 표현식
컴파일 시간 평가와 상수를 지원하는 연산자/함수만 포함하는 식을 말한다.
컴파일 시간 평가를 지원하는 연산자 및 함수 예시
- 컴파일 타임 상수인 피연산자가 있는 산술 연산자 (예: 1 + 2)
- Constexpr 및 consteval 함수 (밑에서 배움)
아래 예시에서는 상수 표현식들을 구별했다.
int main()
{
// 상수가 아닌 변수들
int a { 5 }; // 5 is a constant expression
double b { 1.2 + 3.4 }; // 1.2 + 3.4 is a constant expression
// 한정자를 쓴 compile time 상수들
const int c { 5 }; // 5 is a constant expression
const int d { c }; // c is a constant expression
const long e { c + 2 }; // c + 2 is a constant expression
// 한정자를 썻지만 runtime 상수들
const int f { a }; // a is not a constant expression
const int g { a + 1 }; // a + 1 is not a constant expression
const long h { a + c }; // a + c is not a constant expression
const int i { getNumber() }; // getNumber() is not a constant expression
const double j { b }; // b is not a constant expression
const double k { 1.2 }; // 1.2 is a constant expression
return 0;
}
상수 표현식이 아닌 표현식을 런타임 표현식 이라고 부른다.
예를 들어 std::cout << x << '\n'는 런타임 표현식이다.
x는 컴파일 타임 상수가 아니기도 하고, 출력에 사용되는 연산자<< 는 컴파일 타임 평가를 지원하지 않기 때문이다. (컴파일 타임에 출력을 수행할 수 없기 때문에)
반면, 상수 표현식은 항상 컴파일 시간 평가에 좋다. 즉, 컴파일 시간에 최적화될 가능성이 더 높다.
컴파일러는 상수 표현식이 필요한지 아닌지에 따라서 컴파일 타임이나 런타임에 상수 표현식을 평가할지 여부를 선택할 수 있다.
const int x { 3 + 4 }; // constant expression 3 + 4 must be evaluated at compile-time
int y { 3 + 4 }; // constant expression 3 + 4 may be evaluated at compile-time or runtime
변수 x는 const int타입과 상수 표현식 초기화가 있으므로 컴파일 타임 상수다.
위의 초기화는 컴파일 타임에 평가되어야만 한다. 그렇지 않으면 x는 컴파일 타임에 알려지지 않고, 컴파일 타임 상수가 되지 않는다. (상수는 컴파일 타임에 알려져야 함)
반면, 변수 y는 상수 표현식의 상수 초기화가 필요하지 않으므로 컴파일러는 3 + 4를 컴파일 타임에 평가할지, 아니면 런타임에 평가할지 선택할 수 있다.
일반적으로 현대 컴파일러는 최적화가 쉽고 성능이 더 좋기 때문에 컴파일 타임에 상수 표현식을 평가한다.
int main()
{
int x { 7 }; // x is non-const
std::cout << x << '\n'; // x is a non-constant subexpression
return 0;
}
위와 같은 코드에서 컴파일러는 x를 7로 바꿔서 평가하고, int x { 7 }; 의 표현식은 이제 쓸모가 없으니 지울 수 있다.
하지만 변수이다 보니 언제 값이 다시 바뀔지 모른다. 그렇기 때문에 변수 x를 const로 선언해주면 컴파일러가 확신을 갖고 지울 수 있어서 최적화하기가 더 쉬워진다. 즉, 변수를 상수로 만들면 컴파일러가 최적화하는 데 도움이 된다.
Constexpr
앞서 우리는 컴파일 시간 상수뿐만 아니라 상수 표현식이 무엇인지 정의했다.
const 변수를 선언하면 컴파일러는 해당 변수가 런타임인지 또는 컴파일 타임 상수인지 암시적으로 추적한다.
대부분의 경우 이는 최적화 목적 이외의 다른 용도로는 중요하지 않지만, C++에서 상수 표현식이 필요한 경우가 몇 가지 있다. 그리고 컴파일 타임 상수 변수만 상수 표현식 constexpr에 사용될 수 있다.
const 를 사용할 때 변수는 초기화가 상수 표현식인지 여부에 따라 컴파일 타임 상수 또는 런타임 상수로 끝날 수 있다.
int a { 5 }; // not const at all
const int b { a }; // obviously a runtime const (since initializer is non-const)
const int c { 5 }; // obviously a compile-time const (since initializer is a constant expression)
컴파일 시간 상수는 더 나은 최적화를 가능하게 하고 단점도 거의 없기 때문에 일반적으로 가능하면 컴파일 시간 상수를 사용하는 것이 좋다.
Constexpr 키워드
우리는 원하는 위치에서 컴파일 타임 상수 변수를 얻을 수 있도록 컴파일러의 도움을 받을 수 있는데, 이를 위해 변수 선언에서 const 대신 키워드 constexpr 를 사용한다.
constexpr ("상수 표현식"의 약어) 변수는 상수 표현식으로 초기화되어야 한다. 그렇지 않으면 컴파일 오류가 발생한다.
int five()
{
return 5;
}
int main()
{
constexpr double gravity { 9.8 }; // ok: 9.8 is a constant expression
constexpr int sum { 4 + 5 }; // ok: 4 + 5 is a constant expression
constexpr int something { sum }; // ok: sum is a constant expression
std::cout << "Enter your age: ";
int age{};
std::cin >> age;
constexpr int myAge { age }; // compile error: age is not a constant expression
constexpr int f { five() }; // compile error: return value of five() is not a constant expression
return 0;
}
Const vs constexpr (C++23)
const는 초기화 후에 객체의 값을 변경할 수 없음을 의미한다.
Constexpr은 객체가 컴파일 타임에 알려진 값을 가져야 함을 의미한다.
따라서 Constexpr 변수는 암시적으로 const이지만, Const 변수는 암시적으로 constexpr가 아니다. (상수 표현식으로 초기화한 const 변수 제외)
상수 표현식으로 초기화한 모든 상수 변수는 constexpr로 선언되어야 하고, 상수 표현식으로 초기화하지 않았으면 상수는 const로 선언되어야 한다.
Const 및 constexpr 함수의 매개변수
일반 함수 호출은 런타임 시 평가되며, 제공된 인수는 함수의 매개변수를 초기화하는 데 사용된다.
함수 매개변수의 초기화는 런타임에 발생하므로
- const함수 매개변수는 런타임 상수로 처리된다. (제공된 인수가 컴파일 타임 상수인 경우에도)
- constexpr함수 매개변수는 초기화 값이 런타임까지 결정되지 않으므로 으로 선언할 수 없다.
간단하게 정리하자면
compile time 상수 | compile time에 그 값을 알아야 하는 객체 (예: 리터럴 및 constexpr 변수) |
constexpr | 변수를 compile time상수(및 함수)로 선언하는 키워드 |
상수 표현 | compile time 평가를 지원하는 compile time 상수와 연산자/함수만 포함하는 표현식 |
run time 표현 | 상수 표현식이 아닌 표현식 |
run time 상수 | compile time 상수가 아닌 상수 개체 |
Constexpr 함수
컴파일 타임 상수를 생성하는 데 사용되는 키워드 constexpr를 배웠다.
그리고 런타임이 아닌 컴파일 타임에 평가할 수 있는 식인 상수 표현식도 알아봤다.
int main()
{
constexpr int x{ 5 };
constexpr int y{ 6 };
std::cout << (x > y ? x : y) << " is greater!\n";
return 0;
}
이 코드에서 x와 y는 constexpr이기 때문에, 컴파일러는 상수 표현식 (x>y ? x : y)을 컴파일 타입에 평가할 수 있다.
이 표현식은 더 이상 굳이 런타임에 평가될 필요가 없으므로 프로그램이 더 빠르게 실행된다.
여기에서 std::cout에 표현식이 명명된 함수가 들어가면 더 좋을 것 같다.
int greater(int x, int y)
{
return (x > y ? x : y); // here's our expression
}
int main()
{
constexpr int x{ 5 };
constexpr int y{ 6 };
std::cout << greater(x, y) << " is greater!\n"; // will be evaluated at runtime
return 0;
}
이러면 print 문이 더 깔끔해졌다. 하지만 이 경우는 greater(x,y)가 런타임에 실행되어 속도가 더 느려진다는 단점이 있다.
이러한 문제를 해결하기 위해서 Constexpr 함수는 컴파일 타임에 평가될 수 있는 것을 이용한다.
함수를 constexpr 함수로 만들려면 반환 유형 앞에 constexpr 키워드를 사용하면 된다.
#include <iostream>
constexpr int greater(int x, int y) // now a constexpr function
{
return (x > y ? x : y);
}
int main()
{
constexpr int x{ 5 };
constexpr int y{ 6 };
// We'll explain why we use variable g here later in the lesson
constexpr int g { greater(x, y) }; // will be evaluated at compile-time
std::cout << g << " is greater!\n";
return 0;
}
이는 이전 예제와 동일한 출력을 생성하지만 함수 호출은 greater(x, y)런타임이 아닌 컴파일 타임에 평가된다!!!!
이렇게 함수 호출이 컴파일 타임에 평가되면 컴파일러는 컴파일 타임에 함수 호출의 반환 값을 계산한 다음 함수 호출을 반환 값으로 바꾼다.
따라서 constexpr int g { greater(x,y) } 부분은 constexpr int g { 6 } 으로 바뀐다. (런타임이라면 안바뀜)
하지만 항상 컴파일 타임에만 평가되는 것은 아니다. 'Constexpr 함수'는 런타임에 평가될 수도 있다.
이 경우 constexpr이 아닌 결과를 반환한다.
constexpr int greater(int x, int y)
{
return (x > y ? x : y);
}
int main()
{
int x{ 5 }; // not constexpr
int y{ 6 }; // not constexpr
std::cout << greater(x, y) << " is greater!\n"; // will be evaluated at runtime
return 0;
}
위 코드에서 인수 x와 y는 exper이 아니기 때문에 컴파일 타임에 함수를 확인할 수 없다.
그러나 함수는 런타임 시 계속 확인되어 예상 값을 constexpr이 아닌 int값으로 반환한다.
그럼 constexpr 함수는 컴파일 타임의 언제 평가되는 것인지 아래 예시로 알아보자.
constexpr int greater(int x, int y)
{
return (x > y ? x : y);
}
int main()
{
constexpr int g { greater(5, 6) }; // case 1: always evaluated at compile-time
std::cout << g << " is greater!\n";
std::cout << greater(5, 6) << " is greater!\n"; // case 2: may be evaluated at either runtime or compile-time
int x{ 5 }; // not constexpr but value is known at compile-time
std::cout << greater(x, 6) << " is greater!\n"; // case 3: likely evaluated at runtime
std::cin >> x;
std::cout << greater(x, 6) << " is greater!\n"; // case 4: always evaluated at runtime
return 0;
}
case 1 은 함수의 반환값이 상수표현식이고, 상수표현식 인수를 사용하여 호출했으므로 컴파일 타임에 평가할 수 있다.
case 2 는 함수의 인자는 상수표현식이지만, 반환된 값이 상수 표현식이 필요한 컨텍스트에서 사용되지 않으므로 이 호출을 컴파일 타임에 평가할지 런타임에 평가할지 여부를 자유롭게 선택할 수 있다.
case 3 에서 x는 상수 표현식이 아니지만, 컴파일 시간에 평가될 수 있다.
as - if 규칙에 따라 컴파일러는 constexpr인 x 로 처리하기로 결정하며 컴파일타임에 이 호출을 평가할 수 있다.
case 4 에서 인수 x 값은 컴파일 타임에 알 수 없으므로 이 호출은 항상 런타임에 평가된다.
즉, 상수 표현식이 필요한 곳에 return 값이 사용되는 경우 constexpr 함수는 컴파일 타임에 평가되어야 한다.
그렇지 않으면 컴파일 타임 평가가 보장되지 않는다.
따라서 constexpr 함수는 "컴파일 타임에 평가될 것"이 아니라 "상수 표현식에 사용될 수 있다"고 생각하는 것이 맞다.
그리고 constexpr(또는 consteval) 함수가 컴파일 타임에 평가되는 경우, 이 함수가 호출하는 다른 모든 함수도 컴파일 타임에 평가되어야 한다.
consteval
C++20 에서 함수가 컴파일 타임에 평가 되어야 함 을 나타내는 데 사용되는 키워드 consteval 이 도입됐다.
( immediate functions 이라고 부름)
consteval int greater(int x, int y) // function is now consteval
{
return (x > y ? x : y);
}
int main()
{
constexpr int g { greater(5, 6) }; // ok: will evaluate at compile-time
std::cout << g << '\n';
std::cout << greater(5, 6) << " is greater!\n"; // ok: will evaluate at compile-time
int x{ 5 }; // not constexpr
std::cout << greater(x, 6) << " is greater!\n"; // error: consteval functions must evaluate at compile-time
return 0;
}
위의 예시에서 처음 두 호출은 컴파일 타임에 평가된다. 하지만 세 번째 호출에서 consteval함수는 컴파일 타임에 평가될 수 없으므로 오류가 발생한다.
추가) Constexpr/consteval 함수의 매개변수는 constexpr이 아니다. 또한 함수 내에서 const가 아닌 지역변수를 사용할 수 있음.
consteval 함수의 단점은 런타임에 평가할 수 없기 때문에 constexpr 함수보다 유연성이 떨어진다.
따라서 constexpr 함수가 컴파일 타임에 평가하도록 하는 것이 더 유용할 것 같다.
// Uses abbreviated function template (C++20) and `auto` return type to make this function work with any type of value
// See 'related content' box below for more info (you don't need to know how these work to use this function)
consteval auto compileTime(auto value)
{
return value;
}
constexpr int greater(int x, int y) // function is constexpr
{
return (x > y ? x : y);
}
int main()
{
std::cout << greater(5, 6) << '\n'; // may or may not execute at compile-time
std::cout << compileTime(greater(5, 6)) << '\n'; // will execute at compile-time
int x { 5 };
std::cout << greater(x, 6) << '\n'; // we can still call the constexpr version at runtime if we wish
return 0;
}
constexpr 함수의 반환 값을 consteval 함수에 대한 인수로 사용하는 경우 constexpr 함수는 컴파일 타임에 평가되어야 한다.
Constexpr/consteval 함수는 암시적으로 inline이다.
constexpr 함수는 컴파일 타임에 평가될 수 있으므로 컴파일러는 함수가 호출되는 모든 지점에서 constexpr 함수의 전체 정의를 볼 수 있어야 한다. 전방 선언으로는 충분하지 않다.
이는 여러 파일에서 호출되는 constexpr 함수의 정의가 각 파일에 포함되어야 함을 의미하는데, 이러면 일반적으로 단일 정의 규칙을 위반하게 된다.
이러한 문제를 피하기 위해 constexpr 함수는 암시적으로 인라인이므로 단일 정의 규칙에서 제외된다.
결과적으로 constexpr 함수는 헤더 파일에 정의되는 경우가 많으므로 전체 정의가 필요한 모든 .cpp 파일에 #include된다.
constexpr 함수 호출에서 인수 사용
우리는 constexpr(또는 consteval) 함수가 컴파일 타임에 평가될 때 이 함수가 호출하는 다른 모든 함수도 컴파일 타임에 평가되어야 하는 것을 배웠다.
그런데 런타임에 평가되는 매개변수나 지역변수가 constexpr 함수 호출의 인수로 사용될 수 있다고 한다.
이게 어떻게 가능하냐면 constexpr 또는 consteval 함수가 컴파일 타임에 평가되는 경우, 모든 함수 매개변수 및 지역 변수의 값을 컴파일러에 알려야 하기 때문이다.
이렇게 최적화를 할 수 있는 좋은 기능을 두고 왜 모든 함수나 변수를 constexpr로 구성하지 않을까?
constexpr은 함수의 인터페이스 부분(시각적으로 조작가능한? 정도 의미)이다.
함수가 constexpr로 만들어지면 다른 constexpr 함수에 의해 호출되거나 상수 표현식이 필요한 컨텍스트에서 사용될 수 있지만, 나중에 constexpr을 제거하게 될 경우 코드 오류가 발생하기 때문이 첫 번째 이유이고,
다른 이유로 constexpr은 우리가 런타임에 살펴볼 수 없게 하기 때문에 함수를 디버그하기 더 어렵게 만드는 것이 있다.
하지만 일반적으로 사용 가능하다면, constexpr을 사용하는게 물론 더 좋다.