更改 属性 的不可变类型

Change property of immutable type

我在一个临时的 CQRS 读取存储中存储了不可变类型(query/read 方面,实际上是通过一个带有抽象访问层的简单列表实现的,我不想在这里使用一个完整的文档数据库观点)。这些阅读商店包含如下项目:

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() 的东西,但我 think/hope 那里有更好的东西,性能好且易于使用,我不想写太多重复的代码(懒惰的程序员)。有什么建议可以改进上面的代码吗? SomeItem class 需要保持不变(通过几个不同的线程传输)。

您要查找的通常称为 with 运算符:

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

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

其他 C# 开发人员也缺少此功能,这就是为什么 someone wrote a Fody extension to do exactly that:

这是另一种方法,它手动实现 UpdateWith 方法,但需要 Option<T> 帮助程序 class。 更详细地描述了这种方法:

如果您想做的是(如您评论的那样)更新现有对象的名称,则只读 属性 可能是糟糕的设计。 否则,如果它确实是您要创建的新对象,您可能希望 class 使用 'Dispose' 方法实现一些接口。

遗憾的是,C# 中没有简单的方法。 F# 有 with 关键字,你可以看看 lenses,但在 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 的原则。

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

With-expressions When working with immutable data, a common pattern is to create new values from existing ones to represent a new state. For instance, if our person were to change their last name we would represent it as a new object that’s a copy of the old one, except with a different last name. This technique is often referred to as non-destructive mutation. Instead of representing the person over time, the record represents the person’s state at a given time. To help with this style of programming, records allow for a new kind of expression; the with-expression:

News in C#9

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

Records At the core of classic object-oriented programming is the idea that an object has strong identity and encapsulates mutable state that evolves over time. C# has always worked great for that, But sometimes you want pretty much the exact opposite, and here C#’s defaults have tended to get in the way, making things very laborious.

Recods in C#9

简单的解决方案

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

我建议一个简单且 low-cost 的方法:

  • 向 class 添加一个复制构造函数;
  • 使在克隆期间更改的属性可用于初始化;
  • 通过具有初始化列表的复制构造函数克隆具有更改的对象:
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;
    }
}

扩展解决方案

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

注意:有些地方翻译不正确,敬请谅解。使用google.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