In order to answer the question, we should first clear up some concepts. What is an array and how can it be used? And what is the code in the question, if not an array?
What is an array?
The formal definition of an array is found in the C standard, ISO 9899:2011 6.2.5/20 Types.
An array type describes a contiguously allocated non-empty set of
objects with a particular member object type, called the element type.
In plain English, an array is a collection of items of the same type allocated contiguously, in adjacent memory cells.
For example an array of 3 integers int arr[3] = {1,2,3};
would be allocated in memory like this:
+-------+-------+-------+
| | | |
| 1 | 2 | 3 |
| | | |
+-------+-------+-------+
So what about the formal definition of a multi-dimensional array? Actually, it is the very same definition as cited above. It applies recursively.
If we would allocate a 2D array, int arr[2][3] = { {1,2,3}, {1,2,3} };
it would get allocated in memory like this:
+-------+-------+-------+-------+-------+-------+
| | | | | | |
| 1 | 2 | 3 | 1 | 2 | 3 |
| | | | | | |
+-------+-------+-------+-------+-------+-------+
What we have in this example is actually an array of arrays. An array which has 2 items, each of them an array of 3 integers.
An array is a type like any other
Arrays in C often follow the same type system as regular variables. As shown above, you can have an array of arrays, like you can have an array of any other type.
You can also apply the same kind of pointer arithmetic on n-dimensional arrays as on plain one-dimensional arrays. With a regular one-dimensional arrays, applying pointer arithmetic should be trivial:
int arr[3] = {1,2,3};
int* ptr = arr; // integer pointer to the first element.
for(size_t i=0; i<3; i++)
{
printf("%d ", *ptr); // print contents.
ptr++; // set pointer to point at the next element.
}
This was made possible through "array decay". When arr
was used inside an expression, it "decayed" into a pointer to the first element.
Similarly, we can use the very same kind of pointer arithmetic to iterate through an array of arrays, by using an array pointer:
int arr[2][3] = { {1,2,3}, {1,2,3} };
int (*ptr)[3] = arr; // int array pointer to the first element, which is an int[3] array.
for(size_t i=0; i<2; i++)
{
printf("%d %d %d\n", (*ptr)[0], (*ptr)[1], (*ptr)[2]); // print contents
ptr++; // set pointer to point at the next element
}
Again there was array decay. The variable arr
which was of type int [2][3]
decayed into a pointer to the first element. The first element was an int [3]
and a pointer to such an element is declared as int(*)[3]
- an array pointer.
Understanding array pointers and array decay is necessary in order to work with multi-dimensional arrays.
There are more cases where arrays behave just like regular variables. The sizeof
operator works just the same for (non-VLA) arrays as for regular variables. Examples for a 32 bit system:
int x; printf("%zu", sizeof(x));
prints 4
.
int arr[3] = {1,2,3}; printf("%zu", sizeof(arr));
prints 12
(3*4=12)
int arr[2][3] = { {1,2,3}, {1,2,3} }; printf("%zu", sizeof(arr));
prints 24
(2*3*4=24)
Like any other type, arrays can be used with library functions and generic APIs. Since arrays fulfil the requirement of being allocated contiguously, we can for example safely copy them with memcpy
:
int arr_a[3] = {1,2,3};
int arr_b[3];
memcpy(arr_b, arr_a, sizeof(arr_a));
Contiguous allocation is also the reason why other similar standard library functions like memset
, strcpy
, bsearch
and qsort
work. They are designed to work on arrays allocated contiguously. So if you have a multi-dimensional array, you can efficiently search it and sort it with bsearch
and qsort
, saving you the fuss of implementing binary search and quick sort yourself and thereby re-inventing the wheel for every project.
All of the above consistencies between arrays and other types is a very good thing that we want to take advantage of, particularly when doing generic programming.
What is the pointer-to-pointer thing, if not an array?
Now to get back to the code in the question, which used a different syntax with a pointer-to-pointer. There is nothing mysterious about it. It is a pointer to pointer to type, no more no less. It is not an array. It is not a 2D array. Strictly speaking, it cannot be used to point at an array, nor can it be used to point at a 2D array.
A pointer-to-pointer can however be used to point at the first element of an array of pointers, instead of pointing at the array as whole. And that is how it is used in the question - as a way to "emulate" an array pointer. In the question, it is used to point at an array of 2 pointers. And then each of the 2 pointers is used to point at an array of 3 integers.
This is known as a look-up table, which is a kind of abstract data type (ADT), which is something different from the lower level concept of plain arrays. The main difference is how the look-up table is allocated:
+------------+
| |
| 0x12340000 |
| |
+------------+
|
|
v
+------------+ +-------+-------+-------+
| | | | | |
| 0x22223333 |---->| 1 | 2 | 3 |
| | | | | |
+------------+ +-------+-------+-------+
| |
| 0xAAAABBBB |--+
| | |
+------------+ |
|
| +-------+-------+-------+
| | | | |
+->| 1 | 2 | 3 |
| | | |
+-------+-------+-------+
The 32 bit addresses in this example are made-up. The 0x12340000
box represents the pointer-to-pointer. It contains an address 0x12340000
to the first item in an array of pointers. Each pointer in that array in turn, contains an address pointing at the first item in an array of integers.
And here is where the problems start.
Problems with the look-up table version
The look-up table is scattered all over the heap memory. It is not contiguously allocated memory in adjacent cells, because each call to malloc()
gives a new memory area, not necessarily located adjacently to the others. This in turn gives us lots of problems:
We can't use pointer arithmetic as expected. While we can use a form of pointer arithmetic to index and access the items in the look-up table, we can't do so using array pointers.
We can't use the sizeof operator. Used on the pointer-to-pointer, it would give us the size of a pointer-to-pointer. Used to the first item pointed at, it would give us the size of a pointer. Neither of them is the size of an array.
We can't use standard library functions that excepts an array type (memcpy
, memset
, strcpy
, bsearch
, qsort
and so on). All such functions assume to get arrays as input, with data allocated contiguously. Calling them with our look-up table as parameter would result in undefined behavior bugs, such as program crashes.
Repeated calls of malloc
to allocate several segments leads to heap fragmentation, which in turn results in poor use of RAM memory.
Since the memory is scattered, the CPU cannot utilize cache memory when iterating through the look-up table. Efficient use of the data cache requires a contiguous chunk of memory which is iterated through from top to bottom. This means that the look-up table, by design, has significantly slower access time than a real multi-dimensional array.
For each call to malloc()
, the library code managing the heap has to calculate where there is free space. Similarly for each call to free()
, there is overhead code which has to be executed. Therefore, as few calls to these functions as possible is often preferable, for the sake of performance.
Are look-up tables all bad?
As we can see, there are a lot of problems with pointer-based look-up tables. But they aren't all bad, it is a tool like any other. It just has to be used for the right purpose. If you are looking for a multi-dimensional array, which should be used as an array, look-up tables are clearly the wrong tool. But they can be used for other purposes.
A look-up table is the right choice when you need all dimensions to have completely variable sizes, individually. Such a container can be handy when for example creating a list of C strings. It is then often justified to take the above mentioned execution speed performance loss in order to save memory.
Also, the look-up table has the advantage that you can re-alloce parts of the table in run-time without the need to re-allocate a whole multi-dimensional array. If this is something that needs to be done frequently, the look-up table might even outperform the multi-dimensional array in terms of execution speed. For example, similar look-up tables can be used when implementing a chained hash table.
How to properly allocate a multi-dimensional array dynamically then?
The easiest form in modern C is to simply use a variable-length array (VLA). int array[x][y];
where x
and y
are variables given values in run-time, prior array declaration. However, VLAs have local scope and do not persist throughout the duration of the program - they have automatic storage duration. So while VLAs may be convenient and fast to use for temporary arrays, it is not an universal replacement to the look-up table in the question.
To truly allocate a multi-dimensional array dynamically, so that it gets allocated storage duration, we have to use malloc()
/calloc()
/realloc()
. I'll give one example below.
In modern C, you would use array pointers to a VLA. You can use such pointers even when no actual VLA is present in the program. The benefit of using them over a plain type*
or a void*
is increased type-safety. Using a pointer to a VLA also allows you to pass the array dimensions as parameters to the function using the array, making it both variable and type safe at once.
Unfortunately, in order to use the benefits of having a pointer to VLA, we can't return that pointer as a function result. So if we need to return a pointer to the array to the caller, it must be passed as a parameter (for the reasons described in Dynamic memory access only works inside function). This is fine practice in C, but makes the code a bit hard to read. It would look something like this:
void arr_alloc (size_t x, size_t y, int(**aptr)[x][y])
{
*aptr = malloc( sizeof(int[x][y]) ); // allocate a true 2D array
assert(*aptr != NULL);
}
While this syntax with a pointer to an array pointer might look a bit strange and intimidating, it doesn't get more complex than this even if we add more dimensions:
void arr_alloc (size_t x, size_t y, size_t z, int(**aptr)[x][y][z])
{
*aptr = malloc( sizeof(int[x][y][z]) ); // allocate a true 3D array
assert(*aptr != NULL);
}
Now compare that code with the code for adding one more dimension to the look-up table version:
/* Bad. Don't write code like this! */
int*** arr_alloc (size_t x, size_t y, size_t z)
{
int*** ppp = malloc(sizeof(*ppp) * x);
assert(ppp != NULL);
for(size_t i=0; i<x; i++)
{
ppp[i] = malloc(sizeof(**ppp) * y);
assert(ppp[i] != NULL);
for(size_t j=0; j<y; j++)
{
ppp[i][j] = malloc(sizeof(***ppp) * z);
assert(ppp[i][j] != NULL);
}
}
return ppp;
}
Now that is one unreadble mess of "three-star programming". And lets not even consider 4 dimensions...
The full code of a version using true 2D arrays
#include <stdlib.h>
#include <stdio.h>
#include <assert.h>
void arr_alloc (size_t x, size_t y, int(**aptr)[x][y])
{
*aptr = malloc( sizeof(int[x][y]) ); // allocate a true 2D array
assert(*aptr != NULL);
}
void arr_fill (size_t x, size_t y, int array[x][y])
{
for(size_t i=0; i<x; i++)
{
for(size_t j=0; j<y; j++)
{
array[i][j] = (int)j + 1;
}
}
}
void arr_print (size_t x, size_t y, int array[x][y])
{
for(size_t i=0; i<x; i++)
{
for(size_t j=0; j<y; j++)
{
printf("%d ", array[i][j]);
}
printf("\n");
}
}
int main (void)
{
size_t x = 2;
size_t y = 3;
int (*aptr)[x][y];
arr_alloc(x, y, &aptr);
arr_fill(x, y, *aptr);
arr_print(x, y, *aptr);
free(aptr); // free the whole 2D array
return 0;
}