

Modern C++ Basics - Lifetime & Type Safety
Lifetime Logic: if (resource.alive()) { use(); } else { cry(); }
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&
.
- The lifetime of returned temporaries (not reference or pointer to local variable) can be extended by some references, e.g. we’ve learnt
-
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 usevec[size]
whencapacity > 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
orstd::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 typeT
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
- E.g.
- 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*
withfloat*.
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
orstd::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
andstd::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>
)
- decay: array/function -> pointer, remove references and cv-qualifiers. (
- numeric promotions and conversions
- promotion: will not lose precision,
unsigned char
,wchar_t
,bool
… ->int
. - conversion: may lose precision.
- promotion: will not 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
: not safe, non-pointer-interconvertible cast will keep the original address.Base*
<->void*
<->Derived*
- 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
- reference conversion failure: throw
-
Slow!
-
You can use operator
typeid(xxx)
to getconst 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
.
- Same as
- 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
from0
/NULL
is UB; just usenullptr
/implicit conversion/static_cast
.
- integer:
- 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 toint
. - You cannot do e.g.
reinterpret_cast<float&>
to view the binary (as you might do before); you needstd::bit_cast
orstd::memcpy
.
- More loosely, aliasing ones are also okay. e.g. you can use
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 typeT
.std::get<T/ID>(v)
: return the active alternative of typeT
or throwstd::bad_variant_access
.std::get_if<T/ID>(v)
: return the pointer to the active alternative of typeT
ornullptr
.
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;
}
cppType-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
ornullptr
- throw
std::any
can have SBO (small buffer optimization) likestd::function
.- Some other helpers:
.swap
/std::swap
/.emplace
.type()
, as iftypeid
of the underlying object.std::make_any()
, same as constructingstd::any
.