In C++, unsafe conversions, also known as implicit conversions, allow a value to be implicitly transformed into a value of another type that may not precisely match the original value [1]. This entails numeric conversions being performed and implicit casts being made, if necessary, without triggering compilation errors and sometimes without warnings.
For the sake of simplicity, we will concentrate on a specific unsafe conversion: the integer truncations. A simple example of the problem can be the following [2]:
#include <cstdint>
int main()
{
std::int64_t a = 1;
[[maybe_unused]] std::int32_t b = a;
}
The previous example demonstrates a truncation of the initial variable. While std::int64_t
occupies 8 bytes, std::int32_t
only takes up 4 byte.
Such conversions are commonly referred to as "narrowing" conversions because they place a value into an object that may be too small ("narrow") to contain it.
Both gcc
and clang
compilers issue warnings only when the compiler flag -Wconversion
is enabled [3]. Otherwise, compilers not produce no warnings (and natural not compilation errors).
Uniform initialization helps us
C++11 introduces uniform initialization, a single initialization syntax intended to be versatile and comprehensive. With this feature, warnings for truncation examples are always active; there's no need to activate compiler flags. This initialization method relies on braces and is thus termed "braced initialization." "Uniform initialization" is a conceptual notion, while "braced initialization" refers to the syntactic construct [4].
int main()
{
std::int64_t a = 1;
[[maybe_unused]] std::int32_t b {a};
}
If we compile the previous example with gcc 13.2
it produces the following warning [5]:
<source>: In function 'int main()':
<source>:5:38: warning: narrowing conversion of 'a' from 'int64_t' {aka 'long int'} to 'int32_t' {aka 'int'} [-Wnarrowing]
5 | [[maybe_unused]] std::int32_t b {a};
|
However, clang
is more detailed, since it produces a warning with a possible solution. But it also produces a compilation error [6]:
<source>:5:38: error: non-constant-expression cannot be narrowed from type 'std::int64_t' (aka 'long') to 'std::int32_t' (aka 'int') in initializer list [-Wc++11-narrowing]
5 | [[maybe_unused]] std::int32_t b {a};
| ^
<source>:5:38: note: insert an explicit cast to silence this issue
5 | [[maybe_unused]] std::int32_t b {a};
| ^
| static_cast<int32_t>( )
1 error generated.
Also, MVSC provides an error using the uniform initialization:
<source>(5): error C2397: conversion from 'int64_t' to 'int32_t' requires a narrowing conversion
Compiler returned: 2
static_cast
as a solution
The static_cast
keywords help us to solve the unsafe converion problems [6].
int main()
{
std::int64_t a = 1;
[[maybe_unused]] std::int32_t b {static_cast<int64_t>(a)};
}
Final Problem: Pay attention to const
variable
A critical problem is when the involved variables are defined as const
.
int main()
{
const std::int64_t a = 1;
[[maybe_unused]] const std::int32_t b {static_cast<int64_t>(a)};
}
All major compilers, gcc
, clang
, and MSCV
compile code without any errors or warnings. In particular, the fact that using the const
qualifier doesn't generate compilation errors or warnings is quite vexing, especially considering that a skilled C++ programmer often designates the vast majority of variables as const
.
C++ vs Rust
The allowance of unsafe truncation in C++ can indeed be traced back to its roots in C. C++ was designed with a strong emphasis on compatibility with C, inheriting many of its features and characteristics. Unsafe conversions, which are permitted in C due to its flexible and sometimes implicit type system, are carried over into C++ for reasons of backward compatibility and to facilitate interoperability with existing C codebases. This inheritance from C contributes to the flexibility of C++ but also requires developers to exercise caution and responsibility when dealing with type conversions. Especially in the const
case, in which all major C++ compilers do not provide any errors and warnings.
Now, let's explore how a modern compiler handle these challenges differently. We'll use Rust as a benchmark language, as it shares similarities with C++ being a compiled, general-purpose language that supports low-level operations.
The following example present the problem of the integer truncation [10].
#[allow(dead_code)]
#[allow(unused_variables)]
fn main() {
let a: f64 = 2.0;
let b: f32 = a;
}
the rust compiler produce no warnings, but it reports the error directly.
error[E0308]: mismatched types
--> <source>:6:18
|
6 | let b: f32 = a;
| --- ^ expected `f32`, found `f64`
| |
| expected due to this
error: aborting due to 1 previous error
use the cast to resolve the problem [11]:
#[allow(dead_code)]
#[allow(unused_variables)]
fn main() {
let a: f64 = 2.0;
let b: f32 = a as f32;
}
References
[1]: B. Stroustrup. "Programming Principles and Practice Using C++", Addision Wesley, 2014.
[2]: C++ example with gcc
https://godbolt.org/z/7a3EWaKf8
[3]: C++ example with gcc
and -Wconversion
: https://godbolt.org/z/E4n5oMPxx
[4]: S. Mayers. "Effective Modern C++", O'Reilly, 2014.
[5]: C++ example with gcc
and uniform initialization: https://godbolt.org/z/WrEKqebqb
[6]: C++ example with clang
and uniform initialization: https://godbolt.org/z/v35raf7rs
[7]: C++ example with gcc
and static_cast
: https://godbolt.org/z/6aadse6d3
[8]: C++ example with clang
and const
variables: https://godbolt.org/z/xYYM9dzfb
[9]: Rust documentation: https://doc.rust-lang.org/book/ch03-02-data-types.html
[10]: Rust example without cast: https://godbolt.org/z/K7d6nraP6
[11]: Rust example with cast: https://godbolt.org/z/9oE8zqePc