🔥 Zapisy zamknięte, ale możesz pobrać Roadmapę .NET i dołączyć do listy oczekujących — Pobierz i dołącz do Listy VIP →

Array vs List w C# — API design i decyzje

Array vs List

Kąt widzenia: kontrakt publiczny i enkapsulacja — co eksponujesz przez interfejsy, co ukrywasz, i jakie konsekwencje ma każdy wybór
Przykład przewodni: model domeny koszyka zakupowego w Clean Architecture


Problem, którego nie widać w benchmarkach

Poprzedni post o T[] vs List<T> dotyczył alokacji i wydajności. Ten dotyczy czegoś innego: co zwracasz z metod, co przyjmujesz jako parametry, i co z tego wynika dla kodu, który to konsumuje.

To jest pytanie projektowe, nie wydajnościowe. Zły wybór tu oznacza wyciek implementacji przez API i trudności z późniejszą zmianą bez łamania kontraktu.


Klasyczny błąd: zwracanie List<T> z metod domeny

// ❌ Tak wygląda w połowie projektów w .NET
public class ShoppingCart
{
    public List<CartItem> Items { get; private set; } = new();

    public void AddItem(CartItem item) => Items.Add(item);
}

Problem: Items jest właściwością publiczną typu List<CartItem>. Każdy zewnętrzny kod może zrobić:

var cart = new ShoppingCart();
cart.Items.Add(new CartItem(...));   // ← omija AddItem(), logikę biznesową, eventy
cart.Items.RemoveAt(0);              // ← brak walidacji
cart.Items.Clear();                  // ← brak logiki czyszczenia

Enkapsulacja jest złamana — invarianty domeny (np. “koszyk musi mieć co najmniej jeden element przed złożeniem zamówienia”) można obejść przez bezpośredni dostęp do listy.


Trzy typy zwracane z metod — kiedy który

IReadOnlyList<T> — kontrakt widoku bez modyfikacji

public class ShoppingCart
{
    private readonly List<CartItem> _items = new();

    // Kontrakt: możesz przeglądać, nie możesz modyfikować
    public IReadOnlyList<CartItem> Items => _items;

    public void AddItem(CartItem item)
    {
        // Tutaj masz kontrolę — walidacja, eventy, logika
        if (_items.Any(i => i.ProductId == item.ProductId))
            throw new DomainException($"Produkt {item.ProductId} już jest w koszyku.");

        _items.Add(item);
    }

    public void RemoveItem(Guid productId)
    {
        var item = _items.FirstOrDefault(i => i.ProductId == productId)
            ?? throw new DomainException($"Produkt {productId} nie jest w koszyku.");
        
        _items.Remove(item);
    }
}

IReadOnlyList<T> daje: Count, [index], IEnumerable<T>. Nie daje Add, Remove, Clear.

Uwaga na pułapkę: IReadOnlyList<T> to kontrakt, nie gwarancja immutability. Można go zrzutować:

// Możliwe — i to jest problem
var mutableItems = (List<CartItem>)cart.Items;
mutableItems.Add(new CartItem(...)); // ← znowu omijamy logikę domeny

Jeśli potrzebujesz rzeczywistej immutability — użyj ImmutableArray<T>.

ImmutableArray<T> — prawdziwa niezmienność

using System.Collections.Immutable;

public class Order
{
    private readonly ImmutableArray<OrderLine> _lines;

    public ImmutableArray<OrderLine> Lines => _lines;

    private Order(ImmutableArray<OrderLine> lines)
    {
        _lines = lines;
    }

    // Fabryka — zamówienie tworzone jednorazowo
    public static Order Create(IEnumerable<CartItem> items, decimal taxRate)
    {
        var lines = items
            .Select(i => new OrderLine(
                i.ProductId,
                i.Name,
                i.UnitPrice,
                i.Quantity,
                i.UnitPrice * i.Quantity * (1 + taxRate)))
            .ToImmutableArray();

        return new Order(lines);
    }
}

ImmutableArray<T> ma tę samą strukturę w pamięci co T[] (ciągły blok, brak narzutu) ale rzutowanie z powrotem do listy lub tablicy jest niemożliwe bez skopiowania danych. To strukturalna gwarancja, nie tylko kontrakt interfejsu.

Kiedy ImmutableArray<T> zamiast IReadOnlyList<T>:

  • Gdy obiekt jest value object lub DTO przechodzący przez wiele warstw
  • Gdy chcesz przekazać kolekcję między wątkami bez kopiowania
  • Gdy kolekcja jest tworzona raz i nigdy nie zmienia się

Koszt: ImmutableArray<T> nie ma wydajnych operatorów mutacji — Add i Remove tworzą nową tablicę. W pętlach budowania używaj ImmutableArray<T>.Builder:

var builder = ImmutableArray.CreateBuilder<OrderLine>(initialCapacity: items.Count());
foreach (var item in items)
    builder.Add(new OrderLine(item));

ImmutableArray<OrderLine> result = builder.ToImmutable();
// ToImmutable() z Builder z odpowiednim Capacity = zero kopiowania

T[] w publicznym API — kiedy ma sens

// ✅ T[] jako wynik operacji o stałym rozmiarze
public byte[] Serialize(Order order)
{
    // Rozmiar znany po serializacji — tablica jest naturalnym wyborem
    using var stream = new MemoryStream();
    JsonSerializer.Serialize(stream, order);
    return stream.ToArray();
}

// ✅ T[] jako parametr wejściowy bufora
public void ProcessRawData(byte[] buffer, int offset, int count)
{
    // Klasyczny wzorzec z BCL — buffer + offset + count
}

// ❌ T[] jako kolekcja domenowa — złe
public Product[] GetProducts() // Nie — ktoś zmodyfikuje tablicę
{
    return _products.ToArray();
}

T[] w zwracanej wartości publicznej metody domenowej to zły pomysł: caller może zmodyfikować tablicę (po zaindeksowaniu), a co gorsza — może się spodziewać, że to „żywy” widok na dane, jak przy IReadOnlyList<T>.

Jedyna bezpieczna forma T[] w publicznym API to bufory i dane binarne (gdzie modyfikowalność jest oczekiwana lub kopia jest świadoma).


Sygnatura metody jako kontrakt

Wybór typu w sygnaturze metody mówi callerowi, co może robić:

// Parametr wejściowy — jak restrykcyjny powinien być kontrakt?

// ❌ Za restrykcyjny — zmusza callera do tworzenia List<T>
void ProcessItems(List<CartItem> items)

// ✅ Wystarczający kontrakt — iteracja to wszystko, czego potrzebujesz
void ProcessItems(IEnumerable<CartItem> items)

// ✅ Gdy potrzebujesz Count bez enumeracji
void ProcessItems(IReadOnlyList<CartItem> items)

// ✅ Gdy potrzebujesz Count i indeks
void ProcessItems(IReadOnlyCollection<CartItem> items)

Zasada: parametry wejściowe powinny mieć jak najmniej restrykcyjny typ, który nadal wyraża kontrakt metody.

// Zwracana wartość — jak dużo ekspozycji dajemy?

// ❌ Eksponuje Add/Remove — caller może modyfikować
List<CartItem> GetItems()

// ✅ Tylko odczyt, ale rzutowalny
IReadOnlyList<CartItem> GetItems()

// ✅ Prawdziwa niezmienność — nie rzutowalny
ImmutableArray<CartItem> GetItems()

// ✅ Gdy zwracamy tylko do iteracji LINQ
IEnumerable<CartItem> GetItems()

Dla metod domenowych: IReadOnlyList<T> to dobry domyślny wybór. ImmutableArray<T> gdy gwarantujemy brak zmian i kolekcja przechodzi przez wiele warstw.


LINQ a wybór kolekcji — ukryte koszty

IEnumerable<CartItem> items = cart.Items.Where(i => i.IsOnSale);

// Każde wywołanie Count() lub iteracja re-wykonuje Where() od zera
Console.WriteLine(items.Count());  // Where() ejecuta się raz
Console.WriteLine(items.Count());  // Where() ejecuta się drugi raz

IEnumerable<T> zwrócony z LINQ jest leniwy (lazy). Jeśli używasz wyniku wielokrotnie — zmaterializuj:

// Materialization — egzekucja zapytania raz, wynik w pamięci
IReadOnlyList<CartItem> onSaleItems = cart.Items
    .Where(i => i.IsOnSale)
    .ToList();  // lub .ToArray() — ale ToList() daje IReadOnlyList

// Teraz Count() to dostęp do pola, nie re-egzekucja LINQ
Console.WriteLine(onSaleItems.Count);  // O(1), bez re-egzekucji

Różnica między ToList() a ToArray() w kontekście LINQ to głównie kwestia co zrobisz z wynikiem:

  • ToList() → gdy wynik trafi do IReadOnlyList<T> lub będzie dalej modyfikowany
  • ToArray() → gdy wynik trafia do T[] API lub ReadOnlySpan<T> / ImmutableArray<T>.CreateRange

W praktyce: przy zwracaniu z metod domeny — ToList() bo List<T> implementuje IReadOnlyList<T> bez kopiowania.


Pełny przykład: ShoppingCart w Clean Architecture

// Domain/Entities/ShoppingCart.cs
public class ShoppingCart
{
    private readonly List<CartItem> _items = new();
    private readonly Guid _customerId;

    public Guid Id { get; } = Guid.NewGuid();
    public Guid CustomerId => _customerId;
    
    // IReadOnlyList — kontrakt: możesz przeglądać, nie modyfikować
    public IReadOnlyList<CartItem> Items => _items;
    
    // Computed property — nie przechowujemy, liczymy z Items
    public decimal TotalPrice => _items.Sum(i => i.TotalPrice);
    public int TotalQuantity => _items.Sum(i => i.Quantity);

    public ShoppingCart(Guid customerId)
        => _customerId = customerId;

    public void AddItem(CartItem item)
    {
        var existing = _items.FirstOrDefault(i => i.ProductId == item.ProductId);
        
        if (existing is not null)
        {
            // Aktualizujemy ilość zamiast duplikować
            _items.Remove(existing);
            _items.Add(existing with { Quantity = existing.Quantity + item.Quantity });
        }
        else
        {
            _items.Add(item);
        }
    }

    public void RemoveItem(Guid productId)
    {
        var item = _items.FirstOrDefault(i => i.ProductId == productId)
            ?? throw new DomainException($"CartItem {productId} not found.");
        
        _items.Remove(item);
    }

    public void Clear() => _items.Clear();
}

// Domain/Entities/CartItem.cs
public record CartItem(
    Guid ProductId,
    string Name,
    decimal UnitPrice,
    int Quantity)
{
    public decimal TotalPrice => UnitPrice * Quantity;
}
// Application/Queries/GetCartSummaryQuery.cs
public class GetCartSummaryQueryHandler
{
    private readonly IShoppingCartRepository _repository;

    public GetCartSummaryQueryHandler(IShoppingCartRepository repository)
        => _repository = repository;

    public async Task<CartSummaryDto> HandleAsync(Guid cartId)
    {
        var cart = await _repository.GetByIdAsync(cartId)
            ?? throw new NotFoundException($"Cart {cartId} not found.");

        // IReadOnlyList<CartItem> — LINQ działa normalnie
        return new CartSummaryDto(
            CartId: cart.Id,
            // Select na IReadOnlyList — wynik: IEnumerable, materialization przez ToList
            Lines: cart.Items.Select(i => new CartLineDto(
                i.ProductId, i.Name, i.UnitPrice, i.Quantity, i.TotalPrice)).ToList(),
            TotalPrice: cart.TotalPrice,
            ItemCount: cart.TotalQuantity);
    }
}

// Application/DTOs
public record CartSummaryDto(
    Guid CartId,
    // DTO używa IReadOnlyList — caller nie powinien modyfikować
    IReadOnlyList<CartLineDto> Lines,
    decimal TotalPrice,
    int ItemCount);

public record CartLineDto(
    Guid ProductId,
    string Name,
    decimal UnitPrice,
    int Quantity,
    decimal LineTotal);
// Infrastructure/Repositories/ShoppingCartRepository.cs
public class ShoppingCartRepository : IShoppingCartRepository
{
    private readonly AppDbContext _context;

    public ShoppingCartRepository(AppDbContext context) => _context = context;

    public async Task<ShoppingCart?> GetByIdAsync(Guid cartId)
    {
        // EF Core zwraca dane przez List<T> wewnętrznie
        // Mapujemy do domeny — enkapsulacja zachowana
        var entity = await _context.ShoppingCarts
            .Include(c => c.Items)
            .FirstOrDefaultAsync(c => c.Id == cartId);

        if (entity is null) return null;

        var cart = new ShoppingCart(entity.CustomerId);
        foreach (var item in entity.Items)
            cart.AddItem(new CartItem(item.ProductId, item.Name, item.UnitPrice, item.Quantity));

        return cart;
    }
}

Tabela decyzyjna — co zwracać z metod

SytuacjaTypPowód
Wewnętrzna kolekcja domenyprivate List<T>Mutability potrzebna wewnętrznie
Publiczne pole/właściwość encjiIReadOnlyList<T>Enkapsulacja, brak Add/Remove
DTO lub Value ObjectImmutableArray<T>Gwarancja niezmienności, thread-safe
Parametr metody (iteracja)IEnumerable<T>Minimalny kontrakt
Parametr metody (Count + indeks)IReadOnlyList<T>Wystarczający kontrakt
Metoda serwisu applikacyjnegoIReadOnlyList<T>Caller dostaje widok, nie oryginał
Bufor binarny / interopT[]Standard BCL, Span<T> kompatybilność
Wynik operacji LINQ do wielokrotnego użytkuToList()IReadOnlyList<T>Materialization, O(1) Count

Kompromisy — czego nie ma w tutorialach

IReadOnlyList<T> nie jest gwarancją immutability — jest kontraktem. Reflection lub rzutowanie to obejście. Jeśli piszesz bibliotekę lub modułowy system z niepewnym callerem — użyj ImmutableArray<T>.

ImmutableArray<T> vs IReadOnlyCollection<T>ImmutableArray<T> jest strukturą (value type), więc default(ImmutableArray<T>) to pusta tablica, nie null. Nie musisz sprawdzać null — ale musisz pamiętać, że IsDefault odróżnia „niezainicjalizowany” od „pusty”.

IEnumerable<T> w API publicznym jako parametr wejściowy — doskonały, bo przyjmuje tablice, listy, wyniki LINQ, HashSet. Ale nie używaj go jako zwracanego typu z repository — caller nie wie, czy query jest zmaterializowane czy nie. IReadOnlyList<T> to bezpieczniejsza granica.

Konwersja kosztujelist.ToArray() zawsze kopiuje dane. Jeśli zwracasz kolekcję jako IReadOnlyList<T> a wewnątrz masz List<T>, rzutowanie jest darmowe: List<T> implementuje IReadOnlyList<T> bez kopiowania.


Podsumowanie

Wybór między T[], List<T>, IReadOnlyList<T> i ImmutableArray<T> to nie kwestia wydajności — to kwestia kontraktu, który wyraża Twoje intencje callerowi.

Trzy praktyczne reguły dla kodu produkcyjnego:

  1. Wewnątrz klasyList<T> do budowania, T[] gdy rozmiar znany z góry i nie rośnie.
  2. W publicznym APIIReadOnlyList<T> jako domyślne, ImmutableArray<T> gdy gwarantujesz niezmienność.
  3. Jako parametr wejściowyIEnumerable<T> gdy potrzebujesz tylko iteracji, IReadOnlyList<T> gdy potrzebujesz Count lub indeksu.

Zły wybór tu to nie błąd kompilacji — to wyciek List<T> przez API, który wróci do Ciebie podczas refaktoringu, gdy nagle zmiana wewnętrznej struktury danych łamie sygnatury metod w pięciu miejscach.


Poprzedni post: Array vs List w C# — Pamięć, Wydajność i Wewnętrzna Budowa — jak T[] i List<T> działają w pamięci, kiedy użyć ArrayPool<T> i Span<T>, i jak mierzyć to BenchmarkDotNet.


🔗 Zobacz też


📣 Call To Action

💬 Napisz w komentarzu:
Czego używasz częściej – Array czy List?

📩 Chcesz więcej takich materiałów?
Napisz w komentarzu

👉 subskrybuj nasz kanał na YT!
👉 udostępnij artykuł komuś, kto zaczyna z programowaniem


Zobacz także — powiązane artykuły

👉 MCP w .NET (C#) – jak zbudować serwer AI krok po kroku

👉 Tworzenie klas i obiektów w C# — kompletny przewodnik

👉 Pattern Matching w C# – switch expressions i type patterns

Dołącz do “Od Zera do .NET Developera”

Zacznij swoją przygodę z programowaniem w oparciu o sprawdzone praktyki rynkowe. Wybierz kompletną ścieżkę rozwoju i zbuduj solidne fundamenty.

Dołącz do ścieżki teraz →
Masz pytania? Napisz: mariuszjurczenko@dev-hobby.pl
👨‍💻
Mariusz Jurczenko
Senior .NET Developer · 10+ lat doświadczenia komercyjnego

Programista .NET z doświadczeniem komercyjnym w firmach takich jak NFZ, Kamsoft, Diagnostyka, Hermes Reply Polska czy Etisoft Smart Solutions. Twórca kursów, z których skorzystało już ponad 11 000 osób w Strefie Kursów i ponad 1 000 kursantów na dev-hobby.pl.

Specjalizacja: Clean Code, Clean Architecture i uczenie programowania tak, żeby dało się je naprawdę zrozumieć — nie wykuć.

🚀 Co dalej?

Zobacz to w praktyce na wideo i pobierz darmową roadmapę, żeby ułożyć naukę w spójną ścieżkę do pierwszej pracy.

czytanie to początek

Zamień wiedzę w umiejętności

Pobierz darmową Roadmapę .NET i ułóż takie tematy jak ten w spójną ścieżkę do pierwszej pracy.

Pobieram roadmapę →