Proven C Book←↑→

21 수식과 상수 — 값이 되는 것

먼저 알아야 할 것

6장 정수의 표현 · 정수의 표현
20장 프로그램의 구조 · 문장과 수식의 자리

돌아보기

6장과 7장에서 수를 기계에 담는 법(2의 보수, IEEE 754)을 배웠다. 그러면 소스 코드에 수를 적으면 — 이를테면 12라고 쓰면 — 그것은 언제 기계의 비트가 되는가?

답. 컴파일 때다. 소스의 12는 그냥 글자 두 개(8장의 표현으로 문자 1과 2)이고, 컴파일러가 그것을 읽어 2의 보수 비트 패턴으로 번역해 실행 파일에 심는다. 소스에 적힌 값 표기를 리터럴(literal)이라 부른다 — “글자 그대로”라는 뜻이다. 글자의 세계와 비트의 세계를 잇는 다리가 컴파일러라는 것 — 17장의 릴레이가 여기서도 일하고 있다.

이 장의 필요성과 맥락

뼈대 안으로 들어가는 첫 걸음이라 값을 적는 법이 먼저다. 이 장을 상수 표기의 집합소로 삼은 것은 의도적이다 — 정수·문자·부동소수점·문자열 표기를 흩어 두면 나중에 찾을 곳이 없어진다. 6장에서 배운 정수의 표현이 여기서 처음 소스에 적는 글자로 바뀐다.

이 장이 끝나면

문장의 안쪽으로 들어간다. 소스 코드에 값을 적는 법이 절반이고 — 정수·문자· 부동소수점·문자열 상수의 표기를 이 장에 한자리로 모아 두었으니, 나중에 「이 표기가 뭐였더라」 싶을 때 돌아올 자리다 — 나머지 절반이 값을 계산으로 엮는 법 (연산자와 수식(expression))과 계산의 순서(우선순위와 괄호)다. 단, 이 책의 원칙대로 연산자는 최소 집합만: 덧셈, 뺄셈, 곱셈, 괄호. 이것으로도 놀랄 만큼 멀리 간다.

이 장에서 답할 질문

  1. 그러면 소스에 0.1이라고 적으면, 기계에는 정확히 0.1이 담기는가?
  2. l 소문자를 쓰지 말라는 말을 자주 듣는데?
  3. 16진 부동소수점은 왜 지수부 p 가 필수인가? 그리고 왜 쓰는가?
  4. 그러면 #define 상수는 이제 전부 constexpr 로 바꾸면 되는가?
  5. C++ 의 constexpr 과 같은 것인가?
  6. 나눗셈은 왜 미룬 것인가? 사칙연산에서 하나만 빠지니 오히려 어색한데.
  7. 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 또는 0X0xFF = 255
2진0b 또는 0B0b1010 = 10C23 에서 추가

표 21.1 — 정수 상수를 적는 진법

뒤에 붙는 글자 — 접미어 — 도 있다. 10L, 1U, 3ULL 처럼 쓰는데, 이것은 값이 아니라 타입을 바꾸는 표시다.

접미어무슨 표시인가
u U부호 없는 타입으로
l Llong 이상으로
ll LLlong long 이상으로
wb WB_BitInt 로 (C23)

표 21.2 — 정수 상수의 접미어

지금은 「접미어가 타입을 바꾼다」는 사실만 알아 두면 된다. 여기 나온 타입 이름들과, 접미어가 없을 때 컴파일러가 어떤 타입을 고르는지의 정확한 규칙은 28장에서 정수를 정식으로 배운 뒤 「정수 상수의 타입」 절에서 다룬다. 미리 한 가지만 — 같은 값을 십진으로 적느냐 16진으로 적느냐에 따라 타입이 달라질 수 있다.

문. l 소문자를 쓰지 말라는 말을 자주 듣는데?

답. 글꼴에 따라 1(숫자 1)과 l(엘)이 거의 같아 보이기 때문이다. 10l 이 101 로 읽히는 사고가 실제로 있었다. 접미어는 대문자로 — 10L, 1UL — 가 오랜 관행이고, 이 책도 그렇게 쓴다. 접두어 0x 는 소문자가 관행이라 방향이 반대인데, 이유는 같다: 눈에 잘 갈리는 쪽을 고른 것이다.

21.4 C23 의 자리 구분자 '#

긴 수를 읽기 좋게 끊는 표기가 C23 에 들어왔다. 값에는 아무 영향이 없다 — 표준의 말대로 상수의 값을 정할 때 무시된다.

표준이 직접 든 예가 함정까지 함께 보여 준다. 실측으로 확인하면 이렇다.

적은 것결과왜
12'341234자리 구분자는 숫자 사이에만
0b11'10'11'012372진에도 쓸 수 있다
0x1'2'3'4AB'C'D30544174116진에도
0x'FF오류 — “digit separator after base indicator”0x 바로 뒤에는 못 온다
'1'2오류문자 상수 '1' 뒤에 2 가 온 것으로 읽힌다

표 21.3 — C23 자리 구분자가 되는 자리와 안 되는 자리

마지막 줄이 이 표기의 유일한 위험이다. 구분자는 숫자와 숫자 사이에서만 구분자이고, 맨 앞에 오면 작은따옴표 — 즉 문자 상수의 시작 — 으로 읽힌다.

21.5 문자 상수 (§6.4.4.5)#

타입 이름들은 8장에서 문자 집합을, 28장에서 정수를 배운 뒤라야 온전히 읽힌다 — 지금은 접두어가 타입을 정한다는 것만 보면 된다.

표기타입실측 크기비고
'a'int4★C 에서 문자 상수는 char 가 아니다
u8'a'char8_t1C23. UTF-8 코드 단위 하나여야 한다
u'a'char16_t2UTF-16 코드 단위 하나
U'a'char32_t4UTF-32 코드 단위 하나
L'a'wchar_t4 (리눅스), 2 (윈도)와이드 리터럴 인코딩(8장)
'ab'int4값이 구현 정의. 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장에서 본다).

접미어타입실측 크기
(없음)double8
f Ffloat4
l Llong double16 (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-84
u"가"char16_tUTF-164 (코드 단위 1 + NUL)
U"가"char32_tUTF-328
L"가"wchar_t와이드 리터럴 인코딩8 (리눅스)

표 21.7 — 문자열 리터럴의 접두어와 인코딩

네 가지 성질을 함께 기억한다.

  1. 끝에 NUL 이 붙는다. sizeof "abc" 는 3이 아니라 4다.
  2. 인접한 것은 하나로 이어진다(번역 단계 6). "hello, " "world" 는 한 문자열이다. 긴 문자열을 줄로 나눠 적는 정석이고, 위의 \x 함정을 푸는 방법이기도 하다. 단 접두어가 섞이면 안 된다 — u"a" U"b" 는 컴파일 오류다(제약 위반).
  3. 안에 NUL 을 넣어도 배열은 잘리지 않는다. "a\0b" 는 strlen 이 1, sizeof 는 4다 — 문자열 함수가 멈출 뿐 데이터는 다 있다.
  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 (열거 상수)정수 상수다. 타입은 int59장 — 보통 식별자 마당에 산다
nullptrnullptr_t 타입의 예약어 (C23)37장
true falsebool 값을 주는 예약어 (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 = &copy;           포인터 초기값은 널만 가능
           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 14clang 22
constexpr unsigned int m = -1;오류 — “constexpr initializer not representable in type of object”오류 — “not exactly representable”
constexpr unsigned int m = -1U;OKOK
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(...)가 드디어 정면으로 다뤄진다.

주

  1. ISO/IEC 9899 (C23) 6.7.2, 문단 16. 초안 N3220. ↩
  2. ISO/IEC 9899 (C23) 6.7.2, 문단 5. 표준이 든 예가 constexpr unsigned int minusOne = -1; 은 제약 위반, constexpr unsigned int uint_max = -1U; 는 적법이라는 짝이다. ↩