String.Create and Span<char>
Man, I love .Net Core. More particularly, I love the entire shift in focus from the old .Net Framework ship-vehicle-is-windows, closed-source mindset that under-invested in performance improvements, but in Core perf is at the forefront.
Here’s an interesting example in the age-old problem of string-formatting. I only recently discovered (though it’s been there since Core 2.1) another place where that perf-epiphany Span<T>
makes an impact: String.Create<T>(int len, T state, SpanAction<char,T>).
Why is it cool? Everyone’s had to emit a formatted string at some point, in fact over and over and over we do. The perf-conscious among us seek to do so with a minimum of interim-string allocations and pours (e.g. avoiding long chains of logMsg += ", " + terms[i]
in favor of stringBuilder.AppendJoin( “,”, terms)
, etc.) String.Create takes this a step further.
With String.Create
, as long as you can compute the length up-front, you can supply a callback that’s given access to the final string’s tabula rasa internal char[] via a span. This allows you to write directly into any location in the string’s memory (immutability be damned! ‘cause it’s construction), with no subsequent re-alloc/pour like StringBuilder.ToString()
would do. Lowest possible mem-use.
Be sure to see where you can leverage the TryFormat( Span<char>, … ) method that most object-types support, irradicating per-field, interim string allocs and their mem-copy pours, too.
public class Maven
{
public string Name { get; set; }
public string? Motto { get; set; }
public int FollowerCount { get; set; }
public DateTime RecentActive { get; set; }
public override string ToString()
{
const string mottoLabel = "Motto: ";
const string fctLabel = "Followers: ";
const string recentLabel = "Recent: ";
const int fctLen = 5; // 9999+ or right-padded ct
const int recLen = 10; // 2022/03/25
string eol = Environment.NewLine;
int len =
Name.Length +
(Motto is not null
? eol.Length + mottoLabel.Length + Motto.Length
: 0) +
(eol.Length + fctLabel.Length + fctLen) +
(eol.Length + recentLabel.Length + recLen);
string result = string.Create(
len,
this,
(buffer, self) =>
{
Write( self.Name.AsSpan(), ref buffer);
if (Motto is not null)
{
Write( eol, ref buffer );
Write( mottoLabel, ref buffer );
Write( self.Motto, ref buffer );
}
Write( eol, ref buffer) ;
Write( fctLabel, ref buffer );
Write( self.FollowerCount > 9999
? "9999+"
: $"{self.FollowerCount,-5:g}", ref buffer );
Write( eol, ref buffer);
Write( recentLabel, ref buffer);
self.RecentActive.TryFormat(
buffer,
out int _,
"yyyy/MM/dd");
});
return result;
// ---
static void Write(
ReadOnlySpan<char> term,
ref Span<char> buf)
{
term.CopyTo( buf );
buf = buf.Slice( term.Length );
}
}
}
I like to have a utility-function, Write
, to automatically advance the span. Pretty readable with that in place. You might want to add other Write overloads could handle DateTime, etc. consistently.
Above I kind’a cheated by forcing the format of numbers and dates instead of using a culturally-aware formatting, and I required a left-justified padding on the FollowerCount that ultimately takes a little more space than needed plus incurs an interpolated-string alloc. Tsk, tsk. You grasp the basics, though.
Note: if your length calculation is too big, you’ll have trailing (char)0 elements that may render poorly. Too small, the string will be silently truncated.
The TState arg will typically hold the object you want string-formatted, allowing the callback to be static, sans closures, thus fast as can be. There might be interesting cases, though, where the callback’s state param holds supporting information (culture?) as a tuple of the subject being formatted, or you’re ok with the closure cost, or …
I really should have used String.Create in the Leveraging Exception.Data story’s ToStringEx().
Anyway, enjoy!
— — — — — —
Aside: if you’re still needing to use StringBuilder in many places, you might at least want to consider putting them in a pool for re-use of their scratch buffer memory. See the Object reuse with Object Pool MSDN topic for an example.
Another aside: before this, our team wrote our own stack-based string-builder, gist of which below. It still requires a pour to get the final string, so we’ll likely be migrating to String.Create on touching those callers later.
public ref struct StackStringBuilder
{
private readonly Span<char> buffer; public StackStringBuilder( Span<char> buf ) => buffer = buf; public int Length { get; private set; } = 0; public void Append( int x )
{
Span<char> temp = stackalloc char[ IntMaxLen ];
x.TryFormat( temp, out int xlen );
temp.Slice( 0, xlen )
.CopyTo( this.buffer.Slice( this.Length ) );
this.Length += xlen;
} ... more Append overloads ... public string ToString() =>
new string( this.buffer[ 0..this.Length ] );
}