C 제어문 - if switch 반복문과 컴파일러가 잡아 주는 실수 (C 기초 4장)
이 장에서 배우는 것
3장까지는 값 하나와 식 하나를 봤다. 이제 그 식을 조건과 반복으로 묶는다. C의 제어문은 다른 언어와 모양이 비슷하지만, 조건식이 "0이면 거짓, 0이 아니면 참"인 정수라는 점과 switch가 break 없이 다음 case로 흘러내린다는 점 때문에 C만의 실수가 생긴다.
if/else if사슬과switch를 상황에 맞게 고른다.for,while,do-while의 차이와break,continue의 범위를 안다.- 대입을 비교로 착각한
if (x = 0), 빈 본문if (...);, 의도치 않은 fall-through를 컴파일러 경고로 잡는다. - 반복문을 "변수 값이 줄마다 어떻게 바뀌는가" 표로 추적하는 습관을 들인다.
문제 상황
재시도 횟수를 확인하는 코드가 있다. retries가 3인데도 "재시도 없음" 경로를 타지 않고, 로그를 찍어 보니 retries가 어느새 0이 되어 있다. 다른 곳에서는 로그 수준이 1인데 수준 2의 메시지까지 찍힌다. 두 버그 모두 컴파일은 문제없이 됐다. 첫 번째는 =와 ==, 두 번째는 빠진 break 하나다.
메모리 그림: 조건은 정수, 반복은 표
if (retries = 0) 의 실제 순서
① retries 칸에 0 을 쓴다 retries: [ 3 ] → [ 0 ]
② 식의 값 = 방금 쓴 값 0
③ 0 은 거짓 → 본문 건너뜀 (그리고 retries 는 이미 0)
for (int i = 1; i <= 5; i++) sum += i; 추적 표
┌────┬─────┬────────────┐
│ i │ sum │ i <= 5 ? │
├────┼─────┼────────────┤
│ 1 │ 1 │ 참 │
│ 2 │ 3 │ 참 │
│ 3 │ 6 │ 참 │
│ 4 │ 10 │ 참 │
│ 5 │ 15 │ 참 │
│ 6 │ 15 │ 거짓 → 끝 │
└────┴─────┴────────────┘
i 는 for 안에서만 사는 지역 변수(스택의 한 칸)
반복문 버그는 대부분 "몇 번 도는가"(경계)에서 생긴다. 헷갈리면 이렇게 표를 그려 첫 줄과 마지막 줄, 그리고 끝나는 순간의 값을 확인한다. 10개를 도는 반복은 i = 0; i < 10이거나 i = 1; i <= 10이다. 둘을 섞으면 한 번 더 돌거나 덜 돈다.
조건 자리의 대입은 변수를 0 으로 덮어쓴 뒤 그 값 0 을 거짓으로 판단한다. 비교는 == 로 쓴다.
for 는 초기화 한 번, 조건 검사, 본문, 증감식을 반복하며 조건이 거짓이 되는 순간 끝난다. continue 는 증감식으로, break 는 끝으로 간다.
완성 코드
성적 판정: if 사슬과 switch
#include <stdio.h>
static char grade_of(int score) {
if (score < 0 || score > 100) {
return '?';
} else if (score >= 90) {
return 'A';
} else if (score >= 80) {
return 'B';
} else if (score >= 70) {
return 'C';
}
return 'F';
}
static const char *message_of(char grade) {
switch (grade) {
case 'A':
case 'B':
return "합격";
case 'C':
return "보충 과제";
case '?':
return "점수 오류";
default:
return "재시험";
}
}
int main(void) {
int scores[] = {95, 88, 70, 42, 101};
int count = (int)(sizeof scores / sizeof scores[0]);
for (int i = 0; i < count; i++) {
char g = grade_of(scores[i]);
printf("%3d점 -> %c (%s)\n", scores[i], g, message_of(g));
}
return 0;
}
반복문과 break·continue
#include <stdio.h>
int main(void) {
int sum = 0;
for (int i = 1; i <= 5; i++) {
sum += i;
printf("i=%d sum=%d\n", i, sum);
}
int n = 0;
while (n > 0) {
printf("while 본문\n");
}
do {
printf("do-while 본문은 한 번 실행된다 (n=%d)\n", n);
} while (n > 0);
int table[3][4] = {
{3, 8, 1, 9},
{7, -2, 5, 4},
{6, 0, -1, 2},
};
for (int r = 0; r < 3; r++) {
int used = 0;
for (int c = 0; c < 4; c++) {
if (table[r][c] < 0) {
printf("%d행 %d열에서 음수 발견, 이 행은 중단\n", r, c);
break;
}
if (table[r][c] == 0) {
continue;
}
used++;
}
printf("%d행에서 센 값: %d개\n", r, used);
}
return 0;
}
컴파일러가 잡는 실수들
#include <stdio.h>
int main(void) {
int retries = 3;
if (retries = 0) {
printf("재시도 없음\n");
}
printf("retries = %d\n", retries);
if (retries > 5);
{
printf("이 줄은 조건과 상관없이 실행된다\n");
}
return 0;
}
#include <stdio.h>
int main(void) {
int level = 1;
switch (level) {
case 1:
printf("로그 수준 1: 오류만\n");
case 2:
printf("로그 수준 2: 경고까지\n");
break;
default:
printf("모든 로그\n");
}
return 0;
}
줄별 해설
if (score < 0 || score > 100)— 잘못된 입력을 가장 먼저 걸러 낸다.||는 왼쪽이 참이면 오른쪽을 계산하지 않는다(단락 평가).p != NULL && p->x > 0처럼 앞의 검사가 뒤의 계산을 보호하는 데 쓴다.else if (score >= 90)… — 위에서부터 차례로 검사하므로 범위를 큰 쪽부터 좁혀 가면 조건을 반쯤만 써도 된다. 88점은 첫 조건에서 떨어지고 두 번째에서 잡힌다.switch (grade)— 정수(문자 포함) 값 하나를 여러 상수와 비교할 때 쓴다.case 'A':바로 아래에case 'B':를 붙이면 두 값이 같은 처리를 공유한다. 이것은 의도한 fall-through다.default:— 어떤case에도 맞지 않을 때 실행된다. 빠뜨리지 말고 "예상 밖의 값"을 처리하는 곳으로 쓴다.sizeof scores / sizeof scores[0]— 배열 전체 바이트를 원소 하나의 바이트로 나눠 원소 수를 얻는다. 6장에서 이 식이 함수 안에서는 통하지 않는 이유를 본다.while (n > 0)— 조건을 먼저 검사하므로 한 번도 실행되지 않을 수 있다.do { } while (n > 0);은 본문을 실행한 뒤 검사하므로 최소 한 번 실행된다. 메뉴 입력처럼 "일단 한 번 받고 나서 판단"할 때 쓴다.break— 가장 안쪽의 반복문(또는switch) 하나만 빠져나간다. 바깥for (r ...)는 계속 돈다.continue— 이번 회차의 나머지를 건너뛰고 증감식(c++)으로 간다. 0은 세지 않는다.if (retries = 0)— 비교가 아니라 대입이다. 대입식의 값은 대입한 값(0)이므로 조건은 늘 거짓이고, 변수는 망가진다.if (retries > 5);— 세미콜론이 빈 문장 하나를 만들어 그것이if의 본문이 된다. 뒤의 중괄호 블록은 조건과 상관없는 별개의 블록이다.case 1:뒤break누락(fallthrough.c) — 수준 1을 처리한 뒤 그대로case 2:의 코드까지 실행한다.
실제 실행 결과
$ ./grade
95점 -> A (합격)
88점 -> B (합격)
70점 -> C (보충 과제)
42점 -> F (재시험)
101점 -> ? (점수 오류)
$ ./loops
i=1 sum=1
i=2 sum=3
i=3 sum=6
i=4 sum=10
i=5 sum=15
do-while 본문은 한 번 실행된다 (n=0)
0행에서 센 값: 4개
1행 1열에서 음수 발견, 이 행은 중단
1행에서 센 값: 1개
2행 2열에서 음수 발견, 이 행은 중단
2행에서 센 값: 1개
1행은 -2에서 break로 멈춰 앞의 7 하나만 셌다. 2행은 0을 continue로 건너뛰고 6만 센 뒤 -1에서 멈췄다. 행마다 used가 0으로 다시 시작하는 것은 안쪽 블록에 선언된 변수가 회차마다 새로 만들어지기 때문이다.
대입과 빈 본문: -Wall이 잡는다
$ clang -std=c17 -Wall -Wextra traps.c -o traps
traps.c:6:17: warning: using the result of an assignment as a condition without parentheses [-Wparentheses]
6 | if (retries = 0) {
| ~~~~~~~~^~~
traps.c:6:17: note: place parentheses around the assignment to silence this warning
6 | if (retries = 0) {
| ^
| ( )
traps.c:6:17: note: use '==' to turn this assignment into an equality comparison
6 | if (retries = 0) {
| ^
| ==
traps.c:11:21: warning: if statement has empty body [-Wempty-body]
11 | if (retries > 5);
| ^
traps.c:11:21: note: put the semicolon on a separate line to silence this warning
2 warnings generated.
$ ./traps
retries = 0
이 줄은 조건과 상관없이 실행된다
경고를 무시하고 실행하면 retries가 0으로 바뀌어 있고, 조건과 상관없는 줄이 찍힌다. note가 알려 주는 대로 비교라면 ==로 고친다. 정말 대입한 값을 검사하려는 것이라면 if ((n = read_count()) != 0)처럼 괄호와 비교를 명시한다.
fall-through: 옵션을 켜야 잡힌다
-Wall -Wextra만으로는 fall-through를 경고하지 않는다(출력 없음).
$ clang -std=c17 -Wall -Wextra fallthrough.c -o fallthrough
$ clang -std=c17 -Wall -Wextra -Wimplicit-fallthrough fallthrough.c -o fallthrough
fallthrough.c:8:5: warning: unannotated fall-through between switch labels [-Wimplicit-fallthrough]
8 | case 2:
| ^
fallthrough.c:8:5: note: insert '__attribute__((fallthrough));' to silence this warning
8 | case 2:
| ^
| __attribute__((fallthrough));
fallthrough.c:8:5: note: insert 'break;' to avoid fall-through
8 | case 2:
| ^
| break;
1 warning generated.
$ ./fallthrough
로그 수준 1: 오류만
로그 수준 2: 경고까지
C17 표준에는 fall-through가 의도임을 표시하는 문법이 없어 clang은 확장 문법 __attribute__((fallthrough));를 제안한다. C23부터는 표준 문법 [[fallthrough]];이 생겼다. 이 책은 C17 기준이므로, 의도한 fall-through는 grade.c처럼 case를 빈 줄 없이 붙여 쓰는 형태로만 쓰고 나머지는 모두 break로 끝낸다.
한눈에 보기
| 문 | 조건 검사 | 최소 실행 | 어울리는 곳 |
|---|---|---|---|
for | 본문 전 | 0번 | 횟수가 정해진 반복, 배열 순회 |
while | 본문 전 | 0번 | 끝나는 조건만 아는 반복(입력이 끝날 때까지) |
do-while | 본문 후 | 1번 | 일단 한 번 하고 판단(메뉴, 재시도) |
| 실수 | 경고 이름 | 기본 포함 |
|---|---|---|
if (x = 0) | -Wparentheses | -Wall |
if (x > 5); | -Wempty-body | clang 기본 |
빠진 break | -Wimplicit-fallthrough | 직접 켜야 함 |
switch 에서 enum 값 누락 | -Wswitch | -Wall |
실무에서 자주 틀리는 것
- 중괄호 없는
if에 줄을 추가한다.if (err) log(); return;같은 코드에서 들여쓰기만 보고 두 줄이 조건 안이라고 착각한다. 한 줄짜리 본문에도 중괄호를 친다. - 반복문 안에서 경계 변수를 바꾼다.
for (i = 0; i < n; i++)안에서n이나i를 고치면 도는 횟수를 추적하기 어렵다. 필요하면while로 바꾸고 증감을 한곳에 모은다. - 바깥 반복까지 빠져나가려고
break를 쓴다.break는 한 겹만 나간다. 중첩 반복을 한 번에 끝내려면 함수로 떼어return하거나 플래그 변수를 쓴다. - 부동소수로 반복 횟수를 센다.
for (double x = 0; x != 1.0; x += 0.1)은 0.1이 정확하지 않아 영원히 돈다. 정수로 세고 필요한 값을 계산한다. - 팀 빌드에
-Wimplicit-fallthrough를 넣지 않는다. 상태 기계(state machine)를switch로 짜는 임베디드 코드에서는 이 옵션이 있으면 빠진break를 컴파일 시점에 잡는다.
연습 문제
- 1부터 100까지 중 3의 배수이면서 5의 배수가 아닌 수의 개수를 세는 반복문을
continue를 써서 작성하라. 답은 몇 개인가? int i = 10; while (i > 0) { i -= 3; }가 끝났을 때i의 값과 본문이 실행된 횟수를 추적 표로 구하라.- 다음 코드는 무엇을 출력하는가?
int x = 2; switch (x) { case 1: printf("a"); case 2: printf("b"); case 3: printf("c"); break; default: printf("d"); }
정답
int count = 0; for (int n = 1; n <= 100; n++) { if (n % 3 != 0) continue; if (n % 5 == 0) continue; count++; }3의 배수는 33개, 그중 15의 배수가 6개이므로 답은 27개다. 조건을 걸러 내는continue를 앞에 모으면 본문의 핵심 동작(count++)이 들여쓰기 없이 드러난다.- i: 10 → 7 → 4 → 1 → -2. 조건 검사는 10, 7, 4, 1에서 참이고 -2에서 거짓이다. 본문은 4번 실행되고 끝난 뒤
i는 -2다. "0에서 멈춘다"고 생각하기 쉽지만 조건은> 0을 벗어나는 순간에 멈추므로 0을 지나칠 수 있다. bc.case 2:로 들어가b를 찍고,break가 없으므로case 3:의c까지 찍은 뒤break에서 멈춘다.-Wimplicit-fallthrough를 켜면break없이 흘러 들어가는case 2:와case 3:두 곳에 경고가 나온다.
다음 장에서는 코드를 함수로 나누고, 함수를 부를 때 스택에 무엇이 쌓이는지 그림으로 본다.