Params ReadOnlySpan
12/28/2025 | 6 minutes to read
I have recently come across a method in C# that is best resembled by the following method:
public int Sum(IEnumerable<int> values) { int sum = 0; foreach (var value in values) sum += value; return sum; }
The actual method body looks different, the signature of the method is identical. This method is invoked at 30-40 callsites, and the majority of callsites look like:
Sum([1]); // or Sum([1, 2]); // or Sum([1, 2, 3]);
Besides the above examples, there are a few cases that invoked the Sum method with a real enumerable:
IEnumerable<int> values = [1, 2, 3]; return Sum(values);
Please note that I use .NET 9 for the investigation of this blog post.
Enumerable Allocation
While the syntax of Sum([1]); looks sleek, it encounters an allocation for an integer array which has a single integer value, one. The corresponding IL code confirms this:
IL_0000: ldarg.0 IL_0001: ldc.i4.1 IL_0002: newobj instance void class '<>z__ReadOnlySingleElementList`1'<int32>::.ctor(!0) IL_0007: call instance int32 Benchmark::Sum(class [System.Runtime]System.Collections.Generic.IEnumerable`1<int32>)
The generated IL code allocates a <>z__ReadOnlySingleElementList1` reference type on the heap. This is visible on the benchmark shown in the Allocated column:
| Method | Mean | Error | StdDev | Gen0 | Allocated | |------- |---------:|---------:|----------:|-------:|----------:| | Test | 6.482 ns | 1.352 ns | 0.0741 ns | 0.0076 | 48 B |
The benchmarks are executed using the BenchmarkDotNet library.
48 bytes allocation adds up on a x64 machine as: 16 byte object header and MT, 4 byte length, 4 byte integer for the array. The next 24 bytes are allocated by the foreach loop to create the iterator for z__ReadOnlySingleElementList type. This iterator is also reference type. That is quite a lot of allocation for passing the value of 1 as a single parameter into this method.
To address this, one could introduce using params int[], which would help in certain cases. However, it still incurs an array allocation for the parameters at the callsite.
params ReadOnlySpan
Since the release of C# 13, there is other ways to add params arguments. One way is to use params ReadOnlySpan, which can avoid the additional array allocation.
Let's define an overload of the Sum method using params ReadOnlySpan:
public int Sum(params ReadOnlySpan<int> values) { int sum = 0; foreach (var value in values) sum += value; return sum; }
The only difference compared to the initial Sum implementation is that the input argument is typed as params ReadOnlySpan<int>, the method body remained exactly the same. This Sum overload will take precedence at most callsites such as:
Sum([1]);
With such a simple change the allocations can be avoided. This can be also confirmed by reviewing the generated IL code:
IL_0000: ldarg.0 IL_0001: ldtoken field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=4_Align=4' '<PrivateImplementationDetails>'::'67ABDD721024F0FF4E0B3F4C2FC13BC5BAD42D0B7851D456D88D203D15AAA4504' IL_0006: call valuetype [System.Runtime]System.ReadOnlySpan`1<!!0> [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::CreateSpan<int32>(valuetype [System.Runtime]System.RuntimeFieldHandle) IL_000b: call instance int32 Benchmark::Sum(valuetype [System.Runtime]System.ReadOnlySpan`1<int32>)
The compiler still creates a wrapper around the 1 parameter (as it is part of a collection initializer), but this wrapper is a valuetype in this case, which avoids allocation on the heap. The C# compiler can also avoid the allocation of the iterator at the foreach loop. The compiler can lower the iteration of the ReadOnlySpan to a regular for loop, which does not require an iterator anymore.
The same benchmark confirms zero allocations:
| Method | Mean | Error | StdDev | Allocated | |------- |---------:|----------:|----------:|----------:| | Test | 1.455 ns | 0.4099 ns | 0.0225 ns | - |
Even better, this solution allows invoke the Sum method without a collection initializer, by directly passing the parameters: Sum(1); or Sum(1, 2); or Sum(1, 2, 3);, etc. This syntax is not only shorter, but also more natural to the intent of the developer.
Possible Problems
However, the overload may have some caveats, in this post I will cover two of those.
Overload Resolution
Adding a new overload is a compile time breaking change. The following callsite issues a compile error, once the overload is added:
public int Problem1() { int[] values = [1, 2, 3]; return Sum(values); }
The call is ambiguous between the following methods or properties: 'Example.Sum(params ReadOnlySpan)' and 'Example.Sum(IEnumerable)'
The compiler cannot decide which overload to invoke as an int[] can be passed to both the span as well as enumerable parameter. There are multiple ways to resolve this problem when the developer is in full control of the source code (within the same application), however this can be problematic when the overload is defined in an external, 3rd party codebase.
One elegant solution is to declare an overload resolution priority attribute which tells the compiler which method should be preferred in such case:
[OverloadResolutionPriority(1)] public int Sum(params ReadOnlySpan<int> values)
This resolves the compiler error.
Testing Public APIs
Another issue arises when such a method is part of a public interface, and this interface needs to be mocked as part of unit testing a depending type. For example, IService interface defines the Sum method with a params ReadOnlySpan<int> parameter:
public interface IService { int Sum(params ReadOnlySpan<int> values); }
When someone would like to mock this method using one of the popular mocking libraries (Moq), the mock setup would look as the following snippet:
public void Problem2() { var mock = new Mock<IService>(); // Failes to compile 👇 mock.Setup(x => x.Sum(It.IsAny<ReadOnlySpan<int>>())).Returns(6); }
However, the above snippet returns a compiler error too: The type 'ReadOnlySpan' may not be a ref struct or a type parameter allowing ref structs in order to use it as parameter 'TValue' in the generic type or method 'It.IsAny()'
ReadOnlySpan<T> (and Span<T>) cannot be used as generic type arguments, unless they are explicitly allowed with the allows ref struct constraint by the generic type or method. This is not the case for the IsAny<T>() method, nor for the underlying Expression used.
A way to resolve this, one can manually implement a fake IService type to be used in unit tests. This is reasonable for small interfaces as shown here, but it could be tedious for larger contracts. Another alternative is to use a mocking library that does not incur such issues.
Conclusion
In this post, I explored the benefits and potential challenges of using params ReadOnlySpan<T> in C# 13. The key takeaways are:
Benefits:
Eliminates heap allocations for parameter arrays
Provides a more natural syntax for passing multiple arguments
Maintains good performance characteristics with zero allocations
Works seamlessly with collection initializer syntax
Challenges:
Can create ambiguous method overload situations
Requires careful consideration when used in public APIs
May complicate unit testing when using certain mocking frameworks
While params ReadOnlySpan<T> is a powerful feature for improving performance and reducing allocations, it's important to carefully consider its impact on API design and testing strategy. For internal implementations where you have full control over the codebase, it can be an excellent choice. However, when designing public APIs that will be consumed by other developers, you may want to weigh the performance benefits against the potential testing and maintenance complications.