Lesson 11 / 13
Arrays and Strings
An array's element count being determined at compile time through constant folding, and a string's length being determined at runtime by scanning until the terminating byte; the array–pointer relationship measured through element value and difference without printing an address.
Published Updated
In this lesson
- Distinguishes, by measuring, that array length is determined at compile time and string length at runtime
- Shows with an example how the terminating byte determines a string's boundary at runtime
- Measures the array–pointer relationship through element value and difference, without printing an address
Contents
The Functions lesson measured which definition a call is bound to, how an argument is copied, and how a return is interpreted. This lesson returns to the transferred data itself, comparing how two different data kinds carry their length: an array’s length and a string’s length, even though both rest on the same memory layout, are decided at two separate stages.
The Programming Fundamentals course (M01/K02) already established the list and string
concepts with Python; there, a list’s length was information that came along on its own,
always askable with a call (len). C’s counterpart is measured here: an array’s length
is produced at compile time from a constant expression written in the source, while a
string’s length is stored nowhere at all — it is learned only by scanning at runtime
until a terminating byte is reached. This lesson is the one that shows both ends of the
course’s measurement axis at once: two neighboring values sitting in the same memory
layout give the answer “the source is enough” for one, and “only runtime is enough” for
the other.
Constant Folding: Array Length at Compile Time
The excerpt below is part of the core introduced in the Compilation Stages lesson: an array’s size is produced from a constant expression written in the source.
// stage.c — constant folding (compiler) and string length (runtime) excerpt
#include <stdio.h>
#include <string.h>
int main(void) {
int constant_array[2 * 3 + 1];
printf("%s %d\n", "constant folding", (int)(sizeof constant_array / sizeof constant_array[0]));
const char *text = "hello, world!";
printf("%s %d\n", "string length", (int)strlen(text));
return 0;
}
constant folding 7 string length 13
BS9. The expression 2 * 3 + 1 is bound to no variable — it is built only from
constants, and the compiler folds this expression down to 7 without the program
ever running; constant_array’s element count is already written into the binary as 7.
strlen(text) is the exact opposite: the byte sequence the text pointer points to is
scanned one byte at a time until \0 is found, and the length (13) only emerges once
this scan is complete. Both measurements sit on the same line, in the same function, but
one confirms a number that could be read from the source, while the other produces a
number that can only be learned by running it.
Because constant_array‘s size is embedded in the binary, the expression sizeof constant_array itself requires no computation at runtime either — the compiler has
already replaced this expression with a constant. This shows that sizeof, in most of
its uses, is a compile-time operator: it produces a result by looking only at its
operand’s type, never at its actual value. In a declaration like constant_array[2 * 3 + 1], the expression inside the brackets falls into the same category — the expression
determining an array’s size must be a constant expression; had a variable or a
function call entered it, the compiler could no longer know the size in advance, and this
would enter C’s separate subject of variable-length arrays. The array measured in this
lesson always has a fixed length.
The Terminating Byte’s Role: String Length at Runtime
A string’s length is stored nowhere separately; strlen and similar functions
compute the length by scanning memory until they encounter a \0 byte. When the
terminating byte’s position changes, the same memory area reports a different length.
// terminator.c — the terminating byte's position determines at runtime where a string ends
#include <stdio.h>
#include <string.h>
int main(void) {
char short_string[6] = {'h', 'e', 'l', 'l', 'o', '\0'};
printf("%s %d\n", "length while the terminator is at the end", (int)strlen(short_string));
short_string[2] = '\0';
printf("%s %d\n", "length once the terminator is placed early", (int)strlen(short_string));
return 0;
}
length while the terminator is at the end 5 length once the terminator is placed early 2
BS10. The short_string array allocates six bytes, the first five letters and the
sixth \0; strlen returns 5 because it encounters the terminator right after the
fifth byte. Once short_string[2] is changed to '\0', the array itself is still six
bytes — no memory was reallocated, no element was removed — but strlen now returns 2,
because the scan stops the instant it reaches the third byte. This shows that a string’s
“length” is not actually a storage property but a runtime result: the same memory
layout can report a different length depending on the terminator’s position, and that
position is an ordinary byte that can change at runtime.
This also explains why a string is not a type of its own in C: char short_string[6] is
a char array, and to the language’s eyes carries nothing more than six separate char
values. “String” is only an interpretation built on a convention — that a \0 byte
will be found somewhere inside it, and that byte marks the boundary — not a property the
language itself enforces. There is no such convention for an integer array (int array[]) — there is no part of the language stating a contract for which element marks
the “end”; this is why an integer array’s length is either determined by a constant
expression in the source (like constant_array above) or carried separately in a
variable. A string is a special case, only for char arrays, that carries its own length
within itself through the terminating-byte convention. Manually writing '\0' into
short_string[2] does not disturb the array’s own memory layout at all; it only changes
where this convention stops. Functions like strlen assume this convention — they
trust that a \0 really exists somewhere in the memory a pointer passed to them points
to; if the terminator is absent entirely, the scan runs past the array’s allocated
boundary, and this is an undefined-behavior scenario this course’s general rule does not
try.
The Array–Pointer Relationship: Measuring Without Printing an Address
In most contexts, an array’s name decays into a pointer to its first element. This relationship can be measured without printing an address, purely through element value and difference.
// relationship.c — the array–pointer relationship measured through value and difference, no address printed
#include <stdio.h>
int main(void) {
int array[] = {10, 20, 30, 40};
int *p = array;
printf("%s %d\n", "value accessed via p's offset", *(p + 2));
printf("%s %d\n", "value accessed via array indexing", array[2]);
printf("%s %d\n", "are the two accesses the same", (int)(*(p + 2) == array[2]));
printf("%s %d\n", "are the addresses the same", (int)((p + 2) == &array[2]));
printf("%s %d\n", "difference in elements", (int)(&array[3] - &array[0]));
return 0;
}
value accessed via p's offset 30 value accessed via array indexing 30 are the two accesses the same 1 are the addresses the same 1 difference in elements 3
BS11. With the assignment p = array, p takes the address of the array’s first
element; *(p + 2) and array[2] produce the same value (30), because array[i] is
already shorthand for *(array + i) — both compute the same address and read the same
value. The comparison (p + 2) == &array[2] confirms this too: the two addresses are
equal. The difference &array[3] - &array[0] is not a raw byte difference but a
difference in elements — the result is 3, because there are three int-widths of
stride between the two pointers; this is a behavior the difference operator has specially
defined for pointers in C, given already divided by the int width. No step printed a
real address; everything measured was a value, an equality, or an element difference.
That array[2] is only shorthand for *(array + 2) shows that C does not dedicate a
separate mechanism to array indexing: the square bracket is a syntactic convenience built
on top of pointer arithmetic. This is also why the notations array[2] and 2[array]
mean the same thing — because addition is commutative, array + 2 and 2 + array give
the same address, and both notations go through the same translation. This lesson does
not measure that equivalence, but it does confirm that what underlies array[i] is
genuinely an addition and a dereference — not some special “array access”
instruction. This is a language-level equivalence: both notations compute the same
address and give the same value. How many steps the compiler’s produced machine code
spends is a separate question — array is an array name, p is a variable that must be
read from memory — and this lesson does not measure that.
This equivalence leaves a fine distinction between the array itself and the pointer the
array decays into: the array name by itself carries the name of the whole array, while
p is a pointer variable and can be reassigned to point to another array — p = other_array; is valid, but array = other_array; is not, because array is an array
declaration, not a reassignable pointer variable. As a rule, an array name decays into
the address of its first element whenever it occurs in an expression; a few cases are
exceptions to this, such as being the operand of sizeof or &. What decays is an
address value, not a reassignable pointer variable.
The Bounding Measurement
An array’s length is known at compile time, but this information does not pass to a function. A function taking an array as a parameter receives not the array itself but only the address of its first element, and cannot know the length on its own.
// length.c — array length information does not pass to a call, the receiving side must be given it separately
#include <stdio.h>
#include <stddef.h>
static int sum_assuming_four(int data[]) {
/* this function does not know the array's real size: it assumes a count of 4 */
int sum = 0;
for (int i = 0; i < 4; i++) {
sum += data[i];
}
return sum;
}
static int sum_with_length(int data[], size_t length) {
int sum = 0;
for (size_t i = 0; i < length; i++) {
sum += data[i];
}
return sum;
}
int main(void) {
int large[] = {1, 2, 3, 4, 5, 6};
printf("%s %d\n", "sum with an assumed fixed count", sum_assuming_four(large));
printf("%s %d\n", "sum with a given length", sum_with_length(large, 6));
int small_array[] = {10, 20, 30};
printf("%s %d\n", "sum with a given length, different size", sum_with_length(small_array, 3));
return 0;
}
sum with an assumed fixed count 10 sum with a given length 21 sum with a given length, different size 60
Even though int data[] is written in sum_assuming_four‘s source, it only receives an
int * when called — it carries no information at all about the array’s real size. The
function has written its loop with a fixed bound of 4 and sums large‘s first four
elements, giving 10 — short of the array’s real sum, 21, because the last two
elements are never seen. sum_with_length, by contrast, takes its length as a separate
parameter (length) and produces the correct sum (21); the same function, called with
a differently sized array like small_array, also gives the correct result (60),
because it takes its loop bound from the caller instead of writing it as a fixed value in
the source. This shows how the “a parameter is always a copy” rule measured in the
Functions lesson works for an array: what gets copied is not the array itself, but the
address of its first element — and alongside that address, the array’s element count is
lost unless it is carried separately.
The int data[] written in sum_assuming_four‘s declaration is interpreted by the
compiler as int *data — an array declaration in a parameter list is always converted to
a pointer declaration in C. This is why writing sizeof(data) inside the function body
would not give the array’s size but a pointer’s size; since no size value is printed in
this course, this lesson does not measure that error, but it does explain why
sum_assuming_four had to assume a fixed number (4): the function can in no way
derive the array’s real length from the pointer it is given. This is a direct
consequence of the array–pointer relationship measured above: when an array passes to a
function, only the address of the first element remains, no metadata belonging to the
array itself (such as element count) travels along with that address. This is also the
same constraint behind why the standard library’s string functions (strlen included)
encode length information with a terminating byte: if only a starting address can be
passed to a function, where the data ends must be carried along with that data, as a
mark.
Summary
- An array’s element count is produced at compile time from a constant expression in the source (constant folding); class compiler.
- A string’s length is stored nowhere; it is computed at runtime by scanning until
the terminating
\0byte is reached; class runtime. This lesson thus carries two classes at once. - The terminating byte’s position can change at runtime; the same memory area can report a different string length depending on the terminator’s placement.
- The array–pointer relationship can be measured without printing an address: element
value (
*(p + i)andarray[i]are the same), equality, and difference in elements are enough. - An array’s length information does not pass to a function; the receiving side cannot know the array’s real size on its own unless it takes the length as a separate parameter.
- An array declared as a parameter is converted by the compiler to a pointer; the string’s terminating-byte convention exists precisely for this reason — so that functions forced to work with only a starting address can carry the length along with the data.
Next Step
The two data kinds measured in this lesson — array and string — always stayed within the
boundary of a single variable. The Structs, Unions and Enumerations lesson measures three
new mechanisms that gather several values under a single name: how a struct’s fields are
laid out, how a union shares the same memory, and how an enumeration constant turns into
a compile-time constant. This lesson measured that an array’s length comes from a
constant expression in the source; in the next lesson, the same constant-expression idea
will explain how an enumeration value is produced. Arrays and strings here always carried
a single-type sequence of values — all int or all char; structs, by contrast, will
gather values of different types under a single name, and how those values are laid out
in memory will again be a question decided at compile time.
To keep your progress and take notes, Log in
My notes
Log in to take notes.