领先技术

代码协定中的固定条件和继承

Dino Esposito

Dino Esposito
在本专栏的前几篇文章中,我介绍了两种最常见的软件协定类型(即前置条件和后置条件),并且从 Microsoft .NET Framework 4 中代码协定 API 的角度分析了其语法和语义。本月我将首先介绍第三种最重要的协定类型,即固定条件,然后再探讨当您应用继承时基于协定的类的行为。

固定条件

一般来说,固定条件就是一种在给定的上下文中始终为 true 的条件。在应用于面向对象的软件时,固定条件指示一种针对类的各个实例始终为 true 的条件。固定条件是一种强大的工具,每当给定类的任何实例的状态失效时,它都会及时通知您。换言之,固定条件协定正式定义据以推测类的实例处于良好状态的条件。虽然听起来很重要,但这只是在通过类对业务域建模时要先了解后实施的第一个概念。域驱动设计(DDD) 是目前用于为复杂业务方案建模的成熟方法,而且可在设计时为固定条件逻辑分配一个重要位置。实际上,DDD 极力建议您永远都不要处理处于无效状态的类的实例。同样,DDD 还建议您编写返回有效状态的对象的类的工厂,并且您的对象在每次操作后都以有效状态返回。

DDD 是一种方法,协定的实际实施应该由您来完成。在 .NET Framework 4 中,代码协定可最大限度地减少您的工作量,有效帮助您成功地进行实施。我们来更详细地了解一下 .NET Framework 4 中的固定条件。

固定条件逻辑在哪里?

固定条件是否在某种程度上与优秀的对象建模和软件设计有关?深入了解域至关重要。深入了解域自然会指导您找到您的固定条件。有些类根本不需要固定条件。从根本上说,缺乏固定条件并不是一种警告信号。对应该包含什么内容以及应该执行什么操作没有限制的类就没有固定条件。如果这是您的分析得出的结果,那么再好不过了。

假定有一个类代表要发布的新闻。该类可能有标题、摘要和发布日期。此处的固定条件在哪里?这取决于业务域。发布日期是必需的吗?如果是,则您应该确保新闻始终有有效日期,而“有效日期”的定义也来源于上下文。如果日期是可选的,则您可以保存一个固定条件,并确保先验证属性的内容,然后再将该属性应用于要在其中使用它的上下文中。标题和摘要也可以以同样的方式处理。既没有标题也没有内容的新闻是否有意义?如果这在您正在考虑的业务方案中有意义,则您有一个无固定条件的类。如果没有意义,则要准备好添加几项检查,以防标题和内容为空。

更常见的情况是,无任何行为且充当松散关系数据的容器的类可能缺乏固定条件。如有疑问,我建议您对该类的每个属性都问问“我是否可以在这里存储值?”,而不需要考虑属性是公用的、受保护的还是私用的(只要是通过方法设定的)。这样做应该有助于具体了解您是否会遗漏模型的要点。

与设计的许多其他方面一样,如果在设计过程的早期查找固定条件,则会更富有成效。在开发过程的晚期添加固定条件始终都是可行的,但这样做会增加您在重构方面的成本。如果要这样做,则必须小心,当心回归。

代码协定中的固定条件

在 .NET Framework 4 中,类的固定条件协定是对该类的任何实例始终应为 true 的条件的集合。向类中添加协定时,前置条件用于在该类的调用程序中查找错误,而后置条件和固定条件则用于在类及其子类中查找错误。

您需要通过一个或多个专用的方法来定义固定条件协定。这类方法是实例方法,它们是私有的,返回 void 且有特殊属性(ContractInvariantMethod 属性)加以修饰。此外,固定条件方法不得包含定义固定条件所需的调用之外的代码。例如,您不能在固定条件方法中添加任何类型的逻辑,无论逻辑是否纯净都不行。您甚至不能添加只是用于记录类的状态的逻辑。下面介绍如何为类定义固定条件协定:

public class News {
  public String Title {get; set;}   
  public String Body {get; set;}

  [ContractInvariantMethod]
  private void ObjectInvariant()
  {
    Contract.Invariant(!String.IsNullOrEmpty(Title));
    Contract.Invariant(!String.IsNullOrEmpty(Body));
  }
}

News 类的固定条件是 Title 和 Body 永远不能为 null 或空。 请注意,为了使此代码起作用,您需要根据情况在各种内部版本的项目配置中启用全面的运行时检查(请参见图 1)。

Invariants Require Full Runtime Checking for Contracts

图 1 固定条件要求对协定执行全面的运行时检查

现在,尝试以下简单代码:

var n = new News();

收到协定失败异常会让您感到很吃惊。 您已成功创建 News 类的新实例;遗憾的是该实例的状态无效。 我们需要从一个新的角度看待固定条件。

在 DDD 中,固定条件与工厂的概念相关。 工厂只是负责创建类的实例的公共方法。 在 DDD 中,各个工厂负责返回处于有效状态的域实体的实例。 关键在于,当您使用固定条件时,您应该保证在任意给定时间内均符合条件。

但是有哪些特定时间呢? DDD 及代码协定的实际实施均同意在退出任何公共方法(包括构造函数和 setter)时检查固定条件。 图 2 显示了添加构造函数的 News 类的修订版本。 除了以下一点外,工厂与构造函数基本相同:工厂是静态方法,可拥有自定义且与上下文相关的名称并可产生更具可读性的代码。

图 2 固定条件和可识别固定条件的构造函数

public class News
{
  public News(String title, String body)
  {
    Contract.Requires<ArgumentException>(
      !String.IsNullOrEmpty(title));
    Contract.Requires<ArgumentException>(
      !String.IsNullOrEmpty(body));

    Title = title;
    Body = body;
  }

  public String Title { get; set; }
  public String Body { get; set; }

  [ContractInvariantMethod]
  private void ObjectInvariant()
  {
    Contract.Invariant(!String.IsNullOrEmpty(Title));
    Contract.Invariant(!String.IsNullOrEmpty(Body));
  }
}

使用 News 类的代码可以如下所示:

var n = new News("Title", "This is the news");

因为实例是在满足固定条件的状态下创建并返回的,所以此代码不会引发任何异常。 如果添加以下行,情况会怎样:

var n = new News("Title", "This is the news");
n.Title = "";

将 Title 属性设置为空字符串会使对象处于无效状态。 因为固定条件是在退出公共方法时检查的,而属性 setter 是公共方法,所以您会再次收到异常消息。 有趣的是,如果您使用公用字段而不是公用属性,您会注意到不会检查固定条件,而且代码运行状况良好。 但您的对象处于无效状态。

请注意,使对象处于无效状态不一定是问题的根源。 但在大型系统中,为了安全起见,您可能需要妥善地进行管理,以便在遇到无效状态时自动收到异常信息。 这有助于您管理开发工作和执行测试。 在小型应用程序中,固定条件可能不是必需的,甚至在分析时出现的一些固定条件也不是必需的。

尽管在退出公共方法时必须验证固定条件,但任何公共方法的主体内部的状态都可能是暂时无效的。 重点在于,固定条件在执行公共方法前后都为 true。

如何防止对象进入无效状态? 静态分析工具(如 Microsoft Static Code Checker)能够检测到给定任务是否会违反固定条件。 固定条件使您免受中断行为的危害,同时还可以帮助确定未明确指定的输入。 通过正确指定这些输入,您可以更轻松地在使用给定类的代码中找到错误。

协定继承

图 3 显示了另一个定义了固定条件方法的类。 此类可充当域模型的根。

图 3 域模型的基于固定条件的根类

public abstract class DomainObject
{
  public abstract Boolean IsValid();

  [Pure]
  private Boolean IsValidState()
  {
    return IsValid();
  }

  [ContractInvariantMethod]
  private void ObjectInvariant()
  {
    Contract.Invariant(IsValidState());
  }
}

在 DomainObject 类中,固定条件通过被声明为纯方法(即不发出状态警报)的私有方法表示。 在内部,私有方法调用派生的类将用于指示其自身固定条件的抽象方法。 图 4 显示了可能派生自覆盖 IsValid 方法的 DomainObject 的类。

图 4 覆盖固定条件使用的方法

public class Customer : DomainObject
{
  private Int32 _id;
  private String _companyName, _contact;

  public Customer(Int32 id, String company)
  {
    Contract.Requires(id > 0);
    Contract.Requires(company.Length > 5);
    Contract.Requires(!String.IsNullOrWhiteSpace(company));

    Id = id;
    CompanyName = company;
  }
  ...
public override bool IsValid()
  {
    return (Id > 0 && !String.IsNullOrWhiteSpace(CompanyName)); 
  }
}

该解决方案简单而有效。 我们来尝试获取传递有效数据的 Customer 类的一个新实例:

var c = new Customer(1, "DinoEs");

如果我们停止查看 Customer 构造函数,一切都显得完美无暇。 但是,因为 Customer 从 DomainObject 进行继承,所以要调用 DomainObject 构造函数并检查固定条件。 因为 DomainObject 上的 IsValid 是虚拟的(实际上,是抽象的),所以会根据针对 Customer 的定义将调用重定向到 IsValid。 遗憾的是,检查的实例尚未完全初始化。 您会收到异常消息,但这不是您的错。 (在最新发布的代码协定中,此问题已得到解决,而且直到调用最外部构造函数后才会对构造函数执行固定条件检查。)

此方案映射了一个已知问题:不要从构造函数中调用虚拟成员。 在此例中,发生这种情形的原因并不是您直接以这种方式进行编码,而是协定继承产生了负面影响。 您有两个解决方案:从基类中删除抽象 IsValid,或借助图 5 中的代码。

图 5 覆盖固定条件使用的方法

public abstract class DomainObject
{
  protected Boolean Initialized; 
  public abstract Boolean IsValid();

  [Pure]
  private Boolean IsInValidState()
  {
    return !Initialized || IsValid();
  }

  [ContractInvariantMethod]
  private void ObjectInvariant()
  {
    Contract.Invariant(IsInValidState());
  }
}

public class Customer : DomainObject
{
  public Customer(Int32 id, String company)
  {
     ...
Id = id;
    CompanyName = company;
    Initialized = true;
  }
     ...
}

Initialized 保护的成员充当安保成员,它不会调入已覆盖的 IsValid,直至初始化了实际对象。 Initialized 成员可以是字段,也可以是属性。 不过,如果是属性,您会得到第二轮固定条件 — 严格说来这并不是必需的,因为已经将一切检查了一遍。 在这一方面,使用字段会使代码的运行速度略胜一筹。

派生的类自动接收针对其基类定义的协定,因此从这个意义上来说,协定继承是自动的。 这样,派生的类便会将其自己的前置条件添加到基类的前置条件。 后置条件和固定条件也一样。 处理继承链时,中间语言重写程序会累加协定,并在适当的位置和时间以正确的顺序调用协定。

注意

固定条件并非无懈可击。 有时,固定条件一方面可提供帮助,另一方面则会引起问题,尤其是用在每个类和类层次结构的上下文中时。 尽管您始终都应尽力确定类中的固定条件逻辑,但如果实施固定条件会遇到极端情况,那么我建议您最好从实施中将其排除。 但是,请记住,您遇到的极端情况可能是由模型中的复杂性所导致的。 固定条件是您管理实施问题所需的工具;它们本身并不是问题。

至少有一个理由可以说明累加整个类层次结构的协定是一种微妙的操作。 这不是一两句话可以说清楚的,我们在这里就不讨论了,不过这为下个月的文章提供了很好的素材。

Dino Esposito是《Programming Microsoft ASP.NET 4》(Microsoft Press,2011 年)的作者,同时也是《Microsoft .NET:Architecting Applications for the Enterprise》(Microsoft Press,2008 年)的合著者。Esposito 定居于意大利,经常在世界各地的业内活动中发表演讲。请关注他的 Twitter:twitter.com/despos

衷心感谢以下技术专家对本文的审阅:Manuel FahndrichBrian Grunkemeyer