Methods and Parameters

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

A method is the unit of behaviour. C# gives its parameter list a lot of expressive power — optional and named arguments, four ways to pass by reference, variable-length lists, and compiler-supplied caller information.

Declaring and Calling

public sealed class Calculator
{
    // Block-bodied
    public int Add(int a, int b)
    {
        return a + b;
    }

    // Expression-bodied -- identical semantics
    public int Multiply(int a, int b) => a * b;

    // void, and expression-bodied void
    public void Log(string message) => Console.WriteLine(message);

    // static: belongs to the type, not an instance
    public static int Square(int value) => value * value;
}

Expression bodies work on methods, properties, indexers, constructors, finalizers and operators. Use them when the body really is a single expression; a block is clearer for anything longer.

Overloading and Overload Resolution

Methods may share a name if their signatures differ — by the number of parameters, their types, or their ref/out/in modifiers. Return type alone is never enough.

public static class Printer
{
    public static void Print(int value)    => Console.WriteLine($"int: {value}");
    public static void Print(double value) => Console.WriteLine($"double: {value}");
    public static void Print(string value) => Console.WriteLine($"string: {value}");
    public static void Print(object value) => Console.WriteLine($"object: {value}");

    public static void Demo()
    {
        Print(42);          // int    -- exact match
        Print(42.0);        // double -- exact match
        Print(42L);         // double -- long converts implicitly to double, not to int
        Print("text");      // string -- more specific than object
        Print(true);        // object -- bool matches nothing better
    }
}

The rules, in the order the compiler applies them:

  1. Collect every accessible method with the right name and a compatible parameter count.

  2. Discard those the arguments cannot convert to.

  3. Prefer the candidate whose parameters need the least conversion — an exact match beats an implicit conversion, a derived type beats its base, a non-generic candidate beats a generic one, and a method taking the arguments directly beats one taking params.

  4. If two candidates are equally good, it is a compile error (CS0121: "the call is ambiguous").

Adding an overload to a published library is a source-breaking change waiting to happen: existing call sites may silently bind to the new method. Adding an overload that differs only by an optional parameter is worse — it is also a binary-breaking change, because optional arguments are baked into the call site.

Optional and Named Arguments

public static class Connection
{
    public static string Open(
        string host,
        int port = 443,
        bool useTls = true,
        TimeSpan timeout = default)          // `default` is the only allowed struct default
    {
        TimeSpan effective = timeout == default ? TimeSpan.FromSeconds(30) : timeout;
        return $"{(useTls ? "https" : "http")}://{host}:{port} ({effective.TotalSeconds}s)";
    }

    public static void Demo()
    {
        Console.WriteLine(Open("example.com"));
        Console.WriteLine(Open("example.com", 8080));
        Console.WriteLine(Open("example.com", useTls: false));                 // skip `port`
        Console.WriteLine(Open(port: 9000, host: "example.com"));              // any order
        Console.WriteLine(Open("example.com", timeout: TimeSpan.FromMinutes(1)));
    }
}

Optional parameters must follow all required ones. Named arguments may appear in any order, though arguments before the first named one must still be positional and in order.

The default value is copied into the call site at compile time. Change the default in a library and existing compiled callers keep the old value until they are rebuilt — the reason many library authors prefer overloads for public APIs.

params and params Collections

public static class Summing
{
    public static int Sum(params int[] values)
    {
        int total = 0;
        foreach (int value in values)
        {
            total += value;
        }
        return total;
    }

    // C# 13: params over any collection type the compiler can build.
    public static int SumSpan(params ReadOnlySpan<int> values)
    {
        int total = 0;
        foreach (int value in values)
        {
            total += value;
        }
        return total;
    }

    public static int Count<T>(params IEnumerable<T> items) => items.Count();
    public static int CountList(params List<string> items) => items.Count;

    public static void Demo()
    {
        Console.WriteLine(Sum(1, 2, 3));          // 6  -- an array is allocated
        Console.WriteLine(Sum());                 // 0  -- an empty array
        Console.WriteLine(Sum([1, 2, 3]));        // 6  -- pass one explicitly

        Console.WriteLine(SumSpan(1, 2, 3));      // 6  -- may use stack memory, no allocation
        Console.WriteLine(Count(1, 2, 3));
        Console.WriteLine(CountList("a", "b"));
    }
}

params must be the last parameter. params ReadOnlySpan<T> (C# 13) is the form to prefer for new performance-sensitive APIs: the compiler can satisfy it from stack memory rather than a heap array.

Passing by Value and by Reference

By default every argument is passed by value: the parameter is a copy. For a reference type, what is copied is the reference — so the method can mutate the object but cannot make the caller’s variable point elsewhere.

Modifier Meaning

(none)

Pass by value. A copy.

ref

Pass by reference. Must be assigned before the call; the method may read and write it.

out

Pass by reference. Need not be assigned before the call; the method must assign it before returning.

in

Pass by reference, read-only. An optimisation for large structs; the method may not assign it.

ref readonly

Like in, but the caller must pass a variable (C# 12) — it documents intent at the call site.

public readonly struct BigStruct
{
    public readonly double A, B, C, D, E, F, G, H;
}

public static class Passing
{
    public static void ByValue(int value) => value = 99;

    public static void ByRef(ref int value) => value = 99;

    public static bool TryDivide(int a, int b, out int quotient, out int remainder)
    {
        if (b == 0)
        {
            quotient = remainder = 0;      // must assign on every path
            return false;
        }

        quotient = a / b;
        remainder = a % b;
        return true;
    }

    public static double Magnitude(in BigStruct value)     // no 64-byte copy
        => Math.Sqrt((value.A * value.A) + (value.B * value.B));

    public static void Demo()
    {
        int x = 1;
        ByValue(x);
        Console.WriteLine(x);      // 1 -- unchanged

        ByRef(ref x);              // `ref` required at the call site too
        Console.WriteLine(x);      // 99

        if (TryDivide(17, 5, out int q, out int r))     // declare inline
        {
            Console.WriteLine($"{q} remainder {r}");     // 3 remainder 2
        }

        TryDivide(17, 5, out _, out int onlyRemainder);  // discard what you do not need
        Console.WriteLine(onlyRemainder);

        var big = new BigStruct();
        Console.WriteLine(Magnitude(in big));
    }
}

out is the idiom behind every TryParse/TryGetValue in the BCL: return bool for success, hand the value back through out. Prefer it to exceptions for expected failures, and prefer a nullable or tuple return when you have the choice — out composes poorly with LINQ and async (it is not allowed on async methods at all).

ref Locals and ref Returns

A ref local is an alias for existing storage; a ref return hands that alias back to the caller.

public static class RefReturns
{
    public static ref int FindFirstNegative(int[] values)
    {
        for (int i = 0; i < values.Length; i++)
        {
            if (values[i] < 0)
            {
                return ref values[i];       // an alias for the array slot
            }
        }

        throw new InvalidOperationException("No negative value.");
    }

    public static void Demo()
    {
        int[] numbers = [3, -7, 5];

        ref int slot = ref FindFirstNegative(numbers);
        slot = 0;                                   // writes through the alias

        Console.WriteLine(string.Join(",", numbers));   // 3,0,5

        // `ref readonly` local: an alias you may read but not write.
        ref readonly int first = ref numbers[0];
        Console.WriteLine(first);
    }
}

The compiler enforces ref safety: you may not return a ref to a local, because the storage would be gone. scoped makes that constraint explicit on a parameter, promising that the reference does not escape the method:

public static class Scoped
{
    public static int SumOf(scoped ReadOnlySpan<int> values)
    {
        int total = 0;
        foreach (int value in values)
        {
            total += value;
        }
        return total;     // fine: only the int escapes, not the span
    }
}

See Unsafe Code, Spans and Performance for the full ref-safety model.

Local Functions

A local function is declared inside a method body and can use its locals directly:

public static class LocalFunctions
{
    public static IEnumerable<int> Filtered(IEnumerable<int>? source, int minimum)
    {
        // Argument validation runs eagerly...
        ArgumentNullException.ThrowIfNull(source);

        return Iterate();      // ...the iterator body is lazy

        IEnumerable<int> Iterate()
        {
            foreach (int value in source)
            {
                if (value >= minimum)
                {
                    yield return value;
                }
            }
        }
    }

    public static int Factorial(int n)
    {
        // `static` forbids capturing -- no closure object is allocated.
        static int Compute(int value) => value <= 1 ? 1 : value * Compute(value - 1);

        ArgumentOutOfRangeException.ThrowIfNegative(n);
        return Compute(n);
    }
}

Local functions beat private helper methods when the helper is meaningful only here, and beat lambdas when you need recursion, yield, ref parameters or generic type parameters. Make them static whenever they do not need the enclosing locals — it prevents accidental capture and the allocation that comes with it. They may also carry attributes.

The eager-validation-plus-lazy-iterator pattern above is the canonical reason local functions exist: without it, ArgumentNullException would not be thrown until the caller started enumerating.

Recursion

public static class Recursion
{
    public static long Fibonacci(int n) => n < 2 ? n : Fibonacci(n - 1) + Fibonacci(n - 2);

    // Iterative equivalent -- no stack growth, and far faster.
    public static long FibonacciFast(int n)
    {
        (long previous, long current) = (0, 1);
        for (int i = 0; i < n; i++)
        {
            (previous, current) = (current, previous + current);
        }
        return previous;
    }
}
NET does not guarantee tail-call optimisation, so deep recursion risks StackOverflowException — which

cannot be caught and terminates the process. Convert deep recursion to iteration with an explicit stack.

Method Groups

A method name without parentheses is a method group, convertible to a compatible delegate:

public static class MethodGroups
{
    public static void Demo()
    {
        // Method group conversion -- since C# 11 the compiler caches the delegate instance.
        Func<int, int> square = Square;
        Action<string> log = Console.WriteLine;

        Console.WriteLine(square(7));
        log("via a method group");

        // Especially neat in LINQ.
        int[] values = [1, 2, 3];
        Console.WriteLine(string.Join(",", values.Select(Square)));
    }

    private static int Square(int value) => value * value;
}

Deconstruct Methods

A Deconstruct method (or extension method) lets a type be destructured into a tuple:

public sealed class Rectangle
{
    public Rectangle(double width, double height) => (Width, Height) = (width, height);

    public double Width { get; }
    public double Height { get; }

    public void Deconstruct(out double width, out double height)
        => (width, height) = (Width, Height);

    // Overloads are allowed as long as the arities differ.
    public void Deconstruct(out double width, out double height, out double area)
    {
        (width, height) = (Width, Height);
        area = Width * Height;
    }
}

public static class DeconstructDemo
{
    public static void Run()
    {
        var rect = new Rectangle(3, 4);

        (double w, double h) = rect;
        Console.WriteLine($"{w} x {h}");

        var (width, height, area) = rect;
        Console.WriteLine($"{width} x {height} = {area}");

        // Deconstruction also powers positional patterns.
        if (rect is (3, var tall))
        {
            Console.WriteLine($"three wide, {tall} tall");
        }
    }
}

Records synthesise Deconstruct for their positional parameters automatically. See Tuples, Deconstruction and Anonymous Types.

Caller-Information Attributes

The compiler fills these in at the call site, so you get diagnostics with no run-time cost:

public static class Guards
{
    public static T NotNull<T>(
        T? value,
        [CallerArgumentExpression(nameof(value))] string? expression = null)
        where T : class
        => value ?? throw new ArgumentNullException(expression);

    public static void Trace(
        string message,
        [CallerMemberName] string member = "",
        [CallerFilePath] string file = "",
        [CallerLineNumber] int line = 0)
        => Console.WriteLine($"{Path.GetFileName(file)}:{line} {member}: {message}");

    public static void Demo(string? maybeNull)
    {
        Trace("starting");                     // member: "Demo"

        try
        {
            _ = NotNull(maybeNull);            // message names "maybeNull" automatically
        }
        catch (ArgumentNullException ex)
        {
            Console.WriteLine(ex.ParamName);   // maybeNull
        }
    }
}

ArgumentNullException.ThrowIfNull and the other BCL ThrowIfXxx guards use CallerArgumentExpression internally, which is why they produce a correctly named exception with no nameof at the call site.

See Also