Skip to content
academia.sh

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 \0 byte 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) and array[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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close