Arrays & Pointers
Arrays
Like other languages you have used before, C has arrays. Here’s an example:
#include <stdio.h>
int main() {
int courses[7] = {1110, 1111, 2110, 2112, 2800, 3110, 3410};
int course_total = 0;
for (int i = 0; i < 7; ++i) {
course_total += courses[i];
}
printf("the average course is CS %d\n", course_total / 7);
return 0;
}
You declare an array of 7 int
s like this:
int courses[7];
And you can also, optionally, provide an initial value for all of the things in the array, as we do in the example above:
int courses[7] = {1110, 1111, 2110, 2112, 2800, 3110, 3410};
You access arrays like courses[i]
.
This works for both reading and writing.
You can read more about arrays in the C reference pages.
Pointers
Pointers are (according to me) the essential feature of C. They are what make it C. They are simultaneously dead simple and wildly complex. They can also be the hardest aspect of C programming to understand. So forge bravely on, but do not worry if they seem weird at first. Pointers will feel more natural with time, as you gain more experience as a C programmer.
Memory
Pointers are a way for C programs to talk about memory, so we first need to consider what memory is.
It’s helpful to think of a simplified computer architecture diagram, consisting of a processor and a memory. The processor is where your C code runs; it can do any computation you want, but it can’t remember anything. The memory is where all the data is stored; it remembers a bunch of bits, but it doesn’t do any computation at all. They are connected—imagine wires that allow them to send signals (made of bits) back and forth. There are two things the CPU can do with the memory: it can load the value at a given address of its choosing, and it can store a new value at an address.
Abstractly, we can think of memory as a giant array of bytes. Metaphorically speaking (not actually!), it might be helpful to imagine a C declaration like this:
uint8_t mem[SIZE];
where SIZE
is the total number of bytes in your machine.
Several billion, surely.
In this metaphor, the processor reads from memory by doing something like mem[123]
, and it writes by doing mem[123] = 45
in C.
The “address” works like an index into this metaphorical array of bytes.
Maybe the most important thing to take away from this metaphor is that an address is just bits.
Because, after all, everything is just bits.
You can think of those bits as an integer, i.e., the index of the byte you’re interested in within the imaginary mem
array.
A Pointer is an Address
In C, a pointer is the kind of value for memory addresses. You can think of a pointer as logically pointing to the value at a given address, hence the name.
But I’ll say it again, because it’s important: pointers are just bits.
Recall that a double
variable and a int64_t
variable are both 64-bit values—from the perspective of the computer, there is no difference between these kinds of values.
They are both just groups of 64 bits, and only the way the program treats these bits makes them an integer or a floating-point number.
Pointers are the same way:
they are nothing more than 64-bit values, treated by programs in a special way as addresses into memory.
The size of pointers (the number of bits) depends on the machine you’re running on. In this class, all our code is compiled for the RISC-V 64-bit architecture, so pointers are always 64 bits. (If you’ve ever heard a processor called a “32-bit” or “64-bit” architecture, that number probably describes the size of pointers, among other values. Most modern “normal” computers (servers, desktops, laptops, and mobile devices) use 64-bit processors, but 32-bit and narrower architectures are still commonplace in embedded systems.)
Pointer Types and Reference-Of
In C, the type of a pointer to a value of type T
is T*
.
For example, a pointer to an integer might have type int*
,
and pointer to a floating-point value might be a float*
,
and a pointer to a pointer to a character could have type char**
.
To reiterate, all of these types are nothing more than 64-bit memory addresses.
The only difference is in the way the program treats those addresses:
e.g., the program promises to only store an int
in memory at the address contained in an int*
.
In C, you can think of all data in the program as “living” in memory.
So every variable and every function argument exists somewhere in the giant metaphorical mem
array we imagined above.
That means that every variable has an address: the index in that huge array where it lives.
C has a built-in operator to obtain the address for any variable.
The &
operator, called the reference-of operator, takes a variable and gives you a pointer to the variable.
For example, if x
is an int
variable, then &x
is the address where x
is stored in memory, with type int*
.
Here’s an example where we use &
to get the address of a couple of variables:
#include <stdio.h>
int main() {
int x = 34;
int y = 10;
int* ptr_to_x = &x;
int* ptr_to_y = &y;
printf("ints are %lu bytes\n", sizeof(int));
printf("pointers are %lu bytes\n", sizeof(int*));
printf("x is located at %p\n", ptr_to_x);
printf("y is located at %p\n", ptr_to_y);
return 0;
}
We’re also using the %p
format specifier for printf
, which prints out memory addresses in hexadecimal format.
(By convention, programmers almost always use hex when writing memory addresses.)
Here’s what this program printed once on my machine:
ints are 4 bytes
pointers are 8 bytes
x is located at 0x1555d56bbc
y is located at 0x1555d56bb8
The built-in sizeof
operator tells us that pointers are 8 bytes (64 bits) on our RISC-V 64 architecture, which makes sense.
int
s are 4 bytes, as they are on many modern platforms.
The system is free to choose different addresses for variables, so don’t worry if the addresses are different when you run this program—that’s perfectly normal.
In this output, however, the system is telling us that it chose very nearby addresses for the x
and y
variables: the first 60 bits of these addresses are identical.
The address of x
ends in the 4 bits corresponding to the hex digit c
(12 in decimal), and y
lives at an address ending in 8
.
That means that x
and y
are located right next to each other in memory: y
occupies the 4 bytes at addresses …6bb8
, …6bb9
, …6bba
, and …6bbb
,
and then the 4 bytes for x
begin at the very next address, …6bbc
.
In C, it doesn’t matter where you put the whitespace in a pointer declaration.
Although I’ll try to write declarations like int* x;
consistently in these pages, this means exactly the same thing as int *x;
, and you’ll often see the latter in real-world C code.
You can use whichever you prefer.
Everything Has an Address, Including Pointers
Just to emphasize the idea that, in C, all variables live somewhere in memory,
let’s take a moment to appreciate that ptr_to_x
and ptr_to_y
are themselves variables.
So they also have addresses:
#include <stdio.h>
int main() {
int x = 34;
int y = 10;
int* ptr_to_x = &x;
int* ptr_to_y = &y;
printf("ints are %lu bytes\n", sizeof(int));
printf("pointers are %lu bytes\n", sizeof(int*));
printf("x is located at %p\n", ptr_to_x);
printf("y is located at %p\n", ptr_to_y);
printf("ptr_to_x is located at %p\n", &ptr_to_x);
printf("ptr_to_y is located at %p\n", &ptr_to_y);
return 0;
}
Always remember: pointers are just bits, pointer-typed variables follow the same rules as any other variables.
Pointers as References, and Dereferencing
While pointers are (like everything else) just bits, what makes them useful is that it’s also possible to think of them in a different way: as references to other values. From this perspective, pointers in C resemble references in other languages you have used: it is the power you need to create variables that refer to other values.
The key C feature that makes this view possible is its *
operator,
called the dereference operator.
The C expression *p
means, roughly, “take the pointer p
and follow it to wherever it points in memory, so I can read or write that value (not p
itself).”
You can use the *
operator both to load from (read) and store to (write) memory.
Imagine a pointer p
of type int*
.
Here’s how you read from the place where p
points:
int value = *p;
And here’s how you write to that location where p
points:
*p = 5;
When you’re reading, *p
can appear anywhere in a larger expression too, so you can use *p + 5
to load the value p
points to and then add 5 to that integer.
All this means that you can use pointers and dereferencing to perform “remote control” accesses to other variables, in the same way that references work in other programming languages. Here’s an example:
#include <stdio.h>
int main() {
int x = 34;
int y = 10;
int* ptr = &x;
printf("initially, x = %d and y = %d and ptr = %p\n", x, y, ptr);
*ptr = 41;
printf("afterward, x = %d and y = %d and ptr = %p\n", x, y, ptr);
return 0;
}
The point of this example is that modifying *ptr
changes the value of x
.
It does not, however, change the value of ptr
itself:
that still points to the same place.
To emphasize that pointer-typed variables behave like any other variable, we can also try assigning to the pointer variable.
It is absolutely critical to recognize the subtle difference between assigning to *ptr
and assigning to ptr
:
#include <stdio.h>
int main() {
int x = 34;
int y = 10;
int* ptr = &x;
printf("0: x = %d and y = %d and ptr = %p\n", x, y, ptr);
*ptr = 41;
printf("1: x = %d and y = %d and ptr = %p\n", x, y, ptr);
ptr = &y;
printf("2: x = %d and y = %d and ptr = %p\n", x, y, ptr);
*ptr = 20;
printf("3: x = %d and y = %d and ptr = %p\n", x, y, ptr);
return 0;
}
The thing to pay attention to here is that assigning to ptr
just changes ptr
itself; it does not change x
or y
.
(That’s the rule for assigning to any variable, not just pointers!)
Then, when we assign to *ptr
the second time, it updates y
this time, because that’s where it points.
I hope this kind of “variables that reference other variables” thinking is familiar from using other languages, where references are extremely common. The difference in C is that there is no magic: we get reference behavior out of the “raw materials” of bits, by treating some 64-bit values as addresses in memory. Under the hood, this is how references in other languages are implemented too—but in C, we get direct access to the underlying bits.
Arrays are Mostly Just Pointers
Now that we know about pointers, let’s revisit arrays.
In C, an array is a sequence of values all laid out next to each other in memory.
We can use the &
reference-of operator to check out the addresses of the elements in an array:
#include <stdio.h>
int main() {
int courses[7] = {1110, 1111, 2110, 2112, 2800, 3110, 3410};
printf("first element is at %p\n", &courses[0]);
printf(" next element is at %p\n", &courses[1]);
printf(" last element is at %p\n", &courses[6]);
return 0;
}
When I ran this program on my machine once, it told me that the first element of the array was located at address 0x1555d56b90
, the next element was at 0x1555d56b94
, and so on, with each address increasing by 4 with each element.
Remember that int
s are 4 bytes on our platform, so these addresses mean that the elements are packed densely, each one next to the other.
You can think of the array having a base address \(b\). Then, the address of an element at index \(i\) has this address:
\[ b + s \times i \]
where \(s\) is the size of the elements, in bytes.
Treat an Array as a Pointer to the First Element
In fact, C lets you treat an array itself as if it were a pointer to the first element: i.e., the base address \(b\). This works, for example:
#include <stdio.h>
int main() {
int courses[7] = {1110, 1111, 2110, 2112, 2800, 3110, 3410};
printf("first element is at %p\n", &courses[0]);
printf("the array itself is %p\n", courses);
return 0;
}
And C tells us that, if we treat courses
as a pointer, it has the same address as its first element.
From that perspective, it is helpful to think of an array variable as storing of the address of the first element of the array.
One important takeaway from this realization is that C does not store the length of your array anywhere—just a pointer to the first element.
It’s up to you to keep track of the length yourself somehow.
This means that, if you want to pass an array to a function, you can use a pointer-typed argument:
#include <stdio.h>
int sum_n(int* vals, int count) {
int total = 0;
for (int i = 0; i < count; ++i) {
total += vals[i];
}
return total;
}
int main() {
int courses[7] = {1110, 1111, 2110, 2112, 2800, 3110, 3410};
int sum = sum_n(courses, 7);
printf("the average course is CS %d\n", sum / 7);
return 0;
}
If you do, it is always a good idea to pass the length of the array in a separate argument.
The subscript syntax, like vals[i]
, works the same way for pointers as it does for arrays.
C also lets you declare function arguments with actual array types instead of pointer types. This can quickly get confusing, however, and it has very few benefits over just using pointers—so we recommend against it in essentially every case. Just use pointer types whenever you need to pass an array as an argument to a function.
Pointer Arithmetic
Since we’ve seen that the elements of an array exist right next to each other in memory, can we access them by computing their addresses ourselves?
Absolutely! C supports arithmetic operators like +
and -
on pointers, but they follow a special rule you will need to remember.
Here’s an example:
#include <stdio.h>
void experiment(int* courses) {
printf("courses = %p\n", courses);
printf("courses + 1 = %p\n", courses + 1);
}
int main() {
int courses[7] = {1110, 1111, 2110, 2112, 2800, 3110, 3410};
experiment(courses);
return 0;
}
The important thing to notice here is that adding 1 to courses
increased its value by 4, not by 1.
That’s because the rule in C is that pointer arithmetic “moves” pointers by element-sized chunks.
So because courses
has type int*
, its element size is 4 bytes.
The rule says that, if you write the expression courses + n
, that
will actually add \(n \times 4\) bytes to the address value of courses
.
This may seem odd, but it’s extremely useful:
it means that pointer arithmetic stays pointing to the first byte of an element.
If you think of courses
itself as a pointer to the first int
in the array, then courses + 1
points to the (first byte of) the second int
in the array.
It would be inconvenient and annoying if doing +1
just took us to the second byte in the first element; nobody wants that.
A consequence is that we can use pointer arithmetic directly, along with the dereferencing operator *
, to access the elements of an array:
#include <stdio.h>
void experiment(int* courses) {
printf("courses[0] = %d\n", *(courses + 0));
printf("courses[5] = %d\n", *(courses + 5));
}
int main() {
int courses[7] = {1110, 1111, 2110, 2112, 2800, 3110, 3410};
experiment(courses);
return 0;
}
Now that you know how arrays and pointer arithmetic work, you don’t actually need the subscripting operator!
Instead of writing arr[idx]
, you can always just use *(arr + idx)
.
It means the same thing.
Here’s a fun but mostly useless fact about C programming.
Since arr[idx]
means exactly the same thing as *(arr + idx)
, and because +
is commutative, this also means the same thing as *(idx + arr)
, which can—by the same rules—also be written as idx[arr]
.
So if you really want to confuse the people reading your code, you can always write your array indexing expressions backward:
#include <stdio.h>
void experiment(int* courses) {
printf("courses[0] = %d\n", 0[courses]);
printf("courses[5] = %d\n", 5[courses]);
}
int main() {
int courses[7] = {1110, 1111, 2110, 2112, 2800, 3110, 3410};
experiment(courses);
return 0;
}
But this is, uh, not a great idea in the real world, where your code will actually be read by humans with thoughts and feelings.
Strings are Null-Terminated Character Arrays
Our new knowledge about pointers and arrays now lets us revisit another concept we’ve already been using in C:
strings.
You may recall that we previously told you not to worry about why strings in C have the type char*
.
Now we can demystify this fact:
strings in C are arrays of char
values, each of which is a single character.
On most modern systems (including our RISC-V target), char
is a 1-byte (8-bit) type.
So each char
in a string is a number between 0 and \(2^8-1\), i.e., 255.
Programs use a text encoding to decide which number represents which textual character.
An extremely popular encoding that includes the basic English alphabet is ASCII.
But C saves you the trouble of looking up characters in the ASCII table; you can use a literal 'q'
(note the single quotes!) to get a char
with the numeric value corresponding to a lower-case q character.
As with any other array in C, a string just consists of a pointer to the first element (the first character in this case).
So when you see char* str
, you can think either “str
is a string” or “str
is the address of the first element of a string.”
Also as with any other array, we need a way to know how many elements there are in the array.
Instead of keeping track of the length as an integer, as we have so far, C strings use a different convention:
they use a null character, with value 0, to indicate the end of a string.
You can write this special character as '\0'
.
This means that various functions that process strings work by iterating through all the characters and then stopping when the character is '\0'
.
All this means that you can use everything you know about C arrays and apply them to strings. For example:
#include <stdio.h>
void print_line(char* s) {
for (int i = 0; s[i] != '\0'; ++i) {
fputc(s[i], stdout);
}
fputc('\n', stdout);
}
int main() {
char message[7] = {'H', 'e', 'l', 'l', 'o', '!', '\0'};
print_line(message);
return 0;
}
This shows several C array features that are equally useful for strings (character arrays) as they are for any other array:
- Array initialization, with curly braces.
- Treating arrays as pointers to their first element, so we can pass our
char
array to a function expecting achar*
. - Using array subscript notation, like
s[i]
, on the pointer to access the array’s elements.
One important thing to realize here is that, when we initialize this array “manually” using the array initialization syntax, we have to remember to include the null terminator '\0'
ourselves.
Ordinary string literals, like "Hello!"
, include a null terminator automatically.
So these lines are roughly equivalent:
char message[7] = {'H', 'e', 'l', 'l', 'o', '!', '\0'};
char* message = "Hello!";
If you go the manual route and forget the null terminator, bad things will happen.
Try to imagine what might go wrong in this program if we left off the '\0'
, for example.
There are many possibilities, and none of them are good.
(This is an example of undefined behavior in C, so there is no single answer.)
Fun Pointer Tricks
Here are some useful things you can do with pointers.
Pass by Reference
Pointers are useful for passing parameters by reference. C doesn’t actually have a way to “native” pass-by-reference; everything is passed as a value. But you can pass pointers as values and use those to refer to other values.
For example, this swap function doesn’t work because a
and b
are passed by value:
#include <stdio.h>
void swap(int x, int y) {
int tmp = x;
x = y;
y = tmp;
}
int main() {
int a = 34;
int b = 10;
printf("%d %d\n", a, b);
swap(a, b);
printf("%d %d\n", a, b);
}
But if we pass pointers instead, we can dereference those pointers so we modify the original variables in place. So this version works:
#include <stdio.h>
void swap(int* x, int* y) {
int tmp = *x;
*x = *y;
*y = tmp;
}
int main() {
int a = 34;
int b = 10;
printf("%d %d\n", a, b);
swap(&a, &b);
printf("%d %d\n", a, b);
}
Null Pointers
Because pointers are just integers, you can set the to zero. Zero isn’t actually a valid memory address. That makes the zero value useful for signaling the absence of data. It’s particularly useful for writing functions with optional parameters.
In C, you can use NULL
to get a pointer with value zero.
Here’s an example that extends our swap
function to optionally also produce the sum of the values:
#include <stdio.h>
void swap_and_sum(int* x, int* y, int* sum) {
int tmp = *x;
*x = *y;
*y = tmp;
if (sum != NULL) {
*sum = *x + *y;
}
}
int main() {
int a = 34;
int b = 10;
printf("%d %d\n", a, b);
int sum;
swap_and_sum(&a, &b, &sum);
swap_and_sum(&a, &b, NULL);
printf("%d %d\n", a, b);
printf("sum = %d\n", sum);
}
When a pointer might be null, always remember to include a != NULL
check before using it.
The possibility of accidentally dereferencing a null pointer is Sir Tony Hoare’s “billion-dollar mistake.”
Pointers to Pointers
The type of a pointer to a value of type T
is T*
.
That includes when T
itself is a pointer type!
So you can create pointers to pointers, and so on.
For example, int**
is a pointer to a pointer to an int
.
(It’s not common to go any deeper than two levels, but nothing stops you…)
It’s a silly example, but we can make our swap
function swap int*
s instead of actual int
s:
#include <stdio.h>
void swap(int** x, int** y) {
int* tmp = *x;
*x = *y;
*y = tmp;
}
int main() {
int a = 34;
int b = 10;
int* a_ptr = &a;
int* b_ptr = &b;
printf("%d %d\n", a, b);
swap(&a_ptr, &b_ptr);
printf("%d %d\n", a, b);
}
Pointers to Functions
Maybe you have taken CS 3110, so you know it’s cool to pass functions into other functions. C can do that too, kind of! By creating pointers to functions.
The syntax admittedly looks really weird.
You write T1 (*name)(T2, T3)
for a pointer to a function that takes argument types T2
and T3
and returns a type T1
.
Here’s an example in action:
#include <stdio.h>
int incr(int x) {
return x + 1;
}
int decr(int x) {
return x - 1;
}
int apply_n_times(int x, int n, int (*func)(int)) {
for (int i = 0; i < n; ++i) {
x = func(x);
}
return x;
}
int main() {
int n = 20;
n = apply_n_times(n, 5, &incr);
n = apply_n_times(n, 2, &decr);
printf("n = %d\n", n);
}
Pointers to Anything
Remember that pointers are bits, and all pointers look the same:
they are just memory addresses.
So, if you just look at the bits, there is no difference between an int*
and a float*
and a char*
.
They are all just addresses.
For this reason, C has a special type that means “a pointer to something, but I don’t know what.”
The type is spelled void*
.
It is useful in situations where you don’t care what’s being pointed to.
Here’s a simple program that uses a void*
to wrap up a call to printf
for showing addresses:
#include <stdio.h>
void print_ptr(void* p) {
printf("%p\n", p);
}
int main() {
int x = 34;
float y = 10.0f;
print_ptr(&x);
print_ptr(&y);
}