
我开发过从企业级应用程序到性能关键型系统的各种项目,然而在这些年里,我注意到一件奇怪的事情——每个人都在谈论相同的最佳实践。
今天,我想分享 25 个 C# 实践中被谈论得不够多的技巧。这些习惯将经验丰富的 C# 开发者与那些只遵循教科书的人区分开来。
1. 结构体(Struct)不仅仅是为了性能——它们还能减少 Bug
大多数开发者都知道 C# 中的结构体是值类型,而类是引用类型。大多数关于结构体的讨论都围绕性能优势——如何通过传递结构体避免堆分配,以及它们不需要垃圾回收。但还有一个更大、鲜为人知的优势:结构体可以防止整类 Bug。
假设你正在处理一个接受金额值的 API。常见的做法是使用 decimal
类型:
public void ProcessPayment(decimal amount) { ... }
这虽然可行,但容易出错。有人可能会传递税率而不是金额。
相反,将值包装在结构体中会更清晰:
public readonlystructMoney
{
publicdecimal Amount {get;}
publicMoney(decimal amount)
{
if(amount <0)thrownewArgumentException("Amount cannot be negative.");
Amount = amount;
}
publicstaticimplicitoperatorMoney(decimal amount)=>newMoney(amount);
}
现在,你的 API 在类型级别上强制执行意图:
public void ProcessPayment(Money amount) { ... }
编译器不会让你意外传递税率或随机的 decimal
。在这种情况下,结构体不仅提高了性能,还减少了开发者犯错的可能性。
2. 异步代码不仅仅是 async
和 await
——它还关乎控制执行上下文
当人们讨论 C# 中的异步编程时,大多集中在 async
和 await
上。但这只是表面。真正的力量在于理解执行上下文。
以下是一个常见错误:
public async Task<int> GetDataAsync()
{
var data = await _database.GetRecordsAsync();
return data.Count;
}
乍一看,这似乎没问题。但如果 _database.GetRecordsAsync()
正在进行繁重的 I/O 工作,它会捕获同步上下文,可能导致 UI 应用程序中的死锁或高性能系统中不必要的上下文切换。
更好的方法是使用 ConfigureAwait(false)
,当你不需要在同一上下文中恢复时:
public async Task<int> GetDataAsync()
{
var data = await _database.GetRecordsAsync().ConfigureAwait(false);
return data.Count;
}
这个小改动可以显著提高性能,尤其是在服务器应用程序中。然而,尽管这是一个最佳实践,但在性能关键领域之外的讨论中却很少被提及。
3. 避免 null
不仅仅是使用 Nullable<T>
每个 C# 开发者都曾面对可怕的 NullReferenceException
。这就是为什么 C# 8.0 引入了可空引用类型。然而,即使有了这些功能,开发者仍然过度依赖 null
。
以下是大多数人的做法:
public class UserService
{
public User? GetUser(int id)
{
return _repository.FindById(id);
}
}
现在,每个 GetUser
的调用者都必须检查 null
:
var user = _userService.GetUser(1);
if (user != null)
{
Console.WriteLine(user.Name);
}
这种方法会使代码变得杂乱。相反,更好的方法是返回一个 Option<T>
或特殊的“空”对象:
public sealed class NoUser : User { }
public static readonly User NoUserInstance = new NoUser();
现在,该方法永远不会返回 null
:
public User GetUser(int id)
{
return _repository.FindById(id) ?? NoUserInstance;
}
这个简单的改动消除了 null
检查,并减少了意外的 NullReferenceException
Bug。然而,很少有 C# 开发者始终如一地实现它。
4. Span<T>
和 Memory<T>
是游戏规则改变者——即使你不编写高性能代码
Span<T>
和 Memory<T>
通常在高性能应用程序的上下文中讨论,但它们的真正好处是编写更安全、更高效的代码——而无需指针或不安全块的复杂性。
考虑以下简单示例:
public voidProcessBuffer(byte[] data)
{
for(int i =0; i < data.Length; i++)
{
data[i]=(byte)(data[i]+1);
}
}
使用 Span<T>
,这变得更安全且更灵活:
public voidProcessBuffer(Span<byte> data)
{
for(int i =0; i < data.Length; i++)
{
data[i]=(byte)(data[i]+1);
}
}
为什么这更好?
- 它防止了大型缓冲区的意外复制,从而在大规模应用程序中提高了性能。
通过 Span<T>
和 Memory<T>
,你可以安全高效地操作数据。但由于它们通常与“低级”性能优化相关联,大多数 C# 开发者并未探索它们的全部潜力。
5. 使用 readonly struct
实现真正的不可变性和性能
许多 C# 开发者使用不可变类来确保线程安全性和可预测性。但在某些场景中,不可变结构体甚至更好。
普通结构体仍可能被意外修改:
public struct Point
{
public int X;
public int Y;
}
即使结构体是值类型,如果通过引用传递,它们仍然可以被修改。为了强制执行不可变性,请使用 readonly struct
:
public readonlystructPoint
{
publicint X {get;}
publicint Y {get;}
publicPoint(int x,int y)=>(X, Y)=(x, y);
}
现在,Point
在创建后无法修改,确保了更好的性能和安全性。
6. 使用 CallerMemberName
实现更好的日志记录和调试
日志记录是调试和监控的重要组成部分,但许多开发者手动将方法名称传递到日志中:
public void ProcessOrder()
{
_logger.Log("Processing order in ProcessOrder");
}
相反,使用 [CallerMemberName]
自动捕获方法名称:
public void LogMessage(string message, [CallerMemberName] string caller = "")
{
Console.WriteLine($"{caller}: {message}");
}
现在,你可以简单地调用:
LogMessage("Processing order");
它会自动打印:
ProcessOrder: Processing order
这个小技巧减少了手动错误并提高了日志记录的准确性。
7. 使用 Dictionary<TKey, Lazy<TValue>>
实现高效缓存
在实现缓存时,许多开发者会立即将值存储在字典中:
private Dictionary<int, User> _userCache = new();
但这意味着即使从未使用过,每个条目也会预先计算。更好的方法是使用 Lazy<T>
进行延迟初始化:
private Dictionary<int, Lazy<User>> _userCache = new();
现在,值仅在访问时创建:
var user = _userCache[userId].Value; // 仅在第一次访问时计算
这提高了效率,尤其是在从 API 或数据库加载数据时。
8. 在依赖注入中使用 KeyedService
实现多实现
有时,你有多个接口实现,但标准依赖注入不允许你轻松选择特定的实现。
public interfaceINotification
{
voidSend(string message);
}
publicclassEmailNotification:INotification{...}
publicclassSmsNotification:INotification{...}
与其使用 IEnumerable<INotification>
并手动过滤,不如使用 .NET 8 引入的键控依赖注入:
builder.Services.AddKeyedSingleton<INotification, EmailNotification>("Email");
builder.Services.AddKeyedSingleton<INotification, SmsNotification>("SMS");
然后,像这样解析它:
var smsNotifier = serviceProvider.GetRequiredKeyedService<INotification>("SMS");
这简化了服务解析,避免了不必要的条件逻辑。
9. 使用 Span<T>
避免不必要的字符串分配
在 C# 中,字符串操作通常会导致隐藏的内存分配,尤其是在大规模应用程序中。考虑以下示例:
string input = "John,Doe,Developer";
var parts = input.Split(',');
每次调用 Split()
都会分配一个新的字符串数组。相反,使用 Span<T>
:
ReadOnlySpan<char> input = "John,Doe,Developer";
var firstName = input.Slice(0, 4); // "John"
这种方法避免了不必要的分配,并且速度显著更快。
10. 在异步方法中正确使用 CancellationToken
许多开发者忘记在异步方法中传播 CancellationToken
,导致应用程序无响应。
错误做法:
public async Task FetchData()
{
await Task.Delay(5000); // 无法取消
}
更好的方法:
public async Task FetchData(CancellationToken token)
{
await Task.Delay(5000, token);
}
这确保了如果用户取消操作,它会立即停止,而不是等待。
11. 使用 Enumerable.Range()
实现更简洁的循环
与其使用手动循环,不如使用 Enumerable.Range()
实现更简洁、更具表现力的代码:
foreach (var i in Enumerable.Range(1, 10))
{
Console.WriteLine(i);
}
这种方法更具可读性和功能性,减少了与循环相关的错误。
12. 优先使用 TryParse
而不是 Parse
以避免异常
异常是昂贵的。与其使用 int.Parse()
(在失败时抛出异常):
int value = int.Parse("notANumber"); // 抛出异常
不如使用 TryParse()
来避免不必要的异常处理:
if (int.TryParse("notANumber", out int value))
{
Console.WriteLine($"Valid number: {value}");
}
这提高了性能,并避免了不必要的 try-catch
块。
13. 使用 record struct
实现高性能不可变类型
C# 9 引入了记录(record)用于不可变类型,但 C# 10 进一步改进了它,引入了 record struct
:
public readonly record struct Coordinates(int X, int Y);
这提供了:
非常适合 DTO、事件数据和缓存场景。
14. 使用 string.Create()
优化字符串构建
在构建大型字符串时,与其使用 StringBuilder
,不如使用 string.Create()
,它直接写入内存:
var str = string.Create(5, 'X', (span, ch) =>
{
span.Fill(ch);
});
这避免了中间分配,使其非常适合性能关键型应用程序。
15. 使用 nameof()
而不是硬编码字符串
在方法名称、属性名称或异常消息中使用硬编码字符串容易出错:
throw new ArgumentException("Invalid parameter: customerId");
相反,使用 nameof()
:
throw new ArgumentException($"Invalid parameter: {nameof(customerId)}");
如果变量名称更改,nameof()
会自动更新,减少了维护工作量。
16. 使用 ConditionalWeakTable
将数据与对象关联
许多开发者将元数据存储在字典中,如果对象未被移除,可能会导致内存泄漏。
与其使用:
Dictionary<MyClass, string> _metadata = new();
不如使用 ConditionalWeakTable<T, TValue>
,它在对象被垃圾回收时自动移除数据:
private static readonly ConditionalWeakTable<MyClass, string> _metadata = new();
这确保了没有内存泄漏,非常适合缓存计算值。
17. 使用 Task.WhenAll
而不是多次 await
调用
如果你有多个异步操作,避免顺序等待它们:
await Task1();
await Task2();
await Task3();
相反,使用 Task.WhenAll()
并行运行它们:
await Task.WhenAll(Task1(), Task2(), Task3());
这通过并发运行任务显著减少了执行时间。
18. 使用 sealed
关键字提升性能
默认情况下,C# 类可以被继承,这会由于虚方法分派而增加额外的性能开销。
如果一个类不打算被继承,请将其标记为 sealed
:
public sealed class MyClass
{
public void DoWork() { /* 快速执行 */ }
}
这允许 JIT 编译器优化方法调用,提高性能。
19. 使用 Stopwatch
而不是 DateTime
进行性能测量
在测量执行时间时,开发者通常使用 DateTime
:
var start = DateTime.Now;
// 某些操作
var elapsed = DateTime.Now - start;
这是不准确的,因为 DateTime.Now
受系统时钟变化的影响。相反,使用 Stopwatch
:
var stopwatch = Stopwatch.StartNew();
// 某些操作
stopwatch.Stop();
Console.WriteLine($"Elapsed time: {stopwatch.ElapsedMilliseconds} ms");
Stopwatch
使用高分辨率计时器,使其更加准确。
20. 使用插值字符串处理器实现高效的字符串格式化
使用 $"{var1} {var2}"
进行日志记录很常见,但它会分配不必要的字符串。
在 .NET 6+ 中,使用插值字符串处理器来避免分配:
public void Log(LogLevel level, [InterpolatedStringHandler] ref LogInterpolatedStringHandler message)
{
Console.WriteLine(message);
}
这允许零分配日志记录,提高了高负载应用程序的性能。
21. 使用 Parallel.ForEachAsync
实现真正的异步并行
开发者通常使用 Parallel.ForEach
,但它不支持 async/await
。相反,使用 Parallel.ForEachAsync
:
await Parallel.ForEachAsync(myCollection, async (item, token) =>
{
await ProcessItemAsync(item);
});
这允许真正的并行异步执行,在处理 I/O 密集型操作时提高了性能。
22. 使用 Dictionary.TryAdd
避免异常开销
在向字典添加元素时,开发者通常会先检查键是否存在:
if (!dict.ContainsKey(key))
{
dict.Add(key, value);
}
更好的方法是使用 TryAdd()
,它避免了双重查找开销:
dict.TryAdd(key, value);
这既更快又更高效。
23. 使用 ValueTask<T>
减少高性能代码中的分配
Task<T>
很好,但它总是分配内存。如果一个方法经常返回已完成的任务,请使用 ValueTask<T>
:
public ValueTask<int> GetCachedDataAsync()
{
return new ValueTask<int>(42); // 无堆分配
}
ValueTask<T>
在结果已经可用时避免了不必要的内存分配,提高了性能。
24. 使用 ConfigureAwait(false)
避免异步代码中的死锁
在编写库中的异步代码时,始终使用 ConfigureAwait(false)
以防止 UI 死锁:
await Task.Delay(1000).ConfigureAwait(false);
这告诉运行时不要捕获原始的同步上下文,提高了性能并避免了桌面和 Web 应用程序中的死锁。
25. 使用 BlockingCollection<T>
实现生产者-消费者场景
如果多个线程需要并行处理数据,使用普通队列会导致竞争条件:
Queue<int> queue = new();
queue.Enqueue(10); // 无线程安全
相反,使用 BlockingCollection<T>
实现线程安全的生产者-消费者模式:
var queue = new BlockingCollection<int>();
queue.Add(10);
int item = queue.Take();
这确保了安全的并发访问,提高了多线程性能。
最后总结
- 结构体不仅仅是为了性能——它们可以防止整类 Bug。
- 异步执行上下文与
async
和 await
同样重要。 - 避免
null
不仅仅是使用 Nullable<T>
——它还关乎返回有意义的默认值。 Span<T>
和 Memory<T>
不仅仅是为了性能——它们使内存管理更安全、更容易。readonly struct
CallerMemberName
Lazy<T>
Span<T>
CancellationToken
TryParse()
record struct
string.Create()
nameof()
ConditionalWeakTable
Task.WhenAll
sealed
Stopwatch
Parallel.ForEachAsync
TryAdd()
ValueTask<T>
ConfigureAwait(false)
BlockingCollection<T>
从今天开始将这些技术应用到你的 C# 项目中,立即看到改进!🚀
阅读原文:https://mp.weixin.qq.com/s/H3SpEMFgmxUc9mQArSmRqA
该文章在 2025/2/21 13:07:12 编辑过