30 불리언과 비교
먼저 알아야 할 것
돌아보기
23장에서 =(담기)와 ==(비교)는 기호를 나눠 쓴다고 예고했다. 그러면 3 < 5 같은 비교의 결과는 무엇인가 — 수식은 값이 된다고 했는데 (20장), 비교도 수식이라면 무슨 값이 되는가?
답. 참 또는 거짓 — 그 두 가지만을 담는 값이 된다. C23에서 그 타입의 이름이 bool이고, 값의 이름이 true와 false다. 비교는 “물음”이 아니라 bool 값을 계산하는 수식이라는 것 — 이 관점이 이 장의 중심이고, 다음 장에서 분기가 그 값을 소비한다.
이 장의 필요성과 맥락
bool 이 아니라 아무 스칼라나 될 수 있어서, 「무엇이 참인가」를 정확히 정해 두지 않으면 31장부터 헷갈리기 시작한다.이 장이 끝나면
bool, 비교 연산자들, 그리고 논리 연산자와 그 특별한 성질(단락 평가)까지. 다음 장의 분기가 물을 “조건”이 여기서 만들어진다.이 장에서 답할 질문
&(28장)와&&가 정말로 어떻게 다른가 — 둘 다 AND라 불리는데.
30.1 bool — 두 값짜리 타입
bool은 값의 집합이 {false, true} 둘뿐인 타입이다. 27장의 정수 가족 곁에 있는 가장 작은 그릇인 셈이다. 역사를 한 줄 알아 두면 낯선 구석이 풀린다 — 초기 C에는 불리언 타입이 없었고, 0을 거짓으로 0 아닌 모든 값을 참으로 치는 관행이 그 자리를 대신했다. C99가 뒷문으로 (<stdbool.h> 상자를 통해) 들여왔고, C23에 이르러서야 bool, true, false가 상자 없이 쓰는 정식 키워드가 됐다. 반세기 만의 정식 입적이다 — 그리고 그 관행의 흔적으로, bool을 %d로 찍으면 1과 0으로 나온다(시연에서 확인된다).
30.2 비교 — bool을 만드는 수식
비교 연산자는 여섯이다 — ==(같다), !=(다르다), <, <=, >, >=. 전부 두 값을 재료로 받아 bool을 내놓는 수식이다.
examples/ch30/cmp.c
#include <stdio.h>
int main(void)
{
bool tall = 10 > 3; /* 비교의 결과는 bool 값이다 */
printf("%d\n", tall);
printf("%d\n", 3 < 5 && 2 + 2 == 4);
/* 단락 평가: 왼쪽이 0(거짓)이면 오른쪽은 아예 평가되지 않는다 */
printf("%d\n", 0 && printf("this line is never printed\n"));
return 0;
}
실행 결과
1
1
0
첫 줄이 그 확인이다 — 10 > 3이라는 수식의 값(true)이 변수에 담기고, %d로 1이 찍혔다. 판단은 분기만의 것이 아니라 값으로 저장하고 전달할 수 있는 것이라는 감각 — 조건이 복잡해질수록 이 감각이 코드를 깨끗하게 만든다(bool is_leap = ...처럼 판단에 이름을 붙이는 관행).
흔한 오해. “x == 3 대신 x = 3을 써도 어차피 비슷하게 동작할 것이다”
x = 3은 담기 수식이라 x를 3으로 바꿔 버리고, 그 수식의 값 3은 “0이 아니므로 참”으로 읽힌다 — 조건 자리에 실수로 들어가면 항상 참이면서 변수까지 망가뜨리는 이중 사고가 된다. 역사가 이 함정으로 쌓은 사고가 하도 많아, 컴파일러 경고(-Wall)는 조건 자리의 =를 특별히 지목해 알려 준다 — 17장에서 경고를 켜 두자고 한 이유의 대표 사례다.30.3 논리 연산자 — 판단을 엮는다
판단 여럿을 엮는 연산자가 셋 있다 — AND &&(둘 다 참), OR || (하나라도 참), NOT !(뒤집기). 시연의 둘째 줄이 &&의 예다 — 3 < 5 && 2 + 2 == 4, 두 판단이 모두 참이라 1.
그리고 이 연산자들에는 C에서 각별히 중요한 성질이 있다 — 단락 평가(short-circuit)다. &&는 왼쪽이 거짓이면 오른쪽을 아예 평가하지 않고 거짓을 내놓는다(||는 왼쪽이 참이면 오른쪽을 건너뛴다). 시연의 셋째 줄이 그 증명이다 — 오른쪽의 printf는 부수효과(출력)를 가진 수식인데, 왼쪽이 0(거짓)이라 호출 자체가 일어나지 않았다.
이것은 최적화가 아니라 보장이다 — 그리고 20장에서 “평가 순서는 대부분 미지정”이라 했던 것의 귀한 예외다: &&와 ||는 왼쪽을 먼저 평가하고, 오른쪽 평가 여부까지 계약으로 정한다. 그래서 C 프로그래머는 이 보장을 문지기로 쓴다 — “0이 아닐 때만 나눠라”를 b != 0 && a / b > 10 처럼 왼쪽 판단이 오른쪽의 위험(28장의 0 나누기) 을 막아서게 적는 것이 관용구다.
규칙의 이름을 정확히 적어 둔다 — 단락 평가(short-circuit evaluation)다. 표준의 표현으로는 &&와 ||의 왼쪽 피연산자 평가 뒤에 시퀀스 포인트(33장)가 있고, 오른쪽은 필요할 때만 평가된다. 그래서 이 둘은 평가 순서와 평가 여부를 동시에 보장하는, C에서 몇 안 되는 연산자다 (조건 연산자 ?:도 같은 성질을 가진다 — 고르지 않은 쪽은 평가되지 않는다).
실무의 관용구는 세 가지 모양으로 굳어져 있다.
- 문지기 —
if (p != nullptr && p->count > 0). 왼쪽이 거짓이면 오른쪽의 역참조가 아예 일어나지 않는다(36장의 널 역참조를 이 한 줄이 막는다). - 기본값 채우기 —
ok = load(&cfg) || load_default(&cfg);왼쪽이 성공하면 오른쪽은 시도조차 하지 않는다. - 비싼 검사 뒤로 미루기 —
if (cheap_check(x) && expensive_check(x)). 순서를 바꾸면 결과는 같고 비용만 늘어난다.
함정은 그 뒤집힌 얼굴이다. 오른쪽에 부수효과를 두면 그것이 실행되지 않을 수 있다. if (init() && start())에서 init()이 거짓을 돌려주면 start()는 영영 불리지 않는다 — 의도한 것이라면 좋은 코드이고, 잊고 쓴 것이라면 찾기 어려운 버그다. 그리고 비트 연산자 &·|에는 이 보장이 없다 — 양쪽이 모두 평가된다.
문. &(28장)와 &&가 정말로 어떻게 다른가 — 둘 다 AND라 불리는데.
답. 층이 다르다. &는 비트마다 AND를 하는 산술 연산자다 — 5 & 3은 비트를 맞대 1을 얻는다. &&는 판단 전체의 AND다 — 값이 0인가 아닌가만 보고, 단락 평가의 보장이 있다. 실수로 바꿔 써도 우연히 답이 같은 경우가 많아 더 위험하다 — 2 && 4는 1(둘 다 0 아님)이지만 2 & 4는 0(겹치는 비트 없음)이다. 판단에는 &&, 비트에는 & — 자리를 섞지 않는 것이 수칙이다.
조건을 만들 줄 알게 됐다. 다음 장에서 드디어 프로그램이 갈라선다 — 조건의 값에 따라 다른 길을 가는 것, 분기다. 25장에서 미뤄 둔 “입력 검사”의 숙제도 그때 풀 수 있게 된다.