分享

条款28:避免强制转换操作符

 兰亭文艺 2018-01-17
转换操作符为类之间引入了一层“替换性(substitutability)”。“替换”意味着一个类的实例可以被替换为另一个类的实例。这对我们来说可以是一种好处:一个派生类的对象可以被当做一个基类对象来使用。
例如在经典的Shape类层次中,我们可以创建一 个Shape(形状)基类,并派生出许多子类:Rectangle(长方形)、Ellipse(椭圆)、Circle(圆)等。在任何需要Shape的地方,我们都可以使用一个Circle子类来替换。替换得以实现是因为多态发挥的作用,因为Circle是一个更为具体的Shape类型。当我们创建一个类时,某些转换会自动奏效。例如,任何对象都可以被当作System.Object实例来使用,因为System.Object是整个.NET类层次中的根类。类似地,任何类的对象都可以被隐式地当作它所实现的一个接口或者它的基类来使用。另外,C#语言还支持许多数值转换。
在为我们的类型定义了转换操作符之后,我们实际上是在告诉编译器这些类型可以被当作目标类型来使用。这样的替换经常会导致一些很诡异的bug,因为我们的类型可能并不是目标类型的完美替换品。例如,更改目标类型状态的结果可能并不会反应到我们的类型上。更糟糕的是,如果我们的转换操作符返回一个临时对象,更改的效应将仅限于临时对象,而且随后就会被丢弃变成垃圾对象。最后,转换操作符的调用规则是基于对象的编译时类型,而非运行时类型。为此,类型的使用者可能要执行多次强制转型来调用转换操作符,这样的做法会导致难以维护的代码。
如果希望将一个类型转换为另一个类型,我们应该使用构造器。这种做法更清楚地反映了创建新对象的行为。而转换操作符会为代码引入很难发现的问题。假设我们获得了一个如图3-1所示的类库的代码。其中 Circle类和Ellipse类都派生自Shape类。我们决定保留这个类层次不动,因为虽然Circle和Ellipse有相关性,但是我们并不希望在类层次中出现非抽象的叶子类;并且当试图让Circle类派生自Ellipse类时,会遇到一些实现方面的问题。然而,我们总还是会认为每一个 Circle都可以是一个Ellipse。而且,某些Ellipse也可以被当作Circle来使用。
图3-1  图形类层次
这会导致我们添加两个转换操作符。由于每一个 Circle都是一个Ellipse,因此我们需要添加隐式转换操作符将一个Circle转换为一个Ellipse。当一个类型需要被转换成另一个类型才能正常工作时,隐式转换操作符就会被调用。与此相反,当我们在源代码中使用强制转型操作符时,显式转换操作符便会被调用。看下面的代码:
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( Circlec )  //通过构造器隐式转换为另外一个对象
  {
return new Ellipse( c._center, c._center, c._radius,c._radius );
}
}
有了隐式转换操作符之后,我们就可以在任何需要Ellipse的地方使用Circle对象。而且,这样的转换会自动发生:
public double ComputeArea( Ellipse e )
{
  // 返回椭圆的面积。
}
// 调用:
Circle c = new Circle( new PointF( 3.0f, 0 ), 5.0f );
ComputeArea( c );
上面的例子展示了所谓的“替换”:在本来要求Ellipse的地方使用了Circle。替换后,ComputeArea()函数工作得很好,这很幸运。但是,对于如下的函数:
public void Flatten(Ellipse e )
{
  e.R1 /= 2;
  e.R2 *= 2;
}
// 使用Circle来调用:
Circle c = new Circle( new PointF ( 3.0f, 0 ), 5.0f );
Flatten( c );
就有问题了。Flatten()方法接受一个Ellipse作为参数。编译器必须将Circle转换为Ellipse。这便是我们上面创建的隐式转换操作符的工作。
隐式转换操作符调用后会创建一个临时的 Ellipse对象,然后传递给Flatten()函数作为参数。这个临时的Ellipse对象会被Flatten()函数修改,然后就变成垃圾对象。 Flatten()函数只是在临时的Ellipse对象上显现了一些副作用。结果是真正的Circle对象c什么都没有发生。
将隐式转换操作符更改为显式转换操作符只会强制用户添加一个转型动作:
Circle c = new Circle( new PointF( 3.0f, 0 ), 5.0f );
Flatten( ( Ellipse ) c );
原来的问题并没有解决。强制用户添加转型动作会导致同样的问题——仍然会创建临时对象,将临时对象变平(flatten),然后将其丢弃,而Circle对象c根本没有得到任何更改。然而,如果我们通过创建一个构造器来将Circle转换为Ellipse,那么下面代码的行为就很清晰了:
Circle c = new Circle( new PointF( 3.0f, 0 ), 5.0f );
Flatten ( new Ellipse( c ));
绝大多数程序员看到上面两行代码,立刻就会明白Flattern()中对Ellipse的任何更改都会丢失。这样自然就会对Ellipse对象保持一个追踪:
Circle c = new Circle( new PointF( 3.0f, 0 ), 5.0f );
// 处理Circle。
// ……
// 转换为一个Ellipse。
Ellipse e = new Ellipse(c ); //可以访问Ellipse类的内部变量, 会给类的封装性带来严重的漏洞
Flatten( e );
变量e中保存着变平的Ellipse。通过使用构造器来代替转换操作符,我们不但没有丢失任何功能,反而使得新对象的创建工作变得更加清晰。(对于那些C++老手来说,需要注意C#不会将构造器用做隐式或者显式的转换。只有在显式使用new操作符时,C#才会创建新对象,没有任何例外。因此C#的构造器上不需要使用explicit关键字。)
在转换操作符中返回对象内部的字段,虽然不再出现上述行为,但是它们会带来其他问题——会给类的封装性带来严重的漏洞。因为如果将我们的类型强制转换为其他类型,类的客户程序就可以访问类的内部变量。不管有什么理由,都应该竭力避免这种做法,参见条款23。
综上所述,转换操作符所获得的“替换性”会为代码带来一些问题。提供转换操作符的意思是在向类的用户表明,在本该使用这个类的地方用户可以用其他的类来代替。当访问被替换的对象时,和客户程序打交道的实际上是一些临时对象或者内部字段。这些临时对象被修改之后,结果就会被丢弃。这样诡异的bug是很难发现的,因为进行类型转换的代码是由编译器产生的。因此我们应该避免使用转换操作符。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多