Sooner or later, any developer working with C-like languages gets the idea of treating a two-dimensional array as a one-dimensional one. The reasons vary, but the result is usually the same. In this short post, we'll talk about this dubious approach and explore the potential problems it could bring to your program.
Developers have already discussed array indexing many times, but today, we dive deeper. While analyzing some open-source projects using PVS-Studio, we found code fragments that freely use a multidimensional array as a one-dimensional one. As we tackled this topic, many people had the question, "It still works! Why are they wrong?"
Let's look at a small code fragment:
#define ROWS (2)
#define COLS (4)
int main()
{
int a[ROWS][COLS] = { 0, 1, 2, 3, 4, 5, 6, 7 };
for (int i = 0; i < ROWS * COLS; ++i)
{
printf(" %d", a[0][i]);
}
return 0;
}
Here, the two-dimensional a
array is used as a one-dimensional array: within the loop, we access the elements via a[0][i]
, where i
varies in the range [0; ROWS * COLS]
. But what's wrong with that? After all, according to the standard, all array elements are stored sequentially in memory:
An array type describes a contiguously allocated nonempty set of objects with a particular member object type, called the element type. The element type shall be complete whenever the array type is specified.
In the example, developers access elements sequentially by implicit pointer arithmetic, starting with the very first element. To get a clearer picture, let's look at the standards of C and C++ languages and see what we can expect when writing such code:
6.5.3.2/2:
A postfix expression followed by an expression in square brackets []
is a subscripted designation of an element of an array object. The definition of the subscript operator []
is that E1[E2]
is identical to (*((E1) + (E2)))
. Because of the conversion rules that apply to the binary +
operator, if E1
is an array object (equivalently, a pointer to the initial element of an array object) and E2
is an integer,
E1[E2]
designates the E2
-th element of E1
(counting from zero).
6.5.7/9:
When an expression that has integer type is added to or subtracted from a pointer, the result has the type of the pointer operand. If the pointer operand points to an element of an array object, and the array is large enough, the result points to an element offset from the original element such that the difference of the subscripts of the resulting and original array elements equals the integer expression. In other words, if the expression P
points to the i
-th element of an array object, the expressions
(P) + N
(equivalently, N + (P)
) and (P) - N
(where N
has the value n
) point to, respectively, the i + n
-th and i - n
-th elements of the array object, provided they exist. Moreover, if the expression P
points to the last element of an array object, the expression (P) + 1
points one past the last element of the array object, and if the expression Q
points one past the last element of an array object, the expression (Q) - 1
points to the last element of the array object. If the pointer operand and the result do not point to elements of the same array object or one past the last element of the array object, the behavior is undefined. If the addition or subtraction produces an overflow, the behavior is undefined. If the result points one past the last element of the array object, it shall not be used as the operand of a unary *
operator that is evaluated.
7.6.1.2/1:
A subscript expression is a postfix expression followed by square brackets containing a possibly empty, comma-separated list of initializer-clauses that constitute the arguments to the subscript operator. The postfix-expression and the initialization of the object parameter of any applicable subscript operator function is sequenced before each expression in the expression-list and also before any default argument. The initialization of a non-object parameter of a subscript operator function S, including every associated value computation and side effect, is indeterminately sequenced with respect to that of any other non-object parameter of S.
7.6.1.2/2:
With the built-in subscript operator, an expression-list shall be present, consisting of a single assignment-expression. One of the expressions shall be a glvalue of type "array of T" or a prvalue of type "pointer to T" and the other shall be a prvalue of unscoped enumeration or integral type. The result is of type "T". The type "T" shall be a completely-defined object type. The expression E1[E2]
is identical (by definition) to *((E1)+(E2))
, except that in the case of an array operand, the result is an lvalue if that operand is an lvalue and an xvalue otherwise.
7.6.6/4:
When an expression J
that has integral type is added to or subtracted from an expression P
of pointer type, the result has the type of P
.
P
evaluates to a null pointer value and J
evaluates to 0
, the result is a null pointer value.P
points to an array element i
of an array object x
with n
elements, the expressions P + J
and J + P
(where J
has the value j
) point to the (possibly-hypothetical) array element i + j
of x
if 0 ≤ i + j ≤ n
and the expression P - J
points to the (possibly-hypothetical) array element i - j
of x
if 0 ≤ i − j ≤ n
.Briefly, both standards tell us that pointer arithmetic lets us iterate over the elements of an array, but behavior is undefined in case of array overrun.
Let's return to the example with the two-dimensional a[2][4]
array. The result of the first subscripting operation, a[0]
, will be a pointer to int[4]
, which means that access is limited to the [0...3]
range. Otherwise, developers catch an array overrun with undefined behavior. Problem found.
Just reading this article makes the answer clear.
But who are we to just believe "some" article? Let's arm ourselves with Compiler Explorer, use the GCC 14.2 compiler as an example, and dive into what really happens under the hood.
First, let's check the assembly code when indexing is correct—you can also find the source code at the link.
.LC2:
.string " %d"
main:
; Function prologue
push r12
push rbp
push rbx
; Reserving memory on the stack for an int[2][4] array
sub rsp, 32
; Preparing to initialize a[0] with four elements via xmm0
movdqa xmm0, XMMWORD PTR .LC0[rip]
; Saving the stack pointer to rbx
mov rbx, rsp
; Saving one-past-the-end address of a[0] in r12
lea r12, [rsp+16]
; Initializing a[0] with four elements via xmm0
movaps XMMWORD PTR [rsp], xmm0
; Preparing to initialize arr[1] with four elements via xmm0
movdqa xmm0, XMMWORD PTR .LC1[rip]
; Saving the stack top to rbp
mov rbp, rbx
; Initializing a[1] with four elements via xmm0
movaps XMMWORD PTR [rsp+16], xmm0
.L2:
; Printing the next element of a[0], starting from the stack top
mov esi, DWORD PTR [rbp+0]
mov edi, OFFSET FLAT:.LC2
xor eax, eax
add rbp, 4
call printf
; Make a loop until the end of a[0] (r12)
cmp r12, rbp
jne .L2
.L3:
; Printing the next element of a[1]
mov esi, DWORD PTR [rbx+16]
mov edi, OFFSET FLAT:.LC2
xor eax, eax
add rbx, 4
call printf
; Make a loop until the end of a[0] (r12)
cmp r12, rbx
jne .L3
; Function epilogue
add rsp, 32
xor eax, eax
pop rbx
pop rbp
pop r12
ret
; Initializers of two-dimensional array
.LC0:
.long 0
.long 1
.long 2
.long 3
.LC1:
.long 4
.long 5
.long 6
.long 7
If you believe that the array will be stored linearly in memory, for the x86-64 architecture this is indeed the case: the entire two-dimensional array gets allocated as one contiguous block on the stack, and the compiler actively uses it.
It optimizes the nested loop to two consecutive linear loops. The first iterates over the a[0]
array and outputs every element up to the end of that array. The second loop also iterates over a[0]
, but outputs each element from the a[1]
array using a 16-byte offset, i.e., the size of the a[0]
array of four elements.
Here's what the output looks like:
ASM generation compiler returned: 0
Execution build compiler returned: 0
Program returned: 0
0 1 2 3 4 5 6 7
Since the compiler generates code where the multidimensional array lays out sequentially, you might be wondering:
Let's rewrite the code for one loop and check this statement.
.LC1:
.string " %d"
main:
push rbx
; Reserving stack memory for an int[2][4] array
sub rsp, 32
; Preparing to initialize a[0] with four elements via xmm0
movdqa xmm0, XMMWORD PTR .LC0[rip]
; Saving the stack top to rbx
mov rbx, rsp
; Preparing to initialize a[0] with four elements via xmm0
movaps XMMWORD PTR [rsp], xmm0
.L2:
; Printing the next array element
mov esi, DWORD PTR [rbx]
mov edi, OFFSET FLAT:.LC1
xor eax, eax
add rbx, 4
call printf
; Evaluating the end array address
lea rax, [rsp+32]
; Looping until the end of the array
cmp rbx, rax
jne .L2
add rsp, 32
xor eax, eax
pop rbx
ret
.LC0:
.long 0
.long 1
.long 2
.long 3
You can see the difference in assembly code here.
In this case, the compiler exploited undefined behavior and simply didn't initialize the a[1]
array. Why waste time on this when outputting elements causes array overrun and the compiler is free to do whatever it wants.
Accordingly, the program output depends on pure luck, the Moon's phases, and your coffee fortune telling skills. That's what we got the first time:
ASM generation compiler returned: 0
Execution build compiler returned: 0
Program returned: 0
0 1 2 3 -994840135 32766 100 0
An interesting twist—the GCC compiler actually issued a warning here:
<source>:11:5: warning: iteration 4 invokes
undefined behavior [-Waggressive-loop-optimizations]
11 | printf(" %d", a[0][i]);
| ^~~~~~~~~~~~~~~~~~~~~~
<source>:9:21: note: within this loop
9 | for (int i = 0; i < ROWS * COLS; ++i)
| ^
PVS-Studio analyzer also issues a warning for the code above:
V557 Array overrun is possible. The value of 'i' index could reach 7.
The answer is simple: write loops correctly and don't rely on UB :)
But what if we need the array to be laid out sequentially in memory while still allowing both one-dimensional and multidimensional access? For C++, there's a solution: C++23 introduces std::mdspan
, which lets you index a linear array in any dimensionality you need.
Let's look more closely at an example:
int main()
{
int a[ROWS * COLS] = { 0, 1, 2, 3, 4, 5, 6, 7 };
auto view_2d = std::mdspan { a, ROWS, COLS };
for (auto i = 0uz; i < ROWS; ++i)
{
for (auto j = 0uz; j < COLS; ++j)
{
printf(" %d", view_2d[i, j]);
}
}
}
A one-dimensional array is created here, but we can use it as a two-dimensional array when needed. In this case, the std::mdspan
object doesn't make any hidden copies of the original array, since it's a non-owning wrapper.
Note: As of this writing, std::mdspan
is implemented in Clang 18 (libc++). To check support in other compilers, visit the C++ compiler support page and just search for "std::mdspan: a non-owning multidimensional array reference."
Our team hopes that this short post helped clarify why treating a two-dimensional array as a one-dimensional one is risky and leads to undefined behavior.
We've encountered these issues before and covered them in previous articles. However, to simplify finding dangerous code fragments in your project, you can try PVS-Studio and keep your projects clean, reliable, and error-free.
Français
859