领先技术

C# 4.0 中的 Expando 对象

Dino Esposito

下载代码示例

大多数为 Microsoft .NET Framework 编写的代码都是基于静态类型化的,尽管 .NET 通过反射支持动态类型化。此外,如同 Visual Basic 一样,JScript 10 年前也在 .NET 基础上拥有一个动态类型系统。静态类型化意味着每个表达式都属于一个已知的类型。类型和赋值在编译时均经过验证,因此大多数可能的类型化错误都会被提前发现。

有一个众所周知的例外,那就是当您尝试在运行时执行类型转换时,如果源类型与目标类型不兼容,有时可能会导致动态错误。

静态类型化性能良好、清晰明了,但这是建立在您事先对您的代码(和数据)近乎完全了解这样的假设之上的。现在,大家强烈希望能够将这个限制放宽一点。超越静态类型化通常意味着面临三种截然不同的选择:动态类型化、动态对象以及间接或基于反射的编程。

在 .NET 编程中,从 .NET Framework 1.0 开始就具备了反射功能,并且已经广泛应用以推动特殊框架的发展,例如控制反转 (IoC) 容器。这些框架用于解决运行时的类型依赖关系,从而使您的代码能够直接使用接口,而无需了解对象之后的具体类型及其实际行为。使用 .NET 反射,您能够实现各种间接编程,以便您的代码能够与中间对象进行通信,然后由后者调度对固定接口的调用。您可以通过字符串的形式传递要调用的成员名称,从而使您具有能够从某些外部源读取该名称的灵活性。目标对象的接口是固定不变的 - 在您通过反射执行的任何调用后面,总是会有一个众所周知的接口。 

动态类型化意味着您编译的代码将会忽略可以在编译时检测到的静态类型结构。实际上,动态类型化把所有的类型检查都推迟到了运行时进行。您编写代码时使用的接口仍然是固定不变的,但是您使用的值在不同的时刻可能会返回不同的接口。

.NET Framework 4 引入了一些能够超越静态类型的新功能。我在 2010 年 5 月刊中介绍了新的 dynamic 关键字。在本文中,我会探讨对动态定义的类型(例如 Expando 对象)和动态对象的支持。通过动态对象,您可以通过编程的方式定义类型的接口,而不必通过静态存储在某些程序集中的定义来读取它。动态对象结合了静态类型化对象的正式清洁度和动态类型的灵活性。

动态对象方案

动态对象的目标并不是要取代高质量的静态类型。在可预见的将来,静态类型仍然会保留在软件开发的基础中。通过静态类型化,您可以在编译时可靠地查找类型错误并生成代码;而且正因为如此,生成的代码无需在运行时进行检查,运行速度更快。此外,略过编译步骤的需求使得开发人员和架构师必须在设计软件和定义交互层的公共接口时格外小心。

然而,还是有这样的情况,您要通过编程的方式使用结构相对良好的数据块。理想情况下,您希望这些数据由对象提供。但是相反,无论您是通过网络连接获得还是从磁盘文件读取这些数据,您都是以纯数据流的形式收到的。您有两种选择来使用这些数据:使用间接方法或使用专门类型。

在第一种情况下,您需要采用泛型 API 作为代理,并为您安排查询和更新。在第二种情况下,您会有一个专门的类型,能够完美地为您处理的数据建模。问题是,谁来创建这样一个专门的类型?

在 .NET Framework 的某些部分中,已经有了一些很好的内部模块示例,示范如何为特定的数据块创建专门类型。一个明显的示例就是 ASP.NET Web 窗体。当您发出关于 ASPX 资源的请求时,Web 服务器会检索 ASPX 服务器文件的内容。该内容随后被加载到一个字符串中,以便在 HTML 响应中进行处理。这样您就有了一段结构相对良好的文本可供使用。

若要对此数据进行操作,您需要了解您对服务器控件有哪些引用,然后妥善实例化这些引用并将它们链接到一个页面中。这些完全能够通过为每个请求使用一个基于 XML 的解析程序来实现。但如果这么做,您就需要为每个请求额外付出解析程序的成本,而这项成本有可能是不可接受的。

考虑到因解析数据而额外增加的成本,ASP.NET 团队决定引入一个一次性步骤,将标记解析到一个类中,而使该类能够动态编译。这样,通过从 Web 窗体页的代码隐藏类派生出的一个专门类,将使用一段类似以下代码的简单标记:

<html>
<head runat="server">
  <title></title>
</head>
<body>
  <form id="Form1" runat="server">
    <asp:TextBox runat="server" ID="TextBox1" /> 
    <asp:Button ID="Button1" runat="server" Text="Click" />
    <hr />
    <asp:Label runat="server" ID="Label1"></asp:Label>
  </form>
</body>
</html>

图 1 显示了由标记创建出来的类的运行时结构。灰色的方法名称指的是内部过程,用于将带有 runat=server 元素的元素解析到服务器控件的实例中。

图 1 动态创建的 Web 窗体类的结构

您可以将此方法应用到几乎任何情况中,只要您的应用程序需要反复接收外部数据以进行处理。以流入应用程序的 XML 数据流为例。有多种 API 可以处理 XML 数据,范围从 XML DOM 到 LINQ-to-XML。在任何情况下,您都必须通过查询 XML DOM 或 LINQ-to-XML API 进行间接处理,或使用相同的 API 将原始数据解析到专门对象中。

在 .NET Framework 4 中,动态对象提供了替代方法 - 一个更简单的 API,可基于部分原始数据动态创建类型。以下 XML 字符串是一个简单的示例:

<Persons>
  <Person>  
    <FirstName> Dino </FirstName>
    <LastName> Esposito </LastName>
  </Person>
  <Person>
    <FirstName> John </FirstName>
    <LastName> Smith </LastName>
  </Person>  
</Persons>

在 .NET Framework 3.5 中,若要将其转换为可编程的类型,您可能要使用类似图 2 中的代码。

图 2 使用 LINQ-to-XML 将数据加载到 Person 对象中

var persons = GetPersonsFromXml(file);
foreach(var p in persons)
  Console.WriteLine(p.GetFullName());

// Load XML data and copy into a list object
var doc = XDocument.Load(@"..\..\sample.xml");
public static IList<Person> GetPersonsFromXml(String file) {
  var persons = new List<Person>();

  var doc = XDocument.Load(file);
  var nodes = from node in doc.Root.Descendants("Person")
              select node;

  foreach (var n in nodes) {
    var person = new Person();
    foreach (var child in n.Descendants()) {
      if (child.Name == "FirstName")
        person.FirstName = child.Value.Trim();
      else
        if (child.Name == "LastName")
          person.LastName = child.Value.Trim();
    }
    persons.Add(person);
  }

  return persons;
}

此代码使用 LINQ-to-XML 将原始内容加载到 Person 类的一个实例中:

public class Person {
  public String FirstName { get; set; }
  public String LastName { get; set; }
  public String GetFullName() {
    return String.Format("{0}, {1}", LastName, FirstName);
  }
}

.NET Framework 4 提供了一个不同的 API 来实现相同的目的。此 API 是新的 ExpandoObject 类的中心,更容易编写,而且不需要您规划、编写、调试、测试和维护 Person 类。让我们深入探讨一下 ExpandoObject。

使用 ExpandoObject 类

Expando 对象不是为 .NET Framework 发明的,事实上,它们比 .NET 还早出现几年。我第一次听到有人用这个术语来描述 JScript 对象是在 20 世纪 90 年代中期。Expando 是一种可扩充的对象,其结构完全是在运行时定义的。在 .NET Framework 4 中,您像使用传统的托管对象一样使用 Expando,不同之处在于其结构不在任何程序集外读取,而完全是动态构建的。

Expando 对象非常适合对动态变化的信息进行建模,例如配置文件的内容。让我们看看如何使用 ExpandoObject 类来存储前述 XML 文档的内容。图 3 中显示了完整的源代码。

图 3 使用 LINQ-to-XML 将数据加载到 Expando 对象中

public static IList<dynamic> GetExpandoFromXml(String file) { 
  var persons = new List<dynamic>();

  var doc = XDocument.Load(file);
  var nodes = from node in doc.Root.Descendants("Person")
              select node;
  foreach (var n in nodes) {
    dynamic person = new ExpandoObject();
    foreach (var child in n.Descendants()) {
      var p = person as IDictionary<String, object>);
      p[child.Name] = child.Value.Trim();
    }

    persons.Add(person);
  }

  return persons;
}

函数将返回一系列动态定义的对象。通过 LINQ-to-XML,您可以解析出标记中的节点,并为每个节点创建一个 ExpandoObject 实例。<Person> 下的每个节点的名称都会成为 Expando 对象的一个新属性。属性值是节点的内部文字。基于 XML 内容,您得到了一个 Expando 对象,其 FirstName 属性设置为 Dino。

然而,在图 3 中,您可以看到一个索引器语法,用于填充 Expando 对象。这还需要做进一步的解释。

在 ExpandoObject 类中

ExpandoObject 类位于 System.Dynamic 命名空间中,在 System.Core 程序集中定义。ExpandoObject 代表一个对象,该对象的成员可以在运行时动态添加或删除。该类是密封的,并且可以实现多个接口:

public sealed class ExpandoObject : 
  IDynamicMetaObjectProvider, 
  IDictionary<string, object>, 
  ICollection<KeyValuePair<string, object>>, 
  IEnumerable<KeyValuePair<string, object>>, 
  IEnumerable, 
  INotifyPropertyChanged;

正如您看到的,该类使用了多个可枚举接口,包括 IDictionary<String, Object> 和 IEnumerable,来提供其内容。此外,它还实现了 IDynamicMetaObjectProvider。这是一个标准接口,能够让某个对象在动态语言运行时 (DLR) 内由依照 DLR 互操作性模型编写的程序共享。换句话说,只有实现 IDynamicMetaObjectProvider 接口的对象能够跨 .NET 动态语言共享。例如,Expando 对象可被传递到 IronRuby 这样的组件。如果使用常规的 .NET 托管对象,就无法轻易做到这一点。或许您可以,但不会获得动态行为。

ExpandoObject 类还实现了 INotifyPropertyChanged 接口。这样,添加或修改成员时,该类就会引发一个 PropertyChanged 事件。支持 INotifyPropertyChanged 接口对于在 Silverlight 和 Windows Presentation Foundation 应用程序前端使用 Expando 对象至关重要。

创建 ExpandoObject 实例的方法与创建其他任何 .NET 对象一样,只是存储实例的变量是 dynamic 类型的:

dynamic expando = new ExpandoObject();

此时,为 Expando 对象添加一个属性只是为它分配了一个新的值,如下所示:

expando.FirstName = "Dino";

即使没有任何关于 FirstName 成员及其类型或可见性的信息,也没有关系。这是一个动态代码。正是因为这个原因,如果您使用 var 关键字将 ExpandoObject 实例分配给一个变量,结果会大为不同:

var expando = new ExpandoObject();

此代码的编译和运行都会很正常。但是,根据这个定义,您不允许为 FirstName 属性分配任何值。System.Core 中定义的 ExpandoObject 类没有这样的成员。更准确地说,ExpandoObject 类没有任何公共成员。

这一点很关键。当 Expando 对象的静态类型为 dynamic 时,操作就会绑定为动态操作,包括查找成员。当静态类型为 ExpandoObject 时,操作就会绑定为普通的编译时成员查找。因此,编译器知道 dynamic 是一个特殊类型,但是不知道 ExpandoObject 是一个特殊类型。

图 4 中,您可以看到当 Expando 对象被声明为 dynamic 类型,以及它被当作纯 .NET 对象时,不同的 Visual Studio 2010 智能感知选项。在后一种情况中,智能感知显示了默认的 System.Object 成员,以及集合类的扩展方法列表。

图 4 Visual Studio 2010 智能感知和 Expando 对象

还应注意,在有些情况下,某些商用工具还会提供更多行为。图 5 显示了 ReSharper 5.0,该工具用于捕获对象中当前定义的成员列表。如果成员是通过索引器以编程方式添加的,就不会发生这种情况。

图 5 ReSharper 5.0 智能感知与 Expando 对象

若要向 Expando 对象添加方法,只需将其定义为属性,除非您使用 Action<T> 或 Func<T> 委托来表达行为。例如:

person.GetFullName = (Func<String>)(() => { 
  return String.Format("{0}, {1}", 
    person.LastName, person.FirstName); 
});

方法 GetFullName 会返回一个字符串,该字符串是通过将 Expando 对象中假设存在的姓和名属性合并起来获得的。如果您尝试访问 Expando 对象中缺少的成员,将会收到 RuntimeBinderException 异常。 

由 XML 驱动的程序

为了让您综合理解到目前为止我所讲过的概念,让我为您介绍一个示例,其中数据结构和 UI 结构均在 XML 文件中定义。文件内容被解析到一系列 Expando 对象中,并由应用程序处理。但是,应用程序只处理动态形式的信息,也并未绑定到任何静态类型。

图 3 中的代码定义了一系列动态定义的 person Expando 对象。正如您期望的,如果向 XML 架构中添加一个新节点,就会在 Expando 对象中创建一个新属性。如果您需要从外部源读取成员的名称,应当使用索引器 API 将其添加到 Expando 中。ExpandoObject 类显式实现了 IDictionary<String, Object> 接口。这意味着您需要将 ExpandoObject 接口从字典类型中隔离,以便使用索引器 API 或 Add 方法:

(person as IDictionary<String, Object>)[child.Name] = child.Value;

由于这个行为,您只需要编辑 XML 文件来提供另一个数据集。但是,您如何才能使用这种动态变化的数据呢?您的 UI 需要足够灵活,以便接受一组变化的数据。

让我们举一个简单的示例。在这个示例中,您需要做的就是通过控制台显示数据。假设 XML 文件包含一个部分,用于描述期望的 UI(不管这在上下文中意味着什么)。例如,下面是我的代码:

<Settings>
    <Output Format="{0}, {1}" 
      Params="LastName,FirstName" /> 
  </Settings>

此信息将会通过以下代码加载到另一个 Expando 对象中:

dynamic settings = new ExpandoObject();
  settings.Format = 
    node.Attribute("Format").Value;
  settings.Parameters = 
    node.Attribute("Params").Value;

主要过程将具备以下结构:

public static void Run(String file) {
    dynamic settings = GetExpandoSettings(file);
    dynamic persons = GetExpandoFromXml(file);
    foreach (var p in persons) {
      var memberNames = 
        (settings.Parameters as String).
        Split(',');
      var realValues = 
        GetValuesFromExpandoObject(p, 
        memberNames);
      Console.WriteLine(settings.Format, 
        realValues);
    }
  }

Expando 对象包含输出的格式,以及要显示其值的成员的名称。对于给定的 person 动态对象,您需要使用类似以下的代码加载指定成员的值:

public static Object[] GetValuesFromExpandoObject(
  IDictionary<String, Object> person, 
  String[] memberNames) {

  var realValues = new List<Object>();
  foreach (var m in memberNames)
    realValues.Add(person[m]);
  return realValues.ToArray();
}

因为 Expando 对象实现了 IDictionary<String, Object>,您可以使用索引器 API 来获得和设置值。

最后,从 Expando 对象检索到的一系列值将会传递到控制台以供实际显示。图 6 显示了示例控制台应用程序的两个屏幕,其中的区别仅仅是基础 XML 文件的结构不同。

图 6 由一个 XML 文件驱动的两个示例控制台应用程序

不可否认,这个示例非常简单,但是它的实现机制与更有意思的示例是相似的。请试一试并向我们提供反馈!

Dino Esposito 是 Microsoft Press 出版的《Programming ASP.NET MVC》一书的作者,并且是《Microsoft .NET:Architecting Applications for the Enterprise》(Microsoft Press,2008 年)的合著者。Esposito 定居于意大利,经常在世界各地的业内活动中发表演讲。您可访问他的博客,网址为 weblogs.asp.net/despos

衷心感谢以下技术专家审阅本文:Eric Lippert