【译】自动发现 .NET 5 中代码的潜在错误

  写代码是一件令人兴奋的事情,特别是对于  .NET 开发人员来说,平台越来越智能化了。我们现在默认在 .NET SDK 中包含丰富的诊断和代码建议。在您需要安装 NuGet 包或其他独立工具来进行更多的代码分析之前。现在,您将在新的 .NET 5 SDK 中自动获得这些内容。

  过去,我们一直不愿意向 C# 添加新的警告。这是因为,对于将警告视为错误的用户来说,添加新的警告从技术上来说是一种对源代码的影响。然而,这些年来,在我们遇到的很多情况中,我们也确实想警告人们有些地方出了问题,从常见的编码错误到常见的 API 误用等等。

  从 .NET 5 开始,我们在 C# 编译器中引入了 AnalysisLevel,以一种安全的方式引入新的警告。所有针对 .NET 5 的项目的 AnalysisLevel 默认将被设置为 5,这意味着将引入更多的警告(以及修复它们的建议)。

  让我们讨论一下 AnalysisLevel 可能的值在您的项目中意味着什么。首先我们要注意的是:除非你覆盖默认值,否则 AnalysisLevel 是基于你的目标框架设置的:

目标框架 默认值
net5.0 5
netcoreapp3.1 or lower 4
netstandard2.1 or lower 4
.NET Framework 4.8 or lower 4

  但是,0-3 呢?下面是对每个分析级别值含义的更详细的细分:

AnalysisLevel 对C#编译器的影响 高级平台API分析
5 获得新的编译器语言分析(详细内容如下) Yes
4 与之前版本中向 C# 编译器传递 -warn:4 相同 No
3 与之前版本中向 C# 编译器传递 -warn:3 相同 No
2 与之前版本中向 C# 编译器传递 -warn:2 相同 No
1 与之前版本中向 C# 编译器传递 -warn:1 相同 No
0 与之前版本中向 C# 编译器传递 -warn:0 一样,关闭所有发出警告 No

  由于 AnalysisLevel 与项目的目标框架绑定在一起,除非你改变了你的代码目标框架,否则你永远不会改变默认的分析级别。不过,你可以手动设置分析级别。例如,即使我们的目标是 .NET Core App 3.1 或 .NET Standard (因此 AnalysisLevel 默认为 4),你仍然可以选择更高的级别。这里有一个例子:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <!-- get more advanced warnings for this project -->
    <AnalysisLevel>5</AnalysisLevel>
  </PropertyGroup>

</Project>

  如果你想要最高的分析级别,你可以在你的项目文件中指定 latest:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <!-- be automatically updated to the newest stable level -->
    <AnalysisLevel>latest</AnalysisLevel>
  </PropertyGroup>

</Project>

  如果你很有冒险精神,并且希望尝试实验性的编译器和平台分析,那么可以指定 preview 来获得最新的、最前沿的代码诊断。

  请注意,当您使用 latest 或 preview 时,分析结果可能会因机器而异,这取决于可用的 SDK 和它提供的最高分析级别。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <!-- be opted into experimental code correctness warnings -->
    <AnalysisLevel>preview</AnalysisLevel>
  </PropertyGroup>

</Project>

  最后,也可以设置为 none,这意味着“我不想看到任何新的警告”。在这种模式下,你不会得到任何高级 API 分析,也不会得到新的编译器警告。如果你需要更新框架,但还没有准备好接受新的警告,那么这将非常有用。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5</TargetFramework>
    <!-- I am just fine thanks -->
    <AnalysisLevel>none</AnalysisLevel>
  </PropertyGroup>

</Project>

  你还可以在 Visual Studio 中通过 Code Analysis 属性页配置项目的分析级别。只需从解决方案资源管理器导航到项目属性页。然后转到 Code Analysis 选项卡。

  在未来,我们将为 .NET 的每个版本添加一个新的分析级别。目标是确保给定的分析级别总是表示相同的默认分析集(规则及其严重性)。如果我们想在默认情况下启用现有的规则,我们将在即将到来的分析级别中这样做,而不是更改现有的级别。这确保了已有的项目/源代码总是产生相同的警告,不管 SDK 有多新(当然,除了项目使用 preview 或 latest)。

  由于所有的 .NET 5 项目都将进入分析级别 5,让我们来看看一些新的警告和建议。

分析级别 5 中出现的所有新的警告和错误

  粗体部分将在 .NET 5 发布的时候进入第 5 级。剩下的是 Visual Studio 2019 16.8 预览2 中的 .NET 5 预览8 中的新警告!

 

常见错误的警告

  第一组新的警告旨在发现潜在的错误,通常是在较大的代码库中。现在不需要额外的编译器分析就可以很容易地发现它们。

当表达式永真或永假时发出警告

  这种新的警告非常普遍,考虑以下代码:

public void M(DateTime dateTime)
{
    if (dateTime == null) // warning CS8073
    {
        return;
    }
}

  DateTime 是一个结构体,结构体不能为空。从 .NET 开始,我们将在 CS8073 中警告这种情况。警告信息是:

  Warning CS8073: The result of the expression is always ‘false’ since the value of type ‘DateTime’ is never equal to ‘null’ of type ‘DateTime?’

  很明显,这段代码所做的事情没有意义,但是考虑到这样的检查可能发生在有多个参数要验证的方法中。要解决这个问题,你可以删除代码(因为它总是假的,它没有做任何事情),或者改变它的类型为 DateTime? 如果参数的预期值为 null。

public void M(DateTime? dateTime) // We accept a null DateTime
{
    if (dateTime == null) // No Warnings
    {
        return;
    }
}

不允许在静态类型上用as、 is

  下面是一个很好的小改进:

static class Fiz
{
}

class P
{
    bool M(object o)
    {
        return o is Fiz; // CS7023
    }
}

  因为 Fiz 是一个静态类,所以像 o 这样的实例对象永远不可能是这种类型的实例。我们会收到这样的警告:

  Warning CS7023 The second operand of an ‘is’ or ‘as’ operator may not be static type ‘Fiz’

  解决这个问题的方法是重构我们的代码(也许我们一开始就检查错类型了),或者让类 Fiz 是非静态的:

class Fiz
{
}

class P
{
    bool M(object o)
    {
        return o is Fiz; // no error
    }
}

不允许锁定非引用类型

  锁定非引用类型(比如 int)什么也做不了,因为它们是按值传递的,所以每个堆栈帧上都有不同版本的非引用类型。在过去,对于像 lock(5) 这样简单的情况,我们会警告你对非引用类型的锁定,但是直到最近,我们对泛型方法的也支持警告:

public class P
{
    public static void GetValue<TKey>(TKey key)
    {
        lock (key) // CS0185
        {
        }
    }

    static void Main()
    {
        GetValue(1);
    }
}

  这是一个错误,因为传入 int(在这个不受约束的泛型中允许)实际上不会正确锁定。我们会看到这个错误:

  Error CS0185 ‘TKey’ is not a reference type as required by the lock statement

  要解决这个问题,我们需要指出 GetValue 方法应该只提供引用类型。我们可以使用泛型类型约束来做到这一点,where TKey : class

public class P
{
    public static void GetValue<TKey>(TKey key) where TKey : class
    {
        lock (key) // no error
        {
        }
    }
}

重新抛出以保留堆栈细节

  我们都是“优秀的”开发人员,所以我们的代码不会抛出异常,对吗?好吧,即使是最好的开发人员也需要处理异常,而新程序员常陷入的一个陷阱是:

try
{
    throw new Exception();
}
catch (Exception ex)
{
    // probably logging some info here...

    // rethrow now that we are done
    throw ex; // CA2200
}

  在学校里,我学到如果有人向我扔球,我接住它,我必须把球扔回去!像这样的比喻让很多人相信 throw ex 是重新抛出这个异常的正确方式。遗憾的是,这将改变原来异常中的堆栈。现在您将收到一个警告,说明正在发生这种情况。它是这样的:

  Warning CA2200 Re-throwing caught exception changes stack information

  在几乎所有情况下,这里要做的正确事情是简单地使用 throw 关键字,而不提及我们捕获的异常的变量。

try
{
    throw new Exception();
}
catch (Exception ex)
{
    // probably logging some info here...

    // rethrow now that we are done
    throw;
}

  我们还提供了一个代码修复,可以轻松地在您的文档、项目或解决方案中一次性修复所有这些问题!

不要在值类型中使用 ReferenceEquals

  Equality 在 .NET 中是一个棘手的话题。下一个警告试图使意外地通过引用比较一个 struct 。考虑以下代码:

int int1 = 1;
int int2 = 1;
Console.WriteLine(object.ReferenceEquals(int1, int2)); // warning CA2013

  这将装箱两个 int,而 ReferenceEquals 将总是返回 false 作为结果。我们将看到这个警告描述:

  Warning CA2013: Do not pass an argument with value type ‘int’ to ‘ReferenceEquals’. Due to value boxing, this call to ‘ReferenceEquals’ will always return ‘false’.

  解决此错误的方法是使用相等运算符 == 或 object.Equals:

int int1 = 1;
int int2 = 1;
Console.WriteLine(int1 == int2); // using the equality operator is fine
Console.WriteLine(object.Equals(int1, int2));  // so is object.Equals

跟踪跨程序集中结构的明确赋值(definite assignment)

  很多人可能会惊讶地发现,下一个警告其实并不算是警告:

using System.Collections.Immutable;

class P
{
    public void M(out ImmutableArray<int> immutableArray) // CS0177
    {
    }
}

  这条规则是关于明确赋值的,这是 C# 中一个有用的特性,可以确保你不会忘记给变量赋值。

  Warning CS0177: The out parameter ‘immutableArray’ must be assigned to before control leaves the current method

  目前已经针对几种不同的情况发布了 CS0177,但不是前面展示的情况。这里的历史是,这个 bug 可以追溯到 C# 编译器的原始实现。以前,C# 编译器在计算明确赋值时忽略从元数据导入的值类型中的引用类型的私有字段。这个非常特殊的错误意味着像 ImmutableArray 这样的类型能够逃脱明确赋值分析。

  现在编译器将正确的显示错误,你可以修复它,只要确保它总是分配一个值,像这样:

using System.Collections.Immutable;

class P
{
    public bool M(out ImmutableArray<int> immutableArray) // no warning
    {
        immutableArray = ImmutableArray<int>.Empty;
    }
}

.NET API 使用错误的警告

  下面示例是关于正确使用 .NET 库的。分析级别可以防止现有的 .NET API 的不当使用,但它也会影响 .NET 库的发展。如果设计了一个有用的 API,但它有可能被误用,那么还可以在新增 API 的同时添加一个检测误用的新警告。

不要给从 MemoryManager 的派生类定义终结器

  当你想实现自己的 Memory<T> 类型时,MemoryManager 是一个有用的类。这不是你经常做的事情,但是当你需要它的时候,你真的需要它。这个新的警告会触发这样的情况:

class DerivedClass <T> : MemoryManager<T>
{
    public override bool Dispose(bool disposing)
{
        if (disposing)
        {
            _handle.Dispose();
        }
    }
  
    ~DerivedClass() => Dispose(false); // warning CA2015
}

  向这种类型添加终结器可能会在垃圾收集器中引入漏洞,这是我们都希望避免的!

  Warning CA2015 Adding a finalizer to a type derived from MemoryManager<T> may permit memory to be freed while it is still in use by a Span<T>.

  解决方法是删除这个终结器,因为它会在你的程序中导致非常细微的 bug,很难找到和修复。

class DerivedClass <T> : MemoryManager<T>
{
    public override bool Dispose(bool disposing)
{
        if (disposing)
        {
            _handle.Dispose();
        }
    }
 // No warning, since there is no finalizer here
}

参数传递给 TaskCompletionSource,调用错误的构造函数

  这个警告告诉我们,我们使用了错误的枚举。

var tcs = new TaskCompletionSource(TaskContinuationOptions.RunContinuationsAsynchronously); // warning CA2247

  除非你已经意识到这个问题,否则你可能会在盯着它看一会儿才能发现。问题是这样的,这个构造函数不接受 TaskContinuationOptions 枚举,它接受 TaskCreationOptions 枚举。发生的事情是,我们正在调用的 TaskCompletionSource 的构造函数接受 object 类型参数!考虑到它们的名称特别相似,并且它们的值也非常相似,所以这种错误容易发生。

  Warning CA2247: Argument contains TaskContinuationsOptions enum instead of TaskCreationOptions enum.

  修复它只需要传递正确的枚举类型:

var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); // no warning

当代码不能在所有平台上工作时发出警告

  这个真是太棒了!我不会在这里详细讨论它的复杂之处(期待以后关于这个主题的博客文章)。但这里警告的目的是让您知道,您正在调用的 api 可能无法在您正在构建的所有目标上工作。

  假设我有一个同时在 Linux 和 Windows 上运行的应用程序。我有一个方法,我使用它来获得路径来创建日志文件,根据运行环境,它有不同的行为。

private static string GetLoggingPath()
{
    var appDataDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
    var loggingDirectory = Path.Combine(appDataDirectory, "Fabrikam", "AssetManagement", "Logging");

    // Create the directory and restrict access using Windows
    // Access Control Lists (ACLs).

    var rules = new DirectorySecurity(); // CA1416
    rules.AddAccessRule(
        new FileSystemAccessRule(@"fabrikam\log-readers",
                                    FileSystemRights.Read,
                                    AccessControlType.Allow)
    );
    rules.AddAccessRule(
        new FileSystemAccessRule(@"fabrikam\log-writers",
                                    FileSystemRights.FullControl,
                                    AccessControlType.Allow)
    );

    if (!OperatingSystem.IsWindows())
    {
        // Just create the directory
        Directory.CreateDirectory(loggingDirectory);
    }
    else
    {
        Directory.CreateDirectory(loggingDirectory, rules);
    }

    return loggingDirectory;
}

  我正确地使用了 OperatingSystem.IsWindows() 来检查操作系统是否是 Windows 操作系,但是实际上 if 分支之前已经使用了平台特定的 API,将不能工作在 Linux!

  Warning CA1416: ‘DirectorySecurity’ is unsupported on ‘Linux’

  处理这个问题的正确方法是将所有特定于平台的代码移动到 else 语句中。

private static string GetLoggingPath()
{
    var appDataDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
    var loggingDirectory = Path.Combine(appDataDirectory, "Fabrikam", "AssetManagement", "Logging");

    if (!OperatingSystem.IsWindows())
    {
        // Just create the directory
        Directory.CreateDirectory(loggingDirectory);
    }
    else
    {
        // Create the directory and restrict access using Windows
        // Access Control Lists (ACLs).

        var rules = new DirectorySecurity(); // CA1416
        rules.AddAccessRule(
            new FileSystemAccessRule(@"fabrikam\log-readers",
                                        FileSystemRights.Read,
                                        AccessControlType.Allow)
        );
        rules.AddAccessRule(
            new FileSystemAccessRule(@"fabrikam\log-writers",
                                        FileSystemRights.FullControl,
                                        AccessControlType.Allow)
        );

        Directory.CreateDirectory(loggingDirectory, rules);
    }

    return loggingDirectory;
}

低级编码帮助

  在编写高性能应用程序时,还有一些有用的警告。下面这些警告确保您不需要为这些情况牺牲安全性。

P/Invoke 时不要在 string 参数上使用 OutAttribute

  有时你需要与本地代码进行互操作。.NET 有使用平台调用服务的概念(P/ Invoke)来简化这个过程。但是,在 .NET 中,在向本地库发送数据和从本地库发送数据方面存在一些问题。考虑以下代码:

[DllImport("MyLibrary")]
private static extern void Goo([Out] string s); // warning CA1417

  除非您非常熟悉 P/Invoke 的编写,否则这里的错误并不明显。通常将 OutAttribute 应用于运行时不知道的类型,以指示应该如何封送类型。OutAttribute 表示您正在按值传递数据。字符串按值传递没有意义,而且有可能导致运行时崩溃。

  Warning CA1417 Do not use the ‘OutAttribute’ for string parameter ‘s’ which is passed by value. If marshalling of modified data back to the caller is required, use the ‘out’ keyword to pass the string by reference instead.

  解决这个问题的方法是将其作为一个普通的 out 参数(通过引用传递)来处理。

[DllImport("MyLibrary")]
private static extern void Goo(out string s); // no warning

  或者如果你不需要将字符串封送回调用者,你可以这样做:

[DllImport("MyLibrary")]
private static extern void Goo(string s); // no warning

在适当情况下,string 使用 AsSpan 而不是基于范围的索引器

  这都是为了确保您不会意外地分配字符串。

class Program
{
    public void TestMethod(string str)
    {
        ReadOnlySpan<char> slice = str[1..3]; // CA1831
    }
}

  在上面的代码中,开发者的意图是使用 C# 中新的基于范围的索引特性来索引一个字符串。不幸的是,这实际上会分配一个字符串,除非您首先将该字符串转换为 span。

  Warning CA1831 Use ‘AsSpan’ instead of the ‘System.Range’-based indexer on ‘string’ to avoid creating unnecessary data copies

  解决方法是在这种情况下添加 AsSpan 调用:

class Program
{
    public void TestMethod(string str)
    {
        ReadOnlySpan<char> slice = str.AsSpan()[1..3]; // no warning
    }
}

不要在循环中使用 stackalloc

  stackalloc 关键字非常适合于确保正在进行的操作对垃圾收集器来说比较容易。在过去,stackalloc 关键字用于不安全的代码上下文中,以便在堆栈上分配内存块。但自从 C# 8 以来,它也被允许在 unsafe 的块之外,只要这个变量被分配给一个 Span<T> 或一个 ReadOnlySpan<T>。

class C
{
    public void TestMethod(string str)
    {
        int length = 3;
        for (int i = 0; i < length; i++)
        {
            Span<int> numbers = stackalloc int[length]; // CA2014
            numbers[i] = i;
        }
    }
}

  在堆栈上分配大量内存可能会导致著名的 StackOverflow 异常,即我们在堆栈上分配的内存超过了允许的范围。在循环中分配尤其危险。

  Warning CA2014 Potential stack overflow. Move the stackalloc out of the loop.

  解决方法是将 stackalloc 移出循环:

class C
{
    public void TestMethod(string str)
    {
        int length = 3;
        Span<int> numbers = stackalloc int[length]; // no warning
        for (int i = 0; i < length; i++)
        {
            numbers[i] = i;
        }
    }
}

设置分析级别

  现在您已经看到了这些警告的重要性,您可能永远不想回到一个没有它们的世界,对吗?我知道世界并不总是这样运转的。正如在这篇文章的开头提到的,这些都是打破源代码的改变,你应该在适合自己的时间表中完成它们。我们现在介绍这个的部分原因是为了得到两个方面的反馈:

  1. 我们提出的这一小部分警告是否太过破坏性

  2. 调整警告的机制是否足以满足您的需要

回到 .NET Core 3.1 的分析等级

  如果你只想回到 .NET 5 之前的状态(即.NET Core 3.1 中的警告级别),你所需要做的就是在你的项目文件中将分析级别设置为4。下面是一个例子:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <!-- get the exact same warnings you used to -->
    <AnalysisLevel>4</AnalysisLevel>
  </PropertyGroup>

</Project>

只关闭一个规则

  如果有一个你认为不适用于你的代码库的特定警告,你可以使用一个 editorconfig 文件来关闭它。你可以通过在错误列表中将警告的严重性设置为“none”来做到这一点。

  或者从编辑器中出现警告的灯泡菜单中选择“None”

关闭警告的单个实例

  如果你大部分时间都想使用这个警告,但在少数情况下要关闭它,你可以使用灯泡菜单中的一个:

  在源码中禁止

  在单独的禁止文件中禁止它

 

  在源码中禁用并标记一个特性

总结

  我们希望你对 .NET 5 代码分析的改进感到兴奋,请给我们一些反馈。

原文链接

  https://devblogs.microsoft.com/dotnet/automatically-find-latent-bugs-in-your-code-with-net-5/

 

posted @ 2020-10-14 23:12  MeteorSeed  阅读(1195)  评论(0编辑  收藏  举报