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 the607.4shown in 2021. The params example had no output at all; it printssum=188andsum=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 aparamsarray 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 |
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
| 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.