原则9:在你的 API 中避免转换操作
转换操作引入不同类之间的替代性。替代性的意思是一个类可以被另外一个类代替。这有一个好处:一个子类可以代替基类,像下面 shape 继承结构给出例子。你定义基类 Shape 并自定义它的子类: Rectangle , Ellipse , Circle 等。在期望是 Shape 的地方你可以用 Circle 代替它。这是使用多态的替代性。这个是因为 Circle 是 Shape 的一个特别类型。当你创建一个类,某些转换是自动被允许的。任何对象都可以替代 .NET 类结构的基类 System.Object 的对象。相同的原理,你创建的任何类的对象都可以隐式代替它实现的接口,或者任何基类的接口,或者任何基类。语言还支持一系列的数值转换。
当你定义类的转换操作符,等于告诉编译器你的类型可以代替目标类型。这些转换经常会导致一些微妙错误因为你的类型可能不是能很完美替代目标类型。有一个副作用就是当你修改目标类型的状态可能不会对你的类型产生相同的影响。更糟糕的是,如果你的转换操作符返回一个临时对象,这个临时对象将会被垃圾回收而永久丢失。应用转换操作符的规则是基于编译时的类型,而不是运行时的对象的类型。使用你的类可能需要执行多次转换操作符,这个实践会导致代码很难维护。
如果你想转换任意类型到你的类型,使用构造器。这个更清晰的反应了创建对象的行为。转换操作符会在代码中引入很难发现的问题。假设你的代码的库的继承结构如图1.1一样。 Circle 和 Ellipse 是 Shape 的子类。你打算不考虑这个结构关系,因为你认为,即使 Cirle 和 Ellipse 是相关的,但你不想要结构中非抽象的兄弟关系,当你尝试从 Ellips 类对象得到 Circle 对象就会发现几个实现问题。然而,你实现的每个 Circle 对象都可以是一个 Ellipse 对象。此外,一些 Ellipse 对象可以代替 Circle 对象。
这导致你添加两个转换操作符。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(Circle c)
{
return new Ellipse(c.center, c.center,c.radius, c.radius);
}
}
既然你已经有一个隐式转换操作符,你可以任何期望是 Ellipse 的地方使用 Circle 。此外,这个转换是自动发生的:
public static double ComputeArea(Ellipse e)
{
// return the area of the ellipse.
return e.R1 * e.R2 * Math.PI;
}
// call it:
Circle c1 = new Circle(new PointF(3.0f, 0), 5.0f);
ComputeArea(c1);
这个例子就是我说的代替:一个 Circle 对象可以代替 Ellipse 对象。ComputeArea 函数甚至能在代替后工作。你获得好运气。但是研究下这个函数:
public static 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 方法需要 Ellipse 的参数。编译器会某些情况下会将 Ellipse 转换为 Circle 。你定义的隐式转换就是实现这个工作的。你的转换会被调用,而且 Flatten 函数接受的被隐式转换创建的 Ellipse 对象。这个临时对象呗 Flatten 函数修改,并且立即变成了垃圾。副作用预期是从你的 Flatten 函数发生的,但是仅仅是一个临时变量。最后的结果就是 Circle c 没有发生任何事情。
将隐式转换该为强制转换:
Circle c = new Circle(new PointF(3.0f, 0), 5.0f);
Flatten((Ellipse)c);
原来的问题还是存在。你只是强制你的使用添加强制转换来引起这个问题。你还是创建临时对象, Flatten 函数作用在这个临时对象上,并且丢失这个对象。 Circle 根本没有被改变。相反,如果你创建一个构造器转换 Circle 到 Ellipse ,这个操作就更清晰了:
Circle c = new Circle(new PointF(3.0f, 0), 5.0f);
Flatten(new Ellipse(c));
大多数程序员会看到上面两行代码而立即发现传给 Flatten() 的 Ellipse 的任何修改都会丢失。他们会持有一个新的对象来修复这个问题:
Circle c = new Circle(new PointF(3.0f, 0), 5.0f);
Flatten(c);
// Work with the circle.
// ...
// Convert to an ellipse.
Ellipse e = new Ellipse(c);
Flatten(e);
变量持有了被 Flatten 修改的 Ellipse 对象。通过构造函数代替转换操作符,你还没有失去任何功能,你只是创建新对象就使它更清晰。(老练的 C++ 程序员,应注意, C# 调用构造函数不会进行隐式或显式转换。您可以创建只有当你明确地使用 new 运算符的新对象,并在没有其他时候。所以在 C# 构造器中不需要 explicit 关键字。)
转换操作符你使得对象返回原来没有的行为的域。这会其他一些问题。你隐藏了一个大漏洞在封装的类中。当强制你的类型转换到另一对象时,使用你这个类的可以访问内部变量。在原则26中讨论了最好避免的所有原因。
转换操作符引入了代替的一个方式但会以前你的代码抛出问题。你必须明确:用户预期的类可以在你创建的这个类的对象任何地方使用。当对象的代替可用,你使得调用者用的是你创建的临时对象或能访问内部域。这个微妙的错误很难被发现因为编译器产生了转换对象的代码。在你的 API 里避免转换操作。
小结:
这个原则虽然很简短但却很精辟,告诉我们实际编程应该更多通过规范来约束代码的行为,而不是将诸多情况都交给编译器来处理,这样总会被自己坑到的,要能在代码中严格控制自己的逻辑。周末还有好多工作,加油!
欢迎各种不爽,各种喷,写这个纯属个人爱好,秉持”分享“之德!