【譯】自動發現 .NET 5 中程式碼的潛在錯誤

MeteorSeed發表於2020-10-14

  寫程式碼是一件令人興奮的事情,特別是對於  .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/

 

相關文章