多模式 .NET,第 4 部分:面向对象
Ted Neward
在上一篇文章中,我们探讨了通过过程编程来表示通用性和可变性,并发现了几个有趣的“滑块”,可通过这些滑块将可变性引入设计中。特别是,过程思路揭示了两种设计方法:名称和行为可变性与算法可变性。
随着程序复杂性和要求的日益提高,开发人员发现自己必须想方设法使各种子系统简单明了。我们发现,过程抽象并不会像我们期望的那样进行“缩放”。随着 GUI 的面世,一种新的编程样式已开始出现,许多了解从 Windows 3 SDK 构建 Windows 应用程序的“传统的 SDK”样式和 Charles Petzold 的经典“Windows 编程”(Microsoft Press, 1998) 的读者都将立刻认可该样式。该样式本质上是一个过程,它遵循了一个特别有趣的模式。相关功能的紧密群集节点中的每个过程都以“handle”参数为中心,大多数情况下,该参数将作为第一个(或唯一)参数,或从 Create 调用或类似调用返回该参数:例如,CreateWindow、FindWindow、ShowWindow 等都以窗口句柄 (HWND) 为中心。
当时开发人员未意识到这实际上是一种新的编程方式,几年之后它才让人觉得是一种新的模式。当然,他们事后认识到,它很明显是面向对象的编程,并且这个专栏的大多数读者已熟知其规则和理念。既然如此,我们为何不决定用宝贵的专栏篇幅来描述该主题呢?这是因为,在未将对象并入多模式设计范围的情况下,无法完成多模式设计。
对象基础知识
从很多方面来看,面向对象都是对继承的运用。实现继承是对象设计讨论的主要内容,提倡者建议通过标识系统中的实体和存在通用性的地方,然后将这些通用性提升到一个基类中,创建“属于”关系,从而构建适当的抽象(“名词”)。学生属于人、讲师属于人、人属于对象等。这样,继承就为开发人员提供了分析通用性和可变性的新基准线。
在使用 C++ 的时期,实现继承方法是独立的,但随着时间的推移和经验的不断累积,接口继承已作为一种替代方式出现。实际上,将接口继承引入设计者的工具箱可实现轻型继承关系,声明类型属于一个不同的类型,但不具有父类型的行为或结构。因此,接口提供了一个用于以继承为核心对类型进行“分组”的机制,而不会在其实现上强制施加任何特定限制。
例如,请考虑面向规范对象的示例,它是可绘制(如果只是象征性地)到屏幕的几何形状的层次结构:
class Rectangle
{
public int Height { get; set; }
public int Width { get; set; }
public void Draw() { Console.WriteLine("Rectangle: {0}x{1}", Height, Width); }
}
class Circle
{
public int Radius { get; set; }
public void Draw() { Console.WriteLine("Circle: {0}r", Radius); }
}
各个类之间的通用性表明超类在此处很适用,可避免在每个可绘制的几何形状重复通用性:
abstract class Shape
{
public abstract void Draw();
}
class Rectangle : Shape
{
public int Height { get; set; }
public int Width { get; set; }
public override void Draw() {
Console.WriteLine("Rectangle: {0}x{1}", Height, Width); }
}
class Circle : Shape
{
public int Radius { get; set; }
public override void Draw() { Console.WriteLine("Circle: {0}r", Radius); }
}
目前为止未出现什么问题 - 大多数开发人员到目前为止未对已执行的操作提出任何问题。 遗憾的是,问题总是会在不经意间出现。
再次介绍 Liskov
以下就是所谓的 Liskov 替换原则:继承自另一个类型的任何类型必须对该类型是完全可替换的。 或者,借用最初描述该原则的话:“使 q(x) 成为有关类型为 T 的对象 x 的可验证属性, 而 q(y) 应成为有关类型为 S 的对象 y 的可验证属性,其中 S 是 T 的子类型。”
在实践中,这意味着 Rectangle 的任何特定派生(如 Square 类)必须确保它遵守由基提供的同一行为保证。 由于 Square 实际是一个 Height 和 Width 始终确保相同的 Rectangle,因此像图 1 中的示例一样编写 Square 似乎是合理的。
图 1 派生 Square
class Rectangle : Shape
{
public virtual int Height { get; set; }
public virtual int Width { get; set; }
public override void Draw() {
Console.WriteLine("Rectangle: {0}x{1}", Height, Width); }
}
class Square : Rectangle
{
private int height;
private int width;
public override int Height {
get { return height; }
set { Height = value; Width = Height; }
}
public override int Width {
get { return width; }
set { Width = value; Height = Width; }
}
}
请注意,Height 和 Width 属性此时都是虚拟的,这是为了避免在 Square 类中重写它们时出现任何意外的阴影或切片行为。 至此不会有什么问题。
紧接着,一个 Square 会传入一个方法中,此方法采用一个 Rectangle 并将它“增大”(图形极客有时会调用“转换”):
class Program
{
static void Grow(Rectangle r)
{
r.Width = r.Width + 1;
r.Height = r.Height + 1;
}
static void Main(string[] args)
{
Square s = new Square();
s.Draw();
Grow(s);
s.Draw();
}
}
图 2 显示了调用此代码所获得的最终结果,该结果不是您可能的预期结果。
图 2 调用 Grow 代码获得的意外结果
细心的读者可能已猜到,此处的问题在于,每个属性实现都假定被单独调用,从而必须单独操作以确保始终施加有关 Square 的 Height==Width 这一限制。但 Grow 代码假定传入的是 Rectangle,它完全不知道实际传入的是 Square(按照预期!),并会按照完全适用于 Rectangle 的方式操作。
问题的核心是什么呢?正方形不是长方形。就算它们有很多相似之处,但最终正方形的限制不适用于长方形(顺便说一下,对于椭圆和圆也是如此),尝试根据一个对象对另一个对象进行建模是错误的。尝试从 Rectangle 继承 Square,因为它允许我们重用一些代码,但这是一个错误的假设。实际上,我甚至建议在这两个类型的 Liskov 替换原则被证实正确之前,任一类型都绝不应使用继承来推动重用。
该示例并不是一个新示例 - Robert “Uncle Bob” Martin (bit.ly/4F2R6t) 在 90 年代中期与 C++ 开发人员交谈时曾讨论过 Liskov 和该示例。通过使用接口来描述关系可部分解决一些像这样的问题,但对于这一特定情况不起作用,因为 Height 和 Width 仍是单独属性。
这种情况有解决方案吗?还真没有,没有一个可以同时保留“Square 从 Rectangle 派生”关系的解决方案。最佳解决办法是使 Square 成为 Shape 的直接后代,并完全弃用继承方法:
class Square : Shape
{
public int Edge { get; set; }
public override void Draw() { Console.WriteLine("Square: {0}x{1}", Edge, Edge); }
}
class Program
{
static void Main(string[] args)
{
Square s = new Square() { Edge = 2 };
s.Draw();
Grow(s);
s.Draw();
}
}
当然,我们现在的问题是根本无法将 Square 传入 Grow,似乎那里有一个潜在的代码重用关系。 我们可以使用转换操作将 Square 的视图提供为 Rectangle,来从一个方面解决此问题,如图 3 所示。
图 3 转换操作
class Square : Shape
{
public int Edge { get; set; }
public Rectangle AsRectangle() {
return new Rectangle { Height = this.Edge, Width = this.Edge };
}
public override void Draw() { Console.WriteLine("Square: {0}x{1}", Edge, Edge); }
}
class Program
{
static void Grow(Rectangle r)
{
r.Width = r.Width + 1;
r.Height = r.Height + 1;
}
static void Main(string[] args)
{
Square s = new Square() { Edge = 2 };
s.Draw();
Grow(s.AsRectangle());
s.Draw();
}
}
这样做很有用 – 只不过操作起来有点难。我们还可以使用 C# 转换运算符工具更轻松地将 Square 转换为 Rectangle,如图 4 所示。
图 4 C# 转换运算符工具
class Square : Shape
{
public int Edge { get; set; }
public static implicit operator Rectangle(Square s) {
return new Rectangle { Height = s.Edge, Width = s.Edge };
}
public override void Draw() { Console.WriteLine("Square: {0}x{1}", Edge, Edge); }
}
class Program
{
static void Grow(Rectangle r)
{
r.Width = r.Width + 1;
r.Height = r.Height + 1;
}
static void Main(string[] args)
{
Square s = new Square() { Edge = 2 };
s.Draw();
Grow(s);
s.Draw();
}
}
虽然此方法可能与预期的方法有明显的不同,但它提供了与以前相同的客户端角度,而且不存在早期实现中出现的问题,如图 5 所示。
图 5 使用 C# 转换运算符工具获得的结果
实际上,我们有一个不同的问题 – 在 Grow 方法修改要传入的 Rectangle 之前,它看上去未执行任何操作,这在很大程度上是因为它修改的是 Square 的副本,而不是最初的 Square。我们可以通过以下方式修复该问题:通过让转换运算符将 Rectangle 的一个包含机密引用的新子类返回此 Square 实例,以便对 Height 和 Width 属性所做的修改将依次返回并修改 Square 的边...但在那之后,我们就会回到原来的问题!
无法得到满意的结果
好莱坞电影的结局必须符合观众的预期,不然的话,票房收入就很难得到保证。我不是电影制作人,因此在任何情况下,我都无需向本专栏的读者呈现令其满意的结果。以下是其中的一种情况:尝试将原始代码保留在适当位置,并使其完全用于创建越来越高级的技巧。可能的解决方案是,直接将 Grow 或 Transform 方法移动到 Shape 层次结构上或仅使 Grow 方法返回修改后的对象,而不是修改传入的对象(我们将在另一个专栏中谈论此内容),简而言之,我们无法将原始代码保留在适当的位置并使所有内容正常运行。
所有这些旨在明确展示一点,即面向对象的开发人员可轻松使用继承对通用性和可变性进行建模,可能这样说有点夸张。记住,如果您选择使用继承轴来捕获通用性,则要避免像这样的细小 Bug,您必须确保此通用性贯穿于整个层次结构。
另请记住,继承始终是正可变性(添加新的字段或行为),对继承中的负可变性进行建模(Square 尝试执行的操作)几乎总是会遵循 Liskovian 规则带来灾难。确保所有基于继承的关系包含正通用性,并且所有操作应是正常的。祝您工作愉快!
Ted Neward是 Neward & Associates 的负责人,这是一家专门研究企业 .NET Framework 系统和 Java 平台系统的独立公司。他曾写过 100 多篇文章,是 C# 领域最优秀的专家之一;是 INETA 发言人;并且著作或合著过十几本书,包括《Professional F# 2.0》(Wrox,2010 年)。此外,他还定期提供咨询和指导。您可以通过 ted@tedneward.com 向他提问或咨询,也可以访问他的博客 (blogs.tedneward.com)。
*衷心感谢以下技术专家对本文的审阅:*Anthony Green