C# 13 Params Collections

By Fons Sonnemans, posted on
2190 Views

With the version 17.10.0 Preview 3.0 of Visual Studio Preview you can test some new C# 13 features. In this blog I will explain the params Collection feature as documented in this proposal. To use this feature you have to set the LangVersion in your csproj file to preview.

<Project Sdk="Microsoft.NET.Sdk">
	<PropertyGroup>
		<OutputType>Exe</OutputType>
		<TargetFramework>net8.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>
		<LangVersion>preview</LangVersion>
	</PropertyGroup>
</Project>

In C#, the `params` keyword allows a method to accept a variable number of arguments, providing flexibility in how many parameters you pass without needing to define multiple method overloads. This can make your code more concise and easier to maintain. For instance, it's particularly useful when the exact number of inputs is not known in advance or can vary. Moreover, it simplifies the calling code, as you can pass an array or a comma-separated list of arguments that the method will interpret as an array.

From C# 1.0 till 12.0 params parameter must be an array type. However, it might be beneficial for a developer to be able to have the same convenience when calling APIs that take other collection types. For example, an ImmutableArray<T>, ReadOnlySpan<T>, or plain IEnumerable. Especially in cases where compiler is able to avoid an implicit array allocation for the purpose of creating the collection (ImmutableArray<T>, ReadOnlySpan<T>, etc). This saves Heap memory allocation which improves the performance. The Garbage collector doesn't have to free this memory.

C# 1.0 params array

Lets start with an example of an old-school params array parameter in a Sum method. 

internal class Program {

    static void Main(string[] args) {
        Console.WriteLine(Sum(1, 2, 3, args.Length));
    }

    private static Sum(params int[] values) {
        int sum = 0;
        foreach (var item in values) {
            sum += item;
        }
        return sum;
    }
}

When you decompile this code using a tool like ILSpy or the SharpLab.io website you see that an array of int[4] is created in the Main() method (line 5). This is heap allocation which is something you can/should avoid.

internal class Program
{
    private static void Main(string[] args)
    {
        int[] array = new int[4];
        RuntimeHelpers.InitializeArray(array, (RuntimeFieldHandle)/*OpCode not supported: LdMemberToken*/);
        array[3] = args.Length;
        Console.WriteLine(Sum(array));
    }

    private static int Sum(params int[] values)
    {
        int num = 0;
        int num2 = 0;
        while (num2 < values.Length)
        {
            int num3 = values[num2];
            num += num3;
            num2++;
        }
        return num;
    }
}

C# 13 params Collections

In the next example the Sum() method is using a params ReadOnlySpan<int> parameter in line 7. Nothing else is changed. 

internal class Program {

    static void Main(string[] args) {
        Console.WriteLine(Sum(1, 2, 3, args.Length));
    }

    private static int Sum(params ReadOnlySpan<int> values) {
        int sum = 0;
        foreach (var item in values) {
            sum += item;
        }
        return sum;
    }
}

When you decompile this code you see a  <>y__InlineArray4<int> value is used in the Main() method (line 6). This is a struct which is created by the compiler. It uses the Inline Arrays feature of C# 12. Structs are allocated on the Stack so this code doesn't allocate any heap memory.

internal class Program
{
    [NullableContext(1)]
    private static void Main(string[] args)
    {
        <>y__InlineArray4<int> buffer = default(<>y__InlineArray4<int>);
        <PrivateImplementationDetails>.InlineArrayElementRef<<>y__InlineArray4<int>, int>(ref buffer, 0) = 1;
        <PrivateImplementationDetails>.InlineArrayElementRef<<>y__InlineArray4<int>, int>(ref buffer, 1) = 2;
        <PrivateImplementationDetails>.InlineArrayElementRef<<>y__InlineArray4<int>, int>(ref buffer, 2) = 3;
        <PrivateImplementationDetails>.InlineArrayElementRef<<>y__InlineArray4<int>, int>(ref buffer, 3) = args.Length;
        Console.WriteLine(Sum(<PrivateImplementationDetails>.InlineArrayAsReadOnlySpan<<>y__InlineArray4<int>, int>(ref buffer, 4)));
    }

    private static int Sum([ParamCollection] ReadOnlySpan<int> values)
    {
        int num = 0;
        ReadOnlySpan<int> readOnlySpan = values;
        int num2 = 0;
        while (num2 < readOnlySpan.Length)
        {
            int num3 = readOnlySpan[num2];
            num += num3;
            num2++;
        }
        return num;
    }
}

}

[StructLayout(LayoutKind.Auto)]
[InlineArray(4)]
internal struct <>y__InlineArray4<T>
{
    [CompilerGenerated]
    private T _element0;
}

Benchmark

To compare the performance between the two I have created this Benchmark using BenchmarkDotNet. It compares the Sum of 5 decimals using old-school params arrays and params ReadOnlyCollection<decimal>.

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<BM>();

[MemoryDiagnoser(false)]
[HideColumns("RatioSD", "Alloc Ratio")]
//[ShortRunJob]
public class BM {

    private decimal _value = 500m;

    [Benchmark]
    public decimal CallSumArray() => SumArray(1m, 100m, 200m, 300m, 400m, _value);

    [Benchmark(Baseline = true)]
    public decimal CallSumSpan() => SumSpan(1m, 100m, 200m, 300m, 400m, _value);

    private static decimal SumArray(params decimal[] values) {
        decimal sum = 0;
        foreach (var item in values) {
            sum += item;
        }
        return sum;
    }

    private static decimal SumSpan(params ReadOnlySpan<decimal> values) {
        decimal sum = 0;
        foreach (var item in values) {
            sum += item;
        }
        return sum;
    }  
}

The CallSumSpan method is 28% faster and doesn't allocate any heap memory. The CallSumArray method allocated 120 bytes.

Benchmark summary

Don't want to wait for C# 13

If you don't want to wait for C# 13 you can already use a solution with simular results in C# 12. You can use Collection Expression with a normal ReadOnlySpan<T> parameter. A collection expression contains a sequence of elements between [ and ] brackets, see line 4. 

internal class Program {

    static void Main(string[] args) {
        Console.WriteLine(Sum([1, 2, 3, args.Length]));
    }

    private static int Sum(ReadOnlySpan<int> values) {
        int sum = 0;
        foreach (var item in values) {
            sum += item;
        }
        return sum;
    }
}

When you decompile this code you see the same code you saw when you used the params ReadOnlyCollection<decimal>.

Closure

In this blog post I showed you the new 'params Collection' feature in C# 13, available in Visual Studio Preview 17.10.0 Preview 3.0. It explains how to enable the feature by setting 'LangVersion' to preview in the project file and delves into the benefits of using 'params' with collection types other than arrays, like 'ReadOnlySpan<T>'. This enhancement aims to improve performance by reducing heap memory allocations, thus easing the workload on the garbage collector. The post includes an example of the traditional 'params array' in a `Sum` method to illustrate the concept.

Hopefully Microsoft will add in .NET 9 (and later) more overloads with 'params ReadOnlySpan<T>' to the methods which are using 'params arrays'. For example the String.Split method.

 

Tags

CSharp

All postings/content on this blog are provided "AS IS" with no warranties, and confer no rights. All entries in this blog are my opinion and don't necessarily reflect the opinion of my employer or sponsors. The content on this site is licensed under a Creative Commons Attribution By license.

Leave a comment

Blog comments

0 responses