更改不可变类型的属性

本文关键字:属性 类型 不可变 | 更新日期: 2023-09-27 18:00:16

我已经在临时CQRS读取存储中存储了不可变类型(查询/读取端,实际上是由一个带有抽象访问层的简单列表实现的,我现在不想使用完整的文档数据库)。这些读取存储包含以下项目:

public class SomeItem
{
    private readonly string name;
    private readonly string description;
    public SomeItem(string name, string description)
    {
        this.name = name;
        this.description = description;
    }
    public string Name
    {
        get { return this.name; }
    }
    public string Description
    {
        get { return this.description; }
    }
}

现在我想更改名称,并在第二个命令中更改描述。这些更改应该保持当前状态,这意味着上面的例子:

// initial state
var someItem = new SomeItem("name", "description");
// update name -> newName
someItem = new SomeItem("newName", someItem.Description);
// update description -> newDescription
someItem = new SomeItem(someItem.Name, "newDescription");

如果你有几个属性,这看起来确实很容易出错。。。你必须设法保持当前状态。我可以在每种类型中添加一些类似Clone()的东西,但我认为/希望有更好的东西可以很好地执行并且易于使用,我不想写太多重复代码(懒惰的程序员)。有什么建议可以改进上面的代码吗?SomeItem类需要保持不变(通过几个不同的线程传输)。

更改不可变类型的属性

使用C#9,我们得到了用于此目的的With运算符。

   public record Car
    {
        public string Brand { get; init; }   
        public string Color { get; init; }    
    }
    var car = new Car{ Brand = "BMW", Color = "Red" }; 
    var anotherCar = car with { Brand = "Tesla"};

使用表达式处理不可变数据时,常见的模式是从现有值创建新值以表示新状态。对于例如,如果我们的人要更改他们的姓氏,我们会将其表示为旧对象的副本的新对象,除了一个不同的姓氏。这种技术通常被称为非破坏性突变。而不是代表当事人时间,记录代表了一个人在给定时间的状态。到在这种编程风格的帮助下,记录允许一种新的表示带有表达式:

C#9 中的新闻

注意只有记录支持With运算符。

记录经典面向对象编程的核心思想是对象具有强身份并封装可变状态随着时间的推移而演变。C#在这方面一直做得很好,但是有时你想要的恰恰相反,而这里的C#违约往往会成为阻碍,使事情变得非常艰难。

C#9 中的记录

遗憾的是,在C#中没有简单的方法。F#有with关键字,你可以看看镜头,但在C#中这一切都有点乏味。我能给你的最好的东西是这样的:

class SomeItem
{
  private readonly string name;
  private readonly string description;
  public SomeItem(string name, string description)
  {
    this.name = name;
    this.description = description;
  }
  public SomeItem With
    (
      Option<string> name = null,
      Option<string> description = null
    )
  {
    return new SomeItem
      (
        name.GetValueOrDefault(this.name), 
        description.GetValueOrDefault(this.description)
      );
  }
}

这允许您进行类似的更新

var newItem = oldItem.With(name: "My name!");

我在扩展方法和T4中使用了这种方法,效果很好,但即使手动编写代码,它也相当可靠——如果添加新字段,也必须将其添加到With中,因此效果非常好。

如果您愿意容忍运行时代码生成和降低类型安全性,还有其他一些方法,但这有点违背IMO.

您要查找的通常被称为带运算符

// returns a new immutable object with just the single property changed
someItem = { someItem with Name = "newName" };

不幸的是,与F#不同,C#没有这样的运算符(还没有?)。

其他C#开发人员也缺少这个功能,这就是为什么有人写了一个Fody扩展来做到这一点:

  • https://github.com/mikhailshilkov/With.Fody

这里有另一种方法,它手动实现UpdateWith方法,但需要Option<T>辅助类。Luaan的回答更详细地描述了这种方法:

  • 用C语言实现F#启发的不可变类的"with"更新#

简单解决方案

我也想过这个问题。记录不适合我的目的,因为有必要与EF Core互动。

我建议一种简单而低成本的方法:

  • 向类中添加一个复制构造函数
  • 使克隆过程中更改的属性可用于初始化
  • 通过带有初始化列表的复制构造函数克隆具有更改的对象:
var a = new SomeItem("name", "abracadabra");
var b = new SomeItem(a) {Description="descr"};

简单代码

var a = new SomeItem("name", "abracadabra");
var b = new SomeItem(a) {Description="descr"};
public class SomeItem
{
    private string name;
    private string description;
    public SomeItem(string name, string description)
    {
        Name = name;
        Description = description;
    }
    public SomeItem(SomeItem another): this(another.Name, another.Description)
    {
    }
    public string Name
    {
        get => name;
        init => name = value;
    }
    public string Description
    {
        get => description;
        init => description = value;
    }
}

扩展解决方案

如果在编译时不知道最终类型,那么这种方法很容易扩展。假设有一个类"ValueObject;我们需要克隆其派生类型。

注意:对于某些地方的翻译不正确,我深表歉意。使用谷歌获得英文版。translate

附加代码

using System.Linq.Expressions;
using Light.GuardClauses;
using JetBrains.Annotations;
using static DotNext.Linq.Expressions.ExpressionBuilder;
using ValueObject = Company.Domain....;

/// <summary>
/// The plagiarizer creates a copy of the object with a change in its individual properties using an initializer
/// </summary>
/// <remarks> The foreign object must define a copy constructor, and mutable members must support initialization </remarks>
public struct Plagiarist {
    /// <summary>
    /// Object to be copied
    /// </summary>
    private readonly object _alienObject;
    /// <summary>
    /// Type <see cref="_alienObject" />
    /// </summary>
    private Type _type => _alienObject.GetType();
    /// <summary>
    /// Object parsing Expression
    /// </summary>
    private readonly ParsingInitializationExpression _parser = new();
    public Plagiarist(object alienObject) {
        _alienObject = alienObject.MustNotBeNullReference();
        if (!CopyConstructorIs())
            throw new ArgumentException($"Type {_type.FullName} must implement a copy constructor");
    }
    /// <summary>
    /// Does the object we want to plagiarize have a copy constructor?
    /// </summary>
    /// <returns>True - there is a copy constructor, otherwise - false</returns>
    [Pure]
    private bool CopyConstructorIs() {
        return _type.GetConstructor(new[] { _type }) is not null;
    }
    /// <summary>
    /// Returns a copy of a foreign object with a change in its individual properties using an initializer
    /// </summary>
    /// <param name="initializer">
    /// <see cref="Expression" /> create an object with initialization of those fields,
    /// which need to be changed:
    /// <code>() => new T() {Member1 = "Changed value1", Member2 = "Changed value2"}</code>
    /// or <see cref="Expression" /> create an anonymous type with initialization of those fields
    /// that need to be changed:
    /// <code>() => new {Member1 = "Changed value1", Member2 = "Changed value2"}</code>
    /// </param>
    /// <returns></returns>
    [Pure]
    public object Plagiarize(Expression<Func<object>> initializer) {
        var (newValues, constructParam) = _parser.ParseInitialization(initializer);
        var constrCopies = _type.New(_alienObject.Const().Convert(_type));
        Expression plagiarist = (newValues.Count, constructParam.Count) switch {
            (> 0, _) => Expression.MemberInit(constrCopies, newValues.Values),
            (0, > 0) => Expression.MemberInit(constrCopies, ConstructorInInitializationList(constructParam).Values),
            _ => constrCopies
        };
        var plagiarize = Expression.Lambda<Func<object>>(plagiarist).Compile();
        return plagiarize();
    }
    [Pure]
    public Dictionary<string, MemberAssignment> ConstructorInInitializationList(
        Dictionary<string, Expression> constructorParameters) {
        Dictionary<string, MemberAssignment> initializer = new();
        const BindingFlags flagReflections = BindingFlags.Default | BindingFlags.Instance | BindingFlags.Public;
        var allProperties = _type.GetProperties(flagReflections);
        var allFields = _type.GetFields(flagReflections);
        foreach (var memberName in constructorParameters.Keys) {
            var property = allProperties.FirstOrDefault(s => s.Name ==memberName);
            var field = allFields.FirstOrDefault(s => s.Name == memberName);
            (MemberInfo member, Type memberType) = (property, field) switch {
                ({ }, _) => (property, property.PropertyType),
                (null, { }) => ((MemberInfo)field, field.FieldType),
                _ => throw new ArgumentException($"{_type.FullName} does not contain member {memberName}")
            };
            initializer[memberName] = Expression.Bind(member, constructorParameters[memberName].Convert(memberType));
        }
        return initializer;
    }
    
    /// <summary>
    /// Template "Visitor" for traversing the expression tree in order to highlight
    /// initialization expression and constructor
    /// </summary>
    private class ParsingInitializationExpression : ExpressionVisitor {
        private Dictionary<string, MemberAssignment>? _initializer;
        private Dictionary<string, Expression>? _initializerAnonym;
        /// <summary>
        /// Parses the expression tree and returns the initializer and constructor parameters
        /// </summary>
        /// <param name="initializer"><see cref="Expression" /> to parse</param>
        /// <returns> tuple of initializer and constructor</returns>
        public ParsedInitialization ParseInitialization(Expression initializer) {
            _initializer = new Dictionary<string, MemberAssignment>();
            _initializerAnonym = new Dictionary<string, Expression>();
            Visit(initializer);
            return new ParsedInitialization(_initializer, _initializerAnonym);
        }
        protected override MemberAssignment VisitMemberAssignment(MemberAssignment node) {
            _initializer![node.Member.Name] = node;
            return base.VisitMemberAssignment(node);
        }
        protected override Expression VisitNew(NewExpression node) {
            foreach (var (member, value) in node.Members?.Zip(node.Arguments) ??
                                             Array.Empty<(MemberInfo First, Expression Second)>())
                _initializerAnonym![member.Name] = value;
            return base.VisitNew(node);
        }
        /// <summary>
        /// Type to return values from method <see cref="ParseInitialization" />
        /// </summary>
        /// <param name="Initializer"></param>
        /// <param name="ConstructorParameters"></param>
        public record struct ParsedInitialization(Dictionary<string, MemberAssignment> Initializer,
            Dictionary<string, Expression> ConstructorParameters);
    }
}
public static class ValueObjectPlagiarizer{
    /// <summary>
    /// Creates a copy of the object with a change in its individual properties using an initializer
    /// </summary>
    /// <param name="alien">Object to be plagiarized</param>
    /// <param name="initializer">
    /// <see cref="Expression" /> creating an object of type <typeparamref name="T" />
    /// with initialization of those fields that need to be changed:
    /// <code>ob.Plagiarize(() => new T() {Member1 = "Changed value1", Member2 = "Changed value2"})</code>
    /// or <see cref="Expression" /> create an anonymous type with initialization of those fields that need to be changed:
    /// <code>ob.Plagiarize(() => new {Member1 = "Changed value1", Member2 = "Changed value2"})</code>
    /// </param>
    /// <returns>plagiarism of the object</returns>
    public static object Plagiarize<T>(this ValueObject alien, Expression<Func<T>> initializer)
        where T : class {
        var bodyReduced = initializer.Convert<object>();
        var initializerReduced = Expression.Lambda<Func<object>>(bodyReduced, initializer.Parameters);
        return new Plagiarist(alien).Plagiarize(initializerReduced);
    }
} 

用法

如果SomeItem是ValueObject 的后代

ValueObject a = new SomeItem("name", "abracadabra");
// via type constructor
var b = (SomeItem)a.Plagiarize(()=>new SomeItem(null){Description="descr"});
// anonymous type 
var c = (SomeItem)a.Plagiarize(()=>new{Description="descr"});
b.Description.Should().Be("descr"); //true
c.Description.Should().Be("descr"); //true

如果您想要做的是(正如您所评论的)更新现有对象的名称,那么只读属性可能是糟糕的设计。否则,如果它真的是你想要创建的一个新对象,你可能希望你的类用"Dispose"方法实现一些接口。