Skip to content

Introduction

C++ offers stronger type checking than C. In C, many conversions can be implicit. For instance converting void* to T*, where T is any data structure type, is possible.

However, this practice is unsafe. If the object pointed to by the void* pointer is not of type T, then the effect can be disastrous. While some of the unsafe conversions used in C are still possible in C++, in most cases they must be explicit conversions using C++-like casting operations.

Unsafe Type Casting with Primitive Types

Consider the following code snippet:

c_like_casting.c
#include "c_like_casting.h"

// zephyr
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>

LOG_MODULE_REGISTER(c_like_casting, CONFIG_APP_LOG_LEVEL);

void c_like_casting() {
  char i = 0;
  char j = 0;
  char* p = &i;
  void* q = p;
  int* pp = q;    // unsafe, legal C, not C++
  LOG_DBG("%d %d", i, j);
  *pp = -1;       // This overwrites memory starting at &i 
  LOG_DBG("%d %d", i, j);
}

The int* pp = q; statement does not compile in C++. However, unsafe code is still possible using the statement int* pp = static_cast<int*>(q);, but in this case, the developer’s intention is explicit.

c_like_casting.cpp
#include "c_like_casting.hpp"

// zephyr
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>

// stl
#include <limits>

LOG_MODULE_REGISTER(cpp_like_casting, CONFIG_APP_LOG_LEVEL);

void cpp_like_casting() {
  char i = 0;
  char j = 0;
  char* p = &i;
  void* q = p;
  int* pp = static_cast<int*>(q); // unsafe, but with explicit conversion
  LOG_DBG("%d %d", i, j);
  *pp = -1;                       // This overwrites memory starting at &i 
  LOG_DBG("%d %d", i, j);
}

C-like vs C++-like Type Casting for Unrelated Types

In C++, classes are often used as standard or user-defined types. It is also very common to convert between types (up- or downcasting). When programming in C++, the best choice is to use C++-like type casting.

The reason is demonstrated in this code snippet:

c_vs_cpp_like_casting.cpp
class Base {
protected:
    int32_t _d1 = 0;
public:
    virtual void m1(int32_t i) { 
      _d1 -= i; 
      LOG_DBG("Base::m1(): _d1: %d", _d1);
    }
};

class Derived : public Base {
private:
    int32_t _d2 = 1;
public:
    void m1(int32_t i) override { 
      _d2 += i; 
      LOG_DBG("Derived::m1(): _d1: %d _d2: %d", _d1, _d2);
    }
    void m2(int32_t i) { 
      _d2 -= i; 
      LOG_DBG("Derived::m2(): _d1: %d _d2: %d", _d1, _d2);
    }
};

class Unrelated {
private:
    int16_t _d3 = 1;
public:
    void m() { 
      _d3++; 
      LOG_DBG("Unrelated::m(): _d3: %d", _d3);
    }
};

void c_vs_cpp_like_casting() {
  // Declare one variable of each type.
  Base b;
  Derived d;
  Unrelated u;

  //###################
  // USING c-type cast
  // Primitive types casts
  {
    // Using cast for Base, Derived and Unrelated
    // You can cast almost anything

    // This cast is accepted and correct since Base is a base class to Derived
    // and Naming is a Derived instance
    Base *pb = (Base *) &d;
    // it calls m1 from Derived as expected.
    pb->m1(1);

    // This cast is accepted because a Base instance could be a Derived instance.
    // It is correct because pb is effectively pointing to a Derived instance.
    Derived *pd = (Derived *) pb;
    // it calls m1 from Derived as expected.
    pd->m1(1);

    // But if pb is now pointing to a Base instance it is accepted.
    // One can then call methods of Derived on a Base instance ! -> undefined behavior.
    pb = &b;
    pd = (Derived *) pb;
    // it calls m1 from Base and not Derived...
    pd->m1(1);
    // it calls m2 from Derived but pd is effectively pointing to a Base
    // instance
    pd->m2(1);

    // It is even possible to cast to a type which is totally unrelated to the expression !
    Unrelated *pu = (Unrelated *) &b;
    // it calls m() from Unrelated...
    pu->m();

    // The address of any object can be stored in almost any type (as long as it can store the address)
    uint64_t dummy = (uint64_t) &b;
    LOG_DBG("Dummy: %lld", dummy);

    // In C++, some c-style casts that would be accepted in C are not accepted
    // Here char cannot store the address to b.
    // The code below does not compile.
    // char ch = (char) &b;
  }

  //###################
  // USING static_cast
  // Primitive types
  // Conversion with static casts behaves similarly to c-type cast.
  // It does however reject casting between unrelated types !
  {
    // Using cast for Base, Derived and Unrelated
    // static_cast prevents conversions between unrelated types.
    Base *pb = static_cast<Base *>(&d);
    Derived *pd = static_cast<Derived *>(pb);
    pb = &b;
    pd = static_cast<Derived *>(pb);
    pd->m2(1);
    // this may crash at run time...
    // The code below does not compile
    // Unrelated* pu = static_cast<Unrelated*>(&b);

    // The code below does not compile
    // uint64_t dummy = static_cast<uint64_t>(&b);
  }
}

If you read the code and the comments carefully, you will see that some allowed, unsafe C-like casting are not allowed in C++. In particular, you can’t cast between unrelated types in C++.

If you run this code, you will see strange results in the serial terminal. Some of the values for the class instances are wrong, which shows that memory is being written at the wrong places. It might seem easy to spot these mistakes, but it’s not. This might not be true, because it’s usually really hard to spot when memory is overwritten at the wrong places. Even worse, it may lead to different behaviours depending on the compiler and on the compiler optimisation. If you add the configuration parameter CONFIG_DEBUG_OPTIMIZATIONS=y to the code snippet, you should see the following output in the serial monitor:

serial monitor
*** Booting Zephyr OS build v4.3.0 ***
[00:00:00.253,814] <dbg> programming: c_like_casting: 0 0
[00:00:00.259,643] <dbg> programming: c_like_casting: 255 0
[00:00:00.265,655] <dbg> programming: cpp_like_casting: 0 0
[00:00:00.271,697] <dbg> programming: cpp_like_casting: 255 0
[00:00:00.277,893] <dbg> programming: m1: Derived::m1(): _d1: 0 _d2: 2
[00:00:00.284,851] <dbg> programming: m1: Derived::m1(): _d1: 0 _d2: 3
[00:00:00.291,839] <dbg> programming: m1: Base::m1(): _d1: -1
[00:00:00.298,034] <dbg> programming: m2: Derived::m2(): _d1: -1 _d2: 23140
[00:00:00.305,419] <dbg> programming: m: Unrelated::m(): _d3: -19683
[00:00:00.312,225] <dbg> programming: c_vs_cpp_like_casting: Dummy: 536875472
[00:00:00.319,824] <dbg> programming: m2: Derived::m2(): _d1: -1 _d2: 23139
Some of the values here are wrong, and you can spot this if you look carefully. But what if the error isn’t spotted and doesn’t cause any problems when you try to compile it with this setup? You are happy with the program and now decide to move to more optimised code by selecting CONFIG_SIZE_OPTIMIZATIONS=y. You should observe a different behavior with a crash! A change of behavior when changing compilation options is very difficult to debug!

Note that there are other C++ cast primitives such as dynamic_cast or reinterpret_cast, but these are usually disabled when programming embedded systems because of their cost.