寫代碼是一件令人興奮的事情,特別是對於 .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; } } }
設置分析級別
現在您已經看到了這些警告的重要性,您可能永遠不想回到一個沒有它們的世界,對嗎?我知道世界並不總是這樣運轉的。正如在這篇文章的開頭提到的,這些都是打破源代碼的改變,你應該在適合自己的時間表中完成它們。我們現在介紹這個的部分原因是為了得到兩個方面的反饋:
-
我們提出的這一小部分警告是否太過破壞性
-
調整警告的機制是否足以滿足您的需要
回到 .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/