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

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

dynamic 做了什么

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

dynamic 将成员绑定推迟到运行时变量仍然保存普通 .NET 对象;变化的是成员查找与重载解析发生的时机。
dynamic 表达式在编译阶段生成动态调用站点。执行时,运行时绑定器根据对象的实际类型查找成员或选择重载;匹配成功后执行调用,匹配失败则抛出 RuntimeBinderException。

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

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");

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

监听成员变化

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 用于定义“访问动态成员时应该做什么”。它是一个需要继承的基类,可以拦截成员读取、写入、方法调用、索引访问和类型转换等动态操作。

DynamicObject 将不同语法分派到对应的 Try* 方法只重写业务需要的扩展点;返回 false 表示当前对象无法处理这次动态操作。
设置 record.Name 会进入 TrySetMember 并写入字典;读取 record.Name 会进入 TryGetMember 并返回值或报告绑定失败;调用 record.Remove 会进入 TryInvokeMember 并返回删除结果。

下面的 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);

字段结构未知时,可以使用 JsonDocumentJsonElementJsonNode 显式检查节点。这些 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
读取结构未知的 JSONJsonElementJsonDocumentJsonNode
与 COM、脚本或动态语言运行时交互dynamic
核心业务模型与高频路径明确类型

无论选择哪种动态能力,都应为它设置清晰边界:

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

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