عنوان:

‫بررسی قابلیت FullJoin در NET 11.؛ قطعه گم‌شده پازل LINQ


نویسنده: وحید نصیری
تاریخ: ۱۴۰۵/۰۳/۱۷ ۱۴:۰۰
آدرس: www.dntips.ir
توسعه‌دهندگان اکوسیستم .NET سال‌هاست که از LINQ (Language Integrated Query) به عنوان ابزاری قدرتمند برای پرس‌وجو و مدیریت مجموعه‌ها استفاده می‌کنند. با این حال، غیبت یک قابلیت کلیدی همواره حس می‌شد. ما به راحتی به متدهایی مانند Join (معادل Inner Join) دسترسی داریم و پیاده‌سازی LeftJoin نیز در نسخه‌های اخیر ساده‌تر شده است؛ اما زمانیکه نوبت به یک اتصال کامل دوطرفه یا همان Full Outer Join می‌رسد، LINQ پیاده‌سازی بومی (Built-in) روانی در اختیار ما قرار نمی‌دهد. توسعه‌دهندگان معمولاً برای حل این چالش به ترکیبی از GroupJoin، SelectMany و DefaultIfEmpty یا ترکیب نتایج یک Left Join و Right Join متوسل می‌شوند که علاوه بر کاهش خوانایی کد، بر کارایی (Performance) برنامه نیز تأثیر منفی می‌گذارد.
خوشبختانه بر اساس پروپوزال‌ها و بحث‌های اخیر در تیم توسعه .NET (به‌ویژه در Issueهای دات‌نت ران‌تایم: ^ و ^)، به نظر می‌رسد این خلاء بزرگ قرار است در .NET 11 با معرفی متد بومی FullJoin برطرف شود. در این مقاله به بررسی مفهوم Full Outer Join، ساختار این API پیشنهادی و نحوه عملکرد آن در قالب کد می‌پردازیم.

سلب مسئولیت (Disclaimer): هرچند کلیات این API مورد تأیید (Approved) قرار گرفته است، اما تا زمان نگارش این مقاله هنوز Pull Request (PR) رسمی برای ادغام آن در SDK دات‌نت یا Entity Framework Core باز نشده‌است. بنابراین، ساختار نهایی و جزئیات پیاده‌سازی آن ممکن است تا زمان عرضه نسخه نهایی .NET 11 دستخوش تغییرات شود.

Full Outer Join چیست؟
یک اتصال کامل یا Full Outer Join، تمامی اعضای هر دو مجموعه (Sequences) را در خروجی حفظ می‌کند؛ فارغ از اینکه متناظری در سمت مقابل داشته باشند یا خیر. به طور خلاصه، رفتار این متد در مواجهه با داده‌ها به شرح زیر است:
  • اگر هر دو سمت تطابق داشته باشند: یک جفت متناظر (Pair) در خروجی ایجاد می‌شود.
  • اگر داده فقط در مجموعه سمت چپ باشد: داده سمت چپ حفظ شده و مقدار سمت راست null در نظر گرفته می‌شود.
  • اگر داده فقط در مجموعه سمت راست باشد: داده سمت راست حفظ شده و مقدار سمت چپ null در نظر گرفته می‌شود.
برای درک بهتر، فرض کنید دو مجموعه از مشتریان (customers) و سفارش‌ها (orders) داریم:
var customers = new[]
{
    new Customer(1, "Alice"),
    new Customer(2, "Bob"),
    new Customer(3, "Charlie")
};

var orders = new[]
{
    new Order(1, 100), // سفارش برای Alice
    new Order(1, 101), // سفارش دوم برای Alice
    new Order(4, 102)  // سفارشی بدون مشتری ثبت شده در این آرایه
};
در صورت اعمال FullJoin روی این دو مجموعه، خروجی مفهومی ما شامل موارد زیر خواهد بود:
  • Alice + Order 100 (تطابق کامل)
  • Alice + Order 101 (تطابق کامل)
  • Bob + بدون سفارش (مشتری بدون سفارش - در Inner Join حذف می‌شد)
  • Charlie + بدون سفارش (مشتری بدون سفارش - در Inner Join حذف می‌شد)
  • بدون مشتری + Order 102 (سفارش بدون مشتری - رکورد کلیدی که هم در Inner Join و هم در Left Join حذف می‌شد اما در Full Join باقی می‌ماند).


بررسی ساختار API پیشنهادی (The Proposed API)
ساختار (Signature) متد اصلی ارائه‌شده برای FullJoin به صورت متد توسعه‌دهنده (Extension Method) روی IEnumerable شکل گرفته است:
public static IEnumerable<TResult> FullJoin<TOuter, TInner, TKey, TResult>(
    this IEnumerable<TOuter> outer,
    IEnumerable<TInner> inner,
    Func<TOuter, TKey> outerKeySelector,
    Func<TInner, TKey> innerKeySelector,
    Func<TOuter?, TInner?, TResult> resultSelector);

تفاوت ساختاری با Join معمولی
اگر به پارامتر آخر یعنی resultSelector دقت کنید، متوجه یک تفاوت بنیادی با متد Join معمولی می‌شوید:
Func<TOuter?, TInner?, TResult> resultSelector
در اینجا هر دو ورودی TOuter و TInner به صورت Nullable (?) تعریف شده‌اند. این طراحی کاملاً منطقی و هوشمندانه است؛ چرا که در فرآیند Full Join، در زمان عدم تطابق، قطعاً یکی از طرفین null خواهد بود. این ساختار توسعه‌دهنده را ملزم می‌کند تا در بدنه resultSelector مدیریت مقادیر نول (Null-safety) را به کمک عملگرهای پیش‌فرض دات‌نت انجام دهد.

تنوع اورلودها (Overloads)
علاوه بر متد فوق، پروپوزال دات‌نت شامل موارد زیر نیز می‌شود تا انعطاف‌پذیری به حداکثر برسد:
  • اورلودی که یک IEqualityComparer را برای مقایسه سفارشی کلیدها می‌پذیرد.
  • پشتیبانی از Tuple-overload جهت تسهیل نگاشت‌های سریع بدون نیاز به نوشتن عبارت‌های پیچیده.
  • پیاده‌سازی همگام برای اکوسیستم‌های دیگر مانند IQueryable (جهت استفاده در ORMهایی مثل EF Core) و IAsyncEnumerable (برای پردازش‌های ناهمگام جریان داده‌ها).

نحوه استفاده در کد (مثال عملی)
با توجه به ساختار معرفی شده، پیاده‌سازی سناریوی مشتریان و سفارش‌ها با FullJoin بسیار ساده، تمیز و خوانا خواهد بود:
var results = customers.FullJoin(
    orders,
    c => c.CustomerId,
    o => o.CustomerId,
    (customer, order) => new
    {
        CustomerName = customer?.Name ?? "(no customer)",
        OrderId = order?.Id ?? 0
    });
در این نمونه کد، به زیبایی از عملگر ?. (Null-conditional) و ?? (Null-coalescing) استفاده شده است تا در صورت عدم وجود مشتری یا سفارش، مقادیر پیش‌فرض جایگزین شوند و از بروز خطای NullReferenceException جلوگیری به عمل آید.

چرا این قابلیت یک برگ برنده است؟
شاید بپرسید چرا وجود این متد تا این حد اهمیت دارد؟ در حال حاضر برای شبیه‌سازی این رفتار در LINQ مجبور به نوشتن کدی شبیه به این هستیم:
// راهکار سنتی و پیچیده فعلی (شبیه‌سازی Full Join)
var leftJoin = from customer in customers
               join order in orders on customer.CustomerId equals order.CustomerId into joinedOrders
               from order in joinedOrders.DefaultIfEmpty()
               select new { customer, order };

var rightJoin = from order in orders
                join customer in customers on order.CustomerId equals customer.CustomerId into joinedCustomers
                from customer in joinedCustomers.DefaultIfEmpty()
                select new { customer, order };

var fullJoinAlternative = leftJoin.Union(rightJoin);
معایب این روش سنتی:
  • کاهش شدید خوانایی: کد بسیار طولانی و مستعد خطای منطقی است.
  • افت کارایی (Performance Penalty): ارزیابی مجموعه‌ها در این حالت (به ویژه استفاده از Union برای حذف تکراری‌ها) نیاز به تخصیص حافظه (Memory Allocation) بیشتر و زمان پردازش مضاعف دارد.
متد بومی FullJoin در .NET 11 این فرآیند را در سطح ساختار داده (احتمالاً با استفاده از مکانیزم‌های بهینه‌شده هَش‌تیبل داخلی) تنها با یک بار پیمایش بهینه انجام خواهد داد.

نتیجه‌گیری
معرفی قابلیت FullJoin در .NET 11 (در صورت نهایی شدن پروپوزال‌های #124787 و #120596 ران‌تایم دات‌نت) یکی از کاربردی‌ترین افزونه‌ها به مجموعه ابزار LINQ در سال‌های اخیر خواهد بود. این ویژگی نه تنها کد توسعه‌دهندگان را تمیزتر و منسجم‌تر می‌کند، بلکه بار فکری مدیریت اتصالات پیچیده داده‌ای را کاهش داده و کارایی بالاتری را به ارمغان می‌آورد.

نظرات

  • وحید نصیری در ۱۴۰۵/۰۳/۲۰ ۰۹:۰۳
    بخش تکمیلی با توجه به نگارش نهایی و رسمی ارائه شده (بر اساس Pull Requestهای ادغام‌شده مانند #127236 و #121998 در دات‌نت 11، Preview 5):

    برای درک کامل ابعاد این تغییر بزرگ در دات‌نت ۱۱، بررسی سه نکته کلیدی زیر که در مستندات رسمی به آن‌ها اشاره شده، ضروری است:

    ۱. سناریوهای کاربردی در دنیای واقعی (Real-world Use Cases)
    متن رسمی مایکروسافت به زیبایی به کاربردهای ملموس FullJoin اشاره می‌کند. در توسعه نرم‌افزار، ماژول‌های زیادی وجود دارند که نیاز به تطبیق دو مجموعه داده (Reconciliation) دارند. با متد جدید FullJoin دیگر نیازی به نوشتن کوئری‌های پیچیده دیتابیسی یا پردازش‌های سنگین در حافظه برای سناریوهای زیر نیست:
    • حسابداری و انبارداری: مقایسه موجودی پیش‌بینی‌شده (Expected Inventory) در برابر موجودی واقعی مشاهده‌شده (Observed).
    • مدیریت زیرساخت و DevOps: بررسی سرویس‌های کانفیگ‌شده (Configured) در برابر سرویس‌های واقعاً در حال اجرا و کشف‌شده در شبکه (Discovered).
    • سیستم‌های تاریخچه‌ای (Auditing): مقایسه دو اسنپ‌شات (Snapshot) قدیمی و جدید از یک جدول برای یافتن رکوردهای حذف‌شده، اضافه‌شده یا تغییریافته.

    ۲. اهمیت مدیریت دقیق خروجی‌های Nullable
    اگر به کد نمونه نگاهی بیندازید:
    using System.Linq;
    
    Product[] catalog =
    [
        new("A100", "Keyboard"),
        new("B200", "Mouse"),
    ];
    
    Sale[] sales =
    [
        new("B200", 4),
        new("C300", 2),
    ];
    
    var rows = catalog.FullJoin(
        sales,
        product => product.Sku,
        sale => sale.Sku,
        (product, sale) => new
        {
            Sku = product?.Sku ?? sale!.Sku,
            ProductName = product?.Name ?? "(not in catalog)",
            QuantitySold = sale?.Quantity ?? 0,
        });
    
    foreach (var row in rows)
    {
        Console.WriteLine($"{row.Sku}: {row.ProductName}, sold {row.QuantitySold}");
    }
    
    public sealed record Product(string Sku, string Name);
    public sealed record Sale(string Sku, int Quantity);
    در بدنه resultSelector با این خط مواجه می‌شوید:
    Sku = product?.Sku ?? sale!.Sku,
    این بخش حاوی یک نکته ظریف برای توسعه‌دهندگان است. از آنجایی که در FullJoin احتمال عدم تطابق در هر دو سمت وجود دارد، هر دو شیء product و sale می‌توانند null باشند. اما ما مطمئن هستیم که حداقل یکی از آن‌ها در هر ردیف مقدار دارد (چون اگر هر دو نال باشند، اصلاً ردیفی تولید نمی‌شود). بنابراین با استفاده از ترکیب عملگر هوشمند ?. و عملگر ناوردا (Null-forgiving operator) یعنی !. به کامپایلر اطمینان می‌دهیم که کلید Sku تحت هر شرایطی مقدار غیر نال خواهد داشت.

    ۳. انقلابی کوچک با معرفی اورلودهای مبتنی بر Tuple (برگ برنده Preview 5)
    یکی از جذاب‌ترین بخش‌های این به‌روزرسانی (تحت Issue #121998)، اضافه شدن اورلودهایی است که خروجی را به صورت تایپ چندتایی (Tuple) برمی‌گردانند. این قابلیت برای تمام متدهای خانوادهء Join (شامل Join، LeftJoin، RightJoin، GroupJoin و خود FullJoin) اضافه شده است.
    چرا این قابلیت بسیار کاربردی است؟
    پیش از این، حتی برای یک Inner Join ساده، شما مجبور بودید یک متد لامبدا به عنوان resultSelector بنویسید تا دو شیء را به یک شیء جدید (معمولاً Anonymous Type) نگاشت کند. اما اکنون اگر هدف شما صرفاً دریافت جفت‌های متناظر باشد، می‌توانید اصلاً resultSelector را ننویسید!
    به این مقایسه دقت کنید:
    // روش قدیمی (نیاز به تعریف صریح خروجی):
    var oldWay = catalog.Join(sales, p => p.Sku, s => s.Sku, (p, s) => new { p, s });
    
    // روش جدید در .NET 11 (خروجی مستقیم به صورت جفت/Tuple):
    var newWay = catalog.Join(sales, p => p.Sku, s => s.Sku); 
    // خروجی این متد به طور خودکار IEnumerable<(Product, Sale)> است
    این ویژگی به همراه قابلیت Destructuring در سی‌شارپ، اجازه می‌دهد کدهایی به غایت تمیز، شبیه به کدهای پایتون یا زبان‌های تئوریکال بنویسید:
    foreach (var (product, sale) in catalog.Join(sales, p => p.Sku, s => s.Sku))
    {
        // دسترسی مستقیم و تمیز به اشیاء بدون نیاز به پراپرتی‌های واسط
    }
    نکته فنی: برای متدهای LeftJoin، RightJoin و FullJoin، المان‌های درون این Tupleها به طور خودکار متناظر با ماهیت آن Join، قابلیت Nullable بودن (?) را خواهند داشت.