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

📚 Interview question

What do `in` parameters and `ref readonly` parameters (C# 12) do? Explain passing large structs by readonly reference, hidden defensive copies with non-readonly structs, readonly members, when in makes code slower, the call-site rules for in and ref readonly, and how to decide between by-value, in and ref readonly.

in and ref readonly parameters: readonly references, defensive copies and when they help or hurt.

Senior Theory Advanced defensive-copies in-parameters performance ref-readonly structs

🎯 Key points

  • in passes arguments by readonly reference, avoiding copies of large structs
  • Non-readonly structs suffer hidden defensive copies when members are called on in parameters
  • readonly struct or readonly members (C# 8) remove defensive copies
  • in is optional at the call site and accepts temporaries
  • ref readonly parameters (C# 12) require a variable and warn on temporaries
  • Pass classes and small structs by value
  • Benchmark: in can be slower than by-value for small or mutable structs

⏱ 30-second answer

An in parameter (C# 7.2) passes an argument by readonly reference: no copy of the struct, and the method cannot assign to it. It helps for large structs (roughly over 16 bytes) on hot paths. The trap is defensive copies: if the struct is not readonly, calling a non-readonly method or property on an in parameter forces the compiler to copy it first, because the call might mutate it, which can make in slower than by-value. Mark structs readonly (or individual members readonly, C# 8) to avoid this. in is optional at the call site and accepts rvalues (the compiler creates a temporary). ref readonly parameters (C# 12) express "I need a real reference to a variable": callers should pass an lvalue with ref or in, and passing a temporary produces a warning. For small structs and all classes, pass by value.

🗣 Full answer (senior)

How I would answer this in an interview

in parameters (C# 7.2) pass an argument by readonly reference: no copy, and the callee can't modify it. ref readonly parameters (C# 12) do the same but make the call-site contract stricter and clearer. Both exist mainly for large structs. The catch: with non-readonly structs, the compiler inserts hidden defensive copies, which can make in slower than passing by value. I'd explain each, readonly members, call-site rules, and how to choose.

1. Passing large structs by readonly reference

public readonly struct Matrix4x4d   // 128 bytes
{
    public readonly double M11, M12, /* ... */ M44;
    public Matrix4x4d Multiply(in Matrix4x4d other) { ... }
}

public static Vector3d Transform(in Matrix4x4d m, in Vector3d v) => ...;   // no 128-byte copy per call

Passing a 128-byte struct by value copies it on every call (and every nested call). in passes a pointer instead. Inside the method, the parameter is readonly: assignments to it or its fields don't compile.

2. Hidden defensive copies with non-readonly structs

public struct Money { public decimal Amount; public string Currency; public decimal WithGst() => Amount * 1.1m; }

static decimal Total(in Money m) => m.WithGst();   // compiler copies 'm' before calling WithGst!

The compiler must guarantee m isn't modified. WithGst() isn't marked readonly, so the compiler can't know it doesn't mutate fields — it makes a defensive copy of m and calls the method on the copy. Every member call (methods and property getters on non-readonly structs) through an in parameter may copy the whole struct — silently erasing the benefit or making it slower than by-value.

The same applies to readonly fields of struct type and ref readonly locals/returns.

3. Readonly members

Fix by marking the struct or its members readonly:

public readonly struct Money { ... }                    // all members implicitly readonly; no defensive copies

public struct Counter
{
    public int Value;
    public readonly int Doubled() => Value * 2;         // readonly member: callable without a copy
    public void Increment() => Value++;                  // mutating member
}

Auto-property getters are implicitly readonly; explicit getters aren't unless marked. Analysers (IDE0251) suggest readonly members.

4. When in makes code slower

  • Small structs (≤ 16 bytes, e.g. int, Guid, DateTime, (int, int)): passing by value uses registers; in forces the value into memory and adds indirection on every access.
  • Non-readonly structs: defensive copies on member calls.
  • Reference types: in on a class reference is pointless (it passes a reference to a reference).
  • Async/iterators/lambdas: in parameters can't be used/captured there.
  • Heavy field access in loops: each access is through a pointer; the JIT may not enregister fields.

Rule of thumb: consider in/ref readonly for readonly structs larger than ~16–32 bytes on measured hot paths.

5. Call-site rules: in vs ref readonly

in ref readonly (C# 12)
Call-site modifier Optional (M(x) or M(in x)) ref or in expected (warning if omitted)
Passing an rvalue (M(a + b), M(5)) Allowed — compiler creates a temporary Warning — intended for variables
Intent "Optimisation: don't copy" "I need a reference to your variable" (API semantics)
Overload with by-value By-value overload preferred unless in is written —

ref readonly was introduced for APIs that semantically need the address of a variable (e.g. MemoryMarshal/Unsafe style APIs, or methods that may return a reference derived from the argument), where silently creating a temporary would be a bug. It also lets existing ref parameters be relaxed to readonly without breaking callers that pass ref.

6. Deciding between by-value, in and ref readonly

  1. Default: by value. Simple, fast for small types, works everywhere (async, lambdas).
  2. in for large readonly structs passed frequently on hot paths, where benchmarks show copying costs.
  3. ref readonly when the API needs a reference to an existing variable (aliasing semantics) or for interop/low-level APIs; also when converting ref APIs to read-only without breaking callers.
  4. ref when the callee must mutate the caller's struct.

Always mark the struct readonly before introducing in, then benchmark.

7. A production example

A graphics/pricing engine (heavy matrix maths) passed a 128-byte Matrix struct by value through several layers; BenchmarkDotNet showed copies dominating. We added in — and performance got worse, because Matrix wasn't readonly and every method call made a defensive copy. Marking the struct readonly (it was effectively immutable already) eliminated the copies, and the hot path became ~35% faster than the original. The lesson stuck: readonly struct first, then in, then measure.

🔍 What the interviewer is checking

The interviewer checks whether you understand readonly references and the performance pitfalls.

Green flags

  • Explains in as readonly by-reference passing for large structs.
  • Knows defensive copies and fixes them with readonly structs or members.
  • Knows in accepts temporaries and what ref readonly changes.
  • Insists on benchmarking.

Red flags

  • Adds in everywhere as a performance habit.
  • Unaware of defensive copies.
  • Uses in with classes or tiny structs.

📚 Learn in depth

1. IL representation

  • in T → T& with modreq(InAttribute) (or [IsReadOnly] in some positions) so other compilers respect read-only-ness.
  • ref readonly T → T& with [RequiresLocation] and [IsReadOnly].
  • At run time, both are managed pointers.

2. How defensive copies are emitted

For a call m.WithGst() where m is in Money (non-readonly):

ldarg.0          // Money& m
ldobj Money      // COPY the struct to the stack
stloc.0
ldloca.s 0       // address of the copy
call instance decimal Money::WithGst()

With readonly struct Money, the compiler emits ldarg.0; call ... directly (no copy).

3. ABI considerations

On x64, structs up to 16 bytes may be passed in registers (SysV) or by hidden reference (Windows for > 8 bytes). The JIT's struct promotion can keep fields in registers when passed by value and the callee is inlined. in defeats some of these optimisations by forcing address exposure. Hence small-struct in can be slower.

4. Overload resolution with in

void M(Money m) { }
void M(in Money m) { }
M(x);      // binds to by-value overload
M(in x);   // binds to in overload

The by-value overload is preferred when the call site has no modifier, preventing silent behaviour changes when adding in overloads.

5. in and lambdas/async

in parameters can't be captured by lambdas or used in async methods/iterators (like ref). Local functions called directly can use them.

6. Benchmarking guidance

[Benchmark] public double ByValue() => Compute(_matrix, _vector);
[Benchmark] public double ByIn() => ComputeIn(in _matrix, in _vector);

Test with readonly and non-readonly variants, check disassembly for copies (movdqu sequences), and vary struct sizes (16, 32, 64, 128 bytes).

Try it yourself

  1. Create a non-readonly struct with a method, pass it via in, and inspect IL for ldobj copies.
  2. Mark the struct readonly and compare IL.
  3. Benchmark by-value vs in for 16-byte and 128-byte structs.
  4. Call a ref readonly method with an rvalue and read the warning.

💻 Code

// 32-byte struct, not readonly: in parameters cause defensive copies
public struct MutableBox
{
    public double MinX, MinY, MaxX, MaxY;
    public double Area() => (MaxX - MinX) * (MaxY - MinY);           // might mutate as far as the compiler knows
    public readonly double Width() => MaxX - MinX;                   // readonly member: no copy needed
}

// Readonly struct: in parameters are copy-free
public readonly struct Box(double minX, double minY, double maxX, double maxY)
{
    public double MinX { get; } = minX;
    public double MinY { get; } = minY;
    public double MaxX { get; } = maxX;
    public double MaxY { get; } = maxY;
    public double Area() => (MaxX - MinX) * (MaxY - MinY);

    public bool Overlaps(in Box other) =>
        MinX < other.MaxX && other.MinX < MaxX && MinY < other.MaxY && other.MinY < MaxY;
}

public static class Geometry
{
    public static double AreaSlow(in MutableBox b) => b.Area();      // hidden copy of 32 bytes per call
    public static double WidthFast(in MutableBox b) => b.Width();    // no copy: readonly member
    public static double Area(in Box b) => b.Area();                 // no copy: readonly struct

    // C# 12 ref readonly parameters: the method returns a reference derived from its arguments,
    // so it needs real variables, not hidden temporaries (passing a temporary gives a warning)
    public static ref readonly Box Larger(ref readonly Box a, ref readonly Box b) =>
        ref a.Area() >= b.Area() ? ref a : ref b;
}

public static class Program
{
    public static void Main()
    {
        var mb = new MutableBox { MinX = 0, MinY = 0, MaxX = 4, MaxY = 3 };
        Console.WriteLine(Geometry.AreaSlow(mb));                 // 12 (with a defensive copy)
        Console.WriteLine(Geometry.WidthFast(in mb));             // 4

        var a = new Box(0, 0, 10, 10);
        var b = new Box(5, 5, 25, 25);
        Console.WriteLine(a.Overlaps(b));                         // True
        Console.WriteLine(Geometry.Area(new Box(0, 0, 2, 2)));    // in accepts a temporary

        ref readonly Box larger = ref Geometry.Larger(in a, in b); // alias to b, no copy
        Console.WriteLine(larger.Area());                         // 400
    }
}

⚠ Common mistakes

1. Using in with non-readonly structs

What happens: adding in for performance on mutable structs. Why it is wrong: defensive copies on every member call; often slower. Do instead: make the struct (or members) readonly first.

2. in on small types

What happens: in int, in Guid, in DateTime. Why it is wrong: indirection costs more than copying. Do instead: pass small types by value.

3. in on reference types

What happens: in Customer customer. Why it is wrong: no benefit; just a reference to a reference. Do instead: pass by value.

4. Assuming in prevents all mutation

What happens: expecting the object referenced by a struct field to be immutable. Why it is wrong: in prevents reassigning fields, not mutating referenced objects. Do instead: design deeply immutable types if needed.

5. Using ref readonly where in fits

What happens: ref readonly for simple performance optimisations. Why it is wrong: forces call-site modifiers and warns on rvalues. Do instead: use in for "don't copy"; ref readonly for variable-reference semantics.

6. Not benchmarking

What happens: adding in everywhere on assumption. Why it is wrong: can regress performance. Do instead: measure with BenchmarkDotNet and inspect disassembly.

7. Forgetting readonly getters

What happens: explicit property getters on structs not marked readonly. Why it is wrong: defensive copies when accessed through in. Do instead: mark getters readonly or make the struct readonly.

8. Trying to capture in parameters

What happens: using in parameters inside lambdas or async code. Why it is wrong: compile errors. Do instead: copy to a local first.

🏛 Senior insight: trade-offs

in is a micro-optimisation with a trap built in. The defensive-copy rule means it only works well together with readonly struct design. That is why the real senior advice is: make value types readonly by default, and then in becomes a safe tool for hot paths with large structs.

Question If yes
Is the struct readonly? in is safe to consider
Is it larger than about 16 bytes? in may help
Is the method hot (profiled)? benchmark by-value versus in
Does the API need the variable itself? ref readonly

I would also point out the API compatibility angle: adding in to a public method changes overload resolution for callers, while ref readonly was designed to let libraries tighten ref parameters without breaking them.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate compares in with ref. Why: Checks basic ref, out, in and params semantics.
    How to 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.

  2. When: Candidate mentions defensive copies. Why: readonly structs and members are the fix for defensive copies.
    How to answer

    readonly always means "this storage cannot be reassigned after initialisation", but the scope differs. A readonly field can be set only in its initialiser or a constructor (or an init accessor). const is a compile-time literal copied into every caller, so changing it in a library does nothing until callers recompile; static readonly is read at run time, and the tiered JIT treats it as a constant once initialised, so it is just as fast for primitives. A readonly struct has only readonly fields, and readonly members (C# 8) promise not to mutate this, which removes defensive copies. ref readonly returns give a reference callers cannot write through. None of these make referenced objects immutable: a readonly List<T> can still be added to.

  3. When: Candidate returns a reference from a method. Why: ref readonly returns and locals go with ref readonly parameters.
    How to answer

    ref creates an alias to a storage location instead of a copy. A ref local (ref int slot = ref array[i];) and a ref return (ref T Find(...)) let you read and write an element in place, which avoids copying large structs and double lookups. Ref fields (C# 11) let a ref struct hold a reference, which is how Span<T> is implemented. The compiler enforces ref safety: you cannot return a reference to a local or to a stack-only value that will die, and scoped marks a ref or span parameter as not escaping the method, which makes more call patterns legal. CollectionsMarshal.GetValueRefOrAddDefault returns a ref to a dictionary value so "lookup and update" costs one hash. The risk is dangling or stale references: a ref into a list or dictionary is invalid after the collection resizes.

🛠 Real-world problems: how you handle them

Adding in parameters made a geometry library slower
Situation
A mapping company's geometry library processed [20 million] bounding-box checks per tile render using a 32-byte mutable Box struct.
Signals
After a refactor that added in to all Box parameters, benchmarks showed render time up [15%].
Investigation
Compared IL on sharplab.io and found defensive copies before every property and method call on the in parameters, since Box was not readonly.
Resolution
Made Box a readonly struct with init-only construction, kept in on the hot overlap checks, and removed in from small structs.
Prevention
Enabled an analyser rule for structs that could be readonly and required benchmarks for performance refactors.

How to tell it (STAR)

**Situation:** a [colleague's] refactor added in parameters across our geometry library and tile rendering got [15%] slower. **Task:** I was asked to work out why before release. **Action:** I inspected the IL and found the compiler was making defensive copies because our Box struct was mutable. I made it readonly, kept in only where benchmarks showed a gain, and added an analyser to flag structs that should be readonly. **Result:** rendering ended [20%] faster than the original, and the team learned to benchmark these changes.

↩ Usually asked after

More in Memory-Oriented C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me