Reputation: 3498
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
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
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
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
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
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
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