Search code examples
c++c++11stdshared-ptrsmart-pointers

How to change const std::shared_ptr after first assignment?


If I define a shared_ptr and a const shared_ptr of the same type, like this:

std::shared_ptr<int> first = std::shared_ptr<int>(new int);
const std::shared_ptr<int> second = std::shared_ptr<int>();

And later try to change the value of the const shared_ptr like this:

second = first;

It cause a compile error (as it should). But even if I try to cast away the const part:

(std::shared_ptr<int>)second = first;

The result of the code above is that second ends up being Empty, while first is untouched (eg ref count is still 1).

How can I change the value of a const shared_ptr after it was originally set? Is this even possible with std's pointer?

Thanks!


Solution

  • It is undefined behavior to modify in any way a variable declared as const outside of its construction or destruction.

    const std::shared_ptr<int> second
    

    this is a variable declared as const.

    There is no standard compliant way to change what it refers to after construction and before destruction.

    That being said, manually calling the destructor and constructing a new shared_ptr in the same spot might be legal, I am uncertain. You definitely cannot refer to said shared_ptr by its original name, and possibly leaving the scope where the original shared_ptr existed is illegal (as the destructor tries to destroy the original object, which the compiler can prove is an empty shared pointer (or a non-empty one) based on how the const object was constructed).

    This is a bad idea even if you could make an argument the standard permits it.

    const objects cannot be changed.

    ...

    Your cast to a shared_ptr<int> simply creates a temporary copy. It is then assigned to, and the temporary copy is changed. Then the temporary copy is discarded. The const shared_ptr<int> not being modified is expected behavior. The legality of assigning to a temporary copy is because shared_ptr and most of the std library was designed before we had the ability to overload operator= based on the r/lvalue-ness of the left hand side.

    ...

    Now, why is this the case? Actual constness is used by the compiler as an optimization hint.

    {
      const std::shared_ptr<int> bob = std::make_shared<int>();
    }
    

    in the above case, the compiler can know for certain that bob is non-empty at the end of the scope. Nothing can be done to bob that could make it empty and still leave you with defined behavior.

    So the compiler can eliminate the branch at the end of the scope when destroying bob that checks if the pointer is null.

    Similar optimizations could occur if you pass bob to an inline function that checks for bob's null state; the compiler can omit the check.

    Suppose you pass bob to

    void secret_code( std::shared_ptr<int> const& );
    

    where the compiler cannot see into the implementation of secret_code. It can assume that secret code will not edit bob.

    If it wasn't declared const, secret_code could legally do a const_cast<std::shared_ptr&> on the parameter and set it to null; but if the argument to secret_code is actually const this is undefined behavior. (Any code casting away const is responsible for guaranteeing that no actual modification of an actual const value occurs by doing so)

    Without const on bob, the compiler could not guarantee:

    {
      const std::shared_ptr<int> bob = std::make_shared<int>();
      secret_code(bob);
      if (bob) {
        std::cout << "guaranteed to run"
      }
    }
    

    that the guaranteed to run string would be printed.

    With const on bob, the compiler is free to elimiate the if check above.

    ...

    Now, do not confuse my explanation asto why the standard states you cannot edit const stack variables with "if this doesn't happen there is no problem". The standard states you shall not do it; the consequences if you do it are unbounded and can grow with new versions of your compiler.

    ...

    From comments:

    For deserialize process, which is actually a type of constructor that deserialize object from file. C++ is nice, but it got its imperfections and sometimes its OK to search for less orthodox methods.

    If it is a constructor, make it a constructor.

    In C++17 a function returning a T has basically equal standing to a real constructor in many ways (due to guaranteed elision). In C++14, this isn't quite true (you also need a move constructor, and the compiler needs to elide it).

    So a deserialization constructor for a type T in C++ needs to return a T, it cannot take a T by-reference and be a real constructor.

    Composing this is a bit of a pain, but it can be done. Using the same code for serialization and deserialization is even more of a pain (I cannot off hand figure out how).