在 C# 9 记录上使用 "with" 时忽略特定字段?

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

在使用 with 关键字创建 C# 9 record 的新实例时,我想忽略一些字段,而不是将它们也复制到新实例中。

在下面的例子中,我有一个Hash属性。因为它在计算上非常昂贵,所以它只在需要时计算然后缓存(我有一个深度不可变的记录,所以哈希永远不会为一个实例改变)。

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;
}

调用时

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

changedRecord 包含来自 myRecordhash 值,但我想要的是再次使用默认值 null

是否有机会将 hash 字段标记为“transient”/“internal”/“reallyprivate”...,或者我是否必须编写自己的复制构造函数来模仿此功能?

如果我的理解正确的话,您想使用现有 MyRecord 对象的某些属性创建一个新的 MyRecord 对象吗?

我认为按照这些思路应该可行:

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

如您所见,使用 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 可以防止 Hash 被调用。

也如 with 关键字 doc 所述:

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

我认为允许这样做的唯一内置机制是“复制构造函数”。如 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...

“复制构造函数”只是一个构造函数,它接收与记录类型相同的实例作为参数。如果您只是实现此构造函数,则可以覆盖 with 表达式的默认行为。我已经根据您的代码进行了测试,这是记录声明:

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');
}

请注意,当我调用 属性 getter 时,我会检查 hash 是否为空并打印一条消息。然后我做了一个小程序来检查:

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

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

如果您 运行 这样做,您会注意到对 Hash 的两次调用都将打印出私有 hash 为空的消息。如果您评论复制构造函数,您会看到只有第一次调用打印 null。

所以我认为这可以解决您的问题。这种方法的缺点是您必须手动复制每个 属性 记录,这可能非常烦人。如果你的记录有很多属性,你可以使用反射来迭代然后只复制你想要的。您还可以定义自定义 Attribute 来标记忽略字段。但请记住,使用反射总是有处理开销。

我找到了解决问题的方法。这 不能 解决一般问题,它还有另一个缺点:我必须缓存对象的最后状态,直到重新计算哈希。我知道这是潜在的繁重计算和更高内存使用之间的权衡。

诀窍是在计算散列时记住最后一个对象引用。再次调用 Hash 属性 时,我同时检查对象引用是否已更改(即是否创建了新对象)。

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;

我仍在寻找更好的解决方案。

编辑:我推荐

我找到了一个解决方法:您可以(滥用)使用继承将复制构造函数分成两部分:手动的仅用于 hash(在基础 class 中)和自动的在派生中生成一个 class 复制所有有价值的数据字段。

这还有一个额外的好处,就是可以抽象出您的散列(非)缓存逻辑。这是一个最小的例子 (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);
}