Search code examples
c#structpropertiesfielddefensive-copy

Avoiding defensive copies in readonly record structs


Let's say we have following readonly record struct definition

public readonly record struct S(int A, int B)
{
   
}

Accessing A and B will result in a defensive copy since in a record the positional parameters will be auto-implemented as properties.

We could avoid it by modifying the definition like this

public readonly record struct S(int A, int B)
{
   public readonly int A = A;
   public readonly int B = B;
}

In this case A and B are fields, hence no side effects on access, hence no defensive copies. From my tests and understanding in both definitions the compiler synthesizes the same record specific code(Equality, HashCode generation, etc...).

I am wondering if there is a better way to avoid defensive copies with records. Definition of all the readonly fields can be cumbersome and potentially error prone. Is there any C# Syntax to achieve what I am aiming to do here or are the readonly fields the best I can do?

Also if my understanding of defensive copies and record code synthesis in this example is wrong please explain what I didn't get right.

Edit:

The Measurement:

const int SIZE = 10000;

var sw = new Stopwatch();
A[] aArray = new A[SIZE];
M[] mArray = new M[SIZE];
for(int repeat = 0; repeat < SIZE; repeat++) aArray[repeat] = new A(new(new(new(new(new(new(new(new(new(new(new(Random.Shared.Next()))))))))))));
for(int repeat = 0; repeat < SIZE; repeat++) mArray[repeat] = new M(new(Random.Shared.Next()));
int i = 0;
sw.Start();
for(int repeat = 0; repeat < SIZE; repeat++) i =+ aArray[repeat].B.C.D.E.F.G.H.I.J.K.L.I;
sw.Stop();
System.Console.WriteLine(i);
System.Console.WriteLine(sw.Elapsed);
sw.Reset();
sw.Start();
for(int repeat = 0; repeat < SIZE; repeat++) i =+ mArray[repeat].N.I;
sw.Stop();
System.Console.WriteLine(i);
System.Console.WriteLine(sw.Elapsed);

readonly record struct A(B B){public readonly B B = B;}
readonly record struct B(C C){public readonly C C = C;}
readonly record struct C(D D){public readonly D D = D;}
readonly record struct D(E E){public readonly E E = E;}
readonly record struct E(F F){public readonly F F = F;}
readonly record struct F(G G){public readonly G G = G;}
readonly record struct G(H H){public readonly H H = H;}
readonly record struct H(I I){public readonly I I = I;}
readonly record struct I(J J){public readonly J J = J;}
readonly record struct J(K K){public readonly K K = K;}
readonly record struct K(L L){public readonly L L = L;}
readonly record struct L(int I){public readonly int I = I;}
readonly record struct M(N N){public readonly N N = N;}
readonly record struct N(int I){public readonly int I = I;}

readonly record struct A(B B){}
readonly record struct B(C C){}
readonly record struct C(D D){}
readonly record struct D(E E){}
readonly record struct E(F F){}
readonly record struct F(G G){}
readonly record struct G(H H){}
readonly record struct H(I I){}
readonly record struct I(J J){}
readonly record struct J(K K){}
readonly record struct K(L L){}
readonly record struct L(int I){}
readonly record struct M(N N){}
readonly record struct N(int I){}

I don't know what happened but know I redid the measurement and got similar values in both cases.

To run it just comment out the other definitions. The reason why I nested so many structs in structs is that I wanted to make sure that access of a field of a struct in a field of another struct doesn't make a copy as well.

I am sorry for the confusion. Apparently I did something wrong the first time around. There should not be any defensive copies in both cases.


Solution

  • There are no defensive copies here; readonly records are marked [IsReadOnly], as are get-only properties - which tells the compiler enough that they aren't needed; you can see this in the IL, for example if we do:

    using System;
    
    void NoCopies(in S obj)
    {   // no copies because the *type* is [IsReadOnly]
        Console.WriteLine(obj.A);
        Console.WriteLine(obj.B);
    }
    
    void AlsoHasNoCopies(in T obj)
    {   // no copies because the *members* are [IsReadOnly]
        Console.WriteLine(obj.A);
        Console.WriteLine(obj.B);
    }
    
    void StillNoCopies(in LazyManual obj)
    {   // no copies because the *members* are [IsReadOnly]
        Console.WriteLine(obj.A);
        Console.WriteLine(obj.B);
    }
    
    
    void DoesHaveCopies(in BasicManual obj)
    {   // finally, we have defensive copies
        Console.WriteLine(obj.A);
        Console.WriteLine(obj.B);
    }
    
    
    public readonly record struct S(int A, int B)
    {
       
    }
    
    public record struct T(int A, int B) // not marked readonly
    {
       
    }
    
    public struct LazyManual
    {
        public int A {get;} // auto-marked [IsReadOnly]
        public int B {get;}
    }
    
    public struct BasicManual
    {
        private int _a, _b;
        public int A => _a; // not auto-marked [IsReadOnly]
        public int B => _b;
    }
    

    And compile/decompile this, we see:

    [CompilerGenerated]
    internal class Program
    {
        private static void <Main>$(string[] args)
        {
        }
    
        [CompilerGenerated]
        internal static void <<Main>$>g__NoCopies|0_0([In][IsReadOnly] ref S obj)
        {
            Console.WriteLine(obj.A);
            Console.WriteLine(obj.B);
        }
    
        [CompilerGenerated]
        internal static void <<Main>$>g__AlsoHasNoCopies|0_1([In][IsReadOnly] ref T obj)
        {
            Console.WriteLine(obj.A);
            Console.WriteLine(obj.B);
        }
    
        [CompilerGenerated]
        internal static void <<Main>$>g__StillNoCopies|0_2([In][IsReadOnly] ref LazyManual obj)
        {
            Console.WriteLine(obj.A);
            Console.WriteLine(obj.B);
        }
    
        [CompilerGenerated]
        internal static void <<Main>$>g__DoesHaveCopies|0_3([In][IsReadOnly] ref BasicManual obj)
        {
            BasicManual basicManual = obj;
            Console.WriteLine(basicManual.A);
            basicManual = obj;
            Console.WriteLine(basicManual.B);
        }
    }
    

    Only the last option of a manually implemented type that is not readonly and uses manual properties: has defensive copies.


    For full context, we have:

    [IsReadOnly]
    public struct S : IEquatable<S>
    { ... }
    
    public struct T : IEquatable<T>
    {
        // ...
        public int A
        {
            [IsReadOnly]
            [CompilerGenerated]
            get
            {
                return <A>k__BackingField;
            }
            [CompilerGenerated]
            set
            {
                <A>k__BackingField = value;
            }
        }
    
        public int B
        {
            [IsReadOnly]
            [CompilerGenerated]
            get
            {
                return <B>k__BackingField;
            }
            [CompilerGenerated]
            set
            {
                <B>k__BackingField = value;
            }
        }
    }
    
    public struct LazyManual
    {
        [CompilerGenerated]
        private readonly int <A>k__BackingField;
    
        [CompilerGenerated]
        private readonly int <B>k__BackingField;
    
        public int A
        {
            [IsReadOnly]
            [CompilerGenerated]
            get
            {
                return <A>k__BackingField;
            }
        }
    
        public int B
        {
            [IsReadOnly]
            [CompilerGenerated]
            get
            {
                return <B>k__BackingField;
            }
        }
    }
    
    public struct BasicManual
    {
        private int _a;
    
        private int _b;
    
        public int A
        {
            get
            {
                return _a;
            }
        }
    
        public int B
        {
            get
            {
                return _b;
            }
        }
    }