6
\$\begingroup\$

Recently, I've been going through Scott Meyer's Effective Modern C++ and found the discussion on shared_ptr really interesting. I also watched Louis Brandy's Curiously Recurring C++ Bugs at Facebook talk which also had some details about how shared_ptr works, and I thought it would be fun to implement my own to see if I actually understood it.

Here is my implementation of shared_ptr and make_shared: https://godbolt.org/z/8Yec9K

#include <iostream>
#include <functional>
#include <vector>

template <typename T>
class SharedPtr {
public:
    using DeleteFunctionType = std::function<void(T*)>;

    explicit SharedPtr(): 
        val_(nullptr), 
        ctrlBlock_(nullptr) 
    {
    }

    explicit SharedPtr(std::nullptr_t,
                       DeleteFunctionType = [](T* val) {
                           delete val;
                       }): 
        val_(nullptr),
        ctrlBlock_(nullptr) 
    {
    }

    explicit SharedPtr(T* val, 
                       DeleteFunctionType deleteFunction = [](T* val) {
                           delete val;
                       }):
        val_(val),
        ctrlBlock_(new ControlBlock(1, std::move(deleteFunction))) 
    {
    }

    ~SharedPtr() {
        if (val_ == nullptr) {
            return;
        }
        if (--ctrlBlock_->refCount <= 0) {
            ctrlBlock_->deleteFunction(val_);
            delete ctrlBlock_;

            val_ = nullptr;
            ctrlBlock_ = nullptr;
        }
    }

    SharedPtr(const SharedPtr& rhs):
        val_(rhs.val_), 
        ctrlBlock_(rhs.ctrlBlock_) 
    {
        if (ctrlBlock_ != nullptr) {
            ++ctrlBlock_->refCount;
        }
    }

    SharedPtr& operator=(SharedPtr rhs) {
        swap(rhs);
        return *this;
    }

    void swap(SharedPtr& rhs) {
        using std::swap;
        swap(val_, rhs.val_);
        swap(ctrlBlock_, rhs.ctrlBlock_);
    }

    bool operator==(const SharedPtr& rhs) const {
        return val_ == rhs.val_ && ctrlBlock_ == rhs.ctrlBlock_;
    }

    T* get() const {
        return val_;
    }

    T& operator*() const {
        return *val_;
    }

    T* operator->() const {
        return val_;
    }

    friend void swap(SharedPtr& lhs, SharedPtr& rhs) {
        lhs.swap(rhs);
    }

    operator bool() const {
        return val_ != nullptr;
    }

private: 
    struct ControlBlock {
        ControlBlock(int cnt, DeleteFunctionType fnc): 
            refCount(cnt),
            deleteFunction(fnc) 
        {
        }

        int refCount;
        DeleteFunctionType deleteFunction;
    };

    T* val_;
    ControlBlock* ctrlBlock_;
};


template <typename T, typename... Args>
SharedPtr<T> MakeShared(Args&&... args) {
    return SharedPtr<T>(new T(std::forward<Args>(args)...));
}





struct Foo {
    Foo(int a, int b) : a_(a), b_(b) {}
    int a_;
    int b_;
    void sayHello() {
        std::cout << "Hello from " << *this << "\n";
    }
    friend std::ostream& operator<<(std::ostream& os, const Foo& rhs) {
        os << "Foo(" << rhs.a_ << ", " << rhs.b_ << ")";
        return os;
    }
};

int main() {
    {
        // Basic usage
        SharedPtr<Foo> c; // Default constructor
        SharedPtr<Foo> a(new Foo(1,2)); // Constructor with value
        auto b = a; // Copy constructor
        c = b; // Assignment operator
    }

    {
        // using custom delete
        constexpr int arrSize = 10;
        SharedPtr<int> a(new int[arrSize], [](auto p) {
            delete[] p;
        }); // custom deleter
        auto b = a; // copy constructor -- make sure the custom deleter is propogated
        SharedPtr<int> c;
        c = a; // copy assignment
    }

    {
        // nullptr
        SharedPtr<Foo> a(nullptr);
        auto b = a;
        SharedPtr<Foo> c;
        c = a;
    }

    {
        // Make shared -- basic usage
        SharedPtr<Foo> c; // Default constructor

        auto a = MakeShared<Foo>(2, 3);
        auto b = a; // Copy constructor
        c = b; // Assignment operator
        std::cout << "*c = " << *c << "\n";
        c->sayHello();
    }

    {
        std::vector<SharedPtr<std::vector<int>>> v;
        v.emplace_back();
        v.emplace_back();
        v.emplace_back();
        v.emplace_back();
        v.emplace_back();
        std::cout << v.size() << "\n";
    }
}

Any and all review comments would be greatly appreciated. In particular, I had a few questions when comparing my implementation against the STL's:

  • I noticed that in the STL implementation I looked at, the class and constructor are templated on different types (i.e. the constructor is implemented like: template <typename T> class shared_ptr { public: template <typename U> explicit shared_ptr(U* val); };) I was wondering why the both the class and the constructor need to be templated?
  • The following compiles: std::shared_ptr<int[]> a(new int[10]);, but the similar idea doesn't compile with my implementation. How can I fix this?
  • Is there a downside to my using an std::function to store the custom deleter? I noticed that the STL implementation I looked at doesn't do this, but the type erasure that std::function provides seem to fit in really well with how the custom deleter is supposed to work.
  • I know that std::make_shared is supposed to use only one allocation to allocate both the control block and the T object. I couldn't figure out how to do it in an easy way though. Is there an easy way to implement that with what I have now?

I'm sure there's a lot of other bugs and mistakes with my code, and I would greatly appreciate any and all feedback. Thanks for your time!

\$\endgroup\$
3
  • \$\begingroup\$ Hello! Could you please add the code into the post? \$\endgroup\$
    – indi
    Commented Jan 30, 2021 at 22:14
  • \$\begingroup\$ Sorry, edited it to include the code in the post! \$\endgroup\$
    – eenz
    Commented Jan 30, 2021 at 22:58
  • \$\begingroup\$ Have a read on the articles I wrote on SharedPtr \$\endgroup\$ Commented Jan 31, 2021 at 18:06

2 Answers 2

6
\$\begingroup\$

General stuff

  • The implementation as-is does not attempt to be thread-safe in any way. I guess this is intentional, but might cause problem and/or confusion if not communicated well.
  • There is no std::weak_ptr-like analogue. Might be a new challenge to implement later ;)
  • Move constructor (and assignment operator) would be nice for SharedPtr.
  • I was first confused why the SharedPtr(nullptr_t, DeleteFunctionType) did not store the deleter function, but then I realized that there is no reset-like functionality.
  • Many member functions of SharedPtr could be marked noexcept.

Other than that, I can't spot anything obvious to improve. Well done!

Q & A

  1. For one, the standard mandates it. And the standard mandates it, because it allows some metaprogramming tricks, in this case removing the constructor from overload resolution in case U* is not convertible to T*. (In general, this allows for better error messages, and might enable another overload to be a better fit in case there are equally fit overloads).

  2. Add a specialization for array types: template<typename T> class SharedPtr<T[]> { ... };, with the corresponding array versions of the member functions.

  3. Yes, it might cause a third allocation (e.g. if the deleter is a lambda with non-empty capture clause). The std::shared_ptr avoids this by using type erasure on the control block.

  4. Well, not easy per se. It would require some design changes, mainly on the control block, and letting it actually control the lifetime of the T (currently, it only controls the deleter, not the T object itself). Basically, define a ControlBlockBase<T> class, which provides the control block interface. Then create a derived ControlBlockImmediate<T> which has an additional T member and necessary member functions. (This can also be used to fix the third possible allocation by std::function). Example:

    template<typename T>
    class ControlBlockBase
    {
    public:
        virtual ~ControlBlockBase() = default;
    
        virtual T* get() const = 0;
        void addReference() { ++refCount_; }
        void removeReference() { --refCount_; }
        size_t countReferences() { return refCount_; }
    private:
        size_t refCount_ = 0u;
    };
    
    template<typename T>
    class ImmediateControlBlock : public ControlBlockBase<T>
    {
    public:
        template<typename... Args>
        ImmediateControlBlock(Args&&... args)
            : immediate_(std::forward<Args>(args)...)
        {
            addReference();
        }
    
        T* get() const override { return &immediate_; }
    private:
        T immediate_;
    };
    
    template<typename T, typename Deleter>
    class ControlBlock : public ControlBlockBase<T>
    {
    public:
        ControlBlock(T* ptr, Deleter deleter) : ptr_{ptr}, deleter_{deleter}
        {
            if (ptr_) addReference();
        }
    
        ~ControlBlock()
        {
            assert(countReferences() == 0u);
            if(ptr_) deleter_(ptr_);
        }
    
        T* get() const override { return ptr_; }
    private:
        T* ptr_;
        Deleter deleter_;
    };
    

    Note: This could potentially be further optimized by using template metaprogramming (e.g. removing the need for virtual function calls), but should give an easy introduction to the concept.

\$\endgroup\$
4
  • 2
    \$\begingroup\$ Standard does not require the shared pointer to be thread safe. We have had this discussion before and we asked on SO and nopbody came up with this requirement. stackoverflow.com/q/65584420/14065 In general the standard does not require thread safety on the stnadrd library. You have to do that yourself. \$\endgroup\$ Commented Jan 31, 2021 at 18:19
  • 2
    \$\begingroup\$ @MartinYork I added an answer to that question. Basically, we're both partially correct (I didn't specify which parts should be thread-safe, and neither did you which parts shouldn't). The internal reference counting mechanism is required to be thread-safe (otherwise you could leak in multithreaded environments). \$\endgroup\$
    – hoffmale
    Commented Jan 31, 2021 at 19:59
  • \$\begingroup\$ nobody agreed with your point of view. Hence no votes. \$\endgroup\$ Commented Feb 1, 2021 at 1:01
  • \$\begingroup\$ Thank you for the review and for answering my questions! I learned a lot from this!!! \$\endgroup\$
    – eenz
    Commented Feb 13, 2021 at 18:45
3
\$\begingroup\$

Code Review

The main purpose of the shared pointer is to prevent the leakage of pointers. Thus as soon as you hand the pointer to the shared pointer object, it becomes responsible for it and you must ensure that it is deleted.

Here in the constructor that means you must protect against failures in the constructor. Note: If the constructor fails to complete, then the destructor will not run.

Here you call new which can result in an exception being thrown which result in the constructor not completing and the destructor not being called. So this constructor will leak the pointer val if the new fails.

explicit SharedPtr(T* val, 
                   DeleteFunctionType deleteFunction = [](T* val) {
                       delete val;
                   }):
    val_(val),

    // If this new throws
    // you leak the val pointer.
    ctrlBlock_(new ControlBlock(1, std::move(deleteFunction))) 
{
}

There are a couple of solutions. You can use a version of new that does not throw (and then check for ctrlBlock_ being null. Alternatively you can catch the exception destroy the pointer then re-throw the exception. I like the second version (also as this gives you the opportunity to use try/catch around the whole constructor (not often used).

explicit SharedPtr(T* val, 
                   DeleteFunctionType deleteFunction = [](T* val) {
                       delete val;
                   })
try
    : val_(val)
    , ctrlBlock_(new ControlBlock(1, std::move(deleteFunction))) 
{}
catch(...)
{
     deleteFunction(val_);
     throw;
}

If the refCount drops below zero then you have a serious problem. Calling the deleteFunction is probably not a good idea. I would force the application to exit at this point!

~SharedPtr() {
    if (val_ == nullptr) {
        return;
    }
    // If the count drops below zero
    // Then you have a serious problem.
    // I would force the application to quit as you have probably already
    // called delete on the object if your code is working
    // or some other object is scribbled over the memory in this object
    // and caused a lot of other issues.
    if (--ctrlBlock_->refCount <= 0) {
        ctrlBlock_->deleteFunction(val_);
        delete ctrlBlock_;

        // Not a fan of setting these to null in the first place.
        // 1: Wate of time.
        // 2: Prevents detection of errors in debug version.
        //
        // BUT why are you doing this only if you delete the
        // object and the control block. If you are going to do
        // it then you should always do it.
        val_ = nullptr;
        ctrlBlock_ = nullptr;
    }
}

Your swap should probably marked noexcept.

void swap(SharedPtr& rhs) noexcept {
                    //    ^^^^^^^     Add this.
    using std::swap;
    swap(val_, rhs.val_);
    swap(ctrlBlock_, rhs.ctrlBlock_);
}

Using the swap also implement move semantics:

 SharedPtr(SharedPtrSharedPtr&& move) noexcept
     : val_(nullptr)
     , ctrlBlock(nullptr)
 {
     swap(move);
     move = nullptr; // Force the destination to release.
                     // If this was the last pointer this
                     // will force deallocation of the pointed
                     // at object.

                     // BUT do this after the move to enforce the
                     // strong exception gurantee.
 }
 SharedPtr& operator=(SharedPtr&& move) noexcept
 {
     swap(move);
     move = nullptr;  // reset the move to potentially force a delete.
     return *this;
 }

The bool operator should be explicit.

explicit operator bool() const {
^^^^^^^^
    return val_ != nullptr;
}

Currently a comparison to a bool will force a conversion then do a comparison.

SharedPtr<int>   x(new int{5});
if (x == false) {
    // Do you want the above to compile?
}

// Or even worse
int val = 1;
if (x == val) {
    // Do you want the above to compile?
    // What does a comparison with int mean?
}

\$\endgroup\$
4
  • 1
    \$\begingroup\$ Your move assignment changes the other operand (which likely is not intended, as it keeps the original pointee alive longer than intended). This can be fixed with auto copy = SharedPtr(std::move(move)); swap(copy); return *this; \$\endgroup\$
    – hoffmale
    Commented Jan 31, 2021 at 20:06
  • 1
    \$\begingroup\$ @hoffmale The only requirement is that the other object is left in a valid state. I can see an argument for clearing the state but I don't see a need. \$\endgroup\$ Commented Feb 1, 2021 at 1:03
  • 1
    \$\begingroup\$ As a user, if b was the last reference to an object, I would be surprised if b = std::move(a); (with a staying alive) did not destroy the original pointee of b. (This gets exacerbated in case the pointee is large and a is a global.) \$\endgroup\$
    – hoffmale
    Commented Feb 1, 2021 at 1:15
  • 1
    \$\begingroup\$ @hoffmale Yes. I agree with that. The move assignment should cause the current object to be destroyed (if it is last). Will update. \$\endgroup\$ Commented Feb 1, 2021 at 2:20

Not the answer you're looking for? Browse other questions tagged or ask your own question.