Description
We should consider making a value based StringBuilder to allow low allocation building of strings. While Span allows you to provide a writable buffer, in many scenarios we have a need to get or build strings and we don't know precisely how much space will be needed ahead of time. Having an abstraction that can grow beyond a given initial buffer is particularly useful as it doesn't require looping with Try*
APIs- which can be both complicated and have negative performance implications.
We currently use ValueStringBuilder
for this purpose internally. It starts with an optional initial buffer (which we often stackalloc) and will grow using ArrayPool if needed.
Design Goals
- Allow safe usage of stack memory
- Use pooled memory when needed to reduce GC pressure
- Allow dynamic and explicit capacity growth
- Facilitate interop scenarios (i.e. passing as
char* szValue
) - Follow API semantics of StringBuilder & string where possible
- Be stack allocated
API
Here is the proposed API:
namespace System.Text
{
public ref struct ValueStringBuilder
{
public ValueStringBuilder(Span<char> initialBuffer);
// The logical length of the builder (end of the "string")
public int Length { get; set; }
// Available space in chars
public int Capacity { get; }
// Ensure there is at least this amount of space
public void EnsureCapacity(int capacity);
// Get a pinnable reference to the builder. "terminate" ensures the builder has a null char after Length in the buffer.
public ref char GetPinnableReference(bool terminate = false);
// Indexer, allows setting/getting individual chars
public ref char this[int index] { get; }
// Returns a string based off of the current position
public override string ToString();
// Returns a span around the contents of the builder. "terminate" ensures the builder has a null char after Length in the buffer.
public ReadOnlySpan<char> AsSpan(bool terminate);
// To ensure inlining perf, we have a separate overload for terminate
public ReadOnlySpan<char> AsSpan();
public bool TryCopyTo(Span<char> destination, out int charsWritten);
public void Insert(int index, char value, int count = 1);
public void Insert(int index, ReadOnlySpan<char> value, int count = 1);
public void Append(char c, int count = 1);
public void Append(ReadOnlySpan<char> value);
// This gives you an appended span that you can write to
public Span<char> AppendSpan(int length);
// Returns any ArrayPool buffer that may have been rented
public void Dispose()
}
}
This is the current shape of our internal ValueStringBuilder
:
namespace System.Text
{
internal ref struct ValueStringBuilder
{
public ValueStringBuilder(Span<char> initialBuffer);
public int Length { get; set; }
public int Capacity { get; }
public void EnsureCapacity(int capacity);
/// <summary>
/// Get a pinnable reference to the builder.
/// </summary>
/// <param name="terminate">Ensures that the builder has a null char after <see cref="Length"/></param>
public ref char GetPinnableReference(bool terminate = false);
public ref char this[int index] { get; }
// Returns a string based off of the current position
public override string ToString();
/// <summary>
/// Returns a span around the contents of the builder.
/// </summary>
/// <param name="terminate">Ensures that the builder has a null char after <see cref="Length"/></param>
public ReadOnlySpan<char> AsSpan(bool terminate);
// To ensure inlining perf, we have a separate overload for terminate
public ReadOnlySpan<char> AsSpan();
public bool TryCopyTo(Span<char> destination, out int charsWritten);
public void Insert(int index, char value, int count);
public void Append(char c);
public void Append(string s);
public void Append(char c, int count);
public unsafe void Append(char* value, int length);
public void Append(ReadOnlySpan<char> value);
// This gives you an appended span that you can write to
public Span<char> AppendSpan(int length);
// Returns any ArrayPool buffer that may have been rented
public void Dispose()
}
}
Sample Code
Here is a common pattern on an API that could theoretically be made public if ValueStringBuilder was public:
(Although we would call this one GetFullUserName or something like that.)
The caller is above this method:
Usage of AppendSpan:
I'll add more usage details and possible API surface area.
Notes
- Does using this remove the need for having a
bool TryGet*(Span)
overload? (see https://github.com/dotnet/coreclr/pull/17097/files#r176560435) - Should we allow you to make it non-growable? (So we can avoid TryGet above?)
- Can we get C# to allow using ref structs that have a
Dispose()
in ausing
statement? (Proposal: Allow Dispose by Convention csharplang#93) - Do we care about
AppendFormat
overloads? AppendSpan()
is a little tricky to grok- is there a better term/pattern?