عنوان:

‫بررسی ویژگی Params Collections در C# 13


نویسنده: وحید نصیری
تاریخ: ۱۴۰۳/۰۸/۳۰ ۲۱:۰۰
آدرس: www.dntips.ir
در زبان #C، واژه‌ی کلیدی params، امکان داشتن تعداد متغیری از پارامترها را میسر می‌کند؛ بدون اینکه نیاز باشد تا چندین overload را برای اینکار ارائه داد که سبب داشتن کدهایی جمع و جور می‌شود و همچنین فراخوانی اینگونه متدها نیز ساده‌است و تنها کافی است تا یک آرایه و یا لیستی از آرگومان‌های جدا شده‌ی با کاما را به آن متد، ارسال کنیم.

محدودیت پارامترهای params تا C# 12.0

از نگارش 1 تا 12 زبان سی‌شارپ، نوع تعریف شده‌ی اینگونه پارامترها حتما باید یک آرایه باشد که نمونه‌ای از آن‌را در مثال زیر مشاهده می‌کنید:
namespace CS13Tests;

public static class ParamsCollectionsTests
{
    public static void CallSum() => Console.WriteLine(Sum(1, 2, 3));

    private static int Sum(params int[] values) => values.Sum();
}
این آرایه‌ی params، باید تک‌بعدی بوده و همچنین به عنوان آخرین پارامتر متد نیز معرفی شود. علاوه بر آن، تنها یکبار می‌توان چنین پارامتر ویژه‌ای را تعریف کرد.

Low-Level C# code این قطعه کد (کدی که کامپایلر در پشت صحنه تولید می‌کند)، به صورت زیر است:
namespace CS13Tests
{
  public static class ParamsCollectionsTests
  {
    public static void CallSum()
    {
      Console.WriteLine(ParamsCollectionsTests.Sum(new int[3]
      {
        1,
        2,
        3
      }));
    }

    [NullableContext(1)]
    private static int Sum([ParamArray] params int[] values)
    {
      return ((IEnumerable<int>) values).Sum();
    }
  }
}
این نوع خروجی را برای مثال از طریق منوی Tools -> IL Viewer مخصوص Rider می‌توانید مشاهده کنید.

همانطور که در اینجا مشخص است، فراخوانی متد Sum در متد CallSum، به همراه یک new int[3] هم می‌باشد. یعنی این نوع فراخوانی‌ها، به همراه یک heap allocation اضافی هم در پشت صحنه است که شاید بهتر باشد بتوان آن‌را کاهش داد.


ساده شدن فراخوانی لیست‌ها در C# 12

محدودیت صرفا آرایه بودن نوع params، با معرفی Collection initializers و Spread operator در C# 12، به نحو زیر کمی تعدیل می‌شود:
namespace CS13Tests;

public static class ParamsCollectionsTests
{
    public static void CallPrintNumbersCS12()
    {
        List<int> numbers = [1, 2, 3];
        PrintNumbersCS12([..numbers]); // array
    }

    private static void PrintNumbersCS12(params int[] array) => Console.WriteLine(string.Join(separator: ", ", array));
}
اما ... هنوز همانند نحوه‌ی فراخوانی به صورت ذکر آرگومان‌های جدا شده‌ی با کاما، روان به‌نظر نمی‌رسد و همچنین نیاز است لیست تعریف شده را به آرایه تبدیل کرد.


معرفی C# 13 params Collections

در C# 13، محدودیت فقط آرایه بودن نوع params برطرف شده و اینبار می‌توان انواع مجموعه‌هایی که IEnumerable را پیاده سازی می‌کنند (و به همراه متد Add هستند)، Span و ReadOnlySpanها را نیز تعریف کرد که شامل موارد زیر می‌باشند:
System.Span<T>
System.ReadOnlySpan<T>
System.Collections.Generic.IEnumerable<T>
System.Collections.Generic.IReadOnlyCollection<T>
System.Collections.Generic.IReadOnlyList<T>
System.Collections.Generic.ICollection<T>
System.Collections.Generic.IList<T>

اینبار اگر همان مثال قبلی را با params از نوع ReadOnlySpan بازنویسی کنیم:
public static class ParamsCollectionsTests
{
    public static void CallSumCS13() => Console.WriteLine(SumCS13(1, 2, 3));

    private static int SumCS13(params ReadOnlySpan<int> values)
    {
        var sum = 0;

        foreach (var item in values)
        {
            sum += item;
        }

        return sum;
    }
}
کامپایلر چنین کدی را برای آن تولید می‌کند (Low-Level C# code) که در آن هیچ نوع heap memory allocation‌ ای وجود ندارد:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;

namespace CS13Tests
{
  public static class ParamsCollectionsTests
  {
    public static void CallSumCS13()
    {
      Console.WriteLine(ParamsCollectionsTests.SumCS13(RuntimeHelpers.CreateSpan<int>(__fieldref (<PrivateImplementationDetails>.4636993D3E1DA4E9D6B8F87B79E8F7C6D018580D52661950EABC3845C5897A4D4))));
    }

    private static int SumCS13([ParamCollection, ScopedRef] ReadOnlySpan<int> values)
    {
      int sum = 0;
      ReadOnlySpan<int> readOnlySpan = values;
      for (int index = 0; index < readOnlySpan.Length; ++index)
      {
        int item = readOnlySpan[index];
        sum += item;
      }
      return sum;
    }
  }
}
مزیت یک چنین کدی، کاهش بار و کار garbage collector، در صورت فراخوانی مکرر متد SumCS13 است؛ چون در این حالت دیگر نیاز به heap memory allocation اضافی وجود نخواهد داشت.

و یا اگر مثال متداول دیگری را با یک List جنریک ساده بررسی کنیم:
namespace CS13Tests;

public static class ParamsCollectionsTests
{
    public static void CallCalculateSumCS13() => Console.WriteLine(CalculateSumCS13(1, 2, 3));

    private static int CalculateSumCS13(params List<int> numbers) => numbers.Sum();
}
کامپایلر مجددا چنین کدی را برای آن تولید می‌کند (Low-Level C# code) که در حین فراخوانی آن، هیچ نوع heap memory allocation اضافی وجود ندارد و انتقال لیست اعداد به متد CalculateSumCS13، با استفاده از Spanها مدیریت می‌شود (لیست را به آرایه تبدیل نمی‌کند و فقط یک اشاره‌گر به لیست اصلی است؛ چون Span بر روی Stack قرارگرفته و از نوع ref struct است):
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace CS13Tests
{
  public static class ParamsCollectionsTests
  {
    public static void CallCalculateSumCS13()
    {
      int num1 = 3;
      List<int> intList = new List<int>(num1);
      CollectionsMarshal.SetCount<int>(intList, num1);
      Span<int> span = CollectionsMarshal.AsSpan<int>(intList);
      int index1 = 0;
      span[index1] = 1;
      int index2 = index1 + 1;
      span[index2] = 2;
      int index3 = index2 + 1;
      span[index3] = 3;
      int num2 = index3 + 1;
      Console.WriteLine(ParamsCollectionsTests.CalculateSumCS13(intList));
    }

    [NullableContext(1)]
    private static int CalculateSumCS13([ParamCollection] List<int> numbers)
    {
      return numbers.Sum();
    }
  }
}

یک نکته: بر همین مبنا بسیاری از متدهای NET 9x. برای استفاده از <params Span<T به‌روز رسانی شده‌اند تا به‌بهبود سرعت و کاهش مصرف حافظه‌ی برنامه‌ها کمک کنند.


امکان داشتن overloadهای متعدد با آرگومان params در C# 13 و نحوه‌ی انتخاب بین آن‌ها توسط کامپایلر

در C# 13 می‌توان انواع و اقسام overloadهای زیر را تعریف کرد که در آن‌ها، نوع params تعریف شده، شبیه به هم بوده، اما کمی متفاوت هستند:
namespace CS13Tests;

public static class ParamsCollectionsTests
{
    public static void WriteNames(params string[] names) => Console.WriteLine(string.Join(separator: ", ", names));

    public static void WriteNames(params ReadOnlySpan<string> names)
        => Console.WriteLine(string.Join(separator: ", ", names));

    public static void WriteNames(params IEnumerable<string> names)
        => Console.WriteLine(string.Join(separator: ", ", names));
}
در این حالت اگر فراخوانی آن‌ها با یک متد Linq باشد، از Overload دارای IEnumerable استفاده می‌کند. اگر یک آرایه به آن‌ها ارسال شود، از متد دارای آرگومان params string[] names استفاده می‌کند و اگر فراخوانی آن به صورت ذکر آرگومان‌های جدا شده‌ی با کاما باشد، کامپایلر راسا بهترین انتخاب را انجام خواهد داد که در اینجا همان متد دارای آرگومان params ReadOnlySpan<string> names است:
namespace CS13Tests;

public static class ParamsCollectionsTests
{
    public static void CallWriteNames()
    {
        var persons = new List<Person>
        {
            new(FirstName: "Vahid", LastName: "N"),
            new(FirstName: "User1", LastName: "L1"),
            new(FirstName: "User2", LastName: "L2")
        };

        // IEnumerable overload is used
        WriteNames(persons.Select(person => person.FirstName));

        // array overload is used
        WriteNames(new[]
        {
            "Vahid", "User1", "User2"
        });

        // most efficient overload is used: currently ReadOnlySpan
        WriteNames("Vahid", "User1", "User2");
    }

    private record Person(string FirstName, string LastName);
}