33 반복 — 루프와 불변식
먼저 알아야 할 것
돌아보기
2장에서 “단순한 걸음이라도 1초에 수십억 번이면 어떤 복잡한 일이든 지을 수 있다”고 했다. 그런데 32장까지의 우리 프로그램은 위에서 아래로 한 번 흐르고 끝난다 — 수십억 번의 걸음은 어디서 나오는 것인가?
답. 같은 문장들을 다시 실행하는 장치에서 나온다. 조건이 참인 동안 블록으로 되돌아가는 것 — 루프다. 분기가 흐름을 가르는 장치였다면 루프는 흐름을 감아올리는 장치이고, 이 둘을 갖추는 순간 C는 계산 가능한 모든 것을 적을 수 있는 언어가 된다(이론적으로도 그렇다 — 2장의 계산 모델이 요구하는 전부가 “순차, 분기, 반복”이다).
이 장의 필요성과 맥락
while 의 괄호 안을 설명할 수 없다. 그리고 여기서 익히는 불변식은 뒤에 나올 거의 모든 배열 순회의 사고 도구가 된다.이 장이 끝나면
while, for, do-while)와 새 연산자들(++, +=), do-while 이 이기는 자리 (부호 없는 첨자로 거꾸로 세기), 그리고 루프를 믿는 방법인 불변식 — 무엇을 뜻하고, 실무에서 어떻게 쓰이고, 어떤 버그를 잡아 주는지까지 한 절로 다룬다. 이어서 for 의 세 칸을 하나씩 뜯는다 — 첫 칸에 무엇을 선언할 수 있는지(C23 에서 규칙이 바뀐 자리다)와 거기 선언한 변수가 어디까지 보이고 언제까지 사는지, 조건 칸이 왜 「검사」가 아니라 매 바퀴 실행되는 코드인지, 루프가 끝나지 않는 세 가지 이유, 그리고 겹쳐 도는 루프의 실수들까지. 마지막으로 13장에서 예약해 둔 재회 — Duff’s device — 를 만난다.이 장에서 답할 질문
- 그러면 거꾸로 걷기는 언제나
do-while인가? continue는 셋에서 같은 곳으로 가는가?do-while은 정말 그렇게 안 쓰이는가?- 불변식이 성립하면 루프가 끝나는 것도 보장되는가?
- 형식 검증에서 말하는 「루프 불변식」과 같은 것인가?
- 조건이 영영 거짓이 안 되면 어떻게 되는가?
- 그러면 첨자를 루프 밖에 선언하는 옛 방식(
int i; for (i = 0; ...))은 나쁜가? - 그러면 첫 칸에는 무엇을 적는 것이 좋은가?
- 조건에 부수효과가 있으면, 그 효과는 반드시 일어나는가?
- 이중 루프에서 어느 쪽을 바깥에 두어야 하는가?
33.1 루프 3형제#
while — 가장 원초적인 형태다. “조건이 참인 동안, 블록을 반복하라”:
while (조건) {
반복할 문장들
}조건을 먼저 검사하므로, 처음부터 거짓이면 한 번도 돌지 않는다.
for — 반복의 살림살이(시작·조건·갱신)를 한 줄에 모은 형태다. 시연으로 본다 — 1부터 100까지의 합이다:
examples/ch32/sum.c
#include <stdio.h>
int main(void)
{
int sum = 0;
for (int i = 1; i <= 100; i += 1) {
sum += i; /* 불변식: sum == 1 + 2 + ... + (i - 1)... 갱신 후엔 ...+ i */
}
printf("sum of 1..100 = %d\n", sum);
return 0;
}
실행 결과
sum of 1..100 = 5050
for (int i = 1; i <= 100; i += 1)을 세 칸으로 읽는다 — 시작(int i = 1: 루프 변수를 만들고), 조건(i <= 100: 돌기 전마다 검사), 갱신(i += 1: 한 바퀴 끝날 때마다 실행). 새 연산자 +=는 24장 대입의 줄임꼴로 i = i + 1과 같고, 더 줄인 i++(증가 연산자)도 같은 일을 한다 — 관용적으로 for (...; i++) 꼴을 가장 많이 보게 된다. (++에는 앞에 붙는 꼴과 뒤에 붙는 꼴, 그리고 수식 안에서의 미묘한 사정이 있는데 — 다음 장의 시퀀스 포인트 이야기와 함께 다룬다.)
do-while — 블록을 먼저 한 번 실행하고 조건을 검사하는 형태다 (do { ... } while (조건);). “최소 한 번은 해야 하는 일”(메뉴를 먼저 보여 주고 반복 여부를 묻는 류)에 쓰이고, 셋 중 등장 빈도가 가장 낮다.
셋을 나란히 놓고 같은 일을 시켜 본다.
examples/ch32/three.c
/* 같은 일을 세 형제로 각각 적어 보고, 서로 다른 자리를 드러낸다. */
#include <stdio.h>
/* 1부터 5까지 더하기 — while */
static int sum_while(void)
{
int sum = 0;
int i = 1; /* 시작: 루프 밖에 있다 */
while (i <= 5) { /* 조건 */
sum += i;
i += 1; /* 갱신: 몸통 끝에 있다 — 잊기 쉬운 자리 */
}
return sum;
}
/* 같은 일 — for. 살림(시작·조건·갱신)이 한 줄에 모인다 */
static int sum_for(void)
{
int sum = 0;
for (int i = 1; i <= 5; i += 1)
sum += i;
return sum;
}
/* 같은 일 — do-while. 몸통을 먼저 한 번 실행한다 */
static int sum_do(void)
{
int sum = 0;
int i = 1;
do {
sum += i;
i += 1;
} while (i <= 5);
return sum;
}
/* 셋의 차이가 드러나는 자리 ① — 처음부터 조건이 거짓일 때 */
static int count_while(int n)
{
int turns = 0, i = 0;
while (i < n) { turns++; i++; }
return turns;
}
static int count_do(int n)
{
int turns = 0, i = 0;
do { turns++; i++; } while (i < n);
return turns;
}
/* 셋의 차이가 드러나는 자리 ② — continue 가 어디로 가는가.
for 의 갱신식은 continue 뒤에도 반드시 실행된다. while 로 옮겨 적을 때
갱신을 몸통 끝에 두면, continue 가 그 갱신을 뛰어넘어 루프가 멈추지 않는다. */
static int odd_sum_for(void)
{
int sum = 0;
for (int i = 1; i <= 9; i++) {
if (i % 2 == 0)
continue; /* i++ 는 그래도 실행된다 */
sum += i;
}
return sum;
}
static int odd_sum_while_fixed(void)
{
int sum = 0;
int i = 1;
while (i <= 9) {
if (i % 2 == 0) {
i++; /* 갱신을 여기서도 해 주어야 한다 */
continue;
}
sum += i;
i++;
}
return sum;
}
/* do-while 이 정말로 값을 하는 자리 — 부호 없는 첨자로 거꾸로 걷기.
size_t 는 0 아래로 내려가지 못하므로 for (size_t i = n-1; i >= 0; i--) 는
무한 루프가 된다(41장). do-while 은 「먼저 하나 줄이고, 0 을 처리한 뒤 끝낸다」
를 그대로 적을 수 있다. 단, 몸통이 먼저 도는 형태이므로 n 이 0 이 아니어야
한다 — 그 검사가 이 패턴의 값과 짝을 이룬다. */
static void countdown(size_t n)
{
if (n == 0) { /* do-while 의 「최소 한 번」을 막아 준다 */
printf(" (nothing to visit)\n");
return;
}
printf(" ");
size_t i = n;
do {
i--; /* n-1 부터 0 까지 */
printf("%s%zu", i == n - 1 ? "" : " ", i);
} while (i > 0);
printf("\n");
}
int main(void)
{
printf("[the same job, three ways]\n");
printf(" while : 1..5 -> %d\n", sum_while());
printf(" for : 1..5 -> %d\n", sum_for());
printf(" do-while : 1..5 -> %d\n", sum_do());
printf("\n[what changes when the condition is false from the start]\n");
printf(" while (i < 0) ran %d time(s)\n", count_while(0));
printf(" do ... while (i < 0) ran %d time(s)\n", count_do(0));
printf("\n[continue and the update step]\n");
printf(" for : sum of odd numbers 1..9 = %d\n", odd_sum_for());
printf(" while : sum of odd numbers 1..9 = %d\n", odd_sum_while_fixed());
printf("\n[walking backwards with an unsigned index]\n");
countdown(6);
countdown(1);
countdown(0);
printf(" do-while says it plainly: step down first, handle 0, then stop.\n");
printf(" in the for loop i++ runs even after continue;\n");
printf(" in the while loop the update sits in the body, so continue can skip it -\n");
printf(" that is how a working loop turns into an endless one when it is rewritten.\n");
return 0;
}
실행 결과
[the same job, three ways]
while : 1..5 -> 15
for : 1..5 -> 15
do-while : 1..5 -> 15
[what changes when the condition is false from the start]
while (i < 0) ran 0 time(s)
do ... while (i < 0) ran 1 time(s)
[continue and the update step]
for : sum of odd numbers 1..9 = 25
while : sum of odd numbers 1..9 = 25
[walking backwards with an unsigned index]
5 4 3 2 1 0
0
(nothing to visit)
do-while says it plainly: step down first, handle 0, then stop.
in the for loop i++ runs even after continue;
in the while loop the update sits in the body, so continue can skip it -
that is how a working loop turns into an endless one when it is rewritten.
시연이 재어 보인 것이 셋의 차이 전부다.
| 형태 | 조건을 언제 보는가 | 최소 실행 횟수 | 살림이 있는 자리 |
|---|---|---|---|
while | 몸통 앞 | 0 회 | 시작은 루프 앞에, 갱신은 몸통 안에 흩어진다 |
for | 몸통 앞 | 0 회 | 세 칸에 모여 있다 |
do-while | 몸통 뒤 | 1 회 | while 과 같다 — 대신 「처리하고 나서 멈춤」이 자연스럽다 |
표 33.1 — 루프 3형제 — 조건을 보는 자리와 최소 실행 횟수
표 33.1의 셋째 칸에 for 가 가장 널리 쓰이는 이유가 있다 — 「몇 번 도는가」에 관한 정보가 한 줄에 모여 있어서, 갱신을 잊어 무한 루프가 되는 사고가 구조적으로 줄어든다. 반대로 도는 횟수가 미리 정해지지 않은 반복(파일 끝까지 읽기, 사용자가 그만두겠다고 할 때까지)은 while 이 자연스럽다.
33.1.1 do-while 이 정말로 이기는 자리 — 거꾸로 세기#
「최소 한 번」이라는 성질이 빛을 발하는 대표적인 자리가 부호 없는 첨자로 거꾸로 걷기다. 5, 4, 3, 2, 1, 0 순으로 내려가는 그 흔한 순회인데, 소박하게 적으면 무한 루프가 된다.
for (size_t i = n - 1; i >= 0; i--) /* 끝나지 않는다 */size_t 는 부호가 없어서 언제나 0 이상이다. 조건이 영영 참이고, 0 에서 하나 더 빼면 SIZE_MAX 로 감아 돈다(28장). 마지막 원소인 0 번을 처리하고 그 뒤에 멈춰야 하는데, 검사를 먼저 하는 for 와 while 로는 그 「처리하고 나서 멈춤」이 한 줄로 나오지 않는다.
do-while 은 그 순서를 그대로 적는다 — 먼저 하나 내리고, 쓰고, 0 이면 끝낸다.
size_t i = n;
do {
i--; /* n-1, …, 1, 0 */
use(a[i]);
} while (i > 0); /* 0 을 처리한 뒤에 멈춘다 */시연의 countdown 이 5 4 3 2 1 0 을 찍는 것이 이것이다. 몸통이 먼저 도는 형태라 n 이 0 이면 안 된다는 조건이 붙는데(0 에서 i-- 하면 감아 돈다), 그 검사를 앞에 두는 것까지가 이 패턴의 한 벌이다. 시연이 countdown(0) 에서 아무것도 하지 않고 돌아가는 것이 그 부분이다.
문. 그러면 거꾸로 걷기는 언제나 do-while 인가?
답. 아니다. 같은 일을 for 한 줄로 적는 관용구도 널리 쓰인다.
for (size_t i = n; i-- > 0; ) /* 조건에서 검사와 감소를 함께 끝낸다 */「지금 값이 0 보다 큰지 보고, 그 뒤에 1 을 뺀다」이므로 몸통에서는 n-1 부터 0 까지가 보이고, n 이 0 이어도 안전하다(한 번도 돌지 않는다). 짧지만 조건 칸이 값을 바꾸고 있어서 처음 보면 읽기 어렵다.
갈라 쓰는 기준은 이렇다 — 비어 있을 수 있는 것을 훑으면 for 쪽이 안전하고, 적어도 하나는 있다가 전제인 자리(이미 검사했거나, 원소가 없으면 애초에 들어오지 않는 함수)라면 do-while 쪽이 뜻이 더 잘 드러난다. 두 꼴 다 42장의 역순 순회 절에서 다시 만난다.
문. continue 는 셋에서 같은 곳으로 가는가?
답. 아니다 — 그리고 이것이 for 를 while 로 옮겨 적을 때 무는 함정이다.
for 에서 continue 는 갱신식을 거쳐 조건으로 간다. 그래서 if (...) continue; 를 아무 데나 넣어도 i++ 는 반드시 실행된다. while 에서는 갱신이 몸통 안에 있으므로, continue 가 그 갱신을 건너뛴다 — 잘 돌던 루프가 옮겨 적는 순간 영영 끝나지 않게 된다. 시연의 odd_sum_while_fixed 가 continue 앞에서 한 번 더 i++ 를 하는 이유가 그것이다. (do-while 의 continue 도 조건 검사로 간다 — 42장에서 다시 본다.)
문. do-while 은 정말 그렇게 안 쓰이는가?
답. 반복으로는 그렇다. 그런데 실무 코드에서 do 라는 낱말을 세어 보면 뜻밖에 많이 나오는데, 그 대부분은 반복이 아니다 — 딱 한 번 도는 do { ... } while (0) 이라는 패턴이고, 여러 문장을 담은 매크로를 「한 문장」으로 만들려고 쓴다. 리눅스 커널의 헤더에서만도 수천 번 나온다.
왜 중괄호만으로는 안 되는지, 왜 하필 한 번 도는 루프인지는 매크로를 알아야 뜻이 서는 이야기라 42장에 모아 두었다(매크로 자체는 61장이다). 여기서는 한 가지만 기억하면 된다 — do-while 을 만나면 반복인지 while (0) 인지부터 보라.
33.2 불변식(invariant) — 루프를 믿는 방법#
루프는 눈으로 검사할 수 없는 문장이다. if 는 두 갈래를 다 읽으면 끝이지만, 루프는 몇 바퀴를 도는지가 실행할 때 정해진다. 그래서 「몇 번 도나 세어 보기」로는 확신이 서지 않고, 실제로 루프의 버그는 대개 중간이 아니라 양 끝에서 난다 — 한 바퀴 덜 돌거나 더 돈다. 돌려 보면 대개 맞게 나오기 때문에 시험도 잘 잡지 못한다.
그래서 사람들이 쓰는 도구가 불변식(loop invariant)이다. 이름이 거창하지만 하는 일은 한 문장이다 — 매 바퀴 같은 지점에서 항상 참인 명제를 하나 정해 두고, 그 명제로 루프를 읽는 것.
examples/ch32/invariant.c
/* 불변식을 눈으로 보기 — 매 바퀴 성립하는 명제를 실제로 검사한다.
assert 는 「이 자리에서 이 명제가 참이어야 한다」를 코드로 적는 도구다(17장). */
#include <assert.h>
#include <stdio.h>
/* ① 1부터 n 까지의 합 — 불변식을 표로 찍어 본다.
몸통에 들어설 때마다 sum == 1 + 2 + ... + (i-1) 이 성립한다.
그 오른쪽을 따로 세어 두었다가 맞대어 본다. */
static int sum_to(int n)
{
int sum = 0;
int checked = 0; /* 1 + ... + (i-1) 을 따로 셈한 값 */
printf(" i | sum | 1+...+(i-1) | invariant\n");
printf(" ---+-----+-------------+----------\n");
for (int i = 1; i <= n; i++) {
assert(sum == checked); /* ← 매 바퀴 여기서 성립한다 */
printf(" %2d | %3d | %11d | %s\n", i, sum, checked,
sum == checked ? "holds" : "BROKEN");
sum += i; /* 몸통이 등식을 한 칸 자라게 하고 */
checked += i; /* 갱신 i++ 가 다시 같은 꼴로 만든다 */
}
/* 종료 조건: i > n. 불변식에 i = n+1 을 넣으면 sum == 1 + ... + n */
printf(" loop ended: sum = %d\n", sum);
return sum;
}
/* ② 종료를 보장하는 것은 불변식이 아니라 「줄어드는 양」이다.
아래 루프에서 그 양은 n - i 이고, 매 바퀴 정확히 1씩 준다. */
static void measure_shrinks(int n)
{
int prev = n; /* 직전 바퀴의 남은 걸음 수 */
for (int i = 0; i < n; i++) {
int left = n - i; /* 남은 걸음 = 줄어드는 양 */
assert(left < prev || i == 0);
assert(left >= 0);
prev = left;
}
printf(" the quantity (n - i) fell from %d to 0, one step at a time\n", n);
}
/* ③ 이진 탐색 — 불변식이 실제로 값을 하는 자리.
불변식: 찾는 값이 배열 안에 있다면, 그것은 반드시 [lo, hi) 안에 있다.
구간이 매 바퀴 좁아지고(종료), 빈 구간이 되면 없는 것이다. */
static int binary_search(const int *a, int n, int key)
{
int lo = 0, hi = n; /* 반열림 구간 [lo, hi) */
int steps = 0;
while (lo < hi) {
assert(0 <= lo && lo <= hi && hi <= n); /* 구간이 늘 유효하다 */
int mid = lo + (hi - lo) / 2; /* lo+hi 로 적으면 넘칠 수 있다 */
assert(lo <= mid && mid < hi); /* 그래서 mid 는 구간 안이다 */
steps++;
if (a[mid] == key)
return mid;
if (a[mid] < key)
lo = mid + 1; /* 왼쪽 절반은 답이 아니다 */
else
hi = mid; /* 오른쪽 절반은 답이 아니다 */
}
printf(" (%d not found in %d steps)\n", key, steps);
return -1;
}
int main(void)
{
printf("[the invariant of a summing loop]\n");
int s = sum_to(5);
printf(" 1 + 2 + 3 + 4 + 5 = %d\n", s);
printf("\n[what makes a loop end is a quantity that shrinks]\n");
measure_shrinks(4);
printf("\n[binary search - the invariant is the search range]\n");
int a[] = { 2, 4, 8, 16, 32, 64, 128 };
int n = (int)(sizeof a / sizeof a[0]);
for (int key = 1; key <= 16; key *= 2)
printf(" key %3d -> index %d\n", key, binary_search(a, n, key));
printf(" key %3d -> index %d\n", 5, binary_search(a, n, 5));
printf("\n[the empty range is not a special case - the invariant covers it]\n");
printf(" searching an empty array for 42 -> index %d\n",
binary_search(a, 0, 42));
return 0;
}
실행 결과
[the invariant of a summing loop]
i | sum | 1+...+(i-1) | invariant
---+-----+-------------+----------
1 | 0 | 0 | holds
2 | 1 | 1 | holds
3 | 3 | 3 | holds
4 | 6 | 6 | holds
5 | 10 | 10 | holds
loop ended: sum = 15
1 + 2 + 3 + 4 + 5 = 15
[what makes a loop end is a quantity that shrinks]
the quantity (n - i) fell from 4 to 0, one step at a time
[binary search - the invariant is the search range]
(1 not found in 3 steps)
key 1 -> index -1
key 2 -> index 0
key 4 -> index 1
key 8 -> index 2
key 16 -> index 3
(5 not found in 3 steps)
key 5 -> index -1
[the empty range is not a special case - the invariant covers it]
(42 not found in 0 steps)
searching an empty array for 42 -> index -1
33.2.1 세 조각으로 읽는다#
시연의 첫 루프(1부터 5까지 더하기)를 예로 든다. 몸통에 들어설 때마다 다음이 성립한다.
말로 옮기면 「지금까지 더한 것은 바로 앞까지다」이다. 불변식은 언제나 세 조각으로 확인한다.
| 조각 | 무엇을 확인하는가 | 이 루프에서는 |
|---|---|---|
| 성립(initialization) | 루프에 처음 들어설 때 참인가 | i 가 1 이고 sum 이 0 — 빈 합이라 참 |
| 유지(maintenance) | 한 바퀴를 돌고 나서도 다시 참인가 | 몸통이 sum += i 로 오른쪽을 까지 늘리고, 갱신 i++ 가 다시 「 바로 앞까지」로 만든다 |
| 종료(termination) | 끝나는 순간의 값을 넣으면 무엇이 나오는가 | i 가 6 이므로 sum — 우리가 원한 답 |
표 33.2 — 불변식을 확인하는 세 조각
이 셋을 갖추면 루프가 옳다는 증명이 된다. 수학의 귀납법과 같은 모양이라 — 첫 항에서 참이고, 참이면 다음도 참이니, 모든 항에서 참 — 「몇 바퀴를 도는지 모르는데 어떻게 확신하는가」라는 물음이 여기서 풀린다. 시연은 그 명제를 말로만 두지 않고 assert 로 매 바퀴 실제로 검사하고, 표로 찍어 보인다.
문. 불변식이 성립하면 루프가 끝나는 것도 보장되는가?
답. 아니다. 그것이 이 개념에서 가장 자주 오해되는 자리다.
불변식은 「도는 동안 무엇이 참인가」만 말한다. 영원히 도는 루프에서도 불변식은 얌전히 참일 수 있다. 끝난다는 것은 따로 보여야 하고, 그때 쓰는 것이 매 바퀴 반드시 줄어드는 양이다(변량 · 척도라고 부른다). 시연의 두 번째 함수가 그 양을 재어 보인다 — n - i 가 매 바퀴 정확히 1 씩 줄고, 0 아래로는 갈 수 없으니 루프는 반드시 끝난다.
이 장 뒤에 나오는 준(Zune) 사고가 정확히 이 한 조각이 빠진 사건이다. 그 루프의 불변식(days 는 그해의 남은 날수다)은 멀쩡히 참이었는데, 줄어드는 양이 없는 갈래가 하나 있었다. 그래서 루프를 검사하는 물음이 둘인 것이다 — 「매 바퀴 무엇이 참인가」그리고 「매 바퀴 무엇이 줄어드는가」.
33.2.2 사람들은 이 낱말을 어떻게 쓰는가#
교과서 밖에서 불변식은 네 가지 모습으로 나타난다. 넷 다 「증명을 적는 일」이 아니라 생각을 붙잡아 두는 일이다.
| 쓰는 방식 | 어떻게 생겼나 | 무엇을 얻는가 |
|---|---|---|
| 주석 한 줄 | /* invariant: sum == 1 + ... + (i-1) */ | 가장 흔하다. 읽는 사람이 몸통을 고칠 때 무엇을 깨뜨리면 안 되는지 알게 된다 |
assert 로 검사 | 시연처럼 루프 머리에 assert(...) 를 둔다 | 명제가 코드가 되어 실행 중에 검사된다. 디버그 빌드에서만 켜 두는 것이 관행이다(18장) |
| 이름과 표기로 굳히기 | [lo, hi) 처럼 반열림 구간을 쓰고 end·count 같은 이름을 붙인다 | 불변식을 글이 아니라 코드의 모양으로 강제한다 — 아래 이진 탐색이 그 예다 |
| 자료구조의 불변식 | 「링 버퍼의 head 와 tail 은 언제나 버퍼 안이다」, 「이 배열은 늘 정렬돼 있다」 | 루프 밖으로 넓힌 같은 개념. 이때는 표현 불변식이라 부르고, 계약(53장)의 한 부분이 된다 |
표 33.3 — 불변식을 실무에서 쓰는 네 가지 방식
세 번째가 특히 실무적이다. C 와 그 뒤의 언어들이 끝을 하나 넘겨 가리키는 관행([처음, 끝))을 굳힌 이유가 여기 있다 — 「길이 = 끝 - 처음」과 「빈 것 = 처음 == 끝」이 저절로 성립해서, 경계에서 따로 생각할 것이 없어진다. 41장의 포인터 순회, 43장의 문자열, 표준 라이브러리의 거의 모든 훑기가 이 모양이다.
33.2.3 이진 탐색 — 불변식이 빛을 발하는 자리#
시연의 셋째 함수가 그 예다. 구간 [lo, hi) 를 반씩 줄이며 값을 찾는데, 불변식은 한 문장이다.
“ 찾는 값이 배열 안에 있다면, 그것은 반드시 [lo, hi) 안에 있다. ”
이 한 줄이 네 가지를 한꺼번에 정리해 준다.
- 양 끝을 어떻게 옮길지가 정해진다.
a[mid] < key면mid이하는 답이 아니므로lo = mid + 1, 반대면hi = mid. 「왜 한쪽만+1인가」가 불변식에서 따라 나온다. - 빈 입력이 특별한 경우가 아니게 된다. 원소가 0 개면
lo == hi라 루프가 아예 돌지 않고 「없음」이 나온다. 시연의 마지막 줄이 그 확인이다. - 끝난다는 것이 보인다. 구간의 길이
hi - lo가 매 바퀴 최소한 절반으로 줄어든다 — 줄어드는 양이 여기 있다. - 없는 값을 넣어도 답이 옳다. 루프가 끝나는 순간 구간이 비었고, 불변식에 따르면 「배열 안에 있었다면 그 구간에 있었어야」 하므로 없는 것이다.
실제 사례. 표준 라이브러리에 9년 동안 숨어 있던 이진 탐색의 버그
이진 탐색은 1946년에 처음 발표됐지만 모든 n 에 대해 올바르게 도는 코드는 1962년에야 나왔다고 알려져 있다. 그리고 그 뒤로도 버그가 하나 더 숨어 있었다 — 자바 표준 라이브러리의 Arrays.binarySearch 에 들어 있던 (lo + hi) / 2 로, 2006년에 저자 자신이 공개했을 때 이미 JDK 안에서 아홉 해를 지낸 뒤였다.1 배열이 아주 커지면 lo + hi 가 먼저 넘쳐 음수가 되고, 그 순간 mid 가 구간 밖으로 나간다.
★ 눈여겨볼 것은 불변식이 이 버그를 잡아 준다는 점이다. 「mid 는 [lo, hi) 안에 있다」를 명제로 적어 두면(시연이 assert 로 그렇게 해 두었다) 넘침이 일어나는 순간 그 명제가 깨진다. 그래서 안전한 꼴은 lo + (hi - lo) / 2 이고, 이것은 「빼기를 먼저 하면 넘칠 수 없다」는 28장의 이야기와 같은 처방이다.
교훈은 「이진 탐색은 어렵다」가 아니다 — 경계는 눈으로 지킬 수 없고, 명제로 지켜야 한다는 것이다.
33.2.4 무엇을 얻는가#
| 불변식이 잡아 주는 문제 | 어떻게 |
|---|---|
| off-by-one — 한 바퀴 덜/더 돌기 | 끝나는 순간의 값을 명제에 넣어 보면 답이 맞는지 즉시 드러난다 |
| 빈 입력·한 개짜리 입력 | 「0 번 도는 경우」도 명제가 그대로 덮는다 — 특별한 경우로 따로 적을 필요가 없다 |
| 경계 계산 실수 | mid 나 첨자가 구간 안이라는 명제를 assert 로 두면 실행 중에 잡힌다 |
| 고치다 깨뜨리기 | 몸통을 고칠 때 지켜야 할 것이 주석에 적혀 있다 — 리팩터링의 안전선 |
| 끝나지 않는 루프 | 함께 적는 「줄어드는 양」이 종료를 보장한다 |
표 33.4 — 불변식이 잡아 주는 문제
매번 이렇게 적을 필요는 없다. 그러나 루프를 쓰기 전에 한 문장으로 적어 보는 습관은 가치가 크다 — 적히지 않으면 대개 아직 생각이 정리되지 않은 것이고, 그 상태에서 쓴 루프가 양 끝에서 틀린다. 계약으로서의 코드라는 관점(53장)의 첫 연습이기도 하다.
문. 형식 검증에서 말하는 「루프 불변식」과 같은 것인가?
답. 같은 개념이고, 쓰는 방식만 더 엄격하다. 프로그램을 기계로 증명하는 도구들 — C 에서는 ACSL 주석과 Frama-C 가 대표적이다 — 은 루프마다 불변식과 줄어드는 양을 주석 문법으로 요구하고, 그것으로 증명을 만든다. 항공·의료·원자력처럼 「시험으로는 부족한」 자리에서 실제로 그렇게 쓴다.
우리가 이 장에서 하는 것은 그 도구의 축소판이다 — 같은 물음을 사람의 머리로 묻고, assert 로 반쯤 자동화하는 것. 도구를 쓰지 않아도 묻는 습관은 그대로 쓸모가 있다.
문. 조건이 영영 거짓이 안 되면 어떻게 되는가?
답. 무한 루프다 — 프로그램이 그 자리를 영원히 돈다. 대개는 갱신을 잊은 버그의 결과지만, 의도적인 무한 루프(while (true))도 어엿한 관용구다 — 서버처럼 “끝나지 않는 것이 정상”인 프로그램의 뼈대가 그것이고, 안에서 조건을 보아 break(루프 탈출 — switch의 그 break와 같은 낱말이다)로 나온다. 위험한 것은 무한 루프 자체가 아니라 의도치 않은 무한 루프다.
33.3 for 의 첫 칸 — 무엇을 선언할 수 있는가#
표준은 for 의 세 칸에 이름을 붙여 둔다 — 차례로 clause-1, expression-2, expression-3 이다. 첫 칸만 이름이 다른 이유가 있다. 나머지 둘은 수식이지만, 첫 칸은 수식일 수도 있고 선언일 수도 있기 때문이다. 그 선언에 붙는 규칙이 이 절의 이야기다.
examples/ch32/for_decl.c
/* for 의 첫 칸(clause-1)에서 무엇을 선언할 수 있는가 — 실측판.
C23 로 빌드한다. 주석에 적은 「C99·C11 에서는」 은 -std=c11 -pedantic-errors
로 확인한 결과다. */
#include <stdio.h>
/* ① 한 번의 선언이므로 기본 타입은 하나다. 파생 타입은 함께 적을 수 있다. */
static void one_base_type(void)
{
int a[3] = { 10, 20, 30 };
/* int 하나로 값·포인터·개수를 한꺼번에 만든다 */
for (int *p = a, *end = a + 3, n = 0; p != end; p++, n++)
printf(" a[%d] = %d\n", n, *p);
/* for (int i = 0; double x = 0.0;) 는 문법 오류다 —
"expected expression before 'double'" (gcc·clang 모두) */
}
/* ② 이름의 수명은 루프 하나다 — 세 칸과 몸통까지, 그리고 거기서 끝난다. */
static void scope_of_the_name(void)
{
int i = 100; /* 바깥의 i */
for (int i = 0; i < 2; i++) /* 안쪽 i 가 바깥 i 를 가린다 */
printf(" inside the loop i = %d\n", i);
printf(" after the loop i = %d (the outer one was never touched)\n", i);
}
/* ②-b 수명 — 첫 칸의 변수는 루프 하나에 대해 *객체 하나*다.
몸통 안에서 선언한 변수는 그렇지 않다: 매 바퀴 새로 태어나고 매 바퀴 죽는다. */
static void lifetime_of_the_two(void)
{
int last_body = -1;
for (int i = 0; i < 3; i++) {
int body = 0; /* 매 바퀴 새 객체 — 그래서 늘 0 부터 */
body++;
last_body = body;
printf(" turn %d: counter i = %d (one object, kept), body = %d (reborn)\n",
i, i, body);
}
printf(" the body variable never grew past %d - it died at every closing brace\n",
last_body);
}
/* ②-c 같은 객체라는 것은 주소로 확인된다 — 세 바퀴 모두 같은 주소다. */
static void one_object_one_address(void)
{
const void *first = nullptr;
for (int i = 0; i < 3; i++) {
if (i == 0)
first = (const void *)&i;
printf(" turn %d: &i %s\n", i,
(const void *)&i == first ? "is the same address as turn 0"
: "CHANGED (would mean a new object)");
}
/* 루프를 벗어나면 그 객체의 수명이 끝난다. 위에서 받아 둔 주소를
이제 와서 읽는 것은 정의되지 않은 동작이라 여기서는 쓰지 않는다.
이름 자체도 사라진다 --- 여기서 i 를 적으면 컴파일 오류다. */
}
/* ③ C23 의 auto — 첫 칸에서 타입을 초기값으로부터 추론한다. */
static void c23_auto(void)
{
for (auto i = 0; i < 3; i++) /* i 는 int 로 추론된다 */
printf(" auto i = %d\n", i);
}
/* ④ C23 은 저장 클래스 제약을 없앴다 — 그래서 static 도 적을 수 있다.
적을 수 있다는 것과 적어야 한다는 것은 다르다. 이 함수가 그 이유다. */
static void static_counter(void)
{
for (static int calls = 0; calls < 2; calls++)
printf(" static clause-1: this line ran (calls = %d)\n", calls);
}
int main(void)
{
printf("[one declaration means one base type]\n");
one_base_type();
printf("\n[the name lives in the loop and nowhere else]\n");
scope_of_the_name();
printf("\n[the counter lives for the whole loop, a body variable does not]\n");
lifetime_of_the_two();
printf("\n[one object means one address]\n");
one_object_one_address();
printf("\n[C23: auto infers the type from the initializer]\n");
c23_auto();
printf("\n[C23: a static object in clause-1 is initialized once, ever]\n");
printf(" first call:\n");
static_counter();
printf(" second call:\n");
static_counter();
printf(" third call:\n");
static_counter();
printf(" the second and third calls print nothing - the counter kept its\n");
printf(" value from the first call, so the loop never ran again.\n");
return 0;
}
실행 결과
[one declaration means one base type]
a[0] = 10
a[1] = 20
a[2] = 30
[the name lives in the loop and nowhere else]
inside the loop i = 0
inside the loop i = 1
after the loop i = 100 (the outer one was never touched)
[the counter lives for the whole loop, a body variable does not]
turn 0: counter i = 0 (one object, kept), body = 1 (reborn)
turn 1: counter i = 1 (one object, kept), body = 1 (reborn)
turn 2: counter i = 2 (one object, kept), body = 1 (reborn)
the body variable never grew past 1 - it died at every closing brace
[one object means one address]
turn 0: &i is the same address as turn 0
turn 1: &i is the same address as turn 0
turn 2: &i is the same address as turn 0
[C23: auto infers the type from the initializer]
auto i = 0
auto i = 1
auto i = 2
[C23: a static object in clause-1 is initialized once, ever]
first call:
static clause-1: this line ran (calls = 0)
static clause-1: this line ran (calls = 1)
second call:
third call:
the second and third calls print nothing - the counter kept its
value from the first call, so the loop never ran again.
규칙 ① — 선언은 하나뿐이다. 첫 칸은 선언 한 개가 들어가는 자리라, 기본 타입도 하나다. for (int i = 0; double x = 0.0; ...) 같은 것은 아예 문법이 아니다(gcc·clang 모두 “expected expression before double”). 다만 같은 기본 타입에서 파생된 것들은 얼마든지 함께 적을 수 있다 — 시연의
for (int *p = a, *end = a + 3, n = 0; p != end; p++, n++)이 그 예다. int 하나에서 포인터 둘과 정수 하나가 나왔다. 타입이 정말 달라야 한다면 방법은 둘이다 — 하나를 루프 밖에 두거나, 구조체 하나로 묶거나.
규칙 ② — 이름은 루프에서 태어나 루프에서 죽는다. 이 규칙에는 서로 다른 두 축이 겹쳐 있어서, 절을 하나 떼어 자세히 본다.
33.3.1 첫 칸의 이름은 어디까지 보이고 언제까지 사는가#
두 물음을 갈라 놓는 것이 먼저다. 어디서 그 이름이 보이는가(스코프)와 그 객체가 언제부터 언제까지 존재하는가(수명)는 다른 축이다 — 이름은 안 보이는데 객체는 살아 있을 수 있고(다른 함수를 부르는 동안 우리 지역 변수가 그렇다), 반대는 있을 수 없다.
스코프. 표준이 한 문장으로 정한다 — 「clause-1 이 선언이면, 그것이 선언하는 식별자의 스코프는 선언의 나머지 부분과 루프 전체이고 나머지 두 수식도 포함한다.」2 그림으로 옮기면 이렇다.
int i = 100; /* 바깥의 i */
for (int i = 0; i < 3; i++) { /* ← 여기서 태어난 i 가 */
printf("%d\n", i); /* 조건·갱신·몸통 어디서나 보이고 */
} /* ← 여기서 이름이 사라진다 */
printf("%d\n", i); /* 다시 바깥의 i --- 100 그대로다 */바깥에 같은 이름이 있으면 가린다(shadowing). 시연에서 루프 안의 i 가 0, 1 로 찍히는 동안 바깥 i 는 100 그대로인 것이 그 확인이다. 가림은 문법 위반이 아니라 정상 동작이지만 읽는 사람을 헷갈리게 하므로, gcc·clang 의 -Wshadow 로 경고를 켜 두는 코드베이스가 많다.
수명. 첫 칸의 변수는 저장 클래스를 적지 않았으므로 자동 저장 기간이다. 표준의 규칙은 「그 객체의 수명은 연관된 블록에 들어설 때 시작해 그 블록의 실행이 어떤 식으로든 끝날 때까지이며, 안쪽 블록에 들어가거나 함수를 부르는 것은 실행을 중단시킬 뿐 끝내지 않는다」이다.3 여기서 「연관된 블록」이 무엇인지가 핵심인데, C23 은 반복문 자체를 하나의 블록으로 보고 몸통을 그 안의 또 다른 블록으로 본다.4
그래서 결론이 이렇게 갈린다.
| 어디에 선언했나 | 수명 | 그래서 |
|---|---|---|
첫 칸 for (int i = 0; ...) | 루프 전체에 걸쳐 객체 하나 | 바퀴가 바뀌어도 같은 객체다 — 그래서 값이 이어지고, 주소도 같다 |
몸통 안 for (...) { int t = 0; ... } | 매 바퀴 새로 태어나 그 바퀴 끝에 죽는다 | 초기화가 매번 다시 일어난다 — 값을 누적할 수 없다 |
루프 밖 int i; for (i = 0; ...) | 함수의 블록이 끝날 때까지 | 루프가 끝난 뒤에도 값이 남는다 — 다음 루프가 물려받는 사고 |
표 33.5 — 선언한 자리에 따라 달라지는 수명
시연이 이 셋을 그대로 재어 보인다. 몸통 변수는 세 바퀴 내내 1 을 넘지 못하고 (매 바퀴 0 으로 다시 태어나므로), 첫 칸의 i 는 세 바퀴 모두 같은 주소다 — 객체가 하나라는 뜻이다.
흔한 오해. “루프를 한 바퀴 돌 때마다 카운터가 새로 만들어진다”
C 에서는 그렇지 않다. 첫 칸의 변수는 루프 하나에 객체 하나이고, 바퀴가 바뀌는 것은 그 객체의 값이 바뀌는 것일 뿐이다. 시연이 세 바퀴의 &i 를 맞대어 같은 주소임을 보인다.
헷갈리는 이유가 있다. 몸통 안에서 선언한 변수는 정말로 매 바퀴 새로 태어나고, 그것이 훨씬 자주 쓰이기 때문이다. 그리고 다른 언어의 습관도 있다 — 예컨대 자바스크립트의 let 이나 클로저를 잡는 언어들은 반복마다 새 변수를 만들어 주는 쪽을 택했다. C 는 그런 장치가 없으므로, 「매 바퀴의 값을 따로 남기고 싶다」면 몸통 안에서 선언하거나 어딘가에 직접 담아야 한다.
반례. 루프가 끝난 뒤에도 그 첨자를 가리키기
int *p;
for (int i = 0; i < 3; i++)
p = &i; /* 루프가 끝나면 이 주소는 죽은 객체를 가리킨다 */
printf("%d\n", *p); /* 정의되지 않은 동작 */이름이 사라지는 것과 객체가 사라지는 것은 다른 일이고, 여기서는 둘 다 일어난다. 루프의 실행이 끝나는 순간 그 블록의 자동 저장 기간 객체는 수명이 끝나므로, 주소를 어디에 남겼든 그 주소로 읽는 것은 정의되지 않은 동작이다 (45장의 수명 이야기가 이것이다). gcc 는 이런 패턴 일부를 -Wdangling-pointer 로 잡아 주지만, 저장해 두었다가 나중에 쓰는 형태는 잡지 못한다.
루프가 끝난 뒤에도 값이 필요하다면 방법은 둘이다 — 루프 밖의 변수에 담아 두거나, 첨자가 아니라 찾은 값 자체를 복사해 두는 것.
문. 그러면 첨자를 루프 밖에 선언하는 옛 방식(int i; for (i = 0; ...))은 나쁜가?
답. C89 에서는 그 방법밖에 없었다(첫 칸의 선언은 C99 부터다). 오늘 그 꼴을 보면 대개 옛 코드이거나 C89 를 고수해야 하는 자리다.
새로 쓴다면 첫 칸에서 선언하는 쪽이 낫다. 이유는 취향이 아니라 수명이 짧을수록 실수할 자리가 줄어든다는 것이다 — 루프가 끝나면 이름도 객체도 사라지므로, 다음 루프가 값을 물려받는 사고가 아예 생기지 않고, 같은 함수 안에서 i 를 재사용하다 꼬이는 일도 없다. 59장의 이름 이야기에서 다시 만나는 원칙 그대로다 — 이름은 필요한 만큼만 살려 둔다.
예외는 하나다. 루프가 끝난 자리의 첨자 값이 필요할 때 — 「어디까지 갔는가」를 루프 뒤에서 물어야 한다면 밖에 선언해야 한다. 다만 그럴 때도 대개는 찾은 결과를 따로 담는 편이 읽기 쉽다.
규칙 ③ — 저장 클래스에는 사연이 있다. C99 가 첫 칸의 선언을 허용하면서 같이 넣은 제약이 있었다.
「for 문의 선언부는 저장 클래스가 auto 또는 register 인 객체의 식별자만 선언해야 한다.」5 쉽게 말해 평범한 지역 변수만 적으라는 뜻이고, static·extern·typedef·구조체 정의는 전부 위반이었다.
그런데 C23 이 이 제약을 통째로 지웠다. 표준화 과정에서 「constexpr 은 되는가」라는 물음(영국의 GB-125 의견)이 올라왔고, 답을 궁리하던 중 「이 제약이 왜 있어야 하는지 우리도 모르겠다」는 결론이 나와 제약 자체가 삭제됐다.6 확인해 보면 그렇다 — C23 위원회 초안 N3054 의 6.8.6.1 에는 그 제약이 있고, 그다음 초안 N3096 부터는 없다. 오늘의 C23 본문에 남은 반복문 제약은 단 하나, 「조건식은 스칼라 타입이어야 한다」뿐이다.
재어 보면 컴파일러들도 그대로 따라와 있다.
| 첫 칸에 적은 것 | -std=c11 -pedantic-errors | -std=c23 | 비고 |
|---|---|---|---|
int i = 0 | OK | OK | 기본형 |
int *p = a, n = 0 | OK | OK | 같은 기본 타입의 파생은 함께 |
register int i = 0 | OK | OK | 옛 제약이 허용하던 나머지 하나 |
static int i = 0 | 진단 | OK | gcc: “declaration of static variable … in for loop initial declaration” / clang: “declaration of non-local variable in for loop is a C23 extension” |
typedef int T; | 진단 | OK | clang: “non-variable declaration in for loop is a C23 extension” |
struct P { int x; } p | gcc 진단 | OK | clang 은 C11 에서도 조용했다 — 진단은 의무이되 형태는 구현이 정한다 |
constexpr int n = 3 | C23 의 낱말 | OK | 애초에 이 물음이 제약을 지우게 만들었다 |
auto i = 0 | int 로 오해 | 타입 추론 | C23 의 auto 는 저장 클래스가 아니라 추론이다 |
_Thread_local int i | 오류 | 오류 | 함수 스코프의 이름은 암묵적으로 auto 라 충돌한다 |
int i = 0( -std=c90) | 오류 | — | “for loop initial declarations are only allowed in C99 or C11 mode” |
표 33.6 — for 첫 칸에 적을 수 있는 것 — 표준별·컴파일러별
측정은 gcc 14.2·clang 22.1 로 했다. 표에서 정작 눈에 띄는 것은 진단이 -pedantic-errors 를 켰을 때만 나온다는 사실이다. 그냥 -std=c11 -Wall -Wextra 로 빌드하면 두 컴파일러 모두 static 을 조용히 받아 준다 — 「경고가 없다」가 「표준에 맞다」는 뜻이 아니라는 16장의 이야기가 여기서도 그대로다.
반례. 첫 칸에 static 을 적기
C23 이 허용한다고 해서 좋은 생각이 되는 것은 아니다. static 인 객체는 프로그램이 시작할 때 딱 한 번 초기화되고 값을 계속 지닌다. 그래서
for (static int calls = 0; calls < 2; calls++)
...이 든 함수는 첫 호출에서만 돈다. 두 번째 호출부터 calls 는 이미 2 이고, 「초기화」로 보이는 = 0 은 다시 실행되지 않는다. 시연이 그 침묵을 그대로 찍어 보인다 — 두 번째와 세 번째 호출은 아무것도 인쇄하지 않는다.
static 을 쓰려면 그 뜻(호출 사이에 값을 남긴다)이 필요한 것이므로, 루프의 첫 칸이 아니라 함수 안에 따로 선언해 의도를 드러내는 편이 낫다. 수명 이야기는 45장이다.
문. 그러면 첫 칸에는 무엇을 적는 것이 좋은가?
답. 루프의 살림만 적는 것이 좋다. 자동차 산업의 코딩 규약 MISRA(Motor Industry Software Reliability Association) C 는 아예 「잘 형성된 for 루프」를 규칙으로 못 박아 두었다(규칙 14.2 — 101장) — 첫 칸은 루프 카운터의 초기화만, 조건 칸은 그 카운터의 비교만(부수효과 없이), 갱신 칸은 그 카운터의 증감만. 세 칸 모두 빈 for (;;) 는 예외로 허용한다.
규약을 따르지 않는 자리에서도 이 규칙은 지킬 만하다. for 의 세 칸은 읽는 사람이 「몇 번 도는가」를 한눈에 재구성하는 자리이고, 거기에 다른 일이 섞이는 순간 그 값이 사라지기 때문이다.
33.4 조건 칸은 검사가 아니라 코드다#
첫 칸이 한 번 실행되는 자리라면, 조건 칸은 매 바퀴 실행되는 자리다. 그리고 실행되는 것은 무엇이든 부수효과를 낼 수 있다.
examples/ch32/cond_effect.c
/* 조건 칸은 「검사」가 아니라 매 바퀴 실행되는 코드다 — 그 값을 재어 본다. */
#include <stdio.h>
static int limit_calls = 0;
/* 매 바퀴 불리면서 자기가 몇 번 불렸는지 센다 */
static int limit(void)
{
limit_calls++;
return 5;
}
static int probe_calls = 0;
static int probe(int i)
{
probe_calls++;
return i < 3;
}
int main(void)
{
printf("[the condition runs once more than the body]\n");
int turns = 0;
for (int i = 0; i < limit(); i++)
turns++;
printf(" body ran %d times, limit() was called %d times\n", turns, limit_calls);
printf(" the last call is the one that fails and ends the loop\n");
printf("\n[short-circuit can skip the side effect entirely]\n");
probe_calls = 0;
int guard = 0; /* 왼쪽이 거짓이면 오른쪽은 평가되지 않는다 */
for (int i = 0; guard && probe(i); i++)
;
printf(" guard is false: probe() was called %d time(s)\n", probe_calls);
probe_calls = 0;
guard = 1;
turns = 0;
for (int i = 0; guard && probe(i); i++)
turns++;
printf(" guard is true : probe() was called %d time(s), body ran %d times\n",
probe_calls, turns);
printf("\n[assignment inside the condition - the parentheses are the point]\n");
const char *text = "abc";
const char *p = text;
int c = 0, seen = 0, first = 0;
while ((c = *p++) != '\0') { /* 담고 나서 비교한다 */
if (seen == 0)
first = c;
seen++;
}
printf(" with parentheses : read %d characters, first value stored = %d ('%c')\n",
seen, first, (char)first);
p = text;
seen = 0;
first = 0;
while ((c = (*p++ != '\0'))) { /* 비교부터 하면 0 또는 1 만 담긴다 */
if (seen == 0)
first = c;
seen++;
}
printf(" without parentheses: read %d characters, first value stored = %d\n",
seen, first);
printf(" the character is gone - only the truth value was kept\n");
printf(" gcc says: suggest parentheses around assignment used as truth value\n");
printf("\n[changing the counter inside the condition]\n");
int n = 3;
printf(" while (i < n) visits:");
for (int i = 0; i < n; i++)
printf(" %d", i);
printf("\n while (i++ < n) visits:");
int i = 0;
while (i++ < n)
printf(" %d", i);
printf("\n while (++i < n) visits:");
i = 0;
while (++i < n)
printf(" %d", i);
printf("\n i++ added 1 before the body saw i: 1..3 instead of 0..2,\n");
printf(" and ++i also compares the new value, so it turns one time fewer\n");
return 0;
}
실행 결과
[the condition runs once more than the body]
body ran 5 times, limit() was called 6 times
the last call is the one that fails and ends the loop
[short-circuit can skip the side effect entirely]
guard is false: probe() was called 0 time(s)
guard is true : probe() was called 4 time(s), body ran 3 times
[assignment inside the condition - the parentheses are the point]
with parentheses : read 3 characters, first value stored = 97 ('a')
without parentheses: read 3 characters, first value stored = 1
the character is gone - only the truth value was kept
gcc says: suggest parentheses around assignment used as truth value
[changing the counter inside the condition]
while (i < n) visits: 0 1 2
while (i++ < n) visits: 1 2 3
while (++i < n) visits: 1 2
i++ added 1 before the body saw i: 1..3 instead of 0..2,
and ++i also compares the new value, so it turns one time fewer
흔한 오해. “조건은 그냥 참·거짓을 보는 것이니 몇 번 보든 상관없다”
조건 칸에 함수 호출이 있으면 그 함수는 바퀴마다 다시 불린다. 그리고 횟수는 몸통보다 하나 더 많다 — 마지막 한 번은 「이제 그만」이라고 답하려고 부르는 것이기 때문이다. 시연이 그것을 셌다: 몸통 5 회, 조건 6 회다.
값이 변하지 않는 것을 조건에 두면 그 비용이 통째로 낭비된다. 문자열 길이를 재는 for (i = 0; i < strlen(s); i++) 가 교과서적인 예인데, 글자 하나를 볼 때마다 문자열 전체를 처음부터 다시 세는 셈이다(42–43장). 값이 변한다면 더 나쁘다 — 조건이 상태를 바꾸고 있다는 뜻이다.
부수효과가 조건에 들어가는 것 자체가 죄는 아니다. C 에는 그것을 전제로 한 관용구가 있다.
while ((c = getchar()) != EOF) { ... } /* 읽고, 담고, 비교한다 */한 문장 안에서 「다음 값을 가져와 변수에 담고, 그 값이 끝 표시인지 본다」를 끝낸다 — C 다운 문장이고, 표준 라이브러리의 입력 루프는 대개 이 꼴이다. 다만 괄호가 문법의 일부다. 괄호를 빠뜨린 while (c = getchar() != EOF) 는 비교가 먼저 붙어 c 에 문자가 아니라 0 또는 1 이 담긴다. 시연이 그 두 경우에 실제로 담긴 값을 찍어 보인다(97 대 1). gcc 는 이 자리에 경고를 준다 — “suggest parentheses around assignment used as truth value”.
문. 조건에 부수효과가 있으면, 그 효과는 반드시 일어나는가?
답. 아니다. &&·|| 의 단락 평가(31장) 때문에 일어나지 않을 수 있다.
for (int i = 0; guard && probe(i); i++) /* guard 가 거짓이면 probe 는 아예 안 불린다 */시연이 그 횟수를 셌다 — guard 가 거짓일 때 probe() 는 0 회다. 조건의 오른쪽에 「반드시 해야 하는 일」을 두면 그 일이 조용히 사라진다. CERT(Computer Emergency Response Team) 의 C 코딩 표준이 이것을 따로 규칙으로 두고 있을 만큼 흔한 사고다 (EXP02-C).7
규칙으로 적으면 이렇다 — 조건에 두어도 되는 부수효과는 「다음 값을 가져오는 것」뿐이다. 그 밖의 일(자원 할당, 상태 변경, 로그)은 몸통으로 내린다.
조건 칸이 루프 변수까지 손대면 셈이 한 칸씩 밀린다.
| 조건 칸 | 몸통이 보는 값 | 무엇이 다른가 |
|---|---|---|
i < n | 0, 1, 2 | 갱신은 갱신 칸에서만 일어난다 |
i++ < n | 1, 2, 3 | 비교는 옛 값으로, 몸통은 이미 증가한 값으로 |
++i < n | 1, 2 | 비교도 새 값으로 — 몸통이 도는 횟수까지 하나 준다 |
표 33.7 — 조건 칸이 루프 변수를 건드릴 때
앞의 둘은 도는 횟수가 같은데 몸통이 보는 값이 다르고, 셋째는 횟수마저 다르다. 첨자로 쓰는 자리에서는 이 한 칸이 곧 배열 밖 접근이 된다(39장). 부수효과와 평가 순서의 정식 규칙 — 어디에 경계가 있고 무엇이 정의되지 않은 동작인지 — 은 바로 다음 두 장(34–35장)의 주제다. 여기서는 규율만 가져간다: 조건 칸은 묻기만 하고, 바꾸지 않는다.
33.5 끝나지 않는 루프#
무한 루프는 셋 중 하나로 생긴다 — 갱신을 잊었거나, 갱신은 하는데 진행이 없는 갈래가 있거나, 조건이 타입 때문에 영영 참이거나.
실제 사례. 2008년 12월 31일, 준(Zune) 30GB 가 멈춘 날
그해 마지막 날, 마이크로소프트의 음악 재생기 준 30GB 가 전 세계에서 일제히 멈췄다. 하루가 지나 1월 1일이 되자 저절로 되살아났다. 원인은 기기의 시계 드라이버 — 프리스케일이 공급한 코드로 알려진 — 안의 루프 하나였다.8
while (days > 365) {
if (IsLeapYear(year)) {
if (days > 366) {
days -= 366;
year += 1;
}
} else {
days -= 365;
year += 1;
}
}1980년 1월 1일부터 센 날수를 연·월·일로 바꾸는 코드다. 윤년인 2008년의 마지막 날, days 는 366 이 된다. 조건 days > 365 는 참이라 루프에 들어가고, 윤년이라 위쪽 갈래로 가고, days > 366 은 거짓이라 아무 일도 일어나지 않는다. days 도 year 도 그대로인 채 다시 조건으로 — 영원히.
교훈은 문법이 아니라 사고법에 있다. 무한 루프를 막는 것은 「갱신을 잊지 않기」가 아니라 모든 갈래가 진행을 만드는가를 묻는 일이다. 이 장의 불변식 상자가 준 눈이 그것이다 — 「이 루프의 매 바퀴에서 반드시 줄어드는 양은 무엇인가?」 그 답이 없으면 루프는 끝난다는 보장이 없다.
반례. != 로 끝을 정하기
for (int i = 0; i != n; i += 2) /* n 이 홀수면 지나쳐 버린다 */!= 는 정확히 그 값일 때만 멈춘다. 걸음이 1 이 아니거나, 몸통에서 첨자를 건드릴 여지가 있으면 목표를 뛰어넘고 그대로 달아난다. < 나 <= 는 지나친 뒤에도 거짓이 되므로 같은 실수를 붙잡아 준다.
규칙: 끝을 정할 때는 <·<= 를 쓰고, != 는 「한 걸음씩 걷는 것이 확실한」 자리에만 쓴다. (포인터로 배열을 훑을 때가 그 확실한 자리다 — 41장.)
타입이 원인일 때도 있다. unsigned char 는 0–255 밖의 값을 가질 수 없으므로
for (unsigned char c = 0; c <= 255; c++) /* 조건이 영영 참이다 */는 255 다음에 0 으로 감아 돌아 끝나지 않는다. 다행히 이 패턴은 컴파일러가 잡아 준다 — gcc 는 -Wall -Wextra 에 든 -Wtype-limits 로 “comparison is always true due to limited range of data type” 라고 말한다. 부호 없는 첨자로 거꾸로 세는 i >= 0 도 같은 가족인데, 그 이야기는 42장에 있다.
흔한 오해. “무한 루프는 그냥 멈춰 있는 것이다”
두 가지 이유로 그렇지 않다.
① 보안 문제가 된다. 2022년 OpenSSL 의 BN_mod_sqrt() 함수가 소수가 아닌 나머지 연산의 밑수(modulus)에 대해 영원히 도는 버그가 발견됐다(CVE-2022-0778, 심각도 높음).9 공격자는 곡선 파라미터를 일부러 어긋나게 만든 인증서 하나를 보내는 것으로 서버를 붙잡아 둘 수 있었다 — 인증서 검증 전에 파싱이 먼저 일어나기 때문이다. 끝나지 않는 루프는 「서비스 거부」라는 이름의 취약점이다.
② 코드가 사라질 수도 있다. C11 이후의 표준은, 조건식이 상수 수식이 아니고 몸통·조건·갱신 어디에서도 입출력을 하지 않고 volatile 객체를 건드리지 않고 동기화도 하지 않는 반복문이라면, 구현이 그것이 끝난다고 가정해도 좋다고 정해 두었다.10 끝나지 않을 수 있는 루프를 「끝난다」고 가정한 채 최적화하면, 그 뒤의 코드가 무조건 실행되는 코드로 바뀔 수 있다.
실측하면 정말 그렇다. 다음 프로그램은 spin(1) 에서 영원히 도는데,
static unsigned spin(unsigned i) { while (i) i += 2; return i; }
int main(void) { puts("start"); spin(1); puts("done"); }| 빌드 | 결과 | 왜 |
|---|---|---|
gcc 14 · 16 (-O0–-O2) | 멈춰 있다 | 루프를 그대로 남겼다 |
clang 22 -O0 | 멈춰 있다 | 최적화를 안 했다 |
clang 22 -O1·-O2 | done 을 인쇄하고 끝난다 | 끝난다고 가정하고 루프를 지웠다 |
while (1) { } | 멈춰 있다 (clang -O2) | 상수 수식이라 가정이 적용되지 않는다 |
while (flag) { } (volatile) | 멈춰 있다 (clang -O2) | volatile 접근이 있으면 제외된다 |
표 33.8 — 끝나지 않는 루프를 두고 컴파일러가 하는 일
표의 마지막 두 줄이 중요하다 — 의도적인 무한 루프는 for (;;) 나 while (1) 로 적으라는 관용구에 이런 근거까지 있는 것이다. 임베디드의 주 루프가 이 꼴인 데는 이유가 있다(104장).
정수 오버플로가 끼면 같은 프로그램이 최적화 수준에 따라 다르게 행동한다.
int n = 0;
for (int i = 1; i > 0; i *= 2) n++; /* 부호 있는 오버플로 = 정의되지 않은 동작 */
printf("%d\n", n);-O0 로 빌드하면 31 을 인쇄하고 끝나고, -O2 로 빌드하면 영영 끝나지 않는다(gcc 14 실측). 컴파일러가 「부호 있는 정수는 넘치지 않는다」는 전제 위에서 i > 0 을 항상 참으로 접었기 때문이다. 답이 틀린 것이 아니라 질문이 틀린 것이고, 이런 자리를 정의되지 않은 동작이라 부른다(28·54장).
33.6 겹쳐 도는 루프 — 이중, 삼중#
루프의 몸통도 문장이므로, 그 안에 루프가 또 들어갈 수 있다. 바깥 한 바퀴마다 안쪽이 처음부터 끝까지 돈다 — 그래서 도는 횟수는 더해지는 것이 아니라 곱해진다.
examples/ch32/nested.c
/* 겹쳐 도는 루프 — 이중과 삼중, 그리고 겹칠 때 새로 생기는 실수들. */
#include <stdio.h>
/* ① 이중 for — 바깥 한 바퀴마다 안쪽이 처음부터 다시 돈다 */
static void times_table(void)
{
for (int row = 2; row <= 4; row++) {
printf(" ");
for (int col = 1; col <= 5; col++)
printf("%s%2d x %d = %2d", col == 1 ? "" : " ", row, col, row * col);
printf("\n"); /* 안쪽 루프가 끝난 자리 = 한 줄의 끝 */
}
}
/* ② 안쪽 루프 변수를 바깥에서 만들면 「다시 처음부터」가 사라진다 */
static void forgot_to_reset(void)
{
int visits = 0;
int col = 1; /* 초기화가 바깥에 있다 */
for (int row = 2; row <= 4; row++)
for (; col <= 5; col++) /* 두 번째 바퀴부터 col 은 이미 6 이다 */
visits++;
printf(" counter declared outside : %d cells visited\n", visits);
visits = 0;
for (int row = 2; row <= 4; row++)
for (int c = 1; c <= 5; c++) /* 안쪽에서 선언하면 매 바퀴 새로 시작한다 */
visits++;
printf(" counter declared inside : %d cells visited\n", visits);
}
/* ③ 안쪽에서 바깥 카운터를 올리는 실수 (실제 사례의 패턴).
멈추게 하려고 한도를 두었다 — 실제 코드에는 그런 안전장치가 없다. */
static void wrong_counter(void)
{
int steps = 0;
for (int j = 0; j < 3; j++) {
for (int k = 0; k < 3; j++) { /* k 가 아니라 j 를 올리고 있다 */
steps++;
if (steps >= 100) /* 이 줄이 없으면 끝나지 않는다 */
break;
}
if (steps >= 100)
break;
}
printf(" wrong counter (k stands still): %d steps and still not done\n", steps);
steps = 0;
for (int j = 0; j < 3; j++)
for (int k = 0; k < 3; k++)
steps++;
printf(" right counter : %d steps, 3 x 3 as intended\n", steps);
}
/* ④ 삼중 for — 세 값을 한꺼번에 훑는 전형. 피타고라스 세 쌍을 찾는다. */
static void triples(int n)
{
long long tries = 0;
int found = 0;
for (int a = 1; a <= n; a++)
for (int b = a; b <= n; b++) /* b 를 a 부터 시작해 중복을 없앤다 */
for (int c = b; c <= n; c++) {
tries++;
if (a * a + b * b == c * c) {
printf(" %2d^2 + %2d^2 = %2d^2\n", a, b, c);
found++;
}
}
printf(" n = %d: %d triple(s) found in %lld checks\n", n, found, tries);
}
int main(void)
{
printf("[a double loop - the inner one restarts every outer turn]\n");
times_table();
printf("\n[where the inner counter is declared decides whether it restarts]\n");
forgot_to_reset();
printf("\n[raising the wrong counter in the inner loop]\n");
wrong_counter();
printf("\n[a triple loop - Pythagorean triples up to n]\n");
triples(20);
printf("\n[how fast the work grows]\n");
for (int n = 10; n <= 40; n *= 2) {
long long steps = 0;
for (int a = 1; a <= n; a++)
for (int b = 1; b <= n; b++)
for (int c = 1; c <= n; c++)
steps++;
printf(" n = %2d -> %lld steps (n^3)\n", n, steps);
}
return 0;
}
실행 결과
[a double loop - the inner one restarts every outer turn]
2 x 1 = 2 2 x 2 = 4 2 x 3 = 6 2 x 4 = 8 2 x 5 = 10
3 x 1 = 3 3 x 2 = 6 3 x 3 = 9 3 x 4 = 12 3 x 5 = 15
4 x 1 = 4 4 x 2 = 8 4 x 3 = 12 4 x 4 = 16 4 x 5 = 20
[where the inner counter is declared decides whether it restarts]
counter declared outside : 5 cells visited
counter declared inside : 15 cells visited
[raising the wrong counter in the inner loop]
wrong counter (k stands still): 100 steps and still not done
right counter : 9 steps, 3 x 3 as intended
[a triple loop - Pythagorean triples up to n]
3^2 + 4^2 = 5^2
5^2 + 12^2 = 13^2
6^2 + 8^2 = 10^2
8^2 + 15^2 = 17^2
9^2 + 12^2 = 15^2
12^2 + 16^2 = 20^2
n = 20: 6 triple(s) found in 1540 checks
[how fast the work grows]
n = 10 -> 1000 steps (n^3)
n = 20 -> 8000 steps (n^3)
n = 40 -> 64000 steps (n^3)
시연의 이중 루프는 곱셈표를 찍는다. 읽는 요령은 하나다 — 안쪽 루프가 끝난 자리가 「한 줄이 끝난 자리」이고, 줄바꿈이 그 자리에 있다.
삼중이 되면 그 곱셈이 그대로 비용이 된다. 시연이 세 겹으로 피타고라스 세 쌍 ()을 찾는데, 이면 1,540 번을 본다. 걸음 수는 이 커질수록 으로 자란다 — 10 이면 1,000, 20 이면 8,000, 40 이면 64,000 이다. 한 겹을 더할 때마다 비용에 곱셈이 하나 붙는다는 감각이 중첩 루프에서 가장 먼저 익혀야 할 것이다. (그래서 시연은 안쪽 루프를 b = a 부터, 그다음을 c = b 부터 시작해 같은 조합을 두 번 보지 않는다 — 중첩 루프의 첫 최적화는 대개 이렇게 범위를 줄이는 것이다.)
겹치는 순간 새로 생기는 실수가 넷 있다.
| 실수 | 무슨 일이 일어나는가 | 예방 |
|---|---|---|
| 안쪽 카운터를 바깥에서 선언 | 두 번째 바퀴부터 안쪽 루프가 다시 시작하지 않는다. 시연에서 15 칸이 5 칸이 됐다 | 카운터는 그것을 쓰는 루프의 첫 칸에서 선언한다 |
| 안쪽에서 바깥 카운터를 갱신 | 안쪽이 제자리를 돌거나 바깥이 건너뛴다 | 아래 실제 사례 |
| 두 루프가 같은 이름을 씀 | 안쪽이 바깥을 가려, 바깥 루프의 값이 몸통에서 사라진다 | i·j 대신 row·col 처럼 뜻이 있는 이름을 쓴다 |
break 로 두 겹을 벗으려 함 | break 는 한 겹만 벗는다 | 42장의 네 가지 방법 |
표 33.9 — 루프를 겹칠 때 새로 생기는 실수
실제 사례. Doom 3 의 j 와 k
2011년 id 소프트웨어가 공개한 Doom 3 의 소스에는 이런 이중 루프가 있다 (neo/idlib/geometry/Surface_Polytope.cpp, 65번째 줄).
for ( j = 0; j < w.GetNumPoints(); j++ ) {
for ( k = 0; k < verts.Num(); j++ ) { /* k++ 이어야 한다 */안쪽 루프가 자기 카운터 k 대신 바깥의 j 를 올리고 있다. 정적 분석기가 이 패턴을 따로 진단으로 두고 있을 만큼 흔한 사고다(PVS-Studio V533).11 안쪽의 break 가 대개 먼저 걸려 준 덕에 티가 나지 않았을 뿐, 논리는 이미 어긋나 있었다 — 이듬해 공개된 BFG 에디션의 같은 파일에는 k++ 로 고쳐져 있다.
눈으로 못 잡는 종류라는 것이 이 사례의 값이다. 한 글자다. 그래서 이름을 뜻으로 짓는 것이 규율이 된다 — row·col·vert 였다면 눈에 띄었을 것이다.
문. 이중 루프에서 어느 쪽을 바깥에 두어야 하는가?
답. 결과만 보면 대개 어느 쪽이든 같다. 속도는 다르다. 자료가 기억 속에 늘어선 순서와 걷는 순서가 맞아야 캐시가 실어 온 것을 알뜰히 쓰기 때문이다 (12장). 규칙은 한 줄로 적을 수 있다 — 가장 안쪽 루프가 기억 속에서 가장 촘촘히 움직이게 하라.
이 규칙을 진짜로 쓰려면 「기억 속에 어떤 순서로 늘어서는가」를 알아야 하고, 그것은 여러 겹으로 된 배열의 이야기(40장)다. 그래서 순회 순서와 그 실측 — 같은 계산이 여덟 배까지 벌어지는 표 — 은 42장에 두었다. 다중 루프에서 빠져나오는 법과 goto 의 정당한 자리도 거기에 있다.
33.7 재회 — Duff’s device#
13장에서 예약한 전설을 만날 시간이다. 이제 switch의 폴스루 (32장)와 루프(이 장)를 모두 알고 있다.
1983년, 루카스필름의 Tom Duff는 데이터를 장치로 복사하는 루프가 너무 느려 고심하고 있었다. 한 바퀴마다 드는 살림 비용(조건 검사·갱신)을 줄이려 여덟 개씩 묶어 처리(언롤링)하되 — 개수가 8의 배수가 아닐 때의 나머지 처리를, 그는 아무도 상상 못 한 방법으로 해결했다:
switch (count % 8) {
case 0: do { *to = *from++;
case 7: *to = *from++;
case 6: *to = *from++;
case 5: *to = *from++;
case 4: *to = *from++;
case 3: *to = *from++;
case 2: *to = *from++;
case 1: *to = *from++;
} while ((count -= 8) > 0);
}(*to, *from++ 부분은 36장의 포인터 문법이라 아직 겉모양만 보면 된다 — 핵심은 구조다.) 읽는 법: switch가 루프의 한복판으로 건너 뛴다. case는 이름표일 뿐이라는 32장의 사실을 기억하면 — 이름표가 do-while의 몸통 안에 찍혀 있어도 문법 위반이 아니다. 첫 진입은 나머지 개수만큼만 처리하는 지점으로 뛰어들고, 이후는 do-while이 여덟 줄짜리 몸통을 통째로 돈다. 나머지 처리와 언롤링이 한 몸이 된 것이다.
Duff 자신도 “발견하고 나서 자부심과 혐오감이 뒤섞였다”는 소감을 남긴 이 코드는, 합법과 기괴함의 경계에서 C 문법의 유연함(어쩌면 헐거움)을 보여 주는 기념비다. 그리고 오늘의 교훈은 13장에서 예고한 그대로다 — 이 곡예는 이제 사람의 일이 아니다. 현대 컴파일러는 평범하게 쓴 루프를 알아서 언롤링한다(14장의 편집자). Duff’s device는 박물관의 걸작으로 감상하고, 우리의 루프는 시연의 for처럼 평범하고 읽기 쉽게 쓰는 것 — 그것이 모던 C다.
반복까지 갖추며 흐름의 도구가 완성됐다 — 순차(20장), 분기(32장), 반복(33장). 다음 장은 함수라는 장치의 의미를 파고든다 — 값은 어떻게 건너가는가, 부수효과란 정확히 무엇인가, 그리고 21장에서 심어 둔 씨앗(평가 순서)의 정식 답까지.
(루프의 나머지 기법 — 순회 순서를 캐시에 맞추는 실측, 여러 겹을 한 번에 벗는 네 가지 방법, goto 가 정당한 두 자리, 매크로를 한 문장으로 만드는 do { } while (0), 그리고 역순 순회 같은 관용구 — 은 배열과 매크로를 배운 뒤라야 쓸 수 있어서 42장에 모아 두었다.)
주
- Joshua Bloch. Extra, Extra — Read All About It: Nearly All Binary Searches and Mergesorts are Broken. Google Research Blog, 2006-06-02. 같은 결함이 여러 표준 라이브러리와 교과서에 함께 있었다. ↩
- ISO/IEC 9899 (C23) 6.8.6.4, 문단 1. 초안 N3220. ↩
- ISO/IEC 9899 (C23) 6.2.4, 문단 5–6. ↩
- ISO/IEC 9899 (C23) 6.8.1. 반복문은 primary block, 몸통은 secondary block 이다. 「블록 B 가 바깥 블록 A 의 일부로 나타나면, B 에 딸린 식별자의 스코프와 객체의 수명은 A 의 B 바깥 부분까지 미치지 않는다」. ↩
- ISO/IEC 9899:2011 (C11) 6.8.5 Iteration statements, 제약 3. 초안 N1570.
open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf↩ - N3078: Comment response for GB-125, WG14, 2023.
open-std.org/jtc1/sc22/wg14/www/docs/n3078.htm↩ - EXP02-C. Be aware of the short-circuit behavior of the logical AND and OR operators. SEI CERT C Coding Standard, Carnegie Mellon University.
wiki.sei.cmu.edu↩ - Brian Hayes. The Zune bug. bit-player, 2009-01-02.
bit-player.org/2009/the-zune-bug코드 조각의 출처는 공개된 드라이버 소스로 알려져 있다. ↩ - CVE-2022-0778: Infinite loop in
BN_mod_sqrt()reachable when parsing certificates. OpenSSL Security Advisory, 2022-03-15.openssl-library.org/news/secadv/20220315.txt↩ - ISO/IEC 9899 (C23) 6.8.6.1, 문단 4. 초안 N3220. 각주는 그 의도를 「종료를 증명할 수 없어도 빈 루프를 제거하는 따위의 변환을 허용하려는 것」이라고 밝힌다. ↩
- PVS-Studio. Analyzing the Doom 3 source code, 2011.
pvs-studio.com/en/blog/posts/cpp/0120/진단 V533 = 「for안에서 엉뚱한 변수를 증가시키고 있을 가능성이 높다」. ↩