×

C# LINQ 等了十几年的 FullJoin,.NET 11 终于给补上了

独孤求败 独孤求败 发表于2026-09-17 22:12:11 浏览54 评论0

抢沙发发表评论

用 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 元)

老写法 vs 新写法
老写法 vs 新写法

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 的补齐平时不起眼,真用到的时候才知道省了多少事。


群贤毕至

访客