dokee's site

Back

Modern C++ Basics - Lifetime & Type SafetyBlur image

Lifetime#

Basic concepts#

  • Storage duration

    • Static storage duration
    • Automatic storage duration
    • Dynamic storage duration
    • Thread storage duration
  • The lifetime of an object ends when:

    • it is destroyed
    • the dtor is called
    • its storage is released
    • its storage is reused by non-nested object
  • Temporary objects, as we’ve seen, are only alive until the statement ends (i.e. until ;).

    • The lifetime of returned temporaries (not reference or pointer to local variable) can be extended by some references, e.g. we’ve learnt const&.
  • Corner case: in ctor/dtor, the lifetime of the object has ended, and this is the only place that we can still use its members (with some restrictions).

    • E.g. we’ve known that we cannot call virtual functions in ctor & dtor.

Placement new and storage reuse#

alignas(T) std::byte buf[sizeof(T)];
auto p = new (buf) T {};
p->~T();
cpp
  • You need to manually destruct it (without releasing the buffer!) by calling ~T() before exiting scope.
  • std::vector will allocate memory before inserting elements. You cannot use vec[size] when capacity > size since the lifetime of that element doesn’t begin.
  • For union type, it’s illegal to access an object that’s not in its lifetime (it’s only allowed in C)! (You should use std::memcpy or std::bit_cast otherwise).
  • But you can still use the original array buffer to access other elements whose storage is not reused.
  • Some const variables that cannot be determined at compilation time (e.g. const member constructed in ctor; or allocated on heap; etc.) is still reusable.

Special: unsigned char/std::byte#

  • unsigned char/std::byte array is explicitly regulated to be able to provide storage
    • The only difference is that new object is seen as nested within the array, so the array doesn’t end its lifetime even if you occupy the storage by other objects
    • This property is important for some classes that need a storage with construction of another type
      • Lifetime ending of partial members will cause the whole object ends the lifetime

Pointer semantics in modern C++#

  • In modern C++, pointer is far beyond address; it has type T*, and you can hardly ever access some address by it when there are no underlying objects of type T alive.
  • std::launder addresses pointer provenance issues when reusing storage, ensuring correct type information propagation after placement new or type-punned operations where compiler optimizations might incorrectly assume original object still exists.

Strict aliasing rules#

  • If pointers are not aliased or not contained as member, then compilers can freely assume that they’re different objects.
  • Based on lifetime, we can do optimizations on pointers.
    • E.g. *a += *b; *a += *b; -> *a += 2 * *b
  • For instance, a pointer to class and to its member type will also not be optimized, e.g. class A{ int a; float b; };, A* with float*.
  • unsigned char/std::byte will disable this optimization.

Type punning and aliasing rules#

  • If the underlying type is integer, then using the pointer of its signed/unsigned variant to access it is OK.
  • If convert it to unsigned char*/char*/std::byte*, i.e. it’s legal to view an object as a byte array.
    • However, in this case, it’s possibly illegal to write the element, which may end the lifetime of the original object because of storage reuse.

Implicitly start lifetime#

  • operations like std::malloc/std::calloc/std::realloc or allocators will implicitly begin the lifetime. (it’s UB before C++20)
  • Array of unsigned char/std::byte can be used to implicitly create objects too.
  • An object can begin its implicit lifetime by std::start_lifetime_as or std::start_lifetime_as_array if it’s trivially copyable. (C++23)
  • Additionally, trivially copiable type is exactly the type that can be safely std::memcpy, std::memmove and std::bit_cast. Otherwise it’s not safe to copy byte-wise.

Inheritance Extension#

Slicing problem#

  • Polymorphic base class should hide their copy & move functions (make them protected) if it has data member, otherwise deleting them.

Multiple Inheritance#

class Elephant {
public:
    int weight;
    void test() { std::println("{}", weight); }
};

class Seal {
public:
    int weight;
    void test() { std::println("{}", weight); }
};

class ElephantSeal : public Elephant, public Seal {
public:
    ElephantSeal(int elephant_weight, int seal_weight) :
        Elephant { elephant_weight },
        Seal { seal_weight } {
    }
    using Seal::weight;
    using Elephant::test;
};

int main() {
    ElephantSeal es { 114, 514 };
    std::println("{} {}", es.weight, std::invoke(&Elephant::weight, es));
    es.test();
    std::invoke(&Seal::test, es);
}
cpp
  • This is confusing… it’s usually discouraged to use multiple inheritance with such complexity. (Futhermore, virtual inheritance…)
  • Sometimes this technique is also used in single inheritance, e.g. if ctor just constructs the parent (other things are all default-constructed), then just using.
    • Besides, compiler-generated ones won’t be inherited.
class Child : public Parent {
public:
    using Parent::Parent;
    int aux { 0 };
};
cpp
  • Mixin Pattern:
    • That is, you define many ABCs, which tries to reduce data members and non-pure-virtual member functions as much as you can.
    • They usually denote “-able” functionality, i.e. some kind of ability in one dimension.

Type Safety#

Implicit conversion#

  • lvalue-to-rvalue conversion, array-to-pointer conversion, function-topointer conversion
    • decay: array/function -> pointer, remove references and cv-qualifiers. (std::decay_t<T>)
  • numeric promotions and conversions
    • promotion: will not lose precision, unsigned char, wchar_t, bool … -> int.
    • conversion: may lose precision.
  • (exception-specified) function pointer conversion.
  • qualification conversions: convert a nonconst/non-volatile to a const/volatile one.

static_cast#

  • explicitly denote implicit conversions
    • You can also do inverse operations too, even if it’s narrow (e.g. double->float).
  • scoped enumeration <-> integer or floating point
  • inheritance-related conversions
    • Downcast is dangerous so it needs explicit conversion; you must ensure the original object is just the derived object.
  • pointer conversion
    • T* <-> void* <-> U* (pointer-interconvertible)
      • T == U
      • union U { T t; K k; };
      • struct U { T first_member; }; // standard-layout
      • Base* <-> void* <-> Derived*: not safe, non-pointer-interconvertible cast will keep the original address.
    • Pointer is not address itself in modern C++!

dynamic_cast and RTTI#

  • Can only be used in polymorphic types. (downcast/sidecast)

  • RTTI (Run-Time Type Information/Identification) is preserved to do type check in run time.

    • reference conversion failure: throw std::bad_cast exception
    • pointer conversion failure will return nullptr
  • Slow!

  • You can use operator typeid(xxx) to get const std::type_info.

    • .name()/.hash_code()/.before()
    • std::type_index: use it as keys in associative container.
  • RTTI is unfriendly to shared library (i.e. cross “module boundary”).

  • RTTI is slow no matter in runtime or loading time (to use it with crossing module boundary), which is discouraged in many projects.

const_cast#

  • It tries to drop the cv-qualifiers.
    • When you explicitly know it’s not read-only initially.
    • The second case is when you use library; the author forgets the const in parameter, but it in fact doesn’t write it (which is explicitly documented or you can view the source code).

reinterpret_cast#

  • It is used to process pointers of different types, which is dangerous because of lifetime.
  • converting from an object pointer to another type
    • Same as static_cast<T*>(static_cast<(cv) void*>(xxx)).
    • If you want to convert an old pointer that loses its lifetime to a new pointer, you may also need to use std::launder.
  • converting from a pointer to function to another type of pointer to function; or pointer to member to another one
  • converting pointer to integer or vice versa
    • integer: std::uintptr_t
    • reinterpret_cast from 0/NULL is UB; just use nullptr/implicit conversion/static_cast.
  • Its functionality is hugely restricted due to lifetime.
    • More loosely, aliasing ones are also okay. e.g. you can use reinterpret_cast<unsigned int&> to refer to int.
    • You cannot do e.g. reinterpret_cast<float&> to view the binary (as you might do before); you need std::bit_cast or std::memcpy.

C-style cast#

  • It’s discouraged to use such explicit cast in C++.
  • You can use auto{...}/auto(...) to get a decayed pure rvalue of the expression.

Type-safe union: std::variant#

std::variant<int, float, int, std::vector<float>> v { 0.0f };
cpp
  • Construction:
    • By default, the first alternative is value-initialized.
    • You can also assign a value with the same type of some alternative, then that’s the active alternative.
    • You can also construct the member in place, i.e. by (std::in_place_type<T>, args...) or (std::in_place_index<ID>, args...)
  • Only when all alternatives support copy/move will the variant support copy/move.
  • Access or check the existence of alternative:
    • .index(): return the index of active alternative.
    • std::hold_alternative<T>(v): true if the active alternative is of type T.
    • std::get<T/ID>(v): return the active alternative of type T or throw std::bad_variant_access.
    • std::get_if<T/ID>(v): return the pointer to the active alternative of type T or nullptr.
  • std::monostate: an explicit “empty” state.
  • Some other helpers:
    • .emplace<T/ID>(args...)
    • .swap(v2)/std::swap(v1,v2)
    • comparisons
      • If the indices are not same, then it in fact compares indices.
      • std::variant_npos is seen as smallest.
    • std::variant_size_v<V>
    • std::variant_alternative_t<ID, V>
// Visitor pattern
using Var = std::variant<int, double, std::string>;

template <typename... Ts>
struct Overload : Ts... {
    using Ts::operator()...;
};

int main() {
    Var v1 { 42 };
    Var v2 { 3.14 };
    Var v3 { "hello" };

    auto visitor =
        Overload { [](const int val) { std::println("[int] {}", val); },
                   [](const double val) { std::println("[double] {}", val); },
                   [](const std::string_view& val) { std::println("[string] {}", val); } };

    std::visit(visitor, v1);
    std::visit(visitor, v2);
    std::visit(visitor, v3);

    return 0;
}
cpp

Type-safe void*: std::any#

  • Basic usage:
    • std::any a { 1 };
    • std::any a { std::in_place_t<T>, args... };
    • .reset()
    • .has_value()
  • When you need to get the underlying object, you need to use std::any_cast<T>(a). (use &a to get its pointer)
    • throw std::bad_any_cast or nullptr
  • std::any can have SBO (small buffer optimization) like std::function.
  • Some other helpers:
    • .swap/std::swap/.emplace
    • .type(), as if typeid of the underlying object.
    • std::make_any(), same as constructing std::any.
Modern C++ Basics - Lifetime & Type Safety
https://astro-pure.js.org/blog/c/modern-c-basics/lifetime--type-safety
Author dokee
Published at March 5, 2025
Comment seems to stuck. Try to refresh?✨