在C#中进行浅复制的最快方法

68
我想知道在C#中进行浅层复制的最快方法是什么?我只知道有两种浅层复制的方法:
1. MemberwiseClone 2. 逐个复制每个字段(手动)
我发现(2)比(1)更快。我想知道是否还有其他方法可以进行浅层复制?

1
如果复制不是递归执行的,那么这就是一个浅拷贝。 - Daniel Brückner
1
我想要进行浅拷贝 - tep
3
浅拷贝是指复制数据的引用(因此两个副本都引用相同版本的数据)。深拷贝是指复制所有实际数据(因此有两个独立的数据副本)。 - Jason Williams
如果你需要大量的浅拷贝,也许使用结构体而不是类会更合适? - Luaan
8个回答

82

这是一个复杂的主题,有许多可能的解决方案和各种利弊。这里有一篇精彩的文章(链接),概述了C#中几种不同的复制方式。简要总结如下:

  1. 手动克隆
    繁琐,但控制程度高。

  2. 使用MemberwiseClone克隆
    只创建浅层复制,即对于引用类型字段,原始对象和其副本引用相同的对象。

  3. 使用Reflection(反射)克隆
    默认情况下为浅层复制,可以重写为深层复制。优点:自动化;缺点:反射速度较慢。

  4. 使用Serialization(序列化)克隆
    简单易行,自动化。放弃了一些控制,而且序列化是所有方法中最慢的。

  5. 使用IL或Extension Methods克隆
    更高级的解决方案,不太常见。


2
我明白了。在所有可选项中,我认为(1)是最快的。谢谢! - tep
5
@tep 不,MemberwiseClone 应该是最快的。你的结果可能是由于调试/发布模式或其他类似因素引起的。 - Mr. TA
6
MemberwiseClone 会执行一些额外的检查和操作,因此对于字段较少的对象,它可能实际上会稍微慢一些。但是,在任何情况下,您只有在面临性能问题时才需要关注这一点。不要根据“直觉”进行优化,而应该进行分析。如果你需要频繁地克隆对象以至于 MemberwiseClone 影响了性能,那么你可能应该使用结构体而不是类。或者也许需要更彻底的架构变更 :D - Luaan
你有没有看过 Fasterflect 库?它有一些很好的选项来进行深度序列化。 - Alkampfer

37

我想先引用几句话:

实际上,对于复杂类型,MemberwiseClone通常比其他方法更好。

以及

我很困惑。对于浅拷贝,MemberwiseClone()应该比其他任何方法都要快。[...]

理论上,最好的浅拷贝实现是C++的拷贝构造函数:它在编译时就知道大小,然后对所有字段进行成员逐一克隆。其次是使用memcpy或类似的东西,这基本上就是MemberwiseClone应该的工作方式。这意味着,理论上它应该在性能方面超越所有其他可能性。对吧?

…但显然它并不是非常快,也不能消灭所有其他解决方案。在底部,我实际上发布了一个速度超过2倍的解决方案。所以:错了。

测试MemberwiseClone内部

让我们使用一个简单的可平直类型进行一些测试,以检查这里关于性能的基本假设:

[StructLayout(LayoutKind.Sequential)]
public class ShallowCloneTest
{
    public int Foo;
    public long Bar;

    public ShallowCloneTest Clone()
    {
        return (ShallowCloneTest)base.MemberwiseClone();
    }
}

测试的目的是通过比较MemberwiseClone和原始的memcpy的性能来检查其性能,这是因为它是可平坦化类型。
要进行测试,请编译不安全代码,禁用JIT抑制,编译发布模式并进行测试。我还在每个相关行后放置了计时。 实现1:
ShallowCloneTest t1 = new ShallowCloneTest() { Bar = 1, Foo = 2 };
Stopwatch sw = Stopwatch.StartNew();
int total = 0;
for (int i = 0; i < 10000000; ++i)
{
    var cloned = t1.Clone();                                    // 0.40s
    total += cloned.Foo;
}

Console.WriteLine("Took {0:0.00}s", sw.Elapsed.TotalSeconds);

基本上我运行了这些测试很多次,并检查汇编输出以确保这个东西没有被优化掉等。最终结果是我知道这一行代码大约需要多少秒,即在我的PC上为0.40秒。这是我们使用MemberwiseClone的基准。 实现2:
sw = Stopwatch.StartNew();

total = 0;
uint bytes = (uint)Marshal.SizeOf(t1.GetType());
GCHandle handle1 = GCHandle.Alloc(t1, GCHandleType.Pinned);
IntPtr ptr1 = handle1.AddrOfPinnedObject();

for (int i = 0; i < 10000000; ++i)
{
    ShallowCloneTest t2 = new ShallowCloneTest();               // 0.03s
    GCHandle handle2 = GCHandle.Alloc(t2, GCHandleType.Pinned); // 0.75s (+ 'Free' call)
    IntPtr ptr2 = handle2.AddrOfPinnedObject();                 // 0.06s
    memcpy(ptr2, ptr1, new UIntPtr(bytes));                     // 0.17s
    handle2.Free();

    total += t2.Foo;
}

handle1.Free();
Console.WriteLine("Took {0:0.00}s", sw.Elapsed.TotalSeconds);

如果你仔细观察这些数字,你会注意到一些事情:
- 创建一个对象并复制它大约需要0.20秒。在正常情况下,这是你可以拥有的最快速的代码。 - 然而,要做到这一点,你需要固定和取消固定对象。这将花费你0.81秒。
那么为什么所有这些都如此缓慢呢?
我认为这与GC有关。基本上,实现不能依赖于内存在完整GC之前和之后保持不变(在浅层复制期间,内存的地址可能会在任何时候改变,包括在GC期间)。这意味着你只有两个可能的选择:
1. 固定数据并进行复制。请注意,GCHandle.Alloc只是其中一种方法,众所周知,像C++/CLI这样的东西会给你更好的性能。 2. 枚举字段。这将确保在GC收集之间,你不需要做任何花哨的事情,在GC收集期间,你可以使用GC能力来修改已移动对象的堆栈上的地址。 MemberwiseClone会使用方法1,这意味着由于固定过程,您将遭受性能损失。
更快的实现:
在所有情况下,我们的非托管代码都不能对类型的大小进行假设,因此必须固定数据。对大小进行假设使编译器能够进行更好的优化,例如循环展开、寄存器分配等(就像C++复制构造函数比memcpy更快一样)。不需要固定数据意味着我们不会受到额外的性能损失。由于.NET JIT转换为汇编语言,理论上这意味着我们应该能够使用简单的IL发射来进行更快的实现,并允许编译器进行优化。
因此,总结一下为什么这比本机实现更快?
1.它不需要固定对象;正在移动的对象由GC处理--而且真的,这是无休止地优化的。
2.它可以对要复制的结构的大小进行假设,从而允许更好的寄存器分配、循环展开等。
我们的目标是原始的memcpy或更好的性能:0.17秒。
为了实现这一点,我们基本上只能使用一个call、创建对象和执行一堆copy指令。它看起来有点像上面的Cloner实现,但是有一些重要的区别(最显著的是没有Dictionary和冗余的CreateDelegate调用)。以下是代码:
public static class Cloner<T>
{
    private static Func<T, T> cloner = CreateCloner();

    private static Func<T, T> CreateCloner()
    {
        var cloneMethod = new DynamicMethod("CloneImplementation", typeof(T), new Type[] { typeof(T) }, true);
        var defaultCtor = typeof(T).GetConstructor(new Type[] { });

        var generator = cloneMethod .GetILGenerator();

        var loc1 = generator.DeclareLocal(typeof(T));

        generator.Emit(OpCodes.Newobj, defaultCtor);
        generator.Emit(OpCodes.Stloc, loc1);

        foreach (var field in typeof(T).GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
        {
            generator.Emit(OpCodes.Ldloc, loc1);
            generator.Emit(OpCodes.Ldarg_0);
            generator.Emit(OpCodes.Ldfld, field);
            generator.Emit(OpCodes.Stfld, field);
        }

        generator.Emit(OpCodes.Ldloc, loc1);
        generator.Emit(OpCodes.Ret);

        return ((Func<T, T>)cloneMethod.CreateDelegate(typeof(Func<T, T>)));
    }

    public static T Clone(T myObject)
    {
        return cloner(myObject);
    }
}

我已经测试了这段代码,结果是0.16秒。这意味着它大约比MemberwiseClone快2.5倍。
更重要的是,这个速度与memcpy持平,后者在正常情况下是“最优解”。
个人认为这是最快的解决方案 - 最好的部分是:如果.NET运行时变得更快(支持SSE指令等),这个解决方案也会跟着变快。
编辑注释: 上面的示例代码假设默认构造函数是公共的。如果不是,则调用GetConstructor返回null。在这种情况下,请使用其他GetConstructor签名之一来获取受保护或私有构造函数。 请参阅https://learn.microsoft.com/en-us/dotnet/api/system.type.getconstructor?view=netframework-4.8

嗯,你测试过调用 Cloner<BaseClass>.Clone(someDerivedObject); 吗?我猜它会失败,因为你正在使用静态编译时字段信息来创建克隆器。克隆实现通常在基类中定义(例如 MemberwiseClone),它不可能知道未来的派生类,但仍必须将所有字段复制到层次结构中。 - Bruce Pierson
@BrucePierson 嗯,是的,这不是它的设计方式,对吧?但是,如果这是您的情况,当然可以很容易地使用 this.GetType()。就个人而言,我会尽量避免在基类中添加此类实用程序函数。 - atlaste
但是因为它是一个静态泛型类,所以这里的this.GetType()没有用 - 你不能在这里使用类型推断,对吧? - Bruce Pierson
是的,我明白了。确实,将类型设置为非泛型并将泛型类型参数应用于“Clone”方法会起作用,但即使使用O:1字典,在那个点上也很难有任何性能提升。无论如何,对于这篇非常有趣的文章,我给出+1的评价,不过也许你应该添加一些注意事项作为注释,以免有人将其作为完整的“MemberwiseClone”替代品。 - Bruce Pierson
1
@ChrisGillum 并不完全是这样。它们只关注开发人员的生产力并提供合理的性能。如果你像我一样习惯于编写低级别的 C++,那么以10倍的速度超越 .NET 应用程序并不是真正的挑战... - atlaste
显示剩余10条评论

31

我感到困惑。MemberwiseClone()应该对于浅拷贝的性能比其他任何方法都要好。在CLI中,除了RCW之外的任何类型都可以通过以下序列进行浅拷贝:

  • 在nursery(新生代)中为类型分配内存。
  • memcpy将原始数据从原始位置复制到新的位置。由于目标对象在nursery中,因此不需要写入屏障。
  • 如果对象具有用户定义的终结器,则将其添加到待终止项的GC列表中。
    • 如果源对象已调用SuppressFinalize并且在对象标题中存储了这样的标志,请在克隆中取消设置它。

CLR内部团队的某个人能解释一下为什么情况不是这样吗?


14
@ta.speot.is,巴-汉伯格。这是一个很好的提问点,在这些讨论中,我们大多数人都是为了更好地理解事物而参与讨论的。 - Nicholas Petersen
6
MemberwiseClone是一个外部调用,这意味着它需要固定内存。固定内存是一个相对比较慢的过程。我在下面的回答中发布了我的测试细节和结果。我现在没有原生调用基准数据了,但我相当确信固定内存/本机互操作和额外的虚拟表调用(clone、sizeof)占据了你发现的memcpy的开销。 - atlaste

17

为什么要把事情复杂化呢?MemberwiseClone就足够了。

public class ClassA : ICloneable
{
   public object Clone()
   {
      return this.MemberwiseClone();
   }
}

// let's say you want to copy the value (not reference) of the member of that class.
public class Main()
{
    ClassA myClassB = new ClassA();
    ClassA myClassC = new ClassA();
    myClassB = (ClassA) myClassC.Clone();
}

8
这是使用动态IL生成的一种方法。我在网上找到了它:
public static class Cloner
{
    static Dictionary<Type, Delegate> _cachedIL = new Dictionary<Type, Delegate>();

    public static T Clone<T>(T myObject)
    {
        Delegate myExec = null;

        if (!_cachedIL.TryGetValue(typeof(T), out myExec))
        {
            var dymMethod = new DynamicMethod("DoClone", typeof(T), new Type[] { typeof(T) }, true);
            var cInfo = myObject.GetType().GetConstructor(new Type[] { });

            var generator = dymMethod.GetILGenerator();

            var lbf = generator.DeclareLocal(typeof(T));

            generator.Emit(OpCodes.Newobj, cInfo);
            generator.Emit(OpCodes.Stloc_0);

            foreach (var field in myObject.GetType().GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
            {
                // Load the new object on the eval stack... (currently 1 item on eval stack)
                generator.Emit(OpCodes.Ldloc_0);
                // Load initial object (parameter)          (currently 2 items on eval stack)
                generator.Emit(OpCodes.Ldarg_0);
                // Replace value by field value             (still currently 2 items on eval stack)
                generator.Emit(OpCodes.Ldfld, field);
                // Store the value of the top on the eval stack into the object underneath that value on the value stack.
                //  (0 items on eval stack)
                generator.Emit(OpCodes.Stfld, field);
            }

            // Load new constructed obj on eval stack -> 1 item on stack
            generator.Emit(OpCodes.Ldloc_0);
            // Return constructed object.   --> 0 items on stack
            generator.Emit(OpCodes.Ret);

            myExec = dymMethod.CreateDelegate(typeof(Func<T, T>));

            _cachedIL.Add(typeof(T), myExec);
        }

        return ((Func<T, T>)myExec)(myObject);
    }
}

1
由于这里使用了反射,因此它不太可能比对象的MemberwiseClone更快(后者由CLR在内部执行)。我建议OP在大多数情况下只使用MemberwiseClone,或者如果需要高性能,则按每个类手动复制字段。 - Noldorin
1
请注意,这种方法无法处理继承链,因为您还需要获取底层类型。 - Sam Saffron
3
@Noldorin 错了,它只使用反射来创建DynamicMethod - 后续调用与手动克隆一样快。然而,相对于MemberwiseClone而言,仍然较慢,当然前提是.NET在实现时能够使用“ memcpy”这样的操作。 - Mr. TA
@Mr.TA:如果你并没有真正反驳我的说法,请不要说“不正确”。好吧,反射使用是最小的,并且除了第一次调用以外的减速主要是由于所使用的托管调用,但是仍然存在。 - Noldorin
只是一个提醒,您可以通过指定整个类为泛型来避免静态字典。在泛型类中,您只需要一个单独的静态字段来存储委托。实际上,您还可以创建一个实现ICloneable接口的单例,这比委托更快 :)) - Luaan

6
事实上,MemberwiseClone通常比其他方法更好,特别是对于复杂类型。原因在于:如果您手动创建副本,则必须调用类型的构造函数之一,但是如果使用MemberwiseClone,它只需复制一个内存块。对于那些具有非常昂贵的构建操作的类型,MemberwiseClone绝对是最好的方法。我曾经编写过这样的类型:{string A = Guid.NewGuid().ToString()},我发现MemberwiseClone比创建新实例并手动分配成员要快得多。以下是代码结果:
手动复制:00:00:00.0017099
MemberwiseClone:00:00:00.0009911
namespace MoeCard.TestConsole
{
    class Program
    {
        static void Main(string[] args)
        {
            Program p = new Program() { AAA = Guid.NewGuid().ToString(), BBB = 123 };
            Stopwatch sw = Stopwatch.StartNew();
            for (int i = 0; i < 10000; i++)
            {
                p.Copy1();
            }
            sw.Stop();
            Console.WriteLine("Manual Copy:" + sw.Elapsed);

            sw.Restart();
            for (int i = 0; i < 10000; i++)
            {
                p.Copy2();
            }
            sw.Stop();
            Console.WriteLine("MemberwiseClone:" + sw.Elapsed);
            Console.ReadLine();
        }

        public string AAA;

        public int BBB;

        public Class1 CCC = new Class1();

        public Program Copy1()
        {
            return new Program() { AAA = AAA, BBB = BBB, CCC = CCC };
        }
        public Program Copy2()
        {
            return this.MemberwiseClone() as Program;
        }

        public class Class1
        {
            public DateTime Date = DateTime.Now;
        }
    }

}

最后,我在这里提供我的代码:
    #region 数据克隆
    /// <summary>
    /// 依据不同类型所存储的克隆句柄集合
    /// </summary>
    private static readonly Dictionary<Type, Func<object, object>> CloneHandlers = new Dictionary<Type, Func<object, object>>();

    /// <summary>
    /// 根据指定的实例,克隆一份新的实例
    /// </summary>
    /// <param name="source">待克隆的实例</param>
    /// <returns>被克隆的新的实例</returns>
    public static object CloneInstance(object source)
    {
        if (source == null)
        {
            return null;
        }
        Func<object, object> handler = TryGetOrAdd(CloneHandlers, source.GetType(), CreateCloneHandler);
        return handler(source);
    }

    /// <summary>
    /// 根据指定的类型,创建对应的克隆句柄
    /// </summary>
    /// <param name="type">数据类型</param>
    /// <returns>数据克隆句柄</returns>
    private static Func<object, object> CreateCloneHandler(Type type)
    {
        return Delegate.CreateDelegate(typeof(Func<object, object>), new Func<object, object>(CloneAs<object>).Method.GetGenericMethodDefinition().MakeGenericMethod(type)) as Func<object, object>;
    }

    /// <summary>
    /// 克隆一个类
    /// </summary>
    /// <typeparam name="TValue"></typeparam>
    /// <param name="value"></param>
    /// <returns></returns>
    private static object CloneAs<TValue>(object value)
    {
        return Copier<TValue>.Clone((TValue)value);
    }
    /// <summary>
    /// 生成一份指定数据的克隆体
    /// </summary>
    /// <typeparam name="TValue">数据的类型</typeparam>
    /// <param name="value">需要克隆的值</param>
    /// <returns>克隆后的数据</returns>
    public static TValue Clone<TValue>(TValue value)
    {
        if (value == null)
        {
            return value;
        }
        return Copier<TValue>.Clone(value);
    }

    /// <summary>
    /// 辅助类,完成数据克隆
    /// </summary>
    /// <typeparam name="TValue">数据类型</typeparam>
    private static class Copier<TValue>
    {
        /// <summary>
        /// 用于克隆的句柄
        /// </summary>
        internal static readonly Func<TValue, TValue> Clone;

        /// <summary>
        /// 初始化
        /// </summary>
        static Copier()
        {
            MethodFactory<Func<TValue, TValue>> method = MethodFactory.Create<Func<TValue, TValue>>();
            Type type = typeof(TValue);
            if (type == typeof(object))
            {
                method.LoadArg(0).Return();
                return;
            }
            switch (Type.GetTypeCode(type))
            {
                case TypeCode.Object:
                    if (type.IsClass)
                    {
                        method.LoadArg(0).Call(Reflector.GetMethod(typeof(object), "MemberwiseClone")).Cast(typeof(object), typeof(TValue)).Return();
                    }
                    else
                    {
                        method.LoadArg(0).Return();
                    }
                    break;
                default:
                    method.LoadArg(0).Return();
                    break;
            }
            Clone = method.Delegation;
        }

    }
    #endregion

4

这是一个小型的辅助类,使用反射来访问MemberwiseClone,并缓存委托以避免过多地使用反射。

public static class CloneUtil<T>
{
    private static readonly Func<T, object> clone;

    static CloneUtil()
    {
        var cloneMethod = typeof(T).GetMethod("MemberwiseClone", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
        clone = (Func<T, object>)cloneMethod.CreateDelegate(typeof(Func<T, object>));
    }

    public static T ShallowClone(T obj) => (T)clone(obj);
}

public static class CloneUtil
{
    public static T ShallowClone<T>(this T obj) => CloneUtil<T>.ShallowClone(obj);
}

您可以这样调用它:
Person b = a.ShallowClone();

这非常完美,因为我想要克隆的对象并不是属于我自己的。 - ShrapNull

4

MemberwiseClone需要较少的维护。我不知道默认属性值是否有所帮助,也许可以忽略具有默认值的项目。


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