Ref-Readonly and In

The current version of C# (C# 13 at the time of writing) has in and ref keywords. In this post I investigate using these keywords as parameter modifiers. Specifically, the difference between ref readonly and the in parameter modifier.

Both parameter modifiers are well-suited for methods that have large struct parameters. Regular struct parameters follow copy-by-value semantics. Larger struct copies incur a performance penalty as the larger the struct is the more data has to be copied. I investigated the cost of copies in a post about inline arrays. One approach to address this problem is by passing a reference to the struct parameters.

In modifier

An author of a method could use the in parameter modifier for value type parameters. In the following example as RegularStruct is passed to a method named PassByIn, with the in modifier:

var regularValue = new RegularStruct(1);
ref readonly var result = ref PassByIn(regularValue);

[MethodImpl(MethodImplOptions.NoInlining)]
ref readonly int PassByIn(in RegularStruct value) =>
    ref value._field;

struct RegularStruct(int value)
{
    public int _field = value;
    
    [MethodImpl(MethodImplOptions.NoInlining)]
    public void Increment()
    {
        _field++;
    }
}

The callsite does not need to declare this modifier, and the compiler will pass regularValue as a reference avoiding the copy. If the struct was modified by the PassByIn method, the compiler would execute it on a copy of the original value. In the following example the value printed on the console shows 1:

var regularValue = new RegularStruct(1);
var result = PassByIn(regularValue);
Console.WriteLine(result);

[MethodImpl(MethodImplOptions.NoInlining)]
ref readonly int PassByIn(in RegularStruct value)
{
    value.Increment();
    return ref value._field;
}

The generated assembly code shows how the defensive copy is made prior to invoking the Increment method.

Program.<<Main>$>g__PassByIn|0_0(RegularStruct ByRef)
    L0000: push rbx
    L0001: sub rsp, 0x30
    L0005: xor eax, eax
    L0007: mov [rsp+0x28], rax
    L000c: mov rbx, rcx
    L000f: mov ecx, [rbx]
    L0011: mov [rsp+0x28], ecx
    L0015: lea rcx, [rsp+0x28]
    L001a: call 0x00007fff59e90048
    L001f: mov rax, rbx
    L0022: add rsp, 0x30
    L0026: pop rbx
    L0027: ret

RegularStruct.Increment()
    L0000: inc dword ptr [rcx]
    L0002: ret

Note that, omitting [MethodImpl(MethodImplOptions.NoInlining)] attribute would generate more efficient code, as the JIT can eliminate the copy and the call to the Incremenet method as well.

Ref-Readonly modifier

Replacing the in parameter modifier with ref readonly generates the same assembly code with the same behavior. The C# compiler issues a warning for the callsite of the PassByRefRO method indicating that ref modifier should be used for the ref parameter, as shown below.

var regularValue = new RegularStruct(1);

// When the 👇 ref keyword is omitted a compiler warning is generated:
// warning CS9192: Argument 1 should be passed with 'ref' or 'in' keyword 
ref readonly var result = ref PassByRefRO(ref readonlyValue);
Console.WriteLine(result);

[MethodImpl(MethodImplOptions.NoInlining)]
ref readonly int PassByRefRO(ref readonly RegularStruct value)
{
    value.Increment();
    return ref value._field;
}

Readonly structs

The ref and in parameters avoid the copy at the method invocation, but the Increment method call results in a defensive copy. This negates the previous performance gains. To better address this problem, a developer could declare the struct type as readonly. This way the compiler validates that the type is immutable.

readonly struct ReadOnlyStruct(int value)
{
    public readonly int _field = value; // 👈 must be readonly
    //public void Increment()
    //{
        //_field++; 👈 not allowed
    //}
}

Adding the previous Increment method as a member to this type results in a compilation error: error CS0191: A readonly field cannot be assigned to (except in a constructor or init-only setter of the type in which the field is defined or a variable initializer)

This way unintentional copies can be prevented at compile time:

var readonlyValue = new ReadOnlyStruct(1);
ref readonly var result = ref PassByRefRO(ref readonlyValue);
Console.WriteLine(result);

[MethodImpl(MethodImplOptions.NoInlining)]
ref readonly int PassByRefRO(ref readonly ReadOnlyStruct value) =>
    return ref value._field;

readonly struct ReadOnlyStruct(int value)
{
    public readonly int _field = value;
}

Warnings and Errors at callsite

The runtime behavior of in and ref-readonly parameters are very close. Yet at compile the C# language differentiates them:

  • ref readonly parameters issue a warning to use ref or in modifiers at the callsite

  • ref readonly parameters with ref modifier issues a compiler error when passing rvalues

For example, the following expression PassByRefRO(ref new ReadOnlyStruct(1)); generates a compiler error: error CS1510: A ref or out value must be an assignable variable

Conclusion

After examining ref readonly and in parameter modifiers, we can conclude:

  • Both modifiers help avoid unnecessary copying of large value types by passing them by reference

  • The runtime behavior is nearly identical for both approaches

  • Key differences appear at compile time:

    • in parameters don't require modifiers at the callsite

    • ref readonly parameters enforces ref indication at callsite by generating warnings if ref or in modifiers are missing

    • ref readonly with ref modifier at the callsite prevents using rvalues (temporary values) as arguments

  • Using readonly struct provides additional compile-time safety by preventing unintended modifications and avoiding defensive copies

For a complete reference of modifier combinations and their behavior, consult the official documentation.