C# 13 编译器还真是个大聪明

原创作者:庄晓立(LIIGO)
原创日期:2025年5月9日
原创链接:https://blog.csdn.net/liigo/article/details/147834182
版权所有,转载请注明出处。

版本信息

C# 13 编译器随 .Net 9 SDK 发行。我在项目文件中设置<LangVersion>Latest</LangVersion>确保使用最新版本编译器,并通过C# 13新增的\e转义符再次确认是新版编译器。

VS相关版本详情如下:

Microsoft Visual Studio Community 2022
版本 17.13.6
VisualStudio.17.Release/17.13.6+35931.197
Microsoft .NET Framework
版本 4.8.09037

已安装的版本: Community

Visual C++ 2022   00482-90000-00000-AA309
Microsoft Visual C++ 2022

Azure 应用服务工具 3.0.0 版   17.13.124.35287
Azure 应用服务工具 3.0.0 版

C# 工具   4.13.0-3.25167.3+73eff2b5de2ad38ec602c0a9e82f9125fb85992b
IDE 中使用的 C# 组件。可能使用其他版本的编译器,具体取决于你的项目类型和设置。

Microsoft JVM Debugger   1.0
Provides support for connecting the Visual Studio debugger to JDWP compatible Java Virtual Machines

NuGet 包管理器   6.13.2
Visual Studio 中的 NuGet 包管理器。有关 NuGet 的详细信息,请访问 https://docs.nuget.org/

Visual Basic 工具   4.13.0-3.25167.3+73eff2b5de2ad38ec602c0a9e82f9125fb85992b
IDE 中使用的 Visual Basic 组件。可能使用其他版本的编译器,具体取决于你的项目类型和设置。

Visual F# Tools   17.13.0-beta.25154.2+82a3f54f7140a62e6398403451098c4517747c02
Microsoft Visual F# Tools

分不清类成员有没有被赋值

	class Foo {
	    string Bar;
	    public Foo() { Init(); }
	    private void Init() { Bar = "liigo"; }
	}

编译时编译器给出警告:

编译警告 CS8618:在退出构造函数时,不可为 null 的 字段 “Bar” 必须包含非 null 值。请考虑添加 “required” 修饰符或将该 字段 声明为可为 null。

因为已经配置<Nullable>enable</Nullable>,类成员string Bar不能为null是可以理解的。但是明明已经在构造函数里调用Init函数对Bar赋值了呀,编译器还是坚持给出警告。

我简单的改下代码,把Bar = "liigo";放到构造函数函数体内(贴脸开大呀):

	class Foo {
	    string Bar;
	    public Foo() { Init(); Bar = "liigo"; }
	    private void Init() { }
	}

这样编译器就不报CS8618警告了,它真是个大聪明。语句直接写在构造函数体内它认识,间接写在其他方法体内并在构造函数内直接调用它就不认识了。

那你再回头看看VS窗体设计器自动生成的代码,是不是也是这种模式:

    public partial class Form1 : Form {
        public Form1() {
            InitializeComponent();
        }
        private void InitializeComponent() {
            // ...
        }

临时抑制此CS8618编译警告的办法(眼不见心不烦):

	#pragma warning disable CS8618
	// 被报编译警告的代码块
	#pragma warning restore CS8618

分不清ActionFunc<Task>

假设我有如下两个函数,功能类似,一个同步版本(Foo),一个异步版本(FooAsync):

    void Foo(Action fn) { fn(); }
    async Task FooAsync(Func<Task> fn) { await fn(); }

调用时按需使用这两个版本中的其中一个。一开始没有异步需求就使用Foo:

    Foo(() => {
        Bar();
    });

随着开发进程的深入,闭包内会逐渐写入更多的代码,指不定某个时候就会有调用异步函数的需求,你自然而然的就会写出下面的代码:

    Foo(() => {
        Bar();
        await Task.Delay(1000); // 新增此行
    });

上述代码当然是有问题的,编译器会给出错误提示:

编译错误 CS4034:“await”运算符只能在异步 lambda 表达式 中使用。请考虑使用“async”修饰符标记此 lambda 表达式。

事实上不用等到编译器报错,新版IDE(Visual Studio 17.13.6)就自动给你加上async了,代码最终变成这样:

    // 问题代码
    Foo(async () => {
        Bar();
        await Task.Delay(1000);
    });

上述代码还是有问题的,而且问题很多(这个后面再说)。但是最大的问题是,编译器居然编译通过了!而且没有任何编译警告!

在上述代码中,我调用函数Foo(void Foo(Action fn)),实际传入的参数类型是Func<Task>,而它应当接收的参数类型是Action。如此严重的类型不匹配,它居然编译通过了,没有任何编译警告!

我来验证此二者类型是否匹配,以及编译器能否识别:

    Action fn1 = () => { };
    Func<Task> fn2 = async () => { await Task.Delay(1); };
    fn1 = fn2; // 编译错误:CS0029 无法将类型“System.Func<System.Threading.Tasks.Task>”隐式转换为“System.Action”

上述代码证明,C#编译器能够识别两种闭包类型,且知道Func<Task>不能隐式转换为Action,强行赋值将报编译错误。但是!下面的代码却能无警告编译通过:

    Action fn1 = () => { };
    fn1 = async () => { await Task.Delay(1); }; // 编译通过,无警告! 自相矛盾。问题重现。

这里是不是可以认为是编译器的BUG?我认为是。


我再次回到先前出问题的代码,反思为什么会写出这样的代码:

    // 问题代码
    Foo(async () => {
        Bar();
        await Task.Delay(1000);
    });

    void Foo(Action fn) { fn(); }
    async Task FooAsync(Func<Task> fn) { await fn(); }

其实也不奇怪。Foo有同步和异步两个版本(Foo VS FooAsync),一开始调用其同步版本,后来要变更为调用其异步版本,上述代码恰恰是处在此变更过程中的某个中间状态。我捋一下完整的变更步骤:

  1. 调用同步版本的闭包内新增await调用,触发变更(改为调用Foo的异步版本)
  2. 闭包前面需要增加async关键字(这一步编译器会提示,IDE会代劳)
  3. 将被调用的函数名Foo改为FooAsync
  4. FooAsync函数调用前加await关键字

按说这几个步骤应该一气呵成(这事在往常我也多次做过),但这次因为疏忽只完成前两个步骤,遗漏了后面的步骤,再加上编译器失职编译通过,事后也未及时发现代码缺陷,导致程序运行后出现重大BUG。以下问题代码可以形象的展示为什么会发生运行时问题:

    // 打开文件
    Foo(async () => {
        Bar();
        await Task.Delay(1000);
        // 写文件(问题在这里,写文件将发生在关闭文件之后)
    });
    // 关闭文件

由于我误将异步闭包传入同步版本的Foo函数,导致异步闭包参数被作为后台野任务启动,无人对其await,导致程序流程混乱。

如果我当初把上面完整的变更步骤一口气执行完,代码写成下面这样(增加Async、await),明确调用异步版本的FooAsync函数,就没有问题了:

    // 打开文件
    await FooAsync(async () => {
    ^^^^^    ^^^^^
        Bar();
        await Task.Delay(1000);
        // 写文件
    });
    // 关闭文件

我后来再次反思,改进了Foo函数的定义:

    // 改进前
    void Foo(Action fn) { fn(); }
    async Task FooAsync(Func<Task> fn) { await fn(); }

    // 改进后
    void Foo(Action fn) { fn(); }
    async Task Foo(Func<Task> fn) { await fn(); }

改进前,同步和异步版本采用不同的函数命名(Foo VS FooAsync,异步版本命名加Async后缀);改进后,统一命名为Foo,二者形成重载关系。如此改进后,编译器再次遇到前面的问题代码,就能够给出编译警告(CS4014)。原因在于,编译器根据参数类型可以推断出我在调用Foo异步版本,因而会警告并建议在调用语句前面加await

    // 问题代码
    Foo(async () => {
        Bar();
        await Task.Delay(1000);
    });

编译警告 CS4014: 当前的方法调用返回一个 Task 或 Task 的 async 方法,并且不会将 await 操作符应用到结果中。对 async 方法的调用将启动异步任务。但是,由于未应用 await 操作符,程序将继续运行而不会等待任务完成。在多数情况下,这种行为并不是你想要的。通常,调用方法的其他部分依赖调用结果,或者至少从包含此调用的方法中返回前需要完成此被调用的方法。

一个同样重要的问题是在调用的 async 方法中产生的异常将发生什么情况。在返回 Task 或 Task 的方法中产生的异常存储在返回的任务中。如果你不等待任务完成或显式检查异常,则异常将丢失。如果你等待任务完成,则此异常将重新抛出。

最佳的做法是你应始终等待此调用完成。

仅当你确定不需要等待异步调用完成,并且调用的方法不会产生任何异常时,你可以考虑取消警告。为此,你可以通过将调用的任务结果分配给一个变量来取消警告。

有此编译警告的前提下修正代码是很容易的事情。所有也算是从根源上杜绝了问题代码的出现。

那……怪我喽,是我前面对FooAsync命名不规范吗?在这一点上我(LIIGO)可是严格遵循了官方异步编程建议:

重要信息和建议

  • 添加“Async”作为编写的每个异步方法名称的后缀。

    这是 .NET 中的惯例,以便更为轻松地区分同步和异步方法。 未由代码显式调用的某些方法(如事件处理程序或 Web 控制器方法)并不一定适用。 由于它们未由代码显式调用,因此对其显式命名并不重要。

Add “Async” suffix to asynchronous method names

The .NET style convention is to add the “Async” suffix to all asynchronous method names. This approach helps to more easily differentiate between synchronous and asynchronous methods. Certain methods that aren’t explicitly called by your code (such as event handlers or web controller methods) don’t necessarily apply in this scenario. Because these items aren’t explicitly called by your code, using explicit naming isn’t as important.

🤷‍♂️🤷‍♂️🤷‍♂️🤷‍♀️🤷‍♀️🤷‍♀️

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值