Implicit Conversions in C++

Implicit Conversions in C++

25 Feb 2019    

Let’s go through all the different types of conversions that are allowed in C++. Implicit conversions make C++ code easy to use and result in terse syntax but can often have unintended effects. While ideally, one probably doesn’t need to think about these conversions all the time, it’s good to be aware of how it works in C++ grammar.

What are Implicit Conversions?

An expression e can be implicitly converted to T if and only if T t = e is well formed for some temp var t. The effect of implicit conversion is same as performing above and using temporary to perform conversion followed by initialisation of the new type. The value category of the temporary is dependent on the category of the destination type.

Implicit conversions occur in a lot of places, some may be obvious others aren’t. Some examples of implicit conversions are:

  • The condition expression of an if statement causes an implicit conversion where the destination type is bool
  • When used in the expression of a switch statement, the destination type is integral
  • When used as the source expressions for initialisation, the type of entity being initialised is the destination type which may incur an implicit conversion.

Note: A conversion that is an exact match and doesn’t actually require any changes in types is called an identity conversion.

Contextual Conversions

Certain language constructs require looking up conversions functions when converting from one type to another. This is known as a contextual conversion. For example, an expression can be contextually converted to bool if and only if bool t(e); is well-formed for some temporary variable t.

So, when dealing with an expression e of type E that can be converted to T only if e can use a conversion function from E that returns a const T or a reference to it (const T&). In this case we say that e is contextually implicitly converted to T. An example of this is:

struct Foo
{
  operator int() const
  {
    return 0;
  }
};

int get_foo_value(const Foo& foo)
{
  return foo; // contextually implicitly converted to int
}

Implicit Conversion Sequence

The code we write often has several conversions happening during a call site. The sequence of conversions used to convert an argument in a function call to type of the corresponding parameter of the function is called an implicit conversion sequence. These sequences are only concerned with the type, cv-qualification and value category. Lifetime, storage class, alignment etc are ignored during conversion (but may cause problems later in analysis stage). This is exactly how we can provide a const char* argument to a std::string parameter and have it call the appropriate constructor!

A well formed implicit conversions sequence is one of the following forms:

  1. Standard Conversion Sequence - These are the most common implicit conversions that happen and involve fundamental types and pointers. However, they come in 3 forms and are attempted in the following order:
    • Exact Match - Identity conversions
    • Promotion - Converting from a smaller sized type to a larger sized type
    • Conversion - Converting from similarly sized types
  2. User-Defined Conversion Sequence - While this is not considered in certain scenarios, it uses user defined operators and is limited to one per sequence during overload resolution and generally follows the pattern of Standard Conversion -> User Conversion -> Standard Conversion
  3. Ellipsis Conversion Sequence - Used when the ellipsis (...) operator is used.
  4. List Initialisation Sequence - Used when the user uses the {} syntax to perform initialisation of a function argument. No narrowing conversions are allowed here and in a list sequence of several values, the worst conversion possible is chosen.

In the following sections, we’ll be diving further into the standard conversion sequences as they’re most common and more relevant than the others.

Narrowing Conversions

Some conversions may result in loss of information. This is most likely when moving from a larger type to a smaller sized type. It occurs when converting from/to in the following cases:

  • float to int
  • long double to double or float
  • double to float
  • int or enum to float
  • Larger integral type to smaller integral type (eg: long int to unsigned short)

Note: All the above except the first one is allowed for constant expressions only if there is no truncation and the exact original value can be obtained if conversions is reversed.

Constant Expressions

Constant expressions generally allow all the standard conversions, barring narrowing conversions and are performed implicitly. The only slight difference is that all integral constant expressions are converted to prvalues (These are used in array bounds, bitfields, enums etc). Constant expressions also allow user defined conversions but only if they are constexpr as well.

Boolean conversions

Converting from a bool to another type will always result in either a 0 for false or a 1 for true. Converting to a bool means 0 is false and any other value is true.

Converting to a Pointer

An array to pointer conversion happens when a T[] or T[N] can be converted to T* and this points to the first element.

Similarly a function lvalue can be converted to it’s pointer which points to the function.

nullptr is an integer literal with a value of 0. When converted to a pointer type T* it results in a null pointer value of that type.

Any T* can be converted to void*, points to same first byte in memory as the original pointer.

And lastly, any Derived* pointer can be implicitly converted to its Base* pointer.

L to Rvalue Conversion

A glvalue of type T can be converted to a prvalue of type T if it’s not a function pointer or array. If T is incomplete then it’s ill formed. The conversion follows regular lifetime and scope requirements.

int x = 42; // initializer is a non-string literal -> prvalue
int y = x;  // initializer is an object / lvalue, conversion applied

If T is std::nullptr_t then result is a null pointer constant. Otherwise if T is class type the conversions copy-initialises a temporary value of T from the glvalue and results in a prvalue.

struct my_class { int m; };

my_class x{42};
my_class y{0};

x = y; // lvalue to rvalue conversions applied ONLY to x.m
       // and `my_class` is not converted to an rvalue

The only caveats is if the original object contains an invalid pointer then behaviour is implementation defined. Otherwise, generally, the original value becomes the prvalue result and if it’s not a class type it removes all cv-qualifiers.

Integral Promotion

Smaller integral types can be converted to their larger counterparts via integral promotion.

Note: bool, char and whcar_t are all integral types

Integral promotion is applied and converts the smaller types to a prvalue of int if int can correctly represent al values of the source otherwise it is converted to unsigned int. This happens in the following order and is known as the integer conversion rank:

  • int
  • unsigned int
  • long int
  • unsigned long int
  • long long int
  • unsigned long long int

Additionally an unscoped enum can be converted to any of the above, the signed version gets priority and same can be said for integer bitfields.

One side effect of this is that adding two unsigned short values results in an int:

const auto foo = unsigned short(5) + unsigned short(5);
// Here foo is of type `int`

Integral Conversions

Converting from a signed representation toan unsigned one is okay and will result in expected value. However converting from an unsigned to signed is only valid if the original value is within limits of the destination type, otherwise the conversion is implementation defined.

Floating Point promotion

This one is pretty straight-forward, a float can be converted to a double and the value is unchanged.

Floating Point Conversions

When converting from a source floating type to a destination floating type, if the exact representation of the value is possible in the destination then nothing special happens; however, if the destination value is not exact and say depends on 2 adjacent values then which value is chosen is implementation defined. If the destination cannot represent that value at all it is undefined behaviour.

Floating-Integral Conversions

Converting from a floating point type to an integral type causes truncation and results in UB if the truncated value cannot be represented in destination integral type.

Converting an enum to float is allowed and is as close to the exact value as possible. If not the value chosen is implementation defined.

Conversions in Conditional Operator

Although this section in itself probably requires a whole different tip, we can very briefly mention the caveats associated here. Given a conditional operator as such:

C ? E1 : E2

where

C = condition E1 = first expression E2 = second expression

A conversion is performed when E1 and E1 don’t have the same type, value category or cv qualification. In which case E1 is converted to E. If either is a prvalue, you’ll likely end up getting a prvalue which can cause copies to be created. If they don’t have the same cv qualification it will often result in copies as well.

The best possible way is to make sure both the expressions have the exact same type, value category and cv qualification.

Conclusion

Hopefully this should give you an idea of how C++ tries to convert values and types but more importantly the associated set of implementation and undefined behaviours that can happen and potentially cause problems.