Skip to content

add support for const-only smart pointers #5718

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from

Conversation

Rosdf
Copy link

@Rosdf Rosdf commented Jun 7, 2025

Description

In my project, I am mainly using smart pointers that can hold only pointers to const, but Pybind does not allow them to be passed to Python. Here is a small fix to allow this behavior

Suggested changelog entry:

  • Placeholder.

@Rosdf Rosdf requested a review from henryiii as a code owner June 7, 2025 07:50
@Rosdf
Copy link
Author

Rosdf commented Jun 7, 2025

If idea is ok, I will fix failing builds

@rwgk
Copy link
Collaborator

rwgk commented Jun 7, 2025

TL;DR:

  • The production code changes are small and seem fine at first sight.

  • But your use case leaks. Could you please provide a more convincing use case?


To explain to myself(!): Why do we need the production code changes?

We have existing tests for std::shared_ptr<const T>:

However, const-ness of T is cast away (look for // Const2Mutbl comments), the wrapped object is still a std::shared_ptr<T>, which has (e.g.) operator * (non-const).

In contrast, your StaticPtr only has const T member functions. That's apparently why you need a const-cast somewhere else (your diff):

     template <typename V = void>
     V *&value_ptr() const {
-        return reinterpret_cast<V *&>(vh[0]);
+        if constexpr (std::is_const_v<V>) {
+            return reinterpret_cast<V *&>(const_cast<const void *&>(vh[0]));
+        } else {
+            return reinterpret_cast<V *&>(vh[0]);
+        }
     }

I think that'd be OK (with a C++11-compatible implementation) ... if you can provide a convincing use case:

Looking at your test code, I believe this leaks:

        return StaticPtr(new MyData(std::move(name)));

I don't see a corresponding delete anywhere. Could you please explain?

Could you please provide a more realistic use case?

@Rosdf
Copy link
Author

Rosdf commented Jun 7, 2025

To be honest, this my real use case. I have some number of objects, that are allocate and are alive until program exits, so for them I use StaticPtr wrapper, and they are const because i share them across threads

@rwgk
Copy link
Collaborator

rwgk commented Jun 7, 2025

First impression: That's a pretty involved way of dealing with the situation.

Are you aware of this?

I tested this locally:

#include "pybind11_tests.h"

#include <string>

class MyData {
public:
    static std::unique_ptr<MyData, py::nodelete> create(std::string name) {
        return std::unique_ptr<MyData, py::nodelete>(new MyData(std::move(name)));
    }

    const std::string &getName() const { return name_; }

private:
    explicit MyData(std::string &&name) : name_(std::move(name)) {}

    std::string name_;
};

TEST_SUBMODULE(const_module, m) {
    py::class_<MyData, std::unique_ptr<MyData, py::nodelete>>(m, "Data")
        .def(py::init([](const std::string &name) { return MyData::create(name); }))
        .def_property_readonly("name", &MyData::getName);
}

Could that pattern work for your real use case?

@rwgk
Copy link
Collaborator

rwgk commented Jun 7, 2025

I forgot to add, you could just copy this into your codebase, under your own namespace, if you want keep your C++ code independent from pybind11:

struct nodelete {                                                               
    template <typename T>                                                       
    void operator()(T *) {}                                                     
};                                                                              

@Rosdf
Copy link
Author

Rosdf commented Jun 8, 2025

Using unique_ptr in my code base would not work because it is not copyable.

I can do something like this

class MyData {
public:
    static StaticPtr<MyData> getOrCreate(const std::string &name) {
        // here should be logic to get or create a pointer
        const MyData *ptr = new MyData(std::string(name));

        return StaticPtr(ptr);
    }

    const std::string &getName() const { return name_; }

private:
    explicit MyData(std::string &&name) : name_(std::move(name)) {}

    std::string name_;
};

TEST_SUBMODULE(const_module, m) {
    py::class_<MyData, std::unique_ptr<MyData, py::nodelete>>(m, "Data")
        .def(py::init([](const std::string &name) {
            auto ptr = MyData::getOrCreate(name);
            return std::unique_ptr<MyData, py::nodelete>(const_cast<MyData *>(ptr.get()));
        }))
        .def_property_readonly("name", &MyData::getName);
}

but then i won't be able to easily export some functions that use StaticPtr<MyData>

@rwgk
Copy link
Collaborator

rwgk commented Jun 9, 2025

I tested this locally:

#include "pybind11_tests.h"

#include <string>

class MyData {
public:
    static std::shared_ptr<MyData> create(std::string name) {
        return std::shared_ptr<MyData>(new MyData(std::move(name)), [](MyData *) {});
    }

    const std::string &getName() const { return name_; }

private:
    explicit MyData(std::string &&name) : name_(std::move(name)) {}

    std::string name_;
};

TEST_SUBMODULE(const_module, m) {
    py::class_<MyData, py::smart_holder>(m, "Data")
        .def(py::init([](const std::string &name) { return MyData::create(name); }))
        .def_property_readonly("name", &MyData::getName);
}

The more I think about this, the more doubts I have about that pattern.

What's the purpose of a factory function that always leaks? Make it unobvious that you're leaking memory?

@Rosdf
Copy link
Author

Rosdf commented Jun 9, 2025

It always leaks just for this example; in reality, it first searches for existing pointer associated with this name, if it fails, creates new one.

Using shared_pointer in my code base is also not an option, because it does extra ref counting and takes twice the space.

If it will be more convincing, let's imagine that my use case is this.

template <class T>
class MySmartPointer {
public:
    // regular shared_ptr methods

    const T * get() const { return data_; }

private:
    const T * data_ = nullptr;
    std::atomic<uint32_t> * counter_ = nullptr; 
}

@rwgk
Copy link
Collaborator

rwgk commented Jun 9, 2025

Using shared_pointer in my code base is also not an option, because it does extra ref counting and takes twice the space.

I want to look in two directions then:

  1. Make this PR work on all platforms.

  2. Apart from optimization considerations ("extra ref counting and takes twice the space"), could this work just for you:

Almost identical, just a couple const removed:

template <class T>
class MySmartPointer {
public:
    // regular shared_ptr methods

   /*const*/ T * get() const { return data_; }

private:
    /*const*/ T * data_ = nullptr;
    std::atomic<uint32_t> * counter_ = nullptr; 
}

Then: MySmartPointer<const MyData> (similar to std::shared_ptr<const MyData>).

@@ -33,7 +33,11 @@ struct value_and_holder {

template <typename V = void>
V *&value_ptr() const {
return reinterpret_cast<V *&>(vh[0]);
if constexpr (std::is_const_v<V>) {
return reinterpret_cast<V *&>(const_cast<const void *&>(vh[0]));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we do this unconditionally (without the std::is_const_v<V> test)?

To keep the code simpler.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no it will cause errors like this error: ‘reinterpret_cast’ from type ‘const void**’ to type ‘void**’ casts away qualifiers

@rwgk
Copy link
Collaborator

rwgk commented Jun 9, 2025

I just pushed fc8ca60

Copying the link from there: https://claude.ai/share/4085d9ab-a859-44cc-bb56-450e472f817a

class MyData {
public:
static StaticPtr<MyData> create(std::string name) {
return StaticPtr<MyData>(new MyData(std::move(name)));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming the CI still passes, I'm fine with the production code changes.

However, we need a test case that does not leak. I wouldn't want to surprise someone (years) later who's trying to use this test for leak detection while working on a refactoring, and is plausibly unaware that the test code has leaks. It's also possible that future tooling will flag this code.

@rwgk
Copy link
Collaborator

rwgk commented Jun 9, 2025

For completeness, I also quizzed ChatGPT:

I still have doubts about your StaticPtr design, but I'm fine with the production code changes.

Could you please rename StaticPtr to ConstSmartPtr (in addition to make the test non-leaking)? Please use a namespace in the test (e.g. namespace test_const_smart_ptr), to guard against name clashes (e.g. someone else might try to use MyData elsewhere and then get surprised by ODR violations).

@rwgk rwgk changed the title add support for const pointers in smart pointers add support for const-only pointers smart pointers Jun 9, 2025
@rwgk rwgk changed the title add support for const-only pointers smart pointers add support for const-only smart pointers Jun 9, 2025
@rwgk
Copy link
Collaborator

rwgk commented Jun 9, 2025

I think test_const_only_smart_ptr would be a better name, to clearly distinguish it from e.g. std::shard_ptr<const MyData>.

std::string name_;
};
} // namespace const_only_smart_ptr

Copy link
Collaborator

@rwgk rwgk Jun 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can put

using namespace const_only_smart_ptr;

here. That's local to this translation unit (no danger of name clashes). The only important part is that the definitions are inside a namespace.

I tried that locally, to be sure what I'm writing actually works, then pushed it.

It's now test_const_only_smart_ptr, which is what we usually have here.

@rwgk
Copy link
Collaborator

rwgk commented Jun 9, 2025

ChatGPT is suggesting the below. Could you please adapt?

Here is the full conversation (which also explains why I think it's good to go the extra mile, even though it's only a test): https://chatgpt.com/share/684768ba-fc1c-8008-bdbf-f31844b3ec4d

template <class T>
class non_sync_const_shared_ptr {
public:
    non_sync_const_shared_ptr() = default;
    explicit non_sync_const_shared_ptr(T *ptr) : ptr_(ptr) {
        if (ptr_) {
            ref_count_ = new std::size_t(1);
        }
    }
    non_sync_const_shared_ptr(const non_sync_const_shared_ptr &other)
        : ptr_(other.ptr_), ref_count_(other.ref_count_) {
        if (ref_count_) {
            ++(*ref_count_);
        }
    }
    non_sync_const_shared_ptr &operator=(const non_sync_const_shared_ptr &other) {
        if (this != &other) {
            release();
            ptr_ = other.ptr_;
            ref_count_ = other.ref_count_;
            if (ref_count_) {
                ++(*ref_count_);
            }
        }
        return *this;
    }
    ~non_sync_const_shared_ptr() {
        release();
    }

    const T *get() const { return ptr_; }
    const T &operator*() const { return *ptr_; }
    const T *operator->() const { return ptr_; }
    explicit operator bool() const { return ptr_ != nullptr; }

private:
    void release() {
        if (ref_count_) {
            --(*ref_count_);
            if (*ref_count_ == 0) {
                delete ptr_;
                delete ref_count_;
            }
        }
    }

    const T *ptr_ = nullptr;
    std::size_t *ref_count_ = nullptr;
};

@rwgk
Copy link
Collaborator

rwgk commented Jun 9, 2025

claude.ai has it's own opinion and suggested fixes:

https://claude.ai/share/8eaaf0d9-04ba-48cd-a858-ac0f13ce97bb

I'll stop here. I hope it's pretty easy for you to pick out what's valuable.

@rwgk
Copy link
Collaborator

rwgk commented Jun 10, 2025

Sorry, the test is getting way too big now (partially my fault).

Offline @henryiii suggested simply adding something like a minimal const_only_smart_ptr to the existing test_smart_ptr.cpp,py. I think that'll be best in this case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants