Skip to content

Below is the kind of explanation I would give to a strong senior engineer preparing for a real systems interview.


ref, in, out, ref locals, and ref returns in C#

How they really work and when they actually matter

In normal business applications, most C# code is perfectly fine with ordinary parameters and ordinary return values. You pass data in, get data out, and move on.

But once you enter high-performance systems, that mental model becomes incomplete.

In data-heavy or real-time systems, one of the hidden costs is data movement. Not only allocation. Not only GC. Sometimes the cost is simply: how many times are we copying data around? If the data is large enough, or the operation is repeated millions of times, that cost starts to matter.

That is where features like ref, in, out, ref locals, and ref returns become useful.

They are not “advanced syntax for clever developers.” They are tools for controlling how data flows through memory.

And that is the right lens to use.


1. Big picture

Why these features exist in C#

C# started as a safer, more productive language than C++. Over time, .NET moved into more performance-sensitive areas: game engines, compilers, financial systems, image processing, real-time services, industrial control, and data pipelines.

In those worlds, copying data blindly is not always acceptable.

So the language evolved to give developers more control over:

  • whether a value is copied
  • whether the caller’s original variable can be modified
  • whether a value is returned by copy or by reference
  • whether a large struct can be read without duplication

These features exist because sometimes the default behavior is correct and simple, but not cheap enough.

Passing by value vs passing by reference

At a high level:

  • Pass by value means the method receives its own copy of the argument.
  • Pass by reference means the method receives access to the caller’s original storage location.

That sounds simple, but many developers confuse this, especially with reference types.

The important point is: “reference type” and “passed by reference” are not the same thing.

Those are two different ideas.

Why copying data can matter

If you pass an int, copying is trivial.

If you pass a large struct representing:

  • image region metadata
  • geometry bounds
  • a sensor reading packet
  • a defect record with many fields

then copying can become noticeable.

And if that happens inside:

  • tight loops
  • frame processing
  • image analysis pipelines
  • high-frequency device polling
  • large-scale parsing or transformation code

the cumulative cost can be real.

This is why these features matter much more in systems like:

  • wafer inspection software
  • machine vision processing
  • streaming telemetry pipelines
  • custom collections and parsers
  • low-allocation libraries

and much less in normal CRUD application services.

Why most business apps rarely need them

Most business apps spend more time on:

  • database access
  • network calls
  • serialization
  • business workflows
  • UI rendering
  • human think time

In that world, copying a small or medium object is usually irrelevant compared with I/O and app complexity.

So for most application code, using ref-style features gives little benefit and often harms readability.

That is why experienced engineers do not treat these features as generally “better.” They use them only where data movement is actually part of the performance problem.


2. Passing parameters: value vs ref

Default pass-by-value behavior

C# passes parameters by value by default.

That means the callee receives a copy of the argument.

But what gets copied depends on the type.

For value types

For value types like:

  • int
  • double
  • bool
  • custom struct

the actual value data is copied.

csharp
void Increment(int x)
{
    x++;
}

int value = 10;
Increment(value);
// value is still 10

The method got its own copy of 10.

For reference types

For reference types like classes, the reference is copied, not the object itself.

csharp
sealed class ImageJob
{
    public int Priority { get; set; }
}

void ChangePriority(ImageJob job)
{
    job.Priority = 5;
}

var job = new ImageJob { Priority = 1 };
ChangePriority(job);
// job.Priority is now 5

Many developers look at this and think the object was “passed by reference.”

Not exactly.

What was copied is the reference itself. Both caller and callee now hold references to the same heap object.

If the method reassigns its local parameter, that reassignment does not affect the caller:

csharp
void ReplaceJob(ImageJob job)
{
    job = new ImageJob { Priority = 99 };
}

var job = new ImageJob { Priority = 1 };
ReplaceJob(job);
// original job is unchanged

That is still pass-by-value. The copied thing just happens to be an object reference.

ref parameter

A ref parameter means the method works with the caller’s original variable, not a copy.

csharp
void Increment(ref int x)
{
    x++;
}

int value = 10;
Increment(ref value);
// value is now 11

Now the method can directly mutate the caller’s storage.

This applies to both value types and reference variables.

ref with a reference type variable

csharp
void ReplaceJob(ref ImageJob job)
{
    job = new ImageJob { Priority = 99 };
}

var job = new ImageJob { Priority = 1 };
ReplaceJob(ref job);
// job now points to the new object

This time the caller’s variable itself was modified.

That is the real meaning of ref: not “class vs struct,” but “operate on the original variable.”

What really happens in memory

Conceptually, a normal parameter gets a copy of the incoming value.

A ref parameter instead behaves like an alias to the caller’s storage location.

So instead of:

  • making a new copy in the callee

the language lets the callee refer back to:

  • the original stack slot
  • the original local
  • the original array element
  • the original field, if allowed by the language rules

That is why ref is powerful. It removes one level of copying and gives direct access to existing memory.

But that also makes it more dangerous, because now two names may refer to the same mutable storage.


3. Out parameters in practical usage

Purpose of out

out is for passing a variable by reference when the main goal is for the method to assign a result into it.

It is commonly used when a method needs to return:

  • success/failure as the main result
  • and an extracted value as a secondary result

Classic pattern:

csharp
if (int.TryParse(text, out int value))
{
    Console.WriteLine(value);
}

The method returns bool and writes the parsed number into value.

Initialization requirement

With out, the caller does not need to initialize the variable before passing it.

The callee must assign it before returning.

csharp
bool TryGetExposure(string raw, out double exposure)
{
    if (double.TryParse(raw, out exposure))
        return true;

    exposure = 0;
    return false;
}

That definite-assignment rule is one reason out remains safe and predictable.

Returning multiple values

out is a practical way to return multiple pieces of information without allocating an extra object.

Example in performance-sensitive extraction code:

csharp
bool TryGetDefectBounds(
    ReadOnlySpan<byte> buffer,
    out int x,
    out int y,
    out int width,
    out int height)
{
    if (buffer.Length < 16)
    {
        x = y = width = height = 0;
        return false;
    }

    x = BitConverter.ToInt32(buffer.Slice(0, 4));
    y = BitConverter.ToInt32(buffer.Slice(4, 4));
    width = BitConverter.ToInt32(buffer.Slice(8, 4));
    height = BitConverter.ToInt32(buffer.Slice(12, 4));
    return true;
}

This is ugly compared with returning a record or tuple, but it is allocation-free and extremely explicit.

Why out still exists in modern .NET

People sometimes assume tuples should replace out completely.

In normal application code, tuples are often nicer.

But out still matters because it has advantages:

  • no additional object allocation
  • no tuple construction overhead concerns in hot paths
  • clear “TryXxx” pattern
  • avoids exceptions for expected failure cases
  • familiar across the .NET ecosystem

This is why core APIs still use patterns like:

  • TryParse
  • TryGetValue
  • TryPeek
  • TryDequeue

Trade-offs vs tuples or objects

out advantages

  • efficient
  • good for hot paths
  • good for TryXxx methods
  • avoids object creation

out disadvantages

  • can hurt readability if overused
  • awkward with many outputs
  • less self-documenting than a named type
  • not great for layered business APIs

So the mature decision is simple:

Use out when the method is low-level, performance-sensitive, and naturally follows a TryXxx shape. Avoid it when the API is domain-facing and readability is more important than shaving tiny costs.


4. In parameters: readonly by reference

What in means

in means: pass by reference, but readonly.

So the method receives access to the caller’s original value without copying, but it is not allowed to modify it.

csharp
readonly struct RegionInfo
{
    public readonly int X;
    public readonly int Y;
    public readonly int Width;
    public readonly int Height;

    public RegionInfo(int x, int y, int width, int height)
        => (X, Y, Width, Height) = (x, y, width, height);
}

int Area(in RegionInfo region)
{
    return region.Width * region.Height;
}

This is mainly useful for large value types.

Why in exists

If you pass a large struct by value, the struct gets copied.

If the struct is large and used frequently, that can become wasteful.

in avoids that copy while preserving readonly intent.

So it is a performance feature and an API design signal:

  • “I want efficiency”
  • “I do not want mutation”

Real examples where in can help

Imagine a struct that carries image-analysis metadata:

csharp
readonly struct DefectMeasurement
{
    public readonly int X;
    public readonly int Y;
    public readonly int Width;
    public readonly int Height;
    public readonly double Score;
    public readonly double Contrast;
    public readonly double Circularity;
    public readonly long Timestamp;
}

Passing that by value in a tight loop may repeatedly copy all those fields.

Using in can reduce that copying:

csharp
bool IsCritical(in DefectMeasurement defect)
{
    return defect.Score > 0.95 &&
           defect.Width > 20 &&
           defect.Height > 20;
}

When in is useful

Good candidates:

  • large structs
  • hot-path calculations
  • geometry/math/image metadata structs
  • parsing or transformation loops
  • library code where copying has been measured

When in adds noise

Bad candidates:

  • tiny structs like Point with two small fields unless truly hot
  • non-critical code paths
  • business-layer methods
  • places where the team will not understand why it is there

This is important: in is not automatically a win.

Sometimes the runtime or JIT can already optimize well. Sometimes the struct is too small for the difference to matter. Sometimes the call overhead or code complexity outweighs the benefit.

So experienced engineers do not add in just because a struct exists. They use it when they have reason to believe copy avoidance is meaningful.

Compiler optimizations vs real-world impact

It is easy to read about in and assume it is always faster for structs.

Real life is messier.

Performance depends on:

  • struct size
  • call frequency
  • access patterns
  • whether defensive copies occur
  • JIT optimizations
  • whether instance members are readonly-friendly

So the right attitude is:

  • understand the tool
  • use it in obvious high-value cases
  • benchmark before spreading it widely

5. Ref locals and ref returns

This is where the model becomes really powerful.

What a ref local is

A ref local is a local variable that does not hold a copied value. It holds a reference to an existing storage location.

csharp
int[] data = { 10, 20, 30 };
ref int item = ref data[1];
item = 999;

// data[1] is now 999

item is not a new integer. It is another name for the original array element.

What a ref return is

A ref return means a method returns a reference to existing storage rather than returning a copied value.

csharp
ref int Find(int[] values, Predicate<int> match)
{
    for (int i = 0; i < values.Length; i++)
    {
        if (match(values[i]))
            return ref values[i];
    }

    throw new InvalidOperationException();
}

Usage:

csharp
ref int found = ref Find(values, x => x > 100);
found = 0;

Now the caller can work directly with the original storage.

Why this matters

Normally, returning a value means making a copy.

For small values, fine.

For large structs or mutation of existing storage, that copy can be undesirable.

Ref returns let you say:

  • “Do not give me a copy of the data”
  • “Give me access to the actual location”

That can be useful in:

  • custom collections
  • buffer manipulation
  • parsers
  • image-processing structures
  • high-performance mutable containers

Example: large struct in custom storage

csharp
struct PixelBlockStats
{
    public long Sum;
    public int Min;
    public int Max;
    public int Count;
    public double Mean;
    public double Variance;
}

sealed class StatsBuffer
{
    private readonly PixelBlockStats[] _items;

    public StatsBuffer(int size) => _items = new PixelBlockStats[size];

    public ref PixelBlockStats Get(int index) => ref _items[index];
}

Usage:

csharp
ref PixelBlockStats stats = ref buffer.Get(index);
stats.Count++;
stats.Sum += value;

Without ref, each access might involve copying the struct out and then copying it back if you reassign manually.

With ref, you update the existing struct in place.

Why this is powerful

This can reduce:

  • repeated struct copies
  • unnecessary temporary values
  • extra mutation steps
  • some forms of hidden inefficiency in container access

In hot code, this can matter.

Why this is dangerous

Because now you are exposing raw access to internal storage.

That creates risks:

  • aliasing: multiple references to the same memory
  • mutation from unexpected places
  • broken invariants
  • lifetime safety problems
  • harder reasoning about code

In other words, ref returns move your code one step closer to systems programming.

Sometimes that is exactly what you want. But you must treat it with the same seriousness.


6. Where this matters in a wafer inspection WPF system

Now let’s connect this to a realistic production system.

Imagine a WPF desktop app controlling a wafer inspection machine.

This system might do all of these:

  • receive image frames from cameras
  • decode or annotate image metadata
  • compute defect features
  • transform coordinates between machine space and image space
  • maintain large collections of defect results
  • stream data to UI and storage
  • run continuously for hours or days

Where these features might matter

Processing large image metadata structs

Suppose each defect result includes a large value-type struct with:

  • image coordinates
  • stage coordinates
  • classification flags
  • confidence score
  • timing info
  • source frame index
  • measurement fields

If that struct is copied repeatedly in a hot path, using in for read-only processing or ref for in-place updates can help.

Iterating over large collections of defect data

If you keep results in arrays or custom buffers of structs, a ref local lets you update the actual item in place rather than copy-modify-copy.

Example mindset:

  • extract direct reference to current defect record
  • update normalized coordinates
  • write score
  • set classification bits
  • continue

That is a real use case.

Avoiding copies in tight loops

In high-frequency transformation pipelines, repeated struct copies can multiply.

For example:

  • decode defect data
  • normalize coordinates
  • apply calibration transform
  • map to wafer space
  • compute severity
  • write output record

If each stage copies a 64-byte or 128-byte struct millions of times, the cost becomes real enough to care about.

High-frequency transformation pipelines

This is where engineers start caring about:

  • by-ref readonly passing
  • in-place struct updates
  • direct buffer access
  • avoiding intermediate objects

Not because the syntax is cool, but because the system is doing serious work on every frame.

Where they should not be used

UI code

Do not spread ref-style code through ViewModels, bindings, commands, or UI orchestration.

Why? Because the dominant concerns there are:

  • clarity
  • maintainability
  • correctness
  • predictable state flow

Not micro-level copy avoidance.

Business logic

Order rules, workflow decisions, machine-state transitions, permission checks, recipe validation — these should usually not use ref or in unless there is a proven hotspot and a very good reason.

General application services

Application services should optimize for readability and stable contracts first.

A senior engineer keeps low-level performance tricks in the low-level layer.

That separation is a sign of good judgment.


7. Memory and performance implications

Copying cost vs referencing cost

A copy means bytes move from one place to another.

For tiny values, the cost is negligible.

For large structs in frequent operations, that cost adds up.

A reference is smaller. Passing a reference usually means passing an address-like handle to existing storage rather than duplicating the full payload.

So the trade-off is:

  • copy = simple, isolated, safe
  • reference = less data movement, but more aliasing risk

Stack vs heap considerations

Do not oversimplify this as “ref is stack” and “normal is heap.”

That is not the right model.

The real issue is not just where the object lives, but whether you are:

  • copying the value
  • reusing existing storage
  • mutating shared storage

For structs, copying often means duplicating the value data, wherever it conceptually lives.

For reference types, normal passing copies only the object reference, not the object.

So the biggest gains from in and ref return are usually with value types, especially larger ones.

How ref / in reduce allocations and copies

Strictly speaking, ref and in do not magically eliminate all allocations by themselves.

Their main direct benefit is avoiding copies.

But in real systems, reducing copies often indirectly reduces pressure that would otherwise lead developers to create wrapper objects, defensive clones, or temporary representations.

Ref-based design also appears alongside other low-allocation techniques:

  • arrays instead of per-item objects
  • structs instead of classes in hot data paths
  • pooled buffers
  • Span<T> / Memory<T>
  • direct buffer processing

So the broader pattern is: keep data in place and avoid unnecessary movement.

When the benefit is negligible

The benefit is often negligible when:

  • the struct is small
  • the method is not hot
  • the app is I/O-bound
  • the code runs infrequently
  • UI or DB latency dominates
  • the team will pay more in maintenance than it gains in speed

This is why performance-aware engineers are selective.

The question is not “Can this avoid a copy?”

The question is “Does that copy matter enough to justify the complexity?”


8. Safety rules and limitations

C# intentionally restricts ref usage.

These rules are not arbitrary. They are guardrails.

Why C# restricts ref usage

A reference to memory is only safe if that memory is guaranteed to still exist and still mean what you think it means.

If the language allowed references to unstable storage too freely, you would get classic memory bugs:

  • dangling references
  • invalid aliases
  • mutation of dead data
  • broken invariants

So C# enforces rules around:

  • scope
  • lifetime
  • escape analysis
  • readonly behavior
  • valid return targets

Why you cannot return ref to a local variable

This is the classic example.

csharp
ref int Bad()
{
    int x = 42;
    return ref x; // illegal
}

Why illegal?

Because x stops existing when the method exits. Returning a reference to it would hand the caller access to dead storage.

So the language prevents it.

You can only return a ref to storage whose lifetime safely outlives the method call, such as:

  • an array element
  • a field in an object, when allowed
  • a caller-provided ref parameter
  • other valid, stable storage locations

Aliasing risks

Aliasing means multiple names refer to the same mutable storage.

That is efficient, but dangerous.

If one part of the code changes the value, another part sees the change immediately.

That can make code harder to reason about, especially when:

  • control flow is complex
  • the data structure has invariants
  • concurrency is involved
  • multiple layers share the same mutable data

Readability and maintainability concerns

Ref-heavy code is usually harder to read than ordinary C#.

It makes developers think about:

  • storage location
  • lifetime
  • mutation side effects
  • whether something is copied or aliased

That mental load is worth it only in the part of the system where those details matter.

So the rule is not “avoid these features.” The rule is “contain them.”


9. Common mistakes

Using ref everywhere unnecessarily

This is one of the most common advanced-C# mistakes.

A developer learns that copies cost something, then starts adding ref to many APIs.

Result:

  • inconsistent method signatures
  • reduced readability
  • harder call sites
  • no meaningful performance gain

Production consequence: the codebase becomes more fragile and intimidating, while performance barely moves.

Misunderstanding reference type vs pass-by-reference

Many developers think:

  • “classes are passed by reference”

That is imprecise and causes bugs.

Classes are reference types, but parameters are still passed by value unless you use ref, in, or out.

This misunderstanding leads to broken expectations when reassigning parameters.

Returning unsafe references

When people start using ref returns, they sometimes design APIs that expose internal storage too aggressively.

That can let callers bypass invariants or mutate data in ways the type was trying to control.

Production consequence:

  • corrupted state
  • difficult bugs
  • broken encapsulation

Using ref in public APIs unnecessarily

Public APIs live a long time.

If you expose ref-based shapes publicly, you increase:

  • cognitive burden on users
  • compatibility concerns
  • documentation burden
  • potential misuse

These patterns fit better in internal low-level libraries than broad application APIs.

Over-optimizing without measurement

This is probably the biggest mistake.

A team notices performance issues and starts rewriting code with in, ref, and structs everywhere.

But the real bottleneck is:

  • image decoding
  • native interop
  • locking
  • WPF rendering
  • serialization
  • GC from unrelated allocations
  • disk writes
  • camera SDK stalls

Production consequence: lots of churn, little benefit.

Low-level copy optimization only matters when data copying is actually the problem.

Making code harder to read for tiny gains

Even if an optimization is real, it can still be the wrong choice if the gain is tiny and the readability cost is high.

Strong engineers think in terms of total system value, not local cleverness.


10. When to use vs avoid

Use when

These features make sense when you are:

  • working in hot paths
  • handling large structs
  • building performance-critical libraries
  • manipulating buffers or custom collections
  • avoiding unnecessary copies in tight loops
  • implementing low-allocation pipelines
  • writing parsers, codecs, geometry, image, or numeric routines

Avoid when

Avoid them when you are in:

  • business logic
  • orchestration code
  • general application services
  • UI / ViewModel code
  • code owned by a team unfamiliar with by-ref patterns
  • places with no measured gain

A good rule:

If the code is not performance-sensitive enough for someone to benchmark it, it probably does not need ref-style complexity.


11. Practical .NET examples

Example 1: ref parameter

csharp
static void ClampToSensorLimit(ref int exposure, int min, int max)
{
    if (exposure < min) exposure = min;
    else if (exposure > max) exposure = max;
}

int exposure = 250;
ClampToSensorLimit(ref exposure, 10, 100);
// exposure == 100

Use this when the method’s purpose is to modify the caller’s variable directly.

Example 2: out parameter

csharp
static bool TryReadStagePosition(ReadOnlySpan<byte> packet, out double x, out double y)
{
    if (packet.Length < 16)
    {
        x = 0;
        y = 0;
        return false;
    }

    x = BitConverter.ToDouble(packet.Slice(0, 8));
    y = BitConverter.ToDouble(packet.Slice(8, 8));
    return true;
}

This follows the familiar TryXxx pattern and avoids exceptions for expected parse failure.

Example 3: in with a large struct

csharp
readonly struct InspectionRegion
{
    public readonly int Left;
    public readonly int Top;
    public readonly int Width;
    public readonly int Height;
    public readonly double ScaleX;
    public readonly double ScaleY;
    public readonly long FrameId;

    public InspectionRegion(
        int left, int top, int width, int height,
        double scaleX, double scaleY, long frameId)
    {
        Left = left;
        Top = top;
        Width = width;
        Height = height;
        ScaleX = scaleX;
        ScaleY = scaleY;
        FrameId = frameId;
    }
}

static double ComputeRegionAreaInMicrons(in InspectionRegion region)
{
    return region.Width * region.ScaleX * region.Height * region.ScaleY;
}

This is a reasonable use of in because the struct is non-trivial and readonly usage is natural.

Example 4: ref return from buffer

csharp
struct DefectRecord
{
    public int X;
    public int Y;
    public int Width;
    public int Height;
    public double Score;
}

sealed class DefectBuffer
{
    private readonly DefectRecord[] _records;

    public DefectBuffer(int capacity)
    {
        _records = new DefectRecord[capacity];
    }

    public ref DefectRecord GetRecord(int index) => ref _records[index];
}

Usage:

csharp
var buffer = new DefectBuffer(1024);

ref DefectRecord record = ref buffer.GetRecord(10);
record.X = 120;
record.Y = 80;
record.Score = 0.97;

The caller modifies the actual stored record in place.

Example 5: ref local in a loop

csharp
static void NormalizeScores(DefectRecord[] records, double scale)
{
    for (int i = 0; i < records.Length; i++)
    {
        ref DefectRecord record = ref records[i];
        record.Score *= scale;
    }
}

This can be useful if DefectRecord is large enough that you want to avoid repeated copying while mutating fields.

In a tiny struct, this may not matter much. In a large struct, in a hot loop, it can.


12. Trade-offs

Performance vs readability

This is the central trade-off.

By-ref code can absolutely improve performance in the right places.

But it also makes the code less ordinary.

That means:

  • harder onboarding
  • more care needed in review
  • greater chance of misuse
  • more subtle bugs

So the gain needs to justify the complexity.

Flexibility vs safety

Returning a copy is safer.

Returning a reference is more flexible and more efficient.

But safety usually comes from limiting how much raw storage access you expose.

So many good systems keep ref-heavy techniques internal and expose cleaner APIs above them.

Local optimization vs system-wide clarity

A common failure mode is optimizing small internals at the cost of overall architecture clarity.

A great senior engineer avoids that.

They isolate low-level performance code in:

  • specialized components
  • internal libraries
  • clearly named hot-path helpers
  • well-benchmarked sections

while leaving the rest of the system simple.

That is usually the best balance.


13. Senior engineer mental model

Here is the real mental model.

Think in terms of data movement

Experienced engineers do not think only about “allocation.”

They think about:

  • where data lives
  • how often it is copied
  • whether the copy is necessary
  • whether mutation is local or shared
  • whether the cost repeats in hot loops

That is a much more mature way to reason about performance.

Ask whether copying actually matters

Do not optimize every copy.

Ask:

  • How large is the value?
  • How often is it passed?
  • Is this code on a hot path?
  • Is profiling showing time here?
  • Is copying contributing to cache pressure or throughput loss?
  • Is the code already hard enough without adding by-ref complexity?

That decision process matters more than memorizing syntax.

Use these features sparingly and intentionally

Good engineers do not “sprinkle ref.”

They use it in the small part of the system where it has clear value:

  • hot loops
  • low-level buffers
  • parsers
  • image/data transforms
  • custom collections
  • numeric or geometry code

Everywhere else, they prefer normal C#.

Keep low-level optimizations isolated

This is the most important practical advice.

It is usually a mistake to make the whole codebase feel like systems code.

Instead:

  • isolate the hot path
  • optimize there
  • measure before and after
  • keep the rest of the application boring

That is what strong production engineers do.

They know that performance matters. But they also know that maintainability is part of performance over the life of a system.


Final practical summary

ref, in, out, ref locals, and ref returns are about controlling copies, mutation, and access to storage.

Use them when:

  • the code is performance-critical
  • you are working with large structs
  • repeated copying is real and measurable
  • you are building low-level infrastructure

Avoid them when:

  • the code is mostly business logic
  • readability is the main concern
  • the performance gain is theoretical
  • the team will struggle to maintain it

The mature mindset is:

  • default to normal C#
  • optimize where measurement justifies it
  • prefer isolated low-level usage
  • treat by-ref code as precision tooling, not a style preference

That is the interview-level answer, and also the production answer.

If you want, I can turn this into a Part 2 deep dive on how ref interacts with Span<T>, readonly struct, defensive copies, and JIT behavior, which is where this topic gets even more interesting in real .NET systems.

Docs-first project memory for AI-assisted implementation.