diff options
| author | Stefan Boberg <[email protected]> | 2026-05-05 14:27:20 +0200 |
|---|---|---|
| committer | Stefan Boberg <[email protected]> | 2026-05-05 14:27:20 +0200 |
| commit | 5fc88d696911da2ff97275b5f5dc7d10775696ba (patch) | |
| tree | 3346cb3a661bb1f2ab5a0794135a612dfdd2238d /src | |
| parent | watchdog ephemeral port exhaust (#1022) (diff) | |
| download | archived-zen-sb/shared-string.tar.xz archived-zen-sb/shared-string.zip | |
zencore: add SharedString with intrusive atomic refcountsb/shared-string
Mirrors CompactString's compact length-prefix layout but stores an
atomic<uint32_t> in the buffer header so multiple instances can share
a single allocation. Copies just bump the refcount; the buffer is
freed when the last referencing instance is destroyed.
Diffstat (limited to 'src')
| -rw-r--r-- | src/zencore/include/zencore/string.h | 135 | ||||
| -rw-r--r-- | src/zencore/string.cpp | 144 |
2 files changed, 279 insertions, 0 deletions
diff --git a/src/zencore/include/zencore/string.h b/src/zencore/include/zencore/string.h index 53bfd196d..b62f4a4d1 100644 --- a/src/zencore/include/zencore/string.h +++ b/src/zencore/include/zencore/string.h @@ -7,6 +7,7 @@ #include <stdint.h> #include <string.h> +#include <atomic> #include <charconv> #include <compare> #include <concepts> @@ -1465,6 +1466,140 @@ private: ////////////////////////////////////////////////////////////////////////// +/// Owns a heap-allocated, null-terminated string buffer that can be cheaply +/// shared between instances via an intrusive atomic reference count stored +/// in the buffer header. Copying a SharedString just bumps the refcount; +/// the underlying bytes are released when the last referencing instance is +/// destroyed. +/// +/// The buffer layout is [refcount][length-prefix][chars...][NUL]. The +/// length-prefix scheme matches CompactString: a byte below 0xFF stores the +/// literal length, while 0xFF is a sentinel meaning "length >= 255", +/// recovered via strlen() past the prefix. +/// +/// Empty strings carry no allocation; the default-constructed state is the +/// canonical empty SharedString, and constructing from an empty view leaves +/// the header pointer null. +class SharedString +{ +public: + SharedString() = default; + + explicit SharedString(std::string_view Str) + { + if (Str.empty()) + { + return; + } + m_Header = AllocateBuffer(Str); + } + + ~SharedString() { ReleaseBuffer(m_Header); } + + SharedString(const SharedString& Other) noexcept : m_Header(Other.m_Header) + { + if (m_Header) + { + m_Header->RefCount.fetch_add(1, std::memory_order_relaxed); + } + } + + SharedString& operator=(const SharedString& Other) noexcept + { + if (m_Header != Other.m_Header) + { + if (Other.m_Header) + { + Other.m_Header->RefCount.fetch_add(1, std::memory_order_relaxed); + } + ReleaseBuffer(m_Header); + m_Header = Other.m_Header; + } + return *this; + } + + SharedString(SharedString&& Other) noexcept : m_Header(Other.m_Header) { Other.m_Header = nullptr; } + + SharedString& operator=(SharedString&& Other) noexcept + { + if (this != &Other) + { + ReleaseBuffer(m_Header); + m_Header = Other.m_Header; + Other.m_Header = nullptr; + } + return *this; + } + + const char* c_str() const { return m_Header ? Chars(m_Header) : ""; } + + size_t Size() const + { + if (!m_Header) + { + return 0; + } + const uint8_t Prefix = static_cast<uint8_t>(*PrefixPtr(m_Header)); + return Prefix < LengthFallbackSentinel ? Prefix : LengthFallbackSentinel + strlen(Chars(m_Header) + LengthFallbackSentinel); + } + + std::string_view ToView() const { return {c_str(), Size()}; } + bool IsEmpty() const { return m_Header == nullptr; } + + /// Number of SharedString instances currently sharing this buffer. + /// Returns 0 for the empty state. Intended for tests and diagnostics. + uint32_t UseCount() const { return m_Header ? m_Header->RefCount.load(std::memory_order_relaxed) : 0; } + + operator std::string_view() const { return ToView(); } + +private: + static constexpr uint8_t LengthFallbackSentinel = 0xFF; + + struct Header + { + std::atomic<uint32_t> RefCount; + }; + + static_assert(sizeof(Header) == sizeof(uint32_t), "SharedString::Header must be a single 32-bit refcount with no padding"); + + Header* m_Header = nullptr; + + static char* PrefixPtr(Header* H) { return reinterpret_cast<char*>(H) + sizeof(Header); } + static const char* PrefixPtr(const Header* H) { return reinterpret_cast<const char*>(H) + sizeof(Header); } + static const char* Chars(const Header* H) { return PrefixPtr(H) + 1; } + + static Header* AllocateBuffer(std::string_view Str) + { + // new char[] is required to return memory aligned for any fundamental + // type whose size fits in the allocation, so placement-new of the + // atomic header at offset 0 is well-defined. + const size_t TotalBytes = sizeof(Header) + 1 + Str.size() + 1; + char* Bytes = new char[TotalBytes]; + Header* H = new (Bytes) Header{}; + H->RefCount.store(1, std::memory_order_relaxed); + char* Prefix = Bytes + sizeof(Header); + Prefix[0] = static_cast<char>(Str.size() < LengthFallbackSentinel ? Str.size() : LengthFallbackSentinel); + memcpy(Prefix + 1, Str.data(), Str.size()); + Prefix[1 + Str.size()] = '\0'; + return H; + } + + static void ReleaseBuffer(Header* H) noexcept + { + if (!H) + { + return; + } + if (H->RefCount.fetch_sub(1, std::memory_order_acq_rel) == 1) + { + H->~Header(); + delete[] reinterpret_cast<char*>(H); + } + } +}; + +////////////////////////////////////////////////////////////////////////// + void string_forcelink(); // internal } // namespace zen diff --git a/src/zencore/string.cpp b/src/zencore/string.cpp index 4072aec56..8489841f8 100644 --- a/src/zencore/string.cpp +++ b/src/zencore/string.cpp @@ -1487,6 +1487,150 @@ TEST_CASE("CompactString.implicit_conversion") CHECK(V == std::string_view("view")); } +TEST_CASE("SharedString.default") +{ + SharedString S; + CHECK(S.IsEmpty()); + CHECK(S.Size() == 0); + CHECK(S.ToView() == std::string_view()); + CHECK(S.c_str()[0] == '\0'); + CHECK(S.UseCount() == 0); +} + +TEST_CASE("SharedString.empty") +{ + // Empty input skips allocation: empty == default state. + SharedString S(std::string_view("")); + CHECK(S.IsEmpty()); + CHECK(S.Size() == 0); + CHECK(S.UseCount() == 0); +} + +TEST_CASE("SharedString.short") +{ + SharedString S(std::string_view("hello")); + CHECK(!S.IsEmpty()); + CHECK(S.Size() == 5); + CHECK(S.ToView() == std::string_view("hello")); + CHECK(S.UseCount() == 1); +} + +TEST_CASE("SharedString.sentinel_boundary") +{ + // 254 chars — largest value that fits in the prefix byte + std::string Str254(254, 'x'); + std::string_view View254(Str254); + SharedString S(View254); + CHECK(S.Size() == 254); + CHECK(S.ToView() == View254); +} + +TEST_CASE("SharedString.sentinel_exact") +{ + // 255 chars — hits the 0xFF sentinel, falls back to strlen + std::string Str255(255, 'y'); + std::string_view View255(Str255); + SharedString S(View255); + CHECK(S.Size() == 255); + CHECK(S.ToView() == View255); +} + +TEST_CASE("SharedString.long") +{ + std::string Str512(512, 'z'); + std::string_view View512(Str512); + SharedString S(View512); + CHECK(S.Size() == 512); + CHECK(S.ToView() == View512); +} + +TEST_CASE("SharedString.move") +{ + SharedString A(std::string_view("test")); + SharedString B(std::move(A)); + CHECK(A.IsEmpty()); + CHECK(B.ToView() == std::string_view("test")); + CHECK(B.UseCount() == 1); + + SharedString C(std::string_view("first")); + SharedString D(std::string_view("second")); + D = std::move(C); + CHECK(C.IsEmpty()); + CHECK(D.ToView() == std::string_view("first")); + CHECK(D.UseCount() == 1); +} + +TEST_CASE("SharedString.copy_shares_buffer") +{ + SharedString A(std::string_view("shared")); + CHECK(A.UseCount() == 1); + + SharedString B(A); + CHECK(A.UseCount() == 2); + CHECK(B.UseCount() == 2); + CHECK(A.ToView() == std::string_view("shared")); + CHECK(B.ToView() == std::string_view("shared")); + // Both views must point at the same underlying bytes. + CHECK(A.c_str() == B.c_str()); + + { + SharedString C = A; + CHECK(A.UseCount() == 3); + CHECK(C.c_str() == A.c_str()); + } + // C has gone out of scope; refcount drops back. + CHECK(A.UseCount() == 2); +} + +TEST_CASE("SharedString.copy_assign_releases_old") +{ + SharedString A(std::string_view("alpha")); + SharedString B(std::string_view("beta")); + CHECK(A.UseCount() == 1); + CHECK(B.UseCount() == 1); + + B = A; + // B's old buffer is released; both now share A's buffer. + CHECK(A.UseCount() == 2); + CHECK(B.UseCount() == 2); + CHECK(B.ToView() == std::string_view("alpha")); + CHECK(A.c_str() == B.c_str()); +} + +TEST_CASE("SharedString.self_assign") +{ + SharedString A(std::string_view("self")); + CHECK(A.UseCount() == 1); + + // Self copy-assignment must not release the buffer or change refcount. + SharedString& Aref = A; + A = Aref; + CHECK(A.UseCount() == 1); + CHECK(A.ToView() == std::string_view("self")); + + // Self move-assignment must also be safe. + A = std::move(Aref); + CHECK(A.UseCount() == 1); + CHECK(A.ToView() == std::string_view("self")); +} + +TEST_CASE("SharedString.empty_copy") +{ + SharedString A; + SharedString B(A); + CHECK(A.IsEmpty()); + CHECK(B.IsEmpty()); + CHECK(A.UseCount() == 0); + CHECK(B.UseCount() == 0); +} + +TEST_CASE("SharedString.implicit_conversion") +{ + SharedString S(std::string_view("view")); + std::string_view V = S; + CHECK(V == std::string_view("view")); +} + TEST_SUITE_END(); void |