Proven C Book←↑→

50 수식과 연산자

먼저 알아야 할 것

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

돌아보기

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

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

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

이 장의 필요성과 맥락

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

이 장이 끝나면

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

이 장에서 답할 질문

  1. “좌변값”이라는 이름은 왼쪽에 있다는 뜻인가?
  2. 그러면 for (i = 0; i < n; i++) 의 i++ 는 언제 일어나는가?
  3. 그러면 C 에서는 무엇을 쓰라는 말인가?

50.1 수식이 가진 네 가지#

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

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

표 50.1 — 수식이 가진 것

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

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

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

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

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

50.2 우선순위와 결합#

위쪽이 더 강하게 묶는다. 결합 방향은 같은 세기의 연산자가 나란히 있을 때 어느 쪽부터 묶느냐다 — 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”이 되려면
쉼표,왼→오왼쪽을 먼저 하고 버린다

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

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

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

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

50.3 자주 미끄러지는 자리들#

쓴 것실제로 묶이는 방식의도한 것이라면
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)

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

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

examples/ch49/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장).

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

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

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

50.4.1 표준이 정한 계약#

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

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

차이는 오직 「수식이 내놓는 값」 하나뿐이다. 객체가 바뀌는 결과는 완전히 같다. 그래서 값을 쓰지 않는 자리 — 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장의 회색지대).

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

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

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

표 50.5 — 시퀀스 포인트를 정하는 표준의 문장

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

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

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

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

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)를 건드리므로 계약 안이다. 같은 객체를 두 번 건드리는 순간 계약 밖이 된다는 것이 경계선이다.

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

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

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

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

오늘의 컴파일러에서는 어떤가. 재어 보면 답이 분명하다.

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

표 50.6 — 최적화 수준에 따라 갈리는 결과

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

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

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

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

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

표 50.7 — 같은 코드에 대한 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장의 「중요한 부수효과는 문장을 나눠라」가 같은 말이다.

50.5 연산자별 계약#

이제 갈래별로 하나씩 본다. 각 표는 다섯 칸이다.

플랫폼 노트. 이 절의 근거

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

50.6 후위 연산자#

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

표 50.8 — 산술 연산자의 계약

50.7 단항 연산자#

연산자피연산자결과회색지대자세히
++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장

표 50.9 — 비교 연산자의 계약

50.8 산술 연산자#

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

표 50.10 — 논리 연산자의 계약

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

50.9 시프트 연산자#

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

표 50.11 — 비트 연산자의 계약

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

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

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

50.10 관계·상등 연산자#

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

표 50.12 — 대입 연산자의 계약

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

50.11 비트·논리 연산자#

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

표 50.13 — 멤버·첨자 연산자의 계약

50.12 조건·대입·쉼표#

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

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

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

우선순위는 묶는 규칙이지 평가의 시간 순서가 아니다(14·34장). 순서가 보장되는 자리는 다음 다섯뿐이다.

그 밖에는 아무 순서도 보장되지 않는다.

수식판정
f() + g()미지정 — 어느 쪽이 먼저 불릴지 정해지지 않는다
h(f(), g())미지정 — 인자 평가 순서
i = i++UB — 한 시퀀스 포인트 안에서 i를 두 번 바꾼다
a[i] = i++UB — 같은 이유
i++ + i++UB
f(i++, i++)UB — 인자 사이에는 시퀀스 포인트가 없다
i++, i++정상 — 쉼표 연산자 사이에는 시퀀스 포인트가 있다
(i++) && (i++)정상 — && 사이에 시퀀스 포인트가 있다

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

C11 이후 표준은 이 규칙을 “시퀀스 포인트” 대신 sequenced-before라는 관계로 다시 적었지만, 실무의 결론은 같다 — 한 수식 안에서 같은 객체를 두 번 건드리지 않는다. gcc의 -Wsequence-point가 흔한 경우를 잡아 주지만 전부는 아니다.

50.14 회색지대 총람#

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

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

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

표 50.16 — 상수 수식이 요구되는 자리

50.14.2 미지정(unspecified)#

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

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

50.14.3 구현 정의(implementation-defined)#

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

표 50.18 — 구현이 정하는 자리

50.15 연산자가 아닌 것들#

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

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

표 50.19 — 수식처럼 보이는 것들

복습 정리

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

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

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