用 C# 写业务的朋友,大概率碰过这种场景:两个集合按 key 对着连,配不上的也不能扔,得留下来看看到底是哪边出了问题。对账、比对配置、合并两份数据源,都是这个套路。
搁 SQL 里这就是个 FULL OUTER JOIN,一句话的事。可 LINQ 的 Enumerable 里,Join、GroupJoin 从 LINQ 诞生那天就有,唯独全连接一直缺席。想凑出这个效果,得自己左连接拼右连接,写一大坨。
.NET 11 把这块补上了。新方法叫 FullJoin,随 .NET 11 RC1(9 月 8 日发布,带 go-live 许可)落地,同一批还给了 Join 和 GroupJoin 的元组重载。
以前没 FullJoin 的时候,大家的套路基本是:先做一次左连接,把没匹配上的右表记录丢成 null;再把两个集合反过来调一次,只留左表为 null 的那部分;最后 Concat 起来。大概长这样:
// 1) 左连接:订单为主
var left = orders.GroupJoin(payments,
o => o.OrderId, p => p.OrderId,
(o, ps) => new { Order = o, Payment = ps.FirstOrDefault() }).SelectMany(x => x.Payment is null
? new[] { (x.Order, (Payment?)null) }
: x.Payment.Select(p => (x.Order, (Payment?)p)));
// 2) 反方向再来一遍,只留没匹配上的支付
var right = payments.GroupJoin(orders,
p => p.OrderId, o => o.OrderId,
(p, os) => new { Order = os.FirstOrDefault(), Payment = p })
.Where(x => x.Order is null)
.Select(x => (Order: (Order?)x.Order, Payment: (Payment?)x.Payment));
// 3) 拼起来
var full = left.Concat(right);
能用,但说实话挺丑。第一次接触这写法的人,基本都要盯着看半天才反应过来它在干嘛。而且你是在内存里拼还好,拼错了顶多数据不对;要是在数据库查询表达式附近折腾这类逻辑,怎么看怎么心虚。
FullJoin 的用法和 Join 几乎一样:两个集合、两个 key 选择器,外加一个结果选择器。区别就在结果选择器的参数上——两边都是可空的。配不上的那一侧是 null,另一侧一定不是 null,你照着判断就行。
record Order(string OrderId, string Product);
record Payment(string OrderId, decimal Amount);
var orders = new List<Order>
{
new("S001", "机械键盘"),
new("S002", "显示器"),
new("S003", "鼠标垫"),
};
var payments = new List<Payment>
{
new("S001", 399.00m),
new("S002", 1299.00m),
new("P999", 89.50m), // 一笔找不到订单的支付
};
var reconcile = orders.FullJoin(
payments,
o => o.OrderId,
p => p.OrderId,
(order, payment) => order is null
? $"多出来的支付: {payment!.OrderId}({payment.Amount} 元)"
: payment is null
? $"没付钱的订单: {order.OrderId} - {order.Product}"
: $"对上了: {order.OrderId} - {order.Product}");
foreach (var line in reconcile)
Console.WriteLine(line);
输出:
对上了: S001 - 机械键盘
对上了: S002 - 显示器
没付钱的订单: S003 - 鼠标垫
多出来的支付: P999(89.5 元)
FullJoin 有一个不带结果选择器的重载,直接返回 IEnumerable<(TOuter?, TInner?)>:
var pairs = orders.FullJoin(
payments,
o => o.OrderId,
p => p.OrderId);
foreach (var (order, payment) in pairs)
{
// 没配上的那一侧是 null,自己看着处理
}顺手一起改的还有 Join 和 GroupJoin。以前调 Join 必须传第三个 resultSelector 参数,哪怕你只想要个配对结果。现在元组重载可以直接用:
// Join:返回 (TOuter, TInner),两边都是匹配上的
var matched = orders.Join(payments,
o => o.OrderId,
p => p.OrderId);
// GroupJoin:返回 (TOuter, IEnumerable<TInner>),右侧是个分组
var grouped = orders.GroupJoin(payments,
o => o.OrderId,
p => p.OrderId);
少写一个 lambda,代码矮一截,这点改动我个人还挺欢迎的——Join 的 resultSelector 十个里有九个就是 (o, p) => new { o, p },纯仪式感。
按官方文档的说法,这批连接 API 不只在 Enumerable 上,Queryable 和 AsyncEnumerable 也同步加了。所以如果你在用 IAsyncEnumerable 那套流式管道,也能享受同样的写法。
至于 EF Core 场景下会不会翻译成数据库的 FULL OUTER JOIN,这取决于具体的数据库提供程序怎么实现查询翻译——建议升级后拿自己的真实查询验证一下执行计划,别想当然。
另外两个细节:它是延迟执行的,跟其他 LINQ 方法一致;key 比较器照旧可以传自定义的 IEqualityComparer,不传就用默认的。
只要交集用 Join,要保留主表全部用左连接那套,两边都要就是 FullJoin。以后再碰到“把两边孤儿都捞出来对账”的需求,一行方法调用就够了,不用再手搓两段 Concat。
.NET 11 正式版预计 11 月发布,RC1 已经带 go-live 许可,可以在生产环境试了。这类基础 API 的补齐平时不起眼,真用到的时候才知道省了多少事。