

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::vectorwill allocate memory before inserting elements. You cannot usevec[size]whencapacity > sizesince 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::memcpyorstd::bit_castotherwise). - 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::bytearray 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 typeTalive. std::launderaddresses 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::bytewill 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::reallocor allocators will implicitly begin the lifetime. (it’s UB before C++20) - Array of
unsigned char/std::bytecan be used to implicitly create objects too. - An object can begin its implicit lifetime by
std::start_lifetime_asorstd::start_lifetime_as_arrayif it’s trivially copyable. (C++23) - Additionally, trivially copiable type is exactly the type that can be safely
std::memcpy,std::memmoveandstd::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 == Uunion 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_castexception - 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_castfrom0/NULLis 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_castorstd::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 typeTor throwstd::bad_variant_access.std::get_if<T/ID>(v): return the pointer to the active alternative of typeTornullptr.
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_nposis 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&ato get its pointer)- throw
std::bad_any_castornullptr
- throw
std::anycan have SBO (small buffer optimization) likestd::function.- Some other helpers:
.swap/std::swap/.emplace.type(), as iftypeidof the underlying object.std::make_any(), same as constructingstd::any.