
What is Type Casting?
In software development, a “type cast” is an operation that converts an object to a different type. An example of this would be converting a variable of type int to double. Most object-oriented languages offer some form of type conversion or type casting, especially C++. This article will go over the many variations of type casting that are intrinsic to the language as well as non-standard casts provided by either the Standard Template Library (STL) or custom implementation.
Types of Casts
C++ has five casts built into the language through keywords or operators. These are the most common casts one will encounter when writing or reviewing C++ code.
C-style cast
Named after the language that originated this cast, the C-style cast is a variation of a type cast that uses the C syntax for converting one data type to another. Below is an example of what this cast originally looks like in C:
<stdio.h>int main() { int num = 5; double converted_num = (double)num; // Cast occurs here printf("%f", converted_num); return 0;}
And in C++, the same C-style cast would look like so:
<iostream>int main() { int num = 5; double converted_num = (double)num; // No difference! std::cout << converted_num << "\n"; return 0;}
What’s happening is that the variable num is declared originally as a variable of type int, and then a new variable converted_num of type double is assigned the value of num after the value is cast to a double. The parentheses operator () takes the encapsulated data type name and attempts to cast the adjacent variable or expression to that type.
Now, you may be surprised to learn that the C-style cast is NOT the recommended method for performing type casts in C++. Despite having its genesis in the lingua franca of computer science, C-style casts are generally discouraged in C++ for a couple reasons.
The first reason is that the C++ compiler does not perform any compile-time checks on C-style casts. As a result, runtime errors may occur when attempting to cast one type to another type using this method of casting.
Another issue with C-style casts is the increased burden of readability and maintenance placed on developers of code that uses these casts. Without using outside context, it can be very difficult to ascertain the original intent of a cast.
The reason that the original intent of a C-style cast is so often obscured leads us into the third reason: the C-style cast is actually just performing one of 5 types of casts under the hood without the developer being told. The 5 casts are tried in order1, progressively getting more and more dangerous until the cast is successful (or, at least is allowed by the compiler):
- const_cast
- static_cast
- static_cast then const_cast
- reinterpret_cast
- reinterpret_cast then const_cast
As previously mentioned, the compiler will work down this list until it finds a cast that correctly satisfies the requirements implied by the context of the cast. This is dangerous because a cast can be chosen that “works” but is not “proper”. It’s best to avoid the C-style cast altogether in favor of the richer, more robust options that C++ has to offer.
static_cast
The most common C++-style cast is the static cast which is invoked via the operator static_cast. The static cast is named such because the type conversion occurs statically — that is, at compile time. The compiler is able to perform checks to ensure the cast is type-safe, and the program will fail to compile if an invalid type conversion is attempted. This ensures that any cast done using the static cast will not fail at runtime, adding an extra layer of safety. Here is an example of performing a static cast:
<iostream>int main() { int x = 13; auto y = static_cast<double>(x); std::cout << "Value of y: " << y << "\n"; return 0;}
Let’s also look at an instance of a failed static cast that throws an error at compile time:
<string>int main() { int x = 13; // This does not compile because an integer cannot be cast to a string auto y = static_cast<std::string>(x); return 0;}
The above snippet does not compile because it is not possible to cast an integer type to a string type in C++. What’s really occurring under the hood is that the static_cast operator is attempting to call a variation of the std::string constructor that constructs a string from an integer, but no such constructor is defined for std::string; therefore, the cast cannot be performed.
dynamic_cast
Compile-time casts are not the only option in C++. The dynamic_cast operator enables runtime type conversions which are useful for instances where certain valid type conversions cannot be performed at compile-time. Dynamic casts are most common when performing type casts within a class hierarchy, i.e., converting an object with a base type to a derived type (also known as a downcast). Let’s look at the dynamic_cast in action:
<iostream>class Base { public: virtual void check() { std::cout << "This object is of type Base\n"; }};class Derived : public Base { public: void check() override { std::cout << "This object is of type Derived\n"; }};int main() { Derived derived; Base* base = &derived; auto* new_derived = dynamic_cast<Derived*>(base); // Always check if downcast was successful to prevent segfaults if (new_derived == nullptr) { std::cerr << "Downcast failed.\n"; return 1; } else { new_derived->check(); } return 0;}
Notice that we need to do a nullptr check because it is possible for a dynamic_cast to fail. Since this cast is performed at runtime, the compiler will not catch any bad cast attempts, so we should verify that the cast succeeded before moving on, and failure should be handled gracefully. When compiling and running the code above, you’ll see that it prints “This object is of type Derived“.
const_cast
There are some casts that should be used sparingly and only for good reason, and the const_cast is certainly one of them. C++ has the concept of constness, and this refers to the mutability of an object. If an object is declared or initialized with a const type, its value cannot be changed later in the program because it is immutable; at least, not by conventional means. C++ offers the const_cast keyword which allows for adding or removing constness from an object. Normally, casting away constness is usually considered bad practice, but there are a couple of situations that justify its use. Let’s look at an example of such a case to see how const_cast works.
void legacy_print(char* str);void modern_func(const std::string& message) { legacy_print(const_cast<char*>(message.c_str()));}int main() { const std::string message = "Test"; modern_func(); return 0;}
The legacy_print function in the above example is a C function, and since C did not have the concept of constness, we’re going to assume for the sake of example that this function does not actually modify the parameter passed to it. We have a const string that we want to print, and when we pass that string to the C++ wrapper function, it uses const_cast to remove the constness of the argument so that it is compatible with the C function signature.
This is a cast that can be easily abused, so it is important to use it with caution. NEVER use this cast to just remove the constness of an object without having a legitimate justification. Constness is an important aspect of ensuring data integrity, so be sure to respect an object’s constness as much as possible.
reinterpret_cast
Perhaps the most dangerous cast in our arsenal is the reinterpret_cast. It is named such because that’s exactly what it does: it reinterprets the bytes at a given pointer location to the provided type without performing any compile-time checks to see if the cast is valid. This is useful for interfacing with certain C APIs or for inspecting the bytes of an object (usually by casting to a uint8_t pointer). This cast should be used with caution because there are no static or dynamic safety checks involved; the compiler trusts that you know what you are doing.
Let’s observe an example of this cast in action:
class Object { private: uint32_t data; float more_data; std::string message;};int main() { Object object; auto* bytes = reinterpret_cast<uint8_t*>(&object); for (auto i = 0; i < sizeof(Object); ++i) { printf("%02X ", bytes[i]); } return 0;}
In the above example, we create an instance of type Object, and we grab its address in memory and pass that address to reinterpret_cast. The cast reinterprets the data at the address as uint8_t, or individual bytes. We can then view the individual bytes by printing them to the terminal using the printf function.
std::bit_cast
Since C++20, the standard library provides the bit_cast function in the bit header.2 This cast exists so that the representation of one object can be converted to another, preserving the bit ordering. Essentially, every bit in the original object is mapped one-to-one in the new type representation. This is similar to a plain reinterpret_cast, but the bit ordering and representation is not guaranteed in a reinterpret_cast; that is, attempting to reinterpret_cast an integer to a double will not yield the desired result because the bit representation of a double follows the IEEE format. With the bit_cast, however, it is possible to cast an integer to a double because the bits are mapped in such a way that the representation is preserved. The caveat for bit_cast is that only types of the same size can be used for casting. Converting a single-precision float (32 bit) to a uint64_t is not allowed because they are different sizes.
This can be a little confusing to understand through explanation alone, so let’s view an example.
<bit> <cstdint> <iostream>constexpr double fp = 1337.67;constexpr auto u64 = std::bit_cast<uint64_t>(fp);int main() { std::cout << "Original floating point value: " << fp << "\nConverted to an integer representation: " << u64 << std::endl; return 0;}
The bits in the double-precision floating point are mapped to the bits in the 64-bit unsigned integer. While you might expect to see that the integer is just the double with rounding, you’d be mistaken. Each bit in the floating point is mapped to the corresponding bit in the integer, so in the example above, you’d see these values printed to the terminal:
Original floating point value: 1337.67Converted to an integer representation: 4653597950322860360
Conclusion
Type casting is a fundamental capability of the C++ type system and allows for easily converting between one type to another. There are several safe options for performing a type cast, and each has its own rules and use cases. Knowing how to correctly and safely perform a type cast is crucial to writing idiomatic, robust, and maintainable C++ code.