Skip to content

Commit 4bd10a8

Browse files
committed
docs: Some updates
1 parent a1f57b2 commit 4bd10a8

5 files changed

Lines changed: 267 additions & 18 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
uid: advanced_usage
3+
---
4+
5+
# Advanced usage
6+
7+
This article goes a bit deeper than [Getting started](xref:getting_started) and shows patterns for squeezing the most performance out of `ValueStringBuilder` - mainly around providing your own buffer via `stackalloc`.
8+
9+
## Using a stack-allocated buffer
10+
11+
By default, `new ValueStringBuilder()` rents its initial buffer from `ArrayPool<char>.Shared`. Renting has a (small) cost, so if you know your string is short-lived and small, you can hand the builder a `stackalloc`'d buffer instead:
12+
13+
```csharp
14+
using var stringBuilder = new ValueStringBuilder(stackalloc char[128]);
15+
16+
stringBuilder.Append("Hello ");
17+
stringBuilder.Append("World");
18+
19+
Console.WriteLine(stringBuilder.ToString());
20+
```
21+
22+
Because the buffer lives on the stack, this avoids the array-pool rent/return entirely for as long as the content fits.
23+
24+
### What happens when the buffer is too small?
25+
26+
`stackalloc` only reserves the initial capacity - it does **not** cap how much you can append. If the builder needs to grow beyond the buffer you gave it, [`EnsureCapacity`](xref:LinkDotNet.StringBuilder.ValueStringBuilder.EnsureCapacity(System.Int32)) transparently rents a bigger array from `ArrayPool<char>.Shared`, copies the existing content over, and continues from there:
27+
28+
```csharp
29+
using var stringBuilder = new ValueStringBuilder(stackalloc char[4]);
30+
31+
stringBuilder.Append("This is way longer than 4 characters"); // grows onto the array pool automatically
32+
```
33+
34+
This is why you should still `using`/`Dispose()` the builder even when you started it with a stack buffer, unless you can *guarantee* the content never exceeds the initial size. `Dispose()` only returns a pooled array if one was actually rented, so calling it on a builder that never grew is a cheap no-op.
35+
36+
### Eliding `using` for guaranteed-small content
37+
38+
If you control both the buffer size and every value being appended, you can skip the `using` statement altogether - see the "Fluent notation" section of [Known limitations](xref:known_limitations) for the full example. Do this only when growth is provably impossible (fixed-format output, bounded input, etc.); getting it wrong just means an extra rent/return, not a bug, but it defeats the purpose of using `stackalloc` in the first place.
39+
40+
### Reusing one stack buffer across calls
41+
42+
A common pattern is a small helper method that builds a short string entirely on the stack, without exposing the builder to the caller:
43+
44+
```csharp
45+
private static string FormatCoordinate(int x, int y)
46+
{
47+
Span<char> buffer = stackalloc char[32];
48+
var stringBuilder = new ValueStringBuilder(buffer);
49+
50+
stringBuilder.Append('(');
51+
stringBuilder.Append(x);
52+
stringBuilder.Append(", ");
53+
stringBuilder.Append(y);
54+
stringBuilder.Append(')');
55+
56+
return stringBuilder.ToString();
57+
}
58+
```
59+
60+
As long as `buffer` is large enough for the expected input, this method never touches the heap except for the final `ToString()` call.
61+
62+
> [!WARNING]
63+
> Never return a `stackalloc`-backed `ValueStringBuilder` (or a `ref` to it) from the method that declared the buffer - the buffer's stack frame is gone once the method returns. Pass the builder onward by `ref` to callees instead, as described in [Passing the ValueStringBuilder to a method](xref:pass_to_method).
64+
65+
## Avoiding boxing for value types
66+
67+
`AppendJoin`, `Concat`, `AppendFormat`, `ReplaceGeneric`, and the interpolated-string `Append`/`AppendLine` overloads all special-case common value types (`int`, `long`, `double`, `decimal`, `DateTime`, `DateTimeOffset`, `TimeSpan`, `Guid`, and more) so they're formatted directly into the buffer via `ISpanFormattable` instead of being boxed to `object` first:
68+
69+
```csharp
70+
using var stringBuilder = new ValueStringBuilder();
71+
72+
// No boxing for the int, double or Guid arguments below.
73+
stringBuilder.AppendJoin(", ", [1, 2, 3]);
74+
stringBuilder.AppendFormat($"{42:D5} {3.14:F2} {Guid.NewGuid()}");
75+
```
76+
77+
For a type without a known fast path, the code falls back to the normal `ISpanFormattable`/`ToString()` path, so correctness is never sacrificed - only the hot, common types skip the allocation. See the [comparison](xref:comparison) article for the measured effect.
78+
79+
## Pinning the buffer
80+
81+
`GetPinnableReference()` lets the compiler-generated `fixed` pattern work directly against the builder's internal buffer, which is handy for interop:
82+
83+
```csharp
84+
using var stringBuilder = new ValueStringBuilder();
85+
stringBuilder.Append("Hello World");
86+
87+
fixed (char* buffer = stringBuilder)
88+
{
89+
// buffer points at the first character; not guaranteed to be null-terminated
90+
// past stringBuilder.Length.
91+
}
92+
```
93+
94+
## Converting to and from `System.Text.StringBuilder`
95+
96+
Sometimes you need the richer API of the "big brother" `StringBuilder`, or you're integrating with code that already hands you one. The [`ValueStringBuilderExtensions`](xref:LinkDotNet.StringBuilder.ValueStringBuilderExtensions) class covers both directions:
97+
98+
```csharp
99+
using var stringBuilder = new ValueStringBuilder("Hello World");
100+
101+
System.Text.StringBuilder classic = stringBuilder.ToStringBuilder();
102+
```
103+
104+
```csharp
105+
var classic = new System.Text.StringBuilder("Hello World");
106+
107+
using var stringBuilder = classic.ToValueStringBuilder();
108+
```
109+
110+
Both conversions copy the underlying characters, so the two builders don't share a buffer afterward - mutating one has no effect on the other.
111+
112+
## Iterating without allocating
113+
114+
`ValueStringBuilder` implements `GetEnumerator()`, so it works directly in a `foreach` without ever materializing a `string`:
115+
116+
```csharp
117+
using var stringBuilder = new ValueStringBuilder("Hello World");
118+
119+
foreach (var character in stringBuilder)
120+
{
121+
// character is a char, no intermediate string or array is allocated
122+
}
123+
```
124+
125+
For anything beyond simple iteration, prefer [`AsSpan()`](xref:LinkDotNet.StringBuilder.ValueStringBuilder.AsSpan*) and the `Span<T>`/`ReadOnlySpan<T>` APIs directly.

docs/site/articles/comparison.md

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,37 +15,64 @@ The `StringBuilder` shipped with the .NET Framework itself is a all-purpose stri
1515
- `StringBuilder` works not on `Span<T>` but more on `string`s or `char`s. Sometimes even with pointers
1616
- `StringBuilder` uses chunks to represent the string, which the larger the string gets, the better it can perform. `ValueStringBuilder` only has one internal `Span` as representation which can cause fragmentation on very big strings.
1717
- `StringBuilder` has a richer API as the `ValueStringBuilder`. In the future they should have the same amount of API's as the `StringBuilder` is the "big brother" of this package.
18-
- `ValueStringBuilder` has different API calls like [`IndexOf`](xref:LinkDotNet.StringBuilder.ValueStringBuilder.IndexOf(ReadOnlySpan{System.Char})) or [`LastIndexOf`](xref:LinkDotNet.StringBuilder.ValueStringBuilder.LastIndexOf(ReadOnlySpan{System.Char})).
18+
- `ValueStringBuilder` has different API calls like [`IndexOf`](xref:LinkDotNet.StringBuilder.ValueStringBuilder.IndexOf*) or [`LastIndexOf`](xref:LinkDotNet.StringBuilder.ValueStringBuilder.LastIndexOf*).
1919

2020
## Benchmark
2121

2222
The following table gives you a small comparison between the `StringBuilder` which is part of .NET and the `ValueStringBuilder`:
2323

24-
```
25-
BenchmarkDotNet v0.14.0, macOS Sequoia 15.3.1 (24D70) [Darwin 24.3.0]
24+
```no-class
25+
BenchmarkDotNet v0.15.8, macOS Sequoia 15.7.7 (24G720) [Darwin 24.6.0]
2626
Apple M2 Pro, 1 CPU, 12 logical and 12 physical cores
27-
.NET SDK 9.0.200
28-
[Host] : .NET 9.0.2 (9.0.225.6610), Arm64 RyuJIT AdvSIMD
29-
DefaultJob : .NET 9.0.2 (9.0.225.6610), Arm64 RyuJIT AdvSIMD
27+
.NET SDK 10.0.400
28+
[Host] : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
29+
DefaultJob : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
3030
3131
3232
| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio |
3333
|-------------------- |----------:|---------:|---------:|------:|-------:|----------:|------------:|
34-
| DotNetStringBuilder | 126.74 ns | 0.714 ns | 0.667 ns | 1.00 | 0.1779 | 1488 B | 1.00 |
35-
| ValueStringBuilder | 95.69 ns | 0.118 ns | 0.110 ns | 0.76 | 0.0669 | 560 B | 0.38 |
34+
| DotNetStringBuilder | 120.84 ns | 2.093 ns | 1.748 ns | 1.00 | 0.1779 | 1488 B | 1.00 |
35+
| ValueStringBuilder | 90.14 ns | 1.017 ns | 0.901 ns | 0.75 | 0.0669 | 560 B | 0.38 |
3636
```
3737

38-
For more comparison check the documentation.
39-
40-
Another benchmark shows that this `ValueStringBuilder` uses less memory when it comes to appending `ValueTypes` such as `int`, `double`, ...
38+
`ValueStringBuilder` also avoids boxing value types (`int`, `double`, `DateTime`, `Guid`, and 16 more) passed to
39+
`AppendJoin`, `Concat`, `AppendFormat`, `ReplaceGeneric`, and interpolated strings, and vectorizes [`Trim`/`TrimStart`/`TrimEnd`](xref:trimming)
40+
via `SearchValues<char>`. The following benchmark shows the combined effect against `StringBuilder` for a few representative
41+
operations:
4142

43+
Operations, top to bottom: concatenating 5 mixed values, joining 10 ints with a separator, an interpolated string with
44+
5 value-type holes, replacing a placeholder with a formatted int, and trimming a padded 1000-char buffer.
4245

46+
```no-class
47+
BenchmarkDotNet v0.15.8, macOS Sequoia 15.7.7 (24G720) [Darwin 24.6.0]
48+
Apple M2 Pro, 1 CPU, 12 logical and 12 physical cores
49+
.NET SDK 10.0.400
50+
[Host] : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
51+
DefaultJob : .NET 10.0.11 (10.0.11, 10.0.1126.37416), Arm64 RyuJIT armv8.0-a
52+
53+
54+
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
55+
|------------------------------- |----------:|---------:|---------:|-------:|-------:|----------:|
56+
| StringBuilderConcat | 245.67 ns | 0.456 ns | 0.381 ns | 0.0792 | - | 664 B |
57+
| StringBuilderAppendJoin | 55.29 ns | 0.898 ns | 0.701 ns | 0.0325 | - | 272 B |
58+
| StringBuilderInterpolated | 271.05 ns | 5.366 ns | 6.387 ns | 0.0610 | - | 512 B |
59+
| StringBuilderReplace | 50.69 ns | 0.863 ns | 0.923 ns | 0.0325 | - | 272 B |
60+
| StringBuilderTrim | 920.99 ns | 3.383 ns | 3.165 ns | 0.7629 | 0.0114 | 6384 B |
61+
| ValueStringBuilderConcat | 191.30 ns | 0.971 ns | 0.861 ns | 0.0210 | - | 176 B |
62+
| ValueStringBuilderAppendJoin | 38.77 ns | 0.097 ns | 0.086 ns | 0.0076 | - | 64 B |
63+
| ValueStringBuilderInterpolated | 146.29 ns | 0.462 ns | 0.409 ns | 0.0191 | - | 160 B |
64+
| ValueStringBuilderReplace | 29.46 ns | 0.550 ns | 0.515 ns | 0.0038 | - | 32 B |
65+
| ValueStringBuilderTrim | 124.74 ns | 1.145 ns | 0.956 ns | 0.0057 | - | 48 B |
4366
```
44-
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
45-
|------------------------------- |---------:|--------:|--------:|-------:|-------:|----------:|
46-
| ValueStringBuilderAppendFormat | 821.7 ns | 1.29 ns | 1.14 ns | 0.4330 | - | 3.54 KB |
47-
| StringBuilderAppendFormat | 741.5 ns | 5.58 ns | 5.22 ns | 0.9909 | 0.0057 | 8.1 KB |
4867

49-
```
68+
Comparing each `ValueStringBuilder` row against its `StringBuilder` counterpart above:
69+
70+
| Operation | Time | Allocated |
71+
|--------------|----------------------|-----------------------|
72+
| Concat | 0.78x (1.3x faster) | 0.27x (3.8x less) |
73+
| AppendJoin | 0.70x (1.4x faster) | 0.24x (4.3x less) |
74+
| Interpolated | 0.54x (1.9x faster) | 0.31x (3.2x less) |
75+
| Replace | 0.58x (1.7x faster) | 0.12x (8.5x less) |
76+
| Trim | 0.14x (7.4x faster) | 0.01x (133x less) |
5077

5178
Checkout the [Benchmark](https://github.com/linkdotnet/StringBuilder/tree/main/tests/LinkDotNet.StringBuilder.Benchmarks) for more detailed comparison and setup.

docs/site/articles/known_limitations.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ The base of the `ValueStringBuilder` is a `ref struct`. With that, there are cer
1111

1212
If not off this applies to your use case, you are good to go. Using `ref struct` is a trade for performance and fewer allocations in contrast to its use cases.
1313

14-
`ValueStringBuilder` offers the possibility to "convert" it into a "regular" `System.Text.StringBuilder`. Check out the following extension method via the <xref:LinkDotNet.StringBuilder.ValueStringBuilderExtensions>.
14+
`ValueStringBuilder` offers the possibility to "convert" it into a "regular" `System.Text.StringBuilder` and back. Check out the [`ValueStringBuilderExtensions`](xref:LinkDotNet.StringBuilder.ValueStringBuilderExtensions) for `ToStringBuilder()` and `ToValueStringBuilder()`.
1515

1616
## Fluent notation
1717

@@ -35,4 +35,6 @@ var stringBuilder = new ValueStringBuilder(stackalloc char[128]);
3535

3636
stringBuilder.Append("Hello World"); // Uses 11 bytes
3737
return stringBuilder.ToString();
38-
```
38+
```
39+
40+
See the [advanced usage](xref:advanced_usage) article for more on `stackalloc`-backed buffers, including what happens if the content outgrows them.

docs/site/articles/toc.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
href: concepts.md
66
- name: Passing the ValueStringBuilder to a method
77
href: pass_to_method.md
8+
- name: Trimming
9+
href: trimming.md
10+
- name: Advanced usage
11+
href: advanced_usage.md
812
- name: Comparison
913
href: comparison.md
1014
- name: Known limitations

docs/site/articles/trimming.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
---
2+
uid: trimming
3+
---
4+
5+
# Trimming
6+
7+
`ValueStringBuilder` offers a `Trim` family that mirrors `string.Trim`/`TrimStart`/`TrimEnd`, plus prefix/suffix removal that `string` doesn't have. All of them mutate the builder in place - no new buffer is allocated, only the internal position is adjusted (and the remaining characters shifted left if the start changed).
8+
9+
The whitespace-based overloads (`Trim()`, `TrimStart()`, `TrimEnd()`) are vectorized with `SearchValues<char>`, so they are considerably faster and lower-allocating than trimming a `System.Text.StringBuilder` - see the [comparison](xref:comparison) article for numbers.
10+
11+
## Trimming whitespace
12+
13+
```csharp
14+
using var stringBuilder = new ValueStringBuilder(" Hello World ");
15+
16+
stringBuilder.Trim();
17+
18+
Console.WriteLine(stringBuilder.ToString()); // "Hello World"
19+
```
20+
21+
`TrimStart()` and `TrimEnd()` work the same way but only remove leading or trailing whitespace respectively:
22+
23+
```csharp
24+
using var stringBuilder = new ValueStringBuilder(" Hello World ");
25+
26+
stringBuilder.TrimStart();
27+
Console.WriteLine(stringBuilder.ToString()); // "Hello World "
28+
29+
stringBuilder.TrimEnd();
30+
Console.WriteLine(stringBuilder.ToString()); // "Hello World"
31+
```
32+
33+
## Trimming a specific character
34+
35+
Each method also has an overload that removes a specific character instead of whitespace:
36+
37+
```csharp
38+
using var stringBuilder = new ValueStringBuilder("xxHello Worldxx");
39+
40+
stringBuilder.Trim('x');
41+
42+
Console.WriteLine(stringBuilder.ToString()); // "Hello World"
43+
```
44+
45+
This is useful for cleaning up padding characters, for example the ones produced by [`AppendPadLeft`/`AppendPadRight`](xref:LinkDotNet.StringBuilder.ValueStringBuilder.AppendPadLeft*):
46+
47+
```csharp
48+
using var stringBuilder = new ValueStringBuilder();
49+
stringBuilder.AppendPadLeft("42", 10, '0');
50+
Console.WriteLine(stringBuilder.ToString()); // "0000000042"
51+
52+
stringBuilder.TrimStart('0');
53+
Console.WriteLine(stringBuilder.ToString()); // "42"
54+
```
55+
56+
## Trimming a prefix or suffix
57+
58+
`TrimPrefix` and `TrimSuffix` remove a whole sequence of characters (not just a single one) if the builder starts or ends with it. Unlike the other `Trim` overloads they take a `StringComparison`, so you can opt into a case-insensitive comparison:
59+
60+
```csharp
61+
using var stringBuilder = new ValueStringBuilder("https://example.com/");
62+
63+
stringBuilder.TrimPrefix("https://");
64+
stringBuilder.TrimSuffix("/");
65+
66+
Console.WriteLine(stringBuilder.ToString()); // "example.com"
67+
```
68+
69+
```csharp
70+
using var stringBuilder = new ValueStringBuilder("HELLO.txt");
71+
72+
stringBuilder.TrimSuffix(".TXT", StringComparison.OrdinalIgnoreCase);
73+
74+
Console.WriteLine(stringBuilder.ToString()); // "HELLO"
75+
```
76+
77+
If the builder doesn't start (or end) with the given value, `TrimPrefix`/`TrimSuffix` are a no-op.
78+
79+
## Combining Trim with a stack-allocated buffer
80+
81+
Because none of the `Trim` methods allocate, they combine well with a [`stackalloc`-backed builder](xref:advanced_usage) for fully allocation-free processing of short-lived strings:
82+
83+
```csharp
84+
Span<char> buffer = stackalloc char[64];
85+
var stringBuilder = new ValueStringBuilder(buffer);
86+
87+
stringBuilder.Append(" raw input ");
88+
stringBuilder.Trim();
89+
90+
ReadOnlySpan<char> result = stringBuilder.AsSpan(); // no allocation at all
91+
```

0 commit comments

Comments
 (0)