C Arrays: One-Dimensional, Multidimensional, and Decay
Declare and use one-dimensional and multidimensional C arrays, understand how arrays decay to pointers when passed to functions, recognize that C performs no bounds checking, and distinguish stack arrays from static arrays.
An array in C is a fixed-size, contiguous block of memory holding elements of the same type, indexed starting at zero. Arrays are the foundation for almost everything more complex in C — strings are arrays of characters, buffers are arrays of bytes, and Objective-C's own C underpinnings (NSData bytes, C-style loops over collections) still lean on raw arrays constantly. Unlike higher-level languages, C arrays are 'dumb': they carry no built-in length, no bounds checking, and, as soon as you pass one to a function, they quietly stop being an array at all and become a pointer. Understanding exactly when and why that happens is essential before writing a single line of Objective-C.
One-Dimensional Arrays
A one-dimensional array is declared with a type, a name, and a size in square brackets: int scores[5]; reserves space for exactly five contiguous int values, indexed scores[0] through scores[4]. The size must be known at compile time for a fixed-size declaration (or, in C99 and later, can be a runtime value for a variable-length array on the stack, though VLAs are used sparingly in practice because their size isn't checked either and a large one can blow the stack). Elements are accessed and assigned with the subscript operator: scores[0] = 95; writes into the first slot, scores[2] reads the third.
An array can be initialized at the point of declaration with a brace-enclosed list: int scores[5] = {95, 88, 76, 100, 62};. If you initialize with fewer values than the declared size, the remaining elements are zero-filled: int scores[5] = {95}; leaves indices 1 through 4 as 0. You can also let the compiler infer the size from the initializer list by omitting the number: int scores[] = {95, 88, 76}; creates a three-element array. Every element in a C array must be the same type — there is no mixed-type array the way some dynamic languages allow.
Internally, scores occupies 5 * sizeof(int) bytes of contiguous memory, laid out back-to-back with no gaps (ignoring alignment padding for struct arrays). This contiguity is what makes scores[i] fast: the compiler computes the address as base_address + i * sizeof(int) and reads directly from memory — no searching, no indirection, just arithmetic. That same arithmetic is exactly what pointer arithmetic does later in this module, which is why arrays and pointers are so closely related in C.
• int scores[5]; — a fixed-size array of five ints, indices 0 through 4. • int scores[5] = {95, 88, 76, 100, 62}; — declare and initialize together. • int scores[] = {95, 88, 76}; — size inferred from the initializer list. • Uninitialized trailing elements in a partial initializer list are zero-filled. • Array access is O(1): address = base + index * sizeof(element).
Multidimensional Arrays
C supports multidimensional arrays as arrays of arrays. A 2D array int grid[3][4]; reserves space for 3 rows of 4 ints each — 12 ints total, still laid out as one contiguous block of memory in row-major order (the entire first row, then the entire second row, and so on). Accessing an element uses two subscripts: grid[1][2] reads the element in row 1, column 2. You can initialize a 2D array with nested brace lists: int grid[2][3] = {{1, 2, 3}, {4, 5, 6}};.
Higher dimensions work the same way — float cube[2][3][4]; is a 3D array — but in practice most real C code rarely goes past two dimensions, since anything more becomes hard to reason about and is usually better modeled with a flat array plus manual index math, or a struct. Row-major layout matters for performance: looping with the rightmost index changing fastest (for (i...) for (j...) grid[i][j]) walks memory sequentially and is cache-friendly; looping the other way jumps around memory and is slower for large arrays.
When a multidimensional array is passed to a function, only the first dimension's size can be omitted from the parameter type — every other dimension must be specified, because the compiler needs those sizes to compute the byte offset for grid[i][j] via pointer arithmetic. So a function taking a 2D array of 4-int rows must be declared void printGrid(int grid[][4], int rows); — the 4 cannot be dropped.
• int grid[3][4]; — 3 rows, 4 columns, 12 ints laid out contiguously in row-major order. • grid[1][2] — row 1, column 2 (zero-indexed on both axes). • Nested initializer: int grid[2][3] = {{1,2,3},{4,5,6}};. • Row-major traversal (inner loop over columns) is more cache-friendly than column-major. • In a function parameter, only the first dimension's size may be omitted; inner dimensions are required.
Array Decay: Passing Arrays to Functions
This is the single most important fact about C arrays: when an array is passed as a function argument, it 'decays' into a pointer to its first element. The function does not receive a copy of the array, and it does not receive any information about the array's original size — it receives a plain address. void printAll(int arr[], int length) and void printAll(int *arr, int length) are exactly the same function signature to the compiler; the [] notation in a parameter list is purely cosmetic sugar for a pointer.
The practical consequence is that sizeof behaves completely differently inside the function versus at the array's original declaration site. sizeof(scores) at the point where int scores[5]; was declared correctly reports 20 bytes (5 ints × 4 bytes). But sizeof(arr) inside a function that received scores as a parameter reports the size of a pointer (8 bytes on a 64-bit system) — because arr really is just a pointer there, not an array. This is why every function that operates on a C array must be handed the element count explicitly as a separate parameter; the array itself cannot tell you how big it is once it has decayed.
Array decay also explains why you cannot assign a whole array with = (arr2 = arr1; is a compile error for arrays, unlike structs), and why arr == &arr[0] in a pointer context — the array name, whenever it appears in most expressions (anywhere except sizeof and &arr), evaluates to the address of its first element. This decay-to-pointer behavior is the direct ancestor of why an Objective-C object variable is always a pointer, a connection this module builds toward explicitly in the pointers lesson.
• Passing an array to a function decays it to a pointer to its first element — no size info travels with it. • void f(int arr[], int n) and void f(int *arr, int n) are identical signatures. • sizeof(arr) inside such a function gives the pointer's size, not the original array's size. • Always pass the element count as an explicit second parameter. • An array name decays to &arr[0] in nearly every expression context except sizeof and &arr.
No Bounds Checking: A Real Hazard
C performs zero bounds checking on array access, at compile time or at runtime. int scores[5]; scores[10] = 1; compiles cleanly and runs — it simply writes 40 bytes past the start of scores, corrupting whatever happens to live there: another variable, a saved return address, the heap's bookkeeping metadata, anything. This is not a theoretical warning; out-of-bounds array writes are one of the most common sources of memory corruption bugs and security vulnerabilities in C history (buffer overflow exploits are almost always exactly this).
Reading out of bounds is just as dangerous, if quieter: int leaked = scores[10]; reads whatever garbage bytes happen to occupy that memory and hands them back as if they were a valid int — no crash, no warning, just silently wrong data that can produce bugs far away from the actual mistake and long after it happened. The only defense is discipline: always know your array's true size, always pass it explicitly to functions, and always validate any index that comes from user input, a loop bound, or external data before using it to subscript an array.
This is one of the sharpest differences from Objective-C's own collection types: NSArray and NSMutableArray do perform bounds checking and throw an NSRangeException on an out-of-bounds access rather than silently corrupting memory. That safety comes at the cost of the check itself being extra work at runtime — a tradeoff C arrays deliberately don't make, favoring raw speed and letting the programmer bear full responsibility instead.
• C never checks that an index is within [0, size) — you must guarantee it yourself. • Writing out of bounds corrupts adjacent memory silently; this is the root cause of classic buffer-overflow exploits. • Reading out of bounds returns garbage data with no error or crash. • Objective-C's NSArray/NSMutableArray DO bounds-check and throw NSRangeException — a deliberate safety/performance tradeoff C does not make.
Stack Arrays vs Static Arrays
A local array declared inside a function, like int buffer[100]; written at the top of a function body, is a stack array: its memory lives on the call stack, is created fresh every time the function is entered, and is destroyed automatically the instant the function returns. Stack arrays are fast to allocate (just moving the stack pointer) but dangerous to return a pointer to — returning &buffer[0] from that function hands the caller a pointer to memory that no longer belongs to anything, a classic dangling-pointer bug covered in depth in the next lesson.
A static array, declared either at file scope or inside a function with the static keyword (static int cache[100];), lives for the entire lifetime of the program instead. It is allocated once, before main() even runs, and persists until the program exits. A static array declared inside a function keeps its contents between calls — unlike a stack array, which starts with indeterminate garbage values every single call unless explicitly initialized (a plain int buffer[100]; with no initializer contains whatever bytes happened to be on the stack already, not zeros).
The choice between them is about lifetime and scope: use a stack array for short-lived, function-local scratch space; use a static array when you need data to persist across calls without using a global variable, or when you need memory that unambiguously outlives the function that first touches it. Static and global arrays are, by contrast with an uninitialized stack array, guaranteed to be zero-initialized by the C standard if you don't supply an initializer.
• Stack array: local to the function, allocated on entry, destroyed on return, fast but temporary. • Never return a pointer/address into a stack array from the function that declared it — dangling pointer. • static int cache[100]; — allocated once, persists for the entire program lifetime. • An uninitialized stack array holds garbage; an uninitialized static/global array is guaranteed zero-filled.
Array Declaration and Passing Forms
arr[] in a parameter list is exactly the same as int *arr — the compiler treats them identically. The element count (length) must always be passed alongside a decayed array parameter.
int scores[5]; // stack array, uninitialized (garbage)
int scores[5] = {95, 88, 76, 100, 62}; // declared and initialized
int scores[] = {95, 88, 76}; // size inferred: 3
static int cache[100]; // lives for the whole program
int grid[3][4]; // 2D array, row-major layout
int grid[2][3] = {{1,2,3},{4,5,6}}; // nested initializer
// Decays to a pointer the moment it crosses a function boundary:
void printScores(int arr[], int length) {
for (int i = 0; i < length; i++) {
printf("%d\n", arr[i]);
}
}Code Examples
sizeof(scores) / sizeof(scores[0]) is the standard idiom for computing an array's element count, but it only works where the array is still a real array — inside main(), not inside sumArray() where it has already decayed to a pointer. Expected result: Sum: 421 sizeof(scores) at the declaration site reports the full array size (20 bytes for 5 ints), but sizeof(arr) inside inspect() reports only the size of a pointer (typically 8 bytes on 64-bit systems), because arr decayed into a pointer the moment it was passed. Expected result: At declaration: 20 bytes Inside function: 8 bytes
Common mistake: Assuming sizeof still 'knows' the array's length inside any function it was passed into — it never does, once decay has happened.
There is no runtime check on buffer[4], even though buffer only has valid indices 0-3. Depending on how the compiler lays out local variables, this out-of-bounds write can silently overwrite a completely unrelated variable like guard — this is undefined behavior, and the exact result is not guaranteed, which is what makes these bugs so hard to track down. In real-world code this same class of bug, at larger scale, is the mechanism behind classic stack buffer-overflow security exploits.
#include <stdio.h>
int sumArray(int arr[], int length) {
int total = 0;
for (int i = 0; i < length; i++) {
total += arr[i];
}
return total;
}
int main(void) {
int scores[5] = {95, 88, 76, 100, 62};
int n = sizeof(scores) / sizeof(scores[0]); // valid ONLY at the declaration site
printf("Sum: %d\n", sumArray(scores, n));
return 0;
}#include <stdio.h>
void inspect(int arr[]) {
printf("Inside function: %zu bytes\n", sizeof(arr));
}
int main(void) {
int scores[5] = {95, 88, 76, 100, 62};
printf("At declaration: %zu bytes\n", sizeof(scores));
inspect(scores);
return 0;
}#include <stdio.h>
int main(void) {
int guard = 12345;
int buffer[4] = {1, 2, 3, 4};
buffer[4] = 999; // one past the end — undefined behavior, no error raised
printf("guard = %d\n", guard); // may print 999 depending on stack layout
return 0;
}Array Decay at a Function Boundary
An array only 'knows' its own size at the scope where it was declared. Once it crosses a function boundary, it is a bare pointer — the size must travel alongside it as its own parameter.
main() printScores(arr, length)
┌───────────────────────┐ ┌─────────────────────────┐
│ int scores[5] = │ decays │ int *arr; // was arr[] │
│ {95,88,76,100,62} │ ─────────▶ │ int length; // 5, passed │
│ sizeof = 20 bytes │ (address │ separately │
│ (a real array) │ of first │ sizeof(arr) = 8 bytes │
│ │ element) │ (just a pointer now) │
└───────────────────────┘ └─────────────────────────┘Important Rules
Always pass the element count explicitly alongside any array parameter — the array itself cannot report its own length after decay.
Never trust sizeof(arr) inside a function that received arr as a parameter — compute counts only at the true declaration site.
Validate every array index that comes from external input, a computed offset, or a loop bound before subscripting.
Never return a pointer to a stack-allocated local array from the function that declared it.
Prefer static/global storage (or heap allocation, covered later) when data must outlive the function that creates it.
Buffer Overflows in Practice
The Morris Worm (1988) and countless CVEs since exploited exactly this class of bug — writing past the end of a fixed-size C array (often a stack buffer) to overwrite adjacent memory, including saved return addresses, and redirect program execution. Modern compilers add mitigations like stack canaries and ASLR, but the underlying language-level hazard — arrays with no bounds checking — has never gone away, which is precisely why higher-level layers like Objective-C's NSArray add bounds checking back in at the cost of a runtime check.
void printAll(int arr[]) { int n = sizeof(arr) / sizeof(arr[0]); // WRONG inside a function for (int i = 0; i < n; i++) printf("%d\n", arr[i]); } void printAll(int arr[], int n) { for (int i = 0; i < n; i++) printf("%d\n", arr[i]); }
Once an array has decayed into a function parameter, sizeof(arr) reports the size of a pointer, not the array — the only reliable way to know the length inside the function is to be told it explicitly.
int scores[5] = {1,2,3,4,5}; scores[5] = 6; // writes one past the last valid index int scores[6] = {1,2,3,4,5,6}; // size the array correctly up front
Valid indices for a 5-element array are 0 through 4; writing to index 5 is out of bounds and C will not stop you — it silently corrupts adjacent memory instead of raising an error.
int *getBuffer(void) { int buffer[10]; return buffer; // returns address of a stack array that is about to be destroyed } int *getBuffer(void) { static int buffer[10]; // lives for the whole program, not just this call return buffer; } buffer is destroyed the instant getBuffer() returns because it lives on the stack frame that just got popped; the returned pointer is dangling. Marking it static (or allocating on the heap) gives it a lifetime that outlives the function call.
What happens when a C array is passed as a function argument? A. A full copy of the array is made and passed by value B. It decays into a pointer to its first element, with no size information C. The compiler automatically passes its length alongside it D. It is passed by reference and the function can resize it
B. It decays into a pointer to its first element, with no size information — Arrays decay to a pointer to their first element when passed to a function; the original size is not carried along, which is why the length must be passed as a separate parameter.
Why does sizeof(arr) give a different (smaller) answer inside a function than at the array's original declaration? A. Because the compiler optimizes away unused elements B. Because arr has decayed into a plain pointer inside the function, so sizeof reports the pointer's size C. Because function parameters are always half the size of local variables D. sizeof always returns 4 regardless of context
B. Because arr has decayed into a plain pointer inside the function, so sizeof reports the pointer's size — Once an array decays into a function parameter it is just a pointer; sizeof(arr) there measures the pointer itself (commonly 8 bytes on 64-bit systems), not the original array.
What does C do if you write to an index outside an array's valid range, such as scores[10] on a 5-element array? A. It throws a runtime exception B. It refuses to compile C. It silently performs the write with no bounds checking, risking memory corruption D. It automatically resizes the array to fit
C. It silently performs the write with no bounds checking, risking memory corruption — C performs no bounds checking on array access at all — an out-of-bounds write compiles and runs, silently corrupting whatever memory happens to sit at that offset.
Why is returning a pointer to a plain local (stack) array from the function that declared it dangerous? A. It is not dangerous — stack arrays persist as long as the pointer is used B. The array's memory is reclaimed when the function returns, leaving the pointer dangling C. Stack arrays cannot have their address taken D. It causes a compile-time error
B. The array's memory is reclaimed when the function returns, leaving the pointer dangling — A stack array's memory belongs to that function's stack frame, which is popped the instant the function returns; any pointer into it is left dangling and dereferencing it afterward is undefined behavior.
Write a C function int maxValue(int arr[], int length) that returns the largest value in the array, then call it from main() with a 6-element array. Make sure the element count travels correctly from main() to the function.
Compute the length with sizeof(arr) / sizeof(arr[0]) only inside main(), then pass that computed length as an argument — never recompute it inside maxValue().