Proven C Book←↑→

35 대입과 부수효과

먼저 알아야 할 것

24장 변수 선언 · 이름에 값을 담는다
34장 함수의 의미 · 부수효과와 평가 순서
30장 암묵적 변환 · 타입이 다른 값이 만날 때

돌아보기

34장에서 “부수효과 있는 호출은 문장을 나눠 적으라”고 했다. 그런데 대입 자체가 부수효과다 — x = 1;은 x라는 객체를 바꾸는 일이니까. 그러면 왜 대입은 마음 놓고 써 왔는가?

답. 문장 하나에 부수효과가 하나였기 때문이다. x = 1;에는 바꾸는 객체가 하나뿐이고, 문장의 끝에 시퀀스 포인트가 있으니 다음 문장이 시작될 때는 이미 끝나 있다. 위험은 한 수식 안에 부수효과가 둘 이상 들어갈 때 시작된다 — a[i] = i++처럼. 이 장은 그 경계를 정확히 긋는다.

이 장의 필요성과 맥락

6부를 대입으로 닫는 것이 이상해 보일 수 있다. = 는 24장에서 이미 썼기 때문이다. 그러나 가장 자주 쓰면서 가장 얕게 이해되는 것이 바로 대입이고, 그 깊은 규칙(수식이라는 것, 좌변도 평가된다는 것)은 34장의 평가 순서를 알아야 설명된다. 그래서 처음이 아니라 마지막 자리다.

이 장이 끝나면

이 부의 마지막 장은 C에서 가장 자주 쓰면서 가장 얕게 이해되는 연산자, 대입이다. 대입이 수식이라는 것, 좌변도 평가된다는 것, 복합 대입이 왼쪽을 한 번만 평가한다는 것, 그리고 어디서부터가 계약 밖인지를 정리한다. 여기서 세운 어휘가 50장의 연산자 종합과 54장의 정의되지 않은 동작으로 이어진다.

이 장에서 답할 질문

  1. 그러면 무엇이 계약 밖인가?
  2. x += 1과 x++와 ++x는 결국 같은 일 아닌가?

35.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), 이른바 요다 조건)이 있으나 읽기가 나빠 이 책은 권하지 않는다. 셋째, 가장 확실한 것은 조건 자리에서 대입하지 않는 습관이다.

35.2 대입이 하는 일은 셋이다#

대입 E1 = E2 를 표준의 어휘로 갈라 보면 세 가지 평가다. 둘이 아니다 — 왼쪽도 계산되기 때문이다.

  1. 왼쪽의 값 계산 — E1 을 평가해 어느 객체에 쓸 것인지를 정한다. 표준은 좌값(lvalue) 수식의 값 계산에 「가리켜진 객체의 정체를 정하는 일」이 포함된다고 못박는다(§5.1.2.3).
  2. 오른쪽의 값 계산 — E2 를 평가하고, 그 값을 대입 수식의 타입으로 변환한다(§6.5.17.2p2).1
  3. 저장이라는 부수효과 — 그 값을 앞에서 정한 자리에 써넣는다.

셋으로 갈라야 하는 까닭은 그 사이의 순서가 저마다 다르기 때문이다.

무엇과 무엇표준이 정한 관계
왼쪽의 값 계산 ↔ 오른쪽의 값 계산순서 없음(unsequenced) — 「피연산자들의 평가는 순서가 없다」(§6.5.17.1p3)
두 값 계산 → 저장저장이 뒤(sequenced after) — 같은 조항

표 35.1 — 좌변값과 우변값 — 표준이 정한 관계

「순서 없음」은 「둘 중 하나가 먼저인데 어느 쪽인지 모른다」가 아니다. 그보다 세다 — 표준은 A 가 B 보다 먼저도 뒤도 아닐 수 있다고 말한다(§5.1.2.4p3). 그래서 두 계산이 뒤섞여 일어나도 규격에 맞는다. 「어느 쪽이 먼저인지만 모른다」에 해당하는 말은 따로 있다 — 부정 순서(indeterminately sequenced) 이고, 함수 호출의 본문이 그 관계에 놓인다(§6.5.3.3p8). 그래서 f() 와 g() 의 호출끼리는 뒤섞이지 않지만, 함수를 부르지 않는 두 계산은 뒤섞일 수 있다.

여기에 §6.5.1p2 의 규칙이 얹히면 계약이 완성된다.

문. 그러면 무엇이 계약 밖인가?

답. 표준의 문장 그대로다 — 한 스칼라 객체에 대한 부수효과가, 같은 객체에 대한 다른 부수효과나 그 객체의 값을 쓰는 값 계산과 순서 없이 놓이면 정의되지 않은 동작이다(§6.5.1p2).

표준은 각주 81 에서 예까지 들어 둔다. i = ++i + 1; 과 a[i++] = i; 는 미정의이고, i = i + 1; 과 a[i] = i; 는 허용된다. 앞의 둘은 같은 객체를 두 번 건드리는데 그 둘 사이에 순서가 없고, 뒤의 둘은 건드리는 것이 하나뿐이다.

플랫폼 노트. C++ 는 이 자리를 두 번 고쳤다 — C 는 그대로다

같은 문법이 두 언어에서 다르게 판정되는 드문 자리다. C++ 는 순서 규칙을 두 판에 걸쳐 조였다.

  • C++11 — 대입의 저장이 「두 피연산자의 값 계산 뒤, 그리고 대입 수식의 값 계산 앞」으로 못박혔다. 이 한 줄로 i = ++i; 가 미정의를 벗어난다 — ++i 의 저장은 그 값 계산보다 앞이고, 대입의 저장은 그 값 계산보다 뒤이니 두 저장 사이에 순서가 생기기 때문이다. 다만 i = i++; 는 여전히 미정의였다. 후위 ++ 의 저장은 값 계산 뒤라, 대입의 저장과 순서가 정해지지 않는다.
  • C++17 — P0145R3 이 채택되어 「E1 = E2 에서 오른쪽이 왼쪽보다 먼저 순서 지어진다」가 규칙이 됐다. 그래서 i = i++; 까지 정의된 동작이 된다. 같은 개정이 m[0] = m.size() 같은 흔한 관용구의 결과도 확정했다.

C 는 이 길을 따라가지 않았다. C23(N3220)의 §6.5.17.1p3 은 여전히 「피연산자들의 평가는 순서가 없다」이고, §6.5.1p2 의 미정의 규칙도 그대로다. 위원회에 순서를 확정하자는 제안이 없었던 것은 아니지만, C23 본문은 바뀌지 않았다. 그러므로 C 에서는 i = ++i; 도 i = i++; 도 계약 밖이다.

이 차이가 실무에서 아픈 이유는 한 가지다 — 같은 줄을 C++ 컴파일러가 경고 없이 받아 주는 것을 보고 C 에서도 괜찮다고 믿게 되기 때문이다. 언어를 오가며 코드를 옮길 때 이 자리를 특히 살펴야 한다.

35.3 좌변도 평가된다#

초보자가 가장 자주 놓치는 사실이다. =의 왼쪽은 “값”이 아니라 어디에 쓸지를 정하는 계산이고, 그 계산도 실행된다.

예제의 둘째 묶음이 그 확인이다. a[where()] = what();을 실행하면 where()와 what()이 둘 다 불린다. 좌변은 “그 자리를 어떻게 찾는가”를 계산하고, 우변은 “무엇을 쓸 것인가”를 계산한다.

그러면 어느 쪽이 먼저 계산되는가. 표준은 정하지 않는다. 컴파일러는 우변을 먼저 계산해도 되고 좌변을 먼저 계산해도 된다. 여기서는 두 계산이 모두 함수 호출이라 서로 뒤섞이지는 않는다 — 앞 절에서 본 부정 순서다. 순서만 모를 뿐 한쪽이 끝나야 다른 쪽이 시작한다. 함수 호출이 아닌 두 계산이라면 그런 보호도 없다(순서 없음). 어느 쪽이든 두 계산에 부수효과가 있으면 결과가 달라질 수 있고, 그것이 다음 절의 함정이다.

플랫폼 노트. 컴파일러마다 실제로 다르다

같은 코드를 gcc와 clang이 다른 순서로 계산하는 일은 실제로 있다. 최적화 수준을 바꾸면 한 컴파일러 안에서도 달라진다. “내 컴퓨터에서는 이렇게 나온다”가 근거가 되지 못하는 대표적 자리다 — 표준이 순서를 정하지 않았다는 것은 두 갈래 다 옳다는 뜻이지, 하나를 고른 뒤 계속 그러겠다는 약속이 아니다.

35.4 복합 대입 — 왼쪽을 한 번만 평가한다#

E1 op= E2는 E1 = E1 op E2와 같지 않다. 표준은 “E1을 한 번만 평가한다”는 것만 다르다고 말하는데, 이 한 줄의 차이가 실무에서 크다.

예제의 셋째 묶음을 보라. a[where()] += 1;에서 where()는 한 번만 불린다. 풀어 쓴 a[where()] = a[where()] + 1;이었다면 두 번 불려, 두 개의 다른 칸을 읽고 쓰는 사고가 났을 것이다.

형태왼쪽 평가 횟수메모
a[f()] = a[f()] + 12회f에 부수효과가 있으면 다른 칸을 읽고 쓴다
a[f()] += 11회권장 형태
*p++ += 11회그래도 읽기 어렵다 — 나누어 쓰는 편이 낫다

표 35.2 — 복합 대입에서 왼쪽이 평가되는 횟수

복합 대입의 종류는 열 가지다 — += -= *= /= %= <<= >>= &= ^= |=. 각 연산의 회색지대는 그대로 따라온다는 점을 잊지 말자. x /= 0은 여전히 계약 밖이고, x <<= 40도 그렇다(29장).

문. x += 1과 x++와 ++x는 결국 같은 일 아닌가?

답. 객체를 바꾸는 효과는 같다. 다른 것은 수식의 값이다 — ++x와 x += 1은 바꾼 뒤의 값을 내놓고, x++는 바꾸기 전의 값을 내놓는다. 값을 쓰지 않는 문장(x++;)에서는 셋이 완전히 같고, 그래서 실무에서는 취향의 문제다.

한 가지 실용적인 차이는 있다. x += n은 임의의 값을 더할 수 있고, 포인터에 쓰면 원소 단위로 움직인다(39장). 그리고 C++에서 온 관행 때문에 “값을 쓰지 않을 때는 ++x를 쓴다”는 습관이 퍼져 있는데, C에서는 성능 차이가 없다.

35.5 어디서부터 계약 밖인가#

이제 유명한 수식들을 판정할 수 있다. 아래 코드는 실행 결과를 싣지 않는다 — 계약 밖 수식의 결과를 인쇄하면 “이 컴파일러에서는 이렇게 나온다”는 잘못된 지식을 남기기 때문이다(54장의 원칙).

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++;        /* 계약 밖. "무슨 값이 나오는가"를 묻는 것 자체가 틀렸다 */

이런 코드를 만나면 “결과가 무엇인가”가 아니라 “이 코드는 뜻이 없다”고 읽어야 한다. 계약 밖이라는 것은 값이 이상하다는 뜻이 아니라, 컴파일러가 무슨 코드를 만들어도 되고 진단할 의무도 없다는 뜻이다(54장).

안전한 형태는 문장을 나누는 것이다.

int t = i + 1;            /* 의도가 무엇이었든, 먼저 값을 정하고 */
i = t + 1;                /* 그다음 객체를 바꾼다 */
a[i] = t;

실제 사례. 컴파일러는 이 자리를 알고 있다

gcc와 clang은 -Wsequence-point(gcc)와 -Wunsequenced(clang)로 흔한 경우를 잡아 준다. i = i++;처럼 뻔한 것은 대개 걸리지만, 함수 호출을 거치거나 포인터를 통해 같은 객체에 닿는 경우는 놓친다 — 정적으로 판단하기 어려운 문제이기 때문이다. 그래서 도구는 보조일 뿐이고, 규칙을 아는 것이 먼저다.

35.6 대입에 숨는 변환#

대입은 우변의 값을 왼쪽 타입으로 변환해서 넣는다. 30장의 변환 규칙이 여기서 조용히 적용되고, 조용하다는 점이 위험하다.

예제의 넷째 묶음이 그것이다. c = (char)321에서 값은 잘려 65가 되고, 대입 수식의 값도 그 65다 — 넣은 값이 아니라 들어간 값이 나온다. d = 3.9 뒤에 (int)d가 3인 것도 같은 이야기다(0을 향해 버린다, 29장).

대입무슨 일이 일어나나판정
char c = 300;구현이 정한 방식으로 좁혀진다구현 정의 — 값이 안 담긴다
int n = 3.9;0을 향해 버려 3정상. 다만 의도를 확인할 것
unsigned u = -1;감아 돌아 최댓값정상(무부호는 모듈러)
int n = 3e30;int에 담기지 않는 실수 → 계약 밖UB(undefined behaviour)
float f = 0.1;배정도에서 단정도로 좁힌다정상. 정밀도를 잃는다(52장)

표 35.3 — 대입의 자리별 판정

-Wconversion을 켜면 이런 자리를 컴파일러가 짚어 준다. 경고가 많이 나오는 옵션이라 프로젝트 전체에 켜기는 부담스럽지만, 새 코드에는 켜 두는 것이 좋은 습관이다.

복습 정리

기억할 것요점
대입은 수식값을 낳는다. 그러나 좌변값은 아니다
오른쪽 결합a = b = c는 a = (b = c)
좌변도 평가된다a[f()] = g()에서 f는 반드시 불린다
좌우 순서미지정 — 컴파일러가 정한다
복합 대입왼쪽을 한 번만 평가한다
계약 밖의 기준한 시퀀스 포인트 안에서 같은 객체를 두 번 바꾸거나, 바꾸면서 다른 목적으로 읽는 것
숨은 변환우변은 왼쪽 타입으로 변환되어 들어간다

표 35.4 — 대입과 부수효과 — 기억할 것

제6부가 끝났다 — 타입의 갈래를 펴고 값을 벼리고(27–30장), 흐름을 다스리고 (31–33장), 함수와 대입의 의미까지 갖췄다(34–35장). 다음 부는 이 책의 두 번째 산, 기억이다. 3장의 사물함 복도로 돌아가, 이번에는 C의 문법으로 그 복도를 걷는다.

주

  1. 정확히는 「E1 의 타입」이 아니라 대입 수식의 타입, 곧 E1 이 좌값 변환을 거친 뒤 갖게 될 타입이다(§6.5.17.1p3). 한정자가 떨어진다는 뜻이다. ↩