Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

5.3 Casts and Functions

Casting is one of the most troublesome operations in C/C++ because it instructs the compiler to bypass its type-checking system and trust the programmer. It should be used infrequently and only when no other solution is available. The two primary types of casting in object-oriented programming are upcasting and downcasting.

  • Upcasting is the conversion of a pointer or reference from a derived class to a base class. This is a natural and safe operation that happens automatically in C++, as a derived class is a more specialised version of its base class. Upcasting is fundamental to polymorphism, allowing you to treat an object of a derived type as if it were an object of a base type.
  • Downcasting is the reverse: converting a pointer or reference from a base class to a derived class. This operation is inherently unsafe because a base class pointer might not actually be pointing to a derived class object. You must be cautious with downcasting; a failure to do so can lead to serious errors. C++ offers special casting operators like dynamic_cast to perform downcasting safely with a runtime check.
Upcast-Downcast

Figure 1. Upcasting vs. Downcasting

Think of the class inheritance hierarchy as a tree with the base class at the top. Moving up the tree to the base class is upcasting, while moving down to a more specific, derived class is downcasting.

Cross-casting is a lesser-known form of casting that involves converting a pointer or reference from one derived class to another sibling class that shares the same base class. This is also considered an unsafe operation and typically requires dynamic_cast for a safe runtime check.

Concept Match

Match the Casting Directions

Quiz
Select 0/1

Why is downcasting inherently considered an unsafe operation in C++?

C++ introduced new, explicit casts to make casting safer, clearer, and more expressive by identifying the programmer’s intent. Unlike the older C-style cast (type), these new casts provide compile-time and even run-time checks, reducing errors and improving code readability.

  • static_cast: This cast is for all well-defined, “safe” conversions that the compiler can verify. It handles:
    • Automatic conversions: For example, converting an int to a float.
    • Narrowing conversions: Such as converting a float to an int (data loss may occur here).
    • Casting void*: Converting a void* back to its original pointer type.
    • Downcasting: Converting a base class pointer to a derived class pointer in cases where the inheritance hierarchy is non-polymorphic (i.e., has no virtual functions).
  • dynamic_cast: This is a type-safe downcast used exclusively for polymorphic classes (those with at least one virtual function). Its format is dynamic_cast<type>(expression). The dynamic_cast performs a crucial run-time check to ensure the cast is valid. If the cast fails (e.g., the base class pointer doesn’t actually point to the specified derived class object), it returns a nullptr for a pointer or throws an exception for a reference. This check also helps in resolving ambiguous casts in multiple inheritance scenarios.
  • const_cast: This cast is used specifically to add or remove const or volatile qualifiers from a variable. Its most common use is to remove const from a variable to allow it to be passed to a function that doesn’t accept const arguments.
  • reinterpret_cast: This is the most dangerous and least-used cast. It allows you to convert any pointer or integral type into any other pointer or integral type. It performs a simple bit-level reinterpretation without any type checking. It is intended for low-level, system-specific operations where you need to treat one type as another (e.g., converting a pointer to an integer for storage). Use it with extreme caution!

Here is an example of all four casts:

// Start reading from main() function to help make it easier to understand
#include <iostream>
#include <cstdint>
using namespace std;
// Demo classes with a virtual base class
class Account {
public:
float balance; // for demonstration only!
virtual ~Account() {}
};
class Current : public Account {};
class Test {
public:
float x, y;
};
// Demo Function
void someFunction(int& c) {
c++; // c can be modified
cout << "c has value " << c << "\n"; // will output 11
}
int main() {
float f = 200.6f;
// narrowing conversion, but we have notified the compiler
int x = static_cast<int>(f); // 1
cout << x << "\n";
Account* a = new Current; // upcast - cast derived to base
// type-safe downcast - cast base to derived
Current* c = dynamic_cast<Current*>(a); // 2
const int i = 10; // note i is const
// someFunction(i); // is an error as someFunction could modify the value of i
someFunction(*const_cast<int*>(&i)); // 3
// Allow i to be passed to the function but it will still remain at 10.
cout << "i has value " << i << "\n"; // will still output 10
a = new Account;
a->balance = 1000;
// convert account address to uintptr_t (64-bit safe)
uintptr_t addr = reinterpret_cast<uintptr_t>(a); // 4
// safe to convert uintptr_t address back into an Account pointer
Account* b = reinterpret_cast<Account*>(addr); // 5
cout << "The balance of b is " << b->balance << "\n";
// could convert to any class regardless of inheritance - ok this time! (not safe)
Current* cur = reinterpret_cast<Current*>(addr); // 6
cout << "The balance of cur is " << cur->balance << "\n";
// works, but definitely not safe this time!
Test* test = reinterpret_cast<Test*>(addr); // 7
cout << "The value of the float x is " << test->x
<< " and float y is " << test->y << "\n";
}

The output of this code (64-bit compiler) is:

200
c has value 11
i has value 10
The balance of b is 1000
The balance of cur is 1000
The value of the float x is -1.74315e-31 and float y is 4.59051e-41
  1. The static_cast here demonstrates the most common casting operation that we have previously performed using the C-style cast. In this case, we are simply converting a float into an int and notifying the compiler of our intention. Use a static_cast for all such conversions. In this example 200.6 is converted to 200 as output above.
  2. The dynamic_cast here demonstrates downcasting an Account object to a Current object. A dynamic_cast must be used when there are polymorphic base classes (those with virtual functions). In this example there is no output.
  3. The const_cast here takes the const i and allows it to be passed by reference to a function someFunction() that takes a non-constant parameter by reference. In this function we can treat the value just like a non-constant, but it has no impact on the original constant. As shown in the line directly above we would not have been allowed to pass this constant without the const_cast. Note: Attempting to modify a variable that was originally declared as const through a const_cast is Undefined Behaviour. While it may appear to work in some cases, the compiler is free to optimise based on the assumption that the constant never changes, which can lead to unpredictable results.
  4. The reinterpret_cast here converts the address of the Account object into a uintptr_t.
  5. The reinterpret_cast is used again, this time to convert a uintptr_t into a pointer to an Account object. This is dangerous as it is unchecked, but is perfectly appropriate in this case. The output shows a balance of 1000, so it worked correctly. On a 64-bit system a pointer is usually 64 bits wide, but a long is often only 32 bits (on Windows, for example). Casting a pointer to long therefore risks losing precision, and g++ warns (or errors) about it; therefore, the correct way in modern C++ is to use std::uintptr_t (an unsigned integer type guaranteed to be large enough to hold a pointer) from <cstdint>.
  6. The reinterpret_cast is then used to convert the same uintptr_t value into a Current object. Again it is fine in this case, but unchecked.
  7. Finally, the reinterpret_cast is used to do a very unsafe operation of converting the uintptr_t value into the address of a random Test object. This works perfectly, but it is totally inappropriate as you can see in the output the int value of 1000 has been converted to a float x, but the next block of random memory has also been assigned to the float y. Modifying y would cause unpredictable results, possibly crashing the application.
Code Cloze
C++

Applying Explicit C++ Casts

Quiz
Select 0/1

What is the result of a failed 'dynamic_cast' on a pointer type?

Quiz
Select 0/1

Is it safe to modify a variable that was originally declared as 'const' by using 'const_cast'?

Quiz
Select 0/1

When is it appropriate to use 'reinterpret_cast' in a C++ application?

Using explicit casts helps programmers identify potentially unsafe code because they clearly signal a break from normal type safety rules. This makes it easier to spot potential problems during debugging and code reviews. For example, explicit casts help debugging by providing:

  • Explicit Intent: Unlike C-style casts, which are a “catch-all,” C++ casts (static_cast, dynamic_cast, const_cast, reinterpret_cast) tell you why a cast is being performed. For example, const_cast immediately tells you that a const qualifier is being removed, a risky operation.
  • Compile-time and Run-time Checks: static_cast provides compile-time type checking, while dynamic_cast performs a crucial run-time check for polymorphic types. This prevents silent errors that could lead to crashes or incorrect behaviour. If a dynamic_cast fails, it returns a nullptr, which can be checked, allowing the program to handle the error gracefully instead of crashing.
  • Searching for “Danger Zones”: When debugging, a programmer can search for these specific cast keywords to quickly locate areas where the code is making an assumption about a type, which is a common source of bugs. The presence of a reinterpret_cast or const_cast often indicates a section of code that requires careful scrutiny.

note:::[IEEE 745] IEEE 754 is the universal technical standard for representing floating-point numbers in computers, allowing them to store and calculate extremely large or extremely small real numbers efficiently. It achieves this by dividing a binary sequence (typically 32 bits for single precision or 64 bits for double precision) into three distinct components: a single sign bit that indicates whether the number is positive (0) or negative (1), a biased exponent that defines the number’s magnitude or scale, and a mantissa (or fraction) that holds the significant precision digits. By interpreting these three parts, the hardware calculates the actual numeric value, which provides a standardised way to handle a vast dynamic range of values. There are special bit patterns for cases like zero, infinity, and NaN (Not a Number). This format is used across virtually all modern computing architectures. :::

std::bit_cast: Safe Type-Punning (C++20) Advanced

Section titled “std::bit_cast: Safe Type-Punning (C++20) Advanced”

The four casts above cover most scenarios, but one common pattern remained technically unsafe until C++20: type-punning (reading the raw bit representation of one type as another). This is a routine requirement in embedded and edge computing, for example inspecting the IEEE 754 binary encoding of a float, or packing sensor bytes into a typed struct.

Why reinterpret_cast is not the right tool here
The obvious approach is to take the address of the float, cast it to a uint32_t*, and dereference it, as follows:

float temperature = 23.5f;
// Looks reasonable — but this is Undefined Behaviour (strict aliasing violation):
uint32_t bits = *reinterpret_cast<uint32_t*>(&temperature);

C++‘s strict aliasing rule forbids reading the bytes of a float through a uint32_t*. In practice most compilers generate the expected code, but they are equally entitled to optimise the load away entirely, because the standard permits them to assume the two pointers never alias. The memcpy workaround is defined behaviour but verbose and not constexpr.

We can use std::bit_cast
std::bit_cast<To>(from), added in <bit> (C++20), copies the exact bit pattern of from into a new value of type To. It is well-defined, one line, and constexpr:

#include <bit>
#include <cstdint>
#include <iostream>
using namespace std;
int main() {
float temperature = 23.5f;
// Inspect the raw IEEE 754 bit pattern — well defined and constexpr
uint32_t bits = bit_cast<uint32_t>(temperature);
cout << hex << "Float " << temperature << " as bits: 0x" << bits << "\n";
// Round-trip: recover the float from its bit pattern
float recovered = bit_cast<float>(bits);
cout << dec << "Recovered: " << recovered << "\n";
}

This gives the output:

Float 23.5 as bits: 0x41bc0000
Recovered: 23.5

bit_cast enforces two constraints at compile time that reinterpret_cast ignores entirely:

  • sizeof(To) must equal sizeof(From) (a size mismatch is a compiler error).
  • Both types must be trivially copyable (types with non-trivial constructors or destructors (e.g., std::string) are rejected). Because it is constexpr, bit_cast can also appear inside constexpr functions, which reinterpret_cast cannot:
constexpr uint32_t float_bits(float f) {
return bit_cast<uint32_t>(f);
}
constexpr uint32_t one = float_bits(1.0f); // 0x3f800000 — evaluated at compile time

Use bit_cast whenever you need to reinterpret the bit pattern of a value as it gives you exactly what reinterpret_cast appears to give you, but with defined behaviour and compile-time verification.

Quiz
Select 0/1

Why is using reinterpret_cast to inspect the bit pattern of a float through a uint32_t pointer considered Undefined Behaviour in C++?

Quiz
Select 0/1

Which of the following would cause std::bit_cast to produce a compile-time error?

Parameters can be assigned default argument values, which must appear at the end of the parameter list. When a caller omits an argument for such a parameter, the default value is used automatically.

Example: Default Arguments

// amount has a default argument of 1
void decrement(int &aValue, int amount = 1) {
aValue = aValue - amount;
}
// Usage examples
int x = 10;
decrement(x, 3); // x now has the value 7
decrement(x); // x now has the value 6 (amount defaults to 1)

This example demonstrates that an argument for the amount parameter can be omitted, in which case the default value 1 is used. The full source code is provided in DefaultParameters.cpp.

Rules for Default Arguments in C++:

  • Default argument values must be specified in the function declaration or definition, but not both.
  • Parameters with default arguments must appear at the end of the parameter list.
  • If an argument is supplied for a parameter with a default, arguments must also be supplied for all preceding parameters.
  • Default arguments are evaluated at the call site, not at the point of definition.
  • Function overloading and default arguments can sometimes conflict; be careful to avoid ambiguity.

Rule of thumb: Default arguments are good practice for small, self-contained functions where the default behaviour is obvious. For complex APIs, prefer clear overloads instead.

Code Cloze
C++

Defining Default Arguments

Quiz
Select 0/1

Which of the following is a strict rule for using default arguments in C++ functions?

Constant Parameters
It is often good practice to prevent modification of function parameters where this is not intended. This is particularly important when parameters are passed by reference, as it avoids accidental changes to the original variable. In such cases, the const keyword should be used.

Example: Constant Parameters

// Example with a constant parameter
void decrement(const int &aValue, int amount = 1) {
aValue = aValue - amount; // ERROR: cannot modify a constant parameter
}

In this version, aValue is declared as a constant reference. Any attempt to modify it will cause a compile-time error. The full source code is available in ConstantParameters.cpp.

Of course, a decrement function that cannot decrement would be pointless, but this example illustrates how const protects parameters from modification.

Const-Qualified Methods \

The const keyword can also be applied to class methods to indicate that they do not modify the calling object. This ensures const safety, even if the method’s implementation does not explicitly change the object’s state.

Example: Const-Qualified Methods (constQualified.cpp):

class Number {
private:
int value;
public:
Number(int value) : value(value) {}
void a() const {} // const-qualified method
void b() {} // non-const method
void c() const; // const-qualified declaration
};
void Number::c() const {} // must also include const here
int main() {
Number n(5); // mutable object
n.b(); // allowed
const Number m(10); // const-qualified object
m.a(); // allowed (const method)
m.b(); // error: b() is not const-qualified
return 0;
}

Key rules:

  • Const and non-const methods can be called by non-const objects (mutable instances).
  • Only const-qualified methods can be called by const objects.
  • Even if a non-const method does not modify the object, it cannot be called by a const object unless explicitly marked const.
Concept Match

Match Const Safety Concepts

Quiz
Select 0/1

If an object is declared as 'const', what are the restrictions on calling its member methods?