67 문자 분류 — <ctype.h>
먼저 알아야 할 것
char의 부호돌아보기
9장에서 문자는 코드표의 번호이고 인코딩은 그 번호를 바이트로 적는 방식 이라 했다. 그러면 isalpha('가') 같은 호출은 무엇을 묻는 것인가?
답. 잘못 던진 물음이다. <ctype.h>의 함수들은 한 바이트를 판정한다. UTF-8에서 “가”는 세 바이트이므로 애초에 인자로 들어갈 수 없고, 넣더라도 각 바이트를 따로 볼 뿐이다. 이 헤더는 아스키 시절의 도구다.
그래서 이 장은 짧다. 함수 열두 개의 계약과, C에서 가장 자주 어겨지는 규칙 하나 — char를 그대로 넘기면 계약 밖이다 — 가 전부다. 그 뒤에 기다리는 두 개의 큰 주제, 로케일(locale)과 여러 바이트 문자는 다음 다섯 장이 맡는다.
이 장의 필요성과 맥락
char 의 부호(27장)를 모르면 isalpha(c) 가 왜 위험한지 설명할 수 없고, 이 판정이 로케일에 달렸다는 사실이 바로 다음 장(68장)의 문을 연다. 작은 헤더가 두 장을 잇는다.이 장이 끝나면
EOF가 섞여 들어오는 이유, 그리고 이 판정마저 로케일이 바꾼다는 사실까지.이 장에서 답할 질문
- 아스키만 쓰는 프로그램이라면 이 변환을 생략해도 되지 않는가?
67.1 함수 열두 개를 한눈에
<ctype.h>가 정하는 것은 판정 함수 열한 개와 변환 함수 두 개다. 이름의 규칙이 단순하다 — is로 시작하면 참·거짓을 묻고, to로 시작하면 바꾼다.
| 함수 | 참을 돌려주는 것 | “C” 로케일에서 |
|---|---|---|
isalpha | 글자 | A~Z, a~z |
isdigit | 숫자 | 0~9 — 로케일과 무관하게 이 열 개뿐 |
isalnum | 글자 또는 숫자 | 위의 둘을 합친 것 |
isspace | 공백류 | 칸, \t, \n, \v, \f, \r |
isblank | 줄 안의 공백 | 칸, \t |
isupper·islower | 대문자·소문자 | A~Z / a~z |
ispunct | 인쇄되는 문자 중 글자·숫자·공백이 아닌 것 | !, ,, \# 등 |
isprint | 인쇄되는 문자(칸 포함) | 0x20~0x7E |
isgraph | 인쇄되면서 칸이 아닌 것 | 0x21~0x7E |
iscntrl | 제어 문자 | 0x00~0x1F, 0x7F |
isxdigit | 16진 숫자 | 0~9, A~F, a~f |
toupper·tolower | (변환) 대문자·소문자로 | 해당 없으면 그대로 돌려준다 |
표 67.1
isdigit이 특별하다. 표준은 이 함수가 참을 돌려주는 문자를 로케일과 무관하게 0부터 9까지의 열 개로 못박아 두었다. 다른 판정들이 로케일에 따라 넓어질 수 있는 것과 대조된다 — 숫자를 파싱하는 코드가 그나마 안심할 수 있는 근거다.
67.2 첫 번째 함정 — char를 그대로 넘기지 않는다
examples/ch67/ctype.c
#include <stdio.h>
#include <ctype.h>
#include <limits.h>
#include <string.h>
/* ctype 함수는 int 를 받는다 — 그런데 그 int 는 char 가 아니다 */
int main(void)
{
printf("CHAR_MIN = %d (char on this machine is %s)\n",
CHAR_MIN, CHAR_MIN < 0 ? "signed" : "unsigned");
/* 아스키 범위는 문제가 없다 */
printf("isalpha('A') = %d, isdigit('7') = %d, isspace(' ') = %d\n",
isalpha('A') != 0, isdigit('7') != 0, isspace(' ') != 0);
/* 128 이상의 바이트가 문제다: char 로 넘기면 음수가 된다 */
char bytes[] = { (char)0xC7, (char)0x41, 'A', '\0' }; /* CP949 '가' 의 첫 바이트 */
printf("storing the byte 0xC7 in a char gives: %d\n", bytes[0]);
/* 올바른 관용구: unsigned char 로 바꿔서 넘긴다 */
printf("the correct call: isalpha((unsigned char)b) = %d\n",
isalpha((unsigned char)bytes[0]) != 0);
/* toupper/tolower 도 같은 규칙 */
const char *s = "Hello, World!";
char up[32];
size_t i = 0;
for (; s[i] && i + 1 < sizeof up; i++)
up[i] = (char)toupper((unsigned char)s[i]);
up[i] = '\0';
printf("after toupper: [%s]\n", up);
/* EOF 도 유효한 인자다 — 그래서 인자 타입이 int 다 */
printf("isalpha(EOF) = %d (EOF is a permitted argument)\n", isalpha(EOF) != 0);
return 0;
}
실행 결과
CHAR_MIN = -128 (char on this machine is signed)
isalpha('A') = 1, isdigit('7') = 1, isspace(' ') = 1
storing the byte 0xC7 in a char gives: -57
the correct call: isalpha((unsigned char)b) = 0
after toupper: [HELLO, WORLD!]
isalpha(EOF) = 0 (EOF is a permitted argument)
<ctype.h>의 모든 함수는 int를 받는다. 그리고 표준이 요구하는 인자의 값은 unsigned char로 표현되는 값이거나 EOF다.
문제는 char가 부호 있는 구현이 흔하다는 데 있다(27장). 예제의 출력처럼 0xC7 바이트를 char에 담으면 −57이 되고, 그것을 그대로 isalpha에 넘기면 허용되지 않은 값을 넘기는 것이 된다 — 계약 밖이다. 실제 구현들이 대개 배열 색인으로 만들어져 있어서, 음수 인덱스로 배열 앞을 읽는 사고가 난다.
관용구는 하나다.
isalpha((unsigned char)c)
toupper((unsigned char)c)반례. char를 그대로 넘기기
char *p = line;
while (*p) { if (isspace(*p)) ... ; p++; } /* 0x80 이상 바이트에서 계약 밖 */아스키만 들어온다는 보장이 없으면 반드시 변환한다.
while (*p) { if (isspace((unsigned char)*p)) ... ; p++; }한글·일본어·유럽어 텍스트를 다루는 프로그램에서 이 한 줄이 실제 사고와 무사고를 가른다.
문. 아스키만 쓰는 프로그램이라면 이 변환을 생략해도 되지 않는가?
답. 「아스키만 들어온다」는 가정이 지켜지는 프로그램이 생각보다 드물다. 프로그램은 대개 남의 입력을 받는다 — 파일 이름, 사용자 이름, 붙여 넣은 문자열에 한글 이나 이모지가 섞이는 것을 막을 방법이 없다. 명령줄 인자 하나만으로도 0x80 이상의 바이트가 들어온다.
그리고 비용이 없다. (unsigned char) 변환은 실행 시간에 아무 명령도 만들지 않는 경우가 대부분이다 — 공짜로 계약 안에 머무는 몇 안 되는 자리다.
67.3 두 번째 함정 — EOF가 섞여 들어온다
인자 타입이 char가 아니라 int인 데는 이유가 있다. EOF도 유효한 인자이기 때문이다. fgetc가 돌려주는 값을 그대로 넘길 수 있어야 하고(63장), 그 값은 바이트일 수도 EOF일 수도 있다.
int c;
while ((c = fgetc(f)) != EOF)
if (isalpha(c)) ... /* fgetc 가 준 값은 이미 unsigned char 나 EOF 다 */여기서 c를 char로 받으면 두 가지가 한꺼번에 깨진다 — EOF와 0xFF 바이트를 구별할 수 없게 되고, 값이 음수가 되어 위의 첫 번째 함정에 걸린다. 63장에서 “fgetc의 반환은 반드시 int로 받는다”고 한 규칙이 여기서 되돌아온다.
67.4 판정은 로케일에 달렸다
isalpha가 참을 돌려주는 바이트의 집합은 고정된 것이 아니다. 현재 로케일(정확히는 LC_CTYPE 범주)이 정한다. 프로그램은 "C" 로케일로 시작 하므로 처음에는 아스키 기준이지만, setlocale을 부르는 순간 달라질 수 있다.
바꾸는 것은 판정만이 아니다. 터키어 로케일에서 toupper('i')가 점 있는 대문자 İ가 되는 것이 유명한 사례이고, 소수점이 쉼표가 되는 로케일에서는 printf("%f")의 출력마저 달라진다.
로케일은 그 자체로 한 덩어리의 주제다. 이름이 어떤 규칙으로 만들어지는지, 어떤 표준이 그것을 정하는지, 무엇을 얼마나 바꾸는지는 다음 두 장에서 본다 (68장·69장).
67.5 여러 바이트 문자에는 무의미하다
한 가지가 더 남는다. 이 헤더의 함수들은 바이트 하나를 본다. UTF-8의 “한”은 세 바이트이므로 어느 바이트를 넣어도 뜻이 없다 — isalpha에 0xED를 넣는 것은 “이 조각이 글자인가?”라는 물음이고, 답할 수 없는 물음이다.
여러 바이트로 된 글자를 판정하려면 두 길 중 하나를 간다. 와이드 문자(wide character)로 바꿔 <wctype.h>의 iswalpha를 쓰거나(70·71장에서 다룬다), 바이트열을 그대로 다루면서 필요한 판정만 직접 하는 것이다(72장). 이 책이 권하는 쪽은 뒤쪽이고, 그 이유는 72장에서 밝힌다.
복습 정리
| 상황 | 규칙 | 틀리면 |
|---|---|---|
<ctype.h> 호출 | (unsigned char)로 변환 | 음수 인자 — 계약 밖 |
fgetc 결과 처리 | int로 받는다 | EOF와 0xFF 혼동 |
isdigit | 로케일과 무관하게 0~9 | — |
| 그 밖의 판정 | LC_CTYPE에 달렸다 | 로케일이 바뀌면 결과가 바뀐다 |
| 여러 바이트 문자 | 바이트 단위 판정은 무의미 | 글자가 쪼개진 판정 |
표 67.2
한 바이트의 판정을 끝냈다. 이제 그 판정을 좌우하던 것 — 로케일로 들어간다. 다음 장은 로케일이 무엇이고, ko_KR.UTF-8 같은 이름이 어떤 규칙으로 만들어지는지부터 시작한다.