C++ For C# Developers: Part 20 – Implicit Type Conversion
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
- Part 1: Introduction
- Part 2: Primitive Types and Literals
- Part 3: Variables and Initialization
- Part 4: Functions
- Part 5: Build Model
- Part 6: Control Flow
- Part 7: Pointers, Arrays, and Strings
- Part 8: References
- Part 9: Enumerations
- Part 10: Struct Basics
- Part 11: Struct Functions
- Part 12: Constructors and Destructors
- Part 13: Initialization
- Part 14: Inheritance
- Part 15: Struct and Class Permissions
- Part 16: Struct and Class Wrapup
- Part 17: Namespaces
- Part 18: Exceptions
- Part 19: Dynamic Allocation
- Part 20: Implicit Type Conversion
- Part 21: Casting and RTTI
- Part 22: Lambdas
- Part 23: Compile-Time Programming
- Part 24: Preprocessor
- Part 25: Intro to Templates
- Part 26: Template Parameters
- Part 27: Template Deduction and Specialization
- Part 28: Variadic Templates
- Part 29: Template Constraints
- Part 30: Type Aliases
- Part 31: Deconstructing and Attributes
- Part 32: Thread-Local Storage and Volatile
- Part 33: Alignment, Assembly, and Language Linkage
- Part 34: Fold Expressions and Elaborated Type Specifiers
- Part 35: Modules, The New Build Model
- Part 36: Coroutines
- Part 37: Missing Language Features
- Part 38: C Standard Library
- Part 39: Language Support Library
- Part 40: Utilities Library
- Part 41: System Integration Library
- Part 42: Numbers Library
- Part 43: Threading Library
- Part 44: Strings Library
- Part 45: Array Containers Library
- Part 46: Other Containers Library
- Part 47: Containers Library Wrapup
- Part 48: Algorithms Library
- Part 49: Ranges and Parallel Algorithms
- Part 50: I/O Library
- Part 51: Missing Library Features
- Part 52: Idioms and Best Practices
- Part 53: Conclusion
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:
int
unsigned int
long
unsigned long
long long
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:
int
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:
bool
signed char
,unsigned char
, andchar
short
andunsigned short
int
andunsigned int
long
andunsigned long
long long
andunsigned 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!
#1 by Tom on January 26th, 2023 ·
Hi Jackson. I am absolutly loving the series. Absolutely fantastic resource! I think have found a small typo here:
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:
This is perhaps missing a
&
in but the&
is optional.#2 by jackson on January 26th, 2023 ·
I’m glad you’re enjoying it! Thanks for letting me know about the typo. I’ve updated the post with a fix.