From 147712215bb3be2d606b4182c09e7983b5957176 Mon Sep 17 00:00:00 2001 From: Revar Desmera Date: Thu, 1 Oct 2026 20:25:43 -0700 Subject: [PATCH] Share list storage between prefixes so concat appends in place A list is now a view of the first n elements of a buffer that longer lists may share. concat(acc, ...) extends acc's buffer in place when acc ends at the buffer's frontier and there is room (claiming the slots with one compare-and-swap), and otherwise copies -- into double the room if the list came from appending before, exactly sized if not. Old lists stay valid whatever still holds them, so this works through the interpreter's retained call chain too. The idiomatic tail-recursive accumulator, f(i, concat(acc, [x])), goes from quadratic to linear: a million appends in 0.44 s against an estimated 40 minutes, and the script in BelfrySCAD#668 now reaches the recursion limit in 1.5 s. ListItems keeps the old vector's read interface; its conversion to std::vector is explicit so no copy happens by accident. Co-Authored-By: Claude Opus 5.5 --- bindings/module.cpp | 7 +-- include/openscad_cpp_evaluator/value.hpp | 67 +++++++++++++++++++++-- src/builtins/booleans.cpp | 2 +- src/builtins/function_builtins.cpp | 23 +++++--- src/builtins/primitives_3d.cpp | 2 +- src/expr_eval.cpp | 2 +- src/value.cpp | 54 +++++++++++++++---- tests/test_function_builtins.cpp | 18 +++---- tests/test_value.cpp | 68 ++++++++++++++++++++++++ 9 files changed, 206 insertions(+), 37 deletions(-) diff --git a/bindings/module.cpp b/bindings/module.cpp index f38d938..1577ec2 100644 --- a/bindings/module.cpp +++ b/bindings/module.cpp @@ -73,9 +73,10 @@ oscadeval::Value pyToValue(nb::handle v) { if (nb::isinstance(v) || nb::isinstance(v)) return oscadeval::Value{nb::cast(v)}; if (nb::isinstance(v)) return oscadeval::Value{nb::cast(v)}; if (nb::isinstance(v) || nb::isinstance(v)) { - oscadeval::ValueList vl; - for (nb::handle item : nb::borrow(v)) vl.items.push_back(pyToValue(item)); - return oscadeval::Value{oscadeval::ListPtr(std::make_shared(std::move(vl)))}; + std::vector items; + for (nb::handle item : nb::borrow(v)) items.push_back(pyToValue(item)); + return oscadeval::Value{oscadeval::ListPtr( + std::make_shared(oscadeval::ValueList{std::move(items)}))}; } return oscadeval::Value{}; // monostate = undef } diff --git a/include/openscad_cpp_evaluator/value.hpp b/include/openscad_cpp_evaluator/value.hpp index 8668a3f..a4ae6af 100644 --- a/include/openscad_cpp_evaluator/value.hpp +++ b/include/openscad_cpp_evaluator/value.hpp @@ -2,7 +2,9 @@ #include "openscad_cpp_evaluator/osc_range.hpp" +#include #include +#include #include #include #include @@ -98,13 +100,70 @@ inline std::shared_ptr> capturedLetTrail(const Closure& c) { return std::static_pointer_cast>(c.capturedLet); } -// Defined after Value so both can hold Value by value -- the standard -// recursive-variant pattern (indirection through a forward-declared, -// heap-allocated aggregate). +// A list's elements: a read-only view of the first `size()` elements of a +// buffer that other lists may share. Lists that are prefixes of one another +// share one buffer, which is what makes repeated appending cheap: `acc` +// and `concat(acc, [x])` are the same buffer, the second one element +// longer (listAppend). Defined after Value so both can hold Value by value +// -- the standard recursive-variant pattern. +// +// The buffer only ever grows within the capacity it was given, so element +// addresses never move while any view of it is alive, and an element, once +// written, is never changed. `used` is how far the longest view reaches; +// only a list ending exactly there may extend it, claiming the new slots +// with one compare-and-swap, so two lists sharing a prefix can never write +// the same slot (the loser copies). +struct ListBuffer { + std::vector v; + std::atomic used; + // Set when this buffer came from appending: a list that was appended + // to once is likely to be again, so ITS copies grow geometrically, + // while a one-off concat's result is allocated at exactly its size. + bool fromAppend = false; + explicit ListBuffer(std::vector&& items) : v(std::move(items)), used(v.size()) {} +}; + +class ListItems { +public: + ListItems() = default; + ListItems(std::vector&& items) // NOLINT: implicit, as the old vector was + : buf_(std::make_shared(std::move(items))), n_(buf_->v.size()) {} + ListItems(const std::vector& items) : ListItems(std::vector(items)) {} // NOLINT + ListItems(std::initializer_list items) : ListItems(std::vector(items)) {} + + size_t size() const { return n_; } + bool empty() const { return n_ == 0; } + const Value* data() const { return buf_ ? buf_->v.data() : nullptr; } + const Value* begin() const { return data(); } + const Value* end() const { return data() + n_; } + const Value& operator[](size_t i) const { return data()[i]; } + const Value& at(size_t i) const; + const Value& front() const { return data()[0]; } + const Value& back() const { return data()[n_ - 1]; } + // A copy as a plain vector, for the few callers that build on one. + // Explicit, so a copy is never made by accident binding a reference. + explicit operator std::vector() const { return std::vector(begin(), end()); } + std::vector toVector() const { return std::vector(begin(), end()); } + +private: + ListItems(std::shared_ptr buf, size_t n) : buf_(std::move(buf)), n_(n) {} + std::shared_ptr buf_; + size_t n_ = 0; + friend ListPtr listAppend(const ListPtr& base, std::vector&& extra); +}; + struct ValueList { - std::vector items; + ListItems items; }; +// `base` followed by `extra`, sharing base's buffer whenever base is the +// longest list using it and the buffer has room: amortized O(|extra|) +// rather than O(|base| + |extra|), which is what turns the idiomatic +// tail-recursive accumulator, f(i, concat(acc, [x])), from quadratic into +// linear. Safe whatever else still holds `base`: base keeps seeing only +// its own elements. +ListPtr listAppend(const ListPtr& base, std::vector&& extra); + // Insertion-ordered key/value pairs, not a map: object()'s iteration order // and `==` are order-sensitive (doc: openscad_evaluator/docs/evaluator.md, // the object() entry). diff --git a/src/builtins/booleans.cpp b/src/builtins/booleans.cpp index 673ebed..bb534ca 100644 --- a/src/builtins/booleans.cpp +++ b/src/builtins/booleans.cpp @@ -313,7 +313,7 @@ std::vector generateCsg(Evaluator& ev, const CSGParams& params, con const std::string& op = std::get(params.at("op")); const auto& groupSizes = std::get(params.at("group_sizes"))->items; const auto emptyIsAGroupIt = params.find("empty_is_a_group"); - const std::vector* emptyIsAGroup = + const ListItems* emptyIsAGroup = emptyIsAGroupIt == params.end() ? nullptr : &std::get(emptyIsAGroupIt->second)->items; diff --git a/src/builtins/function_builtins.cpp b/src/builtins/function_builtins.cpp index f45e82c..43b7aa1 100644 --- a/src/builtins/function_builtins.cpp +++ b/src/builtins/function_builtins.cpp @@ -616,11 +616,11 @@ Value builtinSearch(const CallArgs& args, Evaluator& ev, const oscad::Position* const unsigned numReturns = static_cast(static_cast(nrRaw)); const unsigned indexCol = static_cast(static_cast(icRaw)); - static const std::vector kNoItems; + static const ListItems kNoItems; const ListPtr* tablePtr = std::get_if(&tableArg); - const std::vector& table = (tablePtr && *tablePtr) ? (*tablePtr)->items : kNoItems; + const ListItems& table = (tablePtr && *tablePtr) ? (*tablePtr)->items : kNoItems; - const auto itemsOf = [](const Value& v) -> const std::vector& { + const auto itemsOf = [](const Value& v) -> const ListItems& { const ListPtr* l = std::get_if(&v); return (l && *l) ? (*l)->items : kNoItems; }; @@ -629,7 +629,7 @@ Value builtinSearch(const CallArgs& args, Evaluator& ev, const oscad::Position* // only when the entry really is a long enough vector. const auto hits = [&](const Value& needle, const Value& entry) { if (indexCol == 0 && oscEqual(needle, entry)) return true; - const std::vector& ev2 = itemsOf(entry); + const ListItems& ev2 = itemsOf(entry); return indexCol < ev2.size() && oscEqual(needle, ev2[indexCol]); }; const auto num = [](size_t j) { return Value{static_cast(j)}; }; @@ -682,7 +682,7 @@ Value builtinSearch(const CallArgs& args, Evaluator& ev, const oscad::Position* unsigned matchCount = 0; std::vector resultvec; for (size_t j = 0; j < table.size(); ++j) { - const std::vector& entryVec = itemsOf(table[j]); + const ListItems& entryVec = itemsOf(table[j]); if (entryVec.size() <= indexCol) { ev.warn("Invalid entry in search vector at index " + std::to_string(j) + ", required number of values in the entry: " + std::to_string(indexCol + 1) + @@ -1162,9 +1162,9 @@ bool checkMinMax(Evaluator& ev, const std::string& name, const CallArgs& args, c return false; } if (nPos == 1 && std::holds_alternative(positionalAt(args, 0))) { - static const std::vector kEmptyItems; + static const ListItems kEmptyItems; const ListPtr& l = std::get(positionalAt(args, 0)); - const std::vector& items = l ? l->items : kEmptyItems; + const ListItems& items = l ? l->items : kEmptyItems; if (items.empty()) { warnArity(ev, name, "at least 1 vector element", 0, pos); return false; @@ -1346,14 +1346,21 @@ Value evalBuiltinFunctionResolved(Evaluator& ev, BuiltinFnId id, const std::vect toDoubleLenient(getArg(args, 2, "value_count", Value{})), getArg(args, 3, "seed", Value{})); } case BuiltinFnId::Concat: { + // Everything after the first argument, appended to the first + // when it is a list: listAppend extends its buffer in place when + // it can, so an accumulator loop is linear, not quadratic. + const size_t count = positionalCount(args); + const ListPtr* first = count ? std::get_if(&positionalAt(args, 0)) : nullptr; std::vector out; - for (const Value& a : allPositional(args)) { + for (size_t i = (first && *first) ? 1 : 0; i < count; ++i) { + const Value& a = positionalAt(args, i); if (const ListPtr* l = std::get_if(&a); l && *l) { out.insert(out.end(), (*l)->items.begin(), (*l)->items.end()); } else { out.push_back(a); } } + if (first && *first) return Value{listAppend(*first, std::move(out))}; return listOf(std::move(out)); } case BuiltinFnId::Len: { diff --git a/src/builtins/primitives_3d.cpp b/src/builtins/primitives_3d.cpp index cc8c3b7..c290853 100644 --- a/src/builtins/primitives_3d.cpp +++ b/src/builtins/primitives_3d.cpp @@ -133,7 +133,7 @@ CSGParams resolveCube(Evaluator& ev, const oscad::ModularCall& node, EvalContext } else if (l && *l && (*l)->items.size() == 3 && std::all_of((*l)->items.begin(), (*l)->items.end(), [](const Value& v) { return std::holds_alternative(v); })) { - sizeVec = (*l)->items; + sizeVec = (*l)->items.toVector(); } else if (!std::holds_alternative(sizeArg)) { ev.warn("Unable to convert cube(size=" + fmtValue(sizeArg) + ", ...) parameter to a number or a vec3 of numbers", diff --git a/src/expr_eval.cpp b/src/expr_eval.cpp index 33e4455..dac9b09 100644 --- a/src/expr_eval.cpp +++ b/src/expr_eval.cpp @@ -42,7 +42,7 @@ std::optional valueLess(const Value& a, const Value& b) { const ListPtr& la = std::get(a); const ListPtr& lb = std::get(b); - static const std::vector kEmpty; + static const ListItems kEmpty; const auto& ia = la ? la->items : kEmpty; const auto& ib = lb ? lb->items : kEmpty; const size_t n = std::min(ia.size(), ib.size()); diff --git a/src/value.cpp b/src/value.cpp index de17c1f..f58b17d 100644 --- a/src/value.cpp +++ b/src/value.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace oscadeval { @@ -205,8 +206,8 @@ namespace { // matrix*matrix branch of multiply_visitor -- including which check fires // first, since that decides which diagnostic a malformed operand produces. // `fail` records the message and returns undef. -const std::vector& itemsOf(const Value& v) { - static const std::vector kEmpty; +const ListItems& itemsOf(const Value& v) { + static const ListItems kEmpty; const ListPtr* l = std::get_if(&v); return (l && *l) ? (*l)->items : kEmpty; } @@ -219,7 +220,7 @@ Value fail(std::string* error, std::string message) { } // Vector dot product. Sizes are equal by the caller's own check. -Value multVecVec(const std::vector& v1, const std::vector& v2, std::string* error) { +Value multVecVec(const ListItems& v1, const ListItems& v2, std::string* error) { double r = 0.0; for (size_t i = 0; i < v1.size(); ++i) { if (!isNum(v1[i]) || !isNum(v2[i])) { @@ -230,11 +231,11 @@ Value multVecVec(const std::vector& v1, const std::vector& v2, std return Value{r}; } -Value multMatVec(const std::vector& mat, const std::vector& vec, std::string* error) { +Value multMatVec(const ListItems& mat, const ListItems& vec, std::string* error) { std::vector out; out.reserve(mat.size()); for (size_t i = 0; i < mat.size(); ++i) { - const std::vector& row = itemsOf(mat[i]); + const ListItems& row = itemsOf(mat[i]); if (!isVec(mat[i]) || row.size() != vec.size()) { return fail(error, "Matrix must be rectangular. Problem at row " + std::to_string(i)); } @@ -254,14 +255,14 @@ Value multMatVec(const std::vector& mat, const std::vector& vec, s return makeList(std::move(out)); } -Value multVecMat(const std::vector& vec, const std::vector& mat, std::string* error) { +Value multVecMat(const ListItems& vec, const ListItems& mat, std::string* error) { const size_t firstRowSize = itemsOf(mat[0]).size(); std::vector out; out.reserve(firstRowSize); for (size_t i = 0; i < firstRowSize; ++i) { double re = 0.0; for (size_t j = 0; j < vec.size(); ++j) { - const std::vector& row = itemsOf(mat[j]); + const ListItems& row = itemsOf(mat[j]); if (!isVec(mat[j]) || row.size() != firstRowSize) { return fail(error, "Matrix must be rectangular. Problem at row " + std::to_string(j)); } @@ -282,8 +283,8 @@ Value multVecMat(const std::vector& vec, const std::vector& mat, s } // namespace Value matmul(const Value& a, const Value& b, std::string* error) { - const std::vector& al = itemsOf(a); - const std::vector& bl = itemsOf(b); + const ListItems& al = itemsOf(a); + const ListItems& bl = itemsOf(b); // The reference checks emptiness before anything else, so `[] * [1,2]` // is this message rather than a length mismatch. if (al.empty() || bl.empty()) return fail(error, "Multiplication is undefined on empty vectors"); @@ -323,7 +324,7 @@ Value matmul(const Value& a, const Value& b, std::string* error) { std::vector rows; rows.reserve(al.size()); for (size_t i = 0; i < al.size(); ++i) { - const std::vector& srcRow = itemsOf(al[i]); + const ListItems& srcRow = itemsOf(al[i]); if (srcRow.size() != bl.size()) { return fail(error, "matrix*matrix left operand row length does not match right operand row count (" + @@ -618,4 +619,37 @@ void appendEachInto(std::vector& out, const Value& v) { if (!std::holds_alternative(v)) out.push_back(v); } +const Value& ListItems::at(size_t i) const { + if (i >= n_) throw std::out_of_range("list index out of range"); + return data()[i]; +} + +ListPtr listAppend(const ListPtr& base, std::vector&& extra) { + if (!base) return std::make_shared(ValueList{std::move(extra)}); + if (extra.empty()) return base; + const ListItems& items = base->items; + const size_t n = items.n_, k = extra.size(); + if (const auto& buf = items.buf_; buf && buf->v.capacity() - n >= k) { + size_t expected = n; + if (buf->used.compare_exchange_strong(expected, n + k)) { + // This list ended at the buffer's frontier and now owns the + // slots after it. Within capacity, push_back never reallocates, + // so every other view's elements stay where they are. + for (Value& x : extra) buf->v.push_back(std::move(x)); + return std::make_shared(ValueList{ListItems(buf, n + k)}); + } + } + // Copy: a fresh buffer, given room to grow if this list has been + // appended to before (the accumulator case), exact otherwise. + const bool growing = items.buf_ && items.buf_->fromAppend; + std::vector out; + out.reserve(growing ? std::max(2 * (n + k), 16) : n + k); + out.insert(out.end(), items.begin(), items.end()); + for (Value& x : extra) out.push_back(std::move(x)); + auto buf = std::make_shared(std::move(out)); + buf->fromAppend = true; + const size_t len = buf->v.size(); + return std::make_shared(ValueList{ListItems(std::move(buf), len)}); +} + } // namespace oscadeval diff --git a/tests/test_function_builtins.cpp b/tests/test_function_builtins.cpp index b24cc04..4095c42 100644 --- a/tests/test_function_builtins.cpp +++ b/tests/test_function_builtins.cpp @@ -23,7 +23,7 @@ double asNum(const Value& v) { return std::get(v); } bool asBool(const Value& v) { return std::get(v); } bool isUndef(const Value& v) { return std::holds_alternative(v); } std::string asStr(const Value& v) { return std::get(v); } -const std::vector& asList(const Value& v) { return std::get(v)->items; } +const ListItems& asList(const Value& v) { return std::get(v)->items; } } // namespace @@ -742,7 +742,7 @@ TEST(LinearSolve, SolvesASquareSystem) { // asList() returns a reference INTO v, so v has to outlive it -- taking // asList(evalSrc(...)) directly dangles the moment the temporary dies. const Value v = evalSrc("linear_solve([[2,1],[1,3]], [5,10]).x", ev); - const std::vector& x = asList(v); + const ListItems& x = asList(v); ASSERT_EQ(x.size(), 2u); EXPECT_NEAR(asNum(x[0]), 1.0, 1e-12); EXPECT_NEAR(asNum(x[1]), 3.0, 1e-12); @@ -779,7 +779,7 @@ TEST(LinearSolve, SingularityIsRelativeToTheMatrixNotAbsolute) { Evaluator ev; EXPECT_FALSE(asBool(evalSrc("linear_solve([[2e-10,0],[0,2e-10]], [1e-10,1e-10]).singular", ev))); const Value v = evalSrc("linear_solve([[2e-10,0],[0,2e-10]], [1e-10,1e-10]).x", ev); - const std::vector& x = asList(v); + const ListItems& x = asList(v); ASSERT_EQ(x.size(), 2u); EXPECT_NEAR(asNum(x[0]), 0.5, 1e-9); } @@ -789,9 +789,9 @@ TEST(LinearSolve, AMatrixRightHandSideSolvesEveryColumn) { // Solving against the identity is the inverse; [[2,1],[1,3]] has // det 5, so the inverse is [[0.6,-0.2],[-0.2,0.4]]. const Value v = evalSrc("linear_solve([[2,1],[1,3]], [[1,0],[0,1]]).x", ev); - const std::vector& rows = asList(v); + const ListItems& rows = asList(v); ASSERT_EQ(rows.size(), 2u); - const std::vector& r0 = asList(rows[0]); + const ListItems& r0 = asList(rows[0]); ASSERT_EQ(r0.size(), 2u); EXPECT_NEAR(asNum(r0[0]), 0.6, 1e-12); EXPECT_NEAR(asNum(r0[1]), -0.2, 1e-12); @@ -814,7 +814,7 @@ TEST(LinearSolve, OverdeterminedGivesTheLeastSquaresFit) { // y = 1 + 2x, so the residual is zero and a=1, b=2. Evaluator ev; const Value v = evalSrc("linear_solve([[1,0],[1,1],[1,2],[1,3]], [1,3,5,7]).x", ev); - const std::vector& x = asList(v); + const ListItems& x = asList(v); ASSERT_EQ(x.size(), 2u); EXPECT_NEAR(asNum(x[0]), 1.0, 1e-10); EXPECT_NEAR(asNum(x[1]), 2.0, 1e-10); @@ -826,7 +826,7 @@ TEST(LinearSolve, OverdeterminedWithNoExactSolutionMinimisesTheResidual) { // read off this implementation. Evaluator ev; const Value v = evalSrc("linear_solve([[1,0],[1,1],[1,2],[1,3]], [0,1,2,10]).x", ev); - const std::vector& x = asList(v); + const ListItems& x = asList(v); ASSERT_EQ(x.size(), 2u); EXPECT_NEAR(asNum(x[0]), -1.4, 1e-9); EXPECT_NEAR(asNum(x[1]), 3.1, 1e-9); @@ -838,7 +838,7 @@ TEST(LinearSolve, UnderdeterminedGivesTheMinimUmNormSolution) { // asserts the NORM -- that is the whole claim of a minimum-norm solve. Evaluator ev; const Value v = evalSrc("linear_solve([[1,1]], [2]).x", ev); - const std::vector& x = asList(v); + const ListItems& x = asList(v); ASSERT_EQ(x.size(), 2u); EXPECT_NEAR(asNum(x[0]), 1.0, 1e-12); EXPECT_NEAR(asNum(x[1]), 1.0, 1e-12); @@ -848,7 +848,7 @@ TEST(LinearSolve, UnderdeterminedSatisfiesEveryEquation) { // 2 x 3: the plane_intersection shape BOSL2 relies on. Evaluator ev; const Value v = evalSrc("linear_solve([[1,0,1],[0,1,1]], [3,5]).x", ev); - const std::vector& x = asList(v); + const ListItems& x = asList(v); ASSERT_EQ(x.size(), 3u); const double a = asNum(x[0]), b = asNum(x[1]), c = asNum(x[2]); EXPECT_NEAR(a + c, 3.0, 1e-12); diff --git a/tests/test_value.cpp b/tests/test_value.cpp index 749059e..73bb248 100644 --- a/tests/test_value.cpp +++ b/tests/test_value.cpp @@ -607,3 +607,71 @@ TEST(StringEscapes, ABackslashBeforeANewlineTakesTheWholeLineEnding) { EXPECT_EQ(unescapeStringLiteral("x\\\r\ny"), "xy"); // reference keeps the CR EXPECT_EQ(unescapeStringLiteral("x\\\ry"), "xy"); } + +// --- listAppend: lists that are prefixes of one another share a buffer --- + +namespace { +ListPtr numsList(std::initializer_list xs) { + std::vector v; + for (double x : xs) v.push_back(Value{x}); + return std::make_shared(ValueList{std::move(v)}); +} +std::vector nums(const ListPtr& l) { + std::vector out; + for (const Value& v : l->items) out.push_back(std::get(v)); + return out; +} +std::vector one(double x) { return {Value{x}}; } +} // namespace + +TEST(ListAppend, LeavesTheBaseListAsItWas) { + const ListPtr base = numsList({1, 2}); + const ListPtr longer = listAppend(base, one(3)); + EXPECT_EQ(nums(base), (std::vector{1, 2})); + EXPECT_EQ(nums(longer), (std::vector{1, 2, 3})); +} + +TEST(ListAppend, AnAccumulatorCopiesOnlyLogarithmicallyOften) { + // Appends extend the same storage in place, copying only when it fills + // and then into double the room: 10,000 appends, a handful of copies -- + // where copying every time (the old concat) made the loop quadratic. + ListPtr acc = numsList({0}); + int copies = 0; + for (int i = 1; i < 10000; ++i) { + const ListPtr next = listAppend(acc, one(i)); + if (next->items.data() != acc->items.data()) ++copies; + acc = next; // the previous list is still alive here, as in a call chain + } + EXPECT_LE(copies, 12); + ASSERT_EQ(acc->items.size(), 10000u); + for (size_t i = 0; i < 10000; ++i) EXPECT_EQ(std::get(acc->items[i]), double(i)); +} + +TEST(ListAppend, TwoBranchesFromOnePrefixStayApart) { + // Only the list ending at the buffer's frontier may extend it; a second + // extension of the same prefix must copy rather than overwrite the + // first one's element. + const ListPtr base = listAppend(listAppend(numsList({0}), one(1)), one(2)); // has spare capacity + const ListPtr a = listAppend(base, one(10)); + const ListPtr b = listAppend(base, one(20)); + EXPECT_EQ(nums(a), (std::vector{0, 1, 2, 10})); + EXPECT_EQ(nums(b), (std::vector{0, 1, 2, 20})); + EXPECT_EQ(nums(base), (std::vector{0, 1, 2})); + // ...and each branch can go on growing independently. + EXPECT_EQ(nums(listAppend(a, one(11))), (std::vector{0, 1, 2, 10, 11})); + EXPECT_EQ(nums(listAppend(b, one(21))), (std::vector{0, 1, 2, 20, 21})); +} + +TEST(ListAppend, AOneOffConcatIsAllocatedExactly) { + // Only a list that has been appended to grows geometrically, so a plain + // concat(a, b) costs no spare memory. + const ListPtr r = listAppend(numsList({1, 2, 3}), one(4)); + EXPECT_EQ(r->items.size(), 4u); + EXPECT_EQ(nums(r), (std::vector{1, 2, 3, 4})); +} + +TEST(ListAppend, EmptyExtraAndNullBase) { + const ListPtr base = numsList({1}); + EXPECT_EQ(listAppend(base, {}), base); + EXPECT_EQ(nums(listAppend(nullptr, one(5))), (std::vector{5})); +}