26 입력
먼저 알아야 할 것
돌아보기
9장에서 입력도 출력처럼 “문자가 한 줄씩 흐르는 띠”라 했고, 사람의 입력은 엔터로 끝나는 줄 단위가 자연스럽다고 했다(행 버퍼링). 그러면 프로그램이 입력을 받는 자연스러운 단위도 — 줄인가?
답. 바로 그렇다 — 그리고 그것이 이 장의 설계 원리다. 사람은 한 줄을 적고 엔터를 친다. 그러니 프로그램도 한 줄을 통째로 받아 온 다음, 그 안에서 필요한 값을 찾아 읽는 것이 흐름의 결을 따르는 방식이다. 글자의 띠에서 줄을 뜨는 일과, 뜬 줄을 해석하는 일 — 이 둘을 나누면 각각이 단순해진다.
이 장의 필요성과 맥락
이 장이 끝나면
fgets 가 정확히 무엇을 약속하고 무엇은 내 몫으로 남기는지(개행과 잘림), 그리고 해석이 실패할 때 무엇이 남는지까지 본다. 제5부가 여기서 완성된다.이 장에서 답할 질문
- 왜 두 단계로 나누는가? 입력에서 바로
%d를 읽어 주는 함수(scanf)도 있다고 들었는데. - 그릇은 얼마나 크게 잡아야 하는가?
- 그러면
%d로42를 읽은 뒤, 통에는 무엇이 남는가? - 「계약 밖」이라는 말을 잠시 접어 두자. 만약 표준이 「입력 버퍼를 비운다」는 함수를 정식으로 넣어 주었다면, 그 함수는 무엇을 하는 함수여야 하는가?
26.1 두 단계 — 읽고, 해석한다#
시연부터 본다. 표준 입력에서 정수 하나를 받아 제곱을 알려 주는 프로그램이다. (이 실행에서 표준 입력으로 준 내용을 가운데 상자에 보였다.)
examples/ch25/read.c
#include <stdio.h>
int main(void)
{
char line[100]; /* 글자 100칸짜리 공간 — 정식 설명은 33장 */
int n = 0;
fgets(line, sizeof line, stdin); /* 1단계: 한 줄을 통째로 읽는다 */
sscanf(line, "%d", &n); /* 2단계: 그 줄에서 정수를 해석한다 */
printf("%d squared is %d.\n", n, n * n);
return 0;
}
표준 입력으로 준 것
7
실행 결과
7 squared is 49.
새 얼굴이 둘 있다 — 한 단계에 하나씩이다.
1단계, fgets — 한 줄을 통째로 읽는다. fgets(line, sizeof line, stdin)은 표준 입력(stdin이 그 이름이다)에서 한 줄을 읽어 line 이라는 공간에 담는다. 첫 줄의 char line[100];은 “글자 100칸짜리 공간을 잡고 line이라 부른다”는 선언인데 — 여러 칸짜리 공간(배열)의 정식 문법은 39장의 것이라, 지금은 “줄을 담는 그릇”이라고만 알아 두면 된다(의도된 외상이다). 둘째 재료 sizeof line은 “그릇의 칸 수” — fgets에게 그릇 크기를 알려서 넘치게 담는 일이 없게 하는 안전장치다 (sizeof 연산자의 정식 취급은 36장).
2단계, sscanf — 담아 온 줄을 해석한다. sscanf(line, "%d", &n)은 23장 printf의 짝이다 — 같은 서식 언어를 반대 방향으로 쓴다. printf가 값을 글자로 바꿔 내보냈다면, sscanf는 글자(line 속 "7")에서 서식(%d)에 맞는 값을 찾아 변수에 담는다. 이름 앞의 &는 “값을 담을 변수의 자리를 알려 주는 표시”다 — 3장에서 배운 주소가 문법에 처음 얼굴을 내민 순간인데, 정식 취급은 36장에 있다 (이것이 제5부의 마지막 의도된 외상이다).
문. 왜 두 단계로 나누는가? 입력에서 바로 %d를 읽어 주는 함수(scanf)도 있다고 들었는데.
답. 있고, 많은 입문서가 그것부터 가르친다 — 이 책이 다른 길을 택한 이유는 두 가지다. 첫째, 실패의 뒤처리가 깨끗하다. 해석에 실패해도 (숫자가 아닌 것이 들어와도) 줄은 이미 내 그릇에 있으므로, 입력의 띠가 어중간한 자리에서 멈춰 꼬이는 일이 없다 — 입력 띠에서 직접 읽다 실패하면 남은 글자들이 띠에 걸린 채 다음 읽기를 오염시키는데, 이것이 scanf 직접 사용의 고전적 골칫거리다. 둘째, 안전의 결이 같다. 줄 읽기는 그릇 크기를 알려 주며 읽는 구조라 넘침이 원천 차단된다 — 그릇 크기를 말하지 않는 옛 방식들이 어떤 사고를 냈는지, 그리고 이 두 단계 관행이 어떻게 그 사고를 봉쇄하는지가 43장의 이야기다. 처음부터 안전한 결로 손에 익히는 것 — 그것이 이 책의 선택이다.
26.2 fgets의 계약 — 무엇을 주고, 무엇은 말해 주지 않는가#
두 단계의 첫 단계를 정확히 알아 둔다. fgets 는 세 가지를 약속한다.
| 약속 | 무슨 뜻인가 |
|---|---|
| 그릇 크기를 넘기지 않는다 | sizeof line 을 받았으니 그보다 많이 담지 않는다 — 넘침이 원천 차단된다 |
| 끝에 NUL 을 붙인다 | 담긴 것이 문자열로 쓰일 수 있게 한다(43장) |
| 줄바꿈까지 담아 준다 | 읽은 줄의 \n 이 그릇 안에 들어 있다 — 떼어 내는 것은 내 몫이다 |
표 26.1 — fgets 가 하는 약속
세 번째가 초보자를 자주 무는 자리다. 시연으로 눈에 보이게 한다 — 개행을 @ 로 바꿔 찍었다.
examples/ch25/lines.c
/* fgets 의 계약 — 무엇을 담아 주고, 무엇을 알려 주지 않는가. */
#include <stdio.h>
#include <string.h>
int main(void)
{
char line[16]; /* 일부러 작게 잡았다 — 잘림을 보이려고 */
puts("[read one line at a time and echo it back]");
while (fgets(line, sizeof line, stdin) != NULL) {
size_t len = strlen(line);
int has_nl = (len > 0 && line[len - 1] == '\n');
/* 담긴 것을 눈에 보이게 — 개행은 기호로 바꿔서 */
printf(" got: \"");
for (size_t i = 0; i < len; i++)
putchar(line[i] == '\n' ? '@' : line[i]);
printf("\" (%zu bytes, newline at end: %s)\n",
len, has_nl ? "yes" : "no");
if (!has_nl)
puts(" -> no newline means the line was longer than the buffer and was cut");
}
puts("\n[two reasons reading stops]");
puts(" when fgets returns NULL it is either end of file or an error.");
puts(" feof and ferror tell them apart; chapter 63 covers that properly.");
return 0;
}
표준 입력으로 준 것
hi
this line is definitely longer than the box
bye
실행 결과
[read one line at a time and echo it back]
got: "hi@" (3 bytes, newline at end: yes)
got: "this line is de" (15 bytes, newline at end: no)
-> no newline means the line was longer than the buffer and was cut
got: "finitely longer" (15 bytes, newline at end: no)
-> no newline means the line was longer than the buffer and was cut
got: " than the box@" (14 bytes, newline at end: yes)
got: "bye@" (4 bytes, newline at end: yes)
[two reasons reading stops]
when fgets returns NULL it is either end of file or an error.
feof and ferror tell them apart; chapter 63 covers that properly.
26.2.1 잘렸는지는 어떻게 아는가#
시연의 가운데 두 줄이 그 답이다. 그릇보다 긴 줄이 들어오면 fgets 는 그릇이 차는 데까지만 담고 돌아온다 — 그리고 그때는 끝에 개행이 없다.
| 그릇 끝의 모습 | 뜻 |
|---|---|
… \n 으로 끝난다 | 줄 하나를 온전히 받았다 |
| 개행 없이 끝난다 | 줄이 잘렸다 — 나머지는 아직 띠에 남아 있고, 다음 fgets 가 그 뒤를 이어 받는다 |
표 26.2 — 그릇 끝의 모습으로 읽는 입력의 결과
즉 「끝에 개행이 있는가」가 잘림의 신호다. 시연에서 긴 한 줄이 세 번에 나뉘어 들어온 것이 그 증거다.
그래서 실무의 관용구가 둘이다.
line[strcspn(line, "\n")] = '\0'; /* 개행이 있으면 떼어 낸다 */첫째는 개행 떼기다. 위 한 줄이 관용구인데, strcspn(43장)은 「\n 이 처음 나오는 자리」를 돌려주므로 그 자리에 NUL 을 놓으면 개행이 사라진다. 개행이 없으면 문자열의 끝을 가리키므로 아무 일도 일어나지 않는다 — 한 줄로 두 경우가 다 처리된다.
둘째는 잘림을 오류로 다루기다. 「잘렸지만 성공」을 성공으로 넘기면 잘린 이름으로 파일을 찾는 사고가 뒤따른다 — 44장에서 다섯 규율의 하나로 정식으로 다룬다.
문. 그릇은 얼마나 크게 잡아야 하는가?
답. 정답은 없고, 판단의 기준이 있다.
사람이 치는 한 줄이라면 넉넉히 잡는다 — 이름·경로·명령이라면 256이나 1024가 흔한 선택이다. 시연이 16으로 잡은 것은 잘림을 보이려고 일부러 작게 한 것이다.
줄 길이에 상한이 없다면 그릇을 키우는 것으로는 끝나지 않는다. 그때는 잘림을 검사해 이어 붙이거나(반복해서 fgets), 줄 길이만큼 그릇을 늘려 주는 도구를 쓴다 — POSIX 의 getline 이 그것인데 표준 C 에는 없다(71장).
기억할 것은 하나다. 크기를 넘겨받는 함수를 쓰는 한, 그릇이 작아서 생기는 일은 「잘림」이지 「넘침」이 아니다. 잘림은 검사할 수 있고, 넘침은 프로그램이 무너진다.
26.3 통에 무엇이 남는가 — 예상하고, 가져가고, 남긴다#
9장에서 입력의 띠 중간에 통(버퍼)이 있다고 상상해 두었다. 이제 그 통을 실제로 들여다볼 차례다. 여기가 두 단계 방식을 택한 진짜 이유이기 때문이다.
서식으로 읽는 함수(scanf·sscanf 계열)가 하는 일을 표준의 어휘로 적으면 세 걸음이다.
- 예상한다. 서식의 지시자 하나하나가 「이런 모양이 올 것이다」라는 예상이다.
%d는 십진 정수의 모양을,%s는 공백이 아닌 글자들의 모양을 예상한다. - 가져간다. 예상에 맞는 가장 긴 글자들을 가져간다. 표준은 이것을 입력 항목(input item)이라 부르고, 「지정한 폭을 넘지 않으면서, 맞는 입력 차례이거나 그 앞부분인 가장 긴 글자열」이라고 정의한다(§7.23.6.2p9).
- 남긴다. 그리고 이 한 줄이 핵심이다 — 「입력 항목 다음의 첫 글자는 읽히지 않은 채로 남는다」(같은 조항). 가져간 만큼만 없어지고, 나머지는 통에 그대로 있다.
여기에 규칙이 두 개 더 붙는다. 대부분의 지시자는 앞의 공백을 먼저 건너뛴다 — [·c·n 만 예외다(§7.23.6.2p8). 그리고 서식 안의 공백 문자는 「공백이 아닌 첫 글자까지 읽어라」는 지시이고 결코 실패하지 않는다(p5).
문. 그러면 %d 로 42 를 읽은 뒤, 통에는 무엇이 남는가?
답. 개행이 남는다. 42\n 이 통에 있었다면 %d 는 42 까지만 가져간다 — 개행은 십진 정수의 모양이 아니므로 「입력 항목 다음의 첫 글자」이고, 규칙대로 읽히지 않은 채 남는다.
이 남은 개행이 초보자를 괴롭히는 고전적인 자리다. 다음에 한 줄을 읽으면 빈 줄이 읽히고(남아 있던 개행이 그 줄의 끝이 된다), 다음에 한 글자를 읽으면 글자 대신 개행이 온다. 프로그램은 「입력을 건너뛴 것처럼」 보이지만 실제로는 지난번에 남긴 것을 이번에 먹은 것이다.
실제 사례. 두 단계로 읽으면 이 문제가 왜 사라지는가
이 장이 「한 줄을 통째로 읽고, 그다음 해석한다」를 택한 까닭이 여기 있다.
fgets 는 예상하지 않는다. 개행까지(또는 그릇이 찰 때까지) 가져간다. 그러니 통에는 「이번 줄의 찌꺼기」가 남지 않는다 — 다음 줄이 통째로 남을 뿐이다. 해석은 그다음에 내 그릇 안에서(sscanf) 일어나므로, 해석이 실패해도 통은 이미 깨끗하다.
곧 두 단계란 예상을 통에서 떼어 내 그릇으로 옮기는 일이다. 예상이 빗나갔을 때 어지러워지는 것이 통이 아니라 내 그릇이 되므로, 다시 시도하기가 쉽다.
플랫폼 노트. 남은 것을 치우려 할 때
「그러면 통을 비우면 되지 않나」라고 생각하기 쉽고, 실제로 fflush(stdin) 이라 적은 코드가 인터넷에 무척 많다. 그러나 9장에서 말한 대로 비우기는 출력 쪽 동작이라 이것은 계약 밖이다. 어떤 구현에서는 그럴듯하게 동작하고 다른 구현에서는 아무 일도 하지 않는다 — 68장에서 조항과 함께 다시 본다.
남은 것을 치우는 옳은 방법은 읽어서 버리는 것이다. 개행이 나올 때까지 한 글자씩 읽는 짧은 반복이면 된다. 다만 이 책의 두 단계 방식을 쓰면 그럴 일 자체가 드물다.
문. 「계약 밖」이라는 말을 잠시 접어 두자. 만약 표준이 「입력 버퍼를 비운다」는 함수를 정식으로 넣어 주었다면, 그 함수는 무엇을 하는 함수여야 하는가?
답. 적어 보려 하면 정의가 서지 않는다. 9장에서 본 대로 띠의 반대편에 무엇이 있는지 프로그램은 모르기 때문이다.
- 사람이 앉은 터미널이라면 대개 한 줄씩 온다. 사람이 아직 아무것도 치지 않았다면 통은 비어 있고, 버릴 것도 없다.
- 파일이 리다이렉션으로 이어졌다면 이미 다 와 있다. 「비우기」는 파일 전체를 버리는 일이 될 수도 있다.
- 앞 프로그램이 붙은 파이프라면 오는 중이다. 지금 비우고 한 순간 뒤에 또 도착한다.
- 네트워크 건너라면 그 「오는 중」이 훨씬 길고 들쭉날쭉하다.
네 경우에 대해 같은 한 문장으로 정의할 수가 없다. 굳이 적자면 「이 함수를 부른 순간까지 도착해 있던 것을 버린다」가 되는데, 그러면 버리는 양을 내 프로그램이 아니라 상대의 속도가 정한다. 그런 함수는 같은 입력에 같은 결과를 주지 않는다 — 사양이 아니라 상대와의 경주를 적어 둔 것에 가깝다.
그래서 표준이 이 함수를 두지 않은 것은 빠뜨린 것이 아니라고 보는 편이 옳다. 버릴 것은 시점이 아니라 경계로 말해야 한다 — 「지금까지 온 것」이 아니라 「개행까지」처럼. 위의 짧은 반복이 그렇게 적혀 있는 이유이고, 두 단계 읽기가 애초에 그런 자리를 만들지 않는 이유이기도 하다. 68장에서 표준의 조항으로 같은 결론에 다시 이른다.
26.4 해석은 실패할 수 있다#
출력과 달리, 입력에는 본질적인 새 문제가 하나 있다 — 상대가 무엇을 보낼지 모른다. 7을 기대했는데 일곱이 올 수 있다. 그래서 sscanf의 반환값은 해석에 성공한 값의 개수다 — 위 예제라면 성공 시 1, 실패 시 0이다.
지금은 이 반환값을 확인하고도 “성공이면 이 길, 실패면 저 길”로 갈라설 문법 — 분기 — 이 없다. 그래서 위 예제는 반환값을 버렸고, n을 0으로 초기화해 두는 것(24장의 습관)으로 실패 시의 바닥을 깔아 두었다. 갈림길은 다음 부(31장)에서 생기고, “실패를 값으로 알리고 반드시 확인한다”는 규율은 53장에서 정식으로 세운다 — 입력 검사는 그 규율의 첫 실전이 될 것이다.
흔한 오해. “입력은 키보드에서 온다”
7은 파일에 담겨 표준 입력의 띠로 흘러 들어간 것이다(리다이렉션). 23장의 출력이 화면행이 아니듯 입력도 키보드행이 아니라서, 같은 프로그램이 사람과의 대화에도, 파일 처리에도, 프로그램들의 연결에도 고치지 않고 쓰인다. 스트림 설계의 이점이 입력 쪽에서도 그대로 되풀이되는 것이다.26.5 제5부를 닫으며#
이름을 만드는 법을 다 배웠다 — 값의 이름(24장), 일의 이름(25장), 그리고 바깥세상에서 값을 받아 이름에 담는 법(26장)까지. 16장의 외상 장부는 청산되었고, 새로 진 외상은 정확히 둘 — 줄 그릇(char line[100], 39장에서)과 자리 표시(&, 36장에서) — 뿐이며, 둘 다 청산 기일이 적혀 있다.
무엇보다, 이제 프로그램이 대화할 수 있게 됐다. 받아서, 계산하고, 답한다 — 다음 부에서는 그 계산 자체를 벼린다: 정수의 세계를 정면으로 다루고(28–29장), 비교하고 판단하고(31–32장), 반복하고(33장), 함수의 의미를 완성한다(34장). 값과 흐름의 부다.