C / STANDARD LIBRARY TOUR
ctype.h for classifying and converting characters
Use ctype.h to classify and convert single bytes correctly: the unsigned char cast, what the return values really mean, and where locale matters.
What you will learn
- Call every ctype function through a cast: isspace((unsigned char)*p)
- Test predicate results as true/false, never against 1
- Assign toupper/tolower's return value; they convert nothing in place
- Replace range tests like c >= 'a' && c <= 'z' with islower or isalpha
Understanding ctype.h for classifying and converting characters
ctype.h is twelve predicates and two converters, and all of them work on exactly one byte. isalpha, isdigit, isalnum, isxdigit, isspace, isblank, ispunct, isprint, isgraph, iscntrl, isupper and islower each answer a yes/no question about that byte; tolower and toupper map it between cases. The mental model is a lookup table indexed by byte value, filled in by the implementation for the current locale, not arithmetic on character ranges. That distinction is the reason to prefer islower(c) over c >= 'a' && c <= 'z': the standard promises nothing about letters having consecutive codes, and on EBCDIC systems they do not.
The parameter type is int rather than char, and that is deliberate: getchar returns either a byte value or the negative EOF, and both have to be passable without ambiguity. The rule that follows is that the argument must be a value in unsigned char range or exactly EOF. A plain char is signed on most platforms, so *p holding the byte 0xE9 arrives as -23, which is neither a legal byte value nor EOF, and the call is undefined behaviour. Casting with (unsigned char) before the implicit widening to int turns 0xE9 into 233 and keeps every call in range.
Predicates are specified to return nonzero for true and zero for false, and nonzero is not a promise of 1: glibc returns the matched bit of its internal table, which can be a large number. Use the result in a condition, or normalise it with !! before printing it. The converters return the new value and leave their argument alone, so *p = (char)toupper((unsigned char)*p) is the working form, and they return the character unchanged when no mapping applies, which makes a guarding isupper test pointless. All of this is byte-level work; when one letter can span several bytes, the wide-character equivalents in wctype.h are the right tool.
<ctype.h>
<stdio.h>
int main(void)
{
const char *sample = "F7\t?";
for (const char *p = sample; *p != '\0'; p++) {
int c = (unsigned char)*p; /* the only always-legal argument form */
printf("0x%02X alpha=%d digit=%d xdigit=%d space=%d punct=%d\n",
c, !!isalpha(c), !!isdigit(c), !!isxdigit(c),
!!isspace(c), !!ispunct(c));
}
char word[] = "Mixed_Case9";
for (char *p = word; *p != '\0'; p++)
*p = (char)toupper((unsigned char)*p);
printf("%s\n", word);
return 0;
}
Each ctype.h function asks a locale-dependent question about one byte passed as an int in unsigned char range or EOF, which is why you cast the argument and treat the answer as a boolean rather than a number.
Worked examples
Scanning a number by hand
isspace and isdigit drive a small parser and leave the pointer exactly where classification failed.
<ctype.h>
<stdio.h>
int main(void)
{
const char *in = " \t-42abc";
const char *p = in;
while (isspace((unsigned char)*p))
p++;
int sign = 1;
if (*p == '-') {
sign = -1;
p++;
}
int n = 0;
while (isdigit((unsigned char)*p)) {
n = n * 10 + (*p - '0');
p++;
}
printf("value=%d, stopped at '%c' (offset %d)\n", sign * n, *p, (int)(p - in));
return 0;
}
Example explained
Line 1isspace accepts space, tab, newline, vertical tab, form feed and carriage return, so one loop skips both the spaces and the tab.
Line 2*p - '0' is portable arithmetic because the standard requires the ten decimal digits to have consecutive codes; letters carry no such guarantee, which is why isalpha exists at all.
Line 3The moment isdigit returns 0 the loop stops with p still on that byte, so offset 6 reports exactly where scanning ended.
Line 4The (unsigned char) cast is here even though the text is ASCII, because it is what keeps the calls defined if the input ever holds a byte above 0x7F.
Case-insensitive comparison
tolower used on values rather than on storage, giving a strcmp-shaped result without touching either string.
<ctype.h>
<stdio.h>
static int ci_cmp(const char *a, const char *b)
{
while (*a != '\0' && *b != '\0') {
int ca = tolower((unsigned char)*a);
int cb = tolower((unsigned char)*b);
if (ca != cb)
return ca - cb;
a++;
b++;
}
return (unsigned char)*a - (unsigned char)*b;
}
int main(void)
{
printf("%d\n", ci_cmp("Hello", "hello"));
printf("%d\n", ci_cmp("Hello", "HELLO!"));
printf("%d\n", ci_cmp("apple", "Banana"));
return 0;
}
Example explained
Line 1tolower hands back the folded value as an int and returns non-letters unchanged, so no isupper guard is needed for punctuation or digits.
Line 2Comparing ca and cb instead of *a and *b is the entire case-insensitivity; both strings stay read-only const char *.
Line 3"Hello" against "HELLO!" leaves the loop when *a is '\0' and returns 0 - '!', which is -33.
Line 4The closing subtraction goes through unsigned char so a trailing byte above 0x7F still produces a positive difference, matching strcmp's ordering.
Important notes
Everything outside the basic ASCII set is locale-dependent: in the default "C" locale isalpha answers no for every byte above 0x7F, and only a setlocale(LC_CTYPE, "") call can change that.
The hex codes printed above are ASCII values and the standard does not fix them, but the ctype answers themselves are correct whatever encoding the implementation uses.
Common mistakes
Passing a plain char, as in isalpha(*p): a byte of 0xE9 arrives as -23 on signed-char platforms, which is neither an unsigned char value nor EOF, so the call is undefined behaviour that misclassifies bytes on glibc and indexes out of bounds elsewhere.
Writing if (isupper(c) == 1): glibc returns the matched table bit, a value like 256, so the test is false for genuine uppercase letters and the branch silently never runs.
Writing toupper(*p); as a statement and expecting the string to change: the return value is discarded and nothing happens, because the converters never write through their argument.
Try it yourself
Change, predict, then run
Walk the string "a1 B2\t." one byte at a time and print a line per byte naming its class (alpha, digit, space, punct or other) chosen with ctype predicates. Then print the string again with the case of every letter flipped, using isupper to decide between tolower and toupper.
Open the C workspaceCheck your understanding
Why do the ctype.h functions take an int rather than a char, and why should you still cast to (unsigned char) when passing one?
- int lets EOF be passed alongside every unsigned char value; the cast stops a negative plain char from becoming an argument that is neither of those.
- int is faster than char on most CPUs; the cast is purely a style convention that older compilers required.
- int allows a whole multibyte UTF-8 sequence to be passed at once; the cast strips the high bit so the table lookup lands in range.
- The functions convert their argument in place through the wider type; the cast keeps the value positive so the predicate can return 1.
Show answer
EOF is negative and has to be distinguishable from every real byte, so the parameter is widened to int, and the only legal argument values are 0 through UCHAR_MAX plus EOF. A plain char holding 0xE9 is -23 where char is signed, which is neither, hence the cast. Option 3 is tempting because non-ASCII text is exactly where the bug shows up, but an int argument still carries one byte's worth of value: multibyte handling belongs to wctype.h, and the cast preserves the high bit rather than stripping it, turning 0xE9 into 233.