params
improvements- Nullability analysis of collection expressions
- Evaluation of implicit indexers in object initializers
- "That is a divide by object error"
We started today by reviewing the latest proposal for improving params
. This has morphed several times over the past few years; previous issues included
#179 and #1757; this proposal encompasses both of these, and tries to
unify with the C# 12 feature collection expressions. The general driving principle of this proposal, and how the LDM is thinking of the feature, is that if
the user can make a collection of the type, they should be able to mark it params
. This may require some spec work between the two features to extract out
common elements without requiring invasive changing of everywhere that params
exists today, but it's a good driving principle.
Overall, we are very interested in the feature. It's a rare example of something that both adds expressivity to C# while actually simplifying the language:
with this change and a bit of spec work, we have a much simpler story around collections in the language. You can deconstruct them via pattern matching,
index into them, construct them via collection expressions, and provide implicit construction via params
. We consider whether we could simply not do this
feature and let collection expressions fill the gap, but there's one clear problem we need to solve: the BCL would like to add params (ReadOnly)Span
overloads
to many APIs to make them more efficient. This isn't a gap that can be solved by collection expressions, and we think that, if we're going to make a change to
params
, we should do the entire feature to make reasoning about collections in the language easier, rather than just changing the special cases users need
to think about.
We also considered some ref-struct specific design questions. In terms of allocations, we think the right approach is to again align with collection expressions;
if a collection expression wrapping the arguments passed to a params Span
allocates, then the expanded invocation form should behave the same. We left a lot
of leeway in the language for optimizations here, so we think continuing to keep to the same guarantees is the obvious thing to do. It also helps prevent
surprise behavioral differences if a user passes a collection expression to a params
parameter vs calling in expanded form. The other question we considered
is whether to have the params
parameter be scoped
by default. Some members were concerned that params
is not an obvious enough indicator of scoped
ness,
but we have somewhat already crossed this bridge with out
parameters. We also don't have any current scenarios that need an unscoped params
parameter, only
ones that need scoped
. Given that, we think we should start with scoped
by default, and let feedback inform whether we've made the right decision.
Finally, we noticed that the overload resolution tiebreaking rules may be missing a few clauses that collection expressions have around non-ref struct comparisons, so we'll make sure that's in the spec if needed.
Proposal is accepted. We will follow the same allocation guarantess as collection expressions, and ref struct
params
parameters will be scoped
by default.
This issue came up late in the design cycle and presented somewhat of a challenge to the compiler. In particular, some types may violate what we'd otherwise
consider an idiomatic implementation, having an Add
method that allows T?
while the collection iteration type is T
. While we don't think that's a totally
unreasonable pattern, it does generally violate our pre-existing rules for collection expressions. For example, if a collection iteration type is T1
, we don't
allow using an unrelated T2
as an expression, even if the collection type technically has an Add(T2)
method. This differs from collection initializers, and
we think that we should carry that difference through here. This also means that any collection expression that are used with older collection types that only
implement IEnumerable
will not get nullability warnings; this is because IEnumerator.Current
is unannotated. Given that we already skip nullability warnings
there for foreach
, we're ok with skipping them on construction as well.
We will do nullability analysis based on the iteration type of the collection expression.
This is more of a bugfix clarification to the range feature in C# 8. For scenarios where an indexer is used in a nested member initializer, we need to decide what counts as the "indexer" as defined in the spec:
When an initializer target refers to an indexer, the arguments to the indexer shall always be evaluated exactly once. Thus, even if the arguments end up never getting used (e.g., because of an empty nested initializer), they are evaluated for their side effects.
The question becomes: is the "indexer" here the virtual Index
-based indexer, or is it the real int
-based indexer that is called under the hood? This ends
up having an effect on whether we call Length
on the collection multiple times or not. While pathological, this could potentially be observable if the nested
member initializer appends to the containing collection in some fashion. If we treat the Index
-based indexer as the indexer referred to in the spec here, then
appends will be observed. If we instead say the int
-based indexer is the indexer referred to by the spec, appends would not be observed, as Length
would only
be evaluated once. This has an even further wrinkle for empty nested initializers: do such initializers actually evaluate Length
? If the int
-based indexer is
the "indexer" in the above, the wording would suggest yes, as it is an "argument" to the indexer. However, we also think that there's a line of reasoning where
"argument" is only referring to the user-written code: Length
is not user written code, so by that logic, it would be safe to elide in the empty case. That leaves
us with a few options:
- Cache the argument to the virtual
Index
indexer. RevaluateLength
on every nested member initializer. - Cache the argument to the real indexer. Evaluate
Length
even when the nested object initializer is empty. - Cache the argument to the real indexer. Do not evaluate
Length
when the nested object initializer is empty. - Adopt more aggressive caching across the board.
While we think that, if we were redoing the feature today, we'd pick option 4, we don't think that we can make such a change at this point in the language evolution. After some discussion, we settled on option 3, falling back to 2 if it ends up being too complicated to implement.
Option 3, cache the argument to the real indexer, do not evaluate Length
when the nested object initializer is empty, falling back to option 2 if it proves an
implementation challenge.