C# Methods on .NET 10: ref, out, params and Overloads

C# methods re-tested on .NET 10: ref, out and in, C# 13 params ReadOnlySpan, optional arguments, overload traps, and a measured params allocation cost.

Revised November 2026. This tutorial first appeared in Korean in 2021. We recompiled every example on .NET 10.0.14 (C# 14). The overloading snippet did not compile because the double overload was declared to return int (error CS0266), and the full example printed 607.4000000000001, not the 607.4 shown in 2021. The params example had no output at all; it prints sum=188 and sum=39. We also added what C# has gained since then (params ReadOnlySpan<T> in C# 13, local functions, in), the overload-resolution traps we hit while testing, and a measurement of what a params array costs.

This article is for developers who are learning C# methods, and for those who know ref, out and params but want to know where in, local functions and params ReadOnlySpan<int> fit. The short version: C# arguments are copied by default, and you need ref or out to change the caller's variable. Variable-length arguments can now be taken as params ReadOnlySpan<T>, which removed the hidden array allocation in our test. And overload resolution picks an unexpected method more often than most people assume.

Defining a method and returning a value

A method is a named block of code. Its general shape:

[access modifier] [static] return-type Name(parameter list) { body }

The original article listed three access modifiers: private, public and protected. C# has six: those three plus internal, protected internal and private protected. A class member with no modifier is private. The original also said a method has "one or more parameters". Zero is fine too: static int Next() is a perfectly normal method.

The original division example still prints 4. Because both operands are int, the result is truncated, and dividing by zero throws. One-line methods can use => since C# 6 (expression-bodied members):

Console.WriteLine(Division(40, 10));
Console.WriteLine(Division(7, 2));
try { Console.WriteLine(Division(1, 0)); }
catch (DivideByZeroException e) { Console.WriteLine(e.GetType().Name); }

static int Division(int a, int b) => a / b;
4
3
DivideByZeroException

return can appear anywhere in a method and leaves it immediately. When you need to return several values, a tuple is usually clearer than the out parameters described below:

(int min, int max) = MinMax([4, 9, 1, 0, 21, 12]);
Console.WriteLine($"min={min} max={max}");   // min=0 max=21

Passing by value and by reference

Both Swap examples from 2021 behave the same on .NET 10. Without ref the values are copied and the caller's variables do not change; with ref they are swapped:

Swap before: a=40, b=10
Swap after: a=40, b=10      ← Swap(int a, int b)
Swap before: a=40, b=10
Swap after: a=10, b=40      ← Swap(ref int a, ref int b)

The 2021 text described ref as "passing the memory address of the variable". That is a fair analogy, but the case that confuses people more often in C# is passing a reference type, such as an array, by value. What gets copied is the reference to the array. Changing an element is visible to the caller; assigning a new array to the parameter is not:

int[] data = [1, 2, 3];
Mutate(data);        Console.WriteLine(string.Join(" ", data));
Replace(data);       Console.WriteLine(string.Join(" ", data));
ReplaceRef(ref data); Console.WriteLine(string.Join(" ", data));

static void Mutate(int[] a) => a[0] = 100;           // changes the shared array
static void Replace(int[] a) => a = [7, 8, 9];        // changes only the parameter
static void ReplaceRef(ref int[] a) => a = [7, 8, 9]; // changes the caller's variable
100 2 3
100 2 3
7 8 9

The third case is exactly why Array.Resize(ref arr, n) takes ref. See C# arrays for what Resize costs.

ref, out and in

The original ref accumulation example (a=140, a=340, a=640) and the out example (a=100) run unchanged. The difference between them shows up in the rules the compiler enforces, so we broke both rules on purpose:

int a;
Add(ref a, 1);                                   // ref with an unassigned variable
static void Add(ref int x, int y) { x += y; }
static void NoAssign(out int x) { }              // never assigns the out parameter
Program.cs(2,9): error CS0165: Use of unassigned local variable 'a'
Program.cs(4,13): error CS0177: The out parameter 'x' must be assigned to before control leaves the current method

So ref is a read-write parameter, and out is a write-only result. You will see out most often in the Try pattern. Since C# 7 you can declare the variable in the call (out var) or discard it (out _):

if (int.TryParse("123", out var n)) Console.WriteLine($"parsed {n}");
if (!int.TryParse("12a", out _)) Console.WriteLine("not a number");
parsed 123
not a number

in, added in C# 7.2, is a read-only reference. It passes a large struct without copying it while preventing the method from modifying it. Callers may omit the in keyword, and assigning to the parameter is a compile error:

static void Read(in int v) { v = 3; }
error CS8331: Cannot assign to variable 'v' or use it as the right hand side of a ref assignment because it is a readonly variable

Putting in on an int gains nothing. Consider it only for structs of several dozen bytes or more that you pass frequently.

params and C# 13 params collections (measured)

The original params example never showed its output. Here it is on .NET 10:

sum=188
sum=39

params int[] allocates a new array for the arguments on every call. Since C# 13, params also accepts collection types such as ReadOnlySpan<T> and List<T>. The span version keeps the arguments on the stack, so nothing is allocated:

Console.WriteLine(Total());             // 0
Console.WriteLine(Total(1, 2, 3));      // 6
Console.WriteLine(Total([1, 2, 3, 4])); // 10 (a collection expression works too)
Console.WriteLine(TotalList(1, 2, 3));  // 3

static int Total(params ReadOnlySpan<int> xs) { int s = 0; foreach (var x in xs) s += x; return s; }
static int TotalList(params List<int> xs) => xs.Count;

We measured the difference. Three methods sum four ints (each marked [MethodImpl(NoInlining)]), and each is called 10 million times. Release build, Stopwatch for time, GC.GetAllocatedBytesForCurrentThread() for allocations, two warm-up rounds, then three measured runs:

Signature Run 1 Run 2 Run 3 Allocated
Total(params int[] xs) 63.8 ms 64.1 ms 64.7 ms 400.1 MB (40 B/call)
Total(params ReadOnlySpan<int> xs) 31.4 ms 31.5 ms 31.3 ms 0 B
Total4(int a, int b, int c, int d) 10.6 ms 10.6 ms 10.6 ms 0 B
Bar chart of time for 10 million calls: params int[] 64.2 ms, params ReadOnlySpan<int> 31.4 ms, four fixed int parameters 10.6 ms. The span version allocated nothing and took about half the time of the array version.
10 million calls that sum four ints, average of 3 runs after warm-up. .NET 10.0.14, Release build, Apple Silicon Mac. Only params int[] allocated: 40 bytes per call, 400 MB in total.

params int[] allocated 40 bytes per call (array header plus four ints), 400 MB over 10 million calls. The span version allocated nothing and took half the time. When the argument count is fixed, plain parameters were fastest of all. In a typical app a 0.03-second difference over 10 million calls does not matter, but for logging or string-building APIs called constantly, a span overload is worth adding.

The base library already did this. Reflecting over the .NET 10 runtime shows params ReadOnlySpan<T> overloads on string.Join, string.Format, string.Concat, Path.Combine, Task.WhenAll and Console.WriteLine(string, …), and recompiling is enough to pick them up. string.Join(",", a, b, c) allocated 32 bytes per call (only the result string), while passing the same values as new string[] { a, b, c } allocated 80.

Overloading: the 2021 bug and real traps

The original overloading snippet declared static int Add(double a, double b), which does not compile:

Program.cs(18,20): error CS0266: Cannot implicitly convert type 'double' to 'int'. An explicit conversion exists (are you missing a cast?)

With the return type fixed to double, the full example prints (labels translated):

Add(int, int)
60
Add(double, double)
607.4000000000001
Add(int, int, int)
20

The 2021 article showed 607.4. Since .NET Core 3.0, the default double formatting prints the shortest string that round-trips to the same value, so the binary rounding error in 544.2 + 63.2 is now visible. Use ToString("0.##") for display, or decimal when you need exact decimal arithmetic (544.2m + 63.2m prints 607.4).

We then checked which overload the compiler picks among four Show methods taking int, long, double and string?:

Show(1);      // int 1
Show(1L);     // long 1
Show(1.0f);   // double 1      ← no float overload, so double
Show('A');    // int 65        ← a char goes to int, not string!
Show(null);   // string null

The implicit char to int conversion is an easy one to miss. Other results from the same session:

Code Result
Show(null) with Show(string?) and Show(int[]?) error CS0121: the call is ambiguous
Show2(1, 2) with Show2(int, long) and Show2(long, int) error CS0121: the call is ambiguous
int F(int) and long F(int): only the return type differs error CS0111: already defines a member with the same parameter types
G(ref int) and G(out int) error CS0663: cannot differ only on ref and out
Print(5) with Print(int a, int b = 0) and Print(params int[] a) The optional-parameter overload wins over the params expansion
Sum(1, 2) / Sum(arr) with params int[] and params ReadOnlySpan<int> overloads Span version / array version

And the trap that surprises people most with the .NET 6+ console template: overloading in a top-level Program.cs does not compile.

static int F(int a) => a;
static int F(long a) => (int)a;
Program.cs(2,12): error CS0128: A local variable or function named 'F' is already defined in this scope

Static methods below top-level statements are really local functions inside the compiler-generated Main, and local functions cannot be overloaded. Put overloads in a static class.

Optional and named arguments, local functions

Instead of writing several overloads, you can give a parameter a default value (C# 4). Naming arguments at the call site also lets you reorder them:

Console.WriteLine(Greet("Kim"));
Console.WriteLine(Greet("Kim", greeting: "Hi"));
Console.WriteLine(Greet(greeting: "Yo", name: "Lee"));

static string Greet(string name, string greeting = "Hello") => $"{greeting}, {name}";
Hello, Kim
Hi, Kim
Yo, Lee

Optional parameters must come last: Bad(int a = 1, int b) gives error CS1737: Optional parameters must appear after all required parameters. Also, default values are compiled into the caller. If a library changes a default, callers that are not recompiled keep using the old value.

A helper used only inside one method can be hidden as a local function (C# 7). Marking it static (C# 8) forbids capturing outer variables, which avoids accidental closure allocations; our lambda expressions article measures what a capture costs.

Console.WriteLine(Fib(30));   // 832040

static long Fib(int n)
{
    return Go(n);
    static long Go(int k) => k < 2 ? k : Go(k - 1) + Go(k - 2);
}

Which form to use

Decision flow. Start: you are passing an argument. First question: is it a Try-style extra result that the method must produce? If yes, use out; the caller does not need to initialize the variable. If no, must the method change the caller's variable? If yes, use ref; the caller must initialize it. If no, does the method take a variable number of arguments? If yes, use params ReadOnlySpan<T> from C# 13. If no, is it a large struct the method only reads? If yes, use in; otherwise pass by value, the default.
Which parameter form to use. Ask the questions from left to right; most parameters end up as plain by-value parameters.
Situation Use
One result A return value; => if it fits on one line
Several results A tuple, (int Min, int Max)
Success flag plus result Try pattern, bool TryX(…, out T value)
Must change the caller's variable ref (rarely; a return value is usually clearer)
Large struct, read only in
Variable number of arguments params ReadOnlySpan<T> (C# 13); keep a params T[] overload if you need compatibility
Same operation, a few arguments differ Optional and named arguments before more overloads
Helper used inside one method A static local function
Overloads in a top-level Program.cs Not allowed (CS0128); move them to a static class

Passing methods around as values continues in Func and Action delegates and lambda expressions.

Tested with .NET SDK 10.0.403 / runtime 10.0.14, C# 14 (the net10.0 default), macOS on an Apple Silicon Mac.

FAQ

What is the difference between ref and out?

Both pass the caller's variable itself. A ref argument must already hold a value (otherwise error CS0165), while an out argument need not be initialized but the method must assign it before returning (otherwise error CS0177). out is the basis of the Try pattern, as in int.TryParse(s, out var n).

Should I switch from params int[] to params ReadOnlySpan<int>?

It is possible since C# 13 and it helps on hot paths. For 10 million calls passing four ints, params int[] allocated 40 bytes per call (400 MB total) and took 64.2 ms; params ReadOnlySpan<int> allocated nothing and took 31.4 ms (.NET 10, Release, Apple Silicon Mac). With both overloads present, Sum(1, 2) picked the span version and passing an existing array picked the array version.

Why can't I overload methods in a top-level Program.cs?

Static methods written below top-level statements are local functions inside the generated Main, and local functions cannot share a name. The compiler reports error CS0128 (A local variable or function named 'F' is already defined in this scope). Move the overloads into a static class.

  • #C#
  • #methods
  • #params
  • #overloading
  • #.NET

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑