C# 动态对象
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");
字典访问还能在读取成员前调用 ContainsKey 或 TryGetValue,避免成员不存在时直接触发运行时绑定异常。
尽早映射为明确类型
动态对象适合停留在输入、适配器和集成层。数据进入核心业务逻辑前,应完成字段存在性、值类型和业务规则校验,再转换为明确模型。
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);
字段结构未知时,可以使用 JsonDocument、JsonElement 或 JsonNode 显式检查节点。这些 API 虽然代码稍多,但缺失字段、值类型和数组边界都更加明确,也更容易写出可靠的错误处理。
运行时绑定的风险
使用动态对象时,需要主动处理静态类型系统原本可以提前发现的问题:
- 成员名称拼写错误只会在相应代码路径执行时暴露。
- 实际值为
null、成员不存在或参数类型不匹配时可能抛出运行时异常。 - 重载方法的选择取决于参数的运行时类型,修改上游数据类型可能改变调用结果。
- IDE 重命名和查找引用无法可靠追踪字符串形式或运行时生成的成员。
- 动态值跨越过多层级后,调用方很难知道它需要哪些成员。
不要在各层之间直接传递含义不明的 dynamic。如果确实需要动态边界,至少应在同一模块中集中完成成员读取、校验和异常转换。
性能考虑
动态调用需要运行时绑定。动态语言运行时会缓存调用站点的绑定结果,因此重复调用并不是每次都从头解析,但首次绑定和后续类型变化仍然会产生额外成本。
性能差异是否重要取决于调用频率和上下文。配置解析、插件适配等低频路径通常更关注可维护性;高频循环、序列化热路径和数值计算则应优先使用明确类型,并通过基准测试验证实际影响。
测试动态边界
动态代码缺少部分编译期保护,测试需要覆盖数据形状,而不仅是正常流程。至少应包含:
- 所需成员全部存在且类型正确。
- 必填成员缺失。
- 成员存在但值类型错误。
- 成员值为
null或空字符串。 - 上游增加无关成员。
- 映射失败时返回稳定、可诊断的异常信息。
测试重点应放在动态数据到明确模型的转换函数上。核心业务逻辑只接收已经验证的类型后,大部分测试仍可按照普通强类型代码编写。
使用边界
- 动态对象适合放在系统边缘。
- 动态访问集中在少量适配代码中,不向整个调用链扩散。
- 成员读取前检查字段是否存在,并验证实际值类型。
- 结构稳定后及时替换为类、记录类型、接口或泛型。
- 对外部数据完成解析后,尽早映射为内部模型。
- 不在性能敏感路径中默认使用动态调用。
dynamic 的作用是容纳暂时无法确定的结构,而不是取消类型设计。入口可以动态,核心逻辑仍应回到明确类型。