Skip to content

Commit 28e66f8

Browse files
committed
Avoid ToString allocations for each ISpanFormattable value in string.Concat/Join(..., IEnumerable<T>)
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.
1 parent d2d51c8 commit 28e66f8

File tree

1 file changed

+88
-109
lines changed

1 file changed

+88
-109
lines changed

src/libraries/System.Private.CoreLib/src/System/String.Manipulation.cs

Lines changed: 88 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -121,88 +121,8 @@ public static string Concat(params object?[] args)
121121
return result;
122122
}
123123

124-
public static string Concat<T>(IEnumerable<T> values)
125-
{
126-
ArgumentNullException.ThrowIfNull(values);
127-
128-
if (typeof(T) == typeof(char))
129-
{
130-
// Special-case T==char, as we can handle that case much more efficiently,
131-
// and string.Concat(IEnumerable<char>) can be used as an efficient
132-
// enumerable-based equivalent of new string(char[]).
133-
using (IEnumerator<char> en = Unsafe.As<IEnumerable<char>>(values).GetEnumerator())
134-
{
135-
if (!en.MoveNext())
136-
{
137-
// There weren't any chars. Return the empty string.
138-
return Empty;
139-
}
140-
141-
char c = en.Current; // save the first char
142-
143-
if (!en.MoveNext())
144-
{
145-
// There was only one char. Return a string from it directly.
146-
return CreateFromChar(c);
147-
}
148-
149-
// Create the StringBuilder, add the chars we've already enumerated,
150-
// add the rest, and then get the resulting string.
151-
var result = new ValueStringBuilder(stackalloc char[StackallocCharBufferSizeLimit]);
152-
result.Append(c); // first value
153-
do
154-
{
155-
c = en.Current;
156-
result.Append(c);
157-
}
158-
while (en.MoveNext());
159-
return result.ToString();
160-
}
161-
}
162-
else
163-
{
164-
using (IEnumerator<T> en = values.GetEnumerator())
165-
{
166-
if (!en.MoveNext())
167-
return string.Empty;
168-
169-
// We called MoveNext once, so this will be the first item
170-
T currentValue = en.Current;
171-
172-
// Call ToString before calling MoveNext again, since
173-
// we want to stay consistent with the below loop
174-
// Everything should be called in the order
175-
// MoveNext-Current-ToString, unless further optimizations
176-
// can be made, to avoid breaking changes
177-
string? firstString = currentValue?.ToString();
178-
179-
// If there's only 1 item, simply call ToString on that
180-
if (!en.MoveNext())
181-
{
182-
// We have to handle the case of either currentValue
183-
// or its ToString being null
184-
return firstString ?? string.Empty;
185-
}
186-
187-
var result = new ValueStringBuilder(stackalloc char[StackallocCharBufferSizeLimit]);
188-
189-
result.Append(firstString);
190-
191-
do
192-
{
193-
currentValue = en.Current;
194-
195-
if (currentValue != null)
196-
{
197-
result.Append(currentValue.ToString());
198-
}
199-
}
200-
while (en.MoveNext());
201-
202-
return result.ToString();
203-
}
204-
}
205-
}
124+
public static string Concat<T>(IEnumerable<T> values) =>
125+
JoinCore(ReadOnlySpan<char>.Empty, values);
206126

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

894-
using (IEnumerator<T> en = values.GetEnumerator())
814+
using (IEnumerator<T> e = values.GetEnumerator())
895815
{
896-
if (!en.MoveNext())
816+
if (!e.MoveNext())
897817
{
818+
// If the enumerator is empty, just return an empty string.
898819
return Empty;
899820
}
900821

901-
// We called MoveNext once, so this will be the first item
902-
T currentValue = en.Current;
903-
904-
// Call ToString before calling MoveNext again, since
905-
// we want to stay consistent with the below loop
906-
// Everything should be called in the order
907-
// MoveNext-Current-ToString, unless further optimizations
908-
// can be made, to avoid breaking changes
909-
string? firstString = currentValue?.ToString();
910-
911-
// If there's only 1 item, simply call ToString on that
912-
if (!en.MoveNext())
822+
if (typeof(T) == typeof(char))
913823
{
914-
// We have to handle the case of either currentValue
915-
// or its ToString being null
916-
return firstString ?? Empty;
917-
}
824+
// Special-case T==char, as we can handle that case much more efficiently,
825+
// and string.Concat(IEnumerable<char>) can be used as an efficient
826+
// enumerable-based equivalent of new string(char[]).
918827

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

921-
result.Append(firstString);
830+
char c = en.Current; // save the first value
831+
if (!en.MoveNext())
832+
{
833+
// There was only one char. Return a string from it directly.
834+
return CreateFromChar(c);
835+
}
922836

923-
do
837+
// Create the builder, add the char we already enumerated,
838+
// add the rest, and then get the resulting string.
839+
var result = new ValueStringBuilder(stackalloc char[StackallocCharBufferSizeLimit]);
840+
result.Append(c); // first value
841+
do
842+
{
843+
if (!separator.IsEmpty)
844+
{
845+
result.Append(separator);
846+
}
847+
848+
c = en.Current;
849+
result.Append(c);
850+
}
851+
while (en.MoveNext());
852+
return result.ToString();
853+
}
854+
else if (typeof(T).IsValueType && default(T) is ISpanFormattable)
924855
{
925-
currentValue = en.Current;
856+
// Special-case value types that are ISpanFormattable, as we can implement those to avoid
857+
// all string allocations for the individual values. We only do this for value types because
858+
// a) value types are much more likely to implement ISpanFormattable, and b) we can use
859+
// DefaultInterpolatedStringHandler to do all the heavy lifting, and it's more efficient
860+
// for value types as the checks it does for interface implementations are all elided.
861+
862+
T value = e.Current; // save the first value
863+
if (!e.MoveNext())
864+
{
865+
// There was only one value. Return a string from it directly.
866+
return value!.ToString() ?? Empty;
867+
}
926868

927-
result.Append(separator);
928-
if (currentValue != null)
869+
var result = new DefaultInterpolatedStringHandler(0, 0, CultureInfo.CurrentCulture, stackalloc char[StackallocCharBufferSizeLimit]);
870+
result.AppendFormatted(value); // first value
871+
do
929872
{
930-
result.Append(currentValue.ToString());
873+
if (!separator.IsEmpty)
874+
{
875+
result.AppendFormatted(separator);
876+
}
877+
878+
result.AppendFormatted(e.Current);
931879
}
880+
while (e.MoveNext());
881+
return result.ToStringAndClear();
932882
}
933-
while (en.MoveNext());
883+
else
884+
{
885+
// For all other Ts, fall back to calling ToString on each and appending the resulting
886+
// string to a builder.
934887

935-
return result.ToString();
888+
string? firstString = e.Current?.ToString(); // save the first value
889+
if (!e.MoveNext())
890+
{
891+
return firstString ?? Empty;
892+
}
893+
894+
var result = new ValueStringBuilder(stackalloc char[StackallocCharBufferSizeLimit]);
895+
896+
result.Append(firstString);
897+
do
898+
{
899+
if (!separator.IsEmpty)
900+
{
901+
result.Append(separator);
902+
}
903+
904+
result.Append(e.Current?.ToString());
905+
}
906+
while (e.MoveNext());
907+
908+
return result.ToString();
909+
}
936910
}
937911
}
938912

@@ -965,6 +939,11 @@ private static string JoinCore(ReadOnlySpan<char> separator, ReadOnlySpan<string
965939
}
966940
}
967941

942+
if (totalLength == 0)
943+
{
944+
return Empty;
945+
}
946+
968947
// Copy each of the strings into the result buffer, interleaving with the separator.
969948
string result = FastAllocateString(totalLength);
970949
int copiedLength = 0;

0 commit comments

Comments
 (0)