Proven C BookEnglish GitHub

49 수식과 연산자

먼저 알아야 할 것

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

돌아보기

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

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

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

이 장의 필요성과 맥락

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

이 장이 끝나면

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

이 장에서 답할 질문

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

49.1 수식이 가진 네 가지

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

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

표 49.1

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

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

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

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

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

49.2 우선순위와 결합

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

표 49.2

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

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

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

49.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)

표 49.3

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

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

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

49.4.1 표준이 정한 계약

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

표 49.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” 라고 말해 준다 — 확장으로 받아 주는 자리다(12장의 회색지대).

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

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

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

표 49.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 문의 규칙이지 후위 연산자의 규칙이 아니다(32장). 이 자리에서는 i++ 의 결과값을 아무도 쓰지 않으므로, 전위로 바꿔 써도 뜻이 한 글자도 달라지지 않는다.

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

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

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

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

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

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

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

표 49.6

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

49.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)이다

표 49.7

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

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

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

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

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

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

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

49.5 연산자별 계약

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

플랫폼 노트. 이 절의 근거

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

49.6 후위 연산자

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

표 49.8

49.7 단항 연산자

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

표 49.9

49.8 산술 연산자

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

표 49.10

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

49.9 시프트 연산자

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

표 49.11

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

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

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

49.10 관계·상등 연산자

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

표 49.12

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

49.11 비트·논리 연산자

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

표 49.13

49.12 조건·대입·쉼표

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

표 49.14

49.13 평가 순서와 시퀀스 포인트

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

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

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

표 49.15

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

49.14 회색지대 총람

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

49.14.1 정의되지 않은 동작(UB)

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

표 49.16

49.14.2 미지정(unspecified)

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

표 49.17

49.14.3 구현 정의(implementation-defined)

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

표 49.18

49.15 연산자가 아닌 것들

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

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

표 49.19

복습 정리

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

표 49.20

연산자를 계약으로 읽는 눈이 갖춰졌다. 다음 장부터는 그 계약이 가장 미묘해지는 자리들로 들어간다 — 근사의 수학(50장), 실패를 다루는 법(51장), 그리고 계약을 어겼을 때 무슨 일이 일어나는가(52장)다.