Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ All notable changes to **ValueStringBuilder** will be documented in this file. T

## [Unreleased]

### Added

- `FixedSizeValueStringBuilder`: a non-growing `ref struct` string builder backed by a caller-supplied buffer that never allocates on the heap. Appends are atomic and the first one that does not fit latches `Overflowed`, which `ClearOverflow` resets.
- `FixedSizeValueStringBuilder.MoveToValueStringBuilder`: hands the buffer and its content over to a `ValueStringBuilder` which can grow beyond the fixed capacity. The move copies nothing and rents nothing, and consumes the source so both builders can never write into the same memory.

## [3.6.1] - 2026-09-12

### Changed
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ using ValueStringBuilder stringBuilder = new(stackalloc char[128]);
```
Note that this will prevent you from returning `stringBuilder` or assigning it to an `out` parameter.

### A buffer that is never replaced: `FixedSizeValueStringBuilder`

If the content *outgrows* that stack buffer, `ValueStringBuilder` quietly rents a larger one from `ArrayPool<char>.Shared`.
When you need a hard guarantee that this never happens, use `FixedSizeValueStringBuilder`:
```csharp
var builder = new FixedSizeValueStringBuilder(stackalloc char[8]);

builder.Append("123456789"); // does not fit -> nothing is written

string result = builder.ToString(); // "" - never a truncated "12345678"
bool overflowed = builder.Overflowed; // true
```
Appends are atomic: one either fits completely or is dropped, so a formatted number or a surrogate pair is never cut in
half. The first drop latches `Overflowed`, and further appends are ignored until you call `ClearOverflow()` to carry on
deliberately or `Clear()` to start over. There is no `Dispose` - nothing is ever rented.
See the [documentation](https://linkdotnet.github.io/StringBuilder/articles/fixed_size.html) for details.

## What does it solve?
The dotnet version of the `StringBuilder` is an all-purpose version that normally fits a wide variety of needs.
But sometimes, low allocation is key. Therefore I created the `ValueStringBuilder`. It is not a class but a `ref struct` that tries to allocate as little as possible.
Expand Down
27 changes: 27 additions & 0 deletions docs/site/articles/best_practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,33 @@ using var stringBuilder = new ValueStringBuilder(buffer);

You should only skip `using` when you can prove the builder will never grow.

## Reach for `FixedSizeValueStringBuilder` when allocation is not an option

The previous rule is a judgement call you have to get right yourself. If instead you need the compiler and the type to
enforce it, use [`FixedSizeValueStringBuilder`](xref:fixed_size): it has no pool fallback, so there is nothing to
dispose and no way for it to allocate.
Comment on lines +43 to +47

```csharp
var stringBuilder = new FixedSizeValueStringBuilder(stackalloc char[64]);
```

Two things behave differently from `ValueStringBuilder`, and both are deliberate:

* An append that does not fit writes **nothing at all** - a formatted value is never truncated into a different,
valid-looking value.
* After the first such append, `Overflowed` is set and every further append is ignored, even one that would still fit.
Call `ClearOverflow()` to carry on anyway, or `Clear()` to start over.

**Always check `Overflowed` before you trust the result**, and decide there whether to fall back to a growing builder:

```csharp
var stringBuilder = new FixedSizeValueStringBuilder(stackalloc char[64]);
stringBuilder.Append(prefix);
stringBuilder.Append(id);

return stringBuilder.Overflowed ? BuildWithValueStringBuilder() : stringBuilder.ToString();
```

## Prefer `new ValueStringBuilder(capacity)` for predictable medium-sized output

If you can estimate the final size but don't want stack-only restrictions, use the capacity constructor:
Expand Down
44 changes: 44 additions & 0 deletions docs/site/articles/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,48 @@ about 30% of `StringBuilder` for every multi-match case while allocating roughly
optimized and previous algorithms perform about the same, since the optimization mainly pays off once there are
several matches to batch together.

## Fixed-size string building

[`FixedSizeValueStringBuilder`](xref:fixed_size) drops the array-pool fallback entirely, which also removes the
capacity check and the rented-buffer field from every append. The first four rows below build the same
`"Hello World1337"`; the last two use an 8-character buffer that is deliberately too small.
Comment on lines +171 to +173

```no-class
BenchmarkDotNet v0.15.8, macOS 27.0 (26A428) [Darwin 27.0.0]
Apple M2 Pro, 1 CPU, 12 logical and 12 physical cores
.NET SDK 11.0.100-rc.1.26425.128
[Host] : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
DefaultJob : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
```

| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio |
|---------------------------------------- |----------:|----------:|----------:|------:|-------:|----------:|------------:|
| StringBuilderFits | 17.756 ns | 0.3315 ns | 0.2939 ns | 1.00 | 0.0191 | 160 B | 1.00 |
| ValueStringBuilderFits | 14.032 ns | 0.2064 ns | 0.1930 ns | 0.79 | 0.0067 | 56 B | 0.35 |
| ValueStringBuilderFitsWithoutGrowing | 8.410 ns | 0.2035 ns | 0.2499 ns | 0.47 | 0.0067 | 56 B | 0.35 |
| FixedSizeValueStringBuilderFits | 7.214 ns | 0.0424 ns | 0.0376 ns | 0.41 | 0.0067 | 56 B | 0.35 |
| FixedSizeValueStringBuilderInterpolated | 7.280 ns | 0.0387 ns | 0.0362 ns | 0.41 | 0.0067 | 56 B | 0.35 |
| ValueStringBuilderOverflows | 18.475 ns | 0.0570 ns | 0.0533 ns | 1.04 | 0.0067 | 56 B | 0.35 |
| FixedSizeValueStringBuilderOverflows | 1.190 ns | 0.0101 ns | 0.0089 ns | 0.07 | - | - | 0.00 |

Read the two `ValueStringBuilder` "Fits" rows together, because the difference between them is not the fixed-size
builder's doing. Both use `stackalloc`, but the second row gets 64 characters and the first only 32.
`ValueStringBuilder.Append<T>` reserves `bufferSize` (36 by default) characters *before* formatting, so with a
32-character buffer appending the `int` grows the builder even though the finished string is 15 characters long - it
rents 64 chars from `ArrayPool<char>.Shared`, copies, and returns them on `Dispose`. That round trip, not the capacity
check, is most of the 14.0 ns.

Against the row that does not grow, the honest margin is the fourth one: about 15% for identical work, which is the
`Dispose` call, the pool field, and the per-append capacity check. The 56 B that every "Fits" row allocates is the
returned `string` itself, which no builder can avoid.

The interpolated row matches the manual one byte for byte, which is the point of measuring it: the interpolated string
handler formats value-type holes without boxing them, so `$"{Text} {Id}"` costs no more than appending the two parts by
hand.

The last row is *not* the same work done faster. At 1.2 ns and 0 B it is the cost of the latch short-circuiting
everything, because nothing was written and `ToString()` returned `string.Empty`. It is included to show the bounded
worst case: overflowing a `FixedSizeValueStringBuilder` costs nothing and touches no pool, whereas the row above it
shows `ValueStringBuilder` renting, copying, and returning a larger buffer.

Checkout the [Benchmark](https://github.com/linkdotnet/StringBuilder/tree/main/tests/LinkDotNet.StringBuilder.Benchmarks) for more detailed comparison and setup.
16 changes: 16 additions & 0 deletions docs/site/articles/exceptions_and_edge_cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,20 @@ A few operations are intentionally lenient:

`EnsureCapacity(int newCapacity)` is a no-op if the current `Capacity` already satisfies `newCapacity`. Otherwise it rents a new array sized to the **smallest power of two that is `>= newCapacity`**, copies the existing content over, and returns the previous pooled array (if any) to `ArrayPool<char>.Shared`. This means capacity can grow in large jumps (e.g. requesting one more character than a full 64-character buffer rents a 128-character array), which is a deliberate trade-off to keep the number of pool rents low - see [How does it work?](xref:concepts) for the broader buffer strategy.

## Running out of room in `FixedSizeValueStringBuilder`

[`FixedSizeValueStringBuilder`](xref:fixed_size) has a hard capacity and no pool fallback, yet still throws nothing
when you exceed it. An append that does not fit is dropped whole, `Overflowed` is set, and every further append becomes
a no-op until `ClearOverflow()` or `Clear()` is called.

Two consequences are worth knowing before they surprise you:

* **`Remaining` can be greater than zero while `Overflowed` is `true`.** That is expected, not a bug - the latch, not
the free space, decides whether anything more is written.
* **An append that would comfortably fit is still dropped** once the builder has overflowed. This keeps the content a
valid prefix of what you intended instead of a string with a hole in the middle.

Reading members never throw either: `AsSpan()`, `ToString()` and the indexer all see only the characters that were
actually written, and `TryCopyTo` returns `false` rather than throwing when the destination is too small.
Comment on lines +46 to +47

For more on `Dispose()` behavior around the pooled array, including what happens on double-dispose, see [Known limitations](xref:known_limitations#dispose-guarantees).
Loading
Loading