34 대입과 부수효과
먼저 알아야 할 것
돌아보기
33장에서 “부수효과 있는 호출은 문장을 나눠 적으라”고 했다. 그런데 대입 자체가 부수효과다 — x = 1;은 x라는 객체를 바꾸는 일이니까. 그러면 왜 대입은 마음 놓고 써 왔는가?
답. 문장 하나에 부수효과가 하나였기 때문이다. x = 1;에는 바꾸는 객체가 하나뿐이고, 문장의 끝에 시퀀스 포인트가 있으니 다음 문장이 시작될 때는 이미 끝나 있다. 위험은 한 수식 안에 부수효과가 둘 이상 들어갈 때 시작된다 — a[i] = i++처럼. 이 장은 그 경계를 정확히 긋는다.
이 장의 필요성과 맥락
= 는 23장에서 이미 썼기 때문이다. 그러나 가장 자주 쓰면서 가장 얕게 이해되는 것이 바로 대입이고, 그 깊은 규칙(수식이라는 것, 좌변도 평가된다는 것)은 33장의 평가 순서를 알아야 설명된다. 그래서 처음이 아니라 마지막 자리다.이 장이 끝나면
이 장에서 답할 질문
- 그러면
x = x + 1은 왜 안전한가? 여기서도 x를 읽고 x를 바꾸지 않는가? x += 1과x++와++x는 결국 같은 일 아닌가?
34.1 대입은 문장이 아니라 수식이다
x = 1은 명령처럼 보이지만 수식이다 — 값을 낳는다. 그 값은 “왼쪽 타입으로 변환된, 대입된 값”이고, 그래서 다른 수식 안에 넣을 수 있다.
examples/ch34/assign.c
/* 대입이 하는 두 가지 — 값을 내놓는 일과 객체를 바꾸는 일. */
#include <stdio.h>
static int calls;
static int where(void) /* 좌변의 자리를 정하는 계산도 '평가'된다 */
{
calls++;
printf(" where() called - it picks the slot on the left\n");
return 1;
}
static int what(void)
{
calls++;
printf(" what() called - it makes the value on the right\n");
return 42;
}
int main(void)
{
int a[3] = {0, 0, 0};
/* ── ① 대입은 수식이다 — 값을 낳는다 ────────────────────── */
int x, y;
x = (y = 7) + 1; /* y = 7 이라는 수식의 값은 7 */
printf("x = (y = 7) + 1 -> x=%d y=%d\n", x, y);
int p, q, r;
p = q = r = 5; /* 오른쪽 결합 — r 부터 채워진다 */
printf("p = q = r = 5 -> p=%d q=%d r=%d\n", p, q, r);
/* ── ② 좌변도 평가된다 ──────────────────────────────────── */
puts("\nrunning a[where()] = what(); :");
calls = 0;
a[where()] = what();
printf(" both functions ran (%d calls), a[1]=%d\n", calls, a[1]);
puts(" which one runs first is unspecified by the standard.");
/* ── ③ 복합 대입은 왼쪽을 한 번만 평가한다 ──────────────── */
puts("\nrunning a[where()] += 1; :");
calls = 0;
a[where()] += 1;
printf(" where() call count: %d (spelled out as a[where()] = a[where()] + 1 it would be 2)\n",
calls);
printf(" a[1] = %d\n", a[1]);
/* ── ④ 대입은 왼쪽 타입으로 '변환된 값'을 내놓는다 ──────── */
char c;
int wide = 321;
int back = (c = (char)wide); /* 좁혔다가 다시 넓히면 원래 값이 아니다 */
printf("\nchar c = (char)321 -> c=%d, value of (c = ...) = %d\n", c, back);
double d;
int truncated = (int)(d = 3.9); /* 실수 → 정수는 0 쪽으로 버린다 */
printf("d = 3.9 -> d=%.1f, (int)d = %d\n", d, truncated);
/* ── ⑤ 부수효과는 문장을 나눈다 ─────────────────────────── */
int i = 0;
a[i] = 10;
i++; /* 한 수식에 섞지 않는다 */
a[i] = 20;
printf("\nthe safe form: a[0]=%d a[1]=%d i=%d\n", a[0], a[1], i);
return 0;
}
실행 결과
x = (y = 7) + 1 -> x=8 y=7
p = q = r = 5 -> p=5 q=5 r=5
running a[where()] = what(); :
where() called - it picks the slot on the left
what() called - it makes the value on the right
both functions ran (2 calls), a[1]=42
which one runs first is unspecified by the standard.
running a[where()] += 1; :
where() called - it picks the slot on the left
where() call count: 1 (spelled out as a[where()] = a[where()] + 1 it would be 2)
a[1] = 43
char c = (char)321 -> c=65, value of (c = ...) = 65
d = 3.9 -> d=3.9, (int)d = 3
the safe form: a[0]=10 a[1]=20 i=1
출력의 첫 두 줄이 그 확인이다. x = (y = 7) + 1에서 (y = 7)이라는 수식의 값은 7이고, 거기에 1을 더해 x는 8이 된다. p = q = r = 5가 셋 다 5로 만드는 것도 같은 원리다 — 대입은 오른쪽 결합이라 p = (q = (r = 5))로 묶이고, 안쪽 대입의 값이 바깥으로 전달된다.
한 가지는 못박아 두자. 대입의 결과는 좌변값이 아니다. (a = b) = c는 컴파일되지 않는다. C++와 다른 점이고, “대입은 값을 낳지만 자리를 낳지는 않는다”고 기억하면 된다.
흔한 오해. “if (x = 0)은 x가 0인지 검사한다”
이것이 C에서 가장 오래된 오타 사고다. x = 0은 수식이고 그 값은 0이므로, if는 언제나 거짓을 본다. x는 검사되기는커녕 0으로 바뀐다. 비교는 ==다.
방어는 셋이다. 첫째, 컴파일러 경고를 켠다 — gcc의 -Wparentheses가 조건 자리의 대입을 짚어 준다(의도한 것이면 if ((x = f()))처럼 괄호를 한 겹 더 씌워 “일부러 그랬다”고 알린다). 둘째, 상수를 왼쪽에 두는 옛 관행(if (0 == x), 이른바 요다 조건)이 있으나 읽기가 나빠 이 책은 권하지 않는다. 셋째, 가장 확실한 것은 조건 자리에서 대입하지 않는 습관이다.
34.2 대입이 하는 일은 둘이다
대입 E1 = E2가 하는 일을 갈라 보면 이렇다.
- 값을 계산한다 —
E2를 평가하고, 그 값을E1의 타입으로 변환한다. - 객체를 바꾼다 — 그 값을
E1이 가리키는 자리에 써넣는다. 이것이 부수효과다.
둘을 갈라 두는 것이 이 장의 열쇠다. 수식의 값은 곧바로 쓸 수 있지만, 부수효과가 언제 실제로 일어나는지는 다른 문제이기 때문이다. 표준은 부수효과가 다음 시퀀스 포인트까지 끝나기만 하면 된다고 말한다 — 그 안에서 언제 일어나는지는 정하지 않는다.
문. 그러면 x = x + 1은 왜 안전한가? 여기서도 x를 읽고 x를 바꾸지 않는가?
답. 읽는 것과 바꾸는 것이 하나씩이기 때문이다. 규칙을 정확히 적으면 이렇다 — 한 시퀀스 포인트 사이에 같은 객체를 두 번 이상 바꾸거나, 바꾸면서 그 값을 “새 값을 정하는 것 말고 다른 목적으로” 읽으면 계약 밖이다.
x = x + 1에서 x를 읽는 것은 새 값을 정하기 위해서이므로 허용된다. 반면 a[i] = i++에서 왼쪽의 i는 새 i 값을 정하려고 읽는 것이 아니라 어디에 쓸지를 정하려고 읽는 것이다 — 그래서 계약 밖이다.
34.3 좌변도 평가된다
초보자가 가장 자주 놓치는 사실이다. =의 왼쪽은 “값”이 아니라 어디에 쓸지를 정하는 계산이고, 그 계산도 실행된다.
예제의 둘째 묶음이 그 확인이다. a[where()] = what();을 실행하면 where()와 what()이 둘 다 불린다. 좌변은 “그 자리를 어떻게 찾는가”를 계산하고, 우변은 “무엇을 쓸 것인가”를 계산한다.
그러면 어느 쪽이 먼저 계산되는가. 표준은 정하지 않는다(미지정). 컴파일러는 우변을 먼저 계산해도 되고 좌변을 먼저 계산해도 된다. 두 계산에 부수효과가 있으면 그 순서에 따라 결과가 달라질 수 있고, 그것이 다음 절의 함정이다.
플랫폼 노트. 컴파일러마다 실제로 다르다
같은 코드를 gcc와 clang이 다른 순서로 계산하는 일은 실제로 있다. 최적화 수준을 바꾸면 한 컴파일러 안에서도 달라진다. “내 컴퓨터에서는 이렇게 나온다”가 근거가 되지 못하는 대표적 자리다 — 미지정이란 두 갈래 다 옳다는 뜻이지, 하나를 고른 뒤 계속 그러겠다는 약속이 아니다.34.4 복합 대입 — 왼쪽을 한 번만 평가한다
E1 op= E2는 E1 = E1 op E2와 같지 않다. 표준은 “E1을 한 번만 평가한다”는 것만 다르다고 말하는데, 이 한 줄의 차이가 실무에서 크다.
예제의 셋째 묶음을 보라. a[where()] += 1;에서 where()는 한 번만 불린다. 풀어 쓴 a[where()] = a[where()] + 1;이었다면 두 번 불려, 두 개의 다른 칸을 읽고 쓰는 사고가 났을 것이다.
| 형태 | 왼쪽 평가 횟수 | 메모 |
|---|---|---|
a[f()] = a[f()] + 1 | 2회 | f에 부수효과가 있으면 다른 칸을 읽고 쓴다 |
a[f()] += 1 | 1회 | 권장 형태 |
*p++ += 1 | 1회 | 그래도 읽기 어렵다 — 나누어 쓰는 편이 낫다 |
표 34.1
복합 대입의 종류는 열 가지다 — += -= *= /= %= <<= >>= &= ^= |=. 각 연산의 회색지대는 그대로 따라온다는 점을 잊지 말자. x /= 0은 여전히 계약 밖이고, x <<= 40도 그렇다(28장).
문. x += 1과 x++와 ++x는 결국 같은 일 아닌가?
답. 객체를 바꾸는 효과는 같다. 다른 것은 수식의 값이다 — ++x와 x += 1은 바꾼 뒤의 값을 내놓고, x++는 바꾸기 전의 값을 내놓는다. 값을 쓰지 않는 문장(x++;)에서는 셋이 완전히 같고, 그래서 실무에서는 취향의 문제다.
한 가지 실용적인 차이는 있다. x += n은 임의의 값을 더할 수 있고, 포인터에 쓰면 원소 단위로 움직인다(38장). 그리고 C++에서 온 관행 때문에 “값을 쓰지 않을 때는 ++x를 쓴다”는 습관이 퍼져 있는데, C에서는 성능 차이가 없다.
34.5 어디서부터 계약 밖인가
이제 유명한 수식들을 판정할 수 있다. 아래 코드는 실행 결과를 싣지 않는다 — 계약 밖 수식의 결과를 인쇄하면 “이 컴파일러에서는 이렇게 나온다”는 잘못된 지식을 남기기 때문이다(52장의 원칙).
int i = 0, a[4] = {0};
i = i++; /* 계약 밖 — i 를 두 번 바꾼다 */
a[i] = i++; /* 계약 밖 — 바꾸면서, 자리를 정하려고 읽는다 */
i = ++i + i++; /* 계약 밖 — 두 번 바꾼다 */
a[i++] = i; /* 계약 밖 — 같은 이유 */
printf("%d %d", i++, i++); /* 계약 밖 — 인자 사이에 시퀀스 포인트가 없다 */반면 다음은 정상이다.
i = i + 1; /* 읽기는 새 값을 정하기 위한 것 */
a[i] = i; /* 바꾸는 객체는 a[i] 하나, i 는 읽기만 한다 */
i++, i++; /* 쉼표 *연산자* 사이에는 시퀀스 포인트가 있다 */
x = (i++) && (i++); /* && 사이에도 있다 */
f(i++); /* 인자가 하나면 겹치지 않는다 */반례. 한 수식에 부수효과를 모으기
a[i] = ++i + i++; /* 계약 밖. "무슨 값이 나오는가"를 묻는 것 자체가 틀렸다 */이런 코드를 만나면 “결과가 무엇인가”가 아니라 “이 코드는 뜻이 없다”고 읽어야 한다. 계약 밖이라는 것은 값이 이상하다는 뜻이 아니라, 컴파일러가 무슨 코드를 만들어도 되고 진단할 의무도 없다는 뜻이다(52장).
안전한 형태는 문장을 나누는 것이다.
int t = i + 1; /* 의도가 무엇이었든, 먼저 값을 정하고 */
i = t + 1; /* 그다음 객체를 바꾼다 */
a[i] = t;실제 사례. 컴파일러는 이 자리를 알고 있다
gcc와 clang은-Wsequence-point(gcc)와 -Wunsequenced(clang)로 흔한 경우를 잡아 준다. i = i++;처럼 뻔한 것은 대개 걸리지만, 함수 호출을 거치거나 포인터를 통해 같은 객체에 닿는 경우는 놓친다 — 정적으로 판단하기 어려운 문제이기 때문이다. 그래서 도구는 보조일 뿐이고, 규칙을 아는 것이 먼저다.34.6 대입에 숨는 변환
대입은 우변의 값을 왼쪽 타입으로 변환해서 넣는다. 29장의 변환 규칙이 여기서 조용히 적용되고, 조용하다는 점이 위험하다.
예제의 넷째 묶음이 그것이다. c = (char)321에서 값은 잘려 65가 되고, 대입 수식의 값도 그 65다 — 넣은 값이 아니라 들어간 값이 나온다. d = 3.9 뒤에 (int)d가 3인 것도 같은 이야기다(0을 향해 버린다, 28장).
| 대입 | 무슨 일이 일어나나 | 판정 |
|---|---|---|
char c = 300; | 구현이 정한 방식으로 좁혀진다 | 구현 정의 — 값이 안 담긴다 |
int n = 3.9; | 0을 향해 버려 3 | 정상. 다만 의도를 확인할 것 |
unsigned u = -1; | 감아 돌아 최댓값 | 정상(무부호는 모듈러) |
int n = 3e30; | int에 담기지 않는 실수 → 계약 밖 | UB |
float f = 0.1; | 배정도에서 단정도로 좁힌다 | 정상. 정밀도를 잃는다(50장) |
표 34.2
-Wconversion을 켜면 이런 자리를 컴파일러가 짚어 준다. 경고가 많이 나오는 옵션이라 프로젝트 전체에 켜기는 부담스럽지만, 새 코드에는 켜 두는 것이 좋은 습관이다.
복습 정리
| 기억할 것 | 요점 |
|---|---|
| 대입은 수식 | 값을 낳는다. 그러나 좌변값은 아니다 |
| 오른쪽 결합 | a = b = c는 a = (b = c) |
| 좌변도 평가된다 | a[f()] = g()에서 f는 반드시 불린다 |
| 좌우 순서 | 미지정 — 컴파일러가 정한다 |
| 복합 대입 | 왼쪽을 한 번만 평가한다 |
| 계약 밖의 기준 | 한 시퀀스 포인트 안에서 같은 객체를 두 번 바꾸거나, 바꾸면서 다른 목적으로 읽는 것 |
| 숨은 변환 | 우변은 왼쪽 타입으로 변환되어 들어간다 |
표 34.3
제6부가 끝났다 — 타입의 갈래를 펴고 값을 벼리고(26장~29장), 흐름을 다스리고 (30장~32장), 함수와 대입의 의미까지 갖췄다(33장~34장). 다음 부는 이 책의 두 번째 산, 기억이다. 5장의 사물함 복도로 돌아가, 이번에는 C의 문법으로 그 복도를 걷는다.