Skip to content

Commit

Permalink
Avoid ToString allocations for each ISpanFormattable value in string.…
Browse files Browse the repository at this point in the history
…Concat/Join(..., IEnumerable<T>) (#86521)

We can use a T's ISpanFormattable implementation to avoid the individual ToStrings. We also don't need to maintain a separate implementation for Concat; at the cost of one branch per value, we can just reuse the Join implementation and pick up all of its optimizations for Concat.
  • Loading branch information
stephentoub authored May 22, 2023
1 parent 07af177 commit 1df8a19
Showing 1 changed file with 88 additions and 109 deletions.
197 changes: 88 additions & 109 deletions src/libraries/System.Private.CoreLib/src/System/String.Manipulation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,88 +121,8 @@ public static string Concat(params object?[] args)
return result;
}

public static string Concat<T>(IEnumerable<T> values)
{
ArgumentNullException.ThrowIfNull(values);

if (typeof(T) == typeof(char))
{
// Special-case T==char, as we can handle that case much more efficiently,
// and string.Concat(IEnumerable<char>) can be used as an efficient
// enumerable-based equivalent of new string(char[]).
using (IEnumerator<char> en = Unsafe.As<IEnumerable<char>>(values).GetEnumerator())
{
if (!en.MoveNext())
{
// There weren't any chars. Return the empty string.
return Empty;
}

char c = en.Current; // save the first char

if (!en.MoveNext())
{
// There was only one char. Return a string from it directly.
return CreateFromChar(c);
}

// Create the StringBuilder, add the chars we've already enumerated,
// add the rest, and then get the resulting string.
var result = new ValueStringBuilder(stackalloc char[StackallocCharBufferSizeLimit]);
result.Append(c); // first value
do
{
c = en.Current;
result.Append(c);
}
while (en.MoveNext());
return result.ToString();
}
}
else
{
using (IEnumerator<T> en = values.GetEnumerator())
{
if (!en.MoveNext())
return string.Empty;

// We called MoveNext once, so this will be the first item
T currentValue = en.Current;

// Call ToString before calling MoveNext again, since
// we want to stay consistent with the below loop
// Everything should be called in the order
// MoveNext-Current-ToString, unless further optimizations
// can be made, to avoid breaking changes
string? firstString = currentValue?.ToString();

// If there's only 1 item, simply call ToString on that
if (!en.MoveNext())
{
// We have to handle the case of either currentValue
// or its ToString being null
return firstString ?? string.Empty;
}

var result = new ValueStringBuilder(stackalloc char[StackallocCharBufferSizeLimit]);

result.Append(firstString);

do
{
currentValue = en.Current;

if (currentValue != null)
{
result.Append(currentValue.ToString());
}
}
while (en.MoveNext());

return result.ToString();
}
}
}
public static string Concat<T>(IEnumerable<T> values) =>
JoinCore(ReadOnlySpan<char>.Empty, values);

public static string Concat(IEnumerable<string?> values)
{
Expand Down Expand Up @@ -891,48 +811,102 @@ private static string JoinCore<T>(ReadOnlySpan<char> separator, IEnumerable<T> v
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.values);
}

using (IEnumerator<T> en = values.GetEnumerator())
using (IEnumerator<T> e = values.GetEnumerator())
{
if (!en.MoveNext())
if (!e.MoveNext())
{
// If the enumerator is empty, just return an empty string.
return Empty;
}

// We called MoveNext once, so this will be the first item
T currentValue = en.Current;

// Call ToString before calling MoveNext again, since
// we want to stay consistent with the below loop
// Everything should be called in the order
// MoveNext-Current-ToString, unless further optimizations
// can be made, to avoid breaking changes
string? firstString = currentValue?.ToString();

// If there's only 1 item, simply call ToString on that
if (!en.MoveNext())
if (typeof(T) == typeof(char))
{
// We have to handle the case of either currentValue
// or its ToString being null
return firstString ?? Empty;
}
// Special-case T==char, as we can handle that case much more efficiently,
// and string.Concat(IEnumerable<char>) can be used as an efficient
// enumerable-based equivalent of new string(char[]).

var result = new ValueStringBuilder(stackalloc char[StackallocCharBufferSizeLimit]);
IEnumerator<char> en = Unsafe.As<IEnumerator<char>>(e);

result.Append(firstString);
char c = en.Current; // save the first value
if (!en.MoveNext())
{
// There was only one char. Return a string from it directly.
return CreateFromChar(c);
}

do
// Create the builder, add the char we already enumerated,
// add the rest, and then get the resulting string.
var result = new ValueStringBuilder(stackalloc char[StackallocCharBufferSizeLimit]);
result.Append(c); // first value
do
{
if (!separator.IsEmpty)
{
result.Append(separator);
}

c = en.Current;
result.Append(c);
}
while (en.MoveNext());
return result.ToString();
}
else if (typeof(T).IsValueType && default(T) is ISpanFormattable)
{
currentValue = en.Current;
// Special-case value types that are ISpanFormattable, as we can implement those to avoid
// all string allocations for the individual values. We only do this for value types because
// a) value types are much more likely to implement ISpanFormattable, and b) we can use
// DefaultInterpolatedStringHandler to do all the heavy lifting, and it's more efficient
// for value types as the checks it does for interface implementations are all elided.

T value = e.Current; // save the first value
if (!e.MoveNext())
{
// There was only one value. Return a string from it directly.
return value!.ToString() ?? Empty;
}

result.Append(separator);
if (currentValue != null)
var result = new DefaultInterpolatedStringHandler(0, 0, CultureInfo.CurrentCulture, stackalloc char[StackallocCharBufferSizeLimit]);
result.AppendFormatted(value); // first value
do
{
result.Append(currentValue.ToString());
if (!separator.IsEmpty)
{
result.AppendFormatted(separator);
}

result.AppendFormatted(e.Current);
}
while (e.MoveNext());
return result.ToStringAndClear();
}
while (en.MoveNext());
else
{
// For all other Ts, fall back to calling ToString on each and appending the resulting
// string to a builder.

return result.ToString();
string? firstString = e.Current?.ToString(); // save the first value
if (!e.MoveNext())
{
return firstString ?? Empty;
}

var result = new ValueStringBuilder(stackalloc char[StackallocCharBufferSizeLimit]);

result.Append(firstString);
do
{
if (!separator.IsEmpty)
{
result.Append(separator);
}

result.Append(e.Current?.ToString());
}
while (e.MoveNext());

return result.ToString();
}
}
}

Expand Down Expand Up @@ -965,6 +939,11 @@ private static string JoinCore(ReadOnlySpan<char> separator, ReadOnlySpan<string
}
}

if (totalLength == 0)
{
return Empty;
}

// Copy each of the strings into the result buffer, interleaving with the separator.
string result = FastAllocateString(totalLength);
int copiedLength = 0;
Expand Down

0 comments on commit 1df8a19

Please sign in to comment.