Strings
Character arrays, null termination, string input and output, copying, concatenation, comparison, classification, tokenization, numeric conversion, and buffer safety.
C has no built-in string object type. A C string is a convention: a sequence of characters stored in an array and terminated by the first null character, \0. Every string operation depends on that terminator existing within accessible storage.
character: 'C' 'o' 'd' 'e' '\0'
index: 0 1 2 3 4
The string length is four, but the representation needs five array elements. Capacity, length, and termination must remain distinct in your reasoning.
Null Termination
The first \0 marks the logical end of a C string. Characters stored after that position remain part of the array but are invisible to ordinary string functions:
char text[8] = {'C', '\0', 'x', 'x', '\0'};
For string operations, text has length one. The later bytes do not extend it. A terminator must occur within the accessible bound before code may call functions such as strlen, strcmp, or %s; those interfaces search for \0 and do not know the array’s capacity.
Character Arrays
An array of char is only a string when it contains a null terminator within its bounds:
char text[5] = {'C', 'o', 'd', 'e', '\0'}; /* string */
char bytes[4] = {'C', 'o', 'd', 'e'}; /* not a string */
Passing bytes to %s, strlen, or another string function makes the function read beyond the array in search of a terminator, causing undefined behaviour.
An array can hold binary or character data without being a string. The element type does not establish the convention; the stored terminator does.
String Initialization
A string literal can initialize an array:
char language[] = "C17";
The compiler creates four elements: 'C', '1', '7', and \0. Using [] lets the initializer determine the necessary capacity.
This declaration leaves additional writable capacity:
char label[16] = "status";
Elements after the terminator are zero-initialized. The logical string length is six; the array capacity is sixteen.
Arrays and Literal Pointers
These declarations have different mutability:
char editable[] = "hello";
const char *readonly = "hello";
editable is a writable array initialized from the literal. readonly points to a string-literal array that must not be modified. Attempting to modify a string literal has undefined behaviour, even if a compiler accepts char *p = "hello" as legacy-compatible syntax. Use const char * for literal pointers.
Escape Sequences
Escapes become actual stored characters:
char message[] = "first\nsecond";
The array contains a newline character, not two characters \ and n. sizeof message counts every stored character including the final terminator.
String Input
Prefer bounded line input:
enum { NAME_CAPACITY = 64 };
char name[NAME_CAPACITY];
if (fgets(name, sizeof name, stdin) == NULL) {
fputs("could not read name\n", stderr);
return 1;
}
If a newline is read, it is part of the string. Remove it deliberately:
name[strcspn(name, "\n")] = '\0';
If no newline is present, determine whether the line ended at end-of-file or exceeded capacity. A robust input helper can return distinct statuses such as READ_OK, READ_EOF, and READ_TOO_LONG.
For a whitespace-delimited token, a field width protects scanf storage:
char code[8];
if (scanf("%7s", code) != 1) {
/* handle failure */
}
The numeric width is one less than capacity to leave room for \0. Because a variable capacity cannot be substituted directly into a literal format, fgets is easier for reusable input helpers.
Never use gets. It cannot be told the destination capacity and was removed from the C standard.
String Output
puts writes a string followed by a newline:
puts(name);
fputs writes exactly the string to a selected stream and does not add a newline:
fputs("error: ", stderr);
fputs(message, stderr);
fputc('\n', stderr);
printf("%s", text) supports formatting but requires a valid terminated string. A precision can instead place an upper bound on the number of characters read for output:
printf("%.10s\n", text);
If the precision does not exceed the accessible array bound, the conversion need not encounter a null terminator. This can safely print a bounded character sequence, but it does not turn that sequence into a C string or make an arbitrary pointer accessible. Each interface still needs an explicit representation and bound contract.
String Length
strlen counts characters before the first null terminator and returns size_t:
size_t length = strlen("compiler"); /* 8 */
It does not include the terminator. sizeof and strlen answer different questions:
char label[20] = "C";
sizeof label /* 20: array capacity in bytes */
strlen(label) /* 1: current character count */
strlen must scan from the beginning until it finds \0. Calling it repeatedly in a loop condition can repeat work unnecessarily:
size_t length = strlen(text);
for (size_t i = 0; i < length; i++) {
/* ... */
}
Only call strlen when termination is already guaranteed. It cannot safely discover whether an arbitrary byte buffer contains a terminator.
String Copying
strcpy(destination, source) copies through the source terminator. Its precondition is strict: destination capacity must be at least strlen(source) + 1, and source and destination must not overlap.
char source[] = "Ada";
char destination[16];
if (strlen(source) + 1 <= sizeof destination) {
strcpy(destination, source);
}
Check without overflowing the + 1 expression by comparing strlen(source) < capacity when capacity is positive.
Capacity-Aware Copy
A small helper can make truncation policy explicit:
#include <stdbool.h>
#include <stddef.h>
bool copy_string(char destination[], size_t capacity,
const char source[]) {
size_t i = 0;
if (capacity == 0) {
return false;
}
while (source[i] != '\0' && i + 1 < capacity) {
destination[i] = source[i];
i++;
}
destination[i] = '\0';
return source[i] == '\0';
}
The return value says whether the complete source fit. The destination is terminated whenever capacity is nonzero. A production interface should document whether source and destination may overlap; this loop does not support arbitrary overlap.
strncpy is often misunderstood as a universally safe strcpy. If the source length reaches the limit, it does not append a terminator; if the source is short, it pads the remaining range with zero bytes. Use it only when those exact fixed-field semantics are desired.
String Concatenation
strcat(destination, source) finds the destination terminator, then copies the source including its terminator. Required capacity is:
destination length + source length + 1
Check before modifying:
bool append_string(char destination[], size_t capacity,
const char source[]) {
size_t used = 0;
while (used < capacity && destination[used] != '\0') {
used++;
}
if (used == capacity) {
return false;
}
size_t added = strlen(source);
if (added >= capacity - used) {
return false;
}
memcpy(destination + used, source, added + 1);
return true;
}
The bounded first loop verifies that destination is terminated within capacity. The subtraction-based check avoids overflow in used + added + 1. The function still requires a valid source string that does not overlap the destination region.
For formatting several values into a buffer, snprintf is often clearer:
int needed = snprintf(label, sizeof label, "%s: %d", name, score);
if (needed < 0) {
/* encoding or output failure */
} else if ((size_t)needed >= sizeof label) {
/* output was truncated */
}
When successful, snprintf returns the number of characters that would have been written excluding the terminator. This makes truncation detectable.
String Comparison
Arrays cannot be compared by content with ==. After array-to-pointer conversion, left == right compares addresses, not characters.
Use strcmp:
int relation = strcmp(left, right);
if (relation < 0) {
puts("left comes first");
} else if (relation > 0) {
puts("right comes first");
} else {
puts("equal");
}
The contract guarantees negative, zero, or positive—not specifically -1, 0, or 1. Compare with zero.
The order is lexicographic according to values interpreted as unsigned char. It is not locale-aware human dictionary order. Case-insensitive and locale-sensitive comparison require an explicit policy.
strncmp compares at most a requested number of characters. It is appropriate for fixed-length prefixes when used carefully; it does not mean “compare two bounded buffers safely” unless both buffers meet the function’s accessibility contract.
Character Classification
<ctype.h> supplies functions such as isalpha, isdigit, isspace, tolower, and toupper:
#include <ctype.h>
for (size_t i = 0; text[i] != '\0'; i++) {
unsigned char byte = (unsigned char)text[i];
text[i] = (char)toupper(byte);
}
Except for EOF, the argument must be representable as unsigned char. Passing a negative plain char value directly can cause undefined behaviour. The explicit conversion is essential for bytes with the high bit set.
Classification follows the active C locale. It does not decode UTF-8 code points; UTF-8 text may use several bytes per displayed character. A byte-oriented C string length is not necessarily a user-perceived character count.
String Tokenization
Tokenization divides text around delimiters.
strtok
strtok modifies the input string by replacing delimiters with null characters and maintains hidden state between calls:
char line[] = "red,green,blue";
for (char *token = strtok(line, ",");
token != NULL;
token = strtok(NULL, ",")) {
puts(token);
}
Afterward, line no longer contains its original comma-separated form. Consecutive delimiters are treated as one delimiter region, so empty fields are not reported. Hidden state means independent nested tokenizations interfere.
Use strtok only when mutation, collapsed delimiters, and its state model match the task. For CSV, quoted fields, escaped delimiters, or empty fields, write or use a parser for the actual grammar.
Manual Scanning
For simple fields where empty entries matter, scan indices and act at each delimiter or terminator:
for (size_t start = 0, i = 0; ; i++) {
if (line[i] == ',' || line[i] == '\0') {
size_t field_length = i - start;
printf("field length: %zu\n", field_length);
if (line[i] == '\0') {
break;
}
start = i + 1;
}
}
This does not yet copy fields, but its state explicitly preserves zero-length fields.
Numeric Conversion
atoi cannot report invalid syntax or overflow reliably. Prefer strtol, strtoul, strtod, and related functions.
#include <ctype.h>
#include <errno.h>
#include <limits.h>
#include <stdbool.h>
#include <stdlib.h>
bool parse_int(const char text[], int *result) {
char *end;
long value;
if (text == NULL || result == NULL) {
return false;
}
errno = 0;
value = strtol(text, &end, 10);
if (end == text || errno == ERANGE ||
value < INT_MIN || value > INT_MAX) {
return false;
}
while (isspace((unsigned char)*end)) {
end++;
}
if (*end != '\0') {
return false;
}
*result = (int)value;
return true;
}
Validation stages are visible:
| Input | Conversion | Range | Remainder | Result |
|---|---|---|---|---|
"42" | digits consumed | fits | empty | accept 42 |
" -7\n" | digits consumed | fits | whitespace | accept -7 |
"12x" | digits consumed | fits | x | reject |
"hello" | no digits | — | original text | reject |
| huge digits | conversion range error | fails | — | reject |
Only write *result after every check passes, so failure leaves the caller’s previous value unchanged.
Buffer Safety
For every write, reason about three numbers:
- current length;
- total capacity;
- bytes to add, including any required terminator.
Maintain this invariant:
A string buffer has at least one accessible null character at or before its final element.
Useful practices:
- pass capacity with every writable string buffer;
- use
size_tfor lengths and capacities; - validate before adding sizes to avoid arithmetic overflow;
- distinguish rejection from truncation explicitly;
- do not treat arbitrary network or file bytes as a string until termination is established;
- keep ownership and lifetime visible when returning or storing pointers.
Whitespace Normalizer
This complete program reads one bounded line and normalizes runs of whitespace to single spaces, removing leading and trailing whitespace. The transformation happens in place, so the write index never advances beyond the read index.
#include <ctype.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void normalize_spaces(char text[]) {
size_t read = 0;
size_t write = 0;
bool pending_space = false;
while (text[read] != '\0') {
unsigned char byte = (unsigned char)text[read];
if (isspace(byte)) {
if (write != 0) {
pending_space = true;
}
} else {
if (pending_space) {
text[write] = ' ';
write++;
pending_space = false;
}
text[write] = text[read];
write++;
}
read++;
}
text[write] = '\0';
}
int main(void) {
char line[128];
fputs("text: ", stdout);
fflush(stdout);
if (fgets(line, sizeof line, stdin) == NULL) {
fputs("no input\n", stderr);
return EXIT_FAILURE;
}
if (strchr(line, '\n') == NULL) {
int ch = getchar();
if (ch == EOF && ferror(stdin)) {
perror("finishing input line");
return EXIT_FAILURE;
}
if (ch != '\n' && ch != EOF) {
while ((ch = getchar()) != '\n' && ch != EOF) {
}
fputs("line exceeds buffer capacity\n", stderr);
return EXIT_FAILURE;
}
}
normalize_spaces(line);
printf("normalized: \"%s\"\n", line);
return EXIT_SUCCESS;
}
For the bytes representing " red\t fox \n", the core state evolves as follows:
| Read byte | read after | write after | pending | written prefix |
|---|---|---|---|---|
| space | 1 | 0 | false | "" |
| space | 2 | 0 | false | "" |
r | 3 | 1 | false | "r" |
e, d | 5 | 3 | false | "red" |
| tab/spaces | 8 | 3 | true | "red" |
f | 9 | 5 | false | "red f" |
o, x | 11 | 7 | false | "red fox" |
| trailing whitespace | end | 7 | true | "red fox" |
| terminator write | — | — | — | "red fox\0" |
The invariant is that text[0..write) contains the normalized form of the consumed input prefix, without trailing whitespace. pending_space remembers that a separator should be written only if another non-whitespace byte arrives. This delay is what removes trailing whitespace without needing a second cleanup pass.
Because normalization never expands the string, the existing capacity is sufficient. At all times write <= read; before the loop, the original accessible terminator proves there is a final slot where the new terminator can be written. That proof would fail for a transformation that can expand one byte into several.
Length and Capacity Ledger
Every string-writing operation should show its arithmetic before copying. To append suffix to destination:
used = bytes before destination's terminator
added = bytes before suffix's terminator
required = used + added + 1
safe only if required <= capacity
The addition also needs an overflow-safe form:
if (added > capacity - used - 1) {
/* insufficient space, assuming used < capacity */
}
Subtraction is safe only after proving used < capacity; otherwise capacity - used - 1 can underflow. A capacity-aware function should first establish that the destination is already terminated within its bound. Calling strlen on an unterminated buffer does not detect the defect—it reads beyond the buffer searching for a null byte.
Truncation must be an explicit result, not a silent side effect. A display label may reasonably be shortened with a visible marker. Truncating a password, file path, identifier, or security token can turn distinct inputs into the same value. “Always terminate” is necessary for string safety but does not by itself make data loss acceptable.
Byte Grammar
Strings are often input to a grammar. Even a comma-separated format forces choices:
- Are empty fields allowed at the beginning, middle, or end?
- Is whitespace data or decoration?
- Can delimiters appear inside quoted fields?
- How are quote characters escaped?
- Are embedded null bytes impossible by construction?
- Are fields interpreted as raw bytes, UTF-8, or a locale encoding?
strtok answers these questions in one particular way: it mutates input, collapses delimiter runs, and omits empty fields. That is not a defective function; it is a mismatch for formats whose grammar makes other choices.
For manual scanning, the half-open field range [start, end) remains valid even when start == end, which represents an empty field. Keeping ranges instead of immediately inserting terminators can preserve the original input and allow validation before copying.
Text and Unicode
C’s ordinary string functions operate on arrays of char and count bytes. In UTF-8, a displayed character may occupy one to four bytes, and a user-perceived symbol may combine multiple code points. Therefore:
strlen(text) -> byte count before null
number of Unicode code points -> different operation
number of displayed grapheme clusters -> different again
terminal column width -> context-dependent
Byte-oriented operations are entirely appropriate for protocols defined in bytes, ASCII identifiers, and storage management. They become insufficient when a contract promises character counts, case folding, normalization, or cursor movement for arbitrary human text. State the encoding boundary rather than calling every byte a character and hoping test data stays English.
Capacity-Aware Append
A reusable append function can report insufficient space without modifying the destination:
#include <stdbool.h>
#include <stddef.h>
#include <string.h>
bool string_append(char destination[], size_t capacity,
const char source[]) {
if (destination == NULL || source == NULL || capacity == 0) {
return false;
}
size_t used = 0;
while (used < capacity && destination[used] != '\0') {
used++;
}
if (used == capacity) {
return false;
}
size_t added = strlen(source);
if (added > capacity - used - 1) {
return false;
}
memcpy(destination + used, source, added + 1);
return true;
}
The bounded loop never examines destination[capacity]. Once it finds the terminator, the later subtraction capacity - used - 1 is defined and represents spare character slots before the required terminator. A similarly named strnlen function exists in POSIX and some C implementations, but it is not an ISO C17 facility; a bounded-looking name does not automatically make an operation part of the chosen language standard.
The final memcpy also assumes source bytes do not overlap the destination region. If a caller may append from within the same destination buffer, reallocation is not involved but overlap may be; use memmove under a carefully defined policy or reject overlapping ranges. A function’s capacity proof is not its complete aliasing proof.
Cursor Parsing
Parsing with a cursor keeps consumption explicit. For "red,,blue,", define each field as the half-open byte range from start to the next comma or terminator:
| Field | start | delimiter index | length | contents |
|---|---|---|---|---|
| 1 | 0 | 3 | 3 | red |
| 2 | 4 | 4 | 0 | empty |
| 3 | 5 | 9 | 4 | blue |
| 4 | 10 | 10 terminator | 0 | empty |
The parser does not need to modify input to recognize these ranges. A later step can validate or copy a field. This is valuable when diagnostics must quote the original line or several independent parsers need it.
For quoted fields, a delimiter no longer always ends a field. The parser gains a state such as unquoted, quoted, or after-quote, plus an escape policy. At that point, scattered calls to strchr are less reliable than a small documented state machine.
Comparison Policy
strcmp answers bytewise lexicographic ordering under its specified interpretation. Applications often need something else:
identifier equality -> often exact byte equality
ASCII command -> explicit ASCII case folding may be acceptable
human name sorting -> locale/collation policy
filesystem path equality -> platform-specific rules
Unicode user text -> normalization and case-folding policy
security token -> exact length-aware bytes, sometimes constant-time comparison
Calling tolower byte by byte does not implement general Unicode case-insensitive comparison. Even under a single-byte locale, convert arguments through unsigned char and decide whether locale-dependent behaviour is desired.
Do not normalize secrets or identifiers without a specification. Two byte strings that look similar can be intentionally distinct. Conversely, visually identical Unicode text can have different encodings. “Compare strings” is incomplete until equality or ordering semantics are named.
Embedded Null Boundary
Data read with fread can contain zero bytes. Treating it as a string causes functions to stop at the first zero, perhaps ignoring a malicious suffix:
bytes: A D M I N 00 D E N Y
strlen -> 5
actual byte record length -> 10
Convert a byte record to a C string only after proving an encoding rule, available terminator space, and absence or defined treatment of embedded zeros. Length-aware byte interfaces are the right tool for arbitrary file, compressed, cryptographic, and network data.
String Failures
- Forgetting terminator storage: an
n-character string needs at leastn + 1elements. - Modifying a string literal: behaviour is undefined.
- Using
%swithout a width for input: destination capacity can be exceeded. - Comparing strings with
==: this compares pointer values after conversion. - Assuming
strncpyalways terminates: it does not when the source reaches the bound. - Passing negative
charto ctype functions: convert throughunsigned char. - Using
atoifor validated input: invalid syntax and overflow are not reported adequately. - Assuming one byte equals one human character: multibyte encodings break that model.
String Reasoning
- A C string is a null-terminated character sequence stored within accessible array bounds.
- Array capacity includes terminator space; string length does not.
fgetsprovides bounded line acquisition, while parsing and truncation policy remain the program’s responsibility.- Copying and concatenation require explicit destination-capacity proofs.
strcmpcompares contents; pointer equality does not.- ctype functions require
EOFor anunsigned charvalue converted toint. - Tokenizers embody grammar choices about mutation, delimiters, and empty fields.
strtol-family conversion supports lexical, range, and trailing-text validation.
String Problems
Find the Terminator
- Distinguish array capacity, string length, and terminator position.
- Why is
char *text = "hello"; text[0] = 'H';invalid? - What does the sign of
strcmp’s result mean? - Why must ctype arguments be converted through
unsigned char?
Scan the Bytes
- Draw every array element for
char text[8] = "cat";immediately after initialization. - Trace
strtokover"a,,b,"and identify which empty fields are lost. - Apply the
parse_intvalidation stages to" 17x ","+9\n", and an empty line.
Repair the Buffer
- Repair
char name[5]; scanf("%s", name);and explain the limitations of your correction. - Find the capacity error in a concatenation check that tests
used + added <= capacity. - Replace a string comparison using
==with a correct content comparison.
Transform Text
- Implement
trim_newlineand a line-reading function that reports overlong input separately from end-of-file. - Write a capacity-aware string append function without calling
strcat; guarantee termination on every nonzero-capacity path. - Count letters, decimal digits, whitespace bytes, and other bytes in a line using
<ctype.h>correctly. - Parse a comma-separated line while preserving empty fields. State which CSV features your small grammar does not support.
Specify the Capacity
- Design a text-input contract for a username: byte capacity, accepted characters, empty-input policy, truncation policy, and error reporting.
- Compare rejection and truncation for passwords, display labels, and log messages. Explain why one policy is not correct for every string.