通过表达式树进行快速深度复制(C#)
使用 Linq.Expressions 实现的深拷贝代码。包含测试和测试类。
引言
深拷贝在C# 中并未原生实现,每个对象只有 Object.MemberwiseClone()
函数,它只能创建浅拷贝。本文提供了使用表达式树 (Linq.Expressions) 实现的非常快速的深拷贝函数的代码。
背景
通常,深拷贝函数可以通过序列化轻松实现,通过反射需要中等程度的努力,而通过表达式树则需要大量的努力。序列化速度非常慢,并且可能存在一些额外的限制(例如,BinaryFormatter
需要 Serializable
属性,XmlSerializer
不维护循环引用)。反射速度是序列化的 5 倍,但仍然比手动实现的拷贝函数慢得多。表达式树的速度与手动实现的拷贝函数相同,因此是最快的。
有关通过序列化和反射实现的优秀代码,请参阅文章底部的替代方案链接部分。
速度比较
速度比较是使用“替代方案链接”部分提到的序列化和反射代码进行的。使用的对象是 ModerateClass
(包含在 zip 文件中)。
精确数字的表格如下
复制项数 | 1 | 10 | 100 | 1000 | 10000 | 100000 |
序列化 (毫秒) | 6 | 7 | 29 | 189 | 1496 | 12969 |
反射 (毫秒) | 5 | 5 | 10 | 36 | 242 | 2432 |
表达式树 (毫秒) | 19 | 17 | 19 | 20 | 69 | 570 |
为什么表达式树如此之快?
表达式树使开发人员能够在运行时以通用方式创建函数和方法。它与反射密切相关,并且几乎具有相同的可能性(例如,可以访问或赋值隐藏的成员,不需要 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 函数是按照以下工作流程创建的
- 首先使用
MemberwiseClone
函数复制对象。 - 对于
class
,副本存储在引用字典中,键为原始对象。 - 需要深拷贝的字段通过反射获取(注意继承关系),并逐一再次通过
DeepCopyByExpressionTreeObj
复制,这意味着对象Obj1
的已编译 Lambda 函数会调用其子对象Obj1.Obj2
的(不同的)已编译 Lambda 函数。 Delegate
字段和event
被设置为null
。- 对于类或接口数组,通过动态创建的
for
循环为每个索引设置的copy
函数会被调用。值类型数组保持不变。
需要深拷贝字段的 FieldInfo
仅在编译拷贝函数之前获取一次:因此不会减慢已编译函数的执行速度。
注意 (需要深拷贝的字段): 这些是类型为非值类型或非原始类型的字段。具体来说,排除了 string
、enum
和 Decimal
类型。通过这种方式,我们获取了所有 class
(除了 string
)和 struct
(除了基本值类型)。此外,从深拷贝中还排除了不包含任何 class
或 interface
层次结构的 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
- 根据 Alexey Burtsev 的解决方案添加了