Reputation: 83
To better understand the C++ type system, I have endeavored to write a pointer wrapper class which propagates constness similar to std::experimental::propagate_const:
template <typename Pointee> class Ptr {
public:
Ptr() = delete;
explicit Ptr(Pointee *);
Ptr(const Ptr<Pointee> &) = delete;
Ptr(Ptr<Pointee> &&);
Ptr<Pointee> &operator=(const Ptr<Pointee> &) = delete;
Ptr<Pointee> &operator=(Ptr<Pointee> &&);
~Ptr() = default;
const Pointee *operator->() const;
Pointee *operator->();
const Pointee &operator*() const;
Pointee &operator*();
private:
Pointee *mPtr;
};
The wrapper is intended to provide near raw pointer like behavior while also enforcing a kind of 'deep' const correctness and guarding against unintentional aliasing.
To this end, the copy constructor and copy assignment operator are deleted:
However, the above design has two unfortunate consequences.
const Ptr<int> allocateImmutableInt(int val) { return Ptr<int>(new int(val)); }
void foo() {
Ptr<int> immutableInt = allocateImmutableInt(0); // Initializes non-const Ptr from const Ptr
*immutableInt = 100; // Oops, changed value of 'immutable' object
}
The first problem can be partially solved by introducing a move ctor that accepts a const rvalue reference (although this feels somewhat strange and non-idiomatic):
Ptr(const Ptr<Pointee> &&);
However, this actually worsens the second problem. Now a const Ptr can be move constructed into a non-const Ptr even without mandatory move/copy-elision. As far as I can tell, to get around this issue we would need a so called 'const constructor', ie a constructor which can only be invoked to produce a const object:
Ptr(const Ptr<Pointee>&&) const;
Even if c++ supported such a constructor, the second problem would still remain, as c++17 specifically ignores cv-qualification and the viability of constructors when deciding if mandatory move/copy-elision can be applied when initializing an object. There does not currently appear to be a way to ask c++ to check if a copy/move would be viable before applying mandatory copy/move elision to object initialization.
As far as I can tell, std::experimental::propagate_const suffers from these same issues. I am wondering if I have encountered a fundamental limitation of c++, or if I am designing the Ptr wrapper incorrectly? I am aware that these issues can likely be eliminated by creating two types, a Ptr for non-const access and ConstPtr for const-only access. However, this defeats the purpose of creating a const-propagating wrapper in the first place.
Perhaps I have just stumbled upon the reason why both an iterator type and a const_iterator type exist.
Upvotes: 3
Views: 417
Reputation: 17073
You are looking at problems that do not really exist.
- To prevent non-const access of a const pointed-to object by copying a const Ptr into a non-const Ptr.
This should not be a goal, as it goes against the idea of propagating const
.
There are two aspects to propagating const
. First, when the pointer is const
-qualified, the object is also const
-qualified. This aspect you have covered. Second, when the pointer is not const
-qualified, the object uses its natural qualification. That is, if you can copy a const Ptr
into a non-const
Ptr
, then that change propagates to the object, potentially making the object also non-const
. This is desired propagation, not something to prevent.
Keep in mind a major use case for const
propagation: class members. Propagating const
for a member pointer helps ensure const-correctness by making the pointed-to data const
in const
-qualified member functions. Your imagined problems are not applicable to this use case. Don't make the situation more complex than it needs to be.
I am aware that these issues can likely be eliminated by creating two types, a Ptr for non-const access and ConstPtr for const-only access.
This is not necessary. If the object is supposed to remain const
even when the pointer is not const
, then the type should be Ptr<const T>
instead of Ptr<T>
.
As an example, your "immutable int" should look more like the following.
Ptr<const int> allocateImmutableInt(int val) { return Ptr<const int>(new int(val)); }
^^^^^ ^^^^^
The const
has been moved to qualify the int
, making the int
immutable regardless of the const
-qualification of the Ptr
.
Furthermore, you might note that new int(val)
returns an int*
that gets implicitly converted to const int*
for your constructor. You might want to replicate this implicit conversion for Ptr
. A constructor like Ptr(Ptr<std::remove_const<Pointee>> &&)
would do the trick, as long as it is defined only when Pointee
is const
-qualified (to avoid having a conflict with the regular move constructor).
Upvotes: 2
Reputation: 11281
Ptr<int> const allocateImmutableInt(int val) {
return Ptr<int>(new int(val));
}
Doesn't do what you think it does. It doesn't create a const-qualified object that is then passed to the constructor of immutableInt
. -That would not even be possible as you deleted the copy constructor.-
Instead the compiler will infer
Ptr<int> immutableInt = allocateImmutableInt(0);
as
Ptr<int> immutableInt(new int(val));
If you write your main function as:
void foo() {
Ptr<int> const immutableInt = allocateImmutableInt(0);
Ptr<int> mutableInt = std::move(immutableInt); // doesn't compile
*mutableInt = 100;
}
You'll see correct behavior: the move
is not possible due to const and the copy constructor is deleted.
edit:
By the way, your Ptr
class looks a lot like std::unique_ptr
. Thus you could just write:
#include <memory>
void foo() {
std::unique_ptr<int> const immutableInt = std::make_unique<int>(0);
std::unique_ptr<int> mutableInt = std::move(immutableInt); // doesn't compile
*mutableInt = 100;
}
And if you want a const-qualified pointer, write
std::unique_ptr<int const>
Upvotes: 1