第2章:集合和LINQ(建议16-31)

覆盖数组与集合、foreach/for、初始化、泛型集合、集合选择与线程安全、自定义集合和属性,以及LINQ投影、执行模型、IEnumerable/IQueryable与枚举成本。

学习目标

  • 能分析cardinality、dominant operation、ordering、ownership和concurrency,并设计数组或具体泛型集合方案,而不是只背复杂度表
  • 能判断foreach/for、iterator、自定义集合与collection property各自暴露的mutation、index和lifetime contract
  • 能分析IEnumerable或IQueryable查询的plan、execution location、materialization与enumeration count,避免隐藏的重复工作

为什么集合不是容器名,而是一组可观察契约

原书第2章把16条建议放在一起,因为collection choice与LINQ query并非两个独立话题。集合决定数据的shape、ownership和mutation;查询决定何时、在哪里、以多少次遍历观察这些数据。只说“Dictionary是O(1)”或“LINQ更优雅”都不足以评审生产代码。

先预测三个case:订单既要保持到达顺序又要按ID查找,是否只选一个集合;getter-only的IReadOnlyList<T>是否已经immutable;同一IQueryable<T>连续调用三个terminal operators会访问remote source几次。带着答案再写下五个问题:元素数量是否固定;调用者最常做lookup、scan、insert还是queue;顺序和重复是否有业务意义;谁能修改数据;查询在本地执行还是交给remote provider。然后再选type与operator。

数量、遍历、初始化与集合选择(建议16-21)

建议16:元素数量可变的情况下不应使用数组

数组长度在construction时固定,适合fixed-size buffer、interop layout、已完成计算的compact snapshot,以及需要span/index locality的hot path。元素持续增加时手工扩容array会重复copy并分散capacity policy;通常让List<T>管理capacity更清晰。建议的核心不是禁止数组,而是不要让fixed-size representation承担unbounded growth。

public static Order[] LoadSnapshot(IEnumerable<Order> source)
{
    var buffer = new List<Order>();
    foreach (Order order in source)
        buffer.Add(order);
 
    return buffer.ToArray(); // Ownership boundary: exact immutable-by-convention snapshot.
}

若已知count,可用new T[count]并一次填充;若只是减少扩容,可给List初始capacity。数组本身并不immutable,返回array仍允许caller改元素,所以API若承诺不可变,还需ImmutableArray<T>、copy或只读抽象。

建议17:多数情况下使用foreach进行循环遍历

foreach直接表达“按enumeration contract消费每个元素”,避免把index引入不需要position的算法。对array、List和自定义enumerator,compiler生成的机制可能不同;性能结论要看concrete type和target runtime,不能把foreach一概描述为会分配。

decimal total = 0;
foreach (Order order in orders)
{
    if (order.Status == OrderStatus.Confirmed)
        total += order.Amount;
}

遍历期间修改同一List通常使enumerator失效,但这不是“foreach只读”的完整证明:iteration variable不能重新赋值,不代表引用对象不能被修改,也不代表source不会由别处改变。消费代码要知道它拿到的是snapshot、live view还是single-use stream。

建议18:foreach不能代替for

算法需要index、反向删除、步长、相邻元素或原位更新时,for更准确。删除List元素应倒序,避免前移后跳过元素;若collection count可能变化,loop invariant必须显式。

for (int index = orders.Count - 1; index >= 0; index--)
{
    if (orders[index].IsExpired)
        orders.RemoveAt(index);
}

反过来,不要为了拿index就对任何IEnumerable<T>调用ElementAt(index),这可能把一次linear traversal变成quadratic work。确实需要random access时,contract应接收IReadOnlyList<T>或先materialize,并说明memory tradeoff。

建议19:使用更有效的对象和集合初始化

object/collection initializer减少重复receiver,并让construction intent集中;现代C#的constructor、required member与init setter还能在创建阶段约束必填数据。但initializer不是validation替代品:每个setter依次执行,外部可写setter可能留下不完整或可变对象。

var request = new SearchRequest
{
    TenantId = tenantId,
    Limit = 50,
    Tags = { "paid", "recent" },
};

若invariant跨多个fields,优先validated constructor/factory;initializer适合independent options。集合initializer本质上调用匹配的Add,所以异常、重复key和side effect仍由collection实现决定。

建议20:使用泛型集合代替非泛型集合

List<T>Dictionary<TKey,TValue>在compile time限制element type,减少cast与value type boxing;ArrayListHashtable等legacy non-generic集合只在旧接口边界仍可能出现。迁移时不要只机械换类型,还要确定null、equality comparer和duplicate-key行为。

var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
counts[category] = counts.GetValueOrDefault(category) + 1;

这里显式comparer比泛型本身更重要:它定义key identity。generic避免一类runtime error,但不会自动给出正确domain equality。

建议21:选择正确的集合

先按dominant operation和semantic shape选候选,再以真实数据分布验证:

需求常见候选必须再问
positional sequenceList<T> / arraycount是否固定,是否需要snapshot
key lookupDictionary<TKey,TValue>equality、key mutation、ordering
unique membershipHashSet<T>comparer、set operations、iteration order
FIFO/LIFOQueue<T> / Stack<T>boundedness、backpressure、async waiting
sorted lookupSortedDictionary / SortedSetcomparer consistency、range query
producer-consumerChannel<T> / concurrent collectionblocking、completion、ownership transfer

LinkedList只有在已经持有node且频繁插入/删除时才有O(1)更新;寻找node仍是O(n),每node allocation和cache locality也可能让它输给List。复杂度是上界模型,不是benchmark结果。

分步1 / 3

切换variable count、lookup、membership、queue与shared writes

Concurrency、Iterator与Ownership(建议22-25)

建议22:确保集合的线程安全

“线程安全集合”不是给业务workflow盖章。普通Dictionary在没有并发写、且发布后只读时可由多个reader使用;存在并发mutation时,需要锁、concurrent collection、immutable snapshot或single-owner message loop。选哪种取决于复合操作是否必须atomic。

private readonly ConcurrentDictionary<string, int> _counts = new();
 
public void Record(string category)
{
    _counts.AddOrUpdate(category, 1, static (_, current) => checked(current + 1));
}

ConcurrentDictionary单个API提供相应同步,但value factory可能在竞争下调用多次,不能在其中执行不可重复side effect;跨多个keys的check-then-update也不会自动atomic。需要跨状态invariant时,把操作包进同一lock或转交single owner。enumeration拿到的也未必是transactional snapshot,必须查具体API contract。

建议23:避免将List作为自定义集合类的基类

继承List<T>会公开AddInsert、index setter等全部mutation path,derived type无法可靠拦截non-virtual members,domain invariant很容易被绕过。composition让type只暴露允许的operation,并可选择实现IReadOnlyList<T>等最小contract。

public sealed class OrderLines : IReadOnlyList<OrderLine>
{
    private readonly List<OrderLine> _items = new();
 
    public int Count => _items.Count;
    public OrderLine this[int index] => _items[index];
 
    public void Add(OrderLine line)
    {
        ArgumentNullException.ThrowIfNull(line);
        if (_items.Any(existing => existing.ProductId == line.ProductId))
            throw new InvalidOperationException("Duplicate product.");
        _items.Add(line);
    }
 
    public IEnumerator<OrderLine> GetEnumerator() => _items.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

这不是要求每个List都包装一层。只有type有domain name、validation、event或ownership policy时才值得创建custom collection。

建议24:迭代器应该是只读的

iterator应把enumeration当作observation channel,不通过Current赋值改变sequence结构。C# yield return自然提供只读的current slot,但返回reference type仍可修改对象;iterator也可能关闭file、database context或mutable source,从而具备lifetime与repeatability约束。

public IEnumerable<OrderId> EnumerateConfirmedIds()
{
    foreach (Order order in _orders)
        if (order.Status == OrderStatus.Confirmed)
            yield return order.Id;
}

若调用方需要稳定只读结果,返回value projection或immutable snapshot;若是single-pass stream,API/name/documentation应明确。不要在iterator里混入hidden writes,使Count()与foreach产生不同业务副作用。

建议25:谨慎集合属性的可写操作

public setter让caller替换整个集合,public mutable getter又允许caller改变内部内容;两者都可能破坏owner invariant。getter-only List<T>并不等于只读,因为caller仍可Add。根据语义返回IReadOnlyList<T>view、immutable value或defensive copy,并通过methods控制修改。

public sealed class Invoice
{
    private readonly List<InvoiceLine> _lines = new();
    public IReadOnlyList<InvoiceLine> Lines => _lines;
 
    public void AddLine(InvoiceLine line)
    {
        Validate(line);
        _lines.Add(line);
    }
}

IReadOnlyList<T>只限制通过该reference调用mutation;若底层List还被其他代码持有,view仍会变化。需要temporal stability就返回immutable snapshot,而不是把interface narrowing误认为deep immutability。

分步1 / 3

切换foreach、for、initializer、iterator、自定义集合与property

Projection、Execution与Query Boundary(建议26-31)

建议26:使用匿名类型存储LINQ查询结果

anonymous type适合method-local projection:只保留下游需要的fields,具有编译期property类型与value equality,避免为一次性shape创建public DTO。它的边界是不能作为清晰的public return contract跨方法/assembly传播;跨层时使用named record/DTO。

var summaries = orders
    .Where(order => order.IsPaid)
    .Select(order => new
    {
        order.CustomerId,
        Month = order.PaidAt.Year * 100 + order.PaidAt.Month,
        order.Amount,
    });

projection还能降低remote query传输字段,但能否translate取决于provider。不要在anonymous projection中调用任意local method并假设database能执行。

建议27:在查询中使用Lambda表达式

lambda让predicate、selector与key policy贴近operator,适合短且纯的query rule。复杂分支、重复逻辑或需要单独测试/命名时提取method;在IQueryable中,lambda通常形成expression tree,允许的syntax不等于provider支持的translation。

static bool IsReviewable(Order order) =>
    order.IsPaid && order.Amount >= 100m && !order.IsRefunded;
 
IEnumerable<Order> candidates = orders.Where(IsReviewable);

避免query lambda中的mutation、logging或counter increment。deferred/repeated execution会让side effect次数依赖consumer,parallel provider更可能改变顺序。

建议28:理解延迟求值和主动求值之间的区别

WhereSelect等通常返回deferred sequence:构建query不读取source,每次enumeration才执行;ToListToArrayCountFirst等terminal operation主动求值。主动求值既有cost,也建立ownership/time boundary:materialized result固定在那个时刻,不再重跑上游。

IEnumerable<Order> query = repository.Stream().Where(order => order.IsPaid);
 
Order[] snapshot = query.ToArray(); // One deliberate read and a stable local snapshot.
int count = snapshot.Length;
Order first = snapshot[0];

不要无条件ToList:对large/unbounded stream会消耗无限或过多memory;只消费首项可用First/Take短路。反之,source昂贵、会变、只能遍历一次或结果将被多次使用时,应在明确boundary物化。

建议29:区别LINQ查询中的IEnumerable和IQueryable

IEnumerable<T>operator接收delegates,在当前process逐项执行;IQueryable<T>operator构造expression tree,由provider翻译并在remote system执行。两者拥有相似method names,却不是可随意互换的performance hint。

IQueryable<Order> query = db.Orders
    .Where(order => order.TenantId == tenantId && order.IsPaid)
    .OrderByDescending(order => order.PaidAt)
    .Take(50);
 
OrderSummary[] rows = await query
    .Select(order => new OrderSummary(order.Id, order.Amount))
    .ToArrayAsync(cancellationToken);

AsEnumerable()之后,后续operator转为local execution;位置过早会把大量rows拉到memory。provider query要检查generated command、indexes、round trips、cancellation与N+1。可编译的.NET method可能无法翻译,或翻译语义与local comparer/culture不同。

建议30:使用LINQ取代集合中的比较器和迭代器

一次性filter/projection/order pipeline通常比手写stateful iterator与ad-hoc comparer更清晰:OrderBy(x => x.LastName).ThenBy(x => x.Id)把policy放在use site。复用的domain ordering仍应命名为IComparer<T>或key policy;custom streaming state machine、zero-allocation hot path也可能需要显式iterator。

Order[] ordered = orders
    .OrderBy(order => order.CustomerName, StringComparer.Ordinal)
    .ThenBy(order => order.CreatedAt)
    .ThenBy(order => order.Id)
    .ToArray();

必须提供deterministic tie-breaker,尤其分页与remote query。LINQ替代的是重复plumbing,不是取消equality/ordering contract。

建议31:在LINQ查询中避免不必要的迭代

常见浪费包括Count() > 0后再foreach、Where(...).FirstOrDefault()可直接传predicate、loop内反复linear lookup、同一deferred query调用多个terminal operators,以及过早ToList后又创建中间collections。先算需要的结果,再决定一次pass、short circuit、index或materialization。

// Repeated linear lookup: O(ids * users).
// var selected = ids.Select(id => users.FirstOrDefault(user => user.Id == id));
 
var byId = users.ToDictionary(user => user.Id);
var selected = ids
    .Select(id => byId.GetValueOrDefault(id))
    .Where(static user => user is not null)
    .ToArray();

Any()可在首个match短路,Count property可O(1)读取known collection size,但不要为了追求“一次遍历”写出不可维护的mega-loop。先用trace/query log和benchmark确认source被枚举几次、remote执行几次、分配多少,再针对hot path合并。

分步1 / 3

切换projection、lambda、materialize、queryable、ordering与re-enumeration

本章回顾:从Collection Shape追到Execution Count

  1. array与List的边界是fixed snapshot还是managed growth,不是“数组落后”。
  2. foreach表达full traversal,for表达index control;两者都必须服从source mutation和lifetime contract。
  3. generic collection提供type safety,具体type还要由ordering、equality、operation和ownership决定。
  4. thread-safe member不等于compound workflow atomic;read-only interface也不等于immutable snapshot。
  5. LINQ要区分local/remote、deferred/eager和single/repeated enumeration,再讨论可读性与performance。
  6. 原书16条建议最终汇聚为三个可验证问题:数据是什么shape,谁拥有mutation,查询到底执行了几次。

练习

问题 1:订单数量持续增长、需要按ID查找并按到达顺序导出,应选一个还是多个集合?

问题 2:为什么getter-only的IReadOnlyList属性仍不足以证明不可变和线程安全?

问题 3:一个IQueryable查询被CountAsync、FirstAsync和ToListAsync依次调用,有何风险,怎样重构?

术语表

名词解释

本章出现的专业名词,用大白话再讲一遍。

collection contract
compound atomicity
live view
materialization boundary
query provider boundary

讨论

评论区加载中…