在C# 9的记录类型中,在使用“with”语句时如何忽略特定字段?

17
创建一个C# 9的record实例时,如果你想使用with关键字,你可以忽略一些字段,而不是将它们复制到新实例中。 在下面的示例中,我有一个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 字段标记为“短暂”/“内部”/“真正私有”...,或者我需要编写自己的复制构造函数来模仿这个特性?


5
我很确定在此情况下没有内置的解决方案,编写自己的复制构造函数是预期的解决方案。链接中有相关说明。 - Heinzi
我有点不太理解你的需求。当 with 克隆记录时,新记录将获得现有哈希值(它是一个克隆,所以应该具有相同的哈希值),而不是通过再次运行哈希来计算,而只是通过复制该值(因此很便宜)。但是,您的 AnyProp setter(因为它正在更改记录,因此也更改了哈希)肯定会再次将哈希设置为 null,因此下次请求时将重新计算它(考虑 AnyProp 的新值),并且复制构造函数将使用 AnyProp setter 为 AnyProp 提供新值,从而使哈希值变为 null。 - Caius Jard
@CaiusJard 有可能哈希值没有被计算。 - Guru Stron
1
但是OP说“changedRecord包含了myRecord的哈希值”,这只有在计算过/不为空的情况下才能合理地实现? - Caius Jard
@CaiusJard 哈希只是一个使用案例,而不是这个问题的主题。在我的情况下,我对记录的紧凑JSON序列化表示进行SHA256哈希计算(不包括哈希属性本身)。 - Andi
显示剩余3条评论
5个回答

3

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

这种方法的另一个优点是抽象出了您的哈希(非)缓存逻辑。以下是一个最小示例 (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);
}

我不会说这是继承的“滥用”。将哈希抽象化确实是一种优势。 - Andi
这是一个测试用例的示例:https://dotnetfiddle.net/usoupq - Andi
@Andi:很好,感谢您详细说明! - Heinzi
我在这个实现中遇到了一个错误。自动生成的Equals(HashableRecord?)不能直接使用,我猜测是因为哈希字段也被检查了,而这可能还没有设置。解决方法似乎是通过覆盖Equals(HashableRecord)来实现: “public virtual bool Equals(HashableRecord?other)=> other!= null;” (只返回true也可以,但我的Newtonsoft.JSON序列化器会抛出NullReferenceException)。另外,“public override int GetHashCode()=> 0;” - Andi

2
我找到了解决我的问题的方法。这并没有解决一般性的问题,而且它还有另一个缺点:我必须缓存对象的最后状态,直到哈希值被重新计算。我理解这是在潜在的重计算和更高的内存使用之间做出的权衡。
诀窍是记住当计算哈希值时上一个对象的引用。再次调用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;

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

编辑:我推荐使用Heinzi的解决方案


0

正如您在sharplab.io反编译中所看到的,with调用被翻译成<Clone>$()方法调用,该方法内部调用编译器生成的复制构造函数,因此您需要定义自己的复制构造函数以防止调用Hash

正如文档中所述,使用with关键字:

如果您需要自定义记录副本语义,请显式声明具有所需行为的复制构造函数。


0

我认为唯一内置的允许此操作的机制是“复制构造函数”。正如this post中所述:

记录隐式定义了受保护的“复制构造函数”——一个构造函数,它接收一个现有的记录对象,并逐个字段地将其复制到新记录中...

“复制构造函数”只是一个接收相同类型记录实例作为参数的构造函数。如果您实现了这个构造函数,就可以覆盖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 时,我会检查哈希是否为空并打印一条消息。然后我编写了一个小程序进行检查:
var record = new MyRecord { ThisAndMayMoreComplicatedProperties = 100 };
Console.WriteLine($"{record.Hash}");

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

如果你运行它,你会注意到对Hash的两个调用都会打印出私有变量hash为null的消息。如果你注释掉复制构造函数,你会发现只有第一个调用打印了null。

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


-3

如果我理解你的意思正确,你想要创建一个新的MyRecord对象,并使用现有MyRecord对象的一些属性?

我认为以下代码应该可以实现:

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接