Owning type erasure on the stack
Type erased objects on the stack
I already wrote down how to type-erase "simple" types, for complex types, one cannot simply use memcpy
but needs to call constructors and destructors.
Thing get a little more complicated if you want to have a more general class, but more importantly a class with efficient move and assignment operators. It turns out that as one property I was relying on might not hold with some compilers or compiler flags that do not follow the standard.
So it’s time to write down what I’ve tried out.
Shapes
Again, let’s consider a shape
class that type-erases other shapes. What we need, is a storage class that contains the type-erased object, to be used as part of the type-erased class, similarly to how I did for "simple" types:
#include <memory>
#include <new>
#include <utility>
template <int N>
class nontrivial_storage {
alignas(N) unsigned char buffer[N] = {};
struct {
using copy_fun_t = void(void*, const void*);
using move_fun_t = void(void*, void*) noexcept;
using dest_fun_t = void(void*) noexcept;
copy_fun_t* copy;
move_fun_t* move;
dest_fun_t* destroy;
} f;
public:
template <typename T>
explicit nontrivial_storage(T t) noexcept :
f{
.copy = [](void* dest, const void* orig) {std::construct_at(reinterpret_cast<T*>(dest), *std::launder(reinterpret_cast<const T*>(orig)));},
.move = [](void* dest, void* orig) noexcept {std::construct_at(reinterpret_cast<T*>(dest), std::move(*std::launder(reinterpret_cast<T*>(orig))));},
.destroy = [](void* dest) noexcept { std::destroy_at(std::launder(reinterpret_cast<T*>(dest)));},
}
{
static_assert(sizeof(T) <= sizeof(buffer), "buffer is not big enough");
static_assert(N % alignof(T) == 0, "wrong alignment");
f.move(buffer, &t);
static_assert(std::is_nothrow_move_constructible_v<T>, "otherwise move constructor should be noexcept(false)");
}
nontrivial_storage(const nontrivial_storage& other) : f(other.f) {
f.copy(this->buffer, other.buffer);
}
nontrivial_storage(nontrivial_storage&& other) noexcept : f(other.f) {
f.move(this->buffer, other.buffer);
}
nontrivial_storage& operator=(const nontrivial_storage& other) {
if (&other != this) {
f.destroy(this->buffer);
other.f.copy(this->buffer, other.buffer);
this->f = other.f;
}
return *this;
}
nontrivial_storage& operator=(nontrivial_storage&& other) noexcept {
if (&other != this) {
f.destroy(this->buffer);
other.f.move(this->buffer, other.buffer);
this->f = other.f;
}
return *this;
}
~nontrivial_storage() { f.destroy(this->buffer); }
template <typename T>
T* data() noexcept {
return std::launder(reinterpret_cast<T*>(buffer));
}
template <typename T>
const T* data() const noexcept {
return std::launder(reinterpret_cast<const T*>(buffer));
}
};
struct square {
double x;
double y;
void draw() const;
square(int, int);
};
struct circle {
double radius;
void draw() const;
circle(int);
};
class shape {
// should be big enough for all shapes we want to type-erase
using storage = nontrivial_storage<4 * alignof(float)>;
storage data;
using draw_fun = void(const storage&);
draw_fun* ptr;
public:
template <typename T>
shape(T t) :
data(std::move(t)),
ptr([](const storage& data) { data.data<T>()->draw(); }) {}
void draw() const { this->ptr(data); }
};
int main() {
auto s = shape(circle(1));
s.draw();
s = shape(square(1, 2));
s.draw();
}
Alternative implementation without std::construct_at
/std::destroy_at
Instead of using construct_at
and destroy_at
, one can use placement new
and call the destructor directly:
copy = [](void* dest, const void* orig) {std::construct_at(reinterpret_cast<T*>(dest), *std::launder(reinterpret_cast<const T*>(orig)));},
move = [](void* dest, void* orig) noexcept {std::construct_at(reinterpret_cast<T*>(dest), std::move(*std::launder(reinterpret_cast<T*>(orig))));},
destroy = [](void* dest) noexcept { std::destroy_at(std::launder(reinterpret_cast<T*>(dest)));},
// vs
copy = [](void* dest, const void* orig) { ::new (dest) T(*std::launder(reinterpret_cast<const T*>(orig)));},
move = [](void* dest, void* orig) noexcept { ::new (dest) T(std::move(*std::launder(reinterpret_cast<const T*>(orig))));},
destroy = [](void* dest) noexcept { std::launder(reinterpret_cast<T*>(dest))->~T();}
The code is equivalent; both placement new
and construct_at
but in some cases, there are little differences that could be relevant.
The first one is that by using placement new
directly, there are fewer casts. You might need to static_assert
that T
is not an array, but otherwise the code is more compact and legible.
Another difference is that in general, it is possible to destroy const
objects, otherwise it would not be possible to use const
variables.
In fact, std::launder(reinterpret_cast<const T*>(dest))->~T();
compiles without issues. On the other hand std::destroy_at(std::launder(reinterpret_cast<const T*>(dest)));
does not compile.
Possible customization points
The constructor is explicit
; since the main reason for writing this class is to use it as member variable, I see little value to make it possible to have an implicit constructor.
In the current implementation, the move constructor is noexcept
, but there are types where it is not. How should nontrivial_storage
behave? Should it fail to compile? Abort if an exception is thrown? Should it be noexcept(false)
? The given implementation fails to compile, but it is not useful if you want to wrap a type whose move constructor is not noexcept
.
The current copy constructor and conversion constructor are not noexcept
, but if you know that all type-erased types have noexcept
copy constructors, you might want to enforce this property too.
Another, often forgotten, important function is swap
.
Last but not least, the size of the buffer is obviously a customization point too.
Further improvements
Another interesting detail of the current implementation:
nontrivial_storage
always destroys a value before assigning it again, thus the assignment operator of the underlying type is never used. As a side-effect this means that it can be used for types that are non-assignable; for example, those that have a const
or a reference member variable.
While it might be an interesting property, destroying a value and creating a new one is, in general, less efficient than assigning them.
Immagine freeing the memory pointed by std::string
and then doing a new allocation every time, instead of copying the content over if the string already has enough capacity.
Note that this is what happens when using internally a class hierarchy too.
It would be nice if the assignment operator of nontrivial_storage
would use the assignment operator of the underlying class if a new value of the same type is being assigned.
Since I do not want to use type_info
, assuming that the functions created by lambdas have different addresses(!), a possible implementation could look like
#include <cstring>
#include <memory>
#include <new>
#include <utility>
template <int N>
class nontrivial_storage {
alignas(N) unsigned char buffer[N] = {};
struct {
using copy_fun_t = void(void*, const void*);
using assign_copy_fun_t = void(void*, const void*);
using move_fun_t = void(void*, void*) noexcept;
using assign_move_fun_t = void(void*, void*) noexcept;
using dest_fun_t = void(void*) noexcept;
copy_fun_t* copy;
assign_copy_fun_t* assign_copy;
move_fun_t* move;
assign_move_fun_t* assign_move;
dest_fun_t* destroy;
} f;
public:
template <typename T>
explicit nontrivial_storage(const T& t) noexcept :
f{
.copy = [](void* dest, const void* source) { ::new (dest) T(*std::launder(reinterpret_cast<const T*>(source)));},
.assign_copy = [](void* dest, const void* source) {*std::launder(reinterpret_cast<T*>(dest)) = *std::launder(reinterpret_cast<const T*>(source));},
.move = [](void* dest, void* source) noexcept { ::new (dest) T(std::move(*std::launder(reinterpret_cast<T*>(source))));},
.assign_move = [](void* dest, void* source) noexcept {*std::launder(reinterpret_cast<T*>(dest)) = std::move(*std::launder(reinterpret_cast<const T*>(source)));},
.destroy = [](void* dest) noexcept { std::launder(reinterpret_cast<T*>(dest))->~T(); },
}
{
static_assert(sizeof(T) <= sizeof(buffer), "buffer is not big enough");
static_assert(N % alignof(T) == 0, "wrong alignment");
f.copy(buffer, &t);
static_assert(std::is_nothrow_move_constructible_v<T>, "otherwise constructor && should be noexcept(false)");
static_assert(std::is_copy_constructible_v<T>);
}
nontrivial_storage(const nontrivial_storage& other) : f(other.f) {
f.copy(this->buffer, other.buffer);
}
nontrivial_storage(nontrivial_storage&& other) noexcept : f(other.f) {
f.move(this->buffer, other.buffer);
}
nontrivial_storage& operator=(const nontrivial_storage& other) {
if (&other != this) {
if(other.f.assign_copy != this->f.assign_copy ){
f.destroy(this->buffer);
other.f.copy(this->buffer, other.buffer);
}else{
this->f.assign_copy(this->buffer, other.buffer);
}
this->f = other.f;
}
return *this;
}
nontrivial_storage& operator=(nontrivial_storage&& other) noexcept {
if (&other != this) {
if(other.f.assign_move != this->f.assign_move){
f.destroy(this->buffer);
other.f.move(this->buffer, other.buffer);
} else {
this->f.assign_move(this->buffer, other.buffer);
}
this->f = other.f;
}
return *this;
}
~nontrivial_storage() { f.destroy(this->buffer); }
template <typename T>
T* data() noexcept {
return std::launder(reinterpret_cast<T*>(buffer));
}
template <typename T>
const T* data() const noexcept {
return std::launder(reinterpret_cast<const T*>(buffer));
}
};
If other.f.assign_copy == this->f.assign_copy
, then this
and other
have the same object type in their buffer. Thus, instead of destroying the value and creating a new one from scratch, it is possible to use the assignment operator.
On the other hand, if other.f.assign_move != this->f.assign_move
, then this
and other
have different types in their buffer. In this case, the only safe way to assign the new value is to destroy the old one and create a new one from scratch.
The exact same considerations hold for the move assignment operator.
What does the standard say about comparing function pointers?
After spending some time on this topic, I do not think that the C++ standard is clear enough about the uniqueness of function pointers.
For trivial types, the compiler could replace
.assign_copy = [](void* dest, const void* source) {*std::launder(reinterpret_cast<T*>(dest)) = *std::launder(reinterpret_cast<const T*>(source));},
with
.assign_copy = [](void* dest, const void* source) { std::memcpy(dest, source, sizeof(T)); },
in fact, the following functions:
struct pod{int i;};
static_assert(sizeof(int)==sizeof(pod));
void foo(void* dest, const void* source) {*std::launder(reinterpret_cast<int*>(dest)) = *std::launder(reinterpret_cast<const int*>(source));}
void bar(void* dest, const void* source) {*std::launder(reinterpret_cast<pod*>(dest)) = *std::launder(reinterpret_cast<const pod*>(source));}
generate the same code as:
struct pod{int i;};
static_assert(sizeof(int)==sizeof(pod));
void foo(void* dest, const void* source) { std::memcpy(dest, source, sizeof(int)); }
void bar(void* dest, const void* source) { std::memcpy(dest, source, sizeof(int)); }
Thus for different trivial types, as long as sizeof(T)
is the same, the function body is identical.
The standard says the following about comparing pointers:
If at least one of the converted operands is a pointer, pointer conversions, function pointer conversions, and qualification conversions are performed on both operands to bring them to their composite pointer type.
Comparing pointers is defined as follows:
If one pointer represents the address of a complete object, and another pointer represents the address one past the last element of a different complete object, the result of the comparison is unspecified.
Otherwise, if the pointers are both null, both point to the same function, or both represent the same address, they compare equally.
Otherwise, the pointers compare unequal.
But what does "point to the same function" actually mean?
Does the name, namespace, and signature identify if the two functions are the same?
With compiler extension, it is trivial to ensure that two functions are the same:
void foo();
void bar(){}
void foo() __attribute__((alias("bar")));
But even assuming that name, namespace, and signature are the criteria for deciding if two functions are the same, the standard does not specify how a lambda creates a function pointer.
I see nothing that prohibits a lambda with static operator()
from being implemented as a pointer to a function, which can be used by different lambdas under certain conditions; for example if the body is the same.
Thus, with such implementation +[](){} == +[](){}
.
It seems that the MSVC linker 🗄️ is able to merge different functions together, and LLVM 🗄️ does the same in some cases too.
The linked paper Safe ICF: Pointer Safe and Unwinding aware Identical Code Folding in the Gold Linker 🗄️ shows an approach on how to reduce the code size of binaries by merging identical functions together without breaking programs that compare functions pointers. According to this article for GCC5 🗄️, GCC merges functions to, but should not break programs either.
What’s the issue if two different functions have the same address? In the class nontrivial_storage
, the lifetime of the old object does not end correctly, and the lifetime of the new object does not start correctly, which is undefined behavior.
On the other hand, I might be too overly pedantic. The only scenario I can currently imagine where a compiler would merge different functions together is when dealing with trivial types. As for those types, the rules for implicit lifetimes apply, there should be no issue.
Nevertheless, reasoning outside the standard, when compilers are breaking it, does not sound like a healthy approach.
Inconsistencies
While researching how function pointers can be misused by the compiler, I found two example that I find extremely interesting.
The first one uses the extension alias
, for creating function aliases:
#include <cassert>
void foo();
void bar(){}
#ifdef USE_ALIAS1
void foo() __attribute__((alias("_Z3barv")));
#endif
static_assert(&foo != &bar);
#ifdef USE_ALIAS2
void foo() __attribute__((alias("_Z3barv")));
#endif
int main(){
assert(&foo != &bar);
volatile auto f = &foo;
volatile auto b = &bar;
assert(f != b);
}
for Clang, this code compiles without errors, even when USE_ALIAS1
or USE_ALIAS2
is defined, but only executes without errors when both macros are undefined.
For GCC, the code compiles and executes without errors only when both macros are undefined. If USE_ALIAS1
is defined, then both static_assert(&foo != &bar);
and static_assert(&foo == &bar);
do not compile. If USE_ALIAS2
is defined, then static_assert(&foo != &bar);
succeeds at compile-time, but assert(&foo != &bar);
fails at runtime.
The inconsistencies are troublesome. While optimizing, clang assumes that function pointers of different function declarations are always different, even if they are not, and optimizes &foo != &bar
directly to true
. When using volatile
, clang reads the actual value out and does a real comparison. GCC is more conservative, and always reads at runtime the actual value.
I guess the conclusion is to never take an address of a function that has been aliased, as I’m not sure what behaviour I would like to have in this case.
Another example is the following one; as a side-effect Clang manages to creates two equal numbers of type size_t
(the difference is 0
), that are not equal:
#include <cstdlib>
#include <iostream>
void foo() { __builtin_unreachable(); }
void bar() {}
int main() {
auto ap = size_t(&foo);
auto bp = size_t(&bar);
std::cout << ap << "\n"
<< bp << "\n"
<< ap-bp << "\n"
<< (ap == bp ? "true\n" : "false\n");
}
The output of this program is, when compiling with -O2
95027430187360
95027430187360
0
false
The root cause is that the standard says addresses are different, and Clang optimizes based on this information ap == bp
to false, while for ap-bp
it has to read the values out at runtime. In this example, the function a
does not generate any assembly, and thus ends at the same location of bar
, and has the same address.
Alternate tagging system
In case you are wondering if using a separate tagging system would help, the answer is: maybe.
template <typename T>
inline void* create_tag() {
static int tag = -1;
return &tag;
}
template <int N>
class nontrivial_storage {
alignas(N) unsigned char buffer[N] = {};
void* tag;
// f as defined previously
template <typename T>
explicit nontrivial_storage(const T& t) :
f{ /* as before */ },
tag(create_tag<T>())
{
f.copy(buffer, &t);
}
nontrivial_storage& operator=(const nontrivial_storage& other) noexcept(cfg.noexcept_copy) {
if (&other != this) {
if (other.tag != this->tag) {
this->f.destroy(this->buffer);
other.f.copy(this->buffer, other.buffer);
} else {
this->f.assign_copy(this->buffer, other.buffer);
}
this->f = other.f;
this->tag = other.tag;
}
return *this;
}
// ...
};
This approach has the same issues of comparing function pointers.
The first disadvantage is that it adds another member variable, and is partially replicating what type_info
does.
In case you have wondered; I’ve used a non-const
variable, as according to the documentation 🗄️, the flag that could break the code when comparing function pointers would also break code that relies on constants.
Note 📝 | other compiler might provide similar optimisation; for example GCC has the -fmerge-all-constants flag. |
I fear that a smart enough compiler might realize that the variable is never modified, and mark it as constant. Then, independently, a linker would merge them together, just like it might merge functions together. At this point, it is a cat-and-mouse game between the developer and compiler.
Contrary to what I thought at the beginning, maybe it would be better to standardize something similar to nontrivial_storage
as a building block. At that point, it would be guaranteed to work correctly and efficiently, even when using some compiler or compiler flags that are not conformant. At least it would get tested worldwide, so the chances of it misbehaving are definitively lower.
Support for constexpr
With constexpr
placement new 🗄️ and constexpr
cast from void*
: towards constexpr
type-erasure 🗄️ using type-erasure at compile-time is not a feature that can be only achieved with class hierarchies. Unfortunately, you’ll have to wait until C++26 if you need it; until recently I had no use-cases for it, but after writing more compile-time tests, it is something which could be really useful.
Complete implementation
With some template parameters, it is possible to cover most customization points. The resulting class is not optimal, but doing a more space-efficient implementation would make the code more complex.
#include <new>
#include <utility>
template <bool noexcept_copy, bool noexcept_move, bool noexcept_swap>
struct operation_pointers {
using copy_fun_t = void(void*, const void*) noexcept(noexcept_copy);
using assign_copy_fun_t = void(void*, const void*) noexcept(noexcept_copy);
using move_fun_t = void(void*, void*) noexcept(noexcept_move);
using assign_move_fun_t = void(void*, void*) noexcept(noexcept_move);
using swap_fun_t = void(void*, void*) noexcept(noexcept_swap);
using dest_fun_t = void(void*) noexcept;
copy_fun_t* copy = nullptr;
assign_copy_fun_t* assign_copy = nullptr;
move_fun_t* move = nullptr;
assign_move_fun_t* assign_move = nullptr;
swap_fun_t* swap_f = nullptr;
dest_fun_t* destroy = nullptr;
};
template<class T, bool noexcept_copy, bool noexcept_move, bool noexcept_swap>
constexpr auto create_operation_pointers() noexcept {
static_assert(not std::is_array_v<T>);
static_assert(std::is_destructible_v<T>);
// Do not change to const to prevent it to get merged with other objects
static constinit auto mret = []{
auto ret = operation_pointers<noexcept_copy,noexcept_move, noexcept_swap>{
.destroy = [](void* dest) noexcept { std::launder(reinterpret_cast<T*>(dest))->~T(); }
};
if constexpr(std::is_copy_constructible_v<T>){
ret.copy = [](void* dest,const void* source) noexcept(noexcept_copy) {
::new (dest)T(*std::launder(reinterpret_cast<const T*>(source)));
};
ret.assign_copy = [](void* dest,const void* source) noexcept(noexcept_copy) {
*std::launder(reinterpret_cast<T*>(dest)) = *std::launder(reinterpret_cast<const T*>(source));
};
}
if constexpr(std::is_move_constructible_v<T>){
ret.move = [](void* dest, void* source) noexcept(noexcept_move) {
::new (dest) T(std::move(*std::launder(reinterpret_cast<T*>(source))));
};
ret.assign_move = [](void* dest, void* source) noexcept(noexcept_move) {
*std::launder(reinterpret_cast<T*>(dest)) = std::move(*std::launder(reinterpret_cast<T*>(source)));
};
}
if constexpr(std::is_swappable_v<T>){
ret.swap_f = [](void* dest, void* source) noexcept(noexcept_swap) {
using std::swap; swap(*std::launder(reinterpret_cast<T*>(dest)), *std::launder(reinterpret_cast<T*>(source)));
};
}
return ret;
}();
return &mret;
}
struct nontrivial_storage_cfg {
int size;
bool noexcept_move = true;
bool noexcept_copy = false;
bool noexcept_swap = noexcept_move;
bool is_copy_constructible = true;
bool is_move_constructible = true;
bool is_swappable = is_move_constructible;
};
template <nontrivial_storage_cfg cfg>
class nontrivial_storage {
alignas(cfg.size) unsigned char buffer[cfg.size] = {};
operation_pointers<cfg.noexcept_move, cfg.noexcept_copy, cfg.noexcept_swap>* f = nullptr;
template <typename T, class... Args>
explicit nontrivial_storage(T*, Args&&... args) : f(create_operation_pointers<T,cfg.noexcept_move,cfg.noexcept_copy,cfg.noexcept_swap>())
{
static_assert(cfg.size>0, "buffer is not big enough");
static_assert(sizeof(T) <= cfg.size, "buffer is not big enough");
static_assert(cfg.size % alignof(T) == 0, "wrong alignment");
static_assert(not cfg.noexcept_move or std::is_nothrow_move_constructible_v<T>);
static_assert(not cfg.noexcept_copy or std::is_nothrow_copy_constructible_v<T>);
static_assert(not cfg.noexcept_swap or (std::is_nothrow_swappable_v<T> and std::is_nothrow_move_constructible_v<T>));
static_assert(not cfg.is_copy_constructible or std::is_copy_constructible_v<T>);
static_assert(not cfg.is_move_constructible or std::is_move_constructible_v<T>);
static_assert(not cfg.is_swappable or std::is_swappable_v<T>);
static_assert(std::is_destructible_v<T>);
static_assert(not std::is_array_v<T>);
::new (buffer)T(std::forward<Args>(args)...);
}
public:
template<class T, class... Args>
static nontrivial_storage make(Args&&... args) {
return nontrivial_storage(static_cast<T*>(nullptr), std::forward<Args>(args)...);
}
nontrivial_storage(const nontrivial_storage& other) noexcept(cfg.noexcept_copy) requires(cfg.is_copy_constructible) : f(other.f) {
this->f.copy(this->buffer, other.buffer);
}
nontrivial_storage(nontrivial_storage&& other) noexcept(cfg.noexcept_move) requires(cfg.is_move_constructible) : f(other.f) {
this->f->move(this->buffer, other.buffer);
}
nontrivial_storage& operator=(const nontrivial_storage& other) noexcept(cfg.noexcept_copy) requires(cfg.is_copy_constructible) {
if (&other != this) {
if (other.f == this->f) {
this->f.assign_copy(this->buffer, other.buffer);
} else {
this->f.destroy(this->buffer);
other.f.copy(this->buffer, other.buffer);
}
this->f = other.f;
}
return *this;
}
nontrivial_storage& operator=(nontrivial_storage&& other) noexcept(cfg.noexcept_move) requires(cfg.is_move_constructible) {
if (&other != this) {
if (other.f == this->f) {
this->f->assign_move(this->buffer, other.buffer);
} else {
this->f->destroy(this->buffer);
other.f->move(this->buffer, other.buffer);
}
this->f = other.f;
}
return *this;
}
~nontrivial_storage() { this->f->destroy(this->buffer); }
friend void swap(nontrivial_storage& lhs, nontrivial_storage& rhs) noexcept(cfg.noexcept_swap) requires(cfg.is_swappable)
{
if(&lhs != & rhs){
if (lhs.f == rhs.f) {
lhs.f->swap_f(lhs.buffer, rhs.buffer);
} else {
auto tmp = std::move(lhs);
lhs = std::move(rhs);
rhs = std::move(tmp);
}
}
}
template <typename T>
T* data() noexcept {
return std::launder(reinterpret_cast<T*>(this->buffer));
}
template <typename T>
const T* data() const noexcept {
return std::launder(reinterpret_cast<const T*>(this->buffer));
}
};
struct square {
double x;
double y;
void draw() const;
square(int,int);
};
struct circle {
double radius;
void draw() const;
circle(int i);
};
class shape {
// should be big enough for all shapes we want to type-erase
using storage = nontrivial_storage<nontrivial_storage_cfg{.size = 4 * alignof(float)}>;
storage data;
using draw_fun = void(const storage&);
draw_fun* ptr;
public:
template <typename T>
shape(T t) :
data(storage::make<T>(std::move(t))),
ptr([](const storage& data) { data.data<T>()->draw(); }) {}
void draw() const { ptr(data); }
friend void swap(shape& lhs, shape& rhs) noexcept {
swap(lhs.data, rhs.data);
std::swap(lhs.ptr, rhs.ptr);
}
};
int main() {
auto s = shape(circle(0));
s.draw();
s = shape(circle(1));
s.draw();
s = shape(square(2,3));
s.draw();
auto s2 = std::move(s);
s2.draw();
}
Features:
-
outline handwritten vtable
-
can use assignment operator instead of destroying and creating values from scratch for the same type
-
supports for non-copyable and non-moveable types
-
support for
noexcept
copy and move constructors and assignment operators -
support for in-place construction
-
support for efficient swap (unfortunately not by default)
Instead of comparing a function pointer, the address of operation_pointers
, the hand-written vtable, is compared. Since it is not marked const
, and since it depends on all pointers, chances that it gets merged with another objects and that the program misbehaves should be zero.
With nontrivial_storage_cfg
, it is possible to specify if move and copy operations are allowed and if they are noexcept
.
As a generic container, it might be important that nontrivial_storage
supports in-place constructions, as some types cannot be moved or copied.
As it is possible to specify template parameters in functions, but not in constructors, the class has a factory function make
for creating a type-erased object.
As nontrivial_storage
is used inside shape
; the user of shape
does not need to use the factory functions or define the properties of nontrivial_storage
; it is an implementation detail of shape
.
Issues with swap
For completeness, I also implemented a custom swap.
The main issue is that it is easy to overlook.
If the class shape does not implement
friend void swap(shape& lhs, shape& rhs) noexcept {
swap(lhs.data, rhs.data);
std::swap(lhs.ptr, rhs.ptr);
}
then the custom swap of nontrivial_storage
is unused.
This is the opposite of move constructors and assignment operators, where the surrounding class automatically uses the implementation of the inner classes.
Thus the custom swap
for nontrivial_storage
is an optimization like the assignment and movement operator, but you have to opt in, and unfortunately it is not possible to write something like
friend void swap(shape& lhs, shape& rhs) = default;
just like it is possible to = default
special functions (constructors, destructor, assignment operators), and operator==
.
Do you want to share your opinion? Or is there an error, some parts that are not clear enough?
You can contact me anytime.