List 还是 IEnumerable?C#开发者必须掌握的性能与抽象平衡术
在日常开发中,我们经常面临集合类型的选择困境。是使用功能丰富的 List,还是抽象优雅的 IEnumerable?今天我们就来彻底解析这个问题。
从一道经典面试题说起
先看这个简单的代码片段:
// 版本A
public List<Unit> GetAllPlayerUnits()
{
var result = new List<Unit>();
result.AddRange(m_PlayerUnits);
result.AddRange(m_PlayerBuildings);
return result;
}
// 版本B
public IEnumerable<Unit> GetAllPlayerUnits()
{
return m_PlayerUnits.Concat(m_PlayerBuildings);
}
你觉得哪个版本更好?为什么?
核心差异:立即执行 vs 延迟执行
List:立即执行,内存占用明确
// List 版本会立即创建新集合
public List<Unit> GetAliveUnits()
{
var aliveUnits = new List<Unit>();
foreach (var unit in m_AllUnits)
{
if (unit.IsAlive)
aliveUnits.Add(unit); // 立即分配内存
}
return aliveUnits; // 返回完整的列表
}
特点:
- 立即执行所有操作
- 占用明确的内存空间
- 可多次遍历、随机访问
IEnumerable:延迟执行,按需计算
// IEnumerable 版本只是定义查询
public IEnumerable<Unit> GetAliveUnits()
{
foreach (var unit in m_AllUnits)
{
if (unit.IsAlive)
yield return unit; // 每次只返回一个
}
}
特点:
- 定义查询,不立即执行
- 遍历时才计算结果
- 节省内存,支持无限序列
性能对比:数字不会说谎
让我们通过基准测试看看实际差异:
[MemoryDiagnoser]
public class ListVsEnumerableBenchmark
{
private List<int> data = Enumerable.Range(0, 10000).ToList();
[Benchmark]
public int ListVersion()
{
var filtered = new List<int>();
foreach (var item in data)
if (item % 2 == 0)
filtered.Add(item);
int sum = 0;
foreach (var item in filtered)
sum += item;
return sum;
}
[Benchmark]
public int EnumerableVersion()
{
var filtered = data.Where(x => x % 2 == 0);
int sum = 0;
foreach (var item in filtered)
sum += item;
return sum;
}
}
结果可能让你惊讶:
- 内存分配:List版本分配了 40KB,IEnumerable版本几乎为0
- 执行时间:小数据量时差异不大,大数据量时IEnumerable有优势
实战场景分析
场景1:游戏中的单位查找
// ✅ 适合 IEnumerable:只遍历一次,中途可能中断
public Unit FindClosestUnit(IEnumerable<Unit> units, Vector3 position)
{
Unit closest = null;
float minDistance = float.MaxValue;
foreach (var unit in units) // 可能找到就提前退出
{
var dist = Vector3.Distance(unit.Position, position);
if (dist < minDistance)
{
closest = unit;
minDistance = dist;
if (dist < 1f) break; // 提前退出!
}
}
return closest;
}
// ❌ 不适合 IEnumerable:需要多次使用
public void ProcessUnits(IEnumerable<Unit> units)
{
// 问题1:Count() 需要遍历整个集合
Debug.Log($"单位数量: {units.Count()}");
// 问题2:第二次遍历会重新开始
foreach (var unit in units)
unit.Update();
// 问题3:第三次遍历又重来!
foreach (var unit in units)
unit.Render();
}
场景2:Web API 数据流处理
// ✅ 完美适合 IEnumerable:大数据流处理
public async IAsyncEnumerable<Product> StreamProductsAsync()
{
using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
using var command = new SqlCommand("SELECT * FROM Products", connection);
using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
yield return MapToProduct(reader); // 一次返回一个,不缓存全部
}
}
// 客户端可以边接收边处理
await foreach (var product in StreamProductsAsync())
{
// 处理产品,内存占用恒定
await ProcessProductAsync(product);
}
实际选择指南
什么时候该用 List?
用 List 的场景:
- 需要频繁随机访问:
units[5].Attack() - 需要多次遍历:先统计数量,再处理每个元素
- 需要修改集合:添加、删除、插入元素
- 性能关键路径:Update()中需要高效访问
// 游戏中的单位管理器
public class UnitManager
{
// ✅ 适合用 List:需要频繁访问和修改
private List<Unit> m_ActiveUnits = new List<Unit>();
public void AddUnit(Unit unit) => m_ActiveUnits.Add(unit);
public Unit GetUnit(int index) => m_ActiveUnits[index];
public int UnitCount => m_ActiveUnits.Count;
}
什么时候该用 IEnumerable?
用 IEnumerable 的场景:
- 只读遍历一次:查找、过滤、转换
- 链式查询:LINQ的Where、Select、OrderBy
- 大数据流:不需要全部加载到内存
- 方法参数:只需要遍历能力
// 游戏中的技能目标选择
public IEnumerable<Unit> FindTargetsInRange(
Vector3 center,
float range,
IEnumerable<Unit> candidates) // ✅ 参数用 IEnumerable
{
foreach (var unit in candidates)
{
if (Vector3.Distance(unit.Position, center) <= range)
yield return unit; // 延迟执行,节省内存
}
}
折中方案:IReadOnlyCollection
当你需要明确表达"只读且有计数"的意图时:
public IReadOnlyCollection<Unit> GetActiveUnits()
{
// 明确告诉调用者:这是只读的,但有快速计数
return m_ActiveUnits.AsReadOnly();
}
// 调用者知道可以安全地获取 Count,但不能修改
var units = GetActiveUnits();
Debug.Log($"活跃单位: {units.Count}"); // Count 属性,不是 Count() 方法
高级技巧:两者结合的最佳实践
技巧1:适时物化
public class UnitManager
{
private IEnumerable<Unit> m_UnitQuery;
private List<Unit> m_CachedUnits;
private bool m_IsDirty = true;
// 对外暴露 IEnumerable,内部缓存 List
public IEnumerable<Unit> GetVisibleUnits()
{
if (m_IsDirty)
{
m_CachedUnits = m_UnitQuery.Where(u => u.IsVisible).ToList();
m_IsDirty = false;
}
return m_CachedUnits;
}
}
技巧2:混合使用模式
public class DataProcessor
{
// 输入用最抽象的,输出用最合适的
public List<Result> ProcessData(
IEnumerable<Input> inputStream, // 接受任何可遍历的输入
Func<Input, bool> filter)
{
// 中间使用 IEnumerable 避免内存分配
var filtered = inputStream.Where(filter);
// 最终结果用 List 便于调用者使用
var results = new List<Result>();
foreach (var item in filtered)
{
results.Add(ProcessItem(item));
}
return results;
}
}
常见的陷阱与解决方案
陷阱1:多次遍历 IEnumerable
// ❌ 错误做法
var query = data.Where(x => x.IsValid);
if (query.Any()) // 第一次遍历
{
foreach (var item in query) // 第二次遍历
{
// 对于某些集合,这会重新开始!
}
}
// ✅ 正确做法
var list = data.Where(x => x.IsValid).ToList(); // 物化一次
if (list.Count > 0)
{
foreach (var item in list) // 使用缓存版本
{
// 安全!
}
}
陷阱2:异常处理
public IEnumerable<int> GetNumbers()
{
yield return 1;
throw new Exception("Error!");
yield return 2; // 永远不会执行
}
// 调用时异常可能延迟出现
var numbers = GetNumbers();
Console.WriteLine("开始遍历"); // 这行会执行
foreach (var num in numbers) // 这里才抛出异常!
{
Console.WriteLine(num);
}
性能优化建议
- 小集合用 List,大集合考虑 IEnumerable
- Update 中避免频繁创建迭代器
- 使用 AsReadOnly() 明确只读意图
- 考虑 IAsyncEnumerable 处理异步流
// 游戏开发中的实际示例
void Update()
{
// ❌ 每帧都创建新的迭代器
foreach (var unit in GetAllUnits())
{
if (ShouldUpdate(unit))
unit.Update();
}
// ✅ 缓存迭代结果
if (m_UnitsDirty)
{
m_UnitsToUpdate = GetAllUnits().Where(ShouldUpdate).ToList();
m_UnitsDirty = false;
}
foreach (var unit in m_UnitsToUpdate)
{
unit.Update(); // 使用缓存的 List
}
}
总结
选择 List 还是 IEnumerable,本质上是性能与抽象的权衡:
- List 像完整的工具箱:功能齐全,但每次都要把整个箱子搬出来
- IEnumerable 像按需供给的流水线:需要什么给什么,更高效但功能有限
记住这个黄金法则:
- 作为参数,尽量接受
IEnumerable<T>(最灵活) - 作为返回值,考虑调用者的需求
- 作为内部存储,使用最合适的具体类型
- 在性能关键路径,谨慎使用 IEnumerable
在游戏开发中,这个选择尤为重要。一个错误的选择可能导致 GC(垃圾回收)压力增大,影响游戏流畅度。希望这篇文章能帮助你在面对集合类型选择时,做出最合适的决定!