Masz listę produktów, użytkowników albo zamówień i chcesz pokazać ją w konkretnej kolejności? Właśnie temu służy sortowanie w LINQ, często opisywane hasłem linq order by. Pokażę, jak działają OrderBy, OrderByDescending i ThenBy, czym różni się sortowanie kolekcji w pamięci od zapytań do bazy oraz jakie błędy najczęściej prowadzą do nieoczekiwanych wyników.
Najważniejsze zasady sortowania danych w LINQ
-
OrderBysortuje rosnąco według wskazanego klucza. -
OrderByDescendingodwraca kierunek sortowania. -
ThenByiThenByDescendingsłużą do sortowania po kolejnych właściwościach. - Kolejne użycie
OrderBytworzy nowe sortowanie główne i może anulować wcześniejsze kryterium. - W przypadku
IQueryablesortowanie może zostać wykonane w bazie danych, ale zależy to od możliwości dostawcy.
OrderBy zaczyna od jednego klucza sortowania
Najprostszy przypadek wygląda tak, że wybieram właściwość, według której chcę ustawić elementy. Wyrażenie OrderBy przyjmuje funkcję zwracającą tak zwany klucz sortowania, czyli wartość używaną do porównania elementów.
var products = new List
{
new("Monitor", 1299.00m),
new("Klawiatura", 299.00m),
new("Mysz", 149.00m)
};
var orderedProducts = products
.OrderBy(product => product.Price);
foreach (var product in orderedProducts)
{
Console.WriteLine($"{product.Name}: {product.Price} zł");
} Wynik będzie uporządkowany od najtańszego produktu do najdroższego. Sortowanie rosnące jest domyślne, dlatego nie trzeba dopisywać żadnego dodatkowego parametru.
Istotne jest też to, że OrderBy nie zmienia oryginalnej listy. Zwraca nową sekwencję, a zapytanie jest wykonywane dopiero wtedy, gdy wynik zostanie wyliczony, na przykład przez foreach, ToList() albo First(). Taki mechanizm nazywa się odroczonym wykonaniem.
Przeczytaj również: Metoda statyczna w C# - kiedy warto jej używać?
Kluczem nie musi być bezpośrednia właściwość
Lambda może obliczać wartość pomocniczą. Dzięki temu można sortować tekst według długości, datę według roku albo obiekt według wyniku wyrażenia.
var words = new[] { "kot", "samochód", "dom", "programowanie" };
var byLength = words
.OrderBy(word => word.Length);
var byLastCharacter = words
.OrderBy(word => word[^1]);W praktyce najczęściej sortuję po właściwości obiektu, ale możliwość użycia wyrażenia jest bardzo wygodna przy prostych regułach biznesowych. Trzeba tylko uważać, aby nie umieszczać w kluczu kosztownych operacji wykonywanych tysiące razy.
Sortowanie malejące i po wielu właściwościach
Gdy potrzebuję kolejności od największej wartości do najmniejszej, używam OrderByDescending. Nazwa jest długa, ale zasada pozostaje prosta.
var newestOrders = orders
.OrderByDescending(order => order.CreatedAt);Znacznie ciekawszy przypadek pojawia się wtedy, gdy jedna właściwość nie wystarcza. Załóżmy, że chcę pogrupować produkty według kategorii, a w każdej kategorii wyświetlić najpierw najtańsze produkty. Do tego służy ThenBy.
var sortedProducts = products
.OrderBy(product => product.Category)
.ThenBy(product => product.Price)
.ThenBy(product => product.Name);Kolejność ma tutaj znaczenie. Najpierw działa kategoria, potem cena, a na końcu nazwa jako dodatkowe rozstrzygnięcie remisu. ThenBy nie rozpoczyna nowego sortowania, tylko doprecyzowuje poprzednie.
Każde kryterium może mieć inny kierunek. Przykładowo kategorie mogą być alfabetyczne, ale produkty w każdej z nich mogą być ułożone od najdroższego:
var sortedProducts = products
.OrderBy(product => product.Category)
.ThenByDescending(product => product.Price);W składni zapytań ten sam zapis wygląda tak:
var sortedProducts =
from product in products
orderby product.Category, product.Price descending
select product;Składnia zapytań jest tłumaczona na wywołania metod. Wielokrotne pola po orderby odpowiadają użyciu OrderBy oraz kolejnych metod ThenBy. Ja zwykle wybieram składnię metod, gdy zapytanie jest krótkie i zawiera kilka operacji łańcuchowych, ale składnia zapytań może być czytelniejsza przy rozbudowanych połączeniach i grupowaniu.
Składnia metod czy składnia zapytań
Oba warianty wykonują tę samą operację, ale różnią się stylem zapisu. Najważniejsze, aby nie mieszać ich bez powodu i zachować kolejność operacji, która odpowiada temu, co rzeczywiście ma zostać posortowane.
| Cel | Składnia metod | Składnia zapytań |
|---|---|---|
| Sortowanie rosnące | OrderBy(x => x.Name) |
orderby x.Name |
| Sortowanie malejące | OrderByDescending(x => x.Price) |
orderby x.Price descending |
| Drugie kryterium | ThenBy(x => x.Id) |
orderby x.Name, x.Id |
Przykładowo filtr i sortowanie można połączyć w taki sposób:
var activeUsers = users
.Where(user => user.IsActive)
.OrderBy(user => user.LastName)
.ThenBy(user => user.FirstName)
.Select(user => new
{
user.FirstName,
user.LastName
});Najpierw wybieram aktywnych użytkowników, później sortuję ich po nazwisku i imieniu, a dopiero na końcu tworzę uproszczony wynik. Taka kolejność jest czytelna i pozwala uniknąć sortowania danych, które i tak zostaną odrzucone przez Where.
Trzeba uważać na projekcję wykonaną zbyt wcześnie. Jeżeli po Select usunę właściwość potrzebną do sortowania, nie będę mógł się do niej odwołać w dalszej części zapytania. Z tego powodu najczęściej filtruję i sortuję przed projekcją do obiektu anonimowego lub DTO.
IEnumerable i IQueryable wymagają innego myślenia
Dla kolekcji typu IEnumerable, na przykład listy lub tablicy, sortowanie odbywa się w pamięci aplikacji. OrderBy musi przeanalizować wszystkie elementy, dlatego przy dużych kolekcjach operacja ma realny koszt obliczeniowy i pamięciowy.
Jeżeli pracuję z IQueryable, jak w przypadku Entity Framework Core, zapytanie może zostać przetłumaczone na SQL. Wtedy sortowanie wykona baza danych, a do aplikacji trafi już uporządkowany fragment danych.
var page = await dbContext.Products
.Where(product => product.IsAvailable)
.OrderBy(product => product.Name)
.Skip(pageNumber * pageSize)
.Take(pageSize)
.ToListAsync();Przy paginacji dobrze dodaję jeszcze unikalne kryterium końcowe, na przykład identyfikator. Sama nazwa produktu może się powtarzać, a baza danych nie musi zachować stałej kolejności rekordów o identycznej wartości sortowania.
Duże znaczenie ma również moment wywołania ToList(). Zbyt wczesna materializacja przenosi dane do pamięci i sprawia, że kolejne operacje wykonują się lokalnie. Przy pracy z listami przydaje się też rozróżnienie, czym jest długość listy w C#, a czym jej pojemność, bo Count opisuje liczbę elementów, natomiast Capacity zarezerwowane miejsce.
Nie każda lambda da się przetłumaczyć na zapytanie bazy danych. Prosty dostęp do właściwości zwykle nie sprawia problemu, ale własne metody, niestandardowe komparatory czy skomplikowane operacje na tekście mogą wymagać wcześniejszego pobrania danych i sortowania w pamięci.
Teksty, wartości null i własny comparer
Sortowanie liczb i dat jest zazwyczaj przewidywalne. Przy tekstach dochodzą reguły porównywania wielkości liter, kultury i znaków diakrytycznych. Dla technicznych identyfikatorów, kodów i kluczy często wybieram porównywanie niezależne od kultury.
var sortedCodes = codes
.OrderBy(code => code, StringComparer.OrdinalIgnoreCase);StringComparer.OrdinalIgnoreCase porównuje tekst bez uwzględniania wielkości liter, ale nie próbuje dopasować reguł języka polskiego czy innej kultury. To dobry wybór dla kodów, nazw kluczy i wartości przeznaczonych do porównań technicznych. Dla nazw przeznaczonych do prezentacji użytkownikowi decyzję trzeba dopasować do reguł konkretnego języka.
Przy wartościach opcjonalnych trzeba świadomie zdecydować, gdzie mają trafić elementy z null. Prostym rozwiązaniem jest zastąpienie braku pustym tekstem:
var sortedCustomers = customers
.OrderBy(customer => customer.CompanyName ?? string.Empty);To nie jest jednak uniwersalna reguła. Pusta wartość może znaleźć się na początku, podczas gdy w interfejsie użytkownika bardziej sensowne będzie pokazanie nieuzupełnionych rekordów na końcu. Wtedy tworzę dodatkowy klucz logiczny:
var sortedCustomers = customers
.OrderBy(customer => customer.CompanyName is null)
.ThenBy(customer => customer.CompanyName);Jeśli klucz sortowania powstaje po manipulacji tekstem, trzeba poprawnie obsłużyć Unicode. Dotyczy to także nietypowego przypadku sortowania słów według znaków od końca. Przy takich operacjach pomocne jest wyjaśnienie, jak działa odwracanie znaków Unicode w C#, ponieważ zwykłe odwracanie elementów typu char może rozbić niektóre znaki zapisane za pomocą par zastępczych.
Błędy, które psują wynik sortowania
Najczęstszy błąd wygląda niewinnie i polega na użyciu dwóch metod OrderBy jedna po drugiej:
var result = products
.OrderBy(product => product.Category)
.OrderBy(product => product.Price);Drugie wywołanie nie sortuje ceny wewnątrz kategorii. Tworzy nowe sortowanie główne według ceny i ignoruje wcześniejszą kolejność kategorii. Poprawny zapis wykorzystuje ThenBy:
var result = products
.OrderBy(product => product.Category)
.ThenBy(product => product.Price);-
Oczekiwanie modyfikacji listy -
OrderByzwraca wynik, więc trzeba go przypisać lub wyliczyć. - Brak materializacji - przy odroczonym wykonaniu zmiana danych źródłowych może wpłynąć na późniejszy wynik.
-
Sortowanie po
ToString()- często zależy od kultury i bywa kosztowne. - Brak końcowego tie-breakera - przy paginacji powtarzające się wartości mogą powodować zmianę kolejności rekordów.
- Własny comparer w zapytaniu do bazy - dostawca może nie umieć przetłumaczyć go na SQL.
W praktyce szczególnie pilnuję ostatniego kryterium sortowania. Dla danych biznesowych często dodaję ThenBy(x => x.Id), nawet jeśli użytkownik tego nie widzi. Dzięki temu wynik jest deterministyczny, a kolejne strony tabeli nie zmieniają zawartości tylko dlatego, że kilka rekordów ma tę samą nazwę lub datę.
Dobry klucz sortowania rozwiązuje większość problemów
Najprostsza reguła brzmi tak: najpierw wybieram główne kryterium, potem dopisuję ThenBy dla remisów, a na końcu sprawdzam, gdzie faktycznie wykona się zapytanie. Dla małych kolekcji w pamięci wystarczy czytelny łańcuch metod, natomiast przy bazie danych trzeba kontrolować moment materializacji i możliwości translacji.
Samą metodę OrderBy trudno uznać za skomplikowaną. Problemy zaczynają się zwykle wtedy, gdy sortowanie łączymy z wartościami null, tekstem zależnym od kultury, paginacją albo nieświadomym użyciem drugiego OrderBy. Jasno określony klucz i jednoznaczne kryterium końcowe sprawiają, że wynik pozostaje przewidywalny także wtedy, gdy dane rosną i trafiają do produkcyjnej bazy.
