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


Published

Category

C++ Basic

Tags

Contact