Skip to content

Add the net9 StringBuilder params ReadOnlySpan overloads - #605

Merged
SimonCropp merged 1 commit into
mainfrom
stringbuilder-params-span
Sep 10, 2026
Merged

Add the net9 StringBuilder params ReadOnlySpan overloads#605
SimonCropp merged 1 commit into
mainfrom
stringbuilder-params-span

Conversation

@SimonCropp

Copy link
Copy Markdown
Owner

Six members, all net9: AppendFormat with and without a provider, and AppendJoin over ReadOnlySpan<object?> and ReadOnlySpan<string?> with a char or a string separator.

The question that had to be answered first: can these ever be called?

The BCL already has AppendFormat(string, params object?[]) on every target. Extension members are only considered when no applicable type member exists — so if a loose argument list always binds to the array overload, a span extension would be dead code and not worth shipping.

Tested on net8.0 with a marker before writing anything:

call binds to extension reached
sb.AppendFormat("{0}{1}", 1, 2) BCL params object[] no
sb.AppendFormat(fmt, someReadOnlySpan) the polyfill yes

So it is not dead code — it is reached exactly when the caller already has a span, which is the case the API exists for. And it is not a source break either: a loose argument list still compiles to the array overload, exactly as it does without Polyfill, producing identical text.

Both facts are //Note:d on each member. The second one especially, because "I called it and my allocation didn't go away" is otherwise a confusing result.

On net9.0 and later, C# 13's params-collection rules prefer the span overload for a loose list, so the same source allocates less there. That difference is the BCL's, not the polyfill's.

Implementation and the allocation note

Each copies the span to an array and defers to the array overload, which allocates precisely what the span form exists to avoid. That is noted. It cannot be avoided without reimplementing composite formatting, and the "deliverability over perf" rule covers it — the value here is that code written against net9 compiles and behaves correctly on older targets.

A related group deliberately left out

AppendFormat(IFormatProvider, CompositeFormat, ...) — five overloads — is a net8 addition and is not included. CompositeFormat is itself a net8 type that parses and caches a format string, so it would have to be recreated before any of those overloads could exist. That is a separate and much larger piece of work.

Tests

Six tests, on every target framework, so net9.0 and later check the BCL and everything below checks the polyfill. Each span call is asserted against the corresponding array overload rather than a hand-written string, so the oracle is an API that exists unchanged everywhere. Covered: null elements, null separators, empty and single-element spans, and a provider that demonstrably changes the output (de-DE vs invariant for {0:N2}).

There is also a regression guard for loose argument lists, since those bind to the array overload below net9.0 and the span overload from net9.0 — the test pins that both produce the same text.

Verification

Solution clean in Release, Consume clean across all 22 TFMs — including net461 and netstandard2.0, where AppendJoin's array form is itself a Polyfill extension rather than a type member, so those targets were the ambiguity risk. Tests green on net11.0 (1741), net10.0 (1741), net9.0 (1741), net8.0 (1738), net462 (1687), plus PublicTests, EmbeddedTests, UnsafeTests, NoRefsTests and NoExtrasTests.

API count 1155 → 1161.

Six members: AppendFormat with and without a provider, and AppendJoin over
ReadOnlySpan<object> and ReadOnlySpan<string> with a char or a string separator.

These only pay off for a caller that already has a span. Verified on net8 before
writing anything: a loose argument list binds to the BCL params array overload and the
extension is never reached, because an applicable type member is preferred over any
extension. An explicit ReadOnlySpan does reach it. So the polyfill adds the explicit
span form without changing what a loose argument list compiles to. Both facts are noted
on each member, since the second is not obvious.

The CompositeFormat overloads added in net8 are not included. CompositeFormat is itself
a net8 type that parses and caches a format string, so it would have to be recreated
first.

API count 1155 -> 1161.
@SimonCropp SimonCropp added this to the 11.3.0 milestone Sep 10, 2026
@SimonCropp
SimonCropp merged commit b877ffe into main Sep 10, 2026
4 of 6 checks passed
@SimonCropp
SimonCropp deleted the stringbuilder-params-span branch September 10, 2026 10:24
This was referenced Sep 10, 2026
This was referenced Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant