csharplang/meetings/2021/LDM-2021-04-05.md
2021-04-06 15:36:07 -07:00

9.6 KiB

C# Language Design Meeting for April 5th, 2021

Agenda

  1. Interpolated string improvements
  2. Abstract statics in interfaces

Quote of the Day

  • "Every time someone tells me they have a printer that works, I believe they're part of the conspiracy"... "So what I'm getting from this is that you've given up on your printer and are now printing at the office?"

NB: This is, to my knowledge, the largest amount of time between parts 1 and 2 of an LDM quote (approximately 1 hour and 10 minutes, in this instance).

Discussion

Interpolated string improvements

https://github.com/dotnet/csharplang/issues/4487

Our focus today was in getting enough of the open questions in this proposal completed for the dotnet/runtime API proposal to move forward.

Create method shape

We adjusted the shape of the Create method from the initial proposal to facilitate some improvements:

  • In some cases, outing a parameter with a reference type field can cause the GC to introduce a write barrier.
  • For the non-bool case, Create feels more natural.

This may be a micro-optimization, but given that this pattern is not intended to be user-written, but instead lowered to by the compiler, we feel that it's worth it.

Conclusion

Approved.

TryFormatX method shape

The proposal suggests that we should allow TryFormatX calls to either return bool or void, as some builders will want to stop formatting after a certain point if they run out of space, or if they know that their output will not be used. While a void-returning method named TryX isn't necessarily in-keeping with C# style, we will keep the single name for a few reasons:

  • Again, this pattern isn't intended to be directly consumed by the end user, they'll be writing interpolated strings that lower to this.
  • It's harder for API authors to mess up and mix void-returning and bool-returning TryFormatX methods, because you can't overload on return type.
  • It's easier to implement.

We do want to allow mixing of a Create method that has an out bool parameter, as some builder are never going to fail after the first create method. A logger, for example, would return false from Create if the log level wasn't enabled, but the actual format calls will always succeed.

We also looked at ensuring that a single TryFormatInterpolationHole method with optional parameters for both alignment and format components can be used with this pattern. As specified today, these parameters are not named so overload resolution can only look for signatures by parameter type. This means there's no single signature that handle just a format or just an alignment. To resolve this, we will specify the parameter names we use for the alignment and format components (their names will be alignment and format, respectively). The first parameter will still be unnamed.

Another discussion point was on whether we should require builders to always support all possible combinations of a format or alignment component being present. For some types (such as ReadOnlySpan<T>), we're imagining that such components would be just ignored by the builder, and it would be interesting to just have that be a compile error instead of just being silently ignored at runtime. However, the ship on invalid format specifiers sailed a long time ago, and there is potential difficulty in making a good compile-time error experience for these scenarios. Thus, our recommendation is to always include overloads that support these specifiers, even when ignored at runtime.

Conclusion

API shape is approved. We will use named parameters for alignment and format parameters, as appropriate.

Disposal

Finally in interpolated string improvements, we looked at supporting disposal of builder types. Our decision that we will have conditional execution of interpolated string holes means that user code will be running the middle of builder code. This means that an exception can be thrown, and if the builder acquired resources (such as renting from the ArrayPool), it has the potential to be lost if we do not wrap the builder (and potentially larger method call that consumes the builder for non-string arguments) in a try/finally that calls dispose on the builder. There's also an additional complication to the disposal scenario, which is whether we trust the compile-time type of the builder. If the builder is a non-sealed reference type or a type parameter constrained to an interface type that has an abstract static Create method, the actual runtime type could implement IDisposable that we do not know about at runtime. In the language, we're not hugely consistent on this. In some cases we trust that the compile-time type is correct (such as when determining if a sealed/struct type is disposable), and in some cases we emit a runtime check for IDisposable (such as if the enumerator type is a non-sealed type or interface). We also need to consider whether to do pattern-based Dispose for all types (as we do in await foreach) or just for ref structs (as we do for regular foreach).

Even the concept of disposal for these builder types might not be needed. The runtime is a bit leary of having InterpolatedStringBuilder be disposable, because it means that every interpolated string will introduce a try/finally in a method. We really don't want to see people create performance analyzers that tell users to avoid interpolated strings in methods because it will affect inlining decisions. We have some ideas on avoiding this for interpolated strings converted to strings specifically, but a general pattern is concerning. If the main builder type won't be disposable, are we concerned that we're trying to engineer a solution to a problem that doesn't actually exist?

Conclusion

No conclusion on this point today. A small group will examine this in more detail and make recommendations to the LDM based on evaluation.

Abstract statics in interfaces

https://github.com/dotnet/csharplang/issues/4436

We looked at 2 major changes to the proposal today: allowing default implementations and changes to operator restrictions.

Default implementations

The existing proposal specifically scopes default implementations of virtual statics in interfaces out because of concerns about runtime implementation costs. In particular, in order to make default implementations work and be able to call other virtual static members, there must be a hidden "self" type parameter so that the runtime knows what type to call the virtual static method on. That work is still too complicated to bring into C# 10, but a simple change of scope can make a subset of these cases work: we could require that all static virtual members must be called on a type parameter. This takes that hidden "self" parameter and makes it no longer hidden, because there must always be a thing that the user calls that actually contains the type. It's not always necessary to write T itself: for example, t1 + t2 would reference the static virtual + operator on T, so it's being accessed on the type parameter, not on the interface.

However, there are some serious usability concerns for this approach. A default implementation of a virtual static member cannot be inherited by any concrete types that inherit from that interface. This is true for instance methods as well, but that method (or a more derived type's implementation of the method) can be accessed by casting that instance to the interface type in question. For a DIM of a virtual static member, there is no instance that can be cast to the base interface to access the member, so a concrete type must always reimplement the virtual static member or it will be truly inaccessible. For example:

interface I<T> where T : I<T>
{
    public static abstract bool operator ==(T t1, T t2);
    public static virtual bool operator !=(T t1, T t2) => !(t1 == t2);
    public void M() {}
}
class C : I<C>
{
    public static bool operator ==(C c1, C c2) => ...;
}

C c = new C();
c.M(); // M is not accessible
((I)c).M(); // This works, however

_ = c == c; // Fine: C implements ==
_ = c != c; // Not fine: I.!= is a default implementation that C does not inherit. This method cannot be called.

There are 2 workarounds a user could use for this problem: make a generic method that takes a type parameter constrained to I and invoking the member there, or reimplementing the operator on C, thereby removing the benefit of the default implementation in the first place. Neither of these are particularly appealing.

Conclusion

A smaller group will revisit this and decide whether it is useful. We are skeptical of it based on the above problems currently.

Operator restrictions

Today, interfaces are prohibited from implementing == or !=, and from appearing in user-defined conversion operators. We have a long history of this, mainly because with interfaces there is always the change that the underlying type actually implements the interface at runtime, and the language should always prefer built-in conversions to user-defined conversions. However, these operators cannot actually be accessed when the user only has an instance of the interface, they can only be accessed when using a concrete derived type or a type parameter constrained to the interface type. This addresses our concerns about interface implementation at runtime, and conversions from types defined in interfaces is particularly useful for numeric contexts such as being able to allow T t = 1; because T is constrained to an interface that has a user-defined conversion from integers.

Conclusion

Lifting these restrictions is approved.