Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C++  Module 8 – Inheritance and polymorphism

C++ casts and run-time type information (RTTI)

Choose the right C++ named cast, check downcasts with dynamic_cast and typeid, see what -fno-rtti removes, and catch bad casts with sanitizers.

  • Intermediate
  • 20 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Choose the named cast that matches each kind of conversion
  • Check a downcast with dynamic_cast and handle both kinds of failure
  • Explain what -fno-rtti removes and what still works without it
  • Find unsafe casts with -Wold-style-cast and UndefinedBehaviorSanitizer

Before you start

What you’ll learn · Before you start

What you will learn

  • Choose the named cast that matches each kind of conversion
  • Check a downcast with dynamic_cast and handle both kinds of failure
  • Explain what -fno-rtti removes and what still works without it
  • Find unsafe casts with -Wold-style-cast and UndefinedBehaviorSanitizer

Before you start

On this page

static_cast<Circle&>(shape).radius printed radius 2 for a square whose side is 2.0: the cast compiled on both compilers, ran without a word and read the wrong object. A cast converts a value or a pointer to another type on purpose, and each of the four named cast: One of the four cast operators static_cast, dynamic_cast, const_cast and reinterpret_cast. Each may perform only its own kind of conversion, so the compiler rejects one that would do something else, which a C-style cast would allow. of C++ is allowed one kind of conversion, so that the compiler can refuse the rest; the C-style cast (T)x tries several of them in turn, which hides mistakes.

You will choose the cast that says what a conversion does, check a downcast: A conversion of a pointer or a reference to a base class into one to a derived class, such as a Shape* to a Circle*. dynamic_cast checks it while the program runs; static_cast trusts it, and a wrong one is undefined behaviour. with dynamic_cast and the run-time type information (RTTI): The description of its own type that the compiler keeps for each class with virtual functions, which dynamic_cast's checks and typeid read while the program runs. With -fno-rtti GCC and Clang leave it out and reject those uses. (RTTI) it reads, see what -fno-rtti removes, and find the casts that compile but break the rules, with warnings and UndefinedBehaviorSanitizer. Every example runs on Apple clang 21 and GCC 16.

Each named cast is allowed one kind of conversion

This program makes correct use of static_cast, const_cast and reinterpret_cast, and of C++20’s std::bit_cast; the fourth named cast, dynamic_cast, which checks a conversion inside a class hierarchy while the program runs, has the next section to itself. The program is built with -Wold-style-cast, so a C-style cast anywhere in it would be reported:

named_casts.cpp C++ · named_casts.cpp
// One correct use of each named cast, and of std::bit_cast.
#include <bit>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <print>
#include <string>

enum class Grade { pass = 1, merit = 2, distinction = 3 };

// A C function from an old library: it only reads the text, but its parameter is not const.
std::size_t legacy_count_vowels(char* text) {
    std::size_t n = 0;
    for (; *text != '\0'; ++text) n += std::strchr("aeiouAEIOU", *text) != nullptr;
    return n;
}

int main() {
    // static_cast: an ordinary conversion, written down so that a reader sees it.
    double average = static_cast<double>(7) / 2;  // 3.5, not the 3 of integer division
    int whole = static_cast<int>(3.99);            // 3: the fraction is cut off
    int code = static_cast<int>(Grade::merit);     // a scoped enum does not convert on its own
    std::println("static_cast:      {} {} {}", average, whole, code);

    // const_cast: remove const to call an API that will not write through the pointer.
    const std::string word = "education";
    std::println("const_cast:       {} vowels", legacy_count_vowels(const_cast<char*>(word.c_str())));

    // reinterpret_cast: look at the bytes of an object. Reading them as unsigned char is allowed.
    const std::uint32_t value = 0x0A0B0C0D;
    const auto* bytes = reinterpret_cast<const unsigned char*>(&value);
    std::println("reinterpret_cast: bytes {:02x} {:02x} {:02x} {:02x} in memory", bytes[0], bytes[1], bytes[2], bytes[3]);

    // std::bit_cast (C++20): the same bits as another type, with no undefined behaviour.
    std::println("std::bit_cast:    1.0f is {:#010x}", std::bit_cast<std::uint32_t>(1.0f));
}
Built with -Wold-style-cast, without a warning Python · named_casts_session.py
from session import run

# named_casts.cpp built with -Wold-style-cast as well, and run with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra -Wold-style-cast named_casts.cpp -o named_casts && ./named_casts
g++-16 -std=c++23 -Wall -Wextra -Wold-style-cast named_casts.cpp -o named_casts-gcc && ./named_casts-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra -Wold-style-cast named_casts.cpp -o named_casts && ./named_casts
static_cast:      3.5 3 2
const_cast:       5 vowels
reinterpret_cast: bytes 0d 0c 0b 0a in memory
std::bit_cast:    1.0f is 0x3f800000
$ g++-16 -std=c++23 -Wall -Wextra -Wold-style-cast named_casts.cpp -o named_casts-gcc && ./named_casts-gcc
static_cast:      3.5 3 2
const_cast:       5 vowels
reinterpret_cast: bytes 0d 0c 0b 0a in memory
std::bit_cast:    1.0f is 0x3f800000

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 named_casts_session.py

Each line is a cast you will meet in real code. static_cast performs ordinary conversions: static_cast<double>(7) / 2 makes the division happen in floating point, and static_cast<int>(Grade::merit) gives a scoped enum’s number, 2. const_cast removes const: it lets a const std::string reach a C function whose parameter lacks const, which is safe only because that function never writes. reinterpret_cast treats a pointer as another pointer type or as a number: here as const unsigned char*, to show the bytes of a number in memory, lowest first on this byte order: The order in which the bytes of an integer are stored in memory: little-endian puts the least significant byte first, big-endian the most significant byte first. std::byteswap reverses the order, turning one into the other. More in “C++ bitwise operators and the <bit> header” computer, which the standard allows for any object. And when you want the bits of one type as another type, std::bit_cast copies them without breaking any rule: 1.0f is 0x3f800000. The four named casts, compared in pairs:

static_cast or dynamic_cast?
Criterion static_castdynamic_cast
Converts Numbers, enums, void* back to T*, base to derivedPointers and references in a class hierarchy
Checked When compilingWhile the program runs
Use it for Making a conversion visibleA downcast that may fail
Breaks when A downcast meets another classRTTI is turned off
const_cast or reinterpret_cast?
Criterion const_castreinterpret_cast
Converts Only const and volatilePointers to other pointer types or integers
Checked When compilingOnly that both are pointers or integers
Use it for Calling an old API that will not writeLooking at raw bytes
Breaks when Something writes to a const objectThe pointer is used as the wrong type
IEEE 754 Floating-Point Converter Enter 1.0 and choose single precision to see the same bit pattern, 0x3f800000, bit by bit.

When a conversion needs a cast, ask five questions in order, and stop at the first yes:

Five questions in order lead to static_cast, dynamic_cast, const_cast, std::bit_cast or reinterpret_cast; never a C-style cast.1. A value as another type,such as int to double? yes: static_cast2. A base pointer or referenceto a derived class? yes: dynamic_cast, which checks(static_cast only when certain)3. Remove const for codethat will not write? yes: const_cast4. The same bits seenas another type? yes: std::bit_cast5. Raw bytes, or anaddress as a number? yes: reinterpret_castRethink the design.A C-style cast, (T)x,is never the answer.nonononono

Choosing the cast that says what a conversion does

Text description of the diagram

The diagram is a column of five questions, asked from top to bottom. Each box names the cast to write when the answer is yes; an arrow labelled "no" leads to the next question.

  1. Do you need a value as another type, such as an int as a double? Yes: static_cast.
  2. Do you need to turn a pointer or reference to a base class into one to a derived class? Yes: dynamic_cast, which checks the object's real type; static_cast only when the type is certain.
  3. Do you need to remove const to call code that will not write through the pointer? Yes: const_cast.
  4. Do you need the same bits seen as another type, such as the bits of a float as an integer? Yes: std::bit_cast.
  5. Do you need the raw bytes of an object, or an address as a number? Yes: reinterpret_cast.
  6. If every answer is no, the last arrow leads to "Rethink the design. A C-style cast, (T)x, is never the answer."

Checkpoint

Check: What each named cast may do

  1. Question 1 of 2 enum class Grade { pass = 1, merit = 2, distinction = 3 }; Which line gives the number of Grade::merit, 2?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: int code = static_cast<int>(Grade::merit);

    static_cast performs the ordinary, value-preserving conversions, written down so that a reader sees them: double to int, int to double, a scoped enum to its number.

  2. Question 2 of 2 Which expression gives 3.5?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: static_cast<double>(7) / 2

    The cast has to apply to an operand before the division. Each named cast does one kind of conversion, and a value conversion is static_cast's.

A checked downcast asks the object’s real type

A downcast turns a pointer or reference to a base class into one to a derived class. dynamic_cast checks it while the program runs, against the object’s dynamic type: The class of the object an expression refers to while the program runs: a Shape* that points to a Circle reaches an object of the dynamic type Circle. Calls to virtual functions are chosen from it. More in “Virtual functions, override and virtual destructors”, so it works only for classes with at least one virtual function:

downcast.cpp C++ · downcast.cpp
// Checked downcasts: dynamic_cast to a pointer gives nullptr when the object is of another class, and dynamic_cast
// to a reference throws std::bad_cast. typeid names the dynamic type.
#include <memory>
#include <print>
#include <typeinfo>
#include <vector>

struct Shape {
    virtual ~Shape() = default;
};
struct Circle : Shape {
    double radius = 0.5;
};
struct Square : Shape {
    double side = 2.0;
};

int main() {
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>());
    shapes.push_back(std::make_unique<Square>());

    for (const auto& owner : shapes) {
        Shape& shape = *owner;
        if (auto* circle = dynamic_cast<Circle*>(&shape)) std::println("a circle, radius {}", circle->radius);
        else std::println("not a circle: typeid says {}", typeid(shape).name());
    }

    try {
        Circle& circle = dynamic_cast<Circle&>(*shapes[1]);
        std::println("radius {}", circle.radius);
    } catch (const std::bad_cast& e) {
        std::println("dynamic_cast to Circle& threw std::bad_cast: {}", e.what());
    }
    const Shape& first = *shapes[0];
    std::println("first is exactly a Circle: {}", typeid(first) == typeid(Circle));
}
Both kinds of failure, on both compilers Python · downcast_session.py
from session import run

# downcast.cpp built and run with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra downcast.cpp -o downcast && ./downcast
g++-16 -std=c++23 -Wall -Wextra downcast.cpp -o downcast-gcc && ./downcast-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra downcast.cpp -o downcast && ./downcast
a circle, radius 0.5
not a circle: typeid says 6Square
dynamic_cast to Circle& threw std::bad_cast: std::bad_cast
first is exactly a Circle: true
$ g++-16 -std=c++23 -Wall -Wextra downcast.cpp -o downcast-gcc && ./downcast-gcc
a circle, radius 0.5
not a circle: typeid says 6Square
dynamic_cast to Circle& threw std::bad_cast: std::bad_cast
first is exactly a Circle: true

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 downcast_session.py

Step through the loop and the cast to a reference, and watch where each answer comes from:

Interactive · message by message dynamic_cast asks each object what it is Step through the loop over the two shapes, then the cast to a reference. The middle column is the object being cast; the right column is the type information its vptr leads to.
dynamic_cast asks each object what it is, step 7 of 7. Messages so far: main to the object: dynamic_cast<Circle*>; the object to its type information: follow the vptr; its type information to main: a Circle*; its type information to main: nullptr; main to its type information: typeid(shape).name(); its type information to main: throws std::bad_cast; main to its type information: typeid(first).maincasts a Shape&the objectshapes[0], a Circleits type informationsays Circledynamic_cast<Circle*>follow the vptra Circle*nullptrtypeid(shape).name()throws std::bad_casttypeid(first)

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Is it a Circle?. The loop casts its first Shape& with dynamic_cast<Circle*>. The cast does not trust the static type: it looks at the object itself.
  2. The vptr leads to the answer. With GCC and Clang the object's vptr leads to its class's virtual table, and the table holds a pointer to the type information of that class: Circle.
  3. A Circle*. The object is a Circle, so the cast returns a pointer to it, and the program prints a circle, radius 0.5.
  4. The second shape. The same cast on shapes[1] finds the type information of Square. A Square is not a Circle, so the cast to a pointer gives nullptr, and the if goes to its else.
  5. typeid names the type. The else asks typeid(shape).name(). It reads the same type information and prints 6Square, the class name with its length in front, as both compilers write names.
  6. A reference cannot be null. dynamic_cast<Circle&>(*shapes[1]) fails the same check, and a reference has no null value to give, so the cast throws std::bad_cast, which the program catches.
  7. Exactly a Circle?. typeid(first) == typeid(Circle) compares the dynamic type of the first shape with Circle. They are the same class, so the program prints true.

A failed dynamic_cast to a pointer gives a null pointer, so the first loop can ask “is this a circle?” and try something else. A reference cannot be null, so a failed cast to a reference throws std::bad_cast. Rules C.147 and C.148 of the C++ Core Guidelines turn that into a choice: cast to a reference when failure means a bug, to a pointer when it is a valid answer.

typeid(shape) returns a std::type_info for the dynamic type, and comparing two of them tells whether an object is exactly a given class. Its name() is not portable: both compilers print 6Square, the class name with its length in front, because both encode names as the Itanium C++ ABI describes.

Testing for a family of classes with typeid

typeid(shape) == typeid(Circle) is true only for an object whose class is exactly Circle. For a class derived from Circle it is false, while dynamic_cast<Circle*> still succeeds: use the cast when any kind of circle will do.

Every dynamic_cast and typeid asks a question that a virtual function could often answer without one. Rule C.153 of the Guidelines says to prefer a virtual function to casting, and rule C.146 keeps dynamic_cast for the cases where navigating the hierarchy is unavoidable, such as an object handed back by a plugin or a library.

Checkpoint

Check: Checked downcasts

  1. Question 1 of 2 shapes[1] holds a Square. What does dynamic_cast<Circle&>(*shapes[1]) do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It throws std::bad_cast

    A failed dynamic_cast gives nullptr for a pointer and throws std::bad_cast for a reference. Rules C.147 and C.148 turn that into a choice between the two forms.

  2. Question 2 of 2 struct Disc : Circle {}; and shape is a Shape& that refers to a Disc. Which statement is true?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: typeid(shape) == typeid(Circle) is false, but dynamic_cast<Circle*>(&shape) is not nullptr

    Use dynamic_cast when any kind of circle will do, and typeid only when the class must be exactly one class.

Without RTTI, checked downcasts and typeid stop compiling

Some projects turn RTTI off to save the space the type information takes. The LLVM compiler is one: its coding standards rule out RTTI and exceptions to keep its code and its executables smaller. Built with -fno-rtti, the same downcast.cpp stops compiling, and this second program shows what still works:

without_rtti.cpp C++ · without_rtti.cpp
// What still works when a program is built with -fno-rtti: upcasts, casts to void* and exceptions. Built with
// -DTYPEID_INT, it also asks for the type_info of int.
#include <print>
#include <stdexcept>
#include <typeinfo>

struct Shape {
    virtual ~Shape() = default;
};
struct Circle : Shape {
    double radius = 0.5;
};

int main() {
    Circle c;
    Shape* up = dynamic_cast<Shape*>(&c);         // to a base class: no run-time check is needed
    void* whole = dynamic_cast<void*>(up);        // to void*: the address of the complete object
    std::println("upcast works: {}, cast to void* works: {}", up == &c, whole == &c);
    try {
        throw std::runtime_error("thrown and caught");
    } catch (const std::exception& e) {
        std::println("exceptions work: {}", e.what());
    }
#ifdef TYPEID_INT
    std::println("{}", typeid(int).name());
#endif
}
Errors, what still works, and typeid of int Python · no_rtti_session.py
from session import run

# downcast.cpp again, built with -fno-rtti; then what does work without RTTI, and typeid of int.
run("""
clang++ -std=c++23 -fno-rtti -c downcast.cpp
g++-16 -std=c++23 -fno-rtti -c downcast.cpp
clang++ -std=c++23 -fno-rtti without_rtti.cpp -o without_rtti && ./without_rtti
g++-16 -std=c++23 -fno-rtti without_rtti.cpp -o without_rtti-gcc && ./without_rtti-gcc
clang++ -std=c++23 -fno-rtti -DTYPEID_INT -c without_rtti.cpp
g++-16 -std=c++23 -fno-rtti -DTYPEID_INT -c without_rtti.cpp
""", max_lines=14)

Output

$ clang++ -std=c++23 -fno-rtti -c downcast.cpp
downcast.cpp:25:28: error: use of dynamic_cast requires -frtti
   25 |         if (auto* circle = dynamic_cast<Circle*>(&shape)) std::println("a circle, radius {}", circle->radius);
      |                            ^
downcast.cpp:26:59: error: use of typeid requires -frtti
   26 |         else std::println("not a circle: typeid says {}", typeid(shape).name());
      |                                                           ^
downcast.cpp:30:26: error: use of dynamic_cast requires -frtti
   30 |         Circle& circle = dynamic_cast<Circle&>(*shapes[1]);
      |                          ^
downcast.cpp:36:51: error: use of typeid requires -frtti
   36 |     std::println("first is exactly a Circle: {}", typeid(first) == typeid(Circle));
      |                                                   ^
downcast.cpp:36:68: error: use of typeid requires -frtti
   36 |     std::println("first is exactly a Circle: {}", typeid(first) == typeid(Circle));
[2 more lines]
[exit status 1]
$ g++-16 -std=c++23 -fno-rtti -c downcast.cpp
downcast.cpp: In function 'int main()':
downcast.cpp:25:28: error: 'dynamic_cast' not permitted with '-fno-rtti'
   25 |         if (auto* circle = dynamic_cast<Circle*>(&shape)) std::println("a circle, radius {}", circle->radius);
      |                            ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~
downcast.cpp:26:66: error: cannot use 'typeid' with '-fno-rtti'
   26 |         else std::println("not a circle: typeid says {}", typeid(shape).name());
      |                                                                  ^~~~~
downcast.cpp:30:26: error: 'dynamic_cast' not permitted with '-fno-rtti'
   30 |         Circle& circle = dynamic_cast<Circle&>(*shapes[1]);
      |                          ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
downcast.cpp:36:58: error: cannot use 'typeid' with '-fno-rtti'
   36 |     std::println("first is exactly a Circle: {}", typeid(first) == typeid(Circle));
      |                                                          ^~~~~
downcast.cpp:36:81: error: cannot use 'typeid' with '-fno-rtti'
[2 more lines]
[exit status 1]
$ clang++ -std=c++23 -fno-rtti without_rtti.cpp -o without_rtti && ./without_rtti
upcast works: true, cast to void* works: true
exceptions work: thrown and caught
$ g++-16 -std=c++23 -fno-rtti without_rtti.cpp -o without_rtti-gcc && ./without_rtti-gcc
upcast works: true, cast to void* works: true
exceptions work: thrown and caught
$ clang++ -std=c++23 -fno-rtti -DTYPEID_INT -c without_rtti.cpp
without_rtti.cpp:25:24: error: use of typeid requires -frtti
   25 |     std::println("{}", typeid(int).name());
      |                        ^
1 error generated.
[exit status 1]
$ g++-16 -std=c++23 -fno-rtti -DTYPEID_INT -c without_rtti.cpp
without_rtti.cpp: In function 'int main()':
without_rtti.cpp:25:34: error: cannot use 'typeid' with '-fno-rtti'
   25 |     std::println("{}", typeid(int).name());
      |                                  ^
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 no_rtti_session.py

Each compiler rejects every dynamic_cast that needs a run-time check and every typeid. GCC’s manual explains that a dynamic_cast that needs no type information still works, and the session shows it on both compilers: the upcast: A conversion of a pointer or a reference to a derived class into one to a base class, such as a Circle* to a Shape*. For a public base it is implicit and always safe, and it needs no check while the program runs. to Shape*, and the cast to void*, which returns the address of the complete object. Exceptions work too; GCC’s manual says it generates the type information that exception handling needs, and the Clang build behaved the same. Both compilers reject even typeid(int), whose answer is known when compiling. The session, line by line:

What a build with -fno-rtti keeps, on both compilers
Criterion With RTTIWith -fno-rtti
dynamic_cast<Circle*>(&shape) Checks the object while runningError: it needs the check
typeid(shape), typeid(int) The type informationError, even for int
dynamic_cast<Shape*>(&c) An upcast: worksWorks: no check needed
dynamic_cast<void*>(up) The complete object's addressWorks
throw and catch WorksWorks

Code meant for builds without RTTI therefore uses virtual functions, or a kind() function of its own, instead of dynamic_cast.

Checkpoint

Check: Builds without RTTI

  1. Question 1 of 2 A file is built with -fno-rtti. Which lines still compile with both compilers?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • Shape* up = dynamic_cast<Shape*>(&c); // c is a Circle
    • void* whole = dynamic_cast<void*>(up);
    • throw std::runtime_error("thrown and caught");

    -fno-rtti removes the type information, so everything that reads it, a downcast's check and typeid, stops compiling; casts that need none, and exceptions, still work.

  2. Question 2 of 2 Why do the upcast and the cast to void* still compile with -fno-rtti?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Neither needs the type information that -fno-rtti removes

    RTTI is the description of each class that a downcast's check and typeid read. A cast that never reads it, such as an upcast or a cast to void*, works without it.

An unchecked downcast reads the wrong object

static_cast can also downcast, faster than dynamic_cast, because it checks nothing while the program runs:

unchecked_downcast.cpp C++ · unchecked_downcast.cpp
// static_cast downcasts without checking: radius_of() trusts that every Shape it gets is a Circle.
#include <print>

struct Shape {
    virtual ~Shape() = default;
};
struct Circle : Shape {
    double radius = 0.5;
};
struct Square : Shape {
    double side = 2.0;
};

double radius_of(Shape& shape) { return static_cast<Circle&>(shape).radius; }

int main() {
    Square tile;
    std::println("radius {}", radius_of(tile));  // tile is not a Circle: undefined behaviour
}
One bad downcast, four builds (addresses masked) Python · unchecked_downcast_session.py
from session import run

# unchecked_downcast.cpp without sanitizers, with -fsanitize=undefined, and with -fsanitize=vptr. Addresses change on
# every run, so they are shown as 0x…, and the memory dump under each report is left out.
run("""
clang++ -std=c++23 -Wall -Wextra unchecked_downcast.cpp -o plain && ./plain
clang++ -std=c++23 -fsanitize=undefined unchecked_downcast.cpp -o ub && ./ub
clang++ -std=c++23 -fsanitize=undefined,vptr unchecked_downcast.cpp -o vptr && ./vptr
g++-16 -std=c++23 -fsanitize=undefined unchecked_downcast.cpp -o ub-gcc && ./ub-gcc
""", mask_addresses=True)

Output

$ clang++ -std=c++23 -Wall -Wextra unchecked_downcast.cpp -o plain && ./plain
radius 2
$ clang++ -std=c++23 -fsanitize=undefined unchecked_downcast.cpp -o ub && ./ub
radius 2
$ clang++ -std=c++23 -fsanitize=undefined,vptr unchecked_downcast.cpp -o vptr && ./vptr
unchecked_downcast.cpp:14:41: runtime error: downcast of address 0x… which does not point to an object of type 'Circle'
0x…: note: object is of type 'Square'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior unchecked_downcast.cpp:14:41 
unchecked_downcast.cpp:14:41: runtime error: member access within address 0x… which does not point to an object of type 'Circle'
0x…: note: object is of type 'Square'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior unchecked_downcast.cpp:14:41 
radius 2
$ g++-16 -std=c++23 -fsanitize=undefined unchecked_downcast.cpp -o ub-gcc && ./ub-gcc
unchecked_downcast.cpp:14:69: runtime error: member access within address 0x… which does not point to an object of type 'Circle'
0x…: note: object is of type 'Square'
radius 2

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 unchecked_downcast_session.py

The program “works”: it prints radius 2, which is the square’s side read as if it were a circle’s radius, because both members happen to sit at the same offset. Step through what the cast makes the program read:

Interactive · step by step static_cast<Circle&> applied to a Square Step through radius_of(tile). The left column holds the call, the object that exists and what is printed; the right column holds the cast and what it tells the compiler to read.
static_cast<Circle&> applied to a Square, step 5 of 5. Showing radius_of(tile), static_cast<Circle&>, tile, a Square, What a Circle holds, radius 2, The vptr check. In focus: The vptr check.offset 8reads sideradius_of(tile)gets a Shape&static_cast<Circle&>checks nothing nowtile, a Squarevptr, then side = 2.0What a Circle holdsvptr, then radiusradius 2printed by every buildThe vptr checkGCC: on; Clang: add vptr

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A Shape& to a Square. main passes tile, a Square, to radius_of(), which takes a Shape&. Inside the function nothing says which class the object has.
  2. Checked when compiling only. static_cast<Circle&>(shape) compiles because Circle derives from Shape. That is all it checks: it never looks at the object while the program runs.
  3. Where a Circle keeps its radius. .radius is read where a Circle keeps it, 8 bytes in, after the vptr. In tile those 8 bytes hold side, 2.0.
  4. A plausible wrong number. Every build prints radius 2. Reading tile as a Circle is undefined behaviour, and a quiet wrong value is a likely symptom.
  5. Only the vptr check sees it. UndefinedBehaviorSanitizer's vptr check compares the object's vptr with Circle and reports the access. GCC's -fsanitize=undefined includes it; Clang needs -fsanitize=undefined,vptr.

That is undefined behaviour: What a program does when it breaks a rule for which the C++ standard sets no requirements, such as reading past the end of an array. The compiler may assume it never happens, so the result can change with the compiler and the optimisation level. More in “Object lifetime and undefined behaviour in C++”, and a quiet wrong number is a likely symptom. The session replaces the addresses in the reports with 0x…, because they change on every run, and leaves out the memory dump that follows each report. The four builds show a difference between the compilers that is easy to miss:

What each build of unchecked_downcast.cpp reported
Criterion ReportedPrinted
clang++, no sanitizer Nothingradius 2
clang++ -fsanitize=undefined Nothingradius 2
clang++ -fsanitize=undefined,vptr The downcast and the member accessradius 2
g++-16 -fsanitize=undefined The member accessradius 2

GCC includes the vptr check of its sanitizer: A compiler option, such as -fsanitize=address or -fsanitize=undefined, that builds extra checks into a program, so that memory errors and undefined behaviour are reported, with their source line, while it runs. More in “Finding memory bugs with AddressSanitizer and UBSan”, which compares the object’s vptr: The hidden pointer that GCC and Clang store in each object of a class with virtual functions. It points to the virtual table of the object's class and takes 8 bytes on a 64-bit computer, however many virtual functions the class has. More in “Virtual functions, override and virtual destructors” with the type of the cast, in -fsanitize=undefined, and reports the bad access. Clang’s documentation says that vptr is not part of its undefined group, and indeed -fsanitize=undefined alone printed nothing; with -fsanitize=undefined,vptr Clang reports both the downcast and the member access. Use static_cast for a downcast only when the type is certain, and add vptr to Clang’s sanitizer builds.

Checkpoint

Check: Unchecked downcasts

  1. Question 1 of 2 Why did radius_of(tile) print radius 2 on every build?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The cast made the program read the square's side where a Circle keeps its radius, 8 bytes in

    An unchecked downcast to the wrong class is undefined behaviour, and here it printed a plausible wrong number, the most dangerous kind of symptom.

  2. Question 2 of 2 Which build of unchecked_downcast.cpp reported nothing at all?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: clang++ -fsanitize=undefined

    Add vptr to Clang's sanitizer builds when code downcasts with static_cast; with GCC, -fsanitize=undefined already includes it.

Casting away const cannot make an object writable

const_cast changes the type of a pointer or reference, not the object it refers to:

const_violation.cpp C++ · const_violation.cpp
// const_cast removes const from a pointer, but it cannot make a const object writable.
#include <print>

int main() {
    const int limit = 10;
    int* p = const_cast<int*>(&limit);  // compiles
    *p = 20;                            // undefined behaviour: limit was defined const
    int copy = limit;
    std::println("*p is {}, a copy of limit is {}", *p, copy);
}
One object, two values Python · const_violation_session.py
from session import run

# const_violation.cpp without optimisation and with -O2, on both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra const_violation.cpp -o cv && ./cv
clang++ -std=c++23 -Wall -Wextra -O2 const_violation.cpp -o cv-o2 && ./cv-o2
g++-16 -std=c++23 -Wall -Wextra const_violation.cpp -o cv-gcc && ./cv-gcc
g++-16 -std=c++23 -Wall -Wextra -O2 const_violation.cpp -o cv-gcc-o2 && ./cv-gcc-o2
""")

Output

$ clang++ -std=c++23 -Wall -Wextra const_violation.cpp -o cv && ./cv
*p is 20, a copy of limit is 10
$ clang++ -std=c++23 -Wall -Wextra -O2 const_violation.cpp -o cv-o2 && ./cv-o2
*p is 20, a copy of limit is 10
$ g++-16 -std=c++23 -Wall -Wextra const_violation.cpp -o cv-gcc && ./cv-gcc
*p is 20, a copy of limit is 10
$ g++-16 -std=c++23 -Wall -Wextra -O2 const_violation.cpp -o cv-gcc-o2 && ./cv-gcc-o2
*p is 20, a copy of limit is 10

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 const_violation_session.py

Writing through p to limit, an object defined const, is undefined behaviour. All four builds show why: *p reads 20, yet a copy of limit is 10. Step through the lines of main:

Interactive · step by step One const int, two values Step through the lines of main. The left column holds the two int objects; the right column holds the pointer and what the program prints.
One const int, two values, step 5 of 5. Showing limit (*p now reads 20), int* p, copy (int, 10), The printed line (*p is 20, copy is 10). In focus: The printed line.points tocopiedlimit*p now reads 20int* pfrom const_cast<int*>copyint, 10The printed line*p is 20, copy is 10

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A constant. const int limit = 10; defines an object that can never change, so the compilers may use 10 wherever the program reads limit.
  2. A pointer without const. const_cast<int*>(&limit) compiles: it changes the type of the pointer, not the object. p is an int* that points to a const object.
  3. The write. *p = 20; writes to an object defined const, which is undefined behaviour. In these builds the store happened: *p reads 20 afterwards.
  4. The copy. int copy = limit; gets 10: the compilers used the value a const int initialised with 10 must hold, not what the memory holds now.
  5. Two values for one object. All four builds, with and without -O2, print *p is 20, a copy of limit is 10. Undefined behaviour gave one object two values.

The compilers know that a const int initialised with 10 can never change, so they use 10 wherever limit appears. Rule ES.50 of the C++ Core Guidelines says not to cast away const. The legitimate use is the one in named_casts.cpp: an old API whose parameter lacks const although it never writes. What counts is how the object itself was defined: a std::string that is not const may be changed through a const_cast of a const reference to it, because only the reference was const.

Checkpoint

Check: Casting away const

  1. Question 1 of 2 const int limit = 10; then *const_cast<int*>(&limit) = 20;. What did int copy = limit; hold in all four builds?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 10

    Modifying an object defined const is undefined behaviour, and here it gave one object two values: 20 through p, 10 everywhere else.

  2. Question 2 of 2 Which use of const_cast is defined behaviour?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Passing const_cast<char*>(word.c_str()) to an old C function that only reads the text

    const_cast is safe only when nothing writes to an object that was defined const. Rule ES.50 of the Guidelines says not to cast away const at all where the design allows it.

Reinterpreting a pointer never moves it to a base

The previous lesson showed that the second base of a class sits at an offset inside the object. static_cast knows that offset; reinterpret_cast does not:

adjust.cpp C++ · adjust.cpp
// static_cast to a second base class moves the pointer to that base's part; reinterpret_cast keeps the address and
// only pretends. Nothing is called through the wrong pointer: that would be undefined behaviour.
#include <cstddef>
#include <print>
#include <string>

struct Speaker {
    virtual ~Speaker() = default;
    virtual void play(const std::string& sound) { std::println("plays {}", sound); }
};
struct Microphone {
    virtual ~Microphone() = default;
    virtual std::string record() { return "a voice note"; }
};
struct Headset : Speaker, Microphone {};

// How far b is from a, in bytes.
std::ptrdiff_t distance(const void* a, const void* b) {
    return static_cast<const std::byte*>(b) - static_cast<const std::byte*>(a);
}

int main() {
    Headset h;
    auto* by_static = static_cast<Microphone*>(&h);
    auto* by_reinterpret = reinterpret_cast<Microphone*>(&h);
    std::println("static_cast:      the Microphone part is {} bytes into the Headset", distance(&h, by_static));
    std::println("reinterpret_cast: it points {} bytes in, at the Speaker part", distance(&h, by_reinterpret));
    std::println("static_cast result works: {}", by_static->record());
}
Where each cast points Python · adjust_session.py
from session import run

# adjust.cpp built and run with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra adjust.cpp -o adjust && ./adjust
g++-16 -std=c++23 -Wall -Wextra adjust.cpp -o adjust-gcc && ./adjust-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra adjust.cpp -o adjust && ./adjust
adjust.cpp:25:28: warning: 'reinterpret_cast' from class 'Headset *' to its base at non-zero offset 'Microphone *' behaves differently from 'static_cast' [-Wreinterpret-base-class]
   25 |     auto* by_reinterpret = reinterpret_cast<Microphone*>(&h);
      |                            ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
adjust.cpp:25:28: note: use 'static_cast' to adjust the pointer correctly while upcasting
   25 |     auto* by_reinterpret = reinterpret_cast<Microphone*>(&h);
      |                            ^~~~~~~~~~~~~~~~
      |                            static_cast
1 warning generated.
static_cast:      the Microphone part is 8 bytes into the Headset
reinterpret_cast: it points 0 bytes in, at the Speaker part
static_cast result works: a voice note
$ g++-16 -std=c++23 -Wall -Wextra adjust.cpp -o adjust-gcc && ./adjust-gcc
static_cast:      the Microphone part is 8 bytes into the Headset
reinterpret_cast: it points 0 bytes in, at the Speaker part
static_cast result works: a voice note

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 adjust_session.py

Step through the two casts and the pointer each one gives:

Interactive · step by step Two casts of &h to Microphone* Step through the two casts. The left column is the Headset object, part by part; the right column holds the pointer each cast gives.
Two casts of &h to Microphone*, step 4 of 4. Showing Speaker part, Microphone part, by_reinterpret, by_static. In focus: by_reinterpret.points herepoints hereSpeaker partoffset 0Microphone partoffset 8by_reinterpretfrom reinterpret_castby_staticfrom static_cast

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. One Headset. Headset h; holds a Speaker part at offset 0 and a Microphone part at offset 8, as in the previous lesson.
  2. static_cast moves the pointer. static_cast<Microphone*>(&h) knows the layout of Headset, so it adds 8: by_static points at the Microphone part, and calling record() through it works.
  3. reinterpret_cast does not. reinterpret_cast<Microphone*>(&h) keeps the address as it is, 0 bytes in. by_reinterpret claims to point at a Microphone but points at the Speaker part.
  4. Only Clang warns. Clang 21 warns by default, with -Wreinterpret-base-class, and proposes static_cast; GCC 16 says nothing. A call through by_reinterpret would be undefined behaviour, so the program never makes one.

static_cast<Microphone*> moves the pointer 8 bytes to the Microphone part. reinterpret_cast<Microphone*> keeps the address of the Speaker part, so a call through it would run on the wrong object. Clang 21 warns about this cast by default, with -Wreinterpret-base-class; GCC 16 says nothing. Keep reinterpret_cast for raw bytes and addresses, and use std::bit_cast when you want the value of one type as another: reading a float through a std::uint32_t*, the old way to get its bits, breaks the aliasing rule: The rule that an object may be read only through its own type, its signed or unsigned twin, or a byte type such as unsigned char or std::byte. Optimisers rely on it, so reading a float through a std::uint32_t* is undefined behaviour. More in “Memory layout: sizeof, alignment and padding”.

Checkpoint

Check: What reinterpret_cast does not do

  1. Question 1 of 2 Headset derives from Speaker, then Microphone. How many bytes into the Headset does reinterpret_cast<Microphone*>(&h) point?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 0 bytes

    reinterpret_cast keeps the address as it is, so it points at the Speaker part, 0 bytes in, while static_cast moved its pointer 8 bytes to the Microphone part.

  2. Question 2 of 2 float f = 1.0f; Which line gives its bits, 0x3f800000, with no undefined behaviour?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: auto bits = std::bit_cast<std::uint32_t>(f);

    Keep reinterpret_cast for raw bytes and addresses. The bits of one type as another type are std::bit_cast's job, since C++20.

A C-style cast picks whichever cast compiles

The standard lists what a C-style cast: The cast notation (T)x that C++ took over from C. It performs the first of const_cast, static_cast, static_cast then const_cast, reinterpret_cast and reinterpret_cast then const_cast that can express the conversion, even to a private base. (T)x may be: a const_cast, a static_cast, a static_cast followed by a const_cast, a reinterpret_cast, or a reinterpret_cast followed by a const_cast. The first of them that can express the conversion is the one you get, and its static_cast may even convert to a base class that is private.

c_style.cpp C++ · c_style.cpp
// Two C-style casts that compile without a word. Built with -DNAMED, the same conversions are written as
// static_cast, and the compiler checks them.
struct Account {
    long balance = 50'000;
};

class FrozenAccount : private Account {};  // private: nobody outside may treat it as an Account

struct Shape {
    virtual ~Shape() = default;
};
struct Circle : Shape {};
struct Square : Shape {};

#ifdef NAMED
Account* unfreeze(FrozenAccount* f) { return static_cast<Account*>(f); }
Square* as_square(Circle* c) { return static_cast<Square*>(c); }
#else
Account* unfreeze(FrozenAccount* f) { return (Account*)f; }  // ignores the private base
Square* as_square(Circle* c) { return (Square*)c; }         // a Circle is never a Square
#endif
Silent, then warned, then rejected Python · c_style_session.py
from session import run

# c_style.cpp compiled as written, then with -Wold-style-cast, then with the casts written as static_cast.
run("""
clang++ -std=c++23 -Wall -Wextra -c c_style.cpp
g++-16 -std=c++23 -Wall -Wextra -c c_style.cpp
clang++ -std=c++23 -Wall -Wextra -Wold-style-cast -c c_style.cpp
g++-16 -std=c++23 -Wall -Wextra -Wold-style-cast -c c_style.cpp
clang++ -std=c++23 -Wall -Wextra -DNAMED -c c_style.cpp
g++-16 -std=c++23 -Wall -Wextra -DNAMED -c c_style.cpp
""", max_lines=12)

Output

$ clang++ -std=c++23 -Wall -Wextra -c c_style.cpp
$ g++-16 -std=c++23 -Wall -Wextra -c c_style.cpp
$ clang++ -std=c++23 -Wall -Wextra -Wold-style-cast -c c_style.cpp
c_style.cpp:19:46: warning: use of old-style cast [-Wold-style-cast]
   19 | Account* unfreeze(FrozenAccount* f) { return (Account*)f; }  // ignores the private base
      |                                              ^         ~
c_style.cpp:20:39: warning: use of old-style cast [-Wold-style-cast]
   20 | Square* as_square(Circle* c) { return (Square*)c; }         // a Circle is never a Square
      |                                       ^        ~
2 warnings generated.
$ g++-16 -std=c++23 -Wall -Wextra -Wold-style-cast -c c_style.cpp
c_style.cpp: In function 'Account* unfreeze(FrozenAccount*)':
c_style.cpp:19:56: warning: use of old-style cast to 'struct Account*' [-Wold-style-cast]
   19 | Account* unfreeze(FrozenAccount* f) { return (Account*)f; }  // ignores the private base
      |                                                        ^
      |                                              -----------
      |                                              reinterpret_cast<Account*> (f)
c_style.cpp: In function 'Square* as_square(Circle*)':
c_style.cpp:20:48: warning: use of old-style cast to 'struct Square*' [-Wold-style-cast]
   20 | Square* as_square(Circle* c) { return (Square*)c; }         // a Circle is never a Square
      |                                                ^
      |                                       ----------
      |                                       reinterpret_cast<Square*> (c)
$ clang++ -std=c++23 -Wall -Wextra -DNAMED -c c_style.cpp
c_style.cpp:16:68: error: cannot cast 'FrozenAccount' to its private base class 'Account'
   16 | Account* unfreeze(FrozenAccount* f) { return static_cast<Account*>(f); }
      |                                                                    ^
c_style.cpp:7:23: note: declared private here
    7 | class FrozenAccount : private Account {};  // private: nobody outside may treat it as an Account
      |                       ^~~~~~~~~~~~~~~
c_style.cpp:17:39: error: static_cast from 'Circle *' to 'Square *', which are not related by inheritance, is not allowed
   17 | Square* as_square(Circle* c) { return static_cast<Square*>(c); }
      |                                       ^~~~~~~~~~~~~~~~~~~~~~~
2 errors generated.
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra -DNAMED -c c_style.cpp
c_style.cpp: In function 'Account* unfreeze(FrozenAccount*)':
c_style.cpp:16:69: error: 'Account' is an inaccessible base of 'FrozenAccount'
   16 | Account* unfreeze(FrozenAccount* f) { return static_cast<Account*>(f); }
      |                                                                     ^
c_style.cpp: In function 'Square* as_square(Circle*)':
c_style.cpp:17:39: error: invalid 'static_cast' from type 'Circle*' to type 'Square*'
   17 | Square* as_square(Circle* c) { return static_cast<Square*>(c); }
      |                                       ^~~~~~~~~~~~~~~~~~~~~~~
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 c_style_session.py

Step through how the compiler reads the two casts of c_style.cpp:

Interactive · stage by stage (Square*)c tries the casts in order Step through the C-style cast in as_square(), then the one in unfreeze(). Each stage is one way to read the cast, tried in the standard's order.
(Square*)c tries the casts in order, step 6 of 6. Showing (Square*)c ((Account*)f), 1. A const_cast?, 2. A static_cast? (yes, even to a private base), 3. static + const_cast?, 4. A reinterpret_cast?. In focus: (Square*)c.(Square*)c(Account*)f1. A const_cast?no: more than const differs2. A static_cast?yes, even to a private base3. static + const_cast?no, for the same reason4. A reinterpret_cast?yes: any object pointer

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. One notation, five meanings. as_square() writes (Square*)c. A C-style cast is not a cast of its own: the standard lists five ways to read it and uses the first that can express the conversion.
  2. Only const?. A const_cast may only add or remove const and volatile. A Circle* and a Square* differ in more than that, so the first reading fails.
  3. Related classes?. A static_cast between pointers to classes needs one class to derive from the other. Circle and Square only share a base, so it fails, and so does a static_cast followed by a const_cast.
  4. Anything goes. A reinterpret_cast may turn any object pointer into another. It is the first reading that works, so (Square*)c quietly becomes reinterpret_cast<Square*>(c).
  5. The private base. In unfreeze(), (Account*)f already works as the second reading: a static_cast written as a C-style cast may convert to a private base, which a real static_cast refuses.
  6. Make the meaning visible. With -Wold-style-cast both compilers point at the two casts, and GCC proposes reinterpret_cast for each. Written as static_cast, both are rejected, which is the point.

Both functions compile silently on both compilers. unfreeze() reaches into a base class that FrozenAccount keeps private, and as_square() turns a Circle* into a Square*, which can only be a reinterpret_cast. With -Wold-style-cast, both compilers point at the two casts, and GCC even prints a fix-it: A line under a diagnostic's source line that shows the text to type, such as std::cout under cout. Clang and GCC print one when they can guess the change; check that it is what you meant before you apply it. More in “Reading C++ compiler errors and fixing old code”, reinterpret_cast for each. Written as static_cast, the same conversions are rejected, which is the point: a named cast states what you meant, so the compiler can refuse what you did not mean. Rule ES.49 of the C++ Core Guidelines says to use named casts, and rule ES.48 to avoid casts altogether where the design allows it.

Checkpoint

Check: C-style casts

  1. Question 1 of 2 as_square() returns (Square*)c, where c is a Circle*. Which named cast does the compiler perform?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: reinterpret_cast, because neither const_cast nor static_cast can express it

    A C-style cast uses the first of its five readings that can express the conversion. For two unrelated classes that is a reinterpret_cast, done without a word.

  2. Question 2 of 2 (Account*)f compiled, where FrozenAccount derives privately from Account. What happens to static_cast<Account*>(f)?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Both compilers reject it, because Account is a private base of FrozenAccount

    Written as a named cast, the conversion says what it means, so the compiler can refuse what the class's author ruled out.

Practise: five C-style casts to replace

Clean up sensor.h, whose five C-style casts stop the tests from building with -Wold-style-cast -Werror:

The starter code and its sample tests

C header · sensor.h

// Exercise: the tests build with -Wold-style-cast -Werror, so every C-style cast below stops them. Replace each one
// with the cast, or the function, that says what it does, and make every check in sensor_tests.cpp pass.
#pragma once
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <string>

struct Reading {
    virtual ~Reading() = default;
    double celsius = 0;
};
struct HumidReading : Reading {
    int humidity = 0;  // per cent
};

enum class Unit { celsius = 1, fahrenheit = 2 };

// 1. The mean of readings stored as whole tenths of a degree: 215 is 21.5 °C.
inline double mean_celsius(int total_tenths, int count) { return (double)total_tenths / count / 10; }

// 2. The code of a unit, for an old file format.
inline int unit_code(Unit unit) { return (int)unit; }

// 3. A C library function that only reads its text, but takes a char*.
inline std::size_t legacy_length(char* text) { return std::strlen(text); }
inline std::size_t label_length(const std::string& label) { return legacy_length((char*)label.c_str()); }

// 4. The bits of a float, for a checksum.
inline std::uint32_t bits_of(float value) { return *(std::uint32_t*)&value; }

// 5. The humidity of a reading, or -1 when the reading has none.
inline int humidity_of(Reading* reading) {
    HumidReading* humid = (HumidReading*)reading;
    return humid != nullptr ? humid->humidity : -1;
}

C++ · sensor_tests.cpp

// Sample tests for sensor.h. Each check prints what it expects when it fails; the exit status is 0 only when every
// check passes.
#include <print>
#include <string>

#include "sensor.h"

namespace {
int checks = 0;
int passed = 0;

void check(bool ok, const char* what) {
    ++checks;
    if (ok) ++passed;
    else std::println("FAILED: {}", what);
}
}  // namespace

int main() {
    check(mean_celsius(645, 3) == 21.5, "the mean of 64.5 °C over three readings is 21.5 °C");
    check(unit_code(Unit::fahrenheit) == 2, "the code of fahrenheit is 2");
    check(label_length("kitchen") == 7, "the label kitchen has 7 characters");
    check(bits_of(1.0f) == 0x3f80'0000, "the bits of 1.0f are 0x3f800000");

    HumidReading humid;
    humid.humidity = 55;
    Reading plain;
    check(humidity_of(&humid) == 55, "a humid reading reports its humidity");
    check(humidity_of(&plain) == -1, "a plain reading has no humidity");

    std::println("{} of {} checks passed", passed, checks);
    return passed == checks ? 0 : 1;
}
The sample tests against the starter code Python · sensor_session.py
from session import run

# The sample tests built against the starter code with both compilers; -Werror turns each warning into an error.
run("""
clang++ -std=c++23 -Wall -Wextra -Wold-style-cast -Werror sensor_tests.cpp -o sensor_tests && ./sensor_tests
g++-16 -std=c++23 -Wall -Wextra -Wold-style-cast -Werror sensor_tests.cpp -o sensor_tests-gcc && ./sensor_tests-gcc
""", max_lines=40)

Output

$ clang++ -std=c++23 -Wall -Wextra -Wold-style-cast -Werror sensor_tests.cpp -o sensor_tests && ./sensor_tests
In file included from sensor_tests.cpp:6:
./sensor.h:20:66: error: use of old-style cast [-Werror,-Wold-style-cast]
   20 | inline double mean_celsius(int total_tenths, int count) { return (double)total_tenths / count / 10; }
      |                                                                  ^       ~~~~~~~~~~~~
./sensor.h:23:42: error: use of old-style cast [-Werror,-Wold-style-cast]
   23 | inline int unit_code(Unit unit) { return (int)unit; }
      |                                          ^    ~~~~
./sensor.h:27:82: error: use of old-style cast [-Werror,-Wold-style-cast]
   27 | inline std::size_t label_length(const std::string& label) { return legacy_length((char*)label.c_str()); }
      |                                                                                  ^      ~~~~~~~~~~~~~
./sensor.h:30:53: error: use of old-style cast [-Werror,-Wold-style-cast]
   30 | inline std::uint32_t bits_of(float value) { return *(std::uint32_t*)&value; }
      |                                                     ^               ~~~~~~
./sensor.h:34:27: error: use of old-style cast [-Werror,-Wold-style-cast]
   34 |     HumidReading* humid = (HumidReading*)reading;
      |                           ^              ~~~~~~~
5 errors generated.
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra -Wold-style-cast -Werror sensor_tests.cpp -o sensor_tests-gcc && ./sensor_tests-gcc
In file included from sensor_tests.cpp:6:
sensor.h: In function 'double mean_celsius(int, int)':
sensor.h:20:74: error: use of old-style cast to 'double' [-Werror=old-style-cast]
   20 | inline double mean_celsius(int total_tenths, int count) { return (double)total_tenths / count / 10; }
      |                                                                          ^~~~~~~~~~~~
      |                                                                  --------------------
      |                                                                  static_cast<double> (total_tenths)
sensor.h: In function 'int unit_code(Unit)':
sensor.h:23:47: error: use of old-style cast to 'int' [-Werror=old-style-cast]
   23 | inline int unit_code(Unit unit) { return (int)unit; }
      |                                               ^~~~
      |                                          ---------
      |                                          static_cast<int> (unit)
sensor.h: In function 'std::size_t label_length(const std::string&)':
sensor.h:27:101: error: use of old-style cast to 'char*' [-Werror=old-style-cast]
   27 | inline std::size_t label_length(const std::string& label) { return legacy_length((char*)label.c_str()); }
      |                                                                                                     ^
      |                                                                                  -------
      |                                                                                  const_cast<char*> ( )
sensor.h: In function 'uint32_t bits_of(float)':
sensor.h:30:70: error: use of old-style cast to 'uint32_t*' {aka 'unsigned int*'} [-Werror=old-style-cast]
   30 | inline std::uint32_t bits_of(float value) { return *(std::uint32_t*)&value; }
      |                                                                      ^~~~~
      |                                                     ----------------------
      |                                                     reinterpret_cast<std::uint32_t*> (&value)
sensor.h: In function 'int humidity_of(Reading*)':
sensor.h:34:42: error: use of old-style cast to 'struct HumidReading*' [-Werror=old-style-cast]
   34 |     HumidReading* humid = (HumidReading*)reading;
      |                                          ^~~~~~~
      |                           -
      |                           static_cast<  -
      |                                         > (     )

Text longer than 4,000 characters is cut here; run the example yourself to see all of it.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sensor_session.py

Replace each cast with the one that says what it does. GCC’s suggestions are a good start for the first three, but not for the last two: its reinterpret_cast for bits_of() would keep the undefined behaviour, so include <bit> and use std::bit_cast, and humidity_of() must return -1 for a plain Reading, which only a checked cast can tell. Both compilers should then print 6 of 6 checks passed. Build once more with Clang and -fsanitize=undefined,vptr, then change your dynamic_cast to a static_cast and run the tests again.

Then sort the casts below into correct, compiling but unsafe, undefined behaviour and not compiling, and apply the seven ideas of this lesson to cases it has not shown. The idea you know least well is asked about first, and each answer you pick tells you why it holds or where it fails:

Practice that adapts to you

Practise this lesson

19 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 19 Classify this cast.

    Read the code, then choose one answer.

    int n = static_cast<int>(3.99);
    Show the answer: Question 1 of 19

    Answer: Correct

    An ordinary conversion from double to int: the fraction is cut off and n is 3. The value fits in an int, so the result is defined.

  2. Question 2 of 19 Classify this conversion.

    Read the code, then choose one answer.

    float f = 1.0f;
    auto bits = std::bit_cast<std::uint32_t>(f);
    Show the answer: Question 2 of 19

    Answer: Correct

    std::bit_cast (C++20, header <bit>) copies the bits into a new object of a type of the same size, so bits is 0x3f800000 with no undefined behaviour.

  3. Question 3 of 19 Classify this cast.

    Read the code, then choose one answer.

    std::uint32_t value = 0x0A0B0C0D;
    auto* bytes = reinterpret_cast<const unsigned char*>(&value);
    std::println("{:02x}", bytes[0]);
    Show the answer: Question 3 of 19

    Answer: Correct

    Any object may be read as an array of char, unsigned char or std::byte. On the little-endian arm64 computer of the lesson, the first byte is 0d.

  4. Question 4 of 19 Base has no virtual function, and Derived derives from it. What happens to dynamic_cast<Derived*>(base_ptr)?

    Choose one answer.

    Show the answer: Question 4 of 19

    Answer: It does not compile, because Base is not polymorphic

    dynamic_cast reads type information that only classes with virtual functions carry. Give the base a virtual destructor, as Shape has, and the check works.

  5. Question 5 of 19 Classify this cast.

    Read the code, then choose one answer.

    Square tile;
    Shape* s = &tile;
    Circle* c = dynamic_cast<Circle*>(s);
    Show the answer: Question 5 of 19

    Answer: Correct

    dynamic_cast checks the object at run time. tile is not a Circle, so c is a null pointer, which the code can test for.

  6. Question 6 of 19 Classify this cast.

    Read the code, then choose one answer.

    Square tile;
    Shape& s = tile;
    Circle& c = dynamic_cast<Circle&>(s);
    Show the answer: Question 6 of 19

    Answer: Correct

    A reference cannot be null, so a failed dynamic_cast to a reference throws std::bad_cast. That is defined behaviour: catch the exception, or use the pointer form when failure is expected.

  7. Question 7 of 19 What does -fno-rtti remove from a program?

    Choose one answer.

    Show the answer: Question 7 of 19

    Answer: The type information of each class with virtual functions, which dynamic_cast's checks and typeid read

    RTTI is the description of each polymorphic class that the program reads while it runs. Without it, only the casts and operators that read it stop compiling.

  8. Question 8 of 19 Built with -fno-rtti, which of these lines still does not compile?

    Choose one answer.

    Show the answer: Question 8 of 19

    Answer: std::println("{}", typeid(int).name());

    Code meant for builds without RTTI avoids typeid altogether, and replaces downcasts with virtual functions or a kind() function of its own.

  9. Question 9 of 19 What does static_cast<Circle&>(shape) check?

    Choose one answer.

    Show the answer: Question 9 of 19

    Answer: Only, when compiling, that Circle derives from Shape

    A static_cast downcast trusts the programmer. Use it only when the type is certain, and build with the vptr check of UndefinedBehaviorSanitizer to catch the cases where it is not.

  10. Question 10 of 19 Classify this cast.

    Read the code, then choose one answer.

    Square tile;
    Shape& s = tile;
    double r = static_cast<Circle&>(s).radius;
    Show the answer: Question 10 of 19

    Answer: Undefined behaviour

    static_cast to a derived class does not check the object, and tile is not a Circle. In the lesson's run, the program printed the square's side as a radius; only the vptr check of UndefinedBehaviorSanitizer reported it.

  11. Question 11 of 19 Classify this cast.

    Read the code, then choose one answer.

    const int limit = 10;
    *const_cast<int*>(&limit) = 20;
    Show the answer: Question 11 of 19

    Answer: Undefined behaviour

    const_cast removes const from the pointer, but limit itself was defined const, and modifying a const object is undefined behaviour. In the lesson's run, the program afterwards still used 10 for limit while *p read 20.

  12. Question 12 of 19 Classify this cast.

    Read the code, then choose one answer.

    std::string room = "kitchen";
    const std::string& view = room;
    const_cast<std::string&>(view) += "!";
    Show the answer: Question 12 of 19

    Answer: Correct

    What matters is how the object was defined, not the reference you hold. Still, a function that takes a const reference promised not to change the object, so such a cast breaks a promise even when it is defined.

  13. Question 13 of 19 Classify this cast.

    Read the code, then choose one answer.

    long n = reinterpret_cast<long>(3.5);
    Show the answer: Question 13 of 19

    Answer: Does not compile

    reinterpret_cast converts between pointers, and between pointers and integers; it cannot turn a double into a long. Both compilers reject it. A value conversion is a static_cast.

  14. Question 14 of 19 Classify this cast.

    Read the code, then choose one answer.

    float f = 1.0f;
    std::uint32_t bits = *reinterpret_cast<std::uint32_t*>(&f);
    Show the answer: Question 14 of 19

    Answer: Undefined behaviour

    Reading a float object through an lvalue of type std::uint32_t breaks the aliasing rules of the standard, so the compiler may assume it never happens. It often seems to work, which is why it survives in old code.

  15. Question 15 of 19 Headset derives from Speaker, then Microphone. Classify this cast.

    Read the code, then choose one answer.

    Headset h;
    Microphone* m = reinterpret_cast<Microphone*>(&h);
    std::println("{}", m->record());
    Show the answer: Question 15 of 19

    Answer: Undefined behaviour

    The Microphone part starts 8 bytes into a Headset. reinterpret_cast keeps the address of the Speaker part, so the call goes through the wrong object. static_cast<Microphone*>(&h) adjusts the pointer correctly, and Clang warns about this reinterpret_cast by default.

  16. Question 16 of 19 A C-style cast (T)x could be read as more than one named cast. Which one does the compiler use?

    Choose one answer.

    Show the answer: Question 16 of 19

    Answer: The first that works, in the order const_cast, static_cast, static_cast then const_cast, reinterpret_cast, reinterpret_cast then const_cast

    The C-style cast tries five readings in a fixed order and uses the first that can express the conversion, with no word about which one it chose.

  17. Question 17 of 19 c_style.cpp built without a word with -Wall -Wextra on both compilers. Which change to the build makes both compilers point at its two C-style casts?

    Choose one answer.

    Show the answer: Question 17 of 19

    Answer: Add -Wold-style-cast

    -Wold-style-cast is not part of -Wall or -Wextra, so a build has to ask for it. With it, both compilers list every C-style cast with its line, which is where a clean-up starts; with -Werror as well, as in the exercise, a new one stops the build.

  18. Question 18 of 19 Classify this cast.

    Read the code, then choose one answer.

    Circle* c = &some_circle;
    Square* q = static_cast<Square*>(c);
    Show the answer: Question 18 of 19

    Answer: Does not compile

    Circle and Square are not related by inheritance; they only share a base. static_cast refuses the conversion. The C-style cast (Square*)c would accept it silently, as a reinterpret_cast.

  19. Question 19 of 19 FrozenAccount derives privately from Account. Classify this cast.

    Read the code, then choose one answer.

    Account* a = (Account*)&frozen;
    Show the answer: Question 19 of 19

    Answer: Compiles, but unsafe

    A C-style cast may convert to a base class even when it is inaccessible, so this compiles and points to the Account part. It defeats the class's decision to keep that base private, and static_cast would refuse it.

Interview questions

Fresher · What is the difference between static_cast and dynamic_cast?

Both can convert a pointer or reference to a base class into one to a derived class. static_cast does it when the program is compiled and checks only that the classes are related; if the object is not really of the derived class, the result is undefined behaviour. dynamic_cast checks the object’s real type while the program runs, using run-time type information, so it needs a class with at least one virtual function: a failed cast to a pointer gives nullptr, and a failed cast to a reference throws std::bad_cast. dynamic_cast therefore costs a run-time check, and it does not compile with -fno-rtti. static_cast also performs ordinary conversions such as double to int, which dynamic_cast cannot. A good answer adds that a virtual function often makes either downcast unnecessary.

The follow-ups usually test the edges of that answer: which form of dynamic_cast a bug calls for, and a cast between two classes that only share a base. Decide both:

Checkpoint

Interview round: two follow-up questions

  1. Question 1 of 2 A function must receive a Circle through a Shape&; any other shape means a bug in the caller. Which cast fits?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: dynamic_cast<Circle&>(shape), which throws std::bad_cast for any other shape

    Choose the form of dynamic_cast by what a failure means: the reference form throws for a bug, the pointer form gives nullptr for a valid answer.

  2. Question 2 of 2 circle is a Circle* to a real Circle. What does dynamic_cast<Square*>(circle) do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It compiles and gives nullptr, because the object is not a Square

    dynamic_cast asks the complete object whether it has the requested class, from whichever base the pointer has. A Circle has no Square part, so the answer is nullptr.

Two harder questions of this module start from this lesson: a game engine built without RTTI that must tell an enemy from other objects, and a legacy module with about 300 C-style casts:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    Your game engine builds with -fno-rtti, and a new feature needs to know whether a GameObject is an Enemy. Why does dynamic_cast not compile, and what are your options, with the cost of each?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    A legacy module contains about 300 C-style casts, and a bug was traced to one that silently became a reinterpret_cast. How would you clean up the casts safely, and how would you stop new ones?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Use the named cast that matches the conversion: static_cast for ordinary conversions, dynamic_cast for checked downcasts, const_cast only to remove const, reinterpret_cast only for bytes and addresses, and std::bit_cast for bits as another type.
  • A failed dynamic_cast gives nullptr for a pointer and throws std::bad_cast for a reference. It needs RTTI, which -fno-rtti removes along with typeid; upcasts, casts to void* and exceptions still work.
  • A static_cast downcast to the wrong class is undefined behaviour that may print a plausible wrong value. GCC’s -fsanitize=undefined catches it; Clang needs -fsanitize=vptr as well.
  • Writing to an object defined const through a const_cast pointer is undefined behaviour: the compilers kept using the original value.
  • reinterpret_cast never adjusts a pointer for a base class at an offset; static_cast does.
  • A C-style cast silently picks whichever cast compiles, even into a private base. Build with -Wold-style-cast and replace each one.

Terms in this lesson

C-style cast
The cast notation (T)x that C++ took over from C. It performs the first of const_cast, static_cast, static_cast then const_cast, reinterpret_cast and reinterpret_cast then const_cast that can express the conversion, even to a private base.
downcast
A conversion of a pointer or a reference to a base class into one to a derived class, such as a Shape* to a Circle*. dynamic_cast checks it while the program runs; static_cast trusts it, and a wrong one is undefined behaviour.
named cast
One of the four cast operators static_cast, dynamic_cast, const_cast and reinterpret_cast. Each may perform only its own kind of conversion, so the compiler rejects one that would do something else, which a C-style cast would allow.
run-time type information (RTTI)
The description of its own type that the compiler keeps for each class with virtual functions, which dynamic_cast's checks and typeid read while the program runs. With -fno-rtti GCC and Clang leave it out and reject those uses.
upcast
A conversion of a pointer or a reference to a derived class into one to a base class, such as a Circle* to a Shape*. For a public base it is implicit and always safe, and it needs no check while the program runs.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.