Modern C# · Lesson 34

Params collections without an array contract

Accept a C# 13 params ReadOnlySpan<int> with empty and existing-array cases.

The concept and environment

C# 13 extends params parameters beyond arrays to recognized collection types, including ReadOnlySpan. Total therefore accepts several scalar arguments while declaring a read-only span parameter. It can also receive an existing integer array through the supported conversion. The method's real work is still a synchronous checked sum over supplied values. New argument syntax should not obscure the parameter's lifetime or the domain's arithmetic policy. This example is actual C# 13 code running on .NET 9; it does not require the separate .NET 10 projects used for the later C# 14 lessons.

Walk through the portable program

The expanded call supplies two, three, and four, so Total returns nine. The call with no arguments supplies an empty collection; the accumulator remains zero. The existing array contains five and six and produces eleven. Thus the output is expanded:9, empty:0, and array:11. Each addition is checked, although these small inputs cannot overflow. The method reads its parameter only while executing and returns an integer, so no view escapes to be used after the call. The array remains owned by its caller and is not converted into immutable storage.

A deeper design boundary

The params modifier belongs on the final parameter and changes how callers may supply arguments. It does not mean the implementation receives a persistent array in every case. A span provides indexed or enumerated access to a contiguous region for its allowed lifetime; ReadOnlySpan restricts writes through that view. Recognized interface collection targets have their own construction rules. Design the signature around how the callee consumes input, then evaluate compiler lowering and allocations for the real workload if performance matters. The signature alone is not a measurement or a promise about every call site.

Pitfalls and limits

Avoid storing a span in an ordinary class field, capturing it in a delegate, or carrying it across an unsupported suspension boundary. Those lifetime restrictions remain meaningful even when params makes the call look like a familiar array API. Also inspect overload sets when adding a span-based params overload beside an existing array overload: argument and overload resolution are compiler rules, not interchangeable stylistic choices. This lesson intentionally provides one unambiguous overload. Its empty result is a documented additive identity; another domain operation might need to reject empty input rather than silently copy this policy.

Practice one: isolate a contract

First call Total with one value and predict that value, then pass an explicitly declared empty array and predict zero. Hint: distinguish expanded scalar arguments from supplying one existing collection, while keeping the expected arithmetic independent of the invocation form. Add a negative value to a bounded call and predict its effect. Finally test a deliberate overflow and observe the documented exception instead of comparing a wrapped result. Use a separate case so the exceptional path does not prevent you from seeing the successful baseline observations.

Practice two: challenge an assumption

For another exercise, write a non-params version taking ReadOnlySpan and call it with an explicitly constructed array or collection expression. Compare results with Total without making an allocation claim from identical output. Hint: both methods can share a computation contract while offering different call-site convenience. Then outline a benchmark that would measure representative expanded calls and existing-array calls on the target runtime. Keep browser execution, desktop JIT behavior, and any allocation observations labeled separately; a desktop result cannot automatically characterize SharpRune's WebAssembly runtime or every compiler lowering.

Review the resulting contract

A good params-collection review checks the language version, the final parameter type, supported call forms, lifetime boundaries, and empty-input behavior. It also checks whether arithmetic exceptions are intentional and whether new overloads create surprising selections. The example makes those concerns observable with three small calls and checked addition, while avoiding internal storage assumptions. Prefer a clear consumption contract first. Once correctness is established, measurements can answer whether a particular signature improves an actual hot path; concise scalar argument syntax by itself does not establish speed, ownership, or immutability.

A working example

using System;
int Total(params ReadOnlySpan<int> values)
{
    int total = 0;
    foreach (int value in values) total = checked(total + value);
    return total;
}
int[] existing = [5, 6];
Console.WriteLine($"expanded:{Total(2, 3, 4)}");
Console.WriteLine($"empty:{Total()}");
Console.WriteLine($"array:{Total(existing)}");

Expected output

expanded:9
empty:0
array:11
Try this
Compare expanded arguments, empty input, and an existing array. A read-only span parameter is not an immutable owned collection or a universal zero-allocation guarantee.

Further reading