Effective C# 原则28:避免转换操作

JerryXia 发表于 , 阅读 (929)

转换操作是一种等代类型(Substitutability)间操作转换操作。等代类型就是指一个类可以取代另一个类。这可能是件好事:一个派生类的对象可以被它基类的一个对象取代,一个经典的例子就是形状继承。先有一个形状类,然后派生出很多其它的类型:长方形,椭圆形,圆形以及其它。你可以在任何地方用图形状来取代圆形,这就是多态的等代类型。这是正确的,因为圆形就是一个特殊的形状。当你创建一个类时,明确的类型转化是可以自动完成的。正如.Net中类的继承,因为System.Object是所有类型的基类,所以任何类型都可以用System.Obejct来取代。同样的情况,你所创建的任何类型,也应该可以用它所实现的接口来取代,或者用它的基类接口来取代,或者就用基类来取代。不仅如此,C#语言还支持很多其它的转换。

当你为某个类型添加转换操作时,就等于是告诉编译器:你的类型可以被目标类所取代。这可能会引发一些潜在的错误,因为你的类型很可能并不能被目标类型所取代(译注:这里并不是指继承关系上的类型转换,而是C#语言许可我们的另一种转换,请看后文)。它所的副作用就是修改了目标类型的状态后可能对原类型根本无效。更糟糕的是,如果你的转换产生了临时对象,那么副作用就是你直接修改了临时对象,而且它会永久丢失在垃圾回收器。总之,使用转换操作应该基于编译时的类型对象,而不是运行时的类型对象。用户可能须要对类型进行多样化的强制转换操作,这样的实际操作可能产生不维护的代码。

你可以使用转换操作把一个未知类型转化为你的类型,这会更加清楚的表现创建新对象的操作(译注:这样的转换是要创建新对象的)。转换操作会在代码中产生难于发现的问题。假设有这样一种情况,你创建了如图3.1那样的类库结构。椭圆和圆都是从形状类继承下来的,尽管你相信椭圆和圆是相关的,但还是决定保留这样的继承关系。这是因为你不想在继承关系中使用非抽象叶子类,这会在从椭圆类上继承圆类时,有一些不好实现的难题存在。然而,你又意识到每一个圆形应该是一个椭圆,另外某些椭圆也可能是圆形。

(译注:这一原则中作者所给出的例子不是很恰当,而且作者也在前面假设了原因,因此请读者不要对这个例子太钻牛角尖,理解作者所在表达的思想就行了,相信在你的C#开发中可能也会遇到类似的转换问题,只是不太可能从圆形转椭圆。)

这将导致你要添加两个转换操作。因为每一个圆形都是一个椭圆,所以要添加隐式转换从一个圆形转换到新的椭圆。隐式转换会在一个类要求转化为另一个类时被调用。对应的,显示转化就是程序员在代码中使用了强制转换操作符。

public class Circle : Shape
{
  private PointF _center;
  private float _radius;

  public Circle() :
    this ( PointF.Empty, 0 )
  {
  }

  public Circle( PointF c, float r )
  {
    _center = c;
    _radius = r;
  }

  public override void Draw()
  {
    //...
  }

  static public implicit operator Ellipse( Circle c )
  {
    return new Ellipse( c._center, c._center,
      c._radius, c._radius );
  }
}

现在你就已经实现了隐式的转换操作,你可以在任何要求椭圆的地方使用圆形。而且这个转换是自动完成的:

public double ComputeArea( Ellipse e )
{
  // return the area of the ellipse.
}

// call it:
Circle c = new Circle( new PointF( 3.0f, 0 ), 5.0f );
ComputeArea( c );

我只是想用这个例子表达可替代类型:一个圆形已经可以代替一个可椭圆了。ComputeArea函数可以在替代类型上工作。你很幸运,但看下面这个例子:

public void Flatten( Ellipse e )
{
  e.R1 /= 2;
  e.R2 *= 2;
}

// call it using a circle:
Circle c = new Circle( new PointF ( 3.0f, 0 ), 5.0f );
Flatten( c );

这是无效的,Flatten()方法要求一个椭圆做为参数,编译器必须以某种方式把圆形转化为椭圆。确实,也已经实现了一个隐式的转换。而且你转换也被调用了,Flatten()方法得到的参数是从你的转换操作中创建的新的椭圆对象。这个临时对象被Flatten()函数修改,而且它很快成为垃圾对象。正是因为这个临时对象,Flatten()函数产生了副作用。最后的结果就是这个圆形对象,c,根本就没有发生任何改变。从隐式转换修改成显示转换也只是强迫用户调用强制转换而以:

Circle c = new Circle( new PointF( 3.0f, 0 ), 5.0f );
Flatten( ( Ellipse ) c );

原先的问题还是存在。你想让用户调用强制转换为解决这个问题,但实际上还是产生了临时对象,把临时对象进行变平(flatten)操作后就丢掉了。原来的圆,c,还是根本没有被修改过。取而代之的是,如果你创建一个构造函数把圆形转换成椭圆,那么操作就很明确了:

Circle c = new Circle( new PointF( 3.0f, 0 ), 5.0f );
Flatten ( new Ellipse( c ));

相信很多程序员一眼就看的出来,在前面的两行代码中传给Flatten()的椭圆在修改后就丢失了。他们可能会通过跟踪对象来解决这个问题:

Circle c = new Circle( new PointF( 3.0f, 0 ), 5.0f );
// Work with the circle.
// ...

// Convert to an ellipse.
Ellipse e = new Ellipse( c );
Flatten( e );

通过一个变量来保存修改(变平)后的椭圆,通过构造函数来替换转换操作,你不会丢失任何功能:你只是让创建新对象的操作更加清楚。(有经验的C++程序可能注意到C#的隐式转化和显示转换都没有调用构造函数。在C++中,只有明确的使用new操作符才能创建一个新的对象时,其它时候不行。而在C#的构造函数中不用明确的使用关键字。)

从类型里返回字段的转换操作并不会展示类型的行为,这会产生一些问题。你给类型的封装原则留下了几个严重的漏洞。通过把类型强制转化为其它类型,用户可以访问到类型的内部变量。这正是原则23中所讨论的所有原因中最应该避免的。

转换操作提供了一种类型可替代的形式,但这会给代码引发一些问题。你应该已经明白所有这些内容:用户希望可以合理的用某种类型来替代你的类型。当这个可替代类型被访问时,你就让用户在临时对象上工作,或者内部字段取代了你创建的类。随后你可能修改了临时对象,然后丢掉。因为这些转换代码是编译器产生的,因此这些潜在的BUG很难发现。应该尽量避免转换操作。

添加新评论