List 还是 IEnumerable?C#开发者必须掌握的性能与抽象平衡术

7 阅读4分钟

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 的场景:

  1. 需要频繁随机访问units[5].Attack()
  2. 需要多次遍历:先统计数量,再处理每个元素
  3. 需要修改集合:添加、删除、插入元素
  4. 性能关键路径: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 的场景:

  1. 只读遍历一次:查找、过滤、转换
  2. 链式查询:LINQ的Where、Select、OrderBy
  3. 大数据流:不需要全部加载到内存
  4. 方法参数:只需要遍历能力
// 游戏中的技能目标选择
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);
}

性能优化建议

  1. 小集合用 List,大集合考虑 IEnumerable
  2. Update 中避免频繁创建迭代器
  3. 使用 AsReadOnly() 明确只读意图
  4. 考虑 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(垃圾回收)压力增大,影响游戏流畅度。希望这篇文章能帮助你在面对集合类型选择时,做出最合适的决定!