寫 C# 久了之後,會發現效能改善不一定都要做到很複雜
很多時候不是要換架構,也不是一定要上什麼很進階的技巧, 反而只是一些平常寫 Code 時的小習慣
例如少查一次 Dictionary、少產生一個 String、少配置一個 Array,單看一次幾乎沒有差
但如果這段程式每天跑幾十萬、幾百萬次,這些小地方累積起來其實就有差了..
這篇整理幾個我之前看到比較常用的 C# 小技巧, 都不是什麼很屌的技術只是一些小習慣的改變
也不一定每個地方都要硬套
但如果剛好是在大量迴圈、API、Background Service、Parser 或一些高頻處理的地方,就可以注意一下,畢竟量便會引發質變..
1. Dictionary 能用 TryGetValue,就不要多查一次
這種寫法應該很多人這樣寫,包括我
if (users.ContainsKey(userId))
{
var user = users[userId];
Console.WriteLine(user.Name);
}邏輯沒有問題, 但這邊其實查了兩次 Dictionary。
第一次:
users.ContainsKey(userId)第二次:
users[userId]如果只是一般 CRUD,其實不用太在意。 但既然 .NET 已經有:
if (users.TryGetValue(userId, out var user))
{
Console.WriteLine(user.Name);
}那通常直接這樣寫就好。 一次 Lookup 就把 Key 是否存在跟 Value 一起拿回來
這種算是很好改,也不太會讓 Code 變難懂..
2. 大量更新 Dictionary,可以看看 CollectionsMarshal
如果只是單純讀取,TryGetValue() 已經很好用了, 但有些情境是會一直更新 Dictionary 裡面的值
例如統計次數:
Dictionary<string, int> counts = new();
foreach (var item in items)
{
if (counts.TryGetValue(item, out var count))
{
counts[item] = count + 1;
}
else
{
counts[item] = 1;
}
}這段也沒有問題。 只是取得 Value 後,最後還是要再寫回 Dictionary
如果這段真的是很高頻的 Hot Path,可以用:
CollectionsMarshal.GetValueRefOrAddDefault()直接拿 Dictionary 裡 Value 的 Reference
using System.Runtime.InteropServices;
Dictionary<string, int> counts = new();
foreach (var item in items)
{
ref int count =
ref CollectionsMarshal.GetValueRefOrAddDefault(
counts,
item,
out bool exists);
if (!exists)
{
count = 0;
}
count++;
}這樣:
count++;改到的就是 Dictionary 裡面的值。 不用再
counts[item] = count;不過這個我不會建議到處用。 CollectionsMarshal 比較偏低階 API
一般程式碼還是先用 TryGetValue() 就好,真的確認是 Hot Path 再來看這個...
3. 字串忽略大小寫,不要先 ToLower
以前常用:
if (role.ToLower() == "admin")
{
}或者
if (role.ToUpper() == "ADMIN")
{
}看起來很直覺,好像也沒啥問題,但 ToLower() 跟 ToUpper() 都會產生新的 String
如果只是比較大小寫,其實不用先做一次字串轉換
可以直接:
if (string.Equals(
role,
"admin",
StringComparison.OrdinalIgnoreCase))
{
}
如果是 Dictionary,也可以在建立時直接指定
var users = new Dictionary<string, User>(
StringComparer.OrdinalIgnoreCase);這個除了少掉 Allocation,還有 Culture 的問題。 如果是 Username、Role、HTTP Header、Cache Key、Protocol、Identifier
這些程式內部使用的字串, 通常用 Ordinal 或 OrdinalIgnoreCase 會比較合理
4. 切字串很多次,可以開始看 Span
假設有一個字串:
string input = "ABC-123456";以前可能直接:
string prefix = input.Substring(0, 3);
string number = input.Substring(4);這樣很好懂,但 Substring() 會產生新的 String, 如果只是偶爾做一次完全沒差
但如果是在 Parser、Log 處理或大量資料裡面一直切,就會產生很多暫時物件
這時可以改用:
ReadOnlySpan<char> span = input.AsSpan();
ReadOnlySpan<char> prefix = span[..3];
ReadOnlySpan<char> number = span[4..];Span 這邊不會另外建立新的 String,而是直接使用原本字串裡面的那一段資料
所以如果 Code 裡面有很多 Substring()、String.Split()、ToCharArray()
就可以看一下是不是真的需要另外建立新的資料, 如果只是拿其中一段來 Parse,Span<T> 就滿適合的..
5. 固定搜尋一組字元,可以看看 SearchValues
例如我要檢查檔名裡面有沒有非法字元:
char[] invalidChars =
{
'<',
'>',
':',
'"',
'/',
'\\',
'|',
'?',
'*'
};很直覺的寫法:
foreach (char c in input)
{
if (invalidChars.Contains(c))
{
return false;
}
}如果這組搜尋條件會一直重複使用,可以用:
SearchValues<char>例如:
using System.Buffers;
private static readonly SearchValues<char> InvalidChars =
SearchValues.Create("<>:\"/\\|?*");接著:
bool containsInvalid =
input.AsSpan().ContainsAny(InvalidChars);全文長這樣:
using System.Buffers;
public static class FileNameValidator
{
private static readonly SearchValues<char> InvalidChars =
SearchValues.Create("<>:\"/\\|?*");
public static bool IsValid(string fileName)
{
return !fileName
.AsSpan()
.ContainsAny(InvalidChars);
}
}這種比較適合 Parser、Validator、Tokenizer、大量文字掃描或 Protocol Processing 這類情境
尤其搜尋集合是固定的,又會一直重複使用時。 如果只是偶爾檢查一次,就沒差
這些東西當然不用全部套上去,像 TryGetValue()、OrdinalIgnoreCase 這種本來就很好懂,我通常會直接用;CollectionsMarshal、Span<T>
就 真的看情況,確定是在 Hot Path 再處理就好
畢竟為了少一點 Allocation,把原本很單純的 Code 寫到半年後自己都看不懂,好像也沒有比較划算,而且現在都有 AI 了
真的哪天忘記自己在寫什麼,至少還可以先丟給 AI 問一下
這些技巧本來就只是少一次 Lookup、少一次 Allocation 或少一次 Copy,平常可能完全沒感覺,但同一段 Code 跑很多次還是會慢慢累積
不過說真的現在很多 Code 都直接叫 AI 生了,搞不好以後反而更需要偶爾看一下它到底幫你 new 了多少東西 .. :)
---
The bug existed in all possible states.
Until I ran the code.
