Andi
Andi

Reputation: 3498

Ignoring specific fields when using "with" on a C# 9 record?

When creating a new instance of a C# 9 record by using the with keyword, I'd like to ignore some fields instead of copying them into the new instance too.

In the following example, I have a Hash property. Because it is very expensive in computation, it is only computed when needed and then cached (I have a deeply immutable record, so that hash will never change for an instance).

public record MyRecord {

   // All truely immutable properties
   public int ThisAndManyMoreComplicatedProperties { get; init; } 
   // ...

   // Compute only when required, but then cache it
   public string Hash {
      get {
         if (hash == null)
            hash = ComputeHash();
         return hash;
      }
   }

   private string? hash = null;
}

When calling

MyRecord myRecord = ...;
var changedRecord = myRecord with { AnyProp = ... };

changedRecord contains the hash value from myRecord, but what I want to have is the default value null again.

Any chance to mark the hash field as "transient"/"internal"/"reallyprivate"..., or do I have to write my own copy-constructor to mimic this feature?

Upvotes: 18

Views: 2246

Answers (6)

Mark Churchill
Mark Churchill

Reputation: 31

In the case that you don't want to (or can't) burn a base class - and you don't want the boilerplate of filling out a full copy constructor - the hack that I've settled on is having an additional field referencing the record the cache was created for. Then on get I can destroy the cache if cacheFor != this (as the default copy constructor will faithfully copy the reference), and set that var along with the cache.

This is not really recommended, as it has possible implications with serialization and definitely violates the Principle of Least Surprise. I imagine this could be abstracted into a Cache<T> record with GetFor(parent) and it might look a little cleaner - but honestly this is one of those situations where I didn't want to write out a full copy constructor, or encourage myself further down this stupidity.

Upvotes: 0

Andi
Andi

Reputation: 3498

I found a workaround for my problem. This does not solve the general problem, and it has another disadvantage: I have to cache the last state of the object, until the hash was recomputed. I understand this is a tradeoff between a potentially heavy computation and higher memory usage.

The trick is to remember the last object reference when the hash was computed. When calling the Hash property again, I check if meanwhile the object reference has been changed (i.e. if a new object was created).

public string Hash {
   get {
      if (hash == null || false == ReferenceEquals(this, hashRef)) {
         hash = ComputeHash();
         hashRef = this;
      }
      return hash;
   }
}
private string? hash = null;
private MyRecord? hashRef = null;

I'm still looking for a better solution.

EDIT: I recommend Heinzi's solution!

Upvotes: 2

Heinzi
Heinzi

Reputation: 172408

I found a workaround: You can (ab)use inheritance to split the copy constructor in two parts: A manual one only for hash (in the base class) and an auto-generated one in the derived class copying all your valuable data fields.

This has the additional advantage of abstracting away your hash (non-)caching logic. Here's a minimal example (fiddle):

abstract record HashableRecord
{
    protected string hash;
    protected abstract string CalculateHash();
    
    public string Hash 
    {
        get
        {
            if (hash == null)
            {
                hash = CalculateHash(); // do expensive stuff here
                Console.WriteLine($"Calculating hash {hash}");
            }
            return hash;
        }
    }
    
    // Empty copy constructor, because we explicitly *don't* want
    // to copy hash.
    public HashableRecord(HashableRecord other) { }
}

record Data : HashableRecord
{
    public string Value1 { get; init; }
    public string Value2 { get; init; }

    protected override string CalculateHash() 
        => hash = Value1 + Value2; // do expensive stuff here
}

public static void Main()
{
    var a = new Data { Value1 = "A", Value2 = "A" };
    
    // outputs:
    // Calculating hash AA
    // AA
    Console.WriteLine(a.Hash);

    var b = a with { Value2 = "B" };
    
    // outputs:
    // AA
    // Calculating hash AB
    // AB
    Console.WriteLine(a.Hash);
    Console.WriteLine(b.Hash);
}

Upvotes: 3

Guru Stron
Guru Stron

Reputation: 143068

As you can see with sharplab.io decompilation the with call is translated into <Clone>$() method call which internally calls copy constructor generated by compiler, so you need to define your own copy constructor to prevent Hash being called.

Also as the with keyword doc states:

If you need to customize the record copy semantics, explicitly declare a copy constructor with the desired behavior.

Upvotes: 0

Rafael Veronezi
Rafael Veronezi

Reputation: 104

I think the only built-in mechanism to allow this is the "copy constructor". As documented in this post:

A record implicitly defines a protected “copy constructor” – a constructor that takes an existing record object and copies it field by field to the new one...

The "copy constructor" is just a constructor that receives an instance of the same type of the record as an argument. If you just implement this constructor you can override the default behavior of the with expression. I've made a test based on your code, here's the record declaration:

public record MyRecord
{
    protected MyRecord(MyRecord original)
    {
        ThisAndMayMoreComplicatedProperties = original.ThisAndMayMoreComplicatedProperties;
        hash = null;
    }

    public int ThisAndMayMoreComplicatedProperties { get; init; }

    string? hash = null;
    public string Hash
    {
        get
        {
            if (hash is null)
            {
                Console.WriteLine("The stored hash is currently null.");
            }
            return hash ??= ComputeHash();
        }
    }

    string ComputeHash() => "".PadLeft(100, 'A');
}

Notice that when I call the property getter I check if the hash is null and print a message. Then I've made a little program to check:

var record = new MyRecord { ThisAndMayMoreComplicatedProperties = 100 };
Console.WriteLine($"{record.Hash}");

var newRecord = record with { ThisAndMayMoreComplicatedProperties = 200 };
Console.WriteLine($"{newRecord.Hash}");

If you run this you notice that both calls to Hash will print the message that the private hash is null. If you comment the copy constructor you'll see that only the first call prints the null.

So I think this solves your problem. The downside of this method is that you have to manually copy every property of your record, which can be pretty annoying. If you record has lots of properties you can use Reflection to iterate over then and copy only the ones you want. You may also define a custom Attribute to mark ignoring fields. But bear in my mind that using Reflection always have processing overhead.

Upvotes: 1

Slugsie
Slugsie

Reputation: 905

If I'm understanding you correctly you want to create a new MyRecord object with some of the properties of an existing MyRecord object?

I think something along these lines should work:

MyRecord myRecord = ...;
var changedRecord = new MyRecord with { AnyProp = myRecord.AnyProp... };

Upvotes: -3

Related Questions