Professional Documents
Culture Documents
Odszewk: Jon Skeet
Odszewk: Jon Skeet
od
p o d sz e w k i
Wydanie IV
Tytuł oryginału: C# in Depth, Fourth Edition
ISBN: 978-83-283-6030-3
All rights reserved. No part of this book may be reproduced or transmitted in any form
or by any means, electronic or mechanical, including photocopying, recording or by
any information storage retrieval system, without permission from the Publisher.
Autor oraz Helion SA dołożyli wszelkich starań, by zawarte w tej książce informacje
były kompletne i rzetelne. Nie biorą jednak żadnej odpowiedzialności ani za ich
wykorzystanie, ani za związane z tym ewentualne naruszenie praw patentowych
lub autorskich. Autor oraz Helion SA nie ponoszą również żadnej odpowiedzialności
za ewentualne szkody wynikłe z wykorzystania informacji zawartych w książce.
Helion SA
ul. Kościuszki 1c, 44-100 Gliwice
tel. 32 231 22 19, 32 230 98 63
e-mail: helion@helion.pl
WWW: http://helion.pl (księgarnia internetowa, katalog książek)
Drogi Czytelniku!
Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres
http://helion.pl/user/opinie/cshop4_ebook
Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję.
87469504f326f0d7c1fcda56ef61bd79
8
Rekomendacje do wydania trzeciego:
„Bez wątpienia najlepsze źródło wiedzy na temat języka C#, jakie znalazłem”.
— Jon Parish, inżynier oprogramowania w firmie Datasift
„Gorąco polecam tę książkę programistom języka C#, którzy chcą opanować go na poziomie
profesjonalistów”.
— D. Jay, z recenzji w sklepie Amazon
„Ta książka powinna być lekturą obowiązkową dla wszystkich zawodowych programi-
stów używających C#”.
— Stuart Caborn, starszy programista w firmie BNP Paribas
„Jest to bardzo konkretne źródło fachowej wiedzy na temat zmian w języku we wszyst-
kich głównych wersjach C#. To lektura obowiązkowa dla ekspertów z dziedziny pro-
gramowania, którzy chcą znać nowe mechanizmy języka C#”.
— Sean Reilly, programista i analityk w firmie Point2 Technologies
87469504f326f0d7c1fcda56ef61bd79
8
„Po co w kółko czytać o podstawach? Jon koncentruje się na ważnych nowych mecha-
nizmach”.
— Keith Hill, architekt oprogramowania w firmie Agilent Technologies
„Ta pozycja zawiera bogatą wiedzę autora na temat mechanizmów działania języka
C#. Informacje te są przekazywane czytelnikom w formie dobrze napisanej, zwięzłej
i przydatnej książki”.
— Jim Holmes, autor książki Windows Developer Power Tools
87469504f326f0d7c1fcda56ef61bd79
8
Ta książka jest poświęcona równości.
W prawdziwym świecie osiągnąć równość jest znacznie trudniej,
niż przesłonić metody Equals() i GetHashCode() w kodzie.
87469504f326f0d7c1fcda56ef61bd79
8
87469504f326f0d7c1fcda56ef61bd79
8
Spis treści
Przedmowa 17
Wprowadzenie 19
Podziękowania 21
O książce 23
O autorze 27
CZĘŚĆ 2. C# 2 – 5 ...................................................................................... 49
Rozdział 2. C# 2 51
2.1. Typy generyczne 52
2.1.1. Wprowadzenie z użyciem przykładu — kolekcje przed wprowadzeniem
typów generycznych 52
2.1.2. Typy generyczne ratują sytuację 55
2.1.3. Jakie elementy mogą być generyczne? 59
2.1.4. Wnioskowanie typu argumentów określających typ w metodach 60
2.1.5. Ograniczenia typów 62
2.1.6. Operatory default i typeof 64
2.1.7. Inicjowanie typów generycznych i ich stan 67
87469504f326f0d7c1fcda56ef61bd79
8
8 Spis treści
87469504f326f0d7c1fcda56ef61bd79
8
Spis treści 9
87469504f326f0d7c1fcda56ef61bd79
8
10 Spis treści
87469504f326f0d7c1fcda56ef61bd79
8
Spis treści 11
87469504f326f0d7c1fcda56ef61bd79
8
12 Spis treści
87469504f326f0d7c1fcda56ef61bd79
8
Spis treści 13
87469504f326f0d7c1fcda56ef61bd79
8
14 Spis treści
87469504f326f0d7c1fcda56ef61bd79
8
Spis treści 15
87469504f326f0d7c1fcda56ef61bd79
8
16 Spis treści
87469504f326f0d7c1fcda56ef61bd79
8
Przedmowa
Dziesięć lat to dużo czasu dla człowieka i niemal wieczność dla książki technicznej
skierowanej do zawodowych programistów. Dlatego byłem nieco zdumiony, gdy zdałem
sobie sprawę, że minęło 10 lat od czasu wprowadzenia przez Microsoft języka C# 3.0
(w środowisku Visual Studio 2008), kiedy to czytałem wersję roboczą pierwszego wydania
tej książki. Również 10 lat minęło od czasu, gdy Jon dołączył do serwisu Stack Overflow
i stał się użytkownikiem o najwyższej reputacji.
C# był rozbudowanym i złożonym językiem już w 2008 r., a zespoły odpowie-
dzialne za projekt i implementację tego języka przez ostatnią dekadę nie próżnowały.
Jestem zachwycony tym, jak innowacyjny okazał się C# w spełnianiu potrzeb progra-
mistów w wielu różnych obszarach — od gier komputerowych, przez witryny interne-
towe, po niskopoziomowe, wysoce stabilne komponenty systemów komputerowych.
W C# wykorzystano najlepsze rozwiązania z badań akademickich i połączono je z prak-
tycznymi technikami rozwiązywania prawdziwych problemów. Stosowano przy tym
niedogmatyczne podejście. Projektanci języka C# nie zadawali pytań takich jak: „Jak
zaprojektować tę funkcję w najbardziej obiektowy sposób?” lub „Jak zaprojektować
dany mechanizm w najbardziej funkcyjnym stylu?”. Zastanawiali się raczej nad tym, jaki
projekt funkcji będzie najbardziej pragmatyczny, bezpieczny i skuteczny. Jon to rozu-
mie. Nie ogranicza się do wyjaśniania, jak działa język. Opisuje, jak wszystkie elementy
łączą się ze sobą w jednolity projekt, a także wskazuje miejsca, w których jest inaczej.
W przedmowie do pierwszego wydania napisałem, że Jon jest pełen entuzjazmu,
ma bogatą wiedzę, jest utalentowany, dociekliwy i analityczny, a także że jest świetnym
nauczycielem. Wszystko to nadal jest prawdą. Pozwólcie jednak, że dodam do tej listy
wytrwałość i zaangażowanie. Pisanie książki to wymagające zadanie, zwłaszcza gdy
robi się to w wolnym czasie. Powrót do książki i poprawianie jej, by była aktualna,
wymaga równie dużo pracy. Jon zrobił to trzeci raz, przygotowując tę książkę. Mniej
ambitny autor byłby usatysfakcjonowany wprowadzeniem nielicznych poprawek lub
dodaniem rozdziału z nowym materiałem. Jednak ta książka powstała w wyniku prze-
prowadzonej na dużą skalę refaktoryzacji. Efekty mówią same za siebie.
Bardziej niż kiedykolwiek wcześniej nie mogę się doczekać, aby zobaczyć, jakie
fantastyczne rzeczy nowe pokolenie programistów będzie potrafiło robić za pomocą
języka C#, gdy ten będzie wciąż ewoluował i rozwijał się. Mam nadzieję, że książka ta
przyniesie Ci tyle samo przyjemności co mi przez lata. Dziękuję, że zdecydowałeś się
pisać programy w C#.
Eric Lippert,
inżynier oprogramowania w firmie Facebook
87469504f326f0d7c1fcda56ef61bd79
8
18 Przedmowa
87469504f326f0d7c1fcda56ef61bd79
8
Wprowadzenie
Witajcie w czwartym wydaniu książki C# od podszewki. Gdy pisałem pierwsze wyda-
nie, nie myślałem o tym, że 10 lat później będę przygotowywał czwarte wydanie tego
samego tytułu. Obecnie nie byłbym zaskoczony, gdybym za 10 lat miał pisać kolejne
wydanie. Od czasu pojawienia się pierwszego wydania książki projektanci języka C#
wielokrotnie udowodnili, że są zaangażowani w rozwijanie tego języka tak długo, jak
długo branża będzie nim zainteresowana.
Jest to ważne, ponieważ branża znacznie się zmieniła w ostatnich 10 latach. Warto
przypomnieć, że zarówno ekosystem mobilny (w znanej dziś postaci), jak i przetwa-
rzanie w chmurze były w 2008 r. w powijakach. Usługę Amazon EC2 udostępniono
w 2006 r., a platformę Google AppEngine wprowadzono w 2008 r. Platforma Xamarin
została utworzona przez zespół odpowiedzialny za projekt Mono w 20011 r. Docker
pojawił się dopiero w 2013 r.
Dla wielu programistów używających technologii .NET ważną zmianą w naszym
świecie było wprowadzenie platformy .NET Core. Jest to działająca w różnych syste-
mach otwarta wersja platformy zaprojektowana pod kątem zgodności z innymi platfor-
mami (dzięki specyfikacji .NET Standard). Już samo istnienie tej platformy jest dziwne.
To, że Microsoft traktuje ją jako główny przedmiot inwestycji w rozwój technologii
.NET, jest jeszcze bardziej zaskakujące.
Przez wszystkie te lata C# był (i wciąż jest) głównym językiem w technologiach
.NET — niezależnie od tego, czy używasz platform .NET, .NET Core, Xamarin, czy
Unity. Język F# jest zdrową i przyjazną konkurencją, ale nie jest równie popularny
w branży jak C#.
Ja sam programuję w C# mniej więcej od 2002 r. — zarówno zawodowo, jak i jako
entuzjastycznie nastawiony amator. Wraz z upływem lat coraz bardziej pasjonują mnie
szczegóły tego języka. Interesuję się nimi ze względu na nie same, ale — co ważniej-
sze — z powodu ciągłego wzrostu produktywności w obszarze pisania kodu w C#.
Mam nadzieję, że część mojej pasji przeniknęła do tej książki i zachęci Cię do dalszych
podróży w świat języka C#.
87469504f326f0d7c1fcda56ef61bd79
8
20 Wprowadzenie
87469504f326f0d7c1fcda56ef61bd79
8
Podziękowania
Opracowanie książki wymaga dużo pracy i energii. Po części jest to oczywiste. W końcu
strony nie piszą się same. To jednak tylko czubek góry lodowej. Gdybyś otrzymał pierw-
szą wersję tekstu, jaką napisałem, bez redakcji, bez recenzji, bez profesjonalnego
składu itd., podejrzewam, że byłbyś rozczarowany.
Podobnie jak we wcześniejszych wydaniach miałem przyjemność pracować z zespo-
łem z wydawnictwa Manning. Richard Wattenberger przekazywał mi porady i sugestie,
odpowiednio łącząc naciski z wyrozumiałością. Kształtował w ten sposób w wielu
krokach treść książki. Zaskakująco trudne okazało się przede wszystkim opracowanie
najlepszego sposobu używania C# w wersjach od 2 do 4. Dziękuję też Mike’owi Ste-
phensowi i Marjanowi Bace’owi za to, że od początku pomagali w przygotowaniu tego
wydania.
Oprócz ustalenia struktury książki nieodzowny jest też proces recenzowania jej,
aby treść była poprawna i zrozumiała. Ivan Martinovic zarządzał procesem recenzowania
i uzyskał wartościowe informacje zwrotne od osób takich jak: Ajay Bhosale, Andrei
Rînea, Andy Kirsch, Brian Rasmussen, Chris Heneghan, Christos Paisios, Dmytro Lypai,
Ernesto Cardenas, Gary Hubbard, Jassel Holguin Calderon, Jeremy Lange, John
Meyer, Jose Luis Perez Vila, Karl Metivier, Meredith Godar, Michal Paszkiewicz,
Mikkel Arentoft, Nelson Ferrari, Prajwal Khanal, Rami Abdelwahed i Willem van
Ketwicha. Jestem też zobowiązany Dennisowi Sellingerowi za redakcję techniczną
i Ericowi Lippertowi za korektę techniczną. Chcę podkreślić wkład Erica we wszystkie
wydania tej książki. Wkład ten zawsze znacznie wykraczał poza poprawki techniczne.
Wnikliwość, doświadczenie i poczucie humoru Erica były ważnym i nieoczekiwanym
bonusem w całym procesie prac.
Treść to jedna sprawa. Drugą jest jej atrakcyjna prezentacja. Lori Weidert z poświę-
ceniem i zrozumieniem zarządzała złożonym procesem produkcji książki. Sharon
Wilkey z wprawą i niezmierzoną cierpliwością przeprowadziła adjustację. Za skład
i projekt okładki odpowiadała Marija Tudor. Nie potrafię wyrazić, jaką radość sprawia
zobaczenie pierwszych stron po składzie. Przypomina to pierwszą (udaną) próbę kostiu-
mową sztuki, nad którą prace toczyły się od miesięcy.
Chcę podziękować osobom, które bezpośrednio brały udział w pracach nad książką,
i oczywiście także mojej rodzinie za to, że znosiły życie ze mną w kilku ostatnich latach.
Kocham moją rodzinę. Są fantastyczni i jestem im za to wdzięczny.
W końcu żadna z tych rzeczy nie miałaby znaczenia, gdyby nikt nie chciał przeczy-
tać tej pozycji. Dziękuję więc Wam za zainteresowaniem. Mam nadzieję, że poświę-
cenie czasu na lekturę tej książki przyniesie Wam korzyści.
87469504f326f0d7c1fcda56ef61bd79
8
22 Podziękowania
87469504f326f0d7c1fcda56ef61bd79
8
O książce
Kto powinien przeczytać tę książkę?
Ta książka dotyczy języka C#. Często oznacza to, że omawiane będą szczegóły śro-
dowiska uruchomieniowego (odpowiedzialnego za wykonywanie kodu) i bibliotek
wspomagających aplikację, jednak głównym tematem książki jest sam język.
Ta książka ma sprawić, że nabierzesz możliwie dużej wprawy w posługiwaniu się
językiem C#, tak abyś nigdy więcej nie musiał z nim walczyć. Chcę pomóc Ci poczuć
biegłość w używaniu C#, a także, co jest z tym powiązane, nauczyć Cię pracować w nim
w płynny sposób. Pomyśl o C# jak o rzece, po której płyniesz kajakiem. Im lepiej
znasz rzekę, tym szybciej możesz płynąć z jej nurtem. Od czasu do czasu z jakiegoś
powodu możesz zechcieć powiosłować w górę rzeki. Jednak nawet wtedy znajomość
rzeki ułatwi Ci dotarcie do celu bez wywrotek.
Jeśli już programujesz w C# i chcesz lepiej poznać ten język, jest to książka dla
Ciebie! Nie musisz być ekspertem, zakładam jednak, że znasz podstawy C# 1. Obja-
śniam tu całą używaną w tekście terminologię wprowadzoną po wersji C# 1, a także
omawiam starsze pojęcia, które często są błędnie rozumiane (np. parametry i argumenty).
Zakładam jednak, że wiesz, czym jest klasa, obiekt itd.
Nawet jeżeli jesteś ekspertem, ta książka prawdopodobnie okaże się dla Ciebie
przydatna, ponieważ opisane są tu różne sposoby myślenia o znanych Ci już zagadnie-
niach. Możesz też odkryć obszary języka, których nie byłeś świadomy. Mnie przytrafiło
się to w trakcie pisania tej książki.
Jeśli dopiero zaczynasz naukę języka C#, ta książka może na razie być dla Ciebie
mało przydatna. Dostępnych jest wiele wprowadzających książek i internetowych
samouczków z zakresu tego języka. Gdy już opanujesz podstawy, mam nadzieję, że
wrócisz do tej pozycji, aby lepiej poznać język.
Struktura książki
Ta książka zawiera 15 rozdziałów podzielonych na cztery części. W części 1. znajdziesz
krótką historię języka.
Rozdział 1. obejmuje omówienie tego, jak C# był modyfikowany przez lata
i jak wciąż się zmienia. Przedstawiam tu C# w szerszym kontekście platform i
społeczności oraz opisuję, jak materiał prezentowany jest w dalszych częściach
książki.
87469504f326f0d7c1fcda56ef61bd79
8
24 O książce
W części 4. opisuję język C# 7 (aż do wersji C# 7.3), a książka kończy się prognozą
nieodległej przyszłości tego języka.
W rozdziale 11. opisuję integrację krotek z językiem, a także omawiam rodzinę
typów ValueTuple używaną do implementacji krotek.
87469504f326f0d7c1fcda56ef61bd79
8
O kodzie 25
O kodzie
Ta książka zawiera wiele przykładów z kodem źródłowym w numerowanych listingach
i w zwykłym tekście. W obu przypadkach kod źródłowy jest formatowany z użyciem
czcionki o stałej szerokości, aby odróżnić go od reszty tekstu. Czasem kod jest wyróż-
niony pogrubieniem, aby pokazać, że zmienił się w porównaniu z wcześniejszymi kro-
kami opisanymi w rozdziale — np. gdy nowy mechanizm wymaga dodania czegoś do
istniejącego wiersza kodu.
W wielu miejscach oryginalny kod źródłowy został sformatowany w nowy sposób.
Dodany został podział wierszy i zmienione zostały wcięcia, aby dostosować kod do
ilości miejsca na stronach książki. W rzadkich sytuacjach na listingach znajdują się
symbole kontynuacji wiersza (➥). Ponadto z listingów usunięto komentarze z kodu
87469504f326f0d7c1fcda56ef61bd79
8
26 O książce
źródłowego, jeśli dany fragment jest opisany w tekście. Do wielu listingów dołączone są
uwagi objaśniające ważne zagadnienia.
Kod źródłowy przykładów z książki można pobrać z serwera FTP wydawnictwa
Helion (ftp://ftp.helion.pl/przyklady/cshop4.zip) i z witryny wydawnictwa Manning
(http://www.manning.com/books/c-sharp-in-depth-fourth-edition). Aby skompilować
przykłady, będziesz potrzebował pakietu .NET Core SDK w wersji 2.1.300 lub nowszej.
Kilka przykładów (z użyciem technologii Windows Forms i COM) wymaga platformy
.NET dla stacjonarnego systemu Windows, jednak większość programów jest przeno-
śnych dzięki wykorzystaniu platformy .NET Core. Choć do opracowania przykładów
używane było środowisko Visual Studio 2017 (wersja Community Edition), kod powi-
nien działać poprawnie także w edytorze Visual Studio Code.
87469504f326f0d7c1fcda56ef61bd79
8
O autorze
Nazywam się Jon Skeet. Jestem starszym inżynierem oprogramowania w firmie Google
i pracuję w londyńskim biurze tej firmy. Obecnie odpowiadam za tworzenie bibliotek
klienckich dla platformy .NET w platformie Google Cloud. Pozwala mi to połączyć
entuzjazm do pracy w firmie Google z miłością do języka C#. Ponadto zarządzam
w organizacji ECMA grupą techniczną odpowiedzialną za tworzenie standardu języka
C# i reprezentuję firmę Google w .NET Foundation.
Prawdopodobnie najbardziej znany jestem z aktywności w serwisie Stack Overflow
(jest to witryna z pytaniami i odpowiedziami dla programistów). Lubię też wygłaszać
prelekcje na konferencjach i dla grup użytkowników, a także pisać bloga. Wspólnym
czynnikiem wszystkich tych zajęć jest interakcja z innymi programistami. W ten sposób
uczę się najlepiej.
Nieco mniej typowe jest moje hobby — zajmowanie się datami i czasem. Najlepiej
obrazuje to moja praca nad Noda Time, czyli biblioteką do obsługi dat i czasu w plat-
formie .NET. Jest ona używana w kilku przykładach z tej książki. Nawet pomijając
aspekt pisania kodu, czas jest fascynującym tematem pełnym ciekawostek. Jeśli spotkasz
mnie na jakiejś konferencji, zanudzę Cię informacjami na temat stref czasowych i sys-
temów pomiaru czasu.
Redaktorzy chcą, abyś dowiedział się wszystkich tych rzeczy, ponieważ dowodzą,
że mam kwalifikacje do napisania tej książki. Nie traktuj jednak tych informacji jako
dowodu na moją nieomylność. Pokora jest ważną cechą skutecznego inżyniera opro-
gramowania, a ja — jak każdy inny człowiek — popełniam błędy. Kompilatory zwykle
nie interesują się deklaracjami, że ktoś jest autorytetem.
W tej książce starałem się wyraźnie zaznaczać, co uważam za obiektywne fakty na
temat języka C#, a gdzie wyrażam swoje opinie. Mam nadzieję, że dzięki starannym
recenzentom technicznym w książce znajduje się niewiele usterek dotyczących obiek-
tywnych faktów. Doświadczenia z wcześniejszych wydań pokazują jednak, że w tek-
ście mogą wystąpić jakieś nieścisłości. Jeśli chodzi o opinie, moje mogą znacznie różnić
się od Twoich. Nie ma w tym nic złego. Wykorzystaj to, co uznasz za przydatne, a pozo-
stałe informacje możesz zignorować.
87469504f326f0d7c1fcda56ef61bd79
8
28 O autorze
87469504f326f0d7c1fcda56ef61bd79
8
Część 1
Kontekst języka C#
87469504f326f0d7c1fcda56ef61bd79
8
87469504f326f0d7c1fcda56ef61bd79
8
Przetrwają najbystrzejsi
Zawartość rozdziału:
Zwiększenie produktywności programistów dzięki
szybkiemu rozwojowi języka C#
Wybieranie podwersji języka C# umożliwiających
użycie najnowszych funkcji
Uruchamianie języka C# w różnych środowiskach
Korzyści, jakie daje otwarta i zaangażowana
społeczność
Starsze i nowsze wersje języka C# w tej książce
87469504f326f0d7c1fcda56ef61bd79
8
32 ROZDZIAŁ 1. Przetrwają najbystrzejsi
Jakiego typu są poszczególne elementy w sekwencji Books? Ten system typów o tym
nie informuje. Dzięki typom generycznym w C# 2 można określić typ w bardziej sku-
teczny sposób:
public class Bookshelf
{
public IEnumerable<Book> Books { get { ... } }
}
87469504f326f0d7c1fcda56ef61bd79
8
1.1. Ewoluujący język 33
87469504f326f0d7c1fcda56ef61bd79
8
34 ROZDZIAŁ 1. Przetrwają najbystrzejsi
Choć niejawne typowanie jest niezbędne, gdy używasz typów anonimowych, odkryłem,
że jest przydatne także przy korzystaniu ze zwykłych typów. Ważne jest, aby odróżniać
niejawne (ang. implicit) typowanie od typowania dynamicznego (ang. dynamic). Dla
zmiennej map2 nadal używane jest typowanie statyczne, jednak nie trzeba było jawnie
podawać jej typu.
Typy anonimowe są pomocne tylko w ramach jednego bloku kodu. Nie możesz np.
używać ich jako parametrów metod lub typów zwracanych wartości. W C# 7 wpro-
wadzono krotki. Są to typy bezpośrednie łączące zmienne ze sobą. Obsługa krotek
w platformie jest stosunkowo prosta, jednak dodatkowa obsługa ze strony języka pozwala
nadawać nazwy elementom krotek. Na przykład zamiast pokazanego wcześniej typu
anonimowego możesz zastosować następujący kod:
var book = (title: "Lost in the Snow", author: "Holly Webb");
Console.WriteLine(book.title);
Krotki w niektórych sytuacjach mogą zastępować typy anonimowe, ale nie zawsze jest
to możliwe. Jedną z zalet krotek jest to, że można je wykorzystać jako parametry metod
i typy zwracanych wartości. Obecnie zalecam, aby stosować krotki w ramach wewnętrz-
nego interfejsu API programu i nie udostępniać ich publicznie, ponieważ stanowią
prostą kompozycję wartości (nie hermetyzują ich). To dlatego nadal uważam je za narzę-
dzie do tworzenia prostszego kodu, a nie do poprawy ogólnego projektu programów.
Warto wspomnieć funkcję, która może pojawić się w C# 8 — typy w postaci rekor-
dów. Uważam, że w pewnym sensie są to nazwane typy anonimowe (przynajmniej
w najprostszej postaci). Zapewniają korzyści typowe dla typów anonimowych, ponie-
waż nie wymagają pisania szablonowego kodu, a przy tym pozwalają dodać operacje
takie jak w zwykłych klasach. Obserwuj ten kierunek!
87469504f326f0d7c1fcda56ef61bd79
8
1.1. Ewoluujący język 35
Jeśli metoda obsługi kliknięć jest prosta, możliwe, że w ogóle nie chcesz kłopotać się
tworzeniem odrębnej metody i wolisz zastosować metodę anonimową:
button.Click += delegate { MessageBox.Show("Kliknięto!"); }; C# 2.
Metody anonimowe mają dodatkową zaletę, ponieważ działają jak domknięcie. Można
w nich używać zmiennych lokalnych w kontekście, w którym je utworzono. Technika ta
jest jednak rzadko stosowana w C#, ponieważ w C# 3 wprowadzono wyrażenia lambda,
które mają prawie wszystkie zalety metod anonimowych, ale cechują się krótszą
składnią:
button.Click += (sender, args) => MessageBox.Show("Kliknięto!"); C# 3.
UWAGA. W tej sytuacji wyrażenie lambda jest dłuższe niż metoda anonimowa, ponieważ
w metodzie anonimowej wykorzystano rozwiązanie niedostępne w wyrażeniach lambda —
możliwość ignorowania parametrów dzięki pominięciu listy parametrów.
Użyłem metod obsługi zdarzeń jako przykładowych delegatów, ponieważ takie było
główne zastosowanie delegatów w C# 1. W nowszych wersjach C# delegaty są używane
w bardziej zróżnicowanych scenariuszach, przede wszystkim w technologii LINQ.
Technologia LINQ zapewnia też inne korzyści w zakresie inicjowania, ponieważ
udostępnia inicjalizatory obiektów i inicjalizatory kolekcji. Dzięki nim można podać
zestaw właściwości dla nowego obiektu obiektów i dodać te elementy do nowej kolekcji
w jednym wyrażeniu. Łatwiej jest to pokazać, niż opisać. Wykorzystuję tu przykład
z rozdziału 3. Zastanów się nad kodem, który wcześniej był zapisywany w następujący
sposób:
var customer = new Customer();
customer.Name = "Jon";
customer.Address = "UK";
var item1 = new OrderItem();
item1.ItemId = "abcd123";
item1.Quantity = 1;
var item2 = new OrderItem();
item2.ItemId = "fghi456";
item2.Quantity = 2;
var order = new Order();
order.OrderId = "xyz";
order.Customer = customer;
order.Items.Add(item1);
order.Items.Add(item2);
87469504f326f0d7c1fcda56ef61bd79
8
36 ROZDZIAŁ 1. Przetrwają najbystrzejsi
Jest to dobry przykład ceregieli — duża ilość składni, jaka była wymagana w języku,
dawała niewielkie korzyści. W C# 6 kod jest dużo bardziej przejrzysty. Składnia =>
(używana w wyrażeniach lambda) służy do tworzenia składowych z ciałem w postaci
wyrażenia:
public int Count => list.Count;
public IEnumerator<string> GetEnumerator() => list.GetEnumerator();
87469504f326f0d7c1fcda56ef61bd79
8
1.1. Ewoluujący język 37
nie sądziłem, że będę go stosował tak często, jak obecnie to robię, jest interpolacja
łańcuchów znaków. Jest to jedno z usprawnień języka C# związanych z łańcuchami
znaków.
OBSŁUGA ŁAŃCUCHÓW ZNAKÓW
W obsłudze łańcuchów znaków w C# wprowadzono trzy ważne usprawnienia:
W C# 5 wprowadzono atrybuty z informacjami o jednostce wywołującej, co
pozwala kompilatorowi automatycznie podać nazwę metody i pliku jako wartości
parametrów. Ta technika jest bardzo przydatna do celów diagnostycznych — czy
to do trwałego rejestrowania zdarzeń, czy to w doraźnych testach.
W C# 6 wprowadzono operator nameof, który umożliwia reprezentowanie nazw
zmiennych, typów, metod i innych składowych w postaci ułatwiającej refakto-
ryzację.
W C# 6 dodano też literały z interpolowanymi łańcuchami znaków. Nie jest to
nowy pomysł, ale technika ta znacznie ułatwia tworzenie łańcuchów znaków
z dynamicznie pobieranymi wartościami.
Aby zachować zwięzłość, tu przedstawiony jest tylko ostatni z tych punktów. Stosun-
kowo często programiści tworzą łańcuchy znaków z użyciem zmiennych, właściwości,
wyników wywołań metod itd. Mogą to robić w celu rejestrowania zdarzeń, genero-
wania dla użytkowników komunikatów o błędach (jeśli informacje o lokalizacji błędu nie
są istotne), generowania komunikatów o wyjątkach itd.
Oto przykład z mojego projektu Noda Time. Użytkownicy próbują znaleźć kalendarz
na podstawie identyfikatora, a kod zgłasza wyjątek typu KeyNotFoundException, jeśli dany
identyfikator nie istnieje. Przed wersją C# 6 taki kod mógł wyglądać tak:
throw new KeyNotFoundException(
"Nie istnieje kalendarz o identyfikatorze " + id + ".");
Gdy używane jest bezpośrednie formatowanie łańcuchów znaków, kod wygląda tak:
throw new KeyNotFoundException(
string.Format("Nie istnieje kalendarz o identyfikatorze {0}.", id);
UWAGA. Informacje o projekcie Noda Time znajdziesz w punkcie 1.4.2. Nie musisz znać
tego projektu, aby zrozumieć ten przykład.
Wydaje się, że nie jest to istotna zmiana, jednak obecnie nie znoszę pracować bez
interpolacji łańcuchów znaków.
Są to tylko najważniejsze mechanizmy pomagające zwiększyć stosunek sygnału do
szumu w kodzie. Mógłbym opisać także dyrektywę using static i operator ?. z C# 6,
a także dopasowywanie wzorców, dekonstruktory i zmienne out z C# 7. Jednak zamiast
87469504f326f0d7c1fcda56ef61bd79
8
38 ROZDZIAŁ 1. Przetrwają najbystrzejsi
Nie przypomina on staromodnego kodu w C#. Wyobraź sobie, że cofasz się w czasie
do 2007 r., pokazujesz ten kod programiście używającemu C# 2 i wyjaśniasz, że dla
tego kodu dostępne jest sprawdzanie poprawności w czasie kompilacji i obsługa mecha-
nizmu IntelliSense, a wynikiem jest wydajne zapytanie bazodanowe. A dodatkowo
możesz stosować tę samą składnię do zwykłych kolekcji.
Obsługa zapytań o dane spoza bieżącego procesu jest możliwa dzięki drzewom wyra-
żeń. Reprezentują one kod jako dane, a dostawca usług LINQ może przeanalizować
ten kod i przekształcić go na SQL lub inny język zapytań. Choć jest to świetne roz-
wiązanie, sam rzadko z niego korzystam, ponieważ nieczęsto pracuję z SQL-owymi
bazami danych. Używam jednak kolekcji przechowywanych w pamięci i nieustannie
posługuję się technologią LINQ — czy to za pomocą wyrażeń w postaci zapytań, czy
to za pomocą wywołań metod z użyciem wyrażeń lambda.
LINQ nie tylko zapewnia programistom języka C# nowe narzędzia, ale też zachęca
nas do myślenia o przekształcaniu danych w nowy sposób, zgodny z programowaniem
funkcyjnym. Wpływa to na więcej aspektów niż tylko na dostęp do danych. Technologia
LINQ była pierwszym impulsem do wprowadzenia pomysłów funkcyjnych, a wielu
programistów języka C# przyjęło te pomysły i je rozwinęło.
W C# 4 wprowadzono radykalne zmiany w zakresie typowania dynamicznego,
jednak moim zdaniem nie wpłynęło to na równie wielu programistów jak technologia
LINQ. Potem pojawiła się wersja C# 5, która ponownie okazała się przełomem — tym
razem w dziedzinie asynchroniczności.
1.1.4. Asynchroniczność
Asynchroniczność sprawiała problemy w popularnych językach od długiego czasu.
Kilka mniej popularnych języków opracowano, od początku uwzględniając asynchro-
niczność. Ponadto w niektórych językach funkcyjnych dostępna jest wygodna obsługa
asynchroniczności. W C# 5 wprowadzono nowy poziom przejrzystości w programo-
87469504f326f0d7c1fcda56ef61bd79
8
1.1. Ewoluujący język 39
87469504f326f0d7c1fcda56ef61bd79
8
40 ROZDZIAŁ 1. Przetrwają najbystrzejsi
87469504f326f0d7c1fcda56ef61bd79
8
1.1. Ewoluujący język 41
Jeśli nie lubisz bezpośrednio edytować plików projektu, możesz otworzyć właściwości
projektu w środowisku Visual Studio, wybrać zakładkę Kompilacja, a następnie kliknąć
przycisk Zaawansowane w prawym dolnym rogu. Pojawi się okno dialogowe Zaawan-
sowane ustawienia kompilacji pokazane na rysunku 1.1. Można tam wybrać używaną
wersję języka i ustawić inne opcje.
Ta opcja w oknie dialogowym nie jest nowa, jednak częściej przydaje się obecnie
niż w starszych wersjach. Oto dostępne wartości:
domyślna (ang. default) — pierwsza podwersja najnowszej wersji głównej;
najnowsza (ang. lastest) — najnowsza podwersja;
numer konkretnej podwersji — np. 7.0 lub 7.3.
To ustawienie nie zmienia wersji kompilatora. Dostępny staje się natomiast inny zestaw
mechanizmów języka. Jeśli spróbujesz użyć funkcji niedostępnej w docelowej wersji,
komunikat o błędzie kompilacji zwykle będzie zawierał wyjaśnienie, która wersja jest
potrzebna. Jeżeli spróbujesz posłużyć się mechanizmem, który jest zupełnie nieznany
87469504f326f0d7c1fcda56ef61bd79
8
42 ROZDZIAŁ 1. Przetrwają najbystrzejsi
87469504f326f0d7c1fcda56ef61bd79
8
1.3. Ewoluująca społeczność 43
1
Nie zrozum mnie źle — bycie częścią tej społeczności było przyjemnością i od zawsze istniały
osoby eksperymentujące z C# dla przyjemności.
87469504f326f0d7c1fcda56ef61bd79
8
44 ROZDZIAŁ 1. Przetrwają najbystrzejsi
.NET poza głównym nurtem prac. Pod niektórymi względami uznawano to za dzia-
łania wymierzone w Microsoft.
W 2010 r. udostępniono menedżer pakietów NuGet (początkowa nazwa to NuPack),
który znacznie ułatwił tworzenie i używanie bibliotek klas — zarówno komercyjnych,
jak i otwartych. Choć proces pobierania pliku .zip, kopiowania pliku DLL w odpowiednie
miejsce i dodawania referencji do tego pliku nie wydaje się trudny, wszelkie kompli-
kacje mogą zniechęcać programistów.
UWAGA. Menedżery pakietów inne niż NuGet pojawiły się jeszcze wcześniej. Duże znaczenie
miał przede wszystkim projekt OpenWrap rozwijany przez Sebastiena Lamblę.
87469504f326f0d7c1fcda56ef61bd79
8
1.4. Ewoluująca książka 45
UWAGA. Analizowanie języka wersja po wersji nie jest najlepszym sposobem na opano-
wanie go od podstaw, jednak metoda ta przydaje się, jeśli chcesz dokładnie go zrozumieć.
Nie zastosowałbym tego samego podejścia w książce dla początkujących użytkowników
języka C#.
87469504f326f0d7c1fcda56ef61bd79
8
46 ROZDZIAŁ 1. Przetrwają najbystrzejsi
Aby dowiedzieć się więcej o tym projekcie i pobrać jego kod źródłowy, odwiedź stronę
https://nodatime.org lub https://github.com/nodatime/nodatime.
1.4.3. Terminologia
W tej książce starałem się możliwie ściśle trzymać oficjalnej terminologii z zakresu
języka C#. Czasem jednak przedkładałem przejrzystość nad precyzję. Na przykład
w kontekście asynchroniczności często piszę o metodach asynchronicznych, gdy te
same informacje dotyczą również asynchronicznych funkcji anonimowych. Podobnie
inicjalizatory obiektów działają zarówno dla dostępnych pól, jak i dla właściwości,
jednak prościej jest wspomnieć o tych pierwszych raz, a w dalszych objaśnieniach pisać
tylko o właściwościach.
Czasem pojęcia ze specyfikacji rzadko są stosowane przez społeczność. Na przy-
kład w specyfikacji występuje określenie składowa w postaci funkcji (ang. function
member). Może to być metoda, właściwość, zdarzenie, indekser, operator zdefiniowany
przez użytkownika, konstruktor instancji, konstruktor statyczny lub finalizator. To
pojęcie oznacza dowolną składową typu, która może zawierać wykonywalny kod. Jest
ono przydatne do opisywania mechanizmów języka. Okazuje się jednak mniej przy-
datne, gdy analizujesz własny kod. Dlatego możliwe, że nigdy wcześniej nie zetknąłeś
się z tym określeniem. Starałem się ograniczyć używanie tego rodzaju pojęć, jednak
moim zdaniem warto się z nimi zaznajomić, aby lepiej poznać język.
Ponadto dla niektórych zagadnień nie istnieje oficjalna terminologia, jednak warto
pisać o nich za pomocą skrótowych nazw. Określeniem tego rodzaju, którego będę
87469504f326f0d7c1fcda56ef61bd79
8
Podsumowanie 47
Podsumowanie
Uwielbiam język C#. Jest zarówno wygodny w użyciu, jak i ekscytujący. Lubię obser-
wować, jak ewoluuje. Mam nadzieję, że w tym rozdziale udało mi się przekazać Ci część
tej ekscytacji. Był to jednak tylko wstęp. Teraz bez dalszego opóźniania przejdźmy do
głównego tematu książki.
2
Przynajmniej uważamy, że był to Eric. On sam nie ma pewności i uważa, że autorem tej nazwy
mógł być Anders Hejlsberg. Ja jednak zawsze będę kojarzył to określenie z Erikiem (z którym
kojarzy mi się również jego klasyfikacja wyjątków: krytyczne, idiotyczne, irytujące i zewnętrzne).
87469504f326f0d7c1fcda56ef61bd79
8
48 ROZDZIAŁ 1. Przetrwają najbystrzejsi
87469504f326f0d7c1fcda56ef61bd79
8
Część 2
C# 2 – 5
87469504f326f0d7c1fcda56ef61bd79
8
Występuje tu jeden wyjątek od reguły prezentowania krótkich omówień — całko-
wicie zmodyfikowałem opis mechanizmu async/await, który jest najważniejszą funkcją
dodaną w C# 5. W rozdziale 5. opisano, co musisz wiedzieć na temat tego mechanizmu,
a rozdział 6. dotyczy jego implementacji. Jeśli jeszcze nie znasz funkcji async/await,
prawie na pewno powinieneś poużywać jej przez pewien czas przed przejściem do
rozdziału 6., a nawet wtedy lektura może okazać się trudna. Starałem się objaśnić ten
mechanizm tak przystępnie, jak potrafię, jednak zagadnienie to jest z natury złożone.
Zachęcam jednak do zmierzenia się z nim. Dogłębne zrozumienie mechanizmu
async/await może podnieść Twoją pewność siebie w zakresie używania go, nawet jeśli
nigdy nie musiałeś zaglądać do generowanego przez kompilator kodu w języku pośred-
nim. Dobra wiadomość jest taka, że po rozdziale 6. czeka Cię chwila wytchnienia
w postaci rozdziału 7. Jest to najkrótszy rozdział tej książki i okazja na odzyskanie sił
przed rozpoczęciem eksplorowania wersji C# 6.
Po tym wprowadzeniu przygotuj się na nawałnicę mechanizmów.
87469504f326f0d7c1fcda56ef61bd79
8
C# 2
Zawartość rozdziału
Używanie typów i metod generycznych w celu
pisania elastycznego i bezpiecznego kodu
Zapisywanie braku informacji za pomocą typów
bezpośrednich przyjmujących wartość null
Stosunkowo proste tworzenie delegatów
Implementowanie iteratorów bez pisania
szablonowego kodu
Jeśli używasz języka C# od wielu lat, ten rozdział będzie przypomnieniem tego, jak
dużo się w nim zmieniło. Powinieneś być za to wdzięczny zaangażowanemu i inteli-
gentnemu zespołowi projektantów tego języka. Jeśli nigdy nie programowałeś w C#
bez używania typów generycznych, może Cię zastanawiać, jak to w ogóle możliwe, że
język ten zdołał zyskać popularność bez tego mechanizmu1. Niezależnie od poziomu
doświadczenia możesz tu natrafić na funkcje, których nie znasz, i szczegóły, nad któ-
rymi nigdy się nie zastanawiałeś.
Minęło ponad 10 lat od czasu wprowadzenia C# 2 (w Visual Studio 2005). Dlatego
trudno może przychodzić Ci ekscytowanie się mechanizmami z dawnych czasów. Nie
należy jednak lekceważyć znaczenia tej wersji w okresie, kiedy ją wprowadzono. Proces
wprowadzania tej wersji był bolesny. Przechodzenie z C# 1 i .NET 1.x na C# 2
1
Dla mnie wyjaśnienie jest proste — według wielu programistów C# 1 w czasie jego wprowadzenia
pozwalał uzyskać wyższą produktywność niż Java.
87469504f326f0d7c1fcda56ef61bd79
8
52 ROZDZIAŁ 2. C# 2
i .NET 2.0 trwało w branży przez długi czas. Późniejsze zmiany były przyjmowane znacz-
nie szybciej. Pierwszy omawiany mechanizm z wersji C# 2 prawie wszyscy progra-
miści uważają za najważniejszy z tej edycji. Są to typy generyczne.
87469504f326f0d7c1fcda56ef61bd79
8
2.1. Typy generyczne 53
UWAGA. Tablice typów referencyjnych umożliwiają tylko ogólnie bezpieczny zapis wartości.
Powodem jest kowariancja tablic. Traktuję ją jako wczesny błąd projektowy, którego omawianie
wykracza poza zakres tej książki. Eric Lippert opisał tę kwestię na stronie http://mng.bz/gYPv
w ramach serii artykułów poświęconych kowariancji i kontrawariancji.
87469504f326f0d7c1fcda56ef61bd79
8
54 ROZDZIAŁ 2. C# 2
names.Add("Vlissides");
names.Add("Johnson");
names.Add("Helm");
return names;
}
Metoda GenerateNames jest tu bardziej przejrzysta. Nie trzeba znać liczby nazwisk przed
rozpoczęciem dodawania ich do kolekcji. Nic jednak nie chroni przed zapisaniem w niej
wartości innej niż łańcuch znaków. Typ parametru metody ArrayList.Add to Object.
Ponadto choć metoda PrintNames wygląda bezpiecznie, jeśli chodzi o typy, nie jest
taka. Ta kolekcja może zawierać dowolnego rodzaju referencje. Jak myślisz, co się
stanie, jeśli dodasz do tej kolekcji wartość zupełnie innego typu (np. niepasujący tu
WebRequest), a następnie spróbujesz ją wyświetlić? Pętla foreach ukrywa niejawne rzu-
towanie wartości z typu object na string (typ zmiennej name). To rzutowanie zakończy
się niepowodzeniem i zgłoszeniem wyjątku InvalidCastException. Tak więc rozwiązałeś
jeden problem, ale spowodowałeś inny. Czy istnieje rozwiązanie obu opisanych proble-
mów? Przyjrzyj się listingowi 2.3.
Listing 2.3 jest identyczny z listingiem 2.2, jednak typ ArrayList wszędzie zastąpiono
typem StringCollection. Typ StringCollection na działać jak wygodna kolekcja do
ogólnego użytku, ale ponadto ma być wyspecjalizowany do obsługi samych łańcuchów
znaków. Typ parametru metody StringCollection.Add to String. Dlatego nie jest moż-
liwe, że z powodu jakiegoś dziwnego błędu w kodzie do kolekcji dodana zostanie war-
tość typu WebRequest. Dzięki temu w trakcie wyświetlania nazwisk masz pewność, że
87469504f326f0d7c1fcda56ef61bd79
8
2.1. Typy generyczne 55
pętla foreach nie natrafi na referencje do wartości innych niż łańcuch znaków. (Przy-
znaję jednak, że może pojawić się referencja null).
To rozwiązanie jest świetne, jeśli zawsze potrzebujesz tylko łańcuchów znaków.
Jeżeli jednak potrzebna jest kolekcja wartości innego typu, musisz mieć nadzieję, że
w platformie istnieje odpowiednia kolekcja, lub samodzielnie napisać odpowiedni typ.
To ostatnie zadanie było wykonywane tak często, że udostępniono klasę abstrakcyjną
System.Collections.CollectionBase, aby zadanie było trochę mniej żmudne. Istnieją
też generatory kodu, dzięki którym nie trzeba pisać całego kodu ręcznie.
To podejście rozwiązuje oba problemy z poprzednich technik, jednak koszty two-
rzenia wielu dodatkowych typów są zdecydowanie zbyt wysokie. Chodzi tu o koszty
konserwacji, aby program był aktualny po zmianie w generatorze kodu. Istotne są też
koszty wydajności związane z czasem kompilacji, wielkością podzespołów, czasem
kompilacji JIT i utrzymywaniem kodu w pamięci. Najważniejsze jednak są koszty pracy
ludzi, którzy muszą pamiętać o wszystkich dostępnych klasach kolekcji.
Nawet gdyby te koszty nie były zbyt wysokie, brakowałoby możliwości napisania
metody działającej dla dowolnego typu z zachowaniem typowania statycznego, aby
można było np. użyć typu elementów kolekcji w innym parametrze lub jako typu zwra-
canej wartości. Załóżmy, że chcesz napisać metodę tworzącą kopię pierwszych N ele-
mentów kolekcji i zapisującą je w nowej, zwracanej kolekcji. Możesz napisać metodę
zwracającą kolekcję typu ArrayList, ale tracisz wtedy korzyści płynące z typowania
statycznego. Jeśli przekażesz wartość typu StringCollection, oczekujesz, że zwrócona
zostanie wartość tego samego typu. Używanie łańcuchów znaków jest jednym z aspek-
tów danych wejściowych dla metody; aspekt ten należy następnie uwzględnić w danych
wyjściowych. W C# 1 nie było możliwości zapisania tego. Pora przywitać się z typami
generycznymi.
2
Celowo pomijam możliwość użycia interfejsów dla parametrów i typu zwracanej wartości. Jest to
ciekawe zagadnienie, nie chcę jednak odciągać uwagi od typów generycznych.
87469504f326f0d7c1fcda56ef61bd79
8
56 ROZDZIAŁ 2. C# 2
Typy generyczne rozwiązują też problem podawania typu elementu jako danych wej-
ściowych metody. Aby dokładnie opisać to zagadnienie, potrzebna jest dodatkowa ter-
minologia.
PARAMETRY I ARGUMENTY OKREŚLAJĄCE TYP
Pojęcia parametr oraz argument są starsze niż typy generyczne języka C# i były sto-
sowane w innych językach od dziesięcioleci. W metodzie dane wejściowe są deklaro-
wane jako parametry, a podaje się je w wywołaniach jako argumenty. Na rysunku 2.1
pokazano, jak te dwa pojęcia są powiązane ze sobą:
87469504f326f0d7c1fcda56ef61bd79
8
2.1. Typy generyczne 57
87469504f326f0d7c1fcda56ef61bd79
8
58 ROZDZIAŁ 2. C# 2
Gdy deklarujesz typy o różnej arności parametrów generycznych, typy te nie muszą
być tego samego rodzaju (choć zwykle są). Oto skrajny przykład. Przyjrzyj się poniższym
deklaracjom typów, które wszystkie mogą występować w tym samym trudnym do
zrozumienia podzespole:
public enum IAmConfusing {}
public class IAmConfusing<T> {}
public struct IAmConfusing<T1, T2> {}
public delegate void IAmConfusing<T1, T2, T3> {}
public interface IAmConfusing<T1, T2, T3, T4> {}
3
Wprawdzie dozwolone jest pisanie metod generycznych, w których parametr określający typ nie jest
używany w żadnym miejscu sygnatury, ale taka technika rzadko jest przydatna.
87469504f326f0d7c1fcda56ef61bd79
8
2.1. Typy generyczne 59
Choć gorąco odradzam pisanie tego rodzaju kodu, dość często stosowanym wzorcem
jest tworzenie niegenerycznej klasy statycznej, która udostępnia metody pomocnicze
używające innych typów generycznych o tej samej nazwie co nazwa tej klasy (więcej
o klasach statycznych dowiesz się z punktu 2.5.2). W punkcie 2.1.4 opisano klasę Tuple,
która służy do tworzenia instancji różnych generycznych klas Tuple.
Tak więc wiele typów może mieć tę samą nazwę, ale inną arność parametrów gene-
rycznych. Tak samo jest z metodami generycznymi. Technika ta przypomina tworzenie
przeciążonych wersji metody z użyciem różnych parametrów, przy czym tu przecią-
żanie odbywa się na podstawie liczby parametrów określających typ. Warto zauważyć,
że choć arność parametrów generycznych pozwala tworzyć różne deklaracje, nazwy tych
parametrów tego nie umożliwiają. Nie można np. zadeklarować dwóch metod w poka-
zany poniżej sposób:
public void Method<TFirst>() {} Błąd kompilacji. Nie można przeciążać metod wyłącznie
public void Method<TSecond>() {} na podstawie nazw parametrów określających typ.
87469504f326f0d7c1fcda56ef61bd79
8
60 ROZDZIAŁ 2. C# 2
pola,
właściwości,
indeksery,
konstruktory,
zdarzenia,
finalizatory.
Oto przykładowa klasa generyczna pokazująca, że może się wydawać, iż pole jest gene-
ryczne, choć w rzeczywistości jest inaczej:
public class ValidatingList<TItem>
{
private readonly List<TItem> items = new List<TItem>(); Wiele innych składowych.
}
Dalej, w metodzie Main, zadeklarowana jest zmienna typu List<int>, używana później
jako argument wspomnianej metody:
List<int> numbers = new List<int>();
...
List<int> firstTwo = CopyAtMost<int>(numbers, 2);
87469504f326f0d7c1fcda56ef61bd79
8
2.1. Typy generyczne 61
Kompilator w obu przypadkach wygeneruje dokładnie ten sam kod pośredni. W drugim
wywołaniu nie trzeba jednak podawać argumentu określającego typ (int). Kompilator
wywnioskował ten typ. Zrobił to na podstawie argumentu pierwszego parametru
metody. Wartością parametru typu List<T> jest argument typu List<int>, dlatego T
musi być typem int.
We wnioskowaniu typów uwzględniane są tylko argumenty przekazane do metody,
a nie sposób używania wyniku. Ponadto technika ta jest stosowana zerojedynkowo —
albo bezpośrednio podajesz wszystkie argumenty określające typ, albo nie używasz żad-
nego z nich.
Choć wnioskowanie typów dotyczy tylko metod, można je wykorzystać do łatwiejszego
tworzenia instancji typów generycznych. Rozważ np. rodzinę typów Tuple wprowa-
dzoną w .NET 4.0. Obejmuje ona niegeneryczną statyczną klasę Tuple i zestaw klas
generycznych: Tuple<T1>, Tuple<T1, T2>, Tuple<T1, T2, T3> itd. Wspomniana klasa
statyczna obejmuje zestaw przeciążonych metod fabrycznych Create:
public static Tuple<T1> Create<T1>(T1 item1)
{
return new Tuple<T1>(item1);
}
Jest to wartościowa technika, którą warto znać. Zwykle można ją łatwo wykorzystać
i czasem sprawia, że praca z kodem generycznym staje się dużo łatwiejsza.
Nie zamierzam szczegółowo objaśniać wnioskowania typów generycznych. Mecha-
nizm ten zmieniał się, ponieważ projektanci języka znajdowali sposoby na zastosowanie
go w większej liczbie sytuacji. Wybór przeciążonej wersji i wnioskowanie typów są ze
sobą ściśle powiązane, a także łączą się z rozmaitymi innymi mechanizmami (takimi
jak dziedziczenie, konwersje i parametry opcjonalne z C# 4). Uważam, że jest to
najbardziej skomplikowany obszar w specyfikacji4 i nie mógłbym go tu wystarczająco
szczegółowo przedstawić.
4
Nie jestem w tym odosobniony. W czasie, gdy powstaje ta książka, specyfikacja procesu wyboru
przeciążonej wersji jest błędna. Próby poprawienia jej na potrzeby standardu C# 5 ECMA zakoń-
czyły się niepowodzeniem. Spróbujemy ponownie w następnym wydaniu.
87469504f326f0d7c1fcda56ef61bd79
8
62 ROZDZIAŁ 2. C# 2
87469504f326f0d7c1fcda56ef61bd79
8
2.1. Typy generyczne 63
Jednak to rozwiązanie prawie nigdy nie będzie przydatne. Nie możesz wtedy przekazać
np. argumentu List<decimal>, choć w typie decimal zaimplementowany jest interfejs
IFormattable. Wynika to z tego, że nie można dokonać konwersji kolekcji List<decimal>
na kolekcję List<IFormattable>.
Celem jest zapisanie, że parametr jest listą elementów jakiegoś typu, przy czym ten typ
musi implementować interfejs IFormattable. Człon „elementów jakiegoś typu” wskazuje
na to, że potrzebna będzie metoda generyczna. Z kolei fragment „przy czym ten typ
musi implementować interfejs IFormattable” dotyczy możliwości, jaką dają ograni-
czenia typów. Wymagają one dodania klauzuli where na końcu deklaracji metody:
static void PrintItems<T>(List<T> items) where T : IFormattable
Bez ograniczenia typów to wywołanie metody ToString nie skompiluje się. Jedyną
metodą ToString, jaką kompilator znałby dla typu T w takiej sytuacji, byłaby metoda
zadeklarowana w klasie System.Object.
Ograniczenia typów dotyczą nie tylko interfejsów. Dostępne są następujące ogra-
niczenia typów:
Ograniczenie wymuszające podanie typów referencyjnych — where T : class.
Argument określający typ musi być wtedy typem referencyjnym. Niech Cię nie
zmyli słowo kluczowe class. Można tu zastosować dowolny typ referencyjny, w tym
interfejsy i delegaty.
Ograniczenia wymuszające podanie typów bezpośrednich — where T : struct.
Argument określający typ musi być typem bezpośrednim nieprzyjmującym
wartości null (czyli strukturą lub wyliczeniem). Typy bezpośrednie przyjmujące
wartości null (opisane w podrozdziale 2.2) nie są zgodne z tym ograniczeniem.
87469504f326f0d7c1fcda56ef61bd79
8
64 ROZDZIAŁ 2. C# 2
W tym ograniczeniu typ T jest używany jako argument określający typ w generycznym
interfejsie IComparable<T>. Dzięki temu metoda sortująca może porównywać parami
elementy z listy items, używając metody CompareTo z interfejsu IComparable<T>:
T first = ...;
T second = ...;
int comparison = first.CompareTo(second);
To już prawie koniec tego błyskawicznego przeglądu typów generycznych. Chcę jed-
nak opisać jeszcze kilka zagadnień. Zacznę od dwóch dostępnych w C# 2 operatorów
związanych z typami.
87469504f326f0d7c1fcda56ef61bd79
8
2.1. Typy generyczne 65
cych wartości null jest to wartość „same zera” (0, 0.0, 0.0m, false, jednostka kodowa
UTF-16 o wartości liczbowej 0 itd.). Dla typów bezpośrednich przyjmujących wartość
null jest to wartość null właściwa dla danego typu.
Operator default można stosować do parametrów określających typ i typów gene-
rycznych z podanymi argumentami określającymi typ (tymi argumentami też mogą
być parametry określające typ). Na przykład w metodzie generycznej z parametrem
określającym typ T poprawne są wszystkie poniższe wywołania:
default(T)
default(int)
default(string)
default(List<T>)
default(List<List<string>>)
Typem używanym w operatorze default jest typ podany w nawiasie. Operator ten
najczęściej jest stosowany do parametrów określających typ, ponieważ w innych sytu-
acjach zwykle można uzyskać wartość domyślną w odmienny sposób. Załóżmy, że
chcesz użyć wartości domyślnej jako początkowej wartości zmiennej lokalnej, która może
później otrzymać inną wartość, ale nie jest to pewne. Aby omówienie było konkretne,
poniżej pokazano prostą implementację metody, którą może już znasz:
public T LastOrDefault<T>(IEnumerable<T> source)
{ Deklaracja zmiennej lokalnej i przypisanie do niej
T ret = default(T); wartości domyślnej z typu T.
foreach (T item in source)
{ Zastępowanie wartości zmiennej lokalnej wartością
ret = item; bieżącego elementu z sekwencji.
}
return ret; Zwracanie ostatniej przypisanej wartości.
}
Operator typeof jest bardziej złożony. Oto cztery ogólne sytuacje, które trzeba
uwzględnić:
typy generyczne w ogóle nie występują (np. typeof(string)),
występują typy generyczne, ale bez parametrów określających typ (np. typeof
(List<int>)),
używany jest tylko parametr określający typ (np. typeof(T)),
używane są typy generyczne z parametrem określającym typ jako operandem
(np. typeof(List<TItem>) w metodzie generycznej, w której zadeklarowany jest
parametr określający typ TItem),
używane są typy generyczne, ale w operandzie nie ma podanego argumentu
określającego typ (np. typeof(List<>)).
Pierwszy przypadek jest prosty; zwracany jest wtedy podany typ. Wszystkie pozostałe
scenariusze wymagają nieco uwagi, a w ostatnim dostępna jest nowa składnia. Ope-
rator typeof w każdej sytuacji zwraca wartość typu Type, co jednak powinien zwracać
w każdym z opisanych przypadków? Klasę Type wzbogacono o obsługę typów generycz-
nych. Konieczne jest uwzględnienie wielu sytuacji. Oto kilka przykładów:
87469504f326f0d7c1fcda56ef61bd79
8
66 ROZDZIAŁ 2. C# 2
87469504f326f0d7c1fcda56ef61bd79
8
2.1. Typy generyczne 67
Ważne jest to, że jeśli wywołasz metodę w kontekście, gdzie argument określający typ
T ma wartość string (pierwsze wywołanie), wynik wywołania typeof(T) jest taki sam jak
wywołania typeof(string). Podobnie wynik wywołania typeof(List<T>) jest identyczny
jak wywołania typeof(List<string>). Gdy ponownie wywołasz metodę, kiedy argument
określający typ to int, otrzymasz ten sam wynik dla wywołań typeof(int) i typeof
(List<int>). Jeśli kod jest wykonywany w generycznym typie lub generycznej meto-
dzie, parametr określający typ zawsze reprezentuje zamknięty skonstruowany typ.
Z danych wyjściowych warto też zapamiętać format nazwy generycznego typu
używany, gdy stosowana jest refleksja. List`1 oznacza typ generyczny List o gene-
rycznej arności równej 1 (z jednym parametrem określającym typ). Dalej, w nawiasie
kwadratowym, podane są argumenty określające typ.
Ostatni punkt na wcześniejszej liście to typeof(List<>). Argument określający typ
w ogóle nie jest tu podany. Ta składnia jest dozwolona tylko w operatorze typeof
i dotyczy definicji typu generycznego. Składnia dla typów o arności generycznej rów-
nej 1 to NazwaTypu<>. Każdy dodatkowy parametr określający typ wymaga dodania prze-
cinka w nawiasie ostrym. Aby otrzymać definicję typu generycznego Dictionary<TKey,
TValue>, zastosuj wywołanie typeof(Dictionary<,>). Jeśli chcesz uzyskać definicję typu
Tuple<T1, T2, T3>, użyj wywołania typeof(Tuple<,,>).
Ostatnie z omawianych tu zagadnień wymaga zrozumienia różnicy między definicją
typu generycznego i zamkniętymi skonstruowanymi typami. Ważne jest to, jak typy
są inicjowane i jak obsługiwany jest stan na poziomie typu (stan statyczny).
class GenericCounter<T>
{
private static int value; Jedno pole dla każdego zamkniętego skonstruowanego typu.
static GenericCounter()
{
Console.WriteLine("Inicjowanie licznika dla typu {0}", typeof(T));
}
87469504f326f0d7c1fcda56ef61bd79
8
68 ROZDZIAŁ 2. C# 2
class GenericCounterDemo
{
static void Main()
{
GenericCounter<string>.Increment(); Inicjowanie licznika dla typu
GenericCounter<string>.Increment(); GenericCounter<string>.
GenericCounter<string>.Display();
GenericCounter<int>.Display(); Inicjowanie licznika dla typu GenericCounter<int>.
GenericCounter<int>.Increment();
GenericCounter<int>.Display();
}
}
W tych danych wyjściowych warto skupić się na dwóch rzeczach. Po pierwsze, wartość
licznika GenericCounter<string> jest niezależna od wartości licznika GenericCounter<int>.
Po drugie, konstruktor statyczny jest uruchamiany dwukrotnie: raz dla każdego zamknię-
tego skonstruowanego typu. Gdyby nie użyto tu konstruktora statycznego, trudno
byłoby precyzyjnie określić moment inicjowania każdego z tych typów. Typy Generic
Counter<string> i GenericCounter<int> możesz jednak traktować jako niezależne od
siebie.
Dodatkową komplikacją jest to, że typy generyczne można zagnieżdżać w innych
typach generycznych. W takiej sytuacji tworzony jest odrębny typ dla każdej kombinacji
argumentów określających typ. Przyjrzyj się np. następującej klasie:
class Outer<TOuter>
{
class Inner<TInner>
{
static int value;
}
}
Jeśli jako argumentów określających typ użyjesz typów int i string, wymienione niżej
typy będą niezależne od siebie i każdy z nich będzie miał własne pole value:
87469504f326f0d7c1fcda56ef61bd79
8
2.2. Typy bezpośrednie przyjmujące wartość null 69
Outer<string>.Inner<string>
Outer<string>.Inner<int>
Outer<int>.Inner<string>
Outer<int>.Inner<int>
Takie zagnieżdżone typy występują rzadko i łatwo można sobie z nimi radzić, jeśli
wiadomo, że ważny jest typ z pełną specyfikacją, obejmujący wszystkie argumenty
określające typ zarówno w wewnętrznym, jak i zewnętrznym typie generycznym.
To tyle na temat typów generycznych. Były one zdecydowanie najważniejszym
mechanizmem wprowadzonym w C# 2 i znacznym usprawnieniem w porównaniu
z C# 1. Następnym tematem są typy bezpośrednie przyjmujące wartość null. Są one
oparte na typach generycznych.
87469504f326f0d7c1fcda56ef61bd79
8
70 ROZDZIAŁ 2. C# 2
Żadna z tych technik nie jest doskonała. Pierwsze podejście zmniejsza zestaw popraw-
nych wartości (w typie decimal nie powoduje to kłopotów, może jednak być problemem
w typie byte, gdzie bardziej prawdopodobne jest, że potrzebne będą wszystkie warto-
ści). Drugie podejście prowadzi do powstawania żmudnego i powtarzającego się kodu.
Ważniejsze jest jednak to, że oba podejścia są narażone na błędy. Oba wymagają
wykonywania testów przed użyciem wartości, która może być poprawna lub niepra-
widłowa. Jeśli pominiesz sprawdzanie, kod może użyć niepoprawnych danych. Nie-
zauważalnie wykona błędne operacje i możliwe, że przekaże błędy do innych części
systemu. Niezauważalne awarie są najgorsze, ponieważ często trudno jest je wykryć
i cofnąć błędy. Wolę solidne „hałaśliwe” wyjątki, które zatrzymują działanie nieprawi-
dłowego kodu.
W typach bezpośrednich przyjmujących wartość null używane jest (z wykorzysta-
niem hermetyzacji) drugie z tych podejść. Razem z wartością przechowywana jest opcja
informująca, czy daną wartość należy stosować. Kluczem jest tu hermetyzacja. Najprost-
szy sposób używania wartości jest bezpieczny, ponieważ jeśli spróbujesz zastosować
ją w błędny sposób, wystąpi wyjątek. Spójne używanie jednego typu do reprezento-
wania potencjalnie brakujących wartości pozwala uprościć korzystanie z języka,
a autorzy bibliotek uzyskują idiomatyczny sposób reprezentowania takich wartości
w interfejsach API.
Po tym teoretycznym wprowadzeniu pora zobaczyć, co platforma i środowisko CLR
udostępniają w obszarze typów bezpośrednich przyjmujących wartość null. Po tych
podstawach zaprezentowane są dodatkowe mechanizmy wprowadzone w C#, aby uła-
twić korzystanie z takich typów.
87469504f326f0d7c1fcda56ef61bd79
8
2.2. Typy bezpośrednie przyjmujące wartość null 71
public T Value
{
get
{
if (!hasValue)
{ Dostęp do wartości; zgłaszanie
throw new InvalidOperationException(); wyjątku, jeśli jest niedostępna.
}
return value;
}
}
}
Ograniczenie where T : struct dla typu Nullable<T> oznacza, że T może być dowolnego
typu bezpośredniego oprócz innego typu Nullable<T>. Technika ta działa dla typów
podstawowych, wyliczeń, struktur systemowych i struktur zdefiniowanych przez użyt-
kownika. Wszystkie poniższe deklaracje są poprawne:
Nullable<int>
Nullable<FileMode>
Nullable<Guid>
Nullable<LocalDate> (z biblioteki Noda Time).
87469504f326f0d7c1fcda56ef61bd79
8
72 ROZDZIAŁ 2. C# 2
Typ T jest nazywany typem właściwym dla Nullable<T>. Na przykład typem właściwym
dla typu Nullable<int> jest int.
Już sam ten mechanizm — bez dodatkowej obsługi ze strony środowiska CLR,
platformy i języka — pozwala bezpiecznie używać opisanego typu do wyświetlania
filtra ceny maksymalnej:
public void DisplayMaxPrice(Nullable<decimal> maxPriceFilter)
{
if (maxPriceFilter.HasValue)
{
Console.WriteLine("Cena maksymalna: {0}", maxPriceFilter.Value);
}
else
{
Console.WriteLine("Nie ustawiono ceny maksymalnej.");
}
}
Jest to poprawnie działający kod, który sprawdza wartość przed jej użyciem. Co jednak
ze źle napisanym kodem, gdzie sprawdzanie w ogóle jest pominięte lub dotyczy nie-
właściwych danych? Nie da się przypadkowo użyć błędnej wartości. Jeśli spróbujesz
uzyskać dostęp do właściwości maxPriceFilter.Value, a właściwość HasValue ma wartość
false, zgłoszony zostanie wyjątek.
UWAGA. Wiem, że pisałem już o tej kwestii, uważam jednak, że jest na tyle ważna, iż warto
to powtórzyć: postęp wynika nie tylko z ułatwiania pisania poprawnego kodu, ale też z utrud-
niania pisania błędnego kodu lub zmniejszania konsekwencji błędów.
87469504f326f0d7c1fcda56ef61bd79
8
2.2. Typy bezpośrednie przyjmujące wartość null 73
jeśli właściwość HasValue jest równa false, wynikiem jest referencja null,
jeżeli HasValue to true, wynikiem jest referencja do obiektu typu „opakowany
typ T”.
Jeśli programista wie, jak działa ten mechanizm, prawie zawsze potrafi go odpowiednio
zastosować. Występuje tu jednak dziwny efekt uboczny. Metoda GetType() z typu
System.Object nie jest wirtualna, a dość skomplikowane reguły pakowania powodują, że
gdy metoda ta jest wywoływana dla wartości typu bezpośredniego, wartość tę trzeba
zawsze najpierw opakować. Zwykle jest to nieco niewydajne, ale nie powoduje żadnych
problemów. Jednak gdy używane są typy bezpośrednie przyjmujące wartość null, ten
mechanizm albo powoduje wyjątek NullReferenceException, albo zwraca właściwy typ
bezpośredni nieprzyjmujący wartości null. Ilustruje to listing 2.10.
87469504f326f0d7c1fcda56ef61bd79
8
74 ROZDZIAŁ 2. C# 2
Listing 2.10. Wywołanie metody GetType dla typu przyjmującego wartość null
prowadzi do zaskakujących skutków
LITERAŁ NULL
W C# 1 wyrażenie null zawsze oznaczało referencję null. W C# 2 wyrażenie to może
też oznaczać wartość null; jest to więc albo referencja null, albo wartość typu bezpo-
średniego przyjmującego wartość null, gdzie właściwość HasValue jest równa false.
To wyrażenie można stosować w przypisaniach, jako argument metody, w porówna-
niach — w wielu miejscach. Należy zauważyć, że gdy wyrażenie null jest używane
do typu bezpośredniego przyjmującego wartość null, reprezentuje wartość, dla której
87469504f326f0d7c1fcda56ef61bd79
8
2.2. Typy bezpośrednie przyjmujące wartość null 75
właściwość HasValue to false (nie reprezentuje referencji null). Jeśli będziesz próbo-
wał włączyć referencje null do umysłowego modelu działania typów bezpośrednich
przyjmujących wartość null, szybko się pogubisz. Dwa poniższe wiersze oznaczają to
samo:
int? x = new int?();
int? x = null;
Zwykle wolę stosować literał null zamiast bezpośrednio wywoływać konstruktor bez-
parametrowy (czyli preferuję drugi z podanych wierszy). Jednak w porównaniach nie
mam wyraźnych preferencji. Na przykład dwa poniższe wiersze działają tak samo:
if (x != null)
if (x.HasValue)
87469504f326f0d7c1fcda56ef61bd79
8
76 ROZDZIAŁ 2. C# 2
5
Operatory równości i relacyjne też są dwuargumentowe, jednak działają inaczej od pozostałych,
dlatego wymieniono je osobno.
87469504f326f0d7c1fcda56ef61bd79
8
2.2. Typy bezpośrednie przyjmujące wartość null 77
wbudowanych operatorów (a liczby całkowite łatwo jest zapisać), jest to naturalny typ dla
przykładów. W tabeli 2.1 przedstawiono zestaw wyrażeń, sygnaturę przeniesionego
operatora i wynik. Przyjmij, że dostępne są zmienne four, five i nullInt. Każda z nich
jest typu Nullable<int> i ma wartość zgodną z nazwą.
Tabela 2.1. Przykłady zastosowania przeniesionych operatorów do liczb całkowitych przyjmujących
wartość null
87469504f326f0d7c1fcda56ef61bd79
8
78 ROZDZIAŁ 2. C# 2
Jeśli analizowanie reguł jest dla Ciebie łatwiejsze niż wyszukiwanie wartości w tabli-
cach, pamiętaj, że wartość null typu bool? w pewnym sensie oznacza „być może”.
Wyobraź sobie, że każda wartość null w danych wejściowych w tablicy prawdy jest
zmienną. Wtedy w danych wyjściowych zawsze otrzymasz wartość null, ponieważ wynik
zależy od wartości tej zmiennej. Na przykład w trzecim wierszu tablicy wyrażenie true
& y ma wartość true tylko wtedy, gdy y to true. Z kolei wyrażenie true | y jest równe true
niezależnie od wartości y. Dlatego wyniki dla typu przyjmującego wartość null to dla
tych wyrażeń null i true.
W trakcie pracy nad operatorami przeniesionymi, a przede wszystkim logiką dla
typów przyjmujących wartość true, projektanci języka uwzględniali dwa nieco sprzeczne
mechanizmy: referencje null z C# 1 i wartości NULL z języka SQL. W wielu sytu-
acjach nie powodują one konfliktów. W C# 1 nie było uwzględniane stosowanie ope-
ratorów logicznych do referencji null, dlatego nie było problemu z zastosowaniem
przedstawionych wcześniej wyników typowych dla SQL-a. Jednak jeśli chodzi o porów-
nania, opisane definicje mogą zaskoczyć niektórych programistów języka SQL. Jeśli
któraś z porównywanych wartości to NULL, w standardowym SQL-u efekt porównania
tych wartości (sprawdzanie równości, większości lub mniejszości) jest zawsze nieznany.
W C# 2 wynik takich operacji nigdy nie jest równy null, a dwie wartości null są uzna-
wane za równe.
87469504f326f0d7c1fcda56ef61bd79
8
2.2. Typy bezpośrednie przyjmujące wartość null 79
Dla typów bezpośrednich przyjmujących wartość null dostępny jest obecnie także inny
popularny operator. Jego działanie zapewne nie zaskoczy Cię, jeśli uwzględnisz swoją
obecną wiedzę o referencjach null i dostosujesz ją do wartości null.
OPERATOR AS I TYPY BEZPOŚREDNIE PRZYJMUJĄCE WARTOŚĆ NULL
Do wersji C# 2 operator as był dostępny tylko dla typów referencyjnych. W C# 2
można go stosować także do typów bezpośrednich przyjmujących wartość null. Wyni-
kiem jest wartość danego typu bezpośredniego. Jest to wartość null, jeśli pierwotna
referencja była niewłaściwego typu lub miała wartość null. W innych sytuacjach wyni-
kiem jest sensowna wartość. Oto krótki przykład:
static void PrintValueAsInt32(object o)
{
int? nullable = o as int?;
Console.WriteLine(nullable.HasValue ?
nullable.Value.ToString() : "null");
}
...
PrintValueAsInt32(5); Wyświetla 5.
PrintValueAsInt32("jakiś łańcuch znaków"); Wyświetla null.
UWAGA. Działanie operatora as dla typów przyjmujących wartość null jest zaskakująco
powolne. W większości kodu ma to niewielkie znaczenie (zadanie to nie trwa długo np.
w porównaniu z dowolnymi operacjami wejścia – wyjścia), jednak operator ten jest wolniejszy
niż operator is w połączeniu z późniejszym rzutowaniem. Dotyczy to wszystkich wypróbowa-
nych przeze mnie kombinacji platformy i kompilatora.
87469504f326f0d7c1fcda56ef61bd79
8
80 ROZDZIAŁ 2. C# 2
87469504f326f0d7c1fcda56ef61bd79
8
2.3. Uproszczone tworzenie delegatów 81
W C# 2 wprowadzono konwersje grup metod jako rodzaj skrótu. Grupę metod można
niejawnie przekształcić na dowolny typ delegata, używając sygnatury zgodnej z jedną
z przeciążonych wersji danej metody. Zgodność jest opisana dokładnie w punkcie
2.3.3, jednak na razie przyjrzyj się metodom pasującym do sygnatury delegata, na który
chcesz przekształcić metody.
W kodzie używającym typu EventHandler w C# 2 można uprościć tworzenie delegata:
EventHandler handler = HandleButtonClick;
Generowany jest wtedy taki sam kod jak dla wyrażenia tworzącego delegat, przy
czym nowa składnia jest dużo bardziej zwięzła. Obecnie w idiomatycznym kodzie rzadko
występują instrukcje tworzące delegaty. Konwersje grup metod pozwalają skrócić kod
do tworzenia instancji delegata, jednak metody anonimowe oferują znacznie więcej zalet.
6
Sygnatura typu EventHandler to public delegate void EventHandler(object sender, EventArgs e).
87469504f326f0d7c1fcda56ef61bd79
8
82 ROZDZIAŁ 2. C# 2
Ten kod nie wywołuje natychmiast metody Console.WriteLine, a zamiast tego tworzy
delegat, który po uruchomieniu wywołuje tę metodę. Aby sprawdzić typ nadawcy
i argumenty zdarzenia, potrzebne są odpowiednie parametry:
EventHandler handler = delegate(object sender, EventArgs args)
{
Console.WriteLine("Zgłoszono zdarzenie. sender={0}; args={1}",
sender.GetType(), args.GetType());
};
Prawdziwa wartość metod anonimowych staje się widoczna, gdy są używane jako
domknięcie. W domknięciu dostępne są wszystkie zmienne pozostające w zasięgu
w miejscu jego deklaracji — nawet gdyby te zmienne normalnie nie były dostępne
w momencie uruchomienia delegata. Domknięcia są opisane szczegółowo (wraz ze
sposobem traktowania ich przez kompilator) w omówieniu wyrażeń lambda. Na razie
przyjrzyj się krótkiemu przykładowi. Widoczna jest tu metoda AddClickLogger, która
dodaje do dowolnej kontrolki metodę obsługi zdarzeń Click z niestandardowym komu-
nikatem przekazanym do metody AddClickLogger:
void AddClickLogger(Control control, string message)
{
control.Click += delegate
{
Console.WriteLine("Kontrolka została kliknięta: {0}", message);
}
}
Tu zmienna message to parametr metody, jest jednak używana przez metodę anoni-
mową. Metoda AddClickLogger sama nie uruchamia metody obsługi zdarzeń, a jedynie
wiąże tę ostatnią ze zdarzeniem Click. W momencie wykonywania kodu metody ano-
nimowej metoda AddClickLogger zwróciła już sterowanie. Jak to możliwe, że parametr
wciąż istnieje? Za wszystko odpowiada kompilator, abyś nie musiał pisać nudnego
kodu. W punkcie 3.5.2 znajdziesz więcej informacji. Opisano tam przechwytywanie
zmiennych w wyrażeniach lambda. W typie EventHandler nie ma tu nic wyjątkowego.
7
Nie trzeba jej tworzyć w kodzie źródłowym — w kodzie pośrednim metoda nadal występuje.
87469504f326f0d7c1fcda56ef61bd79
8
2.3. Uproszczone tworzenie delegatów 83
Jest to znany typ delegata, który od zawsze jest częścią platformy. Na zakończenie
błyskawicznego przeglądu usprawnień delegatów w C# 2 wróćmy do kwestii zgod-
ności, wspomnianej w kontekście konwersji grup metod.
Teraz wyobraź sobie, że chcesz utworzyć instancję delegata Printer, aby opakować
metodę PrintAnything. Wydaje się, że powinno to być dozwolone. Typ Printer zawsze
otrzyma referencję do wartości typu string, którą można przekształcić na referencję
do typu object dzięki konwersji tożsamościowej. C# 1 nie pozwala jednak na takie
rozwiązanie, ponieważ typy parametrów nie pasują do siebie. W C# 2 można zasto-
sować poniższy kod do tworzenia delegatów i do konwersji grup metod:
Printer p1 = new Printer(PrintAnything);
Printer p2 = PrintAnything;
Możesz też utworzyć jeden delegat, aby opakować inny o zgodnej sygnaturze. Załóżmy,
że używasz drugiego typu delegata zgodnego z metodą PrintAnything:
public delegate void GeneralPrinter(object obj);
Jeśli masz już delegata typu GeneralPrinter, możesz go użyć do utworzenia instancji
typu Printer:
Instancję delegata GeneralPrinter można utworzyć
GeneralPrinter generalPrinter = ...; w dowolny sposób.
Printer printer = new Printer(generalPrinter); Tworzenie delegata typu Printer
opakowującego delegata typu
GeneralPrinter.
87469504f326f0d7c1fcda56ef61bd79
8
84 ROZDZIAŁ 2. C# 2
Także ta operacja jest bezpieczna, ponieważ dowolna wartość, jaką zwróci delegat
StringProvider, jest też dozwoloną wartością zwracaną przez delegat ObjectProvider.
Jednak ten mechanizm nie zawsze działa w oczekiwany sposób. Zgodność między
różnymi parametrami lub typami zwracanych wartości musi być oparta na konwersji
tożsamościowej, która nie zmienia reprezentacji wartości w czasie wykonywania kodu.
Na przykład poniższy kod się nie skompiluje:
public delegate void Int32Printer(int x);
public delegate void Int64Printer(long x); Delegaty przyjmujące 32- i 64-bitowe liczby całkowite.
Instancję delegata Int64Printer można utworzyć
Int64Printer int64Printer = ...; w dowolny sposób.
Int32Printer int32Printer = Błąd! Nie można opakować instancji delegata Int64Printer
new Int32Printer(int64Printer); w instancję delegata Int32Printer.
Dwie pokazane tu sygnatury delegatów nie są ze sobą zgodne. Choć możliwa jest nie-
jawna konwersja z typu int na long, nie jest to konwersja tożsamościowa. Kompilator
mógłby automatycznie tworzyć metodę wykonującą potrzebną konwersję, ale tak się
nie dzieje. W pewnym sensie jest to korzystne, ponieważ mechanizm ten jest zgodny
z opisaną w rozdziale 4. generyczną wariancją.
Warto zauważyć, że choć opisany mechanizm przypomina generyczną wariancję, są
to różne rozwiązania. Między innymi opakowywanie delegatów powoduje utworzenie
nowej instancji delegata (nie jest tak, że istniejący delegat jest traktowany jak instancja
innego typu). Więcej na ten temat dowiesz się, gdy dokładnie przeanalizujesz gene-
ryczną wariancję. Chcę jednak jak najwcześniej zwrócić uwagę na to, że wspomniane
tu mechanizmy nie są identyczne.
To kończy omawianie delegatów w kontekście C# 2. Konwersje grup metod nadal
są powszechnie stosowane, aspekt zgodności często jest wykorzystywany bez zasta-
nawiania się nad nim. Metody anonimowe stosuje się obecnie dość rzadko, ponieważ
wyrażenia lambda oferują prawie wszystkie ich możliwości. Nadal jednak mam do nich
sentyment, ponieważ jako pierwsze uwidoczniły mi wartość domknięć. Jeśli chodzi
o sytuacje, gdy jeden mechanizm prowadzi do drugiego, przyjrzyjmy się teraz prekur-
sorowi mechanizmów asynchronicznych wprowadzonych w C# 5: blokom iteratorów.
2.4. Iteratory
W C# 2 stosunkowo nieliczne interfejsy są bezpośrednio obsługiwane w języku.
IDisposable jest powiązany z instrukcją using, a język gwarantuje implementację inter-
fejsów w tablicach. Jednak oprócz tego bezpośrednio obsługiwane są tylko interfejsy
z rodziny IEnumerable. Interfejs IEnumerable zawsze umożliwiał pobieranie elementów
za pomocą instrukcji foreach, a w C# 2 ten mechanizm rozbudowano w dość oczywisty
sposób o obsługę wprowadzonych w .NET 2 generycznych interfejsów IEnumerable<T>.
Interfejsy IEnumerable reprezentują sekwencje elementów, a choć pobieranie tych
elementów jest bardzo częste, zrozumiała jest też potrzeba generowania sekwencji.
Ręczne implementowanie generycznych i niegenerycznych interfejsów tego typu
byłoby żmudne i narażone na błędy. Dlatego w C# 2 wprowadzono iteratory, aby
uprościć pracę.
87469504f326f0d7c1fcda56ef61bd79
8
2.4. Iteratory 85
Każdy iterator ma typ generowanych elementów (ang. yield type) zależny od typu
zwracanej wartości. Jeśli typ zwracanej wartości to jeden z niegenerycznych interfejsów,
typ generowanych elementów to object. W przeciwnym razie używany jest przekazany
do interfejsu argument określający typ. Na przykład typ generowanych elementów
metody o typie zwracanej wartości IEnumerator<string> to string.
Instrukcja yield return generuje wartości zwracanej sekwencji. Instrukcja yield break
kończy generowanie sekwencji. Podobne konstrukcje, nazywane czasem generatorami,
występują w niektórych innych językach, np. w Pythonie.
Na listingu 2.11 pokazano prostą metodę iteratora, którą możesz dokładnie prze-
analizować. W tej metodzie wyróżnione są instrukcje yield return.
87469504f326f0d7c1fcda56ef61bd79
8
86 ROZDZIAŁ 2. C# 2
87469504f326f0d7c1fcda56ef61bd79
8
2.4. Iteratory 87
Gdy dostępna jest już instancja interfejsu IEnumerator, możesz wielokrotnie wywo-
ływać metodę MoveNext(). Jeśli zwraca ona wartość true, oznacza to, że iterator został
przeniesiony do następnej wartości i można pobrać ją za pomocą właściwości Current.
Gdy metoda MoveNext() zwraca wartość false, oznacza to dojście do końca sekwencji.
Co to ma wspólnego z leniwym wykonywaniem? Skoro już dokładnie wiesz, jakie
polecenia wywołuje kod, w którym używany jest iterator, możesz przyjrzeć się roz-
poczęciu wykonywania ciała metody. W ramach przypomnienia pokazana jest tu
metoda z listingu 2.11:
static IEnumerable<int> CreateSimpleIterator()
{
yield return 10;
for (int i = 0; i < 3; i++)
{
yield return i;
}
yield return 20;
}
87469504f326f0d7c1fcda56ef61bd79
8
88 ROZDZIAŁ 2. C# 2
Jak napisałbyś podobny kod bez iteratorów? Mógłbyś zmodyfikować metodę, aby gene-
rowała kolekcję typu List<int> i zapełniała ją do czasu osiągnięcia limitu. Jednak dla
wysokiego limitu taka lista byłaby długa. Ponadto dlaczego metoda, która potrafi gene-
87469504f326f0d7c1fcda56ef61bd79
8
2.4. Iteratory 89
8
Przynajmniej do czasu przepełnienia zakresu typu int. Wtedy program może zgłosić wyjątek lub
przeskoczyć do dużej liczby ujemnej. Zależy to od tego, czy kod działa w bloku ze sprawdzaniem
wartości.
87469504f326f0d7c1fcda56ef61bd79
8
90 ROZDZIAŁ 2. C# 2
}
finally
{
Console.WriteLine("W bloku finally");
}
}
Przed uruchomieniem tego kodu zastanów się, jak myślisz, co kod wyświetli, jeśli ite-
racyjnie pobierzesz sekwencję zwracaną przez tę metodę. Czy spodziewasz się zoba-
czyć w konsoli tekst W bloku finally po zwróceniu słowa Pierwsza? Możliwe są tu dwa
toki myślenia:
Jeśli uznasz, że wykonywanie kodu jest wstrzymywane po dojściu do instrukcji
yield return, wtedy logiczne jest, iż program nadal znajduje się w bloku try. Nie
trzeba więc wykonywać bloku finally.
Jeżeli sądzisz, że po dojściu do instrukcji yield return sterowanie jest zwracane
do kodu wywołującego metodę MoveNext(), możesz uznać, iż program wychodzi
z bloku try i powinien w standardowy sposób wykonać blok finally.
Choć nie chcę psuć Ci niespodzianki, wyjaśniam, że poprawny jest model ze wstrzy-
mywaniem pracy. To rozwiązanie jest dużo przydatniejsze i pozwala uniknąć niein-
tuicyjnych konsekwencji. Na przykład byłoby dziwne, gdyby każda instrukcja w bloku
try była wykonywana raz, a blok finally trzy razy — raz przy generowaniu każdej
wartości, a następnie po wykonaniu reszty metody.
Teraz można udowodnić, że kod rzeczywiście działa w ten sposób. Kod z listingu 2.15
wywołuje pokazaną metodę, iteracyjnie pobiera wartości z sekwencji i wyświetla je.
Dane wyjściowe z listingu 2.15 pokazują, że blok finally jest wykonywany tylko raz, na
końcu:
Przed pierwszą instrukcją yield
Otrzymana wartość: Pierwsza
Między instrukcjami yield
Otrzymana wartość: Druga
Po drugiej instrukcji yield
W bloku finally
Ten kod dowodzi też leniwego wykonywania. Dane wyjściowe z metody Main() prze-
platają się z danymi wyjściowymi z metody Iterator(), ponieważ iterator kilkakrotnie
wstrzymuje i wznawia pracę.
87469504f326f0d7c1fcda56ef61bd79
8
2.4. Iteratory 91
Do tej pory wszystko wygląda prosto, jednak pokazana technika wymaga iteracyj-
nego pobrania całej sekwencji. Co zrobić, jeśli chcesz zakończyć pracę w trakcie
generowania sekwencji? Jeżeli kod, który pobiera elementy z iteratora, wywoła metodę
MoveNext() tylko raz (np. w sytuacji, gdy potrzebna jest tylko pierwsza wartość z sekwen-
cji), czy iterator pozostanie na zawsze wstrzymany w bloku try i nigdy nie wykona bloku
finally?
I tak, i nie. Jeśli ręcznie zapiszesz wszystkie wywołania obiektu typu IEnumerator<T>
i wywołasz metodę MoveNext() tylko raz, blok finally nigdy nie zostanie wykonany.
Jeżeli jednak użyjesz pętli foreach i zakończy ona pracę przed pobraniem całej sekwen-
cji, blok finally zostanie uruchomiony. Na listingu 2.16 pokazano to, wychodząc z pętli
po napotkaniu wartości różnej od null (czyli natychmiast po rozpoczęciu pracy). Kod
jest prawie identyczny jak na listingu 2.15; dodany fragment wyróżniony jest tu pogru-
bieniem.
Ważny jest ostatni wiersz — kod i tak wykonuje blok finally. Dzieje się to automa-
tycznie po wyjściu z pętli foreach. Powodem jest ukryta instrukcja using. Na listingu 2.17
pokazano, jak listing 2.16 wyglądałby, gdybyś nie używał pętli foreach i musiał ręcznie
napisać analogiczny kod. Jeśli ten kod wygląda znajomo, wynika to z tego, że to samo
zrobiłeś na listingu 2.12. Tym razem jednak więcej uwagi poświęcone jest instrukcji
using.
Listing 2.17. Zmodyfikowany listing 2.16 — tym razem pętla foreach nie jest używana
87469504f326f0d7c1fcda56ef61bd79
8
92 ROZDZIAŁ 2. C# 2
Oto przykład ilustrujący, jak przydatne może być zajmowanie zasobów w blokach ite-
ratora. Na listingu 2.18 pokazano metodę, która zwraca sekwencję wierszy wczytanych
z pliku.
87469504f326f0d7c1fcda56ef61bd79
8
2.4. Iteratory 93
string line;
while ((line = reader.ReadLine()) != null)
{
yield return line;
}
}
}
9
Możesz używać iteratorów do pisania akcesorów właściwości, jednak w tym podrozdziale opisywane
są tylko metody iteratorów (aby zachować zwięzłość). Ich implementacja wygląda tak samo jak dla
akcesorów właściwości.
87469504f326f0d7c1fcda56ef61bd79
8
94 ROZDZIAŁ 2. C# 2
int doubled = i * 2;
Console.WriteLine("Generowanie {0}", doubled);
yield return doubled;
}
}
finally
{
Console.WriteLine("W bloku finally");
}
}
Na listingu 2.19 pokazana jest stosunkowo prosta metoda w jej pierwotnej postaci.
Celowo dodanych zostało pięć aspektów, które mogą nie być oczywiste:
parametr,
zmienna lokalna wymagająca zachowania między instrukcjami yield return,
zmienna lokalna niewymagająca zachowania między instrukcjami yield return,
dwie instrukcje yield return,
blok finally.
Ta metoda iteracyjnie wykonuje pętlę count razy i w każdej iteracji generuje dwie
liczby całkowite: numer iteracji i dwukrotność tej wartości. Na przykład jeśli przekażesz
liczbę 5, metoda wygeneruje: 0, 0, 1, 2, 2, 4, 3, 6, 4, 8.
Dostępny do pobrania kod zawiera kompletną, ręcznie poprawioną i zdekompilo-
waną wersję wygenerowanego kodu. Jest ona dość długa, dlatego nie została zamieszczona
tu w całości. Tu chcę tylko pokrótce pokazać, co jest generowane. Na listingu 2.20
pokazano większość infrastruktury, ale bez szczegółów implementacji. Dalej obja-
śniam ten kod, a następnie opisana jest metoda MoveNext(), która wykonuje większość
rzeczywistej pracy.
87469504f326f0d7c1fcda56ef61bd79
8
2.4. Iteratory 95
initialThreadId = Environment.CurrentManagedThreadId;
}
IEnumerator Enumerable().GetEnumerator()
{
return GetEnumerator(); Jawna implementacja składowych
} niegenerycznego interfejsu.
Naprawdę, tak wygląda uproszczona wersja. Ważną rzeczą, jaką należy zrozumieć, jest
to, że kompilator wygenerował maszynę stanową jako prywatną klasę zagnieżdżoną.
Liczne nazwy generowane przez kompilator nie są poprawnymi identyfikatorami z języka
C#, jednak dla uproszczenia tu zostały użyte nazwy dozwolone w C#. Kompilator
generuje metodę z sygnaturą zadeklarowaną w pierwotnym kodzie źródłowym i to tej
metody używają wszystkie jednostki wywołujące. Metoda ta jedynie tworzy instancję
maszyny stanowej, kopiuje do niej parametry i zwraca maszynę stanową jednostce
wywołującej. Pierwotny kod źródłowy nie jest wywoływany. Jest to zgodne z opisanym
wcześniej leniwym wykonywaniem.
Maszyna stanowa zawiera wszystkie elementy potrzebne do zaimplementowania
iteratora. Oto one:
Wskaźnik informujący o tym, do którego miejsca kod metody został wykonany.
Przypomina on licznik instrukcji w procesorze, ale jest prostszy, ponieważ trzeba
rozróżniać tylko kilka stanów.
Kopia wszystkich parametrów, co pozwala pobrać ich wartości, kiedy będą
potrzebne.
Zmienne lokalne metody.
Ostatnia wygenerowana wartość, dzięki czemu jednostka wywołująca może ją
pobrać za pomocą właściwości Current.
87469504f326f0d7c1fcda56ef61bd79
8
96 ROZDZIAŁ 2. C# 2
W prawie wszystkich sytuacjach maszyna stanowa jest używana tylko raz i działa
w tym samym wątku, w którym ją utworzono. Kompilator generuje kod zoptymali-
zowany pod kątem tego scenariusza. Metoda GetEnumerator() sprawdza wątki i zwraca
wartość this, jeśli maszyna stanowa znajduje się w pierwotnym stanie i działa w tym
samym wątku. To dlatego w maszynie stanowej zaimplementowane są interfejsy
10
IEnumerable<int> i IEnumerator<int>, co w standardowym kodzie zdarza się rzadko .
Jeśli metoda GetEnumerator() zostanie wywołana w innym wątku lub wielokrotnie,
każde wywołanie tworzy nową instancję maszyny stanowej ze skopiowanymi począt-
kowymi wartościami parametrów.
Metoda MoveNext() jest dość skomplikowana. Po pierwszym wywołaniu musi zacząć
wykonywać zapisany w niej kod w standardowy sposób. Po kolejnych wywołaniach
musi przeskakiwać do odpowiedniego miejsca. Między wywołaniami zachowane muszą
być zmienne lokalne, dlatego zapisuje się je w polach maszyny stanowej.
Jeśli kompilowany kod jest optymalizowany, zmienne lokalne nie zawsze są kopio-
wane do pól. Pole używane jest po to, aby w wywołaniu metody MoveNext() można było
śledzić wartość ustawioną we wcześniejszym wywołaniu tej metody. Jeśli przyjrzysz się
zmiennej lokalnej doubled z listingu 2.19, zobaczysz, że nigdy nie jest ona używana
w taki sposób:
for (int i = 0; i < count; i++)
{
Console.WriteLine("Generowanie {0}", i);
yield return i;
int doubled = i * 2;
Console.WriteLine("Generowanie {0}", doubled);
yield return doubled;
}
Kod jedynie inicjuje zmienną, wyświetla ją, a następnie zwraca. Gdy wrócisz do metody,
dawna wartość będzie nieistotna. Dlatego kompilator może ją zoptymalizować i zasto-
sować prawdziwą zmienną lokalną w wersji produkcyjnej. W wersji diagnostycznej pole
może być używane, aby ułatwić debugowanie. Warto zauważyć, że jeśli przestawisz dwa
ostatnie wyróżnione pogrubieniem wiersze (czyli najpierw zwrócisz wartość, a następnie
ją wyświetlisz), opisana optymalizacja nie będzie możliwa.
Jak wygląda metoda MoveNext()? Trudno jest zaprezentować rzeczywisty kod bez
zagłębiania się w szczegółach. Dlatego na listingu 2.21 pokazany jest zarys struktury
tej metody.
10
Jeśli pierwotna metoda zwraca tylko obiekt typu IEnumerator<T>, w maszynie stanowej zaimple-
mentowany jest tylko ten interfejs.
87469504f326f0d7c1fcda56ef61bd79
8
2.4. Iteratory 97
Ta maszyna stanowa zawiera zmienną (tu jej nazwa to state) służącą do zapamięty-
wania miejsca, do którego metoda doszła. Wartości tej zmiennej zależą od implementacji.
W wersji kompilatora Roslyn, z której korzystam, używane są następujące stany:
-3 — wykonywana jest metoda MoveNext(),
-2 — nie wywołano jeszcze metody GenEnumerator(),
-1 — zakończono pracę (z powodzeniem lub nie),
0 — wywołano już metodę GetEnumerator(), ale nie wywołano metody MoveNext()
(początek przykładowej metody),
1 — pierwsza instrukcja yield return,
2 — druga instrukcja yield return.
Gdy wywoływana jest metoda MoveNext(), stan jest używany do przejścia do odpowied-
niego miejsca tej metody. Albo rozpoczyna się jej pierwsze wykonanie, albo praca
jest wznawiana od ostatniej wykonanej instrukcji yield return. Warto zauważyć, że nie
istnieją stany dla pozycji w kodzie takich jak „właśnie przypisano wartość do zmiennej
doubled”, ponieważ nigdy nie trzeba wznawiać działania od takiego miejsca. Wzna-
wianie jest potrzebne tylko od pozycji, w których wcześniej wstrzymano pracę.
Blok fault pod koniec listingu 2.21 to konstrukcja z kodu pośredniego niemająca
bezpośredniego odpowiednika w C#. Przypomina ona blok finally, który jest wyko-
nywany po zgłoszeniu wyjątku, ale nie przechwytuje go. Blok fault służy do wykony-
wania potrzebnych operacji porządkujących. Tu te operacje znajdują się w bloku finally.
Kod z bloku finally jest przenoszony do odrębnej metody wywoływanej w metodzie
Dispose() (jeśli zgłoszony został wyjątek) lub MoveNext() (jeżeli doszła do końca bez
zgłoszenia wyjątku). Metoda Dispose() sprawdza stan, aby ustalić, jakie operacje porząd-
kujące są potrzebne. Zadanie jest tym bardziej skomplikowane, im więcej bloków
finally istnieje.
Analiza tej implementacji nie nauczy Cię nowych technik programowania w C#,
ale pomoże Ci docenić, jak dużo rzeczy kompilator potrafi zrobić za programistę. Ten
sam motyw pojawia się w C# 5 w kontekście mechanizmu async/await, gdzie zamiast
87469504f326f0d7c1fcda56ef61bd79
8
98 ROZDZIAŁ 2. C# 2
87469504f326f0d7c1fcda56ef61bd79
8
2.5. Mniej istotne mechanizmy 99
Jeśli typ jest generyczny, w każdej jego części trzeba zadeklarować ten sam zestaw
parametrów określających typ, używając tych samych nazw. Ponadto jeśli w kilku dekla-
racjach stosowane są ograniczenia tego samego parametru określającego typ, ograni-
czenia te muszą być identyczne. Poszczególne części mogą udostępniać różne interfejsy
implementowane w typie. Implementacja interfejsu nie musi znajdować się w części,
w której ten interfejs jest podany.
METODY CZĘŚCIOWE Z C# 3
W C# 3 wprowadzono dodatkowy mechanizm typów częściowych — metody częściowe.
Są to metody deklarowane bez ciała w jednej części i opcjonalnie implementowane
w innej części. Metody częściowe są domyślnie prywatne, muszą zwracać wartość void
i nie mogą przyjmować parametrów out. Dopuszczalne jest używanie parametrów ref.
W czasie kompilacji zachowywane są tylko te metody częściowe, dla których podano
implementację. Jeśli dla metody częściowej nie istnieje implementacja, wszystkie jej
wywołania są usuwane. Wydaje się to dziwne, jednak dzięki temu w wygenerowanym
kodzie można tworzyć opcjonalne haczyki do dołączania ręcznie pisanego kodu, który
dodaje nowe operacje. Okazuje się, że jest to przydatna technika. Na listingu 2.23
pokazano przykład z dwoma metodami częściowymi. Jedna z nich jest zaimplemento-
wana, druga nie.
87469504f326f0d7c1fcda56ef61bd79
8
100 ROZDZIAŁ 2. C# 2
return ret;
}
87469504f326f0d7c1fcda56ef61bd79
8
2.5. Mniej istotne mechanizmy 101
87469504f326f0d7c1fcda56ef61bd79
8
102 ROZDZIAŁ 2. C# 2
using System;
using WinForms = System.Windows.Forms;
using WebForms = System.Web.UI.WebControls; Wprowadzanie aliasów przestrzeni nazw.
class Test
{
static void Main()
{
Console.WriteLine(typeof(WinForms.Button)); Używanie aliasów do tworzenia nazw
Console.WriteLine(typeof(WebForms.Button)); kwalifikowanych.
}
}
87469504f326f0d7c1fcda56ef61bd79
8
2.5. Mniej istotne mechanizmy 103
kodu i przy pracy z wygenerowanym kodem, ponieważ kolizje nazw typów są tam
częstsze.
ALIASY ZEWNĘTRZNE
Do tej pory pisałem o kolizjach różnych typów o tej samej nazwie, ale z różnych
przestrzeni nazw. A co z kolizją, która sprawia więcej problemów, dotyczącą dwóch
typów o tej samej nazwie i z tej samej przestrzeni nazw, ale z różnych podzespołów?
Jest to skrajny przypadek, ale może się zdarzyć. W C# 2 wprowadzono aliasy
zewnętrzne, aby poradzić sobie z tą sytuacją. Aliasy zewnętrzne są deklarowane w kodzie
źródłowym bez łączenia ich z czymkolwiek. Oto przykład:
extern alias FirstAlias;
extern alias SecondAlias;
W tym samym kodzie źródłowym można używać aliasu w dyrektywie using lub w nazwach
typów z pełnym kwalifikatorem. Na przykład jeśli używasz podzespołu Json.NET, ale
masz też dodatkowy podzespół z zadeklarowanym typem Newtonsoft.Json.Linq.JObject,
możesz napisać następujący kod:
extern alias JsonNet;
extern alias JsonNetAlternative;
using JsonNet::Newtonsoft.Json.Linq;
using AltJObject = JsonNetAlternative::Newtonsoft.Json.Linq.JObject;
...
JObject obj = new JObject(); Używanie zwykłego typu JObject z podzespołu Json.NET.
AltJObject alt = new AltJObject(); Używanie typu JObject z innego podzespołu.
87469504f326f0d7c1fcda56ef61bd79
8
104 ROZDZIAŁ 2. C# 2
ostrzeżenie CS0219 (mówiące o tym, że do zmiennej przypisano wartość, ale nie jest
ona nigdzie używana), możesz zastosować następujący kod:
#pragma warning disable CS0219
int variable = CallSomeMethod();
#pragma warning restore CS0219
Sądzę, że wielkość takiej struktury będzie wynosiła 24 bajty (lub 32 bajty, jeśli śro-
dowisko uruchomieniowe wyrównuje wielkość pól do granic 8 bajtów). Ważne jest to,
że wszystkie dane znajdują się bezpośrednio w wartości. Nie jest potrzebna referencja
87469504f326f0d7c1fcda56ef61bd79
8
2.5. Mniej istotne mechanizmy 105
OSTRZEŻENIE. Choć ostrzegałem już przed używaniem przykładowego kodu z tej książki,
czuję się zmuszony podkreślić to w tym przykładzie. Aby kod był krótki, nie próbowałem
hermetyzować pokazanej struktury. Należy jej używać wyłącznie do zrozumienia składni bufo-
rów o stałej długości.
Gdy podzespół jest podpisywany, trzeba podać klucz publiczny w nazwie podzespołu.
Na przykład w podzespole Noda Time używam następującego kodu:
[assembly: InternalsVisibleTo("NodaTime.Test,PublicKey=0024...4669"]
Prawdziwy klucz publiczny jest oczywiście o wiele dłuższy. Używanie tego atrybutu
do podpisywanych podzespołów nie wygląda dobrze, jednak rzadko będziesz zaglądał
do takiego kodu. Opisanego atrybutu używałem w trzech sytuacjach (w jednej z nich
później tego żałowałem):
Aby umożliwić podzespołowi z testami dostęp do składowych wewnętrznych,
co uprasza testy.
Aby umożliwić narzędziom (nieudostępnianym publicznie) dostęp do składowych
wewnętrznych w celu uniknięcia duplikacji kodu.
Aby umożliwić jednej bibliotece dostęp do wewnętrznych składowych innej,
ściśle powiązanej bibliotece.
87469504f326f0d7c1fcda56ef61bd79
8
106 ROZDZIAŁ 2. C# 2
wersjonowania ma cechy podobne jak kod publiczny. Nie zamierzam więcej stosować tej
techniki.
Z kolei jeśli chodzi o testy i narzędzia, jestem wielkim zwolennikiem udostępniania
wewnętrznego kodu. Wiem, że zgodnie z jednym z dogmatów należy testować tylko
publiczny interfejs API. Jednak często programiści starają się możliwie ograniczać
publiczny interfejs, dlatego zapewnienie testom dostępu do wewnętrznego kodu pozwala
znacznie uprościć testy. To oznacza, że z większym prawdopodobieństwem napiszesz
ich więcej.
Podsumowanie
Zmiany wprowadzone w C# 2 znacznie wpłynęły na wygląd i styl idiomatycz-
nego języka C#. Praca bez typów generycznych lub typów przyjmujących war-
tość null jest naprawdę okropna.
Typy generyczne pozwalają na lepszy opis typu w sygnaturach typów i metod
w interfejsie API. Pozwala to zwiększyć bezpieczeństwo ze względu na typ
w czasie kompilacji bez dużej ilości duplikowania kodu.
W typach referencyjnych od zawsze można było stosować wartość null do repre-
zentowania braku informacji. Dzięki typom bezpośrednim przyjmującym war-
tość null można zastosować tę technikę do typów bezpośrednich, a wsparcie
w języku, środowisku uruchomieniowym i platformie ułatwia korzystanie z takich
typów.
W C# 2 ułatwiono używanie delegatów, a konwersje grup metod stosowane do
zwykłych i anonimowych metod zapewniają jeszcze większe możliwości i zwięk-
szają zwięzłość.
Iteratory umożliwiają generowanie w kodzie leniwie przetwarzanych sekwencji.
Mechanizm ten powoduje wstrzymanie działania metody do czasu zażądania
następnej wartości.
Nie wszystkie mechanizmy są rozbudowane. Niewielkie funkcje, np. typy czę-
ściowe i klasy statyczne, też mogą mieć istotny wpływ. Niektóre z nich dotyczą
tylko części programistów, jednak są ważne w niszowych zastosowaniach.
87469504f326f0d7c1fcda56ef61bd79
8
C# 3 — technologia LINQ
i wszystko, co z nią związane
Zawartość rozdziału:
Łatwe implementowanie prostych właściwości
Bardziej zwięzłe inicjowanie obiektów i kolekcji
Tworzenie typów anonimowych na potrzeby lokalnych
danych
Używanie wyrażeń lambda do tworzenia delegatów
i drzew wyrażeń
Proste zapisywanie złożonych zapytań za pomocą
wyrażeń reprezentujących zapytania
87469504f326f0d7c1fcda56ef61bd79
8
108 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
Formatowanie zależało od stylu pisania kodu, jednak niezależnie od tego, czy taka wła-
ściwość zajmowała jeden długi wiersz, 11 krótkich, czy pięć średnich (tak jak tutaj),
zawsze zawierała tylko szum informacyjny. Był to bardzo rozwlekły sposób na zapisanie,
że potrzebne jest pole, którego wartość ma być dostępna dla jednostek wywołujących
jako właściwość.
W C# 3 znacznie uproszczono zadanie, używając automatycznie implementowanych
właściwości (nazywanych czasem automatycznymi właściwościami). Są to właściwości
bez ciała akcesorów; implementację akcesorów generuje kompilator. Cały wcześniejszy
kod można teraz zastąpić jednym wierszem:
public string Name { get; set; }
87469504f326f0d7c1fcda56ef61bd79
8
3.2. Niejawne określanie typów 109
87469504f326f0d7c1fcda56ef61bd79
8
110 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
Wynikiem zadeklarowania zmiennej lokalnej przy użyciu słowa var zamiast nazwy
typu jest zmienna lokalna o określonym typie. Różnica polega na tym, że typ jest
ustalany przez kompilator na podstawie typu przypisywanej wartości z czasu kompi-
lacji. Wcześniejszy kod da dokładnie ten sam efekt, co poniższy fragment:
string language = "C#";
WSKAZÓWKA. Gdy pojawił się C# 3, wielu programistów unikało słowa kluczowego var,
ponieważ sądziło, że spowoduje to pominięcie licznych testów z czasu kompilacji lub problemy
z wydajnością. Tak się nie dzieje, ponieważ jedynym skutkiem jest wnioskowanie typu zmien-
nej lokalnej. Po deklaracji zmienna działa dokładnie tak samo, jakby została zadeklarowana
z jawnie podaną nazwą typu.
W niektórych sytuacjach możliwe byłoby uniknięcie tych reguł dzięki analizie wszyst-
kich przypisań wartości do zmiennej i wywnioskowaniu typu na tej podstawie. W nie-
których językach stosuje się tę technikę, jednak projektanci języka C# woleli zachować
proste reguły.
Inne ograniczenie dotyczy tego, że słowo var można stosować tylko do zmiennych
lokalnych. Wielokrotnie marzyłem o polach z niejawnym typowaniem, jednak nadal
nie są one obecne (w wersji C# 7.3).
W pokazanym przykładzie używanie słowa var dawało niewielkie korzyści (jeśli
w ogóle). Jawna deklaracja byłaby akceptowalna i równie czytelna. Zwykle są trzy
powody do stosowania słowa kluczowego var:
Nie można podać nazwy typu zmiennej, ponieważ jest to typ anonimowy. Typy
anonimowe są opisane w podrozdziale 3.4. Jest to aspekt tego mechanizmu
związany z LINQ.
Nazwa typu zmiennej jest długa, a sam typ może zostać łatwo wywnioskowany
przez czytelnika na podstawie wyrażenia używanego do zainicjowania zmiennej.
87469504f326f0d7c1fcda56ef61bd79
8
3.2. Niejawne określanie typów 111
Jest to jednak nieeleganckie. Musiałem podzielić instrukcję na dwa wiersze, aby zmie-
ściła się na stronie. Ponadto występuje tu duplikacja kodu. Można jej uniknąć, stosując
słowo var:
var mapping = new Dictionary<string, List<decimal>>();
Ten zapis pozwala przedstawić tę samą ilość informacji za pomocą mniejszej ilości
tekstu, dlatego w mniejszym stopniu odciąga uwagę od reszty kodu. Oczywiście ta
technika działa tylko wtedy, gdy typ zmiennej ma być identyczny z typem inicjującego
ją wyrażenia. Jeśli chcesz zastosować dla zmiennej typ IDictionary<string, List<decimal>>
(interfejs zamiast klasy), słowo var nie będzie pomocne. Jednak w przypadku zmien-
nych lokalnych tego rodzaju rozróżnienie na interfejs i implementację ma zwykle
mniejsze znaczenie.
Gdy pisałem pierwsze wydanie książki C# od podszewki, obawiałem się zmiennych
lokalnych z niejawnym typowaniem. Rzadko stosowałem je poza technologią LINQ,
chyba że bezpośrednio wywoływałem konstruktor (tak jak w poprzednim przykładzie).
Bałem się, że nie będę potrafił łatwo ustalić typu zmiennej w trakcie czytania kodu.
Po dziesięciu latach w dużej mierze zarzuciłem te obawy. Używam słowa var dla
prawie wszystkich zmiennych lokalnych w kodzie testowym. Często stosuję je także
w kodzie produkcyjnym. Moje lęki okazały się bezpodstawne. W prawie każdym miej-
scu potrafię na podstawie samej lektury kodu wywnioskować używany typ. W innych
sytuacjach używam jawnych deklaracji.
Nie twierdzę, że jestem w tym obszarze w pełni spójny. Z pewnością nie jestem
dogmatyczny. Ponieważ zmienne z jawnym typowaniem dają po kompilacji dokładnie
ten sam kod, co zmienne z niejawnym typowaniem, mogę w każdej chwili zmodyfiko-
wać deklaracje w dowolną stronę. Zachęcam do omówienia tej kwestii z ludźmi, którzy
najczęściej będą pracować z Twoim kodem (czy są to koledzy z pracy, czy współpra-
cownicy ze społeczności open source). Poznaj poziom komfortu wszystkich osób i staraj
się do niego dostosować. Następny aspekt niejawnego typowania w C# 3 dotyczy cze-
goś nieco innego. Nie jest bezpośrednio związany ze słowem var, jednak też polega
na pomijaniu nazwy typu, aby kompilator mógł go wywnioskować.
87469504f326f0d7c1fcda56ef61bd79
8
112 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
Pierwszy zapis jest poprawny tylko wtedy, gdy jest częścią deklaracji zmiennej, w któ-
rej podany jest typ tablicy. Na przykład poniższe polecenie jest nieprawidłowe:
int[] array;
array = { 1, 2, 3, 4, 5 }; Niepoprawne.
Druga postać jest zawsze dozwolona, dlatego drugi wiersz w poprzednim przykładzie
może wyglądać tak:
array = new int[] { 1, 2, 3, 4, 5 };
Następne oczywiste pytanie dotyczy tego, w jaki sposób kompilator ustala typ. Jak to
często bywa, precyzyjny opis szczegółów uwzględniający wszystkie przypadki brzegowe
jest skomplikowany. Oto uproszczona sekwencja kroków:
1. Znajdowanie zbioru typów kandydujących na podstawie typu każdego elementu
tablicy, dla którego określono typ.
2. Sprawdzanie dla każdego typu kandydującego, czy możliwa jest niejawna kon-
wersja na ten typ wszystkich elementów tej tablicy. Usuwanie typów kandydu-
jących, które nie spełniają tego warunku.
3. Jeśli pozostanie tylko jeden typ, jest to wywnioskowany typ elementów, a kom-
pilator tworzy odpowiednią tablicę. W przeciwnym razie (jeśli nie pozostał żaden
typ lub jest ich kilka), następuje błąd kompilacji.
Typem elementów tablicy musi być typ jednego z wyrażeń z operacji inicjującej tablicę.
Kompilator nie próbuje znajdować wspólnej klasy bazowej lub wspólnego zaimple-
mentowanego interfejsu. W tabeli 3.1 pokazano przykłady ilustrujące te reguły.
87469504f326f0d7c1fcda56ef61bd79
8
3.3. Inicjalizatory obiektów i kolekcji 113
87469504f326f0d7c1fcda56ef61bd79
8
114 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
Jak utworzyć zamówienie? Należy dodać instancję typu Order i przypisać wartość do
właściwości OrderId i Customer. Nie można przypisać wartości do właściwości Items,
ponieważ jest ona przeznaczona tylko do odczytu. Zamiast tego można dodawać ele-
menty do listy zwróconej przez tę właściwość. Na listingu 3.2 pokazano, jak mógłbyś to
zrobić bez inicjalizatorów obiektów i kolekcji, a zmodyfikowanie klas w celu uproszenia
kodu byłoby niemożliwe.
Ten kod można uprościć, dodając konstruktory do różnych klas, aby inicjować właściwości
na podstawie parametrów. Stosuję tę technikę nawet wtedy, gdy dostępne są inicjalizatory
obiektów i kolekcji. Jednak gdy chce się zachować zwięzłość, to — uwierz mi na słowo —
z różnych powodów nie zawsze można posłużyć się takim rozwiązaniem. Między innymi
nie zawsze kontrolujesz kod używanych klas. Inicjalizatory obiektów i kolekcji znacznie
upraszczają tworzenie i zapełnianie zamówienia, co pokazane jest na listingu 3.3.
87469504f326f0d7c1fcda56ef61bd79
8
3.3. Inicjalizatory obiektów i kolekcji 115
Items =
{
new OrderItem { ItemId = "abcd123", Quantity = 1 },
new OrderItem { ItemId = "fghi456", Quantity = 2 }
}
};
Nie mogę wypowiadać się za wszystkich, ale moim zdaniem listing 3.3 jest dużo bar-
dziej czytelny niż listing 3.2. Struktura obiektu jest oczywista dzięki wcięciom i wystę-
puje tu mniej powtórzeń. Przyjrzyj się teraz dokładnie każdemu fragmentowi kodu.
Inicjalizatory obiektów mogą być używane tylko w wywołaniu konstruktora lub w innym
inicjalizatorze obiektu. W wywołaniu konstruktora można w standardowy sposób podać
argumenty, jednak jeśli nie chcesz tego robić, to nie potrzebujesz listy argumentów
i możesz pominąć nawias (). Wywołanie konstruktora bez listy argumentów to odpo-
wiednik podania pustej listy argumentów. Na przykład dwa poniższe wiersze działają
tak samo:
Order order = new Order() { OrderId = "xyz" };
Order order = new Order { OrderId = "xyz" };
Listę argumentów konstruktora możesz pominąć tylko wtedy, jeśli podajesz inicjali-
zator obiektu lub kolekcji. Na przykład ten kod jest nieprawidłowy:
Order order = new Order; Niepoprawne.
87469504f326f0d7c1fcda56ef61bd79
8
116 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
Jednak inicjalizatory kolekcji mogą być używane częściej do tworzenia nowych kolekcji.
Na przykład następny wiersz deklaruje nową zmienną z listą łańcuchów znaków i zapeł-
nia tę listę:
var beatles = new List<string> { "John", "Paul", "Ringo", "George" };
87469504f326f0d7c1fcda56ef61bd79
8
3.3. Inicjalizatory obiektów i kolekcji 117
Co się jednak stanie, jeśli używany typ kolekcji nie udostępnia metody Add o jednym
parametrze? Wtedy przydatne są inicjalizatory elementów używane z nawiasem klam-
rowym. Drugą najpopularniejszą kolekcją generyczną po typie List<T> jest zapewne
Dictionary<TKey, TValue>. Obejmuje ona metodę Add(key, value). Słownik można
zapełnić za pomocą inicjalizatora kolekcji:
var releaseYears = new Dictionary<string, int>
{
{ "Please please me", 1963 },
{ "Revolver", 1966 },
{ "Sgt. Pepper’s Lonely Hearts Club Band", 1967 },
{ "Abbey Road", 1970 }
};
Kompilator traktuje każdy inicjalizator elementu jako odrębne wywołanie Add. Jeśli
używany jest prosty inicjalizator elementu (bez nawiasu klamrowego), wartość jest
przekazywana jako jedyny argument wywołania Add. Ta technika jest stosowana do
elementów w inicjalizatorze kolekcji List<string>.
Jeśli w inicjalizatorze elementu używany jest nawias klamrowy, inicjalizator też jest
traktowany jak jedno wywołanie Add, ale każde wyrażenie w nawiasie klamrowym jest
traktowane jak jeden argument. Wcześniejszy przykład ze słownikiem jest odpowied-
nikiem następującego kodu:
var releaseYears = new Dictionary<string, int>();
releaseYears.Add("Please please me", 1963);
releaseYears.Add("Revolver", 1966);
releaseYears.Add("Sgt. Pepper’s Lonely Hearts Club Band", 1967);
releaseYears.Add("Abbey Road", 1970);
87469504f326f0d7c1fcda56ef61bd79
8
118 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
nie robi nic innego oprócz zgłaszania wyjątku NotImplementedException. Taki typ może
być przydatny do generowania danych testowych, jednak nie zalecam stosowania tego
podejścia w kodzie produkcyjnym. Chciałbym, aby dostępny był atrybut, który pozwala
bez implementowania interfejsu IEnumerable określić to, że dany typ ma być dostępny
do użytku w inicjalizatorach kolekcji. Wątpię jednak, aby taki atrybut kiedykolwiek
powstał.
87469504f326f0d7c1fcda56ef61bd79
8
3.4. Typy anonimowe 119
Tak wyglądają typy anonimowe, ale do czego się je stosuje? Tu istotna staje się techno-
logia LINQ. Gdy wykonywane jest zapytanie — niezależnie od tego, czy dotyczy ono
SQL-owej bazy danych, czy kolekcji obiektów — programista często chce uzyskać dane
w określonej formie, innej niż pierwotny typ i niemającej dużego sensu poza zapytaniem.
Załóżmy, że tworzysz zapytanie dotyczące grupy osób, z których każda określiła
swój ulubiony kolor. Możliwe, że chcesz otrzymać wynik w formie histogramu. Każdy
wpis w wynikowej kolekcji to kolor i liczba osób, które wybrały ten kolor jako ulu-
biony. Typ reprezentujący ulubiony kolor zapewne nie będzie przydatny w innych
miejscach, ale jest użyteczny w tym konkretnym kontekście. Typy anonimowe umoż-
liwiają zwięzły zapis takich jednorazowych typów bez utraty korzyści, jakie daje typo-
wanie statyczne.
87469504f326f0d7c1fcda56ef61bd79
8
120 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
W tym przykładzie dla każdej właściwości oprócz CustomerName używany jest inicjalizator
z projekcją. Efekt jest identyczny jak w kodzie poniżej, gdzie w typie anonimowym
jawnie podane są nazwy właściwości:
var flattenedItem = new
{
OrderId = order.OrderId,
CustomerName = customer.Name,
Address = customer.Address,
ItemId = item.ItemId,
Quantity = item.Quantity
};
87469504f326f0d7c1fcda56ef61bd79
8
3.4. Typy anonimowe 121
JakasWlasciwosc = zmienna.JakasWlasciwosc
wystarczy zapis:
zmienna.JakasWlasciwosc
Opisana została tu składnia typów anonimowym. Wiesz już, że wynikowe obiekty mają
właściwości, z których możesz korzystać jak ze zwykłych typów. Co się jednak dzieje
na zapleczu?
87469504f326f0d7c1fcda56ef61bd79
8
122 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
jest generyczny i ma jeden parametr określający typ dla każdej właściwości; wiele
typów anonimowych o tych samych nazwach właściwości, ale różnych typach
właściwości, będzie miało inne argumenty określające typ w tym samym typie
generycznym; nie jest to jednak gwarantowane i zależy od kompilatora;
jeśli w dwóch wyrażeniach tworzących obiekt typu anonimowego w jednym pod-
zespole używane są te same nazwy właściwości w tej samej kolejności i te same
typy właściwości, powstaną dwa obiekty tego samego typu (jest to gwarantowane).
Ostatni punkt jest ważny przy ponownym przypisywaniu wartości zmiennych, a także
w tablicach z niejawnym typowaniem, w których używane są typy anonimowe. Według
mojego doświadczenia programiści stosunkowo rzadko ponownie przypisują wartość
zainicjowaną z użyciem typu anonimowego, jednak dobrze, że taka operacja jest moż-
liwa. Na przykład poniższy kod jest w pełni dopuszczalny:
var player = new { Name = "Pam", Score = 4000 };
player = new { Name = "James", Score = 5000 };
Warto zauważyć, że właściwości muszą mieć te same nazwy i typy oraz być podane
w tej samej kolejności, aby w dwóch wyrażeniach tworzących obiekt typu anonimowego
używany był ten sam typ. Na przykład poniższy kod jest niedozwolony, ponieważ
kolejność właściwości w drugim elemencie tablicy jest inna niż w pozostałych:
var players = new[]
{
new { Name = "Priti", Score = 6000 },
new { Score = 7000, Name = "Chris" },
new { Name = "Amanda", Score = 8000 },
};
Choć każdy element tablicy z osobna jest poprawny, typ drugiego elementu unie-
możliwia kompilatorowi wywnioskowanie typu tablicy. To samo stanie się, jeśli podasz
dodatkową właściwość lub zmienisz typ jednej z właściwości.
Choć typy anonimowe są przydatne w technologii LINQ, nie oznacza to, że są
odpowiednim narzędziem do rozwiązania każdego problemu. Przyjrzyj się pokrótce
sytuacjom, w których nie należy ich używać.
3.4.3. Ograniczenia
Typy anonimowe są bardzo przydatne, jeśli potrzebujesz lokalnej reprezentacji samych
danych. Lokalna oznacza tu, że określony kształt danych jest potrzebny tylko w kon-
kretnej metodzie. Gdy zechcesz zapisać ten sam kształt w wielu miejscach, musisz
87469504f326f0d7c1fcda56ef61bd79
8
3.5. Wyrażenia lambda 123
poszukać innej techniki. Choć można zwracać instancje typów anonimowych w meto-
dach lub przyjmować takie instancje jako parametry, wymaga to użycia typów gene-
rycznych lub typu object. Anonimowość takich typów uniemożliwia stosowanie ich
w sygnaturach metod.
Do wersji C# 7 było tak, że jeśli chciałeś zastosować niestandardową strukturę
danych w więcej niż jednej metodzie, musiałeś zadeklarować własną klasę lub strukturę.
W C# wprowadzono krotki (zobacz rozdział 11.), które mogą zastąpić typy anonimowe;
zależy to od oczekiwanego poziomu hermetyzacji.
Jeśli chodzi o hermetyzację, typy anonimowe w praktyce w ogóle jej nie zapew-
niają. Nie możesz dodać do takiego typu sprawdzania poprawności ani dodatkowych
operacji. Jeśli takie mechanizmy są potrzebne, jest to dobra oznaka, że zapewne powi-
nieneś utworzyć własny typ.
Wcześniej wspomniałem, że używanie typów anonimowych w różnych podzespo-
łach razem z wprowadzonym w C# 4 typowaniem dynamicznym stało się trudniejsze,
ponieważ takie typy są wewnętrzne. Zwykle widziałem takie próby w aplikacjach inter-
netowych w modelu MVC, gdzie można zbudować za pomocą typów anonimowych
model strony, a następnie używać go w widoku przy użyciu typu dynamicznego (dynamic;
zobacz rozdział 4.). Ta technika zadziała, jeśli oba fragmenty kodu znajdują się w tym
samym podzespole lub gdy podzespół z kodem modelu udostępnia za pomocą atry-
butu [InternalsVisibleTo] wewnętrzne składowe podzespołowi z kodem widoku.
W zależności od używanej platformy trudno może być spełnić którykolwiek z tych
warunków. Jednak z powodu zalet typowania statycznego zwykle zalecam, aby model
deklarować jako zwykły typ. Wymaga to więcej pracy na początku niż w sytuacji, gdy
używasz typu anonimowego, jednak długoterminowo zwykle pozwala zaoszczędzić czas.
UWAGA. Także Visual Basic udostępnia typy anonimowe, jednak działają one w odmienny
sposób. W C# wszystkie właściwości są używane do ustalania równości i skrótów. Wszystkie
te właściwości są też przeznaczone tylko do odczytu. W Visual Basic działają tak jedynie
właściwości z modyfikatorem Key. Właściwości bez tego modyfikatora są przeznaczone do
odczytu i zapisu oraz nie wpływają na równość i skróty.
87469504f326f0d7c1fcda56ef61bd79
8
124 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
Wyrażenia lambda wprowadzono w C# 3, aby kod był jeszcze bardziej zwięzły. Nazwa
funkcja anonimowa dotyczy zarówno metod anonimowych, jak i wyrażeń lambda. Uży-
wam jej w wielu miejscach tej książki i występuje ona często w specyfikacji języka C#.
Jest wiele powodów, dla których projektanci języka włożyli dużo pracy w usprawnienie
tworzenia instancji delegatów. Jednak najważniejszym z tych powodów jest technologia
LINQ. Gdy przyjrzysz się wyrażeniom reprezentującym zapytania (podrozdział 3.7),
zobaczysz, że są one przekształcane na kod, w którym używane są wyrażenia lambda.
Możesz jednak używać technologii LINQ bez wyrażeń reprezentujących zapytania;
prawie zawsze wymaga to bezpośredniego stosowania wyrażeń lambda w kodzie.
Najpierw przyjrzyj się składni wyrażeń lambda, a następnie szczegółom ich dzia-
łania. Na zakończenie opisane są drzewa wyrażeń. Służą one do reprezentowania kodu
za pomocą danych.
Jednak zarówno lista parametrów, jak i ciało mogą mieć różne reprezentacje. W naj-
bardziej podstawowej postaci lista parametrów wyrażenia lambda wygląda jak lista
parametrów zwykłej lub anonimowej metody. Podobnie ciało wyrażenia lambda może
być blokiem — sekwencją instrukcji w nawiasie klamrowym. W takiej formie wyrażenie
lambda wygląda podobnie do metody anonimowej:
Action<string> action = (string message) =>
{
Console.WriteLine("W delegacie: {0}", message);
};
action("Komunikat");
Do tej pory nie widać istotnej różnicy. Słowo kluczowe delegate zostało zastąpione
sekwencją => i to wszystko. Jednak w specjalnych sytuacjach wyrażenia lambda mogą
być krótsze.
Zacznijmy od zapisania ciała w bardziej zwięzły sposób. Ciało, które składa się
z samej instrukcji return lub jednego wyrażenia, można zredukować do tego jednego
wyrażenia. Słowo kluczowe return (jeśli istnieje) jest wtedy usuwane. W tym przykła-
dzie ciało wyrażenia lambda to wywołanie metody, dlatego można je uprościć:
Action<string> action =
(string message) => Console.WriteLine("W delegacie: {0}", message);
87469504f326f0d7c1fcda56ef61bd79
8
3.5. Wyrażenia lambda 125
Możesz też skrócić listę parametrów, jeśli kompilator potrafi wywnioskować typy
parametrów na podstawie typu, na jaki chcesz przekształcić wyrażenie lambda. Wyra-
żenia lambda nie mają typu, ale można je przekształcić na zgodny typ delegata, a kom-
pilator często potrafi w ramach tej konwersji wywnioskować typy parametrów.
Na przykład we wcześniejszym kodzie kompilator potrafi wykryć, że typ Action
<string> ma jeden parametr typu string, może więc wywnioskować typ parametru
wyrażenia lambda. Przykład można zatem skrócić:
Action<string> action =
(message) => Console.WriteLine("W delegacie: {0}", message);
Przyjrzyj się teraz kilku przykładom ze zwracaniem wartości. W każdej sytuacji wyko-
nywane są poszczególne kroki, aby skrócić zapis. Najpierw zobacz, jak utworzyć dele-
gat mnożący dwie liczby całkowite i zwracający wynik:
Func<int, int, int> multiply =
(int x, int y) => { return x * y; }; Najdłuższa postać.
Użycie ciała w postaci
Func<int, int, int> multiply = (int x, int y) => x * y; wyrażenia.
Teraz użyty zostanie delegat, który przyjmuje długość łańcucha znaków, podnosi ją do
kwadratu i zwraca wynik:
Func<string, int> squareLength = (string text) => Najdłuższa postać.
{
int length = text.Length;
return length * length;
};
Func<string, int> squareLength = text => Usunięcie nawiasu wokół jedynego parametru.
{
int length = text.Length;
return length * length;
};
Na razie nie można zrobić nic więcej, ponieważ ciało obejmuje dwie instrukcje
87469504f326f0d7c1fcda56ef61bd79
8
126 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
Jest to jednak zmiana innego rodzaju niż wcześniejsze. Modyfikowane jest tu działanie
kodu (choć w niewielkim stopniu) zamiast składni. Uwzględnianie tak wielu specjalnych
przypadków może wydawać się dziwne, jednak w praktyce wszystkie one występują
dość często — zwłaszcza w technologii LINQ. Teraz, skoro już rozumiesz składnię,
możesz przyjrzeć się działaniu instancji delegata, a głównie przechwytywaniu zmiennych.
class CapturedVariablesDemo
{
private string instanceField = "pole instancji";
1
Wyrażenia lambda można pisać w konstruktorach, akcesorach właściwości itd. Jednak dla uprosz-
czenia przyjmijmy, że są pisane w metodach.
87469504f326f0d7c1fcda56ef61bd79
8
3.5. Wyrażenia lambda 127
W innym kodzie:
var demo = new CapturedVariablesDemo();
Action<string> action = demo.CreateAction("argument metody");
action("argument wyrażenia lambda");
2
Będę powtarzał to wielokrotnie i nie będę za to przepraszał. Jeśli dopiero poznajesz przechwytywane
zmienne, będziesz potrzebował czasu na przyzwyczajenie się do ich działania.
87469504f326f0d7c1fcda56ef61bd79
8
128 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
Na listingu 3.7 pokazana jest opisana klasa zagnieżdżona i sposób jej użycia w metodzie
CreateAction.
87469504f326f0d7c1fcda56ef61bd79
8
3.5. Wyrażenia lambda 129
87469504f326f0d7c1fcda56ef61bd79
8
130 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
{ Deklaracja zmiennej
string text = string.Format("komunikat {0}", i); lokalnej w pętli.
actions.Add(() => Console.WriteLine(text)); Przechwytywanie zmiennej
} w wyrażeniu lambda.
return actions;
}
W innym kodzie:
List<Action> actions = CreateActions();
foreach (Action action in actions)
{
action();
}
Bardzo istotne jest to, że zmienna text jest zadeklarowana w pętli. Każde dojście do
tej deklaracji powoduje utworzenie instancji zmiennej. Każde wyrażenie lambda
przechwytuje inną instancję tej zmiennej. Powstaje więc pięć różnych zmiennych text,
a każda z nich jest przechwytywana osobno. Są to zupełnie niezależne zmienne. Choć
ten kod nie modyfikuje ich po początkowym przypisaniu wartości, można zmienić je
albo w wyrażeniu lambda, albo w innym miejscu pętli. Zmodyfikowanie jednej zmien-
nej nie wpływa na pozostałe.
Kompilator uwzględnia takie działanie, tworząc odrębne instancje wygenerowanego
typu dla każdej instancji zmiennej. Dlatego metodę CreateAction z listingu 3.8 można
przekształcić na kod z listingu 3.9.
Listing 3.9. Tworzenie wielu instancji kontekstu (po jednej dla każdej instancji zmiennej)
87469504f326f0d7c1fcda56ef61bd79
8
3.5. Wyrażenia lambda 131
W innym kodzie:
List<Action> actions = CreateCountingActions();
actions[0]();
actions[0]();
Każdy delegat jest wywoływany dwukrotnie.
actions[1]();
actions[1]();
Dwa pierwsze wiersze są wyświetlane przez pierwszy delegat. Dwa ostatnie wiersze
wyświetla drugi delegat. Zgodnie z opisem sprzed listingu oba delegaty używają tego
samego licznika outerCounter, ale innych liczników innerCounter.
87469504f326f0d7c1fcda56ef61bd79
8
132 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
Jak kompilator traktuje taki kod? Każdy delegat wymaga własnego kontekstu, w któ-
rym jednak trzeba używać wspólnego kontekstu. Kompilator tworzy więc dwie pry-
watne klasy zagnieżdżone zamiast jednej. Na listingu 3.11 pokazane jest, jak kompi-
lator traktuje kod z listingu 3.10.
Rzadko będziesz musiał analizować wygenerowany kod tego rodzaju, jednak może on
być istotny, jeśli chodzi o wydajność programu. Jeśli używasz wyrażenia lambda
w kodzie, w którym wydajność jest wysoce istotna, powinieneś wiedzieć, ile obiektów
zostanie utworzonych na potrzeby zmiennych przechwytywanych w tym wyrażeniu.
Mógłbym podać więcej przykładów z wieloma wyrażeniami lambda w tym samym
zasięgu, które przechwytują różne zestawy zmiennych, lub z wyrażeniami lambda
w metodach typów prostych. Moim zdaniem analizowanie kodu wygenerowanego przez
kompilator jest fascynujące, jednak zapewne nie chcesz, aby cała książka była poświę-
87469504f326f0d7c1fcda56ef61bd79
8
3.5. Wyrażenia lambda 133
cona temu zagadnieniu. Jeśli zastanawiasz się, jak kompilator traktuje określone wyra-
żenie lambda, możesz łatwo uruchomić dla wygenerowanego kodu dekompilator lub
narzędzie ildasm.
Do tej pory opisane zostało tylko przekształcanie wyrażeń lambda na delegaty,
a podobny efekt można osiągnąć także za pomocą metod anonimowych. Wyrażenia
lambda mają jednak inną supermoc — można je przekształcać na drzewa wyrażeń.
Choć są to tylko dwa wiersze kodu, wykonywanych jest wiele operacji. Zacznijmy od
danych wyjściowych. Jeśli spróbujesz wyświetlić zwykły delegat, wynikiem będzie sam
typ (bez informacji o jego działaniu). Dane wyjściowe z listingu 3.12 ilustrują jednak
operacje wykonywane przez drzewo wyrażenia:
(x, y) => x + y
87469504f326f0d7c1fcda56ef61bd79
8
134 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
bazowa dla wszystkich innych typów drzew wyrażeń. Jest ona używana jako wygodny
kontener na metody fabryczne służące do tworzenia instancji konkretnych klas
pochodnych.
Typ zmiennej adder to drzewo wyrażeń reprezentujące funkcję, która przyjmuje
dwie liczby całkowite i zwraca liczbę całkowitą. Dalej do przypisania wartości do tej
zmiennej używane jest wyrażenie lambda. Kompilator generuje kod, aby utworzyć
odpowiednie drzewo wyrażenia w czasie wykonywania programu. Tu ten kod jest dość
prosty. Możesz samodzielnie go napisać, co pokazane jest na listingu 3.13.
Listing 3.13. Ręcznie napisany kod, który tworzy drzewo wyrażenia reprezentujące
dodawanie dwóch liczb całkowitych
Ten kod jest dość krótki, ale i tak znacznie dłuższy niż wyrażenie lambda. Jeśli dodasz
wywołania metod, dostęp do właściwości, inicjalizatory obiektów i inne elementy, kod
stanie się skomplikowany i narażony na błędy. To dlatego tak ważne jest, że kompi-
lator za programistę przekształca wyrażenia lambda w drzewa wyrażeń. Związanych
z tym jest kilka reguł.
OGRANICZENIA PRZEKSZTAŁCANIA WYRAŻEŃ LAMBDA
W DRZEWA WYRAŻEŃ
Najpoważniejsze ograniczenie dotyczy tego, że na drzewa wyrażeń można przekształ-
cać tylko wyrażenia lambda z ciałem w postaci wyrażenia. Choć wcześniejsze wyra-
żenie lambda (x, y) => x + y spełnia ten warunek, poniższy kod spowoduje błąd
kompilacji:
Expression<Func<int, int, int>> adder = (x, y) => { return x + y; };
Interfejs API drzew wyrażeń został rozbudowany od wersji .NET 3.5 o obsługę bloków
i innych konstrukcji. Jednak w kompilatorze C# nadal obowiązuje opisane ograniczenie
i jest ono spójne z zastosowaniem drzew wyrażeń w technologii LINQ. Jest to jeden
z powodów, dla których inicjalizatory obiektów i kolekcji są tak istotne. Umożliwiają
one zapis inicjowania w jednym wyrażeniu, co pozwala umieścić taki kod w drzewie
wyrażenia.
Ponadto w przekształcanym wyrażeniu lambda nie można używać operatora przy-
pisania, dynamicznego typowania z C# 4 i mechanizmów asynchronicznych z C# 5.
(Wprawdzie w inicjalizatorach obiektów i kolekcji używany jest symbol =, ale w tym
kontekście nie oznacza on operatora przypisania).
87469504f326f0d7c1fcda56ef61bd79
8
3.6. Metody rozszerzające 135
87469504f326f0d7c1fcda56ef61bd79
8
136 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
using System;
namespace NodaTime.Extensions
{
public static class DateTimeOffsetExtensions
{
public static Instant ToInstant(this DateTimeOffset dateTimeOffset)
{
return Instant.FromDateTimeOffset(dateTimeOffset);
}
}
}
87469504f326f0d7c1fcda56ef61bd79
8
3.6. Metody rozszerzające 137
namespace CSharpInDepth.Chapter03
{
class ExtensionMethodInvocation
{
static void Main()
{
var currentInstant =
DateTimeOffset.UtcNow.ToInstant(); Wywołanie metody rozszerzającej.
Console.WriteLine(currentInstant);
}
}
}
3
Jeśli w trakcie lektury śledzisz pobrany kod, może zauważyłeś, że przykładowy kod dla uproszczenia
umieszczony jest w przestrzeniach nazw Chapter01, Chapter02 itd. Tu zrobiłem wyjątek, aby poka-
zać hierarchiczny charakter procesu sprawdzania przestrzeni nazw.
87469504f326f0d7c1fcda56ef61bd79
8
138 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
Zwróć uwagę na kolejność wywołań Where, OrderBy i Select w kodzie. Odpowiadają one
kolejności wykonywania operacji. Ponieważ technologia LINQ działa w leniwy sposób
i usprawnia wszystkie możliwe działania, trudno jest wyjaśnić, kiedy wykonywane są
poszczególne operacje. Jednak zapytanie jest wczytywane zgodnie z kolejnością wyko-
nywania operacji. Na listingu 3.18 pokazane jest to samo zapytanie, ale bez wykorzy-
stania tego, że używane są metody rozszerzające.
87469504f326f0d7c1fcda56ef61bd79
8
3.6. Metody rozszerzające 139
Sformatowałem listing 3.18 w tak czytelny sposób, jak potrafiłem, jednak kod nadal
wygląda okropnie. Wywołania są umieszczone w kodzie źródłowym w kolejności odwrot-
nej do ich wykonywania. Jako pierwsza wykonywana jest metoda Where, która jest
ostatnim wywołaniem na listingu. Ponadto nie jest oczywiste, które wyrażenie lambda
jest powiązane z poszczególnymi wywołaniami. Człon word => word.ToUpper() jest czę-
ścią wywołania Select, jednak między tymi dwoma fragmentami tekstu znajduje się
bardzo dużo kodu.
Możesz spróbować rozwiązać problem w inny sposób i przypisywać wynik wywo-
łania każdej metody do zmiennej lokalnej, a następnie wywoływać metodę przy użyciu
tej zmiennej (zobacz listing 3.19). W tej sytuacji można też najpierw tylko zadeklarować
zapytanie i przypisywać nowe wartości w każdym wierszu, jednak nie zawsze tak jest.
Aby kod był zwięzły, używane jest tu słowo var.
Ta wersja jest lepsza niż kod z listingu 3.18. Operacje ponownie znajdują się we wła-
ściwej kolejności i jest oczywiste, które wyrażenie lambda jest używane dla poszcze-
gólnych działań. Jednak dodatkowe deklaracje zmiennych lokalnych rozpraszają i łatwo
jest użyć błędnej zmiennej.
Zalety łączenia metod w łańcuch nie ograniczają się oczywiście do technologii LINQ.
Używanie wyniku jednego wywołania jako punktu wyjścia dla następnego wywołania
to często stosowana technika. Jednak metody rozszerzające pozwalają tworzyć łańcuchy
w czytelny sposób dla dowolnego typu; dzięki temu nie trzeba w samym typie dekla-
rować metod obsługujących tworzenie łańcuchów. Interfejs IEnumerable<T> nic nie wie
na temat technologii LINQ. Jego jedynym zadaniem jest reprezentowanie ogólnej
sekwencji. To klasa System.Linq.Enumerable dodaje wszystkie operacje na potrzeby
filtrowania, grupowania, złączania itd.
Twórcy wersji C# 3 mogliby poprzestać na tym, co zostało opisane do tej pory.
Przedstawione już mechanizmy znacznie zwiększają możliwości języka i umożliwiają
zapis wielu zapytań LINQ w czytelny sposób. Jednak gdy zapytania są bardziej złożone,
a przede wszystkim wymagają złączeń i grupowania, bezpośrednie używanie metod
rozszerzających może być skomplikowane. Poznaj wyrażenia reprezentujące zapytania.
87469504f326f0d7c1fcda56ef61bd79
8
140 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
Na listingu 3.20 pokazane jest to samo zapytanie zapisane w formie wyrażenia repre-
zentującego zapytanie.
87469504f326f0d7c1fcda56ef61bd79
8
3.7. Wyrażenia reprezentujące zapytania 141
jących, jednak specyfikacja języka tego nie wymaga. Używane mogą być też wywołania
metod instancji lub wywołania delegatów zwróconych przez właściwości o nazwach
Select, Where itd.
Zgodnie ze specyfikacją wyrażeń reprezentujących zapytania oczekuje się, że dostępne
będą określone metody. Nie ma jednak wymogu, by wszystkie te metody były obecne.
Na przykład jeśli napiszesz interfejs API z odpowiednimi metodami Select, OrderBy
i Where, będziesz mógł używać zapytania takiego jak na listingu 3.20, choć nie będzie
możliwe tworzenie zapytań z klauzulą join.
Choć nie znajdziesz tu szczegółowego omówienia wszystkich klauzul dostępnych
w wyrażeniach reprezentujących zapytania, chcę zwrócić Twoją uwagę na dwa powią-
zane zagadnienia. Lepiej uzasadniają one dodanie wyrażeń reprezentujących zapytania
przez projektantów języka.
Taki kod jest łatwy do zrozumienia, gdy istnieje tylko jedna zmienna zakresu. Jednak
początkowa klauzula from nie jest jedynym sposobem podawania zmiennych zakresu.
Najprostsza klauzula służąca do podawania nowej zmiennej zakresu to prawdopo-
dobnie let. Załóżmy, że chcesz użyć długości słowa w wielu miejscach zapytania bez
konieczności wywoływania za każdym razem właściwości Length. Możesz np. posorto-
wać dane na podstawie długości (klauzula orderby) i dodać długość w danych wyjścio-
wych. Klauzula let pozwala napisać zapytanie takie jak na listingu 3.21.
Listing 3.21. Klauzula let, w której wprowadzana jest nowa zmienna zakresu
87469504f326f0d7c1fcda56ef61bd79
8
142 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
Nazwa tmp nie jest częścią procesu przekształcania zapytania. W specyfikacji używany
jest symbol * i nie jest określone, jaką nazwę należy nadać parametrowi w trakcie gene-
rowania drzewa wyrażenia reprezentującego zapytanie. Ta nazwa nie ma znaczenia,
ponieważ nie jest widoczna w trakcie pisania zapytania. Dlatego jest nazywana prze-
zroczystym identyfikatorem.
Nie zamierzam szczegółowo opisywać tu przekształcania zapytania. Temu zagad-
nieniu można poświęcić cały rozdział. Chciałem jednak wspomnieć o przezroczystych
identyfikatorach z dwóch powodów. Po pierwsze, jeśli wiesz, w jaki sposób wprowa-
dzane są dodatkowe zmienne zakresu, nie będziesz zaskoczony, gdy zobaczysz je po
dekompilacji wyrażenia reprezentującego zapytanie. Po drugie według mojego doświad-
czenia takie identyfikatory są najważniejszym powodem stosowania wyrażeń repre-
zentujących zapytania.
Oba zapisy po kompilacji dadzą ten sam kod5. Jednak na potrzeby prostego zapytania
wybrałbym drugą składnię.
UWAGA. Nie istnieje jedno pojęcie reprezentujące to, że nie jest używana składnia wyra-
żenia reprezentującego zapytanie. Zetknąłem się z określeniami składnia metod, składnia
z kropką, składnia płynna i składnia wyrażeń lambda. Tu konsekwentnie używam nazwy skład-
nia metod, jeśli jednak natrafisz na inne pojęcie, nie próbuj szukać drobnych różnic zna-
czeniowych.
5
Kompilator w specjalny sposób traktuje klauzule select, które pobierają tylko bieżący element
zapytania.
87469504f326f0d7c1fcda56ef61bd79
8
3.8. Efekt końcowy — technologia LINQ 143
Nawet gdy zapytanie staje się bardziej skomplikowane, składnia metod może zapew-
niać więcej elastyczności. W LINQ dostępnych jest wiele metod, dla których nie istnieje
odpowiednik ze składnią wyrażeń reprezentujących zapytania. Dotyczy to m.in. prze-
ciążonych wersji metod Select i Where, w których używany jest zarówno indeks ele-
mentu w sekwencji, jak i sam element. Ponadto jeśli chcesz dodać wywołanie metody
na końcu zapytania (np. ToList() w celu zapisania wyniku jako obiektu typu List<T>),
musisz umieścić całe wyrażenie reprezentujące zapytanie w nawiasie. Składnia metod
pozwala dodać kolejne wywołanie na końcu całego wyrażenia.
Nie jestem tak negatywnie nastawiony do wyrażeń reprezentujących zapytania, jak
może się to wydawać. W wielu sytuacjach nie ma dużej różnicy między obiema moż-
liwościami. Dotyczy to także wcześniejszego przykładu z filtrowaniem, sortowaniem
i projekcją. Wyrażenia reprezentujące zapytania są naprawdę przydatne, gdy kompila-
tor wykonuje za programistę dodatkowe zadania z użyciem przezroczystych identyfi-
katorów. Oczywiście możesz uzyskać podobny efekt ręcznie, jednak moim zdaniem
tworzenie typów anonimowych na potrzeby wyników i przetwarzanie tych typów w każ-
dym kolejnym kroku szybko staje się irytujące. Wyrażenia reprezentujące zapytania
znacznie ułatwiają pracę.
W podsumowaniu chcę napisać, że gorąco zachęcam do opanowania obu stylów
pisania zapytań. Jeśli będziesz zawsze używał wyrażeń reprezentujących zapytania lub
nigdy nie będziesz z nich korzystał, stracisz okazje do poprawy czytelności kodu.
Wszystkie nowe mechanizmy z C# 3 zostały już opisane, warto jednak nabrać dystansu
i pokazać, jak współdziałają one, tworząc technologię LINQ.
87469504f326f0d7c1fcda56ef61bd79
8
144 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane
Jeśli pominiesz którykolwiek z tych mechanizmów, technologia LINQ stanie się dużo
mniej przydatna. Oczywiście, gdyby drzewa wyrażeń były niedostępne, mógłbyś prze-
twarzać kolekcję w pamięci. Bez wyrażeń reprezentujących zapytania także mógłbyś
pisać czytelne proste zapytania. Gdyby nie istniały metody rozszerzające, mógłbyś uży-
wać specjalnych klas z wszystkimi potrzebnymi metodami. Jednak wszystkie dostępne
mechanizmy wspaniale się dopełniają.
Podsumowanie
Wszystkie nowe mechanizmy z C# 3 są w jakiś sposób powiązane z pracą
z danymi, a większość z tych funkcji to kluczowe elementy technologii LINQ.
Automatycznie implementowane właściwości umożliwiają zwięzłe udostępnianie
stanu, który nie wymaga dodatkowych operacji.
Niejawne typowanie z użyciem słowa kluczowego var (także w tablicach) jest
niezbędne do korzystania z typów anonimowych, a także umożliwia wyelimino-
wanie powtórzeń długich nazw.
Inicjalizatory obiektów i kolekcji sprawiają, że inicjowanie tych elementów jest
prostsze i bardziej czytelne. Ponadto można inicjować te elementy w jednym
wyrażeniu, co jest niezbędne przy korzystaniu z innych aspektów technologii
LINQ.
Typy anonimowe umożliwiają proste tworzenie typu używanego lokalnie w jed-
nym celu.
Wyrażenia lambda pozwalają tworzyć delegaty w jeszcze prostszy sposób niż
z użyciem metod anonimowych. Umożliwiają też zapis kodu w formie danych
w drzewach wyrażeń, które mogą być używane przez dostawców LINQ do prze-
kształcania zapytań z języka C# na inną postać, np. na kod w SQL-u.
Metody rozszerzające to statyczne metody, które można wywoływać tak, jakby
były metodami instancji. Pozwala to pisać płynne interfejsy nawet dla typów,
których pierwotnie nie zaprojektowano z myślą o takim rozwiązaniu.
Wyrażenia reprezentujące zapytania są przekształcane na inny kod w C#,
w którym zapytanie jest zapisane za pomocą wyrażeń lambda. Choć technika
ta świetnie sprawdza się dla złożonych zapytań, proste zapytania często łatwiej
jest zapisać za pomocą składni metod.
87469504f326f0d7c1fcda56ef61bd79
8
Zwiększanie współdziałania
z innymi technologiami
Zawartość rozdziału:
Używanie dynamicznego typowania na potrzeby
współdziałania z innym kodem i uproszczenia
mechanizmu refleksji
Udostępnianie wartości domyślnych parametrów,
aby jednostka wywołująca nie musiała ich podawać
Określanie nazw argumentów, aby wywołania były
bardziej przejrzyste
Usprawnione pisanie kodu z użyciem bibliotek COM
Przekształcanie między typami generycznymi
z użyciem generycznej wariancji
87469504f326f0d7c1fcda56ef61bd79
8
146 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
Jak na tak krótki kod dzieje się tu wiele rzeczy. Najważniejsze jest to, że ten kod się
skompiluje. Jeśli zmienisz pierwszy wiersz i zadeklarujesz zmienną text typu string,
wywołanie SUBSTR zakończy się niepowodzeniem w czasie kompilacji. Pierwotny kod
87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 147
kompilator skompiluje, nawet nie szukając metody o nazwie SUBSTR. Nie sprawdza też
dostępności metody Substring. Wyszukiwanie obu tych metod ma miejsce w czasie
wykonywania programu.
W trakcie wykonywania programu w drugim wierszu szukana jest metoda Substring,
którą można wywołać z argumentem równym 7. Taka metoda zostaje znaleziona; zwraca
ona łańcuch znaków przypisywany do zmiennej world i wyświetlany w standardowy spo-
sób. Z kolei próba znalezienia metody SUBSTR, którą można wywołać z argumentem
równym 7, kończy się niepowodzeniem i zgłoszeniem wyjątku RuntimeBinderException.
W rozdziale 3. wspomniałem, że proces określania znaczenia nazwy w danym kon-
tekście to wiązanie (ang. binding). Dynamiczne typowanie zmienia moment wiązania
z czasu kompilacji na czas wykonywania programu. Zamiast generować kod pośredni,
który wywołuje metodę o sygnaturze ustalanej precyzyjnie w czasie wykonywania kodu,
kompilator generuje kod pośredni odpowiedzialny za wiązanie i działający na podsta-
wie skutków tego procesu. Dzieje się tak dzięki zastosowaniu typu dynamic.
CZYM JEST TYP DYNAMIC?
Na listingu 4.1 zmienna text jest zadeklarowana jako typu dynamic:
dynamic text = "Witaj, świecie";
Czym jest typ dynamic? Różni się on od innych typów z C#, ponieważ istnieje tylko
w tym języku. Nie jest powiązany z żadnym obiektem typu System.Type. Ponadto nie
jest znany środowisku CLR. Gdy używasz słowa dynamic w C#, w kodzie pośrednim
używany jest typ object opatrzony w razie potrzeby atrybutem [Dynamic].
UWAGA. Jeśli typ dynamic występuje w sygnaturze metody, kompilator musi udostępnić
informację o tym kodowi kompilowanemu z użyciem tej metody. Takich informacji nie trzeba
udostępniać, gdy typ dynamic jest używany dla zmiennej lokalnej.
Dalej poznasz wyjątki od dwóch ostatnich reguł. Na podstawie tej listy możesz z nowej
perspektywy przyjrzeć się listingowi 4.1. Rozważ dwa pierwsze wiersze:
dynamic text = "Witaj, świecie";
string world = text.Substring(7);
87469504f326f0d7c1fcda56ef61bd79
8
148 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
Add("text");
Wywołanie metody z różnymi
Add(10);
wartościami.
Add(TimeSpan.FromMinutes(45));
Każdy rodzaj dodawania jest sensowny dla używanego typu. Jednak gdyby zastoso-
wać typowanie statyczne, operacje te wyglądałyby inaczej. Oto ostatni przykład. Na
listingu 4.3 pokazane jest, jak działa przeciążanie metod z dynamicznie określanym
typem argumentów.
87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 149
{
Console.WriteLine("Metoda z parametrem typu decimal");
}
CallMethod(10);
CallMethod(10.5m); Pośrednie wywołanie metody SampleMethod
CallMethod(10L); z użyciem różnych typów.
CallMethod("text");
87469504f326f0d7c1fcda56ef61bd79
8
150 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 151
87469504f326f0d7c1fcda56ef61bd79
8
152 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
Jeśli znasz platformę Entity Framework lub inny podobny system ORM, opisane
rozwiązanie może nie wydawać Ci się niczym nowym. Możesz stosunkowo łatwo pisać
klasy, które umożliwiają tworzenie podobnego kodu zapytań. Możesz też generować
takie klasy na podstawie schematu bazy danych. Różnica polega na tym, że tu wszystko
dzieje się dynamicznie — nie istnieje klasa Book ani BooksTable. Wszystko ma miejsce
w czasie wykonywania programu. W punkcie 4.1.5 wyjaśniam, czy zwykle jest to
korzystne. Mam jednak nadzieję, że potrafisz dostrzec, iż opisany mechanizm w nie-
których sytuacjach będzie przydatny.
Zanim omówię typy, które umożliwiają opisany proces, przyjrzyj się dwóm zaim-
plementowanym przykładom. Najpierw opisany jest typ z platformy, a dalej przedsta-
wiona jest biblioteka Json.NET.
EXPANDOOBJECT — DYNAMICZNY ZBIÓR DANYCH I METOD
Platforma .NET udostępnia w przestrzeni nazw System.Dynamic typ ExpandoObject. Działa
on w dwóch trybach zależnie od tego, czy chcesz używać go jako dynamicznej wartości.
Na listingu 4.5 pokazany jest krótki przykład, który pomoże zrozumieć dalszy opis.
Console.WriteLine(expando.SomeData);
Dynamiczny dostęp do danych i delegata.
expando.FakeMethod("witaj");
87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 153
sam sposób jak zwykły słownik. Tego typu można używać w ten sposób, wyszukując
klucze podane w czasie wykonywania programu i wykonując podobne operacje.
Ważniejsze jest jednak to, że ten typ implementuje też interfejs IDynamicMetaObject
Provider. Ten interfejs to punkt wyjścia do operacji dynamicznych (zapoznasz się
z nim później). Typ ExpandoObject implementuje ten interfejs, aby umożliwić dostęp
do kluczy słownika za pomocą nazw. Gdy wywołujesz metodę obiektu typu Expando
Object w kontekście dynamicznym, nazwa tej metody jest wyszukiwana wśród kluczy
w słowniku. Jeśli wartość powiązana z danym kluczem jest delegatem o odpowiednich
parametrach, ten delegat jest wywoływany, a wynik jego uruchomienia jest używany
jako wynik wywołania metody.
Na listingu 4.5 zapisane są tylko jedna wartość danych i jeden delegat. Możesz
jednak zapisać wiele elementów o dowolnych nazwach. Omawiany typ to słownik, do
którego dostęp można dynamicznie uzyskać.
Dużą część wcześniejszego przykładu z bazą danych można zaimplementować za
pomocą typu ExpandoObject. Należy utworzyć jeden taki obiekt reprezentujący tabelę
Books, a następnie odrębne obiekty tego typu reprezentujące każdą książkę. W tabeli
powinien znajdować się klucz SearchByAuthor powiązany z odpowiednim delegatem
wykonującym zapytanie. Każda książka powinna mieć klucz Title z zapisanym tytu-
łem itd. Jednak w praktyce programiści częściej bezpośrednio implementują interfejs
IDynamicMetaObjectProvider lub używają typu DynamicObject. Przed omówieniem tych
typów warto przyjrzeć się kolejnej implementacji związanej z dynamicznym dostępem
do danych w formacie JSON.
DYNAMICZNE SPOJRZENIE NA BIBLIOTEKĘ JSON.NET
Format JSON jest ostatnio wszechobecny. Jedną z najpopularniejszych bibliotek do
używania i generowania dokumentów w tym formacie jest Json.NET1. Udostępnia ona
wiele sposobów obsługi danych w tym formacie. Można m.in. przetwarzać je bezpośred-
nio na klasy podane przez użytkownika lub na model obiektowy podobny do technologii
LINQ to XML. Ta ostatnia technika nosi nazwę LINQ to JSON i obejmuje typy takie jak
JObject, JArray i JProperty. Można jej używać podobnie jak technologii LINQ to XML,
korzystając z łańcuchów znaków. Można też zastosować mechanizmy dynamiczne.
Na listingu 4.6 pokazane są oba te podejścia dla tych samych danych w formacie JSON.
1
Oczywiście dostępne są też inne biblioteki dla formatu JSON. Ja akurat najlepiej znam właśnie
Json.NET.
87469504f326f0d7c1fcda56ef61bd79
8
154 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
Użyte dane w formacie JSON są proste, ale obejmują obiekt zagnieżdżony. W drugiej
połowie kodu pokazane jest, jak uzyskać dostęp do tego obiektu za pomocą indekserów
z technologii LINQ to JSON lub przy użyciu dynamicznego widoku.
Którą wersję preferujesz? Każde z tych podejść ma wady i zalety. Oba są narażone
na literówki — czy to w literałach tekstowych, czy to przy dynamicznym dostępie do
właściwości. Widok z typowaniem statycznym zwykle pozwala zapisać nazwy właści-
wości w stałych i wielokrotnie ich używać. Z kolei widok z typowaniem dynamicznym
jest bardziej czytelny w trakcie tworzenia prototypów. W punkcie 4.1.5 przedstawiam
wskazówki dotyczące tego, kiedy i gdzie warto stosować typowanie dynamiczne. Zanim
jednak tam dojdziesz, zastanów się nad swoimi pierwszymi reakcjami. Dalej pokrótce
opisane jest, jak samemu przygotować potrzebny kod.
IMPLEMENTOWANIE DYNAMICZNYCH OPERACJI WE WŁASNYM KODZIE
Dynamiczne operacje są skomplikowane, jednak trzeba je opanować, aby przejść dalej.
Nie oczekuj, proszę, że po lekturze tego podpunktu będziesz umiał napisać produk-
cyjny zoptymalizowany kod na potrzeby dowolnego fantastycznego pomysłu, na jaki
wpadniesz. To tylko punkt wyjścia. Jednak zaprezentowane tu informacje powinny
wystarczyć do eksploracji i eksperymentów, abyś mógł zdecydować, ile pracy chcesz
włożyć w poznanie wszystkich szczegółów.
Gdy prezentowałem typ ExpandoObject, wspomniałem, że implementuje on interfejs
IDynamicMetaObjectProvider. Ten interfejs określa, że obiekt implementuje własne
dynamiczne operacje, zamiast pozwalać na zwykłą pracę infrastrukturze opartej na
refleksji. Ten interfejs wygląda zwodniczo prosto:
public interface IDynamicMetaObjectProvider
{
DynamicMetaObject GetMetaObject(Expression parameter);
}
Złożoność kryje się w typie DynamicMetaObject. Jest to klasa sterująca wszystkimi innymi
elementami. W oficjalnej dokumentacji znajdziesz wskazówkę pomagającą zrozumieć,
na jakim poziomie musisz myśleć, pracując z tym typem:
87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 155
Parametr binder zapewnia informacje takie jak nazwa wywoływanej metody i to, czy
jednostka wywołująca oczekuje, że w procesie wiązania uwzględniana będzie wielkość
liter. Parametr args udostępnia argumenty podane przez jednostkę wywołującą; mają one
postać wartości typu DynamicMetaObject. Wynikiem jest następny obiekt tego typu,
reprezentujący, jak należy przetwarzać wywołanie metody. Wywołanie nie jest wyko-
nywane natychmiast; najpierw tworzone jest drzewo wyrażenia reprezentujące dzia-
łanie tego wywołania.
Wszystko to jest bardzo skomplikowane, jednak umożliwia wydajną obsługę zło-
żonych scenariuszy. Na szczęście nie musisz samodzielnie implementować interfejsu
IDynamicMetaObjectProvider. Ja też nie zamierzam tego robić w tym miejscu. Zamiast tego
przedstawię przykład zastosowania dużo wygodniejszego w użyciu typu DynamicObject.
Klasa DynamicObject działa jak klasa bazowa dla typów, w których implementacja
dynamicznych operacji ma być tak prosta, jak to możliwe. Wynikowy kod może okazać
się mniej wydajny niż bezpośrednia implementacja interfejsu IDynamicMetaObject
Provider, ale jest dużo łatwiejszy do zrozumienia.
W ramach prostego przykładu utworzona zostanie klasa SimpleDynamicExample
z następującymi dynamicznymi działaniami:
wywołanie dowolnej metody tej klasy powoduje wyświetlenie w konsoli komu-
nikatu z nazwą tej metody i jej argumentami,
pobranie właściwości zwykle powoduje zwrócenie nazwy tej właściwości z przed-
rostkiem dowodzącym, że operacja rzeczywiście jest dynamiczna.
87469504f326f0d7c1fcda56ef61bd79
8
156 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
Dalej opisany jest kod, jaki kompilator języka C# generuje po napotkaniu dynamicz-
nych wartości.
87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 157
będziesz musiał ich znać2. Dobra wiadomość jest taka, że kod jest dostępny jako opro-
gramowanie open source, dlatego jeśli to krótkie wprowadzenie Cię do tego zachęci,
możesz przeanalizować dostępne mechanizmy na dowolnym poziomie szczegółowości.
Najpierw zobaczysz, jakie podsystemy odpowiadają za poszczególne aspekty typowania
dynamicznego.
CO ROBIĄ POSZCZEGÓLNE PODSYSTEMY?
Zwykle gdy analizowany jest jakiś mechanizm języka C#, zadania w naturalny sposób
są dzielone między trzy narzędzia:
kompilator języka C#,
środowisko CLR,
biblioteki platformy.
Rysunek 4.1.
Graficzna reprezentacja
komponentów używanych
w typowaniu
dynamicznym
2
Szczerze mówiąc, nie znam szczegółów wystarczająco dobrze, aby opisać całe zagadnienie na wystar-
czającym poziomie.
87469504f326f0d7c1fcda56ef61bd79
8
158 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
UWAGA. Ta biblioteka jest udostępniana razem z platformą, ale nie należy do systemowych
bibliotek platformy. Lubię o niej myśleć jak o bibliotece od niezależnego producenta, przy
czym tym producentem jest akurat Microsoft. Jednak kompilator Microsoft C# jest mocno
powiązany z tą biblioteką, dlatego trudno określić, do której kategorii należałoby zaliczyć tę
bibliotekę.
using Microsoft.CSharp.RuntimeBinder;
using System;
using System.Runtime.CompilerServices;
class DynamicTypingDecompiled
{
private static class CallSites Zapisywanie miejsc wywołań.
{
public static CallSite<Func<CallSite, object, int, object>>
method;
87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 159
Przepraszam za to formatowanie. Starałem się z całych sił, aby przykład był czytelny,
jednak zawiera on dużo kodu z wieloma długimi nazwami. Dobra wiadomość jest taka,
że prawie na pewno nigdy nie zetkniesz się z takim kodem — chyba że z czystej cieka-
wości. Warto zauważyć, że typ CallSite znajduje się w przestrzeni nazw System.
Runtime.CompilerServices i jest niezależny od języka, natomiast klasa Binder pochodzi
z przestrzeni nazw Microsoft.CSharp.RuntimeBinder.
Łatwo zauważyć, że występuje tu wiele miejsc wywołań. Każde miejsce wywołania
jest zapisywane przez wygenerowany kod w pamięci podręcznej. Ponadto środowisko
DLR obejmuje wiele poziomów pamięci podręcznej. Wiązanie to dość skomplikowany
proces. Pamięć podręczna dla każdego miejsca wywołania pozwala poprawić wydajność,
ponieważ wynik każdego wiązania jest zapisywany i nie trzeba ponownie wykonywać
87469504f326f0d7c1fcda56ef61bd79
8
160 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
zadań, przy czym wiadomo, że dla tego samego wywołania może zostać zastosowane
inne wiązanie, jeśli zmieni się kontekst.
W efekcie powstaje zaskakująco wydajny system. Kod nie działa równie wydajnie
jak kod z typowaniem statycznym, ale jest niewiele wolniejszy. Spodziewam się, że
w większości miejsc, gdzie typowanie dynamiczne jest właściwym wyborem z innych
przyczyn, wydajność nie będzie ograniczeniem. W ramach podsumowania typowania
dynamicznego chcę wyjaśnić kilka ograniczeń, na które możesz natrafić, a następnie
przedstawić kilka wskazówek dotyczących tego, gdzie i w jaki sposób warto stosować
typowanie dynamiczne.
METODY ROZSZERZAJĄCE
Binder używany w czasie wykonywania programu nie wybiera metod rozszerzających.
Można sobie wyobrazić, że wykonywałby to zadanie, ale potrzebowałby do tego dodat-
kowych informacji na temat każdej dyrektywy using uwzględnianej w miejscu wywo-
87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 161
łania każdej metody. Należy zauważyć, że nie dotyczy to statycznie wiązanych wywołań,
w których argumentem określającym typ jest dynamic. Dlatego np. kod z listingu 4.10
zostanie skompilowany i wykonany bez problemów.
Listing 4.11. Próba wywołania metody rozszerzającej dla obiektu docelowego typu
dynamic
Nie pokazałem tu danych wyjściowych, ponieważ kod nie dochodzi do etapu ich gene-
rowania. Wcześniej zgłasza wyjątek RuntimeBinderException, ponieważ typ List<T> nie
udostępnia metody Any.
Jeśli chcesz wywoływać metodę rozszerzającą w taki sposób, jakby obiektem doce-
lowym była wartość dynamiczna, musisz użyć zwykłego, statycznego wywołania. Możesz
np. przekształcić ostatni wiersz listingu 4.11 na następującą postać:
bool result = Enumerable.Any(source);
87469504f326f0d7c1fcda56ef61bd79
8
162 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
Po drugie z tego samego powodu wyrażenia lambda nie mogą występować w operacjach
wiązanych dynamicznie. To dlatego na listingu 4.11 nie użyłem wywołania Select
do zademonstrowania problemu z metodami rozszerzającymi. W przeciwnym razie
listing 4.11 wyglądałby tak:
dynamic source = new List<dynamic>
{
5,
2.75,
TimeSpan.FromSeconds(45)
};
dynamic result = source.Select(x => x * 2);
Wiesz już, że ten kod nie zadziała na etapie wykonywania programu, ponieważ nie-
możliwe będzie znalezienie metody rozszerzającej Select. Co więcej, z powodu użycia
wyrażenia lambda ten kod nawet się nie skompiluje. Rozwiązanie problemu z czasu
kompilacji jest takie samo jak wcześniej — wystarczy zrzutować wyrażenie lambda na
typ delegata lub przypisać je najpierw do zmiennej z typowaniem statycznym. W przy-
padku metod rozszerzających (takich jak Select) i tak wystąpi wtedy błąd w czasie
wykonywania programu, jednak takie rozwiązanie będzie poprawne dla zwykłych
metod (takich jak List<T>.Find).
Wyrażenia lambda przekształcane na drzewa wyrażeń nie mogą zawierać żadnych
operacji dynamicznych. Może się to wydawać dziwne, ponieważ środowisko DLR
używa wewnętrznie drzew wyrażeń, jednak to ograniczenie rzadko stanowi problem.
W większości sytuacji, gdy drzewa wyrażeń są przydatne, nie jest jasne, jak typowanie
dynamiczne miałoby działać lub jak je zaimplementować.
W ramach przykładu można spróbować zmodyfikować listing 4.10 i zastosować
zmienną source z typowaniem statycznym oraz interfejs IQueryable<T>, co pokazane jest
na listingu 4.12.
87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 163
W tym listingu występują dwa typy anonimowe, jednak w procesie wiązania nie jest
istotne, czy typ jest anonimowy. Sprawdzana jest natomiast możliwość dostępu do
znalezionych właściwości. Jeśli podzielisz ten kod między dwa podzespoły, wystąpi
problem. Binder wykryje, że typ anonimowy jest wewnętrznym typem podzespołu,
w którym został utworzony, i zgłosi wyjątek RuntimeBinderException. Jeśli natrafisz na
ten problem i możesz zastosować atrybut [InternalsVisibleTo], aby umożliwić pod-
zespołowi, gdzie wykonywane jest wiązanie dynamiczne, dostęp do podzespołu, w któ-
rym tworzony jest typ anonimowy, jest to akceptowalne rozwiązanie.
JAWNA IMPLEMENTACJA INTERFEJSU
Binder działający w czasie wykonywania programu używa typu wartości dynamicznych
z czasu pracy kodu, a następnie przeprowadza wiązanie w taki sam sposób, jakbyś
podał typ zmiennej w czasie kompilacji. Niestety, nie współdziała to dobrze z istniejącym
87469504f326f0d7c1fcda56ef61bd79
8
164 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
Typ List<T> implementuje interfejs IList. Ten interfejs obejmuje właściwość IsFixedSize,
która jest jawnie zaimplementowana w klasie List<T>. Próba dostępu do tej właściwości
za pomocą wyrażenia o typie statycznym List<T> spowoduje błąd kompilacji. Możesz
uzyskać dostęp do tej właściwości za pomocą wyrażenia o typie statycznym IList,
jednak wynikiem zawsze będzie false. A co z dostępem dynamicznym? Binder zawsze
używa typu konkretnego wartości dynamicznej, dlatego nie znajdzie omawianej wła-
ściwości i zgłosi wyjątek RuntimeBinderException. Rozwiązanie polega na przekształ-
ceniu wartości dynamicznej ponownie na interfejs (za pomocą rzutowania lub odrębnej
zmiennej), jeśli wiesz, że chcesz użyć składowej z interfejsu.
Jestem pewien, że każdy, kto regularnie korzysta z typowania dynamicznego, potrafi
przedstawić długą listę zagmatwanych przypadków brzegowych. Jednak opisane zagad-
nienia powinny uchronić Cię przed zbyt częstym zaskoczeniem. Omawianie typowania
dynamicznego kończę krótkim poradnikiem dotyczącym tego, kiedy i jak stosować ten
mechanizm.
87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 165
87469504f326f0d7c1fcda56ef61bd79
8
166 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
zawsze tak się dzieje. Jeżeli w każdym typie składowa jest zadeklarowana niezależnie
(i nie możesz tego zmienić), pozostają Ci same niewygodne rozwiązania.
Nie musisz wtedy stosować refleksji, ale potrzebny może być szereg powtarzających
się kroków obejmujących sprawdzanie typu, rzutowanie i dostęp do składowej. Wzorce
z C# 7 znacznie upraszczają to zadanie, jednak kod nadal może być powtarzalny.
Zamiast tego możesz posłużyć się typowaniem dynamicznym, aby wyrazić następującą
myśl: „Zaufaj mi, wiem, że ta składowa będzie dostępna, choć nie potrafię tego zapisać
z użyciem typowania statycznego”. Nie mam problemów ze stosowaniem takiego
podejścia w testach (gdzie kosztem pomyłki jest jedynie niepowodzenie testu), jednak
w kodzie produkcyjnym byłbym dużo bardziej ostrożny.
UŻYWANIE BIBLIOTEKI ZBUDOWANEJ Z WYKORZYSTANIEM
TYPOWANIA DYNAMICZNEGO
Ekosystem .NET jest bogaty i wciąż rozbudowywany. Programiści rozwijają różnego
rodzaju ciekawe biblioteki i podejrzewam, że w niektórych z nich używane jest typo-
wanie dynamiczne. Potrafię sobie wyobrazić bibliotekę, która umożliwia łatwe tworzenie
prototypów z użyciem interfejsów API REST i RPC oraz nie wymaga generowania
kodu. Taka biblioteka byłaby przydatna na początkowych etapach rozwoju oprogramo-
wania, gdy wiele elementów się zmienia. Później na potrzeby dalszych prac można
wygenerować bibliotekę z typowaniem statycznym.
Takie podejście jest podobne do pokazanego wcześniej przykładu z użyciem biblio-
teki Json.NET. Możliwe, że zechcesz pisać klasy reprezentujące model danych, gdy
już w pełni zdefiniujesz ten model. Jednak na etapie tworzenia prototypów łatwiejsza
może okazać się zmiana danych w formacie JSON i dynamiczny dostęp do nich
w kodzie. Dalej zobaczysz też, że usprawnienia związane z technologią COM sprawiają,
iż często można używać typowania dynamicznego zamiast wielu operacji rzutowania.
W podsumowaniu chcę napisać, że sensowne jest używanie typowania statycznego,
gdy jest to proste. Jednak w niektórych sytuacjach warto zaakceptować, że typowanie
dynamiczne może być użytecznym narzędziem. Zachęcam do przeanalizowania wad
i zalet tego podejścia w każdym kontekście. Kod, który jest akceptowalny jako proto-
typ, a nawet w testach, może okazać się nieodpowiedni jako kod produkcyjny.
Jeśli pominąć kod, który piszesz zawodowo, możliwość reagowania na dynamiczne
operacje za pomocą DynamicObject lub IDynamicMetaObjectProvider jest bardzo przydatna
w zakresie programowania dla przyjemności. Choć sam zdecydowanie unikam typowania
dynamicznego, mechanizm ten został dobrze zaprojektowany i zaimplementowany
w C# oraz stanowi rozległy obszar do eksploracji.
Następny omawiany mechanizm jest nieco inny, choć łączy się z typowaniem dyna-
micznym w kontekście współdziałania z technologią COM. Wracamy teraz do jednego
z aspektów typowania statycznego — podawania argumentów reprezentujących parametry.
87469504f326f0d7c1fcda56ef61bd79
8
4.2. Parametry opcjonalne i argumenty nazwane 167
87469504f326f0d7c1fcda56ef61bd79
8
168 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
87469504f326f0d7c1fcda56ef61bd79
8
4.2. Parametry opcjonalne i argumenty nazwane 169
Aby zobaczyć działanie tych reguł, przeanalizujmy pokazaną wcześniej prostą sygna-
turę metody:
static void Method(int x, int y = 5, int z = 10)
Wynikowe
Wywołanie Uwagi
argumenty
Method(1, 2, 3) x=1; y=2; z=3 Wszystkie argumenty są pozycyjne. Standardowe
wywołanie przed wersją C# 4.
Method(1) x=1; y=5; z=10 Kompilator określa wartości y i z, ponieważ nie podano
powiązanych argumentów.
Method() Brak Błąd — brak argumentu odpowiadającego parametrowi x.
Method(y: 2) Brak Błąd — brak argumentu odpowiadającego parametrowi x.
Method(1, x: 3) x=1; y=5; z=3 Kompilator określa wartość y, ponieważ nie podano
powiązanego argumentu. Argument ten został
pominięty, ale podano nazwany argument z.
Method(1, x: 2, z: 3) Brak Błąd — dwa argumenty reprezentują x.
Method(1, y: 2, y: 2) Brak Błąd — dwa argumenty reprezentują y.
Method(z: 3, y: 2, x: 1) x=1; y=2; z=3 Argumenty nazwane można podawać w dowolnej
kolejności.
W kontekście przetwarzania wywołań metod należy zwrócić uwagę także na dwa inne
ważne aspekty. Po pierwsze argumenty są przetwarzane w kolejności ich występowania
w kodzie źródłowym z wywołaniem metody (od lewej do prawej). W większości sytuacji
nie ma to znaczenia, jednak gdy obliczanie argumentów powoduje efekty uboczne,
kolejność przetwarzania może być istotna. Rozważ np. dwa poniższe wywołania przy-
kładowej metody:
int tmp1 = 0;
Method(x: tmp1++, y: tmp1++, z: tmp1++); x=0; y=1; z=2
int tmp2 = 0;
Method(z: tmp2++, y: tmp2++, x: tmp2++); x=2; y=1; z=0
87469504f326f0d7c1fcda56ef61bd79
8
170 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
W tej sytuacji podawanie nazw argumentów nie zmienia działania kodu. Możesz wybrać
dowolny zapis, który uważasz za najbardziej czytelny. Moim zdaniem oddzielenie
obliczania argumentów od wywołania metody sprawia, że łatwiej jest zrozumieć kolej-
ność obliczeń.
Oto druga kwestia, na którą warto zwrócić uwagę: jeśli kompilator musi podać
wartości domyślne parametrów, te wartości są umieszczane w kodzie pośrednim. Kom-
pilator nie może stwierdzić: „Nie znam wartości tego parametru — użyj wartości domyśl-
nej”. To dlatego wartości domyślne muszą być stałymi na etapie kompilacji. Jest to
jeden z powodów wpływu parametrów opcjonalnych na wersjonowanie.
87469504f326f0d7c1fcda56ef61bd79
8
4.2. Parametry opcjonalne i argumenty nazwane 171
87469504f326f0d7c1fcda56ef61bd79
8
172 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
87469504f326f0d7c1fcda56ef61bd79
8
4.3. Usprawnienia w zakresie współdziałania z technologią COM 173
Konsolidacja komponentu PIA zmienia proces instalacji, a ponadto wpływa na to, jak
traktowany jest typ VARIANT w COM. Gdy stosowane są referencje do komponentu PIA,
wszystkie operacje zwracające wartość typu VARIANT są dostępne w C# z użyciem typu
object. Trzeba wtedy zrzutować wartość na odpowiedni typ, aby używać jego metod
i właściwości.
Gdy stosowana jest konsolidacja komponentu PIA, zamiast typu object używany
jest typ dynamic. Wcześniej wyjaśniłem, że możliwa jest niejawna konwersja z wyra-
żenia typu dynamic na dowolny typ niewskaźnikowy, który jest następnie sprawdzany
w czasie wykonywania programu. Listing 4.16 ilustruje kod otwierający program Excel
i zapełniający 20 komórek z podanego zakresu.
Na listingu 4.16 używane są niektóre mechanizmy objaśnione dalej, jednak na razie skup
się na przypisaniach wartości do zmiennych sheet, start i end. Każde z tych przypisań
standardowo wymagałoby rzutowania, ponieważ przypisywana wartość byłaby typu
87469504f326f0d7c1fcda56ef61bd79
8
174 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
object. Nie musisz jednak określać statycznych typów zmiennych. Jeśli podasz var lub
dynamic jako typ zmiennej, w operacjach stosowane będzie typowanie dynamiczne.
Ale gdy wiem, jakiego typu należy oczekiwać, wolę podawać typ statyczny, ponieważ
uzyskuję wtedy automatyczne sprawdzanie poprawności typu i wsparcie w dalszym
kodzie ze strony mechanizmu IntelliSense.
W bibliotekach COM, w których często używany jest typ VARIANT, wyeliminowanie
rzutowania jest jedną z największych korzyści płynących z typowania dynamicznego.
Kolejny mechanizm dotyczący COM także oparty jest na nowej funkcji z C# 4 i pozwala
wykorzystać parametry opcjonalne w nowy sposób.
Potrzeba dużo kodu (w tym 20 wystąpień wyrażenia ref missing), aby tylko utworzyć
i zapisać dokument. Trudno jest dostrzec w tym kodzie użyteczny fragment w gąszczu
nieistotnych argumentów.
W C# 4 wprowadzono mechanizmy, które razem znacznie upraszczają pracę:
Można zastosować argumenty nazwane, aby — co zostało już opisane — jedno-
znacznie określać, które argumenty mają odpowiadać poszczególnym parametrom.
Można bezpośrednio podać wartości jako argumenty parametrów ref. Kompi-
lator utworzy wtedy na zapleczu zmienną lokalną i przekaże ją przez referencję.
Ten punkt dotyczy tylko bibliotek COM.
87469504f326f0d7c1fcda56ef61bd79
8
4.3. Usprawnienia w zakresie współdziałania z technologią COM 175
Dzięki wszystkim tym technikom można przekształcić listing 4.17 na znacznie krótszy
i bardziej przejrzysty kod pokazany na listingu 4.18.
87469504f326f0d7c1fcda56ef61bd79
8
176 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
W wersjach starszych niż C# 4 indekser można było wywoływać tak, jakby był metodą
get_SynonymInfo. W C# 4 można używać nazwy indeksera, co ilustruje listing 4.19.
87469504f326f0d7c1fcda56ef61bd79
8
4.4. Wariancja generyczna 177
Taki kod wygląda tak naturalnie, że byłbyś zaskoczony, gdyby się nie skompilował —
jednak w wersjach starszych niż C# 4 tak właśnie było.
Jednak czekają Cię też kolejne niespodzianki. Nie wszystko, co zgodnie z oczekiwa-
niami powinno działać, rzeczywiście jest dozwolone — nawet w C# 4. Możesz np.
próbować rozwinąć wnioskowanie na temat sekwencji na listy. Czy dowolna lista łańcu-
chów znaków jest listą obiektów? Możesz sądzić, że tak jest, ale to nieprawda:
IList<string> strings = new List<string> { "a", "b", "c" };
IList<object> objects = strings; Niedozwolone — brak konwersji z typu IList<string>
na IList<object>.
Czym różnią się typy IEnumerable<T> i IList<T>? Dlaczego taki kod nie jest dozwolony?
Odpowiedź jest taka, że ten kod byłby niebezpieczny, ponieważ metody z typu IList<T>
dopuszczają używanie wartości typu T jako danych wejściowych i wyjściowych. Gdy
używasz typu IEnumerable<T>, wartości typu T zawsze są zwracane jako dane wyjściowe.
Jednak w typie IList<T> znajdują się metody takie jak Add, które przyjmują wartość
T jako dane wejściowe. Dlatego dopuszczanie wariancji byłoby tu niebezpieczne. Roz-
budowanie przykładu pozwala się o tym przekonać:
IList<string> strings = new List<string> { "a", "b", "c" };
IList<object> objects = strings;
objects.Add(new object()); Dodawanie obiektu do listy.
string element = strings[3]; Retrieves… - Pobieranie go jako łańcucha znaków.
Każdy wiersz oprócz drugiego sam w sobie jest sensowny. Można dodać referencję
typu object do kolekcji typu IList<object>. Można też pobrać referencję typu string
z kolekcji typu IList<string>. Jeśli jednak można traktować listę łańcuchów znaków
jako listę obiektów typu object, dwie wcześniejsze operacje są sprzeczne. Reguły języka
sprawiające, że drugi wiersz jest niedozwolony, chronią resztę kodu.
Do tej pory zetknąłeś się z wartościami zwracanymi jako dane wyjściowe (IEnume
rable<T>) oraz wartościami używanymi jako dane wejściowe i wyjściowe (IList<T>).
W niektórych interfejsach API wartości zawsze są używane tylko jako dane wejściowe.
Najprostszym przykładem jest tu delegat typu Action<T>, gdzie przekazujesz wartość
typu T, gdy wywołujesz taki delegat. Wariancja jest tu stosowana, ale w odwrotnym
kierunku. Początkowo może się to wydać mylące.
Jeśli korzystasz z delegata typu Action<object>, może on przyjmować referencję
do obiektu dowolnego typu. Z pewnością dozwolone jest przyjmowanie referencji typu
string, a reguły języka dopuszczają konwersję z typu Action<object> na Action<string>:
Action<object> objectAction = obj => Console.WriteLine(obj);
Action<string> stringAction = objectAction;
stringAction("Wyświetl mnie");
87469504f326f0d7c1fcda56ef61bd79
8
178 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
Kompilator na podstawie reszty deklaracji sprawdza, czy użyty modyfikator jest odpo-
wiedni. Na przykład poniższa deklaracja delegata jest nieprawidłowa, ponieważ jako
dane wejściowe używany jest kowariantny parametr określający typ:
public delegate void InvalidCovariant<out T>(T input)
Następna deklaracja interfejsu jest błędna, ponieważ jako dane wyjściowe używany jest
kontrawariantny parametr określający typ:
public interface IInvalidContravariant<in T>
{
T GetValue();
}
Każdy parametr określający typ może mieć tylko jeden z wymienionych modyfikatorów,
jednak dwa parametry określające typ z tej samej deklaracji mogą mieć różne modyfi-
katory. Rozważ np. typ delegata Func<T, TResult>. Przyjmuje on wartość typu T, a zwraca
87469504f326f0d7c1fcda56ef61bd79
8
4.4. Wariancja generyczna 179
wartość typu TResult. Naturalne jest, że typ T powinien być kontrawariantny, a typ
TResult kowariantny. Oto deklaracja takiego delegata:
public TResult Func<in T, out TResult>(T arg)
87469504f326f0d7c1fcda56ef61bd79
8
180 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
87469504f326f0d7c1fcda56ef61bd79
8
4.4. Wariancja generyczna 181
Nie martw się, jeśli wszystko to jest nieco przytłaczające. Prawie nigdy nie zauważysz
nawet, że posługujesz się wariancją generyczną. Przedstawiłem szczegółowe informacje,
aby pomóc Ci w sytuacji, gdy wystąpi błąd czasu kompilacji i nie będzie rozumiał,
z czego wynika3. Podsumujmy te rozważania za pomocą kilku przykładów ilustrujących,
kiedy wariancja generyczna jest przydatna.
Nie podoba mi się to rozwiązanie. Po co tworzyć cały dodatkowy etap w potoku tylko
po to, by zmienić typ w sposób, który zawsze działa? Dzięki wariancji można podać
argument określający typ w wywołaniu ToList(), aby określić oczekiwany typ listy.
Ilustruje to listing 4.21.
3
Jeśli to omówienie okaże się niewystarczające w kontekście danego błędu, zachęcam do zapoznania
się z trzecim wydaniem książki, gdzie znajdziesz jeszcze więcej szczegółów.
87469504f326f0d7c1fcda56ef61bd79
8
182 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
Kompletny kod źródłowy typów użytych na listingu 4.22 znajdziesz w kodzie do pobra-
nia; są to jednak, jak można tego oczekiwać, bardzo proste typy. Najważniejsze jest to,
że na potrzeby wywołań metody Sort można przekształcić obiekt typu AreaComparer
na typ IComparer<Circle>. W wersjach starszych niż C# 4 nie było to możliwe.
Jeśli deklarujesz własne interfejsy lub delegaty generyczne, zawsze warto rozwa-
żyć, czy parametry określające typ mogą być kowariantne lub kontrawariantne. Zwykle
nie starałbym się „na siłę” zapewniać takich cech, jeśli nie wynikają one naturalnie
z kodu, jednak warto się nad nimi zastanowić. Irytujące może być używanie interfejsu,
który mógłby mieć kowariantne parametry określające typ, ale programista nie zasta-
nowił się, czy może to być dla kogoś przydatne.
87469504f326f0d7c1fcda56ef61bd79
8
Podsumowanie 183
Podsumowanie
C# 4 obsługuje typowanie dynamiczne, co polega na tym, że wiązanie jest odra-
czane z czasu kompilacji do czasu wykonywania programu.
Typowanie dynamiczne pozwala wykonywać niestandardowe operacje za pomocą
interfejsu IDynamicMetaObjectProvider i klasy DynamicObject.
Typowanie dynamiczne jest zaimplementowane za pomocą mechanizmów kom-
pilatora i platformy. Platforma stosuje optymalizację i pamięć podręczną, aby
mechanizm ten był akceptowalnie wydajny.
W C# 4 można podać wartości domyślne parametrów. Każdy parametr o wartości
domyślnej jest opcjonalny i nie musi być podawany przez jednostkę wywołującą.
W C# 4 razem z argumentem można określić nazwę parametru, którego wartość
chcesz podać. Ta technika współdziała z parametrami opcjonalnymi i pozwala
podać argumenty tylko dla wybranych parametrów.
W C# 4 komponenty PIA z COM można dowiązać, zamiast podawać referencje
do nich. Dzięki temu model instalacji jest prostszy.
W dowiązanych komponentach PIA wartości typu VARIANT są dostępne z użyciem
typowania dynamicznego, co pozwala uniknąć wielu operacji rzutowania.
Dodano obsługę parametrów opcjonalnych w bibliotekach COM, aby umożliwić
tworzenie opcjonalnych parametrów ref.
Parametry ref w bibliotekach COM można przekazywać przez wartość.
Wariancja generyczna umożliwia bezpieczne konwersje generycznych interfej-
sów i delegatów na podstawie tego, czy wartości są używane jako dane wejściowe,
czy jako dane wyjściowe.
87469504f326f0d7c1fcda56ef61bd79
8
184 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami
87469504f326f0d7c1fcda56ef61bd79
8
Pisanie kodu
asynchronicznego
Zawartość rozdziału:
Na czym polega pisanie kodu asynchronicznego?
Deklarowanie metod asynchronicznych za pomocą
modyfikatora async
Asynchroniczne oczekiwanie z użyciem operatora
await
Zmiany w mechanizmie async/await w języku
od wersji C# 5
Wskazówki użytkowania kodu asynchronicznego
87469504f326f0d7c1fcda56ef61bd79
8
186 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
Choć na ogólnym poziomie projekt technologii TPL jest znakomity, pisanie nieza-
wodnego i czytelnego kodu asynchronicznego z jej użyciem było trudne. Wprawdzie
wsparcie przetwarzania równoległego było świetne, to jednak niektóre ogólne aspekty
asynchroniczności lepiej byłoby rozwiązać w języku niż w samych bibliotekach.
Najważniejszy mechanizm wprowadzony w C# 5 jest zwykle nazywany async/await.
Jest on oparty na technologii TPL. Umożliwia pisanie kodu wyglądającego jak kod
synchroniczny, ale z użyciem operacji asynchronicznych, jeśli są potrzebne. Pozwala
to uniknąć zagmatwanych wywołań zwrotnych, subskrypcji zdarzeń i rozrzuconej po
różnych miejscach obsługi błędów. Zamiast tego w kodzie asynchronicznym można
jednoznacznie zapisać zamiary programisty i używać do tego struktur, które programiści
już znają. Konstrukcje języka wprowadzone w C# 5 pozwalają oczekiwać na asynchro-
niczne operacje. To oczekiwanie wygląda jak zwykłe wywołania blokujące, ponieważ
dalszy kod nie jest wykonywany do czasu zakończenia danej operacji. Dzieje się to jed-
nak bez blokowania bieżącego wątku wykonania. Nie martw się, jeśli te zdania wydają
się sprzeczne ze sobą. W trakcie lektury tego rozdziału wszystko stanie się jasne.
Mechanizm async/await został z czasem nieco zmodyfikowany. Dla uproszczenia
prezentuję tu nowe możliwości z wersji C# 6 i C# 7 razem z opisem pierwotnego
mechanizmu z C# 5. Informuję o wersjach, w których dodano określone funkcje,
abyś wiedział, czy potrzebujesz kompilatora języka C# 6 lub C# 7.
W platformie .NET 4.5 wprowadzono asynchroniczność na dużą skalę, udostępniając
asynchroniczne wersje wielu operacji zgodnie ze wzorcem asynchroniczności opartej
na zadaniach. Pozwala to zapewnić spójny model pracy w wielu interfejsach API.
Podobnie platforma Windows Runtime (jest ona podstawą technologii UWA/UWP —
ang. Universal Windows Applications) stosuje asynchroniczne przetwarzanie wszystkich
długich (i potencjalnie długich) operacji. Także wiele innych nowych interfejsów API,
np. Roslyn i HttpClient, wykorzystuje liczne mechanizmy asynchroniczne. Można ująć
to krótko — większość programistów języka C# będzie musiała korzystać z asynchro-
nicznych mechanizmów przynajmniej w części swojej pracy.
UWAGA. Platforma Windows Runtime jest często nazywana WinRT. Nie należy jej mylić
z systemem Windows RT, który jest wersją systemu Windows 8.x przeznaczoną dla proceso-
rów ARM. Universal Windows Applications (UWA) to zmodyfikowana wersja aplikacji z serwisu
Windows Store. UWP to z kolei nowa wersja technologii UWA używana od systemu Windows 10.
Trzeba zaznaczyć, że język C# nie stał się wszechwiedzący i nie zgaduje, czy chcesz
wykonywać operację współbieżnie, czy asynchronicznie. Kompilator jest inteligentny,
ale nie próbuje eliminować nieodłącznej złożoności asynchronicznego wykonywania kodu.
Nadal musisz starannie przemyśleć kod, jednak piękno mechanizmu async/await polega
na tym, że można pominąć cały żmudny i niezrozumiały szablonowy kod, który kiedyś
był niezbędny. Bez rozpraszania się szczegółami, które kiedyś były konieczne, by kod
był asynchroniczny, możesz skoncentrować się na skomplikowanych aspektach.
Krótkie ostrzeżenie — to zagadnienie jest dość złożone. Ma ono tę nieprzyjemną
zbieżność cech, że jest niezwykle ważne (w praktyce nawet początkujący programiści
muszą je całkiem dobrze opanować), ale jednocześnie dość trudne do zrozumienia.
87469504f326f0d7c1fcda56ef61bd79
8
5.1. Wprowadzenie do funkcji asynchronicznych 187
UWAGA. Warto przypomnieć, że funkcja anonimowa to albo wyrażenie lambda, albo metoda
anonimowa.
Wyrażenia await to miejsce, w którym rzeczy stają się ciekawe z perspektywy języka.
Jeśli operacja, na którą wyrażenie oczekuje, nie zakończyła pracy, funkcja asynchro-
niczna natychmiast zwraca sterowanie, a później wznawia działanie od miejsca jego
zakończenia (w odpowiednim wątku), gdy wartość będzie już dostępna. Naturalny proces
niewykonywania kolejnej instrukcji do czasu zakończenia wcześniejszej zostaje zacho-
wany, ale bez blokowania pracy. Dalej zamienię ten ogólny opis na bardziej konkretną
postać, zanim jednak nabierze on sensu, powinieneś zapoznać się z przykładem.
87469504f326f0d7c1fcda56ef61bd79
8
188 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
public AsyncIntro()
{
label = new Label
{
Location = new Point(10, 20),
Text = "Długość"
};
button = new Button
{
Location = new Point(10, 50),
Text = "Kliknięcie"
};
button.Click += DisplayWebSiteLength; Podłączanie metody obsługi zdarzeń.
AutoSize = true;
Controls.Add(label);
Controls.Add(button);
}
UWAGA. Nie usuwam (za pomocą instrukcji Dispose) zadania zwróconego przez wywołanie
GetStringAsync, choć typ zadania (Task) zawiera implementację interfejsu IDisposable.
Na szczęście zwykle nie trzeba usuwać zadań. Zagadnienie to jest dość skomplikowane,
jednak Stephen Toub objaśnia je w poświęconym mu artykule na blogu: http://mng.bz/E6L3.
87469504f326f0d7c1fcda56ef61bd79
8
5.1. Wprowadzenie do funkcji asynchronicznych 189
UWAGA. HttpClient to pod niektórymi względami nowa i usprawniona wersja typu WebC
lient. Od .NET 4.5 jest to zalecany interfejs API dla protokołu HTTP. HttpClient zawiera
tylko operacje asynchroniczne.
Zauważ, że typ zmiennej task to Task<string>, ale typ wyrażenia await task to string.
Pod tym względem operator await wykonuje operację wypakowywania — przynajmniej
w sytuacji, gdy wartość, na którą kod oczekuje, jest typu Task<TResult> (dalej zoba-
czysz, że można oczekiwać także na wartości innych typów, jednak Task<TResult> to
dobry punkt wyjścia). Wypakowywanie to jeden z aspektów instrukcji await, który
nie jest bezpośrednio związany z asynchronicznością, ale ułatwia życie.
Instrukcja await służy przede wszystkim do unikania blokowania programu w cza-
sie oczekiwania na zakończenie czasochłonnej operacji. Możliwe, że zastanawiasz się,
jak mechanizm ten działa w kategoriach wątków. Na początku i na końcu metody
ustawiana jest właściwość label.Text, dlatego można przyjąć, że obie te instrukcje są
87469504f326f0d7c1fcda56ef61bd79
8
190 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
87469504f326f0d7c1fcda56ef61bd79
8
5.2. Myślenie o asynchroniczności 191
87469504f326f0d7c1fcda56ef61bd79
8
192 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
Jeśli nie jest istotne, jak przebiega proces oczekiwania, wszystkie te czynności można
wykonać w C# 4. Jeżeli akceptowalne jest zablokowanie programu do czasu zakończe-
nia operacji asynchronicznej, token to umożliwia. Gdy używasz typu Task, wystarczy
wywołać metodę Wait(). Powoduje to jednak zajmowanie cennych zasobów (wątku)
bez wykonywania użytecznej pracy. Przypomina to nieco zamówienie pizzy z dowozem
i czekanie pod drzwiami do czasu przyjazdu dostawcy. Lepiej byłoby wtedy zająć się
czymś innym i nie myśleć o pizzy do czasu jej dostarczenia. W tym kontekście pojawia
się słowo kluczowe await.
Gdy oczekujesz na asynchroniczną operację, przekazujesz informację: „Kod dotarł
tak daleko, jak to możliwe w tej chwili; należy wznowić pracę po zakończeniu operacji”.
Co jednak można zrobić, aby nie blokować wątku? To bardzo proste — można od razu
zwrócić sterowanie i kontynuować asynchronicznie pracę kodu. Jeśli chcesz, aby jed-
nostka wywołująca wiedziała, kiedy asynchroniczna metoda skończyła zadanie, możesz
przekazać token do tej jednostki. Można wtedy z wykorzystaniem tokenu zablokować
wątek lub (co bardziej prawdopodobne) użyć innej kontynuacji. Często powstaje wtedy
cały stos asynchronicznych metod wywołujących jedna drugą. To prawie tak, jakby
program wchodził w tryb asynchroniczny w danej sekcji kodu. Nic w języku nie okre-
śla, że opisany proces musi przebiegać w ten właśnie sposób. Jednak to, że ten sam kod,
który używa wyników operacji asynchronicznej, także działa jak operacja asynchroniczna,
z pewnością zachęca do używania tego podejścia.
87469504f326f0d7c1fcda56ef61bd79
8
5.2. Myślenie o asynchroniczności 193
Po omówieniu teorii pora bliżej przyjrzeć się szczegółom działania metod asynchro-
nicznych. Asynchroniczne funkcje anonimowe działają zgodnie z tym samym modelem,
ale znacznie łatwiej jest omówić metody asynchroniczne.
Występują tu trzy rodzaje bloków kodu (metod) i dwa rodzaje granic między nimi
(typów zwracanych wartości). W prostym przykładzie (w konsolowej wersji aplikacji
do pobierania długości strony) może występować kod pokazany na listingu 5.2.
Na rysunku 5.2 pokazane jest, jak szczegóły z listingu 5.2 są powiązane z ele-
mentami z rysunku 5.1.
87469504f326f0d7c1fcda56ef61bd79
8
194 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
87469504f326f0d7c1fcda56ef61bd79
8
5.3. Deklaracje metod asynchronicznych 195
Na rysunku 5.3 pokazane jest, jak te kwestie wpasowują się w model koncepcyjny.
Rysunek 5.3. Ilustracja, w jaki sposób podrozdziały 5.3, 5.4 i 5.5 opisują koncepcyjny model
przetwarzania asynchronicznego
87469504f326f0d7c1fcda56ef61bd79
8
196 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
Ponieważ modyfikator async jest częścią implementacji metody, nie można deklaro-
wać metod abstrakcyjnych ani metod interfejsów z użyciem tego modyfikatora. Można
jednak utworzyć interfejs z metodą zwracającą wartość typu Task<int>. W jednej imple-
mentacji tego interfejsu można zastosować mechanizm async/await, a w innej zwykłą
metodę.
W C# 7 ta lista obejmuje też typy zadań. Wrócę do tego zagadnienia w podrozdziale 5.8
i ponownie w rozdziale 6.
Typy Task i Task<TResult> z .NET 4 reprezentują operację, która może nie być
jeszcze ukończona. Typ Task<TResult> dziedziczy po typie Task. Różnica między tymi
typami polega na tym, że Task<TResult> reprezentuje operację zwracającą wartość ty-
pu TResult, natomiast operacja typu Task w ogóle nie musi zwracać wyniku. Jednak
zwracanie wartości typu Task też jest przydatne, ponieważ pozwala w kodzie wywo-
łującym dołączać własne kontynuacje do zwróconego zadania, wykrywać, czy zadanie
zakończyło się powodzeniem, czy porażką itd. W niektórych sytuacjach mógłbyś
traktować typ Task jak typ Task<void> (gdyby ten ostatni był dozwolony).
UWAGA. Programiści języka F# mogą w tym miejscu z poczuciem wyższości wspomnieć
o typie Unit, który przypomina void, ale jest rzeczywistym typem. Różnice między typami
Task i Task<TResult> mogą być frustrujące. Gdybyś mógł używać void jako argumentu
określającego typ, nie byłaby potrzebna rodzina delegatów Action. Na przykład typ Action
<string> to odpowiednik typu Func<string, void>.
87469504f326f0d7c1fcda56ef61bd79
8
5.4. Wyrażenia await 197
uruchamia otrzymaną metodę obsługi zdarzeń. To, że kod wygenerowany przez kom-
pilator reprezentuje maszynę stanową, która dołącza kontynuację do wartości zwró-
conej przez metodę FetchPriceAsync, jest szczegółem implementacji.
Możesz teraz zasubskrybować zdarzenie i posłużyć się pokazaną metodą w tak sam
sposób jak dowolną inną metodą obsługi zdarzeń:
loadStockPriceButton.Click += LoadStockPrice;
Dla kodu wywołującego jest to w końcu (tak, celowo to powtarzam) zwykła metoda.
Ta metoda ma typ zwracanej wartości void oraz parametry typów object i EventArgs.
Dlatego może reprezentować działania instancji delegata typu EventHandler.
Choć typ wartości zwracanych przez metody asynchroniczne jest ściśle określony,
większość pozostałych aspektów jest taka sama jak w zwykłych metodach. Metody
asynchroniczne mogą być generyczne, statyczne lub niestatyczne, a także mieć dowolne
standardowe modyfikatory dostępu. Obowiązują jednak ograniczenia dotyczące używa-
nych parametrów.
87469504f326f0d7c1fcda56ef61bd79
8
198 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
Składnia wyrażenia await jest prosta — należy podać operator await, a następnie
inne wyrażenie, które generuje wartość. Można oczekiwać na wynik wywołania metody,
zmienną, właściwość. Nie musisz używać prostego wyrażenia. Możesz połączyć kilka
wywołań metod i oczekiwać na wynik:
int result = await foo.Bar().Baz();
Operator await ma niższy priorytet niż operator kropki, dlatego wcześniejszy kod jest
odpowiednikiem następującego zapisu:
int result = await (foo.Bar().Baz());
87469504f326f0d7c1fcda56ef61bd79
8
5.4. Wyrażenia await 199
Wymienione wcześniej składowe nie muszą być publiczne, ale muszą być
dostępne w asynchronicznej metodzie, która oczekuje na daną wartość. Dlatego
zdarza się, że można oczekiwać na wartość określonego typu w jednym fragmen-
cie kodu, ale już nie w innych miejscach. Jest to jednak bardzo rzadka sytuacja.
Jeśli typ T spełnia wszystkie te warunki, możesz sobie pogratulować — możliwe jest
oczekiwanie na wartość typu T. Kompilator potrzebuje jednak jeszcze jednej informacji,
aby ustalić, jakiego typu powinno być wyrażenie await. Ten typ zależy od typu wartości
zwracanej przez metodę GetResult z typu awaitera. Dopuszczalne jest tu używanie
metod void; wtedy wyrażenie await jest traktowane jak wyrażenie bez wyniku, podobne
do wyrażenia, które bezpośrednio wywołuje metodę void. W przeciwnym razie wyrażenie
await jest traktowane tak, jakby zwracało wartość tego samego typu, jaki jest zwracany
przez metodę GetResult.
W ramach przykładu przyjrzyj się statycznej metodzie Task.Yield(). Ta metoda,
w odróżnieniu od większości innych metod klasy Task, nie zwraca zadania. Zamiast
tego zwraca obiekt typu YieldAwaitable. Oto uproszczona wersja omawianych typów:
public class Task
{
public static YieldAwaitable Yield();
}
Widać tu, że typ YieldAwaitable jest zgodny z opisanym wcześniej wzorcem awaitable.
Dlatego poniższy kod jest poprawny:
public async Task ValidPrintYieldPrint()
{
Console.WriteLine("Przed wywołaniem yield");
await Task.Yield(); Dozwolone.
Console.WriteLine("Po wywołaniu yield");
}
Jednak następny fragment jest nieprawidłowy, ponieważ kod próbuje użyć wyniku
oczekiwania na wartość typu YieldAwaitable:
public async Task InvalidPrintYieldPrint()
{
Console.WriteLine("Przed wywołaniem yield");
var result = await Task.Yield(); Błąd — to wyrażenie await nie generuje wartości.
Console.WriteLine("Po wywołaniu yield");
}
87469504f326f0d7c1fcda56ef61bd79
8
200 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
Ten kod nie generuje wyniku, dlatego nie ma czego przypisać do zmiennej.
Nie jest zaskoczeniem, że typ awaitera dla typu Task ma metodę GetResult o typie
zwracanej wartości void, a typ awaitera dla typu Task<TResult> ma metodę GetResult
zwracającą wartość typu TResult.
87469504f326f0d7c1fcda56ef61bd79
8
5.4. Wyrażenia await 201
while (*p != 0)
{
total += *p;
p++;
}
}
}
Console.WriteLine("Zatrzymanie na " + total + " ms");
await Task.Delay(total); Jednak w takim kodzie nie można umieścić
Console.WriteLine("Po zatrzymaniu"); wyrażenia await.
}
Zawsze dozwolone było używanie operatora await w bloku try zawierającym tylko blok
finally. Oznacza to, że zawsze można było używać operatora await w instrukcji using.
Do czasu wprowadzenia C# 5 zespół projektujący C# nie zdołał wymyślić, jak w bez-
pieczny i niezawodny sposób stosować wyrażenia await w wymienionych wcześniej
miejscach. Czasem było to niewygodne, dlatego na potrzeby C# 6 zespół opracował
odpowiednią maszynę stanową, co pozwoliło wyeliminować ograniczenie.
87469504f326f0d7c1fcda56ef61bd79
8
202 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
Wiesz już, jak zadeklarować metodę async i jak używać w niej operatora await.
A co dzieje się po zakończeniu asynchronicznej operacji? Zobacz, jak wartości są zwra-
cane do kodu wywołującego takie operacje.
Widziałeś już przykładowy kod zwracający dane. Przyjrzyj się temu fragmentowi jesz-
cze raz. Tym razem skup się na aspekcie zwracania wartości. Oto istotny fragment
listingu 5.2:
static async Task<int> GetPageLengthAsync(string url)
{
Task<string> fetchTextTask = client.GetStringAsync(url);
int length = (await fetchTextTask).Length;
return length;
}
Widać tu, że typ zmiennej length to int. Jednak typ wartości zwracanej przez metodę to
Task<int>. Wygenerowany kod odpowiada za opakowywanie typów, dlatego jednostka
wywołująca otrzymuje wartość typu Task<int>, która ostatecznie przyjmuje wartość
zwracaną przez metodę w momencie jej zakończenia. Metoda zwracająca niegene-
ryczny obiekt typu Task działa jak zwykła metoda void. W ogóle nie musi zawierać
instrukcji return, a jeśli już ją obejmuje, taka instrukcja musi mieć postać samego
słowa return i nie należy podawać w niej wartości. Niezależnie od zwracanego typu
zadanie przekazuje wyjątki zgłoszone w metodzie asynchronicznej. Wyjątki są opisane
szczegółowo w punkcie 5.6.5.
Mam nadzieję, że na tym etapie domyślasz się już, dlaczego potrzebne jest opako-
wywanie wartości. Metoda prawie na pewno zwróci sterowanie do jednostki wywo-
łującej przed dojściem do instrukcji return, a musi jakoś przekazywać informacje do
tej jednostki. Typ Task<TResult> (nazywany czasem w informatyce future) to obietnica
późniejszego zwrócenia wartości lub wyjątku.
87469504f326f0d7c1fcda56ef61bd79
8
5.6. Przepływ sterowania w metodzie asynchronicznej 203
Jeśli instrukcja return znajduje się w bloku try powiązanym z blokiem finally
(także wtedy, gdy stosowana jest instrukcja using), to — podobnie jak w zwykłym
przepływie programu — wyrażenie używane do obliczenia zwracanej wartości jest
wykonywane natychmiast, ale nie staje się wynikiem zadania do czasu uporządkowania
stanu. Jeżeli w bloku finally zgłoszony zostanie wyjątek, zadanie nie kończy się jedno-
cześnie powodzeniem i porażką; niepowodzenie dotyczy wtedy całego kodu.
Warto przypomnieć kwestię, o której wspomniałem wcześniej — to połączenie
automatycznego opakowywania i wypakowywania sprawia, że asynchroniczność tak
dobrze sprawdza się w modelu kompozycji. Metody asynchroniczne mogą łatwo pobie-
rać wyniki innych metod asynchronicznych, co pozwala tworzyć złożone systemy
z wielu małych bloków. Ta technika jest nieco podobna do technologii LINQ. Progra-
mista pisze operacje dla każdego elementu sekwencji w technologii LINQ, a opako-
wywanie i wypakowywanie sprawia, że można stosować te operacje do sekwencji
i pobierać także sekwencje. W modelu asynchronicznym rzadko trzeba ręcznie obsłu-
giwać zadania. Zamiast tego można oczekiwać na zadanie, aby pobrać jego wynik,
a wynikowe zadanie jest generowane automatycznie w ramach działania metody asyn-
chronicznej. Teraz, kiedy już wiesz, jak wygląda metoda asynchroniczna, łatwiej będzie
przedstawić przykłady ilustrujące przepływ sterowania w kodzie.
Do tej pory tekst dotyczył pierwszego poziomu i od czasu do czasu drugiego. W tym
podrozdziale omówiony jest drugi poziom. Analizuję tu, co język obiecuje. Trzeci poziom
jest przedstawiony w następnym rozdziale, gdzie zobaczysz, co kompilator robi na
zapleczu. (Jednak nawet taką wiedzę można pogłębić. W tej książce nie omawiam niczego
poniżej poziomu kodu pośredniego. Nie opisuję wsparcia asynchroniczności ani wątków
ze strony systemu operacyjnego lub sprzętu).
W zdecydowanej większości sytuacji można — w zależności od kontekstu —
przełączać się w trakcie programowania między dwoma pierwszymi poziomami. Jeśli
nie piszę kodu, który koordynuje wiele operacji, rzadko muszę zastanawiać się nad
drugim poziomem szczegółowości. Zwykle wystarcza mi, aby kod po prostu działał.
Ważne jest to, że można uwzględnić szczegóły, gdy jest to potrzebne.
87469504f326f0d7c1fcda56ef61bd79
8
204 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
Wygląda to tak, jakby słowo await modyfikowało znaczenie całego wyrażenia. W rze-
czywistości await zawsze dotyczy tylko jednej wartości. Wcześniejszy wiersz to odpo-
wiednik następującego zapisu:
Task<string> task = new HttpClient().GetStringAsync(url);
string pageText = await task;
Wynik wyrażenia await można też wykorzystać jako argument metody lub w innym
wyrażeniu. Pomocne jest umysłowe rozdzielenie części powiązanej ze słowem await od
reszty kodu.
Wyobraź sobie, że istnieją dwie metody — GetHourlyRateAsync() i GetHoursWorked
Async(). Zwracają one wartości typów Task<decimal> i Task<int>. Możesz napisać nastę-
pującą skomplikowaną instrukcję:
AddPayment(await employee.GetHourlyRateAsync() *
await timeSheet.GetHoursWorkedAsync(employee.Id));
To, w jaki sposób napiszesz potrzebny kod, to inna kwestia. Jeśli stwierdzisz, że wersja
z jedną instrukcją jest bardziej czytelna, możesz ją zastosować. Jeżeli chcesz rozwinąć
wyrażenie do pełnej postaci, kod będzie dłuższy, ale może okazać się łatwiejszy do
zrozumienia i debugowania. Możesz też zdecydować się na użycie trzeciej postaci,
która wygląda podobnie, ale nie jest identyczna:
Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
AddPayment(await hourlyRateTask * await hoursWorkedTask);
Uważam, że jest to najbardziej czytelna forma, która ponadto może przynieść korzyści
związane z wydajnością. Do tego przykładu wrócimy w punkcie 5.10.2.
Najważniejszym wnioskiem z tego podrozdziału jest to, że musisz umieć ustalić,
na co kod oczekuje i kiedy. W opisanym scenariuszu kod oczekuje na zadania zwracane
przez metody GetHourlyRateAsync i GetHoursWorkedAsync. Kod czeka na nie, aby móc
wywołać metodę AddPayment. Jest to uzasadnione, ponieważ potrzebne są wyniki pośred-
nie, aby można je było pomnożyć przez siebie i przekazać wynik mnożenia jako argu-
ment. Gdyby używano wywołań synchronicznych, wszystko byłoby oczywiste. Jednak
tu chcę objaśnić aspekt oczekiwania. Wiesz już, jak uprościć złożony kod do postaci
wartości, na którą program oczekuje. Wiesz też, kiedy program oczekuje na taką war-
tość. Teraz możesz przejść do tego, co dzieje się w samym procesie oczekiwania.
87469504f326f0d7c1fcda56ef61bd79
8
5.6. Przepływ sterowania w metodzie asynchronicznej 205
87469504f326f0d7c1fcda56ef61bd79
8
206 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
Próbowałem ująć przepływ sterowania w wyrażeniu await na rysunku 5.6, choć kla-
syczne diagramy przepływu sterowania nie zostały zaprojektowane z myślą o operacjach
asynchronicznych.
Przerywaną linię możesz traktować jak kolejny ciąg operacji rozpoczynający się od
góry diagramu. Warto zauważyć, że zakładam tu, iż operacja z wyrażenia await zwraca
wynik. Jeśli oczekujesz na zwykły obiekt typu Task lub podobny obiekt, pobieranie
wyniku oznacza sprawdzanie, czy operacja z powodzeniem zakończyła pracę.
Warto zatrzymać się na chwilę i zastanowić nad tym, co oznacza zwracanie stero-
wania z metody asynchronicznej. Istnieją tu dwie możliwości:
Jest to pierwsze wyrażenie await, na które kod oczekuje, dlatego na stosie znaj-
duje się też pierwotna jednostka wywołująca. (Pamiętaj, że do czasu, gdy kod
naprawdę musi oczekiwać na operację, metoda jest wykonywana synchronicznie).
Kod oczekiwał już na inną operację, która jeszcze nie zakończyła pracy, tak więc
wykonywana jest kontynuacja, która została wywołana przez coś. Stos wywołań
prawie na pewno znacznie zmienił się od momentu uruchomienia metody.
87469504f326f0d7c1fcda56ef61bd79
8
5.6. Przepływ sterowania w metodzie asynchronicznej 207
87469504f326f0d7c1fcda56ef61bd79
8
208 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
87469504f326f0d7c1fcda56ef61bd79
8
5.6. Przepływ sterowania w metodzie asynchronicznej 209
87469504f326f0d7c1fcda56ef61bd79
8
210 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
Na razie pomiń to, że utracone zostają wszystkie pierwotne wyjątki i że strony są pobie-
rane sekwencyjnie. Chcę tu pokazać, że mógłbyś oczekiwać przechwycenia wyjątku
typu HttpRequestException. Próbujesz wykonać asynchroniczną operację z użyciem
obiektu typu HttpClient, a jeśli coś się nie powiedzie, kod zwróci wyjątek HttpRequest
Exception. Chcesz go przechwycić i obsłużyć, prawda? Z pewnością wydaje się, że tak
powinno to wyglądać. Jednak wywołanie GetStringAsync() nie może zgłaszać wyjątków
typu HttpRequestException dla błędów takich jak przekroczenie czasu oczekiwania na
87469504f326f0d7c1fcda56ef61bd79
8
5.6. Przepływ sterowania w metodzie asynchronicznej 211
87469504f326f0d7c1fcda56ef61bd79
8
212 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
zmienia się na Faulted (lub Canceled; zależy to od rodzaju wyjątku), a wyjątek jest
opakowywany w obiekt typu AggreagetException przypisywany do właściwości
Exception zadania.
Po zmianie stanu zadania na jeden ze stanów końcowych można zaplanować
wykonanie wszystkich powiązanych z zadaniem kontynuacji (np. kodu w każdej
metodzie asynchronicznej oczekującej na dane zadanie).
87469504f326f0d7c1fcda56ef61bd79
8
5.6. Przepływ sterowania w metodzie asynchronicznej 213
W danych wyjściowych pojawi się komunikat Pobrano zadanie, a dopiero później wystąpi
błąd. Wyjątek jest zgłaszany synchronicznie przed wyświetleniem tych danych wyjścio-
wych, ponieważ przed sprawdzaniem poprawności nie ma wyrażeń await. Jednak kod
wywołujący dowiaduje się o problemie dopiero w momencie oczekiwania na zwrócone
zadanie. Część procesu sprawdzania poprawności można wykonać wcześniej i nie zaj-
muje to dużo czasu (i nie wymaga wywoływania innych operacji asynchronicznych).
W takich sytuacjach byłoby lepiej, gdyby informacje o niepowodzeniu były zgłaszane
natychmiast, zanim system spowoduje więcej problemów. Na przykład wywołanie
HttpClient.GetStringAsync zgłasza wyjątek natychmiast, jeśli przekazana zostanie refe-
rencja null.
UWAGA. Jeśli pisałeś kiedyś metodę iteratora, w której trzeba było sprawdzać poprawność
argumentów, ten opis może wydać Ci się znajomy. Sytuacja nie jest identyczna, jednak
efekt jest podobny. W blokach iteratora kod metody, w tym kod sprawdzający poprawność
argumentów, w ogóle nie jest wykonywany do czasu pierwszego wywołania metody MoveNext()
dla sekwencji zwracanej przez daną metodę. W kodzie asynchronicznym poprawność jest
sprawdzana natychmiast, ale informacje o wyjątku są przekazywane dopiero po rozpoczęciu
oczekiwania na wyniki.
87469504f326f0d7c1fcda56ef61bd79
8
214 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
Najbardziej lubię trzecią z tych technik. Jej zaletą jest to, że nie wprowadza do klasy
dodatkowej metody, a jednocześnie nie wymaga tworzenia delegata. Na listingu 5.7
pokazana jest pierwsza z tych technik, ponieważ nie wymaga rozwiązań, które nie zostały
jeszcze omówione. Kod pozostałych wersji jest podobny (znajdziesz go w kodzie źró-
dłowym powiązanym z książką). Tu pokazana jest tylko metoda ComputeLengthAsync.
Kod wywołujący nie wymaga zmian.
87469504f326f0d7c1fcda56ef61bd79
8
5.6. Przepływ sterowania w metodzie asynchronicznej 215
mniany wyjątek jest też zgłaszany przez wywołania synchroniczne (np. Task.Wait), jeśli
zostały one anulowane.
Interakcje tego mechanizmu z metodami asynchronicznymi nie są opisane w spe-
cyfikacji języka C#. Zgodnie ze specyfikacją, jeśli w ciele metody asynchronicznej
zgłaszany jest jakikolwiek wyjątek, zadanie zwrócone przez tę metodę znajdzie się
w stanie wskazującym na błąd. Dokładne znaczenie stanu wskazującego na błąd zależy
od implementacji. Jednak w praktyce jest tak, że jeżeli metoda asynchroniczna zgłasza
wyjątek typu OperationCanceledException (lub typu pochodnego, np. TaskCanceled
Exception), zwrócone zadanie będzie miało stan Canceled. Można pokazać, że jedynie
typ wyjątku wpływa na stan zadania. W tym celu należy zgłosić wyjątek typu Operation
CanceledException bezpośrednio, bez używania tokenów anulowania (zobacz listing 5.8).
Ten kod zwraca stan Canceled zamiast Faulted, którego można by oczekiwać po lekturze
specyfikacji. Jeśli wywołasz metodę Wait() dla zadania lub zażądasz wyniku (w przypadku
zadań typu Task<TResult>), wyjątek także zostanie zgłoszony (w wyjątku typu AggregateEx
ception). Dlatego nie musisz bezpośrednio sprawdzać anulowania w każdym zadaniu.
Na wyścigi?
Możliwe, że się zastanawiasz, czy na listingu 5.8 występuje warunek wyścigu. W końcu
wywołujesz metodę asynchroniczną, a następnie natychmiast spodziewasz się ustalonego
stanu. Gdyby ten kod uruchamiał nowy wątek, taka sytuacja byłaby niebezpieczna, tak
jednak nie jest.
Pamiętaj, że do pierwszego wyrażenia await metoda asynchroniczna działa synchronicznie.
Opakowuje wprawdzie wynik i wyjątki, jednak to, że metoda jest asynchroniczna, nie
musi oznaczać, iż używanych jest kilka wątków. Metoda ThrowCancellationException nie
zawiera żadnych wyrażeń await, dlatego działa synchronicznie. W momencie, gdy zwraca
sterowanie, wiadomo, że wynik jest dostępny. Visual Studio wyświetla ostrzeżenie doty-
czące każdej funkcji asynchronicznej, która nie zawiera żadnych wyrażeń await, jednak
w omawianym scenariuszu potrzebna jest właśnie taka funkcja.
Ważne jest to, że jeśli kod oczekuje na operację, która została anulowana, zgłaszany
jest pierwotny wyjątek typu OperationCanceledException. Dlatego jeśli nie podejmiesz
żadnych bezpośrednich działań, zadanie zwrócone przez metodę asynchroniczną też
zostanie anulowane. Anulowanie jest przekazywane w naturalny sposób.
Gratuluję, jeśli dotarłeś do tego miejsca. Omówiłem już większość trudnych zagad-
nień z tego rozdziału. Nadal musisz zapoznać się z kilkoma mechanizmami, jednak są
one znacznie łatwiejsze do zrozumienia niż wcześniejsze punkty. Skomplikowane kwestie
87469504f326f0d7c1fcda56ef61bd79
8
216 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
UWAGA. Jeśli się nad tym zastanawiasz, to wiedz, że nie można używać asynchronicznych
funkcji anonimowych do tworzenia drzew wyrażeń.
Asynchroniczne funkcje anonimowe można tworzyć tak jak inne metody anonimowe
lub wyrażenia lambda, ale z modyfikatorem async na początku. Oto przykład:
Func<Task> lambda = async () => await Task.Delay(1000);
Func<Task<int>> anonMethod = async delegate()
{
Console.WriteLine("Rozpoczęto pracę");
await Task.Delay(1000);
Console.WriteLine("Zakończono pracę");
return 10;
};
Utworzony delegat musi mieć sygnaturę z typem zwracanej wartości zgodnym z metodą
asynchroniczną (void, Task lub Task<TResult> w C# 5 i 6; w C# 7 dodatkowo dozwolone
są niestandardowe typy zadań). Dozwolone jest przechwytywanie zmiennych (tak jak
w innych funkcjach anonimowych) i dodawanie parametrów. Operacja asynchroniczna
nie jest rozpoczynana do momentu wywołania delegata, a wiele wywołań pozwala utwo-
rzyć wiele operacji. Jednak wywołanie delegata uruchamia operację. Podobnie jak
w wywołaniu metody asynchronicznej to nie oczekiwanie na zadanie uruchamia operację;
w ogóle nie musisz używać słowa await do wyniku asynchronicznej funkcji anonimowej.
Na listingu 5.9 pokazany jest bardziej rozbudowany (choć wciąż nieprzydatny) przykład.
87469504f326f0d7c1fcda56ef61bd79
8
5.8. Niestandardowe typy zadań w C# 7 217
Celowo dobrałem wartości w taki sposób, aby druga operacja została ukończona przed
pierwszą. Jednak ponieważ wyświetlanie wyniku ma miejsce dopiero po zakończeniu
pierwszej operacji, dane wyjściowe wyglądają tak (do wyświetlania wyniku używana
jest właściwość Result, która blokuje kod do czasu zakończenia zadania; należy uważać,
gdzie wywoływana jest ta właściwość):
Rozpoczęcie pracy… x=5
Rozpoczęcie pracy… x=3
Zakończenie pracy… x=3
Zakończenie pracy… x=5
Pierwszy wynik: 10
Drugi wynik: 6
Program ten działa dokładnie w ten sam sposób jak po umieszczeniu asynchronicznego
kodu w asynchronicznej metodzie.
Piszę zdecydowanie więcej metod asynchronicznych niż asynchronicznych funkcji
anonimowych, jednak te ostatnie też mogą być przydatne — przede wszystkim w tech-
nologii LINQ. Takich funkcji nie można używać w wyrażeniach reprezentujących
zapytania w LINQ, jednak dozwolone jest wywoływanie analogicznych metod. Asyn-
chroniczne funkcje anonimowe mają pewne ograniczenia. Ponieważ nigdy nie mogą
zwracać wartości logicznej (bool), nie można np. wywołać Where z użyciem funkcji
asynchronicznej. Najczęściej używam w tym kontekście wywołania Select do przekształ-
cania sekwencji zadań jednego typu na sekwencję zadań innego typu. Teraz pora
omówić mechanizm, o którym już kilka razy wspominałem — dodatkowy poziom ogól-
ności wprowadzony w C# 7.
87469504f326f0d7c1fcda56ef61bd79
8
218 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
Łatwo jest opisać typ ValueTask<TResult>. Jest on podobny do typu Task<TResult>, ale
jest typem bezpośrednim. Udostępnia metodę AsTask, która pozwala pobrać z niego
zwykłe zadanie, gdy jest potrzebne (np. w celu dodania go jako jednego elementu
w wywołaniach Task.WhenAll lub Task.WhenAny). Jednak w większości sytuacji można
oczekiwać na wartość tego typu w taki sam sposób jak na zwykłe zadanie.
Jakie zalety ma typ ValueTask<TResult> w porównaniu z typem Task<TResult>?
Wszystko sprowadza się do alokowania pamięci na stercie i przywracania pamięci.
Task<TResult> to klasa, a choć infrastruktura asynchroniczna ponownie wykorzystuje
obiekty typu Task<TResult> po ukończeniu ich zadań, większość metod asynchronicznych
musi tworzyć nowe obiekty tego typu. Alokowanie pamięci dla obiektów w platformie
.NET jest na tyle mało kosztowne, że w większości sytuacji nie trzeba się tym przejmo-
wać. Jeśli jednak tworzysz wiele obiektów lub potrzebna jest wysoka wydajność, warto
w miarę możliwości unikać alokowania pamięci.
Jeżeli w metodzie asynchronicznej używane jest wyrażenie await dotyczące ope-
racji, która nie została ukończona, nie da się uniknąć przydziału pamięci dla obiektu.
Metoda natychmiast zwraca sterowanie, musi jednak zaplanować kontynuację, aby
wykonać resztę kodu po zakończeniu operacji, której dotyczy oczekiwanie. W więk-
szości metod asynchronicznych jest to standardowa sytuacja. Nie spodziewasz się
przecież, że operacja, na którą czekasz, ukończy pracę przed rozpoczęciem oczeki-
wania. W takich scenariuszach używanie obiektów typu ValueTask<TResult> nie przynosi
korzyści i może być nawet bardziej kosztowne.
W tych nielicznych sytuacjach, gdy operacja jest już ukończona przed rozpoczę-
ciem oczekiwania, typ ValueTask<TResult> jest przydatny. Rozważ teraz uproszczoną
wersję praktycznego przykładu. Załóżmy, że chcesz asynchronicznie wczytywać bajt
po bajcie ze strumienia typu System.IO.Stream. Można łatwo dodać warstwę bufora, aby
uniknąć zbyt częstych wywołań ReadAsync do strumienia, warto jednak wtedy zastoso-
wać metodę asynchroniczną hermetyzującą operację zapełniania bufora ze strumienia
(gdy jest to konieczne) i zwracania następnego bajta. Możesz użyć typu byte? z war-
tością null, aby informować, że program dotarł do końca danych. Taką metodę łatwo
jest napisać, jeśli jednak każde jej wywołanie wymaga przydziału pamięci dla nowego
obiektu typu Task<byte?>, powoduje to znaczne obciążenie mechanizmu przywracania
pamięci. Dzięki typowi ValueTask<TResult> przydział pamięci na stercie jest konieczny
tylko w rzadkich sytuacjach, gdy trzeba ponownie zapełnić bufor danymi ze strumienia.
Na listingu 5.10 pokazany jest typ nakładkowy (ByteStream) i przykład użycia go.
87469504f326f0d7c1fcda56ef61bd79
8
5.8. Niestandardowe typy zadań w C# 7 219
Przykład zastosowania:
using (var stream = new ByteStream(File.OpenRead("file.dat")))
{
while ((nextByte = await stream.ReadByteAsync()).HasValue)
{
ConsumeByte(nextByte.Value); Używanie bajta.
}
}
87469504f326f0d7c1fcda56ef61bd79
8
220 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
UWAGA. Możliwe, że zastanawiasz się, co zrobić, jeśli używana jest metoda asynchro-
niczna niezwracająca danych (standardowo typ takiej metody to Task) i jednocześnie nie-
wymagająca planowania kontynuacji po ukończeniu. W takim scenariuszu możesz nadal
zwracać wartość typu Task. Infrastruktura mechanizmu async/await zapisuje wtedy w buforze
zadanie, które może zwrócić z dowolnej metody asynchronicznej zwracającej według deklaracji
obiekt typu Task, jeśli ta metoda ukończy pracę synchronicznie i bez zgłoszenia wyjątku.
Jeżeli metoda synchronicznie zakończy pracę, ale zgłosi wyjątek, koszt przydziału pamięci dla
obiektu typu Task zapewne i tak będzie niewielki w porównaniu z kosztem obsługi wyjątku.
87469504f326f0d7c1fcda56ef61bd79
8
5.8. Niestandardowe typy zadań w C# 7 221
macje o ukończeniu pracy, lub wyjątki, jak wznawiać pracę z użyciem kontynuacji itd.
Zestaw metod i właściwości, jakie programista musi udostępnić, jest znacznie bardziej
złożony niż we wzorcu awaitable. Najłatwiej jest przedstawić kompletny przykład ze
składowymi, jakie należy udostępnić, ale bez ich implementacji (zobacz listing 5.11).
[AsyncMethodBuilder(typeof(CustomTaskBuilder<>))]
public class CustomTask<T>
{
public CustomTaskAwaiter<T> GetAwaiter();
}
W tym kodzie pokazany jest generyczny niestandardowy typ zadania. W typach nie-
generycznych jedyną różnicą w builderze byłoby to, że metoda SetResult powinna być
bezparametrowa.
Ciekawym wymogiem jest metoda AwaitUnsafeOnCompleted. W następnym rozdziale
zobaczysz, że kompilator rozróżnia bezpieczne oczekiwanie i niebezpieczne oczekiwanie.
W tym drugim przypadku to typ awaitable obsługuje przekazywanie kontekstu. Typ
buildera dla niestandardowego zadania odpowiada natomiast za wznawianie pracy po
oczekiwaniu obu rodzajów.
87469504f326f0d7c1fcda56ef61bd79
8
222 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
Ostatni raz powtórzę, że prawie na pewno nie będziesz stosował opisanych tu mecha-
nizmów, chyba że z ciekawości. Nie spodziewam się, abym kiedykolwiek miał imple-
mentować własny typ zadania w kodzie produkcyjnym. Jednak z pewnością będę
używać typu ValueTask<TResult> i cieszę się, że opisane mechanizmy są dostępne.
Skoro już jesteśmy przy przydanych nowych mechanizmach, w C# 7.1 dostępna
jest dodatkowa funkcja, o której warto wspomnieć. Na szczęście jest ona znacznie
prostsza niż niestandardowe typy zadań.
W C# 7.1 ostatni wymóg został usunięty, natomiast pojawił się nieco odmienny wymóg
dotyczący typu zwracanej wartości. W C# 7.1 można utworzyć asynchroniczny punkt
wejścia (o nazwie Main, a nie MainSync), jednak typem zwracanej wartości musi być
Task lub Task<int>, które odpowiadają synchronicznym typom void i int. Asynchroniczny
punkt wejścia, w odróżnieniu od większości metod asynchronicznych, nie może zwra-
cać wartości typu void ani niestandardowego typu zadania.
Oprócz tych zastrzeżeń tworzone są standardowe metody asynchroniczne. Na przy-
kład na listingu 5.12 pokazany jest asynchroniczny punkt wejścia, który wyświetla
w konsoli dwa wiersze i dodaje przerwę między tymi wyświetleniami.
87469504f326f0d7c1fcda56ef61bd79
8
5.10. Wskazówki dotyczące korzystania z asynchroniczności 223
metoda nakładka jest albo bezparametrowa, albo ma parametr typu string[], a zwraca
wartość typu void lub int; te cechy zależą od parametrów i typu zwracanej wartości
asynchronicznego punktu wejścia. Metoda nakładkowa wywołuje rzeczywisty kod,
a następnie wywołuje metodę GetAwaiter() zwróconego zadania i metodę GetResult()
awaitera. Na przykład metoda nakładkowa wygenerowana w listingu 5.11 wygląda tak:
static void <Main>() Metoda ma nazwę niepoprawną w C#, ale poprawną w kodzie pośrednim.
{
Main().GetAwaiter().GetResult();
}
87469504f326f0d7c1fcda56ef61bd79
8
224 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
87469504f326f0d7c1fcda56ef61bd79
8
5.10. Wskazówki dotyczące korzystania z asynchroniczności 225
Nie musisz martwić się tym, jak opisane ustawienie wpływa na jednostkę wywo-
łującą. Załóżmy, że jednostka wywołująca oczekuje na zadanie zwracane przez wywo-
łanie GetPageLengthAsync, a następnie aktualizuje interfejs użytkownika w celu wyświe-
tlenia wyniku. Nawet jeśli kontynuacja w metodzie GetPageLengthAsync działa w wątku
z puli, wyrażenie await wykonywane w kodzie interfejsu użytkownika przechwytuje
kontekst interfejsu użytkownika i planuje uruchamianie swojej kontynuacji w wątku
tego interfejsu, co pozwala później go aktualizować.
Drugi fragment:
Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
AddPayment(await hourlyRateTask * await hoursWorkedTask);
Drugi fragment jest nie tylko krótszy, ale też wykorzystuje przetwarzanie równoległe.
Oba zadania można uruchomić niezależnie, ponieważ dane wyjściowe z drugiego zada-
nia nie są potrzebne jako dane wejściowe pierwszego. Nie oznacza to jednak, że infra-
struktura do obsługi asynchroniczności tworzy dodatkowe wątki. Na przykład, jeśli
dwie operacje asynchroniczne z przykładu to usługi sieciowe, oba żądania kierowane do
usług sieciowych mogą być przetwarzane bez konieczności blokowania wątku w ocze-
kiwaniu na wynik.
Zwięzłość kodu wynika tu z przypadku. Jeśli potrzebujesz przetwarzania równo-
ległego, ale chcesz używać odrębnych zmiennych, możesz napisać następujący kod:
Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
decimal hourlyRate = await hourlyRateTask;
int hoursWorked = await hoursWorkedTask;
AddPayment(hourlyRate * hoursWorked);
87469504f326f0d7c1fcda56ef61bd79
8
226 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
żadnych wyjątków z tego zadania. Jeśli chcesz np. rejestrować wszystkie błędy z zadań,
możesz w zamian zastosować wywołanie Task.WhenAll.
Przetwarzanie równoległe tego rodzaju wymaga oczywiście tego, aby zadania były
niezależne od siebie. W niektórych sytuacjach nie jest to oczywiste. Jeśli jedno zadanie
uwierzytelnia użytkownika, a inne wykonuje operacje dla danej osoby, należy poczekać
na uwierzytelnienie przed rozpoczęciem takiej operacji, nawet jeśli mógłbyś napisać
kod wykonywany równolegle. Mechanizm async/await nie potrafi podejmować takich
decyzji za programistę, jednak umożliwia łatwe tworzenie równoległych operacji
asynchronicznych, jeśli uznasz, że jest to właściwe rozwiązanie.
87469504f326f0d7c1fcda56ef61bd79
8
5.10. Wskazówki dotyczące korzystania z asynchroniczności 227
87469504f326f0d7c1fcda56ef61bd79
8
228 ROZDZIAŁ 5. Pisanie kodu asynchronicznego
Podsumowanie
Istotą asynchroniczności jest uruchamianie operacji i późniejsze kontynuowanie
pracy po ukończeniu tej operacji bez konieczności blokowania kodu.
Mechanizm async/await pozwala pisać standardowo wyglądający kod, który działa
asynchronicznie.
Mechanizm async/await obsługuje konteksty synchronizacji, dzięki czemu kod
interfejsu użytkownika może uruchomić operację asynchroniczną, a następnie
kontynuować pracę w wątku interfejsu użytkownika po ukończeniu tej operacji.
W operacjach asynchronicznych przekazywane są wyniki z powodzeniem ukoń-
czonych zadań i wyjątki.
Ograniczenia wpływają na to, gdzie można używać operatora await, jednak w C#
6 (i nowszych wersjach) występuje mniej ograniczeń niż w C# 5.
Kompilator używa wzorca awaitable do określania, na jakie typy można oczekiwać.
W C# 7 można tworzyć własne niestandardowe typy zadań, jednak prawie zawsze
wystarczy użyć typu ValueTask<TResult>.
W C# 7.1 można pisać asynchroniczne metody Main jako punkty wejścia do
programów.
87469504f326f0d7c1fcda56ef61bd79
8
Implementacja
asynchroniczności
Zawartość rozdziału:
Struktura kodu asynchronicznego
Interakcje z typami builderów z platformy
Wykonywanie jednego kroku w metodzie
asynchronicznej
Wyjaśnienie zmian kontekstu wykonania
w wyrażeniach await
Interakcje z zadaniami niestandardowych typów
87469504f326f0d7c1fcda56ef61bd79
8
230 ROZDZIAŁ 6. Implementacja asynchroniczności
jak przyglądanie się pięknemu kwiatowi przez mikroskop. Nadal można podziwiać piękno,
ale pod mikroskopem widać o wiele więcej niż na pierwszy rzut oka.
Nie każdy jest jednak podobny do mnie. Jeśli chcesz polegać na działaniu mecha-
nizmów, które do tej pory opisałem, i ufasz, że kompilator zrobi to, co powinien, nie ma
w tym absolutnie nic złego. Nic też nie stracisz, jeśli pominiesz na razie ten rozdział
i wrócisz do niego później. Żaden z dalszych rozdziałów nie jest zależny od prezento-
wanych tu informacji. Mało prawdopodobne jest, że będziesz musiał kiedykolwiek
debugować kod na poziomie, na jakim tutaj go opisuję. Wierzę jednak, że dzięki temu
rozdziałowi lepiej zrozumiesz, jak działa mechanizm async/await. Zarówno wzorzec
awaitable, jak i wymogi dotyczące niestandardowych typów zadań staną się bardziej
zrozumiałe, gdy przyjrzysz się generowanemu kodowi. Nie chcę zanadto popadać tu
w mistyczne tony, jednak z pewnością istnieje związek między językiem a programistą,
a analizowanie szczegółów implementacji go wzbogaca.
Można przyjąć zgrubne założenie, że kompilator języka C# przekształca kod C#
używający mechanizmu async/await na kod C# bez tego mechanizmu. Kompilator
potrafi oczywiście działać na niższym poziomie, tworząc reprezentacje pośrednie
w formie kodu pośredniego. Niektórych aspektów mechanizmu async/await z genero-
wanego kodu pośredniego nie da się przedstawić za pomocą standardowego kodu C#,
jednak te sytuacje można łatwo objaśnić.
87469504f326f0d7c1fcda56ef61bd79
8
6.1. Struktura wygenerowanego kodu 231
UWAGA. Często będę pisał, że maszyna stanowa wstrzymuje pracę. Odpowiada to miejscu,
w którym metoda asynchroniczna dochodzi do wyrażenia await, a oczekiwana operacja nie
została jeszcze ukończona. Może pamiętasz z rozdziału 5., że w takiej sytuacji planowane
jest wykonanie w kontynuacji reszty metody asynchronicznej po ukończeniu oczekiwanej
operacji, a metoda asynchroniczna zwraca sterowanie. Przydatne jest też mówienie o kro-
kach metody asynchronicznej. Krok odpowiada kodowi wykonywanemu między miejscami
wstrzymania metody. Nie są to oficjalne określenia, przydają się jednak jako skróty.
87469504f326f0d7c1fcda56ef61bd79
8
232 ROZDZIAŁ 6. Implementacja asynchroniczności
Ten kod jest wygodny i prosty, ponieważ nie występują tu pętle ani bloki try/catch/
finally, o które trzeba się martwić. Przepływ sterowania jest prosty (oczywiście jeśli
pominąć oczekiwanie). Zobacz teraz, co kompilator generuje na podstawie tego kodu.
87469504f326f0d7c1fcda56ef61bd79
8
6.1. Struktura wygenerowanego kodu 233
Listing 6.2. Kod wygenerowany na podstawie listingu 6.1 (metoda MoveNext jest
tu pomijana)
Metoda kontrolna
[AsyncStateMachine(typeof(PrintAndWaitStateMachine))]
[DebuggerStepThrough]
private static unsafe Task PrintAndWait(TimeSpan delay)
{
var machine = new PrintAndWaitStateMachine
{
delay = delay, Inicjalizowanie maszyny stanowej,
builder = AsyncTaskMethodBuilder.Create(), w tym parametrów metody.
state = -1
};
machine.builder.Start(ref machine); Wykonywanie maszyny stanowej
return machine.builder.Task; Zwracanie zadania do miejsca oczekiwania.
} reprezentującego
operację asynchroniczną.
[DebuggerHidden]
void IAsyncStateMachine.SetStateMachine(
IAsyncStateMachine stateMachine)
{ Wiązanie buildera z opakowaną
this.builder.SetStateMachine(stateMachine); maszyną stanową.
}
}
Ten listing już wygląda dość skomplikowanie. Powinienem jednak ostrzec, że większość
pracy jest wykonywana w metodzie MoveNext, a tu na razie całkowicie pominąłem jej
implementację. Listing 6.2 ma przygotować kontekst i zapewnić strukturę, aby poka-
zana dalej implementacja metody MoveNext miała sens. Przyjrzyjmy się teraz po kolei
fragmentom listingu 6.2. Zacznijmy od metody kontrolnej.
87469504f326f0d7c1fcda56ef61bd79
8
234 ROZDZIAŁ 6. Implementacja asynchroniczności
[AsyncStateMachine(typeof(PrintAndWaitStateMachine))]
[DebuggerStepThrough]
private static unsafe Task PrintAndWait(TimeSpan delay)
{
var machine = new PrintAndWaitStateMachine
{
delay = delay,
builder = AsyncTaskMethodBuilder.Create(),
state = -1
};
machine.builder.Start(ref machine);
return machine.builder.Task;
}
UWAGA. Nazwa AsyncTaskMethodBuilder może przywodzić na myśl refleksję, ale typ ten
nie tworzy metod w kodzie pośrednim ani podobnych konstrukcji. Ten builder zapewnia
mechanizmy, z jakich wygenerowany kod korzysta do przekazywania informacji o powodzeniu
i niepowodzeniu, obsługi oczekiwania itd. Jeśli nazwa typ pomocniczy wydaje Ci się lepsza,
możesz myśleć o tym typie w ten sposób.
87469504f326f0d7c1fcda56ef61bd79
8
6.1. Struktura wygenerowanego kodu 235
void IAsyncStateMachine.MoveNext()
{
Implementacja została pominięta.
}
[DebuggerHidden]
void IAsyncStateMachine.SetStateMachine(
IAsyncStateMachine stateMachine)
{
this.builder.SetStateMachine(stateMachine);
}
}
87469504f326f0d7c1fcda56ef61bd79
8
236 ROZDZIAŁ 6. Implementacja asynchroniczności
87469504f326f0d7c1fcda56ef61bd79
8
6.1. Struktura wygenerowanego kodu 237
UWAGA. Zakładamy tu, że awaiter jest zapewniany przez kompilator. Jeśli sam wywołasz
metodę GetAwaiter() i przypiszesz wynik do zmiennej lokalnej, będzie ona traktowana jak
każda inna zmienna lokalna. Tu chodzi o awaitery generowane na podstawie wyrażeń await.
Teraz zastanówmy się nad zmiennymi lokalnymi. Gdy są one używane, kompilator nie
wykorzystuje ponownie pól, ale może całkowicie je pominąć. Jeśli zmienna lokalna jest
używana tylko między dwoma wyrażeniami await, a nie w zasięgu obejmującym takie
wyrażenia, może pozostać zmienną lokalną metody MoveNext().
Łatwiej jest to zrozumieć na przykładzie. Przyjrzyj się następującej metodzie asyn-
chronicznej:
public async Task LocalVariableDemoAsync()
{ Wartość zmiennej x jest przypisywana
int x = DateTime.UtcNow.Second; przed wyrażeniem await.
int y = DateTime.UtcNow.Second; Zmienna y jest używana tylko
Console.WriteLine(y); przed wyrażeniem await.
await Task.Delay();
Console.WriteLine(x); Zmienna x jest używana po wyrażeniu await.
}
Kompilator generuje pole dla zmiennej x, ponieważ jej wartość trzeba zachować po
wstrzymaniu maszyny stanowej. Jednak y może być dostępna tylko jako zmienna lokalna
na stosie w czasie wykonywania kodu.
UWAGA. Kompilator dość dobrze radzi sobie z tworzeniem tylko tylu pól, ile jest potrzebnych.
Czasem jednak możesz dostrzec możliwość optymalizacji, którą kompilator mógłby prze-
prowadzić, ale tego nie robi. Na przykład, jeśli dwie zmienne są tego samego typu i obie są
używane w zasięgu obejmującym wyrażenia await (dlatego wymagają utworzenia pól), ale
nigdy nie znajdują się w zasięgu w tym samym momencie, kompilator mógłby używać jednego
pola dla obu tych zmiennych, podobnie jak robi to z awaiterami. W czasie, gdy powstaje ta
książka, kompilator nie działa w ten sposób, kto jednak wie, co przyniesie przyszłość?
87469504f326f0d7c1fcda56ef61bd79
8
238 ROZDZIAŁ 6. Implementacja asynchroniczności
Możesz sobie wyobrazić, że kompilator modyfikuje kod, dodając nowe zmienne lokalne:
public async Task TemporaryStackDemoAsync()
{
Task<int> task = Task.FromResult(10);
DateTime now = DateTime.UtcNow;
int tmp1 = now.Second;
int tmp2 = now.Hours;
int result = tmp1 + tmp2 * await task;
}
87469504f326f0d7c1fcda56ef61bd79
8
6.1. Struktura wygenerowanego kodu 239
Warto wspomnieć o jeszcze jednej kwestii dotyczącej metody MoveNext() — typ zwra-
canej wartości to void, a nie typ zadania. Tylko metoda kontrolna musi zwracać zadanie.
Jest ono pobierane od buildera z maszyny stanowej po wywołaniu MoveNext() przez
metodę Start() buildera w celu wykonania pierwszego kroku. Wszystkie dalsze wywo-
łania MoveNext() są obsługiwane przez infrastrukturę wznawiania pracy maszyny stano-
wej po wstrzymaniu i nie wymagają powiązanego zadania. W podrozdziale 6.2 (już
niedaleko) zobaczysz, jak wygląda potrzebny kod. Najpierw jednak warto pokrótce opi-
sać metodę SetStateMachine.
87469504f326f0d7c1fcda56ef61bd79
8
240 ROZDZIAŁ 6. Implementacja asynchroniczności
Nie jest on aż tak prosty, ale ten fragment pokazuje istotę tego, co się dzieje. Implemen-
tacja metody SetStateMachine dba o to, by obiekt typu AsyncTaskMethodBuilder miał
referencję do jednej opakowanej wersji maszyny stanowej, której jest częścią. Oma-
wianą metodę trzeba wywołać dla opakowanej wartości. Można ją wywołać tylko po
opakowaniu wartości, ponieważ wtedy dostępna jest referencja do opakowanej wartości.
Jeśli wywołasz tę metodę dla nieopakowanej wartości po procesie opakowywania,
wywołanie nie wpłynie na opakowaną wartość. Pamiętaj, że AsyncTaskMethodBuilder też
jest typem bezpośrednim. Ten zawiły taniec sprawia, że gdy delegat z kontynuacją jest
przekazywany do awaitera, kontynuacja wywołuje metodę MoveNext() dla tej samej opa-
kowanej instancji.
Efekt jest taki, że maszyna stanowa w ogóle nie jest opakowywana, jeśli nie jest to
konieczne, a jeżeli jest to potrzebne, opakowywanie zachodzi tylko raz. Po opakowaniu
wszystkie operacje dotyczą opakowanej wersji. Wymaga to wygenerowania dużej ilości
skomplikowanego kodu, aby uzyskać wydajne rozwiązanie.
Moim zdaniem ten taniec jest jednym z najbardziej intrygujących i dziwacznych
elementów całej asynchronicznej maszynerii. Może się wydawać zupełnie niepotrzebny,
ale wynika z działania opakowywania, a opakowywanie jest niezbędne do zachowania
informacji po wstrzymaniu maszyny stanowej.
Jeśli nie rozumiesz w pełni tego kodu, nie ma w tym nic złego. Jeżeli będziesz
kiedyś diagnozował niskopoziomowy kod asynchroniczny, możesz wrócić do tego pod-
87469504f326f0d7c1fcda56ef61bd79
8
6.2. Prosta implementacja metody MoveNext() 241
void IAsyncStateMachine.MoveNext()
{
int num = this.state;
try
{
TaskAwaiter awaiter1;
switch (num)
{
default:
goto MethodStart;
case 0:
goto FirstAwaitContinuation;
case 1:
goto SecondAwaitContinuation;
}
87469504f326f0d7c1fcda56ef61bd79
8
242 ROZDZIAŁ 6. Implementacja asynchroniczności
MethodStart:
Console.WriteLine("Przed pierwszą przerwą");
awaiter1 = Task.Delay(this.delay).GetAwaiter();
if (awaiter1.IsCompleted)
{
goto GetFirstAwaitResult;
}
this.state = num = 0;
this.awaiter = awaiter1;
this.builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this);
return;
FirstAwaitContinuation:
awaiter1 = this.awaiter;
this.awaiter = default(TaskAwaiter);
this.state = num = -1;
GetFirstAwaitResult:
awaiter1.GetResult();
Console.WriteLine("Między przerwami");
TaskAwaiter awaiter2 = Task.Delay(this.delay).GetAwaiter();
if (awaiter2.IsCompleted)
{
goto GetSecondAwaitResult;
}
this.state = num = 1;
this.awaiter = awaiter2;
this.builder.AwaitUnsafeOnCompleted(ref awaiter2, ref this);
return;
SecondAwaitContinuation:
awaiter2 = this.awaiter;
this.awaiter = default(TaskAwaiter);
this.state = num = -1;
GetSecondAwaitResult:
awaiter2.GetResult();
Console.WriteLine("Po drugiej przerwie");
}
catch (Exception exception)
{
this.state = -2;
this.builder.SetException(exception);
return;
}
this.state = -2;
this.builder.SetResult();
}
To dużo kodu. Możliwe, że zwróciłeś uwagę, iż znajduje się tu dużo instrukcji goto
i etykiet, co prawie nigdy nie zdarza się w ręcznie pisanym kodzie w C#. Podejrzewam,
że na razie ten kod może wydać Ci się niezrozumiały. Chciałem jednak zaprezentować
konkretny przykład, od którego można zacząć omówienie. Możesz do niego wrócić,
gdy uznasz to za konieczne. Teraz powiążę ten kod z ogólną strukturą, a następnie
omówię szczegóły związane z wyrażeniami await. Gdy zakończysz lekturę tego pod-
rozdziału, kod z listingu 6.3 zapewne nadal w ogóle nie będzie Ci się podobał, ale
łatwiej będzie Ci zrozumieć, co ten kod robi i dlaczego.
87469504f326f0d7c1fcda56ef61bd79
8
6.2. Prosta implementacja metody MoveNext() 243
Oto przypomnienie — metoda MoveNext() jest wywoływana raz, gdy metoda asyn-
chroniczna jest uruchamiana po raz pierwszy, a następnie raz w każdej sytuacji, gdy
metoda wznawia pracę po wstrzymaniu spowodowanym wyrażeniem await. Jeśli każde
wyrażenie await używa szybkiej ścieżki, metoda MoveNext() jest wywoływana tylko raz.
Metoda ta odpowiada za następujące operacje:
Wykonywanie kodu od właściwego miejsca (czy będzie to początek pierwotnego
asynchronicznego kodu, czy inny punkt w tym kodzie).
Zachowywanie stanu (zarówno zmiennych lokalnych, jak i lokalizacji w kodzie),
gdy trzeba wstrzymać pracę.
Planowanie kontynuacji, gdy trzeba wstrzymać pracę.
Pobieranie wartości zwracanych przez awaitery.
Przekazywanie wyjątków za pomocą buildera (zamiast pozwalania na zgłoszenie
wyjątku przez samą metodę MoveNext()).
Przekazywanie za pomocą buildera zwracanej wartości lub informacji o ukoń-
czeniu pracy metody.
Na tej podstawie na listingu 6.4 pokazany jest pseudokod ogólnej struktury metody
MoveNext(). W dalszych punktach kod stanie się bardziej złożony z powodu dodatko-
wych mechanizmów sterowania przepływem, jednak będzie to naturalne rozwinięcie tej
wersji.
void IAsyncStateMachine.MoveNext()
{
try
{
switch (this.state)
{
default: goto MethodStart;
case 0: goto Label0A;
case 1: goto Label1A;
case 2: goto Label2A;
Tyle instrukcji case, ile jest wyrażeń await.
}
87469504f326f0d7c1fcda56ef61bd79
8
244 ROZDZIAŁ 6. Implementacja asynchroniczności
W dużym bloku try/catch uwzględniony jest cały kod z pierwotnej metody asynchro-
nicznej. Jeśli w tym bloku wystąpi wyjątek, to niezależnie od sposobu zgłoszenia go
(przez oczekiwanie na nieudaną operację, wywołanie metody synchronicznej zgłaszają-
cej wyjątku lub proste bezpośrednie zgłoszenie wyjątku) wyjątek zostanie przechwycony
i następnie przekazany za pomocą buildera. Jedynie specjalne wyjątki (np. ThreadAbort
Exception i StackOverflowException) powodują, że metoda MoveNext() kończy pracę
zgłoszeniem wyjątku.
W bloku try/catch początek metody MoveNext() zawsze działa jak instrukcja switch
służąca do przechodzenia w odpowiednie miejsce kodu metody na podstawie stanu.
Jeśli stan jest nieujemny, oznacza to, że kod wznawia pracę po wyrażeniu await. W prze-
ciwnym razie można przyjąć, że metoda MoveNext() jest wykonywana po raz pierwszy.
A co z innymi stanami?
W podrozdziale 6.1 wymienione są możliwe stany: nieuruchomiona, wykonywana, wstrzy-
mana i ukończona (stan wstrzymana jest odrębny dla każdego wyrażenia await). Dlaczego
maszyna stanowa nie obsługuje stanów nieuruchomiona, wykonywana i ukończona
w odmienny sposób?
Wynika to z tego, że metody MoveNext() nigdy nie należy wywoływać w stanach wykony-
wana lub ukończona. Wprawdzie można wymusić takie wywołania, pisząc błędną imple-
mentację awaitera lub za pomocą refleksji, jednak standardowo metoda MoveNext() jest
wywoływana tylko w celu uruchamiania lub wznawiania pracy maszyny stanowej. Nie
istnieją nawet odrębne numery stanów wykonywana lub ukończona; dla obu tych stanów
używane jest -1. Istnieje numer dla stanu ukończona (-2), ale maszyna stanowa nigdy nie
sprawdza tej wartości.
87469504f326f0d7c1fcda56ef61bd79
8
6.2. Prosta implementacja metody MoveNext() 245
Jeśli porównasz listingi 6.3 i 6.4, mam nadzieję, że zobaczysz, jak konkretny przy-
kład wpasowuje się w ogólny wzorzec. Objaśniłem już prawie wszystko na temat kodu
wygenerowanego na podstawie prostej metody asynchronicznej, od której zaczęliśmy.
Jedyny fragment, który pozostał do opisania, dotyczy tego, co dokładnie dzieje się
w związku z wyrażeniami await.
W tym kontekście przyjrzyj się pokazanemu na listingu 6.5 fragmentowi listingu 6.3
odpowiadającemu pierwszemu wyrażeniu await.
awaiter1 = Task.Delay(this.delay).GetAwaiter();
if (awaiter1.IsCompleted)
{
goto GetFirstAwaitResult;
}
this.state = num = 0;
this.awaiter = awaiter1;
this.builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this);
87469504f326f0d7c1fcda56ef61bd79
8
246 ROZDZIAŁ 6. Implementacja asynchroniczności
return;
FirstAwaitContinuation:
awaiter1 = this.awaiter;
this.awaiter = default(TaskAwaiter);
this.state = num = -1;
GetFirstAwaitResult:
awaiter1.GetResult();
Nie jest zaskoczeniem, że kod precyzyjnie wykonuje opisane kroki2. Dwie etykiety
reprezentują dwa miejsca, do których kod przeskakuje w zależności od wybranej
ścieżki:
W szybkiej ścieżce program przeskakuje nad kodem wolnej ścieżki.
W wolnej ścieżce program wraca do połowy kodu, gdzie wywoływana jest kon-
tynuacja. Do tego służy instrukcja switch na początku metody.
Dobra wiadomość jest taka, że prawie nigdy nie zetkniesz się z kodem takim jak
omawiany w tym miejscu, chyba że wykonujesz prezentowane tu analizy. Jest też
gorsza wiadomość — rozrastanie się kodu powoduje, że nawet krótkie metody asyn-
chroniczne, także te używające typu ValueTask<TResult>, nie mogą być sensownie roz-
wijane wewnątrzwierszowo przez kompilator JIT. W większości sytuacji jest to nie-
wielka cena, jaką trzeba zapłacić za korzyści oferowane przez mechanizm async/await.
Tak wygląda prosty przypadek z prostym przepływem sterowania. Po tym wpro-
wadzeniu możemy przejść do kilku bardziej skomplikowanych scenariuszy.
2
Nie jest to zaskoczeniem, ponieważ dziwne byłoby, gdybym najpierw przedstawił listę kroków,
a następnie niezgodny z nią kod.
87469504f326f0d7c1fcda56ef61bd79
8
6.3. Jak przepływ sterowania wpływa na metodę MoveNext()? 247
Jak ten kod wygląda po dekompilacji? Bardzo podobnie do kodu z listingu 6.2! Jedyna
różnica polega na tym, że kod:
GetFirstAwaitResult:
awaiter1.GetResult();
Console.WriteLine("Między przerwami");
TaskAwaiter awaiter2 = Task.Delay(this.delay).GetAwaiter();
87469504f326f0d7c1fcda56ef61bd79
8
248 ROZDZIAŁ 6. Implementacja asynchroniczności
Zmiana w maszynie stanowej jest identyczna jak w pierwotnym kodzie. Nie występują
tu dodatkowe pola ani komplikacje związane z wykonywaniem kontynuacji. Używana
jest zwykła pętla.
Przedstawiam ten kod po to, aby pomóc Ci zrozumieć, dlaczego dodatkowe kom-
plikacje są nieuniknione w dalszych przykładach. Na listingu 6.6 nie trzeba przeska-
kiwać do pętli z zewnątrz. Nigdy nie trzeba też wstrzymywać wykonywania kodu
i wyskakiwać z pętli, co skutkuje wstrzymaniem maszyny stanowej. Takie sytuacje
występują, gdy wyrażenia await znajdują się wewnątrz pętli. Przyjrzyjmy się temu.
87469504f326f0d7c1fcda56ef61bd79
8
6.3. Jak przepływ sterowania wpływa na metodę MoveNext()? 249
switch (num)
{
default:
goto MethodStart;
case 0:
goto AwaitContinuation;
}
MethodStart:
Console.WriteLine("Przed pętlą");
this.i = 0; Inicjalizowanie pętli for.
goto ForLoopCondition; Przejście bezpośrednio do sprawdzania warunku z pętli.
ForLoopBody: Ciało pętli for.
Console.WriteLine("Przed wyrażeniem await w pętli");
TaskAwaiter awaiter = Task.Delay(this.delay).GetAwaiter();
if (awaiter.IsCompleted)
{
goto GetAwaitResult;
}
this.state = num = 0;
this.awaiter = awaiter;
this.builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
AwaitContinuation: Miejsce przeskoku po wznowieniu pracy maszyny stanowej.
awaiter = this.awaiter;
this.awaiter = default(TaskAwaiter);
this.state = num = -1;
GetAwaitResult:
awaiter.GetResult();
Console.WriteLine("Po wyrażeniu await w pętli");
this.i++; Iterowanie w pętli for.
ForLoopCondition:
if (this.i < 3)
{ Sprawdzanie warunku z pętli i przejście
do ciała, jeśli warunek jest spełniony.
goto ForLoopBody;
}
Console.WriteLine("Po wyrażeniu await w pętli");
Mógłbym pominąć ten przykład, ale pojawia się tu kilka ciekawych kwestii. Po pierw-
sze kompilator języka C# nie przekształca metody asynchronicznej w analogiczny kod
C# bez mechanizmu async/await. Kompilator musi jedynie wygenerować odpowiedni
kod pośredni. W niektórych miejscach reguły języka C# są bardziej wymagające
niż reguły dotyczące kodu pośredniego. Dotyczy to np. tego, jakie identyfikatory są
poprawne.
Po drugie, choć dekompilatory mogą być przydatne do analizowania kodu asynchro-
nicznego, czasem generują nieprawidłowy kod w języku C#. Gdy po raz pierwszy
zdekompilowałem skompilowany kod z listingu 6.7, otrzymałem pętlę while z ety-
kietą i instrukcję goto spoza pętli próbującą przeskoczyć do wspomnianej etykiety.
Czasem możesz uzyskać poprawny (ale mniej czytelny) kod w C#, nakazując dekom-
pilatorowi, aby nie próbował uzyskać idiomatycznego kodu w C#. Otrzymasz wtedy
bardzo dużą liczbę instrukcji goto.
87469504f326f0d7c1fcda56ef61bd79
8
250 ROZDZIAŁ 6. Implementacja asynchroniczności
Po trzecie, jeśli nie jesteś jeszcze przekonany, to wiedz, że nie chciałbyś ręcznie
pisać takiego kodu. Gdybyś musiał wykonać opisane zadanie w C# 4, bez wątpienia
zastosowałbyś zupełnie inne rozwiązanie, jednak byłoby ono zdecydowanie mniej
eleganckie od metod asynchronicznych, jakie można wykorzystać w C# 5.
Zobaczyłeś już, że oczekiwanie w pętli może przysporzyć ludziom problemów,
nie jest jednak żadną trudnością dla kompilatora. W ostatnim przykładzie ilustrują-
cym przepływ sterowania utrudnimy nieco pracę kompilatorowi, wprowadzając blok
try/finally.
W tym punkcie pokazuję tylko oczekiwanie w bloku try z blokiem finally. Jest to
prawdopodobnie najczęściej stosowany rodzaj bloku try, ponieważ to właśnie jemu
odpowiadają instrukcje using. Na listingu 6.9 pokazana jest dekompilowana później
metoda asynchroniczna. Dane wyjściowe wyświetlane w konsoli służą tylko ułatwieniu
zrozumienia maszyny stanowej.
Możliwe, że wyobrażasz sobie, iż zdekompilowany kod będzie wyglądał mniej więcej tak:
switch (num)
{
default:
goto MethodStart;
87469504f326f0d7c1fcda56ef61bd79
8
6.3. Jak przepływ sterowania wpływa na metodę MoveNext()? 251
case 0:
goto AwaitContinuation;
}
MethodStart:
...
try
{
...
AwaitContinuation:
...
GetAwaitResult:
...
}
finally
{
...
}
...
Każdy wielokropek (…) reprezentuje tu więcej kodu. Z tym podejściem związany jest
pewien problem — nawet w kodzie pośrednim nie można przeskakiwać spoza bloku
try do jego wnętrza. Przypomina to nieco problem z poprzedniego punktu (dotyczącego
pętli), jednak tym razem zamiast reguł języka C# należy uwzględnić reguły języka
pośredniego.
Aby uwzględnić te reguły, kompilator języka C# używa techniki, którą lubię nazy-
wać trampoliną. (Nie jest to oficjalna terminologia, jednak pojęcie to jest stosowane
w innym kontekście w podobnym sensie). Kompilator przeskakuje do miejsca tuż przed
blokiem try, a następnie w bloku try pierwszy fragment kodu przeskakuje do odpowied-
niego miejsca wewnątrz bloku.
Oprócz zastosowania trampoliny trzeba też odpowiednio obsłużyć blok finally.
Są trzy sytuacje, w których wykonywany jest blok finally z wygenerowanego kodu:
dotarcie do końca bloku try,
zgłoszenie wyjątku w bloku try,
konieczność wstrzymania pracy w bloku try z powodu wyrażenia await.
Jeśli metoda asynchroniczna zawiera instrukcję return, jest to kolejna sytuacja. Jeżeli
blok finally jest wykonywany z powodu wstrzymania maszyny stanowej i zwrócenia
sterowania do jednostki wywołującej, nie należy uruchamiać kodu z bloku finally
pierwotnej metody asynchronicznej. W końcu program logicznie wstrzymuje pracę
w bloku try i wznowi działanie po zakończeniu przerwy. Na szczęście takie sytuacje
łatwo jest wykryć. Jeśli maszyna stanowa wciąż działa lub zakończyła pracę, zmienna
lokalna num (która zawsze jest identyczna z polem state) ma wartość ujemną, a po
wstrzymaniu ta zmienna ma wartość nieujemną.
Wszystko to prowadzi do listingu 6.10. Pokazany jest tu kod z zewnętrznego bloku
try metody MoveNext(). Choć kodu nadal jest dużo, w większości jest podobny do tego,
co już widziałeś. Aspekty związane z blokiem try/finally są wyróżnione pogrubieniem.
87469504f326f0d7c1fcda56ef61bd79
8
252 ROZDZIAŁ 6. Implementacja asynchroniczności
switch (num)
{
default:
goto MethodStart;
case 0:
goto AwaitContinuationTrampoline; Przeskok do miejsca tuż przed trampoliną,
} co pozwala wznowić wykonywanie kodu
MethodStart: od odpowiedniego miejsca.
Console.WriteLine("Przed blokiem try");
AwaitContinuationTrampoline:
try
{
switch (num)
{
default:
goto TryBlockStart;
Trampolina w bloku try.
case 0:
goto AwaitContinuation;
}
TryBlockStart:
Console.WriteLine("Przed wyrażeniem await");
TaskAwaiter awaiter = Task.Delay(this.delay).GetAwaiter();
if (awaiter.IsCompleted)
{
goto GetAwaitResult;
}
this.state = num = 0;
this.awaiter = awaiter;
this.builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
return;
AwaitContinuation: Rzeczywista docelowa kontynuacja.
awaiter = this.awaiter;
this.awaiter = default(TaskAwaiter);
this.state = num = -1;
GetAwaitResult:
awaiter.GetResult();
Console.WriteLine("Po wyrażeniu await");
}
finally
{
if (num < 0)
{
Console.WriteLine("W bloku finally"); Ignorowanie bloku finally,
jeśli praca została wstrzymana.
}
}
Console.WriteLine("Po bloku finally");
87469504f326f0d7c1fcda56ef61bd79
8
6.4. Kontekst wykonania i przekazywanie kontekstu 253
skocz do X”, kompilator czasem może zastosować prostszy kod z rozgałęzianiem. Zacho-
wanie spójności w wielu sytuacjach jest ważne, jeśli ktoś ma czytać kod źródłowy,
jednak dla kompilatora spójność nie jest istotna.
Jeden z aspektów, które na razie opisałem bardzo pobieżnie, dotyczy tego, dlaczego
w awaiterach trzeba implementować interfejs INotifyCompletion, a dodatkowo można
zaimplementować interfejs ICriticalNotifyCompletion, a także wpływu implementacji
tych interfejsów na generowany kod. Przyjrzyjmy się teraz bliżej temu zagadnieniu.
87469504f326f0d7c1fcda56ef61bd79
8
254 ROZDZIAŁ 6. Implementacja asynchroniczności
Jeśli kiedyś będziesz pisał własną klasę awaitera, a chcesz, by kod działał popraw-
nie i bezpiecznie w częściowo zaufanych środowiskach, powinieneś zadbać o to, aby
metoda INotifyCompletion.OnCompleted przekazywała kontekst wykonania (za pomocą
wywołań ExecutionContext.Capture i ExecutionContext.Run). Możesz też zaimplemen-
tować interfejs ICriticalNotifyCompletion, zignorować przekazywanie kontekstu i ufać,
że kontekst zostanie przekazany przez infrastrukturę do obsługi asynchroniczności. To
podejście można potraktować jak optymalizację pod kątem standardowego scenariusza,
w którym awaitery są używane tylko przez infrastrukturę do obsługi asynchroniczno-
ści. Nie ma sensu dwukrotne przechwytywanie i przywracanie kontekstu wykonania
w sytuacji, gdy bezpiecznie można to zrobić raz.
W trakcie kompilowania metody asynchronicznej kompilator dla każdego wyrażenia
await generuje wywołanie metody builder.AwaitOnCompleted lub builder.AwaitUnsafeOn
Completed (zależy to od tego, czy w awaiterze zaimplementowano interfejs ICritical
NotifyCompletion). Wymienione metody buildera są generyczne i są powiązane
z ograniczeniami gwarantującymi, że w przekazanych do nich awaiterach zaimple-
mentowany jest właściwy interfejs.
Jeśli będziesz kiedyś implementował własny niestandardowy typ zadania (przy-
pominam, że jest to bardzo mało prawdopodobne w celach innych niż czysto eduka-
cyjne), powinieneś zastosować ten sam wzorzec co w typie AsyncTaskMethodBuilder —
przechwytywać kontekst wykonania w metodach AwaitOnCompleted i AwaitUnsafeOn
Completed, aby w razie potrzeby można było bezpiecznie wywołać metodę ICritical
NotifyCompletion.UnsafeOnCompleted. Skoro już jesteśmy przy niestandardowych zada-
niach, to warto po omówieniu wykorzystania przez kompilator typu AsyncTaskMethod
Builder przypomnieć wymogi dotyczące niestandardowych builderów zadań.
87469504f326f0d7c1fcda56ef61bd79
8
Podsumowanie 255
Podsumowanie
Metody asynchroniczne są przekształcane w metody kontrolne i maszyny stanowe
z użyciem infrastruktury do obsługi asynchroniczności (w postaci builderów).
Maszyna stanowa zapisuje buildery, parametry metod, zmienne lokalne, awaitery
i miejsce wznowienia pracy w kontynuacji.
Kompilator generuje kod pozwalający wrócić po wznowieniu pracy do środka
metody.
87469504f326f0d7c1fcda56ef61bd79
8
256 ROZDZIAŁ 6. Implementacja asynchroniczności
87469504f326f0d7c1fcda56ef61bd79
8
Dodatkowe mechanizmy
z C# 5
Zawartość rozdziału:
Zmiany w przechwytywaniu zmiennych w pętlach
foreach
Atrybuty z informacjami o jednostce wywołującej
87469504f326f0d7c1fcda56ef61bd79
8
258 ROZDZIAŁ 7. Dodatkowe mechanizmy z C# 5
Jakich danych wyjściowych oczekiwałbyś, gdybym nie zwrócił Twojej uwagi na pro-
blem? Większość programistów spodziewa się wyświetlenia x, następnie y, a potem z.
Takie działanie kodu byłoby przydatne. Jednak w rzeczywistości kompilator języka C#
w wersjach sprzed 5 wyświetliłby trzykrotnie z, co nie jest pomocne.
87469504f326f0d7c1fcda56ef61bd79
8
7.2. Atrybuty z informacjami o jednostce wywołującej 259
Ten kod nie wyświetla trzykrotnie ostatniej nazwy, a zamiast tego zgłasza wyjątek
ArgumentOutOfRangeException, ponieważ w momencie rozpoczęcia wykonywania
delegatów wartość i jest równa 3.
Nie jest to przeoczenie ze strony zespołu projektowego odpowiedzialnego za C#.
Chodzi o to, że gdy w inicjatorze pętli for deklarowana jest zmienna lokalna, operacja
ta jest wykonywana raz na cały czas pracy pętli. Składnia tej pętli sprawia, że łatwo
jest dostrzec używany model, z kolei składnia pętli foreach sugeruje model mentalny,
w którym w każdej iteracji stosowana jest jedna zmienna. Pora przejść do ostatniego
nowego mechanizmu z C# 5 — do atrybutów z informacjami o jednostce wywołującej.
87469504f326f0d7c1fcda56ef61bd79
8
260 ROZDZIAŁ 7. Dodatkowe mechanizmy z C# 5
87469504f326f0d7c1fcda56ef61bd79
8
7.2. Atrybuty z informacjami o jednostce wywołującej 261
1
NLog to jedyna znana mi platforma zapisu danych w dzienniku, która bezpośrednio obsługuje
omawiane atrybuty, a i ona obsługuje je tylko warunkowo, w zależności od docelowej wersji
platformy .NET.
87469504f326f0d7c1fcda56ef61bd79
8
262 ROZDZIAŁ 7. Dodatkowe mechanizmy z C# 5
Dzięki metodzie pomocniczej nie trzeba sprawdzać wartości null w każdej właściwo-
ści. Można łatwo przekształcić ją w metodę rozszerzająca, aby uniknąć powielania jej
w każdej implementacji interfejsu.
Ten kod jest nie tylko długi (co się nie zmieniło), ale też podatny na błędy. Problem
wynika z tego, że nazwa właściwości (FirstValue) jest podawana jako literał tekstowy,
a jeśli w ramach refaktoryzacji zmienisz nazwę właściwości, łatwo możesz zapomnieć
o modyfikacji tego literału. Jeśli będziesz miał szczęście, narzędzia i testy pomogą
87469504f326f0d7c1fcda56ef61bd79
8
7.2. Atrybuty z informacjami o jednostce wywołującej 263
dostrzec błąd, jednak takie rozwiązanie i tak jest nieeleganckie. W rozdziale 9. zobaczysz,
że wprowadzony w C# 6 operator nameof ułatwia refaktoryzację takiego kodu, rozwią-
zanie nadal jest jednak podatne na błędy związane z kopiowaniem i wklejaniem.
Dzięki atrybutom z informacjami o jednostce wywołującej większość kodu pozo-
staje taka sama, można jednak sprawić, by to kompilator uzupełniał nazwę właściwości.
Służy do tego atrybut [CallerMemberName] w metodzie pomocniczej. Ilustruje to list-
ing 7.5.
if (value != firstValue)
{
firstValue = value; Zmiany w setterze właściwości.
NotifyPropertyChanged();
}
Pokazane zostały tu tylko zmienione fragmenty kodu. Rozwiązanie jest tak proste.
Teraz po zmianie nazwy właściwości kompilator użyje nowej wersji. Nie jest to prze-
łomowe usprawnienie, ale kod jest teraz lepszy.
W odróżnieniu od mechanizmów zapisu danych w dzienniku ten wzorzec jest
wykorzystywany w platformach MVVM (ang. model-view-viewmodel), które zapewniają
klasy bazowe dla modeli widoków i modeli danych. Na przykład w platformie Xamarin
Forms klasa BindableObject udostępnia metodę OnPropertyChanged z atrybutem Caller
MemberName. Podobnie platforma MVVM Caliburn Micro zawiera klasę PropertyChanged
Base z metodą NotifyOfPropertyChange. To zapewne wszystko, co musisz wiedzieć na
temat atrybutów z informacjami o jednostce wywołującej. Występuje tu jednak kilka
ciekawych osobliwości, związanych przede wszystkim z atrybutem CallerMemberName.
87469504f326f0d7c1fcda56ef61bd79
8
264 ROZDZIAŁ 7. Dodatkowe mechanizmy z C# 5
2
Takie wywołanie daje dodatkowe korzyści w postaci sprawdzania istnienia składowych w czasie
kompilacji i wyższej wydajności w czasie wykonywania programu.
87469504f326f0d7c1fcda56ef61bd79
8
7.2. Atrybuty z informacjami o jednostce wywołującej 265
W pierwszym wywołaniu kod z listingu 7.6 wyświetla numer wiersza równy 0, jednak
oba rozwiązania problemu dają poprawny numer wiersza. Trzeba tu wybrać między
prostym kodem a zachowywaniem dodatkowych informacji. Żadne z rozwiązań nie jest
odpowiednie, jeśli musisz dynamicznie wybierać wersję przeciążonej metody. Ponadto,
co oczywiste, niektóre wersje metody będą wymagać informacji o jednostce wywołującej,
a inne nie. Jeśli chodzi o ograniczenia, moim zdaniem są one sensowne. Pora przejść
do nietypowych nazw.
NIEOCZYWISTE NAZWY SKŁADOWYCH
Gdy kompilator podaje nazwę składowej z jednostki wywołującej, a tą jednostką jest
metoda, nazwa składowej jest oczywista — jest to nazwa metody. Jednak nie wszystko
jest metodą. Oto kilka scenariuszy do rozważenia:
wywołania w konstruktorze instancji,
wywołania w konstruktorze statycznym,
wywołania w finalizatorze,
wywołania w operatorze,
wywołania w inicjalizatorze pola, zdarzenia lub właściwości3,
wywołania w indekserze.
3
Inicjalizatory automatycznie implementowanych właściwości wprowadzono w C# 6. Szczegółowe
informacje znajdziesz w punkcie 8.2.2, jeśli jednak spróbujesz zgadnąć, jak działają takie inicjali-
zatory, prawdopodobnie zrobisz to poprawnie.
87469504f326f0d7c1fcda56ef61bd79
8
266 ROZDZIAŁ 7. Dodatkowe mechanizmy z C# 5
87469504f326f0d7c1fcda56ef61bd79
8
7.2. Atrybuty z informacjami o jednostce wywołującej 267
string[] source =
{
"the", "quick", "brown", "fox",
"jumped", "over", "the", "lazy", "dog"
};
var query = from word in source Wyrażenie reprezentujące zapytanie
where word.Length > 3 z metodami, które przechwytują
select word.ToUpperInvariant(); informacje o jednostce wywołującej.
Console.WriteLine("Dane:");
Console.WriteLine(string.Join(", ", query)); Zapis danych w dzienniku.
Console.WriteLine("CallerInfo:");
Console.WriteLine(string.Join( Zapis w dzienniku informacji
Environment.NewLine, query.CallerInfo)); o jednostce wywołującej zapytanie.
Czy jest to przydatna technika? Szczerze mówiąc, zapewne nie. Pokazuje ona jednak,
że gdy projektanci języka dodają nowy mechanizm, muszą starannie przemyśleć wiele
scenariuszy. Byłoby irytujące, gdyby ktoś znalazł przydatne zastosowanie informacji
o jednostce wywołującej wyrażenia reprezentujące zapytania, a specyfikacja nie okre-
ślałaby jednoznacznie, jak język działa w takiej sytuacji. Do omówienia pozostał
ostatni rodzaj wywołań zmiennej. Mnie wydaje się on jeszcze bardziej wyrafinowany niż
inicjalizatory konstruktora i wyrażenia reprezentujące zapytania. Chodzi tu o tworzenie
instancji atrybutów.
ATRYBUTY OPATRZONE ATRYBUTAMI
Z INFORMACJAMI O JEDNOSTCE WYWOŁUJĄCEJ
Zwykle traktuję stosowanie atrybutów jak podawanie dodatkowych danych. Atrybuty
nie przypominają wywołań, jednak też są kodem, dlatego gdy tworzony jest obiekt
atrybutu (zwykle w celu zwrócenia przez wywołanie z mechanizmu refleksji), wywo-
ływane są konstruktory oraz settery właściwości. Co jest jednostką wywołującą, gdy
tworzysz atrybut, w którym w konstruktorze używane są atrybuty z informacjami o jed-
nostce wywołującej? Przekonajmy się.
87469504f326f0d7c1fcda56ef61bd79
8
268 ROZDZIAŁ 7. Dodatkowe mechanizmy z C# 5
Przede wszystkim potrzebna jest klasa atrybutu. Ten aspekt jest prosty, a ilustruje
go listing 7.9.
[AttributeUsage(AttributeTargets.All)]
public class MemberDescriptionAttribute : Attribute
{
public MemberDescriptionAttribute(
[CallerFilePath] string file = "Nieokreślony plik",
[CallerLineNumber] int line = 0,
[CallerMemberName] string member = "Nieokreślona składowa")
{
File = file;
Line = line;
Member = member;
}
87469504f326f0d7c1fcda56ef61bd79
8
7.2. Atrybuty z informacjami o jednostce wywołującej 269
Console.WriteLine(typeParamInfo.GetCustomAttribute<MDA>());
}
}
87469504f326f0d7c1fcda56ef61bd79
8
270 ROZDZIAŁ 7. Dodatkowe mechanizmy z C# 5
Services. Aby uniknąć kolizji typów, upewnij się, że są one udostępniane tylko
wtedy, jeśli niedostępne są atrybuty zapewniane przez system. Może to być skompli-
kowane (jak wszystkie kwestie związane z wersjonowaniem), a omawianie szczegółów
takiego rozwiązania wykracza poza zakres tej książki.
Gdy zaczynałem pisać ten rozdział, nie sądziłem, że napiszę tak dużo o atrybutach
z informacjami o jednostce wywołującej. Nie mogę powiedzieć, że często używam tego
mechanizmu w codziennej pracy, uważam jednak, że aspekty związane z projektowaniem
są fascynujące. Dzieje się tak nie mimo tego, że omawiany mechanizm jest mało popu-
larny. Ważne jest właśnie to, że jest to mniej istotna funkcja. Zrozumiałe jest, że ważne
mechanizmy — typowanie dynamiczne, typy generyczne, mechanizm async/await —
wymagają dużej ilości pracy nad projektem języka. Jednak mniej istotne funkcje także
związane są z wieloma przypadkami brzegowymi. Różne mechanizmy często są ze
sobą powiązane, dlatego jednym z zagrożeń wprowadzania nowej funkcji jest to, że
może ona w przyszłości utrudnić projektowanie lub implementowanie innych mecha-
nizmów.
Podsumowanie
Od wersji C# 5 przechwytywane zmienne iteracyjne w pętli foreach są bardziej
przydatne.
Możesz używać atrybutów z informacjami o jednostce wywołującej, aby zażądać
od kompilatora podania parametrów na podstawie pliku źródłowego, numeru
wiersza i nazwy składowej jednostki wywołującej.
Atrybuty z informacjami o jednostce wywołującej ilustrują, jaki poziom szcze-
gółowości jest często potrzebny w trakcie projektowania języka.
87469504f326f0d7c1fcda56ef61bd79
8
Część 3
C# 6
87469504f326f0d7c1fcda56ef61bd79
8
87469504f326f0d7c1fcda56ef61bd79
8
Odchudzone właściwości
i składowe z ciałem
w postaci wyrażenia
Zawartość rozdziału:
Automatyczne implementowanie właściwości
przeznaczonych tylko do odczytu
Inicjalizowanie automatycznie implementowanych
właściwości w miejscu ich deklaracji
Eliminowanie zbędnych ceregieli w składowych
z ciałem w postaci wyrażenia
87469504f326f0d7c1fcda56ef61bd79
8
274 ROZDZIAŁ 8. Odchudzone właściwości i składowe z ciałem w postaci wyrażenia
Na pozór ten kod nie wygląda źle, jednak możliwości klasy (mam dostęp do wartości
X i Y) są tu ściśle powiązane z implementacją (używane są dwa pola typu double). Imple-
mentacja nie zapewnia tu kontroli nad polami. Jeśli stan klasy jest bezpośrednio dostępny
za pomocą pól, nie można wykonywać następujących operacji:
sprawdzać poprawności w momencie przypisywania nowych wartości (np. zapo-
biegać wartościom nieskończonym i innym niż liczby dla współrzędnych X i Y);
wykonywać obliczenia w trakcie pobierania wartości (np. jeśli chcesz zapisywać
pola w innym formacie; gdy używane są punkty, jest to mało prawdopodobne,
może jednak być potrzebne w innych sytuacjach).
Możesz stwierdzić, że zawsze można zmienić pole we właściwość później, gdy potrzebne
będą dodatkowe możliwości. Jest to jednak zmiana naruszająca zgodność kodu, czego
prawdopodobnie wolisz unikać. Taka zmiana narusza zgodność z kodem źródłowym,
plikami binarnymi i mechanizmem refleksji. Jest to zbyt poważne ryzyko, aby je podej-
mować tylko po to, by początkowo uniknąć stosowania właściwości.
W C# 1 język prawie nie zapewniał wsparcia w używaniu właściwości. W wyko-
rzystującej właściwości wersji listingu 8.1 konieczne byłoby ręczne zadeklarowanie
podstawowych pól, a także getterów i setterów każdej właściwości. Ilustruje to listing 8.2.
87469504f326f0d7c1fcda56ef61bd79
8
8.1. Krótka historia właściwości 275
kodu. Właściwości tego rodzaju można byłoby udostępniać jako pola, jednak trudno
jest przewidzieć, które właściwości w przyszłości mogą wymagać dodatkowego kodu.
Nawet jeśli możesz precyzyjnie to oszacować, bez powodu pracujesz wtedy na dwóch
poziomach abstrakcji. Dla mnie właściwości działają jak kontrakt udostępniany przez
typ — są oferowanymi przez typ mechanizmami. Pola to jedynie szczegół implemen-
tacji. Są mechanizmem w skrzynce, o którym użytkownicy w zdecydowanej większości
sytuacji nie muszą nic wiedzieć. Prawie zawsze preferuję używanie prywatnych pól.
Ten kod jest niemal identyczny z kodem z listingu 8.2. Różnica polega na tym, że
podstawowe pola nie są tu bezpośrednio używane. Pola otrzymują niewymawialne
nazwy, które nie są poprawnymi identyfikatorami języka C#, ale są dozwolone w śro-
dowisku uruchomieniowym.
Ważne jest to, że w C# 3 umożliwiono automatyczne implementowanie wyłącznie
właściwości przeznaczonych do odczytu i zapisu. Nie zamierzam omawiać tu wszystkich
zalet (i pułapek) związanych z niemodyfikowalnością, istnieje jednak wiele powodów,
dla których możesz chcieć utworzyć klasę Point jako niemodyfikowalną. Aby właści-
wości były naprawdę przeznaczone tylko do odczytu, musisz napisać kod ręcznie, tak
jak na listingu 8.4.
87469504f326f0d7c1fcda56ef61bd79
8
276 ROZDZIAŁ 8. Odchudzone właściwości i składowe z ciałem w postaci wyrażenia
this.x = x;
this.y = y; Inicjalizowanie pól w konstruktorze.
}
}
Jest to, mówiąc delikatnie, irytujące rozwiązanie. Wielu programistów, w tym ja, ucie-
kało się czasem do oszustwa. Jeśli chciałem utworzyć właściwość przeznaczoną tylko do
odczytu, używałem automatycznie implementowanej właściwości z prywatnym setterem,
tak jak na listingu 8.5.
To rozwiązanie działa, ale nie jest w pełni zadowalające. Nie wyraża tego, czego pro-
gramista potrzebuje. Możliwa jest zmiana wartości właściwości w klasie, nawet jeśli
programista tego nie chce. Potrzebna jest właściwość, której wartość jest ustawiana
w konstruktorze i nigdy nie zmienia się w innych miejscach. Ponadto powinna być ona
wiązana z polem w prosty sposób. Do wersji C# 5 język wymagał wyboru między pro-
stotą implementacji lub jasno określonym celem. Wybór jednej z tych cech oznaczał
rezygnację z drugiej. Od wersji C# 6 nie musisz już godzić się na kompromis. Możesz
pisać zwięzły kod jasno określający Twoje zamiary.
87469504f326f0d7c1fcda56ef61bd79
8
8.2. Usprawnienia automatycznie implementowanych właściwości 277
Jedyne elementy, które zmieniły się w porównaniu z listingiem 8.5, to deklaracje wła-
ściwości X i Y. Setter w ogóle w nich nie występuje. Ponieważ nie ma setterów, moż-
liwe, że zastanawiasz się, jak zainicjalizować właściwości w konstruktorze. Odbywa
się to tak jak na listingu 8.4, gdzie właściwości były zaimplementowane ręcznie. Pole
deklarowane z użyciem automatycznie implementowanej właściwości jest przeznaczone
tylko do odczytu, a wszelkie operacje przypisania wartości do właściwości są przekształ-
cane przez komputer na bezpośrednie przypisania wartości do pola. Próba ustawienia
wartości właściwości poza konstruktorem skutkuje błędem kompilacji.
Ponieważ jestem fanem niemodyfikowalności, jest to dla mnie prawdziwy krok
naprzód. Mogę uzyskać pożądany efekt za pomocą niewielkiej ilości kodu. Lenistwo nie
jest teraz przeszkodą do zachowania higieny w kodzie — przynajmniej w tym małym
obszarze.
Następne ograniczenie wyeliminowane w C# 6 dotyczy inicjalizowania właściwości.
Właściwości pokazywane do tej pory albo w ogóle nie były jawnie inicjalizowane, albo
były inicjalizowane w konstruktorze. Co zrobić, jeśli chcesz zainicjalizować właściwość
w taki sposób, jakby była polem?
87469504f326f0d7c1fcda56ef61bd79
8
278 ROZDZIAŁ 8. Odchudzone właściwości i składowe z ciałem w postaci wyrażenia
Ten kod jest mniej więcej tak długi jak wcześniej! W C# 6 ograniczenie związane z ini-
cjalizowaniem zostało wyeliminowane. Teraz można inicjalizować właściwość w miej-
scu jej deklaracji, co ilustruje listing 8.9.
87469504f326f0d7c1fcda56ef61bd79
8
8.2. Usprawnienia automatycznie implementowanych właściwości 279
Nie jest to kod, jaki chciałbym dodać do rzeczywistego kodu bazowego. Brzydota tego
kodu przeważa nad korzyściami oferowanymi przez automatycznie implementowane
właściwości. Wiesz już, jak tworzyć właściwości tylko do odczytu. Dlaczego jednak
trzeba wywoływać konstruktor domyślny w konstruktorze inicjalizującym?
Wynika to ze złożonych reguł przypisywania wartości do pól w strukturach. Ważne
są tu dwie reguły:
W strukturze nie można używać właściwości, metod, indekserów ani zdarzeń,
chyba że kompilator stwierdzi, iż takie elementy mają przypisaną określoną
wartość.
Każdy konstruktor struktury musi przypisać wartości do wszystkich pól przed
zwróceniem sterowania do jednostki wywołującej.
87469504f326f0d7c1fcda56ef61bd79
8
280 ROZDZIAŁ 8. Odchudzone właściwości i składowe z ciałem w postaci wyrażenia
Wynikowy kod jest przejrzysty i spójny oraz wygląda tak, jak sobie tego życzę.
UWAGA. Możesz się zastanawiać, po co tworzyć Point jako strukturę. Nie jest to oczywista
sytuacja. Wydaje się, że punkty w naturalny sposób odpowiadają typom bezpośrednim. Ja
jednak zwykle domyślnie tworzę klasy. Poza biblioteką Noda Time (gdzie używanych jest
wiele struktur) rzadko piszę struktury. Ten przykład nie ma być dla Ciebie sugestią, że powi-
nieneś zacząć częściej korzystać ze struktur. Jeśli jednak tworzysz własne struktury, obecnie
język jest w tym obszarze bardziej pomocny niż wcześniej.
87469504f326f0d7c1fcda56ef61bd79
8
8.3. Składowe z ciałem w postaci wyrażenia 281
Nie twierdzę, że ten kod jest wysoce nieczytelny, jednak obejmuje dużą ilość składni,
którą mógłbym określić mianem ceregieli. Składnia ta ma tylko informować kompilator
87469504f326f0d7c1fcda56ef61bd79
8
282 ROZDZIAŁ 8. Odchudzone właściwości i składowe z ciałem w postaci wyrażenia
o tym, jak traktować istotny kod. Na rysunku 8.1 pokazana jest ta sama właściwość, ale
z opisem istotnych elementów. Ceregiele (nawias klamrowy, instrukcja return i średnik)
są wyróżnione jaśniejszym odcieniem.
Możliwe, że zastanawiasz się, czy opisana technika jest przydatna w praktyce, poza
fikcyjnymi przykładami z książki. Aby przedstawić konkretny przykład, posłużę się
biblioteką Noda Time.
WŁAŚCIWOŚCI POŚREDNIE LUB DELEGUJĄCE
Pokrótce omówione zostaną tu trzy typy z biblioteki Noda Time:
LocalDate — sama data z określonego kalendarza bez komponentu reprezentu-
jącego czas,
87469504f326f0d7c1fcda56ef61bd79
8
8.3. Składowe z ciałem w postaci wyrażenia 283
Wiele właściwości działa w podobny sposób. Usunięcie z każdej z nich członu { get
{ return … }} było prawdziwą przyjemnością i sprawiło, że kod stał się dużo bardziej
przejrzysty.
WYKONYWANIE PROSTYCH OPERACJI NA INNYM ASPEKCIE STANU
We właściwości LocalTime występuje jeden element stanu — nanosekunda w danym
dniu. Wszystkie pozostałe właściwości obliczają wartość na podstawie tego elementu.
Na przykład kod obliczający części sekundy w nanosekundach to prosta operacja
zwracania reszty:
public int NanosecondOfSecond =>
(int) (NanosecondOfDay % NodaConstants.NanosecondsPerSecond);
Ten kod zostanie dodatkowo uproszczony w rozdziale 10., jednak na razie możesz cie-
szyć się tym, jak zwięzła jest właściwość z ciałem w postaci wyrażenia.
Do tej pory koncentrowałem się głównie na właściwościach, aby w naturalny spo-
sób przejść do innych nowych mechanizmów związanych z właściwościami. Ale jak
może domyśliłeś się na podstawie tytułu podrozdziału, także inne składowe mogą mieć
ciało w postaci wyrażenia.
87469504f326f0d7c1fcda56ef61bd79
8
284 ROZDZIAŁ 8. Odchudzone właściwości i składowe z ciałem w postaci wyrażenia
Ważne zastrzeżenie
Właściwości z ciałem w postaci wyrażenia mają pewną wadę — właściwość tylko do odczytu
oraz publiczne pole do odczytu i zapisu różnią się tylko jednym znakiem. W większości
sytuacji po popełnieniu pomyłki nastąpi błąd kompilacji spowodowany użyciem innych
właściwości lub pól w inicjalizatorze pola. Jednak w przypadku statycznych właściwości
lub właściwości zwracających stałą wartość łatwo o błąd. Rozważ różnicę między dwoma
poniższymi deklaracjami:
// Deklaracja właściwości tylko do odczytu.
public int Foo => 0;
// Deklaracja publicznego pola do odczytu i zapisu.
public int Foo = 0;
Kilkakrotnie przysporzyło mi to kłopotów, jeśli jednak już wiesz o tym problemie, możesz
łatwo sprawdzić kod pod jego kątem. Jeżeli się upewnisz, że także osoby badające kod
wiedzą o tym zagadnieniu, zapewne unikniesz trudności.
public static Point Add(Point left, Vector right) => left + right;
Zwróć uwagę na formatowanie użyte w operatorze operator+. Cały kod znajduje się
w jednym wierszu, a przy tym nie jest on zbyt długi. Zwykle umieszczam operator =>
na końcu deklaracji i dodaję wcięcie dla ciała. Możesz formatować kod w dowolny
87469504f326f0d7c1fcda56ef61bd79
8
8.3. Składowe z ciałem w postaci wyrażenia 285
sposób, stwierdziłem jednak, że pokazana technika dobrze się sprawdza dla wszystkich
składowych z ciałem w postaci wyrażenia.
Ciało w postaci wyrażenia możesz też stosować dla metod zwracających wartość
typu void. Wtedy nie istnieje instrukcja return. Eliminowany jest tylko nawias klamrowy.
UWAGA. Opisana technika jest zgodna z działaniem wyrażeń lambda. Warto jednak przy-
pomnieć, że składowe z ciałem w postaci wyrażenia nie są wyrażeniami lambda, choć mają
wspólne cechy.
87469504f326f0d7c1fcda56ef61bd79
8
286 ROZDZIAŁ 8. Odchudzone właściwości i składowe z ciałem w postaci wyrażenia
Żadne z tych ograniczeń nie sprawia, że nie mogę spać po nocach, jednak ta niespój-
ność najwyraźniej na tyle martwiła zespół odpowiedzialny za język C#, że w C# 7
wszystkie wymienione komponenty mogą mieć ciało w postaci wyrażenia. Zwykle nie
skutkuje to eliminowaniem żadnych wyświetlanych znaków, jednak konwencje forma-
towania pozwalają zaoszczędzić miejsce w pionie. Ponadto ta technika poprawia
czytelność, ponieważ informuje, że używana jest prosta składowa. Dla wszystkich
wymienionych komponentów używana jest ta sama składnia, którą już poznałeś. Na
listingu 8.18 znajdziesz kompletny przykład, przedstawiony wyłącznie w celu zapre-
zentowania składni. Ten kod jest przydatny wyłącznie jako przykład, a jeśli chodzi
o metodę obsługi zdarzeń, jest niebezpieczny ze względu na wątki w porównaniu z pro-
stym zdarzeniem opartym na polu.
87469504f326f0d7c1fcda56ef61bd79
8
8.3. Składowe z ciałem w postaci wyrażenia 287
Wygodnym aspektem jest tu to, że akcesor get może mieć ciało w postaci wyrażenia
nawet wtedy, gdy akcesor set ma standardową postać (i na odwrót). Załóżmy, że chcesz,
aby setter akcesora sprawdzał, czy nowa wartość nie jest ujemna. Możesz wtedy zacho-
wać getter z ciałem w postaci wyrażenia:
public int this[int index]
{
get => values[index];
set
{
if (value < 0)
{
throw new ArgumentOutOfRangeException();
}
Values[index] = value;
}
}
Spodziewam się, że w przyszłości taki kod będzie dość często spotykany. Według
mojego doświadczenia w setterach zwykle sprawdzana jest poprawność, natomiast
gettery są przeważnie bardzo proste.
WSKAZÓWKA. Jeśli zauważysz, że piszesz getter z dużą ilością kodu, warto rozważyć,
czy nie lepiej będzie utworzyć metodę. Czasem wybór między getterem a metodą nie jest
oczywisty.
Składowe z ciałem w postaci wyrażenia mają sporo zalet, ale czy mają jakieś wady?
Jak zdecydowanym należy być w zakresie przekształcania wszystkich możliwych skła-
dowych na taki format?
87469504f326f0d7c1fcda56ef61bd79
8
288 ROZDZIAŁ 8. Odchudzone właściwości i składowe z ciałem w postaci wyrażenia
typach składowych tego rodzaju jest bardzo dużo, a ciało w postaci wyrażenia pozwala
znacznie poprawić czytelność takich typów.
Składowe w postaci wyrażenia mogą być intensywnie używane w prawie każdym
kodzie bazowym, z jakim miałem styczność (różnią się pod tym względem w porówna-
niu z niektórymi innymi, niszowymi mechanizmami). Gdy przekształcałem bibliotekę
Noda Time z użyciem C# 6, usunąłem z kodu ok. 50% instrukcji return. To bardzo
duża różnica, która robi się jeszcze większa wraz ze stopniowym wykorzystywaniem
dodatkowych możliwości oferowanych przez C# 7.
Warto jednak zauważyć, że składowe z ciałem w postaci wyrażenia wpływają nie
tylko na wzrost wydajności. Odkryłem, że mają także efekt psychologiczny. Dzięki nim
w większym stopniu mam wrażenie, że stosuję programowanie funkcyjne. To z kolei
sprawia, że czuję się bardziej inteligentny. To prawda, może się to wydać niezbyt
mądre, ale to naprawdę miłe poczucie. Oczywiście możliwe jest, że okażesz się bardziej
racjonalny ode mnie.
Zagrożeniem, jak zawsze, jest nadużywanie omawianych składowych. W niektórych
sytuacjach nie można stosować składowych z ciałem w postaci wyrażenia, ponieważ
kod obejmuje instrukcję for lub podobne konstrukty. W wielu sytuacjach wykonalne
jest przekształcenie zwykłej metody na składową z ciałem w postaci wyrażenia, jednak
nie należy tego robić. Zauważyłem, że dotyczy to składowych z dwóch kategorii:
składowych sprawdzających warunki wstępne,
składowych używających zmiennych z nazwami objaśniającymi kod.
Przykładem z pierwszej kategorii jest używana przeze mnie klasa Preconditions z gene-
ryczną metodą CheckNotNull, która przyjmuje referencję i nazwę parametru. Jeśli ta
referencja jest równa null, metoda zgłasza wyjątek typu ArgumentNullException i podaje
nazwę parametru. W przeciwnym razie metoda zwraca podaną wartość. Pozwala to
stosować wygodną kombinację instrukcji sprawdzania i przypisywania wartości w kon-
struktorach oraz w innych podobnych miejscach.
Ta technika pozwala też (choć nie jest to wymagane) używać wyniku zarówno jako
obiektu, dla którego wywoływana jest metoda, jak i jako argumentu wywołania. Problem
polega na tym, że jeśli nie zachowasz ostrożności, trudno będzie zrozumieć działanie
kodu. Oto metoda z opisanej wcześniej struktury LocalDateTime:
public ZonedDateTime InZone(
DateTimeZone zone,
ZoneLocalMappingResolver resolver)
{
Preconditions.CheckNotNull(zone);
Preconditions.CheckNotNull(resolver);
return zone.ResolveLocal(this, resolver);
}
Ten kod jest prosty i czytelny. Sprawdza, czy argumenty są poprawne, a następnie
wykonuje zadanie, delegując je do innej metody. Taki kod można zapisać w składowej
z ciałem w postaci wyrażenia:
87469504f326f0d7c1fcda56ef61bd79
8
8.3. Składowe z ciałem w postaci wyrażenia 289
Ten kod działa identycznie, ale jest dużo mniej czytelny. Według mojego doświad-
czenia już jedna operacja sprawdzania poprawności każe się zastanowić, czy warto
przekształcić metodę w składową z ciałem w postaci wyrażenia. Jeśli takie operacje są
dwie, takie przekształcenie sprawia za dużo problemów.
Przejdźmy teraz do zmiennych z nazwami objaśniającymi kod. Przedstawiona
wcześniej przykładowa właściwość NanosecondOfSecond jest jedną z wielu właściwości
z typu LocalTime. Mniej więcej połowa z tych właściwości ma ciała w postaci wyrażenia,
przy czym sporo z tych właściwości zawiera dwie instrukcje:
public int Minute
{
get
{
int minuteOfDay = (int) NanosecondOfDay / NanosecondsPerMinute;
return minuteOfDay % MinutesPerHour;
}
}
Ten kod można łatwo zapisać w formie właściwości z ciałem w postaci wyrażenia,
rozwijając wewnątrzwierszowo zmienną minuteOfDay:
public int Minute =>
((int) NanosecondOfDay / NodaConstants.NanosecondsPerMinute) %
NodaConstants.MinutesPerHour;
Ten kod pozwala osiągnąć ten sam cel, jednak w pierwotnej wersji zmienna minuteOfDay
dodaje informacje na temat znaczenia podwyrażenia, dzięki czemu kod jest bardziej
czytelny.
Innego dnia mógłbym dokonać innego wyboru. Jednak w bardziej złożonych scena-
riuszach wykonywanie sekwencji kroków i nazywanie wyników może znacznie ułatwić
pracę, gdy wrócisz do kodu pół roku później. Takie podejście jest też pomocne, gdy
musisz wykonywać kod w trybie kroczenia w debugerze, ponieważ można wtedy łatwo
wykonywać kolejne instrukcje i sprawdzać, czy wyniki są zgodne z oczekiwaniami.
Dobra wiadomość jest taka, że możesz eksperymentować i zmieniać zdanie tak
często, jak będziesz miał ochotę. Składowe z ciałem w postaci wyrażenia to lukier
składniowy, dlatego jeśli z czasem zmienisz preferencje, zawsze możesz przekształcić
na nie więcej kodu lub zrezygnować z nich w miejscach, gdzie zastosowałeś je zbyt
pochopnie.
87469504f326f0d7c1fcda56ef61bd79
8
290 ROZDZIAŁ 8. Odchudzone właściwości i składowe z ciałem w postaci wyrażenia
Podsumowanie
Obecnie automatycznie implementowane właściwości mogą być przeznaczone
tylko do odczytu i być powiązane z polem tylko do odczytu.
Obecnie automatycznie implementowane właściwości mogą mieć inicjalizatory,
dzięki czemu nie trzeba inicjalizować niedomyślnych wartości w konstruktorze.
W strukturach można używać automatycznie implementowanych właściwości
bez konieczności tworzenia łańcuchów wywołań konstruktorów.
Składowe z ciałem w postaci wyrażenia umożliwiają pisanie prostego kodu (z jed-
nym wyrażeniem) bez zbędnych ceregieli.
Choć ograniczenia sprawiają, że w C# 6 dla niektórych rodzajów składowych
nie można stosować ciała w postaci wyrażenia, w C# 7 te ograniczenia wyeli-
minowano.
87469504f326f0d7c1fcda56ef61bd79
8
Mechanizmy związane
z łańcuchami znaków
Zawartość rozdziału:
Używanie literałów tekstowych z interpolacją,
aby zwiększyć czytelność formatowania
Używanie typu FormattableString na potrzeby
lokalizacji tekstu i niestandardowego formatowania
Używanie operatora nameof do tworzenia referencji
ułatwiających refaktoryzację
Każdy wie, jak używać łańcuchów znaków. Jeśli string nie był pierwszym typem danych
platformy .NET, jaki poznałeś, to zapewne był drugim. Klasa string nie zmieniała się
zbytnio w historii platformy .NET. Ponadto od wersji C# 1 w tym języku nie wprowa-
dzono wielu mechanizmów związanych z łańcuchami znaków. Jednak w C# 6 to się
zmieniło dzięki wprowadzeniu nowego rodzaju literałów tekstowych i nowego ope-
ratora. Oba te mechanizmy są szczegółowo opisane w tym rozdziale. Warto jednak
pamiętać, że same łańcuchy znaków w ogóle się nie zmieniły. Oba opisywane mecha-
nizmy zapewniają nowe sposoby otrzymywania łańcuchów znaków, ale nie robią nic
więcej.
Interpolacja łańcuchów znaków, podobnie jak mechanizmy opisane w rozdziale 8.,
nie służy do wykonywania nowych zadań, a jedynie pozwala przeprowadzać operacje
w bardziej czytelny i zwięzły sposób. Nie chcę w ten sposób bagatelizować znaczenia
tego mechanizmu. Wszystko, co pozwala szybciej pisać bardziej przejrzysty kod,
a później szybciej go czytać, zwiększa Twoją produktywność.
87469504f326f0d7c1fcda56ef61bd79
8
292 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
Ostatni z tych wierszy jest w tym rozdziale najważniejszy. Używana jest tu wersja
metody Console.WriteLine, która przyjmuje złożony łańcuch znaków formatowania (ang.
composite format string) obejmujący elementy formatujące i argumenty zastępujące te
elementy. W tym przykładzie używany jest jeden element formatujący, {0}, zastępowany
wartością zmiennej name. Liczba w elemencie formatującym określa indeks argumentu
zastępującego ten element (0 oznacza pierwszą wartość, 1 oznacza drugą wartość itd.).
Ten wzorzec jest wykorzystywany w różnych interfejsach API. Najbardziej oczy-
wisty przykład to statyczna metoda Format z klasy string, która nie robi nic oprócz
odpowiedniego formatowania łańcuchów znaków. Do tej pory wszystko jest proste.
Pora przejść do nieco bardziej skomplikowanych kwestii.
87469504f326f0d7c1fcda56ef61bd79
8
9.1. Przypomnienie technik formatowania łańcuchów znaków w .NET 293
87469504f326f0d7c1fcda56ef61bd79
8
294 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
Aby wartości były wyrównane do prawej (lub uzupełniane z lewej spacjami, jeśli
spojrzeć na to z innej perspektywy), w kodzie zastosowano wartość wyrównania 9.
Gdyby rachunek był bardzo wysoki i wynosił np. milion dolarów, to wyrównanie nie
byłoby widoczne. Określa ono jedynie minimalną szerokość. Jeśli chcesz napisać kod
wyrównywany do prawej dla każdego możliwego zbioru wartości, musisz najpierw usta-
lić szerokość największej wartości. Tworzenie takiego kodu nie jest przyjemne i oba-
wiam się, że nic w C# 6 nie ułatwia napisania go.
Gdy przedstawiłem dane wyjściowe z listingu 9.1 z komputera z ustawieniami regio-
nalnymi Angielski (Stany Zjednoczone), informacja o regionie miała znaczenie. Na
komputerze z ustawieniami regionalnymi Angielski (Wielka Brytania) używany byłby
symbol funta (£). Na maszynie w ustawieniami regionalnymi Francuski separatorem
dziesiętnym byłby przecinek, a symbolem waluty symbol euro, przy czym znajdowałby
się on na końcu łańcucha znaków, a nie na początku. Takie są uroki lokalizacji, która jest
tematem następnego punktu.
9.1.3. Lokalizacja
Na ogólnym poziomie lokalizacja to proces zapewniania, że kod będzie działał popraw-
nie dla wszystkich użytkowników niezależnie od tego, w jakiej części świata się oni
znajdują. Każdy, kto twierdzi, że lokalizacja jest prosta, albo ma w tym obszarze dużo
większe doświadczenie niż ja, albo nie wprowadzał jej wystarczająco często, by odczuć,
jak bardzo może być trudna. Choć świat jest (prawie) okrągły, lokalizacja wymaga
uwzględnienia wielu przypadków brzegowych. Lokalizacja jest trudna we wszystkich
językach programowania, przy czym w każdym z nich problemy są rozwiązywane
w nieco odmienny sposób.
UWAGA. Choć w tym rozdziale używam określenia lokalizacja, część osób preferuje pojęcie
globalizacja. Microsoft używa obu tych nazw w nieco odmienny sposób niż inne organizacje
z tej branży, a różnica między tymi pojęciami jest subtelna. Proszę więc ekspertów, aby
wybaczyli mi moje swobodne podejście. Ogólny obraz jest ważniejszy niż szczegóły termino-
logii — przynajmniej w tym przypadku.
87469504f326f0d7c1fcda56ef61bd79
8
9.1. Przypomnienie technik formatowania łańcuchów znaków w .NET 295
Często w sygnaturze metody występuje nie typ CultureInfo, ale interfejs IFormat
Provider, który jest implementowany w typie CultureInfo.Większość metod zwią-
zanych z formatowaniem ma przeciążone wersje z pierwszym parametrem typu IFormat
Provider podanym przed samym łańcuchem znaków formatowania. Rozważ np. te dwie
sygnatury metody string.Format:
static string Format(IFormatProvider provider,
string format, params object[] args)
static string Format(string format, params object[] args)
Zwykle jeśli dostępne są przeciążone wersje różniące się tylko jednym parametrem,
ten parametr znajduje się na końcu. Dlatego można byłoby oczekiwać, że parametr
provider znajdzie się po parametrze args. To jednak nie zadziała, ponieważ args jest
tablicą parametrów (używany jest tu modyfikator params). Jeśli metoda ma tablicę para-
metrów, ta tablica musi być ostatnim parametrem.
Choć używany jest parametr typu IFormatProvider, wartość przekazywana jako argu-
ment jest prawie zawsze typu CultureInfo. Na przykład, jeśli chcesz sformatować datę
mojego urodzenia (19 czerwca 1976 r.), używając ustawień regionalnych Angielski
(Stany Zjednoczone), możesz posłużyć się następującym kodem:
var usEnglish = CultureInfo.GetCultureInfo("en-US");
var birthDate = new DateTime(1976, 6, 19);
string formatted = string.Format(usEnglish, "Jon urodził się {0:d}", birthDate);
87469504f326f0d7c1fcda56ef61bd79
8
296 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
Dane wyjściowe dla Tajlandii pokazują, że urodziłem się w 2519 r. tajskiego kalendarza
buddyjskiego, a dane wyjściowe dla Afganistanu informują, że urodziłem się w 1355 r.
kalendarza islamskiego:
...
tg-Cyrl 19.06.1976
tg-Cyrl-TJ 19.06.1976
th 19/6/2519
th-TH 19/6/2519
ti 19/06/1976
ti-ER 19/06/1976
...
ur-PK 19/06/1976
uz 19/06/1976
uz-Arab 29/03 1355
uz-Arab-AF 29/03 1355
uz-Cyrl 19/06/1976
uz-Cyrl-UZ 19/06/1976
...
W tym przykładzie widać też, że ujemna wartość wyrównania jest używana do wyrów-
nania do lewej nazw ustawień regionalnych (element formatujący {0,-15}), natomiast
data jest wyrównana do prawej (element formatujący {1,12:d}).
FORMATOWANIE Z UŻYCIEM DOMYŚLNYCH USTAWIEŃ REGIONALNYCH
Jeśli nie podasz dostawcy formatowania lub przekażesz null jako argument odpowia-
dający parametrowi typu IFormatProvider, domyślnie użyta zostanie wartość CultureInfo.
CurrentCulture. Znaczenie tej wartości zależy od kontekstu. Może być ona ustawiona
na poziomie wątku, a niektóre platformy do tworzenia aplikacji internetowych ustawiają
ją przed rozpoczęciem przetwarzania żądania w konkretnym wątku.
Mogę jedynie doradzić zachowanie ostrożności przy używaniu domyślnego usta-
wienia. Upewnij się, że wartość używana w określonym wątku będzie odpowiednia.
(Dokładne sprawdzanie działania wątku jest ważne przede wszystkim w sytuacji, gdy
zaczynasz równolegle wykonywać operacje w wielu wątkach). Jeśli nie chcesz polegać
na domyślnych ustawieniach regionalnych, musisz ustalić ustawienia użytkownika koń-
cowego potrzebne do sformatowania tekstu i jawnie je zastosować.
FORMATOWANIE NA POTRZEBY MASZYN
Do tej pory zakładałem, że chcesz formatować tekst na potrzeby użytkownika końco-
wego. Jednak często jest inaczej. W komunikacji między maszynami (np. w celu par-
sowania parametrów zapytania z adresu URL w usłudze sieciowej) powinieneś sto-
sować niezmienne ustawienia regionalne (ang. invariant culture), tworzone za pomocą
statycznej właściwości CultureInfo.InvariantCulture.
Przyjmijmy, że używasz usługi sieciowej do pobrania listy bestsellerów danego
wydawnictwa. Adres URL tej usługi to https://manning.com/webservices/bestsellers.
Można w nim podać parametr zapytania date, aby znaleźć najlepiej sprzedające się
książki do określonej daty1. Oczekiwałbym, że w tym parametrze dla dat używany
będzie format ISO-8601 (rok-miesiąc-dzień). Na przykład, jeśli chcesz pobrać naj-
1
O ile mi wiadomo, ta usługa sieciowa jest fikcyjna.
87469504f326f0d7c1fcda56ef61bd79
8
9.2. Wprowadzenie do literałów tekstowych z interpolacją 297
lepiej sprzedające się książki do dnia 20 marca 2017 r., powinieneś podać adres URL
https://manning.com/webservices/bestsellers?date=2017-03-20. Aby utworzyć ten adres
URL w aplikacji, która pozwala użytkownikowi wybrać określoną datę, możesz zastoso-
wać następujący kod:
string url = string.Format(
CultureInfo.InvariantCulture,
"{0}?date={1:yyyy-MM-dd}",
webServiceBaseUrl,
searchDate);
87469504f326f0d7c1fcda56ef61bd79
8
298 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
Ta zmiana, podobnie jak składowe z ciałem w postaci wyrażenia, nie wygląda na istotne
usprawnienie. Gdy używany jest jeden element formatujący, w pierwotnym kodzie
trudno o pomyłkę. Przy kilku pierwszych zetknięciach z literałami tekstowymi z inter-
polacją może się nawet okazać, że odczytanie ich zajmie Ci więcej czasu niż w przy-
padku wywołań formatujących łańcuch znaków. Byłem sceptycznie nastawiony do
tego, czy kiedykolwiek polubię takie literały. Jednak obecnie często prawie automatycz-
nie przekształcam starszy kod tak, aby zastosować literały. Uważam, że nieraz pozwalają
one znacznie poprawić czytelność kodu.
Po zapoznaniu się z najprostszym przykładem pora przejść do bardziej złożonego
kodu. Omawiane będą tu te same zagadnienia co wcześniej; najpierw dokładnie przyj-
rzymy się sterowaniu formatowaniem wartości, a następnie zajmiemy się lokalizacją.
87469504f326f0d7c1fcda56ef61bd79
8
9.2. Wprowadzenie do literałów tekstowych z interpolacją 299
87469504f326f0d7c1fcda56ef61bd79
8
300 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
Także dla dosłownych literałów tekstowych można stosować interpolację. Należy wtedy
umieścić znak $ przed @, tak jak przy interpolacji zwykłych literałów tekstowych. Wcze-
śniejsze wielowierszowe dane wyjściowe można uzyskać z użyciem jednego dosłownego
łańcucha literału tekstowego z interpolacją. Ilustruje to listing 9.4.
Ja zapewne nie napisałbym takiego kodu. Nie jest on równie przejrzysty jak trzy odrębne
instrukcje. Ten kod pokazuję tylko jako prosty przykład ilustrujący, co jest możliwe. Tę
technikę możesz stosować w miejscach, w których już sensownie stosujesz dosłowne
literały tekstowe.
Opisana technika jest bardzo wygodna, jednak przedstawiłem tylko niewielką część
tego, co się dzieje. Zakładam, że kupiłeś tę książkę dlatego, iż chciałeś szczegółowo
poznać dostępne mechanizmy.
Ten kod jest traktowany przez kompilator tak, jakbyś napisał następujące instrukcje:
int x = 10;
int y = 20;
string text = string.Format("x={0}, y={1}", x, y);
Console.WriteLine(text);
87469504f326f0d7c1fcda56ef61bd79
8
9.3. Lokalizacja z użyciem typu FormattableString 301
Przekształcenie jest aż tak proste. Jeśli chcesz przyjrzeć się temu zagadnieniu dokład-
nie i samemu sprawdzić, co się dzieje w programie, możesz użyć narzędzia takiego
jak ildasm, aby zbadać kod pośredni wygenerowany przez kompilator.
Jednym z efektów ubocznych tego przekształcenia jest to, że literały tekstowe
z interpolacją (w odróżnieniu od zwykłych i dosłownych literałów tekstowych) nie są
traktowane jak stałe wyrażenia. Choć w niektórych sytuacjach kompilator mógłby
uznać je za stałe (jeśli literał nie zawiera żadnych elementów formatujących lub gdy
wszystkie elementy formatujące to stałe tekstowe bez wyrównywania ani łańcuchów
znaków formatowania), byłby to przypadek brzegowy komplikujący język, a dający tylko
niewielkie korzyści.
Do tej pory wszystkie łańcuchy znaków z interpolacją wymagały wywołania metody
string.Format. Jednak nie zawsze tak jest — i to z uzasadnionych przyczyn, o czym
przekonasz się podczas czytania następnego podrozdziału.
Jak uzyskać podobny wynik z użyciem literałów tekstowych z interpolacją? Takie lite-
rały obejmują dwie pierwsze porcje informacji (złożony łańcuch znaków formatowania
i formatowane wartości), ale nie ma gdzie umieścić w nich ustawień regionalnych.
Byłoby to akceptowalne, gdybyś mógł później dotrzeć do konkretnych porcji informacji,
ale we wszystkich pokazanych do tego miejsca zastosowaniach literałów tekstowych
z interpolacją przeprowadzane było formatowanie łańcucha znaków. Dlatego jako wynik
otrzymywany był jeden łańcuch znaków.
87469504f326f0d7c1fcda56ef61bd79
8
302 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
W tym miejscu pojawia się typ FormattableString. Jest to klasa z przestrzeni nazw
System dodana w platformie .NET 4.6 (i w specyfikacji .NET Standard 1.3 w świecie .NET
Core). Przechowuje ona złożony łańcuch znaków formatowania i wartości, co pozwala
sformatować je później z użyciem dowolnych ustawień regionalnych. Kompilator obsłu-
guje typ FormattableString i w razie potrzeby potrafi przekształcić literał tekstowy
z interpolacją na ten typ zamiast na zwykły łańcuch znaków. Dzięki temu można zmo-
dyfikować prosty przykład z wyświetlaniem daty urodzenia:
var dateOfBirth = new DateTime(1976, 6, 19); Zapisywanie złożonego łańcucha znaków
FormattableString formattableString = formatowania i wartości w obiekcie typu
$"Jon urodził się {dateofBirth:d}"; FormattableString.
var culture = CultureInfo.GetCultureInfo("en-US");
var result = formattableString.ToString(culture); Formatowanie z użyciem określonych
ustawień regionalnych.
Gdy już znasz główny powód istnienia typu FormattableString, możesz przyjrzeć się
temu, jak kompilator używa tego typu, a następnie dokładniej przeanalizować lokalizację.
Choć lokalizacja jest głównym celem stosowania typu FormattableString, można go stoso-
wać także w innych scenariuszach, opisanych w punkcie 9.3.3. Na końcu tego podroz-
działu opisane jest, co zrobić, jeśli kod ma działać w starszych wersjach platformy .NET.
87469504f326f0d7c1fcda56ef61bd79
8
9.3. Lokalizacja z użyciem typu FormattableString 303
int x = 10;
int y = 20;
FormattableString formattable = $"x={x}, y={y}";
Kompilator traktuje ten kod tak, jakbyś zastosował następujący zapis (oczywiście
z użyciem odpowiednich przestrzeni nazw):
int x = 10;
int y = 20;
FormattableString formattable = FormattableStringFactory.Create(
"x={0}, y={1}", x, y);
Wiesz już, kiedy i jak tworzone są instancje typu FormattableString. Teraz zobacz, co
można z nimi robić.
87469504f326f0d7c1fcda56ef61bd79
8
304 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
87469504f326f0d7c1fcda56ef61bd79
8
9.3. Lokalizacja z użyciem typu FormattableString 305
Czasem możesz chcieć przekazać obiekt typu FormattableString do innego kodu w celu
wykonania ostatniego kroku w procesie formatowania. Wtedy warto pamiętać, że typ
FormattableString implementuje interfejs IFormattable, dlatego każda metoda przyjmu-
jąca parametr typu IFormattable przyjmuje też parametr typu FormattableString.
W implementacji metody IFormattable.ToString(string, IFormatProvider) z typu Format
tableString parametr string jest ignorowany, ponieważ dostępne są już wszystkie
potrzebne elementy. Parametr typu IFormatProvider jest używany do wywołania metody
ToString(IFormatProvider).
Gdy wiesz już, jak używać ustawień regionalnych z literałami tekstowymi z inter-
polacją, możesz się zastanawiać, do czego służą inne składowe typu FormattableString.
W następnym punkcie przyjrzysz się przykładowi, który tego dotyczy.
87469504f326f0d7c1fcda56ef61bd79
8
306 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
string sql =
$@"SELECT Description FROM Entries Dynamiczne generowanie instrukcji w SQL-u
WHERE Tag='{tag}' AND UserId={userId}"; z wykorzystaniem danych od użytkownika.
using (var command = new SqlCommand(sql, conn))
{
using (var reader = command.ExecuteReader()) Wykonywanie niezaufanego kodu w SQL-u.
{
... Używanie wyników.
}
}
}
Większość tego listingu jest identyczna z listingiem 9.7. Jedyna różnica polega na
tworzeniu polecenia typu SqlCommand. Zamiast używać literału tekstowego z interpola-
cją do formatowania wartości w instrukcji w SQL-u i przekazywania łańcucha znaków
do konstruktora typu SqlCommand, tu stosowana jest nowa metoda, NewSqlCommand. Jest to
metoda rozszerzająca, którą wkrótce napiszesz. Łatwo się domyślić, że drugi para-
metr tej metody nie jest typu string, tylko typu FormattableString. Literał tekstowy
87469504f326f0d7c1fcda56ef61bd79
8
9.3. Lokalizacja z użyciem typu FormattableString 307
z interpolacją nie zawiera już apostrofów wokół członu {tag}, a używane w bazie typy
parametrów są podane jako łańcuchy znaków formatowania. Jest to niestandardowe
rozwiązanie. Jak działa ten kod?
Najpierw zastanów się nad tym, co kompilator robi na rzecz programisty. Kompi-
lator dzieli literał tekstowy z interpolacją na dwie części — złożony łańcuch znaków
formatowania i argumenty elementów formatujących. Złożony łańcuch znaków forma-
towania generowany przez kompilator wygląda tak:
SELECT Description FROM Entries
WHERE Tag={0:NVarChar} AND UserId={1:Int}
Łatwo można osiągnąć ten efekt. Wystarczy sformatować złożony łańcuch znaków for-
matowania, przekazując argumenty, które po przetworzeniu dają "@p0" i "@p1". Jeśli
w typie tych argumentów zaimplementowany jest interfejs IFormattable, wywołanie
string.Format spowoduje przekazanie łańcuchów znaków formatowania NVarChar i Int.
Dzięki temu można odpowiednio ustawić typy obiektów SqlParameter. Możesz auto-
matycznie wygenerować nazwy, a wartości będą pobierane bezpośrednio z obiektu typu
FormattableString.
Metoda IFormattable.ToString rzadko jest implementowana w taki sposób, aby
powodowała efekty uboczne. Jednak tu ten typ zapisujący formatowanie jest używany
tylko w jednym wywołaniu i możesz go bezpiecznie ukryć przed resztą kodu. Na
listingu 9.9 pokazana jest kompletna implementacja omawianego rozwiązania.
87469504f326f0d7c1fcda56ef61bd79
8
308 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
87469504f326f0d7c1fcda56ef61bd79
8
9.3. Lokalizacja z użyciem typu FormattableString 309
using System.Globalization;
namespace System.Runtime.CompilerServices
{
internal static class FormattableStringFactory
{
internal static FormattableString Create(
string format, params object[] arguments) =>
new FormattableString(format, arguments);
}
}
namespace System
{
internal class FormattableString : IFormattable
{
public string Format { get; }
private readonly object[] arguments;
87469504f326f0d7c1fcda56ef61bd79
8
310 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
this.arguments = arguments;
}
Nie będę szczegółowo objaśniał tego kodu, ponieważ każda składowa jest prosta. Jedyny
element, który może wymagać opisu, to wywołanie formattable?.ToString(CultureInfo.
InvariantCulture) w metodzie Invariant. Użyty tu operator ?. (ang. null conditional
operator) jest opisany szczegółowo w podrozdziale 10.3. Teraz wiesz już wszystko na
temat działania literałów tekstowych z interpolacją. Jak jednak należy je stosować?
87469504f326f0d7c1fcda56ef61bd79
8
9.4. Zastosowania, wskazówki i ograniczenia 311
Przyjrzyjmy się po kolei wszystkim tym kategoriom, aby ustalić, czy literały tekstowe
z interpolacją są w tych obszarach przydatne.
ŁAŃCUCHY ZNAKÓW CZYTELNE DLA MASZYN
Duża ilość kodu odczytuje różne łańcuchy znaków. Istnieją np. formaty dzienników
czytelne dla maszyn, parametry zapytań w adresach URL i tekstowe formaty danych,
takie jak XML, JSON i YAML. We wszystkich tych przypadkach używany jest określony
format, a wszystkie wartości powinny być formatowane według niezmiennych ustawień
regionalnych. Jeśli musisz samodzielnie formatować tekst, typ FormattableString
świetnie się tu sprawdzi, o czym się już przekonałeś. Warto przypomnieć, że do for-
matowania łańcuchów znaków przeznaczonych dla maszyn zwykle i tak należy korzy-
stać z odpowiedniego interfejsu API.
Pamiętaj, że każdy łańcuch znaków dla maszyn może obejmować zagnieżdżony
tekst przeznaczony dla ludzi. Każdy wiersz pliku dziennika może być sformatowany
w specjalny sposób, aby móc łatwo traktować go jak jeden rekord. Jednak komunikat
z takiego rekordu może być przeznaczony dla programistów. Należy uwzględniać to, na
jakim poziomie zagnieżdżenia pracuje każdy fragment kodu.
KOMUNIKATY DLA INNYCH PROGRAMISTÓW
Jeśli pracujesz nad rozbudowanym kodem bazowym, prawdopodobnie natrafisz na
wiele literałów tekstowych przeznaczonych dla innych programistów — czy to współ-
pracowników z tej samej firmy, czy to programistów używających interfejsu API, który
udostępniasz. Takie literały to przede wszystkim:
narzędziowe łańcuchy znaków, np. komunikaty systemu pomocy wyświetlane
w aplikacjach konsolowych,
komunikaty diagnostyczne i o postępach operacji zapisywane w dziennikach lub
w konsoli,
komunikaty z wyjątków.
Według mojego doświadczenia taki tekst zwykle jest wyświetlany w języku angielskim.
Choć niektóre firmy (w tym Microsoft) zadają sobie trud i lokalizują komunikaty o błę-
dach, większość tego nie robi. Lokalizacja wymaga poniesienia znacznych kosztów
związanych zarówno z tłumaczeniem danych, jak i opracowaniem kodu właściwie
używającego tych tłumaczeń. Jeśli wiesz, że użytkownicy poradzą sobie z czytaniem
po angielsku, to — zwłaszcza jeśli mogą chcieć podawać komunikaty w anglojęzycznych
serwisach takich jak Stack Overflow — lokalizowanie łańcuchów znaków z komunika-
tami o błędach zwykle nie jest warte zachodu.
Inną kwestią jest to, czy będziesz się upewniać, że wszystkie wartości w tekście są
sformatowane z użyciem określonych ustawień regionalnych. Zdecydowanie może to
pomóc w zwiększeniu spójności, podejrzewam jednak, że nie jestem jedynym progra-
mistą, który nie poświęca tej kwestii należytej uwagi. Zachęcam jednak do stosowania
jednoznacznego formatowania dat. Format ISO rrrr-MM-dd jest łatwy do zrozumienia
i nie powoduje problemu związanego z tym, czy jako pierwszy podawany jest miesiąc,
czy dzień (taka sytuacja jest typowa dla formatów dd/MM/rrrr i MM/dd/rrrr). Wcześniej
87469504f326f0d7c1fcda56ef61bd79
8
312 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
Jeśli wiesz, że wszyscy programiści odczytujący łańcuchy znaków będą używać tych
samych nieangielskich ustawień regionalnych, sensowne jest zastosowanie do komuni-
katów właśnie tych ustawień.
KOMUNIKATY DLA UŻYTKOWNIKÓW KOŃCOWYCH
W prawie wszystkich aplikacjach przynajmniej część tekstu jest wyświetlana użyt-
kownikom końcowym. Podobnie jak w przypadku programistów trzeba uwzględnić
oczekiwania wszystkich użytkowników, aby móc podjąć właściwe decyzje związane
z wyświetlaniem tekstu. W niektórych sytuacjach możesz mieć pewność, że wszyst-
kim użytkownikom będą odpowiadać te same ustawienia regionalne. Zwykle jest tak,
gdy piszesz aplikację do użytku wewnętrznego w firmie lub innej organizacji działa-
jącej w jednym miejscu. Wtedy dużo bardziej prawdopodobne jest użycie lokalnych
ustawień regionalnych niż języka angielskiego, ponieważ nie musisz przejmować się
tym, że dwóch użytkowników będzie oczekiwać prezentacji tych samych danych na różne
sposoby.
Do tej pory wszystkich opisane scenariusze są zgodne z literałami tekstowymi
z interpolacją. Lubię stosować te literały zwłaszcza do komunikatów w wyjątkach,
ponieważ mogę pisać wtedy zwięzły kod, który nadal zapewnia przydatny kontekst dla
nieszczęsnego programisty analizującego dzienniki i próbującego ustalić, co poszło nie
tak tym razem.
Jednak literały tekstowe z interpolacją rzadko są pomocne, gdy użytkownicy koń-
cowi korzystają z różnych ustawień regionalnych. Mogą też zaszkodzić produktowi,
jeśli nie stosujesz lokalizacji. Wtedy łańcuchy znaków formatowania częściej znajdują
się w plikach zasobów, a nie w kodzie, dlatego zapewne nawet nie będziesz widział
możliwości użycia literałów tekstowych z interpolacją. Zdarzają się jednak wyjątki, np.
wtedy, gdy formatujesz jedną porcję informacji wyświetlanych w określonym znaczniku
HTML lub podobnym miejscu. W takich scenariuszach literały tekstowe z interpolacją
mogą być akceptowalne, jednak nie spodziewaj się, że będziesz z nich często korzystać.
Wiesz już, że nie można używać literałów tekstowych z interpolacją razem z plikami
zasobów. Dalej opisane są inne sytuacje, w których ten mechanizm nie jest pomocny.
87469504f326f0d7c1fcda56ef61bd79
8
9.4. Zastosowania, wskazówki i ograniczenia 313
value = "Po";
Console.WriteLine(formattable); Także wyświetla tekst "Aktualna wartość: Przed".
87469504f326f0d7c1fcda56ef61bd79
8
314 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
PROBLEM Z DWUKROPKAMI
Choć w literałach tekstowych z interpolacją możesz używać niemal dowolnego wyraże-
nia obliczającego wartość, występuje problem z operatorem ?: — jest on mylący w tym
kontekście dla kompilatora i dla składni języka C#. Jeśli nie zachowasz ostrożności,
dwukropek zostanie potraktowany jak separator rozdzielający wyrażenie od łańcucha
znaków formatowania, co spowoduje błąd kompilacji. Na przykład poniższy kod jest
nieprawidłowy:
Console.WriteLine($"Dorosły? {age >= 18 ? "Tak" : "Nie"}");
Dla mnie rzadko jest to problemem, po części dlatego, że zwykle staram się tworzyć
krótsze wyrażenia niż to. Zapewne najpierw przeniósłbym wartość tak/nie do odrębnej
zmiennej typu string. To prowadzi do sytuacji, w których decyzja o zastosowaniu lite-
rału tekstowego z interpelacją jest kwestią preferencji.
87469504f326f0d7c1fcda56ef61bd79
8
9.4. Zastosowania, wskazówki i ograniczenia 315
Jest to jednak zły pomysł. Konieczne jest wtedy formatowanie łańcucha znaków nawet
w sytuacji, gdy zostanie on potem usunięty. Jest tak, ponieważ formatowanie jest wyko-
nywane bezwarunkowo przed wywołaniem metody, a nie w metodzie i tylko w sytuacji,
gdy będzie potrzebne. Choć formatowanie łańcuchów znaków nie jest kosztowną ope-
racją, nie warto niepotrzebnie jej wykonywać.
Możliwe, że zastanawiasz się, czy pomocny byłby tu typ FormattableString. Jeśli
biblioteka do sprawdzania poprawności lub zapisu danych w dzienniku przyjmuje
obiekt tego typu jako parametr wejściowy, można odroczyć formatowanie, a także
kontrolować w jednym miejscu ustawienia regionalne używane do formatowania. Choć
jest to prawdą, i tak za każdym razem trzeba wtedy utworzyć obiekt, co też jest zbęd-
nym kosztem.
FORMATOWANIE NA POTRZEBY CZYTELNOŚCI
Drugim powodem, dla którego czasem nie warto stosować literałów tekstowych z inter-
polacją, jest możliwy spadek czytelności. Krótkie wyrażenia są w pełni akceptowalne
i poprawiają czytelność. Jednak gdy wyrażenie staje się dłuższe, ustalanie, które
fragmenty literału są kodem, a które tekstem, zaczyna zajmować więcej czasu. Moim
zdaniem najgorsze są nawiasy. Jeśli wyrażenie zawiera więcej niż kilka wywołań
metod lub konstruktorów, może stać się trudne do zrozumienia. Problem staje się jeszcze
trudniejszy, gdy tekst dodatkowo zawiera nawiasy.
Oto prawdziwy przykład z biblioteki Noda Time. Ten fragment pochodzi z testów,
a nie z kodu produkcyjnego, chcę jednak, aby także testy były czytelne:
private static string FormatMemberDebugName(MemberInfo m) =>
string.Format("{0}.{1}({2})",
m.DeclaringType.Name,
m.Name,
string.Join(", ", GetParameters(m).Select(p => p.ParameterType)));
Ten kod nie jest zły, wyobraź sobie jednak, że w łańcuchu znaków znajdują się trzy
argumenty. Napisałem taki literał i kod nie był zbyt atrakcyjny. Uzyskałem literał zawie-
rający ponad 100 znaków. Nie dało się go podzielić za pomocą formatowania pionowego,
tak aby każdy argument znajdował się osobno (tak jak w poprzednim przykładzie), dla-
tego czytelność spadła.
W ramach ostatniego zabawnego przykładu pokazującego, jak kiepski może okazać
się omawiany pomysł, przypomnij sobie kod z początku rozdziału:
Console.Write("Jak masz na imię? ");
string name = Console.ReadLine();
Console.WriteLine("Witaj, {0}!", name);
Możesz umieścić cały ten kod w jednej instrukcji, używając literału tekstowego z inter-
polacją. Możliwe, że sceptycznie podchodzisz do tego pomysłu. W końcu ten kod składa
się z trzech odrębnych instrukcji, a literał tekstowy z interpolacją może obejmować
tylko wyrażenia. To prawda, jednak wyrażenia lambda z ciałem w postaci wyrażenia
wciąż są wyrażeniami. Musisz wprawdzie zrzutować wyrażenie lambda na określony
typ delegata, a następnie wywołać go, aby uzyskać wynik, jest to jednak wykonalne.
87469504f326f0d7c1fcda56ef61bd79
8
316 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
Nie jest jednak atrakcyjne. Oto jedno z rozwiązań, w którym przynajmniej każdą instruk-
cję zapisano w odrębnym wierszu dzięki zastosowaniu dosłownych literałów tekstowych
z interpolacją. Trudno jednak napisać o tym kodzie cokolwiek więcej dobrego:
Console.WriteLine($@"Witaj {((Func<string>)(() =>
{
Console.Write("Jak masz na imię? ");
return Console.ReadLine();
}))()}!");
using System;
class SimpleNameof
{
private string field;
static void Main(string[] args)
{
Console.WriteLine(nameof(SimpleNameof));
Console.WriteLine(nameof(Main));
Console.WriteLine(nameof(args));
Console.WriteLine(nameof(field));
}
}
87469504f326f0d7c1fcda56ef61bd79
8
9.5. Dostęp do identyfikatorów za pomocą operatora nameof 317
Do tej pory wszystko jest zrozumiałe. Jednak, co oczywiste, ten sam efekt można
uzyskać za pomocą literałów tekstowych. Kod byłby wtedy krótszy. Dlaczego więc lepiej
jest używać operatora nameof? Krótko mówiąc, chodzi o odporność na błędy. Jeśli zro-
bisz literówkę w literale tekstowym, nic Cię o tym nie poinformuje. Z kolei literówka
w operandzie operatora nameof skutkuje błędem kompilacji.
UWAGA. Kompilator nie wykryje problemu, jeśli podasz inną zmienną o podobnej nazwie.
Jeżeli używasz dwóch zmiennych różniących się jedynie wielkością liter (np. filename i file
Name), możesz łatwo użyć niewłaściwej zmiennej, a kompilator tego nie wykryje. Jest to dobry
powód do tego, by unikać używania podobnych nazw. Jednak stosowanie tak zbliżonych nazw
od zawsze było kiepskim pomysłem. Nawet jeśli nie zmylisz kompilatora, możesz łatwo
zaszkodzić ludzkim czytelnikom.
Kompilator nie tylko poinformuje o popełnionej pomyłce, ale też będzie wiedział, że
operator nameof jest powiązany ze składową lub zmienną, której nazwę podałeś. Jeśli
zmienisz tę nazwę w sposób wykrywany przez mechanizmy refaktoryzacji, zmodyfi-
kowany zostanie także operand operatora nameof.
Przyjrzyj się na przykład listingowi 9.13. Przeznaczenie tego kodu jest nieistotne,
zwróć jednak uwagę na to, że nazwa oldName występuje tu trzykrotnie: w deklaracji
parametru, przy pobieraniu tej nazwy za pomocą operatora nameof i przy pobieraniu
wartości w prostym wyrażeniu.
87469504f326f0d7c1fcda56ef61bd79
8
318 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
To samo podejście działa też dla innych nazw (metod, typów itd.). Operator nameof
ułatwia refaktoryzację w sposób, którego zapisane na stałe literały tekstowe nie umoż-
liwiają. Kiedy jednak należy stosować ten operator?
87469504f326f0d7c1fcda56ef61bd79
8
9.5. Dostęp do identyfikatorów za pomocą operatora nameof 319
public double Height { ... } Implementacja taka jak dla właściwości Width.
[Test]
[TestCaseSource(nameof(AllZones))] Podawanie pola z użyciem operatora nameof.
87469504f326f0d7c1fcda56ef61bd79
8
320 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
Przydatność operatora nameof nie ogranicza się do testów. Można go używać wszędzie
tam, gdzie atrybuty dotyczą relacji. Wyobraź sobie bardziej złożoną metodę Raise
PropertyChanged, gdzie relacje między właściwościami są określone za pomocą atry-
butów, a nie w kodzie:
[DerivedProperty(nameof(Area))
public double Width { ... }
Bez wątpienia istnieje też wiele innych atrybutów, dla których można zastosować to
podejście. Teraz już o tym wiesz, możesz więc znaleźć w istniejącym kodzie bazowym
miejsca, w których operator nameof będzie przydatny. Przed wszystkim powinieneś zwró-
cić uwagę na kod, w którym mechanizm refleksji jest używany do nazw, które znasz
w czasie kompilacji, ale których nie mogłeś wcześniej podać w przejrzysty sposób.
Jednak aby opis był kompletny, trzeba jeszcze omówić kilka subtelnych zagadnień.
87469504f326f0d7c1fcda56ef61bd79
8
9.5. Dostęp do identyfikatorów za pomocą operatora nameof 321
[TestCaseSource(typeof(Cultures), "AllCultures")]
Możesz też użyć zmiennej odpowiedniego typu do uzyskania dostępu do nazwy skła-
dowej, choć dotyczy to tylko składowych instancji. Z kolei nazwę typu można podawać
zarówno dla składowych statycznych, jak i dla składowych instancji. Na listingu 9.16
pokazano wszystkie poprawne kombinacje.
class OtherClass
{
public static int StaticMember => 3;
public int InstanceMember => 3;
}
class QualifiedNameof
{
static void Main()
{
OtherClass instance = null;
Console.WriteLine(nameof(instance.InstanceMember));
Console.WriteLine(nameof(OtherClass.StaticMember));
Console.WriteLine(nameof(OtherClass.InstanceMember));
}
}
Preferuję używanie nazwy typu wszędzie tam, gdzie jest to możliwe. Jeśli używasz
zmiennej, wygląda to tak, jakby wartość tej zmiennej mogła mieć znaczenie. Zmienna
jest jednak uwzględniana w tym kontekście wyłącznie do ustalenia typu w czasie kom-
pilacji. Jeśli używasz typu anonimowego, nie istnieje nazwa typu, którą mógłbyś zasto-
sować, dlatego musisz posłużyć się zmienną.
Składowa musi być dostępna, aby można było ją podać w operatorze nameof. Gdyby
składowa StaticMember lub InstanceMember z listingu 9.16 była prywatna, kod próbujący
uzyskać dostęp do tych nazw nie skompilowałby się.
TYPY GENERYCZNE
Możliwe, że zastanawiasz się, co się stanie, gdy spróbujesz pobrać nazwę typu gene-
rycznego lub metody generycznej, a także w jaki sposób należy je podawać. W operato-
rze typeof można podawać nazwy typów z określonym i nieokreślonym parametrem
określającym typ. Wywołania typeof(List<string>) i typeof(List<>) są poprawne oraz
zwracają inne wyniki.
Operator nameof wymaga podania argumentu określającego typ, ale nie uwzględ-
nia go w wyniku. Ponadto wynik nie obejmuje liczby parametrów określających typ.
Wywołania nameof(Action<string>) i nameof(Action<string, string>) zwracają tylko
"Action". Może to być irytujące, jednak nie wymaga zastanawiania się nad tym, w jaki
sposób wynikowe nazwy powinny reprezentować tablice, typy anonimowe, inne typy
generyczne itd.
Podejrzewam, że wymóg podawania argumentów określających typ może w przy-
szłości zostać wyeliminowany. Pozwoli to zarówno uzyskać spójność z operatorem
87469504f326f0d7c1fcda56ef61bd79
8
322 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
typeof, jak i zrezygnować z podawania typu, który w żaden sposób nie wpływa na wynik.
Jednak uwzględnienie w wynikach liczby argumentów określających typ i samych argu-
mentów tego rodzaju byłoby zmianą naruszającą zgodność, dlatego nie spodziewam
się jej wprowadzenia. W większości sytuacji, gdy takie argumenty są istotne, i tak lepiej
jest zastosować do pobrania typu operator typeof.
W operatorze nameof można podać parametr określający typ, jednak (inaczej niż
w wywołaniu typeof(T)) zawsze zwracana jest wtedy nazwa tego parametru, a nie nazwa
argumentu określającego typ użytego w czasie wykonywania programu. Oto prosty
przykład:
static string Method<T>() => nameof(T); Zawsze zwraca "T".
using System;
class Test
{
static void Main()
{
Console.WriteLine(nameof(GuidAlias));
}
}
Jest to nieco irytujące, ale zamiast predefiniowanych aliasów trzeba używać nazw typów
ze środowiska CLR, a dla typów bezpośrednich przyjmujących wartość null — składni
Nullable<T>:
nameof(Single)
nameof(Nullable<Guid>)
87469504f326f0d7c1fcda56ef61bd79
8
Podsumowanie 323
Podsumowanie
Literały tekstowe z interpolacją umożliwiają pisanie prostszego kodu do forma-
towania łańcuchów znaków.
W literałach tekstowych z interpolacją można stosować łańcuchy znaków forma-
towania, aby podać dodatkowe szczegóły na temat formatowania. Jednak łańcuch
znaków formatowania musi być wtedy znany w czasie kompilacji.
Dosłowne literały tekstowe z interpolacją łączą cechy literałów tekstowych
z interpolacją i dosłownych literałów tekstowych.
87469504f326f0d7c1fcda56ef61bd79
8
324 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków
87469504f326f0d7c1fcda56ef61bd79
8
Szwedzki stół z funkcjami
do pisania zwięzłego kodu
Zawartość rozdziału
Unikanie zaśmiecania kodu przy podawaniu
składowych statycznych
Bardziej selektywne importowanie metod
rozszerzających
Używanie metod rozszerzających w inicjalizatorach
kolekcji
Używanie indekserów w inicjalizatorach kolekcji
Ograniczenie liczby jawnie pisanych testów wartości
null
Przechwytywanie tylko tych wyjątków, które
rzeczywiście Cię interesują
Ten rozdział to worek z funkcjami. Nie występuje tu żaden wspólny motyw oprócz
bardziej zwięzłego zapisu przeznaczenia kodu. W tym rozdziale znalazły się funkcje,
które pozostały po zastosowaniu wszystkich oczywistych sposobów grupowania mecha-
nizmów. W żaden sposób nie zmniejsza to jednak ich przydatności.
87469504f326f0d7c1fcda56ef61bd79
8
326 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
Załóżmy, że istnieje już typ Point reprezentujący w prosty sposób współrzędne karte-
zjańskie. Przekształcenie jest oparte na dość prostych obliczeniach trygonometrycznych:
Kąt jest przekształcany ze stopni na radiany w wyniku pomnożenia go przez π/180.
Stała π jest dostępna jako Math.PI.
Za pomocą metod Math.Cos i Math.Sin określane są składowe x i y punktu odda-
lonego o 1 od środka układu, po czym współrzędne są odpowiednio mnożone.
Na listingu 10.1 pokazana jest cała metoda. Wszystkie przypadki użycia przestrzeni
nazw System.Math są wyróżnione pogrubieniem. Dla wygody pominąłem deklarację
klasy. Kod można umieścić np. w klasie CoordinateConverter lub w metodzie fabrycznej
w samym typie Point.
using System;
...
static Point PolarToCartesian(double degrees, double magnitude)
{
double radians = degrees * Math.PI / 180; Przekształcanie stopni na radiany.
return new Point(
Math.Cos(radians) * magnitude, Obliczenia trygonometryczne kończące konwersję.
Math.Sin(radians) * magnitude);
}
Choć ten fragment nie jest bardzo nieczytelny, można sobie wyobrazić, że gdybyś pisał
instrukcje z większą liczbą operacji matematycznych, powtórzenia członu Math. znacz-
nie zaśmiecałyby kod.
W C# 6 wprowadzono dyrektywę using static, aby uprościć kod tego rodzaju.
Listing 10.2 to odpowiednik listingu 10.1, przy czym importowane są tu wszystkie
składowe statyczne z przestrzeni nazw System.Math.
87469504f326f0d7c1fcda56ef61bd79
8
10.1. Dyrektywa using static 327
...
static Point PolarToCartesian(double degrees, double magnitude)
{
double radians = degrees * PI / 180; Przekształcanie stopni na radiany.
return new Point(
Cos(radians) * magnitude, Obliczenia trygonometryczne kończące konwersję.
Sin(radians) * magnitude);
}
Podobnie instrukcję switch reagującą na kody stanu żądań HTTP można uprościć,
unikając powtarzania nazwy typu wyliczeniowego w każdej etykiecie case:
87469504f326f0d7c1fcda56ef61bd79
8
328 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
Inner inner = 1;
}
Aby wskazać typ Inner w C# 5, trzeba użyć zapisu Outer.Types.Inner, co jest niewy-
godne. W C# 6 dwukrotne zagnieżdżenie stało się mniejszą niedogodnością, ponieważ
wystarczy zastosować jedną dyrektywę using static:
using static Outer.Types;
...
Outer outer = new Outer { Inner = new Inner { Text = "Tu jakiś tekst" } };
87469504f326f0d7c1fcda56ef61bd79
8
10.1. Dyrektywa using static 329
Moim zdaniem ten kod nie jest równie użyteczny jak wcześniejsze przykłady, jednak tech-
nika ta jest dostępna, jeśli będziesz jej kiedyś potrzebować. Także typy zagnieżdżone są
wtedy dostępne za pomocą prostych nazw. Istnieje jednak jeden bardziej skomplikowany
wyjątek w zestawie składowych statycznych importowanych za pomocą dyrektywy using
static. Chodzi tu o metody rozszerzające.
87469504f326f0d7c1fcda56ef61bd79
8
330 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
using System.Collections.Generic;
using static System.Linq.Enumerable;
...
IEnumerable<string> strings = new[] { "a", "b", "c" };
Obecnie język — inaczej niż wcześniej — skłania do tego, by traktować metody roz-
szerzające jako odmienne od metod statycznych. Wpływa to na programistów bibliotek.
Przekształcenie metody, która już istniała w klasie statycznej, w metodę rozszerzającą
87469504f326f0d7c1fcda56ef61bd79
8
10.2. Usprawnienia inicjalizatorów obiektów i kolekcji 331
87469504f326f0d7c1fcda56ef61bd79
8
332 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
W konsoli wynikowy tekst to "Ten tekst…". W wersjach starszych niż C# 6 nie można
było zmodyfikować ostatniego znaku w inicjalizatorze, dlatego potrzebny był następu-
jący kod:
string text = "Ten tekst wymaga przycięcia";
StringBuilder builder = new StringBuilder(text)
{ Ustawianie właściwości Length,
Length = 10 aby przyciąć obiekt typu StringBuilder.
};
builder[9] = '\u2026'; Przekształcanie ostatniego znaku w "…"
Console.OutputEncoding = Encoding.UTF8; Upewnianie się, że konsola
Console.WriteLine(builder); Wyświetlanie zawartości obsługuje format Unicode.
obiektu typu StringBuilder.
Jeśli wziąć pod uwagę, jak mało inicjalizator oferuje w tej sytuacji (jedną właściwość),
rozważyłbym przynajmniej ustawianie długości w odrębnej instrukcji. W C# 6 moż-
na przeprowadzić całą inicjalizację w jednym wyrażeniu, ponieważ dozwolone jest
używanie indeksera w inicjalizatorze obiektu. Na listingu 10.5 jest to pokazane w nieco
naciąganym przykładzie.
Użyłem tu typu StringBuilder nie dlatego, że jest to najbardziej oczywisty typ zawie-
rający indekser, ale aby jednoznacznie pokazać, że używany jest inicjalizator obiektów,
a nie kolekcji.
Można było oczekiwać, że zamiast używać tego typu, posłużę się jakiegoś rodzaju
typem Dictionary<,>, jednak związane jest z tym ukryte niebezpieczeństwo. Jeśli kod
jest poprawny, będzie działał zgodnie z oczekiwaniami. Zachęcam jednak do tego, by
w większości sytuacji używać inicjalizatorów kolekcji. Aby zrozumieć dlaczego, warto
przyjrzeć się przykładowi inicjalizowania dwóch słowników (zobacz listing 10.6).
W jednym używane są indeksery w inicjalizatorze obiektów, a w drugim — inicjalizator
kolekcji.
87469504f326f0d7c1fcda56ef61bd79
8
10.2. Usprawnienia inicjalizatorów obiektów i kolekcji 333
Na pozór obie wersje wydają się swoimi odpowiednikami. Gdy nie występują powta-
rzające się klucze, te fragmenty działają tak samo, a jeśli chodzi o czytelność, preferuję
wersję z inicjalizatorem obiektu. Jednak setter indeksera słownika zastępuje istniejące
wpisy o tym samym kluczu, natomiast metoda Add zgłasza wyjątek, gdy dany klucz już
istnieje.
Na listingu 10.6 celowo dwukrotnie używany jest klucz "B". Łatwo o taką pomyłkę,
zwykle w wyniku kopiowania i wklejania wiersza, gdy programista zapomni zmodyfi-
kować klucz. Żadna z podanych wersji nie pozwala wykryć takiego błędu w czasie
kompilacji, jednak inicjalizator kolekcji przynajmniej nie wykonuje po cichu błędnych
operacji. Jeśli masz testy jednostkowe, które używają tego fragmentu kodu — nawet
jeżeli nie służą one bezpośrednio do sprawdzania zawartości słownika — zapewne szybko
wykryjesz błąd.
Roslyn na ratunek?
Możliwość wykrycia błędu w czasie kompilacji byłaby oczywiście lepsza. Możliwe powinno
być napisanie analizatora, który wykrywa opisany problem zarówno dla inicjalizatorów
kolekcji, jak i inicjalizatorów obiektów. Jeśli inicjalizator obiektów używa indeksera, trudno
wyobrazić sobie wiele scenariuszy, w których sensowne jest wielokrotne używanie tego
samego stałego klucza indeksera. Dlatego zrozumiałe byłoby wyświetlanie ostrzeżenia
w takich sytuacjach.
Nie znam takiego analizatora, jednak mam nadzieję, że w przyszłości powstanie. Po wyeli-
minowaniu opisanego zagrożenia będzie można bezpiecznie stosować indeksery do
słowników.
87469504f326f0d7c1fcda56ef61bd79
8
334 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
Mniej oczywiste sytuacje występują, gdy potrzebny jest kompromis między czytelnością
a możliwością popełnienia opisanego wcześniej błędu. Na listingu 10.7 pokazany jest
typ encji bez schematu. Typ ten obejmuje dwie zwykłe właściwości i umożliwia przy-
pisywanie dowolnych par klucz-wartość. Dalej pokazane są możliwości inicjalizowania
instancji tego typu.
Listing 10.7. Typ encji bez schematu, ale z właściwościami reprezentującymi klucz
Rozważmy teraz dwa sposoby inicjalizowania encji, w której chcesz podać klucz nad-
rzędny, klucz nowej encji i dwie właściwości (nazwisko i miejsce zamieszkania; są to
proste łańcuchy znaków). Można albo zastosować inicjalizator kolekcji i później usta-
wić pozostałe właściwości, albo wykonać całą pracę w inicjalizatorze obiektu i ryzyko-
wać popełnienie literówek w kluczu. Na listingu 10.8 pokazane są obie te możliwości.
87469504f326f0d7c1fcda56ef61bd79
8
10.2. Usprawnienia inicjalizatorów obiektów i kolekcji 335
child1.Key = "klucz-encji";
Odrębne podawanie właściwości dotyczących kluczy.
child1.ParentKey = parent.Key;
Która z tych technik jest lepsza? Drugi zapis jest według mnie dużo bardziej przej-
rzysty. Zwykle i tak zapisałbym klucze dotyczące nazwiska i miejsca zamieszkania
w stałych tekstowych, co pozwala zmniejszyć ryzyko przypadkowego użycia tych samych
kluczy.
Jeśli kontrolujesz tego rodzaju typ, możesz dodać nowe składowe, aby umożliwić
używanie inicjalizatora kolekcji. Można tu dodać właściwość Properties, która albo
bezpośrednio udostępnia słownik, albo udostępnia jego widok. Pozwala to zastosować
inicjalizator kolekcji do zainicjalizowania właściwości Properties w inicjalizatorze
obiektu, gdzie ustawiane są też właściwości Key i ParentKey. Inna możliwość to utwo-
rzenie konstruktora, który przyjmuje klucz i klucz nadrzędny. Można wtedy jawnie
wywoływać konstruktor z użyciem wartości tych kluczy, a następnie podawać właści-
wości dotyczące nazwiska i miejsca zamieszkania w inicjalizatorze kolekcji.
Może się wydawać, że to bardzo dużo szczegółów jak na wybór między używaniem
indekserów w inicjalizatorze obiektów a używaniem inicjalizatora kolekcji (jak w star-
szych wersjach C#). Sam musisz dokonać tego wyboru. Żadna książka nie zapewni Ci
prostych reguł, które pozwolą w każdej sytuacji znaleźć najlepsze rozwiązanie. Pamiętaj
o wadach i zaletach różnych technik oraz kieruj się własnym osądem.
Czasem te ograniczenia okazują się dość restrykcyjne. Zdarza się, że chciałbyś łatwo
tworzyć kolekcję w sposób, którego metody Add udostępniane przez dany typ nie umoż-
liwiają. Opisane warunki nadal obowiązują w C# 6, jednak definicja „odpowiednich
87469504f326f0d7c1fcda56ef61bd79
8
336 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
Stosowany jest tu standardowy proces wyboru wersji przeciążonej metody, aby ustalić,
co oznacza każde z tych wywołań. Jeśli ten proces zakończy się niepowodzeniem,
inicjalizator kolekcji się nie skompiluje. Gdy używany jest tylko zwykły typ List<T>,
pokazany kod się nie skompiluje. Wystarczy jednak dodać jedną metodę rozszerzającą,
aby kompilacja zakończyła się sukcesem:
public static class StringListExtensions
{
public static void Add(
this List<string> list, int value, int count = 1)
{
list.AddRange(Enumerable.Repeat(value.ToString(), count));
}
}
Teraz, kiedy już wiesz, co jest możliwe, pora bliżej przyjrzeć się temu, kiedy używanie
opisanego mechanizmu ma sens.
87469504f326f0d7c1fcda56ef61bd79
8
10.2. Usprawnienia inicjalizatorów obiektów i kolekcji 337
Możliwe, że będziesz musiał przyzwyczajać się przez pewien czas do tej techniki,
jest ona jednak niezwykle przydatna i z pewnością wygodniejsza niż osobne wywołania
jon.Contacts.AddRange(...). Co jednak zrobić, jeśli nie używasz technologii Protocol
Buffers i właściwość Contacts jest typu List<Person>? W C# 6 nie stanowi to problemu.
Możesz utworzyć metodę rozszerzającą typ List<T> i dodać wersję metody Add, która
przyjmuje obiekt typu IEnumerable<T>. Następnie możesz wywołać metodę AddRange
z użyciem takiego obiektu. Ilustruje to listing 10.9.
Gdy ta metoda jest dostępna, wcześniejszy kod działa poprawnie nawet dla typu List<T>.
Jeśli chcesz opracować jeszcze ogólniejsze rozwiązanie, możesz napisać metodę rozsze-
rzającą typ IList<T>, choć w takim podejściu trzeba użyć pętli w ciele metody, ponie-
waż typ IList<T> nie udostępnia metody AddRange.
TWORZENIE WYSPECJALIZOWANYCH WERSJI METODY ADD
Załóżmy, że istnieje klasa Person z właściwością Name, a gdzieś w kodzie wykonujesz
dużo operacji na obiektach typu Dictionary<string, Person>, zawsze używając dla
obiektów typu Person indeksów w postaci nazwisk. Dodawanie elementów do takiego
słownika z użyciem prostego wywołania dictionary.Add(person) może być wygodne,
jednak typ Dictionary<string, Person> nie potrafi stwierdzić, że w indeksach używane
są nazwiska. Jakie masz możliwości?
87469504f326f0d7c1fcda56ef61bd79
8
338 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
Jest to dobre rozwiązanie nawet w wersjach starszych niż C# 6. Obecnie jest ono
jeszcze lepsze dzięki połączeniu mechanizmu using static (ograniczającego zestaw
importowanych metod rozszerzających) z wykorzystaniem metod rozszerzających
w inicjalizatorach kolekcji. Teraz możesz inicjalizować słownik bez powtarzania nazw:
var dictionary = new Dictionary<string, Person>
{
{ new Person { Name = "Jon" } },
{ new Person { Name = "Holly" } }
};
Ważne jest tu utworzenie wyspecjalizowanej wersji interfejsu API dla jednej kon-
kretnej kombinacji argumentów określających typ w Dictionary<,>, ale bez koniecz-
ności zmiany typu tworzonych obiektów. Żaden inny kod nie musi znać tej wyspe-
cjalizowanej wersji, ponieważ jest ona tylko nakładką. Istnieje wyłącznie dla wygody
programisty i nie wpływa na podstawowe działanie obiektów.
UWAGA. To podejście ma też wady. Jedną z nich jest to, że nic nie zapobiega dodaniu
elementu z użyciem danych innych niż nazwisko danej osoby. Jak zawsze zachęcam do samo-
dzielnego przeanalizowania wad i zalet techniki. Nie ufaj ślepo wskazówkom prezentowanym
przeze mnie lub kogokolwiek innego.
87469504f326f0d7c1fcda56ef61bd79
8
10.2. Usprawnienia inicjalizatorów obiektów i kolekcji 339
Na pozór ten kod jest zupełnie bezcelowy. Jest to metoda rozszerzająca, która wywo-
łuje metodę o identycznej sygnaturze. Jednak pozwala to rozwiązać problem związany
z jawną implementacją interfejsu. Dzięki temu metoda Add jest dostępna zawsze, także
w inicjalizatorach kolekcji. Teraz możesz użyć inicjalizatora kolekcji dla typu Concurrent
Dictionary<,>:
var dictionary = new ConcurrentDictionary<string, int>
{
{ "x", 10 },
{ "y", 20 }
};
Z tej techniki należy oczywiście korzystać ostrożnie. Gdy metoda jest ukryta w jawnej
implementacji interfejsu, często ma to zniechęcać przed wywoływaniem jej bez nale-
żytego zastanowienia. W tym kontekście przydatne jest selektywne importowanie metod
rozszerzających za pomocą dyrektywy using static. Możesz utworzyć przestrzeń nazw
zawierającą klasy statyczne z metodami rozszerzającymi, które mają być używane
wyłącznie selektywnie, i importować w każdej sytuacji tylko potrzebną klasę. Niestety,
ta technika powoduje udostępnienie metody Add także w pozostałym kodzie tej samej
klasy. Jednak także tu trzeba ocenić, czy jest to gorsze niż inne rozwiązania.
Metoda rozszerzająca z listingu 10.11 jest ogólna i rozszerza wszystkie słowniki.
Mógłbyś zdecydować się uwzględnić tylko typ ConcurrentDictionary<,>, aby uniknąć
przypadkowego użycia jawnie zaimplementowanej metody Add ze słowników innego typu.
87469504f326f0d7c1fcda56ef61bd79
8
340 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
10.3. Operator ?.
Nie zamierzam opisywać ani uzasadniać konieczności obsługi wartości null. Jest to
coś, z czym musimy sobie radzić, podobnie jak ze złożonymi modelami obiektowymi
z kilkoma poziomami zagnieżdżonych właściwości. Zespół odpowiedzialny za język C#
od dawna zastanawia się nad uproszczeniem obsługi wartości null. Część tych prac
nadal oczekuje na ukończenie, jednak w C# 6 zrobiono duży krok naprzód. Zmiany
pozwalają pisać znacznie krótszy i prostszy kod dzięki określeniu sposobu obsługi war-
tości null bez konieczności wielokrotnego powielania wyrażeń.
87469504f326f0d7c1fcda56ef61bd79
8
10.3. Operator ?. 341
Ten kod zadziała poprawnie, jeśli wiadomo, że każdy klient ma profil, każdy profil
obejmuje domyślny adres wysyłki, a w każdym adresie podane jest miasto. Co się jed-
nak stanie, jeśli dowolny z tych elementów ma wartość null? Wystąpi wyjątek Null
ReferenceException, choć zapewne wolałbyś tylko pominąć danego klienta w wynikach.
Wcześniej trzeba było użyć do tego okropnego kodu, sprawdzającego każdą właściwość
jedna po drugiej pod kątem wartości null z wykorzystaniem operatora &&:
var readingCustomers = allCustomers
.Where(c => c.Profile != null &&
c.Profile.DefaultShippingAddress != null &&
c.Profile.DefaultShippingAddress.Town == "Reading");
Taaak. Mnóstwo powtórzeń. Sytuacja staje się jeszcze gorsza, jeśli trzeba wywołać
metodę zamiast operatora == (który poprawnie obsługuje wartość null — przynajmniej
dla referencji; w punkcie 10.3.3 opisane są możliwe niespodzianki). W jaki sposób
usprawniono sytuację w C# 6? Wprowadzono operator ?., który skraca przetwarzanie,
jeśli wyrażenie ma wartość null. Bezpieczna ze względu na wartość null wersja zapy-
tania wygląda tak:
var readingCustomers = allCustomers
.Where(c => c.Profile?.DefaultShippingAddress?.Town == "Reading");
Ten kod działa identycznie jak pierwsza wersja, jednak używane są tu dwa operatory ?..
Jeśli właściwość c.Profile lub c.Profile.DefaultShippingAddress ma wartość null, całe
wyrażenie po lewej stronie operatora == to null. Możesz się zastanawiać, dlaczego ope-
rator jest używany w tylko dwóch miejscach, skoro cztery elementy mogą być równe
null:
c,
c.Profile,
c.Profile.DefaultShippingAddress,
c.Profile.DefaultShippingAddress.Town.
87469504f326f0d7c1fcda56ef61bd79
8
342 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
87469504f326f0d7c1fcda56ef61bd79
8
10.3. Operator ?. 343
Niestety, ten kod się nie skompiluje. Dodałeś trzeci operator ?., aby nie wywoływać
metody Equals, jeśli w adresie właściwość Town ma wartość null. Jednak teraz wynik
całego wyrażenia to Nullable<bool> zamiast bool, co oznacza, że nasze wyrażenie lambda
na razie nie nadaje się do użytku w metodzie Where.
Jest to dość częste zjawisko związane z operatorem ?.. Za każdym razem, gdy uży-
wasz tego operatora w jakimś warunku, musisz uwzględnić trzy scenariusze:
wszystkie części wyrażenia są przetwarzane, a wynik to true,
wszystkie części wyrażenia są przetwarzane, a wynik to false,
przetwarzanie jest skracane z powodu wartości null, a wynik to null.
Aby uprościć przykład, załóżmy, że masz zmienną name zawierającą odpowiedni tekst.
Jednak zmienna ta może być równa null. Chcesz napisać instrukcję if i wykonać ciało
instrukcji, jeśli miasto w adresie to X, a do sprawdzania miast użyć metody Equals. Jest
to najprostszy sposób na zademonstrowanie użycia warunku. Jednak w rzeczywistości
mógłbyś np. warunkowo pobierać wartość logiczną. W tabeli 10.1 pokazane są dostępne
możliwości w zależności od tego, czy chcesz wykonywać ciało instrukcji, jeśli zmienna
name jest równa null.
Preferuję wersję z operatorem ??. Czytam taki kod w następujący sposób: „Spróbuj
przeprowadzić porównanie, ale domyślnie użyj wartości po operatorze ??, jeśli trzeba
wcześniej zakończyć przetwarzanie”. Jeśli zrozumiesz, że typ wyrażenia (tu jest to
wyrażenie name?.Equals("X")) to Nullable<bool>, nic nie będzie tu dla Ciebie nowinką.
Podobne sytuacje możesz napotkać teraz znacznie częściej niż przed wprowadzeniem
operatora ?..
87469504f326f0d7c1fcda56ef61bd79
8
344 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
Ten kod jest poprawny, ale bardzo długi. Ratunkiem jest tu operator ?.. Nie można go
używać w skróconych wywołaniach delegata w formie handler(...), ale może posłużyć
do warunkowego wywoływania metody Invoke i wystarcza do tego jeden wiersz:
Click?.Invoke(this, EventArgs.Empty);
87469504f326f0d7c1fcda56ef61bd79
8
10.3. Operator ?. 345
Jeśli jest to jedyny wiersz w metodzie (OnClick lub podobnej), dodatkowa zaleta jest taka,
że metoda ma teraz ciało w postaci jednego wyrażenia. Dlatego można ją zapisać jako
metodę z ciałem w postaci wyrażenia. Ta technika jest równie bezpieczna jak pokazany
wcześniej wzorzec, ale dużo bardziej zwięzła.
OPTYMALNE WYKORZYSTANIE INTERFEJSÓW API
ZWRACAJĄCYCH WARTOŚĆ NULL
W rozdziale 9. omawiałem zapisywanie danych w dzienniku i wyjaśniłem, że literały
tekstowe z interpolacją nie powodują wzrostu wydajności kodu. Można je jednak zgrab-
nie połączyć z operatorem ?., jeśli dostępny jest zaprojektowany z myślą o tym wzorcu
interfejs API do zapisu danych w dzienniku. Załóżmy, że dostępny jest tego rodzaju
interfejs API pokazany na listingu 10.12.
Jest to tylko zarys. Kompletny interfejs API do zapisu danych w dzienniku wymagałby
znacznie więcej kodu. Jednak dzięki oddzieleniu kroku pobierania aktywnego obiektu
zapisującego dane dla odpowiedniego poziomu dziennika od kroku zapisu danych
w dzienniku można pisać wydajne i bogate w informacje komunikaty:
logger.Debug?.Log($"Otrzymano żądanie adresu URL {request.Url}");
Jeśli zapis danych na poziomie Debug jest wyłączony, kod nie dojdzie do etapu forma-
towania literału tekstowego z interpolacją i nie trzeba tworzyć do tego żadnego obiektu.
Gdy zapis danych na tym poziomie jest włączony, literał tekstowy z interpolacją jest
przetwarzany i przekazywany do metody Log w standardowy sposób. Nie chcę się za
bardzo wzruszać, ale właśnie tego rodzaju rozwiązania sprawiają, że uwielbiam ewolucję
języka C#.
Oczywiście przede wszystkim interfejs API do zapisu danych w dzienniku musi
odpowiednio obsługiwać opisaną technikę. Jeśli interfejs API, z którego korzystasz,
nie udostępnia takich możliwości, pomocne mogą okazać się metody rozszerzające.
Wiele interfejsów API związanych z mechanizmem refleksji zwraca null w odpo-
wiednich miejscach, a metody FirstOrDefault (i podobne) z technologii LINQ dobrze
współdziałają z operatorem ?.. Także technologia LINQ to XML udostępnia wiele
metod, które zwracają null, jeśli nie potrafią znaleźć żądanych elementów. Załóżmy, że
87469504f326f0d7c1fcda56ef61bd79
8
346 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
W takich sytuacjach trzeba posłużyć się staromodną instrukcją if. Według mojego
doświadczenia to ograniczenie rzadko stanowi problem.
Operator ?. świetnie nadaje się do unikania wyjątków NullReferenceException, jed-
nak czasem wyjątki występują z uzasadnionych przyczyn i potrzebna jest możliwość
ich obsługi. Filtry wyjątków są pierwszą zmianą w strukturze bloku catch od czasu
utworzenia języka C#.
87469504f326f0d7c1fcda56ef61bd79
8
10.4. Filtry wyjątków 347
try
{
... Próba wykonania operacji w sieci.
}
catch (WebException e)
{
if (e.Status != WebExceptionStatus.ConnectFailure)
{ Ponowne zgłoszenie, jeśli błąd
throw; nie dotyczy braku połączenia.
}
... Obsługa braku połączenia.
}
Gdy używany jest filtr wyjątków, a nie chcesz obsługiwać wyjątków, nie są one prze-
chwytywane. Zostają od razu odfiltrowane na poziomie bloku catch:
try
{
... Próba wykonania operacji w sieci.
}
catch (WebException e)
when (e.Status == WebExceptionStatus.ConnectFailure) Przechwytywanie wyłącznie
{ braku połączenia.
... Obsługa braku połączenia.
}
Oprócz konkretnych przypadków takich jak ten filtry wyjątków są przydatne w dwóch
ogólnych scenariuszach: ponawianiu prób i zapisie danych w dzienniku. W pętli po-
nawiającej próby programiści zwykle chcą przechwytywać wyjątki tylko wtedy, gdy
zamierzają jeszcze raz spróbować wykonać operację (jeżeli spełnione są odpowiednie
warunki i nie wyczerpano limitu prób). Przy zapisie danych w dzienniku programista
może nie chcieć przechwytywać wyjątków, ale rejestrować je w procesie ich przekazy-
wania. Zanim przejdziemy do szczegółów dotyczących konkretnych zastosowań, warto
pokazać, jak omawiany mechanizm wygląda w kodzie i jak działa.
string[] messages =
{
"Ten można przechwycić",
"Ten też można przechwycić",
"Ten nie zostanie przechwycony"
};
foreach (string message in messages) Pętla obejmująca blok try/catch wykonuje
{ jedną iterację dla każdego komunikatu.
try
{
87469504f326f0d7c1fcda56ef61bd79
8
348 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
1
Nie znam początków tego modelu przetwarzania wyjątków. Podejrzewam, że jest on powiązany
z mechanizmem SEH (ang. Windows Structured Exception Handling), jednak omawianie tego zagad-
nienia wymagałoby zbytniego zagłębiania się w środowisko CLR.
87469504f326f0d7c1fcda56ef61bd79
8
10.4. Filtry wyjątków 349
87469504f326f0d7c1fcda56ef61bd79
8
350 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
{
Middle();
}
catch (IOException e)
when (LogAndReturn("Niewywoływana", true)) Filtr wyjątków, który nigdy
{ ich nie przechwytuje.
}
catch (Exception e) Filtr wyjątków, który zawsze
when (LogAndReturn("Filtr w metodzie Bottom", true)) je przechwytuje.
{
Console.WriteLine("Przechwycono w metodzie Bottom"); Ten tekst jest wyświetlany,
} ponieważ tu wyjątek zostaje
} przechwycony.
Uf! Wcześniejszy opis i uwagi w listingu zapewniają wystarczającą ilość informacji, aby
się domyślić, jak będą wyglądać dane wyjściowe. Dalej omawiam je, aby mieć pewność,
że są zrozumiałe. Najpierw jednak zobacz, co kod wyświetla:
Filtr w metodzie Middle
Filtr w metodzie Bottom
Finally w metodzie Top
Finally w metodzie Middle
Przechwycono w metodzie Bottom
Przebieg programu pokazany jest na rysunku 10.2. Dla każdego kroku po lewej stronie
pokazany jest stos (z pominięciem metody Main), w środkowej części znajduje się opis,
a po prawej dane wyjściowe dla danego kroku.
87469504f326f0d7c1fcda56ef61bd79
8
10.4. Filtry wyjątków 351
87469504f326f0d7c1fcda56ef61bd79
8
352 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
Gdybyś chciał obsługiwać wszystkie pozostałe wyjątki typu WebException na tym samym
poziomie, poprawne byłoby użycie ogólnego bloku catch (WebException e) {…} bez
filtra wyjątków po dwóch blokach z filtrami specyficznymi dla wartości pola Status.
Teraz gdy wiesz już, jak działają filtry wyjątków, pora wrócić do dwóch opisanych
wcześniej ogólnych scenariuszy. Nie są to jedyne zastosowania filtrów wyjątków, powinny
jednak pomóc Ci w rozpoznaniu podobnych sytuacji. Zacznijmy od ponawiania prób.
2
Jako minimum oczekiwałbym od stosowanego w praktyce mechanizmu ponawiania prób przyjmo-
wania filtra, aby sprawdzać, po których błędach można ponawiać próby, a także opóźnienia między
wywołaniami.
87469504f326f0d7c1fcda56ef61bd79
8
10.4. Filtry wyjątków 353
Jeśli teraz użyjesz filtrów wyjątków do przechwytywania wyjątków tylko wtedy, gdy
zamierzasz ponowić próbę wykonania operacji, kod będzie prosty. Ilustruje to lis-
ting 10.15.
Choć używanie pętli while(true) rzadko jest dobrym pomysłem, tu ma ona sens. Mógł-
byś napisać pętlę z warunkiem opartym na liczniku retryCount, jednak filtr wyjątków
w praktyce już uwzględnia liczbę prób, dlatego taka pętla byłaby myląca. Ponadto
w innej pętli jej koniec byłby osiągalny z punktu widzenia kompilatora, dlatego kod nie
skompilowałby się bez instrukcji return lub throw w końcowej części metody.
Po przygotowaniu tego kodu jego używanie na potrzeby ponawiania prób jest proste:
Func<DateTime> temporamentalCall = () =>
{
DateTime utcNow = DateTime.UtcNow;
if (utcNow.Second < 20)
{
throw new Exception("Nie lubię początku minuty");
}
return utcNow;
};
Zwykle ten kod natychmiast zwróci wynik. Czasem, jeśli uruchomisz go mniej więcej
po 10 sekundach od rozpoczęcia minuty, wywołanie kilkakrotnie nie powiedzie się, po
czym zakończy się sukcesem. W niektórych sytuacjach, gdy wywołasz program dokładnie
87469504f326f0d7c1fcda56ef61bd79
8
354 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
Ten listing jest pod wieloma względami nową wersją listingu 10.14, gdzie zapis danych
służył do analizy działania dwuprzebiegowego procesu obsługi wyjątków. Tu filtr nigdy
nie służy do przechwytywania wyjątku, a cały blok try/catch razem z filtrem istnieją tylko
na potrzeby wywoływania efektu ubocznego w postaci zapisu danych w dzienniku.
87469504f326f0d7c1fcda56ef61bd79
8
10.4. Filtry wyjątków 355
87469504f326f0d7c1fcda56ef61bd79
8
356 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu
Czy ta różnica pozwala spełnić wysokie oczekiwania, jakie mamy wobec nowych mecha-
nizmów języka? Można mieć wątpliwości.
Występują jednak różnice między tymi dwoma fragmentami kodu. Wiesz już, że
moment sprawdzania warunku condition zmienia się w zależności od bloków finally
znajdujących się wyżej na stosie wywołań. Ponadto choć prosta instrukcja throw zwykle
pozwala zachować pierwotny ślad stosu, może powodować drobne różnice, przede
wszystkim w ramce stosu, w której wyjątek jest przechwytywany i ponownie zgłaszany.
Od tego może zależeć, czy diagnozowanie błędu będzie proste, czy bolesne.
Wątpię, czy filtry wyjątków znacznie zmienią życie wielu programistów. W odróż-
nieniu od składowych z ciałem w postaci wyrażenia i literałów tekstowych z interpo-
lacją nie są czymś, czego mi brakuje, gdy muszę pracować nad kodem bazowym z wersji
C# 5. Miło jednak mieć możliwość korzystania z nich.
Spośród mechanizmów opisanych w tym rozdziale zdecydowanie najczęściej uży-
wam dyrektyw using static i operatora ?.. Można je stosować w wielu sytuacjach i cza-
sem pozwalają znacznie poprawić czytelność kodu. Przede wszystkim w kodzie, w którym
używanych jest wiele stałych zdefiniowanych w innych miejscach, dyrektywa using
static może znacznie zmieniać poziom czytelności.
Wspólną cechą dotyczącą operatora ?. oraz inicjalizatorów obiektów i kolekcji jest
możliwość zapisu złożonych operacji w jednym wyrażeniu. Zwiększa to korzyści pły-
nące z inicjalizatorów obiektów i kolekcji wprowadzonych w C# 3. Dzięki nowym
usprawnieniom wyrażenia można stosować do inicjalizowania pól lub jako argumenty
metod, które w innej sytuacji musiałyby być obliczane osobno i w mniej wygodny
sposób.
Podsumowanie
Dyrektywy using static umożliwiają używanie w kodzie statycznych składowych
typów (zwykle stałych i metod) bez podawania nazwy danego typu.
Dyrektywa using static importuje też wszystkie metody rozszerzające z okre-
ślonego typu, dzięki czemu nie trzeba importować wszystkich metod rozszerza-
jących z przestrzeni nazw.
Zmiany w sposobie importowania metod rozszerzających oznaczają, że prze-
kształcanie zwykłych metod statycznych w metody rozszerzające czasem powo-
duje niezgodność z istniejącym kodem.
W inicjalizatorach kolekcji można obecnie używać metod rozszerzających Add,
a także metod zdefiniowanych w inicjalizowanym typie kolekcji.
W inicjalizatorach obiektów można obecnie stosować indeksery, jednak używanie
indekserów i inicjalizatorów kolekcji ma zalety, ale i wady.
Operator ?. znacznie ułatwia tworzenie łańcuchów wywołań, w których jeden
z elementów łańcucha może zwracać wartość null.
Filtry wyjątków zapewniają większą kontrolę nad tym, które wyjątki są prze-
chwytywane. Można do tego wykorzystać dane z wyjątków zamiast samego typu
wyjątku.
87469504f326f0d7c1fcda56ef61bd79
8
Część 4
C# 7 i przyszłe wersje
C
cztery:
# 7 to pierwsza wersja od C# 1, która ma kilka podwersji1. Udostępniono je
1
W Visual Studio 2002 udostępniono C# 1.0, a w Visual Studio 2003 — C# 1.2. Nie wiem, dlaczego
pominięto wersję 1.1. Nie jest też jasne, jakie były różnice między tymi wersjami.
87469504f326f0d7c1fcda56ef61bd79
8
są niedostępne. Rzadko korzystam też z mechanizmów związanych z referencjami,
ponieważ nie programuję rozwiązań, w których te mechanizmy byłyby przydatne. Nie
oznacza to jednak, że nie są to wartościowe funkcje. Nie są one jednak powszechnie
stosowane. Inne mechanizmy dodane w C# 7, np. dopasowywanie wzorców, wyrażenia
throw i usprawnienia literałów liczbowych, z większym prawdopodobieństwem będą
przydatne dla wszystkich programistów, jednak ich wpływ jest zapewne mniejszy niż
bardziej wyspecjalizowanych technik.
Piszę o tym wszystkim tylko po to, abyś miał realistyczne oczekiwania. Jak zawsze
w trakcie lektury zastanów się, jak możesz wykorzystać dany mechanizm we własnym
kodzie. Nie czuj się zmuszony do korzystania z niego. Nikt nie przyznaje punktów za
użycie największej liczby mechanizmów języka w jak najkrótszym fragmencie kodu.
Jeśli stwierdzisz, że obecnie nie znajdujesz zastosowania dla danego mechanizmu, nie
ma w tym nic złego. Zapamiętaj jedynie, że dana funkcja jest dostępna, abyś wiedział
o niej, gdy będziesz pracować w innym kontekście.
Ważne dla mnie jest także wyznaczenie oczekiwań co do rozdziału 15., gdzie opi-
sana jest przyszłość języka C#. Większość tego rozdziału ilustruje mechanizmy już
dostępne w wersjach testowych C# 8, nie ma jednak gwarancji, że wszystkie te roz-
wiązania znajdą się w wersji ostatecznej. Ponadto mogą pojawić się liczne funkcje,
o których w ogóle tu nie wspominam. Mam nadzieję, że opisane tu mechanizmy będą
dla Ciebie równie ekscytujące jak dla mnie, a także że będziesz śledził nowe wersje
testowe i artykuły na blogach publikowane przez członków zespołu odpowiedzialnego
za C#. Nadeszły ciekawe czasy dla programistów języka C# — zarówno ze względu na
obecnie dostępne możliwości, jak i z uwagi na jasną przyszłość.
87469504f326f0d7c1fcda56ef61bd79
8
Łączenie danych
z użyciem krotek
Zawartość rozdziału
Używanie krotek do łączenia danych
Składnia krotek — literały i typy
Przekształcanie krotek
W jaki sposób krotki są reprezentowane w środowisku
CLR?
Alternatywy dla krotek i wskazówki dotyczące
używania krotek
87469504f326f0d7c1fcda56ef61bd79
8
360 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
istnieją już w platformie, ale nie są obsługiwane w języku. Aby jeszcze bardziej skom-
plikować sytuację, w C# 7 te typy krotek nie są używane na potrzeby krotek obsługiwa-
nych przez język. Zamiast tego wykorzystywane są nowe typy z rodziny System.ValueTuple,
opisane w podrozdziale 11.4. W punkcie 11.5.1 znajdziesz ich porównanie z typami
System.Tuple.
Dalej przedstawionych jest kilka implementacji metody MinMax, jednak ten przykład
powinien dać Ci wystarczające wyobrażenie o omawianym mechanizmie, abyś chciał
się zapoznać z wszystkimi dość szczegółowymi opisami z tego rozdziału. Jak na mecha-
nizm, który wydaje się prosty, o krotkach można napisać całkiem dużo. Wszystkie te
informacje są powiązane ze sobą, dlatego trudno przedstawiać je w jakimś logicznym
porządku. Jeśli w trakcie lektury zaczniesz zadawać sobie pytanie: „A co z…?”, zachę-
cam do tego, byś zapamiętał je do końca rozdziału. Nie ma tu nic skomplikowanego,
jednak tekst jest długi — przede wszystkim dlatego, że chcę zaprezentować kompletny
opis. Mam nadzieję, że do momentu zakończenia rozdziału znajdziesz odpowiedzi na
wszystkie swoje pytania1.
1
Jeśli tak nie będzie, powinieneś oczywiście poprosić o dodatkowe informacje na forum Author Online
lub w serwisie Stack Overflow.
87469504f326f0d7c1fcda56ef61bd79
8
11.2. Literały i typy krotek 361
11.2.1. Składnia
W C# 7 wprowadzono dwa nowe elementy składniowe: literały krotek i typy krotek.
Wyglądają one podobnie. W obu przypadkach należy podać rozdzieloną przecinkami
sekwencję dwóch lub więcej elementów w nawiasie. W literale krotki każdy element
ma wartość i opcjonalną nazwę. W typie krotki każdy element ma typ i opcjonalną
nazwę. Na rysunku 11.1 pokazany jest przykładowy literał krotki. Na rysunku 11.2
widoczny jest przykładowy typ krotki. Na każdym rysunku pokazany jest jeden element
nazwany i jeden nienazwany.
Rysunek 11.1. Literał krotki z elementami Rysunek 11.2. Typ krotki z elementami
o wartościach 5 i "tekst". Nazwa drugiego o typach int i Guid. Nazwa pierwszego
elementu to title elementu to x
W praktyce znacznie częściej nazwy podaje się dla wszystkich elementów lub w ogóle
się ich nie używa. Używane mogą być np. typy krotek (int, int) lub (int x, int y,
int z) oraz literały krotek (x: 1, y: 2) lub (1, 2, 3). Nie jest to jednak wymagane.
Używanie nazw nie łączy elementów w dodatkowy sposób. Należy jednak pamiętać
o dwóch ograniczeniach dotyczących nazw:
Nazwy muszą być unikatowe w ramach typu lub literału. Literał krotki (x: 1,
x: 2) jest niedozwolony i nie ma sensu.
Nazwy w postaci ItemN, gdzie N to liczba całkowita, są dozwolone tylko wtedy,
gdy wartość N pasuje do pozycji elementu w literale lub typie (pierwsza pozycja
to 1). Dlatego krotka (Item1: 0, Item2: 0) jest dozwolona, natomiast (Item2: 0,
Item1: 0) jest nieprawidłowa. W następnym podrozdziale zobaczysz, z czego to
wynika.
Typy krotek służą do podawania typów w tych samych miejscach, gdzie używane są
inne nazwy typów: w deklaracjach zmiennych, jako typy wartości zwracanych przez
metody itd. Literały krotek są używane jak dowolne inne wyrażenia określające wartość.
Takie literały jedynie łączą inne elementy w wartość w postaci krotki.
87469504f326f0d7c1fcda56ef61bd79
8
362 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
Wartościami elementów w literałach krotek mogą być dowolne wartości inne niż
wskaźnik. W większości przykładów z tego rozdziału dla wygody używane są stałe
(głównie liczby całkowite i łańcuchy znaków), jednak często jako wartości elementów
stosuje się też zmienne. Podobnie typami elementów w krotce mogą być dowolne typy
niewskaźnikowe: tablice, parametry określające typ, a nawet inne typy krotek.
Teraz, kiedy wiesz już, jak wyglądają typy krotek, możesz zrozumieć typ zwracany
przez metodę MinMax — (int min, int max):
jest to typ krotki o dwóch elementach,
pierwszy element jest typu int i ma nazwę min,
drugi element jest typu int i ma nazwę max.
Wiesz już też, jak utworzyć krotkę za pomocą literału krotki. Możesz więc napisać
kompletną implementację metody. Ilustruje ją listing 11.1.
static (int min, int max) MinMax( Typem zwracanej wartości jest krotka
IEnumerable<int> source) z nazwanymi elementami.
{
using (var iterator = source.GetEnumerator())
{
if (!iterator.MoveNext()) Uniemożliwia używanie pustych sekwencji.
{
throw new InvalidOperationException(
"Nie można znaleźć minimum i maksimum pustej sekwencji");
}
int min = iterator.Current; Używanie zwykłych wartości typu int
int max = iterator.Current; do śledzenia minimum i maksimum.
while (iterator.MoveNext())
{
min = Math.Min(min, iterator.Current); Aktualizowanie zmiennych za pomocą
max = Math.Max(max, iterator.Current); nowego minimum lub maksimum.
}
return (min, max); Tworzenie krotki z użyciem minimum i maksimum.
}
}
Jedyne fragmenty listingu 11.1, gdzie używane są nowe funkcje, to objaśniony już typ
zwracanej wartości oraz instrukcja return, gdzie używany jest literał krotki:
return (min, max)
Do tej pory nie pisałem nic na temat typu literału krotki. Stwierdziłem jedynie, że
takie literały służą do tworzenia wartości krotek, jednak na razie celowo nie doprecy-
zowuję tej kwestii. Warto zauważyć, że użyty tu literał krotki na razie nie obejmuje
żadnych nazw elementów (przynajmniej nie w wersji C# 7.0). Nazwy min i max określają
wartości elementów na podstawie zmiennych lokalnych metody.
87469504f326f0d7c1fcda56ef61bd79
8
11.2. Literały i typy krotek 363
Skoro jesteśmy już przy definiowaniu nazw, warto zdefiniować arność typu lub literału
krotki. Arność oznacza liczbę elementów. Na przykład (int, long) ma arność 1, a ("a",
"b", "c") ma arność 3. Same typy elementów nie mają znaczenia przy określaniu arności.
UWAGA. Nie jest to nowa terminologia. Arność jest uwzględniana także w typach gene-
rycznych, gdzie oznacza liczbę parametrów określających typ. Typ List<T> ma arność 1,
natomiast typ Dictionary<TKey, TValue> ma arność 2.
Wskazówka dotycząca tego, że dobre nazwy elementów pasują do dobrych nazw zmien-
nych, sugeruje, jaki aspekt literałów krotek usprawniono w C# 7.1.
Wnioskowanie jest stosowane nie tylko wtedy, gdy w kodzie używane są proste zmienne.
Krotki często są inicjalizowane na podstawie właściwości. Jest to częste zwłaszcza
w technologii LINQ w połączeniu z projekcjami.
W C# 7.1 nazwy elementów krotek zostają wywnioskowane, gdy wartość jest pobie-
rana ze zmiennej lub właściwości. Odbywa się to w taki sam sposób jak wnioskowanie
nazw w typach anonimowych. Aby zobaczyć, jak użyteczna jest ta technika, rozważ trzy
sposoby zapisu zapytania w technologii LINQ to Objects. To zapytanie złącza dwie
kolekcje, aby pobrać nazwiska, stanowiska i działy pracowników. Najpierw podane jest
tradycyjne zapytanie w LINQ z użyciem typów anonimowych:
from emp in employees
join dept in departments on emp.DepartmentId equals dept.Id
select new { emp.Name, emp.Title, DepartmentName = dept.Name };
87469504f326f0d7c1fcda56ef61bd79
8
364 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
Warto zauważyć, że nadal trzeba podać nazwy elementów Min i Max, ponieważ te
wartości są pobierane za pomocą wywołań metod. Wywołania metod nie pozwalają
wywnioskować nazw ani elementów krotek, ani właściwości typów anonimowych.
Niewielką wadą jest to, że jeśli można wywnioskować dwie takie same nazwy, żadna
z nich nie zostaje użyta. Jeżeli występuje kolizja między wywnioskowaną nazwą a nazwą
podaną jawnie, priorytetowo traktowana jest ta ostatnia, a drugi element pozostaje
nienazwany. Wiesz już, jak podawać typy i literały krotek. Co jednak można z nimi
zrobić?
87469504f326f0d7c1fcda56ef61bd79
8
11.2. Literały i typy krotek 365
Rysunek 11.3. Trzy odrębne zmienne po lewej stronie; dwie zmienne (w tym jedna krotka)
po prawej stronie
lokalnych. Po prawej stronie znajduje się podobny kod, jednak dwie z tych zmiennych
są zapisane w krotce (w owalu). Po prawej stronie nazwisko (name) i liczba punktów
(score) są połączone w krotce o nazwie player (gracz). Gdy chcesz traktować je jako
odrębne zmienne, nadal możesz to robić (np. wyświetlając wartość player.score), ale
możesz też używać ich jak grupy (np. przypisując nową wartość do krotki player).
Gdy już zaczniesz myśleć o krotce jak o zbiorze zmiennych, wiele rzeczy nabierze
więcej sensu. Jak jednak używać tych zmiennych? Zobaczyłeś już, że gdy w krotce
dostępne są elementy nazwane, możesz używać ich za pomocą nazw. Co jednak zrobić,
jeśli element nie ma nazwy?
DOSTĘP DO ELEMENTÓW ZA POMOCĄ NAZW I POZYCJI
Może przypominasz sobie, że obowiązuje ograniczenie dla nazw elementów w postaci
ItemN, gdzie N to liczba. Wynika ono z tego, że każdą zmienną w krotce można wskazać
zarówno za pomocą pozycji, jak i przy użyciu nazwy. Każdemu elementowi odpowiada
jedna zmienna, przy czym można ją wskazać na dwa sposoby. Najłatwiej pokazać to na
przykładzie. Ilustruje to listing 11.2.
Na tym etapie zapewne rozumiesz już, dlaczego zapis (Item1: 10, 20) jest poprawny,
ale już (Item2: 10, 20) jest niedozwolony. W pierwszym przypadku dodawana jest
nadmiarowa nazwa elementu, natomiast w drugim powstaje niejednoznaczność co do
87469504f326f0d7c1fcda56ef61bd79
8
366 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
tego, czy Item2 oznacza pierwszy element (podawany za pomocą nazwy), czy drugi ele-
ment (podawany przy użyciu pozycji). Można by stwierdzić, że zapis (Item5: 10, 20)
powinien być dopuszczalny, ponieważ krotka obejmuje tylko dwa elementy. Jest to
jedna z sytuacji, w których kod wprawdzie nie powoduje technicznie wieloznaczności,
ale z pewnością byłby mało zrozumiały, dlatego i tak jest zabroniony.
Teraz wiesz już, że możesz zmodyfikować wartość krotki po jej utworzeniu. Dla-
tego możesz zmienić metodę MinMax i użyć jednej zmiennej lokalnej w postaci krotki
do zapisywania dotychczasowych wyników, zamiast oddzielać zmienne min i max. Nowe
rozwiązanie pokazane jest na listingu 11.3.
Listing 11.3 jest bardzo, bardzo podobny do listingu 11.1, jeśli chodzi o działanie kodu.
Jedyna różnica to połączenie dwóch z czterech zmiennych lokalnych. Zamiast zmien-
nych source, iterator, min i max używane są teraz zmienne source, iterator i result,
przy czym result obejmuje elementy min i max. Ilość zajmowanej pamięci i wydajność
są takie same. Różny jest tylko zapis kodu. Czy wersja z krotkami jest lepsza? Ocena jest
tu subiektywna, możesz jednak samodzielnie podjąć decyzję. Wybór zapisu to wyłącznie
szczegół implementacji.
TRAKTOWANIE KROTEK JAK POJEDYNCZYCH WARTOŚCI
Skoro już jesteśmy przy różnych implementacjach metody, warto rozważyć jeszcze
inny zapis. Możesz użyć kodu, który najpierw przypisuje nową wartość do elementu
result.min, a następnie do elementu result.max:
result.min = Math.Min(result.min, iterator.Current);
result.max = Math.Max(result.max, iterator.Current);
Jeśli przypiszesz wynik bezpośrednio do zmiennej result, możesz zastąpić cały zbiór
w jednej operacji. Ilustruje to listing 11.4.
87469504f326f0d7c1fcda56ef61bd79
8
11.2. Literały i typy krotek 367
Także tu różnica między obiema implementacjami nie jest duża. Na listingu 11.3 oba
elementy krotki są aktualizowane niezależnie i sprawdzane są wcześniejsze wartości
poszczególnych elementów. Ciekawszym przykładem jest metoda zwracająca ciąg
Fibonacciego2 jako wartość typu IEnumerable<int>. C# pomaga napisać taką metodę,
ponieważ udostępnia iteratory z instrukcją yield, co jednak może okazać się skompli-
kowane. Na listingu 11.5 pokazano w pełni poprawną implementację z C# 6.
87469504f326f0d7c1fcda56ef61bd79
8
368 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
{
var pair = (current: 0, next: 1);
while (true)
{
yield return pair.current;
pair = (pair.next, pair.current + pair.next);
}
}
Na tym etapie trudno jest oprzeć się pokusie dodatkowego uogólnienia rozwiązania,
aby generować dowolne sekwencje liczb. W tym celu należy cały kod związany z cią-
giem Fibonacciego powiązać z argumentami z wywołania metody. Na listingu 11.7
pokazana jest uogólniona metoda GenerateSequence, odpowiednia do generowania
dowolnych sekwencji na podstawie argumentów.
static IEnumerable<TResult>
GenerateSequence<TState, TResult>(
TState seed,
Func<TState, TState> generator,
Func<TState, TResult> resultSelector)
{ Metoda umożliwiająca generowanie
var state = seed; dowolnych sekwencji na podstawie
while (true) wcześniejszego stanu.
{
yield return resultSelector(state);
state = generator(state);
}
}
Przykładowe zastosowanie
var fibonacci = GenerateSequence(
(current: 0, next: 1), Wykorzystanie generatora sekwencji
pair => (pair.next, pair.current + pair.next), do utworzenia ciągu Fibonacciego.
pair => pair.current);
87469504f326f0d7c1fcda56ef61bd79
8
11.3. Typy krotek i konwersje 369
Jednak następny fragment jest błędny, ponieważ literał null nie ma typu:
var invalid = (10, null);
Literał krotki niemający typu, podobnie jak literał null, można przekształcić na postać
mającą typ. Gdy krotka ma typ, nazwy elementów też są częścią tego typu.
Na przykład we wszystkich poniższych przykładach lewa strona jest odpowiednikiem
prawej:
var tuple = (x: 10, 20); (int x, int) tuple = (x: 10, 20);
var array = new[] {("a", 10)}; (string, int)[] array = {("a", 10)};
string[] input = {"a", "b" }; string[] input = {"a", "b" };
var query = input IEnumerable<(string, int)> query =
.Select(x => (x, x.Length)); input.Select<string, (string, int)>
(x => (x, x.Length));
87469504f326f0d7c1fcda56ef61bd79
8
370 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
Wiesz już, jak działają literały krotek mające typ. Co jednak z literałami krotek,
które nie mają typu? Jak przekształcić literał krotki bez nazw na typ krotki z nazwami?
Aby odpowiedzieć na to pytanie, trzeba przyjrzeć się konwersji krotek na ogólnym
poziomie.
Trzeba uwzględnić dwa rodzaje konwersji — z literałów krotek na typy krotek
i z jednego typu krotki na inny taki typ. Podobne rozróżnienie zostało już opisane
w rozdziale 8. Istnieje konwersja z wyrażenia reprezentującego literał tekstowy z inter-
polacją na typ FormattableString, nie można jednak przekształcić typu string na Format
tableString. Podobnie jest w omawianym tu scenariuszu. Najpierw przyjrzyj się kon-
wersji literałów.
Pierwszy punkt jest prosty. Dziwne byłoby przekształcanie literału (5, 5) na typ (int,
int, int). Skąd miałaby pochodzić ostatnia wartość? Drugi punkt jest bardziej skom-
plikowany, omówię go jednak na przykładach. Najpierw spróbuj przeprowadzić nastę-
pującą konwersję:
87469504f326f0d7c1fcda56ef61bd79
8
11.3. Typy krotek i konwersje 371
Choć nie istnieje niejawna konwersja z typu int na byte, możliwa jest niejawna konwer-
sja ze stałej całkowitoliczbowej 5 na typ byte (ponieważ liczba 5 znajduje się w prze-
dziale poprawnych wartości typu byte). Ponadto istnieje niejawna konwersja z literału
tekstowego na typ object. Wszystkie konwersje są poprawne, dlatego cała konwersja
też jest prawidłowa. Hura! Teraz spróbuj wykonać inną konwersję:
(byte, string) tuple = (300, "tekst");
W tej sytuacji kod ma przekształcić stałą całkowitoliczbową 300 na typ byte. Ta stała
wykracza poza przedział poprawnych wartości, dlatego nie istnieje tu niejawna kon-
wersja. Dostępna jest konwersja jawna, nie jest ona jednak pomocna, gdy chcesz uzy-
skać ogólną niejawną konwersję literału krotki. Istnieje niejawna konwersja z literału
tekstowego na typ string, jednak ponieważ nie wszystkie konwersje są prawidłowe,
cała konwersja jest niedozwolona. Jeśli spróbujesz skompilować taki kod, wystąpi błąd
dotyczący wartości 300 w literale krotki:
error CS0029: Cannot implicitly convert type 'int' to 'byte'
Ten komunikat o błędzie jest nieco mylący. Sugeruje, że wcześniejszy przykład także nie
powinien być poprawny. W rzeczywistości kompilator nie próbuje przekształcić war-
tości typu int na typ byte; próbuje natomiast przekształcić wyrażenie 300 na typ byte.
KONWERSJE JAWNE
Dla jawnych konwersji literałów krotek używane są te same reguły co dla konwersji
niejawnych. Wymagane jest, by możliwa była konwersja jawna każdego wyrażenia
reprezentującego element na powiązane typy. Jeśli ten warunek jest spełniony, istnieje
jawna konwersja z literału krotki na typ krotki, dlatego można przeprowadzić rzutowanie
w standardowy sposób.
87469504f326f0d7c1fcda56ef61bd79
8
372 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
Oba te rozwiązania działają tak samo. Gdy konwersja jest stosowana dla całego literału
krotki, kompilator i tak stosuje jawną konwersję dla wszystkich wyrażeń reprezentu-
jących elementy. Jednak druga wersja jest moim zdaniem dużo bardziej czytelna. Między
innymi jaśniej określa przeznaczenie kodu. Wiesz, że potrzebna jest jawna konwersja
z typu int na byte, a jednocześnie akceptujesz, by typ string pozostał niezmieniony.
Jeśli próbujesz przekształcić kilka wartości na określony typ krotki (zamiast korzystać
z wywnioskowanego typu), druga wersja pozwala jednoznacznie pokazać, które kon-
wersje są jawne i mogą skutkować utratą danych. Chroni to przed przypadkową utratą
danych w wyniku jawnej konwersji całej krotki.
ROLA NAZW ELEMENTÓW W KONWERSJACH LITERAŁÓW KROTEK
Może zauważyłeś, że w tym punkcie nic nie piszę o nazwach. Są one prawie zupełnie
nieistotne w kontekście konwersji literałów krotek. Najważniejsze jest to, że można
przekształcić wyrażenie reprezentujące element bez nazwy na element z typem i nazwą.
Kilkakrotnie wykonałeś już taką operację w tym rozdziale i nie zwracałem na to uwagi.
Robiłeś to od początku, od pierwszej implementacji metody MinMax. W ramach przy-
pomnienia — deklaracja tej metody wyglądała tak:
static (int min, int max) MinMax(IEnumerable<int> source)
87469504f326f0d7c1fcda56ef61bd79
8
11.3. Typy krotek i konwersje 373
Kod próbuje przekształcić literał krotki bez nazw elementów3 na typ (int min, int max).
Jest to oczywiście poprawne. W przeciwnym razie nie pokazywałbym tego kodu. Ponadto
jest to wygodne. Nazwy elementów nie są jednak całkowicie bez znaczenia w trakcie
konwersji literałów krotek. Gdy nazwa elementu jest bezpośrednio podana w literale
krotki, kompilator ostrzega, jeśli w docelowym typie dla danego elementu nie podano
nazwy lub gdy podana nazwa jest inna. Oto przykład:
(int a, int b, int c, int, int) tuple =
(a: 10, wrong: 20, 30, pointless: 40, 50);
Drugi z tych komunikatów z ostrzeżeniem nie jest tak przydatny, jak mógłby być,
ponieważ w rzeczywistości w docelowym typie w ogóle nie podano nazwy elementu.
Mam nadzieję, że i tak potrafisz zrozumieć, gdzie tkwi problem.
Czy opisane rozwiązanie jest przydatne? Jak najbardziej. Nie wtedy, gdy deklaru-
jesz zmienną i tworzysz wartość w jednej instrukcji, ale w sytuacji, gdy deklaracja
i tworzenie wartości są rozdzielone. Załóżmy, że metoda MinMax z listingu 11.1 jest
naprawdę długa i że trudno ją zrefaktoryzować. Czy należy zwrócić wartość (min, max),
czy (max, min)? Tak, w tej sytuacji nazwa metoda sprawia, że kolejność elementów jest
dość oczywista. Jednak w niektórych przypadkach jest inaczej. Wtedy użycie nazw
elementów w instrukcji return może być przydatne do sprawdzania poprawności kodu.
Ten kod skompiluje się bez zgłaszania ostrzeżeń:
return (min: min, max: max);
3
Przynajmniej w wersji C# 7.0. W punkcie 11.2.2 zostało napisane, że w C# 7.1 stosowane jest
wnioskowanie nazw.
87469504f326f0d7c1fcda56ef61bd79
8
374 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
Jeśli jednak odwrócisz kolejność elementów, dla każdego z nich zostanie wyświetlone
ostrzeżenie:
return (max: max, min: min); Ostrzeżenie CS8123 (dwukrotnie).
Zauważ, że dotyczy to tylko jawnie podawanych nazw. Nawet w C# 7.1, gdzie nazwy
elementu są wnioskowane na podstawie literału krotki (max, min), nie pojawi się ostrze-
żenie, jeśli przekształcisz wartość na typ krotki (int min, int max).
Zawsze wolę nadawać kodowi taką strukturę, aby program był tak jednoznaczny, że
dodatkowe sprawdzanie poprawności nie jest potrzebne. Warto jednak wiedzieć, że
opisana technika jest dostępna, jeśli jej potrzebujesz — np. w pierwszym kroku przed
refaktoryzacją metody w celu jej skrócenia.
Ten kod skompiluje się bez zgłaszania ostrzeżeń. Ważnym aspektem konwersji typów
krotek niezwiązanym z konwersjami literałów jest konwersja tożsamościowa (a nie tylko
niejawna).
87469504f326f0d7c1fcda56ef61bd79
8
11.3. Typy krotek i konwersje 375
Dotyczy to również typów skonstruowanych, a typy elementów krotek także mogą być
typami skonstruowanymi (o ile konwersja tożsamościowa nadal jest możliwa). Istnieje
więc np. konwersja tożsamościowa między dwoma poniższymi typami:
Dictionary<string, (int, List<object>)>,
Dictionary<string, (int index, List<dynamic> values)>.
87469504f326f0d7c1fcda56ef61bd79
8
376 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
W języku C# typy użytych tu parametrów nie są takie same, jednak aby komunikat
o błędzie był w pełni precyzyjny w kwestii konwersji tożsamościowych, musiałby być
dużo bardziej skomplikowany.
Jeśli uważasz, że oficjalne definicje konwersji tożsamościowych są trudne do zro-
zumienia, możesz myśleć o takich konwersjach w prostszy (choć mniej oficjalny) spo-
sób — dwa typy są identyczne, jeśli nie występuje różnica między nimi w czasie wyko-
nywania programu. To zagadnienie zostanie opisane szczegółowo w podrozdziale 11.4.
BRAK KONWERSJI OPARTEJ NA WARIANCJI GENERYCZNEJ
Po zapoznaniu się z konwersjami tożsamościowymi możesz mieć nadzieję, że da się
zastosować typy krotek z użyciem wariancji generycznej w interfejsach i delegatach.
Niestety, taka możliwość nie istnieje. Wariancja dotyczy tylko typów referencyjnych,
a typy krotek są zawsze typami bezpośrednimi. Wydaje się np., że poniższy kod powi-
nien się skompilować:
IEnumerable<(string, string)> stringPairs = new (string, string)[10];
IEnumerable<(object, object)> objectPairs = stringPairs;
Tak jednak nie jest. Szkoda. Nie wydaje mi się, aby w praktyce często sprawiało to
problem, chcę jednak oszczędzić Ci rozczarowania w sytuacji, gdybyś chciał zastoso-
wać takie rozwiązanie i spodziewał się, że zadziała.
87469504f326f0d7c1fcda56ef61bd79
8
11.3. Typy krotek i konwersje 377
interface ISample
{
void Method((int x, string) tuple);
}
Niewłaściwe
public void Method((string x, object) tuple) {} typy elementów.
public void Method((int, string) tuple) {} Brak nazwy pierwszego elementu.
public void Method((int x, string extra) tuple) {} Drugi element
public void Method((int wrong, string) tuple) {} Pierwszy element ma nazwę, jednak
public void Method((int x, string, int) tuple) {} ma niewłaściwą w pierwotnej
public void Method((int x, string) tuple) {} nazwę. definicji jej nie ma.
W tym przykładzie opisana jest tylko implementacja interfejsu, jednak te same ogra-
niczenia obowiązują przy przesłanianiu składowych z klasy bazowej. Ponadto w przy-
kładzie używane są tylko parametry, a ograniczenia dotyczą również typów zwracanych
wartości. Oznacza to, że dodanie, usunięcie lub zmodyfikowanie nazwy elementu krotki
w składowej interfejsu albo składowej klasy wirtualnej lub abstrakcyjnej narusza zgod-
ność z istniejącym kodem. Dlatego dobrze rozważ zastosowanie takiej techniki w publicz-
nym interfejsie API!
87469504f326f0d7c1fcda56ef61bd79
8
378 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
Na listingu 11.8 pokazane są dwie krotki (jedna z nazwami elementów i druga bez).
Kod sprawdza, czy są one równe, czy nierówne. W obu scenariuszach pokazany jest
też kod generowany przez kompilator na podstawie operatora. Należy zauważyć, że
w wygenerowanym kodzie używane są wersje operatorów udostępniane przez typy
elementów. Typy ze środowiska CLR nie potrafią udostępniać takich możliwości bez
korzystania z mechanizmu refleksji. Dlatego to zadanie lepiej obsługiwać za pomocą
kompilatora.
To już wszystkie potrzebne informacje na temat reguł używania krotek w języku.
Dokładne szczegóły przekazywania nazw elementów w ramach wnioskowania typów
i podobne wiadomości najlepiej opisano w specyfikacji języka. Nawet w tej książce
występuje granica potrzebnej szczegółowości omówień. Choć mógłbyś wykorzystać
zaprezentowane tu informacje i zignorować obsługę krotek w środowisku CLR, będziesz
potrafił lepiej używać krotek i zrozumieć ich działanie, jeśli zrobisz dodatkowy krok
i dowiesz się, jak kompilator przetwarza opisane reguły w kod pośredni.
Otrzymałeś już bardzo dużą dawkę wiedzy. Jeśli jeszcze nie próbowałeś pisać kodu
z użyciem krotek, jest to dobry moment, aby to zrobić. Zrób sobie przerwę od książki
i przed przejściem do omówienia implementacji krotek sprawdź, czy radzisz sobie
z korzystaniem z nich.
87469504f326f0d7c1fcda56ef61bd79
8
11.4. Krotki w środowisku CLR 379
Na razie pomiń pierwsze dwa i ostatni z tych typów (ten ostatni jest opisany w punk-
tach 11.4.7 i 11.4.8). Tu do omówienia pozostają typy o generycznej arności od 2 do 7.
W praktyce to z nich będziesz zapewne korzystał najczęściej.
Opis każdego typu ValueTuple<…> bardzo przypomina wcześniejsze opisy typów
krotek. Typy ValueTuple<…> to typy bezpośrednie z polami publicznymi. Nazwy tych
pól to Item1, Item2 itd. (do Item7). Ostatnie pole w krotce o arności 8 ma nazwę Rest.
Za każdym razem, gdy używasz typu krotki w C#, jest on odwzorowywany na typ
ValueTuple<…>. To odwzorowanie jest oczywiste, gdy w typie krotki w C# nie są używane
nazwy elementów. Na przykład typ (int, string, byte) jest odwzorowywany na typ
ValueTuple<int, string, byte>. Co jednak z opcjonalnymi nazwami elementów z typów
krotek w C#? Typy generyczne są generyczne tylko ze względu na parametry określa-
jące typ. Nie można w magiczny sposób nadać dwóm skonstruowanym typom różnych
nazw pól. Jak kompilator sobie z tym radzi?
Warto zauważyć, że w dolnej połowie rysunku 11.5 znajduje się wiele nazw. Nazwy
zmiennych lokalnych, takie jak w tym kodzie, są używane tylko na etapie kompilacji.
87469504f326f0d7c1fcda56ef61bd79
8
380 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
Jedyny ślad po nich w czasie wykonywania programu znajduje się w pliku PDB two-
rzonym po to, by zapewnić debugerowi dodatkowe informacje. A co z nazwami elemen-
tów widocznymi poza stosunkowo niewielkim kontekstem metody?
NAZWY ELEMENTÓW W METADANYCH
Wróć do używanej kilkakrotnie w tym rozdziale metody MinMax. Załóżmy, że chcesz
utworzyć ją jako metodę publiczną w całym pakiecie metod agregujących, które
wspomagają technologię LINQ to Objects. Szkoda byłoby tracić czytelność zapew-
nianą przez nazwy elementów krotek. Wiesz jednak, że typ CLR wartości zwracanej
przez metodę nie będzie obejmować tych nazw. Na szczęście kompilator może wykorzy-
stać technikę pomocną także dla innych mechanizmów, które nie są bezpośrednio obsłu-
giwane w środowisku CLR, np. dla parametrów out i domyślnych wartości parame-
trów. Tym wybawieniem są atrybuty.
W tym scenariuszu kompilator używa atrybutu TupleElementNamesAttribute (znajduje
się on w tej samej przestrzeni nazw co wiele podobnych atrybutów — System.Runtime.
Compiler.Services), aby zakodować nazwy elementów w podzespole. Na przykład
publiczną deklarację metody MinMax można zapisać w C# 6 tak:
[return: TupleElementNames(new[] {"min", "max"})]
public static ValueTuple<int, int> MinMax(IEnumerable<int> numbers)
Kompilator języka C# 7 nie pozwala skompilować tego kodu. Zgłasza błąd z infor-
macją, że należy bezpośrednio zastosować składnię dla krotek. Jednak jeśli skompi-
lujesz ten sam kod za pomocą kompilatora dla C# 6, otrzymasz podzespół, który możesz
wykorzystać w C# 7, a elementy zwracanej krotki będą dostępne za pomocą nazw.
Omawiany atrybut staje się bardziej skomplikowany, gdy używane są zagnieżdżone
typy krotek. Jest jednak mało prawdopodobne, abyś kiedykolwiek musiał bezpośred-
nio interpretować ten atrybut. Warto jedynie wiedzieć, że taki atrybut istnieje i że
pozwala on przekazywać nazwy elementów także poza zmiennymi lokalnymi. Takie
atrybuty są generowane przez kompilator języka C# nawet na potrzeby składowych
prywatnych, choć w tym przypadku te atrybuty prawdopodobnie nie byłyby potrzebne.
Podejrzewam jednak, że prościej jest traktować wszystkie składowe w ten sam sposób
niezależnie od modyfikatorów dostępu.
BRAK NAZW ELEMENTÓW W CZASIE WYKONYWANIA PROGRAMU
Jeśli nie jest to oczywiste na podstawie wcześniejszego tekstu, warto dodać, że w cza-
sie wykonywania programu w wartości krotki nie występują nazwy elementów. Gdy
wywołasz GetType() dla wartości krotki, otrzymasz typ ValueTuple<…> z odpowiednimi
typami elementów, jednak nazwy elementów z kodu źródłowego będą nieobecne. Jeżeli
wykonasz kod w trybie kroczenia i debuger wyświetli nazwy, wynika to z tego, że debuger
używa dodatkowych informacji do ustalenia pierwotnych nazw elementów. Te nazwy
nie są czymś, co środowisko CLR zna bezpośrednio.
UWAGA. To podejście może wydawać się znajome programistom używającym Javy. Java
w podobny sposób obsługuje typy generyczne z informacjami o typie, które są niedostępne
w czasie wykonywania programu. W Javie nie istnieje coś takiego jak obiekt typu ArrayList
<Integer> lub ArrayList<String>. Używane są tylko obiekty typu ArrayList. W Javie oka-
87469504f326f0d7c1fcda56ef61bd79
8
11.4. Krotki w środowisku CLR 381
zało się to problemem, jednak nazwy elementów krotek nie są równie ważne jak argumenty
określające typ w typach generycznych, dlatego można mieć nadzieję, że nie będą powodo-
wać podobnych kłopotów.
Nazwy elementów istnieją w krotkach w C#, ale już nie w środowisku CLR. A co
z konwersjami?
W tym przykładzie uwzględniane są tylko konwersje między typami krotek, które już
poznałeś. Jednak konwersje z literałów krotek na typy krotek przebiegają w identyczny
sposób. Każda potrzebna konwersja z wyrażenia reprezentującego element na docelowy
typ elementu jest wykonywana w ramach wykonywania odpowiedniego konstruktora
typu ValueTuple<…>.
Dowiedziałeś się już, czego kompilator potrzebuje do obsługi składni dla krotek.
Jednak typy ValueTuple<…> udostępniają dodatkowe techniki, aby ułatwić pracę z tymi
typami. Typy te są bardzo ogólne i nie oferują zbyt wielu możliwości, jednak metoda
ToString() wyświetla czytelne dane wyjściowe oraz dostępnych jest kilka sposobów
porównywania wartości tych typów. Zapoznaj się teraz z dostępnymi technikami.
87469504f326f0d7c1fcda56ef61bd79
8
382 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
Warto przypomnieć, że nazwy nadane elementom krotki nie są znane w czasie wyko-
nywania programu, dlatego nie mogą pojawiać się w wynikach wywołania ToString().
Dlatego takie wyniki są nieco mniej użyteczne niż tekstowa reprezentacja typów ano-
nimowych, choć jeśli wyświetlasz wiele krotek tego samego typu, docenisz brak powtó-
rzeń nazw. Jeden krótki przykład wystarczy, aby zademonstrować wszystkie opisane
wcześniej informacje: Rzutowanie wartości null na łańcuch
znaków, co pozwala wywnioskować
var tuple = (x: (string) null, y: "tekst", z: 10); typ krotki.
87469504f326f0d7c1fcda56ef61bd79
8
11.4. Krotki w środowisku CLR 383
87469504f326f0d7c1fcda56ef61bd79
8
384 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
Compare(Ab, aB);
Compare(aB, aa); Wykonywanie wybranych interesujących porównań.
Compare(aB, ba);
}
Console.WriteLine(
$"{x} i {y} - porównanie: {comparison}; równość: {equal}");
}
87469504f326f0d7c1fcda56ef61bd79
8
11.4. Krotki w środowisku CLR 385
Zaletą porównań tego rodzaju jest to, że wszystko sprowadza się do łączenia operacji.
Obiekt porównujący wie tylko tyle, jak porównać wszystkie poszczególne elementy,
a implementacja krotki deleguje wszystkie porównania do tego obiektu. Przypomina to
nieco działanie technologii LINQ, gdzie zapisywane są operacje na poszczególnych
elementach, jednak żądane jest wykonanie ich na kolekcjach.
Wszystko działa świetnie, jeśli krotki zawierają elementy tego samego typu. Jeżeli
chcesz wykonywać porównania strukturalne na krotkach z elementami różnych rodza-
jów, np. porównywać wartości (string, int, double), musisz się upewnić, że obiekt
porównujący potrafi porównywać łańcuchy znaków, liczby całkowite i liczby zmienno-
przecinkowe o podwójnej precyzji. Jednak w każdym porównaniu trzeba uwzględnić
tylko dwie wartości tego samego typu.
Implementacje typów ValueTuple umożliwiają porównywanie wyłącznie krotek
z takimi samymi argumentami określającymi typ. Jeśli np. spróbujesz porównać krotkę
typu (string, int) z krotką typu (int, string), wyjątek zostanie zgłoszony natychmiast,
przed porównaniem jakichkolwiek elementów. Omawianie przykładowego obiektu
porównującego wykracza poza zakres tej książki, jednak w przykładowym kodzie źró-
dłowym dołączonym do książki znajdziesz zarys takiego obiektu (typ CompoundEquality
Comparer), który powinien być dobrym punktem wyjścia, gdybyś kiedyś potrzebował
zaimplementować podobne rozwiązanie w kodzie produkcyjnym.
To kończy omawianie typów ValueTuple<…> o arności od 2 do 7. Wspomniałem jed-
nak, że wrócę do trzech pozostałych typów wspomnianych w punkcie 11.4.1. Najpierw
przyjrzyj się typom ValueTuple<T1> i ValueTuple<T1, T2, T3, T4, T5, T6, T7, TRest>,
które są ze sobą bardziej powiązane, niż może Ci się wydawać.
87469504f326f0d7c1fcda56ef61bd79
8
386 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
Jest to w pełni prawidłowy kod, przy czym wyrażenie tuple.Item16 jest przekształcane
przez kompilator na postać tuple.Rest.Rest.Item2. Jeśli chcesz stosować rzeczywiste
nazwy pól, oczywiście możesz to robić, jednak nie zalecam tego. Przejdźmy teraz od
długich krotek do ich całkowitego przeciwieństwa.
87469504f326f0d7c1fcda56ef61bd79
8
11.5. Alternatywy dla krotek 387
11.5.1. System.Tuple<…>
Typy System.Tuple<…> z platformy .NET 4 to niemodyfikowalne typy referencyjne (choć
typy elementów mogą być modyfikowalne). Możesz uznać, że takie typy są niemody-
fikowalne w „płytki” sposób, podobnie jak pola readonly.
Największą wadą tych typów jest brak integracji z językiem. Krotki w starszym
stylu są trudniejsze do tworzenia, ich specyfikacje zajmują więcej miejsca, nie są dostępne
konwersje opisane w podrozdziale 11.3 i, co najważniejsze, można stosować tylko nazwy
w formacie ItemX. Choć nazwy stosowane w krotkach z C# 7 są używane tylko w czasie
kompilacji, znacznie zwiększają użyteczność krotek.
Referencyjne typy krotek przypominają kompletne obiekty, a nie zbiory wartości.
W zależności od kontekstu jest to wadą lub zaletą. Zwykle ich używanie jest mniej
wygodne, jednak kopiowanie jednej referencji do dużego obiektu typu Tuple<…> jest
dużo wydajniejsze niż kopiowanie obiektu typu ValueTuple<…>, co wymaga skopiowania
wartości wszystkich elementów. Używanie typu referencyjnego korzystnie wpływa na
pracę w środowisku wielowątkowym. Kopiowanie referencji odbywa się atomowo, nato-
miast kopiowanie krotek typu bezpośredniego — nie.
87469504f326f0d7c1fcda56ef61bd79
8
388 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
Każde z tych zastosowań wymagałoby innych operacji, gdyby w modelu użyto typu
w postaci klasy. Nie musiałbyś się wtedy martwić, że nazwy nie będą uwzględniane lub
że przypadkowo użyjesz współrzędnych kartezjańskich zamiast biegunowych.
87469504f326f0d7c1fcda56ef61bd79
8
11.6. Zastosowania i rekomendacje 389
Jeśli chcesz tymczasowo pogrupować wartości lub tworzysz prototyp i nie jesteś
pewien, czego potrzebujesz, krotki świetnie się sprawdzą. Jeżeli jednak zauważysz,
że używasz krotek w tym samym kształcie w kilku miejscach kodu, zalecam zastąpienie
ich typem nazwanym.
87469504f326f0d7c1fcda56ef61bd79
8
390 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
Nierzadko się zdarza, że w metodzie znajduje się naturalna grupa zmiennych. Czę-
sto świadczy o tym wspólny przedrostek w nazwach takich metod. Na przykład na
listingu 11.11 znajduje się metoda, która może pochodzić z kodu gry i wyświetlać
gracza z najwyższym osiągniętym do danej daty wynikiem. Choć w technologii LINQ to
Objects znajduje się metoda Max, która zwraca najwyższą wartość na potrzeby projekcji,
nie ma metody zwracającej element sekwencji powiązany z tą wartością.
Listing 11.11. Wyświetlanie gracza, który do danej daty uzyskał najwyższy wynik
87469504f326f0d7c1fcda56ef61bd79
8
11.6. Zastosowania i rekomendacje 391
Zmiany zostały wyróżnione pogrubieniem. Czy nowa wersja jest lepsza? Możliwe.
Na poziomie „filozoficznym” jest to dokładnie ten sam kod, jeśli potraktować krotkę jak
kolekcję zmiennych. Ta wersja wydaje mi się bardziej przejrzysta, ponieważ zmniejsza
liczbę jednostek używanych w metodzie na ogólnym poziomie. Oczywiście w tego
rodzaju uproszczonych przykładach odpowiednich dla książek różnice w przejrzystości
są zwykle niewielkie. Gdybyś jednak używał skomplikowanej metody, której nie da się
podzielić na kilka mniejszych metod, zmienne lokalne w postaci krotek mogłyby popra-
wić czytelność w większym stopniu. Podobne rozważania dotyczą także pól.
11.6.3. Pola
Pola, podobnie jak zmienne lokalne, czasem w naturalny sposób są ze sobą powiązane.
Oto przykład z klasy PrecalculatedDateTimeZone z biblioteki Noda Time:
private readonly ZoneInterval[] periods;
private readonly IZoneIntervalMapWithMinMax tailZone;
private readonly Instant tailZoneStart;
private readonly ZoneInterval firstTailZoneInterval;
Nie zamierzam omawiać znaczenia wszystkich tych pól. Mam jednak nadzieję, że jest
widoczne, iż trzy ostatnie są związane z końcową strefą czasową (ang. tail zone). Można
rozważyć przekształcenie czterech pokazanych pól na dwa, z których jedno będzie
krotką:
private readonly ZoneInterval[] periods;
private readonly
(IZoneIntervalMapWithMinMax intervalMap,
Instant start,
ZoneInterval firstInterval) tailZone;
87469504f326f0d7c1fcda56ef61bd79
8
392 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
są tylko do odczytu, a inne nie, to albo musisz pominąć ten aspekt, albo zrezygno-
wać z używania krotki. Ja zwykle rezygnuję wtedy z krotki.
Jeśli niektóre pola są automatycznie generowane i powiązane z automatycznie
implementowanymi właściwościami, trzeba napisać kompletną właściwość,
aby móc używać krotki. W takiej sytuacji zrezygnowałbym z używania krotek.
Można oczekiwać, że ten kod wyświetli liczbę 10. W rzeczywistości zgłaszany jest
wyjątek:
Unhandled Exception: Microsoft.CSharp.RuntimeBinder.RuntimeBinderException:
'System.ValueTuple<int,int>' does not contain a definition for 'x'
87469504f326f0d7c1fcda56ef61bd79
8
Podsumowanie 393
Podsumowanie
Krotki pełnią funkcje zbioru elementów bez hermetyzacji.
Krotki w C# 7 mają inne reprezentacje w języku i w środowisku CLR.
Krotki są typami bezpośrednimi z publicznymi i modyfikowalnymi polami.
Krotki w C# umożliwiają nadawanie nazw elementom.
W strukturach ValueTuple<…> w środowisku CLR nazwy elementów to zawsze
Item1, Item2 itd.
C# umożliwia konwersje typów krotek i literałów krotek.
87469504f326f0d7c1fcda56ef61bd79
8
394 ROZDZIAŁ 11. Łączenie danych z użyciem krotek
87469504f326f0d7c1fcda56ef61bd79
8
Podział krotek
i dopasowywanie wzorców
Zawartość rozdziału:
Podział krotek na wiele zmiennych
Podział typów innych niż krotki
Dopasowywanie wzorców w C# 7
Używanie trzech rodzajów wzorców wprowadzonych
w C# 7
W rozdziale 11. dowiedziałeś się, że krotki umożliwiają proste łączenie danych bez
konieczności tworzenia nowych typów. Jedna zmienna może wtedy pełnić funkcję
worka na inne zmienne. Gdy używałeś krotek, np. do wyświetlania minimalnej i mak-
symalnej wartości z sekwencji liczb całkowitych, wartości pobierałeś z krotki jedna
po drugiej.
Ta technika oczywiście działa i w wielu sytuacjach jest wystarczająca. Jednak często
programista chce podzielić złożoną wartość na odrębne zmienne. Ta operacja to podział
(ang. deconstruction). Złożona wartość może być tu krotką lub obiektem innego typu,
np. KeyValuePair. W C# 7 dostępna jest prosta składnia, która umożliwia zadeklarowanie
lub zainicjalizowanie wielu zmiennych w jednej instrukcji.
Podział odbywa się w bezwarunkowy sposób i jest odpowiednikiem sekwencji
operacji przypisania wartości. Dopasowywanie wzorców jest nieco podobne, ale działa
w bardziej dynamiczny sposób — wartość wejściowa musi pasować do wzorca, aby
wykonany został dalszy kod. W C# 7 wprowadzono dopasowywanie wzorców w kilku
87469504f326f0d7c1fcda56ef61bd79
8
396 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
int e;
string f; Podział na istniejące zmienne.
(e, f) = tuple;
Podejrzewam, że gdybyś zobaczył ten kod i wiedział, że się skompiluje, już potrafił-
byś odgadnąć dane wyjściowe — nawet gdybyś nigdy wcześniej nie czytał nic na temat
krotek lub ich podziału:
a: 10; b: tekst
c: 10; d: tekst
e: 10; f: tekst
87469504f326f0d7c1fcda56ef61bd79
8
12.1. Podział krotek 397
Zaleta tego rozwiązania nie jest oczywista, dopóki nie przyjrzysz się analogicznemu
kodowi, gdzie technika podziału nie jest używana. Kompilator przekształca wcześniej-
szy fragment na następujący kod:
static void Main()
{
var tmp = MethodReturningTuple();
int a = tmp.x;
int b = tmp.y;
string name = tmp.text;
87469504f326f0d7c1fcda56ef61bd79
8
398 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
Trzy deklaracje nie są dla mnie problemem, choć doceniam zwięzłość wcześniejszej
wersji. Irytuje mnie natomiast zmienna tmp. Jej nazwa wskazuje na to, że jest to zmienna
tymczasowa. Jej jedynym przeznaczeniem jest zapamiętanie wyniku wywołania metody,
aby można go było użyć do zainicjalizowania trzech potrzebnych zmiennych: a, b i name.
Choć zmienna tmp jest potrzebna tylko w tym fragmencie kodu, ma ten sam zasięg co
pozostałe zmienne, co mi się nie podoba. Jeśli chcesz zastosować niejawne typowanie dla
niektórych zmiennych i jawne dla innych, też jest to dozwolone. Ilustruje to rysunek 12.1.
Jest to przydatne zwłaszcza w sytuacji, gdy chcesz podać inny typ niż w pierwotnej
krotce, używając w razie potrzeby niejawnej konwersji. Przedstawia to rysunek 12.2.
Jest to odpowiednik użycia słowa var przed każdą zmienną z listy parametrów, a to
z kolei oznacza jawne zażądanie wywnioskowania typu na podstawie przypisywanej
wartości. Użycie słowa var, podobnie jak w zwykłych deklaracjach zmiennych z nie-
jawnie określanym typem, nie oznacza typowania dynamicznego. Powoduje jedynie, że
kompilator wywnioskuje typ zmiennej.
Choć można łączyć niejawne i jawne typowanie zmiennych podanych w nawiasie, nie
można użyć słowa var przed listą zmiennych, a następnie podać typów wybranych
zmiennych:
var (a, long b, name) = MethodReturningTuple(); Błąd — połączenie deklaracji
wewnętrznej i zewnętrznej.
87469504f326f0d7c1fcda56ef61bd79
8
12.1. Podział krotek 399
Jeśli w zasięgu znajduje się zmienna _ (utworzona za pomocą zwykłej deklaracji zmien-
nej), także możesz zastosować pomijanie przy podziale krotki na nowy zestaw zmien-
nych. Istniejąca zmienna zostanie wtedy zachowana.
Z początkowego przeglądu dowiedziałeś się, że w momencie podziału nie trzeba
deklarować nowych zmiennych. Podział można też wykorzystać w sekwencji operacji
przypisania.
W tym scenariuszu kompilator nie traktuje podziału krotki jak sekwencji deklaracji
z powiązanymi wyrażeniami inicjalizacji. Zamiast tego tworzy sekwencję operacji
przypisania. Ma to tę samą zaletę, jeśli chodzi o unikanie zmiennych tymczasowych, co
rozwiązanie z poprzedniego punktu. Na listingu 12.3 pokazany jest przykład z użyciem
tej samej co wcześniej metody MethodReturningTuple().
87469504f326f0d7c1fcda56ef61bd79
8
400 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
Do tej pory wszystko wygląda dobrze, jednak omawiany mechanizm nie ogranicza się
do przypisywania wartości do zmiennych lokalnych. Każde przypisanie, które jest
poprawne jako odrębna instrukcja, jest też dozwolone w połączeniu z podziałem. Może
to być przypisanie wartości do pola, właściwości lub indeksera, włącznie z tablicami
i innymi obiektami.
87469504f326f0d7c1fcda56ef61bd79
8
12.1. Podział krotek 401
WSKAZÓWKA. Jeśli przy próbie zrozumienia tego kodu zaczynasz się martwić o jego
poprawność, oznacza to wyraźny zapach kodu. Gdy już zrozumiesz ten kod, zachęcam do jego
refaktoryzacji. Przy podziale krotek trzeba uwzględnić standardowe trudności z efektami
ubocznymi w wyrażeniach, a problem jest tu dodatkowo nasilony przez to, że na każdym
etapie przetwarzanych jest wiele elementów.
Nie zamierzam długo rozwodzić się nad tym zagadnieniem. Jeden przykład wystarczy,
aby zilustrować problemy, na jakie możesz natrafić. Nie jest to jednak najgorsza moż-
liwa sytuacja. Ten kod można skomplikować na rozmaite sposoby. Na listingu 12.5 krotka
typu (StringBuilder, int) jest dzielona na istniejącą zmienną typu StringBuilder i wła-
ściwość Length powiązaną z tą zmienną.
87469504f326f0d7c1fcda56ef61bd79
8
402 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
(builder, builder.Length) =
Przypisywanie w wyniku podziału.
(new StringBuilder("67890"), 3);
Console.WriteLine(original);
Console.WriteLine(builder);
Gdy docelowym elementem jest zmienna lokalna, dodatkowe przetwarzanie nie jest
konieczne — można bezpośrednio przypisać wartość. Jednak przypisywanie do wła-
ściwości zmiennej wymaga wcześniejszego przetworzenia wartości tej zmiennej
w pierwszym kroku. To dlatego używana jest zmienna targetForLength.
Po utworzeniu krotki na podstawie literału można przypisać do docelowych elemen-
tów różne wartości. Pamiętaj, aby użyć zmiennej targetForLength zamiast obiektu builder,
gdy przypisujesz wartość do właściwości Length. Ta właściwość jest ustawiana w pier-
wotnym obiekcie typu StringBuilder, o zawartości 12345, a nie w nowym obiekcie
o zawartości 67890. To oznacza, że dane wyjściowe z listingów 12.5 i 12.6 wyglądają tak:
123
67890
87469504f326f0d7c1fcda56ef61bd79
8
12.2. Podział typów innych niż krotki 403
1
Są to zupełnie odmienne wzorce od tych omawianych w podrozdziale 12.3. Przepraszam za zbież-
ność pojęć.
87469504f326f0d7c1fcda56ef61bd79
8
404 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
Następnie możesz podzielić dowolny obiekt typu Point na dwie zmienne typu double
w sposób pokazany na listingu 12.7.
Prostota tego rozwiązania jest wspaniała, przynajmniej gdy już przyzwyczaisz się do
jego używania.
Reguły używania metod instancji Deconstruct do podziału obiektów są dość proste:
Metoda musi być dostępna w kodzie, gdzie przeprowadzany jest podział. Na
przykład jeśli cały kod znajduje się w tym samym podzespole, Deconstruct może
być metodą wewnętrzną.
Deconstruct musi być metodą zwracającą void.
Deconstruct musi przyjmować przynajmniej dwa parametry (nie można podzielić
obiektu na jedną wartość).
Deconstruct musi być niegeneryczna.
Może się zastanawiasz, dlaczego w projekcie używane są parametry out, skoro można
dodać wymóg, aby metoda Deconstruct była bezparametrowa i zwracała krotkę. Wynika
to z tego, że przydatna jest możliwość podziału na różne zbiory wartości, co jest wyko-
nalne, jeśli użyć do tego wielu metod — a nie można przeciążać metod wyłącznie na
podstawie typu zwracanej wartości. Aby omówienie było bardziej zrozumiałe, przed-
stawiam przykład z podziałem obiektu typu DateTime, jednak — co oczywiste — nie
można dodawać własnych metod instancji do tego typu. Pora przedstawić metody
rozszerzające odpowiedzialne za podział.
87469504f326f0d7c1fcda56ef61bd79
8
12.2. Podział typów innych niż krotki 405
Metody rozszerzające Deconstruct możesz wykorzystać dla typów, które już udostęp-
niają metody instancji Deconstruct. Metody rozszerzające będą wtedy używane, jeśli
metod instancji nie będzie można zastosować do podziału obiektu (podobnie jest
z wywołaniami zwykłych metod).
87469504f326f0d7c1fcda56ef61bd79
8
406 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
Reguły dotyczące tego, czy metoda może, czy nie może być generyczna, zasługują na
baczniejszą analizę — przede wszystkim dlatego, że pokazują, dlaczego trzeba stosować
różną liczbę parametrów przy przeciążaniu metody Deconstruct. Najważniejsze jest to,
w jaki sposób kompilator traktuje metodę Deconstruct.
87469504f326f0d7c1fcda56ef61bd79
8
12.3. Wprowadzenie do dopasowywania wzorców 407
Gdy tworzysz przeciążone wersje metod Deconstruct, ważna jest liczba para-
metrów out, a nie ich typy. Jeśli utworzysz kilka metod Deconstruct o tej samej
liczbie parametrów out, kompilator nie użyje żadnej z tych metod, ponieważ
w miejscu wywołania nie będzie można określić, której z tych metod należy użyć.
Na tym zakończę, ponieważ nie chcę poświęcać tej kwestii więcej miejsca, niż to
konieczne. Jeśli natrafisz na problemy, których nie potrafisz zrozumieć, spróbuj prze-
prowadzić przedstawione wcześniej transformacje. Możliwe, że dzięki temu kod stanie
się bardziej zrozumiały.
To wszystko, co musisz wiedzieć na temat podziału obiektów. Pozostała część roz-
działu jest poświęcona dopasowywaniu wzorców. Mechanizm ten jest teoretycznie
zupełnie niezależny od podziału obiektów, ma jednak podobny charakter, ponieważ
udostępnia narzędzia pozwalające używać istniejących danych w nowy sposób.
87469504f326f0d7c1fcda56ef61bd79
8
408 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
UWAGA. Jeśli brak nawiasów klamrowych Cię razi, proszę o wybaczenie. Zwykle używam ich
dla wszystkich pętli, instrukcji if itd. Jednak tu takie nawiasy zmniejszają czytelność przy-
datnego kodu (w tym przykładzie i w niektórych dalszych fragmentach z użyciem wzorców).
Dlatego usunąłem je, aby zachować zwięzłość.
Ten kod jest okropny — pełen powtórzeń i długi. Ten sam wzorzec — sprawdź, czy
figura jest określonego typu, a następnie użyj właściwości tego typu — występuje tu
trzykrotnie. Okropność. Ważne jest to, że choć występuje tu wiele instrukcji if, ciało
każdej z nich zwraca wartość, dlatego zawsze wykonywana jest tylko jedna z nich. Na
listingu 12.11 pokazane jest, jak ten sam kod można napisać w C# 7 z użyciem wzorców
w instrukcji switch.
Odbiega to od instrukcji switch ze starszych wersji języka C#, gdzie etykiety były
stałymi. Tu czasem dopasowywana jest zwykła wartość (null), a czasem istotny jest typ
wartości (Rectangle, Circle i Triangle). Gdy dopasowywanie odbywa się na podstawie
typu, tworzona jest nowa zmienna tego typu, którą można wykorzystać do obliczenia
obwodu.
Zagadnienie wzorców w C# ma dwa odrębne aspekty. Są to:
składnia wzorców,
konteksty, w których można z nich korzystać.
87469504f326f0d7c1fcda56ef61bd79
8
12.4. Wzorce dostępne w C# 7.0 409
Początkowo może się wydawać, że wszystko jest nowinką i że podział na te aspekty jest
bezzasadny. Jednak wzorce dostępne w C# 7.0 to tylko początek. Zespół projektowy
odpowiedzialny za C# wyraźnie zaznaczył, że składnia została zaprojektowana z myślą
o udostępnianiu w przyszłości nowych wzorców. Gdy będziesz wiedzieć, gdzie w języku
można stosować wzorce, będziesz mógł łatwo opanować ich nowe rodzaje. To trochę jak
z problemem jajka i kury — trudno jest zademonstrować jeden aspekt bez drugiego.
Zacznijmy od przeglądu rodzajów wzorców dostępnych w C# 7.0.
87469504f326f0d7c1fcda56ef61bd79
8
410 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
Dane wyjściowe są w większości proste, jednak przedostatni wiersz może Cię zaskoczyć:
Input to łańcuch znaków hello
Input to liczba 5 typu long
Input nie pasuje do witaj, 5 typu long lub 10 typu int
Input to liczba 10 typu int
Input nie pasuje do witaj, 5 typu long lub 10 typu int
87469504f326f0d7c1fcda56ef61bd79
8
12.4. Wzorce dostępne w C# 7.0 411
W tym scenariuszu zdecydowanie preferuję wersję z instrukcją switch, jest ona jednak
przesadą, jeśli trzeba zastąpić tylko jedną parę as/if. Wzorzec typu zwykle stosuje się
do zastępowania albo par as/if, albo instrukcji if z operatorem is i rzutowaniem. To
ostatnie rozwiązanie jest potrzebne, gdy sprawdzany typ jest typem bezpośrednim
nieprzyjmującym wartości null.
Typ sprawdzany we wzorcu typu nie może być typem bezpośrednim nieprzyj-
mującym wartości null. Można jednak używać parametru określającego typ, a w czasie
wykonywania programu ten parametr może okazać się typem bezpośrednim nie-
przyjmującym wartości null. Wtedy wzorzec zostaje dopasowany, tylko gdy wartość
jest różna od null. Na listingu 12.14 używana jest wartość int? jako argument określa-
jący typ dla metody, w której parametr określający typ jest używany we wzorcu typu
(choć wyrażenie value is int? t się nie skompiluje).
87469504f326f0d7c1fcda56ef61bd79
8
412 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
wymagają, aby typ x z czasu kompilacji można było zrzutować na typ SomeType. Wydaje
się to sensowne, ale tylko do czasu, gdy zaczniesz używać typów generycznych. Rozważ
pokazaną na listingu 12.15 metodę generyczną, która wyświetla szczegółowe informa-
cje o figurach z wykorzystaniem dopasowywania wzorców.
W C# 7.0 ten listing się nie skompiluje, ponieważ nie można skompilować także poniż-
szego fragmentu:
if (shape is Circle)
{
Circle c = (Circle) shape;
}
87469504f326f0d7c1fcda56ef61bd79
8
12.4. Wzorce dostępne w C# 7.0 413
To podejście jest niezgrabne nawet przy zwykłym rzutowaniu, a sytuacja jeszcze się
pogarsza, gdy próbujesz zastosować elegancki wzorzec typu.
Na listingu 12.15 problem można rozwiązać, albo przyjmując wartość typu IEnume
rable<Shape> (co pozwala wykorzystać generyczną kowariancję, aby możliwa była np.
konwersja z typu List<Circle> na IEnumerable<Shape>), albo używając dla zmiennej shape
typu Shape zamiast T. W innych scenariuszach rozwiązanie nie jest równie proste.
W C# 7.1 problem wyeliminowano, dopuszczając używanie wzorca typu dla wszystkich
typów dozwolonych w operatorze as. Dzięki temu kod z listingu 12.15 jest poprawny.
Podejrzewam, że wzorce typów będą najczęściej używanymi z trzech rodzajów
wzorców wprowadzonych w C# 7.0. Ostatni wzorzec prawie w ogóle nie wygląda jak
wzorzec.
Ta wersja, podobnie jak wzorce typów, powoduje dodanie nowej zmiennej. W odróż-
nieniu od wzorców typów tu kod niczego nie sprawdza. Wzorzec var zawsze zostaje
dopasowany, co skutkuje utworzeniem nowej zmiennej o tym samym typie z czasu
kompilacji co input i o tej samej wartości co input. Wzorzec var (inaczej niż wzorce
typów) zostaje dopasowany nawet wtedy, gdy input to referencja null.
Ponieważ wzorzec var zawsze zostaje dopasowany, używanie go z operatorem is
w instrukcji if w sposób pokazany dla innych wzorców można uznać za bezcelowe. Taki
wzorzec jest najbardziej przydatny w instrukcjach switch w połączeniu z klauzulą zabez-
pieczającą (opisaną w punkcie 12.6.1), choć czasem może okazać się użyteczny także
wtedy, gdy potrzebujesz instrukcji switch opartej na bardziej złożonym wyrażeniu
bez przypisywania go do zmiennej.
Na potrzeby przykładu zastosowania wzorca var bez klauzul zabezpieczających na
listingu 12.16 pokazana jest metoda Perimeter podobna do wersji z listingu 12.11.
Jednak tym razem, jeśli parametr shape ma wartość null, tworzona jest losowa figura.
Wzorzec var służy do określania typu figury, jeśli nie można obliczyć obwodu. Obec-
nie nie jest potrzebny wzorzec stałych z wartością null, ponieważ kod gwarantuje, że
w klauzulach instrukcji switch nigdy nie będzie sprawdzana referencja null.
87469504f326f0d7c1fcda56ef61bd79
8
414 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
W tym przykładzie inną możliwością jest tworzenie zmiennej actualShape przed instruk-
cją switch, sprawdzanie w klauzulach tej zmiennej, a następnie użycie klauzuli default
w pokazany wcześniej sposób.
To już wszystkie wzorce dostępne w C# 7.0. Poznałeś już oba konteksty, w jakich
można je stosować — w operatorze is i instrukcjach switch. Jednak o każdym z tych
scenariuszy można napisać coś więcej.
Nie odczuwałem tego jako problemu, jednak w C# 7.3 ograniczenie to zostało wyeli-
minowane.
Dochodzimy teraz do wzorców tworzących zmienne lokalne, z czym związane jest
oczywiste pytanie: jaki jest zasięg nowo tworzonych zmiennych? Podejrzewam, że
było to powodem wielu dyskusji w zespole rozwijającym C# i społeczności użytkow-
ników tego języka. Ostatecznie ustalono, że zasięgiem tworzonej zmiennej jest zewnętrzny
blok.
Jak można się spodziewać po temacie będącym przedmiotem gorących dyskusji,
podejście to ma wady i zalety. Jedną z rzeczy, których nigdy nie lubiłem we wzorcu as/if
z listingu 12.10, jest to, że w zasięgu powstaje wiele zmiennych, choć zwykle nie
chcesz ich używać poza warunkiem, w którym wartość pasuje do sprawdzanego typu.
Niestety, wzorce typów także działają w podobny sposób. Sytuacja nie jest jednak
identyczna, ponieważ w gałęziach, w których wzorzec nie został dopasowany, zmienna
nie ma przypisanej wartości.
Oto porównanie. Spójrz na następujący kod:
string text = input as string;
if (text != null)
{
Console.WriteLine(text);
}
87469504f326f0d7c1fcda56ef61bd79
8
12.5. Używanie wzorców razem z operatorem is 415
Zmienna text znajduje się w zasięgu i ma przypisaną wartość. Zbliżony kod ze wzorcem
typu wygląda tak:
if (input is string text)
{
Console.WriteLine(text);
}
Po tym fragmencie zmienna text znajduje się w zasięgu, ale nie ma przypisanej war-
tości. Choć taka zmienna zaśmieca przestrzeń deklaracji, może być przydatna, jeśli chcesz
udostępnić inny sposób tworzenia wartości. Oto przykład:
if (input is string text)
{
Console.WriteLine("Input jest już typu string; używany jest ten typ");
}
else if (input is StringBuilder builder)
{
Console.WriteLine("Input jest typu StringBuilder; używany jest ten typ");
text = builder.ToString();
}
else
{
Console.WriteLine(
$"Nie można użyć wartości typu ${input.GetType()}. Wprowadź tekst:");
text = Console.ReadLine();
}
Console.WriteLine($"Wynik końcowy: {text}");
Tu zmienna text powinna pozostać w zasięgu, ponieważ chcesz jej użyć. Wartość do tej
zmiennej jest przypisywana w jeden z dwóch sposobów. Po środkowym bloku zmienna
builder jest niepotrzebna w zasięgu, jednak nie da się uzyskać obu rzeczy jednocześnie.
Oto bardziej techniczny opis przypisania: po wyrażeniu is ze wzorcem, który powo-
duje dodanie zmiennej, ta zmienna (w terminologii ze specyfikacji języka) „ma przypi-
saną określoną wartość po wyrażeniu o wartości true”. Może to być istotne, jeśli chcesz,
aby warunek if robił coś więcej niż sprawdzanie typu. Załóżmy, że chcesz sprawdzać,
czy podana wartość to duża liczba całkowita. To poprawny kod:
if (input is int x && x > 100)
{
Console.WriteLine($"Input to duża liczba całkowita: {x}");
}
Można używać x po &&, ponieważ drugi operand jest przetwarzany tylko wtedy, gdy
pierwszy ma wartość true. Ponadto można używać x w instrukcji if, ponieważ ciało tej
instrukcji jest wykonywane tyko wtedy, gdy oba operandy operatora && są równe true.
Co jednak zrobić, jeśli chcesz obsługiwać zarówno wartości int, jak i long? Możesz spraw-
dzić wartość, jednak nie da się potem stwierdzić, który warunek został spełniony:
if ((input is int x && x > 100) || (input is long y && y > 100))
{
Console.WriteLine($"Input to duża liczba całkowita jakiegoś rodzaju");
}
87469504f326f0d7c1fcda56ef61bd79
8
416 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
Tu zarówno x, jak i y znajdują się w zasięgu w instrukcji if i po niej, choć część z dekla-
racją zmiennej y wygląda tak, jakby mogła nie być uruchamiana. Jednak zmienne mają
przypisaną określoną wartość tylko w bardzo krótkim fragmencie kodu, gdzie spraw-
dzasz, jak duże są wartości.
Wszystko to jest logiczne, może jednak okazać się nieco zaskakujące, gdy pierwszy
raz zetkniesz się z opisanym mechanizmem. Oto dwa wnioski z tego omówienia:
Zasięgiem zmiennej zadeklarowanej na podstawie wzorca w wyrażeniu is jest
zawierający ją blok.
Jeśli kompilator nie zezwala na użycie zmiennej utworzonej na podstawie wzorca,
oznacza to, że reguły języka nie umożliwiają udowodnienia, że do zmiennej
w danym miejscu przypisana będzie wartość.
UWAGA. Instrukcje switch oparte na wzorcach różnią się od dawnych instrukcji switch
obsługujących tylko stałe. Jeśli nie masz doświadczenia w korzystaniu z podobnego mecha-
nizmu z innych języków, przyzwyczajenie się do zmian może zająć Ci trochę czasu.
87469504f326f0d7c1fcda56ef61bd79
8
12.6. Używanie wzorców w instrukcjach switch 417
Wyrażenie musi tu mieć wartość logiczną2, podobnie jak warunek w instrukcji if. Ciało
klauzuli case jest wykonywane tylko wtedy, jeśli podane wyrażenie ma wartość true.
W wyrażeniu można używać dodatkowych wzorców i tworzyć w ten sposób nowe
zmienne.
Przyjrzyj się konkretnemu przykładowi, który dodatkowo ilustruje moją uwagę na
temat specyfikacji opartej na przypadkach. Rozważ poniższą definicję ciągu Fibonacciego:
fib(0) = 0
fib(1) = 1
fib(n) = fib(n-2) + fib(n-1) dla wszystkich n > 1
2
Dozwolona jest także wartość, która może zostać niejawnie przekształcona na wartość logiczną lub
wartość typu udostępniającego operator true. Są to te same wymogi co dla warunków w instrukcji if.
87469504f326f0d7c1fcda56ef61bd79
8
418 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
87469504f326f0d7c1fcda56ef61bd79
8
12.6. Używanie wzorców w instrukcjach switch 419
Listing 12.18. Używanie wielu etykiet case ze wzorcami dla jednego ciała
87469504f326f0d7c1fcda56ef61bd79
8
420 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
WSKAZÓWKA. Choć wiesz już, że kod powiązany z etykietą default jest — niezależnie od
jej lokalizacji — wykonywany tylko wtedy, gdy żadna z etykiet case nie pasuje do sprawdzanych
danych, to niektóre osoby czytające Twój kod mogą o tym nie wiedzieć. Ty sam możesz
zapomnieć o tym do czasu, gdy ponownie będziesz czytać własny kod. Jeśli umieścisz ety-
kietę default w końcowej części instrukcji switch, działanie kodu zawsze będzie jasne.
3
Nie jest to prawdą tylko wtedy, gdy w jednym ciele klauzuli case używana jest zmienna zadeklaro-
wana w ciele wcześniejszej klauzuli case. Prawie zawsze jest to jednak zły pomysł, a problem wynika
tylko ze wspólnego zasięgu takich zmiennych.
87469504f326f0d7c1fcda56ef61bd79
8
12.7. Przemyślenia na temat zastosowań opisanych mechanizmów 421
Kompilator potrafi to stwierdzić. Jeśli umieścisz tę klauzulę case wcześniej niż inne
etykiety case dla tego samego wzorca, kompilator zauważy, że zakrywasz te wcześniej-
sze etykiety, i zgłosi błąd.
Ciała kilku klauzul case można uruchomić w tylko jeden sposób — za pomocą
rzadko stosowanej instrukcji goto. Ta technika jest dozwolona także w instrukcjach switch
opartych na wzorcu, jednak w goto można używać tylko stałych, a docelowa etykieta
case musi być powiązaną z taką stałą i nie może mieć klauzuli zabezpieczającej. Nie
możesz np. użyć goto do przejścia do wzorca typu lub do wartości pod warunkiem, że
powiązana klauzula zabezpieczająca ma wartość true. W praktyce instrukcje goto są
używane w instrukcjach switch tak rzadko, że nie uważam opisanego ograniczenia za
problem.
Wcześniej celowo pisałem o logicznej kolejności przetwarzania. Choć kompilator
C# mógłby przekształcać każdą instrukcję switch na sekwencję instrukcji if/else, może
działać w wydajniejszy sposób. Na przykład jeśli istnieje kilka wzorców typów dotyczą-
cych tego samego typu, ale z różnymi klauzulami zabezpieczającymi, kod może spraw-
dzać wzorzec typu tylko raz, a następnie po kolei analizować każdą klauzulę zabezpie-
czającą. Podobnie dla stałych bez klauzul zabezpieczających (takie stałe muszą być
różne, tak jak w starszych wersjach C#) kompilator może użyć w kodzie pośrednim
instrukcji switch z wcześniejszym sprawdzaniem typu stałych. Omawianie optymali-
zacji wykonywanych przez kompilator wykracza poza zakres tej książki. Jeśli jednak
kiedyś zobaczysz kod pośredni powiązany z instrukcją switch i będzie on w tylko
niewielkim stopniu przypominał kod źródłowy, może to wynikać z wprowadzenia
optymalizacji.
87469504f326f0d7c1fcda56ef61bd79
8
422 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
(np. daty i czasu), a nawet wtedy, gdy obowiązują powszechnie przyjęte konwencje
(np. kolory mają składowe czerwoną, zieloną i niebieską — RGB, ang. red, green,
blue — z opcjonalnym kanałem alfa). Większość obiektów biznesowych nie należy
jednak do tych kategorii. Na przykład produkt w koszyku zakupów w sklepie inter-
netowym ma różne cechy, jednak ich kolejność nie jest oczywista.
Podejrzewam, że podział obiektów niebędących krotką zdarza się rzadziej. Jeśli jednak
używasz punktów, kolorów, wartości w postaci daty i czasu lub podobnych danych,
możesz stwierdzić, że warto szybko podzielić obiekt, jeżeli w przeciwnym razie musiał-
byś wielokrotnie pobierać komponenty za pomocą właściwości. Przed wersją C# 7
można to było zrobić, jednak łatwość deklarowania wielu zmiennych lokalnych za
pomocą podziału może decydować o tym, czy warto to robić.
Jeśli wielokrotnie używasz wzorca w postaci var … when (czyli jedyny warunek wystę-
puje w klauzuli zabezpieczającej), zastanów się, czy naprawdę korzystasz z dopasowy-
wania wzorców. Natrafiałem już na takie sytuacje, niemniej do tej pory i tak decydo-
wałem się na użycie dopasowywania wzorców. Nawet jeśli wydaje się to nie w pełni
właściwe, moim zdaniem można w ten sposób bardziej przejrzyście (niż za pomocą
sekwencji instrukcji if/else) wyrazić zamiar dopasowania danych na podstawie jednego
warunku i wykonania określonych działań.
Oba opisane scenariusze powodują przekształcenie istniejącej struktury kodu,
a zmiany dotyczą tylko szczegółów implementacji. Nie zmienia się wtedy sposób myśle-
nia o logice kodu i jego uporządkowaniu. Możliwość wprowadzenia bardziej rozbu-
dowanych modyfikacji — które też mogą dotyczyć refaktoryzacji w ramach interfejsu
API jednego typu lub publicznego interfejsu API podzespołu i polegać na zmianie
87469504f326f0d7c1fcda56ef61bd79
8
Podsumowanie 423
Podsumowanie
Podział pozwala rozbić wartości na kilka zmiennych za pomocą składni spójnej
dla krotek i innych obiektów.
Typy inne niż krotki są dzielone przy użyciu metody Deconstruct z parametrami
out. Może to być metoda rozszerzająca lub metoda instancji.
Jeśli kompilator może wywnioskować wszystkie typy, kilka zmiennych można
zadeklarować za pomocą podziału z użyciem jednego słowa var.
Dopasowywanie wzorców umożliwia sprawdzanie typu i wartości danych. Nie-
które wzorce pozwalają deklarować nowe zmienne.
Dopasowywanie wzorców można stosować razem z operatorem is i w instruk-
cjach switch.
Wzorce w instrukcji switch mogą mieć dodatkową klauzulę zabezpieczająca
podawaną za pomocą kontekstowego słowa kluczowego when.
Gdy instrukcja switch zawiera wzorce, kolejność etykiet case może zmieniać
działanie tej instrukcji.
87469504f326f0d7c1fcda56ef61bd79
8
424 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców
87469504f326f0d7c1fcda56ef61bd79
8
Zwiększanie wydajności
dzięki częstszemu
przekazywaniu danych
przez referencję Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Zawartość rozdziału:
Tworzenie aliasów zmiennych za pomocą słowa
kluczowego ref
Zwracanie zmiennych przez referencję za pomocą
instrukcji return ref
Wydajne przekazywanie argumentów w parametrach in
Zapobieganie modyfikowaniu danych za pomocą
modyfikatora ref readonly dla zwracanych wartości
i dla zmiennych lokalnych oraz modyfikatora readonly
dla struktur
Metody rozszerzające dla typów docelowych
z modyfikatorem in lub ref
Struktury referencyjne i typ Span<T>
Gdy pojawił się C# 7.0, znalazło się w nim kilka mechanizmów, które wydały mi się
dość dziwne. Były to referencyjne zmienne lokalne i referencyjne zwracane wartości.
Byłem sceptycznie nastawiony co do tego, ilu programistom będą potrzebne te techniki.
87469504f326f0d7c1fcda56ef61bd79
8
426 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
87469504f326f0d7c1fcda56ef61bd79
8
13.1. Przypomnienie — co wiesz o słowie kluczowym ref? 427
Ważne jest to, że gdy w procesie przypisania wartość jednej zmiennej jest kopiowana
do innej, skopiowana zostaje sama wartość. Obie kartki papieru pozostają niezależne,
a późniejsza zmiana jednej ze zmiennych nie wpływa na drugą. Jest to pokazane na
rysunku 13.2.
87469504f326f0d7c1fcda56ef61bd79
8
428 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Parametry ref działają inaczej. Ilustruje to rysunek 13.4. Zamiast tworzyć nową kartkę
papieru, parametr przekazywany przez referencję wymaga, aby jednostka wywołująca
przekazała istniejącą kartkę papieru zamiast samej wartości początkowej. Możesz
przyjąć, że powstaje kartka papieru z zapisanymi dwoma nazwami — jedną używaną
w wywołaniu i jedną w postaci nazwy parametru.
Jeśli metoda zmodyfikuje wartość parametru ref, a tym samym wartość zapisaną na
kartce, to po zwróceniu sterowania przez metodę ta zmiana będzie widoczna w jedno-
stce wywołującej, ponieważ została wprowadzona na pierwotnej kartce.
UWAGA. Są różne sposoby myślenia o parametrach i zmiennych ref. Niektórzy inni autorzy
traktują parametry ref jak zupełnie odrębne zmienne z automatycznie obsługiwaną warstwą
pośrednią, przez którą przechodzą wszystkie operacje dostępu do takich parametrów. Takie
podejście jest bardziej zbliżone do działania kodu pośredniego, jednak moim zdaniem jest
mniej pomocne.
87469504f326f0d7c1fcda56ef61bd79
8
13.2. Zmienne lokalne ref i referencyjne zwracane wartości 429
Nie ma wymogu, zgodnie z którym dla każdego parametru ref trzeba zastosować
odrębną kartkę papieru. Na listingu 13.1 pokazany jest skrajny przykład, który jednak
pozwoli Ci sprawdzić poziom zrozumienia tematu przed przejściem do referencyjnych
zmiennych lokalnych.
Listing 13.1. Używanie tej samej zmiennej dla wielu parametrów ref
Wynik to 12. Wszystkie nazwy (x, p1 i p2) reprezentują tę samą kartkę papieru. Począt-
kowo wartość na kartce to 5. Operacja p1++ zwiększa tę wartość do 6, a p2 *= 2 podwaja
ją do 12. Na rysunku 13.5 pokazana jest graficzna reprezentacja używanych zmiennych.
Typowy sposób myślenia o tej sytuacji zwią-
zany jest z aliasami. We wcześniejszym przykła-
dzie zmienne x, p1 i p2 są aliasami tej samej
lokalizacji w pamięci. Pozwalają w różny sposób
dotrzeć do tego samego fragmentu pamięci.
Przepraszam, jeśli to omówienie wydaje
się długie i nie wnosi niczego nowego. Teraz
jesteś gotów przejść do nowych mechanizmów
C# 7. Dzięki modelowi umysłowemu, w którym
zmienne są traktowane jak kartki papieru, znacz-
Rysunek 13.5. Dwa parametry ref nie łatwiej będzie Ci zrozumieć nowe funkcje
wskazujące tę samą kartkę papieru
języka.
87469504f326f0d7c1fcda56ef61bd79
8
430 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
int x = 10;
ref int y = ref x;
x++;
y++;
Console.WriteLine(x);
Ten kod wyświetla liczbę 12, tak jakbyś dwukrotnie zwiększył wartość x.
Do zainicjalizowania zmiennej lokalnej ref można wykorzystać dowolne wyrażenie
odpowiedniego typu (w tym elementy tablic), które jest traktowane jak zmienna. Jeśli
używasz tablicy dużych modyfikowalnych typów bezpośrednich, możesz uniknąć zbęd-
nych operacji kopiowania, gdy chcesz wprowadzić wiele zmian. Kod z listingu 13.3
tworzy tablicę krotek, a następnie bez kopiowania modyfikuje oba elementy każdego
elementu tablicy.
Przed wprowadzeniem zmiennych lokalnych ref przykładową tablicę można było zmo-
dyfikować na dwa sposoby. Jeden z nich wymagał wielu wyrażeń z dostępem do tablicy:
for (int i = 0; i < array.Length; i++)
{
array[i].x++;
array[i].y *= 2;
}
87469504f326f0d7c1fcda56ef61bd79
8
13.2. Zmienne lokalne ref i referencyjne zwracane wartości 431
Drugi sposób to skopiowanie całej krotki z tablicy, zmodyfikowanie tej krotki i sko-
piowanie jej z powrotem:
for (int i = 0; i < array.Length; i++)
{
var tuple = array[i];
tuple.x++;
tuple.y *= 2;
array[i] = tuple;
}
Żadna z tych technik nie jest specjalnie atrakcyjna. Podejście ze zmienną lokalną ref
pozwala zapisać cel, jakim jest używanie elementu tablicy jak zwykłej zmiennej w ciele
pętli.
Zmienne lokalne ref można też stosować razem z polami. Działanie tej techniki dla
pól statycznych jest przewidywalne, jednak pola instancji mogą Cię zaskoczyć. Przyjrzyj
się listingowi 13.4, gdzie kod przy użyciu zmiennej obj tworzy zmienną lokalną ref
będącą aliasem pola z jednej instancji, a następnie zmienia wartość zmiennej obj, wią-
żąc ją z inną instancją.
Listing 13.4. Tworzenie aliasu pola określonego obiektu z użyciem zmiennej lokalnej ref
class RefLocalField
{
private int value;
Zaskakiwać może środkowy wiersz. Jest on dowodem na to, że użycie zmiennej tmp nie
jest za każdym razem równoznaczne z wywołaniem obj.value. Zmienna tmp jest aliasem
pola obj.value w momencie inicjalizowania tej zmiennej. Na rysunku 13.6 pokazano
stan używanych zmiennych i obiektów w końcowej części metody Main.
87469504f326f0d7c1fcda56ef61bd79
8
432 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Efekt uboczny tego jest taki, że zmienna tmp chroni pierwszy obiekt przed usunięciem
przez mechanizm przywracania pamięci do czasu ostatniego użycia tej zmiennej
w metodzie. Podobnie użycie zmiennej lokalnej ref do elementu tablicy powoduje, że
tablica zawierająca ten element nie zostanie usunięta przez mechanizm przywracania
pamięci.
UWAGA. Zmienna ref wskazująca pole w obiekcie lub element tablicy utrudnia pracę
mechanizmu przywracania pamięci. Ten mechanizm musi ustalić, z jakim obiektem powiązana
jest ta zmienna, i zachować taki obiekt. Zwykłe referencje są prostsze, ponieważ bezpośred-
nio określają używany obiekt. Z kolei każda zmienna ref prowadząca do pola obiektu dodaje
wskaźnik wewnętrzny do struktury danych utrzymywany przez mechanizm przywracania pamięci.
Może to okazać się kosztowne, jeśli jednocześnie używanych jest wiele takich zmiennych.
Jednak zmienne ref mogą znajdować się tylko na stosie, dlatego jest mało prawdopodobne,
że będzie ich na tyle dużo, by spowodować problemy z wydajnością.
Nie można też sprawić, aby zmienna lokalna ref stała się aliasem innej zmiennej.
W modelu z kartką papieru oznacza to, że nie można wymazać nazwy takiej zmiennej
i zapisać jej na innej kartce. Oczywiście tę samą zmienną można w praktyce zadeklaro-
wać kilkakrotnie. Na przykład na listingu 13.3 zmienna element jest deklarowana w pętli:
87469504f326f0d7c1fcda56ef61bd79
8
13.2. Zmienne lokalne ref i referencyjne zwracane wartości 433
W każdej iteracji pętli zmienna element jest aliasem innego elementu tablicy. Jest to
jednak dozwolone, ponieważ w praktyce w każdej iteracji jest to nowa zmienna.
Zmienna używana do zainicjalizowania zmiennej lokalnej ref musi mieć przypisaną
określoną wartość. Mógłbyś się spodziewać, że obie zmienne będą miały ten sam stan,
jednak zamiast jeszcze bardziej komplikować reguły przypisywania wartości, projektanci
języka zadbali o to, aby zmienne lokalne ref zawsze miały przypisaną określoną wartość.
Oto przykład:
int x;
ref int y = ref x; Błąd, ponieważ x nie ma przypisanej określonej wartości.
x = 10;
Console.WriteLine(y);
Ten kod nie próbuje wczytywać zmiennej, dopóki nie zostanie do niej przypisana okre-
ślona wartość, jednak i tak jest nieprawidłowy.
W C# 7.3 zniesiono ograniczenie uniemożliwiające ponowne przypisywanie warto-
ści, jednak zmienne lokalne ref nadal trzeba inicjalizować w miejscu deklaracji, używając
zmiennej z przypisaną określoną wartością. Oto przykład:
int x = 10;
int y = 20;
ref int r = ref x;
r++;
r = ref y; Poprawne tylko w C# 7.3.
r++;
Console.WriteLine($"x={x}; y={y}"); Wyświetlanie x = 11, y = 21.
87469504f326f0d7c1fcda56ef61bd79
8
434 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
x++;
Inkrementowanie wartości obu zmiennych.
y++;
}
}
Gdyby ten kod był poprawny, cały rozwijany przez lata sposób myślenia o polach tylko
do odczytu wymagałby zmiany. Na szczęście jest inaczej. Kompilator uniemożliwia
przypisywanie wartości do zmiennej y w taki sam sposób, jak blokuje próby bezpo-
średniej modyfikacji pola readonlyField. Ten kod byłby jednak dozwolony w kon-
struktorze klasy MixedVariables, ponieważ tam można bezpośrednio zapisywać wartość
pola readonlyField. Oto krótkie podsumowanie — zmienną lokalną ref można inicja-
lizować tylko w taki sposób, aby była aliasem zmiennej, do której można przypisywać
wartość. Jest to zgodne z działaniem języka od wersji C# 1.0 w zakresie używania pól
jako argumentów na potrzeby parametrów ref.
Opisane ograniczenie może być frustrujące, jeśli chcesz wykorzystać aspekt współ-
dzielenia zmiennych lokalnych ref, ale bez potrzeby zapisu. W C# 7.0 stanowi to
problem, jednak — o czym przekonasz się w punkcie 13.2.4 — C# 7.2 zapewnia
rozwiązania.
TYPY — DOZWOLONE SĄ TYLKO KONWERSJE TOŻSAMOŚCIOWE
Typ zmiennej lokalnej ref albo musi być taki sam jak typ zmiennej używanej w inicjali-
zacji, albo możliwa musi być konwersja tożsamościowa między tymi typami. Inne kon-
wersje (nawet dozwolone w wielu innych sytuacjach konwersje referencyjne) nie
wystarczą. Na listingu 13.5 pokazany jest przykład deklaracji lokalnej zmiennej ref
z użyciem opartej na krotkach konwersji tożsamościowej, opisanej w rozdziale 11.
87469504f326f0d7c1fcda56ef61bd79
8
13.2. Zmienne lokalne ref i referencyjne zwracane wartości 435
Ten kod wyświetla 30, ponieważ tuple1 i tuple2 współdzielą miejsce na dane. Ele-
menty tuple1.x i tuple2.a oraz tuple1.y i tuple2.b są takie same.
W tym podrozdziale zapoznałeś się z inicjalizowaniem zmiennych lokalnych ref na
podstawie zmiennych lokalnych, pól i elementów tablic. W C# 7 do zmiennych zaliczany
jest też nowy rodzaj wyrażeń — zmienne zwracane przez metody z instrukcją return ref.
Ten kod wyświetli 11, ponieważ x i y znajdują się na tej samej kartce papieru, tak
jakbyś użył następującego zapisu:
ref int y = ref x;
87469504f326f0d7c1fcda56ef61bd79
8
436 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Oprócz ograniczeń dotyczących tego, co można, a czego nie można zwracać, należy też
pamiętać, że instrukcja return ref jest niedozwolona w metodach asynchronicznych
i blokach iteratorów. Modyfikatora ref nie można używać dla argumentów określających
typ, choć jest on dozwolony w interfejsach i deklaracjach delegatów. Na przykład
poniższy kod jest w pełni poprawny:
delegate ref int RefFuncInt32();
Nie można jednak uzyskać tego samego efektu przy użyciu deklaracji Func<ref int>.
W instrukcji return ref nie trzeba używać zmiennych lokalnych ref. Jeśli chcesz wyko-
nać jedną operację na wyniku, możesz to zrobić bezpośrednio. Na listingu 13.7 poka-
zany jest ten sam kod co na listingu 13.6, ale bez używania zmiennej lokalnej ref.
87469504f326f0d7c1fcda56ef61bd79
8
13.2. Zmienne lokalne ref i referencyjne zwracane wartości 437
class ArrayHolder
{
private readonly int[] array = new int[10];
public ref int this[int index] => ref array[index]; Indekser zwraca element tablicy
} przez referencję.
87469504f326f0d7c1fcda56ef61bd79
8
438 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Zastosowanie tu krotki nie ma większego znaczenia, choć pozwala pokazać, jak przy-
datna jest możliwość modyfikowania krotek. Wprowadzona zmiana zwiększa spójność
języka. Wynik operatora warunkowego można wykorzystać jako argument odpowiadający
parametrowi ref, przypisać do zmiennej lokalnej ref lub wykorzystać w instrukcji return
ref. Wszystko dobrze pasuje do siebie. Następny mechanizm z C# 7.2 dotyczy problemu
opisanego w punkcie 13.2.1 w kontekście ograniczeń zmiennych lokalnych ref. Jak
uzyskać referencję do zmiennej tylko do odczytu?
WSKAZÓWKA. Ponieważ jednym z celów używania modyfikatora ref readonly jest unik-
nięcie kopiowania, możesz być zaskoczony informacją, że czasem efekt jest wprost odwrotny.
Szczegóły poznasz w podrozdziale 13.4. Nie zaczynaj stosować modyfikatora ref readonly
w kodzie produkcyjnym bez wcześniejszej lektury tego podrozdziału!
Dwa miejsca, w których można umieścić ten modyfikator, są ze sobą powiązane. Jeśli
wywołujesz metodę lub indekser zwracający wartość z modyfikatorem ref readonly
i chcesz zapisać wynik w zmiennej lokalnej, także ta zmienna lokalna musi być opatrzona
tym modyfikatorem. Na listingu 13.10 pokazane jest, jak elementy tylko do odczytu są
łączone w łańcuch.
87469504f326f0d7c1fcda56ef61bd79
8
13.2. Zmienne lokalne ref i referencyjne zwracane wartości 439
Listing 13.11. Widok tablicy przeznaczony tylko do odczytu; odczyt nie wymaga tu
kopiowania
class ReadOnlyArrayView<T>
{
private readonly T[] values;
Ten przykład nie pozwala uzyskać istotnej poprawy wydajności, ponieważ int i tak jest
małym typem. Jednak w scenariuszach, gdy używane są większe struktury, ta technika
pozwala uniknąć zbyt częstych alokacji pamięci na stercie i operacji przywracania
pamięci oraz może zapewnić znaczące korzyści.
87469504f326f0d7c1fcda56ef61bd79
8
440 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Szczegóły implementacji
W języku pośrednim metody ref readonly są implementowane jako zwykłe metody zwra-
cające referencje (gdzie wartość jest zwracana przez referencję), ale z atrybutem [In
Attribute] z przestrzeni nazw System.Runtime.InteropServices. W kodzie pośrednim temu
atrybutowi odpowiada modyfikator modreq. Jeśli kompilator nie zna atrybutu InAttribute,
powinien odrzucać wszelkie wywołania takiej metody. Jest to mechanizm zabezpieczający,
który ma zapobiegać niewłaściwemu używaniu wartości zwracanej przez metodę. Wyobraź
sobie kompilator języka C# 7.0 (zna on instrukcje return ref, ale nie rozumie instrukcji
return zwracających wartości z modyfikatorem ref readonly), który próbuje wywołać metodę
z innego podzespołu zwracającą wartość z modyfikatorem ref readonly. Jednostka wywo-
łująca mogłaby wtedy zapisać wynik w zmiennej lokalnej ref umożliwiającej zapis, a następ-
nie zmodyfikować wartość niezgodnie z celem użycia instrukcji return zwracającej wartość
z modyfikatorem ref readonly.
Nie można zadeklarować metody zwracającej wartość z modyfikatorem ref readonly, jeśli
kompilator nie ma dostępu do atrybutu InAttribute. Rzadko stanowi to problem, ponie-
waż atrybut ten jest dostępny w platformie .NET od wersji 1.1 i od specyfikacji .NET Stan-
dard 1.1. Jeżeli jest to bezwzględnie konieczne, możesz też zadeklarować własny atrybut
w odpowiedniej przestrzeni nazw, a kompilator go użyje.
87469504f326f0d7c1fcda56ef61bd79
8
13.3. Parametry in (C# 7.2) 441
Console.WriteLine(text);
}
87469504f326f0d7c1fcda56ef61bd79
8
442 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Jeśli metoda jest dostępna dla jednostek wywołujących, nad którymi nie masz
kontroli (np. jeśli publikujesz bibliotekę za pomocą menedżera NuGet), zmiana
narusza zgodność z istniejącym kodem. Dlatego sytuację trzeba traktować jak
każdą inną zmianę naruszającą zgodność.
Jeżeli kod jest dostępny tylko dla jednostek wywołujących, które z pewnością
zostaną ponownie skompilowane (nawet jeśli nie możesz modyfikować kodu
wywołań), gdy będą używać nowej wersji Twojego podzespołu, działanie tych
jednostek wywołujących nie zostanie naruszone.
Jeśli metoda jest wewnętrzna względem podzespołu1, nie musisz przejmować się
zgodnością plików binarnych, ponieważ wszystkie jednostki wywołujące i tak
zostaną ponownie skompilowane.
Istnieje też inny, rzadziej spotykany scenariusz. Jeśli używasz metody z parametrem ref
tylko po to, aby uniknąć kopiowania (nigdy nie modyfikujesz tego parametru w meto-
dzie), przekształcenie go na parametr in zawsze jest kompatybilne ze względu na pliki
binarne, ale nigdy nie jest kompatybilne ze względu na kod źródłowy. Jest to odwrotna
sytuacja niż przy zmianie parametru przekazywanego przez wartość na parametr in.
W każdej sytuacji zakładam, że zastosowanie parametru in nie narusza semantyki
metody. Nie zawsze jest to jednak słuszne założenie. Zobacz, z czego to wynika.
1
Jeśli w podzespole używany jest atrybut InternalsVisibleTo, sytuacja jest bardziej złożona. Oma-
wianie szczegółów na tym poziomie wykracza poza zakres tej książki.
87469504f326f0d7c1fcda56ef61bd79
8
13.3. Parametry in (C# 7.2) 443
action();
Console.WriteLine($"p = {p}");
}
int y = 10;
ValueParameter(y, () => y++);
}
2
Lubię myśleć, że jest to podobne do zjawiska stanu splątanego nazywanego „oddziaływaniem na
odległość” (ang. spooky action at a distance).
87469504f326f0d7c1fcda56ef61bd79
8
444 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Teraz reguły wyboru wersji metody sprawią, że jeśli argument nie ma modyfikatora in,
wywołana zostanie metoda z parametrem przekazywanym przez wartość:
int x = 5;
Method(5); Wywołanie pierwszej metody.
Method(x); Wywołanie pierwszej metody.
Method(in x); Wywołanie drugiej metody z powodu modyfikatora in.
87469504f326f0d7c1fcda56ef61bd79
8
13.3. Parametry in (C# 7.2) 445
private static double GetScale(in LargeStruct input) => Inna metoda z parametrem in.
input.Weight * input.Score;
87469504f326f0d7c1fcda56ef61bd79
8
446 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Liczne z tych zaleceń mogłyby być łatwo sprawdzane przez analizator Roslyn. Choć
nie znam takiego narzędzia, nie byłbym zaskoczony, gdyby pojawił się pakiet NuGet
działający w ten sposób.
UWAGA. Jeśli odbierasz to jako pośrednio rzucone wyzwanie, masz rację. Daj mi znać, jeśli
znasz tego rodzaju analizator. Dodam wtedy informacje na jego temat w witrynie.
87469504f326f0d7c1fcda56ef61bd79
8
13.4. Deklarowanie struktur tylko do odczytu (C# 7.2) 447
Teraz utwórz klasę z dwoma polami YearMonthDay. Jedno pole jest tylko do odczytu,
a drugie — do odczytu i zapisu. Następnie użyj właściwości Year z obu pól. Ilustruje to
listing 13.16.
Listing 13.16. Dostęp do właściwości za pomocą pól tylko do odczytu oraz do odczytu
i zapisu
class ImplicitFieldCopy
{
private readonly YearMonthDay readOnlyField =
new YearMonthDay(2018, 3, 1);
private YearMonthDay readWriteField =
new YearMonthDay(2018, 3, 1);
Ten kod wczytuje wartość pola, kopiując ją w ten sposób na stos. Dopiero potem
wywoływana jest składowa get_Year(), czyli getter właściwości Year. Porównaj to
z kodem generowanym dla pola do odczytu i zapisu:
ldflda valuetype YearMonthDay ImplicitFieldCopy::readWriteField
call instance int32 YearMonthDay::get_Year()
Tu używana jest instrukcja ldflda, aby wczytać adres pola na stos (zamiast wczytywania
wartości pola za pomocą instrukcji ldfld). Jest to tylko kod pośredni, który nie jest
bezpośrednio wykonywany przez komputer. Całkiem możliwe, że w niektórych sce-
nariuszach kompilator JIT potrafi zoptymalizować ten kod. Jednak zauważyłem, że
w bibliotece Noda Time używanie pól do odczytu i zapisu (z atrybutem wyjaśniającym,
dlaczego nie są przeznaczone tylko do odczytu) pozwoliło znacznie poprawić wydajność.
87469504f326f0d7c1fcda56ef61bd79
8
448 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Kompilator kopiuje wartość, aby uniknąć modyfikowania pola tylko do odczytu przez
kod we właściwości (lub w metodzie, jeśli jest wywoływana). Pola tylko do odczytu mają
uniemożliwiać zmianę ich wartości. Byłoby dziwne, gdyby metoda readOnlyField.
SomeMethod() mogła zmodyfikować pole. C# oczekuje, że każdy setter właściwości
modyfikuje dane. Dlatego w polach tylko do odczytu settery są niedozwolone. Jednak
nawet getter właściwości może próbować zmodyfikować wartość. Tworzenie kopii jest
więc zabezpieczeniem.
Do wersji C# 7.2 wyłącznie pola mogły być tylko do odczytu. Obecnie trzeba uwzględ-
nić także zmienne lokalne ref readonly i parametry in. Napiszmy teraz metodę, która
wyświetla rok, miesiąc i dzień na podstawie parametru przekazywanego przez wartość:
private void PrintYearMonthDay(YearMonthDay input) =>
Console.WriteLine($"{input.Year} {input.Month} {input.Day}");
W kodzie pośrednim używany jest adres wartości znajdującej się już na stosie. Każdy
dostęp do właściwości jest prosty:
ldarga.s input
call instance int32 Chapter13.YearMonthDay::get_Year()
Nie powoduje to tworzenia dodatkowych kopii. Założenie jest takie, że jeśli właściwość
modyfikuje wartość, dozwolona jest modyfikacja zmiennej input. W końcu jest to
zmienna do odczytu i zapisu. Jeżeli jednak zastosujesz dla zmiennej input modyfikator
in, sytuacja będzie wyglądać inaczej:
private void PrintYearMonthDay(in YearMonthDay input) =>
Console.WriteLine($"{input.Year} {input.Month} {input.Day}");
Teraz w kodzie pośrednim metody dla każdego dostępu do właściwości generowany jest
następujący kod:
ldarg.1
ldobj Chapter13.YearMonthDay
stloc.0
ldloca.s V_0
call instance int32 YearMonthDay::get_Year()
87469504f326f0d7c1fcda56ef61bd79
8
13.4. Deklarowanie struktur tylko do odczytu (C# 7.2) 449
Po tej prostej zmianie w deklaracji i bez modyfikowania kodu struktury kod pośredni
generowany dla metody PrintYearMonthDay(in YearMonthDay input) stanie się wydajniejszy.
Każdy dostęp do właściwości wygląda teraz tak:
ldarg.1
call instance int32 YearMonthDay::get_Year()
87469504f326f0d7c1fcda56ef61bd79
8
450 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Jeśli już wcześniej zamierzałeś utworzyć strukturę tylko do odczytu, dodanie modyfi-
katora readonly pozwala kompilatorowi pomóc Ci w sprawdzeniu, czy nie naruszasz
tego zamiaru. Niestety, w Noda Time związany jest z tym drobny problem, który może
dotknąć także Ciebie.
Czy dostrzegasz problem? W ostatnim wierszu kod przypisuje wartość do this. Dlatego
nie mogę zadeklarować struktur z użyciem modyfikatora readonly, co mi się nie podoba.
Obecnie mam trzy możliwości:
Pozostawić struktury w obecnej postaci, co oznacza, że parametry in i zmienne
lokalne ref readonly będą niewydajne.
Usunąć serializację danych XML-owych z następnej wersji biblioteki Noda Time.
Użyć niezabezpieczonego kodu w typie ReadXml i naruszyć w nim modyfikator
readonly. Pakiet System.Runtime.CompilerServices.Unsafe upraszcza to rozwiązanie.
Żadne z tych podejść nie jest atrakcyjne. Nie istnieje też sztuczka, którą mógłbym
pokazać jako sprytne rozwiązanie wszystkich problemów. Uważam, że obecnie struktury
z implementacją interfejsu IXmlSerializable nie mogą być naprawdę przeznaczone
tylko do odczytu. Bez wątpienia istnieją podobne modyfikowalne interfejsy, które
możesz chcieć zaimplementować w strukturze. Podejrzewam jednak, że problem
najczęściej będzie dotyczył właśnie interfejsu IXmlSerializable.
Dobra wiadomość jest taka, że większość czytelników zapewne nie musi mierzyć
się z tym problemem. Jeśli możesz sprawić, aby struktura definiowana przez użyt-
kownika rzeczywiście była przeznaczona tylko do odczytu, zachęcam do zastosowania
omawianego modyfikatora. Pamiętaj tylko, że w kodzie publicznym taka zmiana jest
możliwa w tylko jedną stronę. Modyfikator będziesz mógł bezpiecznie usunąć tylko
w tej dogodnej sytuacji, jeśli możesz ponownie skompilować cały kod używający danej
struktury. Następny dodatek ma zapewniać spójność w języku i udostępniać w meto-
dach rozszerzających te same mechanizmy, które już dostępne są w metodach instancji
w strukturach.
87469504f326f0d7c1fcda56ef61bd79
8
13.5. Metody rozszerzające z parametrami ref i in (C# 7.2) 451
Jeśli napiszesz własną metodę, która przyjmuje tę strukturę w parametrze in, kod
będzie działał poprawnie. Możesz uniknąć kopiowania danych, ale wywołania mogą
wyglądać trochę dziwnie. Na przykład konieczne mogą być wywołania o następującej
postaci:
double magnitude = VectorUtilities.Magnitude(vector);
Wygląda to bardzo źle. Można zastosować metodę rozszerzającą, jednak zwykła metoda
rozszerzająca, taka jak poniższa, będzie kopiować wektor w każdym wywołaniu:
public static double Magnitude(this Vector3D vector)
87469504f326f0d7c1fcda56ef61bd79
8
452 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
powinieneś zastosować parametr in. Możesz też użyć modyfikatora ref, jeśli chcesz
mieć możliwość modyfikowania wartości w pierwotnej lokalizacji bez konieczności
tworzenia nowej wartości i kopiowania jej. Na listingu 13.18 pokazane są dwie przy-
kładowe metody rozszerzające dla typu Vector3D.
public static void OffsetBy(this ref Vector3D orig, in Vector3D off) =>
orig = new Vector3D(orig.X + off.X, orig.Y + off.Y, orig.Z + off.Z);
Nazwy parametrów skróciłem tu bardziej, niż mam w zwyczaju to robić. Celem jest
uniknięcie zbyt długich wierszy w książce. Warto zauważyć, że drugi parametr
w metodzie OffsetBy to parametr in, aby w miarę możliwości uniknąć kopiowania.
Używanie tych metod rozszerzających jest proste. Jedynym aspektem, który może
zaskakiwać, jest to, że — inaczej niż w przypadku zwykłych parametrów ref —
w wywołaniach tych metod nie widać modyfikatora ref. Na listingu 13.19 używane są
obie te metody, a kod tworzy dwa wektory, dodaje drugi wektor do pierwszego, a następ-
nie wyświetla wynikowy wektor i jego długość.
vector.OffsetBy(offset);
87469504f326f0d7c1fcda56ef61bd79
8
13.5. Metody rozszerzające z parametrami ref i in (C# 7.2) 453
ref readonly var alias = ref vector; Błąd — próba użycia zmiennej tylko do odczytu
alias.OffsetBy(offset); jako parametru ref.
Zwróć uwagę na różnice między modyfikatorami in i ref. Jako parametr ref można
podać parametr określający typ, przy czym musi mieć on ograniczenie struct. Metoda
rozszerzająca z modyfikatorem in też może być generyczna (czego dowodzi ostatni
poprawny przykład), jednak rozszerzanym typem nie może być parametr określający
typ. Obecnie nie istnieje ograniczenie, które pozwala zażądać, by typ T był strukturą
tylko do odczytu (readonly struct), co byłoby konieczne, aby generyczny parametr in
był użyteczny. W przyszłych wersjach C# może się to zmienić.
Może się zastanawiasz, dlaczego rozszerzany tym musi być typem bezpośrednim.
Wynika to z dwóch podstawowych przyczyn:
Omawiany mechanizm ma służyć unikaniu kosztownego kopiowania typów
bezpośrednich, dlatego nie przynosi korzyści dla typów referencyjnych.
Gdyby parametr ref mógł być typu referencyjnego, w metodzie nożna byłoby
przypisać do niego referencję null. To byłoby niezgodne z założeniem, jakie
87469504f326f0d7c1fcda56ef61bd79
8
454 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Nie sądzę, aby metody rozszerzające z parametrami ref i in były powszechnie używane,
zapewniają one jednak atrakcyjną spójność w języku.
Mechanizmy, jakie pozostały do omówienia w tym rozdziale, różnią się od tych już
opisanych. W ramach podsumowania poniżej przedstawiona jest lista już poruszonych
kwestii:
zmienne lokalne ref,
instrukcje return ref,
zmienne lokalne ref i zwracane wartości ref tylko do odczytu,
parametry in (jest to przeznaczona tylko do odczytu wersja parametrów ref),
struktury tylko do odczytu, umożliwiające unikanie kopiowania dzięki parame-
trom in oraz zmiennym lokalnym i zwracanym wartościom z modyfikatorem ref
readonly,
metody rozszerzające z parametrami ref i in dla typu docelowego.
Jeśli zacząłeś od parametrów ref i zastanawiałeś się, jak rozwinąć ten mechanizm,
możliwe, że uzyskałeś podobną listę. Teraz przejdziemy do struktur referencyjnych.
Są one powiązane z wszystkimi opisanymi zagadnieniami, ale wyglądają jak typ zupeł-
nie nowego rodzaju.
87469504f326f0d7c1fcda56ef61bd79
8
13.6. Struktury referencyjne (C# 7.2) 455
Nie można używać RefLikeStruct jako typu pola w żadnym typie, który także nie
jest strukturą referencyjną. Nawet zwykłe struktury mogą znaleźć się na stercie
w wyniku opakowywania typów lub jako pole w klasie. Ponadto także w innych
strukturach referencyjnych typ RefLikeStruct można stosować tylko jako typ
pola instancji (a nigdy nie można używać go jako typu pola statycznego).
Nie można opakowywać wartości typu RefLikeStruct w obiekt. Opakowywanie
służy do tworzenia obiektów na stercie, a tego właśnie trzeba uniknąć.
Nie można używać RefLikeStruct jako argumentu określającego typ (ani jawnie,
ani w wyniku wnioskowania typów) dla żadnej metody generycznej ani dla żad-
nego typu generycznego. Dotyczy to także argumentów określających typ
w generycznych strukturach referencyjnych. W kodzie generycznym argumenty
określające typ mogą być używane na wiele sposobów skutkujących umieszcze-
niem wartości na stercie — np. w wyniku utworzenia kolekcji typu List<T>.
Nie można użyć RefLikeStruct[] ani żadnego podobnego typu tablicowego jako
operandu operatora typeof.
Zmienne lokalne typu RefLikeStruct nie mogą być używane nigdzie tam, gdzie
kompilator musiałby je przechwytywać na stercie w specjalnym wygenerowanym
typie. Obejmuje to następujące miejsca:
Metody asynchroniczne, choć to ograniczenie można złagodzić w taki sposób,
aby móc zadeklarować zmienną i używać jej między wyrażeniami await,
o ile nigdy nie jest używana w zasięgu zewnętrznym względem takich wyrażeń
(gdzie zmienna jest zadeklarowana przed wyrażeniem await, ale używana
po nim). Parametry metod asynchronicznych też nie mogą być typu struktury
referencyjnej.
Bloki iteratorów, w których już przestrzegana jest reguła „dozwolone jest
używanie zmiennej typu RefLikeStruct między dwoma wyrażeniami yield”.
Parametrami w blokach iteratorów nie mogą być typy struktur referencyjne.
Dowolna zmienna lokalna przechwytywana przez metody lokalne, wyrażenia
reprezentujące zapytania LINQ, metody anonimowe lub wyrażenia lambda.
3
Skomplikowane oznacza tu tyle, że trudno mi je zrozumieć. Rozumiem ogólne przeznaczenie takich
zmiennych, jednak poziom złożoności związany z zapobieganiem problemom wykracza poza moje
obecne chęci analizowania reguł wiersz po wierszu.
87469504f326f0d7c1fcda56ef61bd79
8
456 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
się już ona na stosie. Po zapoznaniu się z regułami dotyczącymi utrzymywania wartości
na stosie możesz wreszcie przejść do modelowego reprezentanta struktur referencyj-
nych — do typu Span<T>.
UWAGA. Niektóre zastosowania typu Span<T> wymagają dodania referencji do pakietu NuGet
System.Memory. W innych sytuacjach niezbędne jest wsparcie ze strony platformy. Kod pre-
zentowany w tym podrozdziale został skompilowany z użyciem platformy .NET Core 2.1. Nie-
które listingi można skompilować także w starszych wersjach tej platformy.
87469504f326f0d7c1fcda56ef61bd79
8
13.6. Struktury referencyjne (C# 7.2) 457
Oto przykładowe wywołanie tej metody generujące łańcuch znaków składający się z 10
małych liter:
string alphabet = "abcdefghijklmnopqrstuvwxyz";
Random random = new Random();
Console.WriteLine(Generate(alphabet, random, 10));
Kod z listingu 13.20 dwukrotnie alokuje pamięć na stercie — raz dla tablicy elementów
typu char i raz dla łańcucha znaków. W momencie tworzenia łańcucha znaków dane
trzeba skopiować z jednego miejsca w drugie. Możesz nieco usprawnić rozwiązanie,
jeśli wiesz, że zawsze będziesz generować stosunkowo krótkie łańcuchy znaków i że
możesz zastosować niezabezpieczony kod. W takiej sytuacji możesz użyć operatora
stackalloc, co pokazane jest na listingu 13.21.
Ten kod przeprowadza na stercie tylko jedną alokację — łańcucha znaków. Tymczasowy
bufor jest alokowany na stosie, trzeba jednak użyć modyfikatora unsafe, ponieważ
stosowany jest wskaźnik. Niezabezpieczony kod jest dla mnie wyjściem poza strefę
komfortu. Choć jestem prawie pewien, że ten kod jest poprawny, nie chciałbym uży-
wać wskaźników do wykonywania żadnych dużo bardziej złożonych zadań. Ponadto
to rozwiązanie i tak wymaga kopiowania danych z zaalokowanego na stosie bufora do
łańcucha znaków.
Dobra wiadomość jest taka, że typ Span<T> współdziała z operatorem stackalloc,
a nie wymaga przy tym stosowania modyfikatora unsafe. Ilustruje to listing 13.22.
Modyfikator unsafe nie jest konieczny, ponieważ to reguły dotyczące struktur referen-
cyjnych mają zapewniać bezpieczeństwo.
87469504f326f0d7c1fcda56ef61bd79
8
458 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Jestem bardziej przekonany co do bezpieczeństwa tego kodu, jednak nie jest on wydaj-
niejszy. Dane nadal są kopiowane w sposób, który wydaje się zbędny. Można utworzyć
lepsze rozwiązanie. Wystarczy do tego metoda fabryczna z klasy System.String:
public static string Create<TState>(
int length, TState state, SpanAction<char, TState> action)
Używany jest tu typ SpanAction<T, TArg>. Jest to nowy typ delegata o następującej
sygnaturze:
delegate void SpanAction<T, in TArg>(Span<T> span, TArg arg);
Pierwszą rzeczą, na jaką warto zwrócić uwagę, jest to, że delegat potrafi zapisać dane
w łańcuchu znaków. Wydaje się to sprzeczne z wiedzą na temat niemodyfikowalności
łańcuchów znaków. Jednak to metoda Create kontroluje tu dane. Tak, możesz zapisać
w łańcuchu znaków dowolne dane, podobnie jak możesz utworzyć nowy łańcuch
znaków o dowolnej zawartości. Jednak do czasu zwrócenia tego łańcucha znaków dane
będą w nim trwale zapisane. Nie możesz próbować oszukiwać, zachowując przekazany
do delegata obiekt typu Span<char>, ponieważ kompilator dba o to, aby ten obiekt nie
opuścił stosu.
Nadal jednak do wyjaśnienia pozostaje dziwne zastosowanie stanu. Dlaczego musisz
przekazywać stan, który jest następnie przekazywany z powrotem do delegata? Naj-
łatwiej jest objaśnić to na przykładzie. Na listingu 13.23 metoda Create służy do zaim-
plementowania generatora losowych łańcuchów znaków.
87469504f326f0d7c1fcda56ef61bd79
8
13.6. Struktury referencyjne (C# 7.2) 459
Początkowo wydaje się, że kod zawiera wiele zbędnych powtórzeń. Drugi argument
metody string.Create to (alphabet, random). Powoduje on zapisanie parametrów alphabet
i random w krotce, aby działały jak stan. Następnie wartości te są wypakowywane z krotki
w wyrażeniu lambda:
var alphabet2 = state.alphabet;
var random2 = state.random;
87469504f326f0d7c1fcda56ef61bd79
8
460 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
Nie jest to rozwiązanie stosowane przez wielu programistów — nawet w tej niewielkiej
grupie osób regularnie posługujących się niezabezpieczonym kodem. Jak możesz ocze-
kiwać, typy, które najczęściej są używane razem z tą techniką, to Span<T> i ReadOnlySpan<T>,
ponieważ mogą współdziałać z kodem, w którym już używane są wskaźniki.
87469504f326f0d7c1fcda56ef61bd79
8
Podsumowanie 461
Podsumowanie
W C# 7 w wielu obszarach języka dodano obsługę semantyki przekazywania
przez referencję.
W C# 7.0 wprowadzono tylko kilka pierwszych mechanizmów. Jeśli interesuje
Cię pełen zestaw, użyj wersji C# 7.3.
Mechanizmy związane z referencjami mają przede wszystkim poprawiać wydaj-
ność. Jeśli nie piszesz kodu, w którym wydajność ma duże znaczenie, możliwe,
że liczne z tych rozwiązań nie będą Ci potrzebne.
Struktury referencyjne umożliwiają dodanie do platformy nowych abstrakcji,
w tym typu Span<T>. Te abstrakcje są przydatne nie tylko w scenariuszach, gdy
potrzebna jest wysoka wydajność. W przyszłości prawdopodobnie będą ważne
dla dużej grupy programistów używających platformy .NET.
87469504f326f0d7c1fcda56ef61bd79
8
462 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych
87469504f326f0d7c1fcda56ef61bd79
8
Zwięzły kod w C# 7
Zawartość rozdziału
Deklarowanie metod w metodach
Upraszczanie wywołań z użyciem parametrów out
Bardziej czytelne zapisywanie literałów liczbowych
Używanie throw jako wyrażenia
Używanie literału default
87469504f326f0d7c1fcda56ef61bd79
8
464 ROZDZIAŁ 14. Zwięzły kod w C# 7
void PrintAndIncrementX()
{
Console.WriteLine($"x = {x}"); Metoda lokalna.
x++;
}
}
Gdy zobaczysz taki kod po raz pierwszy, może on wyglądać dość dziwnie. Szybko jed-
nak przyzwyczaisz się do tego rozwiązania. Metody lokalne mogą występować w dowol-
nym miejscu w bloku instrukcji: w metodach, konstruktorach, właściwościach, indek-
serach, akcesorach zdarzeń, finalizatorach, a nawet funkcjach anonimowych i innych
metodach lokalnych.
Deklaracja metody lokalnej wygląda podobnie jak deklaracja zwykłej metody, przy
czym trzeba uwzględnić następujące ograniczenia:
Metoda lokalna nie może mieć modyfikatorów dostępu (public itd.).
Metoda lokalna nie może mieć modyfikatorów extern, virtual, new, override,
static i abstract.
Metoda lokalna nie może mieć atrybutów (np. [MethodImpl]).
Metoda lokalna nie może mieć tej samej nazwy co inna metoda lokalna w tej
samej jednostce nadrzędnej. Nie istnieje sposób na przeciążanie metod lokalnych.
Pod innymi względami metody lokalne działają tak jak zwykłe metody. Dotyczy to np.
następujących kwestii:
Może być metodą void lub zwracać wartość.
Może mieć modyfikator async.
Może mieć modyfikator unsafe.
Może być implementowana w bloku iteratora.
Może mieć parametry, także opcjonalne.
Może być generyczna.
Może używać parametrów jednostki nadrzędnej.
Może być używana w konwersji grupy metod na typ delegata.
Na listingu 14.1 pokazane jest, że można zadeklarować taką metodę po miejscu jej
wywołania. Metody lokalne mogą wywoływać same siebie, a także inne metody lokalne
dostępne w zasięgu. Miejsce deklaracji może jednak mieć znaczenie — przede wszyst-
kim w związku z używaniem w metodach lokalnych zmiennych przechwytywanych,
czyli zmiennych lokalnych zadeklarowanych w zewnętrznym kodzie, ale używanych
w metodzie lokalnej.
87469504f326f0d7c1fcda56ef61bd79
8
14.1. Metody lokalne 465
Jednak gdy metoda lokalna znajduje się w pętli, kod jest poprawny1:
static void Valid()
{
for (int i = 0; i < 10; i++)
{
PrintI();
Metoda lokalna jest zadeklarowana w pętli,
void PrintI() => Console.WriteLine(i); dlatego zmienna i znajduje się w zasięgu.
}
}
1
Ten kod może wyglądać dziwnie, ale jest prawidłowy.
87469504f326f0d7c1fcda56ef61bd79
8
466 ROZDZIAŁ 14. Zwięzły kod w C# 7
87469504f326f0d7c1fcda56ef61bd79
8
14.1. Metody lokalne 467
Warto zauważyć, że powodem błędu jest miejsce wywołania metody PrintI. Lokalizacja
deklaracji metody jest poprawna. Jeśli przeniesiesz przypisanie wartości do zmiennej
i przed wywołania PrintI(), kod będzie prawidłowy, nawet jeżeli przypisanie znajduje
się po deklaracji PrintI().
Po drugie, jeśli metoda lokalna zapisuje wartość przechwytywanej zmiennej we
wszystkich możliwych ścieżkach wykonania, ta zmienna będzie miała przypisaną okre-
śloną wartość po każdym wywołaniu tej metody. Oto przykładowy kod, który przypisuje
wartość zmiennej w metodzie lokalnej, a następnie wczytuje tę wartość w metodzie
nadrzędnej:
static void DefinitelyAssignInMethod()
{ Wywołanie metody sprawia,
int i; że zmienna i ma przypisaną
AssignI(); określoną wartość.
Console.WriteLine(i); Można więc wyświetlić tę wartość.
void AssignI() => i = 10; Przypisanie wartości przez metodę.
}
87469504f326f0d7c1fcda56ef61bd79
8
468 ROZDZIAŁ 14. Zwięzły kod w C# 7
class Demo
{
private readonly int value;
public Demo()
{
AssignValue();
void AssignValue()
{
value = 10; Nieprawidłowe przypisanie wartości do pola tylko do odczytu.
}
}
}
To ograniczenie nie jest istotnym problemem, warto jednak o nim pamiętać. Powodem
jest to, aby nie trzeba było zmieniać środowiska CLR pod kątem obsługi metod lokal-
nych. Te metody wymagają tylko transformacji w kompilatorze. To prowadzi do tego,
w jaki sposób kompilator implementuje metody lokalne, przede wszystkim w związku
z obsługą zmiennych przechwytywanych.
2
Gdyby kompilator miał działać w środowisku, w którym metody lokalne istnieją, wszystkie informacje
z tego punktu byłyby zapewne nieistotne w kontekście tego kompilatora.
87469504f326f0d7c1fcda56ef61bd79
8
14.1. Metody lokalne 469
na długo po zwróceniu sterowania przez daną metodę. Dlatego kompilator musi wyko-
nywać różne sztuczki: zapisywać przechwytywane zmienne w klasie i używać w dele-
gacie metody z tej klasy.
Porównaj to z działaniem metod lokalnych. W większości sytuacji metoda lokalna
może być wywoływana tylko w ramach wywołania nadrzędnej metody. Nie trzeba więc
martwić się, że będzie używać przechwyconych zmiennych po zakończeniu wywołania.
Pozwala to na wydajniejszą implementację opartą na stosie bez alokowania pamięci na
stercie. Zacznijmy od stosunkowo prostego przykładu metody lokalnej, która inkre-
mentuje przechwyconą zmienną o wartość podaną jako argument tej metody (zobacz
listing 14.2).
87469504f326f0d7c1fcda56ef61bd79
8
470 ROZDZIAŁ 14. Zwięzły kod w C# 7
Aby utworzyć nowy zasięg, użyłem tu prostej instrukcji if zamiast pętli for lub foreach.
Dzięki temu łatwiej będzie stosunkowo precyzyjnie przedstawić przekształcenia wpro-
wadzane przez kompilator. Na listingu 14.5 pokazane jest, jak kompilator zmienia
metody lokalne w zwykłe.
87469504f326f0d7c1fcda56ef61bd79
8
14.1. Metody lokalne 471
struct OuterScope
{ Struktura wygenerowana na podstawie
public int hour; zewnętrznego zasięgu.
}
struct InnerScope
{ Struktura wygenerowana
public int minute; na podstawie wewnętrznego zasięgu.
}
Ten listing nie tylko ilustruje obsługę wielu zasięgów, ale też pokazuje, że nieprze-
chwytywane zmienne lokalne nie są zapisywane w generowanych strukturach.
Do tej pory opisane zostały sytuacje, w których metoda lokalna może działać tylko
w trakcie pracy metody nadrzędnej. Pozwala to bezpiecznie przechwytywać zmienne
lokalne w pokazany tu wydajny sposób. Zgodnie z moim doświadczeniem ten model
dotyczy większości sytuacji, w których chcę używać metod lokalnych. Zdarzają się jed-
nak odstępstwa od tego bezpiecznego rozwiązania.
UCIECZKA Z WIĘZIENIA! W JAKI SPOSÓB METODY LOKALNE
MOGĄ UCIEC Z NADRZĘDNEGO KODU
Metody lokalne działają jak zwykłe metody w czterech sytuacjach, które mogą unie-
możliwiać kompilatorowi stosowanie omawianej do tego miejsca optymalizacji „prze-
chowuj wszystko na stosie”:
Metoda lokalna może być asynchroniczna, dlatego jeśli wywołanie niemal natych-
miast zwraca zadanie, metoda nie zawsze zdąży skończyć do tego momentu
wykonywanie logicznej operacji.
Metoda lokalna może być zaimplementowana z użyciem iteratorów, dlatego
wywołanie, które tworzy sekwencję, będzie musiało kontynuować wykonywanie
danej metody, gdy zażądana zostanie następna wartość tej sekwencji.
87469504f326f0d7c1fcda56ef61bd79
8
472 ROZDZIAŁ 14. Zwięzły kod w C# 7
Na listingu 14.6 pokazany jest prosty przykład ostatniej z tych sytuacji. Lokalna metoda
Count przechwytuje tu zmienną lokalną z nadrzędnej metody CreateCounter. Metoda
Count jest używana do utworzenia delegata Action, wywoływanego następnie po zwró-
ceniu sterowania przez metodę CreateCounter.
Dla zmiennej count nie można teraz używać struktury na stosie. Wywołania metody
CreateCounter nie będzie już na stosie w momencie uruchomienia delegata. Jednak obec-
nie kod bardzo przypomina funkcję anonimową. Mógłbyś zaimplementować metodę
CreateCounter za pomocą wyrażenia lambda:
static Action CreateCounter()
{
int count = 0; Inna implementacja
return () => Console.WriteLine(count++); (z użyciem wyrażenia lambda).
}
87469504f326f0d7c1fcda56ef61bd79
8
14.1. Metody lokalne 473
public void Count() => Console.WriteLine(count++); Metoda lokalna jest teraz metodą
CountHolder holder = new CountHolder(); instancji w wygenerowanej klasie.
}
Transformacje tego samego rodzaju są wykonywane, gdy metoda lokalna jest używana
w funkcji anonimowej, gdy jest metodą asynchroniczną i gdy jest iteratorem (z instruk-
cjami yield). Osoby, dla których ważna jest wydajność, powinny pamiętać, że metody
asynchroniczne i iteratory mogą skutkować generowaniem wielu obiektów. Jeśli mocno
starasz się uniknąć alokacji i używasz metod lokalnych, możliwe, że lepiej będzie jawnie
przekazywać parametry do tych metod, zamiast przechwytywać zmienne lokalne. Przy-
kład takiego rozwiązania jest pokazany w następnym punkcie.
Oczywiście lista możliwych scenariuszy jest długa. Jedna metoda lokalna może
używać delegata uzyskanego na podstawie konwersji grupy metod opartej na innej
metodzie lokalnej. Możesz też używać metody lokalnej w metodzie asynchronicznej itd.
Z pewnością nie zamierzam omawiać tu wszystkich takich sytuacji. Ten punkt ma Ci
pozwolić dobrze zrozumieć dwa rodzaje transformacji, jakie kompilator może stosować
do obsługi przechwytywanych zmiennych. Aby zobaczyć, co kompilator robi z Twoim
kodem, użyj dekompilatora lub narzędzia ildasm. Pamiętaj, aby wyłączyć wszelkie
optymalizacje, jakie dekompilator mógłby dla Ciebie wprowadzać. (W przeciwnym razie
dekompilator może wygenerować metodę lokalną, co nie będzie pomocne). Teraz gdy
już zobaczyłeś, co można robić z metodami lokalnymi i jak kompilator je traktuje, pora
przejść do wyjaśnienia, kiedy należy je stosować.
87469504f326f0d7c1fcda56ef61bd79
8
474 ROZDZIAŁ 14. Zwięzły kod w C# 7
używaną metodę do kodu, który jej używa, bez zmieniania sygnatury3. W drugim
kroku zajmij się parametrami tej metody. Czy we wszystkich wywołaniach metody jako
argumenty używane są te same zmienne lokalne? Jeśli tak jest, można zamiast argumen-
tów użyć przechwytywanych zmiennych i wyeliminować niektóre parametry z metody
lokalnej. Czasem możliwe jest nawet usunięcie wszystkich parametrów.
W zależności od liczby i wielkości parametrów ten drugi krok może mieć wpływ
na wydajność kodu. Jeśli wcześniej przekazywałeś przez wartość duże typy bezpośred-
nie, były one kopiowane w każdym wywołaniu. Użycie zamiast nich przechwytywanych
zmiennych pozwala pominąć kopiowanie, co może być istotne, jeśli metoda jest często
wywoływana.
Ważną kwestią związaną z metodami lokalnymi jest to, że ich utworzenie pozwala
jasno pokazać, iż są one szczegółem implementacji jakiejś metody, a nie typu. Jeśli masz
metodę prywatną, której działanie ma sens niezależnie od innych metod, ale która na
razie jest używana w tylko jednym miejscu, czasem lepiej jest pozostawić ją bez zmian.
Korzyści (jeśli chodzi o logiczną strukturę typu) są znacznie większe, gdy metoda pry-
watna jest ściśle powiązana z jedną operacją i gdy trudno wyobrazić sobie inne scena-
riusze zastosowania tej metody.
SPRAWDZANIE POPRAWNOŚCI ARGUMENTÓW ITERATORA
LUB METODY ASYNCHRONICZNEJ I OPTYMALIZOWANIE METOD LOKALNYCH
Metody lokalne często tworzone są też wtedy, gdy masz iterator lub metodę asyn-
chroniczną i chcesz zachłannie sprawdzać poprawność argumentów. Na przykład na
listingu 14.8 pokazana jest przykładowa implementacja jednej z wersji przeciążonej
metody Select z technologii LINQ to Objects. Sprawdzanie poprawności argumentów
nie odbywa się w bloku iteratora, dlatego ma miejsce zaraz po wywołaniu metody,
natomiast pętla foreach rozpoczyna pracę dopiero wtedy, gdy jednostka wywołująca
zacznie iteracyjnie pobierać zwracaną sekwencję.
3
Czasem konieczne są modyfikacje parametrów określających typ w sygnaturze. Jeśli jedna metoda
generyczna wywołuje drugą metodę, to po przeniesieniu drugiej metody do pierwszej często można
wykorzystać parametry określające typ z tej pierwszej metody. Ilustruje to listing 14.9.
87469504f326f0d7c1fcda56ef61bd79
8
14.1. Metody lokalne 475
IEnumerable<TResult> SelectImpl(
IEnumerable<TSource> validatedSource,
Func<TSource, TResult> validatedSelector)
{
foreach (TSource item in validatedSource)
{
yield return validatedSelector(item);
}
}
}
87469504f326f0d7c1fcda56ef61bd79
8
476 ROZDZIAŁ 14. Zwięzły kod w C# 7
Ty oczywiście możesz mieć inne preferencje, jednak jak zawsze przestrzegam przed
stosowaniem nowych technik tylko dlatego, że jest to możliwe. Wypróbowanie takich
technik w ramach eksperymentów jest oczywiście wskazane, jednak nie pozwól, aby
nowinki skusiły Cię do poświęcenia czytelności.
Pora na dobre wiadomości. Pierwszy mechanizm omawiany w tym rozdziale był
najbardziej rozbudowany. Pozostałe techniki są dużo prostsze.
Pod kilkoma względami zmienne przekazywane jako argumenty out działają podobnie
jak zmienne generowane w wyniku dopasowywania wzorców:
Jeśli dana wartość nie jest istotna, możesz użyć jako nazwy jednego podkreślenia,
aby pominąć tę wartość.
Możesz użyć słowa var, aby zadeklarować zmienną o niejawnie określanym typie
(typ zostaje wtedy wywnioskowany na podstawie typu parametru).
Zmiennej podawanej jako argument out nie możesz używać w drzewie wyrażenia.
Zasięgiem takiej zmiennej jest zawierający ją blok.
87469504f326f0d7c1fcda56ef61bd79
8
14.2. Zmienne out 477
Przed wersją C# 7.3 zmiennych out nie można było używać w inicjalizatorach
pól, właściwości i konstruktorów ani w wyrażeniach reprezentujących zapytania.
Dalej pokazany jest przykład.
Zmienna będzie miała określoną wartość wtedy i tylko wtedy, jeśli metoda zosta-
nie wywołana.
Aby zrozumieć ostatni punkt, przyjrzyj się poniższemu kodowi. Próbuje on przetworzyć
dwa łańcuchy znaków i zsumować zapisane w nich wartości:
static int? ParseAndSum(string text1, string text2) =>
int.TryParse(text1, out int value1) &&
int.TryParse(text2, out int value2)
? value1 + value2 : (int?) null;
class ParsedText
{
public string Text { get; }
public bool Valid { get; }
87469504f326f0d7c1fcda56ef61bd79
8
478 ROZDZIAŁ 14. Zwięzły kod w C# 7
Choć ograniczenia obowiązujące przed wersją C# 7.3 nigdy nie były dla mnie proble-
mem, dobrze, że obecnie je zniesiono. W rzadkich sytuacjach, gdy przydatne było użycie
w inicjalizatorach zmiennych out lub zmiennych wygenerowanych we wzorcu, inne
rozwiązania były dość irytujące i zwykle wymagały utworzenia nowej metody tylko
w tym celu.
To już wszystko na temat zmiennych podawanych jako argumenty out. Nowa tech-
nika jest tylko przydatnym skrótem pozwalającym uniknąć irytujących instrukcji dekla-
rowania zmiennych.
Wszystkie wiersze to robią. Jednak trzeci wiersz pozwala to łatwo stwierdzić, a dwa
pozostałe wymagają nieco dłuższego namysłu (przynajmniej ode mnie). Jednak nawet
ostatni wiersz wymaga dłuższego sprawdzenia, niż byłoby to możliwe, ponieważ trzeba
sprawdzić, czy zawiera właściwą liczbę bitów. Gdyby tylko można było jeszcze bardziej
doprecyzować kod…
4
Projektanci języka C# słusznie zrezygnowali z koszmarnych literałów ósemkowych, które w Javie
odziedziczono po języku C. Jaka jest wartość liczby 011? No przecież „oczywiste”, że 9.
87469504f326f0d7c1fcda56ef61bd79
8
14.3. Usprawnienia w literałach liczbowych 479
Ta swoboda ma jednak swoją cenę. Kompilator nie sprawdza, czy znak podkreślenia
jest umieszczony w sensownych miejscach. Możesz nawet umieścić wiele znaków
podkreślenia obok siebie. Oto poprawne, ale niegodne naśladowania przykłady:
int wideFifteen = 1____________________5;
ulong notQuiteAlternatingWords = 0xffff_000_ffff_0000;
To ostatnie ograniczenie zostało zniesione w C# 7.2. Choć czytelność kodu jest kwestią
względną, zdecydowanie wolę podawać podkreślenie po specyfikatorze podstawy, jeśli
stosuję podkreślenia także w innych miejscach. Oto przykłady:
0b_1000_0111 i 0b1000_0111,
0x_ffff_0000 i 0xffff_0000.
87469504f326f0d7c1fcda56ef61bd79
8
480 ROZDZIAŁ 14. Zwięzły kod w C# 7
Jednak nie wszędzie wyrażenia throw są dozwolone, ponieważ nie wszędzie mają one
sens. Na przykład nie można ich używać bezwarunkowo w przypisaniach lub jako argu-
mentów metod:
int invalid = throw new Exception("Ten kod nie ma sensu");
Console.WriteLine(throw new Exception("Ten także nie"));
87469504f326f0d7c1fcda56ef61bd79
8
14.5. Literał default (C# 7.1) 481
Następny mechanizm także związany jest z zapisem tej samej logiki, ale w prostszy
sposób, i polega na uproszczeniu operatora default dzięki literałom default.
Wynikiem operatora default jest wartość domyślna używana dla danego typu, jeśli pole
pozostanie niezainicjalizowane. Jest to referencja null w typach referencyjnych, zero
odpowiedniego typu we wszystkich typach liczbowych, U+0000 dla typu char, false
dla typu bool i wartość z polami ustawionymi na odpowiednie wartości domyślne dla
wszystkich pozostałych typów bezpośrednich.
Gdy w C# 4 dodano parametry opcjonalne, jednym ze sposobów na podanie war-
tości domyślnej parametru było zastosowanie operatora default. Jeśli nazwa typu jest
długa, może to być niewygodne, ponieważ nazwę typu trzeba podać zarówno dla para-
metru, jak i dla wartości domyślnej. Jednym z największych winowajców był tu typ
CancellationToken — przede wszystkim dlatego, że standardowa nazwa parametru tego
typu to cancellationToken. Typowa sygnatura metody asynchronicznej mogła więc
wyglądać tak:
public async Task<string> FetchValueAsync(
string key,
CancellationToken cancellationToken = default(CancellationToken))
Deklaracja drugiego parametru jest tak długa, że wymaga całego wiersza w tej książce —
liczy 64 znaki.
W C# 7.1 w niektórych sytuacjach można użyć zapisu default zamiast default(T)
i pozwolić kompilatorowi ustalić, jaki typ jest potrzebny. Choć może to być przydatne
także w sytuacjach innych niż w przykładzie, podejrzewam, że taki scenariusz był
jednym z ważnych powodów wprowadzenia zmian. Wcześniejszy przykład można teraz
zapisać tak:
public async Task<string> FetchValueAsync(
string key, CancellationToken cancellationToken = default)
Ten zapis jest dużo bardziej przejrzysty. Bez podawania typu default jest literałem,
a nie operatorem, i działa podobnie jak literał null (przy czym działa dla wszystkich
typów). Ten literał, podobnie jak null, nie ma typu, jednak można go przekształcić na
dowolny typ. Docelowy typ można wywnioskować, np. w tablicy o niejawnie okre-
ślanym typie:
var intArray = new[] { default, 5 };
var stringArray = new[] { default, "tekst" };
87469504f326f0d7c1fcda56ef61bd79
8
482 ROZDZIAŁ 14. Zwięzły kod w C# 7
W tym fragmencie kodu nie podano bezpośrednio żadnych nazw typów, jednak dla
intArray niejawnie używany jest typ int[] (i literał default jest przekształcany na 0),
a dla stringArray niejawnie stosowany jest typ string[] (i literał default jest zastępowany
referencją null). Podobnie jak dla literału null, tak i tu musi być określony jakiś typ,
aby można było przekształcić na niego wartość. Nie można zażądać od kompilatora
wywnioskowania typu, jeśli nie ma żadnych informacji na jego temat:
var invalid = default;
var alsoInvalid = new[] { default };
Literał default jest traktowany jak wyrażenie stałe, jeśli jest przekształcany na wartość
typu referencyjnego lub typu prostego. Dzięki temu możesz stosować go w atrybutach.
Warto wiedzieć o pewnej ciekawostce — pojęcie domyślna (ang. default) ma kilka
znaczeń. Może oznaczać wartość domyślną typu lub wartość domyślną parametru
opcjonalnego. Literał default zawsze oznacza wartość domyślną odpowiedniego typu.
Może to prowadzić do niejasności, jeśli używasz go jako argumentu opcjonalnego para-
metru o odmiennej wartości domyślnej. Przyjrzyj się listingowi 14.11.
Ten kod wyświetli 0, ponieważ tyle wynosi wartość domyślna typu int. Język jest
w pełni spójny, jednak ten kod może prowadzić do niejasności z powodu różnych zna-
czeń słowa „domyślne”. Radziłbym więc unikać stosowania literału default w takich
sytuacjach.
87469504f326f0d7c1fcda56ef61bd79
8
14.6. Argumenty nazwane w dowolnym miejscu listy argumentów (C# 7.2) 483
Przed wersją C# 7.2 sposobami na zwiększenie przejrzystości było albo użycie argu-
mentów nazwanych dla trzech ostatnich parametrów, co wyglądało dość dziwnie, albo
zastosowanie zmiennej lokalnej objaśniającej kod:
TableSchema schema = null;
client.UploadCsv(table, schema, csvData, options);
Ten kod jest bardziej przejrzysty, ale nadal nie jest idealny. W C# 7.2 można stosować
argumenty nazwane w dowolnym miejscu listy argumentów, dlatego można jednoznacz-
nie określić znaczenie drugiego argumentu bez żadnych dodatkowych instrukcji:
client.UploadCsv(table, schema: null, csvData, options);
87469504f326f0d7c1fcda56ef61bd79
8
484 ROZDZIAŁ 14. Zwięzły kod w C# 7
sposób, kod staje się niejasny. Jeszcze gorzej jest, gdy używane są parametry opcjonalne.
Prościej jest zakazać takiego rozwiązania, dlatego zespół projektujący język podjął taką
właśnie decyzję. Jako następny opisany jest mechanizm od zawsze dostępny w środo-
wisku CLR, ale udostępniony dopiero w C# 7.2.
87469504f326f0d7c1fcda56ef61bd79
8
14.8. Drobne usprawnienia z C# 7.3 485
enum SampleEnum {}
static void EnumMethod<T>() where T : struct, Enum {}
static void DelegateMethod<T>() where T : Delegate {}
static void UnmanagedMethod<T>() where T : unmanaged {}
...
DelegateMethod<Action>();
DelegateMethod<Delegate>(); Wszystkie poprawne (niestety).
DelegateMethod<MulticastDelegate>();
87469504f326f0d7c1fcda56ef61bd79
8
486 ROZDZIAŁ 14. Zwięzły kod w C# 7
Metod statycznych nie można wywoływać w taki sposób, jakby były metodami
instancji.
Metod instancji nie można wywoływać w taki sposób, jakby były metodami
statycznymi.
87469504f326f0d7c1fcda56ef61bd79
8
Podsumowanie 487
Nie jest to nowy modyfikator dla atrybutów, jednak wcześniej nie był on dostępny
w tym kontekście — przynajmniej nie według oficjalnych materiałów i nie w kompilato-
rze Microsoftu. W kompilatorze Mono ten modyfikator był dozwolony już od jakiegoś
czasu. Jest to następna niespójność w specyfikacji, która została wyeliminowana
w C# 7.3.
Podsumowanie
Metody lokalne umożliwiają jednoznaczne określanie, że dany fragment kodu jest
szczegółem implementacji jednej operacji i nie jest przeznaczony do ogólnego
użytku w samym typie.
Zmienne out zmniejszają ilość ceregieli w kodzie, dzięki czemu w niektórych
sytuacjach można skrócić kilka instrukcji (deklarowanie zmiennej i jej używanie)
do jednego wyrażenia.
Literały dwójkowe pozwalają poprawić przejrzystość kodu, gdy chcesz zapisać
liczbę całkowitą, ale wzorzec bitów jest ważniejszy niż sama wartość.
Literały z wieloma cyframi, które mogą być niejasne dla czytelników, stają się
bardziej przejrzyste po wstawieniu separatorów cyfr.
Wyrażenia throw (podobnie jak zmienne out) często umożliwiają zapisanie
w jednym wyrażeniu kodu, który wcześniej wymagał kilku instrukcji.
Literały default eliminują nadmiarowość. Dzięki nim nie trzeba dwukrotnie
zapisywać tych samych informacji5.
W odróżnieniu od innych mechanizmów argumenty nazwane, które nie wystę-
pują na końcu listy argumentów, mogą zwiększać długość kodu źródłowego, ale
za to poprawiają jego przejrzystość. Ponadto jeśli wcześniej stosowałeś wiele
argumentów nazwanych, choć chciałeś podać nazwę tylko jednego z nich na
jednej ze środkowych pozycji, będziesz mógł usunąć niektóre nazwy bez spadku
czytelności.
5
Widzisz, jak irytująca jest nadmiarowość? Przepraszam, ale nie mogłem się powstrzymać.
87469504f326f0d7c1fcda56ef61bd79
8
488 ROZDZIAŁ 14. Zwięzły kod w C# 7
87469504f326f0d7c1fcda56ef61bd79
8
C# 8 i kolejne wersje
Zawartość rozdziału
Zapisywanie wymogu obsługi lub braku obsługi
wartości null w typach referencyjnych
Używanie wyrażeń switch z dopasowywaniem wzorców
Rekurencyjne dopasowywanie wzorców
we właściwościach
Używanie składni dla indeksów i przedziałów do pisania
zwięzłego i spójnego kodu
Używanie asynchronicznych wersji instrukcji using,
foreach i yield
87469504f326f0d7c1fcda56ef61bd79
8
490 ROZDZIAŁ 15. C# 8 i kolejne wersje
Adres zwykle obejmuje znacznie więcej informacji niż nazwa kraju, jednak jedna wła-
ściwość wystarcza na potrzeby przykładów z tego rozdziału. Gdy dostępne są te klasy,
na ile bezpieczny jest poniższy kod?
Customer customer = ...;
Console.WriteLine(customer.Address.Country);
Jeśli wiesz (w jakiś sposób), że zmienna customer jest różna od null i zawsze ma przypi-
sany adres, to rozwiązanie jest poprawne. Skąd jednak możesz to wiedzieć? Jeżeli
wynika to tylko z analizy dokumentacji, jakie zmiany trzeba wprowadzić, aby kod stał
się bardziej bezpieczny?
Od wersji C# 2 dostępne są typy bezpośrednie przyjmujące wartość null, typy
bezpośrednie nieprzyjmujące wartości null i typy referencyjne niejawnie przyjmujące
wartość null. W tabelce ilustrującej typy przyjmujące wartość null i nieprzyjmujące jej
oraz typy bezpośrednie i referencyjne zapełnione są więc trzy z czterech komórek,
jednak czwarta pozostaje nieokreślona, co ilustruje tabela 15.1.
87469504f326f0d7c1fcda56ef61bd79
8
15.1. Typy referencyjne przyjmujące wartość null 491
Ponieważ w górnym wierszu uwzględniana jest tylko jedna możliwość, oznacza to, że nie
da się zapisać, iż niektóre wartości referencyjne mogą być równe null, a inne zawsze
powinny być różne od null. Gdy natrafisz na problem z nieoczekiwaną wartością null,
trudno może być określić źródło błędu, chyba że kod jest starannie udokumentowany
i konsekwentnie stosowane są testy pod kątem wartości null1.
Ponieważ istnieje obecnie bardzo duża ilość kodu .NET bez czytelnego dla ma-
szyn rozróżnienia na referencje, które mogą być równe null, i te, które zawsze muszą
być różne od null, rozwiązanie problemu trzeba wprowadzać bardzo ostrożnie. Co
można zrobić?
87469504f326f0d7c1fcda56ef61bd79
8
492 ROZDZIAŁ 15. C# 8 i kolejne wersje
Listing 15.2. Model, gdzie żadne właściwości nie przyjmują wartości null
Na tym etapie „nie można” utworzyć obiektu typu Customer bez podania nazwiska i adresu
różnych od null. Ponadto „nie można” utworzyć obiektu typu Addres bez określenia
państwa różnego od null. Celowo umieściłem człon nie można w nawiasie, a przyczyny
opisane są w punkcie 15.1.4.
Teraz ponownie rozważ kod wyświetlający dane wyjściowe w konsoli:
Customer customer = ...;
Console.WriteLine(customer.Address.Country);
Ten kod jest bezpieczny, pod warunkiem że wszyscy poprawnie przestrzegają kon-
traktów. Ta wersja nie tylko nie zgłosi wyjątku, ale też chroni przed przekazaniem war-
tości null do metody Console.WriteLine, ponieważ państwo w adresie jest różne od null.
W porządku, kompilator sprawdza więc, że wartości są różne od null. Co z sytu-
acjami, gdy chcesz dopuścić wartości null? Pora zapoznać się z nową składnią, o której
wcześniej wspomniałem.
87469504f326f0d7c1fcda56ef61bd79
8
15.1. Typy referencyjne przyjmujące wartość null 493
Zastosujmy teraz nową technikę do modelu z klasą Customer. Załóżmy, że adres klienta
może być równy null. Wymaga to zmodyfikowania klasy Customer w następujący sposób:
zmiany typu właściwości,
albo usunięcia w konstruktorze parametru reprezentującego adres, albo przekształ-
cenia tego parametru na wersję przyjmującą null, albo utworzenia nowej wersji
konstruktora.
Sam typ Address nie wymaga modyfikacji, zmienia się tylko sposób jego używania. Na
listingu 15.3 pokazana jest nowa wersja klasy Customer. Zdecydowałem się usunąć
w konstruktorze parametr reprezentujący adres.
Świetnie, teraz jednoznacznie określiłeś swoje intencje. Właściwość Name nie będzie
równa null, natomiast właściwość Address może przyjmować tę wartość. Kompilator
wyświetli teraz nowe ostrzeżenie, gdy spróbujesz wyświetlić państwo z adresu użyt-
kownika:
CS8602 Possible dereference of a null reference.
87469504f326f0d7c1fcda56ef61bd79
8
494 ROZDZIAŁ 15. C# 8 i kolejne wersje
Przyjrzyj się ostrzeżeniu, które obecnie jest wyświetlane, i rozważ wszystkie sposoby
pozwalające go uniknąć. Obecnie używany jest następujący kod:
Console.WriteLine(customer.Address.Country);
87469504f326f0d7c1fcda56ef61bd79
8
15.1. Typy referencyjne przyjmujące wartość null 495
Warto zwrócić tu uwagę na interesującą kwestię — kompilator musi śledzić nie tylko
typ zmiennej. Gdyby reguła była tak prosta jak „dereferencja wartości typu referen-
cyjnego przyjmującego null powoduje ostrzeżenie”, kod nadal powodowałby ostrze-
żenie (choć byłby bezpieczny). Zamiast tego kompilator w każdym miejscu kodu
sprawdza, czy wartość zmiennej może być równa null (podobnie jak śledzi, czy zmienne
mają przypisaną określoną wartość). Do czasu dojścia do ciała instrukcji if kompilator
wie, że wartość zmiennej address jest różna od null, dlatego nie ostrzega przed derefe-
rencją. Trzecie podejście, pokazane na listingu 15.6, jest podobne do drugiego, ale nie
wymaga zmiennej lokalnej.
if (customer.Address != null)
{
Console.WriteLine(customer.Address.Country);
}
else
{
Console.WriteLine("(Address unknown)");
}
Nawet jeśli rozumiesz, że drugi przykład można skompilować bez ostrzeżeń, listing 15.6
może okazać się nieco zaskakujący. Kompilator śledzi nie tylko to, czy wartość zmiennej
może być równa null. Sprawdza to także w przypadku właściwości. Zakłada, że jeśli
dwukrotnie używasz tej samej właściwości tego samego obiektu, wynik w obu sytu-
acjach będzie taki sam.
Może Cię to niepokoić. To oznacza, że omawiany mechanizm nie gwarantuje ochrony
przed dereferencją wartości null. Inny wątek może zmodyfikować wartość właściwości
Address między dwoma jej wywołaniami, a samą właściwość Address można tak napisać,
aby losowo zwracała czasem wartość null. Istnieją też inne sposoby na zmylenie kom-
pilatora i przekonanie go, że kod jest poprawny, choć w rzeczywistości nie jest w pełni
bezpieczny. Zespół projektujący C# wie o tym i akceptuje taki stan rzeczy, ponieważ
uznał to za pragmatyczny kompromis między bezpieczeństwem a kłopotliwym kodem.
Kod używający mechanizmów z C# 8 będzie dużo lepiej zabezpieczony przed war-
tościami null niż w starszych wersjach języka, jednak zapewnienie pełnego bezpieczeń-
stwa prawie na pewno wymagałoby bardziej inwazyjnych zmian, które zniechęciłyby
wielu programistów. Dopóki będziesz rozumiał ograniczenia stosowanej techniki, nic
Ci nie grozi.
Zobaczyłeś już, że kompilator stara się zrozumieć, które wartości mogą być równe
null. Co można zrobić, gdy kompilator nie ma tak rozbudowanego kontekstu jak pro-
gramista?
87469504f326f0d7c1fcda56ef61bd79
8
496 ROZDZIAŁ 15. C# 8 i kolejne wersje
static void PrintLength(string? text) Dane wejściowe mogą być równe null.
{
if (!string.IsNullOrEmpty(text)) Jeśli IsNullOrEmpty zwraca
{ false, wartość jest różna od null.
Console.WriteLine($"{text}: {text!.Length}"); Użycie operatora „a niech to”
} do uspokojenia kompilatora.
else
{
Console.WriteLine("Pusta lub null");
}
}
W tym przykładzie wiesz coś, czego kompilator nie wie na temat powiązania danych
wejściowych metody string.IsNullOrEmpty z wartością zwracaną przez tę metodę. Jeśli
metoda zwraca false, dane wejściowe nie mogą być równe null. Dlatego można
przeprowadzić dereferencję wartości i pobrać długość łańcucha znaków. Jeśli w zwykły
sposób wywołasz instrukcję text.Length, kompilator zgłosi ostrzeżenie. Wywołanie text!.
Length informuje kompilator, że wiesz więcej o kodzie i bierzesz odpowiedzialność
za sprawdzenie danej wartości.
Byłoby jednak dobrze, gdyby kompilator wykrywał zależność między danymi wej-
ściowymi i wynikiem metody string.IsNullOrEmpty. Wrócimy do tej kwestii w punk-
cie 15.1.7.
Drugie zastosowanie operatora „a niech to” znacznie łatwiej jest zilustrować za
pomocą realistycznego przykładu. Wcześniej wspomniałem, że wciąż należy sprawdzać
2
Wątpię, aby kiedykolwiek został on oficjalnie nazwany operatorem damn it, jednak podejrzewam,
że nazwa ta przyjmie się w społeczności użytkowników, podobnie jak wszyscy używają dla plat-
formy Microsoft .NET Compiler Platform pierwotnej nazwy Roslyn.
87469504f326f0d7c1fcda56ef61bd79
8
15.1. Typy referencyjne przyjmujące wartość null 497
poprawność parametrów pod kątem wartości null, ponieważ nadal możliwe jest otrzy-
manie takiej wartości. Możesz też dodać testy jednostkowe związane ze sprawdzaniem
poprawności, jednak wtedy kompilator wyświetli ostrzeżenie, ponieważ przekazujesz
wartość null, choć zaznaczyłeś, że nie powinna się ona pojawiać. Na listingu 15.8 poka-
zano, jak naprawić to za pomocą operatora „a niech to”.
[Test]
public void Customer_NameValidation()
{
Address address = new Address("UK");
Assert.Throws<ArgumentNullException>( Celowe przekazanie wartości null
() => new Customer(null!, address)); dla parametru nieprzyjmującego null.
}
87469504f326f0d7c1fcda56ef61bd79
8
498 ROZDZIAŁ 15. C# 8 i kolejne wersje
87469504f326f0d7c1fcda56ef61bd79
8
15.1. Typy referencyjne przyjmujące wartość null 499
87469504f326f0d7c1fcda56ef61bd79
8
500 ROZDZIAŁ 15. C# 8 i kolejne wersje
87469504f326f0d7c1fcda56ef61bd79
8
15.1. Typy referencyjne przyjmujące wartość null 501
string? a = ...;
if (!string.IsNullOrEmpty(a))
{
Console.WriteLine(a.Length);
}
object b = ...;
if (!ReferenceEquals(b, null))
{
Console.WriteLine(b.GetHashCode());
}
XElement c = ...;
string d = (string) c;
W każdej z tych sytuacji semantyka wywoływanego kodu jest istotna. W tych przy-
kładach kompilator musiałby wiedzieć, że:
jeśli wynik wywołania string.IsNullOrEmpty to false, dane wejściowe nie mogą
być równe null;
jeżeli wynik wywołania ReferenceEquals to false i jedna z wartości wejściowych
to referencja null, druga wartość wejściowa jest różna od null;
jeśli dane wejściowe operatora konwersji z typu XElement na typ string są różne
od null, dane wyjściowe też są różne od null.
87469504f326f0d7c1fcda56ef61bd79
8
502 ROZDZIAŁ 15. C# 8 i kolejne wersje
Czy ten kod powinien być prawidłowy? Ponadto co on oznacza? Przede wszystkim co
się stanie, gdy utworzysz obiekt tej klasy bez ustawienia wartości właściwości Value?
Dla typu Wrapper<int> domyślna wartość właściwości Value to 0.
Dla typu Wrapper<int?> domyślna wartość właściwości Value to null (dla typu int?).
Dla typu Wrapper<string> domyślna wartość właściwości Value to referencja null.
To źle, ponieważ jest to niezgodne z tym, że typ właściwości Value to typ string
nieprzyjmujący null.
Dla typu Wrapper<string?> domyślna wartość właściwości Value to referencja null.
Jest to dozwolone, ponieważ tu typem właściwości Value jest typ string przyjmu-
jący null.
Sytuacja staje się jeszcze bardziej skomplikowana, gdy zdasz sobie sprawę, że w czasie
wykonywania programu Wrapper<int> i Wrapper<int?> to różne typy środowiska CLR,
jednak Wrapper<string> i Wrapper<string?> to ten sam typ środowiska CLR.
Nie wiem, jak te komplikacje zostaną rozwiązane w C# 8, jednak zespół jest ich
świadom. Cieszę się, że to oni muszą poradzić sobie z tym problemem, a nie ja, ponie-
waż głowa mnie boli od samego myślenia o tych zagadnieniach.
W tym przykładzie używana była tylko składnia z C# 7 bez jawnego stosowania
typów przyjmujących null. Co się stanie, jeśli spróbujesz użyć zapisu T? w typie gene-
rycznym lub w metodzie generycznej?
W C# 7, jeśli używasz parametru określającego typ T, zapis T? można stosować
tylko wtedy, jeśli dla T obowiązuje ograniczenie wymagające użycia typu bezpośredniego
nieprzyjmującego null. Wtedy T? oznacza typ Nullable<T>. Jest to dość proste, co
jednak zrobić z typami referencyjnymi przyjmującymi null? Możliwe, że potrzebne
będzie nowe ograniczenie związane z typami referencyjnymi nieprzyjmującymi null.
Wtedy można będzie stosować zapis T?, gdy zgodnie z ograniczeniami T musi być typem
bezpośrednim nieprzyjmującym null lub typem referencyjnym nieprzyjmującym null.
Nie oczekuję pojawienia się jednego ograniczenia oznaczającego „dowolnego typu nie-
przyjmującego null”, ponieważ reprezentacja typów przyjmujących null jest w typach
bezpośrednich i referencyjnych zupełnie inna.
OPCJONALNE SPRAWDZANIE POPRAWNOŚCI PARAMETRÓW
Jedyne zaimplementowane do tej pory zmiany są istotne w czasie kompilacji. Kod
pośredni generowany przez kompilator się nie zmienia, a programista i tak musi
sprawdzać poprawność parametrów, aby chronić się przed kodem ignorującym ostrze-
żenia kompilatora, zawierającym operator „a niech to” lub skompilowanym z użyciem
starszych wersji języka C#.
Jest to zrozumiałe, jednak kod do sprawdzania poprawności jest dość szablonowy.
Operator ??, operator nameof i wyrażenia throw to mechanizmy, które w niektórych
sytuacjach pomagają poprawić kod potrzebny do sprawdzania poprawności. Jednak
sprawdzanie poprawności nadal jest irytujące i łatwo o nim zapomnieć.
87469504f326f0d7c1fcda56ef61bd79
8
15.1. Typy referencyjne przyjmujące wartość null 503
87469504f326f0d7c1fcda56ef61bd79
8
504 ROZDZIAŁ 15. C# 8 i kolejne wersje
Na listingu 15.9 pokazany jest analogiczny kod z użyciem wyrażenia switch, ale nadal
ze zwykłą metodą z ciałem w postaci bloku.
87469504f326f0d7c1fcda56ef61bd79
8
15.2. Wyrażenia switch 505
Należy tu zwrócić uwagę na wiele zagadnień, dlatego nie próbowałem opisywać ich
w kodzie za pomocą uwag. Oto wszystkie różnice między instrukcją switch a wyrażeniem
switch:
87469504f326f0d7c1fcda56ef61bd79
8
506 ROZDZIAŁ 15. C# 8 i kolejne wersje
Możesz sformatować ten kod w dowolny sposób — np. przenieść człon shape switch do
pierwszego wiersza lub zastosować dla nawiasów klamrowych ten sam poziom wcięcia
co dla deklaracji metody.
Ważną różnicą między instrukcjami switch i wyrażeniami switch jest to, że te
ostatnie zawsze muszą zwracać jakiś wynik (może nim być wyjątek). Wyrażenie switch
nie może nic nie robić ani nie generować wartości. Możliwe jest napisanie wyrażenia
switch, które nie jest kompletne, czyli nie zawsze dopasowuje wszystkie wartości, choć
można się przed tym zabezpieczyć za pomocą znaku _. W wersji zapoznawczej, której
używałem, niekompletne wyrażenie skutkowało ostrzeżeniem kompilatora i wygene-
rowaniem przez niego nieprawidłowego kodu pośredniego. To ostrzeżenie może zostać
przekształcone na błąd kompilacji. Możliwe też, że kompilator będzie wstrzykiwał kod
generujący wyjątek (np. InvalidOperationException), aby poinformować, że w kodzie
napotkano nieoczekiwaną sytuację.
Problem, jaki mam z wyrażeniami switch w ich obecnej postaci, polega na tym, że
nie da się zapisać kilku wzorców, które powinny zwracać ten sam wynik. W instrukcji
switch można użyć wtedy kilku etykiet case, jednak w wyrażeniach switch na razie nie
istnieje odpowiednik tej techniki. Zespół pracujący nad C# wie, że taka możliwość jest
potrzebna, dlatego mam nadzieję, że pojawi się ona przed udostępnieniem C# 8.
Zastosowania wzorców zwiększyły się w C# 8 dzięki zastosowaniu wyrażeń switch,
ale też same wzorce są rozbudowywane.
87469504f326f0d7c1fcda56ef61bd79
8
15.3. Rekurencyjne dopasowywanie wzorców 507
W każdej z tych sytuacji potrzebny jest nie tyle obiekt reprezentujący figurę, co jego
właściwości. Możesz użyć zagnieżdżonych wzorców var, aby dopasować takie właści-
wości do dowolnej wartości i wygenerować zmienne ze wzorca odpowiadające każdej
potrzebnej właściwości. Na listingu 15.11 pokazana jest kompletna metoda ze wzorcami
zagnieżdżonymi.
Czy ten kod jest bardziej przejrzysty od wcześniejszej wersji? Nie jestem pewien. Uży-
łem tego kodu, ponieważ można do niego łatwo przejść z wcześniejszego przykładu.
Jednak mógłbym się łatwo ograniczyć do wersji z listingu 15.10. Dalej przyjrzysz się
bardziej skomplikowanemu przykładowi, gdzie opisany mechanizm jest bardziej atrak-
cyjny, jednak tamten kod trudno byłoby od razu zrozumieć.
Warto zauważyć, że choć obiekty typu Rectangle, Circle i Triangle nie są już prze-
chwytywane w zmiennych wzorców (wcześniej były to rect, circle i triangle), wynika
to tylko z tego, że te zmienne nie są już potrzebne. Można jednak dodawać zmienne
wzorców w taki sam sposób jak wcześniej. Na przykład gdybyś chciał opisywać figury,
mógłbyś użyć wzorca do opisu płaskiego prostokąta o zerowej wysokości:
Rectangle { Height: 0 } rect => $"Płaski prostokąt o wysokości {rect.Width}"
Jest to przydatne, gdy masz wiele właściwości, ale we wzorcach sprawdzanych jest tylko
kilka z nich. Teraz przyjrzymy się wzorcom opartym na podziale.
87469504f326f0d7c1fcda56ef61bd79
8
508 ROZDZIAŁ 15. C# 8 i kolejne wersje
Następnie możesz uprościć obliczenia obwodu, aby dokonać podziału na trzy zmienne,
zamiast podawać nazwę każdej właściwości. Wtedy w wyrażeniu switch zamiast nastę-
pującego kodu:
Triangle { SideA: var a, SideB: var b, SideC: var c } => a + b + c
Ponownie warto się zastanowić, czy jest to bardziej czytelne niż dopasowywanie
z użyciem typu. Niewykluczone, że jest. Podejrzewam, że z czasem każdy programista
wykształci własne preferencje co do dopasowywania wzorców i opracuje konwencje dla
kodu bazowego, nad jakim pracuje.
87469504f326f0d7c1fcda56ef61bd79
8
15.4. Indeksy i przedziały 509
Ważna jest tu kolejność. Na przykład klient z adresem, gdzie kraj to USA, pasuje do
każdego wzorca oprócz pierwszego. Mógłbyś utworzyć bardziej selektywne wzorce
(np. używając wzorca stałych dla null, aby dopasować klientów z właściwością Address
równą null), jednak łatwiej jest polegać na kolejności.
Usprawnienia dopasowywania wzorców w C# 8 pozwalają zastosować wzorce
w niektórych sytuacjach, gdzie obecnie konieczne są instrukcje if. Wyrażenia switch
dodatkowo zwiększają możliwości programistów. Spodziewam się, że programiści będą
pisać coraz więcej kodu z użyciem wzorców. Jak zawsze ważne jest, aby nie przesadzić.
Nie cały kod staje się prostszy, jeśli zapisać go z użyciem wzorców zamiast za pomocą
starszych struktur sterowania przepływem. Jest to jednak obszar, w którym z pewnością
można dużo zmienić w języku C#. Następny omawiany mechanizm to tak naprawdę
dwie funkcje, która udało się udostępnić dzięki dwóm nowym typom z platformy .NET.
87469504f326f0d7c1fcda56ef61bd79
8
510 ROZDZIAŁ 15. C# 8 i kolejne wersje
Interesujące jest tu wyróżnione wyrażenie 1..^1. Aby zrozumieć ten kod, musisz poznać
dwa nowe typy.
Index start = 2;
Index end = ^2;
Range all = ..;
Range startOnly = start..;
Range endOnly = ..end;
Range startAndEnd = start..end;
Range implicitIndexes = 1..5;
3
Jest to nieco nieintuicyjne, gdy używasz obiektu typu Index razem z indekserem, ma jednak dużo
więcej sensu w przypadku przedziałów, gdzie górne ograniczenie nie należy do pobieranych ele-
mentów. Przedział z górnym ograniczeniem ^0 oznacza więc uwzględnienie elementów do końca
sekwencji, co zapewne jest zgodne z Twoimi oczekiwaniami.
87469504f326f0d7c1fcda56ef61bd79
8
15.4. Indeksy i przedziały 511
Listing 15.15. Używanie wersji indeksera przyjmującej indeks i przedział dla łańcucha
znaków i obszaru
87469504f326f0d7c1fcda56ef61bd79
8
512 ROZDZIAŁ 15. C# 8 i kolejne wersje
Indeksery dla łańcuchów znaków i obszarów przyjmujące obiekt typu Range traktują
górne ograniczenie przedziału jako element, który do niego nie należy. Przedział [2..7]
zwraca elementy o indeksach 2, 3, 4, 5 i 6.
Na listingu 15.15 przedziały obejmują indeksy początkowy i końcowy, a obie war-
tości indeksu są podawane, licząc od początku sekwencji. W indekserach można sto-
sować dowolne przedziały, o ile podane indeksy są poprawne w sekwencji, do której są
używane. Na przykład użycie zapisu text[^8..] w kodzie z listingu 15.15 zwróci świecie!
jako ostatnich osiem znaków łańcucha text.
Możesz też posłużyć się zapisem text[^13..5], co zwróci itaj. W łańcuchu znaków
o długości 14 znaków indeks ^13 to odpowiednik indeksu 1, dlatego zapis text[^13..5]
to odpowiednik (w tym przykładzie, ponieważ jest to zależne od długości łańcucha text)
zapisu text[1..5], który powoduje zwrócenie czterech znaków po pierwszym. Teraz
przyjrzymy się usprawnionej obsłudze asynchroniczności w języku.
87469504f326f0d7c1fcda56ef61bd79
8
15.5. Lepsza integracja asynchroniczności 513
Dla klas, które obsługują asynchroniczne zwalnianie zasobów, dodany zostanie nowy
interfejs:
public interface IAsyncDisposable
{
Task DisposeAsync();
}
87469504f326f0d7c1fcda56ef61bd79
8
514 ROZDZIAŁ 15. C# 8 i kolejne wersje
Ten kod jest prosty, kryją się w nim jednak dwa złożone aspekty, które trzeba uwzględnić:
Biblioteki zwykle oczekują na zadania z użyciem wywołania ConfigureAwait(false).
Aplikacje zwykle oczekują na zadania bez tego wywołania. Jeśli kompilator automa-
tycznie obsługuje oczekiwanie, to jak użytkownik może skonfigurować ten proces?
Naturalna byłaby możliwość anulowania procesu zwalniania zasobów. Jak wpa-
sowuje się to w interfejs i kod w miejscu wywołania?
Zespół projektujący język C# jest świadom obu tych zagadnień. Spodziewam się, że
zostaną one uwzględnione przed udostępnieniem nowej wersji języka. Te same kwestie
dotyczą też innych mechanizmów asynchronicznych z wersji C# 8. Mam nadzieję, że
także tam problem zostanie wyeliminowany. Przyjrzyj się teraz następnej funkcji —
asynchronicznej iteracji z użyciem pętli foreach.
87469504f326f0d7c1fcda56ef61bd79
8
15.5. Lepsza integracja asynchroniczności 515
Może się to wydawać skomplikowane, jednak dobra wiadomość jest taka, że prawdo-
podobnie nie będziesz musiał samodzielnie wykonywać żadnych z opisanych operacji.
Nowa instrukcja foreach await zrobi wszystko za Ciebie.
Przyjrzyj się przykładowi, który jest w dużym stopniu oparty na moim doświad-
czeniu w pracy z interfejsami API z platformy Google Cloud. Wiele tych interfejsów
udostępnia operacje zwracania list, np. listy kontaktów z książki adresowej lub listy
maszyn wirtualnych w klastrze. Zbiór wyników może być zbyt długi, aby zwrócić go
w jednej odpowiedzi RPC. Dlatego stosowany jest wzorzec oparty na stronach: każda
odpowiedź zawiera token następnej strony, który klient podaje w kolejnym żądaniu
pobrania dalszych danych. W pierwszym żądaniu klient nie podaje tokenu strony,
a ostatnia odpowiedź nie zawiera takiego tokenu. Uproszczony interfejs API tego rodzaju
może wyglądać tak jak na listingu 15.17.
Listing 15.17. Uproszczona usługa do zwracania list miast oparta na żądaniach RPC
4
Co dziwne, jest to niespójne z działaniem większości metod TryXyz, które zwracają wartość typu bool
i używają parametru out do przekazywania wartości. W ostatecznej wersji języka opisane tu podej-
ście może się więc zmienić.
87469504f326f0d7c1fcda56ef61bd79
8
516 ROZDZIAŁ 15. C# 8 i kolejne wersje
Ten kod jest nieporęczny do bezpośredniego użytku, ale można go łatwo opakować
w klienta udostępniającego interfejs API pokazany na listingu 15.18.
Gdy dostępny jest obiekt typu GeoClient, możesz wreszcie użyć instrukcji foreach await.
Ilustruje to listing 15.19.
Ostateczna wersja jest znacznie prostsza niż kod, jaki musiałem pokazać w ramach
wstępu do tego przykładu — i to mimo pominięcia implementacji klasy GeoClient. To
dobrze, ponieważ pokazuje to zalety omawianego mechanizmu. Zacząłeś od stosunkowo
złożonych definicji interfejsów IGeoService oraz IAsyncEnumerable<T>, po czym wyko-
rzystałeś je w prosty i wydajny sposób z użyciem instrukcji foreach await.
UWAGA. Kod źródłowy dołączony do książki zawiera kompletny przykład z działającą
w pamięci fikcyjną implementacją omawianej usługi.
87469504f326f0d7c1fcda56ef61bd79
8
15.5. Lepsza integracja asynchroniczności 517
Instrukcja foreach await, podobnie jak synchroniczna instrukcja foreach, nie wymaga
implementacji interfejsów IAsyncEnumerable<T> i IAsyncEnumerator<T>. Instrukcja foreach
await będzie oparta na wzorcu, dlatego obsługiwany będzie każdy typ udostępniający
metodę GetAsyncEnumerator(), która zwraca typ udostępniający odpowiednie metody
WaitForNextAsync i TryGetNext. Może to pozwalać na pewne optymalizacje, jednak podej-
rzewam, że i tak najczęściej używane będą wymienione wcześniej interfejsy.
Na razie zobaczyłeś, jak pobierać asynchroniczne sekwencje. A co z ich genero-
waniem?
Obsługa tych sytuacji zależy od tego, czy jednostka wywołująca uruchomiła metodę
WaitForNextAsync(), czy TryGetNext(). Aby rozwiązanie działało wydajnie, wygenero-
wany kod powinien przełączać się między trybami synchronicznym (gdy wartości są
generowane bez oczekiwania) i asynchronicznym (w trakcie oczekiwania na operację
87469504f326f0d7c1fcda56ef61bd79
8
518 ROZDZIAŁ 15. C# 8 i kolejne wersje
asynchroniczną). Mogę na ogólnym poziomie wyobrazić sobie, jak osiągnąć taki cel,
cieszę się jednak, że to nie ja muszę implementować to rozwiązanie.
Planowane są też inne funkcje, na razie niedostępne w wersji zapoznawczej języka
C# 8. Zostaną one omówione w większym skrócie.
87469504f326f0d7c1fcda56ef61bd79
8
15.6. Funkcje, które nie znalazły się w wersji zapoznawczej 519
int Count()
{
using (var iterator = GetEnumerator())
{
int count = 0;
while (iterator.MoveNext())
{
count++;
}
}
}
return count;
}
87469504f326f0d7c1fcda56ef61bd79
8
520 ROZDZIAŁ 15. C# 8 i kolejne wersje
87469504f326f0d7c1fcda56ef61bd79
8
15.6. Funkcje, które nie znalazły się w wersji zapoznawczej 521
Możesz też posłużyć się nową składnią wyrażenia with, przypominającą inicjalizator
obiektu:
var newPoint = oldPoint with { X = 10, Y = 20 };
Obie wersje są kompilowane do tego samego kodu pośredniego. W ten sposób tworzony
jest tylko jeden nowy obiekt.
To tylko prosty przykład. Sytuacja staje się bardziej skomplikowana, gdy używasz
typu złożonego i chcesz zmodyfikować tylko jeden element na końcu hierarchii. Załóżmy,
że używasz typu Contact z właściwością Address i chcesz utworzyć nowy obiekt typu
Contact, który jest prawie identyczny z wcześniejszym — różni się tylko jednym polem
z właściwości Address. Możliwe, że w C# 8 nadal będzie to skomplikowane. Jednak
składnia wyrażenia with może zostać rozbudowana i w przyszłości uprościć pracę
w takich sytuacjach (podobnie rozbudowywana była składnia dopasowywania wzorców).
Dostępne możliwości są ekscytujące. Tworzenie i używanie typów niemodyfiko-
walnych w C# od dawna było problemem. Krotki wprowadzone w C# 7 eliminują jedną
wadę typów anonimowych, natomiast typy rekordowe rozwiązują inny kłopot. Zawsze
lubiłem typy anonimowe za pracę, jaką kompilator wykonuje, generując kod operatorów
porównywania, konstruktorów i właściwości. Szkoda, że nie można było nazywać takich
typów i dodawać później do nich nowych operacji. Typy rekordowe eliminują te braki
i dodają nowe możliwości. Na koniec chcę opisać kilka mechanizmów, które wymagają
bardziej nieszablonowego myślenia.
87469504f326f0d7c1fcda56ef61bd79
8
522 ROZDZIAŁ 15. C# 8 i kolejne wersje
Technika new dla typu docelowego nie zmienia czytelności w miejscach, gdzie możesz
użyć słowa var. Pozwala jednak skrócić prawą stronę deklaracji:
Dictionary<string, List<DateTime>> entryTimesByName = new();
Jeśli kompilator potrafi określić, jaki typ programista miał na myśli, wywołując kon-
struktor, można całkowicie pominąć nazwę typu. Wprowadza to interesujące kompli-
kacje w związku z wywoływaniem składowych. Na przykład w wywołaniu Method(new())
typ docelowy jest określany na podstawie parametru metody, co jest poprawne, jeśli
metoda Method nie jest generyczna ani przeciążona.
87469504f326f0d7c1fcda56ef61bd79
8
15.7. Udział w pracach 523
Wnioski
Ten rozdział zawiera znacznie więcej tekstu niż kodu — przede wszystkim dlatego, że
nie chciałem prezentować zbyt wiele kodu, który może okazać się błędny w momencie
udostępnienia C# 8. Wątpię w to, że wszystkie opisane tu mechanizmy będą dostępne
w C# 8. Uważam jednak, że prawdopodobnie niektóre z nich zostaną dodane do języka.
87469504f326f0d7c1fcda56ef61bd79
8
524 ROZDZIAŁ 15. C# 8 i kolejne wersje
Byłbym zdziwiony, gdyby typy referencyjne przyjmujące null lub techniki związane
ze wzorcami nie znalazły się w C# 8.
Co będzie dalej? No cóż, zapewne podwersje wersji C# 8, a później wersja C# 9.
Niektóre funkcje wersji C# 9 zapewne już są dostępne jako propozycje w serwisie
GitHub. Sądzę jednak, że pojawią się też nowe pomysły, o których jeszcze w ogóle nie
rozmawiano. Podejrzewam, że C# będzie ewoluował, aby spełniać potrzeby programi-
stów w obliczu zmian w branży informatycznej.
87469504f326f0d7c1fcda56ef61bd79
8
Dodatek A
Funkcje języka wprowadzone
w poszczególnych wersjach
Ta książka jest uporządkowana według wersji, jednak trudno może być szybko zapo-
znać się z funkcjami wprowadzonymi w poszczególnych wydaniach języka. Dotyczy to
przede wszystkim mechanizmów dodawanych w podwersjach z rodziny C# 7. Mecha-
nizmy te zwykle były usprawnieniem technik wprowadzonych w C# 7.0.
Ponadto przydatna jest wiedza o tym, czy dana funkcja języka wymaga wsparcia
ze strony środowiska uruchomieniowego lub platformy, czy polega tylko na sztuczkach
stosowanych przez kompilator. Ten dodatek ma udostępniać tego rodzaju informacje
w możliwe prostej postaci.
Jednym z aspektów, o których do tego miejsca nie wspominałem, jest ewolucja
wnioskowania typów generycznych w poszczególnych wersjach języka. Mechanizm
ten zmieniał się wielokrotnie i zwykle w sposób, który jest zbyt skomplikowany do
opisania w krótkich słowach. Zachęcam do tego, aby przyjmować, że w każdej nowej
wersji wnioskowanie typów generycznych mogło zostać usprawnione.
C# 2
Typy generyczne Wymagane wsparcie środowiska 2.1
uruchomieniowego i platformy
Typy bezpośrednie przyjmujące null Wymagane wsparcie środowiska 2.2
uruchomieniowego i platformy
Konwersje grup metod 2.3.1
Metody anonimowe 2.3.2
Wariancja i kontrawariancja delegatów1 2.3.3
Iteratory (instrukcja yield return) 2.4
Typy częściowe 2.5.1
Klasy statyczne 2.5.2
Różne poziomy dostępu dla getterów 2.5.3
i setterów właściwości
1
Dotyczy tworzenia delegata na podstawie metody o zgodnej, ale nieidentycznej sygnaturze. Nie jest
to tym samym co wariancja typów generycznych wprowadzona w C# 4.
87469504f326f0d7c1fcda56ef61bd79
8
526 DODATEK A. Funkcje języka wprowadzone w poszczególnych wersjach
C# 3
Metody częściowe 2.5.1
Automatycznie implementowane 3.1
właściwości
Niejawne typowanie zmiennych 3.2.2
lokalnych (var)
Niejawne typowanie tablic (new[]) 3.2.3
Inicjalizatory obiektów 3.3.2
Inicjalizatory kolekcji 3.3.3
Typy anonimowe 3.4
Wyrażenia lambda (delegaty) 3.5
Wyrażenia lambda (drzewa wyrażeń) Wymaga wsparcia platformy 3.5.3
(typy drzew wyrażeń)
Metody rozszerzające Wymaga wsparcia platformy (atrybuty) 3.6
Wyrażenia reprezentujące zapytania 3.7
C# 4
Typowanie dynamiczne Wymagane wsparcie platformy 4.1
(środowisko Dynamic Language
Runtime, które jednak nie jest częścią
środowiska uruchomieniowego)
Parametry opcjonalne 4.2
Argumenty nazwane 4.2
Konsolidowane podzespoły PIA Wymagane wsparcie środowiska 4.3.1
uruchomieniowego i platformy
Specjalne reguły stosowania 4.3.2
parametrów opcjonalnych w COM
Dostęp do indekserów nazwanych 4.3.3
(tylko w COM)
Generyczna wariancja dla interfejsów Zmiany w platformie dotyczące 4.4
i delegatów interfejsów i delegatów (wsparcie
środowiska uruchomieniowego było
już dostępne)
Zmiany w implementacji instrukcji lock Wymagane wsparcie platformy: Wydanie trzecie,
Monitor.Enter(object, ref bool) punkt 13.4.1
Zmiany w implementacji zdarzeń Wydanie trzecie,
podobnych do pól punkt 13.4.2
Dostęp do zdarzeń podobnych do pól Wydanie trzecie,
w klasie z ich deklaracją punkt 13.4.2
87469504f326f0d7c1fcda56ef61bd79
8
DODATEK A. Funkcje języka wprowadzone w poszczególnych wersjach 527
C# 5
Async/await Wymagane wsparcie platformy Rozdziały 5. i 6.
(typy zadań i dodatkowa infrastruktura
używana przez kompilator)
Modyfikacje w przechwytywaniu Zmiany w działaniu, ale tylko w kodzie, 7.1
zmiennej iteracyjnej w pętli foreach który był prawie na pewno błędny
we wcześniejszych wersjach
Atrybuty z informacjami o jednostce Wymagane wsparcie platformy 7.2
wywołującej (same atrybuty)
C# 6
Automatycznie implementowane 8.2.1
właściwości tylko do odczytu
Inicjalizatory automatycznie 8.2.2
implementowanych właściwości
Wyeliminowanie wymogu wywoływania 8.2.3
this() w konstruktorach struktur
zawierających automatycznie
implementowane właściwości
Składowe z ciałem w postaci wyrażenia 8.3
Literały tekstowe z interpolacją Dodatkowa obsługa typu 9.2, 9.3
FormattableString, gdy dostępne są
ta klasa i FormattableStringFactory
Operator nameof 9.5
Dyrektywa using static 10.1
Indeksery w inicjalizatorach obiektów 10.2.1
Inicjalizatory kolekcji używające metod 10.2.2
rozszerzających Add
Operator ?. 10.3
Filtry wyjątków 10.4
Usunięte restrykcje dotyczące 5.4.2
oczekiwania w blokach try z blokiem
catch, w blokach catch i w blokach finally
C# 7.0
Krotki Wsparcie platformy (typy ValueTuple) 11.2 – 11.4
Podział z użyciem metod Deconstruct Przed udostępnieniem kompilatora C# 12.1, 12.2
7.2 wymagany był typ ValueTuple, jednak
nie jest to funkcja języka C# 7.2
(wprowadzona została tylko zmiana
implementacji)
Pierwsze wzorce: wzorce stałych, 12.4
wzorce typów i wzorzec var
Używanie wzorców z operatorem is 12.5
Używanie wzorców w instrukcjach switch, 12.6
w tym klauzule zabezpieczające (when)
Zmienne lokalne ref 13.2.1
Zwracane wartości ref 13.2.2
Dwójkowe literały całkowitoliczbowe 14.3.1
Separatory w postaci podkreślenia 14.3.2
w literałach liczbowych
87469504f326f0d7c1fcda56ef61bd79
8
528 DODATEK A. Funkcje języka wprowadzone w poszczególnych wersjach
C# 7.1
Literał default 14.5
Usprawnienia dopasowywania wzorców 12.4.2
typów do wartości typów generycznych
Asynchroniczne punkty wejścia 5.9
(async Task Main)
Wnioskowanie nazw elementów krotek 11.2.2
C# 7.2
Możliwość współdziałania operatora ?: 13.2.3
z wartościami z modyfikatorem ref
Zmienne lokalne i zwracane wartości Metody zwracające wartość 13.2.4
z modyfikatorem ref readonly z modyfikatorem ref readonly mogą być
wywoływane tylko przez kompilatory,
które go rozumieją. Ponadto w czasie
kompilacji wymagany jest atrybut
InAttribute, jest on jednak dostępny
od wersji .NET 1.1 i .NET Standard 1.1
Parametry in Wymaga atrybutu IsReadOnlyAttribute, 13.3
który jednak jest dodawany
do podzespołu, jeśli jest niedostępny
w docelowej platformie
Struktury tylko do odczytu Wymaga atrybutu IsReadOnlyAttribute 13.4
(tak jak w poprzednim punkcie)
Metody rozszerzające z parametrami 13.5
ref i in
Struktury przypominające referencje Wymaga atrybutu IsReadOnlyAttribute 13.6
(co opisano wcześniej). Ponadto takie
struktury mają atrybut ObsoleteAttribute
z określonym komunikatem. Wersje
kompilatorów obsługujące takie struktury
ignorują ten atrybut, jednak wcześniejsze
kompilatory uniemożliwiają stosowanie
takich typów
Obsługa wywołania stackalloc dla typu Wymagane wsparcie platformy 13.6.2
Span<T>
Argumenty nazwane niewystępujące 14.6
na końcu listy argumentów
Modyfikator dostępu private protected 14.7
Separatory podkreślenia w literałach 14.3.2
liczbowych bezpośrednio
po specyfikatorze podstawy 0x lub 0b
C# 7.3
Dostęp do buforów o stałej długości 2.5.6
za pomocą pól bez konieczności
podawania instrukcji fixed
Operatory == i != dla krotek Konieczna jest dostępność krotek, 11.3.6
ale nie ma nowych wymagań
87469504f326f0d7c1fcda56ef61bd79
8
DODATEK A. Funkcje języka wprowadzone w poszczególnych wersjach 529
87469504f326f0d7c1fcda56ef61bd79
8
87469504f326f0d7c1fcda56ef61bd79
8
87469504f326f0d7c1fcda56ef61bd79
8