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,重载解析就可能推迟到运行时。最终选择哪个重载,取决于动态参数当时保存的实际类型。
dynamic value = 42;
Print(value); // int
value = "42";
Print(value); // string
static void Print(int value) => Console.WriteLine("int");
static void Print(string value) => Console.WriteLine("string");
如果把 value 改成 DateTime,代码仍然能够通过编译,但执行 Print(value) 时会因为没有匹配的重载而抛出 RuntimeBinderException。这意味着修改上游值的实际类型,可能在不改变调用代码的情况下改变重载结果。相关规则可参考 .NET 文档中的 dynamic 使用说明。
null 与 dynamic 的边界
dynamic 可以保存 null,但成员访问仍然需要一个实际对象。下面的代码能够通过成员检查,执行时却会失败:
dynamic value = null!;
Console.WriteLine(value.Length);
dynamic 也不是一种可以在运行时识别的 CLR 类型。对象在运行时仍然是字符串、整数、普通类或其他具体类型,因此不应使用 value is dynamic 判断一个值是否来自动态变量。无法从对象本身得知调用方最初把它声明为 object 还是 dynamic。
什么时候使用动态对象
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,避免成员不存在时直接触发运行时绑定异常。
监听成员变化
ExpandoObject 还实现了 INotifyPropertyChanged。通过动态语法或字典接口新增、修改和删除成员时,可以收到对应的属性变更通知。
using System.ComponentModel;
using System.Dynamic;
dynamic settings = new ExpandoObject();
var notifications = (INotifyPropertyChanged)settings;
notifications.PropertyChanged += (_, eventArgs) =>
{
Console.WriteLine($"{eventArgs.PropertyName} 发生了变化");
};
settings.Theme = "dark";
settings.Theme = "light";
这项能力适合桌面 UI 数据绑定、配置编辑器或短生命周期的适配对象。不过,能够发送变更通知并不代表 ExpandoObject 适合作为领域模型;稳定的数据仍然更适合使用明确类型。
使用 DynamicObject 定义动态行为
ExpandoObject 解决的是“运行时增删成员”,而 DynamicObject 用于定义“访问动态成员时应该做什么”。它是一个需要继承的基类,可以拦截成员读取、写入、方法调用、索引访问和类型转换等动态操作。
下面的 DynamicRecord 只实现最常用的三种行为:
using System.Dynamic;
public sealed class DynamicRecord : DynamicObject
{
private readonly Dictionary<string, object?> values =
new(StringComparer.Ordinal);
public override bool TryGetMember(
GetMemberBinder binder,
out object? result)
{
return values.TryGetValue(binder.Name, out result);
}
public override bool TrySetMember(
SetMemberBinder binder,
object? value)
{
values[binder.Name] = value;
return true;
}
public override bool TryInvokeMember(
InvokeMemberBinder binder,
object?[]? args,
out object? result)
{
if (binder.Name == "Remove" &&
args is [string name])
{
result = values.Remove(name);
return true;
}
result = null;
return false;
}
public override IEnumerable<string> GetDynamicMemberNames()
{
return values.Keys;
}
}
使用时,动态语法会被转发到相应的 Try* 方法:
dynamic record = new DynamicRecord();
record.Name = "LT Monster"; // TrySetMember
Console.WriteLine(record.Name); // TryGetMember
Console.WriteLine(record.Remove("Name")); // TryInvokeMember
Try* 方法返回 true,表示当前对象已经处理了这次操作;返回 false,表示它无法处理。运行时绑定器会继续按照绑定规则处理,最终仍找不到有效操作时会抛出 RuntimeBinderException。因此,成员缺失时不要一律返回 true 并把结果设为 null,否则调用方无法区分“成员不存在”和“成员值就是 null”。
GetDynamicMemberNames 不决定成员是否存在,但能向调试器和其他工具暴露当前可用的动态名称。实际项目通常只需要重写与业务模型相关的少数方法,没有必要实现 DynamicObject 的全部扩展点。
DynamicObject 扩展点速查
DynamicObject 还提供索引、转换、创建和运算符等扩展点。它们的返回值规则与成员访问相同:能够完成操作时返回 true,无法处理时返回 false。
| 场景 | 可重写方法 | 典型用途 |
|---|---|---|
| 读取或写入成员 | TryGetMember / TrySetMember | 将未知属性名映射到字典、远程字段或配置项 |
| 调用成员或对象本身 | TryInvokeMember / TryInvoke | 实现命令式 API,或让对象表现得像委托 |
| 读取或写入索引 | TryGetIndex / TrySetIndex | 支持 value[key]、多维索引或路径式访问 |
| 类型转换 | TryConvert | 将动态包装对象转换为明确接口或业务类型 |
| 二元、一元运算 | TryBinaryOperation / TryUnaryOperation | 为动态值定义加减、比较或取反语义 |
| 创建实例 | TryCreateInstance | 拦截把动态对象当作构造器调用的场景 |
这些方法并不要求全部实现。索引、转换和运算符会显著扩大对象的隐式行为,只有在调用语法确实比明确方法更能表达业务含义时才值得重写。否则,普通方法或接口更容易发现、测试和维护。
尽早映射为明确类型
动态对象适合停留在输入、适配器和集成层。数据进入核心业务逻辑前,应完成字段存在性、值类型和业务规则校验,再转换为明确模型。
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。如果确实需要动态边界,至少应在同一模块中集中完成成员读取、校验和异常转换。
Lambda 和方法组仍需明确类型
Lambda 表达式和方法组本身没有独立的运行时类型,必须根据目标委托类型才能完成转换。因此,它们不能在缺少类型信息时直接作为动态调用的参数。
dynamic processor = new TextProcessor();
// processor.Transform(value => value.ToString());
// 编译错误:Lambda 不能直接作为动态操作的参数。
var result = processor.Transform(
(Func<int, string>)(value => value.ToString()));
Console.WriteLine(result);
public sealed class TextProcessor
{
public string Transform(Func<int, string> formatter)
{
return formatter(42);
}
}
同样,直接传递 Console.WriteLine 这样的重载方法组也可能缺少足够信息。先转换为明确的委托,或者先把动态对象转换为已知接口,再调用相应方法,可以让编译器恢复类型检查。
性能考虑
动态调用需要运行时绑定。动态语言运行时会缓存调用站点的绑定结果,因此重复调用并不是每次都从头解析,但首次绑定和后续类型变化仍然会产生额外成本。
性能差异是否重要取决于调用频率和上下文。配置解析、插件适配等低频路径通常更关注可维护性;高频循环、序列化热路径和数值计算则应优先使用明确类型,并通过基准测试验证实际影响。
测试动态边界
动态代码缺少部分编译期保护,测试需要覆盖数据形状,而不仅是正常流程。至少应包含:
- 所需成员全部存在且类型正确。
- 必填成员缺失。
- 成员存在但值类型错误。
- 成员值为
null或空字符串。 - 上游增加无关成员。
- 映射失败时返回稳定、可诊断的异常信息。
测试重点应放在动态数据到明确模型的转换函数上。核心业务逻辑只接收已经验证的类型后,大部分测试仍可按照普通强类型代码编写。
如何选择
动态能力有多个层次,先判断需求属于“保存任意值”“增删成员”还是“自定义成员行为”,通常比直接选择 dynamic 更可靠。
| 需求 | 推荐方案 |
|---|---|
| 只需要保存任意类型的值 | object,读取时使用模式匹配或明确转换 |
| 数据结构固定 | 类、记录类型、接口或泛型 |
| 临时组装需要增删字段的对象 | ExpandoObject |
| 自定义成员读取、调用或转换规则 | 继承 DynamicObject |
| 读取结构未知的 JSON | JsonElement、JsonDocument 或 JsonNode |
| 与 COM、脚本或动态语言运行时交互 | dynamic |
| 核心业务模型与高频路径 | 明确类型 |
无论选择哪种动态能力,都应为它设置清晰边界:
- 动态对象适合放在系统边缘。
- 动态访问集中在少量适配代码中,不向整个调用链扩散。
- 成员读取前检查字段是否存在,并验证实际值类型。
- 结构稳定后及时替换为类、记录类型、接口或泛型。
- 对外部数据完成解析后,尽早映射为内部模型。
- 不在性能敏感路径中默认使用动态调用。
dynamic 的作用是容纳暂时无法确定的结构,而不是取消类型设计。入口可以动态,核心逻辑仍应回到明确类型。