21 수식과 상수 — 값이 되는 것
먼저 알아야 할 것
돌아보기
6장과 7장에서 수를 기계에 담는 법(2의 보수, IEEE 754)을 배웠다. 그러면 소스 코드에 수를 적으면 — 이를테면 12라고 쓰면 — 그것은 언제 기계의 비트가 되는가?
답. 컴파일 때다. 소스의 12는 그냥 글자 두 개(8장의 표현으로 문자 1과 2)이고, 컴파일러가 그것을 읽어 2의 보수 비트 패턴으로 번역해 실행 파일에 심는다. 소스에 적힌 값 표기를 리터럴(literal)이라 부른다 — “글자 그대로”라는 뜻이다. 글자의 세계와 비트의 세계를 잇는 다리가 컴파일러라는 것 — 17장의 릴레이가 여기서도 일하고 있다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 그러면 소스에
0.1이라고 적으면, 기계에는 정확히 0.1이 담기는가? l소문자를 쓰지 말라는 말을 자주 듣는데?- 16진 부동소수점은 왜 지수부
p가 필수인가? 그리고 왜 쓰는가? - 그러면
#define상수는 이제 전부constexpr로 바꾸면 되는가? - C++ 의
constexpr과 같은 것인가? - 나눗셈은 왜 미룬 것인가? 사칙연산에서 하나만 빠지니 오히려 어색한데.
printf("안녕")도 값이 되는가? 수식의 자리에 함수 부르기가 적혀 있는데.
21.1 리터럴 — 소스에 적는 값#
리터럴은 이미 여러 번 만났다. 헬로 월드의 0이 정수 리터럴이고, "안녕, 세상!\n"은 문자열 리터럴이다 — 따옴표 안의 글자들이 “글자 그대로” 값이 된다. 정수 리터럴에 소수점을 붙이면(3.5) 7장의 부동소수점 값이 된다.
문. 그러면 소스에 0.1이라고 적으면, 기계에는 정확히 0.1이 담기는가?
답. 아니다 — 7장에서 배운 그대로다. 컴파일러는 0.1이라는 리터럴을 가장 가까운 이웃(3FB9 9999 9999 999A)으로 번역한다. 리터럴은 “값을 그대로 적는 표기”이지만, 그 값이 표현 가능한 눈금 위에 없으면 번역 단계에서 이미 근사가 일어난다. 소스에 적었다고 정확한 것이 아니다 — 7장의 교훈이 소스 코드의 세계에서도 그대로 유효하다.
21.2 상수를 적는 법 — 한눈에 보기#
앞 절에서 리터럴을 만났으니, 이 자리에서 소스에 값을 적는 표기를 한 번에 모아 둔다. 표준은 이것을 어휘 요소 조항(§6.4.4, §6.4.5)에 흩어 두었고, 실무에서 헷갈리는 것은 거의 언제나 접두어·접미어·이스케이프 셋이다.
이 절은 참조용이다. 지금 전부 외울 필요는 없고, 「그런 표기가 있다」는 지도만 얻어 두었다가 필요할 때 돌아오면 된다. 시연도 그런 뜻으로 읽는다 — 출력을 훑어 보며 표기와 결과를 눈으로 잇는 정도면 충분하다.
examples/ch20/constants.c
/* 상수를 적는 방법들 — 표기가 값과 타입을 어떻게 정하는가. */
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <uchar.h>
int main(void)
{
puts("[integer constants - four bases and digit separators (C23)]");
printf(" 1234 = %d\n", 1234); /* 10진 */
printf(" 0755 = %d <- a leading 0 means octal\n", 0755);
printf(" 0xFF = %d\n", 0xFF);
printf(" 0b1010 = %d <- binary, new in C23\n", 0b1010);
printf(" 1'000'000 = %d <- digit separators, new in C23\n", 1'000'000);
printf(" 0b11'10'11'01 = %d\n", 0b11'10'11'01);
puts("\n[접미어가 타입을 정한다]");
printf(" sizeof 1 = %zu, sizeof 1L = %zu, sizeof 1LL = %zu\n",
sizeof 1, sizeof 1L, sizeof 1LL);
printf(" sizeof 1U = %zu, sizeof 1wb = %zu ← wb 는 C23 의 _BitInt\n",
sizeof 1U, sizeof 1wb);
puts("\n[문자 상수 — 접두어가 타입을 정한다]");
printf(" 'a' 크기 %zu, 값 %d ← C 에서 문자 상수는 int 다\n",
sizeof 'a', 'a');
printf(" u8'a' 크기 %zu (char8_t)\n", sizeof u8'a');
printf(" u'a' 크기 %zu (char16_t)\n", sizeof u'a');
printf(" U'a' 크기 %zu (char32_t)\n", sizeof U'a');
printf(" L'a' 크기 %zu (wchar_t)\n", sizeof L'a');
/* 'ab' 같은 다중 문자 상수는 값이 구현 정의라 -Wmultichar 가 경고한다.
여기서는 경고를 켠 채 두고, 값은 본문의 실측 표로만 보인다. */
puts("\n[이스케이프 — 8진과 16진]");
printf(" '\\101' = %d, '\\x41' = %d ← 둘 다 'A'\n", '\101', '\x41');
printf(" \"\\x41\" \"1\" = \"%s\" ← 16진은 가장 긴 열을 먹는다.\n",
"\x41" "1");
puts(" 그래서 \"\\x411\" 이 아니라 문자열을 쪼개 이어 붙인다.");
puts("\n[부동소수점 상수]");
printf(" 3.14 1e3=%g 1.=%g .5=%g\n", 1e3, 1., .5);
printf(" 0x1p-3 = %g ← 16진 부동소수점. 지수부 p 는 필수다\n", 0x1p-3);
printf(" sizeof 1.0 = %zu, 1.0f = %zu, 1.0L = %zu\n",
sizeof 1.0, sizeof 1.0f, sizeof 1.0L);
printf(" 0.1 == 0.1f ? %s ← 접미어가 다르면 값도 다르다\n",
(double)0.1f == 0.1 ? "예" : "아니오");
puts("\n[문자열 리터럴]");
printf(" sizeof \"abc\" = %zu ← NUL 이 한 칸 더 붙는다\n", sizeof "abc");
printf(" \"hello, \" \"world\" = \"%s\" ← 인접한 것은 하나로 이어진다\n",
"hello, " "world");
printf(" \"a\\0b\": strlen = %zu, sizeof = %zu ← 안에 NUL 을 넣어도 배열은 남는다\n",
strlen("a\0b"), sizeof "a\0b");
printf(" sizeof u8\"가\" = %zu, sizeof u\"가\" = %zu, sizeof U\"가\" = %zu\n",
sizeof u8"\uAC00", sizeof u"\uAC00", sizeof U"\uAC00");
return 0;
}
실행 결과
[integer constants - four bases and digit separators (C23)]
1234 = 1234
0755 = 493 <- a leading 0 means octal
0xFF = 255
0b1010 = 10 <- binary, new in C23
1'000'000 = 1000000 <- digit separators, new in C23
0b11'10'11'01 = 237
[접미어가 타입을 정한다]
sizeof 1 = 4, sizeof 1L = 8, sizeof 1LL = 8
sizeof 1U = 4, sizeof 1wb = 1 ← wb 는 C23 의 _BitInt
[문자 상수 — 접두어가 타입을 정한다]
'a' 크기 4, 값 97 ← C 에서 문자 상수는 int 다
u8'a' 크기 1 (char8_t)
u'a' 크기 2 (char16_t)
U'a' 크기 4 (char32_t)
L'a' 크기 4 (wchar_t)
[이스케이프 — 8진과 16진]
'\101' = 65, '\x41' = 65 ← 둘 다 'A'
"\x41" "1" = "A1" ← 16진은 가장 긴 열을 먹는다.
그래서 "\x411" 이 아니라 문자열을 쪼개 이어 붙인다.
[부동소수점 상수]
3.14 1e3=1000 1.=1 .5=0.5
0x1p-3 = 0.125 ← 16진 부동소수점. 지수부 p 는 필수다
sizeof 1.0 = 8, 1.0f = 4, 1.0L = 16
0.1 == 0.1f ? 아니오 ← 접미어가 다르면 값도 다르다
[문자열 리터럴]
sizeof "abc" = 4 ← NUL 이 한 칸 더 붙는다
"hello, " "world" = "hello, world" ← 인접한 것은 하나로 이어진다
"a\0b": strlen = 1, sizeof = 4 ← 안에 NUL 을 넣어도 배열은 남는다
sizeof u8"가" = 4, sizeof u"가" = 4, sizeof U"가" = 8
21.3 정수 상수 (§6.4.4.1)#
| 진법 | 표기 | 예 | 비고 |
|---|---|---|---|
| 10진 | 1 ~ 9 로 시작 | 1234 | |
| 8진 | 0 으로 시작 | 0755 = 493 | 가장 흔한 함정 — 08 은 오류다 |
| 16진 | 0x 또는 0X | 0xFF = 255 | |
| 2진 | 0b 또는 0B | 0b1010 = 10 | C23 에서 추가 |
표 21.1 — 정수 상수를 적는 진법
뒤에 붙는 글자 — 접미어 — 도 있다. 10L, 1U, 3ULL 처럼 쓰는데, 이것은 값이 아니라 타입을 바꾸는 표시다.
| 접미어 | 무슨 표시인가 |
|---|---|
u U | 부호 없는 타입으로 |
l L | long 이상으로 |
ll LL | long long 이상으로 |
wb WB | _BitInt 로 (C23) |
표 21.2 — 정수 상수의 접미어
지금은 「접미어가 타입을 바꾼다」는 사실만 알아 두면 된다. 여기 나온 타입 이름들과, 접미어가 없을 때 컴파일러가 어떤 타입을 고르는지의 정확한 규칙은 28장에서 정수를 정식으로 배운 뒤 「정수 상수의 타입」 절에서 다룬다. 미리 한 가지만 — 같은 값을 십진으로 적느냐 16진으로 적느냐에 따라 타입이 달라질 수 있다.
문. l 소문자를 쓰지 말라는 말을 자주 듣는데?
답. 글꼴에 따라 1(숫자 1)과 l(엘)이 거의 같아 보이기 때문이다. 10l 이 101 로 읽히는 사고가 실제로 있었다. 접미어는 대문자로 — 10L, 1UL — 가 오랜 관행이고, 이 책도 그렇게 쓴다. 접두어 0x 는 소문자가 관행이라 방향이 반대인데, 이유는 같다: 눈에 잘 갈리는 쪽을 고른 것이다.
21.4 C23 의 자리 구분자 '#
긴 수를 읽기 좋게 끊는 표기가 C23 에 들어왔다. 값에는 아무 영향이 없다 — 표준의 말대로 상수의 값을 정할 때 무시된다.
표준이 직접 든 예가 함정까지 함께 보여 준다. 실측으로 확인하면 이렇다.
| 적은 것 | 결과 | 왜 |
|---|---|---|
12'34 | 1234 | 자리 구분자는 숫자 사이에만 |
0b11'10'11'01 | 237 | 2진에도 쓸 수 있다 |
0x1'2'3'4AB'C'D | 305441741 | 16진에도 |
0x'FF | 오류 — “digit separator after base indicator” | 0x 바로 뒤에는 못 온다 |
'1'2 | 오류 | 문자 상수 '1' 뒤에 2 가 온 것으로 읽힌다 |
표 21.3 — C23 자리 구분자가 되는 자리와 안 되는 자리
마지막 줄이 이 표기의 유일한 위험이다. 구분자는 숫자와 숫자 사이에서만 구분자이고, 맨 앞에 오면 작은따옴표 — 즉 문자 상수의 시작 — 으로 읽힌다.
21.5 문자 상수 (§6.4.4.5)#
타입 이름들은 8장에서 문자 집합을, 28장에서 정수를 배운 뒤라야 온전히 읽힌다 — 지금은 접두어가 타입을 정한다는 것만 보면 된다.
| 표기 | 타입 | 실측 크기 | 비고 |
|---|---|---|---|
'a' | int | 4 | ★C 에서 문자 상수는 char 가 아니다 |
u8'a' | char8_t | 1 | C23. UTF-8 코드 단위 하나여야 한다 |
u'a' | char16_t | 2 | UTF-16 코드 단위 하나 |
U'a' | char32_t | 4 | UTF-32 코드 단위 하나 |
L'a' | wchar_t | 4 (리눅스), 2 (윈도) | 와이드 리터럴 인코딩(8장) |
'ab' | int | 4 | 값이 구현 정의. GCC 는 -Wmultichar 로 경고한다 |
표 21.4 — 문자 상수의 접두어와 타입
흔한 오해. “'a' 는 char 니까 크기가 1이다”
C++ 에서는 맞고 C 에서는 틀리다. 실측하면 갈린다 — 같은 sizeof('a') 가 C 에서 4, C++ 에서 1이다.
표준의 문장은 「정수 문자 상수의 타입은 int 다」(§6.4.4.5p11)이다. 값은 「그 문자를 char 객체에 담았다가 int 로 변환했을 때의 값」이라, 값은 우리가 기대하는 그대로이고 타입만 넓다.
이 차이가 드러나는 자리는 대개 sizeof 와 _Generic, 그리고 함수 오버로딩이 있는 C++ 쪽이다. C 만 쓴다면 실질적 피해는 거의 없지만, 두 언어에 함께 쓰이는 헤더에서는 알고 있어야 한다.
이스케이프는 다음이 전부다(§6.4.4.5).
| 갈래 | 표기 | 주의 |
|---|---|---|
| 반드시 이스케이프 | \' \\ | 작은따옴표와 역슬래시는 반드시 이 형태로 |
| 해도 되고 안 해도 되고 | \" \? | 문자열 안에서는 \" 가 필요하다 |
| 비그래픽 문자 | \a \b \f \n \r \t \v | 의미는 §5.2.3 에 정의되어 있다 |
| 8진 | \ + 8진 숫자 | 최대 세 자리 |
| 16진 | \x + 16진 숫자 | ★자릿수 제한이 없다 |
| 유니버설 문자 이름 | \uXXXX \UXXXXXXXX | 기본 문자 집합 밖의 글자를 이름으로 |
표 21.5 — 이스케이프의 갈래
반례. "\x411" — 16진 이스케이프가 뒷글자까지 먹는다
표준이 못박는다 — 8진이나 16진 이스케이프는 그것을 이룰 수 있는 가장 긴 문자 열이다(§6.4.4.5p7). 8진은 세 자리에서 멈추지만 16진은 멈추지 않는다.
"\x411" /* 'A'(0x41) 뒤에 '1' 이 아니라, 0x411 을 요구한 것 */실측하면 GCC 가 hex escape sequence out of range 라고 경고한다. 고치는 법은 문자열을 쪼개어 잇는 것이다 — 인접한 문자열 리터럴은 하나로 이어지므로 (아래), "\x41" "1" 은 정확히 "A1" 이 된다.
21.6 부동소수점 상수 (§6.4.4.3)#
십진 형태는 소수점이 있거나 지수부가 있거나 둘 중 하나를 갖춰야 한다. 그래서 1., .5, 1e3 이 모두 유효하고 1 은 정수 상수다.
부동소수점에도 접미어가 있다(타입의 정체는 7장과 52장에서 본다).
| 접미어 | 타입 | 실측 크기 |
|---|---|---|
| (없음) | double | 8 |
f F | float | 4 |
l L | long double | 16 (x86-64 리눅스) |
df dd dl | _Decimal32 / _Decimal64 / _Decimal128 (C23) | 4 / 8 / 16 |
표 21.6 — 부동소수점 상수의 접미어
16진 부동소수점(C99)도 있다 — 0x1p-3 은 정확히 0.125 다.
문. 16진 부동소수점은 왜 지수부 p 가 필수인가? 그리고 왜 쓰는가?
답. e 를 쓸 수 없기 때문이다. 16진에서 e 는 숫자 14라 지수의 시작으로 읽을 수 없다. 그래서 이진 지수를 뜻하는 p 를 따로 두었고, p 는 생략할 수 없다 — 생략하면 어디까지가 가수인지 정할 수 없다. 0x1p-3 은 「1 × 2−3」이다.
쓰는 이유는 정확성이다. 7장에서 본 대로 십진 0.1 은 이진에 정확히 담기지 않지만, 16진 표기는 이진 표현을 그대로 옮겨 적는 것이라 번역에서 반올림이 일어나지 않는다. 그래서 부동소수점 시험의 기대값, 표준 라이브러리의 상수 표, 부동소수점을 다루는 논문의 예제가 이 표기를 쓴다. printf 의 %a 가 같은 표기로 찍어 준다 (부록 B).
접미어가 값까지 바꾼다는 것도 짚어 둔다. 실측에서 (double)0.1f == 0.1 은 거짓이다 — 0.1f 는 float 눈금 위의 가장 가까운 값이고, 0.1 은 double 눈금 위의 가장 가까운 값이라 서로 다른 수다(7·52장).
21.7 문자열 리터럴 (§6.4.5)#
| 표기 | 원소 타입 | 인코딩 | 실측 sizeof |
|---|---|---|---|
"가" | char | 리터럴 인코딩(8장) | 4 (UTF-8 3바이트 + NUL) |
u8"가" | char8_t | 언제나 UTF-8 | 4 |
u"가" | char16_t | UTF-16 | 4 (코드 단위 1 + NUL) |
U"가" | char32_t | UTF-32 | 8 |
L"가" | wchar_t | 와이드 리터럴 인코딩 | 8 (리눅스) |
표 21.7 — 문자열 리터럴의 접두어와 인코딩
네 가지 성질을 함께 기억한다.
- 끝에 NUL 이 붙는다.
sizeof "abc"는 3이 아니라 4다. - 인접한 것은 하나로 이어진다(번역 단계 6).
"hello, " "world"는 한 문자열이다. 긴 문자열을 줄로 나눠 적는 정석이고, 위의\x함정을 푸는 방법이기도 하다. 단 접두어가 섞이면 안 된다 —u"a" U"b"는 컴파일 오류다(제약 위반). - 안에 NUL 을 넣어도 배열은 잘리지 않는다.
"a\0b"는strlen이 1,sizeof는 4다 — 문자열 함수가 멈출 뿐 데이터는 다 있다. - 수정하면 정의되지 않은 동작이다. 실측하면 대개 실행 중에 죽는다(읽기 전용 구역에 놓이기 때문이다). 그래서 문자열 리터럴은
const char *로 받는다.
반례. char *s = "abc"; s[0] = 'X';
컴파일은 통과하고(C 에서 문자열 리터럴의 타입은 const 가 아닌 char[N] 이다), 실행하면 죽는다 — 실측에서 SIGSEGV 였다.
C++ 는 이 자리를 아예 막았다(문자열 리터럴이 const char[N] 이라 대입이 오류다). C 는 옛 코드 호환 때문에 열어 두었고, 그래서 습관으로 막아야 한다 — const char *s = "abc"; 로 받으면 컴파일 시점에 잡힌다. -Wwrite-strings 로 경고를 켜 두는 방법도 있다.
21.8 상수처럼 보이는 것들#
| 표기 | 정체 | 자세히 |
|---|---|---|
RED (열거 상수) | 정수 상수다. 타입은 int | 59장 — 보통 식별자 마당에 산다 |
nullptr | nullptr_t 타입의 예약어 (C23) | 37장 |
true false | bool 값을 주는 예약어 (C23) | 31장 |
(int[]){1,2,3} 복합 리터럴 | 상수가 아니라 객체다 — 주소를 얻을 수 있다 | 48장 |
#define N 100 | 상수가 아니라 토큰 치환이다 | 61장 |
constexpr int n = 10; | C23 의 진짜 상수 — 상수 수식에 쓸 수 있다 | 바로 아래 절 |
표 21.8 — 상수처럼 보이는 것들의 정체
마지막 두 줄의 구별이 실무에서 힘을 발휘한다. 매크로는 타입도 스코프도 없고(61장), const int 는 C 에서 상수 수식이 아니다 — C++ 와 갈리는 자리다. 다만 「못 쓴다」가 아니라 어디서 못 쓰는가가 정확한 말이라, 실측으로 갈라 둔다.
const int n = 10; 을 이렇게 쓰면 | 결과(GCC, C23) |
|---|---|
블록 안에서 int a[n]; | 통과한다 — 다만 상수 배열이 아니라 가변 길이 배열(variable-length array, 줄여서 VLA)이 된다 |
파일 스코프에서 int a[n]; | 오류 — variably modified 'a' at file scope |
블록 안에서 static int a[n]; | 오류 — storage size of 'a' isn't constant |
case n: | 오류 — case label does not reduce to an integer constant |
표 21.9 — const int 가 걸리는 자리
즉 진짜 상수 수식이 필요한 자리에서 걸린다. C23 의 constexpr 이 그 자리를 메우려고 들어왔다 — 다음 절이 그 이야기다.
21.9 constexpr — C23 이 들여온 진짜 상수#
C 에는 오랫동안 「상수」라 부를 만한 것이 넷 있었고, 넷 다 어딘가 모자랐다.
| 무엇 | 타입이 있는가 | 상수 수식인가 | 모자란 점 |
|---|---|---|---|
100 (리터럴) | 있다 | 그렇다 | 이름이 없다 — 같은 값을 여기저기 적게 된다 |
#define N 100 | 없다 | (치환된 뒤에 판단된다) | 스코프도 타입도 없고, 디버거에 이름이 남지 않는다(61장) |
enum { N = 100 } | int 뿐 | 그렇다 | long long·double·문자열은 담을 수 없다 |
const int n = 100; | 있다 | 아니다 | 배열 크기·case 라벨 등 진짜 상수가 필요한 자리에서 걸린다 |
표 21.10 — 네 가지 「상수」와 그 결핍
constexpr 은 이 표의 빈칸을 채우려고 들어온 낱말이다 — 타입이 있고, 이름이 있고, 스코프가 있으면서, 상수 수식인 값.
examples/ch20/constexpr.c
/* C23 의 constexpr — 「진짜 상수」가 어디까지 되는가. */
#include <stdio.h>
constexpr int table_size = 4; /* 파일 스코프: 정적 저장 기간 + 내부 연결 */
constexpr double half = 0.5;
constexpr long long big = 1LL << 40;
int table[table_size]; /* 배열 크기 — 가변 길이 배열이 아니다 */
static_assert(sizeof table / sizeof table[0] == 4, "table_size is a constant");
constexpr int doubled = table_size * 2; /* 상수로 상수를 만든다 */
struct limits { int low, high; };
constexpr struct limits range = { 1, 9 };
static_assert(range.high == 9, "a member of a constexpr struct is a constant");
/* 전처리기는 constexpr 을 모른다 — 아래에서 그 사실을 인쇄한다 */
#if table_size == 4
static const char *preproc = "the preprocessor saw table_size == 4";
#else
static const char *preproc = "the preprocessor never saw it: the name became 0";
#endif
static const char *classify(int x)
{
switch (x) {
case table_size: return "exactly the table size"; /* case 라벨 */
case doubled: return "twice the table size";
default: return "something else";
}
}
struct packed {
unsigned flags : table_size; /* 비트 필드의 폭 */
};
int main(void)
{
static int copy = table_size + 1; /* 정적 초기화 */
enum { same_again = table_size }; /* 열거 상수의 값 */
printf("[constexpr is a constant expression]\n");
printf(" array size : %zu\n", sizeof table / sizeof table[0]);
printf(" case label : %s\n", classify(4));
printf(" case label : %s\n", classify(8));
printf(" static init : %d\n", copy);
printf(" enum value : %d\n", (int)same_again);
printf(" bit-field : %d bits\n", table_size);
printf(" struct member : range.high = %d (checked with static_assert)\n",
range.high);
printf("\n[it has a type, unlike a macro]\n");
printf(" half = %g (double)\n", half);
printf(" big = %lld (long long)\n", big);
printf(" const is implicit: %s\n",
_Generic(&table_size, const int *: "yes, &table_size is const int *",
int *: "no", default: "?"));
printf("\n[a block-scope constexpr is an ordinary object with an address]\n");
constexpr int local = 7;
const int *p = &local;
printf(" local = %d, read through a pointer = %d\n", local, *p);
printf("\n[but the preprocessor runs before any of this]\n");
printf(" %s\n", preproc);
printf(" #if and #define live in a different world (chapter 57)\n");
/* 아래는 전부 컴파일 오류다 — 값이 정확히 표현되어야 하기 때문이다.
constexpr unsigned int m = -1; 값이 표현 불가
constexpr float f = 0.1; double 0.1 은 float 로 정확하지 않다
constexpr int *q = © 포인터 초기값은 널만 가능
constexpr const char *s = "abc"; 같은 이유로 불가
constexpr volatile int v = 1; volatile·restrict·atomic 금지
constexpr int no_init; 정의이자 초기화여야 한다 */
return 0;
}
실행 결과
[constexpr is a constant expression]
array size : 4
case label : exactly the table size
case label : twice the table size
static init : 5
enum value : 4
bit-field : 4 bits
struct member : range.high = 9 (checked with static_assert)
[it has a type, unlike a macro]
half = 0.5 (double)
big = 1099511627776 (long long)
const is implicit: yes, &table_size is const int *
[a block-scope constexpr is an ordinary object with an address]
local = 7, read through a pointer = 7
[but the preprocessor runs before any of this]
the preprocessor never saw it: the name became 0
#if and #define live in a different world (chapter 57)
21.9.1 무엇을 적는 것인가#
constexpr 은 저장 클래스 지정자가 오는 자리에 적는다(그 자리의 일곱 낱말은 45장에 모아 두었다). 뜻은 표준의 한 문장으로 요약된다 — 「constexpr 로 선언된 객체는 그 값이 번역 시간에 영구히 고정되고, 아직 없다면 const 한정이 암묵적으로 붙는다. 그 식별자는 상수 수식으로 취급된다.」1
그래서 시연이 보인 자리들에 전부 쓸 수 있다.
| 쓸 수 있는 자리 | const int 로는 |
|---|---|
배열 크기 int table[table_size]; | 가변 길이 배열이 되거나 오류가 난다 |
case table_size: | 오류 |
static int copy = table_size + 1; | 오류 |
enum { same = table_size }; | 오류 |
static_assert(...) | 오류 |
비트 필드의 폭 unsigned f : table_size; | 오류 |
다른 constexpr 의 초기식 | 오류 |
표 21.11 — constexpr 를 쓸 수 있는 자리
문법에서 기억할 것은 셋이다. ① 선언이 아니라 정의여야 하고 초기식이 있어야 한다. constexpr int n; 은 오류다(“constexpr requires an initialized data declaration”). ② 초기식은 상수 수식이어야 한다. ③ 함께 적을 수 있는 저장 클래스는 auto·register·static 셋뿐이라, extern constexpr 과 thread_local constexpr 은 오류다.
21.9.2 const 와 무엇이 다른가#
흔한 오해. “constexpr 은 const 의 강화판이다”
비슷해 보이지만 두 낱말이 대답하는 질문이 다르다.
const는 「이 이름으로는 바꾸지 않겠다」는 약속이다. 값이 언제 정해지는지에 대해서는 아무 말도 하지 않는다 — 실행 중에 정해져도 된다.constexpr은 「이 값은 번역 시간에 정해진다」는 사실이다. 그 결과로const도 따라온다(표준이 암묵적으로 붙인다).
방향이 반대인 셈이다. 그래서 const int n = f(); 는 되지만 constexpr int n = f(); 는 안 된다 — 함수 호출은 상수 수식이 아니다. 시연의 _Generic 이 그 암묵적 const 를 눈에 보이게 찍어 준다: &table_size 의 타입은 const int * 다.
실무의 판별법은 한 줄이다 — 배열 크기나 case 라벨에 쓸 값이면 constexpr, 그저 바꾸지 않겠다는 표시면 const.
21.9.3 값이 정확히 담겨야 한다#
constexpr 에는 다른 선언에 없는 제약이 하나 더 있다. 초기식의 값이 대상 타입에 정확히 표현되어야 하고, 값이 바뀌어서는 안 된다.2 평범한 초기화라면 조용히 잘려 들어갔을 값이 여기서는 걸린다.
| 적은 것 | gcc 14 | clang 22 |
|---|---|---|
constexpr unsigned int m = -1; | 오류 — “constexpr initializer not representable in type of object” | 오류 — “not exactly representable” |
constexpr unsigned int m = -1U; | OK | OK |
constexpr float f = 0.1; | 오류 — double 의 0.1 은 float 눈금에 없다 | 오류 |
constexpr float f = (float)0.1; | OK — 캐스트로 뜻을 밝히면 된다 | OK |
constexpr signed short s = 70000; | 경고(-Woverflow) | 오류 |
constexpr unsigned char c = 300; | 경고(-Woverflow) | 오류 |
표 21.12 — 값이 정확히 담기지 않을 때 두 컴파일러의 반응
마지막 두 줄이 실측의 수확이다. 제약 위반에는 진단이 요구될 뿐 그 형태까지 정해지지는 않으므로, 경고로 알리는 gcc 도 규격을 어기는 것은 아니다. 다만 경고를 오류로 올려 두지 않으면(-Werror) 조용히 잘린 값이 상수가 되어 버린다 — 이 자리에서 -Wall -Wextra 를 켜 두는 습관이 제 몫을 한다(16장).
포인터에는 더 센 제약이 붙는다 — 널이어야 한다.
constexpr int *p = nullptr; /* OK */
constexpr const char *s = "abc"; /* 오류: "constexpr pointer initializer is not null" */
constexpr char s2[] = "abc"; /* OK — 배열로 받으면 된다 */문자열 상수를 constexpr 로 이름 붙이려다 첫날에 만나는 벽이 이것이다. 답은 포인터 대신 배열로 선언하는 것이다.
21.9.4 파일 스코프에 두면 어떻게 되는가#
constexpr 은 매크로를 대신하는 자리에 자주 놓이므로, 헤더에 넣어도 되는지가 곧 실무의 물음이 된다. 표준의 답은 이렇다 — 파일 스코프의 constexpr 객체는 정적 저장 기간이고, 그 식별자는 내부 연결(static 과 같은)이다. 같은 정의를 본 번역 단위마다 자기 사본을 하나씩 둔다는 뜻이라, 헤더에 적어도 중복 정의로 충돌하지 않는다(56장).
블록 안에 적으면 평범한 지역 객체다 — 자동 저장 기간이고, 주소도 얻을 수 있다. 시연의 &local 이 그 확인이다. 「상수」이지만 객체라는 것이 매크로와 갈리는 자리다.
문. 그러면 #define 상수는 이제 전부 constexpr 로 바꾸면 되는가?
답. 값에 이름을 붙이는 용도라면 그렇다. 타입이 생기고, 스코프가 생기고, 디버거에 이름이 보이고, 실수를 컴파일러가 잡아 준다.
그런데 매크로만 할 수 있는 일이 하나 남는다 — 전처리기의 조건이다. 전처리기는 컴파일러보다 먼저 돌기 때문에 constexpr 이름을 알지 못하고, #if 안에서 그 이름은 0 으로 바뀐다. 시연이 그 사실을 인쇄하게 해 두었다 — #if table_size == 4 는 참이 아니라 거짓으로 판정된다(0 == 4). 오류도 경고도 없이 조용히 그렇게 된다.
그래서 규칙은 이렇게 갈린다 — #if 로 갈라야 하는 값은 #define 으로, 코드에서 값으로 쓰는 상수는 constexpr 로. 전처리기의 세계가 왜 따로 있는지는 61장에서 정면으로 다룬다.
문. C++ 의 constexpr 과 같은 것인가?
답. 이름만 같고 범위가 다르다. C++ 의 constexpr 은 함수에도 붙어 컴파일 시간에 함수를 실행시키지만, C23 에는 constexpr 함수가 없다 — 객체 정의에만 붙는다. 실제로 constexpr int f(void) { ... } 는 오류다(“constexpr can only be used in variable declarations”, clang).
C 가 그렇게 좁게 들여온 데는 이 언어의 성격이 있다. 컴파일 시간 실행은 언어를 두 배로 키우는 기능이라, 「작고 예측 가능한 언어」라는 C 의 성질과 맞바꿔야 한다. 당장 값이 필요한 자리는 이 절의 constexpr 로 메우고, 그 이상은 두지 않은 것이다.
21.10 수식 — 계산되어 값이 되는 것#
리터럴을 연산자로 엮으면 수식(expression)이 된다. 2 + 3 * 4 처럼. 수식의 정의는 한 문장이면 된다 — 계산되어(평가되어) 값이 되는 것. 이 “값이 된다”는 성질이 수식의 전부이자, 앞으로 C를 읽는 눈의 절반이다. 코드의 어느 자리에 값이 필요하든, 그 자리에는 수식이 올 수 있다 — 리터럴 하나도 가장 단순한 수식이다.
이 장의 연산자는 넷뿐이다 — 더하기 +, 빼기 -, 곱하기 *, 그리고 괄호 ( ). 이 책의 나선형 원칙대로, 나머지 연산자들은 필요해지는 장에서 하나씩 합류한다(비교는 31장, 나눗셈은 29장 — 나눗셈이 뒤로 미뤄진 이유는 잠시 뒤에).
시연이다. 아래 예제의 %d는 “이 자리에 정수 값을 십진법으로 찍어라” 라는 표시로, 정식 설명은 23장에서 한다 — 지금은 계산 결과를 눈으로 확인하는 창으로만 쓴다.
examples/ch20/expr.c
#include <stdio.h>
int main(void)
{
printf("%d\n", 2 + 3 * 4); /* 곱셈이 먼저 계산된다 */
printf("%d\n", (2 + 3) * 4); /* 괄호가 순서를 명시한다 */
return 0;
}
실행 결과
14
20
21.11 순서 — 우선순위, 그리고 괄호라는 관행#
위 시연의 첫 줄이 14인 것은 곱셈이 덧셈보다 먼저 계산됐기 때문이다. 수학 시간의 관례 그대로이고, C는 이런 “누가 먼저인가”의 서열 — 우선순위(precedence) — 을 모든 연산자에 정해 두었다.
여기서 이 책의 권고를 미리 밝힌다. 우선순위 표를 외우지 말고, 괄호를 써라. C의 연산자는 수십 개이고 서열표는 15줄이 넘는다 — 그것을 다 외운 사람들끼리도 헷갈려서 사고가 난다. 둘째 줄처럼 괄호로 순서를 명시하면 외울 것도, 오해할 것도 없다. 전체 서열표는 부록에 참고 자료로 두고, 본문은 “산술은 수학 관례대로, 그 밖에는 괄호”로 간다.
문. 나눗셈은 왜 미룬 것인가? 사칙연산에서 하나만 빠지니 오히려 어색한데.
답. 정수의 나눗셈이 수학의 나눗셈과 다르기 때문이다. C에서 7 / 2는 3.5가 아니라 3이다 — 정수끼리의 나눗셈은 소수점 아래를 버린 몫이고, 짝꿍 연산자 %(나머지)와 세트로 이해해야 뜻이 선다. 그 “버림”의 정확한 규칙(음수에서는 어느 쪽으로 버리나)까지 포함해서, 6장의 정수 세계 위에서 제대로 다룰 가치가 있는 주제라 29장에 자리를 잡아 두었다. 어중간하게 지금 소개해서 “7/2=3.5인 줄 알았는데”라는 오개념을 만드는 것보다, 정색하고 한 번에 다루는 것이 낫다는 판단이다.
문. printf("안녕")도 값이 되는가? 수식의 자리에 함수 부르기가 적혀 있는데.
답. 좋은 눈이다 — 된다. 함수를 부르는 것 자체가 수식이고, 따라서 값이 된다. 그 값이 무엇이고 어디로 가는지가 바로 다음 장의 주제다. 이 질문 하나로 다음 장의 문이 이미 반쯤 열렸다.
21.12 미리 심는 주의 하나 — 계산의 시간 순서는 다를 수 있다#
우선순위에 관해, 지금은 씨앗만 심어 둘 주의가 하나 있다. 우선순위는 “누가 누구의 재료인가”라는 묶기의 규칙이지, 기계가 시간상 무엇을 먼저 계산하는가가 아니다 — 이를테면 덧셈의 왼쪽 재료와 오른쪽 재료 중 어느 쪽을 먼저 계산할지는 표준이 정해 두지 않았고, 컴파일러마다 다를 수 있다. 2 + 3 같은 수식에서야 아무래도 좋지만, 재료의 계산이 출력 같은 흔적을 남기는 경우에는 그 흔적의 순서가 달라질 수 있다는 뜻이다.
다만 안심할 것 — 문장과 문장 사이의 순서는 철석같이 보장된다. 20장의 “위에서 아래로 한 문장씩”은 계약이다. 흔들릴 수 있는 것은 한 문장 안쪽뿐이고, 그 안쪽의 정확한 규칙(부수효과, 시퀀스 포인트라는 개념)은 재료가 더 갖춰진 뒤 34장에서 정면으로 다룬다. 지금의 실전 수칙은 하나면 된다: 순서가 중요한 일들은 한 문장에 욱여넣지 말고 문장을 나눈다.
정리하면 — 소스에 값을 적는 표기가 리터럴이고, 계산되어 값이 되는 것이 수식이며, 묶는 순서는 산술 관례와 괄호로 다스리되 한 문장 안의 계산 시간 순서는 달라질 수 있다. 다음 장에서는 값을 소비하고 생산하는 가장 중요한 장치 — 함수를 부르는 법 — 를 익힌다. 헬로 월드의 심장 printf(...)가 드디어 정면으로 다뤄진다.