I’ve been experimenting with a minimal unique_ptr–style class to see Empty Base Class Optimization(EBCO) in action when the deleter is empty:
#include <iostream>
template <typename T, typename Deleter=std::default_delete<T>>
class Smart_Ptr : private Deleter
{
public:
explicit Smart_Ptr(T* p =nullptr, Deleter&& deleter=Deleter()) noexcept:
Deleter(std::move(deleter)),
m_data(p)
{}
explicit Smart_Ptr(T* p =nullptr, const Deleter& deleter=Deleter()) noexcept:
Deleter(deleter),
m_data(p)
{}
explicit Smart_Ptr(T* p =nullptr, Deleter& deleter=Deleter()) noexcept:
Deleter(deleter),
m_data(p)
{}
~Smart_Ptr()
{
if (m_data)
Deleter::operator()(m_data);
}
T* m_data;
};
class A {
public:
A() { std::cout << "Ctor\n"; }
~A() { std::cout << "Dtor\n"; }
A(const A& other) { std::cout << "Copy Ctor\n"; }
A& operator=(const A& other) { std::cout << "Copy Assign\n"; return *this; }
A(A&& other) noexcept { std::cout << "Move Ctor\n"; }
A& operator=(A&& other) noexcept { std::cout << "Move Assign\n"; return *this; }
};
int main()
{
auto res = [](A* ptr){ delete ptr;};
Smart_Ptr<A,decltype(res)> up(new A(),res);
std::cout << sizeof(up) << '\n';
}
Questions:
Does this minimal version accurately demonstrate EBCO when the deleter is empty? I’ve tested it and the size seems to reflect that the empty base was optimized away.
Do we have to overload constructors to handle all value categories (like lvalue and rvalue deleters), since the class itself is templated — and we can't template the constructor to use universal references? I don't understand the explanation on how section (3,4) works in this link https://en.cppreference.com/w/cpp/memory/unique_ptr/unique_ptr
1 Answer 1
About empty base class optimization
Empty base class optimization used to be a useful trick, but is now obsolete. Since C++20 we have [[no_unique_address]], which serves the same purpose, without all the problems.
You have done EBCO "correctly", so an empty deleter should not inflate the object size on any decent compiler. Using private inheritance is especially wise, since the fact that Smart_ptr inherits from Deleter should not be part of its public interface.
But EBCO was always a hack, and it’s not something you should do when you have other options.
The problem is that inheritance is simply wrong: inheritance would imply that Smart_ptr "IS A" Deleter, meaning that anything that accepts a Deleter by pointer or reference should accept a Smart_ptr as well. But that’s nonsense of course. A Smart_ptr is not a Deleter, a Smart_ptr has a Deleter; composition is the correct abstraction, not inheritance.
Making the inheritance private doesn’t make the problem go away. While most of the rest of the world will not see that Smart_ptr "IS A" Deleter, everything within Smart_ptr will. And so will any friends of Smart_ptr.
Why can that be a problem? Well, there are a lot of ways that behaviour can be changed unintentionally and subtly, and often from unexpected places.
Let’s say two completely different groups of developers are working on Smart_ptr and a concrete deleter type separately... which is actually likely to be the normal case. The Smart_ptr team writes a foo() member function that takes some argument, and other member functions call foo(). Unbeknownst to them, the developers making the deleter type also add a foo() function... as a non-public implementation detail... and make it protected so only more specialized implementations of the deleter can call it. Unfortunately, some of the calls in Smart_ptr are a better match for the deleter’s foo() than the one in Smart_ptr. Chaos ensues.
Or for something even crazier, let’s imagine Smart_ptr has a friend that calls foo(). Now imagine if the calls there are a better match for the deleter’s foo(). You would end up with the absolutely batshit situation where a client is correctly using the interface it has been granted clearance to use, but is actually calling a function that isn’t even defined in Smart_ptr, and that it has no idea even exists because it (the friend) does not have access to the deleter’s internals. From their perspective, the behaviour would be completely bizarre, happening seemingly out of nowhere.
This is why [[no_unique_address]] was standardized.
This is the correct way to implement Smart_ptr, to still get the benefit of empty deleter types not inflating the object size:
template<typename T, typename Deleter = std::default_delete<T>>
class Smart_Ptr
{
T* m_data;
[[no_unique_address]] Deleter m_deleter;
// ...
};
You will find this also makes using the deleter easier:
~Smart_Ptr()
{
if (m_data)
//Deleter::operator()(m_data); // ugly
m_deleter(m_data); // simple and obvious
}
There may also be optimization benefits. On some platforms, there is a cost for accessing any data member of a class other than the first, because the address of the first data member coincides with the address of the object; no extra computation is required to offset the address. In the EBCO version, when the deleter is non-empty, its data members get first dibs on that premium position. In the [[no_unique_address]] version, the wrapped pointer always gets the prime real estate. That’s what you want, because the thing you want when using the smart pointer is the wrapped pointer; the only time the deleter is actually used is when you are done with the smart pointer.
And, of course, it also opens up the possibility of deleter types that can’t be inherited from.
Bottom line: don’t use EBCO anymore. It was a hacky technique that is now thankfully obsolete.
Abuse of default parameters
I am not a fan of default parameters, and this class is a pretty glaring example of why.
Let’s take a look at those constructors:
explicit Smart_Ptr(T* p =nullptr, Deleter&& deleter=Deleter()) noexcept:
Deleter(std::move(deleter)),
m_data(p)
{}
explicit Smart_Ptr(T* p =nullptr, const Deleter& deleter=Deleter()) noexcept:
Deleter(deleter),
m_data(p)
{}
explicit Smart_Ptr(T* p =nullptr, Deleter& deleter=Deleter()) noexcept:
Deleter(deleter),
m_data(p)
{}
Riddle me this: if I write auto p = Smart_ptr<int>{};... which constructor gets called?
It gets even worse, because even if you could get it to pick a constructor, the whole conceptual model is incorrect. Let’s say I could write auto p = Smart_ptr<int, deleter_t>{new int};, and it would somehow select one of the three constructors. Which one? Doesn’t really matter, because every single one of them is wrong.
If I do auto p = Smart_ptr<int, deleter_t>{new int};, what I expect to happen is that the deleter will be default-initialized (or, at least, value-initialized). But that’s not what happens. No matter which constructor is picked, the deleter will be value-initialized and then copied or moved.
I get that default parameters save some code duplication, and I freely admit that I am not in agreement with many C++ experts on this, but I think that default parameters should be avoided.
Certainly in this case, they cause plenty of trouble. Instead do something like this:
template<typename T, typename D>
class Smart_ptr
{
T* m_data = nullptr;
D m_deleter;
public:
// Note: NOT explicit
constexpr Smart_ptr() noexcept(std::is_nothrow_default_constructible_v<D>) = default;
// Deleter is value-initialized, and NOT copied or moved.
constexpr explicit Smart_ptr(T* p) noexcept(std::is_nothrow_default_constructible_v<D>)
: m_data{p}
, m_deleter{} // this line is not necessary, but harmless
{}
constexpr /*explicit*/ Smart_ptr(T* p, Deleter const& d)
: m_data{p}
, m_deleter{d}
{}
constexpr /*explicit*/ Smart_ptr(T* p, Deleter&& d)
: m_data{p}
, m_deleter{std::move(d)}
{}
// Don't really need overloads for `Deleter&` or anything else.
//
// Yes, it is technically possible that a deleter could have
// different behaviour when copied from a non-constant lvalue
// reference, or a constant rvalue reference, or `volatile` could
// make a difference, or anything else. But that would be bizarre
// and non-idiomatic, so you don't really need to humour any devs
// that would do that.
};
That’s one more overload than you’ve got, but probably close to the same amount of actual characters because the parameter defaults aren’t being repeated over and over. Yes, we can technically reduce your 3 overloads to 1 (we’ll get to that later), and we can only reduce the above from 4 to 3... but I would argue that’s correct, because you actually want three different behaviours.
Two of those different behaviours are easy to explain: you want the deleter to be default-initialized (or value-initialized) when possible, and not unnecessarily copied or moved. The only time the deleter should be copied or moved is when the user is providing one to copy or move from. So you need at least two overloads: one that copies or moves the deleter, and one that just default-initializes (or value-initializes) it.
So you want at least:
Smart_ptr(T*); // deleter is default/value initialized
Smart_ptr(T*, Deleter); // deleter is copied/moved
As an optimization, the second one could be split into:
Smart_ptr(T*, Deleter const&);
Smart_ptr(T*, Deleter&&);
That saves you a move.
You could also make use of perfect forwarding to merge those two overloads back into one, and still avoid that extra move.
But the point is that these are all just optimizations. No matter how many overloads you split them up into, there are still fundamentally only two behaviours: one where the deleter is created ex nihilo, and one where it is obtained from the user. So you need at least two constructors.
The third behaviour is trickier to explain, but it has to do with implicitness.
You absolutely do not want the smart pointer to be implicitly convertible from a raw pointer. The reasons for that should be obvious. So you certainly do want the constructor that takes a raw pointer to be explicit.
Okay, so why can’t you do this?
explicit Smart_ptr(T* = nullptr);
The answer is because while you want the constructor that takes a raw pointer to be explicit... you probably don’t want the default constructor to be explicit.
We don’t want the following function to compile:
auto f() -> Smart_ptr<int>
{
auto p = get_a_raw_int_pointer_from_somewhere();
return p;
}
Do we really mean to wrap p in a smart pointer? Is the above an accident? It is impossible to know.
Thus, we require construction from a raw pointer to be explicit:
auto f() -> Smart_ptr<int>
{
auto p = get_a_raw_int_pointer_from_somewhere();
return Smart_ptr{p};
}
Now our intention is clear: We really did mean to take ownership of p.
But what if we want to return an empty smart pointer? What is wrong with the following?
auto f() -> Smart_ptr<int>
{
return {};
}
I would argue: nothing is wrong with that. We want to return a value-initialized smart pointer. {} is the short-hand for value-initialization. The above function is clearly expressing the intent.
But if the default constructor is explicit, then the above would not compile, and we would have to write:
auto f() -> Smart_ptr<int>
{
return Smart_ptr<int>{};
}
Is that really necessary? I don’t think so. I don’t see how that extra verbosity improves anything. I don’t see how it makes anything safer.
Thus, I say there should be at least three different construction behaviours:
- Implicit default construction.
- Explicit construction from a raw pointer (where the deleter is created ex nihilo).
- Construction from a raw pointer and a given deleter to be copied or moved from.
The last one could be either explicit or implicit; your choice. The difference is that if it is explicit, the following will not compile:
auto f() -> Smart_ptr<int, deleter_t>
{
auto p = get_a_raw_int_pointer_from_somewhere();
auto d = get_a_deleter_from_somewhere();
return {p, std::move(d)};
}
You would need to write:
auto f() -> Smart_ptr<int, deleter_t>
{
auto p = get_a_raw_int_pointer_from_somewhere();
auto d = get_a_deleter_from_somewhere();
return Smart_ptr{p, std::move(d)};
}
Is that necessary? I don’t think so, because unlike the case with just a pointer, it seems unlikely that someone would accidentally make a smart pointer with a deleter. I mean, I guess it could happen, but it seems far less likely than the raw pointer case, and probably not worth making the smart pointer class harder to use. But your mileage may vary.
Incidentally, you will notice that std::unique_ptr has an overloaded constructor that takes a std::nullptr_t, and that constructor is implicit. One of the reasons for this is so that you can do:
auto f() -> Smart_ptr<int>
{
return nullptr;
}
... instead of requiring:
auto f() -> Smart_ptr<int>
{
return Smart_ptr<int>{nullptr};
}
I would not call this a fourth behaviour: it is just a different way of expressing the default constructor’s behaviour. The intent is the same.
Perfect forwarding
So, one of the three behaviours you need is expressed by:
constexpr Smart_ptr(T* p, Deleter d) noexcept(std::is_nothrow_move_constructible_v<D>)
: m_data{p}
, m_deleter{std::move(d)}
{}
This constructor is perfectly okay as is. It will work with both lvalue and rvalue deleters, and even with non-copyable, move-only deleters:
auto p = new int{};
auto d = deleter_t{};
auto sp1 = Smart_ptr{p, d}; // lvalue deleter
auto sp2 = Smart_ptr{p, std::move(d)}; // rvalue deleter
However, it is non-optimal. In the lvalue case, the deleter is first copied from d to the constructor parameter, and then moved from that into m_deleter. In the rvalue case, the deleter is first moved from d to the constructor parameter, and then moved from that into m_deleter Note that in both cases, there is an extra move.
Now, an extra move is no big deal, most of the time. Most types have highly optimized moves; often just a pointer swap.
But if we want to eliminate that extra move, we can do so, at the cost of now requiring two constructor overloads instead of one:
constexpr Smart_ptr(T* p, Deleter const& d) noexcept(std::is_nothrow_copy_constructible_v<D>)
: m_data{p}
, m_deleter{d}
{}
constexpr Smart_ptr(T* p, Deleter&& d) noexcept(std::is_nothrow_move_constructible_v<D>)
: m_data{p}
, m_deleter{std::move(d)}
{}
With perfect forwarding we can combine those two overloads back into one:
template<typename D>
constexpr Smart_ptr(T* p, D&& d) noexcept(D{std::forward<D>(d)})
: m_data{p}
, m_deleter{std::forward<D>(d)}
{}
This works because of a combination of how deduction and reference collapsing works.
Of course, we really should constrain this template. We only want it to work when D is Deleter, modulo const-ness and referencing, if any.
So, we can do this:
template<typename D>
requires std::same_as<std::remove_cvref_t<D>, Deleter>
constexpr Smart_ptr(T* p, D&& d) noexcept(D{std::forward<D>(d)})
: m_data{p}
, m_deleter{std::forward<D>(d)}
{}
Voilà. A perfectly optimal constructor that works regardless of whether you pass an lvalue deleter, or an rvalue deleter.
Constraints
You should always constrain your template parameters, as far as possible.
In this case, there don’t really seem to be any constraints on T. There doesn’t seem to be any reason to disallow incomplete types, or even void (so long as the type is complete before the deleter call, or the deleter allows incomplete types or void). Unless anyone has any objections, I would leave that unconstrained.
But the deleter absolutely should have constraints. It is intended to be a function object that can be called with a T*. Thus, it should be constrained at least with std::invocable<Deleter, T*>:
template<
typename T,
std::invocable<T*> Deleter
>
class Smart_ptr
{
// ...
There might be other constraints you want on the deleter, like that it is an object type. But let’s answer the question before we go any deeper.
Those mysterious unique_ptr constructor overloads
The std::unique_ptr specification has those two mysterious constructor overloads: numbers 3 and 4. What are they about?
Well, unique_ptr has an interesting feature. The deleter can be an object type... or it can be a reference type. That is quite rare in the standard library. For example, with std::vector<T, Allocator>, neither of its template parameters are allowed to be reference types.
Why might this be useful? Consider the following:
auto p = some_int_pointer();
auto d = deleter_t{};
auto u = std::unique_ptr<int, deleter_t>{p, d};
In the above code, the deleter within the unique pointer is a copy of d. You could also do:
auto p = some_int_pointer();
auto d = deleter_t{};
auto u = std::unique_ptr<int, deleter_t>{p, std::move(d)};
In this case, the deleter within the unique pointer is moved from d.
But here is the crucial point: in both cases, the deleter within the unique pointer is a different object from d. It can be copy-constructed from d or move constructed from d... but it is not actually d.
Sometimes this really matters. Sometimes you want the deleter to not just be copied from something (or moved from something)... but to actually be that thing. For example, imagine if the deleter is a concurrent deleter and has some synchronization primitives within it. Most synchronization primitives are neither copyable nor movable; they depend on being in a fixed (memory) location. Such a deleter would be impossible to use if unique_ptr only kept a deleter object internally. (Well, not impossible. As usual, the impossible can be made possible with just another level of abstraction.)
So instead, you could do this:
auto p = some_int_pointer();
auto d = deleter_t{};
auto u = std::unique_ptr<int, deleter_t&>{p, d};
// ------------------------------------^
Now the deleter within the unique pointer is a reference... and, specifically, a reference to d. Nothing has been copied, moved, or constructed.
In order to make this work, some jiggery-pokery is required.
If unique_ptr only worked with deleters that are objects, it would only need the following constructor overloads:
unique_ptr(T*, Deleter const&);
unique_ptr(T*, Deleter&&);
Technically, it would only need unique_ptr(T*, Deleter), but then there would be an extra, unnecessary move.
But clearly this won’t work if we want to allow the deleter to be a non-const (lvalue) reference. If we took the deleter as a const reference... how would we (safely) then remove the const internally?
Instead, we would need to take it as a non-const reference to begin with:
unique_ptr(T*, Deleter&);
If we want the deleter to be a const (lvalue) reference, then the above won’t work, because a const reference can’t be passed to a non-const reference parameter. We need the const back:
unique_ptr(T*, Deleter const&);
In both cases when the deleter is a lvalue reference, want to disallow construction from rvalues, because that would lead to dangling. Thus, when the deleter is a non-const lvalue reference, we need:
unique_ptr(T*, Deleter&);
unique_ptr(T*, Deleter&&) = delete;
And when the deleter is a const lvalue reference, we need:
unique_ptr(T*, Deleter const&);
unique_ptr(T*, Deleter const&&) = delete;
So, put another way, when the deleter is an object (the normal case), unique_ptr is basically:
// unique_ptr<int, deleter_t>
class unique_ptr
{
T* _p;
Deleter _d;
public:
unique_ptr(T* p, Deleter const& d)
: _p{p}
, _d{d}
{}
unique_ptr(T* p, Deleter&& d)
: _p{p}
, _d{std::move(d)}
{}
// ...
When the deleter is a non-const reference, it is basically:
// unique_ptr<int, deleter_t&>
// -------------------------^
class unique_ptr
{
T* _p;
Deleter& _d;
// ----^
public:
unique_ptr(T* p, Deleter& d)
: _p{p}
, _d{d}
{}
unique_ptr(T* p, Deleter&& d) = delete;
// ...
When the deleter is a const reference, it is basically:
// unique_ptr<int, deleter_t const&>
// --------------------------^^^^^^
class unique_ptr
{
T* _p;
Deleter const& _d;
// -----^^^^^^
public:
unique_ptr(T* p, Deleter const& d)
: _p{p}
, _d{d}
{}
unique_ptr(T* p, Deleter&& d) = delete;
// ...
And these are the three cases being described in the cppreference docs:
- (a) is the normal case, when the deleter is an object:
unique_ptr<T, D>. It takes the deleter as an object... just split into two overloads for optimization purposes. - (b) is when the deleter is a non-
constlvalue reference:unique_ptr<T, D&>. It takes the deleter as a non-constlvalue reference, and blocks taking rvalue references. - (c) is when the deleter is a
constlvalue reference:unique_ptr<T, D const&>. It takes the deleter as aconstlvalue reference, and blocks taking rvalue references.
There are actually a few more special cases in unique_ptr. For example, the deleter can also be a pointer, and when it is, unique_ptr is not default constructible (because that would mean creating a deleter that is nullptr).
(Incidentally, in case you were curious, all of this bullshit is not necessary when using allocators, because allocators are meant to be handles. You never need to take a reference to an allocator. You can have a single memory resource, and create as many handles—that is, allocators—to it as you please. It’s rather unfortunate that the smart pointers were not developed to use allocators, but at the time, allocators were very poorly specified.)
Putting it all together
So, putting everything I’ve said above together, we get:
template<
typename T,
std::invocable<T*> Deleter = std::default_delete<T>
>
class Smart_Ptr
{
T* m_data = nullptr;
[[no_unique_address]] Deleter m_deleter;
public:
// Note: unique_ptr simply bans deleters whose default constructor
// throws. If you want to follow the same pattern, you don't need
// the conditional part of the noexcept.
constexpr Smart_ptr() noexcept(std::is_nothrow_default_constructible_v<Deleter>) = default;
constexpr explicit Smart_ptr(T* p) noexcept(std::is_nothrow_default_constructible_v<Deleter>)
: m_data{p}
{}
// Optional: Non-explicit, for nullptr:
constexpr Smart_ptr(std::nullptr_t) noexcept(std::is_nothrow_default_constructible_v<Deleter>)
{}
template<typename D>
requires std::same_as<std::remove_cvref_t<D>, Deleter>
constexpr Smart_ptr(T* p, D&& d) noexcept(Deleter{std::forward<D>(d)})
: m_data{p}
, m_deleter{std::forward<D>(d)}
{}
constexpr Smart_ptr(Smart_ptr&& p) noexcept(std::is_nothrow_move_constructible_v<Deleter>)
: m_data{p.m_data}
, m_deleter{std::move(p.m_deleter)}
{
p.m_data = nullptr;
}
~Smart_Ptr()
{
if (m_data)
m_deleter(m_data);
}
constexpr auto operator=(Smart_ptr&& p) noexcept(std::is_nothrow_swappable_v<Deleter>) -> Smart_ptr&
{
swap(*this, p);
// Could clear p here, but if the deleter can throw, that would be dangerous.
return *this;
}
friend auto swap(Smart_ptr& a, Smart_ptr& b) noexcept(std::is_nothrow_swappable_v<Deleter>) -> void
{
std::ranges::swap(a.m_deleter, b.m_deleter);
std::ranges::swap(a.m_data, b.m_data);
}
};
Now, if you want to add support for references to deleters—like std::unique_ptr does—things get trickier. Not a whole lot really changes, but there are some subtleties. For example, in the move constructor, you no longer want to move the deleter. (That would turn the deleter into an rvalue, which would not bind to an lvalue reference... and if the deleter is an lvalue reference, m_deleter is an lvalue reference.)
Hope this helps!
EXTENSIONS TO ANSWER
(Stuff added due to discussions in the comments on this answer, or from other sources.)
The const problem, and possible solutions
The problem boils down to this stripped down version of the class:
template<typename T, typename Deleter>
struct Smart_ptr
{
template<typename D>
requires std::same_as<std::remove_cvref_t<D>, Deleter>
Smart_ptr(T* p, D&& d)
{}
};
... used like this:
constexpr auto d = [](int*) {};
auto p = Smart_ptr<int, decltype(d)>{new int, d}; // <- compile error
(example)
The error boils down to "constraints not satisfied", and specifically points to std::is_same.
First, let’s understand why this is happening.
The smart pointer is explicitly instantiated as Smart_ptr<int, decltype(d)>. d is a lambda, but it is constexpr, which makes it implicitly const. You’d get the same error if you just did auto const d = [](int*) {};. So let’s call the lambda type __lambda_t. That means we are instantiating Smart_ptr<int, __lambda_t const>. Note the const.
The constructor is a template, with the second parameter being D&&. When instantiated as above, D gets deduced to __lambda_t const&.
The constraint is std::same_as<std::remove_cvref_t<D>, Deleter>:
Deleteris__lambda_t const.Dis__lambda_t const&, whichremove_cvreftransforms to__lambda_t.
... and __lambda_t ≠ __lambda_t const. Thus, the constraint fails.
So, how to fix this?
One solution is to strip away the offending const by doing remove_cvref to Deleter as well (or, technically, just remove_const, because Deleter can’t be a reference anyway). So:
template<typename T, typename Deleter>
struct Smart_ptr
{
template<typename D>
requires std::same_as<std::remove_cvref_t<D>, std::remove_const_t<Deleter>>
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Smart_ptr(T* p, D&& d)
{}
};
This solves the problem... but is it the best solution?
Well, here’s the thing... I think it is an error to allow the deleter to be const in any case.
Allowing the deleter to be const is morally equivalent to doing (effectively, when is_const_v<Deleter> is true):
template<typename T, typename Deleter>
struct Smart_ptr
{
T* _p;
Deleter const _d;
// ^^^^^
// ...
And const data members are almost always a bad idea.
To see why they’re a bad idea here, we need to slightly expand the minimal example above to:
template<typename T, typename Deleter>
class Smart_ptr
{
Deleter _d;
public:
template<typename D>
requires std::same_as<std::remove_cvref_t<D>, std::remove_const_t<Deleter>>
Smart_ptr(T*, D&& d)
: _d{std::forward<D>(d)}
{}
auto operator=(Smart_ptr&& p) -> Smart_ptr&
{
_d = std::move(p._d);
return *this;
}
};
... and then actually try a move assignment:
auto f()
{
constexpr auto d = [](int*) {};
auto p1 = Smart_ptr<int, decltype(d)>{new int, d};
auto p2 = Smart_ptr<int, decltype(d)>{new int, d};
p1 = std::move(p2); // <- compile error
}
The smart pointer is intentionally non-copyable for safety reasons, but if you use a const deleter, you make it non-movable as well. That strikes me as plain wrong. I can’t think of a single scenario where you would want a (smart) pointer to be non-copyable and non-movable when it is not actually const itself. I say a const deleter is always an error, even if it won’t actually manifest until you try moving.
So I would say the correct fix here is not to add the remove_const type trait to the constructor constraint, and instead add a not is_const trait to the class:
template<typename T, typename Deleter>
requires (not std::is_const_v<Deleter>)
class Smart_ptr
{
Deleter _d;
public:
template<typename D>
requires std::same_as<std::remove_cvref_t<D>, Deleter>
Smart_ptr(T*, D&& d)
: _d{std::forward<D>(d)}
{}
auto operator=(Smart_ptr&& p) -> Smart_ptr&
{
_d = std::move(p._d);
return *this;
}
};
Of course, this now breaks the example usage, because it rejects decltype(d) for being a __lambda_t const. But, as I argue above, I think that is correct.
To fix the usage, then the correct thing to do is to strip the const from the lambda type:
auto p1 = Smart_ptr<int, std::remove_const_t<decltype(d)>>{new int, d};
Yes, that is unfortunately ugly. But I do think it is correct.
And it can be made less ugly in a number of ways. One way is to add deduction guides:
template<typename T, typename Deleter>
requires (not std::is_const_v<Deleter>)
class Smart_ptr
{
Deleter _d;
public:
template<typename D>
requires std::same_as<std::remove_cvref_t<D>, Deleter>
Smart_ptr(T*, D&& d)
: _d{std::forward<D>(d)}
{}
auto operator=(Smart_ptr&& p) -> Smart_ptr&
{
_d = std::move(p._d);
return *this;
}
};
template<typename T, typename Deleter>
Smart_ptr(T*, Deleter&&) -> Smart_ptr<T, std::remove_cvref_t<Deleter>>;
... and then take advantage of class template argument deduction, and don’t specify the deleter type:
auto f()
{
constexpr auto d = [](int*) {};
// Verbose, but correct:
auto p1 = Smart_ptr<int, std::remove_const_t<decltype(d)>>{new int, d};
// Much simpler:
auto p2 = Smart_ptr{new int, d};
p1 = std::move(p2);
}
In my mind, this makes more sense, because in practice, I don’t want to be spelling out the deleter type if I don’t have to. That just leads to verbosity and the potential for errors when types go out of sync.
But a perhaps even better solution is to use tag types. This is a technique that wasn’t really used in the standard library originally, though they have been slowly phased in with stuff like std::allocator_arg, std::in_place, std::from_range. I really think tags should be more widely used. There are quite a few bugs in the standard library that could have been avoided had tags been widely used from the start.
The gist of tagging is that when creating a smart pointer with a deleter, you would do it like this:
auto p = Smart_ptr{p, with_deleter(d)};
As you can see, it clearly spells out what you’re doing. If you saw auto p = std::unique_ptr{p, d};, how would you know, without looking for the documentation, what is going on there? Even with the documentation, sometimes it isn’t clear what is going on without knowing what the types of p and d are. If you just saw std::string{a, b}, you have no way of knowing what is going on: a could be a string and b a length, or if a a string and b an allocator, or a and b an iterator pair. But imagine if instead you saw std::string{a, with_length(b) or std::string{a, with_allocator(b)}? Now even without looking at the docs, you can deduce that you are constructing a string with something string-like (a), and then either a length or an allocator.
Smart_ptr{d, with_deleter(d)} implies you are making a smart pointer from something that can be converted to a smart pointer... and a deleter. Smart_ptr{with_deleter(d)} implies that you are effectively value-initializing a smart pointer (which, as with raw pointers, implies its basically nullptr)... with the given deleter.
You could even take advantage of the tag to avoid unnecessary duplication of types. For example, let’s say you want to use a custom deleter_t, but you want it to be value-initialized. Right now, you have to write:
auto p = Smart_ptr<type_that_p_points_to, deleter_t>{p};
Note that the type of the pointer has to be explicitly specified, even though it could be perfectly well deduced. That is unnecessary repetition.
Instead, you could use a tag like so:
auto p = Smart_ptr{p, with_deleter<deleter_t>};
Now there is no need to repeat the pointer type.
There are a dozen different techniques for making tag types, and as far as I know, none of them are widely accepted as idiomatic, so I can’t point to a single "correct" way to do it. Here is one way, done very roughly:
struct private_use_only_t {};
template<typename D>
struct with_deleter_t
{
constexpr auto operator()() const { return D{}; }
};
template<typename D>
struct with_deleter_t<D&>
{
D& _d;
constexpr auto operator()() const noexcept -> D& { return _d; }
};
template<typename D>
struct with_deleter_t<D&&>
{
D&& _d;
constexpr auto operator()() const noexcept -> D&& { return std::move(_d); }
};
template<>
struct with_deleter_t<void>
{
constexpr explicit with_deleter_t(private_use_only_t) noexcept {}
template<typename D>
constexpr auto operator()(D&& d) const noexcept
{
return with_deleter_t<std::add_rvalue_reference_t<D>>{std::forward<D>(d)};
}
};
inline constexpr auto with_deleter = with_deleter_t<void>{private_use_only_t{}};
... which you would use with the smart pointer like this:
template<typename T, deleter_for<T> Deleter = std::default_delete<T>>
class smart_ptr
{
T* _p = nullptr;
[[no_unique_address]] Deleter _d;
public:
// Default-constructed deleter (implicit):
constexpr smart_ptr() = default;
constexpr smart_ptr(std::nullptr_t) {}
// Default-constructed deleter (explicit):
constexpr smart_ptr(with_deleter_t<void>) {}
constexpr smart_ptr(with_deleter_t<Deleter>) {}
constexpr explicit smart_ptr(T* p) : _p{p} {}
constexpr smart_ptr(T* p, with_deleter_t<void>) : _p{p} {}
constexpr smart_ptr(T* p, with_deleter_t<Deleter>) : _p{p} {}
// Copied/moved deleter:
template<typename D>
constexpr smart_ptr(with_deleter_t<D> d)
: _d{std::invoke(d)}
{}
template<typename D>
constexpr smart_ptr(T* p, with_deleter_t<D> d)
: _p{p}
, _d{std::invoke(d)}
{}
// ...
};
template<typename T, typename D>
smart_ptr(T*, with_deleter_t<D>) -> smart_ptr<T, std::remove_cvref_t<D>>;
... and use in practice like this:
// Assume:
// auto d = deleter_t{};
// auto const cd = deleter_t{};
// With deduction:
// Default constructed deleter:
//smart_ptr{}; // obviously can't work
smart_ptr{new int};
//smart_ptr{new int, with_deleter<deleter_t>}; // would love this to work
smart_ptr{new int, with_deleter_t<deleter_t>{}};
// Copied deleter:
smart_ptr{new int, with_deleter(d)};
smart_ptr{new int, with_deleter(cd)};
smart_ptr{new int, with_deleter(std::move(cd))}; // has to be copied, because const
// Moved deleter:
smart_ptr{new int, with_deleter(std::move(d))};
// Without deduction:
// Default constructed deleter:
smart_ptr<int, deleter_t>{};
smart_ptr<int, deleter_t>{new int};
smart_ptr<int, deleter_t>{new int, with_deleter};
//smart_ptr<int, deleter_t>{new int, with_deleter<deleter_t>}; // would love this to work
smart_ptr<int, deleter_t>{new int, with_deleter_t<deleter_t>{}};
// Copied deleter:
smart_ptr<int, deleter_t>{new int, with_deleter(d)};
smart_ptr<int, deleter_t>{new int, with_deleter(cd)};
smart_ptr<int, deleter_t>{new int, with_deleter(std::move(cd))}; // has to be copied, because const
// Moved deleter:
smart_ptr<int, deleter_t>{new int, with_deleter(std::move(d))};
There are other techniques for making tag types like this, and I’ve skipped over some stuff, like why you should give all the tag types explicit default constructors. But this should demonstrate the paradigm.
-
\$\begingroup\$ I don’t know how to thank you! You spent a lot of time writing this, and I’ll be forever grateful. It was super informative—I’ll need to reread it several times. Here’s my version using universal references. I tried your approach, but when I made the lambda constexpr, the requires clause threw an error, so I had to apply the type trait to both types: godbolt.org/z/9bxvY8W6G \$\endgroup\$sam– sam2025年04月26日 02:34:18 +00:00Commented Apr 26 at 2:34
-
\$\begingroup\$ Yes, when you made the lambda
constexpr, you also implicitly made itconst, sodecltype(lambdaDeleter)isSomeInternalLambdaType const, which is whatDeletergets set to. ThenDdeduces asSomeInternalLambdaType const&, and when youremove_cvrefthat you getSomeInternalLambdaType... which is not the same asSomeInternalLambdaType const. Doing theremove_cvrefonDeleterremoves thatconst, which makes them compare the same. That’s one way to fix the issue. 1/2 \$\endgroup\$indi– indi2025年04月26日 03:08:51 +00:00Commented Apr 26 at 3:08 -
1\$\begingroup\$ Another solution is to use a deduction guide like
template<typename T, typename Deleter> SmrtPtr(T*, Deleter&&) -> SmrtPtr<T, std::remove_cvref_t<Deleter>>;, and then don’t explicitly specify the types and let deduction happen:SmrtPtr ptr_with_lambda(new A(),lambdaDeleter);(notSmrtPtr<A,decltype(lambdaDeleter)> ptr_with_lambda(new A(),lambdaDeleter);). This has the benefit of makingSmrtPtreasier to use, since you don’t need to specify the types all the time. (modified example) 2/2 \$\endgroup\$indi– indi2025年04月26日 03:12:28 +00:00Commented Apr 26 at 3:12 -
1\$\begingroup\$ Extended the answer to cover the previous comments. \$\endgroup\$indi– indi2025年04月27日 22:10:40 +00:00Commented Apr 27 at 22:10
[[no_unique_address]]and composition instead. Also you don’t need to overload constructors to handle all value categories; you can use forwarding references (aka "universal references"), but what you are doing in the code above is not that. In that first constructor,Deleter&&is not a forwarding reference, it is an rvalue reference. \$\endgroup\$template<typename T> struct A { A(T&&); };This is a forwarding/universal reference constructor:template<typename T> struct A { template<typename U> A(U&&); };. The forwarding/universal type in the constructor argument must be a template parameter of the constructor... not a template parameter of the class, so argument deduction can happen. (You may also want to constrainUto be aT, like so:template<typename T> struct A { template<typename U> requires same_as<remove_cvref_t<U>, T> A(U&&); };. \$\endgroup\$