Chapter 09 of 24

9. Strings and text

Text is stored as characters

C does not have a built-in string object. A C string is an array of characters that ends with a special zero-valued character called the null terminator ('\0'). That final character tells string functions where the text ends.

  1. Count space for every visible character and one extra position for '\0'.
  2. When writing or joining text, keep track of the array capacity so the terminator still fits.
  3. When reading a line from a person, limit the amount read and check whether the input was complete.

For example, the word "cat" needs four character positions: 'c', 'a', 't', and '\0'. This representation explains why strings need careful capacity checks.

C has no built-in string object. A C string is an array of char ending with
the null character '\0'.

char greeting[] = "Hello";

This array contains six characters: H, e, l, l, o, and '\0'.
Storage must have room for the terminator and any later characters.

Useful functions from <string.h> include strlen, strcmp, strchr,
memcpy, and memset. strcmp(a, b) == 0 means the strings are equal; do not
compare string contents with ==.

Never write beyond a string's buffer. For input, a bounded line read is often
safer than an unbounded conversion:

#include <stdio.h>

int main(void)
{
char line[128];
if (fgets(line, sizeof line, stdin) != NULL) {
    printf("Read: %s", line);
} else {
    return 1;
}
return 0;
}

fgets may keep a newline if one fits. Handle long lines deliberately: a
truncated line may need to be drained or rejected. Be careful with
strncpy: it does not always append a null terminator.

Text encoding is a separate concern. A char is a byte-sized storage unit in
C, not necessarily a complete human-readable character in every encoding.

How a string is stored

For char word[] = "cat";, the array contains four elements: 'c', 'a',
't', and '\0'. strlen(word) returns 3 because it counts characters
before the terminator; sizeof word returns the array's storage size (4 in
this example while word is still an array). A pointer to a string literal
does not provide writable storage:

const char *message = "Hello";

Treat string literals as read-only. If you need to modify text, copy it into
a writable array with enough capacity.

Build strings within known capacity

Keep the destination capacity available whenever writing text. A safe
approach is to track the number of characters already used and ensure every
append leaves space for '\0'. snprintf can format into a bounded buffer,
but its return value must be checked: a nonnegative result greater than or
equal to the buffer size means the output was truncated.

#include <stdbool.h>
#include <stdio.h>

bool make_label(char *destination, size_t capacity, int id)
{
    if (destination == NULL || capacity == 0) {
        return false;
    }

    int written = snprintf(destination, capacity, "item-%d", id);
    return written >= 0 && (size_t)written < capacity;
}

If snprintf truncates, the destination is still terminated when capacity
is nonzero, but the function reports failure. Include <stddef.h> when
size_t is not otherwise declared.

String functions often rely on receiving a valid null-terminated string. If a
terminator is missing, functions such as strlen may read past the array.
Compare strings with strcmp, copy only when capacity is known, and avoid
assuming that a line from fgets always includes a newline. A line longer
than the buffer arrives in pieces unless the program detects and handles that
case.

Checkpoint: Draw the four array elements in "cat" and mark the
terminator. Explain why strlen and sizeof can return different results.

Reading a line safely

fgets reads at most capacity - 1 characters and then terminates the
buffer. It stops after a newline, at end-of-file, or when the buffer fills.
After a successful read:

  1. Search for a newline with strchr.
  2. If present, replace it with '\0' when the application wants the newline
    removed.
  3. If absent and the input was not at end-of-file, decide whether the line
    was too long. Read and discard the remainder before asking for another
    line, or reject the input.
  4. Distinguish an empty line from a failed read by checking the return value
    of fgets.
#include <stdio.h>
#include <string.h>

int main(void)
{
    char line[80];
    if (fgets(line, sizeof line, stdin) == NULL) {
        return 1;
    }

    char *newline = strchr(line, '\n');
    if (newline != NULL) {
        *newline = '\0';
    } else {
         // The line may be longer than the buffer; reject or drain it here. 
    }

    printf("Line: [%s]\n", line);
    return 0;
}

The example removes a newline only when it was actually read. It leaves
long-line handling as an explicit policy rather than silently treating a
truncated prefix as a complete line.

The buffer size is a capacity in bytes, not necessarily a count of visible
characters for a multibyte encoding. C's basic character functions operate
on bytes/character codes; full Unicode text processing requires understanding
the chosen encoding and using an appropriate library.

Comparing, copying, and concatenating

strcmp compares two valid null-terminated strings lexicographically. Its
result is negative, zero, or positive; only the sign is meaningful. It is
not guaranteed to be exactly -1 or 1. A common mistake is writing
if (strcmp(left, right)) while meaning “equal”; equality is the case where
the result is zero.

Before copying or concatenating, calculate or track the required capacity.
For a destination to hold n visible characters, it needs at least n + 1
bytes for the terminator, and that addition itself must not overflow. Bounded
formatting with snprintf reports the number of characters that would have
been written, excluding the terminator; check for an error and truncation.
Do not treat strncpy as a universally safe string-copy function: it may
leave the destination unterminated and pads with zero bytes in some cases.

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

bool join_names(char *destination, size_t capacity,
                const char *first, const char *second)
{
    if (destination == NULL || first == NULL || second == NULL ||
        capacity == 0) {
        return false;
    }

    int written = snprintf(destination, capacity, "%s %s", first, second);
    return written >= 0 && (size_t)written < capacity;
}

bool same_text(const char *left, const char *right)
{
    return left != NULL && right != NULL && strcmp(left, right) == 0;
}

snprintf bounds the write, and the return value tells whether the full
result fit. strcmp returns zero for equal strings.

Parsing numbers from text

Convert text in stages: read a complete line, call strtol or strtod,
check that parsing consumed at least one character, check range errors, skip
permitted trailing whitespace, then reject any remaining unexpected text.
Use the correct end-pointer type and reset errno before conversion when
checking for ERANGE. Validate the resulting value against the application's
allowed range before narrowing it to another type.

This separates input acquisition from numeric conversion and policy. It also
makes cases such as "12abc", a blank line, a very large number, and
end-of-file explicit.

#include <stdbool.h>
#include <stdio.h>

bool parse_int_line(const char *text, int *output);

int main(void)
{
    const char line[] = "42";
    int number = 0;
    if (parse_int_line(line, &number)) {
        printf("Parsed: %d\n", number);
    } else {
        fputs("Please enter a whole number.\n", stderr);
    }
    return 0;
}

This caller uses the parse_int_line helper defined in the preceding worked
example. Link that helper when building this program.

Exercises: implement a line reader with a documented maximum length;
write a string comparison wrapper that returns a Boolean; parse a whole
integer line and reject trailing non-whitespace characters.

Exercises: count vowels; detect a palindrome; trim the newline from a
line; join two strings into a destination buffer after checking its capacity.


Suggested topics

0% read