We’ve actually seen quite a bit of implicit type conversion so far in the series. We’ve converted integers to floats (float f = 123), arrays to pointers (int* p = a), base type pointers to derived type pointers (D* p = &b), and many more. Today we’ll gather all those casual conversions up into one article that goes over all the rules, including user-defined type conversions.

Table of Contents

When Implicit Type Conversion Happens

Both C# and C++ feature implicit type conversion, but there are many language-specific differences. In C++, implicit conversion occurs when using a wider variety of language features.

First up, and just like in C#, it happens when calling a function with a type other than the type of the function’s argument:

void Foo(float x)
{
}
Foo(1); // int -> float

Similarly, and also in C#, it happens when returning a value whose type isn’t the function’s return type:

float Bar()
{
    return 1; // int -> float
}

All the boolean logic operators require bool operands, so any non-bool needs conversion. This can be implicit in C++, but not C#:

bool b1 = !1; // int -> bool
bool b2 = false || 1; // int -> bool
bool b3 = true && 1; // int -> bool

The same goes for conditionals in C++, but not C#:

if (1) // int -> bool
{
}
 
bool b4 = 1 ? false : true; // int -> bool

And for C++ loops, not not C# loops:

while (1) // int -> bool
{
}
for (; 1; ) // int -> bool
{
}
do
{
} while (1); // int -> bool

The switch statement requires an integral type, so there’s conversion required when using anything else. Both languages support this:

switch (false) // bool -> int
{
}

The C++ delete operator only deletes typed pointers. Here we have a user-defined conversion operator that converts a struct to an int*:

struct ConvertsToIntPointer
{
    operator int*() { return nullptr; }
};
delete ConvertsToIntPointer{}; // ConvertsToIntPointer -> int*

Finally, both of C++’s noexcept and explicit can be conditional and require a bool:

void Baz() noexcept(1) // int -> bool
{
}
 
// C++20 and later
struct HasConditionalExplicit
{
    explicit(1) HasConditionalExplicit() {} // int -> bool
};
Standard Conversions

We’ve already talked about user-defined conversions, but these aren’t the only ways to implicitly convert between types. The language itself has many “standard” conversions that don’t require us to write any code.

Usually these change the type itself, but in a few cases they just change its classification:

int x = 123;
const int y = x; // int -> const int
                 // also, lvalue -> rvalue

It’s always OK to treat a non-const as a const since that just adds restriction. We can’t go the other way as that would remove the const restriction.

Similarly, we can go from non-throwing functions to possibly-throwing but not the other way:

void DoStuff() noexcept
{
    throw 1;
}
 
void (*pFuncNoexcept)() noexcept = &DoStuff;
void (*pFunc)() = pFuncNoexcept; // non-throwing -> possibly-throwing
void (*pFuncNoexcept2)() noexcept = pFunc; // Compiler error

With those out of the way, all the rest of the standard conversions will change the type. First up we have function-to-pointer conversions. The previous example “took the address” of DoStuff to get a pointer to it, as we’ve seen before, but the is optional because there’s a standard conversion from functions to function pointers:

void DoStuff()
{
}
 
void (*pFunc)() = DoStuff; // function -> function pointer

Note that this doesn’t work on non-static member functions as they require a class instance in order to implicitly pass the this argument:

struct Vector2
{
    float X;
    float Y;
 
    float SqrMagnitude() const noexcept
    {
        return X*X + Y*Y;
    }
};
 
Vector2 vec{2, 4};
// All of these are compiler errors:
float (*sqrMagnitude1)() = Vector2::SqrMagnitude
float (*sqrMagnitude2)() = vec.SqrMagnitude;
float (*sqrMagnitude3)(Vector2*) = Vector2::SqrMagnitude
float (*sqrMagnitude4)(Vector2*) = vec.SqrMagnitude;

Next we have array-to-pointer conversions:

int arr[]{1, 2, 3};
int* pArray = arr; // int[3] -> int*

This is known as “array to pointer decay” and it’s allowed because the two concepts are very similar. Semantically, it’s kind of like pointers are the “base class” of arrays. They’re both essentially a pointer and can be treated like an array (x[123]), but arrays are of a known size (sizeof(arr) == sizeof(int)*3). So one way to think about this is like an “upcast” from a derived class (array more information) to a base class (pointer with less information).

When it comes to numbers, we have two broad categories: promotion and conversion. Promotion won’t change the value of the number, but conversion might. Promotion generally increases the size of a number since larger sizes can represent all the values of smaller sizes. The same happens in C# when we, for example, pass a short to a function that takes an int.

This is commonly needed since all the arithmetic operators (e.g. x + y) require an int or larger. So the smaller primitive types will be promoted to an int for all these operators as well as for all the reasons we saw above.

First, signed char is always promoted to int:

signed char c = 'A';
int i = c + 1; // c is promoted from 'signed char' to int
DebugLog(i); // 66 (ASCII for 'B')

The sizes of char, unsigned char, unsigned short, and int depend on factors such as the compiler and CPU architecture. If int can hold the full range of values for char, unsigned char, unsigned short, and char8_t, which is usually the case, they’re promoted to int. If it can’t, they’re promoted to unsigned int.

unsigned char c = 'A'; // or 'unsigned short' or 'char8_t'
auto i = c + 1; // c is promoted from 'unsigned char' to int or 'unsigned int'
DebugLog(i); // 66 (ASCII for 'B')

The compiler also determines the size of wchar_t. It, as well as char16_t and char32_t, will be promoted to the first type that’s big enough to hold the full range of values:

  1. int
  2. unsigned int
  3. long
  4. unsigned long
  5. long long
  6. unsigned long long
wchar_t c = 'A';
auto i = c + 1; // c is promoted from 'wchar_t' to at least an int
DebugLog(i); // 66 (ASCII for 'B')

Unscoped enumerations that don’t have a fixed underlying type are also promoted into the same list of types:

enum Color // No underlying type
{
    Red,
    Green,
    Blue
};
 
Color c = Red;
auto i = c + 1; // c is promoted from 'Color' to at least an int
DebugLog(i); // 1

If it does have a fixed underlying type, it’s promoted to that type and then that type can be promoted:

enum Color : int // Has an underlying type
{
    Red,
    Green,
    Blue
};
 
Color c = Red;
long i = c + 1L; // c is promoted from 'Color' to int and then to long
DebugLog(i); // 1

Bit fields will be promoted to the smallest size that can hold the full value range of the bit field, but it’s a short list:

  1. int
  2. unsigned int
struct ByteBits
{
    bool Bit0 : 1;
    bool Bit1 : 1;
    bool Bit2 : 1;
    bool Bit3 : 1;
    bool Bit4 : 1;
    bool Bit5 : 1;
    bool Bit6 : 1;
    bool Bit7 : 1;
};
 
ByteBits bb{0};
int i = bb.Bit0 + 1; // bit field is promoted from 1 bit to int
DebugLog(i); // 1

The bool type is promoted to int with false becoming 0 and true becoming 1 (not just non-zero). This isn’t allowed in C#:

bool b = true;
int i = b + 1; // b is promoted from bool to int with value 1
DebugLog(i); // 2

The float type is promoted to double, which works in both languages:

float f = 3.14f;
double d = f + 1.0; // f is promoted from float to double
DebugLog(d); // 4.14

Everything else is a numeric conversion, not promotion. Unlike promotion, the value may change during conversion.

First, there’s conversion to an unsigned integer type. The result is smallest unsigned value modulus 2n where n is the number of bits in the destination type. If the source type was signed, it’s sign-extended or truncated. If it was unsigned, it’s zero-extended or truncated. This isn’t allowed in C#:

int32_t si = 257;
uint8_t ui = si; // si is converted from int32_t to uint8_t
                 // ui = 257 % 2^8 = 257 % 256 = 1
DebugLog(ui); // 1

When converting to a signed integer type, the value won’t be changed if it can be represented in the destination type. Otherwise, the value was implementation-defined until C++20. Since C++20, the value is required by the C++ Standard to be computed like the conversion to unsigned: the source value modulus 2n where n is the number of bits in the destination type. This also isn’t allowed in C#:

uint32_t ui1 = 123;
int8_t si1 = ui1; // ui1 is converted from uint32_t to int8_t
                  // The value doesn't change since it can be held in int8_t
DebugLog(si1); // 1
 
uint32_t ui2 = 257;
int8_t si2 = ui2; // ui2 is converted from uint32_t to int8_t
                  // Implementation-defined value until C++20
                  // Since C++20:
                  // si2 = 257 % 2^8 = 257 % 256 = 1
DebugLog(si2); // 1 in C++20, unknown in C++17 and before

We saw above that when bool must become an int, it’s promoted from false to 0 and true to 1. For all other integer types, this is technically a conversion but it generates the same result. Despite not losing any precision like the above conversions, C# also forbids this:

bool b = true;
long i = b + 1; // b is converted from bool to long with value 1
DebugLog(i); // 2

If a floating point type needs to become another floating point type, it’s value is preserved exactly if that’s possible in the destination type. If that’s not possible and the source value is between two values in the destination type, one of them will be chosen. Usually the nearest value is chosen. Otherwise, the conversion is undefined behavior. This is also forbidden by C#:

double d = 3.14f;
float f = d; // d is converted from double to float
DebugLog(f); // 3.14

When converting a floating point type to an integer type, the fractional part is discarded. If the value can’t fit, it’s undefined behavior. Unlike above, there’s no modulus applied for unsigned integer types. This too won’t work in C#:

float f1 = 3.14f;
int8_t i1 = f1; // f1 is converted from float to int8_t
                // Fractional part (0.14) is discarded
DebugLog(i1); // 3
 
float f2 = 257.0f;
uint8_t i2 = f2; // f2 is converted from float to int8_t
                 // Value won't fit. Modulus not applied
                 // This is undefined behavior!
DebugLog(i2); // Could be anything!

The other way around, integers converted to floating point, works differently. They can be converted to any floating point type. The integer’s value is preserved exactly if that’s possible in the floating point type. Otherwise, if the integer’s value is between two values in the floating point type then one of those two values will be chosen and that’s usually the nearest value. Otherwise, the integer value won’t fit and that’s undefined behavior. C# allows this, too. For bool, we simply get 0 or 1 as with conversions to integer types, but not in C# as it’s disallowed there.

int8_t i = 123;
float f1 = i; // i is converted from int8_t to float
DebugLog(f1); // 123
 
bool b = true;
float f2 = b; // b is converted from bool to float
DebugLog(f2); // 1

A “null pointer constant” in C++ is any integer literal with the value 0, any constant with the value 0, or nullptr. These can all be converted to any pointer type. Only null is allowed in C#.

int* p1 = 0; // Integer constant with value 0 is converted to int*
DebugLog(p1); // 0
 
int* p2 = nullptr; // nullptr is converted to int*
DebugLog(p2); // 0

Similar to the “decaying” we saw with the array-to-pointer conversion, the shedding of noexcept, and the adding of const, all pointer types convert to void* since it’s a “pointer to anything.” This is allowed in both languages:

int x = 123;
int* pi = &x;
void* pv = pi; // int* is converted to void*
DebugLog(pv == pi); // true

As we’ve seen before when discussing inheritance, derived class pointers convert to base class pointers. The result points to the base class subobject of the derived class. Similarly, C# allows this with class references.

struct Vector2
{
    float X;
    float Y;
};
 
struct Vector3 : Vector2
{
    float Z;
};
 
Vector3 vec{};
vec.X = 1;
vec.Y = 2;
vec.Z = 3;
Vector3* pVec3 = &vec;
Vector2* pVec2 = pVec3; // Vector3* is converted to Vector2*
DebugLog(pVec2->X, pVec2->Y); // 1, 2

Pointers to non-static members of base classes can likewise be converted to pointers to non-static members of derived classes:

float Vector2::* pVec2X = &Vector2::X;
float Vector3::* pVec3X = pVec2X; // Pointer to base member is converted
                                  // to pointer to derived member
Vector3 vec{};
vec.X = 1;
vec.Y = 2;
vec.Z = 3;
Vector3* pVec3 = &vec;
Vector2* pVec2 = pVec3;
DebugLog((*pVec2).*pVec2X, (*pVec3).*pVec3X); // 1, 1

Note that this isn’t allowed for virtual inheritance:

struct Vector2
{
    float X;
    float Y;
};
 
struct Vector3 : virtual Vector2 // Virtual inheritance
{
    float Z;
};
 
float Vector2::* pVec2X = &Vector2::X;
float Vector3::* pVec3X = pVec2X; // Compiler error

Finally, all integer types, floating point types, unscoped enumerations, pointers, and pointers to members can be converted to bool. Zero and null become false and everything else becomes true. C# doesn’t allow any of these.

int i = 123;
bool b1 = i; // int is converted to bool
DebugLog(b1); // true
 
float f = 3.14f;
bool b2 = f; // float is converted to bool
DebugLog(b2); // true
 
Color c = Red;
bool b3 = c; // Color is converted to bool
DebugLog(b3); // false
 
int* p = nullptr;
bool b4 = p; // int* is converted to bool
DebugLog(b4); // false
 
float Vector2::* pVec2X = &Vector2::X;
bool b5 = pVec2X; // Pointer to member is converted to bool
DebugLog(b5); // true
Conversion Sequences

Now that we know about promotions and conversions, let’s see how they’re sequenced in order to change types. First, C++ has a “standard conversion sequence” that consists of the following steps which mostly don’t apply to C#:

1) Zero or one conversions from an lvalue to an rvalue, array to pointer decays, and function to pointer conversions
2) Zero or one promotions or conversions of a number
3) Zero or one function pointer conversions, including non-throwing to possibly-throwing (only allowed in C++17 and later)
4) Zero or one non-const to const conversion

C++ also has an “implicit conversion sequence” with these steps:

1) Zero or one standard conversion sequences
2) Zero or one user-defined conversions
3) Zero or one standard conversion sequences

When we’re passing an argument to a class’ constructor or to a user-defined conversion function, we can only use the standard conversion sequence. That cuts out the possibility of calling a user-defined conversion operator:

struct MyClass
{
    MyClass(const int32_t)
    {
    }
};
 
uint8_t i1{123};
MyClass mc{i1}; // 1) lvalue to rvalue
                // 2) Promotion from uin8_t to uint32_t
                // 3) N/A
                // 4) uint8_t to 'const uint8_t'
 
struct C
{
};
 
struct B
{
    operator C()
    {
        return C{};
    }
};
 
struct A
{
    operator B()
    {
        return B{};
    }
};
 
// Compiler error: user-defined conversion operators not allowed here
C c = A{};

Otherwise, we’re allowed to use implicit conversion sequences. Here’s a class that automatically closes files but converts to a FILE* so it can be used with a wide variety of functions in the C++ Standard Library, such as fwrite that writes to a file:

class File
{
    FILE* handle;
 
public:
 
    File(const char* path, const char* mode)
    {
        handle = fopen(path, mode);
    }
 
    ~File()
    {
        fclose(handle);
    }
 
    operator FILE*()
    {
        return handle;
    }
};
 
void Foo()
{
    File writer{"/path/to/file", "w"};
    char msg[] = "hello";
 
    // fwrite looks like this:
    //   std::size_t fwrite(
    //     const void* buffer,
    //     std::size_t size,
    //     std::size_t count,
    //     std::FILE* stream);
 
    // Last argument is implicitly converted from File to FILE*
    fwrite(msg, sizeof(msg), 1, writer);
 
    // Note: File destructor called here to close the file
}
Overflows

Integer math may result in an “overflow” where the result doesn’t fit into the integer type. C++ doesn’t have C#’s checked and unchecked contexts. Instead, it handles overflow differently depending on whether the math is signed or unsigned.

For signed math, an overflow is undefined behavior:

int32_t a = 0x7fffffff;
int32_t b = a + 1; // Overflow. Undefined behavior!
DebugLog(b); // Could be anything!

This isn’t as catastrophic as it may seem. The compiler will usually just generate an addition instruction and the overflow will be handled according to the CPU architecture’s rules for overflow. Only in cases where the compiler can prove a signed integer overflow will happen, like this example, is it likely to generate unexpected CPU instructions. It may also generate a compiler warning to bring this to the attention of the programmer. Usually the result ends up being the same in C# and C++, but technically it doesn’t have to.

Unsigned math is more forgiving. An overflow is simply performed modulus 2n where n is the number of bits in the integer type:

uint8_t a = 255;
uint8_t b = a + 1; // Overflow. b = (255 + 1) % 256 = 0.
DebugLog(b); // 0
Arithmetic

We’ve seen a lot of promotion and conversion due to arithmetic already, but only covered simple cases so far. There are quite a few more rules for determining which operands are promoted or converted and what the “common type” arithmetic is performed on should be.

First of all, C++20 deprecates mixing floating point and enum types or enum types with other enum types. These were never allowed in C#.

enum Color
{
    Red,
    Green,
    Blue
};
 
// Deprecated: mixed enum and float
auto a = Red + 3.14f;
 
enum RangeType
{
    Melee,
    Distance
};
 
// Deprecated: mixed enum types
auto b = Red + Melee;

Integers get promoted first. Then, for all the binary operators except shifts, a series of specific type changes occur. First, if either operand is a long double then the other operand is converted to a long double. The same happens for double and float. C# has essentially the same behavior.

int i = 123;
long double ld = 3.14;
long double sum1 = ld + i; // i is converted from int to 'long double'
DebugLog(sum1); // 126.14
 
double d = 3.14;
double sum2 = d + i; // i is converted from int to double
DebugLog(sum2); // 126.14
 
float f = 3.14f;
double sum3 = f + i; // i is converted from int to float
DebugLog(sum3); // 126.14

For signed and unsigned integers, we need to consider the “conversion rank” of the types involved:

  1. bool
  2. signed char, unsigned char, and char
  3. short and unsigned short
  4. int and unsigned int
  5. long and unsigned long
  6. long long and unsigned long long

char8_t, char16_t, char32_t, and wchar_t have the same conversion rank as their underlying type, which depends on factors like the compiler, OS, and CPU architecture.

With that in mind, the operand with lower conversion rank is converted to the type of the operand with the greater conversion rank if both operands are either signed or unsigned. C# behaves the same way.

unsigned char uc = 'A'; // Coversion rank = 2
unsigned long ul = 1; // Conversion rank = 5
 
// uc has lower conversion rank, so it's converted to unsigned long
unsigned long sum = uc + ul;
DebugLog(sum); // 66 (ASCII for 'B')

Otherwise, one operand is signed and the other is unsigned. C# doesn’t allow this, but C++ does. In this case, if the unsigned operand’s conversion rank is greater than or equal to the signed operand’s conversion rank then the signed operand is converted to the type of the unsigned operand:

short s = 123; // Coversion rank = 3
unsigned long ul = 1; // Conversion rank = 5
 
// s has lower conversion rank, so it's converted to unsigned long
unsigned long sum = s + ul;
DebugLog(sum); // 124

If that’s not the case but the signed type can represent all the values of the unsigned type, the unsigned operand converted to the type of the signed operand:

long l = 123; // Coversion rank = 5
unsigned short us = 1; // Conversion rank = 3
 
// l has greater conversion rank and long can represent all the
// values of 'unsigned short', so us is converted to long
long sum = l + us;
DebugLog(sum); // 124

And if that’s not the case either, both operands are converted to the unsigned counterpart of the signed type:

long l = 123; // Coversion rank = 5
unsigned int ui = 1; // Conversion rank = 4
 
// Assume int and long are both 4 bytes (e.g. Windows)
// l has greater conversion rank but long can't represent all the
// values of 'unsigned int', so l is converted from long to unsigned long
// and ui is converted from unsigned int to unsigned long
unsigned long sum = l + ui;
DebugLog(sum); // 124
Narrowing Conversions

So far we’ve seen types either stay the size size or get bigger. Sometimes, the types get smaller or otherwise lose precision. These are called “narrowing” conversions and they’re a subset of the implicit conversions we saw above. C# never allows these, but C++ does. For example, the conversion from floating point to integer loses precision by truncating the fractional part:

float f = 3.14f;
int i = f;
DebugLog(i); // 3

Converting from a larger floating point type to a smaller ones may also lose precision:

long double ld1 = 3.14;
double d1 = ld1; // 'long double' -> double
DebugLog(d1); // 3.14
 
long double ld2 = 3.14;
float f1 = ld2; // 'long double' -> float
DebugLog(f1); // 3.14
 
double d2 = 3.14;
float f2 = d2; // double -> float
DebugLog(d2); // 3.14

Converting from an integer or enumeration to a floating point type might also lose precision if the floating point type can’t exactly represent the integer:

uint64_t i = 0xffffffffffffffff;
float f = i; // uint64_t -> float
DebugLog(f); // 1.84467e+19
uint64_t i2 = f;
DebugLog(i == i2); // false

These narrowing conversions are allowed by copy initialization as we’ve seen here, but forbidden by aggregate and list initialization:

// Compiler error: aggegate initialization with narrowing
int i1{3.14f};
IntHolder i2{3.14f};
int i3 = {3.14f};
 
// Compiler error: list initialization with narrowing
int i4[] = { 3.14f, 3.14f };
 
// OK: copy initialization with narrowing
int i5 = 3.14f; // copy
int i6(3.14f); // copy

Avoiding narrowing is yet-another reason to prefer initializing with curly braces.

Conclusion

This concludes the deep dive into C++’s implicit type conversion system. It’s a lot more permissive than C#. That allows our code to be more terse, but also opens us up to a lot of potential bugs. So it’s important that we understand all the rules from numeric promotion to conversion ranks to overflow handling.

In the next article, we’ll go into explicit conversion by talking about type casts. Stay tuned!