Arrays and strings · about 45 min

Strings and the null terminator

Why this matters

Text is everywhere: names, file paths, commands, log lines, HTTP headers. Most languages have a built-in string type that knows its own length and grows as needed. C does not. In C a string is just an array of char with a special marker at the end, and every string function in the library, every printf("%s"), every file name you pass to the operating system relies on that marker being there. Understanding exactly what a C string is, byte by byte, is what separates code that works from code that prints garbage or gets exploited.

A string is a char array ending in '\0'

A string in C is a sequence of characters followed by a null character, the char with value 0, written '\0'. The null character is called the terminator. It is how every function knows where the text stops.

#include <stdio.h>

int main(void)
{
    char word[] = "cat";

    printf("as a string: %s\n", word);
    printf("sizeof word = %zu\n", sizeof word);
    for (int i = 0; i < (int)sizeof word; i++) {
        printf("word[%d] = %3d", i, word[i]);
        if (word[i] != '\0') {
            printf("  '%c'", word[i]);
        }
        printf("\n");
    }
    return 0;
}
as a string: cat
sizeof word = 4
word[0] =  99  'c'
word[1] =  97  'a'
word[2] = 116  't'
word[3] =   0

The string literal "cat" in double quotes stands for 4 bytes: the three letters (their ASCII codes, as you learned in the types module) and a 0. The array is sized to fit all four.

  word
  +------+------+------+------+
  | 'c'  | 'a'  | 't'  | '\0' |
  |  99  |  97  | 116  |   0  |
  +------+------+------+------+
     0      1      2      3

Do not confuse these three:

writtentypemeaning
'\0'charthe null character, value 0: the terminator
'0'charthe digit zero, value 48
"0"stringtwo bytes: 48 and 0

and 'a' (one char, single quotes) versus "a" (a string of two bytes, double quotes).

Length versus size

The length of a string is the number of characters before the terminator. The size of the array is how many bytes it can hold. They are different numbers, and the array can be much bigger than the string in it:

#include <stdio.h>

int length(const char s[])
{
    int n = 0;
    while (s[n] != '\0') {
        n++;
    }
    return n;
}

int main(void)
{
    char buf[20] = "hello";

    printf("%s has %d characters, buffer holds %zu bytes\n", buf, length(buf), sizeof buf);
    buf[0] = 'j';
    printf("%s\n", buf);
    buf[2] = '\0';
    printf("%s (length %d)\n", buf, length(buf));
    return 0;
}
hello has 5 characters, buffer holds 20 bytes
jello
je (length 2)
  buf after buf[2] = '\0':
  +---+---+----+---+---+----+----+-- ... --+----+
  | j | e | \0 | l | o | \0 | \0 |         | \0 |
  +---+---+----+---+---+----+----+-- ... --+----+
    0   1   2    3   4   5    6               19
            ^ the string ends here; "lo" is still in memory but no longer part of it

The length function is a hand-written version of the library's strlen. Notice that it has to walk the whole string to find the end: finding the length of a C string takes time proportional to its length. That is the price of not storing the length.

Since strings are arrays, everything from the previous lessons applies: a function receives the address of the first char, can modify the caller's string unless the parameter is const, and needs no length parameter here only because the terminator marks the end.

Initializing char arrays

char a[10] = "hi";             /* 'h' 'i' '\0' and seven more '\0' */
char b[] = "hi";               /* exactly 3 bytes */
char c[] = {'h', 'i', '\0'};   /* same as b, the long way */
char d[2] = "hi";              /* legal, but NO terminator: not a string! */
char e[1] = "hi";              /* warning: initializer-string for array of 'char' is too long */

Case d is a trap: C allows a literal to exactly fill the array, silently dropping the '\0', and gcc does not warn. Leave the brackets empty, or make them at least one larger than the text.

When the terminator is missing

Every string function keeps reading until it finds a 0 byte. If there is none inside the array, it carries on into whatever memory comes next:

#include <stdio.h>

int main(void)
{
    char before[4] = "zzz";
    char letters[3] = {'a', 'b', 'c'};   /* no room for '\0' */
    printf("[%s]\n", letters);
    return 0;
}

In the sandbox this printed [abczzz]: printf ran off the end of letters and into the neighboring array before, stopping only at its terminator. On another system it might print other garbage or crash. It compiled without any warning about the problem. This is undefined behavior, and in real programs it leaks secrets (printing memory that holds a password) or crashes far from the actual mistake.

Printing and reading strings

%s prints a string: give it the array name (no index, no &). printf prints characters until the terminator. Width and precision work: %-10s pads to 10, %.3s prints at most 3 chars.

scanf("%s", buf) reads one word: it skips leading whitespace, then copies characters until the next whitespace, and adds the terminator. Two important details:

#include <stdio.h>
#include <ctype.h>

void shout(char s[])
{
    for (int i = 0; s[i] != '\0'; i++) {
        s[i] = toupper((unsigned char)s[i]);
    }
}

int main(void)
{
    char word[50];

    while (scanf("%49s", word) == 1) {
        shout(word);
        printf("%s!\n", word);
    }
    return 0;
}
$ echo "hello  world of C" | ./prog
HELLO!
WORLD!
OF!
C!

The loop for (int i = 0; s[i] != '\0'; i++) is the idiom for walking a string. toupper from <ctype.h> returns the uppercase version of a letter and leaves other characters alone; the (unsigned char) cast is there because the ctype functions are only defined for non-negative char values, and char may be signed.

String literals are read-only

The text of a string literal lives in a read-only part of the program. char s[] = "cat"; makes a copy in an array you own, which you may modify. You will later see char *p = "cat";, which does not copy, and writing through such a p crashes. That difference is covered in the pointers module.

Common mistakes

Recap

Going further: run man ascii in the sandbox and find NUL, the name of the terminator byte. Then write a function that counts the words in a string (runs of non-space characters).

Exercises

Exercise 1 · quiz

How many bytes?

What does this print?

char s[] = "hello";
printf("%zu\n", sizeof s);
Answer
  • 6

A string literal always carries a hidden terminating '\0'. "hello" is the 5 letters plus the null character, so the array s is created with 6 chars, and sizeof s is 6. The length of the string (what strlen reports) is still 5: the terminator marks the end but is not part of the text.

Exercise 2 · quiz

Which are proper strings?

Which of these arrays hold a properly null-terminated string? Select all that apply.

char a[6] = "hi";
char b[3] = {'y', 'e', 's'};
char c[4] = {'y', 'e', 's'};
char d[3] = "abc";
char e[] = "";
Answer
  • a

  • c

  • e

a: "hi" plus '\0', the remaining bytes are zero too. b: three chars and no room for a terminator, so printing it with %s runs into whatever follows in memory. c: the 4th element was not initialized explicitly, so it is 0, a terminator. d: a special C rule lets a literal fill the array exactly and drop the '\0' when there is no room; d is three chars, not a string (gcc does not even warn). e: the empty string, one byte holding '\0', length 0.

Exercise 3 · write code

Write your own strlen

Write

int my_strlen(const char s[])

that returns the number of characters in the string s, not counting the terminating '\0'. Do not use <string.h>. For example my_strlen("hello") is 5 and my_strlen("") is 0.

The check calls your function with several strings.

File: main.c

Starting point

#include <stdio.h>

int my_strlen(const char s[])
{
    /* count characters until you reach '\0' */
    return 0;
}

int main(void)
{
    printf("%d\n", my_strlen("hello"));
    return 0;
}

Example

expected output

5
Hint 1

Start a counter at 0 and keep looking at s[counter] until it is '\0'.

Hint 2

while (s[n] != '\0') n++; then return n.

Reference solution
#include <stdio.h>

int my_strlen(const char s[])
{
    int n = 0;
    while (s[n] != '\0') {
        n++;
    }
    return n;
}

int main(void)
{
    printf("%d\n", my_strlen("hello"));
    return 0;
}

Exercise 4 · write code

Capitalize every word

Write

void capitalize(char s[])

that modifies the string in place so the first letter of every word is uppercase and every other letter is lowercase. A word starts at the beginning of the string or right after a space. Leave spaces and non-letters as they are (use toupper and tolower from <ctype.h>).

"hello wORLD"        becomes  "Hello World"
"  two  spaces"      becomes  "  Two  Spaces"
"c99 is OLD"         becomes  "C99 Is Old"

The starter's main reads one line, calls your function, and prints the result between square brackets, for example [Hello World]; keep it as it is. (It uses fgets, which reads a whole line; the next lesson explains it.)

File: main.c

Starting point

#include <stdio.h>
#include <ctype.h>
#include <string.h>

void capitalize(char s[])
{
    /* your code here */
}

int main(void)
{
    char line[200];
    if (fgets(line, sizeof line, stdin) == NULL) {
        return 1;
    }
    line[strcspn(line, "\n")] = '\0';   /* remove the newline fgets keeps */
    capitalize(line);
    printf("[%s]\n", line);
    return 0;
}

Example

input

hello wORLD

expected output

[Hello World]

Example

input

  two  spaces

expected output

[  Two  Spaces]
Hint 1

Walk the string with for (int i = 0; s[i] != '\0'; i++), remembering in a variable whether the previous character was a space (start it as 1, 'true').

Hint 2

If at the start of a word, apply toupper, otherwise tolower. toupper and tolower leave non-letters unchanged.

Hint 3

Pass (unsigned char)s[i] to toupper/tolower: the ctype functions are only defined for values representable as unsigned char (or EOF).

Reference solution
#include <stdio.h>
#include <ctype.h>
#include <string.h>

void capitalize(char s[])
{
    int start = 1;   /* is the next character the start of a word? */
    for (int i = 0; s[i] != '\0'; i++) {
        unsigned char ch = (unsigned char)s[i];
        if (start) {
            s[i] = toupper(ch);
        } else {
            s[i] = tolower(ch);
        }
        start = (s[i] == ' ');
    }
}

int main(void)
{
    char line[200];
    if (fgets(line, sizeof line, stdin) == NULL) {
        return 1;
    }
    line[strcspn(line, "\n")] = '\0';   /* remove the newline fgets keeps */
    capitalize(line);
    printf("[%s]\n", line);
    return 0;
}