如果你跟我一樣,習慣性的優先使用 “using declaration” 來撰寫 using 語句,並且翻寫範例時會自動將 “using statement” 轉換成 “using declaration”,那麼不久的將來你可能也會遇到這個問題。

using declaration

在 C# 8 更新中推出了 using declaration,它是對 using 語句的簡化和擴展。使用 “using declaration” 有以下幾個好處:

  1. 程式碼簡潔性:讓程式碼更簡潔,省去了 using 語句的需求,使程式碼看起來更整潔,特別是當有多個 using 語句時。例如,對比傳統的 using 語句 “using statement”:

    1
    2
    3
    4
    using (var stream = new MemoryStream())
    {
    // 使用 stream
    }

    與 “using declaration”:

    1
    2
    using var stream = new MemoryStream();
    // 使用 stream
  2. 範圍性:只在當前的範圍中有效。在範圍結束時,相應的資源會被自動關閉和釋放,不需要額外的 Dispose() 調用。關閉的順序如下,從最後一個到第一個:

    1
    2
    3
    4
    5
    6
    7
    8
    { 
    using var f1 = new FileStream("...");
    using var f2 = new FileStream("..."), f3 = new FileStream("...");
    ...
    // Dispose f3
    // Dispose f2
    // Dispose f1
    }
  3. 可讀性:提高了程式碼的可讀性,使開發者更容易識別出哪些變數是具有限定範圍的。

    1
    using var stream = new MemoryStream();

    1
    var stream = new MemoryStream();

總而言之,”using declaration” 提供了一種更簡潔、可讀性更高的方式來處理需要釋放的資源,同時還具有範圍性,有助於減少資源泄漏的可能性,使程式碼更加安全可靠。

BUT!!! 會寫這篇文章就是因為我在使用 “using declaration” 時遇到了一個坑,讓我不得不重新思考使用 “using declaration” 的時機。如果你跟我一樣,習慣性的優先使用 “using declaration” 來撰寫 using 語句,並且翻寫範例時會自動將 “using statement” 轉換成 “using declaration”,那麼你可能也會遇到這個問題。

錯誤案例

這次我碰到的就是 Zip 操作時常用的類別 ZipArchive,以下是常見的 ZipArchive 使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private static byte[] CompressFilesToZipWithUsingStatement(Dictionary<string, byte[]> filesToCompress)
{
using (var zipMemoryStream = new MemoryStream())
{
using (var zipArchive = new ZipArchive(zipMemoryStream, ZipArchiveMode.Create))
{
foreach (var fileName in filesToCompress.Keys)
{
var fileData = filesToCompress[fileName];
var zipEntry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal);

using (var entryStream = zipEntry.Open())
{
entryStream.Write(fileData, 0, fileData.Length);
}
}
}
return zipMemoryStream.ToArray();
}
}

但如果我們不經過大腦與測試將所有 “using statement” 改成 “using declaration”,就會發生一點問題:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static byte[] CompressFilesToZipWithUsingDeclaration(Dictionary<string, byte[]> filesToCompress)
{
using var zipMemoryStream = new MemoryStream();
using var zipArchive = new ZipArchive(zipMemoryStream, ZipArchiveMode.Create);

foreach (var fileName in filesToCompress.Keys)
{
var fileData = filesToCompress[fileName];
var zipEntry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal);

using var entryStream = zipEntry.Open();
entryStream.Write(fileData, 0, fileData.Length);
}

return zipMemoryStream.ToArray();
}

以下是使用範例:

1
2
3
4
5
6
7
8
9
10
11
12
var filesToCompress = new Dictionary<string, byte[]>()
{
["mio.txt"] = Encoding.UTF8.GetBytes("Mio"),
["miffy.txt"] = Encoding.UTF8.GetBytes("Miffy")
};

var zipFileStatement = CompressFilesToZipWithUsingStatement(filesToCompress);
var zipFileDeclaration = CompressFilesToZipWithUsingDeclaration(filesToCompress);

File.WriteAllBytes("using-statement.zip", zipFileStatement);
File.WriteAllBytes("using-declaration.zip", zipFileDeclaration);
Console.WriteLine("壓縮完成!");

如果執行上面的程式碼,你會發現 “using declaration” 後產生的壓縮檔案是壞的,而 “using statement” 產生的壓縮檔案是正確的。

/images/2023-10-23/zip-compresses-corrupted.jpg

釋放資源的時機

不是所有類別在釋放資源時,只是單純地釋放資源,也可能會進行額外的操作。

讓我們來看看 ZipArchive 在 Dispose 時會發生什麼事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
protected virtual void Dispose(bool disposing)
{
if (disposing && !_isDisposed)
{
try
{
switch (_mode)
{
case ZipArchiveMode.Read:
break;
case ZipArchiveMode.Create:
case ZipArchiveMode.Update:
default:
Debug.Assert(_mode == ZipArchiveMode.Update || _mode == ZipArchiveMode.Create);
WriteFile();
break;
}
}
finally
{
CloseStreams();
_isDisposed = true;
}
}
}

可以看到當 ZipArchiveMode 為 Create 或 Update 時,會呼叫 WriteFile 方法:

1
2
3
4
5
6
private void WriteFile()
{
// ... 省略部分程式碼

WriteArchiveEpilogue(startOfCentralDirectory, sizeOfCentralDirectory);
}

這邊我們只需要關注在最後一個方法 WriteArchiveEpilogue

1
2
3
4
5
6
// writes eocd, and if needed, zip 64 eocd, zip64 eocd locator
// should only throw an exception in extremely exceptional cases because it is called from dispose
private void WriteArchiveEpilogue(long startOfCentralDirectory, long sizeOfCentralDirectory)
{
// ... 省略部分程式碼
}

看方法說明可以知道是用於寫入壓縮檔案的結尾,那這個與壓縮檔錯誤有什麼關係呢?回頭看原先的語法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static byte[] CompressFilesToZipWithUsingDeclaration(Dictionary<string, byte[]> filesToCompress)
{
using var zipMemoryStream = new MemoryStream();
using var zipArchive = new ZipArchive(zipMemoryStream, ZipArchiveMode.Create);

foreach (var fileName in filesToCompress.Keys)
{
var fileData = filesToCompress[fileName];
var zipEntry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal);

using var entryStream = zipEntry.Open();
entryStream.Write(fileData, 0, fileData.Length);
}

return zipMemoryStream.ToArray();
}

還記得 “using declaration” 的資源釋放時機是區塊的結束,也就是說在 return zipMemoryStream.ToArray(); 結束離開區塊後。所以在 zipArchive 釋放資源之前,也就是寫入壓縮檔的結尾之前,就已經將 zipMemoryStream 轉換成 byte[] 並回傳了,所以壓縮檔案當然會壞掉!

心法

除非明確了解類別的 Dispose 行為,否則 IDE 提示你簡化時你在簡化,可以看到圖片中只有 MemoryStreamzipEntry.Open()(Stream) 兩個類型時會有修正通知,這是由於 Visual Studio 分析器(Overview of source code analysis)判斷出這兩個類型在 Dispose 時只是單純地釋放資源,不會有額外的操作,所以可以安全地轉換成 “using declaration”。

/images/2023-10-23/IDE0063.png