Proven C Book←↑→

32 결정 — if와 switch

먼저 알아야 할 것

31장 불리언과 비교 · 비교의 결과는 참·거짓이다

돌아보기

13장에서 “if 는 공짜가 아니다”라는 감각을 예고했다 — 분기 예측이 틀리면 파이프라인을 비우는 벌금을 문다고. 그러면 프로그래머는 분기를 피해야 하는가?

답. 아니다 — 분기는 프로그램의 뼈대이고, 예측기는 일상 코드에서 90% 넘게 맞힌다. 그 감각의 쓸모는 “분기를 쓰지 말라”가 아니라 분기의 비용이 일정하지 않음을 아는 것이다 — 규칙적인 갈림길은 싸고, 무작위한 갈림길은 비싸다(정렬된 배열 사건). 성능이 문제 되는 극히 일부 코드에서만 꺼내는 지식이고, 오늘 배우는 분기는 마음껏 쓰면 된다.

이 장의 필요성과 맥락

값을 다 벼렸으니 이제 흐름이다. 2장에서 기계 모델을 그리며 「결과가 0이면 건너뛰어라」 라고 적었던 것이 서른 장 만에 C 문법의 얼굴로 돌아온다. 이 재회를 만들기 위해 분기를 6부 한복판까지 미뤄 둔 것이다 — 보통의 입문서라면 훨씬 앞에 놓을 자리다.

이 장이 끝나면

프로그램이 갈림길을 만난다. 조건에 따라 다른 길을 가는 if와, 여러 갈래를 한 번에 벌이는 switch — 두 분기 장치를 익힌다. 2장의 계산 모델(“결과가 0이면 건너뛰어라”)이 드디어 C 문법의 얼굴로 나타나는 장이다.

이 장에서 답할 질문

  1. 갈래가 하나뿐이면 중괄호를 생략해도 된다고 들었다 — 써도 되는가?
  2. if의 사다리로도 다 되는데, switch가 굳이 필요한 이유는 무엇인가?

32.1 갈라서 가기 — 프로그램이 판단한다는 것#

20장에서 프로그램은 위에서 아래로 한 문장씩 실행된다고 했다. 그 규칙만으로는 프로그램이 늘 같은 일을 한다. 입력이 90점이든 40점이든 같은 문장들이 같은 순서로 돈다. 그러나 쓸모 있는 프로그램은 상황에 따라 다른 일을 한다 — 점수가 60 이상이면 「통과」, 아니면 「재시험」을 찍고, 파일이 열리지 않으면 거기서 멈춘다.

이것을 분기(branch) 또는 선택이라 한다. 실행의 흐름이 갈림길에서 한쪽 길을 고른다. 순서도를 그린다면 마름모 하나에서 길이 둘로 갈라지는 그림이다. 고르는 기준은 31장에서 만든 참·거짓의 값이다.

표준은 이 장치를 선택문(selection statement)이라 부르고 이렇게 정의한다.

“ 선택문은 제어 수식(controlling expression)의 값에 따라 여러 부속 블록 가운데 하나를 고른다.1 ”

정의의 두 낱말, 제어 수식과 부속 블록이 이 장의 뼈대다. 제어 수식은 갈림길에서 묻는 물음이고(if (score >= 60)의 score >= 60), 부속 블록은 고를 수 있는 길들이다. C의 선택문은 둘이다. 물음이 「참인가 거짓인가」이면 if, 「값이 무엇인가」이면 switch 다. 둘 다 하나의 길만 고른다 — 고르지 않은 길의 문장은 실행되지 않는다.

32.2 if — 조건이 참이면#

문법은 읽는 그대로다:

if (조건) {
    조건이 참일 때의 문장들
} else {
    거짓일 때의 문장들   /* else 부분은 없어도 된다 */
}

조건 자리에는 bool이 되는 수식(31장)이 오고, 갈래마다 블록(20장)이 온다. 갈래가 여럿이면 else if로 사다리를 만든다 — 시연이 그 모습이다. 26장의 입력 관행(fgets+sscanf)이 다시 나오는 것도 눈여겨보면 된다.

examples/branching/grade.c

#include <stdio.h>

int main(void)
{
    char line[100];
    int score = 0;

    fgets(line, sizeof line, stdin);
    sscanf(line, "%d", &score);

    if (score >= 90) {
        printf("%d: excellent\n", score);
    } else if (score >= 80) {
        printf("%d: good\n", score);
    } else if (score >= 70) {
        printf("%d: fair\n", score);
    } else {
        printf("%d: needs work\n", score);
    }
    return 0;
}

표준 입력으로 준 것

87

실행 결과

87: good

사다리는 위에서부터 차례로 검사되고, 처음 참이 되는 갈래 하나만 실행된다 — 87점이 “good”에서 멈추고 아래로 내려가지 않는 것이 그 보장이다. 그래서 사다리의 순서가 곧 논리다: 좁은 조건을 위에, 넓은 조건을 아래에 두는 것이 정석이다.

문. 갈래가 하나뿐이면 중괄호를 생략해도 된다고 들었다 — 써도 되는가?

답. 문법상 허용된다 — 그리고 이 책은 쓰지 않기를 권한다. 중괄호 없는 분기는 “다음 문장 하나만” 갈래에 속하는데, 나중에 문장을 한 줄 보태며 중괄호 없음을 잊는 실수가 고전적 사고 경로다. 실제로 애플의 SSL 라이브러리에서 이 패턴으로 검증 코드가 분기 밖으로 밀려나 보안 검사가 통째로 건너뛰어진 사건(“goto fail” 사고, 2014)이 있었다2 — 들여쓰기는 사람 눈에만 보이고 컴파일러에게는 안 보인다는 것(20장)이 값비싸게 증명된 날이다. 갈래에는 언제나 중괄호 — 이 책의 전 예제가 따르는 수칙이다.

32.3 switch — 값으로 갈라서는 판#

갈림길이 “어느 범위인가”가 아니라 “어느 값인가”로 갈릴 때는 전용 장치가 따로 있다 — switch다.

examples/branching/season.c

#include <stdio.h>

int main(void)
{
    char line[100];
    int month = 0;

    fgets(line, sizeof line, stdin);
    sscanf(line, "%d", &month);

    switch (month) {
    case 12:
    case 1:
    case 2:                     /* 세 case가 하나의 답을 공유한다(폴스루) */
        printf("month %d: winter\n", month);
        break;
    case 3:
    case 4:
    case 5:
        printf("month %d: spring\n", month);
        break;
    case 6:
    case 7:
    case 8:
        printf("month %d: summer\n", month);
        break;
    case 9:
    case 10:
    case 11:
        printf("month %d: autumn\n", month);
        break;
    default:
        printf("month %d: no such month\n", month);
        break;
    }
    return 0;
}

표준 입력으로 준 것

12

실행 결과

month 12: winter

읽는 법 — switch (month)가 값을 하나 들고 판(switch 의 블록)에 들어서면, 실행은 그 값과 일치하는 case 이름표로 건너뛴다. 그리고 여기가 switch의 가장 중요한 성질이다: case는 담장이 아니라 이름표라서, 일단 건너뛴 뒤에는 다음 case를 지나쳐 아래로 계속 흐른다. 이 흐름을 폴스루(fall-through)라 부르고, 멈추고 싶은 자리에 break(판을 나가라)를 명시한다.

폴스루는 양날이다. 시연처럼 여러 값이 한 답을 공유하게 묶는 데는 제격이지만(12·1·2월이 겨울 하나로), break를 잊으면 의도치 않은 갈래까지 흘러 들어가는 고전적 사고가 된다 — 그래서 컴파일러 경고와 C23의 명시 표기([[fallthrough]] — “의도된 흐름임”을 적는 문서)가 이 자리를 지킨다. default는 어느 이름표에도 안 맞을 때의 갈래다 — “그런 달은 없다”처럼, 예상 밖의 값을 잡는 그물로 항상 두는 것이 관행이다.

실제 사례. 표준이 든 기묘한 예 — 도달하지 않는 선언

case 이름표가 담장이 아니라는 성질을 극단까지 밀면 이상한 코드가 가능해진다. 다음은 C 표준이 switch 절의 예제로 직접 든 것이다.

switch (expr) {
    int i = 4;          /* ← 여기로는 아무도 오지 않는다 */
    f(i);               /* ← 여기도 */
case 0:
    i = 17;
    /* 아래로 흘러 들어간다 */
default:
    printf("%d\n", i);
}

switch에 들어서면 실행은 일치하는 이름표로 곧장 건너뛴다. 그래서 블록 맨 앞의 두 줄은 어떤 값에서도 실행되지 않는다. 그런데 i라는 변수는 있다 — 블록에 들어선 이상 그 블록의 지역 변수는 자리를 얻기 때문이다(45장의 자동 저장 기간).

결과가 기묘하다. expr이 0이면 case 0:으로 뛰어 i = 17이 실행되고 17이 찍히지만, 0이 아니면 default:로 곧장 뛰므로 i는 한 번도 초기화되지 않은 채 읽힌다 — 56장에서 정식으로 다룰 미정값 읽기다. 초기화하는 줄이 눈앞에 버젓이 적혀 있는데도 그렇다.

이 예제가 표준에 있는 이유는 “이렇게 쓰라”가 아니라 case가 이름표일 뿐이라는 사실의 귀결을 못박기 위해서다. 실무의 규칙은 둘이다 — switch 블록 맨 앞에 선언을 두지 않는다, 그리고 case 안에서 변수를 선언해야 하면 중괄호로 블록을 만든다(그러지 않으면 컴파일러가 “case 이름표를 건너뛰는 초기화”라고 거절하기도 한다).

문. if의 사다리로도 다 되는데, switch가 굳이 필요한 이유는 무엇인가?

답. 둘의 차이는 표현력이 아니라 의도의 전달이다. switch는 “하나의 값을 여러 상수와 맞춰 본다”는 의도를 문법으로 못박아서, 읽는 사람과 컴파일러 모두에게 구조가 보인다 — 컴파일러는 그 구조를 이용해 점프 표 같은 빠른 코드로 바꾸기도 하고(14장의 편집자가 좋아하는 모양이다), 경고 기능은 “열거된 값 중 빠진 case”를 짚어 줄 수 있다. 값 하나로 갈라서면 switch, 범위·복합 조건이면 if 사다리 — 도구를 의도에 맞추는 것이다. 그리고 다음 장에서, 이 폴스루라는 성질을 극한까지 악용(?)한 전설의 코드를 드디어 만난다.

갈림길을 배웠다 — 그러나 프로그램의 진짜 힘은 갈라서는 것이 아니라 되풀이하는 것에서 나온다. 2장에서 “단순한 걸음도 수십억 번이면”이라 했던 그 반복을, 다음 장에서 반복문(loop)으로 손에 넣는다.

주

  1. ISO/IEC 9899 (C23) 6.8.5.1, 문단 2. ↩
  2. CVE-2014-1266 — CVE 는 공개된 취약점에 붙는 번호다(common vulnerabilities and exposures). 2014. National Vulnerability Database, NIST. nvd.nist.gov/vuln/detail/CVE-2014-1266 ↩