C# 是静态类型语言,大多数成员访问、方法调用和类型转换都会在编译阶段完成检查。dynamic 则把这些操作的绑定推迟到运行时,使代码能够处理编译期无法确定具体类型的对象。

动态类型适合解决边界处的不确定性,但不应成为绕过类型设计的默认方式。它省去的编译期约束,最终会转化为运行时异常、测试成本和维护成本。

dynamic 做了什么

dynamic 不是一个独立的 CLR 类型。动态变量在底层仍然保存普通的 .NET 对象,只是编译器不会立即验证针对它的成员访问,而是把操作交给运行时绑定器处理。

下面两个变量保存的都是字符串,但编译器对它们的处理方式不同:

object objectValue = "hello";
dynamic dynamicValue = "hello";

// objectValue 的编译期类型是 object,必须先转换类型。
Console.WriteLine(((string)objectValue).Length);

// Length 会在运行时根据实际对象类型进行绑定。
Console.WriteLine(dynamicValue.Length);

如果运行时对象不存在相应成员,代码仍然能够通过编译,但执行时会抛出 RuntimeBinderException

using Microsoft.CSharp.RuntimeBinder;

dynamic value = 42;

try
{
    Console.WriteLine(value.Trim());
}
catch (RuntimeBinderException exception)
{
    Console.WriteLine(exception.Message);
}

什么时候使用动态对象

dynamic 的价值主要来自对象结构确实无法在编译期表达的场景,例如:

  • 与 COM、脚本运行时或其他动态语言交互。
  • 适配只在运行时公开成员的第三方对象。
  • 处理字段由用户配置、插件或元数据决定的临时结构。
  • 在系统边缘构造短生命周期的数据载体。

如果数据结构稳定,普通类、记录类型、接口或泛型通常更合适。外部 JSON 也不意味着一定要使用 dynamic;只要字段结构已知,优先反序列化为明确的 DTO。

object、dynamic 与 ExpandoObject

这几个概念经常一起出现,但解决的问题并不相同。

类型成员检查时机能否动态增删成员适用场景
object编译期只能使用 object 的成员保存任意类型,使用前显式判断或转换
dynamic运行时取决于实际对象推迟成员绑定
匿名对象编译期方法内部的固定只读结构
ExpandoObject运行时需要在运行时组装成员的对象

dynamic 决定调用如何绑定,ExpandoObject 则提供一个可以动态增删成员的实际对象。把普通对象赋给 dynamic 并不会让普通对象获得新的成员。

使用 ExpandoObject

ExpandoObject 位于 System.Dynamic 命名空间。通过 dynamic 引用它时,可以像操作普通对象一样添加属性和方法。

using System.Dynamic;

dynamic user = new ExpandoObject();
user.Name = "LT Monster";
user.Role = "Author";
user.Introduce = (Func<string>)(() => $"{user.Name} / {user.Role}");

Console.WriteLine(user.Introduce());

赋给动态成员的 Lambda 表达式需要先转换为明确的委托类型。上例使用 Func<string>,否则编译器无法确定 Lambda 应转换成哪一种委托。

按字典访问成员

ExpandoObject 实现了 IDictionary<string, object?>。需要遍历、检查或删除成员时,字典接口通常比动态语法更可靠。

using System.Dynamic;

dynamic user = new ExpandoObject();
user.Name = "LT Monster";
user.Role = "Author";

var properties = (IDictionary<string, object?>)user;

properties["Email"] = "hello@example.com";

if (properties.TryGetValue("Name", out var name))
{
    Console.WriteLine(name);
}

foreach (var (key, value) in properties)
{
    Console.WriteLine($"{key}: {value}");
}

properties.Remove("Email");

字典访问还能在读取成员前调用 ContainsKeyTryGetValue,避免成员不存在时直接触发运行时绑定异常。

尽早映射为明确类型

动态对象适合停留在输入、适配器和集成层。数据进入核心业务逻辑前,应完成字段存在性、值类型和业务规则校验,再转换为明确模型。

using System.Dynamic;

dynamic source = new ExpandoObject();
source.Name = "LT Monster";
source.Role = "Author";

var user = MapUser((IDictionary<string, object?>)source);
Console.WriteLine(user.Name);

static User MapUser(IDictionary<string, object?> values)
{
    return new User(
        ReadRequiredString(values, "Name"),
        ReadRequiredString(values, "Role"));
}

static string ReadRequiredString(
    IDictionary<string, object?> values,
    string key)
{
    if (!values.TryGetValue(key, out var value) ||
        value is not string text ||
        string.IsNullOrWhiteSpace(text))
    {
        throw new ArgumentException($"字段 {key} 缺失或不是有效字符串。");
    }

    return text;
}

public sealed record User(string Name, string Role);

完成映射后,后续代码重新获得自动补全、重构支持、静态分析和编译期类型检查。

JSON 不一定需要 dynamic

使用 System.Text.Json 时,JsonSerializer.Deserialize<dynamic> 默认得到的实际对象通常是 JsonElement,而不是可以通过任意属性名访问的动态对象。下面的代码能够通过编译,但访问 Name 时会在运行时失败:

using System.Text.Json;

const string json = """
{
  "name": "LT Monster",
  "role": "Author"
}
""";

dynamic payload = JsonSerializer.Deserialize<dynamic>(json)!;

// payload 的实际类型是 JsonElement,不存在 Name 成员。
Console.WriteLine(payload.Name);

字段结构已知时,直接反序列化为 DTO:

using System.Text.Json;

const string json = """
{
  "name": "LT Monster",
  "role": "Author"
}
""";

var options = new JsonSerializerOptions
{
    PropertyNameCaseInsensitive = true
};

var user = JsonSerializer.Deserialize<UserPayload>(json, options)
    ?? throw new JsonException("用户数据不能为空。");

Console.WriteLine(user.Name);

public sealed record UserPayload(string Name, string Role);

字段结构未知时,可以使用 JsonDocumentJsonElementJsonNode 显式检查节点。这些 API 虽然代码稍多,但缺失字段、值类型和数组边界都更加明确,也更容易写出可靠的错误处理。

运行时绑定的风险

使用动态对象时,需要主动处理静态类型系统原本可以提前发现的问题:

  • 成员名称拼写错误只会在相应代码路径执行时暴露。
  • 实际值为 null、成员不存在或参数类型不匹配时可能抛出运行时异常。
  • 重载方法的选择取决于参数的运行时类型,修改上游数据类型可能改变调用结果。
  • IDE 重命名和查找引用无法可靠追踪字符串形式或运行时生成的成员。
  • 动态值跨越过多层级后,调用方很难知道它需要哪些成员。

不要在各层之间直接传递含义不明的 dynamic。如果确实需要动态边界,至少应在同一模块中集中完成成员读取、校验和异常转换。

性能考虑

动态调用需要运行时绑定。动态语言运行时会缓存调用站点的绑定结果,因此重复调用并不是每次都从头解析,但首次绑定和后续类型变化仍然会产生额外成本。

性能差异是否重要取决于调用频率和上下文。配置解析、插件适配等低频路径通常更关注可维护性;高频循环、序列化热路径和数值计算则应优先使用明确类型,并通过基准测试验证实际影响。

测试动态边界

动态代码缺少部分编译期保护,测试需要覆盖数据形状,而不仅是正常流程。至少应包含:

  • 所需成员全部存在且类型正确。
  • 必填成员缺失。
  • 成员存在但值类型错误。
  • 成员值为 null 或空字符串。
  • 上游增加无关成员。
  • 映射失败时返回稳定、可诊断的异常信息。

测试重点应放在动态数据到明确模型的转换函数上。核心业务逻辑只接收已经验证的类型后,大部分测试仍可按照普通强类型代码编写。

使用边界

  • 动态对象适合放在系统边缘。
  • 动态访问集中在少量适配代码中,不向整个调用链扩散。
  • 成员读取前检查字段是否存在,并验证实际值类型。
  • 结构稳定后及时替换为类、记录类型、接口或泛型。
  • 对外部数据完成解析后,尽早映射为内部模型。
  • 不在性能敏感路径中默认使用动态调用。

dynamic 的作用是容纳暂时无法确定的结构,而不是取消类型设计。入口可以动态,核心逻辑仍应回到明确类型。