65.9K
CodeProject 正在变化。 阅读更多。
Home

通过表达式树进行快速深度复制(C#)

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (61投票s)

2016年7月12日

MIT

7分钟阅读

viewsIcon

163408

downloadIcon

6166

使用 Linq.Expressions 实现的深拷贝代码。包含测试和测试类。

引言

深拷贝C# 中并未原生实现,每个对象只有 Object.MemberwiseClone() 函数,它只能创建浅拷贝。本文提供了使用表达式树 (Linq.Expressions) 实现的非常快速的深拷贝函数的代码。

背景

通常,深拷贝函数可以通过序列化轻松实现,通过反射需要中等程度的努力,而通过表达式树则需要大量的努力。序列化速度非常慢,并且可能存在一些额外的限制(例如,BinaryFormatter 需要 Serializable 属性,XmlSerializer 不维护循环引用)。反射速度是序列化的 5 倍,但仍然比手动实现的拷贝函数慢得多。表达式树的速度与手动实现的拷贝函数相同,因此是最快的

有关通过序列化和反射实现的优秀代码,请参阅文章底部的替代方案链接部分。

速度比较

速度比较是使用“替代方案链接”部分提到的序列化和反射代码进行的。使用的对象是 ModerateClass(包含在 zip 文件中)。

精确数字的表格如下

复制项数110100100010000100000
序列化 (毫秒)6729189149612969
反射 (毫秒)5510362422432
表达式树 (毫秒)1917192069570

为什么表达式树如此之快?

表达式树使开发人员能够在运行时以通用方式创建函数和方法。它与反射密切相关,并且几乎具有相同的可能性(例如,可以访问或赋值隐藏的成员,不需要 public 构造函数等)。与反射的区别在于,您需要编写更长的代码,并且表达式树创建的方法在使用前需要进行编译。运行时编译需要一些时间(这解释了为什么表达式树在复制对象数量较少时,存在一个固定的性能开销)。但是,如果程序重用了“运行时编译”的方法,它的速度就与编译后的源代码一样快。

优点和局限性

代码的优点列于下

  • 复制公共私有成员
  • 适用于只读字段和属性
  • (新增) 适用于结构体
  • 也适用于循环引用
  • 具有多个引用的对象仅复制一次
  • 复制继承的类型
  • 复制泛型类型
  • 在多线程中运行良好
  • 适用于没有无参构造函数的类
  • 类不需要 Serializable 属性

代码的局限性

  • 适用于 .NET 4 及更高版本 (C# 4.0 及更高版本)
  • 浅拷贝 struct(用户定义的包含类字段的 struct 将有问题)
  • 不复制委托和事件(而是留下 null
  • ComObjects 失效(例如,某些 WPF Dispatcher 子对象或 Excel Interop)
  • 对任何非托管对象(例如,来自某些外部 C++ 库)失效

如何使用代码

将文件 DeepCopyByExpressionTrees.cs 复制到您的解决方案中的某个位置。

然后,您可以将深拷贝作为扩展方法应用于任何对象

var copy = original.DeepCopyByExpressionTree();

或者直接在对象上实现 DeepCopy 方法

class ExampleClass
{
    public ExampleClass DeepCopy()
    {
        return this.DeepCopyByExpressionTree();
    }
}

实现说明

如果您不关心代码的技术细节,可以跳过本节。

主要方法和引用字典

泛型 public static 方法 T DeepCopyByExpressionTree<T>(this T original,...) 调用非泛型 private 方法 object DeepCopyByExpressionTreeObj(object original,...),并将一个新创建的类型为 Dictionary<object, object> 的引用字典传递给它。引用字典(或拷贝字典)存储原始类及其对应的副本 - 它们基于 ReferenceEquals 比较进行重用,因此我们避免了对同一对象的多次复制或循环引用问题。

创建和存储已编译的 Lambda 函数

对于每种类类型,都会创建一个特定的动态深拷贝函数。它通过函数 GetOrCreateCompiledLambdaCopyFunction(Type type) 创建(或检索),该函数是线程安全的,并将已编译的函数存储到 static 字典 Dictionary<Type, Func<...>> CompiledCopyFunctionsDictionary 中。

拷贝函数的工作流程

类型特定的深拷贝 Lambda 函数是按照以下工作流程创建的

  1. 首先使用 MemberwiseClone 函数复制对象。
  2. 对于class,副本存储在引用字典中,键为原始对象。
  3. 需要深拷贝的字段通过反射获取(注意继承关系),并逐一再次通过 DeepCopyByExpressionTreeObj 复制,这意味着对象 Obj1 的已编译 Lambda 函数会调用其子对象 Obj1.Obj2 的(不同的)已编译 Lambda 函数。
  4. Delegate 字段和 event 被设置为 null
  5. 对于类或接口数组,通过动态创建的 for 循环为每个索引设置的 copy 函数会被调用。值类型数组保持不变。

需要深拷贝字段的 FieldInfo仅在编译拷贝函数之前获取一次:因此不会减慢已编译函数的执行速度。

注意 (需要深拷贝的字段): 这些是类型为非值类型或非原始类型的字段。具体来说,排除了 stringenumDecimal 类型。通过这种方式,我们获取了所有 class(除了 string)和 struct(除了基本值类型)。此外,从深拷贝中还排除了不包含任何 classinterface 层次结构的 struct 类型。

注意 2 (属性): 我们根本不关心属性 - 每个属性都有其声明的或自动生成的字段,因此仅复制字段就足够了。

注意 3 (只读字段): 只读字段是唯一会减慢复制速度的因素,因为表达式树在这里不起作用,并且需要重复调用反射。 点击此处了解更多

表达式树是如何参与的

通过表达式树,我们动态地模仿常规代码(与反射一样)。我通过展示一个调用 MemberwiseClone、类型转换和创建新对象的赋值的小示例代码来说明这个过程。

常规代码中,我们会轻松地写一行

ExampleClass output = (ExampleClass)input.MemberwiseClone();

但这段代码只能在 ExampleClass 内部使用,因为 MemberwiseClone 是受保护的。

使用反射,代码将是

MethodInfo MemberwiseCloneMethod = 
    typeof(Object).GetMethod("MemberwiseClone", BindingFlags.Instance | BindingFlags.NonPublic);

ExampleClass output = (ExampleClass)MemberwiseCloneMethod.Invoke(input, null);

这已经可以从 class 外部调用了。

而使用表达式树,我们编写

ParameterExpression inputParameter = Expression.Parameter(typeof(ExampleClass));
ParameterExpression outputParameter = Expression.Parameter(typeof(ExampleClass));

MethodInfo MemberwiseCloneMethod = 
    typeof(Object).GetMethod("MemberwiseClone", BindingFlags.Instance | BindingFlags.NonPublic);

Expression lambdaExpression = 
                Expression.Assign(
                    outputParameter,
                    Expression.Convert(
                        Expression.Call(
                            inputParameter,
                            MemberwiseCloneMethod),
                        typeof(ExampleClass)));

关注点 (结构体和只读字段)

如果没有对 struct 的深拷贝,那么对于 Dictionary<,> 这样基本的类,深拷贝也无法正常工作,因为字典将键值对存储在 Dictionary<,>.Entry 类型的struct中,因此这些 Entry struct 必须进行深拷贝。请参阅 Dictionary<,> 的参考源。因此,我们迫切需要实现 struct 的拷贝。

struct 拷贝代码中棘手的部分是可写和只读字段。通过表达式树复制可写字段必须直接在结构体类型上执行(无需转换或装箱),但对于复制只读字段,我们必须使用反射,而结构体中的反射必须通过装箱来完成。考虑到表达式树代码的复杂性及其有限的调试能力,这项实现非常困难。

以下代码(从表达式树代码翻译成常规 C# 代码)说明了其中的障碍。

// definition of variables
ExampleStruct copy = (ExampleStruct)original.MemberwiseClone();
Object boxedCopy = (Object)copy;
FieldInfo fieldInfo = copy.GetType().GetField("field");

// WRITABLE fields in structs (we use pure expression trees)
copy.field = value;                      // WORKING
((ExampleStruct)boxedCopy).field = value // NOT WORKING: we assigned field of temporary struct instance
fieldInfo.SetValue(boxedCopy, value);    // WORKING BUT SLOW (if repeated many times)

// READONLY fields in structs (we have to use reflection call in expression trees)
copy.field = value;                      // NOT WORKING: because of readonly
fieldInfo.SetValue(copy, value);         // NOT WORKING: copy was expected of type System.Object
fieldInfo.SetValue((Object)copy, value); // NOT WORKING: value is again lost because of unwanted boxing
fieldInfo.SetValue(boxedCopy, value);    // WORKING

替代方案链接

Cygon 编写了一个替代的表达式树深拷贝器完整文章),并由 Ymris 进行了改进(codeplex 项目)。Marcin Juraszek 也编写了另一个代码(存在一些限制)(codeplex 项目)。

Alexey Burtsev 编写了最佳的经过良好测试反射替代方案(codeplex 项目)。它通过了 zip 文件中包含的所有测试。

最佳的经过测试且代码量最短的方案是通过 BinaryFormatter 进行序列化代码 1代码 2)。尽管这是最慢的选项,并且需要 Serializable 属性,但它能很好地克隆我们解决方案能克隆的一切,并且还能克隆一些委托和事件。它也通过了所有测试。

致谢

感谢我以前的老板Roger Lord,他提出了使用表达式树进行快速深拷贝的整个想法。

历史

更改列表

  • [2016-07-12]
    • 添加了实现说明
    • 关于只读属性的说明
    • 替代方案链接
  • [2016-07-13]
    • 修正了关于序列化的说明:BinaryFormatter可以处理循环引用
    • [次要] 将类名 ClassType 更改为 ExampleClass
  • [2016-07-14]
    • [主要]上传了新的 .zip 文件:代码可以处理结构体(因此也处理字典)
    • 还在同一个 zip 文件中上传了更好的测试
    • 添加了“关注点”部分
    • [次要] 编辑了“拷贝函数的工作流程”部分
  • [2016-07-15]
    • 上传了新的 .zip 文件:结构体被更好地过滤以进行深拷贝 - 在某些情况下提高了速度
    • [次要] 上传了完整的 Visual Studio 解决方案
    • [次要] 编辑了“拷贝函数的工作流程”部分
  • [2016-07-21]
    • 致谢
    • [次要] 添加了替代方案
    • [次要] 标题中删除了“of Objects”,HTTP 地址未更改
  • [2016-08-03]
    • HTTP 地址已更改
    • [次要] 措辞略有修改
  • [2017-01-27]
    • 修复了 Object 类型字段的拷贝,这些字段被赋值为字符串(以及值类型)
  • [2017-01-30]
    • 根据 Alexey Burtsev 的解决方案添加了 ReferenceEqualityComparer
© . All rights reserved.