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