dotnet / csharplang

The official repo for the design of the C# programming language
11.49k stars 1.02k forks source link

[Proposal]: `params in` parameters #8301

Open stephentoub opened 3 months ago

stephentoub commented 3 months ago

params in parameters

Summary

It should be possible for params parameters to also be in.

Motivation

I can have an in parameter and initialize it with a collection expression:

using System.Collections;
using System.Collections.Generic;

C.M([1, 2, 3]);

public static class C 
{
    public static void M(in MyLargeStruct list) {}
}

public struct MyLargeStruct : IEnumerable<int>
{
    public void Add(int i) {}
    public IEnumerator<int> GetEnumerator() => null!;
    IEnumerator IEnumerable.GetEnumerator() => null!;
}

And I can have a params parameter and initialize it with a list of arguments (dropping the brackets of the collection expression):

using System.Collections;
using System.Collections.Generic;

-C.M([1, 2, 3]);
+C.M(1, 2, 3);

public static class C 
{
-   public static void M(in MyLargeStruct list) {}
+   public static void M(params MyLargeStruct list) {}
}

public struct MyLargeStruct : IEnumerable<int>
{
    public void Add(int i) {}
    public IEnumerator<int> GetEnumerator() => null!;
    IEnumerator IEnumerable.GetEnumerator() => null!;
}

but it's currently an error to have both in and params:

public static void M(params in MyLargeStruct list) {}
error CS1611: The params parameter cannot be declared as in

I'm not aware of any reason why these shouldn't be allowed in conjunction. And with the introduction of collection expressions and their synergy with params, it's strange that one way of representing the same situation works and the other doesn't. For cases where a large struct is used and is thus desirable to be passed by reference, and where that struct is initializable with a collection expression, it'd be nice to be able to also allow someone to choose to use the params syntax.

The actual documentation for CS1611 refers to ref and out: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/params-arrays#method-declaration-rules

CS1611: The params parameter cannot be declared as in ref or out

and it makes sense that ref and out can't be used with params. But that same reasoning doesn't apply to in. Maybe it just inherited the behavior and we never thought to fix it?

Detailed design

TBD

Drawbacks

TBD

Alternatives

TBD

Unresolved questions

TBD

Design meetings

jaredpar commented 3 months ago

I'm not aware of any reason why these shouldn't be allowed in conjunction.

I don't think there is anything fundamentally wrong but there are a few parts that we'd need to think through. Let's change up the example a bit to use a ref struct collection.

C.M([1, 2, 3]);

public static class C 
{
    public static void M(in MyLargeRefStruct list) {}
}

[CollectionBuilder(typeof(MyLargeRefStruct), "Create")]
public ref struct MyLargeRefStruct
{
    public IEnumerator<int> GetEnumerator() => throw null!;
    public static MyLargeRefStruct Create(ReadOnlySpan<int> span) => throw null!;
}

Today the params collections feature has the following line:

Params parameters are implicitly scoped when their type is a ref struct. UnscopedRefAttribute can be used to override that.

The underlying motivation of this was to make params naturally friendly to stackalloc of the collections. Having implicitly scoped values meant users couldn't escape the value so using stackalloc at the call site was safe / unlikely to cause friction. When in is inserted into the mix that friction angle goes way because the language doesn't support the notion of in scoped yet. The best way can do here is scoped in which still allows for the following:

// Okay 
MyLargeRefStruct M1(params in MyLargeRefStruct s) => s;
// Error: can't escape value `s` to calling method
MyLargeRefStruct M2(params MyLargeRefStruct s) => s;

The behavior in M1 isn't wrong but it is the type of situation we specifically wanted to avoid when we designed params collections. If the language did support in scoped I strongly suspect we'd end up designing params in to be implicitly

That gives me a little pause in doing in params before ref scoped. Maybe we could scope (hehe) it down to allowing the non ref struct case.

Mrxx99 commented 3 months ago

If params in is supported should params ref readonly also be supported? Or would this be implicitly the case?

jjonescz commented 3 months ago

should params ref readonly also be supported?

That would not make much sense as one should not pass rvalues to ref readonly parameters nor use them without ref/in callsite modifier.

RikkiGibson commented 3 months ago

The behavior in M1 isn't wrong but it is the type of situation we specifically wanted to avoid when we designed params collections.

I think the main drawback is if the params in parameter can be returned by value, then we can't reuse memory which is referenced by that value. e.g.

ReadOnlySpan<int> M(params in ReadOnlySpan<int> span) => span; // ok

// user code
var span1 = M([1, 2, 3]);
M([4, 5, 6]);
Console.Write(span1[0]); // needs to be '1'
colejohnson66 commented 3 months ago

Presumably, param scoped ref could allow the compiler to reuse the allocated span.

jaredpar commented 3 months ago

Presumably, param scoped ref could allow the compiler to reuse the allocated span.

scoped ref doesn't prevent this as the scoped prevents the ref from being returned but does nothing to prevent the value from being returned.

// Works
Span<char> M(scoped ref Span<char> s) => s;

To prevent the value, and the ref, from being returned we'd need to support ref scoped.

// Error
Span<char> M(ref scoped Span<char> s) => s;
RikkiGibson commented 3 months ago

Our ref lifetimes model is currently held back by the fact that every lifetime can be related to every other lifetime. For any two lifetimes, they are either the same or one is known to be bigger and the other smaller. But in order to do ref scoped, I think we want the ability to say that the referent's lifetime is not known to be either bigger or smaller than any other ref scoped referent. They are different lifetimes which have no known relation to any existing returnable or ref scoped lifetimes. (They would be defined as bigger than local scopes though so that locals can refer to them.)

That way you aren't struggling with, well, the ref is not returnable, unless you smuggle it out through a second ref scoped parameter.

colejohnson66 commented 2 months ago

Sounds like we need lifetimes ala Rust ;)

Span['b]<char> M<'a, 'b>(scoped ref Span['a]<char> s)
    where 'b : 'a =>
    s;
jaredpar commented 2 months ago

@colejohnson66 if you haven't read the proposal for ref scoped yet you will probably find it interesting. The rough conclusion is that implementing ref scoped is a fairly simple extension of the current model. It can also likely allow for ref fields to ref struct in a limited fashion.

At the same time it's also likely the limit of what we can achieve in C# without going to explicit lifetimes.