One of the big motivations behind C (and perhaps the reason it became so ubiquitous) is that it's more or less a portable assembly language. It provides just enough abstraction for your code to work on multiple platforms, but doesn't provide so much abstraction that you lose the ability to look at the C code and predict what the generated assembly would look like. A number of C's design decisions were made because they mirrored the functionality available at the hardware level.
Regarding arrays, it's important to remember that C is not an object-oriented language. Compound objects like arrays or structs do not actually exist. The only data types that the vast majority of CPUs understand are integers of varying sizes, floating-point numbers, and pointers (which are arguably just integers). Everything else in the C language is syntax sugar.
Arrays don't decay into pointers when you pass them to a function. Your "array" never was anything other than a pointer in the first place. When you work with arrays in assembly, you do your own pointer arithmetic. For example, an array dereference in MIPS assembly language might look like:
lw $4, 8($3)
Register 3 holds the base address of the array. This instruction takes that base address, adds an offset of 8 bytes to it to form the target address, and then copies the value from that target address into register 4. C's syntax is a thin veneer over this. The array/pointer in your code is your base address. The compiler calculates the offset for you based on your array index and the size of each array entry.
Structures work essentially the same way. When you declare a structure, the compiler calculates the offset to each member. The struct
in your code is merely a pointer. When you reference a struct member, the compiler simply adds that pre-computed offset to the value of the pointer.
When you think of these things as merely being shorthand for pointer arithmetic, many of C's other details make more sense as well.
- The
offsetof()
macro merely pulls values from the compiler's internal table of pre-computed offsets.
- You can't access the members of a struct when no definition is in scope, because the compiler hasn't computed an offset for that member yet. You can use pointers to a structure after only a forward reference because that's enough information for the compiler to know it's just a pointer like any other.
- Arrays appear to be passed "by reference" to functions. Technically it's just a pointer, and you're passing the pointer by value like any other integer-like data type.
- A struct can be passed to a function "by value" because the compiler knows the size of the structure (i.e., how many bytes to copy). Struct assignment
b = a
is shorthand for memcpy(&b, &a, sizeof(a))
.
- You can declare a loose pointer like
int* ptr = 0xFF000000;
and then use it like ptr[14] = 0;
even though it's not an array. The compiler is simply doing the pointer arithmetic you asked it to do. This is also the basis for many types of type punning.
sizeof()
doesn't work on dynamically-allocated arrays or arrays passed into a function. A pointer doesn't contain any length information, so the compiler has no way to know the size when given only a base address. sizeof()
only works if the array's definition is in the current scope since the compiler can remember how much space it allocated.
union
is handled essentially the same as struct
except each member's offset is calculated relative to the base address instead of relative to the end of the previous member.
C was designed to be very close to the metal. The programmer was responsible for things like keeping track of array bounds, ensuring pointers were compatible with underlying data, etc., which is the way it was in assembly. C was a step forward in that respect, moving some of that burden from the programmer to the compiler.
Modern languages really do spoil us. We have a tremendous amount of computing resources available both at compile time and at run time. We can treat arrays like first-class objects, and the runtime engine will remember their size for us, throw an exception if we exceed their bounds, etc. It's easy to forget just how limited and primitive computers used to be. Some old C compilers only had access to enough memory to remember the first 8 characters of a variable or function name, everything after that was ignored. The preprocessor, compiler, linker, etc. were all separate programs, and compiling a single .c file would require multiple passes and lots of intermediate files. C was originally implemented on a PDP-7 computer, which came standard with just 9KB of memory. The things we take for granted now were either wildly impractical or not yet invented.