Skip to content

Latest commit

 

History

History
96 lines (69 loc) · 7.47 KB

LDM-2023-11-15.md

File metadata and controls

96 lines (69 loc) · 7.47 KB

C# Language Design Meeting for November 15th, 2023

Agenda

Quote of the Day

  • "That is a divide by object error"

Discussion

params improvements

#7700
#7661

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 scopedness, 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.

Conclusion

Proposal is accepted. We will follow the same allocation guarantess as collection expressions, and ref struct params parameters will be scoped by default.

Nullability analysis of collection expressions

#5354
#7626

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.

Conclusion

We will do nullability analysis based on the iteration type of the collection expression.

Evaluation of implicit indexers in object initializers

#7684

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:

  1. Cache the argument to the virtual Index indexer. Revaluate Length on every nested member initializer.
  2. Cache the argument to the real indexer. Evaluate Length even when the nested object initializer is empty.
  3. Cache the argument to the real indexer. Do not evaluate Length when the nested object initializer is empty.
  4. 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.

Conclusion

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.