Array vs List w C# — API design i decyzje

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 doIReadOnlyList<T>lub będzie dalej modyfikowanyToArray()→ gdy wynik trafia doT[]API lubReadOnlySpan<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
| Sytuacja | Typ | Powód |
|---|---|---|
| Wewnętrzna kolekcja domeny | private List<T> | Mutability potrzebna wewnętrznie |
| Publiczne pole/właściwość encji | IReadOnlyList<T> | Enkapsulacja, brak Add/Remove |
| DTO lub Value Object | ImmutableArray<T> | Gwarancja niezmienności, thread-safe |
| Parametr metody (iteracja) | IEnumerable<T> | Minimalny kontrakt |
| Parametr metody (Count + indeks) | IReadOnlyList<T> | Wystarczający kontrakt |
| Metoda serwisu applikacyjnego | IReadOnlyList<T> | Caller dostaje widok, nie oryginał |
| Bufor binarny / interop | T[] | Standard BCL, Span<T> kompatybilność |
| Wynik operacji LINQ do wielokrotnego użytku | ToList() → 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 kosztuje — list.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:
- Wewnątrz klasy —
List<T>do budowania,T[]gdy rozmiar znany z góry i nie rośnie. - W publicznym API —
IReadOnlyList<T>jako domyślne,ImmutableArray<T>gdy gwarantujesz niezmienność. - Jako parametr wejściowy —
IEnumerable<T>gdy potrzebujesz tylko iteracji,IReadOnlyList<T>gdy potrzebujeszCountlub 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ż
- C# Podstawy Programowania: Twój Pierwszy Krok w Świat Kodowania
- AI w .NET: Zostań Architektem Inteligentnych Aplikacji!
- C# Clean Architecture w Praktyce
- C# – Zbuduj Własnego Tetrisa! Kompletny Przewodnik
- 7 Dniowe Wyzwanie C# Tic Tac Toe
- C# Zbuduj Profesjonalny Portal Randkowy od Podstaw!
📣 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 →🚀 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.
- 🗺️ Pobierz darmową roadmapę Junior .NET Developer — 12 kroków od podstaw C# do pierwszej pracy: dev-hobby.pl
- 🎬 Subskrybuj kanał YouTube — nowe filmy co tydzień.
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ę →
1 comment