Thus, due to the microscopic overhead (several unoccupied bytes) on the stack, you can create objects of an arbitrary class.
The implementation may look like this – there is a buffer for the object itself (in the form std::aligned_storage
); and some data that allows you to correctly steer the object: for example, call the destructor of exactly the type that is currently contained in static_ptr
.
template <typename T>
struct move_constructer {
static void call(T* lhs, T* rhs) {
new (lhs) T(std::move(*rhs));
}
};
// call `move_constructer<T>::call(dst, src);`However, what if the class T
doesn’t have a move constructor?
There’s a chance that T
has a move assignment operator, then you must use it. If it is not there, then you need to “break” the compilation.
The newer the C++ standard, the easier it is to write code for such things. We get the following code (compiles in C ++17 ):
template <typename T>
struct move_constructer {
static void call(T* lhs, T* rhs) {
if constexpr (std::is_move_constructible_v<T>) {
new (lhs) T(std::move(*rhs));
} else if constexpr (std::is_default_constructible_v<T> && std::is_move_assignable_v<T>) {
new (lhs) T();
*lhs = std::move(*rhs);
} else {
[]<bool flag = false>(){ static_assert(flag, "move constructor disabled"); }();
}
}
};
(on line 10 the compilation is broken in the form static_assert
happens with a hackeight )
However, it would be nice to point out noexcept
-specifier when possible. In C++20 we get the following code, as simple as possible at the moment:
template <typename T>
struct move_constructer {
static void call(T* lhs, T* rhs)
noexcept (std::is_nothrow_move_constructible_v<T>)
requires (std::is_move_constructible_v<T>)
{
new (lhs) T(std::move(*rhs));
}
static void call(T* lhs, T* rhs)
noexcept (std::is_nothrow_default_constructible_v<T> && std::is_nothrow_move_assignable_v<T>)
requires (!std::is_move_constructible_v<T> && std::is_default_constructible_v<T> && std::is_move_assignable_v<T>)
{
new (lhs) T();
*lhs = std::move(*rhs);
}
};
Similarly, with the analysis of cases, you can make a structure move_assigner
. Could still be done copy_constructer
and copy_assigner
, but they are not needed in our implementation. AT static_ptr
copy constructor and copy assignment operator will be removed (as in unique_ptr
).
Implementation: std::type_info on the knee Although in static_ptr
any object can lie, we still need to somehow “know” what type it is there. For example, so that we can call the destructor of this particular object, and do other things.
After several attempts, I developed this option – I need a structure ops
:
struct ops {
using binary_func = void(*)(void* dst, void* src);
using unary_func = void(*)(void* dst);
binary_func move_construct_func;
binary_func move_assign_func;
unary_func destruct_func;
};
And a couple of helper functions for translation void*
in T*
…
template<typename T, typename Functor>
void call_typed_func(void* dst, void* src) {
Functor::call(static_cast<T*>(dst), static_cast<T*>(src));
}
template<typename T>
void destruct_func(void* dst) {
static_cast<T*>(dst)->~T();
}
And now we can for each type T
have your copy ops
:
template<typename T>
static constexpr ops ops_for{
.move_construct_func = &call_typed_func<T, move_constructer<T>>,
.move_assign_func = &call_typed_func<T, move_assigner<T>>,
.destruct_func = &destruct_func<T>,
};
using ops_ptr = const ops*;
static_ptr
will store a reference to ops_for<T>
where T
is the class of the object that is currently in static_ptr
.
Implementation: I like to move it, move it Copy static_ptr
it will be impossible – you can only muvat in another static_ptr
. The choice of the move method depends on the type of objects that lie in these two static_ptr
:
Both static_ptr
empty (dst_ops = src_ops = nullptr
): To do nothing.
static_ptr
contain the same type (dst_ops = src_ops
): do move assign and destroy the object src
.
static_ptr
contain different types (dst_ops != src_ops
): destroy the object in dst
do move construct destroy the object in src
making an assignment dst_ops = src_ops
.
You get this method:
// moving objects using ops
static void move_construct(void* dst_buf, ops_ptr& dst_ops,
void* src_buf, ops_ptr& src_ops) {
if (!src_ops && !dst_ops) {
// both object are nullptr_t, do nothing
return;
} else if (src_ops == dst_ops) {
// objects have the same type, make move
(*src_ops->move_assign_func)(dst_buf, src_buf);
(*src_ops->destruct_func)(src_buf);
src_ops = nullptr;
} else {
// objects have different type
// delete the old object
if (dst_ops) {
(*dst_ops->destruct_func)(dst_buf);
dst_ops = nullptr;
}
// construct the new object
if (src_ops) {
(*src_ops->move_construct_func)(dst_buf, src_buf);
(*src_ops->destruct_func)(src_buf);
}
dst_ops = src_ops;
src_ops = nullptr;
}
}
Implementation: buffer size and alignment Now we need to decide what will be the default buffer size and what will be the alignmentnine because std::aligned_storage
requires knowing these two values.
It is clear that the alignment of the descendant class may exceed the alignment of the ancestor classten . Therefore, alignment should be as high as possible, which only happens. The type will help us with this. std::max_align_t
eleven :
static constexpr std::size_t align = alignof(std::max_align_t);
On my systems, this value is 16, but there may be non-standard values somewhere.
By the way, memory from the heap (from malloc
) is also aligned to the maximum possible alignment, automatically.
The default buffer size can be set to 16 bytes or sizeof(T)
– which will be more.
template<typename T>
struct static_ptr_traits {
static constexpr std::size_t buffer_size = std::max(static_cast<std::size_t>(16), sizeof(T));
};
It is clear that almost always this value will need to be redefined by its own value so that objects of all descendant classes are placed. It is advisable to do this in the form of a macro so that it is quick to write. You can make such a macro to override the buffer size in one class:
#define STATIC_PTR_BUFFER_SIZE(Tp, size) \
namespace sp { \
template<> struct static_ptr_traits<Tp> { \
static constexpr std::size_t buffer_size = size; \
}; \
}
// example:
STATIC_PTR_BUFFER_SIZE(IEngine, 1024)
However, this is not enough for the selected size to be “inherited” by all descendant classes of the desired one. Another macro can be made for this using std::is_base
:
#define STATIC_PTR_INHERITED_BUFFER_SIZE(Tp, size) \
namespace sp { \
template<typename T> requires std::is_base_of_v<Tp, T> \
struct static_ptr_traits<T> { \
static constexpr std::size_t buffer_size = size; \
}; \
}
// example:
STATIC_PTR_INHERITED_BUFFER_SIZE(IEngine, 1024)
Implementation: sp::static_ptr Now we can give the implementation of the class itself. It has only two fields – a link to ops
and a buffer for the object:
template<typename Base>
requires(!std::is_void_v<Base>)
class static_ptr {
private:
static constexpr std::size_t buffer_size = static_ptr_traits<Base>::buffer_size;
static constexpr std::size_t align = alignof(std::max_align_t);
// Struct for calling object's operators
// equals to `nullptr` when `buf_` contains no object
// equals to `ops_for<T>` when `buf_` contains a `T` object
ops_ptr ops_;
// Storage for underlying `T` object
// this is mutable so that `operator*` and `get()` can
// be marked const
mutable std::aligned_storage_t<buffer_size, align> buf_;
// ...
First, let’s implement the method reset
which removes the object – this method is often used:
// destruct the underlying object
void reset() noexcept(std::is_nothrow_destructible_v<Base>) {
if (ops_) {
(ops_->destruct_func)(&buf_);
ops_ = nullptr;
}
}
We implement basic constructors by analogy with std::unique_ptr
:
// operators, ctors, dtor
static_ptr() noexcept : ops_{nullptr} {}
static_ptr(std::nullptr_t) noexcept : ops_{nullptr} {}
static_ptr& operator=(std::nullptr_t) noexcept(std::is_nothrow_destructible_v<Base>) {
reset();
return *this;
}
Now we can implement move constructor and move assignment operator. To accept the same type, you need to do this:
static_ptr(static_ptr&& rhs) : ops_{nullptr} {
move_construct(&buf_, ops_, &rhs.buf_, rhs.ops_);
}
static_ptr& operator=(static_ptr&& rhs) {
move_construct(&buf_, ops_, &rhs.buf_, rhs.ops_);
return *this;
}
However, it is better if we can take static_ptr
for other types. The other type must fit into the buffer and be a descendant of the current type:
template<typename Derived>
struct derived_class_check {
static constexpr bool ok = sizeof(Derived) <= buffer_size && std::is_base_of_v<Base, Derived>;
};
And you need to declare “friends” all instances of the class:
// support static_ptr's conversions of different types
template <typename T> friend class static_ptr;
Then the two previous methods can be rewritten like this:
template<typename Derived = Base>
static_ptr(static_ptr<Derived>&& rhs)
requires(derived_class_check<Derived>::ok)
: ops_{nullptr}
{
move_construct(&buf_, ops_, &rhs.buf_, rhs.ops_);
}
template<typename Derived = Base>
static_ptr& operator=(static_ptr<Derived>&& rhs)
requires(derived_class_check<Derived>::ok)
{
move_construct(&buf_, ops_, &rhs.buf_, rhs.ops_);
return *this;
}
Copying is prohibited:
static_ptr(const static_ptr&) = delete;
static_ptr& operator=(const static_ptr&) = delete;
The destructor destroys the object in the buffer:
~static_ptr() {
reset();
}
To create an object in the buffer, let’s make a method emplace
. The old object is deleted (if it exists), a new one is created in the buffer, and the pointer to ops
.
// in-place (re)initialization
template<typename Derived = Base, typename ...Args>
Derived& emplace(Args&&... args)
noexcept(std::is_nothrow_constructible_v<Derived, Args...>)
requires(derived_class_check<Derived>::ok)
{
reset();
Derived* derived = new (&buf_) Derived(std::forward<Args>(args)...);
ops_ = &ops_for<Derived>;
return *derived;
}
We will make the accessor methods the same as in std::unique_ptr
:
// accessors
Base* get() noexcept {
return ops_ ? reinterpret_cast<Base*>(&buf_) : nullptr;
}
const Base* get() const noexcept {
return ops_ ? reinterpret_cast<const Base*>(&buf_) : nullptr;
}
Base& operator*() noexcept { return *get(); }
const Base& operator*() const noexcept { return *get(); }
Base* operator&() noexcept { return get(); }
const Base* operator&() const noexcept { return get(); }
Base* operator->() noexcept { return get(); }
const Base* operator->() const noexcept { return get(); }
operator bool() const noexcept { return ops_; }
By analogy with std::make_unique
and std::make_shared
let’s make a method sp::make_static
:
template<typename T, class ...Args>
static static_ptr<T> make_static(Args&&... args) {
static_ptr<T> ptr;
ptr.emplace(std::forward<Args>(args)...);
return ptr;
}
The implementation is available on GitHub 12 !
How to use sp::static_ptr? It’s simple! I made unit tests that show the lifetime of the objects living inside static_ptr
thirteen .
In the test, you can see typical scenarios for working with static_ptr
and what happens to the objects inside them.
Benchmark For benchmarks, I used the library google/benchmark
fourteen . The code for this is in the repository.fifteen .
I have considered two scenarios, each of them checks std::unique_ptr
and sp::static_ptr
:
Creating a smart pointer and calling an object method.
Iterate over a vector of 128 smart pointers, each with a method call.
In the first scenario, the gain sp::static_ptr
should be due to the lack of allocation, in the second scenario due to the locality of memory. Although, of course, it is clear that compilers are very smart and can optimize “bad” scenarios well, depending on the optimization flags.
Let’s run the benchmark in a Debug build:
***WARNING*** Library was built as DEBUG. Timings may be affected.
-------------------------------------------------------------------------------------------------
Benchmark Time CPU Iterations
-------------------------------------------------------------------------------------------------
BM_SingleSmartPointer<std::unique_ptr<IEngine>> 207 ns 207 ns 3244590
BM_SingleSmartPointer<sp::static_ptr<IEngine>> 39.1 ns 39.1 ns 17474886
BM_IteratingOverSmartPointer<std::unique_ptr<IEngine>> 3368 ns 3367 ns 204196
BM_IteratingOverSmartPointer<sp::static_ptr<IEngine>> 1716 ns 1716 ns 397344
In Release build:
-------------------------------------------------------------------------------------------------
Benchmark Time CPU Iterations
-------------------------------------------------------------------------------------------------
BM_SingleSmartPointer<std::unique_ptr<IEngine>> 14.5 ns 14.5 ns 47421573
BM_SingleSmartPointer<sp::static_ptr<IEngine>> 3.57 ns 3.57 ns 197401957
BM_IteratingOverSmartPointer<std::unique_ptr<IEngine>> 198 ns 198 ns 3573888
BM_IteratingOverSmartPointer<sp::static_ptr<IEngine>> 195 ns 195 ns 3627462
Thus, there is a certain performance gain for sp::static_ptr
which is the stack-only counterpart of std::unique_ptr
.
Links Boost.SmartPtr
std::unique_ptr – cppreference.com
C++ Memory Pool and Small Object Allocator | by Debby Nirwan
std::aligned_storage – cppreference.com
Placement new operator in C++ – GeeksforGeeks
ceph – github.com
ceph/static_ptr.h – github.com
c++ – constexpr if and static_assert
Objects and alignment – cppreference.com
godbolt.com – the alignment of the descendant class is greater than that of the ancestor class
std::max_align_t – cppreference.com
Izaron/static_ptr – github.com
Izaron/static_ptr, test_derives.cc – github.com
google/benchmark – github.com
Izaron/static_ptr, benchmark.cc – github.com