Repository navigation
Make rfl::CopyableBox copy-assignable - #769
Open
clockblocker wants to merge 2 commits into
Open
clockblocker wants to merge 2 commits into
clockblocker wants to merge 2 commits into
Conversation
rfl::CopyableBox is documented as copyable, but only its copy
constructor works. Copy-assigning a CopyableBox, or a struct that holds
one directly, does not compile:
error: no viable overloaded '='
error: object of type 'DecisionTree::Node' cannot be assigned
because its copy assignment operator is implicitly deleted
This test does not build until the next commit fixes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy assignment operator for Copyability::COPYABLE took a `const Box<T>&`. Inside the class template, `Box<T>` names `Box<T, Copyability::NON_COPYABLE>`, so for a CopyableBox this was an assignment from the other Box type, not a copy assignment operator. The class declares its own copy assignment (`operator=(const Box&) requires NON_COPYABLE = delete`), so the compiler does not add an implicit one either. A CopyableBox therefore had no usable copy assignment, and neither did any struct that holds a CopyableBox directly. The cross-type operator never compiled when called: `this != &other` compares pointers to distinct types. Take `const Box&` instead. The new object is still copied before the old one is destroyed, so assigning from a Box inside the replaced object (`root = root->...lesser`) is safe. This makes the test from the previous commit pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
rfl::CopyableBoxis documented as copyable, but only its copy constructor works.box2 = box1does not compile, and neither does copy-assigning a struct that holds aCopyableBoxdirectly, such asDecisionTree::Nodefrom the docs and tests.Summary
Two commits. The first adds a test that does not build on
main. The second is the fix:template <class T, Copyability C = Copyability::NON_COPYABLE> class Box { - Box& operator=(const Box<T>& other) requires (C == Copyability::COPYABLE) { + Box& operator=(const Box& other) requires (C == Copyability::COPYABLE) { if(this != &other) { ptr_ = std::make_unique<T>(*other); } return *this; } Box& operator=(const Box& _other) requires (C == Copyability::NON_COPYABLE) = delete;Cause: inside the class,
Box<T>meansBox<T, NON_COPYABLE>. For aCopyableBox, the operator was therefore an assignment from the other Box type, not a copy assignment. The deletedoperator=(const Box&)is a user-declared copy assignment, so the compiler adds no implicit one either. ACopyableBoxhad no copy assignment at all.The body is unchanged. It copy-constructs the new
Tbefore the old one is destroyed, so assigning from a Box inside the value being replaced is safe (root = root->...lesser, covered by the test).Evidence
Before (
main): the test from the first commit does not compile:After:
generic.copyable_box_copy_assignmentpasses. All local suites pass: generic 99, json 265, cli 71, env 41, json_c_arrays_and_inheritance 7.test_box.cppalso passes-Wall -Werrorsyntax checks with Apple clang 17 (-std=c++20) and GCC 16.2 (-std=c++20, and-std=c++23 -DREFLECTCPP_USE_STD_EXPECTED). Apple clang with-std=c++23fails on the two older tests intest_box.cpp(Box()is instantiated for aDecisionTreethat has no default constructor). The errors are the same onmain, so this PR does not cause them.AddressSanitizer (Clang and GCC 16), assigning from a Box inside the value it replaces:
root = root->...lesserwith the test'sDecisionTree, and with a depth-2 subtree of a depth-4 tree: clean with this PR. It is also clean with an in-place*ptr_ = *other, becauserfl::Variant::operator=copies into a temporary before it destroys the current alternative.root = root->children[0]withstruct Tree { double value; std::vector<rfl::CopyableBox<Tree>> children; }: clean with this PR. In-place gives a heap-use-after-free with GCC/libstdc++ and a container-overflow with Clang/libc++.Benchmark on
test_copyable_box::DecisionTree, Apple M1,-std=c++20 -O3 -DNDEBUG, Apple clang 17 and GCC 16.2. Each value is the best of 8 interleaved runs, and each run takes the best of 15 samples. Allocations were counted by replacingoperator new.e0ee3f6):b = adoes not compile, so this measures the only workaround,b = rfl::CopyableBox<DecisionTree>(a)andn2 = DecisionTree::Node(n1).b = aandn2 = n1.*ptr_ = *other, measured for reference.Clang on the first line, GCC on the second.
18.5 ns
18.5 ns
1.0 ns
93.2 µs
92.3 µs
92.3 µs
58.2 ns
37.0 ns
2.2 ns
93.0 µs
92.3 µs
92.0 µs
Heap allocations per assignment, Clang on the first line, GCC on the second:
1 alloc
1 alloc
0 allocs
2047 allocs
2047 allocs
2046 allocs
2 allocs
2 allocs
0 allocs
2046 allocs
2046 allocs
2044 allocs
The fix adds no cost. For boxes,
b = amatches the workaround, with the trees within ±1.2%. For a node with two leaves,n2 = n1is about 22 ns faster than the workaround, with the same allocations; the workaround also builds and destroys a temporaryNode. In-place saves the allocation, which is visible on small boxes. It is not used here because, with in-place, assigning from a Box inside the value being replaced is only safe ifT's own copy assignment allows it (see thestd::vectorcase above). The docs sayCopyableBoxcalls the contained type's copy assignment operator. The existing body copy-constructs instead, and this PR keeps that.Benchmark source (
bench.cpp)Built once per header tree (
git archive <sha> include), with-DWORKAROUNDfore0ee3f6.Merge Danger
Door: two-way
One line in a header. Reverting restores the old declaration.
Blast Radius: low
CopyableBox<T>, and structs that hold one, become copy-assignable.rfl::Boxis unchanged, and so is moving aBoxinto aCopyableBox. The removed overload, assigning a non-copyableBox<T>to aCopyableBox<T>, never compiled when called: onmain,copyable = noncopyablefails atif(this != &other)with "comparison of distinct pointer types" (Clang and GCC 16). Nothing that compiled before stops compiling. Only a trait such asstd::is_assignable_v<rfl::CopyableBox<T>&, const rfl::Box<T>&>changes, from true to false. reflect-cpp itself checks no assignability traits.🤖 Generated with Claude Code