Effective C# 原则27:避免使用ICloneable

JerryXia 发表于 , 阅读 (1,742)

ICloneable看上去是个不错的主意:为一个类型实现ICloneable接口后就可以支持拷贝了。如果你不想支持拷贝,就不要实现它。
但你的对象并不是在一个“真空”的环境中运行,但考虑到对派生类的些影响,最好还是对ICloneable支持。一但某个类型支持ICloneable,
那么所有的派生类都必须保持一致,也就是所有的成员必须支持ICloneable接口或者提供一种机制支持拷贝。最后,支持深拷贝的对象,在创建设计时如果包含有网络结构的对象,会使拷贝很成问题。ICloneable也觉察到这个问题,在它的官方定义中有说明:它同时支持深拷贝和浅拷贝。浅拷贝是创建一个新的对象,这个新对象对包含当前对象中所有成员变量的拷贝。如果这些成员变量是引用类型的,那么新的对象与源对象包含了同样的引用。而深拷贝则可以很好的拷贝所有成员变量,引用类型也被递归的进行了拷贝。对于像整型这样的内置类型,深拷贝和浅拷贝是一样的结果。哪一种是我们的类型应该支持的呢?这取决于类型本身。但同时在一个类型中混用深拷贝和浅拷贝会导致很多不一致的问题。一但你涉及到ICloneable这个问题,这样的混用就很难解脱了。大多数时候,我们应该完全避免使用ICloneable,让类更简单一些。这样使用和实现都相对简单得多。

任何只以内置类型做为成员的值类型不必支持ICloneable;
用简单的赋值语句对结构的所有值进行拷贝比Clone()要高效得多。Clone()方法必须对返回类型进行装箱,这样才能强制转化成一个System.Object的引用。而调用者还得再用强制转化从箱子中取回这个值。我知道你已经有足够的能力这样做,但不要用Clone()函数来取代赋值语句。

那么,当一个值类型中包含一个引用类型时又会怎样呢?最常见的一种情况就是值类型中包含一个字符串:

public struct ErrorMessage
{
    private int errCode;
    private int details;
    private string msg;
    // details elided
}

字符串是一个特殊情况,因为它是一个恒定类。如果你指定了一个错误消息串,那么所有的错误消息类都引用到同一个字符串上。而这并不会导致任何问题,这与其它一般的引用类型是不一样的。如果你在任何一个引用上修改了msg变量,你会就为它重新创建了一个string对象(参见原则7)。

(译注:string确实是一个很有意思的类,很多C++程序员对这个类不理解,也很有一些C#程序对它不理解,导致很多的低效,甚至错误问题。应该好好的理解一下C#里的string(以及String和StringBulider之间的关系)这个类,这对于学好C#是很有帮助的。因为这种设计思想可以沿用到我们自己的类型中。)

一般情况,如果一个结构中包含了一个任意的引用类型,那么拷贝时的情况就复杂多了。这也是很少见的,内置的赋值语句会对结构进行浅拷贝,这样两个结构中的引用变量就引用到同个一个对象上。如果要进行深拷贝,那么你就必须对引用类型也进行拷贝,而且还要知道该引用类型上是否也支持用Clone()进行深拷贝。不管是哪种情况,你都不用对值类型添加对ICloneable的支持,赋值语句会对值类型创建一个新的拷贝。

一句概括值类型:没有任何理由要给一个值类型添加对ICloneable接口的支持!
好了,现在让我们再看看引用类型。引用类型应该支持ICloneable接口,以便明确的给出它是支持深拷贝还是浅拷贝。明智的选择是添加对ICloneable的支持,因为这样就明确的要求所有派生类也必须支持ICloneable。看下面这个简单的继承关系:

class BaseType : ICloneable
{
  private string _label = "class name";
  private int [] _values = new int [ 10 ];

  public object Clone()
  {
    BaseType rVal = new BaseType( );
    rVal._label = _label;
    for( int i = 0; i < _values.Length; i++ )
      rVal._values[ i ] = _values[ i ];
    return rVal;
  }
}

class Derived : BaseType
{
  private double [] _dValues = new double[ 10 ];

  static void Main( string[] args )
  {
    Derived d = new Derived();
    Derived d2 = d.Clone() as Derived;

    if ( d2 == null )
      Console.WriteLine( "null" );
  }
}

如果你运行这个程序,你就会发现d2为null。虽然Derived是从BaseType派生的,但从BaseType类继承的Clone()函数并不能正确的支持Derived类:它只拷贝了基类。BaseType.Clone()创建的是一个BaseType对象,不是派生的Derived对象。这就是为什么程序中的d2为null而不是派生的Derived对象。即使你克服了这个问题,BaseType.Clone()也不能正确的拷贝在Derived类中定义的_dValues数组。一但你实现了ICloneable,
你就强制要求所有派生类也必须正确的实现它。实际上,你应该提供一个hook函数,让所有的派生类使用你的拷贝实现(参见原则21)。在拷贝时,派生类可以只对值类型成员或者实现了ICloneable接口的引用类型成员进行拷贝。对于派生类来说这是一个严格的要求。在基类上实现ICloneable接口通常会给派生类添加这样的负担,因此在密封类中应该避免实现ICloneable
接口。

因此,当整个继承结构都必须实现ICloneable时,你可以创建一个抽象的Clone()方法,然后强制所有的派生类都实现它。

在这种情况下,你需要定义一个方法让派生类来创建基类成员的拷贝。可以通过定义一个受保护的构造函数来实现:

class BaseType
{
  private string _label;
  private int [] _values;

  protected BaseType( )
  {
    _label = "class name";
    _values = new int [ 10 ];
  }

  // Used by devived values to clone
  protected BaseType( BaseType right )
  {
    _label = right._label;
    _values = right._values.Clone( ) as int[ ] ;
  }
}

sealed class Derived : BaseType, ICloneable
{
  private double [] _dValues = new double[ 10 ];

  public Derived ( )
  {
    _dValues = new double [ 10 ];
  }

  // Construct a copy
  // using the base class copy ctor
  private Derived ( Derived right ) :
    base ( right )
  {
    _dValues = right._dValues.Clone( )
      as double[ ];
  }

  static void Main( string[] args )
  {
    Derived d = new Derived();
    Derived d2 = d.Clone() as Derived;
    if ( d2 == null )
      Console.WriteLine( "null" );
  }

  public object Clone()
  {
    Derived rVal = new Derived( this );
    return rVal;
  }
}

基类并不实现ICloneable接口;
通过提供一个受保护的构造函数,让派生类可以拷贝基类的成员。叶子类,应该都是密封的,必要它应该实现ICloneable接口。基类不应该强迫所有的派生类都要实现ICloneable接口,但你应该提供一些必要的方法,以便那些希望实现ICloneable接口的派生类可以使用。

ICloneable接口有它的用武之地,但相对于它的规则来说,我们应该避免它。对于值类型,你不应该实现ICloneable接口,应该使用赋值语句。对于引用类型来说,只有在拷贝确实有必要存在时,才在叶子类上实现对ICloneable的支持。基类在可能要对ICloneable
进行支持时,应该创建一个受保护的构造函数。总而言之,我们应该尽量避免使用ICloneable接口。

添加新评论