Lesson 17: C# 12 — 小特性集

Lambda 默认参数 · 任意类型别名 · Experimental · Interceptors

前置:已完成 Lesson 141516,覆盖了 C# 12 的四个主要特性。本课收尾剩余四个小特性。
阅读:Microsoft Learn: C# 12

一、Lambda 默认参数 Default Lambda Parameters 每日用

1.1 痛点:当你想把 Lambda 当"带可选配置的小函数"时

C# 11 的 Lambda 不支持默认参数。碰到需要"有可选配置的回调"时,你只有三条路——每条都痛:

做法痛在哪儿
① 每次全传 retry(() => CallApi(), 3, 1000) 大部分调用用默认值,重复传参数是噪音
② 写方法组 async Task Retry3(...){ … } 不能捕获上下文变量;为一个简单逻辑写完整方法——太重
③ 多写重载 Lambda var retry3 = (act) => retry(act, 3, 1000); 参数组合爆炸——N 个可选参数需要 2^N 个重载
本质问题:Lambda 本来适合"随手写的小逻辑"——但一旦这个逻辑有合理的默认配置,它就变成了"需要方法重载的笨重大逻辑"。C# 12 修复了这一点。

1.2 实战场景一:异步重试包装器 Retry Wrapper

这是微服务/HTTP 调用中最常见的模式——封装重试逻辑,默认重试 3 次、间隔 1 秒,但允许调用方覆盖:

😣 C# 11 —— 必须写完整方法

// 你只能把重试逻辑写成普通方法:
async Task<T> RetryAsync<T>(
    Func<Task<T>> action,
    int maxRetries = 3,
    int delayMs = 1000)
{
    for (int i = 0; i <= maxRetries; i++)
    {
        try { return await action(); }
        catch when (i < maxRetries)
        { await Task.Delay(delayMs); }
    }
    throw new InvalidOperationException();
}
// 问题:要单独定义方法,不能捕获局部变量
// 也不能内联到调用链中

😎 C# 12 —— Lambda 内联,参数可选

// 就地在调用链中定义,捕获上下文:
var retry = async (
    Func<Task> action,
    int maxRetries = 3,        // 温和重试
    int delayMs = 1000,          // 1 秒间隔
    bool throwOnFail = true) =>   // 默认抛异常
{
    for (int i = 0; i <= maxRetries; i++)
    {
        try { await action(); return; }
        catch when (i < maxRetries)
        { await Task.Delay(delayMs); }
    }
    if (throwOnFail) throw; // 否则静默
};

// 90% 的调用:用默认值,干干净净
await retry(() => httpClient.GetAsync(url));

// 特殊情况:覆盖重试次数和间隔
await retry(() => CallPaymentApi(), maxRetries: 0);
await retry(() => CallSlowSvc(), delayMs: 5000, maxRetries: 5);

1.3 实战场景二:日志/通知辅助函数

每个项目都会写一个"带默认日志级别"的 log helper——C# 12 之后一行搞定:

// 定义:msg 必传,level 和 ts 可选
var log = (
    string msg,                     // 必传
    string level = "INFO",          // 默认 INFO
    DateTime? ts = null) =>          // 默认当前时间
{
    var time = ts ?? DateTime.Now;
    Console.WriteLine(
        $"[{time:HH:mm:ss}] [{level}] {msg}");
};

// 日常使用:只传消息,level 和时间自动填充
log("订单处理完成");
// → [14:32:05] [INFO] 订单处理完成

// 出错时:覆盖日志级别
log("支付接口超时", level: "ERROR");
// → [14:32:10] [ERROR] 支付接口超时

// 补日志时:手动指定时间戳
log("历史记录", ts: new DateTime(2026, 6, 10));
为什么不用方法?这个 log lambda 能捕获外部变量——比如捕获 httpContext 自动附加请求 ID,或捕获 ILogger 实例写入结构化日志。普通 static 方法做不到;实例方法又太重。

1.4 实战场景三:配置构建器——工厂 + 默认值组合

// 典型场景:创建 API 客户端时大部分配置用默认值
var createClient = (
    string baseUrl,
    int timeoutSec = 30,
    bool useAuth = true,
    string apiVersion = "v1") =>
{
    var client = new HttpClient { BaseAddress = new Uri(baseUrl) };
    client.Timeout = TimeSpan.FromSeconds(timeoutSec);
    if (useAuth) client.DefaultRequestHeaders.Add("Authorization", GetToken());
    client.DefaultRequestHeaders.Add("Api-Version", apiVersion);
    return client;
};

// 日常:一行搞定
var api = createClient("https://api.mysvc.com");

// 调用旧版 API:覆盖版本号
var legacyApi = createClient("https://api.mysvc.com", apiVersion: "v0");

// 长时间批量任务:拉长超时
var batchApi = createClient("https://api.mysvc.com", timeoutSec: 300);

1.5 规则速查

规则说明
默认参数放最后和普通方法一样:有默认值的参数必须在无默认值参数之后
默认值在调用时求值不是定义时。如果默认表达式引用变量,取调用时刻的值(见下例)
支持 paramsC# 13 进一步允许 params 修饰符在 Lambda 中(下节课)
// ⚠ 默认值在调用时求值——不是定义时
int x = 10;
var get = (int offset = 1) => x + offset;
int r1 = get();       // 11 —— x=10 + offset=1
x = 20;
int r2 = get();       // 21 —— x=20 + offset=1(取调用时的 x)
// 这与方法的可选参数行为一致:
// 默认值是语法糖——调用时编译器补上省略的参数

// ✅ 默认参数必须在最后
var f1 = (int a, int b = 1) => a + b;      // ✅
var f2 = (int a = 1, int b) => a + b;      // ❌ CS1737
何时不该用 Lambda 默认参数:如果逻辑复杂到超过 ~10 行,或者需要 XML 文档注释、单元测试覆盖——还是写普通方法。Lambda 默认参数适合轻量、内联、需要捕获上下文的场景,不是方法替代品。

二、任意类型别名 Alias Any Type 每日用

2.1 以前只有命名类型能取别名——现在都可以

// ❌ C# 11:这些全部编译失败
// using Ints = int[];
// using Point = (int X, int Y);
// using unsafe Ptr = int*;

// ✅ C# 12:全部合法
using Ints = int[];                         // 数组
using Point2D = (int X, int Y);           // 元组——最实用!
using Coordinate = (double Lat, double Lng);
using unsafe IntPtr = int*;               // 指针(需 unsafe 上下文)
using MyMap = Dictionary<string, object>; // 泛型实例(以前也可以)

2.2 元组别名——最实用的场景

// C# 11:元组类型散落各处,没有统一名字
public (int x, int y) Transform((int x, int y) input) { ... }
// 如果某天要加第三个值?改 N 处。编译不报错但逻辑错。

// C# 12:定义一次,到处使用
using Point2D = (int X, int Y);
public Point2D Transform(Point2D input) { ... }
// 改结构只改一行。编译帮你在所有使用处报错。
限制:开放泛型别名仍不支持——using MyList<T> = List<T>; 不行。语言团队还在讨论语义。

2.3 和 global using 组合

// GlobalUsings.cs——全局可用
global using Coordinate = (double Lat, double Lng);
global using UserId = System.Guid;

2.4 设计趣谈:C# 在"抄"TypeScript 吗?

看起来像——但不是单向学习。任意类型别名、集合展开、原始字符串……这些 C# 新语法确实有 TypeScript/JavaScript 的影子。但真相更有趣:C# 之父 Anders Hejlsberg 同时也是 TypeScript 首席架构师,C# 现任首席 Mads Torgersen 也深度参与 TS 设计。同一批人在同一栋楼里面对同一批开发者的痛点,把同一个好想法在两个语言里各自落地。时间线上有来有回:?. 可选链 C# 先出(2015→2016),元组类型 TS 先出(2014→2017),模式匹配各自演化。真正的功能孵化器是 F#——记录、模式匹配、可区分联合(提案中)在 F# 里已有十年历史。
根本差异不会消失:TypeScript 是结构类型(形状相同就是同类型),C# 是名义类型(名字不同就是不同类型)。using 别名只是语法糖——底层类型系统纹丝不动。
📖 详细时间线见学习记录 0016-csharp-typescript-coevolution

三、Experimental 属性 ExperimentalAttribute

标记类型、方法、程序集为"实验性"。使用者编译时收到警告——不是错误,但明确提示"这个 API 可能变"。

// 库作者——标记实验 API:
[Experimental("DIAG001")]
public class NewFeature { }

[Experimental("DIAG002", UrlFormat = "https://docs.mylib.com/migration/{0}")]
public void BetaMethod() { }

// 使用者——编译时收到警告:
var x = new NewFeature();  // ⚠ 警告 DIAG001: 'NewFeature' is experimental

// 有意识地抑制警告(表示你接受实验性 API 的风险):
#pragma warning disable DIAG001
var x = new NewFeature();
#pragma warning restore DIAG001

// 也可以在 .csproj 中全局抑制:
// <NoWarn>DIAG001;DIAG002</NoWarn>
用途:库作者发布新 API 时,先标记为 Experimental,给用户试用但不承诺稳定。等 API 稳定后去掉标记——这是语义版本控制的编译时表达。
继承规则:标记在类型上 → 所有成员继承;标记在程序集上 → 整个程序集的类型都是实验性的。

四、拦截器 Interceptors C# 14 转正

拦截器允许在编译时把某个方法调用替换为另一个方法调用——是源生成器Source Generator的高级玩法。

版本演进:C# 12 引入时是实验性预览(需 <InterceptorsPreviewNamespaces> + Features=InterceptorsPreview);C# 13 保持预览;C# 14 / .NET 10 转正——去掉 [Experimental] 标记,配置项改名为 <InterceptorsNamespaces>,API 稳定。
来源:C# 14: Introducing Interceptors — Anthony Giretti
// C# 12 / 13(预览):csproj 配置
// <Features>InterceptorsPreview</Features>
// <InterceptorsPreviewNamespaces>
//   $(InterceptorsPreviewNamespaces);MyGen
// </InterceptorsPreviewNamespaces>

// C# 14 / .NET 10(稳定):简化为
// <InterceptorsNamespaces>
//   $(InterceptorsNamespaces);MyGen
// </InterceptorsNamespaces>

// 原理:
// 1. 源生成器扫描代码,找到特定的方法调用位置
// 2. 生成拦截器方法,声明要"替换"该位置的调用
// 3. 编译时(IL 生成前),Roslyn 将调用重定向到拦截器
// → 零运行时开销,零反射,Native AOT 完全兼容

4.1 C# 14 转正后的实战场景

拦截器的目标是库作者和框架作者,不是日常应用代码。转正后的几个典型用途:

场景说明
零分配日志ILogger.LogInformation(msg, args) 编译时替换为 LoggerMessage.Define 委托——消除装箱和字符串拼接
透明链路追踪拦截每个 HttpClient.SendAsync 调用,自动注入 correlation header——调用方代码零修改
AOT 反射消除将依赖反射的调用在编译时替换为直接代码——Native AOT 的关键技术
编译时配置校验拦截 builder.Configuration["key"],编译时检查 key 是否存在于 appsettings.json
⚠ 仍然不是日常开发工具。拦截器必须由源生成器自动生成([InterceptsLocation] 包含文件路径、行号、列号和内容哈希,源码一改全变,无法手写)。使用者需在 csproj 中显式声明允许的命名空间——第三方包不能静默拦截调用。面向对象:NuGet 包作者 / 框架作者 / AOT 优化场景。

五、小测验

1/3 · Lambda 默认参数求值时机——阅读代码:
int threshold = 5;
var check = (int value, int min = threshold) => value >= min;
threshold = 10;
bool result = check(7);

resulttrue 还是 false?为什么?
2/3 · C# 12 中 using Point = (int X, int Y); 在 C# 11 中是什么状态?
3/3 · 关于 ExperimentalAttribute,哪个说法正确?

六、C# 12 四课总结

课程内容日常频率
14. Primary Constructors消灭 DI 样板;捕获语义;class vs record 差异⭐⭐⭐⭐⭐
15. Collection Expressions[ ] 统一语法;.. 展开;编译器优化⭐⭐⭐⭐⭐
16. ref readonly + Inline Arrays参数语义三角;安全固定缓冲区⭐⭐⭐
17. 小特性集(本课)Lambda 默认参数;类型别名;Experimental;拦截器⭐⭐⭐⭐

七、下一步

💬 有问题?C# 12 四个特性还有模糊的地方?Lambda 默认参数和普通方法有哪些细微差异?随时问。