50 수식과 연산자
먼저 알아야 할 것
돌아보기
21장에서 “표를 외우지 말고 괄호를 쓰라”고 했고, 그 뒤로 연산자를 필요한 자리마다 조금씩 꺼내 썼다 — 29장의 시프트, 31장의 비교, 35장의 대입, 39장의 포인터 산술처럼. 그러면 지금 이 장에서 무엇을 새로 배우는가?
답. 같은 것을 다른 눈으로 본다. 지금까지는 “이 자리에서 필요한 만큼”이었다면, 이제는 연산자 하나하나를 계약으로 읽는다 — 무엇을 받고(피연산자 제약), 무엇을 돌려주며(결과의 타입과 값 범주), 어디서부터 계약 밖인가(회색지대).
이 눈이 필요한 이유는 실무에 있다. 남의 코드를 읽다 막히는 자리, 컴파일러가 왜 그렇게 최적화했는지 알아야 하는 자리, 그리고 “왜 여기서만 이상하게 도는가”를 추적하는 자리 — 전부 연산자의 계약을 봐야 풀린다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- “좌변값”이라는 이름은 왼쪽에 있다는 뜻인가?
- 그러면
for (i = 0; i < n; i++)의i++는 언제 일어나는가? - 그러면 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 == c | a & (b == c) | (a & b) == c |
a << 1 + 2 | a << (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 = 0 | a = (b = 0) | (그대로다 — 오른쪽 결합) |
x < y < z | (x < y) < z | x < y && y < z |
!x & y | (!x) & y | !(x & y) |
sizeof a + 1 | (sizeof a) + 1 | sizeof(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++) |
|---|---|---|
&++x | lvalue required as unary '&' operand — 오류 | 통과 |
++x = 5 | lvalue 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 연산자별 계약#
이제 갈래별로 하나씩 본다. 각 표는 다섯 칸이다.
- 피연산자 — 표준이 요구하는 것. 어기면 컴파일러가 진단해야 한다(제약 위반).
- 결과 — 값의 타입, 그리고 좌변값인지.
- 회색지대 — 54장의 세 낱말로 구분한다. UB(undefined behaviour)는 정의되지 않은 동작, 미지정은 몇 갈래 중 하나이되 어느 쪽인지 정해지지 않은 것, 구현 정의는 구현이 정해 문서로 밝히는 것.
- 자세히 — 그 연산자를 서사로 다룬 장.
플랫폼 노트. 이 절의 근거
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, 타입은 int | 31장 | |
(타입)x 캐스트 | 스칼라끼리 | 그 타입의 값. 좌변값이 아니다 | 구현 정의: 포인터↔정수 변환. UB: 정렬을 어긴 포인터를 따라가기, 함수 포인터와 객체 포인터의 상호 변환 | 30·38장 |
sizeof | 완전한 객체 타입이나 수식. 함수 타입·불완전 타입·비트필드에는 쓸 수 없다 | size_t 값 | 가변 길이 배열이면 실행 중에 평가된다 — 그 밖에는 피연산자가 평가되지 않는다 | 36·39장 |
alignof | 완전한 객체 타입의 이름(수식이 아니다) | size_t 값 | 38장 |
표 50.9 — 비교 연산자의 계약
50.8 산술 연산자#
| 연산자 | 피연산자 | 결과 | 회색지대 | 자세히 |
|---|---|---|---|---|
* 곱셈 | 산술 타입 | 통상 산술 변환의 공통 타입 | UB: 부호 있는 정수의 넘침 | 28·30장 |
/ 나눗셈 | 산술 타입 | 같음 | UB: 제수가 0, INT_MIN / -1 | 29·52장 |
% 나머지 | 정수 타입만 | 같음 | UB: 제수가 0, INT_MIN % -1 | 29장 |
+ 덧셈 | 둘 다 산술, 또는 완전한 객체 타입 포인터와 정수 | 산술이면 공통 타입, 포인터면 포인터 | UB: 정수 넘침, 배열 범위를 벗어난 포인터 산술 | 28·39장 |
- 뺄셈 | 둘 다 산술, 포인터와 정수, 또는 같은 배열 안의 두 포인터 | 포인터끼리면 ptrdiff_t | UB: 다른 배열의 포인터끼리 빼기, 결과가 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, 타입은 int | UB: 같은 배열(또는 같은 객체)에 속하지 않는 두 포인터의 크기 비교 | 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 : b | c는 스칼라. a·b는 둘 다 산술이거나, 호환되는 구조체·공용체이거나, 둘 다 void이거나, 호환되는 포인터이거나, 한쪽이 널 포인터 상수 | 두 갈래의 공통 타입 하나 | 보장: 조건 평가 뒤 시퀀스 포인트가 있고, 고르지 않은 쪽은 평가되지 않는다 | 34장 |
= | 왼쪽은 수정 가능한 좌변값 | 왼쪽 타입으로 변환된 값. 좌변값이 아니다 | UB: 겹치는 객체 사이의 대입(정확히 겹치고 호환되는 타입이면 허용), 한 시퀀스 포인트 안 이중 수정 | 24장 |
복합 대입 op= | E1 op= E2 | E1 = 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장)다.