-
Notifications
You must be signed in to change notification settings - Fork 4.8k
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
[API Proposal]: DefaultInterpolatedStringHandler - expose buffer and reset #110505
Comments
/cc @stephentoub from discussion on #110504 |
Related: #110427 |
lol, my team are troublemakers; good to see that both me and @BrennanConroy saw the problem re also cross-ref #55390 which was previously closed; one of the objections there was the "exposing |
Radical additional step, which would still require this API change; consider:
At the moment this becomes: // ...
defaultInterpolatedStringHandler.AppendFormatted(valueTuple.Item2);
someType.DoThing(defaultInterpolatedStringHandler.ToStringAndClear()); Wouldn't it be nice if the // ...
defaultInterpolatedStringHandler.AppendFormatted(valueTuple.Item2);
someType.DoThing(defaultInterpolatedStringHandler.Text);
defaultInterpolatedStringHandler.Clear(); This is simpler for consumers (not requiring a Maybe it could apply only to:
|
Isn't this the same as #55390? I tried making the same proposal years ago but it got rejected, there's the API noted at the end of the comments in that issue 🥲 EDIT: nvm I missed the fact you called that out already. Not entirely sure I am fully convinced of how usable this would be in practice though if you were not actually guaranteed to be able to get a span from the handler 🤔 |
API review did agree to possibly adding a “try get text” method in the future. This proposal would allow things like this: try
{
if (!dish.TryGetSpan(out ReadOnlySpan<char> span))
span = dish.ToString();
// use span
}
finally
{
dish.Clear();
} |
@colejohnson66 yes, that's exactly what I meant by
|
An alternative would be to expose the |
@Sergio0694 yes exactly the same - I didn't see yours at the time. I guess this is just "more data points" in terms of "this API is really desirable, and here's concrete examples why", with the "if not" being: we manually copy |
This is interesting. @jaredpar, @333fred, timelines aside, is that feasible from a language perspective? Basically allowing an interpolated string to target type to a span, preferred over string, and using an exposed API on the default builder to get the span? |
If you modify the string one, can you do the same for the utf8 one?
The _pos field and _success field are internal, thus
is required there too if you don't want to rewrite your own. |
@stephentoub assuming it is restricted to "scoped" scenarios (so the span can't be snagged) the biggest problem I can see there is lifetime management - it would effectively need to work like
Where |
actually, assignment to a string handler type already works; with the addition of using System;
using System.Runtime.CompilerServices;
int id = 42;
string name = "def";
DefaultInterpolatedStringHandler value = $"abc {id} def {name}";
DoSomething(value.Text); // this .Text does not currently work
value.Clear(); // this .Clear() does not currently work
static void DoSomething(ReadOnlySpan<char> value){} The only question there is: should the public-facing int id = 42;
string name = "def";
using DefaultInterpolatedStringHandler value = $"abc {id} def {name}";
DoSomething(value.Text); Demonstration, although notes that it creates a shadow copy of A small part of me wonders whether the above should/could actually compile more like: MyStringHandler myStringHandler = new MyStringHandler(9, 2);
+ try
+ {
myStringHandler.AppendLiteral("abc ");
myStringHandler.AppendFormatted(value);
myStringHandler.AppendLiteral(" def ");
myStringHandler.AppendFormatted(value2);
- MyStringHandler myStringHandler2 = myStringHandler;
- try
- {
- <<Main>$>g__DoSomething|0_0(myStringHandler2.Text);
+ <<Main>$>g__DoSomething|0_0(myStringHandler.Text);
}
finally
{
- myStringHandler2.Dispose();
+ myStringHandler.Dispose();
} That's probably a question for @jaredpar ;p (although to be fair, |
Yes. And scoped, whether explicit or much more commonly implicit, is the dominant form with
The compiler would just emit a call to Clear after the method call. It wouldn't even need to be in a finally block. DefaultInterpolatedStringHandler is called "default" and in CompilerServices because it's there to serve the needs of the C# compiler and C# language specification; it embodies whatever semantics the language needs. All Clear does is return any rented memory to the pool, and in general, we're fine if exceptional paths don't return to the pool (some folks even argue for not doing so).
Per the above, I don't think so. |
I've started using that approach almost exclusively; the reason being: a lot of code is async, and if an exception happens in async: we don't know whether the buffer is still being touched by a competing code path, and we're seeing a TCS that has been cancelled by a CT. |
You don't know that for sync either, e.g. if code does: public int Compute(byte[] buffer)
{
Task t1 = Task.Run(() => Compute(buffer.AsSpan(0, buffer.Length / 2));
Task t2 = Task.Run(() => Compute(buffer.AsSpan(buffer.Length / 2));
return t1.Result + t2.Result;
} The point being, it's not really about the signature being sync vs async but rather about whether the implementation uses fork/join patterns. If an async method doesn't fork (the vast vast majority don't), or if it guarantees that all forked work will have quiesced (e.g. Task.WhenAll), then there's no difference in this regard. But we digress... |
This makes me think back of the |
One item to think about is generally the language doesn't treat parameters special in this way. Instead we'd say that it's a supported conversion in the language that works on locals too. But it already works on locals today and produces locals that are safe to escape where as this proposal is aiming to create values that are not: // works today
ReadOnlySpan<char> M(string str) => $"hello {str}"; Need to think about how we'd differentiate these cases. Essentially when is it okay to convert an interpolated string that cannot escape. Earlier there was a mention that we could leverage |
Tagging subscribers to this area: @dotnet/area-system-runtime |
Here's a fully worked example of how two proposed tweaks combine to allow complex cache scenarios to use alloc-free code paths, including retroactively when a string API already exists: https://gist.github.com/mgravell/750bed134f36f417f97c217297552a88 |
namespace System.Runtime.CompilerServices;
public ref struct DefaultInterpolatedStringHandler
{
public void Clear();
public ReadOnlySpan<char> Text { get; }
} |
@mgravell Is it an alternative also to build a custom InterpolatedStringHandler with StringBuilderCache as a storage? @stephentoub FYI |
@TrayanZapryanov not really no - if you're going to end up with a |
@mgravell The idea is to get span from StringBuilder - I think this can be used. And then it will be zero alloc too. |
My gut says "that won't be as efficient", but Eric tells me I should race my horses, so: | Method | Mean | Error | StdDev | Gen0 | Allocated |
|----------------------------------- |---------:|---------:|---------:|-------:|----------:|
| Tuple | 11.18 ns | 0.047 ns | 0.041 ns | - | - |
| String | 35.17 ns | 0.176 ns | 0.156 ns | 0.0033 | 56 B |
| DISH_Span | 34.90 ns | 0.203 ns | 0.190 ns | - | - |
| StackAlloc_DISH_Span | 32.61 ns | 0.155 ns | 0.145 ns | - | - |
| StackAllocSkipLocalsInit_DISH_Span | 32.31 ns | 0.101 ns | 0.090 ns | - | - |
| SBC_Span | 34.60 ns | 0.251 ns | 0.235 ns | - | - | where
Conclusion: the zero-alloc string-like approaches are all within rounding-error distance of each-other; the alloc-version is also in the same field, but: we'd love to avoid that unnecessary alloc. It feels like this is a natural and useful API for
To pre-empt "but that data says we should just use tuples, why are we discussing this?" - see comment in the bench: // note: tuples are great, but aren't as useful as you'd hope for
// caching; it requires a TKey cache (which doesn't work with IMemoryCache etc),
// or we pay for boxing - and we'd need a stringify API for IDistributedCache,
// plus it doesn't benefit from the hash-balancing/re-seeding features of string
// (hash-poisoning protection) (and fundamentally: the topic here is where the existing API design is around string-like keys, and we just want to make it more efficient) |
There is no public StringBuilderCache.
It is not much safer. Depending on the implementation it has either the same concerns or different ones. Not returning an array to the pool is the least of the concerns. |
@mgravell Thank you very much for spending time to test my proposal. you'd need to add and maintain a lot of AppendFormatted overloads on your custom type to retain feature parity with DefaultInterpolatedStringHandler
Again - I am not against your approach, just an idea that popup in my mind when I saw pending PR. |
No. The compiler requires very specific constructors and named methods.
It's not, though. When do you remove a builder from the cache, and if you don't remove it, how do you avoid corruption if the same builder is used by two different consumers at the same time? How do you avoid allocation for larger strings without the cache growing too large, and if you allow it to grow large, how do you avoid that being a process-wide leak? Etc |
My point is that you'd still need to offer them up to the compiler, though, via |
Thank you both for the information. |
Background and motivation
Cross-reference #110504
In APIs where it is desirable to efficiently pass in interpolated string values, for example as keys, there is not currently a mechanism to access a span-based implementation.
DefaultInterpolatedStringHandler
does everything required for such scenarios, but after append: onlyToStringAndClear
is exposed.API Proposal
source
API Usage
Alternative Designs
DefaultInterpolatedStringHandler
DefaultInterpolatedStringHandler
, forwarding all the append methods, and use unsafe-accessor etc to invoke theinternal
APIsRisks
If the simple
Text
approach is used, it locks the implementation to a single buffer (notReadOnlySequence<char>
or similar);TryGetSpan
leaves that open, withToStringAndClear
being a viable fallback.Failure to call
Clear
could leak arrays (not a hard leak; the GC will still catch them, but: not ideal); that sounds like a "use the API correctly" problem; alternatively, perhaps the C# compiler itself could invoke theClear()
method (once it ispublic
, and possibly by looking for an attribute to be sure) after the invoke, as a fallback, so that this is mitigated.The text was updated successfully, but these errors were encountered: