Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for ISpanFormattable in StringBuilder.Append(Object) #92193

Closed
ghost opened this issue Sep 17, 2023 · 5 comments
Closed

Add support for ISpanFormattable in StringBuilder.Append(Object) #92193

ghost opened this issue Sep 17, 2023 · 5 comments
Labels
area-System.Runtime in-pr There is an active PR which will close this issue when it is merged needs-further-triage Issue has been initially triaged, but needs deeper consideration or reconsideration

Comments

@ghost
Copy link

ghost commented Sep 17, 2023

Currently, StringBuilder.Append(Object) is calling Object.ToString to get a string representation of the object passed as a parameter:

public StringBuilder Append(object? value) => (value == null) ? this : Append(value.ToString());

Can we consider adding support for objects that implement ISpanFormattable here instead of calling ToString for them? This will allow to write directly to the underlying buffer rather than allocate a temporary string.

Currently to avoid the temporary string when appending an object, we can use the overload Append(ref AppendInterpolatedStringHandler) but it's not intuitive to use an interpolated string just to append an object like:

Version version = new() { Major = 2, Minor = 3, Patch = 140 };
var sb = new StringBuilder();
sb.Append(version); // ToString will be called here and a temporary string will be allocated
sb.Append($"{version}"); // ISpanFormattable.TryFormat will be called here
var result = sb.ToString();

public struct Version : ISpanFormattable
{
    const int Int32NumberBufferLength = 10 + 1; // 10 for the longest input: 2,147,483,647. We need 1 additional byte for the terminating null

    public int Major { get; init; }
    public int Minor { get; init; }
    public int Patch { get; init; }

    public override string ToString()
    {
        Span<char> destination = stackalloc char[(3 * Int32NumberBufferLength) + 3]; // at most 3 Int32s and 3 periods
        _ = TryFormatCore(destination, out int charsWritten);
        return destination.Slice(0, charsWritten).ToString();
    }

    public string ToString(string? format, IFormatProvider? formatProvider)
    {
        return ToString();
    }

    public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
    {
        return TryFormatCore(destination, out charsWritten);
    }

    private bool TryFormatCore(Span<char> destination, out int charsWritten)
    {
        return destination.TryWrite($"{Major}.{Minor}.{Patch}", out charsWritten);
    }
}

Benchmark:

[MemoryDiagnoser]
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Bench
{
    private readonly Version _version = new() { Major = 2, Minor = 3, Patch = 140 };
 
    [Benchmark]
    public string AppendObject()
    {
        var sb = new StringBuilder();
        sb.Append(_version);
        return sb.ToString();
    }

    [Benchmark]
    public string AppendInterpolated()
    {
        var sb = new StringBuilder();
        sb.Append($"{_version}");
        return sb.ToString();
    }
}
Method Mean Gen0 Allocated
AppendObject 78.03 ns 0.1032 216 B
AppendInterpolated 45.64 ns 0.0688 144 B
@dotnet-issue-labeler dotnet-issue-labeler bot added the needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners label Sep 17, 2023
@ghost ghost added the untriaged New issue has not been triaged by the area owner label Sep 17, 2023
@vcsjones vcsjones added area-System.Runtime and removed needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners labels Sep 17, 2023
@ghost
Copy link

ghost commented Sep 17, 2023

Tagging subscribers to this area: @dotnet/area-system-runtime
See info in area-owners.md if you want to be subscribed.

Issue Details

Currently, StringBuilder.Append(Object) is calling Object.ToString to get a string representation of the object passed as a parameter:

public StringBuilder Append(object? value) => (value == null) ? this : Append(value.ToString());

Can we consider adding support for objects that implement ISpanFormattable here instead of calling ToString for them? This will allow to write directly to he underlying buffer rather than needing to allocate a temporary string.

Currently to avoid the temporary string when appending an object, we can use the overload Append(ref AppendInterpolatedStringHandler) but it's not intuitive to use an interpolated string just to append an object like:

Version version = new() { Major = 2, Minor = 3, Patch = 140 };
var sb = new StringBuilder();
sb.Append(Version); // ToString will be called here and a temporary string will be allocated
sb.Append($"{version}"); // ISpanFormattable.TryFormat will be called here
var result = sb.ToString();

public struct Version : ISpanFormattable
{
    const int Int32NumberBufferLength = 10 + 1; // 10 for the longest input: 2,147,483,647. We need 1 additional byte for the terminating null

    public int Major { get; init; }
    public int Minor { get; init; }
    public int Patch { get; init; }

    public override string ToString()
    {
        Span<char> destination = stackalloc char[(3 * Int32NumberBufferLength) + 3]; // at most 3 Int32s and 3 periods
        _ = TryFormatCore(destination, out int charsWritten);
        return destination.Slice(0, charsWritten).ToString();
    }

    public string ToString(string? format, IFormatProvider? formatProvider)
    {
        return ToString();
    }

    public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
    {
        return TryFormatCore(destination, out charsWritten);
    }

    private bool TryFormatCore(Span<char> destination, out int charsWritten)
    {
        return destination.TryWrite($"{Major}.{Minor}.{Patch}", out charsWritten);
    }
}

Benchmark:

[MemoryDiagnoser]
[HideColumns("Error", "StdDev", "Median", "RatioSD")]
public class Bench
{
    private readonly Version _version = new() { Major = 2, Minor = 3, Patch = 140 };
 
    [Benchmark]
    public string AppendObject()
    {
        var sb = new StringBuilder();
        sb.Append(_version);
        return sb.ToString();
    }

    [Benchmark]
    public string AppendInterpolated()
    {
        var sb = new StringBuilder();
        sb.Append($"{_version}");
        return sb.ToString();
    }
}
Method Mean Gen0 Allocated
AppendObject 78.03 ns 0.1032 216 B
AppendInterpolated 45.64 ns 0.0688 144 B
Author: cakescience
Assignees: -
Labels:

area-System.Runtime, untriaged

Milestone: -

@ghost ghost added the in-pr There is an active PR which will close this issue when it is merged label Sep 17, 2023
@manandre
Copy link
Contributor

Good catch!
The optimization is already correctly applied for AppendFormat.
We should apply it to Append in a similar way.
Implemented in #92197

@tannergooding tannergooding added needs-further-triage Issue has been initially triaged, but needs deeper consideration or reconsideration and removed untriaged New issue has not been triaged by the area owner labels Jun 24, 2024
@stephentoub
Copy link
Member

Per #92197 (comment)

@stephentoub stephentoub closed this as not planned Won't fix, can't repro, duplicate, stale Jul 1, 2024
@julealgon
Copy link

Can someone elaborate what goes into the decision of optimizing an existing method with a type check (like this), vs just providing an overload with the specific interface instead, such as:

public StringBuilder Append(ISpanFormattable value)

Or:

public StringBuilder Append<TSpanFormattable>(TSpanFormattable value)
    where TSpanFormattable : ISpanFormattable

Wouldn't the overload approach be more "idiomatic"/"less magical" than the internal type-check based optimization? What is the reason to not do something like that?

@MichalPetryka
Copy link
Contributor

What is the reason to not do something like that?

I assume the only concern would be with bloating the API surface for something that's effectively an implementation detail.

@github-actions github-actions bot locked and limited conversation to collaborators Aug 1, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-System.Runtime in-pr There is an active PR which will close this issue when it is merged needs-further-triage Issue has been initially triaged, but needs deeper consideration or reconsideration
Projects
None yet
Development

Successfully merging a pull request may close this issue.

6 participants