📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How are arguments passed to methods in C#? Explain pass-by-value for value and reference types, ref, out, in, ref readonly, params, and optional and named parameters.

C# parameter passing in depth: copies of values versus copies of references, the ref/out/in/ref readonly modifiers, params collections (C# 13), optional and named arguments, and their performance and versioning trade-offs.

Mid-Level Theory Intermediate csharp language-basics methods parameters params ref

🎯 Key points

  • Everything is passed by value by default: value types copy the data, reference types copy the reference
  • Reassigning a reference-type parameter does not affect the caller; mutating the object does
  • ref passes the variable itself; it must be definitely assigned before the call
  • out must be assigned by the callee before it returns; used by the TryParse pattern; out var and out _ at the call site
  • in is a read-only reference, good for large structs; non-readonly structs can cause hidden defensive copies
  • ref readonly (C# 12) is like in but the caller must pass a variable, so intent is explicit
  • params accepts a variable number of arguments; C# 13 params collections allow Span<T>, ReadOnlySpan<T>, IEnumerable<T> and others
  • Optional parameter defaults are baked into the caller at compile time; async methods and iterators cannot have ref, out or in parameters

⏱ 30-second answer

C# passes everything by value by default. For a value type that copies the data; for a reference type it copies the reference, so the method can change the object but reassigning the parameter does not affect the caller. ref passes the caller's variable itself; out is a ref the callee must assign, used by TryParse; in and ref readonly pass a read-only reference, useful for large structs. params takes a variable number of arguments, and since C# 13 it can be a ReadOnlySpan<T> to avoid allocating. Optional parameters' defaults are compiled into the caller, which matters when versioning public APIs.

🗣 Full answer (senior)

How I would answer this in an interview

The headline is: C# passes everything by value by default — including references. For a value type the value is a copy of the data; for a reference type the value is a copy of the reference. ref, out, in and ref readonly switch to pass-by-reference, which passes the address of the caller's variable.

1. Default: pass by value

void Bump(int n) => n++;                    // caller's int unchanged
void Rename(Customer c) => c.Name = "New";  // caller SEES the change (same object)
void Replace(Customer c) => c = new();      // caller does NOT see it (copy of reference)
  • Value type argument: the callee gets its own copy of the data. Large structs are copied in full on every call.
  • Reference type argument: the callee gets a copy of the reference, so it can mutate the shared object but cannot make the caller's variable point somewhere else.
  • This is why "reference types are passed by reference" is wrong — they are references passed by value.

2. Passing by reference

Modifier Caller must initialise? Callee must assign? Callee may write? Call-site syntax Typical use
ref Yes No Yes ref x Swap, update in place, large structs you intend to mutate
out No Yes, on every path Yes out x / out var x Try-pattern: int.TryParse(s, out var n)
in Yes No No in x or nothing Pass large readonly structs without copying
ref readonly (C# 12) Yes No No ref x or in x (warning if omitted) Like in, but documents that a variable (not an rvalue) is expected

Rules worth knowing:

  • out and ref require the caller to pass a variable, not a property or expression.
  • in lets you pass an rvalue (Calc(in 42) or just Calc(42)); the compiler creates a temporary.
  • in on a non-readonly struct can trigger hidden defensive copies whenever you call a member, wiping out the benefit. Use it with readonly struct.
  • None of these can be used in async methods or iterators (the reference could outlive the stack frame), though C# 13 relaxed this for ref locals and ref struct locals that do not cross an await/yield.

3. params

  • params T[] values lets callers pass a variable number of arguments: Log("a", "b", "c").
  • The compiler allocates an array on every call (except zero arguments, which uses Array.Empty<T>()).
  • C# 13 params collections: params ReadOnlySpan<T>, params IEnumerable<T>, params List<T> and others. With params ReadOnlySpan<T> the arguments go on the stack — no allocation. .NET 9+ BCL APIs such as string.Join, Path.Combine and Task.WhenAll added span overloads, so existing code gets faster just by recompiling.

4. Optional and named parameters

public Task SendAsync(string to, string subject, bool highPriority = false,
                      int retries = 3, CancellationToken ct = default) { ... }

await SendAsync("[email protected]", "Hi", retries: 5, ct: token);
  • Optional parameter defaults must be compile-time constants (or default/new() for value types), and they are copied into the caller — the same versioning trap as const.
  • Named arguments improve readability for booleans and numbers (highPriority: true is clearer than true) and let you skip optionals.
  • Optional parameters combined with overloads can make overload resolution surprising; the compiler prefers the candidate that does not need defaults filled in.

5. A production example

A pricing engine passed a 96-byte Quote struct by value through a deep call chain in a tight loop. Profiling with dotnet-trace showed significant time in memcpy-like copying. Making the struct readonly and passing it by in removed the copies. Separately, a shared library changed SendAsync(..., int retries = 3) to retries = 5; services that were not rebuilt kept retrying 3 times, which only surfaced when the downstream API was flaky. We now add a new overload rather than changing public defaults, and keep CancellationToken ct = default as the last parameter on every async API.

🔍 What the interviewer is checking

The interviewer is testing whether the candidate understands the memory model behind calls, a frequent source of bugs and misconceptions.

Green flags

  • Correctly explains "references passed by value" with an example where reassignment does not affect the caller.
  • Knows the difference between ref, out and in, and the defensive-copy issue.
  • Mentions C# 12 ref readonly or C# 13 params collections.
  • Knows optional defaults are compiled into callers.

Red flags

  • Says objects are copied when passed to methods.
  • Uses ref on every reference-type parameter "to avoid copying".
  • Does not know out must be assigned by the callee.

📚 Learn in depth

1. What the IL looks like

void A(int x)          -> parameter type int32
void B(ref int x)      -> parameter type int32&           (managed pointer)
void C(out int x)      -> int32& + [Out] attribute
void D(in int x)       -> int32& + modreq(InAttribute) + [IsReadOnly]
void E(ref readonly int x) -> int32& + [RequiresLocation] + [IsReadOnly]

At the CLR level ref, out, in and ref readonly are all the same thing: a managed pointer (T&) to the caller's storage. The differences are compiler-enforced rules encoded as attributes. That is why you cannot overload M(ref int) and M(out int) — the CLR signatures are identical.

A managed pointer can point into the stack, into the middle of a heap object (a field or array element), or into unmanaged memory. The GC understands these interior pointers and updates them when it moves objects. Because a managed pointer can point to the stack, it must never outlive the frame — which is the reason for the ref safety rules and why refs cannot be stored in heap fields or captured by lambdas.

2. Value-type copies and the cost model

Passing a struct by value copies all its bytes. Rough guidance:

  • Up to ~16 bytes (two registers on x64): passing by value is usually cheapest — values travel in registers.
  • Larger structs are copied to the stack for each call; at 64–128+ bytes in hot loops, in/ref readonly with a readonly struct wins.
  • Passing by reference has its own cost: every field access becomes an indirection, and the JIT may be less able to keep fields in registers. Always benchmark.

3. Defensive copies explained

public struct Counter { public int Value; public void Increment() => Value++; }

void Use(in Counter c)
{
    c.Increment();   // compiles! but mutates a hidden COPY
    Console.WriteLine(c.Value); // original unchanged
}

The compiler must guarantee c is not modified through an in parameter. For a non-readonly struct it cannot know whether Increment mutates, so it copies c to a temp and calls the method on the temp. This silently costs a copy per member call and hides bugs. Marking the struct readonly (or the member readonly) removes the copy and makes mutation a compile error.

4. out and definite assignment

out flips definite-assignment responsibility:

  • In the callee, the parameter starts unassigned; reading it before assigning is CS0269, and returning without assigning is CS0177.
  • In the caller, the variable is definitely assigned after the call, which is why if (dict.TryGetValue(k, out var v)) Use(v); works.
  • out var _ (discard) ignores a value without declaring a variable.

The Try-pattern returns bool for the expected failure case and uses out for the result. With nullable annotations, [NotNullWhen(true)] out T? value tells the compiler the value is non-null when the method returns true.

5. How params works, old and new

Classic params T[]:

Log("a", "b");   // compiled as Log(new[] { "a", "b" })
Log();           // compiled as Log(Array.Empty<string>())
Log(null);       // passes a null array, not an array containing null!

C# 13 params ReadOnlySpan<T>:

static int Sum(params ReadOnlySpan<int> values) { ... }
Sum(1, 2, 3);  // compiler uses an InlineArray on the stack - zero allocations

Overload resolution prefers the span overload over the array overload when both exist, which is how the BCL made string.Concat, Path.Combine and similar calls allocation-free in .NET 9 without source changes.

6. Optional parameters and binary compatibility

The default value is stored as metadata on the parameter (.param [n] = int32(3)) and the caller's compiler reads it and emits the constant at the call site. Consequences:

  • Changing a default is a binary breaking change for callers that are not recompiled.
  • Adding a new optional parameter to an existing public method is also binary breaking: the method signature changes, and old binaries throw MissingMethodException. Add an overload instead.
  • Reflection and dynamic callers see the default through ParameterInfo.DefaultValue.

7. Overload resolution with ref kinds, params and optionals

  • M(int) and M(ref int) can coexist (different ref-kind), but ref/out/in among themselves cannot.
  • The compiler prefers, in order: an exact match without expansion, then a match that needs no optional-parameter filling, then params expanded form.
  • Named arguments can make a call ambiguous or select a different overload than positional arguments would — keep public overload sets small and distinct.

Try it yourself

  1. Write a non-readonly struct with a mutating method, pass it by in, call the method, and print the field.
  2. Benchmark passing a 128-byte struct by value vs in with BenchmarkDotNet.
  3. In SharpLab, compare the IL for a params int[] call and a params ReadOnlySpan<int> call.
  4. Change a library's optional default and run an un-rebuilt consumer to see the old value used.

💻 Code

namespace Basics;

public readonly record struct Quote(decimal Price, decimal Fx, decimal Margin, decimal Tax);

public static class Parameters
{
    public static void Main()
    {
        // Reference copied by value
        var items = new List<int> { 1, 2, 3 };
        Reset(items);
        Console.WriteLine(items.Count);           // 0: Clear() hit the caller's list, new() did not

        // ref
        int a = 1, b = 2;
        Swap(ref a, ref b);
        Console.WriteLine($"{a} {b}");             // 2 1

        // out + discard
        if (TryParseAge("42", out var age)) Console.WriteLine(age);
        _ = TryParseAge("oops", out _);

        // in with a readonly struct: no copies
        var q = new Quote(100m, 1.5m, 0.1m, 0.1m);
        Console.WriteLine(Total(in q));

        // params collections (C# 13): no array allocation
        Console.WriteLine(Sum(1, 2, 3, 4));

        // named + optional arguments
        Send("[email protected]", urgent: true);
    }

    private static void Reset(List<int> list)
    {
        list.Clear();       // visible to caller
        list = new() { 99 }; // only changes the local copy of the reference
    }

    private static void Swap(ref int x, ref int y) => (x, y) = (y, x);

    private static bool TryParseAge(string text, out int age)
        => int.TryParse(text, out age) && age is >= 0 and <= 150;

    private static decimal Total(in Quote q) => q.Price * q.Fx * (1 + q.Margin) * (1 + q.Tax);

    private static int Sum(params ReadOnlySpan<int> values)
    {
        int total = 0;
        foreach (var v in values) total += v;
        return total;
    }

    private static void Send(string to, int retries = 3, bool urgent = false)
        => Console.WriteLine($"to={to} retries={retries} urgent={urgent}");
}

⚠ Common mistakes

1. Saying "reference types are passed by reference"

What happens: a candidate expects void Reset(Customer c) => c = new Customer(); to replace the caller's object. Why it is wrong: references are passed by value; reassigning the parameter only changes the local copy. Do instead: return the new object, or use ref Customer c if you genuinely need to reassign the caller's variable (rare).

2. Mutating shared objects passed in and surprising callers

What happens: a method receives a list or DTO and quietly modifies it (sorting, trimming, adding defaults). Why it is wrong: callers do not expect side effects; bugs appear far from the cause. Do instead: accept read-only abstractions (IReadOnlyList<T>), return new values, and make DTOs immutable with records and init.

3. Using in with non-readonly structs

What happens: in BigStruct s is added for performance, then members are called on s. Why it is wrong: the compiler inserts defensive copies for every member call, often making it slower than passing by value, and mutating calls silently affect a copy. Do instead: use in only with readonly struct (or readonly members), and benchmark.

4. Using out for multiple return values in new APIs

What happens: void Calculate(out decimal tax, out decimal total, out string error). Why it is wrong: awkward to call, impossible in async methods, and hard to evolve. Do instead: return a record or a named tuple; keep out for the Try-pattern.

5. Changing optional parameter defaults in a shared library

What happens: int timeoutSeconds = 30 becomes 60 in a NuGet package. Why it is wrong: defaults are baked into callers, so un-rebuilt consumers keep the old value. Do instead: treat public defaults as part of the contract; add an overload or an options object (SendOptions) instead.

6. Adding an optional parameter to an existing public method

What happens: Send(string to) becomes Send(string to, bool urgent = false). Why it is wrong: it is source compatible but binary breaking — existing compiled callers throw MissingMethodException. Do instead: add a new overload and keep the old signature.

7. Using params T[] in hot paths

What happens: logging or helper methods with params object[] called millions of times. Why it is wrong: an array (and boxing for value types) is allocated on every call, adding GC pressure. Do instead: provide fixed-arity overloads, use params ReadOnlySpan<T> (C# 13), or use source-generated logging (LoggerMessage).

8. Passing null to a params method

What happens: Log(null) expecting an array with one null element. Why it is wrong: null binds to the array parameter itself, so the method receives a null array and may throw. Do instead: pass new string?[] { null } explicitly, and guard params arrays against null.

9. Boolean arguments without names

What happens: Export(data, true, false, true). Why it is wrong: unreadable and easy to swap arguments silently. Do instead: use named arguments (includeHeaders: true), an enum, or an options record.

10. Forgetting CancellationToken or putting it in the wrong place

What happens: async APIs without a token, or with the token in the middle of the parameter list. Why it is wrong: callers cannot cancel work when a request is aborted, and the convention (CA2016 analyser) expects it last. Do instead: add CancellationToken cancellationToken = default as the last parameter and pass it through every call.

🏛 Senior insight: trade-offs

Parameter choice is an API design and performance decision.

Goal Choice
Clear public API by-value parameters, records for groups of values
Try pattern bool TryX(..., out T result)
Avoid copying big structs readonly struct + in (measure first)
Variable arguments without allocation params ReadOnlySpan<T>
Optional settings that will grow an options object or overloads, not many optional parameters
Async API no ref/out/in; return a tuple or result type
  • Most structs should be 16 bytes or less; then by-value passing is cheapest and in adds indirection.
  • Boolean parameters with names (urgent: true) or small enums make call sites readable.
  • In public libraries, adding or changing optional parameters is a breaking change; seniors design APIs with versioning in mind.

🔗 Follow-ups the interviewer may ask

  1. When: The candidate mentions out parameters or the TryParse pattern. Why: After inputs, the interviewer asks how outputs should be designed, especially replacing out parameters in async code.
    How to answer

    The return type is the contract, so I would make it honest. Not-found becomes Task<Order?> with nullable reference types on, so callers are forced to handle null, or a Result<Order> if there are several expected failure reasons. The two out parameters must go because async methods cannot have out parameters; their information moves into the result object. The internal List<Order> is replaced with IReadOnlyList<Order> (or a copy) so callers cannot mutate our state. C# gives us void, values, tuples, records, out/Try pattern, ref returns, IEnumerable<T>/IAsyncEnumerable<T>, and Task/ValueTask.

  2. When: The candidate discusses passing values by reference or allocation. Why: Local functions support ref/out/params and capture by reference, which extends the parameter discussion.
    How to answer

    A local function is a named method declared inside another method. Unlike a lambda it can be recursive, generic, an iterator or async, and called before it is declared. The big practical difference is allocation: if a local function captures variables and is only called directly, the compiler passes them in a struct by reference, so there is no heap allocation, while a capturing lambda always allocates a closure object and a delegate. A static local function cannot capture anything, which guarantees no hidden allocations. I use them for helpers used in one method and for the eager-validation pattern in iterators and async methods.

  3. When: Candidate mentions overloads versus optional parameters Why: Checks method signatures and overload resolution
    How to answer

    A method is a named block of code with a signature: its name plus the number, types, order and ref/out/in kinds of its parameters (the return type is not part of it, so you cannot overload by return type alone). Instance methods run on an object and can use this; static methods belong to the type and cannot. Overloading means several methods with the same name and different signatures; the compiler picks the best match at compile time, preferring exact types over conversions and normal forms over params. Expression-bodied methods (=> expr) are short forms for one expression. Extension methods are static methods with a this parameter that look like instance methods; an instance method always wins if one fits. C# 14 adds extension blocks, which also allow extension properties and static extension members.

🛠 Real-world problems: how you handle them

Hot pricing loop slowed by copying large structs
Situation
A quoting engine evaluated millions of price scenarios per minute for an insurance product, passing a large mutable Quote struct by value through several helper methods.
Signals
CPU at 90% on the pricing pods, throughput well below target, and a profiler showing time spent in memcpy-style copying and struct constructors.
Investigation
Used dotnet-trace and the Visual Studio CPU profiler to find hot methods, inspected the JIT output with DOTNET_JitDisasm, and wrote BenchmarkDotNet comparisons of by-value, in with a mutable struct, and in with a readonly struct.
Resolution
Made Quote a readonly record struct, passed it with in through the hot path, and split rarely used fields into a separate class to shrink the struct.
Prevention
Added the benchmarks to CI with a regression threshold and a coding guideline: structs over 16 bytes must be readonly and justified.

How to tell it (STAR)

**S:** Our pricing engine [at company] was missing its throughput target. **T:** I was asked to find the bottleneck without changing results. **A:** I profiled it, saw time going into copying a large mutable struct, proved with BenchmarkDotNet that in on a non-readonly struct made it worse due to defensive copies, then made the struct readonly and passed it by in. **R:** Throughput improved by [x%], CPU dropped, and the benchmark now guards it in CI.

↩ Usually asked after

More in Language Basics

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me