Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Rename "Target-typed static member lookup" to "access" and spec factory containers #9601
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
Uh oh!
There was an error while loading. Please reload this page.
Rename "Target-typed static member lookup" to "access" and spec factory containers #9601
Changes from all commits
3d5d606
c3e5036
bd79d7f
2e9494a
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
There are no files selected for viewing
Target-typed static member
lookupaccessChampion issue: #9138
Thanks to those who provided insight and input into this proposal, especially @CyrusNajmabadi!
Summary
This feature enables a type name to be omitted from static member access when it is the same as the target type.
This reduces construction and consumption verbosity for factory methods, nested derived types, enum values, constants, singletons, and other static members. In doing so, the way is also paved for discriminated unions to benefit from the same concise construction and consumption syntaxes.
Motivation
Repeating a full type name before each
.
can be redundant. This happens often enough that it would make sense to put the choice in developers' hands to avoid the redundancy. Today, there's no scalable workaround for the verbosity in the following example:This feature brings a dramatic quality-of-life improvement:
The implications are clear for discriminated unions. Creation and consumption of class-based discriminated unions will likely involve nested derived types. When creating or consuming nested derived types, you would be able to just type
.
and receive exactly the relevant list of types grouped together by the nesting—perfect for selecting a case for a discriminated union. It would be hard to stomach either reading or writing long type names before each.
.This proposal furthers the language design team's interest in pursuing this space, separately from discriminated unions, but also in anticipation of discriminated unions. Quoting LDM notes from Sept 2022:
There has also been steady interest in this feature in terms of community discussions and upvotes.
Detailed design
Basic expression
There is a new primary expression, target-typed member binding expression, which starts with
.
and is followed by an identifier:.Xyz
. What makes it a target-typed member binding, rather than some other kind of member binding, is its location as a primary expression.If this expression appears in a location where there is no target type, a compiler error is produced. Otherwise, this expression is bound in the same way as though the identifier had been qualified with the target type.
To determine whether there is a target type and what that target type is, the language adds an implicit target-typed member binding conversion, from target-typed member binding expression to any type. The conversion succeeds regardless of the target type. This allows errors to be handled in binding after the conversion succeeds, such as not finding an accessible or applicable member with the given identifier, or such as the expression evaluating to an incompatible type. For example:
The fact that the conversion succeeds regardless of target type also enables target-typing with invocations.
This is sufficient to allow this new construct to be combined with constructs which allow target-typing. For example:
If the resulting bound member is a constant, it may be used in locations which require constants, no differently than if it was qualified:
Pattern matching
A core scenario for this proposal is to be able to match nested derived types without repeating the containing type name, which may be long especially with generics.
This includes nested patterns:
Any type expression in a type pattern may begin with a
.
. It is bound as though it was qualified with the type of the expression or pattern being matched against. If such qualified access is permitted, the target-typed type pattern is permitted. If such qualified access is not permitted, the target-typed type pattern fails with the same message.Target-typing with overloadable operators
A core scenario for this proposal is using bitwise operators on flags enums. To enable this without adding arbitrary limitations, this proposal enables target-typing on the operands of overloadable operators.
Other target-typed expressions besides
.Xyz
will be able to benefit from this, such asnull
,default
and[]
. One exception to this target-typednew
, which explicitly states that it may not appear as an operand of a unary or binary operator. If desired, we could lift this restriction so that it is not the odd one out:This is done by adding three new conversions: unary operator target-typing conversion, binary operator target-typing conversion, and binary cross-operand target-typing conversion. All existing conversions are better than these new conversions.
For a unary operator expression such as
~e
, we define a new implicit unary operator target-typing conversion that permits an implicit conversion from the unary operator expression to any typeT
for which there is a conversion-from-expression frome
toT
.For a binary operator expression such as
e1 | e2
, we define a new implicit binary operator target-typing conversion that permits an implicit conversion from the binary operator expression to any typeT
for which there is a conversion-from-expression frome1
toT
and/or frome2
toT
.For either operand of a binary operator expression such as
e1 | e2
, if one expression has a typeT
and the other expression does not have a type, and there is a conversion-from-expression from the typeless operand expression toT
, we define a new implicit binary cross-operand target-typing conversion that permits an implicit conversion from the typeless operand expression toT
.Target-typing from one operand to another is helpful in the following scenario:
Target-typing with invocations
A core scenario for this proposal is calling factory methods. This enables the use of the feature with some of today's class and struct types. This also provides symmetry between production and consumption of values at a future point when there is a facility for
is .Some(42)
to light up without a type hierarchy, which is a future potential with discriminated unions.To enable target-typing for the invoked expression within an invocation expression, a new conversion is added, invocation target-typing conversion. All existing conversions are better than this new conversion.
For an invocation expression such as
e(...)
where the invoked expressione
is a target-typed member binding expression, we define a new implicit invocation target-typing conversion that permits an implicit conversion from the invocation expression to any typeT
for which there is a target-typed member binding conversion frome
toTₑ
.Even though the conversion always succeeds when the invoked expression
e
is a target-typed member binding expression, further errors may occur if the invocation expression cannot be bound for any of the same reasons as though the target-typed member binding expression was a non-target-typed expression, qualified as a member ofT
. For instance, the member might not be invocable, or might return a type other thanT
.Target-typing after the
new
keywordA core scenario for this proposal is enable the
new
operator to look up nested derived types. This provides symmetry between production and consumption of values with class DUs and with today's type hierarchies based on nested derived classes or records.This balances the consumption syntax:
This would continue to be target-typed static member access (since nested types are members of their containing type), which is distinct from target-typed
new
since a definite type is provided to thenew
operator.TODO: spec the conversion
Notes
As with target-typed
new
, targeting a nullable value type should access members on the inner value type:As with target-typed
new
, overload resolution is not influenced by the presence of a target-typed static member expression. If overload resolution was influenced, it would become a breaking change to add any new static member to a type.Factory containers
Summary (factory containers)
Target-typed static members will be found on separate factory container types, for example:
Option<ImmutableArray<int>> result = .Some([42]);
- CallsSome<T>
on the nongenericOption
typeSearchValues<char> separators = .Create(',', ';');
- CallsCreate(ReadOnlySpan<char>)
on the nongenericSearchValues
typeTensor<T> c = .Add(a, b);
- CallsAdd<T>
on the nongenericTensor
typeThis will happen automatically when the factory member is on a nongeneric sibling type with the same name. There is also an opt-in model to enable the same lookup in classes where the name does not match. For example:
IEnumerable<int> numbers = .Range(1, 10);
- CallsRange
on theEnumerable
typeIEqualityComparer<string> comparer = .OrdinalIgnoreCase;
- CallsOrdinalIgnoreCase
on theStringComparer
typeIEqualityComparer<T> comparer = .Default;
- CallsDefault
onEqualityComparer<T>
For the above examples to work, the
IEnumerable<T>
interface definition would be decorated with an attribute referring to theEnumerable
class, and theIEqualityComparer<T>
interface definition would be decorated with an attribute referring to theStringComparer
andEqualityComparer<>
classes.Motivation (factory containers)
A common .NET pattern for obtaining a value of some generic type is to call a factory method on a nongeneric type of the same name so that the type argument can be inferred. The core libraries follow this pattern. For example:
KeyValuePair.Create<TKey, TValue>
to obtain aKeyValuePair<TKey, TValue>
Task.FromResult<T>
to obtain aTask<T>
Vector.Add<T>
to obtain aVector<T>
, along with many other factory methods for other operations.Tensor.Add<T>
to obtain aTensor<T>
, along with many other factory methods for other operations.Vector128.Add<T>
to obtain aVector128<T>
, along with many other factory methods for other operations.ImmutableArray.Create<T>
to obtain anImmutableArray<T>
Tuple.Create<T1, T2, ...>
to obtain aTuple<T1, T2, ...>
Channel.CreateBounded<T>
to obtain aChannel<T>
SearchValues.Create(ReadOnlySpan<byte>)
to obtain aSearchValues<byte>
, and overloads for<char>
and<string>
Expression.Lambda<TDelegate>
to obtain anExpression<TDelegate>
This pattern has wide uptake in community APIs as well. The SDK steers users in this direction with the CA1000: Do not declare static members on generic types rule.
Following these patterns and warnings, existing
Option<T>
types should put their.Some
factory method on a nongeneric class,Option.Some<T>
. This enables generic type inference, soOption.Some(42)
can be written.However, this means that the method
Option.Some<T>
does not exist. This would cause an odd asymmetry: in the core proposal, you'd be able to write.None
, but not.Some(val)
. TheNone
member is onOption<T>
because there are no type inference opportunities, in the same manner asImmutableArray<T>.Empty
. But theSome
member is on nongenericOption
, not onOption<T>
.There is a clear relationship between
Option<T>
andOption
,KeyValuePair<TKey, TValue>
andKeyValuePair
,Task<T>
andTask
,ImmutableArray<T>
andImmutableArray
,Tensor<T>
andTensor
. The relationship is clear both from the naming convention and due to the static members on the factory type that return instances of the generic type.It would be a lost opportunity not to make use of this clear relationship in order to make consumption more consistent.
In addition, if a separate proposal were to make
IEnumerable<T>
the target type of spreads and foreach, the syntaxforeach (var x in .Range(1, 10))
or[.. .Repeat(1, 10)]
would just fall out.Detailed design (factory containers)
As a guiding principle, the outcome for the call site can be thought of as equivalent to static extension methods being provided on the generic type which directly call the factory member on the generic type. The specifics below are aimed at this equivalence.
Member lookup for a target-typed member binding expression would consider not just static members on the targeted type, but also applicable factory members, a subset of the static members of the target type's related factory container types.
A type
F
serves as a factory container type for another typeG
ifF
is explicitly referenced by a FactoryContainerAttribute onG
as defined below, or implicitly ifF
has no type parameters of its own andG
does have type parameters of its own, and they are both declared in the same module, and they are both declared in either the same containing type if any, or the same namespace if not.A member of a factory container type is an applicable factory member if it is static and the result of evaluating the member has an identity conversion to the target type. If the target-typed member binding expression is the expression of an invocation expression, then the result of evaluating the invocation is considered instead, and any inferred type arguments in the invocation that appear in the return type will be inferred outside-in to match the target type. For example,
Option<short> opt = .Some(42)
will inferOption.Some<short>
.The filter of applicable factory members means that
IEqualityComparer<string> comparer = .OrdinalIgnoreCase;
will work ifIEqualityComparer<T>
declares[FactoryContainer(typeof(StringComparer))]
, butIEqualityComparer<int> comparer = .OrdinalIgnoreCase;
will fail with an error that.OrdinalIgnoreCase
cannot be found, rather than an error that it returns a comparer with an incompatible type.This is the definition of the attribute that enables a type to use factory members in another type when constructed through target-typed static member access:
Errors will be produced in the following scenarios:
Alternatives (factory containers)
Static extension methods would be able to achieve the same end goal of writing
.Some(42)
forOption<T>
or.Range(1, 10)
forIEnumerable<int>
or.OrdinalCompareCase
forIEqualityComparer<string>
. This would not fall foul of the CA1000: Do not declare static members on generic types rule, since the extension method itself is not declared on the generic type.While this shows the power of combining the core proposal with static extension methods, there are scaling problems with using static extension methods to provide the missing consistency in consumption syntax.
Specification
'.' identifier type_argument_list?
is consolidated into a standalone syntax,member_binding
, and this new syntax is added as a production of the §12.8.7 Member access grammar:TODO: patterns
Further spec simplification
TODO: Flesh out. Introduce
binding
, which is either ofmember_binding
, orelement_binding
(with'['
)Limitations
One of the use cases this feature serves is production and consumption of values of nested derived types, for discriminated unions and other scenarios. But one consumption scenario that is left out of this improvement is
results.OfType<.Error>()
. It's not possible to target-type in this location because theT
is not correlated withresults
. This problem would likely only be solvable in a general way with annotations that would need to ship with theOfType
declaration.A new operator could solve this, such as
results.SelectNonNull(r => r as .Error?)
.Drawbacks
Ambiguities
There are a couple of ambiguities, with parenthesized expressions and conditional expressions. See each link for details.
Factory methods public in generic types
The availability of this feature will flip a current framework design guideline on its head, namely CA1000: Do not declare static members on generic types. Currently, the design guideline is to declare a static nongeneric class with a generic helper method so that inference is possible:
ImmutableArray.Create<T>
, notImmutableArray<T>.Create
. When people declare Option types, it's similarlyOption.Some<T>
, notOption<T>.Some
.When target-typing
Option<int> opt = .Some(42)
, what will be called is a static method on theOption<T>
type rather than on a static helperOption
type. This will require library authors to provide public factory methods in both places, if they want to cater to both target-typed construction (.Some(42)
) and to non-target-typed inference (var opt = Option.Some(42);
).Anti-drawbacks
There's been a separate request to mimic VB's
With
construct, allowing dotted access to the expression anywhere within the block:This doesn't seem to be a popular request among the language team members who have commented on it. If we go ahead with the proposed target-typing for
.Name
syntax, this seals the fate of the requestedwith
statement syntax shown here.Alternatives
Alternative: doing nothing
Generally speaking, production and consumption of discriminated union values will be fairly onerous as mentioned in the Motivation section, e.g. having to write
is Option<ImmutableArray<Xyz>>.None
rather thanis .None
.Workaround:
using static
As a mitigation,
using static
directives can be applied as needed at the top of the file or globally. This allows syntax such asGetMethod("Name", Public | Static)
today.This comes with a severe limitation, however, in that it doesn't help much with generic types. If you import
Option<int>
, you can writeis Some
, but only forOption<int>
and notOption<string>
or any other constructed type.Secondly, the
using static
workaround suffers from lack of precedence. Anything in scope with the same name takes precedence over the member you're trying to access. For example,case IBinaryOperation { OperatorKind: Equals }
binds toobject.Equals
and fails. The proposed syntax for this feature solves this with the leading.
, which unambiguously shows that the identifier that follows comes from the target type, and not from the current scope.Third, the
using static
workaround is an imprecise hammer. It puts the names in scope in places where you might not want them. Imaginevar materialType = Slate;
: Maybe you thought this was an enum value in your roofing domain, but accidentally picked up a web color instead.The
using static
approach has also not found broad adoption over fully qualifying. There are very low hit counts on grep.app and github.com forusing static System.Reflection.BindingFlags
.Alternative: no sigil
The target-typed static member
lookupaccess feature benefits from the precision of the.
sigil, but it does not require a sigil. Here's how the feature would look without a sigil:A sigil is strongly recommended for two reasons: user comprehension, and power.
Firstly, the feature would be harder to understand without a sigil. Without a sigil, locations that are target-typeable allow you to silently stumble through a wormhole into a universe with extra names in it to look up. This is a powerful event with opportunity for confusion. That's a good match for new syntax indicating "I want to access the names on the other side of this wormhole."
The presence of
.
makes reading much more efficient. If no such marker is in place, it will slow down understanding of code. Every identifier will need to be considered as to whether it is in a target-typing location and could be referring to something on that type. The chance of collisions is expected to be high. It can be difficult from context to know if target-typing is in play in a given scenario. Syntaxes such asnull
ornew()
make it clear that a target type is affecting the meaning of the expression, but a plain identifier on its own does not make this clear. It's hard to tell which locations are target-typeable and which are not. It can require a lot of backtracking while reading, and in some cases you need to know whether there are multiple overloads with varying types at this position.A sigil thus provides essential context. It asserts that the location is target-typeable, and furthermore that the name is coming from the target type. Most importantly of all, the author's intention of target-typed
lookupaccess is preserved even if an overload is added which causes target-typing to fail. Without the sigil, it would not be clear whether the original author was trying to look up something in scope, or was trying to access something off the target type. The sigil prevents spooky action at a distance which changes the fundamental meaning of the expression.Secondly, the feature would become less powerful without a sigil. To avoid changes in meaning, this would have to prefer binding to other things in the current scope name, with target-typing as a fallback. This would result in unpleasant interruptions with no recourse other than typing out the full type name. These interruptions are expected to be frequent enough to hamper the success of the feature.
This specific sigil is a good fit with modern language sensibilities and audiences. The Swift and Dart languages have both added the
.xyz
syntax with the same meaning as this proposal for C#:Open questions
Ambiguity with parenthesized expression
This is valid grammar today, which fails in binding if
A
is a type and not a value:(A).B
.The new grammar we're adding would allow this to be parsed as a cast followed by a target-typed static member
lookupaccess. This new interpretation is consistent with(A)new()
and(A)default
working today, but it would not be practically useful.A.B
is a simpler and clearer way to write the same thing.Should
(A).B
continue tofail, or be made to work the same asA.B
whenA
is a typefail whenA
is a type, or be made to work the same asA.B
?Recommendation:
(A).B
should continue to fail whenA
is a type. Even though blocking this syntax is an additional rule, the syntax is not beneficial.Ambiguity with conditional expression
There is an ambiguity if target-typed static member
lookupaccess is used as the first branch of a conditional expression, where it would parse today as a null-safe dereference:expr ? .Name : ...
We can follow the approach already taken for the similar ambiguity in collection expressions with
expr ? [
possibly being an indexer and possibly being a collection expression.Alternatively, target-typed static member
lookupaccess could be always disallowed within the first branch of a conditional expression unless surrounded by parens:expr ? (.Name) : ...
. The downside is that this puts a usability burden onto users, since the compiler can work out the ambiguity by looking ahead for the:
as with collection expressions.Recommendation: Allow
expr ? .Name :
by looking ahead for:
, just as with collection expressions.