Proven C Book←↑→

52 수식과 연산자

먼저 알아야 할 것

21장 수식과 상수 · 값이 되는 것과 우선순위
35장 대입과 부수효과 · 평가 순서와 시퀀스 포인트
39장 배열 · 첨자와 포인터 산술
47장 구조체 · 멤버 접근

돌아보기

21장에서 “표를 외우지 말고 괄호를 쓰라”고 했고, 그 뒤로 연산자를 필요한 자리마다 조금씩 꺼내 썼다 — 29장의 시프트, 31장의 비교, 35장의 대입, 39장의 포인터 산술처럼. 그러면 지금 이 장에서 무엇을 새로 배우는가?

답. 같은 것을 다른 눈으로 본다. 지금까지는 “이 자리에서 필요한 만큼”이었다면, 이제는 연산자 하나하나를 계약으로 읽는다 — 무엇을 받고(피연산자 제약), 무엇을 돌려주며(결과의 타입과 값 범주), 어디서부터 계약 밖인가(회색지대).

이 눈이 필요한 이유는 실무에 있다. 남의 코드를 읽다 막히는 자리, 컴파일러가 왜 그렇게 최적화했는지 알아야 하는 자리, 그리고 “왜 여기서만 이상하게 도는가”를 추적하는 자리 — 전부 연산자의 계약을 봐야 풀린다.

이 장의 필요성과 맥락

9부는 「이미 배운 것을 다시, 끝까지」의 부다. 그 첫 장이 연산자 총람인 이유는 앞의 쉰한 장에 걸쳐 연산자가 필요할 때마다 조금씩 등장했기 때문이다. 흩어진 것을 여기서 한 번 모아 두면, 부록 A 는 조회표로만 쓰면 된다.

이 장이 끝나면

흩어져 배운 연산자를 한자리에 모아 다시 본다. 수학의 수식·연산자와 C의 그것이 어떻게 다른지, 표준이 수식·연산자·피연산자·부수효과를 어떻게 정의하는지에서 시작해, 수식이 가진 네 가지(값·타입·값 범주·부수효과), 우선순위와 결합의 전체 표, 연산자별 계약, 평가 순서와 시퀀스 포인트(sequence point), 그리고 회색지대 총람까지. 이 장을 지나면 부록 A는 조회표로만 쓰면 된다.

이 장에서 답할 질문

  1. 부수효과가 없는 수식은 써도 아무 일이 없는가? x + 1; 같은 문장 말이다.
  2. “좌변값”이라는 이름은 왼쪽에 있다는 뜻인가?
  3. 그러면 for (i = 0; i < n; i++) 의 i++ 는 언제 일어나는가?
  4. 그러면 C 에서는 무엇을 쓰라는 말인가?
  5. 왜 UB까지 가는가? 「둘 중 하나의 순서로 계산된다」 정도의 미지정이면 충분하지 않은가?

52.1 수학의 수식, C의 수식#

「수식」과 「연산자」는 수학에서 먼저 배운 낱말이다. C가 그 낱말을 빌려 왔지만 뜻은 꽤 달라졌다. 차이를 먼저 분명히 해 두면 이 장의 나머지가 모두 그 차이의 귀결로 읽힌다.

수학에서 수식은 수와 변수와 연산 기호를 규칙에 맞게 엮은 것이고, 하나의 값을 나타낸다. 2 + 3 × 4 는 14를 나타낸다. 연산자는 피연산자를 받아 결과 하나를 내는 함수다 — 덧셈은 실수 둘을 받아 실수 하나를 내는 사상이다. 그리고 수식을 「계산한다」고 해서 세상의 무엇이 바뀌지는 않는다. x + 1 을 백 번 계산해도 x 는 그대로이고, 언제 계산하든 어떤 순서로 계산하든 값은 같다. 그래서 수학에서 x = x + 1 은 틀린 등식이다.

C에서 수식은 같은 모양을 하고 있지만 네 가지가 다르다.

무엇수학C
수식이 하는 일값을 나타낸다값을 계산하거나, 객체·함수를 가리키거나, 무언가를 바꾸거나, 그 몇 가지를 함께 한다
계산하면 세상이 바뀌는가아니다바뀔 수 있다 — x = x + 1 은 등식이 아니라 「x 를 하나 늘려라」이다. 이것이 부수효과다
값의 범위수는 끝이 없다모든 값에 타입이 있고 범위가 유한하다. INT_MAX + 1 은 계약 밖이고(29장), 부동소수점에서는 (a + b) + c 와 a + (b + c) 가 다를 수 있다(54장)
계산의 시간시간 밖의 관계기계가 시간 속에서 한 단계씩 한다. 그 순서가 다 정해져 있지 않다 — 이 장 뒤의 「평가 순서와 시퀀스 포인트」

표 52.1 — 수학의 수식과 C의 수식

★ 둘째 줄과 넷째 줄이 만나는 자리가 C 수식의 가장 까다로운 곳이다. 수식이 무언가를 바꾸고, 그 바뀌는 시점이 다 정해져 있지 않다면 — 같은 객체를 한 수식 안에서 두 번 바꾸는 코드의 뜻은 무엇이 되는가. 이 장은 그 물음에 표준이 준 답으로 끝난다.

52.2 엄밀한 정의 — 수식, 연산자, 피연산자#

표준은 수식을 이렇게 정의한다.

“ 수식(expression)은 연산자와 피연산자의 나열로서, 값의 계산을 지정하거나, 객체나 함수를 지칭하거나, 부수효과를 일으키거나, 그 몇 가지를 함께 하는 것이다. 연산자의 피연산자들의 값 계산은 그 연산자 결과의 값 계산보다 먼저 순서 지어진다.1 ”

「또는」으로 이어진 네 갈래가 앞 표의 첫 줄이다. 예를 하나씩 붙이면 이렇다.

갈래예설명
값의 계산을 지정한다a + b, n * 2, x > 0수학의 수식과 가장 닮은 갈래
객체를 지칭한다x, *p, a[i], s.name값이 아니라 자리를 가리킨다 — 좌변값(36장)
함수를 지칭한다printf, main함수 지시자. 부르기 전의 「그 함수」 자체
부수효과를 일으킨다x = 1, i++, puts("hi")계산 말고 일어나는 일 — 다음 절
그 몇 가지를 함께i++ (값 계산 + 부수효과), *p = 0 (객체 지칭 + 부수효과)대부분의 실전 수식이 여기 든다

표 52.2 — 수식이 하는 네 가지 일

정의의 둘째 문장도 짚어 둔다. (a + b) * c 에서 a + b 의 값과 c 의 값이 정해진 뒤에야 곱셈의 값이 정해진다 — 당연해 보이지만, 표준이 연산자와 피연산자 사이에 보장하는 순서는 이것뿐이다. a + b 와 c 가운데 무엇을 먼저 계산할지는 말하지 않는다.

연산자와 피연산자는 표준이 낱말(토큰)을 분류하는 자리에서 정의한다.

“ 구두점(punctuator)은 독자적인 문법적·의미적 뜻이 있는 기호다. 문맥에 따라 수행할 연산(그 결과로 값이나 함수 지시자를 내거나, 부수효과를 일으키거나, 그 몇 가지를 함께 하는)을 지정할 수 있는데, 그 경우 그것을 연산자(operator)라 한다(어떤 문맥에는 다른 꼴의 연산자도 있다). 피연산자(operand)는 연산자가 작용하는 대상이다.2 ”

이 정의에서 실용적인 사실이 셋 나온다.

수식은 되풀이해 쌓인다. 가장 작은 수식 — 표준이 일차 수식(primary expression)이라 부르는 식별자·상수·문자열 리터럴·괄호로 싼 수식 — 이 잎이 되고, 연산자가 수식들을 피연산자로 받아 더 큰 수식을 만든다. a + b * c 는 b * c 라는 수식을 피연산자로 받는 덧셈이다. 그래서 수식은 나무 모양이고, 그 나무의 모양을 정하는 것이 우선순위와 결합이다(표준의 말로 「연산자와 피연산자의 묶음은 문법이 정한다」, §6.5.1p3). 그리고 다른 수식의 일부가 아닌 가장 바깥의 수식 — 문장 x = a + b; 의 x = a + b 전체 — 을 완전 수식(full expression)이라 한다(§6.8.1p4). 이 낱말이 시퀀스 포인트에서 다시 나온다.

52.3 부수효과 — 값 말고 일어나는 일#

수학의 수식과 C의 수식을 가르는 결정적인 낱말이 부수효과(side effect)다. 표준의 정의는 이렇다.

“ volatile 객체에 접근하는 것, 객체를 수정하는 것, 파일을 수정하는 것, 또는 그런 일 가운데 하나를 하는 함수를 부르는 것은 모두 부수효과이고, 실행 환경의 상태를 바꾸는 일이다. 수식의 평가는 일반적으로 값 계산과 부수효과의 시작을 함께 포함한다.3 ”

목록은 넷이다. 이 책에서 이미 만난 것들로 바꾸면 이렇다.

표준의 낱말이 책에서 만난 꼴비고
객체를 수정한다x = 1, x += 2, i++, --n대입과 증감 연산자가 부수효과를 위해 있는 연산자다(35장)
파일을 수정한다fputs, fprintf, fwrite화면 출력도 표준 출력 스트림에 쓰는 일이다(23장)
volatile 객체에 접근한다하드웨어 레지스터 읽기읽기만 해도 부수효과다 — 장치는 읽혔다는 사실에 반응할 수 있다(114장)
그런 일을 하는 함수를 부른다printf("hi"), rand(), strtok(...)함수 몸통이 위의 일을 하면 부르는 것 자체가 부수효과다

표 52.3 — 부수효과의 네 갈래

C의 연산자 가운데 스스로 부수효과를 일으키는 것은 셋뿐이다 — 대입(= 과 복합 대입), 증감(++·--), 함수 호출. 연산자가 아닌 예외가 하나 있다. 표의 셋째 줄대로 volatile 객체의 값을 읽는 것 자체가 부수효과이므로, volatile int *reg 에서 *reg 를 읽기만 하는 수식도 부수효과를 품는다. 나머지 연산자(+, *, <, &&, ?: …)는 수학의 연산자처럼 값만 계산한다. 다만 그 피연산자 안에 부수효과가 있으면 수식 전체는 부수효과를 품는다.

examples/expressions/side_effects.c

#include <stdio.h>

static int calls;

static bool hit(bool v)          /* 불릴 때마다 calls 를 바꾼다 --- 부수 효과 */
{
    calls++;
    return v;
}

int main(void)
{
    puts("[1] an expression has a value, and may also do something");
    int x = 0;
    int v = (x = 5) + 1;        /* 대입식의 값은 5, 그리고 x 가 바뀐다 */
    printf("v = %d, x = %d\n", v, x);

    int i = 3;
    int post = i++;             /* 값은 3, 효과는 i 를 4 로 */
    int pre  = ++i;             /* 값은 5, 효과는 i 를 5 로 */
    printf("post = %d, pre = %d, i = %d\n", post, pre, i);

    puts("\n[2] && skips its right side --- and the side effects in it");
    calls = 0;
    bool r = hit(false) && hit(true);
    printf("result %d, hit() called %d time(s)\n", r, calls);
    calls = 0;
    r = hit(true) || hit(false);
    printf("result %d, hit() called %d time(s)\n", r, calls);

    puts("\n[3] the comma operator: left, then right; value of the right");
    int c = (calls = 0, calls + 10);
    printf("c = %d\n", c);

    puts("\n[4] one change per object between sequence points");
    int k = 0;
    k++, k++;                   /* 쉼표 연산자 --- 사이에 시퀀스 포인트 */
    k++;                        /* 완전 수식이 끝날 때마다 시퀀스 포인트 */
    k++;
    printf("k = %d\n", k);
    return 0;
}

실행 결과

[1] an expression has a value, and may also do something
v = 6, x = 5
post = 3, pre = 5, i = 5

[2] && skips its right side --- and the side effects in it
result 0, hit() called 1 time(s)
result 1, hit() called 1 time(s)

[3] the comma operator: left, then right; value of the right
c = 10

[4] one change per object between sequence points
k = 4

첫 묶음이 「값」과 「효과」를 갈라 보여 준다. (x = 5) + 1 에서 대입식의 값은 5이고 그래서 v 는 6이 되었으며, 그와 별도로 x 를 5로 바꾸는 효과가 일어났다. i++ 의 값은 바꾸기 전의 3, ++i 의 값은 바꾼 뒤의 5다. 둘째 묶음은 &&·|| 가 오른쪽을 평가하지 않으면 그 안의 부수효과(여기서는 calls 를 늘리는 함수 호출)도 일어나지 않는다는 것을 보인다. 셋째 묶음의 쉼표 연산자는 왼쪽을 평가한 뒤 그 값을 버리고 오른쪽의 값을 낸다.

정의의 마지막 문장 「부수효과의 시작」에 주목하자. 평가는 부수효과를 시작할 뿐이고, 그것이 언제 끝나는지는 다른 규칙이 정한다. x = 5 의 값 5는 지금 정해져도, x 의 메모리에 5가 실제로 써지는 순간은 그 뒤 어딘가일 수 있다. 이 틈이 이 장 뒤쪽 「평가 순서와 시퀀스 포인트」의 출발점이다.

문. 부수효과가 없는 수식은 써도 아무 일이 없는가? x + 1; 같은 문장 말이다.

답. 아무 일도 없다. 그리고 컴파일러는 그것을 알고 계산 자체를 생략한다. 표준은 이렇게 허락해 둔다 — 실제 구현은 수식의 일부가 값이 쓰이지 않고 필요한 부수효과도 내지 않는다고 추론할 수 있으면 그 부분을 평가하지 않아도 된다(§5.1.2.4p4). gcc와 clang은 그런 문장에 -Wunused-value 경고를 준다(「statement with no effect」).

같은 조항이 14장의 최적화를 떠받친다. 부수효과가 없는 계산은 언제 하든, 몇 번 하든, 하지 않든 관찰 가능한 결과가 같으므로 컴파일러가 마음대로 옮기고 합치고 지운다. 거꾸로 말하면 컴파일러가 함부로 손대지 못하는 것이 부수효과다 — 그래서 부수효과가 언제 일어나야 하는지에 관한 규칙이 따로 필요하다.

52.4 수식이 가진 네 가지#

C의 수식은 언제나 네 가지를 함께 가진다. 이 넷을 갈라 놓고 보는 습관이 이 장의 전부라 해도 좋다.

가진 것무엇인가
값계산의 결과. 2 + 3의 값은 5
타입그 값이 어떤 그릇에 담기는가. 컴파일 시간에 정해진다(24장)
값 범주좌변값(자리를 가리킴)인가 아닌가. x는 좌변값, x + 1은 아니다
부수효과객체를 바꾸거나 바깥 세계에 영향을 주는가(35장)

표 52.4 — 수식이 가진 것

값 범주라는 낱말이 낯설 텐데, 실은 이미 쓰고 있었다. 대입의 왼쪽에 올 수 있는 것이 좌변값이고(35장), &를 붙일 수 있는 것도 좌변값이다(36장). 배열 이름은 좌변값이지만 대입의 왼쪽에는 올 수 없는 특수한 경우다(39장).

문. “좌변값”이라는 이름은 왼쪽에 있다는 뜻인가?

답. 역사적으로는 그랬다(left value). 그러나 지금은 위치를 가리키는 수식이라는 뜻으로 쓰는 편이 정확하다 — 대입의 왼쪽에 올 수 있는 것이 그 성질의 한 가지 귀결일 뿐이다. *p는 대입의 오른쪽에 있어도 좌변값이고, const int c는 좌변값이지만 수정 가능한 좌변값은 아니라서 대입의 왼쪽에 올 수 없다.

그래서 표준은 “수정 가능한 좌변값(modifiable lvalue)”이라는 말을 따로 쓴다. 이 장의 표에서 대입과 증감의 피연산자 칸에 그 말이 나오는 이유다.

수식의 잎사귀인 상수를 적는 표기 — 진법·접두어·접미어·이스케이프·문자열 리터럴 — 는 21장 「상수를 적는 모든 방법」에 한자리로 모아 두었고, 정수 상수의 타입이 정해지는 규칙은 28장에 있다. 이 장은 그 잎사귀를 엮는 연산자를 다룬다.

52.5 우선순위와 결합#

아래 표는 연산자 묶음을 우선순위가 높은 것부터 늘어놓았다 — 위쪽 줄의 연산자일수록 더 강하게 묶는다. 결합 방향은 같은 세기의 연산자가 나란히 있을 때 어느 쪽부터 묶느냐다 — a - b - c가 (a - b) - c인 것은 왼쪽 결합이기 때문이고, a = b = 0이 a = (b = 0)인 것은 오른쪽 결합이기 때문이다.

묶음연산자결합결합이 그런 이유
후위() [] . -> ++(후위) --(후위), 복합 리터럴왼→오a.b.c처럼 왼쪽부터 파고들어야 뜻이 선다
단항++ -- + - ! ~ (타입) * & sizeof alignof오→왼피연산자에 가장 가까운 것부터 붙는다: - -x, *&x
곱셈류* / %왼→오산수의 관례
덧셈류+ -왼→오뺄셈은 왼쪽 결합이라야 뜻이 맞는다
시프트<< >>왼→오a << 1 << 2는 차례로 민다
관계< <= > >=왼→오그래서 x < y < z가 수학과 달라진다
상등== !=왼→오
비트 AND&왼→오
비트 XOR^왼→오
비트 OR|왼→오
논리 AND&&왼→오단락 평가가 왼쪽부터라야 뜻이 산다
논리 OR||왼→오같은 이유
조건?:오→왼a ? b : c ? d : e가 사다리로 읽힌다
대입= += -= *= /= %= &= ^= |= <<= >>=오→왼a = b = 0이 “둘 다 0”이 되려면
쉼표,왼→오왼쪽을 먼저 하고 버린다

표 52.5 — 연산자 묶음별 결합 방향

흔한 오해. “우선순위가 높으면 먼저 계산된다”

우선순위는 묶는 규칙이지 계산의 시간 순서가 아니다. f() * g() + h()에서 곱셈이 덧셈보다 강하게 묶이는 것은 맞지만, h()가 f()보다 먼저 불려도 아무 문제가 없다. 세 호출의 순서는 미지정이다.

이 구별은 35장에서 이미 만났다 — 우선순위는 “무엇과 무엇이 한 덩어리인가”를 정하고, 시퀀스 포인트는 “무엇이 무엇보다 먼저 끝나는가”를 정한다. 둘은 다른 질문이고, 뒤의 절에서 후자를 따로 다룬다.

52.6 우선순위 때문에 자주 미끄러지는 자리들#

쓴 것실제로 묶이는 방식의도한 것이라면
a & b == ca & (b == c)(a & b) == c
a << 1 + 2a << (1 + 2)(a << 1) + 2
*p++*(p++)(*p)++
*p.x*(p.x)(*p).x 또는 p->x
(int)x + y((int)x) + y(int)(x + y)
a = b = 0a = (b = 0)(그대로다 — 오른쪽 결합)
x < y < z(x < y) < zx < y && y < z
!x & y(!x) & y!(x & y)
sizeof a + 1(sizeof a) + 1sizeof(a + 1)
a ? b : c = d(a ? b : c) = d(대개 오류)a ? b : (c = d)

표 52.6 — 우선순위 때문에 다르게 묶이는 자리

표는 표일 뿐이다. 같은 것을 돌려 보면 훨씬 빨리 손에 붙는다 — 아래 시연은 괄호를 넣은 것과 안 넣은 것을 나란히 찍는다.

examples/expressions/precedence.c

// 우선순위·결합성·짧은 회로를 눈으로 확인한다.
// 표를 외우는 대신, 괄호를 넣은 것과 안 넣은 것을 나란히 찍어 본다.
#include <stdio.h>

static int calls = 0;

static int loud(int value)          // 불릴 때마다 흔적을 남긴다
{
    calls += 1;
    return value;
}

int main(void)
{
    int x = 6;

    puts("1. precedence --- what binds tighter");
    printf("  2 + 3 * 4      = %d   (same as 2 + (3 * 4))\n", 2 + 3 * 4);
    printf("  (2 + 3) * 4    = %d\n", (2 + 3) * 4);
    printf("  1 << 2 + 3     = %d   (same as 1 << (2 + 3): + binds tighter than <<)\n",
           1 << (2 + 3));

    puts("2. the classic trap --- == binds tighter than &");
    printf("  x & 1 == 0     is read as x & (1 == 0) = %d\n", x & (1 == 0));
    printf("  what was meant: (x & 1) == 0         = %d\n", (x & 1) == 0);

    puts("3. associativity --- which side groups first");
    printf("  10 - 4 - 3     = %d   (left to right: (10 - 4) - 3)\n", 10 - 4 - 3);
    int a, b;
    a = b = 5;                      // 대입은 오른쪽부터 묶인다
    printf("  a = b = 5      gives a=%d b=%d   (right to left)\n", a, b);

    puts("4. comparison does not chain the way mathematics does");
    printf("  1 < 2 < 3      = %d   ((1 < 2) is 1, and 1 < 3 is true)\n", (1 < 2) < 3);
    printf("  3 > 2 > 1      = %d   ((3 > 2) is 1, and 1 > 1 is false)\n", (3 > 2) > 1);

    puts("5. short circuit --- the right side may never run");
    calls = 0;
    if (0 && loud(1))
        puts("  not reached");
    printf("  after 0 && loud(1): loud() was called %d time(s)\n", calls);
    calls = 0;
    if (1 || loud(1))
        (void)0;
    printf("  after 1 || loud(1): loud() was called %d time(s)\n", calls);
    return 0;
}

실행 결과

1. precedence --- what binds tighter
  2 + 3 * 4      = 14   (same as 2 + (3 * 4))
  (2 + 3) * 4    = 20
  1 << 2 + 3     = 32   (same as 1 << (2 + 3): + binds tighter than <<)
2. the classic trap --- == binds tighter than &
  x & 1 == 0     is read as x & (1 == 0) = 0
  what was meant: (x & 1) == 0         = 1
3. associativity --- which side groups first
  10 - 4 - 3     = 3   (left to right: (10 - 4) - 3)
  a = b = 5      gives a=5 b=5   (right to left)
4. comparison does not chain the way mathematics does
  1 < 2 < 3      = 1   ((1 < 2) is 1, and 1 < 3 is true)
  3 > 2 > 1      = 0   ((3 > 2) is 1, and 1 > 1 is false)
5. short circuit --- the right side may never run
  after 0 && loud(1): loud() was called 0 time(s)
  after 1 || loud(1): loud() was called 0 time(s)

넷째 묶음을 눈여겨보라. 1 < 2 < 3 은 참인데 3 > 2 > 1 은 거짓이다. 수학의 버릇대로 읽으면 둘 다 참이어야 하지만, C 는 왼쪽부터 둘씩 묶으며 비교의 결과를 0 또는 1이라는 수로 바꾸어 다음 비교에 넘긴다. 다섯째 묶음은 && 와 || 의 단락 평가다 — 왼쪽만으로 답이 정해지면 오른쪽은 아예 평가되지 않는다. 그래서 오른쪽에 「반드시 해야 하는 일」을 두면 그 일이 조용히 사라진다(33장).

특히 시연 앞 표의 첫 줄(a & b == c)은 C의 유명한 설계 흠으로 꼽힌다 — 비트 연산자가 비교 연산자보다 약하게 묶이는 것은 초기 C가 &&·||를 갖기 전의 흔적이다. 그래서 비트 검사에는 괄호가 사실상 필수다. 표준도 각주에서 a<b<c가 수학과 다르게 읽힌다는 것을 따로 짚어 둔다.

52.7 전위와 후위 — 같은 일을 하고 다른 값을 준다#

++와 --는 앞에 붙일 수도 뒤에 붙일 수도 있다. 앞에 붙인 전위와 뒤에 붙인 후위, 두 꼴의 차이를 정확히 아는 것이 C 수식을 읽는 힘의 상당 부분이라, 여기서 한자리에 모아 본다.

52.7.1 표준이 정한 계약#

전위 ++x (§6.5.4.1)후위 x++ (§6.5.3.5)같은가
피연산자수정 가능한 좌변값이고 실수 타입 또는 포인터 타입같음같다
객체에 하는 일1 을 더한다1 을 더한다같다
수식의 값바꾼 뒤의 값바꾸기 전의 값다르다
결과가 좌변값인가아니다아니다같다 (C++ 는 다르다 — 아래)
정의되는 방식++E 는 (E += 1) 과 같다따로 정의된다다르다

표 52.7 — 전위와 후위 증감의 차이

차이는 오직 「수식이 내놓는 값」 하나뿐이다. 객체가 바뀌는 결과는 완전히 같다. 그래서 값을 쓰지 않는 자리 — for 의 세 번째 칸, 문장 하나로 쓰는 i++; — 에서는 둘이 뜻까지 완전히 같다.

흔한 오해. “실수 타입이라 했으니 정수에는 못 쓰는 것 아닌가”

표준이 말하는 실수 타입(real type)은 「부동소수점」이 아니다. §6.2.5 의 분류로 정수 타입과 실수 부동소수점 타입을 합친 것이고, 빠지는 것은 복소수뿐이다. 즉 int·char·bool·double·포인터에 다 쓸 수 있고, double _Complex 에는 표준상 쓸 수 없다.

실측하면 GCC 는 z++(복소수)를 그냥 통과시키고, -Wpedantic 을 켜야 “ISO C does not support ++ and -- on complex types” 라고 말해 준다 — 확장으로 받아 주는 자리다(13장의 회색지대).

52.7.2 값이 정해지는 때와 메모리가 바뀌는 때#

여기가 이 절의 핵심이다. x++ 에서 「값이 정해지는 사건」과 「메모리가 바뀌는 사건」은 다른 사건이고, 표준은 그 둘의 순서만 정한다.

무엇표준의 문장
후위 (§6.5.3.5p2)결과의 값 계산(value computation)은 피연산자의 저장 값을 갱신하는 부수효과보다 먼저 순서 지어진다
전위 (§6.5.4.1p2 → §6.5.17.1p3)++E 는 (E += 1) 이고, 대입에서 왼쪽 피연산자를 갱신하는 부수효과는 양쪽 피연산자의 값 계산 뒤에 순서 지어진다
모든 수식 (§6.5.1p1)피연산자들의 값 계산은 연산자 결과의 값 계산보다 먼저 순서 지어진다

표 52.8 — 증감에서 값 계산과 저장의 순서를 정하는 표준의 문장

읽는 법이 중요하다. 표준은 상대 순서만 못박았지 시각을 못박지 않았다.

흔한 오해. “후위 증가는 문장이 끝날 때(세미콜론에서) 증가한다”

아주 널리 퍼진 오해다. 표준 어디에도 그런 말이 없다. 정한 것은 「결과값이 먼저 정해지고, 저장은 그 뒤」라는 순서뿐이고, 실제 저장이 언제 일어나는지는 다음 시퀀스 포인트 전까지 아무 때나다 — 다음 문장의 첫 명령 바로 앞일 수도, 같은 수식 한복판일 수도 있다.

이 오해가 위험한 이유는 그것이 「그러니 한 수식에서 두 번 써도 순서가 정해져 있겠지」라는 다음 생각을 부르기 때문이다. 그렇지 않다.

i = i++ + 1;                 /* 계약 밖 — 정의되지 않은 동작 */
a[i] = i++;                  /* 계약 밖 */
printf("%d %d\n", i++, i++); /* 계약 밖 */

§6.5.1p2 가 못박는다 — 같은 스칼라 객체에 대한 순서 지어지지 않은 부수효과가 다른 부수효과나 그 객체의 값 계산과 겹치면 정의되지 않은 동작이다. 전위든 후위든 똑같이 걸린다. GCC 는 -Wsequence-point(-Wall 에 포함)로 “operation on ‘i’ may be undefined” 라고 알려 준다 — 다만 잡아 주지 못하는 모양도 많으니 경고에만 기대지 않는다.

문. 그러면 for (i = 0; i < n; i++) 의 i++ 는 언제 일어나는가?

답. 세 번째 칸은 매 반복의 몸통이 끝난 뒤 평가된다 — 그것은 for 문의 규칙이지 후위 연산자의 규칙이 아니다(33장). 이 자리에서는 i++ 의 결과값을 아무도 쓰지 않으므로, 전위로 바꿔 써도 뜻이 한 글자도 달라지지 않는다.

헷갈리는 자리는 while (*d++ = *s++); 처럼 결과값을 쓰는 곳이다. 여기서는 「지금 가리키는 곳을 쓰고, 포인터는 다음 칸으로」가 한 수식에 담긴다. 두 개의 ++ 가 서로 다른 객체(d 와 s)를 건드리므로 계약 안이다. 같은 객체를 두 번 건드리는 순간 계약 밖이 된다. 거기가 경계선이다.

52.7.3 「전위가 빠르다」는 이야기의 진실#

실제 사례. ++ 와 – 는 PDP-11 때문에 생기지 않았다

「++는 PDP-11 의 자동 증가 주소 지정 방식을 쓰려고 만들어졌다」는 설명이 아직도 돌아다닌다. C 를 만든 데니스 리치가 직접 부정한 이야기다. 「C 언어의 발전」에서 그는 이렇게 적었다 — 사람들이 흔히 그렇게 짐작하지만 역사적으로 불가능하다. B 가 개발될 때는 PDP-11 자체가 없었기 때문이다. 다만 PDP-7 에 「자동 증가」 메모리 셀이 몇 개 있었고 그것이 아마 톰프슨에게 이 연산자를 떠올리게 했겠지만, 그 셀을 구현에 직접 쓰지는 않았고, 더 큰 동기는 아마 ++x 의 번역 결과가 x=x+1 보다 작다는 관찰이었다고 한다. 전위와 후위 양쪽으로 일반화한 것은 톰프슨 자신의 몫이었다.

즉 「번역이 작아진다」는 동기는 진짜였다 — 다만 그것은 ++x 와 x=x+1 의 비교였지, ++x 와 x++ 의 비교가 아니었다. 오늘의 통설은 그 사실이 한 번 굴절된 형태다.

오늘의 컴파일러에서는 어떤가. 이 x86-64 기계의 gcc 14 와 clang 19 로 재어 보면 답이 분명하다.

자리최적화 없이(-O0)보통 빌드(-O2)
for (…; i++) 대 ++i — 값을 쓰지 않음생성된 어셈블리가 한 바이트도 다르지 않다같음
a = (*b)++ 대 a = ++(*b) — 값을 씀gcc 는 명령 수 9 대 11 — 후위 쪽이 오히려 적었다. clang 은 거꾸로 후위가 하나 많았다3 대 3 으로 같다

표 52.9 — 전위와 후위 증감이 낳는 기계어 — 최적화 수준별, x86-64 gcc 14 · clang 19

읽어 낼 것은 둘이다. 첫째, C 의 스칼라에 대해서는 속도 차이가 없다. 값을 쓰지 않으면 컴파일러가 같은 코드를 낸다. 둘째, 값을 쓰는 자리에서 차이가 나더라도 그것은 「후위가 느리다」가 아니라 「두 코드가 다른 계산을 한다」는 뜻이다 — 하나는 옛 값을, 하나는 새 값을 필요로 하니까.

52.7.4 C++ 에서는 두 가지가 달라진다#

플랫폼 노트. 좌변값 여부와 사용자 정의 타입

① 결과가 좌변값인가. C 에서는 전위·후위 둘 다 좌변값이 아니다. C++ 는 전위만 좌변값으로 만들었다. 실측하면 이렇게 갈린다.

코드C (GCC)C++ (G++)
&++xlvalue required as unary '&' operand — 오류통과
++x = 5lvalue required as left operand of assignment — 오류통과
&x++오류오류 — 후위는 C++ 에서도 값(prvalue)이다

표 52.10 — 같은 코드에 대한 C 와 C++ 의 판정

그래서 ++x = 5 같은 코드는 C++ 에서는 컴파일되고 C 에서는 안 된다. 두 언어를 오가는 코드에서 조심할 자리이고, C++ 에서도 읽기 어려워 쓰지 않는 것이 관례다.

② 사용자 정의 타입의 후위는 복사를 만든다. 이것이 C++ 세계에서 「전위를 기본으로」라는 규약이 자리 잡은 진짜 이유다. 후위 연산자는 바꾸기 전의 값을 돌려주어야 하므로, 클래스라면 옛 상태의 사본을 하나 만들어 두었다가 돌려준다.

복사 생성자에 세는 장치를 달아 실측하면 — 반복자 하나를 1000번 전진시켰을 때 전위는 복사 0회, 후위는 복사 1000회였다. -O2 에서도 그대로였다(복사가 눈에 보이는 부수효과를 가지면 최적화기가 지울 수 없다).

C 에는 이 문제가 없다. C 의 ++ 는 스칼라에만 붙고 스칼라의 「사본」은 레지스터 하나이므로, 앞의 표 「전위와 후위 증감이 낳는 기계어」에서 본 대로 차이가 사라진다. 「전위를 쓰라」는 조언을 C 에 그대로 옮기면 근거 없는 규칙이 된다.

문. 그러면 C 에서는 무엇을 쓰라는 말인가?

답. 이 책의 권고는 이렇다.

  • 값을 쓰지 않는 자리에서는 전위를 기본으로. 성능 때문이 아니라 읽는 사람에게 주는 신호 때문이다 — 「이 수식의 값은 쓰지 않는다」가 드러난다. C++ 로 넘어갈 때 그대로 통하는 습관이기도 하다. 다만 for (i = 0; i < n; i++) 는 K&R 이래의 관용구라 그대로 두는 코드베이스도 많다. 팀이 정하고 지키면 된다.
  • 값을 쓰는 자리에서는 필요한 쪽을 쓴다. 옛 값이 필요하면 후위, 새 값이 필요하면 전위다. 여기서는 취향이 아니라 계산이 고른다.
  • 그리고 진짜 규칙 하나 — 한 수식 안에서 같은 객체를 두 번 건드리지 않는다. 전위든 후위든, 이것만 지키면 이 연산자로 사고 날 일이 없다. 34장의 「중요한 부수효과는 문장을 나눠라」가 같은 말이다.

52.8 연산자별 계약#

이제 연산자의 계약을 우선순위 표의 묶음별로 하나씩 본다. 각 표는 다섯 칸이다.

플랫폼 노트. 이 절의 근거

규칙은 ISO/IEC 9899:2024(C23)의 수식 조항(§6.5)과 대조해 적었다. 판마다 달라진 자리는 그 사실을 함께 적었다. 인용이 필요하면 부록 P의 안내대로 발행된 표준을 근거로 삼아야 한다.

52.9 후위 연산자#

연산자피연산자결과회색지대자세히
a[i] 첨자한쪽은 완전한 객체 타입을 가리키는 포인터, 다른 쪽은 정수가리키는 타입의 좌변값. a[i]는 *(a+i)와 같다UB: 배열의 경계 밖 접근(한 칸 지난 자리를 따라가는 것 포함)39장
f(...) 호출함수 또는 함수를 가리키는 포인터함수의 반환 타입. 좌변값이 아니다미지정: 인자들의 평가 순서. UB: 원형과 어긋난 인자22·25·67장
s.m 멤버구조체·공용체 값과 그 멤버 이름멤버의 타입. 왼쪽이 좌변값이면 좌변값UB: 공용체에서 마지막에 쓴 것이 아닌 멤버 읽기(공통 초기 순서는 예외)47·49장
p->m 멤버구조체·공용체를 가리키는 포인터와 멤버 이름멤버의 타입, 좌변값UB: 널이거나 유효하지 않은 포인터47장
x++ 후위 증가수정 가능한 좌변값 — 실수 타입(정수·실수 부동소수점. 복소수는 제외) 또는 포인터 타입바꾸기 전의 값. 좌변값이 아니다UB: 순서 없는 이중 수정, 부호 있는 정수의 넘침33장
x-- 후위 감소같음바꾸기 전의 값같음33장

표 52.11 — 후위 연산자의 계약

52.10 단항 연산자#

연산자피연산자결과회색지대자세히
++x --x수정 가능한 좌변값(실수 타입 또는 포인터형)바꾼 뒤의 값. 좌변값이 아니다 — C++ 는 다르다++E는 (E += 1)과 같다 — 넘침 규칙도 같다33장
&x 주소함수 지시자, []·*의 결과, 또는 비트 필드가 아니고 register로 선언되지 않은 객체의 좌변값그것을 가리키는 포인터제약을 어기면 컴파일 오류다36장
*p 역참조포인터 타입가리키는 타입의 좌변값UB: 널·수명이 끝난 객체·정렬을 어긴 주소·출처 밖36·38·45장
+x산술 타입진급된 값21·30장
-x산술 타입진급된 값UB: 부호 있는 정수의 넘침(-INT_MIN)28장
~x정수 타입진급 뒤의 비트 반전진급된 폭에서 일어난다 — 좁은 타입에 담을 때 주의29장
!x스칼라(산술 또는 포인터)0 또는 1, 타입은 int31장
(타입)x 캐스트스칼라끼리그 타입의 값. 좌변값이 아니다구현 정의: 포인터↔정수 변환. UB: 정렬을 어긴 포인터를 따라가기, 함수 포인터와 객체 포인터의 상호 변환30·38장
sizeof완전한 객체 타입이나 수식. 함수 타입·불완전 타입·비트필드에는 쓸 수 없다size_t 값가변 길이 배열이면 실행 중에 평가된다 — 그 밖에는 피연산자가 평가되지 않는다36·39장
alignof완전한 객체 타입의 이름(수식이 아니다)size_t 값38장

표 52.12 — 단항 연산자의 계약

52.11 산술 연산자#

연산자피연산자결과회색지대자세히
* 곱셈산술 타입통상 산술 변환의 공통 타입UB: 부호 있는 정수의 넘침28·30장
/ 나눗셈산술 타입같음UB: 제수가 0, INT_MIN / -129·54장
% 나머지정수 타입만같음UB: 제수가 0, INT_MIN % -129장
+ 덧셈둘 다 산술, 또는 완전한 객체 타입 포인터와 정수산술이면 공통 타입, 포인터면 포인터UB: 정수 넘침, 배열 범위를 벗어난 포인터 산술28·39장
- 뺄셈둘 다 산술, 포인터와 정수, 또는 같은 배열 안의 두 포인터포인터끼리면 ptrdiff_tUB: 다른 배열의 포인터끼리 빼기, 결과가 ptrdiff_t에 안 담기는 경우28·39장

표 52.13 — 산술 연산자(곱셈·나눗셈·나머지·덧셈·뺄셈)의 계약

정수 나눗셈은 0을 향해 버린다(C99 이후 확정). 그래서 몫이 표현 가능한 한 (a/b)*b + a%b == a가 성립한다.

52.12 시프트 연산자#

연산자피연산자결과회색지대자세히
E1 << E2둘 다 정수 타입진급된 왼쪽 피연산자의 타입UB: E2가 음수이거나 진급된 E1의 폭 이상. UB: E1이 부호 있는 음수이거나, 부호 있는 양수인데 E1 × 2^E2가 결과 타입에 안 담기는 경우6·29장
E1 >> E2둘 다 정수 타입같음UB: E2가 음수이거나 폭 이상. 구현 정의: E1이 부호 있는 음수일 때의 결과6·29장

표 52.14 — 시프트 연산자의 계약

흔한 오해. “C23이 두 보수를 강제했으니 음수 시프트도 이제 정의됐다”

두 보수 표현이 강제된 것은 맞다(C23). 그러나 시프트 조항은 그대로다 — 부호 있는 음수의 좌시프트는 C23에서도 UB이고, 음수의 우시프트는 구현 정의다. 대부분의 컴파일러가 산술 시프트로 동작하지만 그것은 표준의 약속이 아니라 그 구현의 약속이다.

실무의 규칙은 하나다 — 시프트는 부호 없는 타입 위에서 한다. 부호 있는 값을 밀어야 하면 무부호로 옮겨 밀고 되돌린다. 횟수는 언제나 0 <= n < 폭인지 확인한다.

52.13 관계·상등 연산자#

연산자피연산자결과회색지대자세히
< <= > >=둘 다 실수 타입, 또는 호환되는 객체 타입을 가리키는 두 포인터0 또는 1, 타입은 intUB: 같은 배열(또는 같은 객체)에 속하지 않는 두 포인터의 크기 비교31·38장
== !=둘 다 산술, 호환되는 포인터끼리, 한쪽이 void*, 한쪽이 널 포인터 상수·nullptr_t 등0 또는 1, int미지정: 어떤 객체의 “한 칸 지난” 포인터와 바로 뒤 객체의 포인터가 같게 비교될 수 있다31·37장

표 52.15 — 관계·상등 연산자의 계약

두 연산자군은 계약이 다르다. 상등 비교는 서로 다른 객체 사이에도 허용되지만 크기 비교는 같은 배열 안에서만 뜻이 있다. 배열이 아닌 객체는 “길이 1짜리 배열”처럼 취급된다. 실수에서 +0.0과 -0.0은 같다고 비교된다(54장).

52.14 비트·논리 연산자#

연산자피연산자결과회색지대자세히
& ^ |둘 다 정수 타입통상 산술 변환의 공통 타입부호 비트까지 다루게 되므로 무부호 위에서 쓴다29장
&&둘 다 스칼라0 또는 1, int보장: 왼쪽이 0이면 오른쪽은 평가되지 않고, 그 사이에 시퀀스 포인트가 있다31장
||둘 다 스칼라0 또는 1, int보장: 왼쪽이 0이 아니면 오른쪽은 평가되지 않는다31장

표 52.16 — 비트·논리 연산자의 계약

52.15 조건·대입·쉼표#

연산자피연산자결과회색지대자세히
c ? a : bc는 스칼라. a·b는 둘 다 산술이거나, 호환되는 구조체·공용체이거나, 둘 다 void이거나, 호환되는 포인터이거나, 한쪽이 널 포인터 상수두 갈래의 공통 타입 하나보장: 조건 평가 뒤 시퀀스 포인트가 있고, 고르지 않은 쪽은 평가되지 않는다34장
=왼쪽은 수정 가능한 좌변값왼쪽 타입으로 변환된 값. 좌변값이 아니다UB: 겹치는 객체 사이의 대입(정확히 겹치고 호환되는 타입이면 허용), 한 시퀀스 포인트 안 이중 수정24장
복합 대입 op=E1 op= E2E1 = E1 op E2와 같되 E1을 한 번만 평가한다연산 자체의 회색지대(넘침·0 나누기)는 그대로 적용된다34장
, 쉼표아무 수식 둘오른쪽의 타입과 값보장: 왼쪽을 평가하고 그 값을 버린 뒤 시퀀스 포인트. 함수 인자 목록의 쉼표는 연산자가 아니다34장

표 52.17 — 그 밖의 연산자의 계약

52.16 평가 순서와 시퀀스 포인트#

앞에서 두 가지를 보았다. 수식의 평가는 부수효과를 시작할 뿐이고(앞의 「부수효과」 절), 우선순위는 묶는 규칙이지 평가의 시간 순서가 아니다(14·34장). 그러면 부수효과는 언제 끝났다고 믿을 수 있는가. 이 절이 그 답이다 — 이 책이 21장에서 씨앗을 심고 34장와 35장에서 조각씩 다룬 것을 여기서 정식으로 닫는다.

52.16.1 세 가지 관계 — 먼저, 순서 없음, 어느 쪽인지 모름#

C11 이후의 표준은 평가들 사이의 순서를 관계로 적는다.

“ 먼저 순서 지어짐(sequenced before)은 한 스레드가 실행하는 평가들 사이의 비대칭적이고 추이적인 쌍별 관계로, 그 평가들 사이에 부분 순서를 만든다. 두 평가 A와 B에 대해 A가 B보다 먼저 순서 지어지면, A의 실행은 B의 실행보다 앞서야 한다. … A가 B보다 먼저도 뒤도 아니게 순서 지어지면 A와 B는 순서 없음(unsequenced)이다. A가 B보다 먼저이거나 뒤이되 어느 쪽인지 미지정이면 A와 B는 부정 순서(indeterminately sequenced)다.4 ”

관계뜻대표적인 자리
먼저 순서 지어짐A가 다 끝난 뒤 B가 시작한다&& 의 왼쪽 → 오른쪽, 한 문장 → 다음 문장
부정 순서하나가 끝난 뒤 다른 하나가 시작하지만, 어느 쪽이 먼저인지 모른다f() + g() 의 두 함수 호출
순서 없음아무 순서도 없다. 두 평가가 뒤섞여 일어날 수도 있다a + b 의 두 피연산자, x = y 의 양쪽 값 계산

표 52.18 — 두 평가 사이에 성립할 수 있는 세 관계

핵심은 「순서 없음」이 셋 가운데 가장 약한 관계라는 데 있다. 「둘 중 하나가 먼저인데 어느 것인지 모른다」가 아니다 — 기계가 A를 반쯤 하고 B를 하고 A의 나머지를 해도 규격에 맞는다. 함수 호출의 몸통만은 예외적으로 쪼개지지 않는다. 부르는 쪽의 다른 평가와 부정 순서로 놓이므로(§6.5.3.3p8), 두 함수의 몸통이 뒤섞이지는 않는다.

52.16.2 시퀀스 포인트 — 「여기까지는 다 끝났다」#

시퀀스 포인트(sequence point)는 위의 관계를 한꺼번에 세우는 지점이다. 표준의 말로, A와 B의 평가 사이에 시퀀스 포인트가 있으면 A에 딸린 모든 값 계산과 부수효과가 B에 딸린 모든 값 계산과 부수효과보다 먼저 순서 지어진다(§5.1.2.4p3). 즉 그 지점을 지나면 앞의 부수효과는 전부 끝났다.

표준이 꼽는 시퀀스 포인트는 이것이 전부다(부속서 C).

자리그래서
완전 수식과 다음 완전 수식 사이가장 중요한 줄. 문장 x = 1; 의 끝, if·switch·while·do 의 조건, for 의 세 칸 각각, return 의 수식, 복합 리터럴 밖의 초기화식 각각이 완전 수식이다(§6.8.1p4)
&&·|| 의 첫째와 둘째 피연산자 사이왼쪽의 부수효과가 끝난 뒤 오른쪽을 본다. p && p->n 이 안전한 이유
쉼표 연산자의 왼쪽과 오른쪽 사이인자를 가르는 쉼표는 아니다 — 수식의 정의 절에서 본 구분자/연산자 구별이 여기서 차이가 된다
?: 의 조건과 고른 가지 사이고르지 않은 가지는 평가되지 않는다
함수 호출에서, 함수 지시자와 인자들의 평가가 끝난 뒤 실제 호출 전몸통에 들어갈 때 인자들의 부수효과는 끝나 있다. 다만 인자들끼리의 순서는 정해지지 않는다
라이브러리 함수가 돌아오기 직전표준 함수 안의 부수효과는 돌아오는 시점에 끝나 있다
서식 입출력의 변환 지정자 하나를 처리한 직후, qsort·bsearch 의 비교 함수 호출 앞뒤printf·scanf 와 정렬·탐색 함수 안쪽의 세부 규칙

표 52.19 — 시퀀스 포인트의 전체 목록 (C23 부속서 C)

시퀀스 포인트가 아닌 순서도 몇 가지 있다. 앞에서 읽은 「피연산자의 값 계산 → 연산자 결과의 값 계산」(§6.5.1p1), 대입에서 「양쪽의 값 계산 → 저장」(§6.5.17.1p3, 35장), 후위 증감에서 「결과의 값 계산 → 저장」(§6.5.3.5p2)이 그렇다. 이것들은 특정한 두 사건의 순서만 정할 뿐, 그 사이에 다른 부수효과가 끝났다고 보장하지 않는다.

52.16.3 계약 밖이 되는 규칙 — 한 객체에 순서 없는 두 접근#

이제 규칙의 핵심 문장이다.

“ 스칼라 객체에 대한 부수효과가, 같은 스칼라 객체에 대한 다른 부수효과나 같은 스칼라 객체의 값을 쓰는 값 계산과 순서 없음이면, 동작은 정의되지 않는다.5 ”

계약 밖이 되는 꼴은 둘이다. 같은 객체를 순서 없이 두 번 바꾸거나, 바꾸면서 순서 없이 그 값을 읽는 것이다. 앞의 관계표로 판정하면 이렇다.

수식판정
f() + g()미지정 — 두 호출은 부정 순서. 어느 쪽이 먼저 불릴지 정해지지 않는다
h(f(), g())미지정 — 인자 평가 순서
i = i++UB — i 를 바꾸는 두 부수효과(대입, 증가)가 순서 없음
i = i++ + 1UB — 같은 이유
a[i] = i++UB — i++ 의 부수효과와 첨자 i 의 값 계산이 순서 없음
i++ + i++UB
f(i++, i++)UB — 인자를 가르는 쉼표는 연산자가 아니라 시퀀스 포인트가 없다
printf("%d %d", i, i++)UB — 같은 이유. 읽기와 바꾸기가 순서 없음
i++, i++정상 — 쉼표 연산자 사이에는 시퀀스 포인트가 있다
(i++) && (i++)정상 — && 사이에 시퀀스 포인트가 있다
i = i + 1정상 — 읽기(값 계산)가 저장보다 먼저 순서 지어진다(§6.5.17.1p3)
x = y = 0정상 — 바뀌는 객체가 서로 다르다

표 52.20 — 자주 보는 수식의 판정

미지정과 UB의 차이가 실물로 보인다. 이 책이 같은 코드를 두 컴파일러로 돌렸다.

static int f(void) { puts("f"); return 1; }
static int g(void) { puts("g"); return 2; }
printf("args %d %d\n", f(), g());     /* 미지정 */
printf("%d %d\n", i, i++);            /* UB, i 는 3 */
수식gcc 14clang 19
printf(…, f(), g()) 가 찍은 순서g 다음 f — 오른쪽 인자부터f 다음 g — 왼쪽 인자부터
printf("%d %d", i, i++) 의 출력4 33 3

표 52.21 — 두 컴파일러가 실제로 고른 순서 (x86-64, -O2)

첫 줄은 미지정이라 둘 다 옳다. 어느 순서로 부르든 각 함수는 온전히 한 번씩 불렸다. 둘째 줄은 UB라 어느 쪽도 「옳은 답」이 아니다 — 같은 소스가 컴파일러마다 다른 수를 찍었고, 다른 판이나 다른 최적화 수준에서는 또 다를 수 있다.

문. 왜 UB까지 가는가? 「둘 중 하나의 순서로 계산된다」 정도의 미지정이면 충분하지 않은가?

답. 미지정으로 정하면 컴파일러는 두 순서 가운데 하나와 똑같은 결과를 보장해야 한다. 그런데 순서 없음의 진짜 쓸모는 그보다 크다. 컴파일러가 두 피연산자의 계산을 뒤섞어, 이를테면 한쪽의 읽기와 다른 쪽의 쓰기를 명령 수준에서 엇갈리게 배치해 파이프라인을 채우거나 레지스터를 아낄 수 있다(13장). 같은 객체에 순서 없는 두 쓰기가 있으면 그렇게 뒤섞인 결과는 어느 한 순서와도 같지 않을 수 있다 — 반쯤 쓰인 값이 남을 수도 있다. 표준은 그 경우를 정의하기보다, 그런 수식을 쓰지 말라고 정하는 쪽을 택했다.

C++는 C++17에서 대입의 오른쪽을 왼쪽보다 먼저 평가하도록 정하는 등 순서를 일부 굳혀, 「자주 보는 수식의 판정」 표의 UB 가운데 몇몇을 정의된 동작으로 바꾸었다. C는 그대로다. 두 언어를 오가는 코드라면 더더욱 이런 수식을 쓰지 않는다.

도구는 흔한 꼴을 잡는다. 위 코드에 두 컴파일러가 한 말이다.

gcc:   warning: operation on ‘i’ may be undefined [-Wsequence-point]
clang: warning: unsequenced modification and access to 'i' [-Wunsequenced]
clang: warning: unsequenced modification and access to 'i' [-Wunsequenced]

다만 포인터 둘이 같은 객체를 가리키는 경우(*p = (*q)++ 에서 p == q)처럼 같은 객체인지가 실행 중에야 정해지는 꼴은 어느 쪽도 잡지 못한다. 그래서 실전 규칙은 34장의 그것 그대로다 — 한 완전 수식 안에서 한 객체는 한 번만 바꾸고, 바꾸는 객체를 같은 수식의 다른 자리에서 읽지 않는다. 순서가 중요하면 문장을 나눈다. 문장의 끝이 가장 확실한 시퀀스 포인트다.

52.17 회색지대 총람#

56장의 세 낱말로 연산자 관련 항목만 모았다. 전체 목록은 표준의 부속서 J에 있다.

52.17.1 정의되지 않은 동작(UB)#

자리조건
/ %제수가 0. INT_MIN / -1, INT_MIN % -1
+ - * ++ --부호 있는 정수의 넘침
<<횟수가 음수이거나 폭 이상. 부호 있는 음수의 좌시프트. 부호 있는 양수라도 결과가 안 담기는 경우
>>횟수가 음수이거나 폭 이상
* 역참조널·수명이 끝난 객체·정렬 위반·출처 밖 포인터
[]배열 경계 밖 접근
+ - 포인터 산술배열의 범위(한 칸 지난 자리까지)를 벗어난 결과
- 포인터끼리서로 다른 배열의 포인터
< <= > >=같은 배열·객체에 속하지 않는 포인터끼리의 크기 비교
. -> 공용체마지막에 쓴 멤버가 아닌 멤버 읽기(공통 초기 순서는 예외)
수식 일반한 시퀀스 포인트 안에서 같은 객체를 두 번 바꾸거나, 바꾸면서 그 값을 다른 목적으로 읽는 것

표 52.22 — 연산자에서 정의되지 않은 동작이 되는 자리

52.17.2 미지정(unspecified)#

자리무엇이 정해지지 않았나
부분 수식f() + g()의 평가 순서
함수 인자인자들끼리의 평가 순서
== !=한 객체의 “한 칸 지난” 포인터가 이웃 객체의 포인터와 같게 비교될 수 있는지
채움 바이트구조체의 채움(padding) 값 — 비교에 memcmp를 쓰면 안 되는 이유(48장)

표 52.23 — 미지정으로 남은 자리

52.17.3 구현 정의(implementation-defined)#

자리구현이 정하는 것
>>부호 있는 음수를 밀 때의 결과(대개 산술 시프트)
정수 변환부호 있는 타입에 담기지 않는 값을 변환한 결과(C23에서도 그렇다)
포인터 ↔ 정수변환 결과와 왕복 가능성(uintptr_t가 있을 때의 왕복만 표준이 보장한다)
char부호 있음/없음 — >>와 비교에서 갈린다(8장)
비트필드배치 순서와 채움

표 52.24 — 구현이 정하는 자리

52.18 연산자가 아닌 것들#

문법에서 같은 글자를 쓰지만 연산자가 아닌 것들이 있다.

모양정체자세히
f(a, b)의 쉼표함수 호출 문법의 구분자 — 시퀀스 포인트가 없다34장
int a, b;의 쉼표선언 문법의 구분자24장
{1, 2}의 쉼표초기화 목록의 구분자39·47장
(타입){...}복합 리터럴 — 캐스트가 아니라 객체를 만드는 문법48장
sizeof(int)의 괄호타입 이름을 감싸는 문법 — 함수 호출이 아니다36장
# ##전처리기 연산자 — 번역의 다른 단계에서 동작한다65장
{.x = 1}의 점지정 초기화 문법 — 멤버 접근이 아니다47장
int *p;의 별표선언자 문법 — 역참조가 아니다69장

표 52.25 — 연산자처럼 보이지만 연산자가 아닌 것들

복습 정리

기억할 것요점
수식이 가진 것값·타입·값 범주·부수효과 넷
우선순위묶는 규칙일 뿐, 계산 순서가 아니다
결합같은 세기끼리 어느 쪽부터 묶는가
한 수식 안에서 순서가 보장되는 곳&&·||·?:·쉼표 연산자·함수 호출 다섯 자리뿐
회색지대UB·미지정·구현 정의를 낱말로 구별해 읽는다
실무 수칙괄호를 쓰고, 한 수식에서 같은 객체를 두 번 건드리지 않는다

표 52.26 — 수식과 연산자 — 기억할 것

연산자를 계약으로 읽는 눈이 갖춰졌다. 다음 장은 그 연산자들 가운데 비트 쪽을 실제로 부리는 법이다(53장) — 관용구와 함정, 그리고 C23 이 그 관용구들에 붙인 이름. 그다음부터는 계약이 가장 미묘해지는 자리들로 들어간다 — 근사의 수학(54장), 실패를 다루는 법(55장), 그리고 계약을 어겼을 때 무슨 일이 일어나는가(56장)다.

주

  1. ISO/IEC 9899 (C23) 6.5.1, 문단 1. 원문은 “An expression is a sequence of operators and operands that specifies computation of a value, or that designates an object or a function, or that generates side effects, or that performs a combination thereof.” ↩
  2. ISO/IEC 9899 (C23) 6.4.6, 문단 2. ↩
  3. ISO/IEC 9899 (C23) 5.1.2.4, 문단 2. ↩
  4. ISO/IEC 9899 (C23) 5.1.2.4, 문단 3. ↩
  5. ISO/IEC 9899 (C23) 6.5.1, 문단 2. 이어지는 문장은 「부분 수식들의 허용되는 순서가 여럿이면, 그중 어느 하나에서라도 그런 순서 없는 부수효과가 일어날 때 동작은 정의되지 않는다」이다. ↩