Implicit type conversion
Introduction
I find nothing more frustrating than bugs related to type conversions in C. Who would suspect that, in a statically typed language like C, the compiler might change the type of a variable declared by the developer? Very frustrating indeed. But, as bad as it seems, at least this behaviour is described in the C23 standard:
- qualification conversion — C23 6.3.2.3
- array-to-pointer conversion — C23 6.3.2.1
- integer promotion — C23 6.3.1.1
- floating promotion - C23 6.3.1.8
- pointer conversion to
void *— C23 6.3.2.3
In this article we’ll summarise the implicit type conversions described by the standard. You can't be frustrated by bugs you aren't writing, right? (In fact you can; if you’ve ever done a code review you know what I’m talking about. But let’s get back to the article.)
Qualification conversion
According to C23 6.3.2.3:
For any qualifier q, a pointer to a non-q-qualified type may be converted to a pointer to the q-qualified
version of the type; the values stored in the original and converted pointers shall compare equal.
Let’s examine this with an example:
#include <stdio.h>
void lib_func(const int *number) { printf("Number: %d\n", *number); }
int main(void) {
puts("Running app...");
int i = 7;
const int j = 9;
lib_func(&i);
printf("&i=%p\n", (void *)&i);
puts("Finished app");
}
which outputs:
❯ ./a.out
Running app...
Number: 7
&i=0x7ffe74c1a5fc
Finished app
There is no compilation error; i is automatically converted to const int. Doing things the other way—changing from const int to int is not allowed, as nothing in the standard permits it.
Let's consider another example:
#include <stdio.h>
void poke(const int *p) { *(int *)p = 99; }
int main(void) {
const int k = 7;
poke(&k);
printf("%d\n", k);
}
This is undefined behaviour, because as mentioned before, nothing in the standard allows casting out const.
Array-to-pointer conversion
According to C23 6.3.2.1:
Except when it is the operand of the sizeof operator, or typeof operators, or the unary & operator,
or is a string literal used to initialize an array, an expression that has type “array of type” is converted
to an expression with type “pointer to type” that points to the initial element of the array object and
is not an lvalue.
Let’s examine this with an example:
#include <stdio.h>
void lib_func(int numbers[10]) {
printf("[%s] sizeof(numbers): %lu\n", __func__, sizeof(numbers));
}
int main(void) {
puts("Running app...");
int numbers[10] = {9, 8, 7, 6, 5, 4, 3, 2, 1, 0};
lib_func(numbers);
printf("sizeof(numbers): %lu\n", sizeof(numbers));
puts("Finished app");
}
Despite the warnings, this is valid C and behaves as expected under the standard.
However, when we pass the array to lib_func the environment does not copy the whole array; it just passes a pointer to the first element. That’s why we see a big difference in the supposed sizes:
❯ ./a.out
Running app...
[lib_func] sizeof(numbers): 8
[main] sizeof(numbers): 40
Finished app
Array-to-pointer conversion is especially tricky when you return an array from a function, as the next example shows:
#include <stdio.h>
int *make_array(void) {
//
return (int[]){4, 5, 6};
}
int main(void) {
int *p = make_array();
printf("%d\n", p[1]);
}
Although this compiles, the behaviour is undefined. make_array doesn’t copy the array; it merely returns a pointer to the first element of a compound literal whose lifetime ends when the function returns.
If you need to return multiple values, wrap them in a structure. The following program is completely defined C:
#include <stdio.h>
struct ArrayOutput {
int array[3];
};
struct ArrayOutput make_array(void) {
return (struct ArrayOutput){.array = {4, 5, 6}};
}
int main(void) {
struct ArrayOutput out = make_array();
int *p = out.array;
printf("%d\n", p[1]);
}
Because out is a named object that exists for the entire duration of main, the pointer p remains valid during the printf call.
Integer promotion
According to C23 6.3.1.1:
... if an int can represent all values of the original type (as restricted
by the width, for a bit-field), the value is converted to an int; otherwise,
it is converted to an unsigned int. These are called the integer promotions.
All other types are unchanged by the integer promotions.
Let’s examine this with an example:
#include <stdio.h>
int main(void) {
char c = 'A';
printf("sizeof(c): %lu\n", sizeof(c));
printf("sizeof(c + 1): %lu\n", sizeof(c + 1));
return 0;
}
which outputs:
❯ ./a.out
sizeof(c): 1
sizeof(c + 1): 4
Even though we are not changing the type of char, it is promoted to int; it looks very non-intuitive, doesn’t it?
But the rule is simple: whenever a type narrower than int is used in an expression, it is promoted to int.
The list of possible expressions is quite long:
- ordinary arithmetic
- bitwise operations
- relational/equality operators
- logical operators
- the conditional (
?:) operator - array subscripts and pointer arithmetic
- the controlling expression of
switchand itscaselabels
Floating promotion
According to C23 6,3,1.8:
Otherwise, if the corresponding real type of either operand is double, the other operand is
converted, without change of type domain, to a type whose corresponding real type is double.
Otherwise, if the corresponding real type of either operand is float, the other operand is
converted, without change of type domain, to a type whose corresponding real type is float.
Let’s examine this with an example:
#include <stdio.h>
void lib_double(double number) { printf("Number: %f\n", number); }
void lib_float(float number) { printf("Number: %f\n", number); }
int main(void) {
puts("Running app...");
float f = 7.5;
double d = 9.9999;
lib_double(f);
lib_float(d);
puts("Finished app");
}
Which outputs:
❯ ./a.out
Running app...
Number: 7.500000
Number: 9.999900
Finished app
The float is implicitly converted to double, similiarly double is implicitly converted to float. This can feel counterintuitive, but it’s well-defined behavior in C.
Pointer conversion to void *
According to C23 6.3.2.3:
A pointer to void may be converted to or from a pointer to any object type. A pointer to any object
type may be converted to a pointer to void and back again; the result shall compare equal to the
original pointer.
Let’s examine this with an example:
#include <stdint.h>
#include <stdio.h>
int func(void *number) {
(void)number;
return 0;
}
int bunc(int *number) {
(void)number;
printf("%c\n", *number);
return 0;
}
int strunc(char *number) {
(void)number;
return 0;
}
int dstrunc(char **number) {
(void)number;
return 0;
}
int main(void) {
puts("Running app...");
char c = 'A';
printf("func(&c): %d\n", func(&c));
void *cp = &c;
bunc(cp);
/* bunc(&c); // would fail */
strunc(cp);
/* strunc(&c); // would fail */
dstrunc(cp);
/* dstrunc(&c); // would fail */
puts("Finished app");
}
which outputs:
❯ ./a.out
Running app...
func(&c): 0
A
Finished app
As we can observe, casting a pointer to void * makes it implicitly convertible to any object-pointer type; this behaviour does not occur between other pointer types.
Conclusions
Being aware of the implicit conversions can help you write more predictable and portable C code. Whenever possible, make conversions explicit to avoid subtle bugs—especially in complex expressions or cross-platform projects.