Download as pdf or txt
Download as pdf or txt
You are on page 1of 531

Jon Skeet

od
p o d sz e w k i

Wydanie IV
Tytuł oryginału: C# in Depth, Fourth Edition

Tłumaczenie: Tomasz Walczak

Projekt okładki: Studio Gravite / Olsztyn; Obarek, Pokoński, Pazdrijowski, Zaprucki

ISBN: 978-83-283-6030-3

Original edition copyright © 2019 by Manning Publications Co.


All rights reserved.

Polish edition copyright © 2020 by Helion SA


All rights reserved.

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.

Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu


niniejszej publikacji w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii
metodą kserograficzną, fotograficzną, a także kopiowanie książki na nośniku filmowym,
magnetycznym lub innym powoduje naruszenie praw autorskich niniejszej publikacji.

Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź


towarowymi ich właścicieli.

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ę.

 Poleć książkę na Facebook.com  Księgarnia internetowa


 Kup w wersji papierowej  Lubię to! » Nasza społeczność
 Oceń książkę

87469504f326f0d7c1fcda56ef61bd79
8
Rekomendacje do wydania trzeciego:

„Lektura obowiązkowa, którą każdy programista używający platformy .NET powinien


przeczytać przynajmniej raz”.
— Dror Helper, architekt oprogramowania w firmie Better Place

„C# od podszewki to najlepsze źródło do nauki mechanizmów języka C#”.


— Andy Kirsch, architekt oprogramowania w firmie Venga

„Ta książka pomogła mi podnieść wiedzę na temat języka C# na następny poziom”.


— Dustin Laine, właściciel firmy Code Harvest

„Ta książka otworzyła mi oczy na ciekawy język programowania, który do niedawna


niesłusznie ignorowałem”.
— Ivan Todorović, starszy programista w firmie AudatexGmbH (Szwajcaria)

„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

Rekomendacje do wydania drugiego:

„Jeśli chcesz zgłębić język C#, ta książka jest lekturą obowiązkową”.


— Tyson S. Maxwell, starszy inżynier oprogramowania w firmie Raytheon

„Idziemy o zakład, że będzie to najlepsza książka na temat C# 4.0”.


— Nikander Bruggeman i Margriet Bruggeman,
konsultanci z zakresu platformy .NET z firmy Lois & Clark IT Services

„Przydatny i ciekawy wgląd w ewolucję języka C# 4”.


— Joe Albahari, autor książek LINQPad i C# 4.0 in a Nutshell

„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

„Znajdziesz tu wszystko, co powinieneś wiedzieć o języku C#, a z czego nie zdawałeś


sobie sprawy”.
— Jared Parsons, starszy inżynier ds. rozwoju oprogramowania w firmie Microsoft

Rekomendacje do wydania pierwszego:

„W prostych słowach — C# od podszewki to prawdopodobnie najlepsza książka infor-


matyczna, jaką czytałem”.
— Craig Pelie, autor książki System iNetwork

„Programuję w C# od czasu powstania tego języka, a w tej książce znalazłem ciekawe


niespodzianki, które zaskoczyły nawet mnie. Duże wrażenie zrobił na mnie przede
wszystkim świetny opis delegatów, metod anonimowych, kowariancji i kontrawariancji.
Nawet jeśli jesteś doświadczonym programistą, z C# od podszewki dowiesz się czegoś
nowego na temat języka C# […] Żadna inna książka o języku C# nie zbliża się nawet
do poziomu tej pozycji”.
— Adam J. Wolf, grupa użytkowników .NET z Southeast Valley

„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

„Każde pojęcie jest tu użyte odpowiednio i we właściwym kontekście, a każdy przykład


jest dobrze dobrany i zawiera najmniejszą ilość kodu potrzebną do pełnego opisu
danego mechanizmu; jest to rzadka cecha”.
— Franck Jeannin, z recenzji w sklepie Amazon UK

„Jeśli programujesz w języku C# od kilku lat i chcesz poznać jego mechanizmy, ta


książka będzie dla Ciebie idealna”.
— Golo Roden, autor, prelegent i instruktor z zakresu platformy .NET
i technologii pokrewnych

„Najlepsza książka na temat języka C#, jaką kiedykolwiek czytałem”.


— Chris Mullins, posiadacz tytułu C# MVP

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ĘŚĆ 1. KONTEKST JĘZYKA C# ................................................................ 29


Rozdział 1. Przetrwają najbystrzejsi 31
1.1. Ewoluujący język 31
1.1.1. System typów pomocny w dużej i małej skali 32
1.1.2. Jeszcze bardziej zwięzły kod 34
1.1.3. Prosty dostęp do danych w technologii LINQ 38
1.1.4. Asynchroniczność 38
1.1.5. Równowaga między wydajnością a złożonością 40
1.1.6. Przyspieszona ewolucja — używanie podwersji 41
1.2. Ewoluująca platforma 42
1.3. Ewoluująca społeczność 43
1.4. Ewoluująca książka 44
1.4.1. Wyjaśnienia na różnym poziomie 45
1.4.2. Przykłady, w których wykorzystano projekt Noda Time 45
1.4.3. Terminologia 46
Podsumowanie 47

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

2.2. Typy bezpośrednie przyjmujące wartość null 69


2.2.1. Cel — reprezentowanie braku informacji 69
2.2.2. Wsparcie w środowisku CLR i platformie — struktura Nullable<T> 70
2.2.3. Obsługa dostępna w języku 74
2.3. Uproszczone tworzenie delegatów 80
2.3.1. Konwersje grupy metod 81
2.3.2. Metody anonimowe 81
2.3.3. Zgodność delegatów 83
2.4. Iteratory 84
2.4.1. Wprowadzenie do iteratorów 85
2.4.2. Leniwe wykonywanie 86
2.4.3. Przetwarzanie instrukcji yield 87
2.4.4. Znaczenie leniwego wykonywania 88
2.4.5. Przetwarzanie bloków finally 89
2.4.6. Znaczenie obsługi bloku finally 92
2.4.7. Zarys implementacji 93
2.5. Mniej istotne mechanizmy 98
2.5.1. Typy częściowe 98
2.5.2. Klasy statyczne 100
2.5.3. Inny poziom dostępu do getterów i setterów właściwości 101
2.5.4. Aliasy przestrzeni nazw 101
2.5.5. Dyrektywy pragma 103
2.5.6. Bufory o stałej wielkości 104
2.5.7. Atrybut InternalsVisibleTo 105
Podsumowanie 106

Rozdział 3. C# 3 — technologia LINQ i wszystko, co z nią związane 107


3.1. Automatycznie implementowane właściwości 108
3.2. Niejawne określanie typów 108
3.2.1. Terminologia związana z typami 109
3.2.2. Zmienne lokalne z typowaniem niejawnym (var) 110
3.2.3. Tablice z niejawnym typowaniem 112
3.3. Inicjalizatory obiektów i kolekcji 113
3.3.1. Wprowadzenie do inicjalizatorów obiektów i kolekcji 113
3.3.2. Inicjalizatory obiektów 115
3.3.3. Inicjalizatory kolekcji 116
3.3.4. Zalety inicjowania za pomocą jednego wyrażenia 118
3.4. Typy anonimowe 118
3.4.1. Składnia i podstawy działania 119
3.4.2. Typ generowany przez kompilator 121
3.4.3. Ograniczenia 122
3.5. Wyrażenia lambda 123
3.5.1. Składnia wyrażeń lambda 124
3.5.2. Przechwytywanie zmiennych 126
3.5.3. Drzewa wyrażeń 133
3.6. Metody rozszerzające 135
3.6.1. Deklarowanie metody rozszerzającej 136
3.6.2. Wywoływanie metod rozszerzających 136
3.6.3. Łączenie wywołań metod w łańcuch 138

87469504f326f0d7c1fcda56ef61bd79
8
Spis treści 9

3.7. Wyrażenia reprezentujące zapytania 140


3.7.1. Wyrażenia reprezentujące zapytania są przekształcane
z kodu C# na inny kod C# 140
3.7.2. Zmienne zakresu i identyfikatory przezroczyste 141
3.7.3. Kiedy stosować poszczególne składnie w LINQ? 142
3.8. Efekt końcowy — technologia LINQ 143
Podsumowanie 144

Rozdział 4. Zwiększanie współdziałania z innymi technologiami 145


4.1. Typowanie dynamiczne 146
4.1.1. Wprowadzenie do typowania dynamicznego 146
4.1.2. Dynamiczne operacje poza mechanizmem refleksji 151
4.1.3. Krótkie spojrzenie na zaplecze 156
4.1.4. Ograniczenia i niespodzianki związane z typowaniem dynamicznym 160
4.1.5. Sugestie dotyczące użytkowania 164
4.2. Parametry opcjonalne i argumenty nazwane 166
4.2.1. Parametry o wartościach domyślnych i argumenty z nazwami 167
4.2.2. Określanie znaczenia wywołań metody 168
4.2.3. Wpływ na wersjonowanie 170
4.3. Usprawnienia w zakresie współdziałania z technologią COM 172
4.3.1. Konsolidacja podzespołów PIA 172
4.3.2. Parametry opcjonalne w COM 174
4.3.3. Indeksery nazwane 175
4.4. Wariancja generyczna 176
4.4.1. Proste przykłady zastosowania wariancji 176
4.4.2. Składnia wariancji w deklaracjach interfejsów i delegatów 178
4.4.3. Ograniczenia dotyczące wariancji 179
4.4.4. Wariancja generyczna w praktyce 181
Podsumowanie 183

Rozdział 5. Pisanie kodu asynchronicznego 185


5.1. Wprowadzenie do funkcji asynchronicznych 187
5.1.1. Bliskie spotkania asynchronicznego stopnia 187
5.1.2. Analiza pierwszego przykładu 189
5.2. Myślenie o asynchroniczności 190
5.2.1. Podstawy asynchronicznego wykonywania kodu 191
5.2.2. Konteksty synchronizacji 192
5.2.3. Model działania metod asynchronicznych 193
5.3. Deklaracje metod asynchronicznych 195
5.3.1. Typy wartości zwracanych przez metody asynchroniczne 196
5.3.2. Parametry metod asynchronicznych 197
5.4. Wyrażenia await 197
5.4.1. Wzorzec awaitable 198
5.4.2. Ograniczenia dotyczące wyrażeń await 200
5.5. Opakowywanie zwracanych wartości 202
5.6. Przepływ sterowania w metodzie asynchronicznej 203
5.6.1. Na co kod oczekuje i kiedy? 203
5.6.2. Przetwarzanie wyrażeń await 205

87469504f326f0d7c1fcda56ef61bd79
8
10 Spis treści

5.6.3. Używanie składowych zgodnych ze wzorcem awaitable 208


5.6.4. Wypakowywanie wyjątków 208
5.6.5. Ukończenie pracy metody 211
5.7. Asynchroniczne funkcje anonimowe 216
5.8. Niestandardowe typy zadań w C# 7 217
5.8.1. Typ przydatny w 99,9% przypadków — ValueTask<TResult> 217
5.8.2. 0,1% sytuacji — tworzenie własnych niestandardowych typów zadań 220
5.9. Asynchroniczne metody main w C# 7.1 222
5.10. Wskazówki dotyczące korzystania z asynchroniczności 223
5.10.1. Jeśli jest to akceptowalne, używaj ConfigureAwait,
aby nie przechwytywać kontekstu 223
5.10.2. Włączanie przetwarzania równoległego dzięki uruchomieniu wielu
niezależnych zadań 225
5.10.3. Unikaj łączenia kodu synchronicznego z asynchronicznym 226
5.10.4. Wszędzie, gdzie to możliwe, zezwalaj na anulowanie operacji 226
5.10.5. Testowanie kodu asynchronicznego 227
Podsumowanie 228

Rozdział 6. Implementacja asynchroniczności 229


6.1. Struktura wygenerowanego kodu 231
6.1.1. Metoda kontrolna — przygotowania i pierwszy krok 233
6.1.2. Struktura maszyny stanowej 235
6.1.3. Metoda MoveNext() (ogólny opis) 238
6.1.4. Metoda SetStateMachine i taniec z opakowywaniem maszyny stanowej 240
6.2. Prosta implementacja metody MoveNext() 241
6.2.1. Kompletny konkretny przykład 241
6.2.2. Ogólna struktura metody MoveNext() 243
6.2.3. Zbliżenie na wyrażenia await 245
6.3. Jak przepływ sterowania wpływa na metodę MoveNext()? 247
6.3.1. Przepływ sterowania między wyrażeniami await jest prosty 247
6.3.2. Oczekiwanie w pętli 248
6.3.3. Oczekiwanie w bloku try/finally 250
6.4. Kontekst wykonania i przekazywanie kontekstu 253
6.5. Jeszcze o niestandardowych typach zadań 254
Podsumowanie 255

Rozdział 7. Dodatkowe mechanizmy z C# 5 257


7.1. Przechwytywanie zmiennych w pętlach foreach 257
7.2. Atrybuty z informacjami o jednostce wywołującej 259
7.2.1. Podstawowe działanie 259
7.2.2. Rejestrowanie informacji w dzienniku 261
7.2.3. Upraszczanie implementacji interfejsu INotifyPropertyChanged 261
7.2.4. Przypadki brzegowe dotyczące atrybutów z informacjami
o jednostce wywołującej 263
7.2.5. Używanie atrybutów z informacjami o jednostce wywołującej
w starszych wersjach platformy .NET 269
Podsumowanie 270

87469504f326f0d7c1fcda56ef61bd79
8
Spis treści 11

CZĘŚĆ 3. C# 6 .......................................................................................... 271


Rozdział 8. Odchudzone właściwości i składowe z ciałem
w postaci wyrażenia 273
8.1. Krótka historia właściwości 274
8.2. Usprawnienia automatycznie implementowanych właściwości 276
8.2.1. Automatycznie implementowane właściwości przeznaczone tylko
do odczytu 276
8.2.2. Inicjalizowanie automatycznie implementowanych właściwości 277
8.2.3. Automatycznie implementowane właściwości w strukturach 279
8.3. Składowe z ciałem w postaci wyrażenia 281
8.3.1. Jeszcze prostsze obliczanie właściwości tylko do odczytu 281
8.2.2. Metody, indeksery i operatory z ciałem w postaci wyrażenia 284
8.3.3. Ograniczenia dotyczące składowych z ciałem w postaci wyrażenia w C# 6 286
8.3.4. Wskazówki dotyczące używania składowych z ciałem w postaci wyrażenia 287
Podsumowanie 290

Rozdział 9. Mechanizmy związane z łańcuchami znaków 291


9.1. Przypomnienie technik formatowania łańcuchów znaków w .NET 292
9.1.1. Proste formatowanie łańcuchów znaków 292
9.1.2. Niestandardowe formatowanie z użyciem łańcuchów znaków
formatowania 293
9.1.3. Lokalizacja 295
9.2. Wprowadzenie do literałów tekstowych z interpolacją 299
9.2.1. Prosta interpolacja 299
9.2.2. Łańcuchy znaków formatowania w literałach tekstowych
z interpolacją 300
9.2.3. Dosłowne literały tekstowe z interpolacją 300
9.2.4. Obsługa literałów tekstowych z interpolacją przez kompilator (część 1.) 302
9.3. Lokalizacja z użyciem typu FormattableString 302
9.3.1. Obsługa literałów tekstowych z interpolacją przez kompilator (część 2.) 303
9.3.2. Formatowanie obiektu typu FormattableString z użyciem określonych
ustawień regionalnych 305
9.3.3. Inne zastosowania typu FormattableString 306
9.3.4. Używanie typu FormattableString w starszych wersjach
platformy .NET 310
9.4. Zastosowania, wskazówki i ograniczenia 311
9.4.1. Programiści i maszyny, ale raczej nie użytkownicy końcowi 311
9.4.2. Sztywne ograniczenia literałów tekstowych z interpolacją 314
9.4.3. Kiedy można stosować literały tekstowe z interpolacją,
ale nie należy tego robić? 315
9.5. Dostęp do identyfikatorów za pomocą operatora nameof 317
9.5.1. Pierwsze przykłady stosowania operatora nameof 317
9.5.2. Standardowe zastosowania operatora nameof 319
9.5.3. Sztuczki i kruczki związane z używaniem operatora nameof 322
Podsumowanie 325

87469504f326f0d7c1fcda56ef61bd79
8
12 Spis treści

Rozdział 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu 325


10.1. Dyrektywa using static 325
10.1.1. Importowanie składowych statycznych 326
10.1.2. Metody rozszerzające i dyrektywa using static 329
10.2. Usprawnienia inicjalizatorów obiektów i kolekcji 331
10.2.1. Indeksery w inicjalizatorach obiektów 331
10.2.2. Używanie metod rozszerzających w inicjalizatorach kolekcji 335
10.2.3. Kod testów a kod produkcyjny 339
10.3. Operator ?. 340
10.3.1. Proste i bezpieczne dereferencje właściwości 340
10.3.2. Szczegółowe omówienie operatora ?. 341
10.3.3. Obsługa porównań logicznych 342
10.3.4. Indeksery i operator ?. 344
10.3.5. Skuteczne używanie operatora ?. 344
10.3.6. Ograniczenia operatora ?. 346
10.4. Filtry wyjątków 346
10.4.1. Składnia i semantyka filtrów wyjątków 347
10.4.2. Ponawianie operacji 352
10.4.3. Zapis danych w dzienniku jako efekt uboczny 354
10.4.4. Pojedyncze, specyficzne filtry wyjątków 355
10.4.5. Dlaczego po prostu nie zgłaszać wyjątków? 355
Podsumowanie 356

CZĘŚĆ 4. C# 7 I PRZYSZŁE WERSJE .........................................................357


Rozdział 11. Łączenie danych z użyciem krotek 359
11.1. Wprowadzenie do krotek 360
11.2. Literały i typy krotek 361
11.2.1. Składnia 361
11.2.2. Wnioskowanie nazw elementów w literałach krotek (C# 7.1) 364
11.2.3. Krotki jako zbiory zmiennych 365
11.3. Typy krotek i konwersje 369
11.3.1. Typy literałów krotek 369
11.3.2. Konwersje z literałów krotek na typy krotek 371
11.3.3. Konwersja między typami krotek 374
11.3.4. Zastosowania konwersji 377
11.3.5. Sprawdzanie nazw elementów przy dziedziczeniu 377
11.3.6. Operatory równości i nierówności (C# 7.3) 378
11.4. Krotki w środowisku CLR 379
11.4.1. Wprowadzenie do typów System.ValueTuple<…> 379
11.4.2. Obsługa nazw elementów 380
11.4.3. Implementacje konwersji krotek 381
11.4.4. Tekstowe reprezentacje krotek 382
11.4.5. Standardowe porównania na potrzeby sprawdzania równości
i sortowania 383
11.4.6. Strukturalne porównania na potrzeby sprawdzania równości
i sortowania 384

87469504f326f0d7c1fcda56ef61bd79
8
Spis treści 13

11.4.7. Krotki jednowartościowe i duże krotki 386


11.4.8. Niegeneryczna struktura ValueTuple 387
11.4.9. Metody rozszerzające 387
11.5. Alternatywy dla krotek 388
11.5.1. System.Tuple<…> 388
11.5.2. Typy anonimowe 388
11.5.3. Typy nazwane 389
11.6. Zastosowania i rekomendacje 389
11.6.1. Niepubliczne interfejsy API i kod, który można łatwo modyfikować 390
11.6.2. Zmienne lokalne 390
11.6.3. Pola 392
11.6.4. Krotki i typowanie dynamiczne nie współdziałają dobrze ze sobą 393
Podsumowanie 394

Rozdział 12. Podział krotek i dopasowywanie wzorców 395


12.1. Podział krotek 396
12.1.1. Podział na nowe zmienne 397
12.1.2. Używanie podziału do przypisywania wartości istniejącym zmiennym
i właściwościom 399
12.1.3. Szczegóły podziału literałów krotek 403
12.2. Podział typów innych niż krotki 403
12.2.1. Metody instancji odpowiedzialne za podział obiektów 403
12.2.2. Odpowiedzialne za podział metody rozszerzające a przeciążanie metod 404
12.2.3. Obsługa wywołań Deconstruct w kompilatorze 406
12.3. Wprowadzenie do dopasowywania wzorców 407
12.4. Wzorce dostępne w C# 7.0 409
12.4.1. Wzorce stałych 409
12.4.2. Wzorce typów 410
12.4.3. Wzorzec var 413
12.5. Używanie wzorców razem z operatorem is 414
12.6. Używanie wzorców w instrukcjach switch 416
12.6.1. Klauzule zabezpieczające 417
12.6.2. Zasięg zmiennej ze wzorca w klauzulach case 418
12.6.3. Kolejność przetwarzania w instrukcjach switch opartych na wzorcu 420
12.7. Przemyślenia na temat zastosowań opisanych mechanizmów 421
12.7.1. Wykrywanie możliwości podziału obiektów 422
12.7.2. Wykrywanie możliwości dopasowywania wzorców 422
Podsumowanie 423

Rozdział 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych


przez referencję 425
13.1. Przypomnienie — co wiesz o słowie kluczowym ref? 427
13.2. Zmienne lokalne ref i referencyjne zwracane wartości 429
13.2.1. Zmienne lokalne ref 430
13.2.2. Instrukcja return ref 435
13.2.3. Operator warunkowy ?: i wartości z modyfikatorem ref (C# 7.2) 437
13.2.4. Modyfikator ref readonly (C# 7.2) 438

87469504f326f0d7c1fcda56ef61bd79
8
14 Spis treści

13.3. Parametry in (C# 7.2) 440


13.3.1. Zgodność wstecz 441
13.3.2. Zaskakująca modyfikowalność parametrów in — zmiany zewnętrzne 442
13.3.3. Przeciążanie metod z użyciem parametrów in 444
13.3.4. Wskazówki dotyczące parametrów in 444
13.4. Deklarowanie struktur tylko do odczytu (C# 7.2) 446
13.4.1. Wprowadzenie — niejawne kopiowanie zmiennych tylko do odczytu 446
13.4.2. Modyfikator readonly dla struktur 449
13.4.3. Serializowane dane w XML-u są z natury przeznaczone
do odczytu i zapisu 450
13.5. Metody rozszerzające z parametrami ref i in (C# 7.2) 451
13.5.1. Używanie parametrów ref i in w metodach rozszerzających,
aby uniknąć kopiowania 451
13.5.2. Ograniczenia dotyczące metod rozszerzających
z pierwszym parametrem ref lub in 453
13.6. Struktury referencyjne (C# 7.2) 454
13.6.1. Reguły dotyczące struktur referencyjnych 455
13.6.2. Typ Span<T> i wywołanie stackalloc 456
13.6.3. Reprezentacja struktur referencyjnych w kodzie pośrednim 460
Podsumowanie 461

Rozdział 14. Zwięzły kod w C# 7 463


14.1. Metody lokalne 463
14.1.1. Dostęp do zmiennych w metodach lokalnych 465
14.1.2. Implementowanie metod lokalnych 468
14.1.3. Wskazówki dotyczące użytkowania 473
14.2. Zmienne out 476
14.2.1. Wewnątrzwierszowe deklaracje zmiennych na potrzeby parametrów out 476
14.2.2. Zniesione w C# 7.3 ograniczenia dotyczące zmiennych out i zmiennych
generowanych we wzorcach 477
14.3. Usprawnienia w literałach liczbowych 478
14.3.1. Dwójkowe literały całkowitoliczbowe 478
14.3.2. Separatory w postaci podkreślenia 479
14.4. Wyrażenia throw 480
14.5. Literał default (C# 7.1) 481
14.6. Argumenty nazwane w dowolnym miejscu listy argumentów (C# 7.2) 482
14.7. Dostęp private protected (C# 7.2) 484
14.8. Drobne usprawnienia z C# 7.3 484
14.8.1. Ograniczenia typów generycznych 484
14.8.2. Usprawnienia w wyborze wersji przeciążonych metod 485
14.8.3. Atrybuty pól powiązanych z automatycznie implementowanymi
właściwościami 486
Podsumowanie 487

87469504f326f0d7c1fcda56ef61bd79
8
Spis treści 15

Rozdział 15. C# 8 i kolejne wersje 489


15.1. Typy referencyjne przyjmujące wartość null 490
15.1.1. Jaki problem rozwiązują typy referencyjne przyjmujące wartość null? 490
15.1.2. Zmiana działania typów referencyjnych w kontekście wartości null 491
15.1.3. Poznaj typy referencyjne przyjmujące null 492
15.1.4. Działanie typów referencyjnych przyjmujących null w czasie kompilacji i w
czasie wykonywania kodu 493
15.1.5. Operator „a niech to” 496
15.1.6. Wrażenia z wprowadzania typów referencyjnych przyjmujących null 498
15.1.7. Przyszłe usprawnienia 500
15.2. Wyrażenia switch 504
15.3. Rekurencyjne dopasowywanie wzorców 506
15.3.1. Dopasowywanie z użyciem właściwości we wzorcach 507
15.3.2. Wzorce oparte na podziale 507
15.3.3. Pomijanie typów we wzorcach 508
15.4. Indeksy i przedziały 509
15.4.1. Typy i literały Index i Range 510
15.4.2. Stosowanie indeksów i przedziałów 511
15.5. Lepsza integracja asynchroniczności 512
15.5.1. Asynchroniczne zwalnianie zasobów z użyciem instrukcji using await 512
15.5.2. Asynchroniczne iteracje z użyciem instrukcji foreach await 514
15.5.3. Asynchroniczne iteratory 517
15.6. Funkcje, które nie znalazły się w wersji zapoznawczej 518
15.6.1. Domyślne metody interfejsu 518
15.6.2. Typy rekordowe 520
15.6.3. Krótki opis jeszcze innych funkcji 521
15.7. Udział w pracach 523
Wnioski 523

Dodatek A. Funkcje języka wprowadzone w poszczególnych wersjach 525

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.

W części 2. opisane są wersje języka C# od 2. do 5. Jest to zmodyfikowana i skrócona


wersja tekstu z trzeciego wydania książki.

87469504f326f0d7c1fcda56ef61bd79
8
24 O książce

 W rozdziale 2. przedstawione są różne mechanizmy wprowadzone w C# 2, w


tym typy generyczne, typy przyjmujące wartość null, metody anonimowe i ite-
ratory.
 W rozdziale 3. wyjaśniam, jak mechanizmy z C# 3 tworzą technologię LINQ.
Najważniejsze funkcje opisane w tym rozdziale to wyrażenia lambda, typy
anonimowe, inicjalizatory obiektów i wyrażenia w postaci zapytań.
 W rozdziale 4. opisane są mechanizm z C# 4. Największą zmianą w tej wersji
było wprowadzenie dynamicznego określania typów. Pojawiły się też jednak inne
modyfikacje związane z parametrami opcjonalnymi, argumentami nazwanymi,
generyczną wariancją i ułatwieniem współdziałania z technologią COM.
 W rozdziale 5. rozpoczyna się omawianie głównego mechanizmu wprowadzonego
w C# 5 — async/await. W tym rozdziale opisuję, jak stosować tę technikę, ale nie
objaśniam szczegółowo jej działania na zapleczu. Przedstawiam tu także wzbo-
gacające asynchroniczność mechanizmy wprowadzone w nowszych wersjach
języka C#, w tym niestandardowe typy zadań i asynchroniczną metodę main.
 Rozdział 6. zawiera uzupełnienie omówienia techniki async/await. Tu szcze-
gółowo opisuję, w jaki sposób kompilator obsługuje metody asynchroniczne,
tworząc maszyny stanowe.
 Rozdział 7. zawiera krótkie omówienie kilku wprowadzonych w wersji C# 5
mechanizmów innych niż async/await. Po szczegółach zaprezentowanych w roz-
dziale 6. możesz potraktować rozdział 7. jako przerywnik przed przejściem do
następnej części książki.

Część 3. zawiera szczegółowe omówienie C# 6.


 W rozdziale 8. omawiam składowe z ciałem w postaci wyrażenia. Pozwalają one
wyeliminować długą składnię przy deklarowaniu bardzo prostych właściwości
i metod. Opisuję tu też automatycznie implementowane właściwości. Prezento-
wane tu techniki pozwalają skrócić kod źródłowy.
 Rozdział 9. zawiera opis mechanizmów języka C# 6 dotyczących łańcuchów
znaków: interpolowanych literałów i operatora nameof. Choć oba te mechanizmy
to jedynie nowe sposoby generowania łańcuchów znaków, należą do najwygod-
niejszych aspektów wersji C# 6.
 W rozdziale 10. przedstawiam pozostałe funkcje wprowadzone w C# 6. Ich
jedyną cechą wspólną jest to, że pomagają pisać zwięzły kod źródłowy. Spośród
opisanych tu mechanizmów prawdopodobnie najprzydatniejszy jest operator ??.
Pozwala on w przejrzysty sposób zapisywać wyrażenia z przetwarzaniem skróco-
nym, w których mogą wystąpić wartości null. Pomaga to uniknąć nielubianego
wyjątku NullReferenceException.

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

 Rozdział 12. zawiera wprowadzenie do dekonstruktorów i dopasowywania wzor-


ców. Są to techniki pozwalające w zwięzły sposób przetwarzać istniejące wartości.
Dopasowywanie wzorców w instrukcji switch pomaga uprościć obsługę różnych
typów wartości w sytuacji, gdy nie można wykorzystać dziedziczenia.
 W rozdziale 13. omawiam przekazywanie przez referencję i powiązane mecha-
nizmy. Choć parametry ref są dostępne w C# od pierwszej wersji tego języka,
w C# 7 wprowadzono wiele nowych technik, takich jak zwracanie zmiennych
ref i lokalne zmienne ref. Te mechanizmy mają przede wszystkim zwiększać
wydajność kodu dzięki ograniczeniu kopiowania.
 Rozdział 14. kończy omówienie wersji C# 7. Opisuję tu kolejny zbiór prostych
mechanizmów pozwalających skrócić kod. Moje ulubione techniki z tej grupy to
metody lokalne, zmienne out i literały domyślne. Dostępne są też jednak także
inne perełki.
 Rozdział 15. dotyczy przyszłości języka C#. Pracując z wersją wstępną języka C#
8 dostępną w czasie powstawania tej książki, zapoznałem się z typami referencyj-
nymi przyjmującymi wartość null, wyrażeniami switch, usprawnieniami dopa-
sowywania wzorców, a także przedziałami i dalszą integracją asynchroniczności
z podstawowymi mechanizmami języka. Cały ten rozdział zawiera spekulacje,
mam jednak nadzieję, że rozbudzi Twoją ciekawość.

W dodatku znajdziesz wygodną listę informacji o tym, które mechanizmy wprowadzono


w poszczególnych wersjach języka C#, a także o tym, czy występują wymogi doty-
czące środowiska uruchomieniowego lub platformy ograniczające kontekst używania tych
mechanizmów.
Zakładam, że rozdziały będą czytane po kolei (przynajmniej w trakcie pierwszej
lektury). Dalsze rozdziały są oparte na wcześniejszych, dlatego możesz natrafić na
trudności, jeśli spróbujesz czytać tekst w innej kolejności. Jednak po pierwszej lekturze
sensowne jest korzystanie z tej książki jak z encyklopedycznego źródła informacji.
Możesz wrócić do jakiegoś zagadnienia, gdy zechcesz przypomnieć sobie składnię lub
zapoznać się z daną kwestią dokładniej niż w trakcie pierwszego czytania.

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.

Inne materiały internetowe


W internecie dostępnych jest wiele materiałów na temat języka C#. Poniżej wymie-
nione są te uznane przez autora za najbardziej przydatne. Znajdziesz jednak także wiele
innych źródeł.
 Dokumentacja platformy.NET Microsoftu:
https://docs.microsoft.com/dotnet.
 Dokumentacja interfejsu API platformy .NET:
https://docs.microsoft.com/dotnet/api.
 Repozytorium używane do projektowania języka C#:
https://github.com/dotnet/csharplang.
 Repozytorium Roslyn:
https://github.com/dotnet/roslyn.
 Standard ECMA języka C#:
http://www.ecma-international.org/publications/standards/Ecma-334.htm.
 Serwis Stack Overflow:
https://stackoverflow.com.

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#

G dy studiowałem informatykę na uniwersytecie, jeden ze studentów poprawił


wykładowcę w kwestii pewnego szczegółu zapisanego na tablicy. Wykładowca spojrzał
nieco zirytowany i powiedział: „Tak, wiem. To uproszczenie. Naginam prawdę w tym
miejscu, aby pokazać większą prawdę”. Choć mam nadzieję, że w części I nie ukry-
wam zbyt wiele, zdecydowanie dotyczy ona większej prawdy.
Większość tej książki to szczegółowy opis języka C#. W niektórych miejscach
przyglądam mu się pod mikroskopem, aby dostrzec najdrobniejsze szczegóły. Jednak
najpierw w rozdziale 1. wzrok skierowany będzie na ogólną historię tego języka; pokażę
tam, jak C# wpasowuje się w ogólny kontekst świata informatyki.
Znajdziesz tu kod, który możesz potraktować jako przystawkę przed głównym
daniem, jakie serwowane jest w pozostałych rozdziałach książki. W tym miejscu szcze-
góły nie są istotne. Ta część dotyczy głównie idei i motywów występujących w rozwoju
języka C#. Dzięki temu rozwiniesz nastawienie, które pozwoli Ci docenić sposób reali-
zacji tych idei.
Do dzieła!

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

Wybór najciekawszych aspektów języka C# przedstawianych w tym miejscu nie był


łatwy. Niektóre techniki są fascynujące, ale rzadko stosowane. Inne są niezwykle ważne,
ale obecnie powszechnie znane programistom języka C#. Mechanizmy takie jak
async/await są pod wieloma względami bardzo przydatne, ale trudno jest je zwięźle
opisać. Bez dalszego przedłużania przejdźmy więc do tego, jak daleko C# zaszedł
w miarę upływu czasu.

1.1. Ewoluujący język


We wcześniejszych wydaniach książki znajdował się jeden przykład pokazujący ewolu-
cję języka w wersjach omawianych w poszczególnych wydaniach. Obecnie nie da się
tego zrobić w sposób ciekawy dla czytelników. Choć w rozbudowanej aplikacji można
zastosować prawie wszystkie nowe mechanizmy, w każdym fragmencie kodu zdatnym
do zamieszczenia na drukowanej stronie można wykorzystać tylko wybrane funkcje.

87469504f326f0d7c1fcda56ef61bd79
8
32 ROZDZIAŁ 1. Przetrwają najbystrzejsi

Dlatego w tym podrozdziale wybrałem najważniejsze moim zdaniem motywy w ewo-


lucji języka C# i przedstawiłem krótkie przykłady ilustrujące usprawnienia. Przed-
stawiona tu lista jest wysoce niekompletna. Ten rozdział nie ma też uczyć Cię oma-
wianych funkcji. Ma natomiast pokazywać, w jak dużym stopniu znane Ci funkcje
ulepszają język, a także dać przedsmak mechanizmów, których być może jeszcze nie
poznałeś.
Jeśli uważasz, że niektóre z omawianych mechanizmów są wzorowane na znanych
Ci językach, prawie na pewno masz rację. Zespół odpowiedzialny za C# nie waha
się wykorzystywać znakomitych pomysłów z innych języków i dostosowywać ich do
języka C#. To bardzo dobra informacja! Jako źródło inspiracji dla wielu funkcji języka
C# warto wymienić przede wszystkim język F#.
UWAGA. Możliwe, że największą wartością języka F# jest nie to, na co pozwala on używa-
jącym go programistom, ale jego wpływ na C#. Nie chcę przez to lekceważyć wartości F#
jako samodzielnego języka lub sugerować, że nie należy go bezpośrednio używać. Jednak
obecnie społeczność użytkowników języka C# jest znacznie większa od społeczności pro-
gramistów F# i powinna być wdzięczna twórcom F# za inspiracje dla zespołu rozwijającego
język C#.

Zacznijmy od jednego z najważniejszych aspektów języka C# — systemu typów.

1.1.1. System typów pomocny w dużej i małej skali


C# od początku był językiem z typowaniem statycznym. Typy zmiennych, parametrów,
wartości zwracanych przez metody itd. są podawane w kodzie. Im precyzyjniej określisz
kształt danych przyjmowanych i zwracanych przez kod, w tym większym stopniu kom-
pilator pomoże Ci uniknąć pomyłek.
Jest to prawdą zwłaszcza w sytuacji, gdy rozwijana aplikacja się rozrasta. Jeśli możesz
wyświetlić kod całego programu na jednym ekranie (lub utrzymywać go w całości
w pamięci), język z typowaniem statycznym nie daje dużych korzyści. Jednak wraz ze
wzrostem skali coraz ważniejsze staje się, aby kod w spójny i skuteczny sposób określał,
co robi. Możesz do tego wykorzystać dokumentację, jednak typowanie statyczne pozwala
przekazywać informacje w sposób czytelny dla komputera.
Wraz z ewolucją języka C# system typów zaczął umożliwiać bardziej precyzyjne
opisy. Najbardziej oczywistym przykładem są typy generyczne. W C# 1 można było
używać kodu o następującej postaci:
public class Bookshelf
{
public IEnumerable Books { get { ... } }
}

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

W C# 2 wprowadzono też typy o wartości null, co pozwala zapisać brak informacji


bezpośrednio, bez uciekania się do magicznych wartości (takich jak -1 dla indeksu
kolekcji lub DateTime.MinValue dla daty).
W C# 7 umożliwiono informowanie kompilatora, że struktura zdefiniowana przez
użytkownika powinna być niemodyfikowalna. Służą do tego deklaracje readonly struct.
Głównym przeznaczeniem tej techniki jest zwiększenie wydajności kodu generowa-
nego przez kompilator, jednak dodatkową korzyścią jest informowanie o zamiarach
programisty.
W C# 8 planuje się dodanie typów referencyjnych o wartości null, które mają jeszcze
bardziej poprawić możliwości komunikacji. Do tej pory nic w języku nie pozwalało
określić, czy referencja (zwracana wartość, parametr lub zwykła zmienna lokalna)
może mieć wartość null. Prowadzi to do powstawania kodu narażonego na błędy (jeśli
nie jesteś ostrożny) lub szablonowego kodu do sprawdzania poprawności (jeżeli jesteś
ostrożny). Żadne z tych rozwiązań nie jest idealne. W C# 8 będzie obowiązywać zało-
żenie, że jeśli programista nie poda bezpośrednio, iż dana zmienna może mieć wartość
null, wartość null będzie niedozwolona. Przyjrzyj się następującej deklaracji metody:
string Method(string x, string? y)

Za pomocą typów parametrów określono, że argument odpowiadający parametrowi x nie


powinien mieć wartości null, natomiast argument odpowiadający parametrowi y może
być równy null. Typ zwracanej wartości określa, że metoda nie będzie zwracać war-
tości null.
Inne zmiany w systemie plików w C# działają na mniejszą skalę i dotyczą imple-
mentowania pojedynczych metod, a nie powiązań różnych komponentów w dużym
systemie. W C# 3 wprowadzono typy anonimowe i zmienne lokalne z niejawnym typo-
waniem (var). Techniki te pomagają ograniczyć wadę niektórych języków z typowaniem
statycznym — rozwlekłość. Jeśli chcesz zastosować dane o określonej formie wyłącznie
w jednej metodzie i nigdzie indziej, tworzenie całego typu tylko na potrzeby tej metody
byłoby przesadą. Typy anonimowe pozwalają zwięźle określić kształt danych bez utraty
korzyści związanych z typowaniem statycznym:
var book = new { Title = "Lost in the Snow", Author = "Holly Webb" };
string title = book.Title; Nazwa i typ są sprawdzane
string author = book.Author; przez kompilator.

Typy anonimowe są używane przede wszystkim w zapytaniach w technologii LINQ.


Jednak zasada tworzenia typu wyłącznie na potrzeby jednej metody nie jest zależna od
tej technologii.
Podobnie zbędne wydaje się jawne podawanie typu zmiennej (za pomocą wywo-
łania konstruktora tego typu) inicjowanej w tej samej instrukcji. Dobrze wiem, która
z poniższych instrukcji jest dla mnie bardziej czytelna:
Dictionary<string, string> map1 = new Dictionary<string, string>(); Jawne
var map2 = new Dictionary<string, string>(); Niejawne typowanie.
typowanie.

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!

1.1.2. Jeszcze bardziej zwięzły kod


Jednym z powtarzających się aspektów związanych z nowymi mechanizmami w C#
jest możliwość zapisywania pomysłów w coraz bardziej zwięzły sposób. Tak jest w
obszarze systemów typów (dzięki typom anonimowym), ale dotyczy to także wielu innych
funkcji. Ten trend jest opisywany za pomocą wielu słów, określających głównie to, co
można wyeliminować dzięki nowym technikom. Mechanizmy języka C# pozwalają
ograniczyć ceregiele, usunąć szablonowy kod i uniknąć śmieci. Są to różne sposoby
opisu tych samych skutków. Nie chodzi o to, że obecnie nadmiarowy kod był błędny —
był jedynie rozpraszający i niepotrzebny. Przyjrzyj się kilku zmianom, jakie pojawiły
się w C# w tym obszarze.
KONSTRUKTORY I INICJOWANIE
Najpierw zastanów się nad tworzeniem i inicjowaniem obiektów. Prawdopodobnie
najbardziej (i w wielu etapach) zmieniły się delegaty. W C# 1 trzeba było napisać
odrębną metodę delegata, aby ją wskazać, a następnie utworzyć samego delegata za
pomocą długiej składni. Na przykład aby zasubskrybować nową metodę obsługi zdarzeń
kliknięcia przycisku w C# 1, trzeba było napisać następujący kod:
button.Click += new EventHandler(HandleButtonClick); C# 1.

87469504f326f0d7c1fcda56ef61bd79
8
1.1. Ewoluujący język 35

W C# 2 wprowadzono konwersje grup metod i metody anonimowe. Jeśli chciałeś


zachować metodę HandleButtonClick, konwersje grup metod pozwalały zmodyfikować
wcześniejszy kod na następującą postać:
button.Click += HandleButtonClick; C# 2.

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);

Wprowadzone w C# 3 inicjalizatory obiektów i kolekcji sprawiają, że kod jest dużo


bardziej przejrzysty:
var order = new Order
{
OrderId = "xyz",

87469504f326f0d7c1fcda56ef61bd79
8
36 ROZDZIAŁ 1. Przetrwają najbystrzejsi

Customer = new Customer { Name = "Jon", Address = "UK" },


Items =
{
new OrderItem { ItemId = "abcd123", Quantity = 1 },
new OrderItem { ItemId = "fghi456", Quantity = 2 }
}
};

Nie sugeruję szczegółowego zapoznawania się z którymkolwiek z tych przykładów.


Ważne jest to, że drugi zapis jest dużo prostszy od pierwszego.
DEKLARACJE METOD I WŁAŚCIWOŚCI
Jednym z najbardziej oczywistych przykładów upraszczania zapisu są automatycznie
implementowane właściwości. Wprowadzono je w C# 3, jednak później zostały popra-
wione. Przyjrzyj się właściwości, której implementacja w C# 1 wyglądałaby tak:
private string name;
public string Name
{
get { return name; }
set { name = value; }
}

Automatycznie implementowane właściwości pozwalają zapisać tę właściwość w jednym


wierszu:
public string Name { get; set; }

Ponadto w C# 6 wprowadzono składowe z ciałem w postaci wyrażenia, które dodatkowo


eliminują ceregiele. Załóżmy, że piszesz klasę opakowującą istniejącą kolekcję łańcu-
chów znaków i chcesz oddelegować składowe Count i GetEnumerator() tej klasy do obsługi
wspomnianej kolekcji. W wersjach starszych niż C# 6 musiałbyś napisać kod podobny
do poniższego:
public int Count { get { return list.Count; } }

public IEnumerator<string> GetEnumerator()


{
return list.GetEnumerator();
}

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();

Choć poszczególni programiści mogą różnie oceniać przydatność składowych z ciałem


w postaci wyrażenia, byłem zaskoczony tym, jak dużą różnicę ta technika sprawiła
w czytelności mojego kodu. Uwielbiam takie składowe! Innym mechanizmem, o którym

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.

W C# 6 kod stał się nieco prostszy dzięki literałom z interpolowanymi łańcuchami


znaków, w których można bezpośrednio wstawić wartość identyfikatora id:
throw new KeyNotFoundException($"Nie istnieje kalendarz o identyfikatorze {id}.");

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

rozbudowywać ten rozdział i opisywać każdą funkcję z każdej wersji, przejdźmy do


mechanizmu, który był w większym stopniu rewolucją niż ewolucją — do technologii
LINQ.

1.1.3. Prosty dostęp do danych w technologii LINQ


Jeśli zapytasz programistów języka C#, co uwielbiają w tym języku, zapewne podadzą
technologię LINQ. Poznałeś już kilka mechanizmów związanych z tą technologią,
jednak najbardziej przełomowym z nich są wyrażenia w postaci zapytań. Spójrz na
poniższy kod:
var offers =
from product in db.Products
where product.SalePrice <= product.Price / 2
orderby product.SalePrice
select new {
product.Id, product.Description,
product.SalePrice, product.Price
};

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

waniu operacji asynchronicznych w popularnych językach. Stało się to dzięki mecha-


nizmowi async/await. Obejmuje on dwa uzupełniające się elementy związane z metodami
asynchronicznymi:
 Metody asynchroniczne generują wynik reprezentujący asynchroniczną opera-
cję. Nie wymaga to starań ze strony programisty. Wynik jest zwykle typu Task
lub Task<T>.
 W metodach asynchronicznych używane jest wyrażenie await do pobrania wyniku
asynchronicznej operacji. Jeśli metoda próbuje oczekiwać (await) na operację,
która nie została jeszcze ukończona, zostaje asynchronicznie wstrzymana do
czasu zakończenia tej operacji, po czym wznawia pracę.

Uwaga. Bardziej poprawne byłoby nazwanie takich asynchronicznych metod


funkcjami, ponieważ metody anonimowe i wyrażenia lambda też mogą być
asynchroniczne.
Sytuacja komplikuje się przy próbie precyzyjnego opisu, czym jest asynchroniczna
operacja i asynchroniczne wstrzymywanie. Na razie nie będę wyjaśniał tych kwestii.
Ważne jest to, że można pisać asynchroniczny kod, który wygląda prawie jak lepiej znany
programistom kod synchroniczny. To podejście umożliwia też naturalne stosowanie
współbieżności. Oto przykład — przyjrzyj się poniższej metodzie asynchronicznej, która
może być wywoływana w metodzie obsługi zdarzeń w aplikacji Windows Forms:
private async Task UpdateStatus()
{
Task<Weather> weatherTask = GetWeatherAsync Równoległe rozpoczynanie
Task<EmailStatus> emailTask = GetEmailStatusAsync(); dwóch operacji.

Weather weather = await weatherTask; Asynchroniczne oczekiwanie


EmailStatus email = await emailTask; na zakończenie tych operacji.

weatherLabel.Text = weather.Description; Aktualizowanie


inboxLabel.Text = email.InboxCount.ToString(); interfejsu użytkownika.
}

Ten kod, oprócz uruchamiania dwóch równoległych operacji i oczekiwania na ich


wyniki, pokazuje, że mechanizm async/await uwzględnia kontekst synchronizacji. Kod
aktualizuje interfejs użytkownika, co można zrobić tylko w wątku tego interfejsu,
a jednocześnie uruchamia długie operacje i oczekuje na ich ukończenie. Przed wpro-
wadzeniem mechanizmu async/await wymagało to skomplikowanego i podatnego na
błędy kodu.
Nie twierdzę, że mechanizm async/await jest uniwersalnym rozwiązaniem proble-
mów z asynchronicznością. Ta technika nie eliminuje w magiczny sposób złożoności
typowej dla tego obszaru. Pozwala jednak skupić się na trudnych aspektach asynchro-
niczności, ponieważ nie trzeba pisać dużych ilości szablonowego kodu, który był
potrzebny w przeszłości.
Wszystkie mechanizmy, które opisane zostały do tej pory, mają upraszczać kod.
Ostatni aspekt, o którym chcę wspomnieć, jest nieco inny.

87469504f326f0d7c1fcda56ef61bd79
8
40 ROZDZIAŁ 1. Przetrwają najbystrzejsi

1.1.5. Równowaga między wydajnością a złożonością


Pamiętam moje pierwsze zetknięcie z Javą. Był to w pełni interpretowany i boleśnie
powolny język. Po pewnym czasie wprowadzono opcjonalne kompilatory JIT (ang.
just-in-time), a ostatecznie przyjmowano prawie za pewnik, że każda implementacja Javy
używa kompilatora JIT.
Zapewnienie wysokiej wydajności Javy wymagało dużo pracy. Nikt nie wykonałby
tej pracy, gdyby język był kiepski. Jednak programiści dostrzegali jego potencjał
i odczuwali wzrost produktywności. Szybkość programowania i udostępniania opro-
gramowania często jest ważniejsza niż szybkość samej aplikacji.
W przypadku C# sytuacja wyglądała nieco inaczej. Środowisko CLR (ang. Common
Language Runtime) od początku było całkiem wydajne. Ponadto język umożliwiał współ-
działanie z kodem natywnym i pozwalał na pisanie niezabezpieczonego kodu o wyso-
kiej wydajności z użyciem wskaźników. Wydajność języka C# wciąż jest zwiększana.
Z nieco gorzkim uśmieszkiem zwracam uwagę na to, że Microsoft wprowadza obecnie
warstwową kompilację JIT, działającą podobnie jak kompilator HotSpot JIT z Javy.
Jednak dla różnego obciążenia wymogi związane z wydajnością też są różne.
W podrozdziale 1.2 zobaczysz, że C# jest obecnie używany w zaskakująco różnorodnych
platformach, w tym w grach i mikrousługach, gdzie wymogi dotyczące wydajności
mogą być wysokie.
Asynchroniczność pomaga w niektórych sytuacjach poprawić wydajność, jednak
wersją, w której zwrócono najwięcej uwagi na szybkość działania kodu, jest C# 7.
Struktury tylko do odczytu i znaczne rozszerzenie zastosowań referencji (ref) pomagają
unikać niepotrzebnego kopiowania. Dostępny w nowych platformach mechanizm Span<T>
i obsługa typów strukturalnych działających podobnie jak typy referencyjne ogranicza
niepotrzebne przydzielanie i zwalnianie pamięci. Twórcy języka mają najwyraźniej
nadzieję, że jeśli te techniki będą starannie stosowane, zaspokoją potrzeby różnych
programistów.
Mam jednak pewne obawy dotyczące tych mechanizmów, ponieważ wydają mi
się skomplikowane. Trudniej mi zrozumieć metodę z parametrem in niż ze zwykłymi
parametrami przekazywanymi przez wartość. Jestem też przekonany, że minie trochę
czasu, zanim przyzwyczaję się do tego, co mogę robić z lokalnymi i zwracanymi zmien-
nymi referencyjnymi.
Mam nadzieję, że wymienione mechanizmy będą używane z umiarem. Uprasz-
czają one kod w sytuacjach, w których jest to korzystne, i bez wątpienia będą mile
widziane przez programistów odpowiedzialnych za konserwację kodu. Chciałbym
poeksperymentować z tymi mechanizmami w prywatnych projektach i znaleźć rów-
nowagę między wzrostem wydajności a wyższą złożonością kodu.
Nie chcę jednak, aby moje ostrzeżenia Cię zniechęcały. Sądzę, że zespół odpowie-
dzialny za C# dokonał właściwych wyborów w kwestii dodania nowych mechanizmów.
Nie ma przy tym znaczenia, jak często używam nowych rozwiązań w swojej pracy.
Chciałem jedynie zauważyć, że nie musisz używać danej funkcji tylko dlatego, iż jest
dostępna. Świadomie zdecyduj o tym, czy zwiększyć złożoność kodu. Skoro już jeste-
śmy przy decyzjach, w C# 7 wprowadzono po raz pierwszy od C# 1 nową metafunk-
cję — podwersje (nazywane też wersjami pomocniczymi).

87469504f326f0d7c1fcda56ef61bd79
8
1.1. Ewoluujący język 41

1.1.6. Przyspieszona ewolucja — używanie podwersji


Numery wersji C# są nieco zagmatwane. Sytuację dodatkowo komplikuje to, że wielu
programistów — co zrozumiałe — myli numery wersji platformy i języka. Nie ma np.
wersji C# 3.5; w platformie .NET 3.0 używany był C# 2, a w platformie .NET 3.5 —
C# 3. C# 1 miał dwie podwersje: C# 1.0 i C# 1.2. Od wersji C# 2 do C# 6 pojawiały
się tylko główne wersje, zwykle powiązane z nową wersją środowiska Visual Studio.
W C# 7 to się zmieniło. Pojawiły się podwersje C# 7.0, C# 7.1, C# 7.2 i C# 7.3.
Każda z nich jest dostępna w Visual Studio 2017. Moim zdaniem wysoce prawdopo-
dobne jest, że ten wzorzec będzie kontynuowany w C# 8. Celem jest umożliwienie
szybkiego modyfikowania nowych mechanizmów na podstawie informacji od progra-
mistów. Większość zmian z wersji od C# 7.1 do C# 7.3 to poprawki lub rozszerzenia
rozwiązań wprowadzonych w C# 7.0.
Zmienność funkcji języka może budzić obawy — zwłaszcza w dużych organizacjach.
Zapewnienie pełnej obsługi nowej wersji języka może wymagać modyfikacji lub uno-
wocześnienia rozbudowanej infrastruktury. Ponadto programiści mogą poznawać
i wprowadzać nowe funkcje w różnym tempie. Jeśli więc język zmienia się częściej, niż
jesteś do tego przyzwyczajony, może to być nieco uciążliwe.
Dlatego kompilator języka C# domyślnie używa najstarszej podwersji najnowszej
obsługiwanej wersji głównej. Jeśli używasz kompilatora języka C# 7 i nie podasz
podwersji, domyślnie używana będzie podwersja C# 7.0. Jeśli chcesz zastosować
nowszą podwersję, musisz podać ją w pliku projektu i dodać nowe funkcje. Możesz
zrobić to na dwa sposoby (oba powodują ten sam efekt). Możesz bezpośrednio zmody-
fikować plik projektu i dodać element <LangVersion> w elemencie <PropertyGroup>:
<PropertyGroup>
... Inne właściwości.
<LangVersion>latest</LangVersion> Określanie wersji języka
</PropertyGroup> używanej w projekcie.

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

Rysunek 1.1. Ustawienia wersji języka w środowisku Visual Studio

kompilatorowi (np. zastosujesz funkcję z C# 7 w kompilatorze języka C# 6), komu-


nikat o błędzie będzie zwykle mniej zrozumiały.
C# jako język przeszedł długą drogę od czasu wprowadzenia pierwszej wersji. A co
z platformami, w których działa?

1.2. Ewoluująca platforma


Kilka ostatnich lat było bardzo ekscytujących dla programistów używających platformy
.NET. Niektóre doświadczenia były frustrujące, ponieważ zarówno firma Microsoft, jak
i społeczność skupiona wokół platformy .NET musiały pogodzić się ze skutkami
wprowadzenia bardziej otwartego modelu rozwoju technologii. Jednak ogólne efekty
ciężkiej pracy tak wielu osób są zdumiewające.
Przez wiele lat uruchamianie kodu w języku C# prawie zawsze wymagało używa-
nia systemu Windows. Zwykle używana była aplikacja kliencka oparta na technologii
Windows Forms lub WPF (ang. Windows Presentation Foundation) albo aplikacja
serwerowa napisana przy użyciu ASP.NET i działająca przeważnie na serwerze IIS (ang.
Internet Information Server). Inne możliwości były dostępne już od dawna (przede
wszystkim projekt Mono ma długą historię), jednak większość prac programistycznych
w obszarze platformy .NET wciąż była powiązana z Windowsem.
W czasie, gdy piszę te słowa (czerwiec 2018 r.), świat technologii .NET wygląda
zupełnie inaczej. Najważniejszym produktem jest obecnie .NET Core. Jest to środo-
wisko uruchomieniowe i platforma — przenośna, otwarta, w pełni wspierana przez
Microsoft w wielu systemach operacyjnych i udostępniająca solidne narzędzia dla pro-
gramistów. Jeszcze kilka lat temu byłoby to nie do pomyślenia. Jeśli dodać do tego
przenośne i otwarte środowisko IDE Visual Studio Code, otrzymasz kwitnący ekosystem
.NET, w którym programiści pracują na różnego rodzaju lokalnych platformach,
a następnie instalują oprogramowanie na rozmaitych platformach serwerowych.

87469504f326f0d7c1fcda56ef61bd79
8
1.3. Ewoluująca społeczność 43

Jednak błędem byłaby nadmierna koncentracja na platformie .NET Core i igno-


rowanie wielu innych dostępnych dziś sposobów uruchamiania kodu w języku C#.
Środowisko Xamarin umożliwia uruchamianie kodu w wielu platformach mobilnych.
Powiązana z nim platforma do tworzenia interfejsów GUI (Xamarin Forms) pozwala
programistom tworzyć interfejsy użytkownika, które wyglądają stosunkowo jednorodnie
w różnych urządzeniach, a przy tym pozwalają wykorzystać możliwości poszczególnych
platform.
Unity to jedna z najbardziej popularnych na świecie platform do tworzenia gier.
Obejmuje zmodyfikowane środowisko uruchomieniowe Mono i kompilację AOT
(ang. ahead-of-time), dlatego może stanowić wyzwanie dla programistów języka C#
przyzwyczajonych do bardziej tradycyjnych środowisk uruchomieniowych. Jednak dla
wielu osób platforma Unity będzie pierwszym i prawdopodobnie jedynym kontaktem
z tym językiem.
Wymienione popularne platformy w żadnym razie nie są jedynymi możliwościami
używania C#. Niedawno korzystałem z narzędzi Try .NET i Blazor, aby sprawdzić nowe
możliwości interakcji między przeglądarką a językiem C#.
Try .NET pozwala użytkownikom pisać kod w przeglądarce (z funkcją automatycz-
nego uzupełniania instrukcji), a następnie kompilować go i uruchamiać. Narzędzie to
doskonale nadaje się do eksperymentów z językiem C#, ponieważ ułatwia ten proces tak
bardzo, jak jest to możliwe.
Blazor to platforma do uruchamiania stron Razor bezpośrednio w przeglądarce.
W tym modelu nie ma stron generowanych przez serwer, a następnie wyświetlanych
w przeglądarce. Kod interfejsu użytkownika działa w przeglądarce, używając wersji
środowiska uruchomieniowego Mono przekształconego na format WebAssembly. Pomysł
używania całego środowiska uruchomieniowego wykonującego kod w języku pośrednim
(ang. Intermediate Language — IL) za pomocą silnika JavaScriptu w przeglądarce —
i to nie tylko na komputerach, ale też na telefonach komórkowych — jeszcze kilka lat
temu uznałbym za absurdalny. Cieszę się, że inni programiści mieli więcej wyobraźni.
Liczne innowacje w tym obszarze były możliwe tylko dzięki społeczności, która jest
bardziej niż kiedykolwiek wcześniej otwarta i nastawiona na współpracę.

1.3. Ewoluująca społeczność


Działam w społeczności programistów używających C# od czasów wersji C# 1.0
i nigdy nie widziałem, by była tak aktywna jak obecnie. Gdy zacząłem posługiwać się
C#, był on postrzegany głównie jako korporacyjny język programowania. W niewiel-
kim tylko stopniu łączono go z zabawą i eksploracją1. Z powodu takiej historii otwarty
ekosystem języka C# rozwijał się stosunkowo powoli w porównaniu z innymi języ-
kami, w tym z Javą, którą też uznawano za język dla korporacji. Mniej więcej w czasie
wprowadzenia wersji C# 3 społeczność alt.NET zaczęła myśleć o rozwoju technologii

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ę.

Przeskoczmy teraz do roku 2014, kiedy to Microsoft ogłosił, że platforma kompilatora


Roslyn stanie się projektem o otwartym dostępie do kodu źródłowego rozwijanym
przez nową organizację .NET Foundation. Następnie ogłoszono prace nad platformą
.NET Core. Początkowo projekt nosił nazwę Project K, a później DNX, aż w końcu
opracowano narzędzia .NET Core, udostępnione i stabilne. Następnie opracowano
ASP.NET Core. I Entity Framework Core. A także Visual Studio Code. Lista produktów,
które są aktywnie rozwijane w serwisie GitHub, jest naprawdę długa.
Technologie były ważne, jednak dla zdrowej społeczności równie istotne było nowe
podejście Microsoftu do otwartego oprogramowania. Pojawiło się wiele niezależnych
otwartych pakietów, pozwalających m.in. na innowacyjne wykorzystanie platformy
Roslyn i integrację z narzędziami .NET Core.
Żadne z tych rozwiązań nie powstało w próżni. Rozwój chmur obliczeniowych
sprawił, że platforma .NET Core stała się jeszcze ważniejsza dla ekosystemu .NET.
Obsługa Linuksa nie jest już opcjonalna. Ponieważ dostępna jest platforma .NET Core,
nie ma nic dziwnego w umieszczeniu usługi opartej na ASP.NET Core w obrazie
Dockera, zainstalowaniu jej z użyciem systemu Kubernetes i używaniu jako jednej
części większej aplikacji, w której wykorzystywane są różne języki. Wymiana dobrych
pomysłów między wieloma społecznościami istnieje od zawsze, jednak nigdy nie była
tak intensywna jak obecnie.
Możesz nauczyć się języka C#, używając przeglądarki. Kod w C# możesz urucha-
miać w dowolnym urządzeniu. Pytania na temat tego języka możesz zadawać w ser-
wisie Stack Overflow i wielu innych witrynach. W serwisie GitHub możesz dołączyć do
dyskusji na temat przyszłości języka w repozytorium zespołu odpowiedzialnego za C#.
C# nie jest doskonały. Nadal musimy wspólnie wykonać trochę pracy, aby społeczność
użytkowników C# była dla każdego tak przyjazna, jak to możliwe. Jednak już teraz
znajdujemy się w dobrym miejscu.
Lubię myśleć, że C# od podszewki też ma swoje miejsce w społeczności użyt-
kowników języka C#. Jak ewoluowała ta książka?

1.4. Ewoluująca książka


Czytasz czwarte wydanie książki C# od podszewki. Choć nie ewoluowała ona w takim
tempie jak język, platforma i społeczność, także się zmieniła. Ten podrozdział pomoże
Ci zrozumieć zawartość tej książki.

87469504f326f0d7c1fcda56ef61bd79
8
1.4. Ewoluująca książka 45

1.4.1. Wyjaśnienia na różnym poziomie


Pierwsze wydanie książki C# od podszewki pojawiło się w kwietniu 2008 r., co zbiegło
się w czasie z moim dołączeniem do firmy Google. Miałem wtedy świadomość, że wielu
programistów dobrze zna C# 1, jednak używają oni wersji C# 2 i C# 3 bez solidnego
zrozumienia współdziałania wszystkich mechanizmów. Starałem się zapełnić tę lukę,
omawiając język na poziomie, który pomoże czytelnikom zrozumieć nie tylko to, co
każda funkcja robi, ale też dlaczego zaprojektowano ją w dany sposób.
Z czasem potrzeby programistów się zmieniają. Mam wrażenie, że społeczność przez
osmozę lepiej zrozumiała język (przynajmniej jego wcześniejsze wersje). Dogłębne
zrozumienie języka nie jest powszechne, jednak w czwartym wydaniu chcę położyć
nacisk na jego nowsze wersje. Nadal uważam, że warto zrozumieć ewolucję języka
wersja po wersji, jednak analizowanie wszystkich szczegółów mechanizmów z wersji
C# 2 – 4 jest mniej potrzebne.

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#.

Nie przepadam za grubymi książkami. Nie chcę, by C# od podszewki onieśmielała,


by trudno było ją utrzymać w ręku lub pisać w niej. Omawianie wersji C# 2 – 4 na 400
stronach nie wydawało mi się zasadne. Dlatego skróciłem opis tych wersji. Wspomi-
nam o każdej funkcji, omawiając szczegóły tam, gdzie uważam to za sensowne. To
omówienie jest jednak krótsze niż w wydaniu trzecim. Objaśnienia z czwartego wydania
możesz traktować jak przegląd tematów, które już znasz. Pomoże Ci to w znalezieniu
zagadnień, o których zechcesz przeczytać więcej w trzecim wydaniu. Odsyłacz do elek-
tronicznej wersji trzeciego wydania znajdziesz na stronie http://www.manning.com/books/
c-sharp-in-depth-fourth-edition. Wersje C# 5 – 7 są w niniejszym wydaniu opisane
szczegółowo. Asynchroniczność nadal jest tematem trudnym do zrozumienia, a w wyda-
niu trzecim, co oczywiste, w ogóle nie przedstawiono wersji C# 6 i 7.
Pisanie, podobnie jak inżynieria oprogramowania, jest często poszukiwaniem równo-
wagi. Mam nadzieję, że równowaga, jaką uzyskałem między szczegółowością a zwię-
złością, będzie Ci odpowiadać.
WSKAZÓWKA. Jeśli używasz fizycznej kopii tej książki, gorąco zachęcam do pisania
w niej. Rób notatki w miejscach, w których się z czymś nie zgadzasz, a także przy wyjątkowo
przydatnych fragmentach. Ta czynność ułatwi Ci zapamiętywanie treści, a notatki posłużą
Ci później jako przypomnienie.

1.4.2. Przykłady, w których wykorzystano projekt Noda Time


Większość przykładów prezentowanych w tej książce to samodzielne projekty. Jednak
aby lepiej zaprezentować przydatność niektórych mechanizmów, warto pokazać, gdzie
używam ich w kodzie produkcyjnym. Zwykle wykorzystuję do tego projekt Noda Time.
Jest to otwarty projekt, który rozpocząłem w 2009 r., aby utworzyć lepszą bibliotekę
do obsługi dat i czasu w platformie .NET. Jednak ma ona także drugie przeznaczenie —
jest dla mnie doskonałym projektem eksperymentalnym. Pomaga rozwijać umiejętności

87469504f326f0d7c1fcda56ef61bd79
8
46 ROZDZIAŁ 1. Przetrwają najbystrzejsi

w zakresie projektowania interfejsów API, dowiedzieć się czegoś na temat wydajności


i testów porównawczych, a także badać nowe mechanizmy języka C#. A wszystko to
oczywiście bez uszkadzania oprogramowania użytkowników.
W każdej nowej wersji języka C# wprowadzono mechanizmy, które mogłem wyko-
rzystać w Noda Time. Dlatego uważam, że warto ich użyć w przykładach w tej książce.
Cały kod jest dostępny w serwisie GitHub. Oznacza to, że możesz sklonować ten kod
i samodzielnie z nim eksperymentować. Przykłady zastosowań Noda Time nie mają
nakłonić Cię do korzystania z tej biblioteki, nie będę jednak narzekał, jeśli taki będzie
efekt uboczny lektury tej książki.
W dalszych rozdziałach zakładam, że wiesz, czym jest biblioteka Noda Time. Jeśli
chodzi o jej przydatność w przykładach, ważne są następujące aspekty:
 Kod musi być możliwie czytelny. Jeśli dana funkcja języka umożliwia refaktory-
zację kodu pod kątem czytelność, korzystam z tej możliwości.
 W Noda Time używane jest wersjonowanie semantyczne, a nowe wersje główne
pojawiają się rzadko. Zwracam też uwagę na zgodność nowych mechanizmów
języka ze starszym kodem.
 Nie mam ściśle określonych celów z zakresu wydajności, ponieważ biblioteka
Noda Time może być używana w wielu kontekstach o różnych wymaganiach.
Staram się dbać o wydajność i stosuję mechanizmy, które ją poprawiają, o ile nie
zwiększają one znacznie złożoności kodu.

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

używał prawdopodobnie najczęściej, jest niewypowiadalna nazwa (ang. unspeakable


name). To pojęcie zostało wymyślone przez Erica Lipperta i dotyczy identyfikatorów
generowanych przez kompilator na potrzeby implementacji mechanizmów takich jak
bloki iteratora lub wyrażenia lambda2. Taki identyfikator jest poprawny w środowisku
CLR, ale już nie w języku C#. Tej nazwy nie można wypowiedzieć w języku, dlatego
gwarantowane jest, że nie będzie ona powodować konfliktów z Twoim kodem.

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

W tej części książki omówione są wszystkie mechanizmy wprowadzone od


wersji C# 2 (udostępnionej w Visual Studio 2005) do C# 5 (dodanej w Visual Stu-
dio 2012). Jest to ten sam zestaw mechanizmów, którego dotyczyło całe trzecie wydanie
tej książki. Wiele z tych funkcji wydaje się teraz zamierzchłą historią. Na przykład przyj-
mujemy za pewnik, że w C# dostępne są typy generyczne.
Był to bardzo produktywny okres dla twórców języka C#. Niektóre z mechanizmów
omawianych w tej części to: typy generyczne, typy bezpośrednie przyjmujące wartość
null, metody anonimowe, konwersje grup metod, iteratory, typy częściowe, klasy sta-
tyczne, automatycznie implementowane wartości, zmienne lokalne z niejawnie określa-
nym typem, tablice z niejawnie określanym typem, inicjalizatory obiektów, inicjalizatory
kolekcji, typy anonimowe, wyrażenia lambda, metody rozszerzające, wyrażenia repre-
zentujące zapytania, typowanie dynamiczne, parametry opcjonalne, argumenty nazwane,
usprawnienia w technologii COM, generyczna kowariancja i kontrawariancja, technika
async/await i atrybuty z informacjami o wywołaniu. Uff!
Zakładam, że większość czytelników przynajmniej na podstawowym poziomie zna
liczne z tych funkcji, dlatego opisy w tej części są krótkie. Aby zachować zwięzłość,
przedstawiam tu mniej szczegółów niż w wydaniu trzecim. Ma to na celu uwzględ-
nienie różnych potrzeb czytelników. Ten tekst stanowi:
 wprowadzenie do mechanizmów, z którymi mogłeś się nie zetknąć;
 przypomnienie o funkcjach, które kiedyś znałeś, ale zapomniałeś;
 objaśnienie przyczyn wprowadzenia mechanizmów — po co je udostępniono
i dlaczego zaprojektowano je w taki, a nie inny sposób;
 krótkie źródło informacji na wypadek, gdybyś wiedział, co chcesz zrobić,
ale zapomniał składni.

Jeśli interesują Cię szczegóły, zapoznaj się z wydaniem trzecim.

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.

2.1. Typy generyczne


Typy generyczne umożliwiają pisanie kodu o ogólnym przeznaczeniu, który jest bez-
pieczny w czasie kompilacji i pozwala używać tego samego typu w wielu miejscach bez
wcześniejszej wiedzy o tym, jaki to będzie typ. Gdy wprowadzono typy generyczne,
stosowano je przede wszystkim w kolekcjach. Jednak obecnie występują one w wielu
miejscach kodu w C#. Prawdopodobnie najczęściej stosuje się je w:
 kolekcjach (gdzie są równie przydatne jak zawsze),
 delegatach (przede wszystkim w technologii LINQ),
 kodzie asynchronicznym, gdzie Task<T> to obietnica przyszłej wartości typu T,
 typach bezpośrednich przyjmujących wartość null (więcej o nich dowiesz się
z podrozdziału 2.2).
W żadnym razie nie są to wszystkie zastosowania typów generycznych, jednak już te
cztery punkty oznaczają, że programiści używający C# regularnie korzystają z typów
generycznych. Zalety typów generycznych najprościej jest objaśnić na podstawie
kolekcji, ponieważ możesz przyjrzeć się kolekcjom z platformy .NET 1 i porównać je
z generycznymi kolekcjami z .NET 2.

2.1.1. Wprowadzenie z użyciem przykładu


— kolekcje przed wprowadzeniem typów generycznych
W .NET 1 dostępne były trzy podstawowe rodzaje kolekcji:
 Tablice — są one jawnie obsługiwane w języku i środowisku uruchomieniowym.
Wielkość tablicy jest ustalana w momencie jej inicjowania.
 Kolekcje obiektów — ich wartości (i klucze, jeśli są używane) są opisane w inter-
fejsie API za pomocą typu System.Object. Nie istnieje specjalne wsparcie takich
kolekcji w języku ani środowisku uruchomieniowym, choć można stosować do
tych kolekcji mechanizmy języka takie jak indeksery i instrukcja foreach. Naj-
częściej używane kolekcje tego rodzaju to ArrayList i Hashtable.
 Wyspecjalizowane kolekcje — ich wartości są opisywane w interfejsie API za
pomocą konkretnych typów, a dana kolekcja może być używana tylko do wartości
danego typu. Na przykład StringCollection to kolekcja łańcuchów znaków. Jej
interfejs API jest podobny jak w kolekcji ArrayList, jednak wartości są wskazy-
wane za pomocą typu String, a nie Object.
Dla tablic i wyspecjalizowanych kolekcji używane jest typowanie statyczne. Oznacza
to, że interfejs API uniemożliwia zapis w kolekcji wartości niewłaściwego typu, a gdy
pobierasz wartość z kolekcji, nie musisz rzutować jej na oczekiwany typ.

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.

Oto konkretny przykład — załóżmy, że w jednej metodzie (GenerateNames) chcesz


utworzyć kolekcję łańcuchów znaków (nazwisk). Następnie chcesz wyświetlić te łańcu-
chy znaków w innej metodzie (PrintNames). Omówione zostaną tu trzy sposoby prze-
chowywania kolekcji nazwisk — tablica, ArrayList i StringCollection — wraz z wadami
i zaletami każdego z nich. Kod w każdym przypadku będzie wyglądał podobnie (dotyczy
to głównie metody PrintNames), ale zapoznaj się z przykładami. Najpierw przyjrzyj się
przedstawionym na listingu 2.1 tablicom.

Listing 2.1. Generowanie i wyświetlanie nazwisk z użyciem tablic

static string[] GenerateNames()


{
string[] names = new string[4]; Długość tablicy musi być znana
names[0] = "Gamma"; w momencie jej tworzenia.
names[1] = "Vlissides";
names[2] = "Johnson";
names[3] = "Helm";
return names;
}

static void PrintNames(string[] names)


{
foreach (string name in names)
{
Console.WriteLine(name);
}
}

Nie zastosowałem tu inicjalizatora tablicy, ponieważ chcę zaprezentować sytuację, w któ-


rej nazwiska są pobierane jedno po drugim — np. w wyniku odczytu z pliku. Warto
przy tym zauważyć, że trzeba od początku zadeklarować tablicę o odpowiedniej dłu-
gości. Gdybyś rzeczywiście wczytywał nazwiska z pliku, musiałbyś przed rozpoczę-
ciem ustalić, ile jest nazwisk, lub napisać bardziej skomplikowany kod. Mógłbyś np.
na początku utworzyć jedną tablicę, po jej wypełnieniu skopiować jej zawartość do
większej tablicy itd. Musiałbyś wtedy pamiętać o utworzeniu końcowej tablicy o wła-
ściwej wielkości, gdybyś uzyskał tablicę większą niż dokładna liczba nazwisk.
Kod potrzebny do śledzenia długości kolekcji, przydzielania nowych tablic itd. jest
powtarzalny i można go umieścić w typie. Okazuje się, że tak właśnie działa typ ArrayList
(zobacz listing 2.2).

Listing 2.2. Generowanie i wyświetlanie nazwisk za pomocą typu ArrayList

static ArrayList GenerateNames()


{
ArrayList names = new ArrayList();
names.Add("Gamma");

87469504f326f0d7c1fcda56ef61bd79
8
54 ROZDZIAŁ 2. C# 2

names.Add("Vlissides");
names.Add("Johnson");
names.Add("Helm");
return names;
}

static void PrintNames(ArrayListnames)


{
foreach (string name in names) Co się stanie, jeśli w ArrayList zapisana
{ zostanie wartość inna niż łańcuch znaków?
Console.WriteLine(name);
}
}

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. Generowanie i wyświetlanie nazwisk za pomocą typu StringCollection

static StringCollection GenerateNames()


{
StringCollection names = new StringCollection();
names.Add("Gamma");
names.Add("Vlissides");
names.Add("Johnson");
names.Add("Helm");
return names;
}

static void PrintNames(StringCollection names)


{
foreach (string name in names)
{
Console.WriteLine(name);
}
}

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.1.2. Typy generyczne ratują sytuację


Przejdźmy od razu do rozwiązania na potrzeby metod GenerateNames i PrintNames. Jest
nim typ generyczny List<T>. Jest to kolekcja, w której T to typ elementów kolekcji. W tym
przykładzie jest to typ string. Możesz wszędzie zastąpić typ StringCollection typem
2
List<string> , co pokazane jest na listingu 2.4.

Listing 2.4. Generowanie i wyświetlanie nazwisk za pomocą typu List<T>

static List<string> GenerateNames()


{
List<string> names = new List<string>();
names.Add("Gamma");
names.Add("Vlissides");
names.Add("Johnson");
names.Add("Helm");
return names;
}

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

static void PrintNames(List<string> names)


{
foreach (string name in names)
{
Console.WriteLine(name);
}
}

Typ List<T> rozwiązuje wszystkie opisane do tej pory problemy:


 Nie musisz z góry znać wielkości kolekcji (inaczej niż w przypadku tablic).
 W interfejsie API typ elementu wszędzie jest podawany jako T, dzięki czemu
wiesz, że kolekcja List<string> może zawierać tylko referencje do łańcuchów
znaków. Jeśli spróbujesz dodać wartość innego typu, wystąpi błąd kompilacji
(inaczej niż przy stosowaniu typu ArrayList).
 Możesz zastosować ten typ do elementów dowolnego innego typu. Nie musisz
przy tym martwić się o pisanie kodu i zarządzanie nim (inaczej niż w typie
StringCollection i podobnych typach).

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ą:

Rysunek 2.1. Powiązania między parametrami i argumentami metod

Wartości argumentów są używane jako początkowe wartości parametrów w metodzie.


W typach generycznych występują parametry określające typ i argumenty określające
typ. Są one oparte na tej samej zasadzie, ale dotyczą typów. Deklaracja generycznego
typu lub generycznej metody obejmuje parametr określający typ. Parametr ten podaje
się w nawiasie ostrym po nazwie typu lub metody. W kodzie w ciele deklaracji można
używać parametru określającego typ jak zwykłego typu (ale takiego, o którym zbyt
dużo nie wiadomo).

87469504f326f0d7c1fcda56ef61bd79
8
2.1. Typy generyczne 57

W kodzie, w którym używane są typ generyczny lub metoda generyczna, argu-


menty określające typ należy podać w nawiasie ostrym po nazwie typu lub metody.
Na rysunku 2.2 pokazano tę relację na przykładzie typu List<T>.
Teraz wyobraź sobie kompletny interfejs
API typu List<T> — wszystkie sygnatury
metod, właściwości itd. Jeśli używasz poka-
zanej na rysunku zmiennej list, każde wy-
stąpienie T z interfejsu API jest zmieniane
na string. Na przykład metoda Add w typie
List<T> ma następującą sygnaturę:
public void Add(T item)

Jeśli jednak wpiszesz fragment list.Add(


w środowisku Visual Studio, mechanizm
IntelliSense wyświetli taką listę możliwo-
Rysunek 2.2. Powiązania między parametrami
ści, jakby parametr item był typu string.
i argumentami określającymi typ Jeżeli spróbujesz przekazać argument innego
typu, wystąpi błąd kompilacji.
Choć na rysunku 2.2 pokazana jest generyczna klasa, także metody mogą być gene-
ryczne. W takiej metodzie deklarowane są parametry określające typ i można je stoso-
wać razem z innymi elementami sygnatury metody. W metodach parametry określające
typ często są używane jako argumenty określające typ w innych typach z sygnatury.
Na listingu 2.5 pokazano rozwiązanie, którego nie dałoby się uzyskać w starszych wer-
sjach języka. Ten kod tworzy nową kolekcję zawierającą pierwszych N elementów
istniejącej, przy czym zachowane jest typowanie statyczne.

Listing 2.5. Kopiowanie elementów z jednej kolekcji do innej

W metodzie zadeklarowany jest parametr określający typ,


public static List<T> CopyAtMost<T>( T. Parametr ten jest używany w parametrach i jako typ
List<T> input, int maxElements) zwracanej wartości.
{
int actualCount = Math.Min(input.Count, maxElements);
List<T> ret = new List<T>(actualCount); Parametr określający typ
for (int i = 0; i < actualCount; i++) jest używany w ciele metody.
{
ret.Add(input[i]);
}
return ret;
}

static void Main()


{
List<int> numbers = new List<int>();
numbers.Add(5);
numbers.Add(10);
numbers.Add(20);
Wywołanie metody z użyciem int
List<int> firstTwo = CopyAtMost<int>(numbers, 2); jako parametru określającego typ.
Console.WriteLine(firstTwo.Count);
}

87469504f326f0d7c1fcda56ef61bd79
8
58 ROZDZIAŁ 2. C# 2

W wielu metodach generycznych parametr określający typ jest używany w sygnaturze


tylko raz3 i nie jest argumentem określającym typ żadnych typów generycznych. Jednak
możliwość użycia parametru określającego typ do zapisania powiązań między typami
zwykłych parametrów a typem zwracanej wartości jest bardzo ważnym aspektem
wpływającym na przydatność typów generycznych.
W typach generycznych możesz wykorzystać parametry określające typ jako argu-
menty określające typ, gdy deklarujesz klasę bazową lub implementujesz interfejs.
Na przykład w typie List<T> zaimplementowany jest interfejs IEnumerable<T>, dlatego
deklarację klasy można zapisać w następujący sposób:
public class List<T> : IEnumerable<T>

UWAGA. W rzeczywistości w typie List<T> zaimplementowanych jest wiele interfejsów.


To tylko uproszczony zapis.

ARNOŚĆ W TYPACH I METODACH GENERYCZNYCH


W typach i metodach generycznych można zadeklarować wiele parametrów określa-
jących typ, rozdzielając je przecinkami w nawiasie ostrym. Na przykład deklaracja
generycznego odpowiednika klasy Hashtable z platformy .NET 1 wygląda tak:
public class Dictionary<TKey, TValue>

Arność parametrów generycznych deklaracji to liczba parametrów określających typ.


To prawda, że pojęcie to jest bardziej przydatne dla autorów niż w codziennym użytko-
waniu i pisaniu kodu. Uważam jednak, że warto je znać. Możesz przyjąć, że deklaracja
generyczna to taka, w której arność parametrów generycznych wynosi 0.
Arność parametrów generycznych deklaracji to aspekt stanowiący o jej wyjątkowości.
Wspomniałem już o wprowadzonym w .NET 2.0 interfejsie IEnumerable<T>. Jest to
inny typ niż interfejs IEnumerable z .NET 1.0. Możesz napisać metody o tej samej nazwie,
ale różnej arności parametrów generycznych — nawet jeśli pozostałe części sygnatur
tych metod są identyczne:
Niegeneryczna metoda
public void Method() {} (arność parametrów generycznych równa 0).
public void Method<T>() {} Metoda o arności parametrów generycznych równej 1.
public void Method<T1, T2>() {} Metoda o arności parametrów generycznych równej 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.

Sygnatury tych metod są uznawane za identyczne, dlatego według standardowych


reguł przeciążania niedozwolone jest tworzenie takich metod. Możesz napisać przecią-
żone wersje metod z różnymi nazwami parametrów określających typ, ale metody te
muszą się różnić w innych aspektach (np. mieć inną liczbę zwykłych parametrów). Nie
pamiętam jednak, żebym kiedykolwiek chciał stosować tę technikę.
Skoro już omawiane są zestawy parametrów określających typ, warto wspomnieć,
że w jednej deklaracji nie można podać dwóch takich parametrów o tej samej nazwie
(podobnie jak nie można zadeklarować dwóch zwykłych parametrów o identycznej
nazwie). Nie możesz np. zadeklarować metody w następujący sposób:
Błąd kompilacji. Powtarzający się parametr
public void Method<T, T>() {} określający typ T.

Dozwolone jest jednak podawanie dwóch identycznych argumentów określających typ.


Często jest to właściwe podejście. Na przykład aby utworzyć odwzorowanie łańcuchów
znaków na łańcuchy znaków, możesz zastosować typ Dictionary<string, string>.
We wcześniejszym przykładzie z nazwami IAmConfusing pojawiło się wyliczenie,
które nie było generyczne. To nie przypadek, ponieważ posłuży to do omówienia następ-
nej kwestii.

2.1.3. Jakie elementy mogą być generyczne?


Nie wszystkie typy i składowe mogą być generyczne. W kontekście typów jest to sto-
sunkowo proste — po części dlatego, że można deklarować niewiele rodzajów typów.
Wyliczenia nie mogą być generyczne, jednak klasy, struktury, interfejsy i delegaty
można tworzyć jako generyczne.
Jeśli chodzi o składowe typów, sytuacja jest bardziej złożona. Niektóre składowe na
pozór wydają się generyczne, ponieważ używają innych typów generycznych. Pamiętaj
jednak, że deklaracja jest generyczna tylko wtedy, jeśli pojawiają się w niej nowe para-
metry określające typ.
Metody i typy zagnieżdżone mogą być generyczne, jednak poniższe elementy
muszą być niegeneryczne:

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.
}

Parametr określający typ nazwano tu TItem, aby odróżnić go od parametru określają-


cego typ T z deklaracji List<T>. Tu pole items jest typu List<TItem>. Parametr TItem jest
używany jako argument określający typ w kolekcji List<T>; TItem został jednak wpro-
wadzony w deklaracji klasy, a nie w deklaracji pola.
W większości sytuacji trudno jest wyobrazić sobie, w jaki sposób składowa mogłaby
być generyczna. Czasem jednak chciałem napisać generyczny konstruktor lub indekser.
Rozwiązaniem prawie zawsze okazywało się napisanie metody generycznej.
Jeśli chodzi o metody generyczne, to w omówieniu ich wywołań przedstawiłem
tylko uproszczony opis argumentów określających typ. W niektórych sytuacjach kom-
pilator potrafi sam ustalić wartość argumentów określających typ w wywołaniu, a pro-
gramista nie musi podawać ich w kodzie źródłowym.

2.1.4. Wnioskowanie typu argumentów


określających typ w metodach
Wróć do kluczowych fragmentów listingu 2.5. Zadeklarowana jest w nim generyczna
metoda:
public static List<T> CopyAtMost<T>(List<T> input, int maxElements)

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);

Wyróżniono tu wywołanie metody. W wywołaniu CopyAtMost należy podać argument


określający typ, ponieważ metoda ta ma parametr określający typ. Jednak tego argu-
mentu nie musisz podawać w kodzie źródłowym. Możesz zmodyfikować kod w następu-
jący sposób:
List<int> numbers = new List<int>();
...
List<int> firstTwo = CopyAtMost(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);
}

public static Tuple<T1, T2> Create<T1, T2>(T1 item1, T2 item2)


{
return new Tuple<T1, T2>(item1, item2);
}

Metody te wydają się zbędne, jednak umożliwiają zastosowanie wnioskowania typów


w miejscach, gdzie bez tych metod w momencie tworzenia krotki konieczne byłoby
podanie argumentów określających typ. Zamiast poniższego zapisu:
new Tuple<int, string, int>(10, "x", 20)

można zastosować ten kod:


Tuple.Create(10, "x", 20)

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

Na szczęście jest to jedna z dziedzin, w której zrozumienie szczegółów nie jest


wysoce pomocne w codziennej pracy programisty. W każdej sytuacji są trzy możliwości:
 Wnioskowanie typu zakończyło się powodzeniem i dało oczekiwany efekt. Hura.
 Wnioskowanie typu zakończyło się powodzeniem, ale efekt jest niezgodny
z oczekiwaniami. Wystarczy wtedy jawnie podać argumenty określające typ lub
zrzutować niektóre z argumentów. Na przykład jeśli w poprzednim wywołaniu
Tuple.Create chcesz użyć typu Tuple<int, object, int>, możesz jawnie podać
argumenty określające typ w wywołaniu Tuple.Create, wywołać polecenie new
Tuple<int, object, int>(...) lub użyć wywołania Tuple.Create(10, (object)
"x", 20).
 Wnioskowanie typu zakończyło się niepowodzeniem w czasie kompilacji. Czasem
ten problem można rozwiązać, rzutując niektóre argumenty. Na przykład literał
null nie ma typu, dlatego wnioskowanie typu nie powiedzie się dla wywołania
Tuple.Create(null, 50), ale zakończy się sukcesem dla wywołania Tuple.Create
((string) null, 50). W innych sytuacjach konieczne jest jawne podanie argu-
mentów określających typ.

W dwóch ostatnich scenariuszach to, które podejście wybierzesz, rzadko ma istotny


wpływ na czytelność kodu. Zrozumienie szczegółów wnioskowania typów może spra-
wić, że łatwiej będzie Ci przewidzieć, co zadziała, a co nie, jednak czas poświęcony
na studiowanie specyfikacji zapewne Ci się nie zwróci. Jeśli ten temat Cię ciekawi,
wiedz, że nigdy nie zniechęcam nikogo do czytania specyfikacji. Nie zdziw się jednak,
jeśli naprzemiennie będziesz miał poczucie, że stanowi ona labirynt podobnych do
siebie krętych dróżek i gmatwaninę różnych od siebie zawiłych ścieżek.
Te alarmistyczne uwagi na temat skomplikowanych szczegółów języka nie powinny
jednak umniejszać wygody, jaką daje wnioskowanie typów. Dzięki temu mechanizmowi
używanie języka C# jest znacznie łatwiejsze.
Do tej pory wszystkie omawiane parametry określające typy nie miały ograniczeń.
Można było podać w ich miejscu dowolny typ. Nie zawsze jest to jednak odpowiednie
rozwiązanie. Czasem programista chce, aby dla danego parametru określającego typ
można było podawać tylko wybrane typy. Przydatne są wtedy ograniczenia typów.

2.1.5. Ograniczenia typów


Gdy w generycznym typie lub w generycznej metodzie zadeklarowany jest parametr
określający typ, można też podać ograniczenia typu. Ograniczają one listę typów, jakie
można podać jako argumenty określające typ. Załóżmy, że chcesz napisać metodę
formatującą listę elementów i mieć pewność, iż do formatowania użyte zostaną usta-
wienia dla konkretnej kultury, a nie dla domyślnej kultury z wątku. Interfejs IFormattable
udostępnia odpowiednią metodę, ToString(string, IFormatProvider), jak jednak zapew-
nić używanie listy elementów odpowiedniego typu? Możesz się spodziewać, że roz-
wiązaniem jest następująca sygnatura:
static void PrintItems(List<IFormattable> items)

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>.

UWAGA. Powody tego opisane są szczegółowo w rozdziale 4. w kontekście generycznej


wariancji. Na razie potraktuj ten fragment jak prosty przykład ilustrujący ograniczenia.

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

Ograniczenie parametru T nie zmienia tego, jakie wartości można przekazywać do


metody. Ograniczenie wpływa jedynie na to, co można zrobić z wartością typu T
wewnątrz metody. Kompilator wie, że typ T implementuje interfejs IFormattable,
dlatego umożliwia wywoływanie metody IFormattable.ToString(string, IFormatProvider)
dla dowolnej wartości typu T, co ilustruje listing 2.6.

Listing 2.6. Wyświetlanie elementów za pomocą specyficznej kultury z użyciem


ograniczeń typów

static void PrintItems<T>(List<T> items) where T : IFormattable


{
CultureInfo culture = CultureInfo.InvariantCulture;
foreach (T item in items)
{
Console.WriteLine(item.ToString(null, culture));
}
}

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

 Ograniczenia wymuszające dostępność konstruktora — where T : new(). Argu-


ment określający typ musi wtedy mieć publiczny konstruktor bezparametrowy.
Pozwala to stosować instrukcję new T() w ciele kodu, aby tworzyć nowe instancje
typu T.
 Ograniczenie dotyczące możliwości konwersji — where T : JakisTyp. Tu JakisTyp
może być klasą, interfejsem lub innym parametrem określającym typ:
 where T : Control
 where T : IFormattable
 where T1 : T2

Ograniczenia można łączyć, stosując umiarkowanie skomplikowane reguły. Jeśli zła-


miesz te reguły, zwykle zobaczysz zrozumiały komunikat o błędzie od kompilatora.
Ciekawą i dość często stosowaną formą ograniczeń jest używanie parametru okre-
ślającego typ w samym ograniczeniu:
public void Sort(List<T> items) where T : IComparable<T>

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);

Ja najczęściej używam ograniczeń typów dotyczących interfejsów. Podejrzewam jednak,


że rodzaj stosowanych ograniczeń zależy w dużym stopniu od rodzaju pisanego kodu.
Gdy w generycznej deklaracji znajduje się kilka parametrów określających typ,
dla każdego z tych parametrów można ustawić zupełnie inny zestaw ograniczeń:
Metoda generyczna z dwoma parametrami
TResult Method<TArg, TResult>(TArg input) określającymi typ: TArg i TResult.
where TArg : IComparable<TArg> TArg musi zawierać implementację
where TResult : class, new() interfejsu IComparable<TArg>.
TResult musi być typem referencyjnym
z konstruktorem bezparametrowym.

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.

2.1.6. Operatory default i typeof


W C# 1 dostępny był już operator typeof() przyjmujący nazwę typu jako jedyny
operand. W C# 2 dodano operator default() i nieco rozszerzono zastosowania ope-
ratora typeof.
Operator default łatwo jest opisać. Operandem jest nazwa typu lub parametr okre-
ślający typ, a wynikiem jest wartość domyślna określonego typu. Jest to wartość, jaką
otrzymasz, jeśli zadeklarujesz pole danego typu i nie przypiszesz do niego wartości.
W typach referencyjnych jest to wartość null. W typach bezpośrednich nieprzyjmują-

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

 Jeśli np. wyświetlasz typy z podzespołu zawierającego typ List<T>, oczekujesz,


że uzyskasz typ List<T> bez informacji o argumencie określającym typ T. Jest to
definicja typu generycznego.
 Jeśli wywołasz GetType() dla obiektu List<int>, oczekujesz, że otrzymasz typ
z informacjami na temat argumentu określającego typ.
 Jeśli chcesz pobrać typ bazowy dla definicji typu generycznego klasy zadekla-
rowanej w następujący sposób:
class StringDictionary<T> : Dictionary<string, T>

otrzymasz typ z jednym konkretnym argumentem określającym typ (string dla


parametru określającego typ TKey z deklaracji Dictionary<TKey, TValue>) i jeden
argument określający typ, który to argument jest tylko parametrem określającym
typ (T dla parametru TValue).

Trzeba szczerze przyznać, że jest to bardzo skomplikowane, jednak złożoność jest


nieodłączna od tego obszaru. Wiele metod i właściwości klasy Type pozwala np.
przejść od definicji typu generycznego do typu z podanymi wszystkimi argumentami
określającymi typ i na odwrót.
Wróćmy do operatora typeof. Najprostszy do zrozumienia przykład to typeof(List
<int>). To wywołanie zwraca obiekt typu Type reprezentujący typ List<T> z argumen-
tem określającym typ int; wynik jest taki sam jak dla wywołania new List<int>().GetType().
Następne wywołanie, typeof(T), zwraca argument określający typ T używany w danym
miejscu w kodzie. Zawsze jest to zamknięty skonstruowany typ, co w specyfikacji ozna-
cza, że jest to zwykły typ bez parametrów określających typ. Choć w większości miejsc
staram się szczegółowo wyjaśniać terminologię, nazewnictwo związane z typami gene-
rycznymi (otwarte, zamknięte, skonstruowane, ograniczone, bez ograniczeń) bywa
mylące i w praktyce prawie nigdy nie jest przydatne. Zamknięte skonstruowane typy
zostaną omówione dalej, ale pozostałych pojęć nie będę objaśniał.
Najłatwiej jest zademonstrować omawiane zagadnienie na przykładzie wywołania
typeof(T). W tym samym przykładzie używane jest też wywołanie typeof(List<T>).
Na listingu 2.7 zadeklarowano metodę generyczną, która wyświetla wyniki wywołań
typeof(T) i typeof(List<T>) w konsoli. Dalej znajdują się wywołania tej metody z dwoma
różnymi argumentami określającymi typ.

Listing 2.7. Wyświetlanie wyniku wywołania operatora typeof

static void PrintType<T>()


{ Wyświetlanie wyników wywołań
Console.WriteLine("typeof(T) = {0}", typeof(T)); typeof(T) i typeof(List<T>).
Console.WriteLine("typeof(List<T>) = {0}", typeof(List<T>));
}

static void Main()


{ Wywołanie metody z wartością string
PrintType<string>(); jako argumentem określającym typ.
PrintType<int>(); Wywołanie metody z wartością int
} jako argumentem określającym typ.

87469504f326f0d7c1fcda56ef61bd79
8
2.1. Typy generyczne 67

Oto efekt wywołania kodu z listingu 2.7:


typeof(T) = System.String
typeof(List<T>) = System.Collections.Generic.List`1[System.String]
typeof(T) = System.Int32
typeof(List<T>) = System.Collections.Generic.List`1[System.Int32]

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).

2.1.7. Inicjowanie typów generycznych i ich stan


Gdy używałeś operatora typeof, zobaczyłeś, że List<int> i List<string> to różne typy
konstruowane na podstawie tej samej definicji typu generycznego. Różnica dotyczy
nie tylko sposobu używania tych typów, ale też ich inicjowania i obsługi pól statycznych.
Każdy zamknięty skonstruowany typ jest inicjowany osobno i ma własny niezależny
zestaw pól statycznych. Na listingu 2.8 pokazane jest to za pomocą prostego generycz-
nego licznika (nie jest on bezpieczny ze względu na wątki).

Listing 2.8. Sprawdzanie pól statycznych w generycznych typach

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

public static void Increment()


{
value++;
}

public static void Display()


{
Console.WriteLine("Licznik dla typu {0}: {1}", typeof(T), value);
}
}

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();
}
}

Oto dane wyjściowe z listingu 2.8:


Inicjowanie licznika dla typu System.String
Licznik dla typu System.String: 2
Inicjowanie licznika dla typu System.Int32
Licznik dla typu System.Int32: 0
Licznik dla typu System.Int32: 1

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.

2.2. Typy bezpośrednie przyjmujące wartość null


Tony Hoare w 1965 r. wprowadził referencje null w języku Algol, po czym nazwał je
„błędem kosztującym miliard dolarów”. Niezliczeni programiści irytowali się, gdy ich
kod zgłaszał wyjątki NullReferenceException (.NET), NullPointerException (Java) lub ich
odpowiedniki. Są one tak częstym problemem, że poświęcono im kanoniczne pytania
w serwisie Stack Overflow, do których odsyłani są autorzy setek innych pytań. Skoro
wartości null sprawiają tyle problemów, dlaczego w C# 2 i platformie .NET 2.0
zwiększono możliwość ich stosowania za pomocą typów bezpośrednich przyjmują-
cych wartość null? Przed omówieniem implementacji tego mechanizmu warto zasta-
nowić się nad problemem, który takie typy miały eliminować, i wcześniejszymi roz-
wiązaniami.

2.2.1. Cel — reprezentowanie braku informacji


Czasem przydatna jest zmienna reprezentująca jakieś informacje, które jednak nie
w każdej sytuacji są dostępne. Oto kilka prostych przykładów:
 Tworzysz model zamówienia od klienta obejmującego informacje na temat firmy.
Możliwe jednak, że klient nie składa zamówienia w imieniu firmy.
 Tworzysz model osoby obejmujący datę urodzin i datę śmierci. Możliwe jednak,
że dana osoba wciąż żyje.
 Tworzysz model filtra produktów obejmujący zakres cen. Możliwe jednak, że
klient nie podał ceny maksymalnej.

We wszystkich tych sytuacjach potrzebna jest możliwość reprezentowania braku war-


tości. Możliwe, że dostępne są kompletne informacje, nadal jednak trzeba uwzględ-
nić w modelu ich brak. W innych scenariuszach informacje mogą być niekompletne.
W drugim przykładzie możesz nie znać daty urodzenia nie dlatego, że dana osoba się
nie urodziła, ale dlatego, że system nie zawiera tych informacji. Czasem trzeba repre-
zentować różnicę między „wiadomo, że dane nie istnieją” a „dane nieznane”, jednak
często wystarczy zapisać brak informacji.

87469504f326f0d7c1fcda56ef61bd79
8
70 ROZDZIAŁ 2. C# 2

W typach referencyjnych dostępny jest sposób na zapisanie braku informacji. Służą


do tego referencje null. Jeśli używasz klasy Company, a klasa Order zawiera referencję do
firmy powiązanej z danym zamówieniem, możesz ustawić tę referencję na wartość null,
jeśli klient nie podał firmy.
W C# 1 w typach bezpośrednich nie istniał odpowiednik takich referencji. Brak
danych reprezentowany był zwykle za pomocą dwóch technik:
 Używanie zarezerwowanej wartości do reprezentowania braku danych. Możesz
np. używać wartości decimal.MaxValue w filtrze cen, aby reprezentować brak okre-
ślonej ceny minimalnej.
 Przechowywanie odrębnej opcji logicznej, która informuje, czy w innym polu
znajduje się rzeczywista wartość, czy też wartość pola należy ignorować. Jeśli
sprawdzasz taką opcję przed użyciem powiązanego innego pola i opcja wskazuje
na brak wartości, wartość ta jest pomijana.

Ż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.

2.2.2. Wsparcie w środowisku CLR i platformie


— struktura Nullable<T>
Istotą obsługi typów bezpośrednich przyjmujących wartość null jest struktura Nullable<T>.
Jej podstawowa wersja wygląda tak:

87469504f326f0d7c1fcda56ef61bd79
8
2.2. Typy bezpośrednie przyjmujące wartość null 71

public struct Nullable<T> where T : struct Generyczna struktura z ograniczeniem,


{ zgodnie z którym T musi być typem
private readonly T value; bezpośrednim nieprzyjmującym wartości null.
private readonly bool hasValue;

public Nullable(T value) Konstruktor służący do podawania wartości


{
this.value = value;
this.hasValue = true;
} Właściwość służąca
do sprawdzania, czy dostępna
public bool HasValue { get { return hasValue; } } jest prawdziwa wartość.

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;
}
}
}

Widać tu, że w jedynym zadeklarowanym konstruktorze pole hasValue jest ustawiane


na wartość true. Jednak ta struktura, podobnie jak wszystkie inne, ma też konstruktor
bezparametrowy, w którym wartość pola hasValue pozostaje równa false, a pole value
przyjmuje wartość domyślną typu T:
Nullable<int> nullable = new Nullable<int>();
Console.WriteLine(nullable.HasValue); Wyświetla wartość False

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).

Jednak poniższe deklaracje są niedozwolone:


 Nullable<string> (string jest typem referencyjnym),
 Nullable<int[]> (tablice to typy referencyjne, nawet jeśli ich elementy są typu
bezpośredniego),
 Nullable<ValueType> (ValueType nie jest typem bezpośrednim),
 Nullable<Enum> (Enum nie jest typem bezpośrednim),
 Nullable<Nullable<int>> (typ Nullable<int> przyjmuje wartość null),
 Nullable<Nullable<Nullable<int>>> (próba dodatkowego zagnieżdżenia typu
przyjmującego wartość null niczego nie zmienia).

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.

Struktura Nullable<T> udostępnia metody i operatory:


 Bezparametrowa metoda GetValueOrDefault() zwraca wartość zapisaną w struk-
turze, a gdy właściwość HasValue jest równa false — wartość domyślną danego
typu.
 Sparametryzowana metoda GetValueOrDefault(T defaultValue) zwraca wartość
zapisaną w strukturze, a gdy właściwość HasValue jest równa false — podaną
wartość domyślną.
 Metody Equals(object) i GetHashCode() zadeklarowane w typie object są prze-
słonięte w intuicyjny sposób. Najpierw porównywane są wartości właściwości
HasValue, a następnie — gdy w obu obiektach są one równe true — porównywane
są wartości właściwości Value.
 Stosowana jest niejawna konwersja z typu T na Nullable<T>, która zawsze koń-
czy się powodzeniem i — jeśli właściwość HasValue jest równa true — zwróce-
niem wartości. Jest to odpowiednik wywołania sparametryzowanego konstruktora.
 Stosowana jest niejawna konwersja z typu Nullable<T> na T, która powoduje
zwrócenie zapisanej wartości (jeśli właściwość HasValue jest równa true) lub
zgłoszenie wyjątku InvalidOperationException (jeśli HasValue ma wartość false). Jest
to odpowiednik użycia właściwości Value.

87469504f326f0d7c1fcda56ef61bd79
8
2.2. Typy bezpośrednie przyjmujące wartość null 73

Do tematu konwersji wracam przy omawianiu wsparcia tego mechanizmu w języku.


Do tej pory jedyną omawianą tu sytuacją, w której środowisko CLR musi rozumieć typ
Nullable<T>, jest wymuszanie ograniczenia typów struct. Inny aspekt działania środo-
wiska CLR jest ściśle powiązany z wartościami null. Chodzi tu o pakowanie (ang. boxing).
PAKOWANIE
W kontekście pakowania typy bezpośrednie przyjmujące wartość null działają inaczej
od typów bezpośrednich, które nie przyjmują wartości null. Gdy pakowany jest typ
nieprzyjmujący wartości null, powstaje referencja do obiektu typu, który jest opako-
wanym odpowiednikiem pierwotnego typu. Załóżmy, że napisałeś następujący kod:
int x = 5;
object o = x;

Wartość o to referencja do obiektu typu „opakowany int”. Różnica między opakowanym


i zwykłym typem int jest zwykle w C# niewidoczna. Na przykład jeśli wywołasz
polecenie o.GetType(), zwrócona wartość typu Type będzie równa wartości zwróconej
przez instrukcję typeof(int). W niektórych językach (np. w C++/CLI) programiści mogą
odróżnić pierwotny typ wartości od jego opakowanego odpowiednika.
Dla typów bezpośrednich przyjmujących wartość null opakowany odpowiednik
nie istnieje. Efekt opakowania wartości typu Nullable<T> zależy od wartości właściwości
HasValue:

 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”.

Listing 2.9 ilustruje obie te możliwości.

Listing 2.9. Skutki pakowania typu bezpośredniego przyjmującego wartość null

Nullable<int> noValue = new Nullable<int>(); Pakowanie wartości, dla której


object noValueBoxed = noValue; właściwość HasValue to false.
Console.WriteLine(noValueBoxed == null); Wyświetla true (wynikiem
pakowanie jest referencja null).
Nullable<int> someValue = new Nullable<int>(5); Pakowanie wartości, dla której
object someValueBoxed = someValue; właściwość HasValue to true.
Console.WriteLine(someValueBoxed.GetType()); Wyświetla System.Int32
(wynikiem jest opakowany typ int).

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

Nullable<int> noValue = new Nullable<int>();


// Console.WriteLine(noValue.GetType()); Spowodowałoby zgłoszenie
wyjątku NullReferenceException.
Nullable<int> someValue = new Nullable<int>(5);
Console.WriteLine(someValue.GetType()); Wyświetla System.Int32
(tak samo jak wywołanie typeof(int)).

Zobaczyłeś już obsługę omawianych typów w platformie i środowisku CLR. Jednak


w C# zrobiono jeszcze więcej, aby ułatwić pracę z typami bezpośrednimi przyjmują-
cymi wartość null.

2.2.3. Obsługa dostępna w języku


C# 2 mógł zostać udostępniony z kompilatorem, który wykrywałby typy bezpośrednie
przyjmujące wartość null tylko wtedy, gdy używane jest ograniczenie typu struct.
Byłoby to bardzo niekorzystne, warto jednak rozważyć minimalną niezbędną obsługę
takich typów, aby docenić wszystkie mechanizmy dodane po to, by lepiej wpasować
w język typy bezpośrednie przyjmujące wartość null. Zacznijmy od najprostszej tech-
niki — uproszczenia nazw takich typów.
PRZYROSTEK ? W NAZWACH TYPÓW
Dodanie ? na końcu nazwy typu bezpośredniego, który nie przyjmuje wartości null,
to odpowiednik użycia typu Nullable<T> dla danego typu bezpośredniego. Ta technika
działa dla skrótowych nazw bezpośrednich typów (int, double itd.), a także dla pełnych
nazw typów. Na przykład cztery poniższe deklaracje oznaczają to samo:
 Nullable<int> x;
 Nullable<Int32> x;
 int? x;
 Int32? x;

Możesz je dowolnie zamieniać, a wygenerowany kod pośredni pozostanie taki sam.


W praktyce wszędzie stosuję przyrostek ?, jednak inne zespoły mogą stosować inne
konwencje. Dla przejrzystości dalej w książce używam składni Nullable<T>, ponieważ
znak ? w tekście może być mylący (w kodzie rzadko stanowi to problem).
Jest to najprostsze usprawnienie języka, jednak motyw umożliwiania pisania zwię-
złego kodu będzie powtarzał się w dalszych miejscach tego podrozdziału. Przyrostek
? pozwala łatwo podać typ. Następny mechanizm dotyczy łatwego zapisu wartości.

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)

Podejrzewam, że jestem niekonsekwentny w stosowaniu tych zapisów. Nie zachęcam


tu do niespójności, jednak jest to obszar, w którym niekonsekwencja nie powoduje
dużych problemów. Zawsze możesz później zmienić zdanie bez obaw o brak zgodności
z resztą kodu.
KONWERSJE
Wiesz już, że typ Nullable<T> umożliwia niejawną konwersję z typu T na Nullable<T>
i jawną konwersję z typu Nullable<T> na T. W języku dodatkowo rozbudowano kon-
wersje, pozwalając na tworzenie ich łańcuchów. Gdy dostępne są dwa typy bezpo-
średnie nieprzyjmujące wartości null, S i T, oraz możliwa jest konwersja z S na T (np.
z typu int na decimal), obsługiwane są także następujące konwersje:
 z Nullable<S> na Nullable<T> (niejawna lub jawna; zależy to od pierwotnej kon-
wersji),
 z S na Nullable<T> (niejawna lub jawna; zależy to od pierwotnej konwersji),
 z Nullable<S> na T (zawsze jawna).

Te konwersje przebiegają w dość oczywisty sposób — wartość null jest przekazywana,


a w razie potrzeby stosowana jest konwersja z typu S na T. Proces rozbudowywania
operacji o odpowiednie przekazywanie wartości null jest nazywany przenoszeniem
(ang. lifting).
Warto zwrócić uwagę na pewną kwestię — można jawnie przeprowadzać kon-
wersję na typy przyjmujące wartość null i nieprzyjmujące wartości null. W technologii
LINQ to XML technika ta jest stosowana ze znakomitym skutkiem. Dostępne są np.
jawne konwersje z typu XElement na typy int i Nullable<int>. Wiele operacji w techno-
logii LINQ to XML zwraca referencję null, jeśli zażądasz znalezienia nieistniejącego
elementu. Konwersja na typ Nullable<int> przekształca referencję null na wartość null
i pozwala przekazać tę wartość bez zgłaszania wyjątku. Jeśli jednak spróbujesz prze-
kształcić referencję null typu XElement na typ int nieprzyjmujący wartości null, zgło-
szony zostanie wyjątek. Dostępność obu konwersji sprawia, że łatwo można w bez-
pieczny sposób obsługiwać opcjonalne i wymagane elementy.

87469504f326f0d7c1fcda56ef61bd79
8
76 ROZDZIAŁ 2. C# 2

Konwersje są jedną z postaci operatorów, które mogą być wbudowane w C# lub


definiowane przez użytkownika. Inne operatory zdefiniowane dla typów nieprzyj-
mujących wartości null są traktowane podobnie, jeśli chodzi o tworzenie ich odpo-
wiedników zgodnych z wartościami null.
OPERATORY PRZENIESIONE
C# umożliwia przeciążanie następujących operatorów:
 jednoargumentowych: + ++ - -- ! ~ true false
 dwuargumentowych5: + - * / % & | ^ << >>
 równości: == !=
 relacyjnych: < > <= >=

Gdy te operatory są przeciążane dla typu bezpośredniego T nieprzyjmującego wartości


null, typ Nullable<T> obsługuje te same operatory z nieco zmienionymi typami ope-
randów i wyniku. Są to tak zwane operatory przeniesione (ang. lifted operators) nie-
zależnie od tego, czy są operatorami wbudowanymi (np. dodawaniem dla typów licz-
bowych), czy operatorami zdefiniowanymi przez użytkownika (np. dodawaniem obiektu
typu TimeSpan do obiektu typu DateTime). Obowiązuje tu kilka ograniczeń:
 Operatory true i false nigdy nie są przenoszone. Stosuje się je jednak bardzo
rzadko, dlatego nie stanowi to dużej straty.
 Przenoszone są tylko operatory przyjmujące operandy w postaci typów bezpo-
średnich nieprzyjmujących wartości null.
 W operatorach jednoargumentowych i dwuargumentowych (innych niż ope-
ratory równości i relacyjne) typem zwracanym przez pierwotny operator musi
być typ bezpośredni nieprzyjmujący wartości null.
 W operatorach równości i relacyjnych typem zwracanym przez pierwotny ope-
rator musi być typ logiczny (bool).
 Operatory & i | dla typu Nullable<bool> działają w opisany dalej specjalny sposób.

We wszystkich wymienionych operatorach typami operandów stają się odpowiedniki


pierwotnych typów przyjmujące wartość null. W operatorach jedno- i dwuargumen-
towych także zwracana wartość jest typu przyjmującego wartość null. Jeśli jeden
z operandów jest wtedy równy null, zwracane jest null. Operatory równości i rela-
cyjne zachowują zwracany typ logiczny nieprzyjmujący wartości null. Jeśli chodzi
o operatory równości, dwie wartości null są uznawane za równe, a wartość null i dowolna
wartość różna od null są uważane za różne. Z kolei operatory relacyjne zawsze zwracają
wartość false, jeśli któryś z operatorów to null. Gdy oba operatory są różne od null,
w standardowy sposób używany jest operator dla typu nieprzyjmującego wartości null.
Wszystkie te reguły mogą wydawać się bardziej skomplikowane, niż w rzeczywi-
stości są. W większości sytuacji wszystko działa tak, jak zapewne się spodziewasz.
Najłatwiej zobaczyć to na kilku przykładach. Ponieważ dla typu int istnieje tak dużo

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

Wyrażenie Przeniesiony operator Wynik


-nullInt int? –(int? x) null
-five int? –(int? x) -5
five + nullInt int? +(int? x, int? y) null
five + five int? +(int? x, int? y) 10
four & nullInt int? &(int? x, int? y) null
four & five int? &(int? x, int? y) 4
nullInt == nullInt bool ==(int? x, int? y) true
five == five bool ==(int? x, int? y) true
five == nullInt bool ==(int? x, int? y) false
five == four bool ==(int? x, int? y) false
four < five bool <(int? x, int? y) true
nullInt < five bool <(int? x, int? y) false
five < nullInt bool <(int? x, int? y) false
nullInt < nullInt bool <(int? x, int? y) false
nullInt <= nullInt bool <=(int? x, int? y) false

Prawdopodobnie najbardziej zaskakującym wierszem w tej tabeli jest ostatni z nich —


wartość null nie jest mniejsza ani równa względem innej wartości null, choć wydaje się,
że są one równe (tak jak w siódmym wierszu)! To bardzo dziwne, choć zgodnie z moim
doświadczeniem w praktyce raczej nie powinno sprawiać problemów. Na liście ogra-
niczeń dotyczących przenoszenia operatorów wspomniałem, że typ Nullable<bool>
działa nieco inaczej niż pozostałe typy.
LOGIKA DLA TYPÓW PRZYJMUJĄCYCH WARTOŚĆ NULL
Do przedstawiania wyników logicznych operacji boolowskich dla wszystkich możliwych
kombinacji danych wejściowych często używana jest tablica prawdy. To samo podejście
można zastosować do logiki operacji na typie Nullable<Boolean>, przy czym dla wszyst-
kich danych wejściowych trzeba uwzględnić tu trzy wartości (true, false i null) zamiast
tylko true i false. Dla typu Nullable<bool> nie ma zdefiniowanych warunkowych ope-
ratorów logicznych (wykorzystujących przetwarzanie skrócone operatorów && i ||), co
upraszcza pracę.
Tylko logiczne operatory AND i OR (& i |) działają w specjalny sposób. Pozostałe
operatory — jednoargumentowa logiczna negacja (!) i XOR (^) — działają według
tych samych reguł, co pozostałe operatory przeniesione. Aby omówienie było kompletne,
w tabeli 2.2 przedstawiona jest tablica prawdy dla wszystkich czterech dostępnych
operatorów logicznych dla typu Nullable<bool>. Wyróżnione są wyniki, które byłyby
inne, gdyby nie obowiązywały dodatkowe reguły dla typu Nullable<bool>.

87469504f326f0d7c1fcda56ef61bd79
8
78 ROZDZIAŁ 2. C# 2

Tabela 2.2. Tablica prawdy dla operatorów z typu Nullable<bool>

x y x&y x|y x^y !x


true true true true false false
true false false true true false
true null null true null false
false true false true true true
false false false false false true
false null false null null true
null true null true null null
null false false null null null
null null null null null null

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.

Wyniki działania operatorów przeniesionych


są specyficzne dla języka C#
Operatory przeniesione i konwersje (w tym opisana w tym podrozdziale logika dla typu
Nullable<bool>) są udostępniane przez kompilator języka C#, a nie przez środowisko CLR
lub samą platformę. Jeśli użyjesz narzędzia ildasm do kodu, który przetwarza operatory dla
typów przyjmujących wartość null, zobaczysz, że kompilator tworzy odpowiedni kod
pośredni do sprawdzania wartości null i odpowiednio je obsługuje.
Poszczególne języki mogą działać w tym obszarze w inny sposób. Koniecznie należy zwró-
cić na to uwagę, jeśli chcesz przenosić kod między różnymi językami platformy .NET. Na
przykład w Visual Basicu operatory przeniesione są traktowane bardziej jak w SQL-u, dla-
tego wynik porównania x < y to Nothing, jeśli x lub y jest równe Nothing.

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.

Ta technika umożliwia bezpieczne przekształcanie dowolnej referencji na wartość


w jednym kroku, choć standardowo musiałbyś po konwersji sprawdzić, czy wynik to
null. W C# 1 konieczne było używanie operatora is i późniejsze rzutowanie, co jest
nieeleganckim rozwiązaniem, ponieważ środowisko CLR musi dwukrotnie przepro-
wadzać to samo sprawdzanie typu.

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.

W C# 7 wprowadzono jeszcze lepsze rozwiązanie (oparte na dopasowywaniu wzorców;


jest ono opisane w rozdziale 12.), które można wykorzystać w większości miejsc, gdzie
stosowałem operator as dla typów bezpośrednich przyjmujących wartość null. Jeśli
jednak chcesz, by typem wynikowym był Nullable<T>, operator as będzie przydatny.
Ponadto w C# 2 wprowadzono zupełnie nowy operator przeznaczony specjalnie do
wygodnej obsługi wartości null.
OPERATOR ??
Dość często zdarza się, że programista chce używać typów bezpośrednich przyjmu-
jących wartość null (dotyczy to też typów referencyjnych) i udostępniać wartość
domyślną, gdy dane wyrażenie ma wartość null. W C# 2 wprowadzono operator ??
(ang. null-coalescing operator), który służy właśnie do tego.
Jest to operator dwuargumentowy, którzy przetwarza wyrażenie first ?? second,
wykonując następujące kroki (to tylko ogólny opis):

87469504f326f0d7c1fcda56ef61bd79
8
80 ROZDZIAŁ 2. C# 2

1. Przetwarzanie członu first.


2. Jeśli wynik jest różny od null, jest to wynik całego wyrażenia.
3. W przeciwnym razie przetwarzany jest człon second i to on określa wynik całego
wyrażenia.

Jest to tylko ogólny opis, ponieważ w formalnych regułach w specyfikacji uwzględ-


nione są też konwersje między typami first i second. W większości zastosowań tego
operatora konwersje nie są istotne, dlatego nie będą ich omawiał w tym miejscu. Jeśli
Cię one interesują, łatwo znajdziesz ich objaśnienie w specyfikacji.
Warto zwrócić uwagę na jeden aspekt reguł działania tego operatora. Jeśli typem
pierwszego operandu jest typ bezpośredni przyjmujący wartość null, a typem dru-
giego operandu jest typ właściwy dla pierwszego operandu, typem całego wyrażenia
jest ten typ właściwy (nieprzyjmujący wartości null). Na przykład poniższy kod jest
w pełni poprawny:
int? a = 5;
int b = 10;
int c = a ?? b;

Warto zauważyć, że wartość jest przypisywana bezpośrednio do c, choć typ zmiennej


c to int nieprzyjmujący wartości null. Jest to możliwe tylko dlatego, że zmienna b ma typ
nieprzyjmujący wartości null. Dlatego wiadomo, że wynik całego wyrażenia nie może
być równy null. Operator ?? można łatwo łączyć z nim samym. Wyrażenie w postaci
x ?? y ?? z sprawdza y tylko wtedy, jeśli x ma wartość null, oraz sprawdza z tylko wtedy,
gdy zarówno x, jak i y są równe null.
Praca z wartościami null stała się jeszcze łatwiejsza (i częściej są one wynikiem
wyrażenia) w C# 6 dzięki dodaniu operatora ?. (jest on opisany w podrozdziale 10.3).
Połączenie ?. i ?? jest świetnym sposobem na obsługę możliwych wartości null w róż-
nych miejscach kodu. Podobnie jak wszystkie techniki, tak i tę najlepiej jest stosować
z rozwagą. Jeśli zauważysz, że czytelność kodu spada, rozważ zastosowanie kilku instruk-
cji, aby nie próbować wykonywać zbyt wielu działań w jednym kroku.
To tyle na temat wprowadzonych w C# 2 typów bezpośrednich przyjmujących
wartość null. Omówione zostały już dwa najważniejsze mechanizmy języka C# 2,
jednak nadal trzeba opisać kilka ważnych funkcji i zestaw mniej istotnych. Następne
w kolejności będą delegaty.

2.3. Uproszczone tworzenie delegatów


Podstawowe przeznaczenie delegatów nie zmieniło się od czasu ich wprowadzenia.
Mają one hermetyzować fragment kodu, aby można go było przekazywać i w razie
potrzeby wykonywać w sposób bezpieczny ze względu na typ (jeśli chodzi o typ zwra-
canej wartości i parametry). W C# 1 delegaty prawie zawsze służyły do obsługi zdarzeń
i uruchamiania wątków. Prawie się to nie zmieniło, gdy w 2005 wprowadzono C# 2.
Dopiero w 2008 r. technologia LINQ pomogła programistom nabrać swobody w prze-
kazywaniu funkcji z różnych przyczyn.

87469504f326f0d7c1fcda56ef61bd79
8
2.3. Uproszczone tworzenie delegatów 81

W C# 2 wprowadzono trzy nowe sposoby tworzenia instancji delegata, a także


możliwość deklarowania generycznych delegatów takich jak EventHandler<TEventArgs>
i Action<T>. Zacznijmy od konwersji grupy metod.

2.3.1. Konwersje grupy metod


Grupa metod oznacza jedną lub kilka metod o tej samej nazwie. Każdy programista
używający C# stosował je od zawsze, nawet o tym nie myśląc. Wynika to z tego, że każde
wywołanie metody dotyczy grupy metod. Spójrz na ten prosty kod:
Console.WriteLine("Witaj");

Wyrażenie Console.WriteLine dotyczy grupy metod. Kompilator sprawdza argumenty,


aby ustalić, którą przeciążoną wersję z tej grupy ma wywołać. Grupy metod były
używane nie tylko w wywołaniach w C# 1, ale też w wyrażeniach tworzących delegaty.
Był to jedyny sposób, w jaki w tym języku można było utworzyć instancję delegata.
Załóżmy, że używasz następującej metody:
private void HandleButtonClick(object sender, EventArgs e)

Teraz możesz utworzyć instancję typu EventHandler6 w następujący sposób:


EventHandler handler = new EventHandler(HandleButtonClick);

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;

Podobna technika działa też przy subskrybowaniu zdarzeń i usuwaniu subskrypcji:


button.Click += 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.

2.3.2. Metody anonimowe


Możliwe, że spodziewałeś się w tym miejscu wielu szczegółów na temat metod ano-
nimowych. Jednak dokładniejszy opis pozostawiam dla mechanizmu będącego następcą
metod anonimowych — dla wyrażeń lambda. Wprowadzono je w C# 3. Podejrzewam,
że gdyby wyrażenia lambda istniały przed metodami anonimowymi, te ostatnie nigdy by
nie powstały.

6
Sygnatura typu EventHandler to public delegate void EventHandler(object sender, EventArgs e).

87469504f326f0d7c1fcda56ef61bd79
8
82 ROZDZIAŁ 2. C# 2

Jednak wprowadzenie metod anonimowych w C# 2 sprawiło, że zacząłem trak-


tować delegaty w zupełnie nowy sposób. Metody anonimowe umożliwiają tworzenie
instancji delegatów bez tworzenia rzeczywistej wskazywanej metody7. Jeśli chcesz utwo-
rzyć instancję delegata, wystarczy zapisać potrzebny kod wewnątrzwierszowo. Należy
podać słowo kluczowe delegate, opcjonalnie dodać parametry, a następnie zapisać kod
w nawiasie klamrowym. Na przykład, jeśli chcesz utworzyć metodę obsługi zdarzeń
wyświetlającą po zgłoszeniu zdarzenia tekst w konsoli, możesz to zrobić w bardzo
prosty sposób:
EventHandler handler = delegate
{
Console.WriteLine("Zgłoszono zdarzenie");
};

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.

2.3.3. Zgodność delegatów


W C# 1 do utworzenia instancji delegata potrzebna była metoda o sygnaturze, która ma
dokładnie ten sam typ zwracanej wartości i typy parametrów (włącznie z modyfikato-
rami ref i out). Załóżmy, że używane są pokazane tu deklaracja delegata i metoda:
public delegate void Printer(string message);

public void PrintAnything(object obj)


{
Console.WriteLine(obj);
}

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.

Kompilator zezwala na takie operacje, ponieważ są one bezpieczne. Każdy argument,


który można przekazać do delegata typu Printer, można też bezpiecznie przekazać do
delegata typu GeneralPrinter. Kompilator pozwala na wykonanie w odwrotnym kierunku
analogicznej operacji dotyczącej typów zwracanych wartości. Ilustruje to następny
przykład:
Delegaty bezparametrowe
public delegate object ObjectProvider(); zwracające wartość.
public delegate string StringProvider();
Instancję delegata StringProvider można utworzyć
StringProvider stringProvider = ...; w dowolny sposób.
ObjectProvider objectProvider = Tworzenie instancji delegata ObjectProvider opakowującej
new ObjectProvider(stringProvider); instancję delegata StringProvider.

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

2.4.1. Wprowadzenie do iteratorów


Iterator to metoda lub właściwość implementowana za pomocą bloku iteratora, który
jest zwykłym blokiem kodu zawierającym instrukcję yield return lub yield break. Bloki
iteratora można wykorzystać tylko do implementowania metod lub właściwości o jed-
nym z poniższych typów zwracanych wartości:
 IEnumerable,
 IEnumerable<T> (gdzie T może być parametrem określającym typ lub zwykłym
typem),
 IEnumerator,
 IEnumerator<T> (gdzie T może być parametrem określającym typ lub zwykłym
typem).

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.

Listing 2.11. Prosty iterator generujący liczby całkowite

static IEnumerable<int> CreateSimpleIterator()


{
yield return 10;
for (int i = 0; i < 3; i++)
{
yield return i;
}
yield return 20;
}

Po utworzeniu tej metody można ją wywołać i iteracyjnie przetwarzać wyniki w zwykłej


pętli foreach:
foreach (int value in CreateSimpleIterator())
{
Console.WriteLine(value);
}

Ta pętla wyświetli następujące dane wyjściowe:


10
0
1
2
20

87469504f326f0d7c1fcda56ef61bd79
8
86 ROZDZIAŁ 2. C# 2

Na razie nie jest to szczególnie interesujące. Mógłbyś zmodyfikować metodę, aby


tworzyła kolekcję List<int>, zastąpić każdą instrukcję yield return wywołaniem Add(),
a następnie na końcu metody zwrócić listę. Dane wyjściowe pętli byłyby wtedy
identyczne, jednak kod działałby w inny sposób. Ważną cechą iteratorów jest leniwe
wykonywanie.

2.4.2. Leniwe wykonywanie


Leniwe wykonywanie (inaczej leniwe wartościowanie) wymyślono w ramach rachunku
lambda w latach 30. ubiegłego wieku. Podstawowa idea jest tu prosta — kod należy
wykonywać tylko wtedy, gdy potrzebna jest obliczana w nim wartość. Zastosowania
tej techniki znacznie wykraczają poza iteratory, jednak tu jest ona opisywana właśnie
w kontekście iteratorów.
Aby wyjaśnić sposób wykonywania kodu, na listingu 2.12 przekształcono pętlę foreach
na w znacznej mierze analogiczny kod, w którym używana jest pętla while. Dla uprosz-
czenia używany jest tu lukier składniowy w postaci instrukcji using, dzięki czemu
automatycznie wywoływane jest polecenie Dispose.

Listing 2.12. Rozwinięta pętla foreach

IEnumerable<int> enumerable = CreateSimpleIterator(); Wywołanie metody iteratora.


using (IEnumerator<int> enumerator = Pobieranie obiektu typu IEnumerator<T>
enumerable.GetEnumerator()) z obiektu typu IEnumerable<T>.
{
while (enumerator.MoveNext()) Przejście do następnej wartości
{ (jeśli taka istnieje).
int value = enumerator.Current; Pobieranie aktualnej wartości.
Console.WriteLine(value);
}
}

Jeśli nigdy wcześniej nie zastanawiałeś się nad interfejsami IEnumerable/IEnumerator


(i ich generycznymi odpowiednikami), teraz jest dobry moment, aby się upewnić, że
rozumiesz różnicę między nimi. IEnumerable reprezentuje sekwencję, która umożli-
wia iterację. IEnumerator odpowiada kursorowi w sekwencji. Kilku instancji interfejsu
IEnumerator można zwykle użyć do iteracyjnego przetwarzania tej samej instancji inter-
fejsu IEnumerable bez zmieniania stanu tego ostatniego. Z kolei interfejs IEnumerator
z natury ma zmienny stan. Każde wywołanie metody MoveNext() to żądanie przesu-
nięcia kursora do następnego elementu sekwencji, której dotyczy iteracja.
Jeśli nie jest to w pełni zrozumiałe, możesz potraktować interfejs IEnumerable jak
książkę, a interfejs IEnumerator jak zakładkę. W danym momencie w książce może
znajdować się wiele zakładek. Przeniesienie zakładki do następnej strony nie zmienia
stanu książki ani innych zakładek, jednak zmienia stan tej konkretnej zakładki — jej
pozycję w tekście. Metoda IEnumerable.GetEnumerator() jest pewnego rodzaju przygo-
towaniem. Żąda od sekwencji utworzenia instancji interfejsu IEnumerator skonfiguro-
wanego na potrzeby iteracyjnego przetwarzania danej sekwencji. Przypomina to umiesz-
czenie nowej zakładki na początku książki.

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;
}

Wywołanie CreateSimpleIterator() nie powoduje wykonania ciała tej metody.


Jeśli umieścisz punkt przerwania w pierwszym wierszu (yield return 10) i urucho-
misz kod w trybie kroczenia, po wywołaniu metody kod nie dojdzie do punktu prze-
rwania. Nie stanie się to także po wywołaniu metody GetEnumerator(). Ciało pokazanej
metody zostanie uruchomione dopiero po wywołaniu MoveNext(). Co się wtedy stanie?

2.4.3. Przetwarzanie instrukcji yield


Nawet po uruchomieniu metoda dojdzie tylko do miejsca, do którego musi dojść.
Wykonywanie jest wstrzymywane po wystąpieniu dowolnego z poniższych warunków:
 zgłoszenie wyjątku,
 dojście do końca metody,
 dojście do instrukcji yield break,
 przetworzenie operandu instrukcji yield return, co oznacza gotowość do zwró-
cenia wyniku.

Zgłoszony wyjątek jest przekazywany w standardowy sposób. Po dojściu do końca


metody lub instrukcji yield break metoda MoveNext() zwraca wartość false, aby poin-
formować o dojściu do końca sekwencji. Jeśli kod dotrze do instrukcji yield return,
właściwość Current jest ustawiana na wygenerowaną wartość i metoda MoveNext()
zwraca true.

UWAGA. Warto doprecyzować poprzedni akapit. Wyjątek jest przekazywany w standardowy


sposób, jeśli już wykonywany jest kod iteratora. Nie należy zapominać, że kod iteratora
zaczyna pracę dopiero wtedy, gdy wywołujący go kod zaczyna przetwarzanie zwracanej sekwen-
cji. To wywołanie MoveNext() powoduje zgłoszenie wyjątku, a nie pierwsze wywołanie metody
iteratora.

87469504f326f0d7c1fcda56ef61bd79
8
88 ROZDZIAŁ 2. C# 2

W pokazanym tu prostym przykładzie metoda MoveNext() po rozpoczęciu pracy docho-


dzi do instrukcji yield return 10;, ustawia właściwość Current na wartość 10, a następnie
zwraca true.
Opis pierwszego wywołania metody MoveNext() jest prosty, co jednak z późniejszymi
wywołaniami? Nie można zaczynać pracy od początku, ponieważ sekwencja składałaby
się wtedy z nieskończonej liczby wartości 10. Zamiast tego zwrócenie sterowania przez
MoveNext() jest odpowiednikiem wstrzymania tej metody. Wygenerowany kod śledzi
miejsce, do którego program doszedł w tej metodzie, a także inne aspekty stanu (np.
zmienną lokalną i w pętli). Po ponownym wywołaniu metody MoveNext() jej wykony-
wanie jest wznawiane od zapamiętanego punktu. To właśnie sprawia, że metoda ta jest
wykonywana leniwie. Ten właśnie mechanizm trudno jest samodzielnie poprawnie
napisać.

2.4.4. Znaczenie leniwego wykonywania


Aby zrozumieć, dlaczego leniwe wykonywanie jest tak ważne, zobacz kod wyświetla-
jący ciąg liczb Fibonacciego do czasu osiągnięcia pierwszej wartości większej niż 1000.
Na listingu 2.13 przedstawiono metodę Fibonacci(), która zwraca nieskończoną sekwen-
cję, a następnie metodę pobierającą elementy tej sekwencji do czasu napotkania wartości
większej niż ustalony limit.

Listing 2.13. Iteracyjne pobieranie elementów ciągu Fibonacciego

static IEnumerable<int> Fibonacci()


{
int current = 0;
int next = 1;
while (true) Pętla nieskończona? Tylko w sytuacji, jeśli ciągle żądasz kolejnych liczb.
{
yield return current; Generowanie aktualnej liczby ciągu Fibonacciego.
int oldCurrent = current;
current = next;
next = next + oldCurrent;
}
}

static void Main()


{
foreach (var value in Fibonacci()) Wywołanie metody, aby otrzymać ciąg.
{
Console.WriteLine(value); Wyświetlanie bieżącej wartości.
if (value > 1000) Warunek wyjścia z pętli.
{
break;
}
}
}

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

rować ciąg Fibonacciego, miałaby dodatkowo znać warunek zakończenia generowania


wartości? Załóżmy, że czasem chcesz zakończyć generowanie liczb w zależności od
tego, jak długo są wyświetlane, w innych sytuacjach w zależności od liczby wyświetlo-
nych wartości, a czasem na podstawie bieżącej wartości. Nie chcesz przecież imple-
mentować tej metody trzy razy.
Aby uniknąć tworzenia listy, możesz wyświetlać wartości w pętli. To jednak sprawia,
że metoda Fibonacci() jest jeszcze bardziej powiązana z tym, jak chcesz w danej sytu-
acji wykorzystać generowane wartości. Ponadto co się stanie, jeśli zechcesz dodać te
wartości do siebie, zamiast je wyświetlać? Napiszesz następną metodę? Jest to poważne
naruszenie zasady podziału obowiązków.
Rozwiązanie z iteratorem to dokładnie to, czego potrzebujesz. Reprezentuje ono
nieskończoną sekwencję i nie robi nic więcej. W kodzie używającym iteratora można
pobrać dowolną liczbę wartości8 i używać ich w dowolny sposób.
Ręczne implementowanie ciągu Fibonacciego nie jest bardzo trudne. Między
wywołaniami nie trzeba utrzymywać dużej ilości stanu, a przepływ sterowania jest
prosty. (Pomocne tu jest to, że występuje tylko jedna instrukcja yield return). Jednak gdy
kod jest bardziej skomplikowany, samodzielne pisanie go nie jest łatwe. Kompilator
nie tylko generuje kod śledzący miejsce, do którego doszedł program, ale też potrafi
w inteligentny sposób obsługiwać bloki finally, co nie jest tak proste, jak mogłoby się
wydawać.

2.4.5. Przetwarzanie bloków finally


Może się wydawać dziwne, że spośród całej składni, jaką C# udostępnia do zarzą-
dzania przepływem sterowania, skupiam się na blokach finally. Jednak sposób ich
obsługi w iteratorach jest ciekawy i ważny ze względu na przydatność iteratorów.
W praktyce znacznie częściej będziesz stosował instrukcje using zamiast samych bloków
finally, możesz jednak traktować instrukcje using jako mechanizm zbudowany z bloków
finally, dlatego w obu przypadkach obowiązują te same zasady.
Aby zademonstrować przepływ sterowania, na listingu 2.14 pokazany jest prosty blok
iteratora generujący dwa elementy w bloku try i wyświetlający informacje o postępie
w konsoli. Z tej metody można korzystać na kilka sposobów.

Listing 2.14. Iterator zapisujący informacje o postępie

static IEnumerable<string> Iterator()


{
try
{
Console.WriteLine("Przed pierwszą instrukcją yield");
yield return "Pierwsza";
Console.WriteLine("Między instrukcjami yield");
yield return "Druga";
Console.WriteLine("Po drugiej instrukcji yield");

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.

Listing 2.15. Prosta pętla foreach do iteracyjnego pobierania i wyświetlania wartości

static void Main()


{
foreach (string value in Iterator())
{
Console.WriteLine("Otrzymana wartość: {0}", value);
}
}

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.

Listing 2.16. Wychodzenie z pętli foreach przy korzystaniu z iteratora

static void Main()


{
foreach (string value in Iterator())
{
Console.WriteLine("Otrzymana wartość: {0}", value);
if (value != null)
{
break;
}
}
}

Oto dane wyjściowe z listingu 2.16:


Przed pierwszą instrukcją yield
Otrzymana wartość: Pierwsza
W bloku finally

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

static void Main()


{
IEnumerable<string> enumerable = Iterator();
using (IEnumerator<string> enumerator = enumerable.GetEnumerator())
{
while (enumerator.MoveNext())
{
string value = enumerator.Current;

87469504f326f0d7c1fcda56ef61bd79
8
92 ROZDZIAŁ 2. C# 2

Console.WriteLine("Otrzymana wartość: {0}", value);


if (value != null)
{
break;
}
}
}
}

Ważnym aspektem jest instrukcja using. Gwarantuje ona, że niezależnie od sposobu


zakończenia pracy zostanie wywołana metoda Dispose obiektu typu IEnumerator<string>.
Jeśli w momencie zakończenia pracy metoda iteratora będzie wstrzymana w bloku try,
metoda Dispose wykona blok finally. Czyż nie jest to pomysłowe?

2.4.6. Znaczenie obsługi bloku finally


Może się wydawać, że opisana technika to mało istotny szczegół. Ma ona jednak duży
wpływ na zakres zastosowań iteratorów. Dzięki tej technice iteratory można stosować
w metodach, które zajmują zasoby (np. uchwyty plików) wymagające zwolnienia.
Oznacza to także, że iteratory można łączyć w łańcuch z innymi iteratorami, których
dotyczy ten sam wymóg. W rozdziale 3. dowiesz się, że w technologii LINQ to Objects
często używane są sekwencje, a niezawodne zwalnianie zasobów jest niezbędne do tego,
by móc pracować z plikami i innymi zasobami.

Opisane rozwiązanie wymaga,


by jednostka wywołująca zwalniała iterator
Jeśli nie wywołasz metody Dispose iteratora (a kod nie dotarł do końca sekwencji), może
to skutkować wyciekaniem zasobów, a przynajmniej opóźnionym ich porządkowaniem.
Należy tego unikać.
Niegeneryczny interfejs IEnumerator nie dziedziczy po interfejsie IDisposable, jednak pętla
foreach sprawdza, czy obiekt używany w czasie wykonywania programu zawiera imple-
mentację interfejsu IDisposable i w razie potrzeby wywołuje metodę Dispose. Generyczny
interfejs IEnumerator<T> dziedziczy po interfejsie IDisposable, co upraszcza pracę.
Jeśli iteracyjnie pobierasz elementy, ręcznie wywołując metodę MoveNext() (co ma swoje
zastosowania), też powinieneś zastosować opisaną wcześniej technikę. Jeżeli iteracja doty-
czy kolekcji generycznego typu IEnumerable<T>, możesz użyć instrukcji using tak jak
w listingu z przekształconą pętlą foreach. Gdy znajdujesz się w tej nieszczęśliwej sytuacji,
że iteracja dotyczy niegenerycznej sekwencji, powinieneś wykonywać te same testy inter-
fejsu, co kompilator w metodzie foreach.

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.

Listing 2.18. Wczytywanie wierszy z pliku

static IEnumerable<string> ReadLines(string path)


{
using (TextReader reader = File.OpenText(path))
{

87469504f326f0d7c1fcda56ef61bd79
8
2.4. Iteratory 93

string line;
while ((line = reader.ReadLine()) != null)
{
yield return line;
}
}
}

Podobną metodę dodano w platformie .NET 4.0 (File.ReadLines). Jednak metoda


z platformy nie działa dobrze, jeśli wywołasz ją raz i chcesz wielokrotnie iteracyjnie
pobierać wyniki. Metoda z platformy otwiera plik tylko raz. Metoda z listingu 2.18
otwiera plik na potrzeby każdej iteracji, co upraszcza zrozumienie kodu. Wadą jest opóź-
nienie wyjątków, gdy plik nie istnieje lub nie można go odczytać. W projekcie API
zawsze występują złożone wady i zalety różnych rozwiązań.
Ta metoda została przedstawiona, aby pokazać, jak ważna jest poprawna obsługa
zwalniania iteratora. Jeśli pętla foreach, która zgłosi wyjątek lub wcześniej zwróci
sterowanie, pozostawi wiszący uchwyt do otwartego pliku, metoda będzie prawie bez-
użyteczna. Przed zakończeniem omawiania iteratorów warto na chwilę przyjrzeć się
temu, jak są zaimplementowane.

2.4.7. Zarys implementacji


Zawsze uważałem, że warto na ogólnym poziomie zapoznać się z tym, jak kompilator
przetwarza kod — zwłaszcza w skomplikowanych sytuacjach związanych np. z itera-
torami, mechanizmem async/await i funkcjami anonimowymi. W tym punkcie znajdziesz
tylko krótki opis. W artykule ze strony http://csharpindepth.com dostępnych jest znacz-
nie więcej informacji. Pamiętaj, że szczegóły są zależne od kompilatora. Może się
okazać, że w innych kompilatorach zastosowano odmienne podejście. Podejrzewam
jednak, że większość kompilatorów działa zgodnie z podobną strategią.
Pierwszą rzeczą, jaką należy zrozumieć, jest to, że choć napisałeś metodę9, kom-
pilator generuje zupełnie nowy typ z implementacją odpowiednich interfejsów. Ciało
metody jest przenoszone do metody MoveNext() wygenerowanego typu i dostosowywane
do semantyki działania iteratorów. Dalej zobaczysz, jaki kod kompilator generuje na
podstawie listingu 2.19.

Listing 2.19. Metoda iteratora przetwarzana przez kompilator

public static IEnumerable<int> GenerateIntegers(int count)


{
try
{
for (int i = 0; i < count; i++)
{
Console.WriteLine("Generowanie {0}", i);
yield return i;

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.

Listing 2.20. Infrastruktura kodu wygenerowanego na podstawie iteratora

public static IEnumerable<int> GenerateIntegers( Namiastka metody z pierwotnie


int count) zadeklarowaną sygnaturą.
{
GeneratedClass ret = new GeneratedClass(-2); Wygenerowana klasa reprezentująca
ret.count = count; maszynę stanową.
return ret;
}

private class GeneratedClass


: IEnumerable<int>, IEnumerator<int>
{
public int count;
private int state;
private int current; Wszystkie pola maszyny stanowej
(mają różne przeznaczenie).
private int initialThreadId;
private int i;

public GeneratedClass(int state)


Konstruktor wywoływany przez namiastkę metody
{ i metodę GetEnumerator.
this.state = state;

87469504f326f0d7c1fcda56ef61bd79
8
2.4. Iteratory 95

initialThreadId = Environment.CurrentManagedThreadId;
}

public bool MoveNext() { ... } Główny kod maszyny stanowej.

public IEnumerator<int> GetEnumerator() { ... } Tworzy w razie potrzeby


nową maszynę stanową.
public void Reset()
{
throw new NotSupportedException(); Wygenerowane iteratory nigdy
} nie udostępniają metody Reset.

public void Dispose() { ... } Wykonuje w razie potrzeby bloki finally

public int Current { get { return current; } } Właściwość Current zwracająca


ostatnią wygenerowaną wartość.
private void Finally1() { ... }
Body of… - Ciało bloku finally używanego w metodach MoveNext i Dispose

IEnumerator Enumerable().GetEnumerator()
{
return GetEnumerator(); Jawna implementacja składowych
} niegenerycznego interfejsu.

object IEnumerator.Current { get { return current; } }


}

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.

Jednostka wywołująca powinna wykonywać następującą sekwencję operacji:

87469504f326f0d7c1fcda56ef61bd79
8
96 ROZDZIAŁ 2. C# 2

1. Wywołanie metody GetEnumerator() w celu pobrania obiektu typu IEnumerator


<int>.
2. Wielokrotne wywołanie metody MoveNext() i właściwości Current obiektu typu
IEnumerator<int> do czasu zwrócenia wartości false przez metodę MoveNext().
3. Wywołanie metody Dispose w celu wykonania potrzebnych operacji porządku-
jących (niezależnie od tego, czy został zgłoszony wyjątek).

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

Listing 2.21. Uproszczona metoda MoveNext()

public bool MoveNext()


{
try
{
switch (state)
{ Tablica skoków pozwalająca przejść
w odpowiednie miejsce metody.
} Zwracanie kodu metody
po każdej instrukcji yield return.
}
fault Blok fault (wykonywany tylko po wystąpieniu wyjątków).
{
Dispose(); Porządkowanie stanu po wystąpieniu wyjątków.
}
}

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

wstrzymywać kod do czasu kolejnego wywołania metody MoveNext(), wstrzymuje się


metody asynchroniczne do czasu zakończenia asynchronicznie wykonywanej operacji.
Omówione już zostały najważniejsze mechanizmy wprowadzone w C# 2. W tej
wersji dodano też kilka mniejszych funkcji. Ich omówienie jest dość proste, dlatego
wszystkie są opisane w jednym miejscu. Nie są one powiązane ze sobą, jednak czasem
język jest projektowany właśnie w taki sposób.

2.5. Mniej istotne mechanizmy


Niektóre mechanizmy opisane w tym podrozdziale są, według mojego doświadczenia,
stosowane rzadko. Inne są powszechnie wykorzystywane w każdym nowym kodzie
pisanym w języku C#. Miejsce potrzebne do opisania danego mechanizmu nie zawsze
jest skorelowane z przydatnością tego ostatniego. W tym podrozdziale opisano nastę-
pujące zagadnienia:
 Typy częściowe, które umożliwiają podział jednego typu między kilka plików
źródłowych.
 Klasy statyczne używane dla typów narzędziowych.
 Różny poziom dostępności (publiczny, prywatny itd.) dla akcesorów get i set
we właściwościach.
 Usprawnienie aliasów przestrzeni nazw, aby ułatwić pracę z kodem, w którym te
same nazwy są stosowane w wielu przestrzeniach nazw lub podzespołach.
 Dyrektywy pragma umożliwiające stosowanie dodatkowych mechanizmów spe-
cyficznych dla kompilatora, np. tymczasowe wyłączanie ostrzeżeń.
 Bufory o stałym rozmiarze przeznaczone na dane w niezabezpieczonym kodzie.
 Upraszczający testowanie atrybut [InternalsVisibleTo].

Każdy z tych mechanizmów jest niezależny od pozostałych, a kolejność ich omawiania


nie ma znaczenia. Jeśli znasz któreś z opisywanych tu zagadnień wystarczająco dobrze,
aby wiedzieć, że jest dla Ciebie nieistotne, możesz bezpiecznie je pominąć bez wpływu
na dalszą lekturę.

2.5.1. Typy częściowe


Typy częściowe umożliwiają zadeklarowanie jednej klasy, struktury lub interfejsu
w kilku częściach i zwykle w kilku plikach źródłowych. Technika ta jest przeważnie
używana razem z generatorami kodu. Kilka generatorów kodu może tworzyć różne
części tego samego typu, który można później dodatkowo wzbogacić o ręcznie pisany
kod. Te różne części są łączone przez kompilator i działają tak, jakby wszystkie zostały
zadeklarowane razem.
Typy częściowe deklaruje się, dodając modyfikator partial do deklaracji typu. Mody-
fikator ten musi znajdować się przy każdej części typu. Na listingu 2.22 pokazano
przykładową deklarację z dwoma częściami. Widać tu, że metoda zadeklarowana
w jednej części może być używana w innej.

87469504f326f0d7c1fcda56ef61bd79
8
2.5. Mniej istotne mechanizmy 99

Listing 2.22. Prosta klasa częściowa

partial class PartialDemo


{
public static void MethodInPart1()
{
MethodInPart2(); Używanie metody zadeklarowanej w drugiej części.
}
}

partial class PartialDemo


{
private static void MethodInPart2() Metoda używana w pierwszej części.
{
Console.WriteLine("Metoda MethodInPart2");
}
}

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.

Listing 2.23. Dwie metody częściowe — jedna zaimplementowana, druga nie

partial class PartialMethodsDemo


{
public PartialMethodsDemo()
{
OnConstruction(); Wywołanie niezaimplementowanej metody częściowej.
}

public override string ToString()


{
string ret = "Pierwotna zwracana wartość";
CustomizeToString(ref ret); Wywołanie zaimplementowanej metody częściowej.

87469504f326f0d7c1fcda56ef61bd79
8
100 ROZDZIAŁ 2. C# 2

return ret;
}

partial void OnConstruction();


partial void CustomizeToString(ref string text); Deklaracje metody częściowej.
}

partial class PartialMethodsDemo


{
partial void CustomizeToString(ref string text) Implementacja metody częściowej.
{
text += " - zmodyfikowana!";
}
}

Na listingu 2.23 pierwsza część zapewne byłaby wygenerowanym kodem, co umożliwia


dodanie operacji na etapie tworzenia obiektu i przy tworzeniu jego tekstowej repre-
zentacji. Druga część odpowiada ręcznie napisanemu kodowi i nie wymaga modyfi-
kowania procesu tworzenia obiektu, jednak zmieniana jest tu tekstowa reprezentacja
zwracana przez metodę ToString(). Choć metoda CustomizeToString nie może bezpo-
średnio zwracać wartości, może przekazywać informacje do jednostki wywołującej za
pomocą parametru ref.
Ponieważ metoda OnConstruction nie jest zaimplementowana, zostaje usunięta przez
kompilator. Jeśli niezaimplementowana metoda częściowa z parametrami zostanie
wywołana, argumenty nie będą nawet przetwarzane.
Jeśli będziesz kiedyś pisał generator kodu, gorąco zachęcam do generowania w nim
klas częściowych. Klasy częściowe mogą się okazać przydatne także w ręcznie pisanym
kodzie. Używałem ich np. do podziału testów dużych klas na kilka plików źródłowych,
aby lepiej uporządkować testy.

2.5.2. Klasy statyczne


Klasy statyczne to takie, które zadeklarowano z użyciem modyfikatora static. Jeśli
kiedykolwiek pisałeś klasy narzędziowe składające się z samych metod statycznych,
takie klasy są dobrym kandydatem na klasy statyczne. W takich klasach nie można
deklarować metod, właściwości, zdarzeń ani konstruktorów instancji, można jednak
umieszczać w nich zwykłe typy zagnieżdżone.
Choć dozwolone jest deklarowanie zwykłych klas zawierających same składowe
statyczne, dodanie modyfikatora static sygnalizuje zamiary programisty, jeśli chodzi
o oczekiwany sposób używania danej klasy. Kompilator wie, że instancje klas statycz-
nych nigdy nie są tworzone, dlatego nie pozwala na używanie ich jako typów zmiennych
lub argumentów określających typ. Na listingu 2.24 pokazano krótki przykład ilustru-
jący, co jest dozwolone, a co nie.

Listing 2.24. Ilustrowanie działania klas statycznych

static class StaticClassDemo


{ Dozwolone: w klasach statycznych
public static void StaticMethod() { } można deklarować metody statyczne.

87469504f326f0d7c1fcda56ef61bd79
8
2.5. Mniej istotne mechanizmy 101

public void InstanceMethod() { } Niedozwolone: w klasach statycznych


nie można deklarować metod instancji.
public class RegularNestedClass Dozwolone: w klasach statycznych można
{ deklarować zwykłe typy zagnieżdżone.
public void InstanceMethod() { } Dozwolone: w zwykłym typie zagnieżdżonym w klasie
} statycznej można zadeklarować metodę instancji.
}
... Dozwolone: wywołanie metody
StaticClassDemo.StaticMethod(); statycznej z klasy statycznej.
StaticClassDemo localVariable = null; Niedozwolone: nie można zadeklarować zmiennej,
której typ to klasa statyczna.
List<StaticClassDemo> list = Niedozwolone: klasy statycznej nie można używać jako
new List<StaticClassDemo>(); argumentu określającego typ.

Klasy statyczne mają też dodatkową cechę — metody rozszerzające (wprowadzone


w C# 3) można deklarować tylko w niezagnieżdżonych niegenerycznych klasach
statycznych.

2.5.3. Inny poziom dostępu do getterów i setterów właściwości


Trudno w to uwierzyć, ale w C# 1 właściwość miała tylko jeden modyfikator dostępu.
Był on używany zarówno dla gettera, jak i dla settera (jeśli oba te elementy istniały).
W C# 2 wprowadzono możliwość utworzenia jednego akcesora jako bardziej prywat-
nego od drugiego. Wymaga to dodania modyfikatora do bardziej prywatnego akcesora.
Ta technika prawie zawsze jest używana do tego, by utworzyć setter bardziej prywatny
od gettera. Najczęściej stosowane połączenie to publiczny getter i prywatny setter:
private string text;

public string Text


{
get { return text; }
private set { text = value; }
}

W tym przykładzie kod mający dostęp do settera właściwości mógłby bezpośrednio


ustawić wartość pola. Jednak w bardziej złożonym kodzie można dodać sprawdzanie
poprawności lub powiadomienia o zmianach. Używanie właściwości pozwala na
hermetyzację takich operacji. Choć takie operacje można też umieścić w metodzie,
używanie właściwości jest w C# bardziej idiomatyczne.

2.5.4. Aliasy przestrzeni nazw


Przestrzenie nazw umożliwiają zadeklarowanie wielu typów o tej samej nazwie, ale
w różnych przestrzeniach nazw. Pozwala to uniknąć stosowania długich i zagmatwanych
nazw typów tylko po to, aby były unikatowe. Już w C# 1 dostępne były przestrzenie
nazw, a nawet aliasy przestrzeni nazw. Dzięki temu można było jednoznacznie wskazać
typ, jeśli programista używał jednego fragmentu kodu, w którym potrzebował typów
o tej samej nazwie, ale z różnych przestrzeni nazw. Na listingu 2.25 pokazano, że
w jednej metodzie można używać klas Button z technologii Windows Forms i ASP.NET
Web Forms.

87469504f326f0d7c1fcda56ef61bd79
8
102 ROZDZIAŁ 2. C# 2

Listing 2.25. Aliasy przestrzeni nazw w C# 1

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.
}
}

W C# 2 rozbudowano obsługę aliasów przestrzeni nazw w trzech ważnych aspektach.


SKŁADNIA ALIASÓW PRZESTRZENI NAZW
UŻYWANYCH JAKO KWALIFIKATOR
Składnia WinForms.Button z listingu 2.25 działa dobrze, o ile nie istnieje typ WinForms.
Jeśli taki typ istnieje, kompilator potraktuje wywołanie WinForms.Button jako próbę uży-
cia składowej Button typu WinForms, a nie jako użycie aliasu przestrzeni nazw. W C# 2
rozwiązano ten problem, wprowadzając nową składnię — aliasy przestrzeni nazw
używane jako kwalifikator. Ta składnia to para dwukropków. Jest ona używana wyłącz-
nie do aliasów przestrzeni nazw, co pozwala wyeliminować wieloznaczność. Jeśli zasto-
sujesz tę składnię, metoda Main z listingu 2.25 będzie wyglądać tak:
static void Main()
{
Console.WriteLine(typeof(WinForms::Button));
Console.WriteLine(typeof(WebForms::Button));
}

Eliminowanie wieloznaczności jest pomocne nie tylko dla kompilatora. Ważniejsze


jest to, że mechanizm ten pomaga zrozumieć każdemu, kto czyta kod, iż identyfikator
przed znakami :: to alias przestrzeni nazw, a nie nazwa typu. Zachęcam do używania
znaków :: wszędzie tam, gdzie stosowane są aliasy przestrzeni nazw.
ALIAS GLOBALNEJ PRZESTRZENI NAZW
Choć w kodzie produkcyjnym typy rzadko są deklarowane w globalnej przestrzeni nazw,
czasem tak się zdarza. Przed pojawieniem się C# 2 nie istniał sposób na to, by podać
pełny kwalifikator dla typów z tej przestrzeni nazw. W C# 2 wprowadzono nazwę
global jako alias, który zawsze dotyczy globalnej przestrzeni nazw. Ten alias nie tylko
pozwala wskazywać typy z globalnej przestrzeni nazw, ale też może być używany jako
korzeń dla nazw z pełnym kwalifikatorem (w tym kontekście używam go najczęściej).
Na przykład niedawno pisałem kod z wieloma metodami, w których używane były
parametry typu DateTime. Po dodaniu innego typu o tej nazwie do tej samej prze-
strzeni nazw pojawił się problem z deklaracjami tych metod. Choć mogłem dodać alias
dla przestrzeni nazw System, łatwiej było zmienić zapis typu każdego parametru na
global::System.DateTime. Zauważyłem, że aliasy przestrzeni nazw, a przede wszystkim
alias globalnej przestrzeni nazw, są przydatne zwłaszcza w trakcie pisania generatorów

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.

Pozostaje jeden problem — jak powiązać każdy alias zewnętrzny z podzespołem?


Służący do tego mechanizm jest zależny od implementacji. Powiązania można podać np.
w opcjach projektu lub w wierszu poleceń kompilatora.
Nie pamiętam, abym kiedykolwiek musiał używać aliasów zewnętrznych. Sądzę,
że stosuje się je jako prowizoryczne rozwiązanie do czasu wymyślenia innego sposobu
na uniknięcie kolizji nazw. Cieszę się jednak, że takie aliasy istnieją i pozwalają sto-
sować takie tymczasowe rozwiązania.

2.5.5. Dyrektywy pragma


Dyrektywy pragma to specyficzne dla implementacji dyrektywy pozwalające przekazy-
wać dodatkowe informacje kompilatorowi. Dyrektywa pragma nie może zmieniać
działania programu w sposób sprzeczny ze specyfikacją języka C#, ale można w niej
modyfikować aspekty spoza tej specyfikacji. Jeśli kompilator nie zna danej dyrektywy,
może zgłosić ostrzeżenie (ale już nie błąd). Składnia dyrektyw pragma jest prosta. Należy
podać słowo #pragma jako pierwszą część wiersza różną od spacji, a następnie wpisać
tekst dyrektywy.
Kompilator Microsoft C# obsługuje dyrektywy pragma z zakresu ostrzeżeń i sum
kontrolnych. Dyrektywy dotyczące sum kontrolnych widziałem tylko w generowanym
kodzie, jednak dyrektywy dotyczące ostrzeżeń są przydatne do wyłączania i ponownego
włączania różnych ostrzeżeń. Na przykład aby wyłączyć w określonym fragmencie kodu

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

Do wersji C# 6 ostrzeżenia można było podawać tylko za pomocą liczb. W kompila-


torze Roslyn zwiększono rozszerzalność procesu kompilacji, dzięki czemu inne pakiety
mogą przekazywać ostrzeżenia w ramach kompilacji. Aby to uwzględnić, w języku
umożliwiono stosowanie przedrostka (np. CS oznacza kompilator języka C#) do identy-
fikowania ostrzeżeń. Zachęcam do tego, by zawsze podawać ten przedrostek (CS0219
zamiast samego 0219 w pokazanym przykładzie), aby kod był bardziej przejrzysty.
Jeśli pominiesz identyfikator, wszystkie ostrzeżenia zostaną wyłączone lub włączone.
Nigdy nie stosowałem tego mechanizmu i odradzam stosowanie go. Zwykle lepiej jest
naprawić sytuację opisaną w ostrzeżeniu zamiast wyłączać to ostatnie. Ponadto zwycza-
jowe wyłączanie ostrzeżeń może skutkować ukryciem problemu kryjącego się w kodzie.

2.5.6. Bufory o stałej wielkości


Bufory o stałej wielkości to następny mechanizm, którego nigdy nie używałem w kodzie
produkcyjnym. Nie oznacza to, że nie będą przydatne dla Ciebie — zwłaszcza jeśli często
używasz kodu współdziałającego z kodem natywnym.
Bufory o stałej wielkości można stosować tylko w niezabezpieczonym kodzie i tylko
w strukturach. Powodują one wewnętrzną alokację porcji pamięci w strukturze z uży-
ciem modyfikatora fixed. Na listingu 2.26 pokazano prostą przykładową strukturę, która
reprezentuje 16 bajtów dowolnych danych i dwie 32-bitowe liczby całkowite repre-
zentujące wersję i podwersję tych danych.

Listing 2.26. Używanie buforów o stałej wielkości do tworzenia wersjonowanych


porcji danych binarnych

unsafe struct VersionedData


{
public int Major;
public int Minor;
public fixed byte Data[16];
}

unsafe static void Main()


{
VersionedData versioned = new VersionedData();
versioned.Major = 2;
versioned.Minor = 1;
versioned.Data[10] = 20;
}

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

do odrębnej tablicy bajtów. Tę strukturę można wykorzystać do współdziałania z kodem


natywnym lub w zwykłym kodzie zarządzanym.

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.

USPRAWNIONY DOSTĘP DO BUFORÓW


O STAŁEJ WIELKOŚCI W POLACH W C# 7.3
Na listingu 2.26 pokazano dostęp do buforów o stałej wielkości z użyciem zmiennej
lokalnej. Gdyby zmienna versioned była polem, dostęp do elementów struktury versioned.
Data wymagałby przed wersją C# 7.3 instrukcji fixed w celu utworzenia wskaźnika.
Od wersji C# 7.3 możliwy jest bezpośredni dostęp do buforów o stałej wielkości zapi-
sanych w polach, przy czym kod nadal musi znajdować się w niezabezpieczonym
kontekście.

2.5.7. Atrybut InternalsVisibleTo


Ostatnia funkcja wprowadzona w C# 2 jest w równym stopniu mechanizmem języka,
jak i platformy oraz środowiska uruchomieniowego. Nie jest nawet wymieniona w spe-
cyfikacji języka, choć podejrzewam, że jest uwzględniona w każdym nowym kompi-
latorze C#. Platforma udostępnia atrybut [InternalsVisibleToAttribute]. Jest to atry-
but z poziomu podzespołu obejmujący jeden parametr, który określa inny podzespół.
Dzięki temu wewnętrzne składowe podzespołu opatrzonego tym atrybutem mogą
być używane w podzespole podanym w atrybucie. Oto przykład:
[assembly:InternalsVisibleTo("MyProduct.Test")]

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.

Ostatnia z tych decyzji okazała się błędem. Programiści są przyzwyczajeni do modyfi-


kowania wewnętrznego kodu bez przejmowania się wersjonowaniem. Jednak gdy kod
wewnętrzny jest udostępniany innej, niezależnie wersjonowanej bibliotece, w zakresie

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

Nowe mechanizmy wprowadzone w C# 2 były w większości niezależne od siebie.


Typy proste przyjmujące wartość null wymagały typów generycznych, jednak były to
odrębne funkcje, których nie rozwijano w jednym celu.
C# 3 był inny. Obejmował wiele nowych mechanizmów, z których każdy był
przydatny sam w sobie, jednak prawie wszystkie z nich opracowano na potrzeby więk-
szego celu — technologii LINQ. W tym rozdziale opisano każdy z tych mechanizmów
z osobna, a następnie pokazano, jak łączą się one ze sobą. Pierwsza z opisanych funkcji
jako jedyna nie jest bezpośrednio powiązana z technologią LINQ.

87469504f326f0d7c1fcda56ef61bd79
8
108 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane

3.1. Automatycznie implementowane właściwości


Do wersji C# 3 każdą właściwość trzeba było zaimplementować ręcznie, z ciałem
akcesorów get i/lub set. Kompilator generował implementację dla zdarzeń podobnych
do pól, ale nie dla właściwości. To oznaczało, że często używane były właściwości
o następującej postaci:
private string name;
public string Name
{
get { return name; }
set { name = value; }
}

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; }

Warto zauważyć, że w deklaracji w kodzie źródłowym nie występuje teraz deklaracja


pola. Pole istnieje, ale jest tworzone automatycznie przez kompilator i otrzymuje nazwę,
której nie można stosować w kodzie w C#.
W C# 3 nie można zadeklarować automatycznych właściwości przeznaczonych
tylko do odczytu. Nie można też podać początkowej wartości w deklaracji. Oba te
mechanizmy wprowadzono (wreszcie!) w C# 6. Są one opisane w podrozdziale 8.2.
Do wersji C# 6 stosunkowo często tworzono właściwości pozornie przeznaczone tylko
do odczytu, stosując prywatny akcesor set:
public string Name { get; private set; }

Wprowadzenie automatycznie implementowanych właściwości w C# 3 miało poważny


wpływ na ograniczenie ilości szablonowego kodu. Są one przydatne tylko wtedy, gdy
właściwość jedynie pobiera i ustawia wartość pola. Jednak zgodnie z moim doświad-
czeniem dotyczy to dużego odsetka właściwości.
Wspomniałem już, że automatycznie implementowane właściwości nie są bezpo-
średnio związane z technologią LINQ. Przejdźmy więc do pierwszego mechanizmu
powiązanego z tą technologią — niejawnie określanych typów tablic i zmiennych
lokalnych.

3.2. Niejawne określanie typów


Aby przedstawić możliwie precyzyjny opis mechanizmów wprowadzonych w C# 3,
trzeba najpierw zdefiniować kilka pojęć.

87469504f326f0d7c1fcda56ef61bd79
8
3.2. Niejawne określanie typów 109

3.2.1. Terminologia związana z typami


Do sposobu obsługi systemu typów w językach programowania stosowanych jest wiele
pojęć. Niektórzy ludzie posługują się nazwami słaba kontrola typów i ścisła kontrola
typów. Ja jednak staram się ich unikać, ponieważ nie są ściśle zdefiniowane i dla różnych
programistów mogą znaczyć co innego. W dwóch innych aspektach panuje większy
konsensus. Chodzi tu o typowanie statyczne i dynamiczne oraz jawne i niejawne. Są one
opisane poniżej.
TYPOWANIE STATYCZNE I DYNAMICZNE
Języki z typowaniem statycznym to zwykle języki kompilowane. Kompilator potrafi
określić typ każdego wyrażenia i sprawdzić, czy jest poprawnie używany. Na przykład,
jeśli wywołujesz metodę obiektu, kompilator może wykorzystać informacje o typie, aby
sprawdzić, czy dostępna jest właściwa metoda. Używa do tego typu wyrażenia, dla
którego wywoływana jest metoda, nazwy metody, a także liczby i typów argumentów.
Określanie znaczenia operacji takich jak wywołanie metody lub dostęp do pola to
wiązanie. W językach z typowaniem dynamicznym wiązanie w większości lub w całości
ma miejsce w czasie wykonywania programu.
UWAGA. W różnych miejscach zobaczysz, że niektóre wyrażenia w C# w kodzie źródłowym,
np. literał null, nie mają typu. Jednak kompilator zawsze określa typ na podstawie kontekstu
użycia danego wyrażenia. Na tym etapie typ można wykorzystać do sprawdzenia, jak używane
jest wyrażenie.

Jeśli pominąć wprowadzone w C# 4 (i opisane w rozdziale 4.) wiązanie dynamiczne,


C# jest językiem typowanym statycznie. Choć wybór używanej implementacji metody
wirtualnej powinien zależeć od typu obiektu zastosowanego w czasie wykonywania
programu, proces wiązania polegający na ustaleniu sygnatury metody ma miejsce
w czasie kompilacji.
JAWNE I NIEJAWNE TYPOWANIE
W językach z jawnym typowaniem w kodzie źródłowym podawane są wszystkie używane
typy. Mogą to być np. typy zmiennych lokalnych, pól, parametrów metod i wartości
zwracanych przez metody. W języku z niejawnym typowaniem programista może pomi-
nąć typy w kodzie źródłowym, aby inny mechanizm (kompilator lub jakieś narzędzie
używane w czasie wykonywania programu) mógł wywnioskować typ na podstawie
kontekstu.
W C# w większości używane jest jawne typowanie, choć jeszcze przed wersją
C# 3 w niektórych miejscach stosowane było typowanie niejawne, np. wnioskowanie
typów generycznych argumentów określających typ, co opisane jest w punkcie 2.1.4.
Można też stwierdzić, że stosowanie konwersji niejawnych (np. z typu int na long)
sprawia, iż w języku w mniejszym stopniu stosowane jest typowanie jawne.
Po przedstawieniu tych różnych aspektów kontroli typów można przejść do mecha-
nizmów języka C# 3 związanych z typowaniem niejawnym. Najpierw opisane zostaną
zmienne lokalne z typowaniem niejawnym.

87469504f326f0d7c1fcda56ef61bd79
8
110 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane

3.2.2. Zmienne lokalne z typowaniem niejawnym (var)


Zmienne lokalne z typowaniem niejawnym to zmienne deklarowane z użyciem kon-
tekstowego słowa var zamiast za pomocą nazwy typu:
var language = "C#";

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.

Sposób wnioskowania typu prowadzi do dwóch ważnych reguł związanych ze zmien-


nymi lokalnymi z niejawnie określanym typem:
 zmienna musi być inicjowana w miejscu deklaracji,
 wyrażenie używane do zainicjowania zmiennej musi mieć typ.

Oto nieprawidłowy kod ilustrujący te reguły:


var x;
Brak początkowej wartości.
x = 10;

var y = null; Początkowa wartość nie ma 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

 Precyzyjnie określony typ zmiennej nie ma dużego znaczenia, a wyrażenie uży-


wane do zainicjowania jej zapewnia wystarczającą ilość informacji dla czytelni-
ków kodu.

Przykłady dotyczące pierwszego z tych punktów znajdziesz w podrozdziale 3.4. Łatwo


można natomiast przedstawić punkt drugi. Załóżmy, że chcesz utworzyć słownik odwzo-
rowujący nazwę na listę wartości typu decimal. Można to zrobić za pomocą zmiennej
z jawnym typowaniem:
Dictionary<string, List<decimal>> mapping =
new Dictionary<string, List<decimal>>();

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

3.2.3. Tablice z niejawnym typowaniem


Czasem musisz utworzyć tablicę bez zapełniania jej i ustawić dla wszystkich ele-
mentów wartość domyślną. Składnia tej operacji nie zmieniła się od C# 1. Zawsze
wygląda to tak:
int[] array = new int[10];

Jednak często celem jest utworzenie tablicy o określonej początkowej zawartości. Do


wersji C# 3 można to było zrobić na dwa sposoby:
int[] array1 = { 1, 2, 3, 4, 5};
int[] array2 = new int[] { 1, 2, 3, 4, 5};

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 };

W C# 3 wprowadzono trzeci zapis, w którym typ tablicy jest niejawnie określany na


podstawie jej zawartości:
array = new[] { 1, 2, 3, 4, 5 };

Tę technikę można stosować w dowolnym miejscu, pod warunkiem że kompilator


potrafi wywnioskować typ elementów tablicy na podstawie podanych wartości. To
rozwiązanie działa także dla tablic wielowymiarowych, tak jak w poniższym przykładzie:
var array = new[,] { { 1, 2, 3 }, { 4, 5, 6 } };

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

Tabela 3.1. Przykłady inferencji typów tablic z niejawnym typowaniem

Wyrażenie Wynik Uwagi


new[] { 10, 20 } int[] Wszystkie elementy są typu int.
new[] { null, null } Błąd Żaden z elementów nie ma typu.
new[] { "xyz", null } string[] Jedyny typ kandydujący to string, a literał null można
przekształcić na ten typ.
new[] { "abc", new object() } object[] Typy kandydujące to string i object. Możliwa jest niejawna
konwersja z typu string na object, ale nie w drugą stronę.
new[] { 10, new DateTime() } Błąd Typy kandydujące to int i DateTime. Nie jest możliwa
niejawna konwersja w żadną stronę.
new[] { 10, null } Błąd Jedyny typ kandydujący to int, ale nie jest możliwa
konwersja z null na typ int.

Tablice z typowaniem niejawnym są używane głównie jako udogodnienie pozwalające


ograniczyć ilość kodu źródłowego. Wyjątkiem są tablice elementów typów anonimo-
wych, gdzie programista nie może jawnie podać typu, nawet gdyby chciał to zrobić.
W każdym razie jest to udogodnienie, którego z pewnością brakowałoby mi, gdybym
musiał pracować bez niego.
Następny mechanizm też dotyczy upraszczania tworzenia i inicjowania obiektów,
ale działa w inny sposób.

3.3. Inicjalizatory obiektów i kolekcji


Inicjalizatory obiektów i inicjalizatory kolekcji umożliwiają łatwe tworzenie nowych
obiektów i kolekcji z użyciem wartości początkowych (podobnie jak w jednym wyra-
żeniu można utworzyć i zapełnić tablicę). Ten mechanizm jest ważny w LINQ z powodu
przekształcania zapytań, jednak okazuje się niezwykle przydatny także w innych kon-
tekstach. Konieczne jest tu stosowanie typów modyfikowalnych, co może być irytujące,
jeśli próbujesz pisać kod funkcyjny; jeżeli jednak możesz zastosować inicjalizatory, są
one bardzo przydatne. Przed zapoznaniem się ze szczegółami przyjrzyj się teraz pro-
stemu przykładowi.

3.3.1. Wprowadzenie do inicjalizatorów obiektów i kolekcji


W ramach skrajnie uproszczonego przykładu zobacz, jak może wyglądać zamówienie
w systemie sklepu elektronicznego. Na listingu 3.1 pokazano trzy klasy. Reprezentują
one: zamówienie, klienta i jedną pozycję w zamówieniu.

Listing 3.1. Modelowanie zamówienia w systemie sklepu elektronicznego

public class Order


{
private readonly List<OrderItem> items = new List<OrderItem>();

public string OrderId { get; set; }


public Customer Customer { get; set; }
public List<OrderItem> Items { get { return items; } }
}

87469504f326f0d7c1fcda56ef61bd79
8
114 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane

public class Customer


{
public string Name { get; set; }
public string Address { get; set; }
}

public class OrderItem


{
public string ItemId { get; set; }
public int Quantity { get; set; }
}

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.

Listing 3.2. Tworzenie i zapełnianie zamówienia bez inicjalizatorów obiektów i kolekcji

var customer = new Customer();


customer.Name = "Jon"; Tworzenie obiektu typu Customer.
customer.Address = "UK";

var item1 = new OrderItem();


item1.ItemId = "abcd123"; Tworzenie pierwszego obiektu typu OrderItem.
item1.Quantity = 1;

var item2 = new OrderItem();


item2.ItemId = "fghi456"; Tworzenie drugiego obiektu typu OrderItem.
item2.Quantity = 2;

var order = new Order();


order.OrderId = "xyz";
order.Customer = customer; Tworzenie zamówienia.
order.Items.Add(item1);
order.Items.Add(item2);

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.

Listing 3.3. Tworzenie i zapełnianie zamówienia z użyciem inicjalizatorów obiektów


i kolekcji

var order = new Order


{
OrderId = "xyz",
Customer = new Customer { Name = "Jon", Address = "UK" },

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.

3.3.2. Inicjalizatory obiektów


Składniowo inicjalizator obiektu to sekwencja inicjalizatorów składowych podanych
w nawiasie klamrowym. Każdy inicjalizator składowej ma postać właściwość =
wartość-inicjalizatora, gdzie właściwość to nazwa inicjowanego pola lub właściwości,
a wartość-inicjalizatora to wyrażenie, inicjalizator kolekcji lub inicjalizator innego
obiektu.
UWAGA. Inicjalizatory obiektów najczęściej są używane razem z właściwościami. W taki
sposób zostały opisane w tym rozdziale. Pola nie mają akcesorów, jednak można zastoso-
wać tu oczywiste odpowiedniki — wczytywać pole zamiast wywoływać akcesor get i zapisy-
wać wartość pola zamiast wywoływać akcesor set.

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.

Inicjalizator obiektu określa, jak zainicjować każdą właściwość wymienioną w inicja-


lizatorach składowych. Jeśli człon wartość-inicjalizatora (część na prawo od znaku =)
to zwykłe wyrażenie, jest ono przetwarzane, a wartość zostaje przekazana do akcesora
set właściwości. W ten sposób działa większość inicjalizatorów obiektów z listingu 3.3.
Dla właściwości Items używany jest pokazany dalej inicjalizator kolekcji.
Jeśli wartość-inicjalizatora to inny inicjalizator obiektu, akcesor set nie jest wywo-
ływany. Zamiast tego uruchomiony zostaje akcesor get, po czym zagnieżdżony inicja-
lizator obiektu jest stosowany do wartości zwróconej przez daną właściwość. Na przy-
kład kod z listingu 3.4 tworzy obiekt typu HttpClient i modyfikuje zbiór nagłówków
domyślnych przesyłanych wraz z każdym żądaniem. Kod ustawia tu nagłówki From i Date
(zostały one wybrane tylko dlatego, że są najprostsze do ustawienia).

87469504f326f0d7c1fcda56ef61bd79
8
116 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane

Listing 3.4. Modyfikowanie nagłówków domyślnych nowego obiektu typu HttpClient


za pomocą zagnieżdżonego inicjalizatora obiektu

HttpClient client = new HttpClient


{ Wywołanie akcesora get obiektu
DefaultRequestHeaders = typu DefaultRequestHeaders.
{
From = "user@example.com", Wywołanie akcesora set dla nagłówka From.
Date = DateTimeOffset.UtcNow Wywołanie akcesora set dla nagłówka Date.
}
};

Kod z listingu 3.4 jest odpowiednikiem poniższego fragmentu:


HttpClient client = new HttpClient();
var headers = client.DefaultRequestHeaders;
headers.From = "user@example.com";
headers.Date = DateTimeOffset.UtcNow;

Jeden inicjalizator obiektu może obejmować w sekwencji inicjalizatorów składowych


zestaw zagnieżdżonych inicjalizatorów obiektów, inicjalizatorów kolekcji i zwykłych
wyrażeń. Skoro już jesteśmy przy inicjalizatorach kolekcji, warto im się przyjrzeć.

3.3.3. Inicjalizatory kolekcji


Składniowo inicjalizator kolekcji to rozdzielona przecinkami lista inicjalizatorów elemen-
tów umieszczona w nawiasie klamrowym. Każdy inicjalizator elementu to albo pojedyn-
cze wyrażenie, albo rozdzielona przecinkami lista wyrażeń (także w nawiasie klamro-
wym). Inicjalizatory kolekcji mogą być używane tylko w wywołaniu konstruktora lub
w inicjalizatorze obiektu. Obowiązują też dodatkowe ograniczenia dotyczące typów,
dla jakich można je stosować (kwestia ta jest opisana dalej). Na listingu 3.3 pokazany
jest inicjalizator kolekcji używany w inicjalizatorze obiektu. Oto przypomnienie tego
listingu. Inicjalizator kolekcji jest tu wyróżniony pogrubieniem:
var order = new Order
{
OrderId = "xyz",
Customer = new Customer { Name = "Jon", Address = "UK" },
Items =
{
new OrderItem { ItemId = "abcd123", Quantity = 1 },
new OrderItem { ItemId = "fghi456", Quantity = 2 }
}
};

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" };

Kompilator kompiluje ten kod do postaci wywołania konstruktora, po którym następuje


seria wywołań metody Add:

87469504f326f0d7c1fcda56ef61bd79
8
3.3. Inicjalizatory obiektów i kolekcji 117

var beatles = new List<string>();


beatles.Add("John");
beatles.Add("Paul");
beatles.Add("Ringo");
beatles.Add("George");

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);

Następnie w standardowy sposób wyszukiwana jest najbardziej odpowiednia prze-


ciążona wersja metody Add. Między innymi przeprowadzane jest wnioskowanie typu,
jeśli istnieją generyczne metody Add.
Inicjalizatory kolekcji są poprawne tylko dla typów z implementacją interfejsu
IEnumerable, choć typy te nie muszą implementować interfejsu IEnumerable<T>. Projek-
tanci języka przeanalizowali typy w platformie udostępniające metodę Add i stwierdzili,
że najlepszym sposobem na podzielenie ich na kolekcje i niekolekcje jest sprawdzanie,
czy implementują interfejs IEnumerable. Aby zrozumieć, dlaczego jest to istotne, rozważ
metodę DateTime.Add(TimeSpan). Typ DateTime naturalnie nie jest kolekcją, dlatego
poniższy kod byłby dziwny:
DateTime invalid = new DateTime(2020, 1, 1) { TimeSpan.FromDays(10) }; Niepoprawne.

Kompilator nigdy nie używa implementacji interfejsu IEnumerable w trakcie kompilo-


wania inicjalizatora kolekcji. Stwierdziłem, że czasem wygodne jest tworzenie w pro-
jektach testowych typów z metodą Add i implementacją interfejsu IEnumerable, która

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ł.

3.3.4. Zalety inicjowania za pomocą jednego wyrażenia


Może się zastanawiasz, co to wszystko ma wspólnego z technologią LINQ. Stwier-
dziłem, że prawie wszystkie nowe mechanizmy z C# 3 dodano na potrzeby tej tech-
nologii, jak więc związane są z nią inicjalizatory obiektów i kolekcji? Wyjaśnienie jest
takie, że w innych miejscach technologii LINQ potrzebna była możliwość zapisywania
kodu w jednym wyrażeniu. Na przykład w wyrażeniach reprezentujących zapytania nie
można zapisać klauzuli select, która wymaga wielu instrukcji w celu wygenerowania
danych wyjściowych dla określonych danych wejściowych.
Możliwość inicjowania nowych obiektów w jednym wyrażeniu jest przydatna nie
tylko w technologii LINQ, ale też np. do upraszczania inicjalizatorów pól, argumentów
metod lub nawet operandów w warunkowym operatorze ?:. Dla mnie technika ta jest
przydatna zwłaszcza w inicjalizatorach pól statycznych do budowania użytecznych
tablic wyszukiwania. Naturalnie im dłuższe staje się wyrażenie inicjujące, tym bardziej
uzasadniony może być jego podział.
Inicjowanie w jednym wyrażeniu jest nawet rekurencyjnie ważne na potrzeby tego
właśnie mechanizmu. Na przykład gdyby nie można było użyć inicjalizatora obiektu
do tworzenia obiektów typu OrderItem, inicjalizator kolekcji nie byłby tak wygodny do
zapełniania właściwości Order.Items.
W dalszym tekście wszędzie tam, gdzie wspominam, że nowa lub usprawniona
funkcja ma wersję obsługującą pojedyncze wyrażenia (dotyczy to np. wyrażeń lambda
opisywanych w podrozdziale 3.5 lub składowych z ciałem w postaci wyrażenia omó-
wionych w podrozdziale 8.3), warto pamiętać, że inicjalizatory obiektów i kolekcji zwięk-
szają przydatność danego mechanizmu.
Inicjalizatory obiektów i kolekcji pozwalają pisać bardziej zwięzły kod do tworzenia
i zapełniania instancji typu. Wymagają jednak tego, by istniał już odpowiedni typ.
Następny mechanizm, typy anonimowe, pozwala tworzyć obiekty bez wcześniejszego
zadeklarowania typu. Nie jest to aż tak dziwne, jak może się wydawać.

3.4. Typy anonimowe


Typy anonimowe umożliwiają tworzenie obiektów, z których można korzystać zgodnie
z typowaniem statycznym bez konieczności wcześniejszego deklarowania typu. Może
się wydawać, że takie typy są tworzone dynamicznie w czasie wykonywania programu.
Rzeczywistość jest jednak bardziej skomplikowana. Zobaczysz tu, jak typy anonimowe
wyglądają w kodzie źródłowym i jak są obsługiwane przez kompilator. Poznasz też
kilka ich ograniczeń.

87469504f326f0d7c1fcda56ef61bd79
8
3.4. Typy anonimowe 119

3.4.1. Składnia i podstawy działania


Najprostszy sposób na objaśnienie typów anonimowych to rozpoczęcie od przykładu.
Na listingu 3.5 pokazany jest fragment kodu tworzący obiekt z właściwościami Name
i Score.

Listing 3.5. Typ anonimowy z właściwościami Name i Score

var player = new


{
Name = "Rajesh", Tworzenie obiektu typu anonimowego z właściwościami Name i Score.
Score = 3500
};

Console.WriteLine("Imię gracza: {0}", player.Name);


Wyświetlanie wartości właściwości.
Console.WriteLine("Wynik gracza: {0}", player.Score);

Ten krótki przykład ilustruje ważne aspekty związane z typami anonimowymi:


 Składnia jest podobna jak dla inicjalizatorów obiektów, ale nie wymaga poda-
wania nazwy typu. Należy podać słowo new, otworzyć nawias klamrowy, podać
właściwości i zamknąć nawias. Jest to wyrażenie tworzące obiekt typu anonimo-
wego. Wartości właściwości można podać jako zagnieżdżone wyrażenia tworzące
obiekt typu anonimowego.
 W deklaracji zmiennej player używane jest słowo var, ponieważ typ nie ma
nazwy, którą można byłoby zastosować zamiast var. Deklaracja zadziała także dla
typu object, jednak nie byłaby wtedy tak przydatna.
 W tym kodzie używane jest typowanie statyczne. Visual Studio stosuje automa-
tyczne uzupełnianie nazw właściwości Name i Score zmiennej player. Jeśli zigno-
rujesz te nazwy i spróbujesz użyć właściwości, która nie istnieje (np. player.Points),
kompilator zgłosi błąd. Typy właściwości zostają wywnioskowane na podstawie
przypisanych do nich wartości; player.Name to właściwość typu string, a player.
Score to właściwość typu int.

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

Porównanie z klasami anonimowymi z Javy


Jeśli znasz Javę, możliwe, że zastanawiasz się nad podobieństwami między typami ano-
nimowymi z C# a klasami anonimowymi z Javy. Nazwy mogą sugerować, że będą one
podobne, jednak w rzeczywistości znacznie różnią się zarówno ze względu na składnię, jak
i na przeznaczenie.
Historycznie głównym zastosowaniem klas anonimowych w Javie było implementowanie
interfejsów lub rozszerzanie klas abstrakcyjnych w celu przesłonięcia tylko jednej lub
dwóch metod. Typy anonimowe w C# nie umożliwiają implementowania interfejsu ani dzie-
dziczenia po klasach innych niż System.Object. W typach anonimowych dużo ważniejsze
są dane niż wykonywalny kod.

C# udostępnia też skrót w wyrażeniach tworzących obiekty typu anonimowego. Ten


skrót pozwala skopiować właściwość lub pole z innego miejsca i użyć tej samej nazwy.
Ta składnia nosi nazwę inicjalizatora z projekcją. Na potrzeby przykładu wróć do
uproszczonego modelu danych sklepu internetowego. Dostępne są tam trzy klasy:
 Order — OrderId, Customer, Items,
 Customer — Name, Address,
 OrderItem — ItemId, Quantity.

Gdzieś w kodzie możesz chcieć utworzyć obiekt z wszystkimi tymi informacjami na


temat określonej pozycji z zamówienia. Jeśli istnieją zmienne order, customer i item
odpowiedniego typu, możesz łatwo użyć typu anonimowego do reprezentowania spłasz-
czonych informacji:
var flattenedItem = new
{
order.OrderId,
CustomerName = customer.Name,
customer.Address,
item.ItemId,
item.Quantity
};

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
};

Inicjalizatory z projekcją są najbardziej przydatne, gdy wykonujesz zapytanie i chcesz


pobrać tylko podzbiór właściwości lub połączyć właściwości z wielu obiektów w jeden
obiekt. Jeśli nazwa, jaką chcesz nadać właściwości w typie anonimowym, jest taka sama,
jak nazwa źródłowego pola lub źródłowej właściwości, kompilator może wywniosko-
wać tę nazwę za Ciebie. Dlatego zamiast kodu:

87469504f326f0d7c1fcda56ef61bd79
8
3.4. Typy anonimowe 121

JakasWlasciwosc = zmienna.JakasWlasciwosc

wystarczy zapis:
zmienna.JakasWlasciwosc

Jeśli kopiujesz wiele właściwości, inicjalizatory z projekcją pozwalają znacznie zmniej-


szyć ilość powtórzeń w kodzie źródłowym. Może od tego zależeć, czy uda Ci się
utworzyć wyrażenie, które zmieści się w jednym wierszu, lub czy będziesz musiał
umieszczać każdą właściwość w odrębnym wierszu.

Refaktoryzacja a inicjalizatory z projekcją


Choć prawdą jest, że dwa ostatnie listingi dają ten sam skutek, nie oznacza to, że obie
techniki działają identycznie. Co się stanie, gdy zmienisz nazwę właściwości Address na
CustomerAddress?
W wersji z inicjalizatorami z projekcją nazwa właściwości w typie anonimowym też się
zmieni. W wersji z jawnie podawanymi nazwami właściwości nazwa w typie anonimowym
pozostanie taka sama. Według mojego doświadczenia rzadko stanowi to problem, warto
jednak pamiętać o tej różnicy.

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?

3.4.2. Typ generowany przez kompilator


Choć typ anonimowy nigdy nie występuje w kodzie źródłowym, kompilator generuje go.
Nie wymaga to żadnych sztuczek w środowisku uruchomieniowym — środowisko używa
typu, przy czym nazwa tego ostatniego jest niepoprawna w C#. Taki typ ma kilka cie-
kawych cech. Niektóre wynikają ze specyfikacji, inne nie. Gdy używasz kompilatora
Microsoft C#, typ anonimowy ma następujące cechy:
 jest klasą (jest to gwarantowane);
 jego klasą bazową jest object (jest to gwarantowane);
 jest zamknięty (nie jest to gwarantowane, choć trudno wyobrazić sobie, jakie
korzyści dałoby utworzenie tego typu jako otwartego);
 właściwości są przeznaczone tylko do odczytu (jest to gwarantowane);
 parametry konstruktora mają takie same nazwy jak właściwości (nie jest to
gwarantowane, a cecha ta może być przydatna w kontekście refleksji);
 jest wewnętrzny dla podzespołu (nie jest to gwarantowane i może być irytujące,
gdy używane jest typowanie dynamiczne);
 przesłania metody GetHashCode() i Equals(), dlatego dwie instancje są równe
tylko wtedy, gdy wszystkie ich właściwości są równe (obsługiwane są też właści-
wości równe null); przesłanianie tych metod jest gwarantowane, jednak sposób
obliczania skrótu nie jest określony;
 przesłania w przydatny sposób metodę ToString(), wyświetlając nazwy właści-
wości i ich wartości; nie jest to gwarantowane, jednak taka metoda jest nie-
zwykle pomocna w trakcie diagnozowania problemów;

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 };

Podobnie można utworzyć tablicę, używając typów anonimowych i opisanej w punk-


cie 3.2.3 składni dla tablic z niejawnym typowaniem:
var players = new[]
{
new { Name = "Priti", Score = 6000 },
new { Name = "Chris", Score = 7000 },
new { Name = "Amanda", Score = 8000 },
};

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.

Jesteśmy w połowie omówienia funkcji wprowadzonych w C# 3. Do tej pory wszystkie


opisywane mechanizmy są związane z danymi. Kolejne mechanizmy dotyczą w więk-
szym stopniu wykonywalnego kodu. Będą to najpierw wyrażenia lambda, a następnie
metody rozszerzające.

3.5. Wyrażenia lambda


W rozdziale 2. zobaczyłeś, że metody anonimowe znacznie ułatwiają tworzenie instancji
delegatów, ponieważ pozwalają podać kod metody wewnątrzwierszowo:
Action<string> action = delegate(string message)
{ Tworzenie delegata za pomocą
Console.WriteLine("W delegacie: {0}", message); metody anonimowej.
};
action("Komunikat"); Wywoływanie delegata.

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#.

UWAGA. Nazwa wyrażenia lambda pochodzi z rachunku lambda — dziedziny matematyki


i informatyki zapoczątkowanej przez Alonzo Churcha w latach 30. ubiegłego wieku. Church
używał greckiej litery lambda (λ) do oznaczania funkcji i nazwa ta się przyjęła.

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.

3.5.1. Składnia wyrażeń lambda


Podstawowa składnia wyrażeń lambda zawsze ma następującą postać:
lista-parametrów => ciało

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);

Dalej opisany jest przykład ze zwracaniem wartości. Wyrażenie lambda skrócone do


tej postaci ma ciało w postaci wyrażenia. Wyrażenia lambda z nawiasem klamrowym
mają ciało w postaci instrukcji.

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);

Ponadto jeśli wyrażenie lambda ma dokładnie jeden parametr i jest on wywnioskowany,


można pominąć nawias dla listy parametrów:
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.

Func<int, int, int> multiply = (x, y) => x * y; Wnioskowanie typów parametrów.


Parametry są dwa, dlatego nie można usunąć nawiasu.

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) => Wnioskowanie typów parametrów.


{
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

Jeśli akceptowalne jest dwukrotne przetwarzanie właściwości Length, można skrócić


drugi przykład:
Func<string, int> squareLength = text => text.Length * text.Length;

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.

3.5.2. Przechwytywanie zmiennych


W opisie przechwytywania zmiennych w metodach anonimowych w punkcie 2.3.2
obiecałem wrócić do tego tematu w kontekście wyrażeń lambda. Jest to prawdopo-
dobnie najbardziej skomplikowany aspekt tych wyrażeń. Z pewnością poświęcono mu
wiele pytań w serwisie Stack Overflow.
Aby utworzyć instancję delegata na podstawie wyrażenia lambda, kompilator prze-
kształca kod wyrażenia lambda na metodę. Następnie można w czasie wykonywania
programu utworzyć delegat w dokładnie taki sposób, jakby dostępna była grupa metod.
W tym punkcie pokazany jest rodzaj przekształceń wykonywanych przez kompilator.
Ten fragment jest napisany tak, jakby kompilator przekształcał kod źródłowy na inny
kod źródłowy, w którym nie występują wyrażenia lambda. Jednak w rzeczywistości
kompilator nie potrzebuje przekształconego kodu źródłowego, a jedynie generuje
odpowiedni kod pośredni.
Zacznijmy od przypomnienia tego, czym jest przechwycona zmienna. W wyrażeniu
lambda można posłużyć się dowolną zmienną, która mogłaby być używana w danym
miejscu w zwykłym kodzie. Dostępne są więc: pola statyczne, pola instancji (jeśli wyra-
żenie lambda jest umieszczone w metodzie instancji1), zmienna this, parametry metody
i zmienne lokalne. Wszystkie te elementy to zmienne przechwycone, ponieważ są
zadeklarowane poza bezpośrednim kontekstem wyrażenia lambda. Możesz porównać
je z parametrami wyrażenia lambda i zmiennymi lokalnymi zadeklarowanymi w takim
wyrażeniu. Nie są one zmiennymi przechwyconymi. Na listingu 3.6 pokazane jest
wyrażenie lambda, w którym przechwytywane są różne zmienne. Dalej zobaczysz,
w jaki sposób kompilator traktuje taki kod.

Listing 3.6. Przechwytywanie zmiennych w wyrażeniu lambda

class CapturedVariablesDemo
{
private string instanceField = "pole instancji";

public Action<string> CreateAction(string methodParameter)


{
string methodLocal = "zmienna lokalna metody";
string uncaptured = "nieprzechwytywana zmienna lokalna";

Action<string> action = lambdaParameter =>


{
string lambdaLocal = "Zmienna lokalna wyrażenia lambda";
Console.WriteLine("Pole instancji: {0}", instanceField);

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

Console.WriteLine("Parametr metody: {0}", methodParameter);


Console.WriteLine("Zmienna lokalna metody: {0}", methodLocal);
Console.WriteLine("Parametr wyrażenia lambda: {0}", lambdaParameter);
Console.WriteLine("Zmienna lokalna wyrażenia lambda: {0}", lambdaLocal);
};
methodLocal = "zmodyfikowana zmienna lokalna metody";
return action;
}
}

W innym kodzie:
var demo = new CapturedVariablesDemo();
Action<string> action = demo.CreateAction("argument metody");
action("argument wyrażenia lambda");

Występuje tu wiele zmiennych:


 instanceField to pole instancji klasy CapturedVariablesDemo i jest przechwytywane
w wyrażeniu lambda,
 methodParameter to parametr metody CreateAction i jest przechwytywany w wyra-
żeniu lambda,
 methodLocal to zmienna lokalna metody CreateAction i jest przechwytywana
w wyrażeniu lambda,
 uncaptured to zmienna lokalna metody CreateAction, która nie jest jednak używana
w wyrażeniu lambda, dlatego nie jest w nim przechwytywana,
 lambdaParameter to parametr samego wyrażenia lambda, dlatego nie jest zmienną
przechwytywaną,
 lambdaLocal to zmienna lokalna wyrażenia lambda, dlatego nie jest zmienną prze-
chwytywaną.

Należy zrozumieć, że wyrażenie lambda przechwytuje same zmienne, a nie wartość


zmiennej z momentu utworzenia delegata2. Jeśli zmodyfikujesz zmienne przechwyty-
wane między punktem utworzenia delegata a jego użyciem, dane wyjściowe odzwier-
ciedlą te zmiany. Podobnie wyrażenie lambda może zmieniać wartość przechwyconych
zmiennych. Jak kompilator sobie z tym radzi? W jaki sposób gwarantuje, że wszystkie
zmienne są dostępne w momencie uruchomienia delegata?
IMPLEMENTOWANIE PRZECHWYTYWANYCH ZMIENNYCH
W WYGENEROWANEJ KLASIE
Należy uwzględnić trzy ogólne przypadki:
 Gdy żadne zmienne nie są przechwytywane, kompilator może utworzyć metodę
statyczną. Żaden dodatkowy kontekst nie jest potrzebny.
 Jeśli jedyne przechwytywane zmienne to pola instancji, kompilator może utwo-
rzyć metodę instancji. Przechwytywanie jednego pola instancji jest jak prze-
chwytywanie ich stu, ponieważ potrzebny jest tylko dostęp do obiektu this.

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

 Jeżeli przechwytywane są zmienne lokalne lub parametry, kompilator tworzy


prywatną klasę zagnieżdżoną, aby zapisać w niej kontekst, a następnie dodaje do
tej klasy metodę instancji zawierającą kod wyrażenia lambda. Pierwotna metoda
obejmująca wyrażenie lambda jest modyfikowana, aby używała nowej klasy
zagnieżdżonej na potrzeby każdego dostępu do przechwytywanych zmiennych.

Szczegóły implementacji mogą być różne


Możesz zetknąć się z odstępstwami od tego opisu. Na przykład gdy wyrażenie lambda
nie zawiera zmiennych przechwytywanych, kompilator może zamiast metody statycznej
utworzyć klasę zagnieżdżoną z jedną instancją. W zależności od sposobu tworzenia dele-
gatów występują niewielkie różnice w ich wydajności. W tym punkcie opisuję minimalny
zestaw operacji, jakie kompilator musi wykonać, aby przechwytywane zmienne były
dostępne. Twórcy kompilatora mogą jednak stosować bardziej złożone rozwiązania.

Ostatni scenariusz jest oczywiście najbardziej skomplikowany, dlatego koncentruję się


właśnie na nim. Zacznijmy od listingu 3.6. W ramach przypomnienia poniżej znajdziesz
metodę, która tworzy wyrażenie lambda. Aby zachować zwięzłość, deklaracja klasy
została pominięta:
public Action<string> CreateAction(string methodParameter)
{
string methodLocal = "zmienna lokalna metody";
string uncaptured = "nieprzechwytywana zmienna lokalna";

Action<string> action = lambdaParameter =>


{
string lambdaLocal = "Zmienna lokalna wyrażenia lambda";
Console.WriteLine("Pole instancji: {0}", instanceField);
Console.WriteLine("Parametr metody: {0}", methodParameter);
Console.WriteLine("Zmienna lokalna metody: {0}", methodLocal);
Console.WriteLine("Parametr wyrażenia lambda: {0}", lambdaParameter);
Console.WriteLine("Zmienna lokalna wyrażenia lambda: {0}", lambdaLocal);
};
methodLocal = "zmodyfikowana zmienna lokalna metody";
return action;
}

Zgodnie z wcześniejszym opisem kompilator tworzy prywatną klasę zagnieżdżoną


przeznaczoną na potrzebny dodatkowy kontekst, a następnie umieszcza w tej klasie
metodę instancji z kodem wyrażenia lambda. Kontekst jest zapisywany w zmiennych
instancji tej zagnieżdżonej klasy. W omawianym scenariuszu kontekst obejmuje:
 referencję do pierwotnej instancji klasy CapturedVariablesDemo, co pozwala na
późniejszy dostęp do pola instanceField,
 zmienną typu string przeznaczoną na przechwytywany parametr metody,
 zmienną typu string przeznaczoną na przechwytywaną zmienną lokalną.

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

Listing 3.7. Przekształcanie wyrażenia lambda z przechwytywanymi zmiennymi

private class LambdaContext Wygenerowana klasa do przechowywania


{ przechwytywanych zmiennych.
public CapturedVariablesDemoImpl originalThis;
public string methodParameter; Przechwytywane zmienne.
public string methodLocal;

public void Method(string lambdaParameter) Ciało wyrażenia lambda stało się


{ metodą instancji.
string lambdaLocal = "zmienna lokalna wyrażenia lambda";
Console.WriteLine("Pole instancji: {0}",
originalThis.instanceField);
Console.WriteLine("Parametr metody: {0}", methodParameter);
Console.WriteLine("Zmienna lokalna metody: {0}", methodLocal);
Console.WriteLine("Parametr wyrażenia lambda: {0}", lambdaParameter);
Console.WriteLine("Zmienna lokalna wyrażenia lambda: {0}", lambdaLocal);
}
}

public Action<string> CreateAction(string methodParameter)


{
LambdaContext context = new LambdaContext();
context.originalThis = this;
context.methodParameter = methodParameter;
context.methodLocal = "zmienna lokalna metody"; Wygenerowana klasa jest
string uncaptured = "nieprzechwytywana zmienna lokalna"; używana dla wszystkich
przechwytywanych
zmiennych.
Action<string> action = context.Method;
context.methodLocal = "zmodyfikowana zmienna lokalna metody";
return action;
}

Warto zwrócić uwagę na modyfikację pola context.methodLocal w końcowej części


metody CreateAction. Gdy delegat zostanie wywołany, zobaczy tę modyfikację. Podobnie
jeśli delegat zmodyfikuje którąś z przechwytywanych zmiennych, w każdym wywołaniu
widoczne będą wyniki wcześniejszych wywołań. Jest to podkreśleniem faktu, że kompila-
tor gwarantuje, iż przechwytywane są zmienne, a nie ich wartość z danego momentu.
Na listingach 3.6 i 3.7 dla przechwytywanych zmiennych trzeba było utworzyć
tylko jeden kontekst. Zgodnie z terminologią ze specyfikacji oznacza to, że instancja
każdej zmiennej była tworzona tylko raz. Skomplikujmy teraz nieco sytuację.
WIELE INSTANCJI ZMIENNYCH LOKALNYCH
Aby nieco uprościć zadanie, tym razem przechwytywana będzie tylko jedna zmienna
lokalna i nie będą używane parametry ani pola instancji. Na listingu 3.8 pokazana jest
metoda, która tworzy listę działań, a następnie wykonuje je jedno po drugim. W każdym
działaniu przechwytywana jest zmienna text.

Listing 3.8. Wielokrotne tworzenie instancji zmiennej lokalnej

static List<Action> CreateActions()


{
List<Action> actions = new List<Action>();
for (int i = 0; i < 5; i++)

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)

private class LambdaContext


{
public string text;

public void Method()


{
Console.WriteLine(text);
}
}

static List<Action> CreateActions()


{
List<Action> actions = new List<Action>();
for (int i = 0; i < 5; i++)
{ Tworzenie nowego kontekstu
LambdaContext context = new LambdaContext(); dla każdej iteracji pętli.
context.text = string.Format("komunikat {0}", i);
actions.Add(context.Method); Używanie kontekstu
} do utworzenia działania.
return actions;
}

Mam nadzieję, że jest to zrozumiałe. Przeszliśmy od używania jednego kontekstu dla


wyrażenia lambda do jednego kontekstu dla każdej iteracji pętli. Na zakończenie
omawiania przechwytywanych zmiennych przyjrzyj się jeszcze bardziej skomplikowa-
nemu przykładowi, który łączy obie możliwości.

87469504f326f0d7c1fcda56ef61bd79
8
3.5. Wyrażenia lambda 131

PRZECHWYTYWANIE ZMIENNYCH Z WIELU ZASIĘGÓW


To zasięg zmiennej text powoduje, że kod tworzy jedną jej instancję dla każdej iteracji
pętli. Jednak w jednej metodzie może występować wiele zasięgów, a każdy zasięg może
zawierać deklaracje zmiennych lokalnych. Jedno wyrażenie lambda może więc prze-
chwytywać zmienne z różnych zasięgów. Przykład pokazany jest na listingu 3.10.
Kod tworzy tu dwie instancje delegata. Każda z tych instancji przechwytuje po dwie
zmienne. Obie instancje przechwytują tę samą zmienną outerCounter, ale każda prze-
chwytuje inną zmienną innerCounter. Delegaty wyświetlają aktualną wartość liczników
i zwiększają ją. Każdy delegat jest uruchamiany dwukrotnie, dzięki czemu widać różnicę
w działaniu przechwytywanych zmiennych.

Listing 3.10. Przechwytywanie zmiennych z wielu zasięgów

static List<Action> CreateCountingActions()


{
List<Action> actions = new List<Action>();
int outerCounter = 0; Jedna zmienna przechwytywana
for (int i = 0; i < 2; i++) w obu delegatach.
{
int innerCounter = 0; Nowa zmienna dla każdej iteracji pętli.
Action action = () =>
{
Console.WriteLine(
"outerCounter: {0}; innerCounter: {1}",
Wyświetlanie i zwiększanie
outerCounter, innerCounter);
wartości liczników.
outerCounter++;
innerCounter++;
};
actions.Add(action);
}
return actions;
}

W innym kodzie:
List<Action> actions = CreateCountingActions();
actions[0]();
actions[0]();
Każdy delegat jest wywoływany dwukrotnie.
actions[1]();
actions[1]();

Oto dane wyjściowe kodu z listingu 3.10:


outerCounter: 0; innerCounter: 0
outerCounter: 1; innerCounter: 1
outerCounter: 2; innerCounter: 0
outerCounter: 3; innerCounter: 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.

Listing 3.11. Przechwytywanie zmiennych z wielu zasięgów prowadzi do powstania


kilku klas

private class OuterContext


{
Kontekst dla zewnętrznego zasięgu.
public int outerCounter;
}

private class InnerContext


{ Kontekst dla wewnętrznego zasięgu
public OuterContext outerContext; używający zewnętrznego zasięgu.
public int innerCounter;

public void Method() Metoda tworząca delegat.


{
Console.WriteLine(
"outerCounter: {0}; innerCounter: {1}",
outerContext.outerCounter, innerCounter);
outerContext.outerCounter++;
innerCounter++;
}
}

static List<Action> CreateCountingActions()


{
List<Action> actions = new List<Action>();
OuterContext outerContext = new OuterContext(); Tworzenie jednego kontekstu
outerContext.outerCounter = 0; zewnętrznego.
for (int i = 0; i < 2; i++)
{
InnerContext innerContext = new InnerContext();
Tworzenie kontekstu wewnętrznego
innerContext.outerContext = outerContext;
dla każdej iteracji pętli.
innerContext.innerCounter = 0;
Action action = innerContext.Method;
actions.Add(action);
}
return actions;
}

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ń.

3.5.3. Drzewa wyrażeń


Drzewa wyrażeń służą do reprezentowania kodu jako danych. Jest to podstawowy
mechanizm umożliwiający w technologii LINQ wydajną pracę z dostawcami danych
takimi jak bazy SQL-owe. Kod pisany w C# można dzięki niemu analizować w czasie
wykonywania programu i przekształcać na instrukcje w SQL-u.
Delegaty zapewniają kod, który można uruchamiać, natomiast drzewa wyrażeń
zapewniają kod umożliwiający inspekcję (podobnie jak w mechanizmie refleksji). Choć
można generować drzewa wyrażeń bezpośrednio w kodzie, częściej żąda się od kom-
pilatora, aby przekształcił wyrażenie lambda w takie drzewo. Na listingu 3.12 pokazano
prosty przykład tworzenia drzewa wyrażeń reprezentującego dodawanie dwóch liczb.

Listing 3.12. Proste drzewo wyrażeń dodające dwie liczby całkowite

Expression<Func<int, int, int>> adder = (x, y) => x + y;


Console.WriteLine(adder);

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

Kompilator nie „oszukuje” tu i nie korzysta z zapisanego na stałe łańcucha znaków.


Tekstowa reprezentacja jest generowana na podstawie drzewa wyrażenia. To pokazuje,
że kod jest dostępny do analizy w czasie wykonywania programu. Do tego właśnie mają
służyć drzewa wyrażeń.
Przyjrzyj się typowi zmiennej adder: Expression<Func<int, int, int>>. Najłatwiej
jest podzielić go na dwie części Expression<TDelegate> i Func<int, int, int>. Druga część
jest używana jako argument określający typ dla pierwszej. Ta druga część to typ dele-
gata, który przyjmuje dwa parametry całkowitoliczbowe i zwraca liczbę całkowitą. Typ
zwracanej wartości jest podawany w ostatnim parametrze określającym typ. (Tak więc
delegat typu Func<string, double, int> przyjmuje parametry typów string i double,
a zwraca wartość typu int).
Expression<TDelegate> to typ drzewa wyrażenia powiązany z typem TDelegate.
TDelegate musi być typem delegata, co nie jest zapisane jako ograniczenie typu, ale
jest sprawdzane w czasie wykonywania programu. Expression to tylko jeden z wielu
typów związanych z drzewami wyrażeń. Wszystkie te typy znajdują się w przestrzeni
nazw System.Linq.Expressions. Niegeneryczna klasa Expression to abstrakcyjna klasa

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

ParameterExpression xParameter = Expression.Parameter(typeof(int), "x");


ParameterExpression yParameter = Expression.Parameter(typeof(int), "y");
Expression body = Expression.Add(xParameter, yParameter);
ParameterExpression[] parameters = new[] { xParameter, yParameter };

Expression<Func<int, int, int>> adder =


Expression.Lambda<Func<int, int, int>>(body, parameters);
Console.WriteLine(adder);

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

KOMPILOWANIE DRZEW WYRAŻEŃ DO POSTACI DELEGATÓW


Możliwość wykonywania zapytań dotyczących zdalnych źródeł danych, o czym wspo-
mniano wcześniej, nie jest jedynym zastosowaniem drzew wyrażeń. Mogą być one
wartościowym narzędziem do dynamicznego tworzenia wydajnych delegatów w czasie
wykonywania programu, choć zwykle jest to obszar, w którym przynajmniej część
drzewa wyrażenia jest generowana na podstawie ręcznie pisanego kodu, a nie prze-
kształcana z wyrażenia lambda.
Typ Expression<TDelegate> udostępnia metodę Compile(), która zwraca typ dele-
gata. Taki delegat może być używany w taki sam sposób jak każdy inny. Oto prosty
przykład — kod z listingu 3.14 pobiera drzewo wyrażenia dla zmiennej adder i kompi-
luje je do postaci delegata, który potem wywołuje, co daje wynik 5.

Listing 3.14. Kompilowanie drzewa wyrażenia do postaci delegata i wywołanie


delegata w celu uzyskania wyniku

Expression<Func<int, int, int>> adder = (x, y) => x + y;


Func<int, int, int> executableAdder = adder.Compile(); Kompilowanie drzewa wyrażenia
Console.WriteLine(executableAdder(2, 3)); do postaci delegata.
Wywołanie delegata w standardowy sposób.

To podejście można stosować razem z mechanizmem refleksji do akcesorów właści-


wości i wywołań metod, aby tworzyć delegaty i zapisywać je. Uzyskany kod będzie
równie wydajny, jakbyś napisał analogiczny kod ręcznie. Na potrzeby jednego wywo-
łania metody lub dostępu do właściwości istnieją metody do bezpośredniego tworzenia
delegatów, jednak czasem konieczne są dodatkowe etapy przekształceń lub zmian,
które można łatwo przedstawić w drzewie wyrażenia.
Dalej, przy łączeniu wszystkich informacji, opisane jest, dlaczego drzewa wyrażeń
są tak ważne w technologii LINQ. Do omówienia pozostały tu tylko dwa mechanizmy
języka. Pierwsze z nich to metody rozszerzające.

3.6. Metody rozszerzające


Gdy je po raz pierwszy opisano, wydawały się niepotrzebne. Są to metody statyczne,
które można wywoływać tak, jakby były metodami instancji, wykorzystując pierwszy
parametr. Załóżmy, że istnieje następująca metoda statyczna:
ExampleClass.Method(x, y);

Jeśli przekształcisz metodę ExampleClass.Method w metodę rozszerzającą, możesz


wywołać ją tak:
x.Method(y);

To wszystko, co robią metody rozszerzające. Jest to jedno z najprostszych przekształceń


wykonywanych przez kompilator języka C#. Ma ono jednak wpływa na czytelność
kodu, jeśli wywołania metod są łączone w łańcuch. Jest to opisane dalej (wreszcie
z użyciem prawdziwych przykładów dotyczących technologii LINQ), najpierw jednak
przyjrzyj się składni.

87469504f326f0d7c1fcda56ef61bd79
8
136 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane

3.6.1. Deklarowanie metody rozszerzającej


Metody rozszerzające są deklarowane poprzez dodanie słowa kluczowego this przed
pierwszym parametrem. Taka metoda musi być zadeklarowana w klasie statycznej,
która nie jest zagnieżdżona ani generyczna. Do wersji C# 7.2 pierwszy parametr takiej
metody nie mógł być parametrem ref. Więcej na ten temat dowiesz się w podroz-
dziale 13.5. Choć klasa zawierająca taką metodę nie może być generyczna, sama metoda
rozszerzająca może taka być.
Typ pierwszego parametru można nazwać typem docelowym lub typem rozsze-
rzanym metody rozszerzającej. W specyfikacji, niestety, nie ma podanej nazwy tego
mechanizmu.
W interfejsie API Noda Time dostępna jest metoda rozszerzająca, która przekształca
obiekty typu DateTimeOffset na typ Instant. W strukturze Instant istnieje już służąca do
tego metoda statyczna, jednak przydatna jest też metoda rozszerzająca do wykonywania
tej operacji. Kod takiej metody pokazany jest na listingu 3.15. Na tym listingu podana
jest deklaracja przestrzeni nazw, ponieważ będzie ona istotna przy omawianiu wyszu-
kiwania metod rozszerzających przez kompilator języka C#.

Listing 3.15. Metoda rozszerzająca ToInstant dla typu docelowego DateTimeOffset


z interfejsu API Noda Time

using System;

namespace NodaTime.Extensions
{
public static class DateTimeOffsetExtensions
{
public static Instant ToInstant(this DateTimeOffset dateTimeOffset)
{
return Instant.FromDateTimeOffset(dateTimeOffset);
}
}
}

Kompilator dodaje atrybut [Extension] do metody i zawierającej ją klasy — i to tyle.


Ten atrybut znajduje się w przestrzeni nazw System.Runtime.CompilerServices. Oznacza
on, że programista chce mieć możliwość wywoływania metody ToInstant() tak, jakby
była zadeklarowana jako metoda instancji typu DateTimeOffset.

3.6.2. Wywoływanie metod rozszerzających


Poznałeś już składnię wywoływania metod rozszerzających. Wywołuje się je tak, jakby
były metodą instancji typu użytego dla pierwszego parametru. Trzeba się jednak upew-
nić, że kompilator zdoła znaleźć taką metodę.
Ważna jest tu kwestia priorytetów. Jeśli istnieje zwykła metoda instancji pasująca
do danego wywołania, kompilator zawsze wybierze ją zamiast metody rozszerzającej.
Nie ma znaczenia to, czy metoda rozszerzająca ma „lepsze” parametry. Jeśli kompilator
może użyć metody instancji, nawet nie szuka metod rozszerzających.

87469504f326f0d7c1fcda56ef61bd79
8
3.6. Metody rozszerzające 137

Po nieudanym zakończeniu wyszukiwania metod instancji kompilator szuka metod


rozszerzających na podstawie przestrzeni nazw, w jakiej znajduje się kod wywołujący
daną metodę, i obecnych dyrektyw using. Załóżmy, że wywołanie znajduje się w klasie
3
ExtensionMethodInvocation w przestrzeni nazw CSharpInDepth.Chapter03 . Na listingu 3.16
pokazane jest, jak zapewnić kompilatorowi wszystkie informacje potrzebne do znale-
zienia metody rozszerzającej.

Listing 3.16. Wywoływanie metody rozszerzającej ToInstant() poza interfejsem API


Noda Time

using NodaTime.Extensions; Importowanie przestrzeni nazw NodaTime.Extensions.


using System;

namespace CSharpInDepth.Chapter03
{
class ExtensionMethodInvocation
{
static void Main()
{
var currentInstant =
DateTimeOffset.UtcNow.ToInstant(); Wywołanie metody rozszerzającej.
Console.WriteLine(currentInstant);
}
}
}

Kompilator sprawdza dostępność metod rozszerzających w następujących miejscach:


 w klasach statycznych z przestrzeni nazw CSharpInDepth.Chapter03,
 w klasach statycznych z przestrzeni nazw CSharpInDepth,
 w klasach statycznych z globalnej przestrzeni nazw,
 w klasach statycznych z przestrzeni nazw podanych w dyrektywach using prze-
strzeni nazw (są to dyrektywy using, w których podana jest tylko przestrzeń nazw,
np. using System),
 w klasach statycznych podanych za pomocą dyrektyw using static (dotyczy to
tylko C# 6; zagadnienie to jest opisane w podrozdziale 10.1).

Kompilator zaczyna od środka (od najbardziej specyficznej przestrzeni nazw) i zmierza


na zewnętrz (do globalnej przestrzeni nazw). Na każdym etapie sprawdza klasy sta-
tyczne z danej przestrzeni nazw i powiązane z dyrektywami using z deklaracji prze-
strzeni nazw. Szczegóły związane z kolejnością dyrektyw prawie nigdy nie są istotne.
Jeśli natrafisz na sytuację, gdzie przestawienie dyrektyw using zmienia to, która metoda
rozszerzająca jest używana, zwykle najlepiej jest zmienić nazwę którejś z metod. Należy
jednak pamiętać, że na każdym etapie znalezionych może zostać wiele metod rozsze-
rzających poprawnych dla danego wywołania. W takiej sytuacji kompilator dokonuje

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

standardowego wyboru przeciążonej wersji spośród wszystkich metod rozszerzających


znalezionych w tym kroku. Gdy kompilator znajdzie odpowiednią metodę do wywołania,
wygeneruje identyczny kod pośredni jak dla zwykłego wywołania metody statycznej
(zamiast użycia jej jako metody rozszerzającej).

Metody rozszerzające mogą być wywoływane dla wartości null


Metody rozszerzające różnią się od metod instancji w zakresie obsługi wartości null. Wróć
do początkowego przykładu:
x.Method(y);
Jeśli Method to metoda instancji, a x to referencja null, ten kod spowoduje zgłoszenie
wyjątku NullReferenceException. Z kolei jeśli Method to metoda rozszerzająca, zostanie ona
wywołana z wartością x jako pierwszym argumentem nawet wtedy, gdy x to null. Czasem
w metodzie jest określone, że pierwszy argument musi być różny od null. Wtedy należy
sprawdzać ten argument i ewentualnie zgłaszać wyjątek ArgumentNullException. W innych
sytuacjach metoda rozszerzająca może być celowo zaprojektowana pod kątem popraw-
nej obsługi pierwszego argumentu o wartości null.

Wróćmy do omawiania, dlaczego metody rozszerzające są ważne w technologii LINQ.


Pora przyjrzeć się pierwszemu zapytaniu.

3.6.3. Łączenie wywołań metod w łańcuch


Na listingu 3.17 pokazane jest proste zapytanie. Przyjmuje ono ciąg słów, filtruje je
na podstawie długości, sortuje w naturalny sposób, a następnie przekształca litery na
wielkie. Używane są tu wyrażenia lambda i metody rozszerzające, ale już nie inne
mechanizmy z C# 3. Wszystkie pozostałe mechanizmy zostaną połączone w końcowej
części rozdziału. Na razie chcę się skupić na czytelności tego prostego kodu.

Listing 3.17. Proste zapytanie dotyczące łańcuchów znaków

string[] words = { "kask", "buty", "laptop", "butelka" }; Proste źródło danych.


IEnumerable<string> query = words
.Where(word => word.Length > 4)
.OrderBy(word => word) Filtrowanie, sortowanie i przekształcanie.
.Select(word => word.ToUpper());

foreach (string word in query)


{
Console.WriteLine(word); Wyświetlanie wyników.
}

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

Listing 3.18. Proste zapytanie bez używania metod rozszerzających

string[] words = { "kask", "buty", "laptop", "butelka" };


IEnumerable<string> query =
Enumerable.Select(
Enumerable.OrderBy(
Enumerable.Where(words, word => word.Length > 4),
word => word),
word => word.ToUpper());

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.

Listing 3.19. Proste zapytanie podzielone na zestaw instrukcji

string[] words = { "kask", "buty", "laptop", "butelka" };


var tmp1 = Enumerable.Where(words, word => word.Length > 4);
var tmp2 = Enumerable.OrderBy(tmp1, word => word);
var query = Enumerable.Select(tmp2, word => word.ToUpper());

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

3.7. Wyrażenia reprezentujące zapytania


Prawie wszystkie mechanizmy wprowadzone w C# 3 wspomagają tworzenie używanych
tylko w LINQ wyrażeń reprezentujących zapytania. Takie wyrażenia pozwalają pisać
zwięzły kod za pomocą klauzul charakterystycznych dla zapytań (select, where, let,
group by itd.). Takie zapytanie jest następnie przekształcane na postać różną od zapytania
i kompilowane w standardowy sposób4. Zacznijmy od krótkiego przykładu, aby objaśnie-
nie było bardziej przejrzyste. Na listingu 3.17 używane było następujące zapytanie:
IEnumerable<string> query = words
.Where(word => word.Length > 4)
.OrderBy(word => word)
.Select(word => word.ToUpper());

Na listingu 3.20 pokazane jest to samo zapytanie zapisane w formie wyrażenia repre-
zentującego zapytanie.

Listing 3.20. Wprowadzające wyrażenie reprezentujące zapytanie z filtrowaniem,


sortowaniem i projekcją

IEnumerable<string> query = from word in words


where word.Length > 4
orderby word
select word.ToUpper();

Fragment listingu 3.20 wyróżniony pogrubieniem to wyrażenie reprezentujące zapy-


tanie. Jest ono bardzo zwięzłe. Powtórzenia nazwy word jako parametru wyrażeń
lambda zostały zastąpione jednokrotnym podaniem tej nazwy jako zmiennej zakresu
w klauzuli from. Zmienna ta jest dalej używana w pozostałych klauzulach. Co dzieje się
z wyrażeniem reprezentującym zapytanie z listingu 3.20?

3.7.1. Wyrażenia reprezentujące zapytania


są przekształcane z kodu C# na inny kod C#
W tej książce wiele mechanizmów języka jest opisanych za pomocą innego kodu
w C#. Na przykład w omówieniu zmiennych przechwytywanych w punkcie 3.5.2
pokazałem kod w C#, który można napisać, aby osiągnąć ten sam efekt co za pomocą
wyrażenia lambda. Celem jest objaśnienie kodu generowanego przez kompilator. Nie
oczekuję jednak, że kompilator rzeczywiście będzie generował kod w C#. W specyfi-
kacji opisane są efekty przechwytywania zmiennych, a nie przekształcanie kodu źró-
dłowego.
Wyrażenia reprezentujące zapytania działają inaczej. W specyfikacji opisano je za
pomocą składniowych przekształceń wykonywanych przed wyborem lub wiązaniem
przeciążonych wersji. Kod z listingu 3.20 nie tylko daje ten sam efekt końcowy co
kod z listingu 3.17 — w rzeczywistości jest przekształcany na kod z listingu 3.17 przed
dalszym przetwarzaniem. W języku nie jest ściśle opisane, jaki ma być wynik tego
dalszego przetwarzania. W wielu sytuacjach ten wynik to wywołania metod rozszerza-
4
Ten opis przypomina makra z języka C, jednak mechanizm z C# jest bardziej złożony. W C# makra
nadal są niedostępne.

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.

3.7.2. Zmienne zakresu i identyfikatory przezroczyste


W wyrażeniach reprezentujących zapytania wprowadzono zmienne zakresu. Różnią się
one od zwykłych zmiennych. Działają jak element wejściowych w każdej klauzuli zapy-
tania. Zobaczyłeś już, jak podać zamienną zakresu w klauzuli from na początku wyra-
żenia reprezentującego zapytanie. Oto wyrażenie z listingu 3.20 z wyróżnioną zmienną
zakresu:
Wprowadzenie zmiennej
from word in words zakresu w klauzuli from.
where word.Length > 4
orderby word Użycie zmiennej zakresu w dalszych klauzulach.
select word.ToUpper()

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

from word in words


let length = word.Length
where length > 4
orderby length
select string.Format("{0}: {1}", length, word.ToUpper());

Wtedy w zasięgu jednocześnie dostępne są dwie zmienne zakresu. Dowodem na to jest


użycie zmiennych length i word w klauzuli select. Jak uwzględnić to w trakcie prze-
kształcania zapytania? Potrzebny jest sposób na to, aby wziąć pierwotną sekwencję słów
i wygenerować sekwencję par słowo – długość. Następnie w klauzulach, w których
można używać zmiennych zakresu, należy uzyskać dostęp do odpowiedniego elementu
z takich par. Na listingu 3.22 pokazane jest, jak listing 3.21 jest przekształcany przez
kompilator używający typu anonimowego do reprezentowania pary wartości.

87469504f326f0d7c1fcda56ef61bd79
8
142 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane

Listing 3.22. Przekształcanie zapytania z użyciem przezroczystego identyfikatora

words.Select(word => new { word, length = word.Length })


.Where(tmp => tmp.length > 4)
.OrderBy(tmp => tmp.length)
.Select(tmp =>
string.Format("{0}: {1}", tmp.length, tmp.word.ToUpper()));

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.

3.7.3. Kiedy stosować poszczególne składnie w LINQ?


Wyrażenia reprezentujące zapytania są atrakcyjne, jednak nie zawsze stanowią naj-
prostszy sposób reprezentowania zapytań. Zawsze wymagają klauzuli from na początku
i klauzuli select lub group by na końcu. Wydaje się to sensowne, jednak oznacza, że
jeśli potrzebujesz zapytania wykonującego np. jedną operację filtrowania, musisz
uwzględnić sporo dodatkowych elementów. Jeżeli użyjesz tylko filtrowania z wcześniej-
szego zapytania dotyczącego słów, uzyskasz następujące wyrażenie:
from word in words
where word.Length > 4
select word

Porównaj to z zapytaniem zapisanym za pomocą składni metod:


words.Where(word => word.Length > 4)

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.

3.8. Efekt końcowy — technologia LINQ


Nie zamierzam omawiać w tym miejscu różnych dostępnych obecnie dostawców
technologii LINQ. Technologia LINQ, której używam (zdecydowanie) najczęściej, to
LINQ to Objects, gdzie stosowane są klasy statyczne typu Enumerable i delegaty. Jednak
aby pokazać, jak wszystkie elementy współdziałają ze sobą, przyjmijmy, że używasz
zapytania z poziomu platformy Entity Framework. Nie jest to rzeczywisty kod, który
możesz przetestować, ale byłby poprawny, gdybyś miał bazę danych o odpowiedniej
strukturze:
var products = from product in dbContext.Products
where product.StockCount > 0
orderby product.Price descending
select new { product.Name, product.Price };

W tym jednym przykładzie, który ma tylko cztery wiersze, używane są wszystkie


wymienione mechanizmy:
 typy anonimowe razem z inicjalizatorem z projekcją (pobierana jest tylko nazwa
i cena produktu);
 niejawne typowanie z użyciem słowa kluczowego var, ponieważ bez tego nie
można zadeklarować w przydatny sposób typu zmiennej products;

87469504f326f0d7c1fcda56ef61bd79
8
144 ROZDZIAŁ 3. C# 3 — technologia LINQ i wszystko, co z nią związane

 wyrażenia reprezentujące zapytania, bez których można byłoby się tu obejść,


ale które znacznie upraszczają pracę w przypadku bardziej skomplikowanych
zapytań;
 wyrażenia lambda, które są wynikiem przekształcenia wyrażenia reprezentują-
cego zapytanie;
 metody rozszerzające, które umożliwiają zapis przekształconego zapytania za
pomocą klasy Queryable, ponieważ obiekt dbContext.Products implementuje inter-
fejs IQueryable<Product>;
 drzewa wyrażeń, które umożliwiają przekazanie logiki zapytania jako danych
do dostawcy technologii LINQ, dzięki czemu to zapytanie można przekształcić
na kod w SQL-u i wydajnie wykonać w bazie danych.

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

C# 4 była ciekawą wersją. Najważniejszą zmianą było wprowadzenie dynamicznego


typowania z użyciem typu dynamic. Ten mechanizm sprawia, że w C# w jednym języku
dostępne jest zarówno typowanie statyczne (w większości kodu), jak i typowanie dyna-
miczne (gdy używany jest typ dynamic). Jest to rzadkie w świecie języków programowania.
Typowanie dynamiczne dodano na potrzeby współdziałania z innym kodem, co
jednak dla wielu programistów okazało się mało istotne w codziennej pracy. Najważ-
niejsze mechanizmy z innych wersji (typy generyczne, technologia LINQ, mechanizm
async/await) stały się naturalną częścią przybornika większości programistów używających
C#. Jednak dynamiczne typowanie wciąż jest stosowane dość rzadko. Jestem pewien, że
jest przydatne dla osób, które go potrzebują, a już na pewno jest to ciekawe rozwiązanie.

87469504f326f0d7c1fcda56ef61bd79
8
146 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami

Inne mechanizmy z C# 4 także poprawiają współdziałanie z innym kodem, a przede


wszystkim z technologią COM. Niektóre usprawnienia są specyficzne dla tej technologii.
Dotyczy to np. nazwanych indekserów, niejawnych argumentów typu ref i zagnież-
dżonych typów umożliwiających współdziałanie z innymi technologiami. Opcjonalne
parametry i nazwane argumenty są przydatne w kontekście COM, jednak można je
stosować także w pełni zarządzanym kodzie. Są to dwa mechanizmy wprowadzone
w C# 4, z których korzystam na co dzień.
W C# 4 udostępniono też aspekt typów generycznych dostępny w środowisku CLR
od wersji 2. (była to pierwsza wersja środowiska uruchomieniowego, w której dodano
typy generyczne). Generyczna wariancja jest jednocześnie prosta i skomplikowana. Na
pozór wydaje się oczywista — np. sekwencja łańcuchów znaków naturalnie jest też
sekwencją obiektów. Potem jednak okazuje się, że lista łańcuchów znaków nie jest
wcale listą obiektów, co jest sprzeczne z oczekiwaniami niektórych programistów.
Omawiany mechanizm jest przydatny, jednak jego dokładna analiza może skutkować
bólem głowy. Zwykle możesz korzystać z tego mechanizmu, nawet nie wiedząc, że to
robisz. Mam nadzieję, że gdy po lekturze tego rozdziału będziesz musiał bliżej przyjrzeć
się kodowi, który działa niezgodnie z oczekiwaniami, będziesz dobrze przygotowany do
sprawnego rozwiązania problemu. Zacznijmy od omówienia typowania dynamicznego.

4.1. Typowanie dynamiczne


Niektóre mechanizmy związane są z dużą ilością nowej składni, jednak po jej przed-
stawieniu nie trzeba już nic wyjaśniać. Typowanie dynamiczne to zupełne przeciwień-
stwo takiej sytuacji. Składnia jest tu bardzo prosta, jednak można prawie w nieskoń-
czoność szczegółowo omawiać wpływ tego mechanizmu i jego implementację. W tym
podrozdziale najpierw opisane są podstawy, dalej zapoznasz się z kilkoma szczegółami,
a na zakończenie znajdziesz kilka wskazówek wyjaśniających, jak i kiedy stosować typo-
wanie dynamiczne.

4.1.1. Wprowadzenie do typowania dynamicznego


Zacznijmy od przykładu. Na listingu 4.1 pokazano dwie próby pobrania podłańcucha
z tekstu. Na razie nie staram się wyjaśniać, po co miałbyś stosować typowanie dyna-
miczne. Pokazuję tylko, jak działa ta funkcja.

Listing 4.1. Pobieranie podłańcucha za pomocą typowanie dynamicznego

dynamic text = "Witaj, świecie"; Deklaracja zmiennej typu dynamic.


string world = text.Substring(7); Wywołanie metody Substring (dozwolone).
Console.WriteLine(world);

string broken = text.SUBSTR(7); Próba wywołania SUBSTR powoduje zgłoszenie wyjątku.


Console.WriteLine(broken);

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.

Podstawowe reguły dotyczące typu dynamic są proste:


1. Możliwa jest niejawna konwersja z dowolnego typu niewskaźnikowego na typ
dynamic.
2. Możliwa jest niejawna konwersja z wyrażenia typu dynamic na dowolny typ
niewskaźnikowy.
3. Wyrażenie zawierające wartość typu dynamic zwykle jest wiązane w czasie wyko-
nywania programu.
4. Większość wyrażeń zawierających wartość typu dynamic ma w czasie kompilacji
typ dynamic.

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);

W pierwszym wierszu następuje konwersja z typu string na dynamic. Jest to dozwolone


na mocy reguły 1. W drugim wierszu uwzględniane są wszystkie trzy pozostałe reguły:

87469504f326f0d7c1fcda56ef61bd79
8
148 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami

 wywołanie text.Substring(7) jest wiązane w czasie wykonywania programu


(reguła nr 3),
 typ tego wyrażenia w czasie kompilacji to dynamic (reguła nr 4),
 zachodzi niejawna konwersja z typu wyrażenia na typ string (reguła nr 2).

Konwersja z wyrażenia typu dynamic na typ niedynamiczny także zachodzi dynamicznie.


Jeśli zadeklarujesz zmienną world jako typu int, kod się skompiluje, ale w czasie wyko-
nywania programu zgłoszony zostanie wyjątek RuntimeBinderException. Jeśli zadekla-
rujesz tę zmienną jako typu XNamespace, kod się skompiluje, a w czasie wykonywania
programu binder posłuży się zdefiniowaną przez użytkownika niejawną konwersją
z typu string na XNamespace. Pamiętając o tym, przyjrzyj się kolejnym przykładom
dynamicznego wiązania.
DYNAMICZNE WIĄZANIE W RÓŻNYCH KONTEKSTACH
Do tej pory wiązanie dynamiczne prezentowane było tu tylko na podstawie dynamicz-
nego wyboru typu, dla którego wywoływana jest metoda, i konwersji. Jednak prawie
dowolny aspekt wykonywania kodu może być dynamiczny. Na listingu 4.2 wiązanie
dynamiczne pokazane jest w kontekście operatora dodawania. Kod wykonuje tu trzy
rodzaje dodawania na podstawie typu wartości dynamicznie określanego w czasie
wykonywania programu.

Listing 4.2. Dodawanie dynamicznie określanych wartości

static void Add(dynamic d)


{
Console.WriteLine(d + d); Dodawanie na podstawie typu używanego
} w czasie wykonywania programu.

Add("text");
Wywołanie metody z różnymi
Add(10);
wartościami.
Add(TimeSpan.FromMinutes(45));

Oto wynik wykonania kodu z listingu 4.2:


texttext
20
01:30:00

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.

Listing 4.3. Dynamiczne wybieranie wersji przeciążonej metody

static void SampleMethod(int value)


{
Console.WriteLine("Metoda z parametrem typu int");
}

static void SampleMethod(decimal value)

87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 149

{
Console.WriteLine("Metoda z parametrem typu decimal");
}

static void SampleMethod(object value)


{
Console.WriteLine("Metoda z parametrem typu object");
}

static void CallMethod(dynamic d)


{
SampleMethod(d); Dynamiczne wywołanie metody SampleMethod.
}

CallMethod(10);
CallMethod(10.5m); Pośrednie wywołanie metody SampleMethod
CallMethod(10L); z użyciem różnych typów.
CallMethod("text");

Oto dane wyjściowe z listingu 4.3:


Metoda z parametrem typu int
Metoda z parametrem typu decimal
Metoda z parametrem typu decimal
Metoda z parametrem typu object

Interesujące są przede wszystkim trzeci i czwarty wiersz danych wyjściowych. Wynika


z nich, że przy wybieraniu wersji przeciążonej metody w czasie wykonywania programu
uwzględniane są konwersje. W trzecim wierszu wartość typu long jest przekształcana na
typ decimal, a nie int, choć podana jest liczba całkowita z zakresu typu int. W wierszu
czwartym wartość typu string jest przekształcana na typ object. Celem jest to, by wią-
zanie w czasie wykonywania programu w miarę możliwości działało tak samo jak
wiązanie w czasie kompilacji, tylko dla określanych w czasie wykonywania programu
typów dynamicznych wartości.

Tylko dynamiczne wartości są analizowane dynamicznie


Kompilator stara się zagwarantować, że w czasie wykonywania programu dostępne będą
właściwe informacje. Gdy wiązanie dotyczy wielu wartości, typ z czasu kompilacji jest
używany dla wszystkich wartości ze statycznie określanym typem, a typ z czasu wykona-
nia jest stosowany dla wszystkich wartości z dynamicznie ustalanym typem. Zwykle jest
to nieistotne, jednak w kodzie źródłowym do pobrania znajdziesz odpowiedni przykład
z komentarzami.

Wynik wywołania każdej dynamicznie wiązanej metody ma w czasie kompilacji typ


dynamic. Jeśli typ wartości zwracanej przez daną metodę to void, a wynik tej metody
jest używany (np. przypisywany do zmiennej), wiązanie kończy się niepowodzeniem.
Dotyczy to większości dynamicznie wiązanych operacji. Kompilator ma niewiele infor-
macji na temat takich operacji. Od tej reguły występuje jednak kilka wyjątków.

87469504f326f0d7c1fcda56ef61bd79
8
150 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami

CO KOMPILATOR MOŻE SPRAWDZAĆ


W DYNAMICZNIE WIĄZANYM KONTEKŚCIE?
Jeśli kontekst wywołania metody jest znany w czasie kompilacji, kompilator potrafi
sprawdzić, jakie metody o danej nazwie istnieją. Jeżeli okaże się wtedy, że w czasie
wykonania niemożliwe będzie dopasowanie żadnej metody, zgłaszany jest błąd kompi-
lacji. Dotyczy to następujących metod:
 metod instancji i indekserów, jeśli docelowym obiektem nie jest dynamiczna
wartość,
 metod statycznych,
 konstruktorów.

Na listingu 4.4 pokazane są różne przykłady wywołań używających dynamicznych war-


tości, które to wywołania kończą się niepowodzeniem.

Listing 4.4. Przykładowe błędy czasu kompilacji związane z dynamicznymi wartościami

Nie istnieje metoda String.Substring


przyjmująca cztery parametry.
dynamic d = new object();
int invalid1 = "text".Substring(0, 1, 2, d); Nie istnieje generyczna
bool invalid2 = string.Equals<int>("foo", d); metoda String.Equals.
string invalid3 = new string(d, "broken"); Nie istnieją dwuparametrowe konstruktory
char invalid4 = "text"[d, d]; typu String z drugim argumentem typu string.
Nie istnieje dwuparametrowy
indekser typu String.

Choć kompilator potrafi stwierdzić, że te konkretne przykłady są z pewnością nie-


prawidłowe, to nie zawsze jest to możliwe. Wiązanie dynamiczne jest krokiem w nie-
znane, chyba że zachowasz daleko posuniętą ostrożność co do używanych wartości.
Gdyby pokazane przykłady można było skompilować, używane byłoby w nich wią-
zanie dynamiczne. Inaczej jest tylko w nielicznych przypadkach.
JAKIE OPERACJE Z DYNAMICZNYMI WARTOŚCIAMI
NIE SĄ WIĄZANE DYNAMICZNIE?
Prawie wszystko, co robisz z dynamicznymi wartościami, obejmuje pewnego rodzaju
wiązanie dynamiczne i wyszukiwanie odpowiednich wywołań metod, właściwości,
konwersji, operatorów itd. Jest tylko kilka zadań, dla których kompilator nie musi gene-
rować kodu odpowiedzialnego za wiązanie:
 Przypisywanie wartości do zmiennej typu object lub dynamic. Nie jest wtedy
konieczna konwersja, dlatego kompilator może po prostu skopiować istniejącą
referencję.
 Przekazywanie argumentu do metody z parametrem typu object lub dynamic.
Sytuacja jest taka jak przy przypisywaniu wartości do zmiennej, przy czym
zmienną jest tu parametr.
 Sprawdzanie typu wartości za pomocą operatora is.
 Próba konwersji wartości z użyciem operatora as.

87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 151

Choć infrastruktura obsługująca wiązanie w czasie wykonywania programu wyszukuje


konwersje zdefiniowane przez użytkownika, to jeśli przekształcasz wartość dynamiczną
na konkretny typ za pomocą rzutowania lub niejawnie, w operatorach is i as nigdy nie
stosuje się takich konwersji, dlatego wiązanie nie jest konieczne. Podobnie prawie
wszystkie operacje z wartościami dynamicznymi zwracają dynamiczne wyniki.
DLA JAKICH OPERACJI Z WARTOŚCIAMI DYNAMICZNYMI
UŻYWANY JEST TYP STATYCZNY?
Kompilator próbuje maksymalnie ułatwiać pracę. Jeśli wyrażenie zawsze może być
tylko jednego konkretnego typu, kompilator stosuje ten typ jako typ wyrażenia na eta-
pie kompilacji. Na przykład jeśli d to zmienna typu dynamic, poniższe stwierdzenia są
prawdziwe:
 Wyrażenie new SomeType(d) ma na etapie kompilacji typ SomeType, choć kon-
struktor jest wiązany dynamicznie w czasie wykonywania programu.
 Wyrażenie d is Some Type ma na etapie kompilacji typ bool.
 Wyrażenie d as Some Type ma na etapie kompilacji typ SomeType.

To już wszystkie szczegóły, jakich potrzebujesz w tym wprowadzeniu. W punkcie 4.1.4


zapoznasz się z nieoczekiwanymi sytuacjami w czasie kompilacji i w czasie wykony-
wania programu. Na razie jednak poznałeś przedsmak dynamicznego typowania
i możesz przyjrzeć się możliwościom tego mechanizmu poza obszarem zwykłego wią-
zania w czasie wykonywania programu.

4.1.2. Dynamiczne operacje poza mechanizmem refleksji


Jednym z zastosowań typowania dynamicznego jest żądanie od kompilatora i platformy
wykonywania operacji związanych z refleksją na podstawie składowych zadeklarowanych
w standardowy sposób w typach. Choć jest to w pełni akceptowalne zastosowanie,
typowanie dynamiczne oferuje więcej możliwości. Jednym z powodów wprowadzenia
tej techniki było usprawnienie współdziałania z językami dynamicznymi, które umoż-
liwiają modyfikowanie wiązań w trakcie pracy programu. Wiele języków dynamicznych
umożliwia też przechwytywanie wywołań w czasie wykonywania programu. Ta tech-
nika pozwala np. na automatyczny zapis danych w pamięci podręcznej lub dzienniku,
a także na udostępnianie funkcji i pól, które nigdy nie są zadeklarowane za pomocą
danej nazwy w kodzie źródłowym.
FIKCYJNY PRZYKŁAD DOSTĘPU DO BAZY DANYCH
W ramach (niezaimplementowanego) przykładu ilustrującego zadania, jakie możesz chcieć
wykonywać, wyobraź sobie, że masz bazę danych zawierająca tabelę książek wraz z ich
autorami. Dynamiczne typowanie pozwala utworzyć kod następującego rodzaju:
dynamic database = new Database(connectionString);
var books = database.Books.SearchByAuthor("Holly Webb");
foreach (var book in books)
{
Console.WriteLine(book.Title);
}

87469504f326f0d7c1fcda56ef61bd79
8
152 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami

Ten kod związany jest z następującymi dynamicznymi operacjami:


 Klasa Database reaguje na żądanie właściwości Books, kierując do schematu bazy
danych zapytanie o tabelę Books i zwracając obiekt reprezentujący tę tabelę.
 Obiekt reprezentujący tabelę reaguje na wywołanie metody SearchByAuthor.
Obiekt wykrywa, że wywołanie zaczyna się od członu SearchBy i że w schemacie
szukana jest kolumna Author. Następnie generuje kod w SQL-u, aby zgłosić
zapytanie o daną kolumnę z użyciem podanego argumentu, po czym zwraca listę
obiektów reprezentujących wiersze.
 Każdy obiekt reprezentujący wiersz reaguje na właściwość Title, zwracając war-
tość kolumny Title.

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.

Listing 4.5. Zapisywanie i pobieranie elementów za pomocą typu ExpandoObject

dynamic expando = new ExpandoObject();


expando.SomeData = "Jakieś dane"; Przypisywanie danych do właściwości.
Action<string> action = Przypisywanie
input => Console.WriteLine("Dane wejściowe to '{0}'", input); delegata
expando.FakeMethod = action; do właściwości.

Console.WriteLine(expando.SomeData);
Dynamiczny dostęp do danych i delegata.
expando.FakeMethod("witaj");

IDictionary<string, object> dictionary = expando;


Console.WriteLine("Klucze: {0}", Traktowanie obiektu ExpandoObject jako
słownika w celu wyświetlenia kluczy.
string.Join(", ", dictionary.Keys));

dictionary["OtherData"] = "Inne dane"; Zapisywanie statycznych danych i pobieranie


Console.WriteLine(expando.OtherData); ich za pomocą dynamicznej wartości.

Gdy typ ExpandoObject jest używany w kontekście z typowaniem statycznym, stanowi


słownik par nazwa – wartość i implementuje interfejs IDictionary<string, object> w taki

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.

Listing 4.6. Dynamiczne używanie danych w formacie JSON

string json = @"


{
'name': 'Jon Skeet',
'address': { Zapisane na stałe przykładowe dane
'town': 'Reading', w formacie JSON.
'country': 'UK'
}
}".Replace('\'', '"');

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

JObject obj1 = JObject.Parse(json); Przetwarzanie danych w formacie JSON na typ


JObject.
Console.WriteLine(obj1["address"]["town"]); Używanie widoku z typowaniem statycznym.

dynamic obj2 = obj1;


Używanie widoku z typowaniem dynamicznym.
Console.WriteLine(obj2.address.town);

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:

Reprezentuje dynamiczne wiązanie i kod wiązania obiektu uczestniczącego w wią-


zaniu dynamicznym.
Choć używałem tej klasy, nie poważę się stwierdzić, że w pełni rozumiem to zdanie.
Nie potrafiłbym też przygotować lepszego opisu. Zwykle programista tworzy klasę, która
dziedziczy po typie DynamicMetaObject, przesłaniając niektóre dostępne w nim metody
prywatne. Na przykład jeśli chcesz umożliwić dynamiczne wywoływanie metod, powi-
nieneś przesłonić następującą metodę:

87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 155

public virtual DynamicMetaObject BindInvokeMember


(InvokeMemberBinder binder, DynamicMetaObject[] args);

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.

Na listingu 4.7 pokazane jest, jak używać tej klasy.

Listing 4.7. Przykład oczekiwanego używania dynamicznych operacji

dynamic example = new SimpleDynamicExample();


example.CallSomeMethod("x", 10);
Console.WriteLine(example.SomeProperty);

Dane wyjściowe powinny wyglądać tak:


Wywołano: CallSomeMethod(x, 10)
Pobrano: SomeProperty

W nazwach CallSomeMethod i SomeProperty nie ma nic wyjątkowego. Jeśli chcesz, możesz


napisać kod, który reaguje na konkretne nazwy. Nawet opisane do tej pory proste
operacje mogą być trudne do uzyskania w niskopoziomowym interfejsie. Na listingu 4.8
pokazano, jak łatwo uzyskać pożądany efekt za pomocą typu DynamicObject.

Podobnie jak w metodach klasy DynamicMetaObject, także i tu otrzymasz bindery, gdy


przesłonisz metody klasy DynamicObject. Jednak nie musisz martwić się o drzewa wyra-
żeń lub inne wartości typu DynamicMetaObject. Wartość zwracana przez każdą metodę
informuje, czy dynamiczny obiekt z powodzeniem wykonał operację. Zwrócenie warto-
ści false skutkuje zgłoszeniem wyjątku typu RuntimeBinderException.

87469504f326f0d7c1fcda56ef61bd79
8
156 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami

Listing 4.8. Implementowanie klasy SimpleDynamicExample

class SimpleDynamicExample : DynamicObject


{
public override bool TryInvokeMember(
InvokeMemberBinder binder,
object[] args,
out object result)
{
Obsługa wywołań metod.
Console.WriteLine("Wywołano: {0}({1})",
binder.Name, string.Join(", ", args));
result = null;
return true;
}

public override bool TryGetMember(


GetMemberBinder binder,
out object result)
{ Obsługa dostępu do właściwości.
result = "Pobrano: " + binder.Name;
return true;
}
}

To wszystko, co zamierzam przedstawić tu na temat implementowania dynamicznych


operacji. Mam jednak nadzieję, że prostota listingu 4.8 zachęci Cię do eksperymentów
z klasą DynamicObject. Nawet jeśli nigdy nie używałeś jej w kodzie produkcyjnym,
eksperymentowanie z nią może być bardzo ciekawe. Jeżeli chcesz ją wypróbować, ale
nie masz sprecyzowanych pomysłów, zawsze możesz spróbować zaimplementować
przykład z typem Database opisany na początku podrozdziału. W ramach przypomnienia
przedstawiam kod, którego wykonywanie należy umożliwić:
dynamic database = new Database(connectionString);
var books = database.Books.SearchByAuthor("Holly Webb");
foreach (var book in books)
{
Console.WriteLine(book.Title);
}

Dalej opisany jest kod, jaki kompilator języka C# generuje po napotkaniu dynamicz-
nych wartości.

4.1.3. Krótkie spojrzenie na zaplecze


Zapewne zauważyłeś już, że lubię sprawdzać, jaki kod pośredni jest używany przez
kompilator języka C# do implementowania różnych mechanizmów. Zobaczyłeś już,
że przechwytywane zmienne w wyrażeniach lambda mogą skutkować generowaniem
dodatkowych klas i że wyrażenia lambda przekształcane na drzewa wyrażeń skutkują
wywołaniami metod z klasy Expression. Typowanie dynamiczne działa nieco podobnie
do drzew wyrażeń, ponieważ też skutkuje reprezentowaniem kodu źródłowego za pomocą
danych, ale dzieje się to na większą skalę.
W tym punkcie znajdziesz jeszcze mniej szczegółów niż w poprzednim. Choć
szczegóły dotyczące omawianego zagadnienia są interesujące, prawie na pewno nie

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.

Niektóre mechanizmy są obsługiwane wyłącznie przez kompilator języka C#. Doty-


czy to np. niejawnego typowania. Platforma nie musi udostępniać żadnych typów do
obsługi słowa kluczowego var, a środowisko uruchomieniowe nie wie, czy programista
używa typowania niejawnego, czy jawnego.
Na drugim krańcu spektrum są typy generyczne, które wymagają rozbudowanej
obsługi w kompilatorze, środowisku uruchomieniowym i platformie. Używane są do
tego interfejsy API związane z refleksją. Technologia LINQ znajduje się gdzieś pomię-
dzy tymi skrajnościami — kompilator udostępnia różne mechanizmy opisane w roz-
dziale 3., a platforma zapewnia nie tylko implementację technologii LINQ to Objects,
ale też interfejsy API drzew wyrażeń. Jednak środowisko uruchomieniowe nie wyma-
gało zmian. Jeśli chodzi o typowanie dynamiczne, sytuacja jest bardziej skompliko-
wana. Na rysunku 4.1 pokazana jest graficzna reprezentacja używanych elementów.

Rysunek 4.1.
Graficzna reprezentacja
komponentów używanych
w typowaniu
dynamicznym

Środowisko CLR nie wymaga zmian, choć podejrzewam, że w wersji 4. wprowadzono


na potrzeby typowania dynamicznego pewne optymalizacje w stosunku do wersji 2.
Kompilator z pewnością generuje inny kod pośredni. Dalej opisano ilustrujący to
przykład. Jeśli chodzi o obsługę typowania dynamicznego w platformie i bibliotekach,
ważne są dwa aspekty. Pierwszy z nich to środowisko DLR (ang. Dynamic Language

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

Runtime), które udostępnia niezależną od języka infrastrukturę, np. typ DynamicMetaObject.


To środowisko odpowiada za wykonywanie wszystkich dynamicznych operacji. Jednak
druga biblioteka, Microsoft.CSharp.dll, nie jest częścią samej platformy.

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ę.

Wspomniana biblioteka odpowiada za wszystkie zadania specyficzne dla języka C#.


Na przykład jeśli zgłaszasz wywołanie metody, w którym jeden argument jest wartością
dynamiczną, to ta biblioteka wybiera przeciążoną wersję metody w czasie wykonywania
programu. Działa ona tak samo jak kompilator C# odpowiedzialny za wiązanie, ale robi
to w kontekście wszystkich dynamicznych interfejsów API.
Jeśli kiedyś w projekcie natrafisz na referencję do biblioteki Microsoft.CSharp.dll
i będziesz się zastanawiał, do czego została użyta, odpowiedzią jest typowanie dyna-
miczne. Jeżeli nigdzie nie korzystasz z typowania dynamicznego, możesz bezpiecznie
usunąć referencję do tej biblioteki. Gdy usuniesz tę referencję, ale używasz typowania
dynamicznego, nastąpi błąd kompilacji, ponieważ kompilator języka C# generuje
wywołania do tego podzespołu z tą biblioteką. Skoro już jesteśmy przy kodzie gene-
rowanym przez kompilator języka C#, przyjrzyjmy się takiemu kodowi.
KOD POŚREDNI GENEROWANY NA POTRZEBY TYPOWANIA DYNAMICZNEGO
Wróćmy do początkowego przykładu typowania dynamicznego, ale skróćmy go jeszcze
bardziej. Oto dwa pierwsze wiersze pokazanego dynamicznego kodu:
dynamic text = "Witaj, świecie";
string world = text.Substring(7);

Całkiem proste, prawda? Wykonywane są tu dwie operacje dynamiczne:


 wywołanie metody Substring,
 konwersja wyniku na typ string.

Listing 4.9 przedstawia zdekompilowaną wersję kodu wygenerowanego na podstawie


tych dwóch wierszy. Dla przejrzystości dodałem kontekst w postaci deklaracji klasy
i metody Main.

Listing 4.9. Wynik dekompilacji dwóch prostych operacji dynamicznych

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

public static CallSite<Func<CallSite, object, string>>


conversion;
}

static void Main()


{
object text = "Witaj, świecie";
if (CallSites.method == null) Tworzenie miejsca wywołania
{ metody, gdy jest to potrzebne.
CSharpArgumentInfo[] argumentInfo = new[]
{
CSharpArgumentInfo.Create(
CSharpArgumentInfoFlags.None, null),
CSharpArgumentInfo.Create(
CSharpArgumentInfoFlags.Constant |
CSharpArgumentInfoFlags.UseCompileTimeType,
null)
};
CallSiteBinder binder =
Binder.InvokeMember(CSharpBinderFlags.None, "Substring",
null, typeof(DynamicTypingDecompiled), argumentInfo);
CallSites.method =
CallSite<Func<CallSite, object, int, object>>.Create(binder);
}

if (CallSites.conversion == null) Tworzenie miejsca wywołania


{ konwersji, gdy jest to potrzebne.
CallSiteBinder binder =
Binder.Convert(CSharpBinderFlags.None, typeof(string),
typeof(DynamicTypingDecompiled));
CallSites.conversion =
CallSite<Func<CallSite, object, string>>.Create(binder);
}
object result = CallSites.method.Target(
Uruchomienie miejsca wywołania metody.
CallSites.method, text, 6);
Uruchomienie miejsca
string str = wywołania konwersji.
CallSites.conversion.Target(CallSites.conversion, result);
}
}

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.

4.1.4. Ograniczenia i niespodzianki


związane z typowaniem dynamicznym
Trudno jest dodać typowanie dynamiczne do języka, który od początku został zaprojek-
towany jako język ze statyczną kontrolą typów. Nie jest więc zaskoczeniem, że w nie-
których miejscach te dwa podejścia nie współdziałają dobrze ze sobą. Przygotowałem
listę wybranych aspektów typowania dynamicznego, która obejmuje ograniczenia
i potencjalne niespodzianki, na jakie możesz natrafić w czasie wykonywania programu.
Lista ta nie jest kompletna, jednak obejmuje większość często napotykanych problemów.
TYPY DYNAMICZNE I TYPY GENERYCZNE
Używanie typu dynamic z typami generycznymi może być ciekawe. Oto stosowane
w czasie kompilacji reguły określające, gdzie można używać typu dynamic:
 Nie można określić, że klasa implementuje interfejs, jeśli argumentem określa-
jącym typ dla tego interfejsu jest typ dynamic.
 Nie można używać typu dynamic w ograniczeniach typów.
 Dla klasy można podać klasę bazową z typem dynamic jako argumentem okre-
ślającym typ; dotyczy to także argumentów określających typ dla interfejsów.
 Można używać typu dynamic jako argumentu określającego typ dla interfejsów
w czasie tworzenia zmiennych.

Oto przykładowy nieprawidłowy kod:


class DynamicSequence : IEnumerable<dynamic>
class DynamicListSequence : IEnumerable<List<dynamic>>
class DynamicConstraint1<T> : IEnumerable<T> where T : dynamic
class DynamicConstraint2<T> : IEnumerable<T> where T : List<dynamic>

Jednak wszystkie poniższe instrukcje są poprawne:


class DynamicList : List<dynamic>
class ListOfDynamicSequences : List<IEnumerable<dynamic>>
IEnumerable<dynamic> x = new List<dynamic> { 1, 0.5 }.Select(x => x * 2);

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.10. Zapytanie LINQ dotyczące listy wartości dynamicznych

List<dynamic> source = new List<dynamic>


{
5,
2.75,
TimeSpan.FromSeconds(45)
};
IEnumerable<dynamic> query = source.Select(x => x * 2);
foreach (dynamic value in query)
{
Console.WriteLine(value);
}

Jedyne dynamiczne operacje to mnożenie (x * 2) i wybór wersji przeciążonej metody


Console.WriteLine. Wywołanie Select jest wiązane w standardowy sposób w czasie kom-
pilacji. Aby pokazać błędny przykład, należy zmienić typ zmiennej source na dynamic
i użyć prostszej operacji LINQ — Any(). Jeśli nadal będziesz używał wywołania Select,
natrafisz na inny problem, który zostanie opisany dalej. Na listingu 4.11 pokazane są
wprowadzone zmiany.

Listing 4.11. Próba wywołania metody rozszerzającej dla obiektu docelowego typu
dynamic

dynamic source = new List<dynamic>


{
5,
2.75,
TimeSpan.FromSeconds(45)
};
bool result = source.Any();

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);

To wywołanie jest wiązane w czasie wykonywania programu, ale tylko w zakresie


wyboru przeciążonej wersji metody.
FUNKCJE ANONIMOWE
Dla funkcji anonimowych obowiązują trzy ograniczenia. Dla uproszczenia tu przed-
stawiam je dla wyrażeń lambda.

87469504f326f0d7c1fcda56ef61bd79
8
162 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami

Po pierwsze metod anonimowych nie można przypisywać do zmiennych typu dynamic,


ponieważ kompilator nie wie wtedy, jakiego rodzaju delegat należy utworzyć. Jest to
akceptowalne, jeśli chcesz rzutować pośrednie zmienne z typowaniem statycznym
lub je stosować (a następnie kopiować ich wartość) i możliwe jest dynamiczne wywo-
ływanie delegata. Na przykład poniższy kod jest nieprawidłowy:
dynamic function = x => x * 2;
Console.WriteLine(function(0.75));

Jednak ta wersja jest poprawna i wyświetla wynik 1.5:


dynamic function = (Func<dynamic, dynamic>) (x => x * 2);
Console.WriteLine(function(0.75));

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.

Listing 4.12. Próba użycia elementów dynamicznego typu w interfejsie IQueryable<T>

List<dynamic> source = new List<dynamic>


{
5,
2.75,
TimeSpan.FromSeconds(45)
};

87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 163

IEnumerable<dynamic> query = source


.AsQueryable()
.Select(x => x * 2); Teraz ten wiersz się nie skompiluje.

Wynikiem wywołania AsQueryable() jest obiekt typu IQueryable<dynamic>. Używane jest


dla niego typowanie statyczne, jednak metoda Select przyjmuje drzewo wyrażenia
zamiast delegata. To oznacza, że wyrażenie lambda (x => x * 2) trzeba przekształcić na
drzewo wyrażenia, co jednak wymaga operacji dynamicznej, dlatego kompilacja zakoń-
czy się niepowodzeniem.
TYPY ANONIMOWE
Wspomniałem o tym problemie, gdy zaczynałem omawiać typy anonimowe. Warto
jeszcze raz opisać to zagadnienie — kompilator generuje typy anonimowe w kodzie
pośrednim jako zwykłe klasy. Poziom dostępu do tych klas to internal, dlatego nie
można ich używać spoza podzespołu, w którym są zadeklarowane. Zwykle nie stanowi
to problemu, ponieważ typy anonimowe przeważnie są używane tylko w jednej meto-
dzie. Jednak typowanie dynamiczne sprawia, że można wczytywać właściwości instancji
typów anonimowych, ale tylko pod warunkiem, że dany kod ma dostęp do wygene-
rowanej klasy. Na listingu 4.13 pokazana jest sytuacja, w której takie rozwiązanie jest
poprawne.

Listing 4.13. Dynamiczny dostęp do właściwości typu anonimowego

static void PrintName(dynamic obj)


{
Console.WriteLine(obj.Name);
}

static void Main()


{
var x = new { Name = "Abc" };
var y = new { Name = "Def", Score = 10 };
PrintName(x);
PrintName(y);
}

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

mechanizmem języka C# — jawnym implementowaniem interfejsów. Gdy jawnie


implementujesz interfejs, implementowana składowa jest dostępna tylko wtedy, gdy
używasz obiektu jako interfejsu, a nie jako określonego typu.
Łatwiej to pokazać niż opisać. Na listingu 4.14 jako przykład zastosowano typ List<T>.

Listing 4.14. Przykład jawnej implementacji interfejsu

List<int> list1 = new List<int>();


Console.WriteLine(list1.IsFixedSize); Błąd kompilacji.

IList list2 = list1;


Console.WriteLine(list2.IsFixedSize); Powodzenie — wyświetlenie false.

dynamic list3 = list1;


Console.WriteLine(list3.IsFixedSize); Błąd wykonania.

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.

4.1.5. Sugestie dotyczące użytkowania


Od razu powiem, że nie jestem zwolennikiem typowania dynamicznego. Nie pamiętam,
kiedy ostatnio zastosowałem tę technikę w kodzie produkcyjnym. Robię to zawsze
ostrożnie i po wielu testach poprawności oraz wydajności.
Jestem fanem typowania statycznego. Według mojego doświadczenia podejście to
ma cztery ważne zalety:
 Gdy popełniam błędy, zwykle wykrywam je wcześniej — w czasie kompilacji,
a nie w czasie wykonywania programu. Jest to ważne zwłaszcza w ścieżkach
kodu, których kompletne przetestowanie może być trudne.
 Niektóre edytory udostępniają uzupełnianie kodu. Nie ma to większego znaczenia,
jeśli chodzi o szybkość pisania, ale jest doskonałym sposobem na badanie tego, co
mogę zrobić w dalszej kolejności — zwłaszcza jeśli używam mało znanego mi typu.
Edytory dla języków dynamicznych oferują świetne narzędzia do uzupełniania
kodu, ale mają dostęp do mniejszej ilości informacji, dlatego nigdy nie będą
równie precyzyjne jak edytory dla języków z typowaniem statycznym.

87469504f326f0d7c1fcda56ef61bd79
8
4.1. Typowanie dynamiczne 165

 Zmusza mnie to do zastanowienia się nad interfejsem API, jaki udostępniam —


nad parametrami, typami zwracanych wartości itd. Gdy już zdecyduję, jakich
typów wartości kod ma przyjmować i zwracać, typy te stają się gotową doku-
mentacją. Wystarczy, że dodam komentarze dla elementów, które nie są oczywiste
(np. na temat przedziału akceptowanych wartości).
 Kod z typowaniem statycznym jest zwykle wydajniejszy w porównaniu z kodem
z typowaniem dynamicznym, ponieważ typowanie statyczne sprawia, że część
zadań jest wykonywana na etapie kompilacji, a nie w czasie wykonywania pro-
gramu. Nie chcę przeceniać tej kwestii, ponieważ nowe środowiska urucho-
mieniowe mają bardzo duże możliwości, jednak z pewnością warto uwzględnić
różnice w wydajności.
Jestem pewien, że fan typowania dynamicznego będzie potrafił przedstawić podobną
listę ważnych zalet tego podejścia. Ja jednak nie jestem właściwą do tego osobą. Podej-
rzewam, że takie zalety są bardziej widoczne w językach zaprojektowanych od początku
z myślą o typowaniu dynamicznym. C# jest językiem w większości typowanym statycz-
nie, a jego pochodzenie jest oczywiste. To dlatego występują wymienione wcześniej
przypadki brzegowe. Mimo to przedstawiam kilka wskazówek dotyczących tego, kiedy
możesz rozważyć zastosowanie typowania dynamicznego.
PROSTSZA REFLEKSJA
Załóżmy, że stosujesz refleksję, aby uzyskać dostęp do właściwości lub metody. Znasz
nazwę składowej w czasie kompilacji, ale z jakiegoś powodu nie możesz podać typu
statycznego. Znacznie łatwiej jest wykorzystać typowanie dynamiczne, aby zażądać
dostępu od bindera używanego w czasie wykonywania programu, niż uzyskać dostęp
bezpośrednio za pomocą interfejsu API mechanizmu refleksji. Korzyści są tym większe,
jeśli konieczne byłoby wykonanie wielu etapów refleksji. Rozważ np. następujący
fragment kodu:
dynamic value = ...;
value.SomeProperty.SomeMethod();

Refleksja wymagałaby wykonania następujących kroków:


1. Pobranie obiektu typu PropertyInfo na podstawie typu pierwotnej wartości.
2. Pobranie i zapisanie wartości tej właściwości.
3. Pobranie obiektu typu MethodInfo na podstawie typu wyniku zwróconego przez
właściwość.
4. Wykonanie metody dla tego wyniku.
Jeśli dodasz sprawdzanie, czy właściwość i metoda istnieją, powstanie cały blok kodu.
Efekt nie jest bezpieczniejszy niż pokazane wcześniej rozwiązanie dynamiczne, jest
natomiast mniej czytelny.
WSPÓLNE SKŁADOWE BEZ WSPÓLNEGO INTERFEJSU
Czasem znasz z góry wszystkie możliwe typy wartości i chcesz użyć dla każdego z nich
składowej o tej samej nazwie. Jeśli te typy implementują wspólny interfejs lub mają
wspólną klasę bazową, gdzie zadeklarowana jest ta składowa, to świetnie. Jednak nie

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.

4.2. Parametry opcjonalne i argumenty nazwane


Parametry opcjonalne i argumenty nazwane mają ograniczony zakres działania. Zwią-
zane są z podawaniem argumentów wywołania metody, konstruktora, indeksera lub
delegata. Parametry opcjonalne umożliwiają jednostce wywołującej całkowite pominię-

87469504f326f0d7c1fcda56ef61bd79
8
4.2. Parametry opcjonalne i argumenty nazwane 167

cie argumentu. Argumenty nazwane umożliwiają jednoznaczne poinformowanie kom-


pilatora i czytelnika o tym, z którym parametrem powiązany jest dany argument.
Zaczniemy od prostego przykładu, a następnie przejdziemy do szczegółów. W całym
podrozdziale omawiam tylko metody. Te same reguły dotyczą jednak wszystkich innych
składowych, w których mogą występować parametry.

4.2.1. Parametry o wartościach domyślnych i argumenty z nazwami


Na listingu 4.15 pokazano prostą metodę o trzech parametrach, z których dwa są
opcjonalne. Za pomocą kilku wywołań tej metody pokazane są różne mechanizmy.

Listing 4.15. Wywołanie metody z parametrami opcjonalnymi

static void Method(int x, int y = 5, int z = 10) Jeden parametr wymagany


{ i dwa opcjonalne.
Console.WriteLine("x={0}; y={1}; z={2}", x, y, z); Samo wyświetlanie wartości
} parametrów.
...
Method(1, 2, 3); x=1; y=2; z=3
Method(x: 1, y: 2, z: 3); x=1; y=2; z=3
Method(z: 3, y: 2, x: 1); x=1; y=2; z=3
Method(1, 2); x=1; y=2; z=10
Method(1, y: 2); x=1; y=2; z=10
Method(1, z: 3); x=1; y=5; z=3
Method(1); x=1; y=5; z=10
Method(x: 1); x=1; y=5; z=10

Na rysunku 4.2 pokazane są deklaracja metody i jedno wywołanie, co pozwala przej-


rzyście przedstawić terminologię.

Rysunek 4.2. Składnia


parametrów opcjonalnych
i wymaganych oraz argumentów
nazwanych i pozycyjnych

Składnia jest prosta:


 Wartość domyślną parametru można podać po jego nazwie, używając znaku
równości między nazwą a wartością. Każdy parametr z wartością domyślną jest
opcjonalny. Wszystkie parametry bez wartości domyślnej są wymagane. Para-
metry z modyfikatorami ref lub out nie mogą mieć wartości domyślnych.
 Przed wartością argumentu można podać jego nazwę. Należy wtedy umieścić dwu-
kropek między nazwą a wartością. Argument bez nazwy to argument pozycyjny.

87469504f326f0d7c1fcda56ef61bd79
8
168 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami

Wartość domyślna parametru musi być jednym z następujących wyrażeń:


 Stała z czasu kompilacji, np. literał liczbowy lub tekstowy albo null.
 Wyrażenie default, np. default(CancellationToken). W podrozdziale 14.5 wyja-
śniam, że w C# 7.1 wprowadzono literał default, dlatego można napisać default
zamiast default(CancellationToken).
 Wyrażenie new, np. new Guid() lub new CancellationToken(). Jest to dozwolone
tylko dla typów bezpośrednich.

Wszystkie parametry opcjonalne muszą znajdować się po wszystkich parametrach


wymaganych. Wyjątkiem są tablice parametrów, czyli parametry z modyfikatorem
params.

OSTRZEŻENIE. Choć możesz zadeklarować metodę z parametrem opcjonalnym, po którym


następuje tablica parametrów, wywoływanie takiej metody może być niewygodne. Zachęcam
do unikania tej techniki i nie wyjaśniam tu, jak przetwarzane są wywołania takich metod.

Tworzenie parametrów opcjonalnych ma umożliwiać jednostce wywołującej pomijanie


ich, jeśli potrzebna wartość jest taka sama jak domyślna. Zobacz teraz, jak kompilator
traktuje wywołania metod z parametrami domyślnymi i (lub) argumentami nazwanymi.

4.2.2. Określanie znaczenia wywołań metody


Jeśli zapoznasz się ze specyfikacją, zobaczysz, że proces określania tego, które argu-
menty są powiązane z poszczególnymi parametrami, to jeden z elementów wyboru
przeciążonej wersji metody. Proces ten jest też powiązany z wnioskowaniem typu.
Zagadnienie to jest nieoczekiwanie skomplikowane, dlatego w tym miejscu je uprosz-
czę. Skoncentruję się na sygnaturze jednej metody. Załóżmy, że została ona ustalona
w procesie wyboru przeciążonej wersji.
Obowiązujące reguły można łatwo wymienić:
 Wszystkie argumenty pozycyjne muszą znajdować się przed wszystkimi argu-
mentami nazwanymi. W C# 7.2 ta reguła została nieco rozluźniona, co opisane
jest w podrozdziale 14.6.
 Argumenty pozycyjne zawsze odpowiadają parametrom z analogicznych pozycji
z sygnatury metody. Pierwszy argument pozycyjny odpowiada pierwszemu para-
metrowi, drugi argument pozycyjny odpowiada drugiemu parametrowi itd.
 Argumenty nazwane są dopasowywane na podstawie nazw, a nie według pozycji.
Argument o nazwie x odpowiada parametrowi x. Argumenty nazwane można
podać w dowolnej kolejności.
 Każdemu parametrowi może odpowiadać tylko jeden argument. Nie można użyć
tej samej nazwy dla dwóch argumentów nazwanych. Nie można też zastosować
argumentu nazwanego dla parametru, dla którego już istnieje argument pozycyjny.
 Dla każdego parametru wymaganego trzeba podać argument z wartością.
 Dla parametrów opcjonalnych nie trzeba podawać argumentów. Wtedy kompi-
lator używa wartości domyślnej jako argumentu.

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)

Widać tu, że x jest parametrem wymaganym, ponieważ nie ma on wartości domyślnej.


Z kolei y i z to parametry opcjonalne. W tabeli 4.1 pokazanych jest kilka wywołań i ich
wyniki.
Tabela 4.1. Przykładowe wywołania metody z argumentami nazwanymi i parametrami opcjonalnymi

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

Te dwa wywołania różnią się wyłącznie kolejnością argumentów nazwanych. Wpływa


to jednak na wartości przekazywane do metody. W obu sytuacjach kod mógłby być bar-
dziej czytelny. Gdy efekty uboczne obliczania argumentów są istotne, zachęcam do
wykonywania tych operacji jako odrębnych instrukcji i przypisywania wyników do
nowych zmiennych lokalnych, które następnie są przekazywane bezpośrednio jako
argumenty metody:
int tmp3 = 0;
int argX = tmp3++;
int argY = tmp3++;
int argZ = tmp3++;
Method(x: argX, y: argY, z: argZ);

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.

4.2.3. Wpływ na wersjonowanie


Wersjonowanie publicznych interfejsów API w bibliotekach to poważny problem —
znacznie istotniejszy i mniej zrozumiały, niż lubimy przyznawać. Choć zgodnie z wer-
sjonowaniem semantycznym każda zmiana powodująca niezgodność wymaga utworzenia
nowej wersji głównej, to jeśli uwzględniasz mało prawdopodobne scenariusze, niemal
każda modyfikacja może prowadzić do niezgodności z jakimś kodem zależnym od biblio-
teki. Jednak parametry opcjonalne i argumenty nazwane powodują wyjątkowo duże
problemy w zakresie wersjonowania. Przyjrzyj się różnym aspektem tego problemu.
ZMIANY NAZW PARAMETRÓW POWODUJĄ NIEZGODNOŚĆ
Załóżmy, że istnieje biblioteka zawierająca omawianą wcześniej metodę, a metoda ta
jest publiczna:
public static Method(int x, int y = 5, int z = 10)

Teraz przyjmijmy, że chcesz zmodyfikować tę metodę i utworzyć nową wersję:


public static Method(int a, int b = 5, int c = 10)

Ta zmiana powoduje niezgodność. Kod, w którym w wywołaniach tej metody używano


nazwanych argumentów, przestanie działać, ponieważ wcześniejsze nazwy już nie ist-
nieją. Sprawdzaj nazwy parametrów równie starannie jak nazwy typów i składowych!
ZMIANY WARTOŚCI DOMYŚLNYCH SĄ CO NAJMNIEJ ZASKAKUJĄCE
Wspomniałem już, że wartości domyślne są kompilowane w wywołaniu w kodzie
pośrednim. Jeśli wywołanie i kod metody znajdują się w tym samym podzespole,
zmiana wartości domyślnej nie powoduje problemu. Jednak gdy wywołanie i metoda
są zapisane w różnych podzespołach, modyfikacja wartości domyślnej będzie widoczna
tylko po ponownym skompilowaniu kodu wywołania.
Nie zawsze stanowi to problem, a jeśli przewidujesz, że wartość domyślna może
ulec zmianie, uzasadnione może być opisanie tego w dokumentacji. Zmiana wartości
domyślnej z pewnością może jednak zaskoczyć niektórych programistów używających
Twojego kodu — zwłaszcza w sytuacji, gdy występują skomplikowane łańcuchy zależ-
ności. Jednym ze sposobów na uniknięcie problemu jest używanie specjalnej wartości
domyślnej, która zawsze oznacza: „Pozwól metodzie wybrać wartość w czasie wyko-
nywania programu”. Na przykład jeśli metoda przyjmuje parametr typu int, można użyć

87469504f326f0d7c1fcda56ef61bd79
8
4.2. Parametry opcjonalne i argumenty nazwane 171

wartości domyślnej Nullable<int>, gdzie wartość domyślna null oznacza, że metoda


wybierze wartość. Później można zmienić implementację metody tak, aby używana
była inna wartość. W każdym kodzie używającym nowej wersji stosowane będą wtedy
nowe operacje niezależnie od tego, czy kod zostanie ponownie skompilowany.
DODAWANIE PRZECIĄŻONYCH WERSJI JEST SKOMPLIKOWANE
Jeśli uważasz, że wybieranie sygnatur przeciążonej metody jest skomplikowane w sytu-
acji, gdy ich wersja jest tylko jedna, pomyśl o tym, że sytuacja staje się znacznie gorsza,
gdy próbujesz dodać nowe sygnatury przeciążonej metody bez spowodowania nie-
zgodności. Nowa wersja musi obejmować wszystkie pierwotne sygnatury metod, aby
uniknąć naruszenia zgodności plików binarnych. Ponadto wszystkie wywołania pier-
wotnych metod powinny w nowej wersji prowadzić do wyboru tych samych, a przy-
najmniej równoważnych sygnatur. To, czy parametr jest wymagany, czy opcjonalny, nie
jest częścią sygnatury. Zmiana parametru opcjonalnego na wymagany i w drugą stronę
nie skutkuje naruszeniem zgodności plików binarnych. Taka zmiana może jednak pro-
wadzić do naruszenia zgodności kodu źródłowego. Jeśli nie zachowasz ostrożności,
możesz łatwo spowodować wieloznaczność przy wyborze sygnatury przeciążonej metody,
jeśli dodasz nową sygnaturę z większą liczbą parametrów opcjonalnych.
Jeżeli dwie sygnatury są zgodne z procesem wyboru sygnatury przeciążonej metody
(obie są poprawne dla danego wywołania) i żadna z nich nie jest lepsza od drugiej, jeśli
chodzi o konwersję argumentów na parametry, decydować o wyborze mogą parametry
domyślne. Sygnatura, w której nie ma parametrów opcjonalnych bez podanych argu-
mentów, jest „lepsza” od metody z przynajmniej jednym parametrem opcjonalnym bez
podanego argumentu. Jednak sygnatura z jednym niepodanym parametrem nie jest
lepsza od sygnatury z dwoma takimi parametrami.
Jeśli możesz uniknąć dodawania nowych sygnatur przeciążonej metody z opcjo-
nalnymi parametrami, nie dodawaj ich. Najlepiej pamiętać o tej możliwości od początku
prac. Jednym ze wzorców programistycznych związanych z tworzeniem metod, które
mają dużo opcji, jest tworzenie klasy reprezentującej wszystkie takie opcje i pobieranie
obiektu tej klasy jako parametru opcjonalnego. Można wtedy dodawać opcje, umiesz-
czając nowe właściwości w takiej klasie. Nie wymaga to modyfikowania sygnatury
metody.
Mimo wszystkich opisanych zastrzeżeń zachęcam do stosowania parametrów
opcjonalnych, aby upraszczać kod wywołań metody w standardowych przypadkach.
Jestem też wielkim fanem doprecyzowywania wywołań za pomocą argumentów nazwa-
nych. Ma to znaczenie zwłaszcza w sytuacji, gdy wiele parametrów tego samego typu
można pomylić ze sobą. Na przykład zawsze używam argumentów nazwanych w wywo-
łaniach metody MessageBox.Show z technologii Windows Forms. Nie mogę zapamiętać,
czy najpierw należy podać nagłówek okna komunikatu, czy tekst. W trakcie pisania
kodu pomocny może być mechanizm IntelliSense, jednak w trakcie lektury programu
sytuacja nie zawsze jest oczywista, chyba że używane są argumenty nazwane:
MessageBox.Show(text: "To tekst", caption: "To nagłówek");

87469504f326f0d7c1fcda56ef61bd79
8
172 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami

Następny podrozdział dotyczy mechanizmu, z którego wielu czytelników nie musi


korzystać, a z którym inne osoby stykają się każdego dnia. Choć COM to pod wieloma
względami przestarzała technologia, nadal jest używana w dużej ilości kodu.

4.3. Usprawnienia w zakresie współdziałania


z technologią COM
Przed wprowadzeniem wersji C# 4 Visual Basic był lepszym językiem, jeśli potrzebne
było współdziałanie z technologią COM. Visual Basic zawsze był mniej ścisłym języ-
kiem, przynajmniej jeśli tego potrzebowałeś, i od początku udostępniał argumenty
nazwane i opcjonalne.
C# 4 znacznie uprościł pracę użytkownikom technologii COM. Jeśli jednak nie
używasz COM, nie stracisz nic ważnego, pomijając ten podrozdział. Żadna z omawia-
nych tu funkcji nie jest istotna poza COM.
UWAGA. COM to akronim od Component Object Model — technologii wprowadzonej przez
Microsoft w 1993 r. na potrzeby współdziałania różnych języków w systemie Windows.
Kompletne omówienie tej technologii wykracza poza zakres tej książki. Jeśli jednak chcesz
z niej korzystać, prawdopodobnie już ją znasz. Najczęściej używane biblioteki technologii COM
to zapewne te powiązane z pakietem Microsoft Office.

Zacznijmy od mechanizmu wykraczającego poza język, związanego głównie z instalacją,


ale wpływającego też na sposób udostępniania operacji.

4.3.1. Konsolidacja podzespołów PIA


Gdy używasz typu z technologii COM, używasz podzespołu wygenerowanego na
potrzeby biblioteki komponentów. Zwykle używasz wtedy podzespołu PIA (ang. primary
interop assembly) wygenerowanego przez firmę udostępniającą dany komponent. Możesz
użyć narzędzia tlbimp (Type Library Importer), aby wygenerować taki podzespół z wła-
snymi bibliotekami COM.
Przed wersją C# 4 na komputerze, w którym uruchamiany był kod, dostępny musiał
być kompletny komponent PIA. Ponadto musiał on mieć tę samą wersję, której użyto
w trakcie kompilacji. To wymagało albo udostępniania komponentu PIA razem z apli-
kacją, albo wiary w to, że odpowiednia wersja jest już zainstalowana.
Od wersji C# 4 i środowiska Visual Studio 2010 można dowiązać komponent PIA
zamiast dodawać referencje do niego. W środowisku Visual Studio na stronie właściwości
referencji należy wtedy użyć opcji Osadź typy międzyoperacyjne.
Gdy ta opcja jest włączona, odpowiednie części komponentu PIA są dodawane bez-
pośrednio do podzespołu. Dołączane są tylko fragmenty używane w danej aplikacji.
W trakcie działania kodu nie ma znaczenia, czy na maszynie klienckiej dostępna jest
dokładnie ta sama wersja komponentu, której użyto w trakcie kompilacji. Ważne jest,
aby dostępny był cały kod potrzebny aplikacji. Na rysunku 4.3 pokazano różnicę
między podawaniem referencji (dawna metoda) a konsolidacją (nowa technika) w zakre-
sie działania kodu.

87469504f326f0d7c1fcda56ef61bd79
8
4.3. Usprawnienia w zakresie współdziałania z technologią COM 173

Rysunek 4.3. Porównanie podawania


referencji i konsolidowania

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.

Listing 4.16. Zapisywanie wartości komórek z podanego zakresu w programie Excel


z użyciem niejawnej konwersji wartości dynamicznej

var app = new Application { Visible = true };


app.Workbooks.Add();
Worksheet sheet = app.ActiveSheet;
Range start = sheet.Cells[1, 1];
Range end = sheet.Cells[1, 20];
sheet.Range[start, end].Value = Enumerable.Range(1, 20).ToArray();

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.

4.3.2. Parametry opcjonalne w COM


Niektóre metody z technologii COM przyjmują wiele parametrów. Często wszystkie
te parametry są typu ref. To oznacza, że w wersjach starszych niż C# 4 prosta operacja
zapisywania pliku w edytorze Word była niezwykle pracochłonna, co ilustruje listing 4.17.

Listing 4.17. Tworzenie dokumentu w edytorze Word i zapisywanie go w wersjach


starszych niż C# 4

object missing = Type.Missing; Zmienna tymczasowa na parametry ref.

Application app = new Application { Visible = true }; Uruchamianie edytora Word.


Document doc = app.Documents.Add
ref missing, ref missing,
Tworzenie dokumentu i dodawanie
ref missing, ref missing);
do niego treści.
Paragraph para = doc.Paragraphs.Add(ref missing);
para.Range.Text = "Dziwny dawny kod";

object fileName = "demo1.docx";


doc.SaveAs2(ref fileName, ref missing,
ref missing, ref missing, ref missing,
ref missing, ref missing, ref missing, Zapisywanie dokumentu.
ref missing, ref missing, ref missing,
ref missing, ref missing, ref missing,
ref missing, ref missing);

doc.Close(ref missing, ref missing, ref missing);


app.Application.Quit( Zamykanie edytora Word.
ref missing, ref missing, ref missing);

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

 Parametry ref można tworzyć jako opcjonalne, a następnie pomijać w kodzie


wywołań. Jako wartość domyślna używana jest wartość Type.Missing. Ten punkt
dotyczy tylko bibliotek COM.

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.

Listing 4.18. Tworzenie i zapisywanie dokumentu za pomocą edytora Word w C# 4

Application app = new Application { Visible = true };


Document doc = app.Documents.Add(); Parametry opcjonalne zostały
Paragraph para = doc.Paragraphs.Add(); wszędzie pominięte.
para.Range.Text = "Prosty nowy kod";

doc.SaveAs2(FileName: "demo2.docx"); Dla przejrzystości używany jest


nazwany argument.
doc.Close();
app.Application.Quit();

Różnica w czytelności jest niezwykle duża. Wyeliminowano wszystkich 20 wystąpień


ref missing, a także samą zmienną. Argument przekazywany do SaveAs2 odpowiada
pierwszemu parametrowi tej metody. Dlatego można by zastosować argument pozy-
cyjny zamiast nazwanego, jednak podanie nazwy zwiększa przejrzystość kodu. Jeśli
zechcesz podać wartość jednego z dalszych parametrów, możesz użyć do tego nazwy;
nie musisz przy tym podawać wartości wszystkich pośrednich parametrów.
Argument metody SaveAs2 ilustruje też automatyczne tworzenie parametrów typu
ref. Zamiast deklarować zmienną o wartości demo2.docx, a następnie przekazywać tę
wartość przez referencję, można przekazać ją w kodzie źródłowym bezpośrednio.
Kompilator sam przekształci ją na parametr typu ref. Ostatni mechanizm związany
z technologią COM dotyczy kolejnego aspektu, w którym Visual Basic oferuje nieco
większe możliwości niż C#.

4.3.3. Indeksery nazwane


Indeksery są dostępne w C# od zawsze. Są używane przede wszystkim do kolekcji —
np. do pobierania elementów z listy za pomocą indeksu lub pobierania wartości ze
słownika z użyciem klucza. Jednak w C# indeksery nigdy nie mają nazw w kodzie
źródłowym. Można napisać tylko indekser domyślny dla danego typu. Możesz podać
nazwę indeksera przy użyciu atrybutu i będzie ona używana w innych językach, jednak
C# nie pozwala rozróżniać indekserów na podstawie nazw. A przynajmniej było tak
do wersji C# 4.
W innych językach można pisać i stosować indeksery nazwane. Dlatego można
uzyskać dostęp do różnych aspektów obiektu za pomocą indeksów, używając nazwy,
aby jednoznacznie określić potrzebne dane. W C# nadal jest to niemożliwe w standar-
dowym kodzie na platformę .NET, zrobiono jednak wyjątek na potrzeby typów COM.
Łatwiej będzie przedstawić to za pomocą przykładu.
Typ Application w edytorze Word udostępnia indekser nazwany typu SynonymInfo.
Oto deklaracja takiego indeksera:

87469504f326f0d7c1fcda56ef61bd79
8
176 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami

SynonymInfo SynonymInfo[string Word, ref object LanguageId = Type.Missing]

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.

Listing 4.19. Dostęp do indeksera nazwanego

Application app = new Application { Visible = false };

object missing = Type.Missing; Dostęp do synonimów


SynonymInfo info = app.get_SynonymInfo("method", ref missing); przed wersją C# 4.
Console.WriteLine("'method' ma {0} znaczeń", info.MeaningCount);

info = app.SynonymInfo["index"]; Prostszy kod z użyciem


Console.WriteLine("'index' ma {0} znaczeń", info.MeaningCount); indeksera nazwanego.

Na listingu 4.19 widać, że parametry opcjonalne można stosować zarówno w indekse-


rach nazwanych, jak i w zwykłych wywołaniach metod. W kodzie sprzed wersji C# 4
trzeba było zadeklarować zmienną i przekazać ją przez referencję do metody o dziw-
nej nazwie. W C# 4 można zastosować indekser za pomocą nazwy i pominąć argu-
ment powiązany z drugim parametrem.
Był to krótki przegląd mechanizmów języka C# 4 związanych z COM. Mam nadzieję,
że korzyści płynące z wprowadzenia tych mechanizmów są oczywiste. Choć nie korzy-
stam regularnie z COM, przedstawione tu zmiany sprawiają, że byłoby mi łatwiej
używać tej technologii, gdybym w przyszłości był do tego zmuszony. Zakres korzyści
zależy od struktury biblioteki COM, której używasz. Na przykład jeśli stosowanych jest
dużo parametrów ref i zwracanych wartości typu VARIANT, ułatwienie będzie większe
niż w sytuacji, gdy biblioteka zawiera niewiele parametrów i zwraca wartości typów
konkretnych. Jednak już sama możliwość konsolidacji komponentów PIA sprawia, że
instalowanie oprogramowania jest znacznie łatwiejsze.
Zbliżamy się do końca omawiania C# 4. Ostatni mechanizm może być trudny do
zrozumienia, jednak możesz korzystać z niego bez myślenia o tym.

4.4. Wariancja generyczna


Wariancja generyczna jest łatwiejsza do pokazania niż opisania. Chodzi tu o bezpieczne
przekształcanie między typami generycznymi na podstawie argumentów określających
typ, przy czym trzeba zwrócić baczną uwagę na kierunek przepływu danych.

4.4.1. Proste przykłady zastosowania wariancji


Zacznijmy od przykładu zastosowania znanego Ci już interfejsu IEnumerable<T>. Repre-
zentuje on sekwencję elementów typu T. Zrozumiałe jest, że dowolna sekwencja łań-
cuchów znaków jest też sekwencją obiektów. Wariancja dopuszcza tę możliwość:
IEnumerable<string> strings = new List<string> { "a", "b", "c" };
IEnumerable<object> objects = strings;

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.

UWAGA. W przykładach konsekwentnie używam typów string i object, ponieważ są to


klasy znane wszystkim programistom języka C# i niepowiązane z żadnym konkretnym kontek-
stem. Przykłady dotyczą jednak także innych klas z tą samą relacją klasa bazowa – klasa
pochodna.

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

Na podstawie tych przykładów można zdefiniować kilka pojęć:


 Kowariancja ma miejsce, gdy wartości są zwracane tylko jako dane wyjściowe.
 Kontrawariancja zachodzi, gdy wartości są przyjmowane tylko jako dane wejściowe.
 Inwariancja dotyczy wartości używanych jako dane wejściowe i wyjściowe.

Te definicje na razie celowo są nieco niejasne. Dotyczą w większym stopniu ogólnych


zagadnień niż języka C#. Zostaną one doprecyzowane po omówieniu składni języka C#
związanej z wariancją.

4.4.2. Składnia wariancji w deklaracjach interfejsów i delegatów


Pierwszą cechą wariancji w C# jest to, że można ją stosować tylko dla interfejsów
i delegatów. Nie możesz np. posłużyć się wariancją do klas lub struktur. Po drugie wa-
riancja jest definiowana niezależnie dla każdego parametru określającego typ. Choć
potocznie można stwierdzić, że typ „IEnumerable<T> jest kowariantny”, bardziej precy-
zyjne jest określenie, że „IEnumerable<T> jest kowariantny ze względu na T”. Wynika
z tego składnia deklaracji interfejsów i delegatów, gdzie każdy parametr określający typ
ma odrębny modyfikator. Oto deklaracje interfejsów IEnumerable<T> i IList<T> oraz dele-
gata Action<T>:
public interface IEnumerable<out T>
public delegate void Action<in T>
public interface IList<T>

Widać tu, że modyfikatory in i out wpływają na rodzaj wariancji parametrów określają-


cych typ:
 dla parametru określającego typ z modyfikatorem out używana jest wariancja,
 dla parametru określającego typ z modyfikatorem in używana jest kontrawa-
riancja,
 dla parametru określającego typ bez modyfikatorów używana jest inwariancja.

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)

W codziennej pracy zapewne częściej będziesz używać istniejących interfejsów i dele-


gatów z wariancją niż je deklarować. Jeśli chodzi o dozwolone argumenty określające
typ, występują tu pewne ograniczenia. Są one opisane w następnym punkcie.

4.4.3. Ograniczenia dotyczące wariancji


Warto przypomnieć wcześniejszy punkt — wariancję można stosować tylko do inter-
fejsów i delegatów. Wariancja nie jest dziedziczona w klasach i strukturach z imple-
mentacją takich interfejsów. Klasy i struktury zawsze są inwariantne. Załóżmy, że
chcesz utworzyć następującą klasę:
public class SimpleEnumerable<T> : IEnumerable<T> Modyfikator out jest tu
{ niedozwolony.
Implementacja.
}

Nie pozwala to przeprowadzać konwersji z typu SimpleEnumerable<string> na Simple


Enumerable<object>. Mógłbyś jednak przekształcić wartość typu SimpleEnumerable
<string> na IEnumerable<object>, wykorzystując kowariancję interfejsu IEnumerable<T>.
Przyjmijmy, że używasz delegata lub interfejsu z kowariantnymi lub kontrawa-
riantnymi parametrami określającymi typ. Jakie konwersje są dostępne? Do wyja-
śnienia reguł potrzebne będą definicje:
 Konwersja z użyciem wariancji to konwersja kowariantna.
 Konwersja kowariantna jest jedną z odmian konwersji referencyjnej. Konwersja
referencyjna cechuje się tym, że nie modyfikuje danej wartości (zawsze jest nią
referencja); zmieniany jest jedynie typ z czasu kompilacji.
 Konwersja tożsamościowa to przekształcenie jednego typu na ten sam typ
z perspektywy środowiska CLR. Z perspektywy języka C# mogą to być te same
typy (np. konwersja z typu string na string) lub różne typy (np. konwersja
z typu object na dynamic).

Załóżmy, że chcesz przekształcić wartość typu IEnumerable<A> na typ IEnumerable<B> dla


danych argumentów określających typ A i B. Jest to dozwolone, jeśli istnieje konwersja
tożsamościowa lub niejawna konwersja referencyjna z A na B. Poprawne są np. nastę-
pujące konwersje:
 Z typu IEnumerable<string> na typ IEnumerable<object>, ponieważ możliwa jest
niejawna konwersja referencyjna z klasy na klasę bazową (lub klasę bazową klasy
bazowej itd.).
 Z typu IEnumerable<string> na typ IEnumerable<IConvertible>, ponieważ możliwa
jest niejawna konwersja referencyjna z klasy na każdy zaimplementowany w niej
interfejs.

87469504f326f0d7c1fcda56ef61bd79
8
180 ROZDZIAŁ 4. Zwiększanie współdziałania z innymi technologiami

 Z typu IEnumerable<IDisposable> na typ IEnumerable<object>, ponieważ możliwa


jest niejawna konwersja referencyjna z dowolnego typu referencyjnego na typy
object i dynamic.

Z kolei te konwersje są niedozwolone:


 Z typu IEnumerable<object> na typ IEnumerable<string>; możliwa jest jawna kon-
wersja referencyjna z typu object na string, ale nie istnieje niejawna konwersja
tego rodzaju.
 Z typu IEnumerable<string> na typ IEnumerable<Stream>; te klasy nie są powiązane
ze sobą.
 Z typu IEnumerable<int> na typ IEnumerable<IConvertible>; możliwa jest niejawna
konwersja z typu int na IConvertible, jest to jednak konwersja z opakowywaniem,
a nie konwersja referencyjna.
 Z typu IEnumerable<int> na typ IEnumerable<long>; możliwa jest niejawna kon-
wersja z typu int na long, jest to jednak konwersja liczbowa, a nie referencyjna.

Widać tu, że wymóg, zgodnie z którym konwersja między argumentami określającymi


typ musi być referencyjna lub tożsamościowa, wpływa na typy w nieraz zaskakujący
sposób.
W tym przykładzie typ IEnumerable<T> ma tylko jeden argument określający typ. A co
dzieje się, gdy takich argumentów jest więcej? Są one sprawdzane parami, aby zagwa-
rantować, że każda konwersja jest odpowiednia dla parametrów określających typ.
Oto bardziej formalne ujęcie — dana jest deklaracja typu generycznego z n para-
metrami określającymi typ: T<X1, …, Xn>. Konwersja z typu T<A1, …, An> na T<B1, …, Bn>
jest analizowana kolejno dla każdego parametru określającego typ i każdej pary argu-
mentów określających typ. Dla każdego i od 1 do n:
 Jeśli Xi jest kowariantny, musi istnieć konwersja tożsamościowa lub niejawna
konwersja referencyjna z Ai na Bi.
 Jeśli Xi jest kontrawariantny, musi istnieć konwersja tożsamościowa lub niejawna
konwersja referencyjna z Bi na Ai.
 Jeśli Xi jest inwariantny, musi istnieć konwersja tożsamościowa z Ai na Bi.

Aby przedstawić to na konkretnym przykładzie, rozważmy typ Func<in T, out TResult>.


Zgodnie z regułami:
 Dozwolona jest konwersja z typu Func<object, int> na Func<string, int>,
ponieważ:
 pierwszy parametr określający typ jest kontrawariantny i istnieje niejawna
konwersja referencyjna z typu string na object,
 drugi parametr określający typ jest kowariantny i istnieje konwersja tożsa-
mościowa z typu int na int.
 Dozwolona jest konwersja z typu Func<dynamic, string> na Func<object,
IConvertible>, ponieważ:

87469504f326f0d7c1fcda56ef61bd79
8
4.4. Wariancja generyczna 181

 pierwszy parametr określający typ jest kontrawariantny i istnieje konwersja


tożsamościowa z typu dynamic na object,
 drugi parametr określający typ jest kowariantny i istnieje niejawna konwersja
referencyjna z typu string na IConvertible.
 Niedozwolona jest konwersja z typu Func<string, int> na Func<object, int>,
ponieważ:
 pierwszy parametr określający typ jest kontrawariantny i nie istnieje niejawna
konwersja referencyjna z typu object na string,
 drugi parametr określający typ nie ma znaczenia; konwersja jest niepoprawna
z powodu pierwszego parametru.

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.

4.4.4. Wariancja generyczna w praktyce


Nieraz możesz korzystać z wariancji generycznej, nawet nie wiedząc, że się nią posłu-
gujesz. Kod działa wtedy tak, jak tego oczekujesz. Nie jest konieczne, abyś wiedział,
że korzystasz z wariancji generycznej. Chcę jednak przedstawić kilka przykładów,
w których wariancja generyczna jest przydatna.
Rozważmy najpierw technologię LINQ i typ IEnumerable<T>. Załóżmy, że chcesz
wykonać zapytanie dotyczące łańcuchów znaków, ale uzyskać obiekt typu List<object>
zamiast List<string>. Możliwe, że zamierzasz później dodać do listy inne elementy.
Listing 4.20 pokazuje, że przed wprowadzeniem kowariancji najprostszym rozwiązaniem
było dodatkowe wywołanie Cast.

Listing 4.20. Tworzenie kolekcji typu List<object> na podstawie zapytania o łańcuchy


znaków; wersja bez wariancji

IEnumerable<string> strings = new[] { "a", "b", "cdefg", "hij" };


List<object> list = strings
.Where(x => x.Length > 1)
.Cast<object>()
.ToList();

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

Listing 4.21. Tworzenie kolekcji typu List<object> na podstawie zapytania o łańcuchy


znaków; wersja z wariancją

IEnumerable<string> strings = new[] { "a", "b", "cdefg", "hij" };


List<object> list = strings
.Where(x => x.Length > 1)
.ToList<object>();

To rozwiązanie działa, ponieważ dane wyjściowe wywołania Where są typu IEnumerable


<string>, a żądasz od kompilatora, by traktował dane wejściowe wywołania ToList()
jako typu IEnumerable<object>. Jest to dozwolone dzięki wariancji.
Zauważyłem, że kontrawariancja jest przydatna w połączeniu z interfejsem
IComparer<T>. Jest to interfejs służący do porównań wartości różnych typów na potrzeby
sortowania. Załóżmy, że istnieje klasa bazowa Shape z właściwością Area, a także klasy
pochodne Circle i Rectangle. Możesz napisać klasę AreaComparer z implementacją inter-
fejsu IComparer<Shape>. Taką klasę można zastosować do sortowania elementów kolekcji
List<Shape> w miejscu przy użyciu wywołania List<T>.Sort(). Jak jednak posortować
kolekcję typu List<Circle> lub List<Rectangle>? Przed wprowadzeniem wariancji gene-
rycznej istniały różne rozwiązania, ale na listingu 4.22 pokazane jest, jak proste jest
obecne rozwiązanie.

Listing 4.22. Sortowanie kolekcji typu List<Circle> przy użyciu interfejsu


IComparer<Shape>

List<Circle> circles = new List<Circle>


{
new Circle(5.3),
new Circle(2),
new Circle(10.5)
};
circles.Sort(new AreaComparer());
foreach (Circle circle in circles)
{
Console.WriteLine(circle.Radius);
}

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

Asynchroniczność od lat utrudnia życie programistom. Jest przydatna, ponieważ pozwala


uniknąć blokowania wątku w oczekiwaniu na zakończenie jakiegoś zadania, ale jej
poprawne zaimplementowanie jest trudne.
Nawet w platformie .NET (która wciąż jest stosunkowo nowa) dostępne są trzy
modele, które mają upraszczać asynchroniczność:
 Podejście z wywołaniami Begin…/End… z .NET 1.x, gdzie do przekazywania wyni-
ków używane są interfejs IAsyncResult i metoda AsyncCallback.
 Oparty na zdarzeniach wzorzec operacji asynchronicznych z .NET 2.0, imple-
mentowany z użyciem typów BackgroundWorker i WebClient.
 Technologia TPL (ang. Task Parallel Library) wprowadzona w .NET 4.0 i roz-
budowana w .NET 4.5.

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

W tym rozdziale koncentruję się na asynchroniczności z perspektywy „zwykłego


programisty”. Dzięki temu będziesz mógł używać mechanizmu async/await bez koniecz-
ności zrozumienia wszystkich szczegółów. W rozdziale 6. znajdziesz dużo dokładniejsze
omówienie szczegółów implementacji. Uważam, że będziesz lepszym programistą,
jeśli zrozumiesz, co dzieje się na zapleczu. Z pewnością jednak możesz wykorzystać
wiedzę z tego rozdziału i skutecznie korzystać z mechanizmu async/await, zanim poznasz
go na bardziej szczegółowym poziomie. Nawet w tym rozdziale będziesz poznawał tę
technikę stopniowo. W kolejnych punktach znajdziesz coraz więcej szczegółów.

5.1. Wprowadzenie do funkcji asynchronicznych


Na razie napisałem, że wersja C# 5 uprościła pisanie kodu asynchronicznego, jednak
przedstawiłem tylko króciutki opis służących do tego mechanizmów. Pora to naprawić,
a następnie przyjrzeć się przykładowi.
W C# 5 wprowadzono funkcje asynchroniczne. Są to funkcje anonimowe lub
metody zadeklarowane z użyciem modyfikatora async. W funkcjach asynchronicznych
można używać operatora await do tworzenia wyrażeń await.

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.

5.1.1. Bliskie spotkania asynchronicznego stopnia


Zacznijmy od czegoś prostego, aby zademonstrować asynchroniczność w praktyczny
sposób. Programiści często narzekają na opóźnienie sieci powodujące opóźnienia
w aplikacjach. Jednak opóźnienie pozwala łatwo pokazać, dlaczego asynchroniczność
jest tak istotna — zwłaszcza gdy używana jest platforma do tworzenia graficznych
interfejsów użytkownika (ang. graphical user interface — GUI) taka jak Windows Forms.
Pierwszy przykład (zobacz listing 5.1) to prosta aplikacja Windows Forms, która pobiera
tekst ze strony poświęconej tej książce i wyświetla w etykiecie długość kodu HTML
wspomnianej strony.

Listing 5.1. Asynchroniczne wyświetlanie długości strony

public class AsyncIntro : Form


{
private static readonly HttpClient client = new HttpClient();
private readonly Label label;
private readonly Button button;

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);
}

async void DisplayWebSiteLength(object sender, EventArgs e)


{
label.Text = "Pobieranie...";
string text = await client.GetStringAsync(
Rozpoczynanie pobierania strony.
"http://csharpindepth.com");
label.Text = text.Length.ToString(); Aktualizowanie interfejsu użytkownika.
}

static void Main()


{
Application.Run(new AsyncIntro()); Punkt wejścia (tylko uruchamia formularz).
}
}

W pierwszej części kod tworzy interfejs użytkownika i łączy go w prosty sposób


z metodą obsługi zdarzeń przycisku. Ciekawa jest tu metoda DisplayWebSiteLength. Gdy
klikniesz przycisk, pobierany jest tekst strony, a etykieta jest aktualizowana, aby wyświe-
tlała długość kodu HTML (liczbę znaków).

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.

Mógłbym napisać krótszy przykładowy program konsolowy, mam jednak nadzieję, że


listing 5.1 jest bardziej przekonującym przykładem. Jeśli usuniesz kontekstowe słowa
kluczowe async i await, zmienisz typ HttpClient na WebClient i GetStringAsync na Download
String, kod nadal się skompiluje i będzie działał, jednak interfejs użytkownika będzie
zablokowany na czas pobierania zawartości strony. Jeżeli uruchomisz wersję asynchro-
niczną (najlepiej z użyciem wolnego połączenia sieciowego), zobaczysz, że interfejs
użytkownika reaguje na działania użytkownika. W czasie pobierania strony wciąż
możesz poruszać oknem.

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.

Większość programistów zna dwie podstawowe reguły korzystania z wątków w Win-


dows Forms:
 Nie należy wykonywać żadnych czasochłonnych operacji w wątku interfejsu
użytkownika.
 Nie należy operować kontrolkami interfejsu użytkownika poza wątkiem inter-
fejsu użytkownika.

Możliwe, że uważasz Windows Forms za przestarzałą technologię, jednak dla większości


platform do tworzenia interfejsów GUI obowiązują te same reguły (łatwiej je przed-
stawić niż ich przestrzegać). W ramach ćwiczenia możesz spróbować na kilka sposobów
napisać kod podobny do listingu 5.1, ale bez użycia mechanizmu async/await. W tym
bardzo prostym przykładzie oparta na zdarzeniach metoda WebClient.DownloadString
Async nie jest złym rozwiązaniem, jednak gdy potrzebny będzie bardziej złożony prze-
pływ sterowania (z obsługą błędów, oczekiwaniem na wiele stron itd.), starszy kod
szybko stanie się trudny w konserwacji, natomiast kod z wersji C# 5 będzie można
modyfikować w naturalny sposób.
Na tym etapie metoda DisplayWebSiteLength może wydawać się magią. Wiesz już,
że robi to, czego potrzebujesz, nie masz jednak pojęcia, jak to robi. Pora na prostą
analizę tego kodu. Skomplikowane szczegóły zostawiam na później.

5.1.2. Analiza pierwszego przykładu


Zacznijmy od drobnego rozbudowania metody. Na listingu 5.1 wywołanie await jest
używane bezpośrednio do wartości zwracanej przez metodę HttpClient.GetStringAsync.
Można jednak rozdzielić wywołanie od oczekiwania na wynik:
async void DisplayWebSiteLength(object sender, EventArgs e)
{
label.Text = "Fetching...";
Task<string> task = client.GetStringAsync("http://csharpindepth.com");
string text = await task;
label.Text = text.Length.ToString();
}

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

wykonywane w wątku interfejsu użytkownika. Jednak w czasie oczekiwania na pobra-


nie strony internetowej interfejs użytkownika najwyraźniej nie jest zablokowany.
Sztuczka polega na tym, że metoda zwraca sterowanie bezpośrednio po dojściu do
wyrażenia await. Do tego miejsca wykonuje kod synchronicznie w wątku interfejsu
użytkownika, podobnie jak każda inna metoda obsługi zdarzeń. Jeśli w pierwszym
wierszu umieścisz punkt przerwania i dojdziesz do niego w debugerze, zobaczysz, że
ze śladu stosu wynika, iż przycisk zgłasza zdarzenie Click (i wywołuje metodę Button.
OnClick). Po dojściu do słowa await kod sprawdza, czy wynik jest już dostępny. Jeśli
wynik jest niedostępny (co prawie na pewno będzie prawdą), kod planuje wykonanie
kontynuacji po zakończeniu operacji w internecie. W tym przykładzie kontynuacja
obejmuje resztę metody, co w praktyce oznacza przejście na koniec wyrażenia await.
Kontynuacja jest wykonywana w wątku interfejsu użytkownika, co jest potrzebne do
modyfikowania tego interfejsu.

DEFINICJA. Kontynuacja jest wywołaniem zwrotnym wykonywanym po zakończeniu operacji


asynchronicznej (lub dowolnego zadania typu Task). W metodzie asynchronicznej kontynuacja
zachowuje stan metody. Podobnie jak domknięcie zachowuje środowisko ze zmiennymi, kon-
tynuacja zapamiętuje punkt, do którego dotarł kod, dlatego po uruchomieniu może wznowić
pracę od tego miejsca. Klasa Task udostępnia specjalną metodę do dodawania kontynuacji:
Task.ContinueWith.

Jeśli umieścisz punkt przerwania po wyrażeniu await i ponownie uruchomisz kod, to


przy założeniu, że wyrażenie await musiało zaplanować wykonanie kontynuacji, zoba-
czysz, że w śladzie stosu nie ma już metody Button.OnClick. Ta metoda dawno zakoń-
czyła pracę. Stos wywołań to teraz pusta pętla zdarzeń Windows Forms z kilkoma
warstwami infrastruktury do obsługi wywołań asynchronicznych. Taki stos wywołań jest
podobny jak po wywołaniu Control.Invoke w wątku tła w celu odpowiedniego zaktuali-
zowania interfejsu użytkownika, jednak wszystko dzieje się automatycznie. Początkowo
może Cię niepokoić, że stos wywołań tak bardzo się zmienia, jest to jednak bezwzględ-
nie konieczne do skutecznego działania asynchroniczności.
Kompilator osiąga pożądany efekt, tworząc skomplikowaną maszynę stanową. Jest
to szczegół implementacji opisany w rozdziale 6. Na razie skoncentruję się na możli-
wościach oferowanych przez mechanizm async/await. Najpierw potrzebny jest bardziej
konkretny opis tego, co próbujesz osiągnąć i co jest dostępne w języku.

5.2. Myślenie o asynchroniczności


Jeśli poprosisz programistę o opisanie asynchronicznego wykonywania kodu, możliwe,
że usłyszysz wykład o wielowątkowości. Choć jest to ważny aspekt typowego wyko-
rzystania asynchroniczności, wielowątkowość nie jest niezbędna do asynchronicznego
wykonywania kodu. Aby w pełni docenić działanie asynchroniczności w C# 5, najlepiej
jest pominąć kwestię wątków i wrócić do podstaw.

87469504f326f0d7c1fcda56ef61bd79
8
5.2. Myślenie o asynchroniczności 191

5.2.1. Podstawy asynchronicznego wykonywania kodu


Asynchroniczność leży u podstawy modelu wykonywania kodu znanego programistom
języka C#. Przyjrzyj się prostemu kodowi:
Console.WriteLine("Pierwsze");
Console.WriteLine("Drugie");

Można oczekiwać, że pierwsze wywołanie zostanie zakończone, po czym uruchomione


zostanie drugie wywołanie. Wykonanie przechodzi po kolei od jednej instrukcji do
drugiej. Jednak asynchroniczny model wykonywania kodu nie działa w ten sposób.
Zamiast tego używane są w nim kontynuacje. Gdy kod rozpoczyna jakieś zadanie,
określane jest, co ma się stać po zakończeniu tego zadania. Możliwe, że zetknąłeś się
z określeniem wywołanie zwrotne oznaczającym podobną ideę. Jednak wywołanie
zwrotne to bardziej ogólne pojęcie. W kontekście przetwarzania asynchronicznego
używam tej nazwy do wywołań zwrotnych zachowujących stan programu, a nie do
dowolnych wywołań zwrotnych służących do innych celów (np. do metod obsługi
zdarzeń z interfejsu GUI).
Kontynuacje są zwykle reprezentowane w .NET w formie delegatów i przeważnie
określają operacje, do których przekazywane są wyniki operacji asynchronicznej. To
dlatego w celu używania metod asynchronicznych w klasie WebClient w wersjach star-
szych niż C# 5 trzeba było skonfigurować różne zdarzenia, aby określić, jaki kod ma
być wykonywany w przypadku powodzenia operacji, niepowodzenia itd. Problem
polega na tym, że tworzenie wszystkich delegatów na potrzeby skomplikowanej sekwen-
cji kroków jest bardzo skomplikowane — nawet wtedy, gdy używane są wyrażenia
lambda. Jeszcze trudniejsze jest zapewnienie poprawnej obsługi błędów. (Gdy mam
dobry dzień, jestem prawie pewien, że w ręcznie pisanym kodzie asynchronicznym
ścieżki związane z udanym wykonywaniem operacji są poprawne. Zwykle mam mniej
pewności co do poprawności reagowania na niepowodzenia).
Wszystkim, co robi słowo kluczowe await w C#, jest żądanie od kompilatora utwo-
rzenia kontynuacji. Jak na zagadnienie, które można opisać w tak prosty sposób, wpływ
tej techniki na czytelność kodu i spokój ducha programistów jest zaskakująco duży.
Wcześniej opisałem asynchroniczność w wyidealizowany sposób. W praktyce wzo-
rzec asynchroniczności opartej na zadaniach wygląda nieco inaczej. Kontynuacja nie
jest przekazywana do asynchronicznej operacji. Taka operacja rozpoczyna pracę
i zwraca token, który później można wykorzystać do podania kontynuacji. Token ten
reprezentuje bieżącą operację, która może zostać ukończona przed zwróceniem ste-
rowania do kodu z jej wywołaniem lub wciąż być wykonywana. Ten token jest później
używany wszędzie tam, gdzie chcesz przekazać następującą informację: nie można
kontynuować pracy do czasu zakończenia tej operacji. Zwykle token ma postać obiektu
typu Task lub Task<TResult>, jednak nie musi tak być.
UWAGA. Opisywany tu token różni się od tokenu anulowania, przy czym oba te tokeny
uwidaczniają to, że programista nie musi wiedzieć, co dzieje się na zapleczu. Wystarczy wie-
dzieć, jakie możliwości daje token.

87469504f326f0d7c1fcda56ef61bd79
8
192 ROZDZIAŁ 5. Pisanie kodu asynchronicznego

Przepływ wykonywania kodu w metodzie asynchronicznej w C# 5 zwykle wygląda tak:


1. Wykonywanie jakichś zadań.
2. Uruchamianie operacji asynchronicznej i zapamiętywanie zwróconego tokenu.
3. Ewentualne wykonywanie dodatkowych zadań (często nie można robić dalszych
postępów do czasu zakończenia asynchronicznej operacji; wtedy ten krok jest
pusty).
4. Oczekiwanie (z użyciem tokenu) na zakończenie asynchronicznej operacji.
5. Wykonywanie dodatkowych zadań.
6. Zakończenie pracy.

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.

5.2.2. Konteksty synchronizacji


Wcześniej wspomniałem, że jedną z podstawowych zasad pisania kodu interfejsu
użytkownika jest to, że nie możesz aktualizować takiego interfejsu, jeśli nie znajdujesz
się w odpowiednim wątku. Na listingu 5.1, gdzie asynchronicznie sprawdzana jest dłu-
gość strony internetowej, trzeba było się upewnić, że kod po wyrażeniu await będzie
wykonywany w wątku interfejsu użytkownika. Funkcje asynchroniczne wracają do wła-
ściwego wątku dzięki obiektowi typu SynchronizationContext. Ten typ to klasa istniejąca
od wersji .NET 2.0. Jest ona używana także przez inne komponenty, np. Background
Worker. Klasa SynchronizationContext stanowi uogólnienie idei wykonywania dele-
gata we właściwym wątku. Komunikaty Post (asynchroniczny) i Send (synchroniczny)
z tej klasy są podobne do wywołań Control.BeginInvoke i Control.Invoke z technologii
Windows Forms.

87469504f326f0d7c1fcda56ef61bd79
8
5.2. Myślenie o asynchroniczności 193

W różnych środowiskach wykonania używane są różne konteksty. Na przykład jeden


kontekst może umożliwiać dowolnemu wątkowi z puli wykonanie określonych działań.
Dostępne są też inne informacje kontekstowe, jeśli jednak zastanawiasz się, jak to
możliwe, że metody asynchroniczne są wykonywane dokładnie tam, gdzie to potrzebne,
powinieneś skupić się na kontekście synchronizacji.
Więcej informacji o klasie SynchronizationContext znajdziesz w artykule Stephena
Cleary’ego z magazynu MSDN (http://mng.bz/5dCw). To zagadnienie jest ważne
przede wszystkim dla programistów używających ASP.NET. Kontekst w ASP.NET
może łatwo sprawić, że nieostrożny programista spowoduje zakleszczenie w kodzie, który
wygląda poprawnie. Sytuacja wygląda nieco inaczej w ASP.NET Core, a Stephen
poświęcił tej kwestii inny wpis na blogu: http://mng.bz/5YrO.

Wywołania Task.Wait() i Task.Result w przykładach


W niektórych przykładach użyłem wywołań Task.Wait() i Task.Result, ponieważ pozwala to
uprościć kod. Zwykle można bezpiecznie stosować je w aplikacjach konsolowych, ponie-
waż nie występuje tam kontekst synchronizacji, a kontynuacje metod asynchronicznych
zawsze są wykonywane z użyciem wątków z puli.
Jednak w praktyce powinieneś bardzo uważać, gdy korzystasz z tych metod. Obie blokują
wątek do czasu zakończenia działania, co oznacza, że jeśli wywołasz je w wątku, w któ-
rym trzeba uruchomić kontynuację, możesz łatwo doprowadzić do zakleszczenia aplikacji.

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.

5.2.3. Model działania metod asynchronicznych


O metodach asynchronicznych warto myśleć w sposób pokazany na rysunku 5.1.

Rysunek 5.1. Model granic w kodzie asynchronicznym

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

Listing 5.2. Pobieranie długości strony w metodzie asynchronicznej

static readonly HttpClient client = new HttpClient();

static async Task<int> GetPageLengthAsync(string url)


{
Task<string> fetchTextTask = client.GetStringAsync(url);
int length = (await fetchTextTask).Length;
return length;
}

static void PrintPageLength()


{
Task<int> lengthTask =
GetPageLengthAsync("http://csharpindepth.com");
Console.WriteLine(lengthTask.Result);
}

Rysunek 5.2. Łączenie szczegółów z listingu 5.2 z ogólnym wzorcem przedstawionym


na rysunku 5.1

Istotna jest tu głównie metoda GetPageLengthAsync, dodałem jednak metodę PrintPage


Length, aby zaprezentować interakcje między metodami. Przede wszystkim trzeba
określić poprawne typy na granicach metod. Ten rysunek będzie powtarzał się w róż-
nych postaciach w tym rozdziale.
Teraz jesteś gotów do przyjrzenia się pisaniu metod asynchronicznych i temu, jak
działają. Trzeba omówić tu dużo kwestii, ponieważ to, co możesz zrobić, i to, co się
stanie po wybraniu określonych technik, łączy się ze sobą.
Używane są tu tylko dwa nowe elementy składni: async to modyfikator używany
do deklarowania metod asynchronicznych, a operator await służy do wykorzystywa-
nia wyników operacji asynchronicznych. Jednak prześledzenie przekazywania infor-
macji między elementami programu szybko staje się trudne — zwłaszcza gdy musisz
uwzględnić to, co stanie się w przypadku niepowodzenia. Starałem się tu wyodrębnić
poszczególne aspekty, jednak kod musi radzić sobie ze wszystkim jednocześnie. Jeśli
w trakcie lektury tego podrozdziału zaczniesz się zastanawiać: „A co z…?”, czytaj
dalej. Możliwe, że szybko znajdziesz odpowiedź.
W trzech następnych punktach metoda asynchroniczna jest opisana w trzech krokach:
 deklarowanie metody asynchronicznej,
 używanie operatora await do asynchronicznego oczekiwania na zakończenie
operacji,
 zwracanie wartości po zakończeniu pracy metody.

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

Zacznijmy od deklaracji metody. Jest to najłatwiejsze zagadnienie.

5.3. Deklaracje metod asynchronicznych


Składnia deklaracji metod asynchronicznych jest identyczna jak innych metod, przy
czym trzeba dodać kontekstowe słowo kluczowe async. Może się ono znajdować w dowol-
nym miejscu przed typem zwracanej wartości. Wszystkie poniższe zapisy są poprawne:
public static async Task<int> FooAsync() { ... }
public async static Task<int> FooAsync() { ... }
async public Task<int> FooAsync() { ... }
public async virtual Task<int> FooAsync() { ... }

Ja wolę podawać modyfikator async bezpośrednio przed typem zwracanej wartości.


Możesz jednak stosować własną konwencję. Jak zawsze powinieneś omówić stosowane
konwencje z zespołem i starać się zachować spójność w kodzie bazowym.
Kontekstowe słowo kluczowe async jest związane z pewną tajemnicą — projek-
tanci języka w ogóle nie musieli go dodawać. W podobny sposób, jak kompilator prze-
chodzi w „tryb bloku iteratora”, gdy w metodzie z odpowiednim typem zwracanej
wartości używana jest instrukcja yield return lub yield break, kompilator mógłby
wykrywać użycie await w metodzie i na tej podstawie przechodzić w tryb asynchro-
niczny. Cieszę się jednak, że słowo async jest wymagane, ponieważ znacznie ułatwia
czytanie kodu, w którym używane są metody asynchroniczne. Od razu określa ono
oczekiwania, dlatego programista może aktywnie szukać wyrażeń await i wywołań blo-
kujących, które należy przekształcić w wywołania asynchroniczne i wyrażenia await.
Ważne jest jednak to, że modyfikator async nie jest reprezentowany w wygenero-
wanym kodzie. Jeśli chodzi o metodę wywołującą, jest to zwykła metoda, która zwraca
zadanie. Możesz zmodyfikować istniejącą metodę (o odpowiedniej sygnaturze), aby
używała modyfikatora async. Możesz też wprowadzić zmiany w drugim kierunku.
Taka zmiana nie narusza zgodności ani w kodzie źródłowym, ani w plikach binarnych.

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ę.

5.3.1. Typy wartości zwracanych przez metody asynchroniczne


Komunikacja między jednostką wywołującą a metodą asynchroniczną odbywa się
z użyciem zwracanych wartości. W C# 5 funkcje asynchroniczne mogą mieć następu-
jące typy zwracanych wartości:
 void,
 Task,
 Task<TResult> (dla jakiegoś typu TResult, który sam może być parametrem
określającym typ).

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>.

Możliwość zwracania wartości void w metodach asynchronicznych została dodana


w celu zapewnienia zgodności z metodami obsługi zdarzeń. Mógłbyś np. używać metody
obsługi kliknięcia przycisku z interfejsu użytkownika:
private async void LoadStockPrice(object sender, EventArgs e)
{
string ticker = tickerInput.Text;
decimal price = await stockPriceService.FetchPriceAsync(ticker);
priceDisplay.Text = price.ToString("c");
}

Jest to metoda asynchroniczna, jednak dla kodu wywołującego ją (metody OnClick


przycisku lub dowolnego kodu platformy zgłaszającego zdarzenie) nie ma to znaczenia.
Taki kod nie musi wiedzieć, kiedy zakończono obsługę zdarzenia — kiedy wczytana
została cena akcji i zaktualizowany został interfejs użytkownika. Kod wywołujący jedynie

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.

OSTRZEŻENIE. Subskrybowanie zdarzeń to chyba jedyna sytuacja, w jakiej zachęcam


do zwracania wartości void w metodzie asynchronicznej. W innych scenariuszach, gdy nie
potrzebujesz zwracać konkretnej wartości, najlepiej jest zadeklarować metodę zwracającą
wartość typu Task. Dzięki temu jednostka wywołująca może oczekiwać na zakończenie ope-
racji, wykrywać niepowodzenie itd.

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.

5.3.2. Parametry metod asynchronicznych


Żadne parametry metody asynchronicznej nie mogą mieć modyfikatorów out i ref. Jest
to zrozumiałe, ponieważ te modyfikatory służą do przekazywania informacji z powrotem
do kodu wywołującego. Niektóre metody asynchroniczne mogą nie zostać wykonane
przed zwróceniem sterowania do jednostki wywołującej, dlatego wartość parametru
przekazywanego przez referencję może nie być ustawiona. Sytuacja może być jeszcze
bardziej dziwna. Wyobraź sobie przekazywanie zmiennej lokalnej jako argumentu
odpowiadającego parametrowi ref. Metoda asynchroniczna może próbować ustawić
wartość tej zmiennej po zakończeniu pracy metody wywołującej. Próba wykonania tej
operacji nie ma większego sensu, dlatego kompilator na to nie zezwala. Ponadto typami
parametrów metody asynchronicznej nie mogą być typy wskaźnikowe.
Po zadeklarowaniu metody możesz napisać jej ciało i oczekiwać na inne operacje
asynchroniczne. Zobacz teraz, jak i gdzie można stosować wyrażenia await.

5.4. Wyrażenia await


Metody z modyfikatorem async deklaruje się tylko po to, by móc używać w nich wyra-
żeń await. Wszystkie pozostałe aspekty takich metod wyglądają standardowo. Można
stosować wszystkie mechanizmy przepływu sterowania — pętle, wyjątki, instrukcje
using itd. Gdzie więc można używać wyrażenia await i jak ono działa?

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());

Istnieją jednak ograniczenia co do tego, na jakie wyrażenia można oczekiwać. Muszą


to być wyrażenia awaitable. W tym kontekście pojawia się wzorzec awaitable.

5.4.1. Wzorzec awaitable


Wzorzec awaitable służy do tworzenia typów, jakie można stosować razem z operatorem
await. Rysunek 5.4 ma przypomnieć, że omawiana jest tu druga granica z rysunku 5.1,
związana z tym, jak metoda asynchroniczna komunikuje się z inną operacją asynchro-
niczną. Wzorzec awaitable służy do określania, czym jest operacja asynchroniczna.

Rysunek 5.4. Wzorzec awaitable umożliwia metodom asynchronicznym asynchroniczne


oczekiwanie na ukończenie operacji

Możesz sądzić, że omawiany mechanizm jest oparty na interfejsach (podobnie jak


kompilator wymaga, aby typ implementował interfejs IDisposable, jeśli dostępna ma być
instrukcja using). Tu jednak używany jest wzorzec. Wyobraź sobie, że istnieje wyra-
żenie typu T, na które kod ma oczekiwać. Kompilator wykonuje wtedy następujące testy:
 T musi udostępniać bezparametrową metodę instancji GetAwaiter() lub dostępna
musi być metoda rozszerzająca przyjmująca jeden parametr typu T. Metoda
GetAwaiter nie może zwracać wartości void. Typ wartości zwracanej przez tę
metodę jest nazywany typem awaitera.
 W typie awaitera zaimplementowany musi być interfejs System.Runtime.INotify
Completion. Ten interfejs ma jedną metodę: void OnCompleted(Action).
 Typ awaitera musi udostępniać właściwość instancji IsCompleted typu bool; ta
właściwość musi umożliwiać odczyt.
 Typ awaitera musi mieć niegeneryczną bezparametrową metodę instancji Get
Result.

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();
}

public struct YieldAwaitable


{
public YieldAwaiter GetAwaiter();

public struct YieldAwaiter : INotifyCompletion


{
public bool IsCompleted { get; }
public void OnCompleted(Action continuation);
public void GetResult();
}
}

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

Środkowy wiersz metody InvalidPrintYieldPrint jest nieprawidłowy z dokładnie tego


samego powodu, co następna instrukcja:
var result = Console.WriteLine("WriteLine to metoda void");

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.

Historyczne znaczenie metod rozszerzających


Możliwość tworzenia metody GetAwaiter jako metody rozszerzającej wynika głównie z przy-
czyn historycznych. Obecnie ma to mniejsze znaczenie. C# 5 udostępniono mniej więcej
w tym samym czasie co .NET 4.5, gdzie wprowadzono metody GetAwaiter w typach Task
i Task<TResult>. Gdyby metoda GetAwaiter musiała być prawdziwą metodą instancji, byłoby
to problemem dla programistów zmuszonych do używania .NET 4.0. Jednak dzięki obsłu-
dze metod rozszerzających można dodać do klas Task i Task<TResult> obsługę mechanizmu
async/await, używając pakietu NuGet, który niezależnie dodaje potrzebne metody rozsze-
rzające. Oznaczało to także, że społeczność mogła przetestować wersje wstępne kompi-
latora języka C# 5 bez testowania wersji wstępnych platformy .NET 4.5.
W kodzie przeznaczonym na nowe platformy, gdzie dostępne są wszystkie potrzebne
metody GetAwaiter, rzadko konieczna jest możliwość przekształcania istniejącego typu na
typ awaitable za pomocą metod rozszerzających.

Więcej szczegółów na temat używania składowych we wzorcu awaitable znajdziesz


w podrozdziale 5.6, gdzie opisany jest przepływ sterowania w metodach asynchronicz-
nych. To jednak jeszcze nie koniec omawiania wyrażeń await. Związanych jest z nimi
kilka ograniczeń.

5.4.2. Ograniczenia dotyczące wyrażeń await


Istnieją ograniczenia co do tego, gdzie można używać wyrażeń await (podobnie jest
z używaniem instrukcji yield return). Najbardziej oczywiste ograniczenie dotyczy
tego, że takie wyrażenia można stosować tylko w metodach async i funkcjach anoni-
mowych async (co jest opisane w podrozdziale 5.7). Nawet w metodach async nie można
użyć operatora await w funkcji anonimowej, jeśli ta ostatnia też nie jest opatrzona mody-
fikatorem async.
Operatora await nie można też używać w niezabezpieczonym kontekście. To nie
oznacza, że w metodzie async nie można stosować niezabezpieczonego kodu. Niedo-
zwolone jest jedynie używanie operatora await w takim kodzie. Na listingu 5.3 pokazany
jest fikcyjny przykład, w którym wskaźnik jest używany do pobierania kolejnych znaków
łańcucha, aby ustalić sumę wartości jednostek kodowych UTF-16 z tego łańcucha. Ten
kod nie robi niczego naprawdę przydatnego, ale pokazuje, jak zastosować niezabez-
pieczony kod w metodzie asynchronicznej.
Operatora await nie można też używać w blokadach. Jeśli kiedyś zauważysz, że
chcesz zastosować blokadę do czasu zakończenia operacji asynchronicznej, powinieneś
zmienić projekt kodu. Nie staraj się pominąć ograniczenia narzucanego przez kompilator,

87469504f326f0d7c1fcda56ef61bd79
8
5.4. Wyrażenia await 201

Listing 5.3. Używanie niezabezpieczonego kodu w metodzie asynchronicznej

static async Task DelayWithResultOfUnsafeCode(string text)


{
int total = 0;
unsafe W metodzie asynchronicznej można stosować niezabezpieczony kod.
{
fixed (char* textPointer = text)
{
char* p = textPointer;

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.
}

ręcznie stosując wywołania Monitor.TryEnter i Monitor.Exit w bloku try/finally. Tak


zmodyfikuj kod, aby w czasie wykonywania operacji blokada nie była potrzebna. Jeśli
w danej sytuacji taka zmiana projektu byłaby skrajnie niewygodna, rozważ użycie typu
SemaphoreSlim i jego metody WaitAsync.
Monitor używany w instrukcji lock można zwolnić tylko w tym samym wątku,
który pierwotnie zajął ten monitor. Jest to sprzeczne z tym, że różne wątki mogą wyko-
nywać kod przed wyrażeniem await i po takim wyrażeniu. Nawet jeśli używany jest
ten sam wątek (np. dzięki temu, że kod działa w kontekście synchronizacji interfejsu
GUI), w tym samym wątku między rozpoczęciem a zakończeniem asynchronicznej ope-
racji wykonywany może być inny kod. Taki kod mógłby wkroczyć do monitora zabez-
pieczonego instrukcją lock, co prawie na pewno jest niezgodne z oczekiwaniami pro-
gramisty. Można to podsumować tak, że instrukcje lock i asynchroniczność nie
współdziałają dobrze ze sobą.
Są też miejsca, w których operator await był niedozwolony w C# 5, ale jest
poprawny od wersji C# 6:
 blok try w bloku catch,
 blok catch,
 blok finally.

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.

5.5. Opakowywanie zwracanych wartości


Zobaczyłeś już, jak deklarować granice między kodem wywołania a metodą asyn-
chroniczną, a także jak oczekiwać na operacje asynchroniczne w metodach asynchro-
nicznych. Teraz pora zobaczyć, jak instrukcje zwracające wartość używane są do
zaimplementowania pierwszej granicy, związanej ze zwracaniem wartości do kodu
wywołującego (zobacz rysunek 5.5).

Rysunek 5.5. Zwracanie wyniku do jednostki wywołującej przez metodę asynchroniczną

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.

5.6. Przepływ sterowania w metodzie asynchronicznej


Mechanizm async/await można stosować na kilku poziomach:
 Możesz oczekiwać, że mechanizm oczekiwania zrobi to, czego oczekujesz; nie
musisz dokładnie definiować, co trzeba zrobić.
 Możesz wywnioskować działanie kodu, jeśli chodzi o to, co się stanie i w którym
wątku, ale bez zrozumienia tego, w jaki sposób jest to osiągane.
 Możesz zagłębić się w działanie infrastruktury, która odpowiada za wykonywane
działania.

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.

5.6.1. Na co kod oczekuje i kiedy?


Zacznijmy od pewnego uproszczenia. Czasem wyrażenie await jest używane do wyniku
łańcucha wywołań metod (i w niektórych sytuacjach właściwości):

87469504f326f0d7c1fcda56ef61bd79
8
204 ROZDZIAŁ 5. Pisanie kodu asynchronicznego

string pageText = await new HttpClient().GetStringAsync(url);

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));

Obowiązują tu standardowe reguły przetwarzania wyrażeń języka C#, a lewy ope-


rand operatora * musi zostać w pełni obliczony przed przejściem do prawego operandu.
Dlatego wcześniejszą instrukcję można rozwinąć do następującej postaci:
Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
decimal hourlyRate = await hourlyRateTask;
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
int hoursWorked = await hoursWorkedTask;
AddPayment(hourlyRate * hoursWorked);

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

5.6.2. Przetwarzanie wyrażeń await


Gdy program dochodzi do wyrażenia await, możliwe są dwa scenariusze — albo asyn-
chroniczna operacja, na którą kod oczekuje, została ukończona, albo nie została. Jeśli
operacja zakończyła pracę, przepływ sterowania jest prosty — należy przejść dalej.
Jeżeli operacja zakończyła się niepowodzeniem lub przechwyciła wyjątek reprezen-
tujący niepowodzenie, zgłaszany jest wyjątek. W przeciwnym razie pobierany jest wynik
operacji (np. wartość typu string z obiektu typu Task<string>) i można przejść do następ-
nej części programu. Wszystko to dzieje się bez przełączania kontekstu wątków lub
dołączania kontynuacji.
W ciekawszym scenariuszu operacja asynchroniczna wciąż jest wykonywana.
Wtedy metoda asynchronicznie oczekuje na zakończenie operacji, a następnie konty-
nuuje pracę w odpowiednim kontekście. Asynchroniczne oczekiwanie tak naprawdę
oznacza, że metoda w ogóle nie jest wykonywana. Kontynuacja jest dołączana do ope-
racji asynchronicznej, a metoda zwraca sterowanie. Infrastruktura do obsługi operacji
asynchronicznych gwarantuje, że kontynuacja działa we właściwym wątku. Zwykle jest
to wątek z puli wątków (gdzie nie ma znaczenia, który wątek jest używany) lub wątek
interfejsu użytkownika. Wybór wątku zależy od kontekstu synchronizacji (opisanego
w punkcie 5.2.2) i można go kontrolować za pomocą wywołania Task.ConfigureAwait
(omówionego w punkcie 5.10.1).

Zwracanie i kończenie operacji


Prawdopodobnie najtrudniejszym aspektem opisu asynchronicznych operacji jest obja-
śnianie tego, kiedy metoda zwraca sterowanie (albo do pierwotnej jednostki wywołującej,
albo do dowolnego kodu będącego kontynuacją), a kiedy kończy pracę. Metoda asynchro-
niczna, w odróżnieniu od większości innych metod, może zwracać sterowanie wielokrot-
nie, kiedy nie ma więcej pracy do wykonania w danym momencie.
Wróćmy do wcześniejszej analogii z dostawcą pizzy. Załóżmy, że dostępna jest metoda
EatPizzaAsync, która obejmuje zadzwonienie do pizzerii w celu złożenia zamówienia,
spotkanie z dostawcą, oczekiwanie na ostygnięcie pizzy i zjedzenie jej. Taka metoda może
zwracać sterowanie po każdym z trzech pierwszych kroków, ale kończy pracę dopiero po
czwartym etapie (zjedzeniu pizzy).

Z perspektywy programisty wygląda do tak, jakby metoda była wstrzymywana do czasu


zakończenia asynchronicznej operacji. Kompilator upewnia się, że wszystkie zmienne
lokalne używane w metodzie mają te same wartości, co przed uruchomieniem kontynu-
acji (podobnie dzieje się w blokach iteratora).
Przyjrzyj się pokazanemu na listingu 5.4 przykładowi z dwoma scenariuszami
w prostej aplikacji konsolowej, gdzie używana jest jedna metoda asynchroniczna ocze-
kująca na dwa zadania. Task.FromResult zawsze zwraca ukończone zadanie, natomiast
Task.Delay zwraca zadanie kończące pracę po określonej przerwie.

Listing 5.4. Oczekiwanie na zakończone i niezakończone zadania

static void Main()


{
Task task = DemoCompletedAsync(); Wywołanie metody asynchronicznej.
Console.WriteLine("Metoda zwróciła sterowanie");

87469504f326f0d7c1fcda56ef61bd79
8
206 ROZDZIAŁ 5. Pisanie kodu asynchronicznego

task.Wait(); Blokowanie do czasu zakończenia zadania.


Console.WriteLine("Zadanie zostało ukończone");
}

static async Task DemoCompletedAsync()


{
Console.WriteLine("Przed pierwszym wywołaniem await");
await Task.FromResult(10); Oczekiwanie na zakończone zadanie.
Console.WriteLine("Między wywołaniami await");
await Task.Delay(1000); Oczekiwanie na niezakończone zadanie.
Console.WriteLine("Po drugim wywołaniu await");
}

Oto dane wyjściowe z listingu 5.4:


Przed pierwszym wywołaniem await
Między wywołaniami await
Metoda zwróciła sterowanie
Po drugim wywołaniu await
Zadanie zostało ukończone

Oto ważne aspekty związane z kolejnością operacji:


 Metoda asynchroniczna nie zwraca sterowania, gdy oczekuje na ukończone
zadanie. Synchronicznie kontynuuje wtedy działanie. To dlatego dwa pierwsze
komunikaty są wyświetlane bez żadnych innych informacji między nimi.
 Metoda asynchroniczna zwraca sterowanie, gdy oczekuje na zadanie wykonywane
z opóźnieniem. To dlatego trzeci komunikat to Metoda zwróciła sterowanie (jest
on wyświetlany w metodzie Main). Metoda asynchroniczna potrafi wykryć, że
operacja, której dotyczy oczekiwanie (wykonywane z opóźnieniem zadanie), nie
zakończyła jeszcze pracy. Dlatego zwraca sterowanie, aby nie blokować programu.
 Zadanie zwracane przez metodę asynchroniczną zostaje ukończone dopiero po
zakończeniu działania tej metody. To dlatego komunikat Zadanie zostało ukończone
jest wyświetlany po tekście Po drugim wywołaniu await.

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

W pierwszym scenariuszu zwykle


do jednostki wywołującej zwracany jest
obiekt Task lub Task<TResult>. Oczy-
wiście wynik metody nie jest wtedy
jeszcze dostępny. Nawet jeśli nie ma
wartości, którą można byłoby zwrócić,
nie wiadomo, czy metoda zakończy
działanie bez zgłoszenia wyjątku.
Z tego powodu zwracane zadanie nie
jest jeszcze ukończone.
W tym ostatnim scenariuszu jed-
nostka uruchamiająca wywołanie zwro-
tne zależy od kontekstu. Na przykład,
jeśli w interfejsie użytkownika Win-
dows Forms uruchomiłeś metodę
asynchroniczną w wątku interfejsu
użytkownika i nie przeszedłeś celowo
do innego wątku, cała metoda będzie
wykonywana w wątku interfejsu użyt-
kownika. W pierwszej części metody Rysunek 5.6. Model obsługi wyrażenia await
wykonywana będzie jakaś metoda ob- z perspektywy użytkownika
sługi zdarzeń, która uruchomiła metodę
asynchroniczną.Jednak później uruchomione zostanie wywołanie zwrotne od wewnętrznej
infrastruktury technologii Windows Forms (zwykle nazywanej pompą komunikatów),
podobne jak przy wywołaniu Control.BeginInvoke(kontynuacja). Tu dla kodu wywołu-
jącego (niezależnie od tego, czy jest to pompa komunikatów z technologii Windows
Forms, infrastruktura obsługi puli wątków, czy jeszcze co innego) zadanie nie jest istotne.
Warto przypomnieć, że do czasu dojścia do pierwszego asynchronicznego wyrażenia
await metoda jest wykonywana w pełni synchronicznie. Wywołanie metody asynchro-
nicznej nie jest odpowiednikiem uruchomienia nowego zadania w odrębnym wątku.
To programista musi zadbać o to, by zawsze pisać metody asynchroniczne w taki sposób,
aby szybko zwracały sterowanie. Zwykle należy unikać wykonywania długich operacji
blokujących w metodzie asynchronicznej (choć zależy to od kontekstu działania kodu).
Takie operacje należy umieścić w odrębnej metodzie i utworzyć dla niej obiekt
typu Task.
Wróćmy na chwilę do scenariusza, w którym wartość, na jaką kod czeka, jest już
gotowa. Możliwe, że zastanawiasz się, dlaczego operacja natychmiast kończąca pracę
miałaby być reprezentowana z użyciem asynchroniczności. Przypomina to nieco wywo-
łanie metody Count() dla sekwencji w technologii LINQ. W ogólnym scenariuszu
konieczne może być iteracyjne pobranie każdego elementu sekwencji. Jednak w nie-
których sytuacjach (np. wtedy, gdy sekwencja okazuje się być typu List<T>) możliwe
są proste optymalizacje. Przydatne jest utworzenie jednego abstrakcyjnego rozwiąza-
nia, które obsługuje oba scenariusze, ale bez spadku wydajności w czasie wykonywania
programu.

87469504f326f0d7c1fcda56ef61bd79
8
208 ROZDZIAŁ 5. Pisanie kodu asynchronicznego

Jako praktyczny przykład dotyczący asynchronicznego interfejsu API rozważ asyn-


chroniczny odczyt danych ze strumienia powiązanego z plikiem na dysku. Wszystkie
dane, jakie chcesz wczytać, mogły już zostać pobrane z dysku do pamięci, np. w ramach
wcześniejszego żądania ReadAsync. Dlatego sensowne jest natychmiastowe ich użycie
bez stosowania wszystkich mechanizmów asynchronicznych. Oto inny przykład —
załóżmy, że w architekturze programu używasz bufora. Możesz korzystać z niego
w automatyczny sposób, jeśli istnieje asynchroniczna operacja, która pobiera wartość
albo z zapisanego w pamięci roboczej bufora (zwracane jest wtedy ukończone zadanie),
albo z pamięci zewnętrznej (zwracane jest wtedy nieukończone zadanie, kończone po
obsłużeniu wywołania do pamięci zewnętrznej). Teraz znasz już podstawy przepływu
sterowania, możesz więc zobaczyć, jak wpasowuje się w ten proces wzorzec awaitable.

5.6.3. Używanie składowych zgodnych ze wzorcem awaitable


W punkcie 5.4.1 opisałem wzorzec awaitable, który trzeba zaimplementować w danym
typie, aby móc oczekiwać na wyrażenie tego typu. Teraz można odwzorować różne
elementy tego wzorca na operacje, jakie chcesz wykonać. Rysunek 5.7 to odpowiednik
rysunku 5.6, ale nieco rozbudowany i zmodyfikowany; w nowej wersji używany jest
wzorzec awaitable zamiast ogólnych opisów.
Po obejrzeniu rysunku 5.7 możesz się zastanawiać, o co tyle hałasu. Dlaczego warto
dodawać obsługę tego mechanizmu do języka? Jednak dołączanie kontynuacji jest
bardziej skomplikowane, niż może się to wydawać. W prostych sytuacjach, gdy prze-
pływ sterowania jest w pełni liniowy (wykonanie jakiejś pracy, oczekiwanie na coś,
wykonanie dalszej pracy, oczekiwanie na coś innego), można dość łatwo wyobrazić
sobie, jak kontynuacja może wyglądać w formie wyrażenia lambda (nawet jeśli nie jest
to elegancki kod). Jednak gdy kod zawiera pętle i warunki, a chcesz zapisać cały kod
w jednej metodzie, zadanie jest dużo trudniejsze. To wtedy właśnie uwidaczniają się
zalety mechanizmu async/await. Choć można stwierdzić, że kompilator stosuje jedynie
lukier składniowy, czytelność kodu z ręcznie tworzonymi kontynuacjami i kontynu-
acjami dodawanymi przez kompilator jest zupełnie inna.
Do tej pory opisywałem ścieżkę udanego wykonania, gdzie wszystkie wartości, na
jakie oczekujesz, są z powodzeniem zwracane. Co dzieje się w przypadku niepowo-
dzenia?

5.6.4. Wypakowywanie wyjątków


Idiomatyczny sposób reprezentowania niepowodzeń w .NET jest oparty na wyjątkach.
Obsługa wyjątków, podobnie jak zwracanie wartości do jednostki wywołującej, wymaga
dodatkowego wsparcia ze strony języka. Gdy oczekujesz na asynchroniczną operację,
która zakończyła się niepowodzeniem, problem mógł wystąpić dawno temu i w zupełnie
innym wątku. Standardowe synchroniczne przekazywanie wyjątków na stosie nie jest
wtedy naturalnym rozwiązaniem. Zamiast tego infrastruktura mechanizmu async/await
podejmuje działania, by obsługa błędów asynchronicznych była możliwie podobna do
obsługi błędów synchronicznych. Jeśli potraktujesz niepowodzenie jako jakiegoś rodzaju
wynik, podobny sposób obsługi wyjątków i zwracanych wartości będzie zrozumiały.

87469504f326f0d7c1fcda56ef61bd79
8
5.6. Przepływ sterowania w metodzie asynchronicznej 209

Rysunek 5.7. Obsługa


wyrażenia await z użyciem
wzorca awaitable

Przekazywanie wyjątków z metod asynchronicznych jest opisane w punkcie 5.6.5. Naj-


pierw jednak zobacz, co dzieje się, gdy oczekujesz na operację zakończoną niepowo-
dzeniem.
Metoda GetResult() obiektu awaitera ma pobierać zwracaną wartość, jeśli jest ona
dostępna, a także odpowiada za przekazywanie wyjątków z operacji asynchronicznej
z powrotem do metody. Nie jest to tak proste, jak się wydaje, ponieważ w modelu
asynchronicznym jeden obiekt typu Task może reprezentować wiele operacji, które
powodują wiele błędów. Choć dostępne są też inne implementacje wzorca awaitable,
warto przeanalizować przede wszystkim typy Task i Task<TResult>, ponieważ to na
obiekty tych typów będziesz oczekiwał najczęściej.
Typy Task i Task<TResult> informują o niepowodzeniu na kilka sposobów:
 Gdy operacja asynchroniczna zakończyła się niepowodzeniem, właściwość Status
zadania przyjmuje wartość Faulted (a wywołanie IsFaulted zwraca wartość true).

87469504f326f0d7c1fcda56ef61bd79
8
210 ROZDZIAŁ 5. Pisanie kodu asynchronicznego

 Właściwość Exception zwraca wyjątek typu AggregateException, zawierający


wszystkie wyjątki (może być ich wiele), które spowodowały niepowodzenie zada-
nia. Jeśli w zadaniu nie wystąpił błąd, właściwość ta ma wartość null.
 Metoda Wait() zgłasza wyjątek typu AggregateException, jeśli końcowy stan zada-
nia wskazuje na błąd.
 Właściwość Result obiektu typu Task<TResult> (który także oczekuje na zakoń-
czenie pracy) również zgłasza wyjątek typu AggregateException.

Ponadto zadania można anulować, używając typów CancellationTokenSource i Cancella


tionToken. Po anulowaniu zadania metoda Wait() i właściwość Result zgłaszają wyjątek
typu AggregateException zawierający wyjątek typu OperationCanceledException (a dokład-
niej — typu TaskCanceledException, który dziedziczy po typie OperationCanceledException),
a status to Canceled zamiast Faulted.
Jeśli kod oczekuje na zadanie, które zakończyło się niepowodzeniem lub anulo-
waniem, zgłaszany jest wyjątek, przy czym nie jest on typu AggregateException. Dla
wygody w większości sytuacji zgłaszany jest pierwszy wyjątek zawarty w obiekcie typu
AggregateException. Przeważnie jest to korzystne rozwiązanie. Jest ono zgodne z duchem
modelu asynchronicznego, ponieważ można pisać kod asynchroniczny wyglądający
bardzo podobnie do kodu synchronicznego. Przyjrzyj się np. listingowi 5.5, gdzie kod
próbuje pobierać adresy URL jeden po drugim do czasu, aż jeden z nich okaże się
poprawny lub wyczerpane zostaną dostępne adresy.

Listing 5.5. Przechwytywanie wyjątków w trakcie pobierania stron internetowych

async Task<string> FetchFirstSuccessfulAsync(IEnumerable<string> urls)


{
var client = new HttpClient();
foreach (string url in urls)
{
try
{ Zwracanie łańcucha znaków po udanym
return await client.GetStringAsync(url); wykonaniu operacji.
} Przechwytywanie i wyświetlanie
catch (HttpRequestException exception) błędu w innej sytuacji.
{
Console.WriteLine("Nieudana próba pobrania strony {0}: {1}",
url, exception.Message);
}
}
throw new HttpRequestException("Żaden adres URL nie był poprawny");
}

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

serwer, ponieważ ta metoda jedynie uruchamia operację. Do czasu wykrycia takiego


błędu ta metoda zwróci sterowanie, dlatego może jedynie zwrócić zadanie, które kończy
się niepowodzeniem i zawiera wyjątek typu HttpRequestException. Gdybyś wywołał
metodę Wait() dla takiego zadania, zgłoszony zostałby wyjątek typu AggregateException
zawierający wyjątek typu HttpRequestException. Metoda GetResult awaitera zgłasza
natomiast wyjątek HttpRequestException, który można w standardowy sposób przechwycić
w bloku catch.
Taki model grozi oczywiście utratą informacji. Jeśli w zakończonym niepowodze-
niem zadaniu wystąpiło wiele wyjątków, metoda GetResult może zgłosić tylko jeden
z nich (arbitralnie wybierany jest pierwszy wyjątek). Możesz zmodyfikować pokazany
wcześniej kod, tak aby po wystąpieniu błędu jednostka wywołująca mogła przechwycić
wyjątek typu AggregateException i sprawdzić wszystkie powody problemu. Ważne jest
to, że niektóre metody platformy działają w ten sposób. Na przykład metoda Task.
WhenAll() asynchronicznie czeka na zakończenie wielu zadań (wymienionych
w wywołaniu metody). Jeśli którekolwiek z tych zadań zakończy się niepowodzeniem,
wynikiem jest obiekt zawierający wyjątki z wszystkich zadań zakończonych błędem.
Jeżeli jednak kod oczekuje tylko na zadanie zwrócone przez metodę WhenAll(), otrzy-
mywany jest wyłącznie pierwszy wyjątek. Jeśli chcesz szczegółowo zbadać wyjątki,
zwykle najprostszym rozwiązaniem jest użycie właściwości Task.Exception każdego
z pierwotnych zadań.
Oto podsumowanie — wiesz już, że metoda GetResult() awaitera służy do przeka-
zywania zarówno poprawnych wyników, jak i wyjątków. Gdy używany jest typ Task lub
Task<TResult>, metoda GetResult() wypakowuje wyjątek AggregateException z zakoń-
czonego niepowodzeniem zadania i zgłasza pierwszy z wyjątków wewnętrznych. To
wyjaśnia, w jaki sposób metoda asynchroniczna może używać wyników z innej operacji
asynchronicznej. Jak jednak przekazuje swój wynik do kodu z wywołaniem takiej
metody?

5.6.5. Ukończenie pracy metody


Warto przypomnieć kilka kwestii:
 Metoda asynchroniczna zwykle zwraca sterowanie przed ukończeniem pracy.
 Sterowanie jest zwracane zaraz po dojściu do wyrażenia await, jeśli operacja,
na którą kod oczekuje, nie została jeszcze ukończona.
 Jeśli używana metoda nie zwraca wartości void (wtedy jednostka wywołująca nie
może w łatwy sposób ustalić, co się dzieje), zwracana wartość jest pewnego rodzaju
zadaniem. Przed wersją C# 7 były to obiekty typów Task i Task<TResult>, a od
wersji C# 7 można też używać niestandardowych typów zadań (zagadnienie to
jest objaśnione w podrozdziale 5.8). Na razie dla uproszczenia załóżmy, że uży-
wany jest typ Task<TResult>.
 Zadanie odpowiada za informowanie o czasie i sposobie ukończenia pracy przez
metodę asynchroniczną. Jeśli metoda ukończyła pracę w zwykły sposób, stan
zadania zmienia się na RanToCompletion, a we właściwości Result umieszczana
jest zwracana wartość. Jeżeli w ciele metody zgłoszony został wyjątek, stan zadania

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).

Tak, to brzmi jak powtórka


Możliwe, że zastanawiasz się, czy przypadkowo nie cofnąłeś się o kilka stron i nie czytasz
tego samego fragmentu po raz drugi. Czy te same zagadnienia nie są opisane w kontek-
ście oczekiwania na wyniki?
Dokładnie tak jest. Pokazuję tu, co metoda asynchroniczna robi, aby poinformować o spo-
sobie ukończenia pracy, podczas gdy wcześniej wyjaśniałem, w jaki sposób wyrażenie await
sprawdza, w jaki sposób operacje zostały ukończone. Gdyby te opisy nie były podobne,
byłoby to dziwne, ponieważ metody asynchroniczne zwykle są łączone w łańcuch wywołań.
Wartość, na którą oczekuje jedna metoda asynchroniczna, często jest wartością zwracaną
przez inną metodę asynchroniczną. W bardziej wymyślnych słowach można napisać, że
operacje asynchroniczne łatwo się komponują.

Wszystkie opisane działania wykonuje kompilator z wykorzystaniem dość rozbudowanej


infrastruktury. Niektóre szczegóły są opisane w następnym rozdziale (choć nie objaśniam
wszystkich zawiłości; nawet ja mam pewne granice). Niniejszy rozdział dotyczy przede
wszystkim działania mechanizmów, na jakich możesz polegać w swoim kodzie.
UDANE ZWRACANIE WARTOŚCI
Udane zwrócenie wartości to najprostszy scenariusz. Jeśli metoda zgodnie z deklaracją
ma zwracać obiekt typu Task<TResult>, w instrukcji return trzeba podać wartość typu T
(lub wyrażenie, które można przekształcić na typ TResult), a infrastruktura asynchro-
niczna przekaże tę wartość do zadania.
Jeśli typ zwracanej wartości to Task lub void, instrukcje return muszą mieć postać
samego słowa return (bez podanej wartości). Dozwolone jest też wyjście wykonania
poza koniec metody, tak jak w nieasynchronicznych metodach void. W obu sytuacjach
nie istnieje przekazywana wartość, jednak stan zadania zmienia się w odpowiedni sposób.
LENIWE PRZETWARZANIE WYJĄTKÓW
I SPRAWDZANIE POPRAWNOŚCI ARGUMENTÓW
Najważniejszą kwestią związaną z wyjątkami jest to, że metoda asynchroniczna nigdy
bezpośrednio nie zgłasza wyjątku. Nawet jeśli pierwszą operacją w ciele metody jest
zgłoszenie wyjątku, zwracane jest zadanie zakończone niepowodzeniem. (W tym sce-
nariuszu stan zadania natychmiast jest ustawiany na „zakończone niepowodzeniem”).
Powoduje to nieco problemów przy sprawdzaniu poprawności argumentów. Załóżmy,
że chcesz wykonać w metodzie asynchronicznej określone operacje po sprawdzeniu
poprawności parametrów, które nie przyjmują wartości null. Jeśli sprawdzisz popraw-
ność parametrów w taki sam sposób jak w zwykłym kodzie synchronicznym, jednostka
wywołująca nie dowie się o problemie do czasu rozpoczęcia oczekiwania na zadanie.
Przykład pokazany jest na listingu 5.6.

87469504f326f0d7c1fcda56ef61bd79
8
5.6. Przepływ sterowania w metodzie asynchronicznej 213

Listing 5.6. Błędne sprawdzanie poprawności argumentu w metodzie asynchronicznej

static async Task MainAsync()


{ Celowe przekazanie
Task<int> task = ComputeLengthAsync(null); błędnego argumentu.
Console.WriteLine("Pobrano zadanie");
int length = await task; Oczekiwanie na wynik.
Console.WriteLine("Długość: {0}", length);
}

static async Task<int> ComputeLengthAsync(string text)


{
if (text == null)
{ Zgłoszenie wyjątku tak
throw new ArgumentNullException("tekst"); szybko, jak to możliwe.
}
await Task.Delay(500); Symulowanie rzeczywistej asynchronicznej pracy.
return text.Length;
}

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.

Nie zawsze jest to powód do zmartwień. Szybkie sprawdzanie poprawności argumentów


w wielu sytuacjach można traktować jako przydatną, ale opcjonalną cechę. Z powodu
pragmatyzmu stałem się mniej pedantyczny w tej kwestii we własnym kodzie. W więk-
szości scenariuszy różnica w czasie zgłaszania wyjątków nie ma dużego znaczenia. Jeśli
jednak chcesz synchronicznie zgłaszać wyjątki w metodzie zwracającej zadanie, masz
trzy możliwości. Wszystkie one są odmianą tego samego podejścia.
Pomysł polega na tym, aby napisać metodę nieasynchroniczną, która zwraca zadanie.
Taka metoda sprawdza poprawność argumentów, a następnie wywołuje odrębną funk-
cję asynchroniczną, w której należy założyć, że argumenty są prawidłowe. Trzy wersje
tego podejścia związane są z tym, jak reprezentowana jest funkcja asynchroniczna:

87469504f326f0d7c1fcda56ef61bd79
8
214 ROZDZIAŁ 5. Pisanie kodu asynchronicznego

 Można posłużyć się odrębną metodą asynchroniczną.


 Można zastosować asynchroniczną funkcję anonimową (ta technika jest używana
w następnym punkcie).
 W C# 7 i nowszych wersjach można używać lokalnej metody asynchronicznej.

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.

Listing 5.7. Wczesne sprawdzanie poprawności argumentów w odrębnej metodzie

static Task<int> ComputeLengthAsync(string text) Metoda nieasynchroniczna, aby wyjątki


{ nie były opakowywane w zadanie.
if (text == null)
{
throw new ArgumentNullException("tekst");
}
return ComputeLengthAsyncImpl(text); Po sprawdzeniu poprawności praca jest
} delegowana do metody z implementacją operacji.

static async Task<int> ComputeLengthAsyncImpl(string text)


{
await Task.Delay(500); W metodzie asynchronicznej można przyjąć,
return text.Length; że dane wyjściowe zostały sprawdzone.
}

Teraz po wywołaniu ComputeLengthAsync z argumentem null wyjątek jest zgłaszany


synchronicznie zamiast zwracania przez metodę zadania zakończonego niepowodzeniem.
Przed przejściem do asynchronicznych funkcji anonimowych warto na chwilę wrócić
do anulowania operacji. Wspomniałem już kilkakrotnie o tej kwestii, jednak warto prze-
analizować ją bardziej szczegółowo.
OBSŁUGA ANULOWANIA OPERACJI
W .NET 4 w technologii TPL (ang. Task Parallel Library) wprowadzono jednolity
model anulowania z użyciem dwóch typów: CancellationTokenSource i Cancellation
Token. Pomysł polega na tym, że można utworzyć źródłowy obiekt typu Cancellation
TokenSource, a następnie zażądać od niego tokenu — obiektu typu CancellationToken,
który jest przekazywany do operacji asynchronicznej. Następnie można zgłosić anulo-
wanie tylko w źródle, a zostanie to odzwierciedlone w tokenie. Oznacza to, że ten sam
token można przekazać do wielu operacji i nie martwić się o to, że będą przeszkadzać
sobie w pracy. Token anulowania można stosować na wiele sposobów. Najbardziej
idiomatyczne podejście polega na wywołaniu metody ThrowIfCancellationRequested.
Powoduje to zgłoszenie wyjątku typu OperationCanceledException, jeśli operację powią-
zaną z tokenem anulowano. Jeśli operacji nie anulowano, metoda nic nie robi1. Wspo-
1
Przykład znajdziesz w kodzie źródłowym powiązanym z książką.

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).

Listing 5.8. Tworzenie anulowanego zadania w wyniku zgłoszenia wyjątku typu


OperationCanceledException

static async Task ThrowCancellationException()


{
throw new OperationCanceledException();
}
...
Task task = ThrowCancellationException();
Console.WriteLine(task.Status);

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

powrócą w następnym rozdziale, w trakcie analizowania tego, co kompilator robi na


zapleczu. Na razie możesz cieszyć się względnie prostą lekturą.

5.7. Asynchroniczne funkcje anonimowe


Nie zamierzam poświęcać dużo miejsca asynchronicznym funkcjom anonimowym. Jak
zapewne oczekujesz, łączą one dwa mechanizmy: funkcje anonimowe (wyrażenia lambda
i metody anonimowe) i funkcje asynchroniczne (kod zawierający wyrażenia await).
Umożliwiają one tworzenie delegatów reprezentujących operacje asynchroniczne.
Wszystko, czego dowiedziałeś się do tej pory o metodach asynchronicznych, dotyczy
także asynchronicznych funkcji anonimowych.

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.

Listing 5.9. Tworzenie i wywoływanie funkcji asynchronicznej z użyciem wyrażenia


lambda

Func<int, Task<int>> function = async x =>


{
Console.WriteLine("Rozpoczęcie pracy... x={0}", x);
await Task.Delay(x * 1000);
Console.WriteLine("Zakończenie pracy... x={0}", x);
return x * 2;
};
Task<int> first = function(5);
Task<int> second = function(3);
Console.WriteLine("Pierwszy wynik: {0}", first.Result);
Console.WriteLine("Drugi wynik: {0}", second.Result);

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.

5.8. Niestandardowe typy zadań w C# 7


W C# 5 i C# 6 funkcje asynchroniczne (czyli metody asynchroniczne i asynchroniczne
funkcje anonimowe) mogą zwracać tylko wartości typów void, Task i Task<TResult>.
W C# 7 to ograniczenie zostało nieco rozluźnione, a typem wartości zwracanych przez
funkcje asynchroniczne może być dowolny typ opatrzony odpowiednim dekoratorem.
Warto przypomnieć, że mechanizm async/await zawsze umożliwiał oczekiwanie na
wartości niestandardowych typów zgodnych ze wzorcem awaitable. Nowy mechanizm
pozwala napisać metodę asynchroniczną, która zwraca wartość niestandardowego typu.
To zagadnienie jest jednocześnie skomplikowane i proste. Jest skomplikowane,
ponieważ jeśli chcesz utworzyć własny typ zadania, czeka Cię sporo żmudnej pracy. Jest
to prawdziwe wyzwanie. Jednocześnie zagadnienie jest proste, ponieważ prawie na
pewno nie będziesz pisał takich typów w celach innych niż eksperymenty. Zamiast
nich będziesz używał typu ValueTask<TResult>. Przyjrzyjmy się teraz temu typowi.

5.8.1. Typ przydatny w 99,9% przypadków — ValueTask<TResult>


W czasie, gdy powstaje ta książka, typ System.Threading.ValueTask<TResult> jest auto-
matycznie dostępny tylko w platformie netcoreapp2.0. Można go znaleźć także w pakie-
cie NuGet System.Threading.Tasks.Extensions, co znacznie zwiększa jego dostępność.
Najważniejsze jest to, że pakiet ten jest zgodny ze specyfikacją netstandard1.0.

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.

Listing 5.10. Opakowywanie strumienia na potrzeby wydajnego asynchronicznego


dostępu do danych bajt po bajcie

public sealed class ByteStream : IDisposable


{
private readonly Stream stream;
private readonly byte[] buffer;
private int position; Indeks następnego zwracanego bajta w buforze.
private int bufferedBytes; Liczba bajtów w buforze.

public ByteStream(Stream stream)


{

87469504f326f0d7c1fcda56ef61bd79
8
5.8. Niestandardowe typy zadań w C# 7 219

this.stream = stream; Bufor o wielkości 8 KB powoduje,


buffer = new byte[1024 * 8]; że rzadko trzeba oczekiwać na dane.
}

public async ValueTask<byte?> ReadByteAsync()


{
if (position == bufferedBytes) Ponowne zapełnianie bufora, gdy jest to konieczne.
{
position = 0;
bufferedBytes = await Asynchroniczne wczytywanie
stream.ReadAsync(buffer, 0, buffer.Length) danych ze strumienia.
.ConfigureAwait(false); Konfigurowanie operacji oczekiwania,
if (bufferedBytes == 0) aby ignorowała kontekst.
{
return null; Informowanie o końcu strumienia po jego osiągnięciu.
}
}
return buffer[position++]; Zwracanie następnego bajta z bufora.
}

public void Dispose()


{
stream.Dispose();
}
}

Przykład zastosowania:
using (var stream = new ByteStream(File.OpenRead("file.dat")))
{
while ((nextByte = await stream.ReadByteAsync()).HasValue)
{
ConsumeByte(nextByte.Value); Używanie bajta.
}
}

Na razie pomiń wywołanie ConfigureAwait w metodzie ReadByteAsync. Wrócimy do tego


w podrozdziale 5.10 w kontekście omówienia skutecznego używania mechanizmu
async/await. Reszta kodu jest prosta. Cały ten kod można napisać bez używania typu
ValueTask<TResult>, jednak taka wersja byłaby dużo mniej wydajna.
W pokazanym kodzie większość wywołań metod ReadByteAsync nie powoduje użycia
operatora await, ponieważ nadal dostępne są dane w buforze do zwrócenia. Metoda
byłaby równie przydatna, gdyby oczekiwano na inną wartość, która zwykle jest dostępna
natychmiast. W punkcie 5.6.2 wyjaśniłem, że gdy oczekujesz na operację, która już
zakończyła pracę, wykonanie jest kontynuowane synchronicznie. Oznacza to, że nie
trzeba planować kontynuacji i można uniknąć przydziału pamięci dla obiektów.
Jest to uproszczona wersja prototypu klasy CodedInputStream z pakietu Google.Protobuf
(jest to używana w .NET implementacja protokołu serializacji Protocol Buffers Google’a).
W praktyce dostępnych jest wiele metod, a każda z nich wczytuje synchronicznie lub
asynchronicznie niewielką porcję danych. Deserializowanie komunikatu z wieloma
polami całkowitoliczbowymi może wymagać wielu wywołań metod. Dlatego każdora-
zowe zwracanie w asynchronicznej metodzie obiektu typu Task<TResult> byłoby nie-
akceptowalnie niewydajne.

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.

Dla większości programistów możliwość używania typu ValueTask<TResult> jako


typu wartości zwracanych przez metody asynchroniczne jest ważną zaletą wersji C# 7
w dziedzinie asynchroniczności. Mechanizm ten zaimplementowano jednak jako
technikę do ogólnego użytku, co pozwala tworzyć własne typy wartości zwracanych
w metodach asynchronicznych.

5.8.2. 0,1% sytuacji — tworzenie własnych


niestandardowych typów zadań
Chcę jeszcze raz podkreślić, że prawie z pewnością nigdy nie będziesz potrzebował tych
informacji. Nie zamierzam nawet próbować wymyślić przypadku użycia typu innego
niż ValueTask<TResult>, ponieważ wszystko, co przychodzi mi na myśl, wydaje mi się
zagmatwane. Mimo to książka byłaby niekompletna, gdybym nie przedstawił wzorca,
jakiego kompilator używa do ustalenia, że dany typ jest typem zadania. W następnym
rozdziale pokazuję szczegółowo, w jaki sposób kompilator używa tego wzorca; prezentuję
tam kod generowany na podstawie metod asynchronicznych.
Oczywiste jest, że w niestandardowym typie zadania trzeba zaimplementować
wzorzec awaitable. To jednak nie wszystko. Aby utworzyć niestandardowy typ zadania,
trzeba napisać powiązany typ buildera i zastosować atrybut System.Runtime.Compiler
Services.AsyncMethodBuilderAttribute, aby poinformować kompilator o relacji między
dwoma używanymi typami. Jest to nowy atrybut dostępny w tym samym pakiecie
NuGet co typ ValueTask<TResult>. Jeśli jednak nie chcesz używać dodatkowego pakietu,
możesz dodać własną deklarację atrybutu (w odpowiedniej przestrzeni nazw i z potrzebną
właściwością BuilderType). Kompilator pozwoli wtedy na opatrywanie typów zadań tym
atrybutem.
Typ zadania może być generyczny ze względu na jeden parametr określający typ
lub niegeneryczny. Jeśli jest generyczny, parametr określający typ w typie awaitera
musi być typu GetResult. Jeżeli jest niegeneryczny, GetResult musi mieć typ zwracanej
wartości void2. Builder musi być generyczny lub niegeneryczny zgodnie z tymi samymi
regułami co typ zadania.
Typ buildera to miejsce, w którym kompilator wchodzi w interakcje z kodem pro-
gramisty, gdy kompiluje metodę zwracającą wartość niestandardowego typu. Kom-
pilator musi wiedzieć, jak utworzyć niestandardowe zadanie, jak przekazywać infor-
2
Trochę mnie to zaskoczyło. Oznacza to, że nie można napisać niestandardowego typu zadania repre-
zentującego operację, która zawsze zwraca łańcuch znaków. Jednak ponieważ cały mechanizm sto-
suje się dość rzadko, prawdopodobieństwo, że ktoś będzie potrzebował mało popularnego scena-
riusza w tym mechanizmie, jest niskie.

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).

Listing 5.11. Szkielet składowych potrzebnych w generycznym typie zadania

[AsyncMethodBuilder(typeof(CustomTaskBuilder<>))]
public class CustomTask<T>
{
public CustomTaskAwaiter<T> GetAwaiter();
}

public class CustomTaskAwaiter<T> : INotifyCompletion


{
public bool IsCompleted { get; }
public T GetResult();
public void OnCompleted(Action continuation);
}

public class CustomTaskBuilder<T>


{
public static CustomTaskBuilder<T> Create();

public void Start<TStateMachine>(ref TStateMachine stateMachine)


where TStateMachine : IAsyncStateMachine;

public void SetStateMachine(IAsyncStateMachine stateMachine);


public void SetException(Exception exception);
public void SetResult(T result);

public void AwaitOnCompleted<TAwaiter, TStateMachine>


(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : INotifyCompletion
where TStateMachine : IAsyncStateMachine;

public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>


(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : INotifyCompletion
where TStateMachine : IAsyncStateMachine;

public CustomTask<T> Task { get; }


}

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

UWAGA. Określenie niebezpieczny nie jest tu bezpośrednio związane ze słowem kluczowym


unsafe, choć oba niosą podobne przesłanie: „ostrożnie, smoki!”.

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ń.

5.9. Asynchroniczne metody main w C# 7.1


W C# wymogi dotyczące punktu wejścia od dawna pozostają takie same:
 Musi istnieć metoda o nazwie Main.
 Ta metoda musi być statyczna.
 Ta metoda musi zwracać wartość typu void lub int.
 Musi to być metoda bezparametrowa lub z jednym parametrem typu string[]
(bez modyfikatorów ref lub out).
 Metoda ta musi być niegeneryczna i musi być zadeklarowana w typie niegene-
rycznym (także typy zewnętrzne muszą być niegeneryczne, jeśli metoda jest
zadeklarowana w typie zagnieżdżonym).
 Nie może to być metoda częściowa bez implementacji.
 Nie można użyć do niej modyfikatora async.

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.

Listing 5.12. Prosty asynchroniczny punkt wejścia

static async Task Main()


{
Console.WriteLine("Przed przerwą");
await Task.Delay(1000);
Console.WriteLine("Po przerwie");
}

Kompilator obsługuje asynchroniczne punkty wejścia w taki sposób, że tworzy syn-


chroniczną metodę, którą oznacza jako rzeczywisty punkt wejścia do podzespołu. Ta

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();
}

Asynchroniczne punkty wejścia są przydatne, gdy piszesz niewielkie programy narzę-


dziowe lub eksperymentalny kod, w którym używany jest interfejs API z obsługą asyn-
chroniczności (np. taki jak w kompilatorze Roslyn).
Opisane zostały tu mechanizmy asynchroniczne z perspektywy języka. Jednak zna-
jomość funkcji języka to nie to samo co umiejętność skutecznego ich wykorzystania.
Dotyczy to zwłaszcza asynchroniczności, która z natury jest skomplikowana.

5.10. Wskazówki dotyczące korzystania


z asynchroniczności
Ten podrozdział nie może być kompletnym przewodnikiem po skutecznym korzystaniu
z asynchroniczności. To zagadnienie wymagałoby całej książki. To już prawie koniec
i tak długiego rozdziału, dlatego ograniczam się do zaprezentowania najważniejszych —
wedle mojego doświadczenia — wskazówek. Gorąco zachęcam do zapoznania się
z punktem widzenia innych programistów. Przede wszystkim Stephen Cleary i Stephen
Toub napisali wiele wpisów na blogach i artykułów z bardzo szczegółowym omówieniem
poruszanych tu kwestii. W tym podrozdziale przedstawiam najprzydatniejsze sugestie,
jakie mogę opisać akceptowalnie zwięźle (kolejność nie jest tu istotna).

5.10.1. Jeśli jest to akceptowalne, używaj ConfigureAwait,


aby nie przechwytywać kontekstu
W punktach 5.2.2 i 5.6.2 opisałem konteksty synchronizacji i ich wpływ na operator
await. Na przykład, jeśli kod działa w wątku interfejsu użytkownika w technologii WPF
lub Windows Forms oraz oczekuje na asynchroniczną operację, kontekst synchronizacji
interfejsu użytkownika i infrastruktura asynchroniczna gwarantują, że kontynuacja
uruchamiana po operatorze await będzie działać w tym samym wątku interfejsu użyt-
kownika. W kodzie interfejsu użytkownika jest to korzystne, ponieważ można później
bezpiecznie korzystać z interfejsu użytkownika.
Jednak w trakcie pisania kodu biblioteki (lub kodu w aplikacji, który nie używa
interfejsu użytkownika) nie należy wracać do wątku interfejsu użytkownika, nawet jeśli
kod pierwotnie w nim działał. Zwykle im mniej kodu jest wykonywane w wątku inter-
fejsu użytkownika, tym lepiej. Dzięki temu interfejs użytkownika można aktualizować
bardziej płynnie i nie staje się on wąskim gardłem. Oczywiście jeśli piszesz bibliotekę
interfejsu użytkownika, zapewne zechcesz wrócić do wątku interfejsu użytkownika.

87469504f326f0d7c1fcda56ef61bd79
8
224 ROZDZIAŁ 5. Pisanie kodu asynchronicznego

Jednak w większości bibliotek (z obszaru logiki biznesowej, usług sieciowych, dostępu


do baz danych itd.) nie jest to potrzebne.
Metoda ConfigureAwait została zaprojektowana właśnie w tym celu. Przyjmuje ona
parametr określający, czy zwrócony obiekt awaitable przechwytuje w trakcie oczeki-
wania kontekst. W praktyce chyba zawsze widziałem tę metodę z przekazywaną warto-
ścią false. W kodzie biblioteki nie pisałbyś kodu pobierającego długość strony w poka-
zany wcześniej sposób:
static async Task<int> GetPageLengthAsync(string url)
{
var fetchTextTask = client.GetStringAsync(url);
int length = (await fetchTextTask).Length;
Wyobraź sobie, że tu znajduje się więcej kodu.
return length;
}

Zamiast tego należy wywołać metodę ConfigureAwait(false) dla zadania zwróconego


przez wywołanie client.GetStringAsync(url) i oczekiwać na wynik:
static async Task<int> GetPageLengthAsync(string url)
{
var fetchTextTask = client.GetStringAsync(url).ConfigureAwait(false);
int length = (await fetchTextTask).Length;
Ten sam dodatkowy kod.
return length;
}

Zastosowałem tu małą sztuczkę, używając niejawnego typowania dla zmiennej fetch


TextTask. W pierwszym przykładzie używany jest typ Task<int>; w drugim jest to
typ ConfiguredTaskAwaitable<int>. Jednak większość kodu, z jakim się stykam, i tak
bezpośrednio oczekuje na wynik. Używana jest wtedy następująca składnia:
string text = await client.GetStringAsync(url).ConfigureAwait(false);

Wywołanie ConfigureAwait(false) powoduje, że kontynuacja nie zostanie zaplanowana


z użyciem pierwotnego kontekstu synchronizacji. Zamiast tego będzie wykonywana
w wątku z puli. Warto zauważyć, że nowa wersja działa inaczej od pierwotnej tylko
wtedy, jeśli w momencie rozpoczęcia oczekiwania zadanie nie zostało ukończone.
Jeżeli zadanie jest już ukończone, metoda działa synchronicznie nawet po wywołaniu
ConfigureAwait(false). Dlatego każde zadanie, na które oczekuje kod biblioteki, należy
skonfigurować w pokazany sposób. Nie wystarczy wywołać ConfigureAwait(false) dla
pierwszego zadania w metodzie asynchronicznej i liczyć na to, że reszta metody będzie
działać w wątku z puli.
Ten mechanizm sprawia, że należy zachować ostrożność w trakcie pisania kodu
biblioteki. Spodziewam się, że w przyszłości może powstać lepsze rozwiązanie (np.
ustawianie wartości domyślnej dla całego podzespołu). Jednak na razie musisz być
czujny. Zachęcam do używania analizatora z projektu Roslyn do sprawdzania, gdzie
zapomniałeś skonfigurować zadanie przed oczekiwaniem na nie. Mam dobre doświad-
czenia z pakietem NuGet ConfigureAwaitChecker.Analyzer, jednak dostępne są też inne
podobne narzędzia.

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ć.

5.10.2. Włączanie przetwarzania równoległego


dzięki uruchomieniu wielu niezależnych zadań
W punkcie 5.6.1 opisanych zostało kilka fragmentów kodu realizujących jeden cel: usta-
lenie pensji pracownika na podstawie stawki godzinowej i liczby przepracowanych
godzin. Dwa ostatnie fragmenty kodu wyglądały tak:
Task<decimal> hourlyRateTask = employee.GetHourlyRateAsync();
decimal hourlyRate = await hourlyRateTask;
Task<int> hoursWorkedTask = timeSheet.GetHoursWorkedAsync(employee.Id);
int hoursWorked = await hoursWorkedTask;
AddPayment(hourlyRate * hoursWorked);

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);

Jedyna różnica między tym kodem a pierwotnym rozwiązaniem polega na tym, że


przestawiłem wiersze drugi i trzeci. Zamiast oczekiwać na zadanie hourlyRateTask,
a następnie uruchamiać zadanie hoursWorkedTask, kod uruchamia oba zadania i ocze-
kuje na nie.
W większości sytuacji jest tak, że jeśli możesz równolegle wykonywać niezależne
operacje, warto to robić. Pamiętaj jednak, że jeżeli zadanie hourlyRateTask zakończy
się niepowodzeniem, nie uzyskasz wyniku zadania hoursWorkedTask, w tym nie poznasz

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.

5.10.3. Unikaj łączenia kodu synchronicznego z asynchronicznym


Choć asynchroniczność nie działa na zasadzie „wszystko albo nic”, znacznie trudniej
zaimplementować ją poprawnie, gdy część kodu jest synchroniczna, a inne fragmenty
są asynchroniczne. Przełączanie się między tymi podejściami jest najeżone trudno-
ściami. Niektóre problemy są subtelne, inne poważne. Jeśli używasz udostępniającej
tylko operacje synchroniczne biblioteki do obsługi pracy w sieci, trudno będzie napisać
bezpieczną asynchroniczną nakładkę dla takich operacji (i na odwrót).
Przede wszystkim musisz pamiętać o zagrożeniach związanych z używaniem wła-
ściwości Task<TResult>.Result i metody Task.Wait() do synchronicznego pobierania
wyników operacji asynchronicznych. Taka technika może spowodować zakleszczenie.
W najczęstszym scenariuszu operacja asynchroniczna wymaga wtedy, aby kontynuacja
była wykonywana w wątku zablokowanym w oczekiwaniu na ukończenie operacji.
Stephen Toub zamieścił na blogu dwa świetne i szczegółowe artykuły na ten temat:
Should I expose synchronous wrappers for asynchronous methods? (czy powinienem
udostępniać synchroniczne nakładki dla metod asynchronicznych) i Should I expose
asynchronous wrappers for synchronous methods? (czy powinienem udostępniać asyn-
chroniczne nakładki dla metod synchronicznych). Uwaga na spoiler — jak zapewne
się domyślasz, odpowiedź na oba te pytania to „nie”. Podobnie jak dla wszystkich reguł
istnieją tu wyjątki, jednak gorąco zachęcam do tego, by dobrze zrozumieć zasadę przed
jej złamaniem.

5.10.4. Wszędzie, gdzie to możliwe,


zezwalaj na anulowanie operacji
Anulowanie to jeden obszar, dla którego nie występuje ścisły odpowiednik w kodzie
synchronicznym, gdzie zwykle trzeba czekać z kontynuowaniem pracy na zwrócenie
sterowania przez metodę. Możliwość anulowania asynchronicznej operacji daje bardzo
dużo swobody, ale wymaga współdziałania różnych mechanizmów. Jeśli chcesz używać
metody, która nie pozwala przekazywać tokenu anulowania, dostępnych jest dużo mniej
możliwości. Możesz napisać dość zawiły kod, aby metoda asynchroniczna kończyła
pracę ze stanem „anulowana”, a następnie ignorować końcowy wynik zadania, którego
nie można anulować. Jednak to rozwiązanie jest dalekie od ideału. Pożądana jest moż-
liwość zatrzymania każdej wykonywanej operacji bez konieczności przejmowania się
zasobami wymagającymi zwolnienia pamięci, które mogłyby zostać zwrócone przez
asynchroniczną metodę po jej ukończeniu.

87469504f326f0d7c1fcda56ef61bd79
8
5.10. Wskazówki dotyczące korzystania z asynchroniczności 227

Na szczęście większość niskopoziomowych asynchronicznych interfejsów API udo-


stępnia tokeny anulowania jako parametr. Wystarczy więc samemu zastosować ten
sam wzorzec. Zwykle polega to na przekazywaniu w argumencie wywołań wszystkich
metod asynchronicznych tego samego tokenu anulowania, który metoda otrzymała jako
parametr. Nawet jeśli w danym momencie umożliwianie anulowania nie jest konieczne,
zachęcam do tego, aby konsekwentnie od początku udostępniać tę opcję, ponieważ jej
późniejsze dodawanie jest trudne.
Stephen Toub umieścił na blogu doskonały artykuł na temat trudności w radzeniu
sobie z operacjami asynchronicznymi bez możliwości anulowania. Aby znaleźć ten
artykuł, wpisz w wyszukiwarce „How do I cancel non-cancelable async operations?”.

5.10.5. Testowanie kodu asynchronicznego


Testowanie kodu asynchronicznego może być bardzo trudne, zwłaszcza jeśli chcesz
przetestować samą asynchroniczność. (Testy odpowiadające na pytanie: „Co się stanie,
jeśli anuluję operację między drugim a trzecim asynchronicznym wywołaniem w meto-
dzie?” wymagają dość zaawansowanego kodu).
Nie jest to niemożliwe, jeśli jednak chcesz przeprowadzić kompletne testy, przy-
gotuj się na ciężką przeprawę. Gdy pisałem trzecie wydanie tej książki, miałem nadzieję,
że do 2019 r. powstaną solidne platformy umożliwiające stosunkowo łatwe testowanie
kodu asynchronicznego. Niestety, rozczarowałem się w tej kwestii.
Większość platform do wykonywania testów jednostkowych obsługuje jednak testy
asynchroniczne. Ta obsługa jest bardzo ważna, jeśli chcesz pisać testy metod asyn-
chronicznych. Wynika to ze wspomnianych wcześniej powodów związanych z trudno-
ściami z łączeniem kodu synchronicznego i asynchronicznego. Pisanie testów asyn-
chronicznych jest zwykle proste — wystarczy napisać metodę testową z modyfikatorem
async i zadeklarować, że zwraca wartość typu Task zamiast void:
[Test]
public async Task FooAsync()
{
Kod do testowania metody produkcyjnej FooAsync.
}

Platformy testowe często udostępniają metodę Assert.ThrowsAsync służącą do spraw-


dzania, czy wywołanie metody asynchronicznej zwraca zadanie, które ostatecznie koń-
czy się niepowodzeniem.
Gdy testujesz kod asynchroniczny, często warto utworzyć zadanie, które jest już
ukończone (ma określony wynik lub zakończyło się niepowodzeniem). Przydatne są
wtedy metody Task.FromResult, Task.FromException i Task.FromCanceled.
Więcej możliwości oferuje typ TaskCompletionSource<TResult>. Ten typ jest często
używany w infrastrukturze asynchronicznej w platformie. Umożliwia on utworzenie
zadania, które reprezentuje wykonywaną operację, i późniejsze ustawienie wyniku (w tym
wyjątku lub informacji o anulowaniu), kiedy to zadanie kończy pracę. Ten typ jest nie-
zwykle przydatny, gdy chcesz zwracać zadanie z zależności reprezentowanej przez atrapę,
a jednocześnie sprawić, by zwrócone zadanie na dalszym etapie testu było ukończone.

87469504f326f0d7c1fcda56ef61bd79
8
228 ROZDZIAŁ 5. Pisanie kodu asynchronicznego

Jednym z aspektów typu TaskCompletionSource<TResult>, o którym warto wiedzieć,


jest to, że gdy ustawiasz wynik, kontynuacje dołączone do powiązanego zadania mogą
być uruchamiane synchronicznie w tym samym wątku. Szczegóły dotyczące działania
kontynuacji zależą od różnych aspektów wątków i kontekstów synchronizacji. Gdy
wiesz, że istnieje taka możliwość, można stosunkowo łatwo ją uwzględnić. Skoro już
o niej wiesz, możesz uniknąć marnowania czasu z powodu zdumienia, jakie było moim
udziałem.
Jest to niekompletne podsumowanie tego, czego nauczyłem się przez mniej wię-
cej cztery lata pisania kodu asynchronicznego. Nie chcę jednak odbiegać od tematu tej
książki (którym jest język C#, a nie asynchroniczność). Zobaczyłeś już, jak działa
mechanizm async/await z perspektywy programista. Jednak nie opisałem na razie szcze-
gółowo tego, co dzieje się na zapleczu, choć wzorzec awaitable daje pewne poszlaki.
Jeśli jeszcze nie eksperymentowałeś z mechanizmem async/await, gorąco zachęcam,
abyś zrobił to teraz, przed przejściem do następnego rozdziału, gdzie opisane są szcze-
góły implementacji. Te szczegóły są ważne, ale trudno jest je zrozumieć. Ciężko je
opanować bez doświadczenia w używaniu mechanizmu async/await. Jeśli nie masz
takiego doświadczenia i nie chcesz poświęcić teraz czasu na eksperymenty z tym mecha-
nizmem, doradzam, aby na razie pominąć następny rozdział. Dotyczy on tylko szcze-
gółów implementacji asynchroniczności. Obiecuję, że nie ominie Cię nic innego.

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

Dokładnie pamiętam wieczór 28 października 2010 r. Anders Hejlsberg prezentował


wtedy mechanizm async/await na konferencji PDC. Krótko przed rozpoczęciem pre-
lekcji udostępniono mnóstwo materiałów do pobrania, w tym zarys zmian w specyfi-
kacji języka C#, wersję CTP (ang. Community Technology Preview) kompilatora C# 5,
a także slajdy prezentacji Andersa. W pewnym momencie śledziłem na żywo prelekcję,
przeglądałem slajdy i instalowałem wersję CTP kompilatora. Zanim Anders skończył,
pisałem już kod asynchroniczny i wypróbowywałem różne mechanizmy.
W kilku kolejnych tygodniach zacząłem analizować te mechanizmy i sprawdzać, jaki
kod kompilator generuje. Próbowałem też napisać własną uproszczoną implementację
biblioteki dostępnej z wersją CTP kompilatora. Badałem wszelkie aspekty nowych
mechanizmów. Gdy pojawiły się nowe wersje, sprawdzałem, co się zmieniło, i coraz
lepiej wiedziałem, co dzieje się na zapleczu. Im więcej widziałem, tym bardziej doce-
niałem to, jak dużo szablonowego kodu kompilator generuje dla programistów. To tak

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ć.

Wersje diagnostyczna i produkcyjna kodu różnią się od siebie;


to samo może dotyczyć także przyszłych implementacji
W trakcie pisania tego rozdziału zdałem sobie sprawę z różnic między wersjami diagno-
styczną i produkcyjną kodu asynchronicznego. W wersji diagnostycznej generowane
maszyny stanowe są klasami, a nie strukturami. (Ułatwia to debugowanie, a przede
wszystkim zwiększa swobodę w trakcie stosowania mechanizmu Edytuj i Kontynuuj). Gdy
pisałem trzecie wydanie książki, było inaczej. Implementacja kompilatora się zmieniła.
Możliwe, że będzie się zmieniać także w przyszłości. Jeśli zdekompilujesz kod asynchroniczny
skompilowany przy użyciu C# 8, może on wyglądać nieco inaczej niż kod prezentowany
w tym miejscu.
Choć jest to zaskakujące, nie należy się tym zanadto przejmować. Szczegóły implemen-
tacji z definicji mogą się z czasem zmieniać. Nie unieważnia to wniosków wynikających
z analizy konkretnej implementacji. Trzeba jedynie pamiętać, że proces uczenia się jest
tu inny niż w modelu „to są reguły języka C# i mogą się one zmieniać jedynie w ściśle
określony sposób”.
W tym rozdziale omawiam kod generowany w wersji produkcyjnej. Różnice między wer-
sjami dotyczą głównie wydajności, a uważam, że dla większości czytelników ważniejsza
będzie wydajność wersji produkcyjnej niż wersji diagnostycznej.

Wygenerowany kod przypomina cebulę — ma warstwy złożoności. Zaczniemy od


zewnątrz i będziemy przechodzić do środka, do skomplikowanych aspektów: wyrażeń
await oraz tańca awaiterów i kontynuacji. Aby zachować zwięzłość, omawiam tylko
metody asynchroniczne, bez asynchronicznych funkcji anonimowych. Mechanizmy
w obu scenariuszach są podobne, dlatego nie nauczysz się niczego nowego, powtarzając
te same informacje.

87469504f326f0d7c1fcda56ef61bd79
8
6.1. Struktura wygenerowanego kodu 231

6.1. Struktura wygenerowanego kodu


W rozdziale 5. wspomniałem, że implementacja (zarówno w prezentowanej tu przy-
bliżonej formie, jak i kod generowany przez kompilator) ma postać maszyny stanowej.
Kompilator generuje prywatną strukturę zagnieżdżoną, aby reprezentować metodę
asynchroniczną. Musi też dodać metodę o tej samej sygnaturze jak w deklaracji. Nazy-
wam tę metodę metodą kontrolną. Jest ona prosta, ale uruchamia resztę kodu.

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.

Maszyna stanowa śledzi miejsce wykonywania kodu w metodzie asynchronicznej. Wystę-


pują cztery stany logiczne zgodne ze standardową kolejnością wykonywania metody:
 nieuruchomiona,
 wykonywana,
 wstrzymana,
 ukończona (z powodzeniem lub po błędzie).

Tylko stany z rodziny „wstrzymana” są zależne od struktury metody asynchronicznej.


Każde wyrażenie await w metodzie odpowiada odrębnemu stanowi zwracanemu w celu
uruchomienia dalszego kodu. W czasie działania maszyny stanowej nie musi ona śledzić
dokładnego miejsca wykonywania kodu. Działa ona jak zwykły kod, a procesor śledzi
wskaźnik instrukcji w podobny sposób jak w kodzie synchronicznym. Stan jest rejestro-
wany w momencie, gdy maszyna stanowa musi wstrzymać pracę. Ma to umożliwiać
późniejsze wykonywanie kodu od punktu, do którego program dotarł. Na rysunku 6.1
pokazane są przejścia między możliwymi stanami.

Rysunek 6.1. Diagram zmian stanu

Warto skonkretyzować te rozważania za pomocą rzeczywistego kodu. Na listingu 6.1


pokazana jest prosta metoda asynchroniczna. Nie jest ona tak prosta, jak byłoby to
możliwe, pozwala jednak zademonstrować jednocześnie kilka zagadnień.

87469504f326f0d7c1fcda56ef61bd79
8
232 ROZDZIAŁ 6. Implementacja asynchroniczności

Listing 6.1. Prosta wprowadzająca metoda asynchroniczna

static async Task PrintAndWait(TimeSpan delay)


{
Console.WriteLine("Przed pierwszą przerwą");
await Task.Delay(delay);
Console.WriteLine("Między przerwami");
await Task.Delay(delay);
Console.WriteLine("Po drugiej przerwie");
}

Na tym etapie warto zwrócić uwagę na trzy kwestie:


 Dostępny jest parametr, którego należy używać w maszynie stanowej.
 Metoda obejmuje dwa wyrażenia await.
 Metoda zwraca obiekt typu Task, dlatego trzeba zwrócić zadanie, które zostanie
ukończone po wyświetleniu ostatniego wiersza, ale bez określonego wyniku.

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.

Spróbuj zrobić to w domu


Zwykle używam do pracy tego rodzaju narzędzi ildasm i Redgate Reflector. Poziom
optymalizacji ustawiam na C# 1, aby dekompilator nie rekonstruował metod asynchro-
nicznych. Dostępne są też inne dekompilatory, jednak niezależnie od tego, który z nich
wybierzesz, zachęcam do sprawdzenia także kodu pośredniego. Widziałem subtelne błędy
w przetwarzaniu wyrażeń await przez kompilatory. Często błędy tego rodzaju dotyczyły
kolejności wykonywania kodu.
Jeśli nie masz na to ochoty, nie musisz wykonywać opisanych tu zadań. Jeżeli jednak
zastanawiasz się, co kompilator robi z określonym mechanizmem z kodu, a ten rozdział
nie daje odpowiedzi na to pytanie, wystarczy przeprowadzić dekompilację. Pamiętaj jed-
nak o różnicy między wersjami diagnostyczną i produkcyjną oraz nie zrażaj się nazwami
generowanymi przez kompilator, ponieważ mogą zmniejszać czytelność wyników.

Za pomocą dostępnych narzędzi możesz zdekompilować kod z listingu 6.1 do postaci


podobnej do kodu z listingu 6.2. Wiele nazw generowanych przez kompilator języka
C# nie jest poprawnych w tym języku. Aby uzyskać działający kod, przekształciłem
te nazwy w poprawne identyfikatory. W innych sytuacjach zmieniłem nazwy w celu
zwiększenia czytelności kodu. Ponadto zmodyfikowałem kolejność przypadków i etykiet
w maszynie stanowej. Nowa wersja jest idealnym logicznym odpowiednikiem wyge-
nerowanego kodu, jest jednak dużo bardziej czytelna. W innych miejscach używałem
instrukcji switch nawet dla dwóch przypadków, choć kompilator mógł zastosować
tam instrukcję if/else. W takich miejscach instrukcja switch reprezentuje bardziej
ogólny przypadek, działający także wtedy, jeśli istnieje wiele punktów, do których
kod może przejść. Jednak na potrzeby prostszych sytuacji kompilator może generować
prostszy kod.

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ą.

Prywatna struktura reprezentująca maszynę stanową


[CompilerGenerated]
private struct PrintAndWaitStateMachine : IAsyncStateMachine
{ Stan maszyny stanowej Builder powiązany z typami
public int state; (miejsce wznowienia pracy). infrastruktury do obsługi
public AsyncTaskMethodBuilder builder; asynchroniczności.
private TaskAwaiter awaiter; Awaiter, z którego pobierany
public TimeSpan delay; Parametr jest wynik w momencie
pierwotnej metody. wznawiania pracy.
void IAsyncStateMachine.MoveNext()
{ Tu znajduje się główny kod maszyny stanowej.
}

[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.

6.1.1. Metoda kontrolna — przygotowania i pierwszy krok


Większość metody kontrolnej z listingu 6.2 jest prosta; wyjątkiem jest tu typ AsyncTask
MethodBuilder. Jest to typ bezpośredni będący częścią wspólnej infrastruktury do
obsługi asynchroniczności. W dalszej części rozdziału zobaczysz, jak maszyna stanowa
wchodzi w interakcje z obiektem tego typu.

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;
}

Atrybuty dodane do metody są przeznaczone dla narzędzi. Nie wpływają na standar-


dowe wykonywanie kodu i nie musisz ich znać, aby zrozumieć wygenerowany kod
asynchroniczny. Maszyna stanowa jest zawsze tworzona w metodzie kontrolnej z uży-
ciem trzech porcji informacji:
 parametrów (tu występuje tylko parametr delay), z których każdy odpowiada
odrębnemu polu maszyny stanowej;
 buildera, który zależy od typu wartości zwracanej przez metodę asynchroniczną;
 początkowego stanu, zawsze równego -1.

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.

Po utworzeniu maszyny stanowej metoda kontrolna żąda od buildera maszyny jej


uruchomienia, przekazując samą maszynę przez referencję. Na kilku kolejnych stronach
wielokrotnie zetkniesz się z przekazywaniem przez referencję. Wynika to z potrzeby
zapewnienia wydajności i spójności. Zarówno typ maszyny stanowej, jak i typ Async
TaskMethodBuilder to modyfikowalne typy bezpośrednie. Przekazanie zmiennej machine
do metody Start przez referencję pozwala uniknąć kopiowania stanu. Takie rozwiązanie
jest wydajniejsze i gwarantuje, że zmiany wprowadzone w stanie w metodzie Start
będą widoczne po zwróceniu sterowania przez tę metodę. W czasie pracy tej metody
zmieniać się może także stan pola builder w maszynie. Dlatego ważne jest, aby używać
pola machine.builder zarówno w wywołaniu Start, jak i później we właściwości Task.
Załóżmy, że pole machine.builder zostało zapisane w zmiennej lokalnej:
var builder = machine.builder;
builder.Start(ref machine); Nieprawidłowa próba refaktoryzacji.
return builder.Task;

Po tej zmianie modyfikacje stanu wprowadzane bezpośrednio w metodzie builder.Start()


nie byłyby widoczne w polu machine.builder (i na odwrót), ponieważ używana byłaby
kopia buildera. To dlatego ważne jest, aby machine.builder był polem, a nie właści-
wością. Nie chcesz pracować na kopii buildera w maszynie stanowej. Zamiast tego
należy operować bezpośrednio na wartości zapisanej w maszynie stanowej. To z tego

87469504f326f0d7c1fcda56ef61bd79
8
6.1. Struktura wygenerowanego kodu 235

rodzaju szczegółami nieprzyjemnie jest zmagać się samodzielnie i dlatego modyfiko-


walne typy bezpośrednie oraz pola publiczne prawie zawsze są złym pomysłem.
(W rozdziale 11. zobaczysz, w jaki sposób takie konstrukcje mogą być przydatne, jeśli
są używane z rozwagą).
Uruchomienie maszyny nie powoduje utworzenia nowych wątków. Kod jedynie
uruchamia metodę MoveNext() maszyny stanowej. Metoda jest wykonywana do miejsca,
w którym ta maszyna musi wstrzymać pracę w celu oczekiwania na kolejną operację
asynchroniczną, lub do momentu zakończenia pracy maszyny stanowej. Oznacza to, że
wykonywany jest jeden krok. W obu opisanych sytuacjach metoda MoveNext() zwraca
sterowanie. Wtedy także metoda machine.builder.Start() zwraca sterowanie i można
zwrócić do jednostki wywołującej zadanie reprezentujące metodę asynchroniczną.
Builder odpowiada za tworzenie tego zadania i za odpowiednie modyfikowanie jego
stanu w trakcie wykonywania metody asynchronicznej.
To tyle o metodzie kontrolnej. Pora przyjrzeć się samej maszynie stanowej.

6.1.2. Struktura maszyny stanowej


Wciąż pomijam większość kodu maszyny stanowej (z metody MoveNext()), poniżej
przypominam jednak strukturę typu reprezentującego tę maszynę:
[CompilerGenerated]
private struct PrintAndWaitStateMachine : IAsyncStateMachine
{
public int state;
public AsyncTaskMethodBuilder builder;
private TaskAwaiter awaiter;
public TimeSpan delay;

void IAsyncStateMachine.MoveNext()
{
Implementacja została pominięta.
}

[DebuggerHidden]
void IAsyncStateMachine.SetStateMachine(
IAsyncStateMachine stateMachine)
{
this.builder.SetStateMachine(stateMachine);
}
}

Atrybuty nie są tu istotne. Oto ważne aspekty tego typu:


 Implementuje on interfejs IAsyncStateMachine, używany w infrastrukturze do
obsługi asynchroniczności. Ten interfejs obejmuje tylko dwie pokazane tu metody.
 Pola, gdzie zapisywane są informacje, które maszyna stanowa musi zapamięty-
wać między kolejnymi krokami.
 Metoda MoveNext(), wywoływana raz w momencie uruchomienia maszyny stano-
wej i raz po każdym wznowieniu pracy maszyny po jej wstrzymaniu.
 Metoda SetStateMachine(), która w wersji produkcyjnej kodu zawsze ma tę samą
implementację.

87469504f326f0d7c1fcda56ef61bd79
8
236 ROZDZIAŁ 6. Implementacja asynchroniczności

Widziałeś już jedno zastosowanie typu z implementacją interfejsu IAsyncStateMachine,


choć było ono nieco ukryte. AsyncTaskMethodBuilder.Start() to metoda generyczna
z ograniczeniem, zgodnie z którym jako parametr określający typ trzeba podać typ
z implementacją wspomnianego interfejsu. Po wykonaniu operacji konfiguracyjnych
metoda Start() uruchamia metodę MoveNext(), aby maszyna stanowa zrobiła pierwszy
krok w metodzie asynchronicznej.
Używane w omawianym procesie pola można podzielić na pięć ogólnych kategorii:
 aktualny stan (np. nieuruchomiona, wstrzymana w określonym wyrażeniu
await itd.),
 builder metody używany do komunikowania się z infrastrukturą do obsługi
asynchroniczności i udostępniania zwracanego obiektu typu Task,
 awaitery,
 parametry i zmienne lokalne,
 zmienne tymczasowe na stosie.

Stan i builder są stosunkowo proste. Stan to liczba całkowita o jednej z następujących


wartości:
 -1 — nieuruchomiona lub aktualnie wykonywana (nie ma znaczenia, która
z tych sytuacji zachodzi),
 -2 — ukończona (z powodzeniem lub w wyniku błędu),
 inne wartości — wstrzymana w określonym wyrażeniu await.

Wcześniej wspomniałem, że typ buildera zależy od typu wartości zwracanej przez


metodę asynchroniczną. Przed wersją C# 7 typem buildera zawsze był AsyncVoidMethod
Builder, AsyncTaskMethodBuilder lub AsyncTaskMethodBuilder<T>. W C# 7 (z powodu
wprowadzenia niestandardowych typów zadań) do niestandardowego typu zadania
używany jest typ buildera podany w atrybucie AsyncTaskMethodBuilderAttribute.
Pozostałe pola są bardziej skomplikowane, ponieważ każde z nich zależy od ciała
metody asynchronicznej, a kompilator próbuje użyć tak niewielu pól, jak jest to moż-
liwe. Najważniejszą rzeczą do zapamiętania jest to, że potrzebne są pola na tylko te
wartości, które są niezbędne do wznowienia pracy maszyny stanowej w danym punkcie.
Czasem kompilator potrafi wykorzystać te same pola w różnych celach, a czasem może
zupełnie je pominąć.
Pierwszy przykład ilustrujący wielokrotne używanie pól dotyczy awaiterów. W danym
momencie ważny jest tylko jeden awaiter, ponieważ maszyna stanowa może w danej
chwili oczekiwać na tylko jedną wartość. Kompilator tworzy jedno pole dla każdego
używanego typu awaitera. Jeśli metoda asynchroniczna oczekuje na dwie wartości typu
Task<int>, jedną wartość typu Task<string> i trzy niegeneryczne wartości typu Task,
tworzone są trzy pola: TaskAwaiter<int>, TaskAwaiter<string> i niegeneryczny TaskAwa-
iter. Kompilator używa dla każdego wyrażenia await odpowiedniego pola na podstawie
typu awaitera.

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ść?

Ponadto występują też zmienne tymczasowe na stosie. Są tworzone, gdy wyrażenie


await jest używane w ramach większego wyrażenia i trzeba zapamiętać wartości pośred-
nie. W prostym przykładzie z listingu 6.1 nie jest to potrzebne, dlatego na listingu 6.2
występują tylko cztery pola: ze stanem, z builderem, z awaiterem i z parametrami.
Przykładem wymagającym zmiennej tymczasowej jest poniższa metoda:
public async Task TemporaryStackDemoAsync()
{
Task<int> task = Task.FromResult(10);
DateTime now = DateTime.UtcNow;
int result = now.Second + now.Hours * await task;
}

Używanie metody asynchronicznej nie zmienia reguł języka C# obowiązujących przy


przetwarzaniu operandów. Właściwości now.Second i now.Hours trzeba obliczyć przed
oczekiwaniem na zadanie, a wyniki trzeba zapamiętać, aby móc wykonać operacje
arytmetyczne później, gdy maszyna stanowa wznowi pracę po ukończeniu zadań. To
oznacza, że trzeba posłużyć się polami.

87469504f326f0d7c1fcda56ef61bd79
8
238 ROZDZIAŁ 6. Implementacja asynchroniczności

UWAGA. W omawianym scenariuszu programista wie, że wywołanie Task.FromResult


zawsze zwróci ukończone zadanie. Jednak kompilator tego nie wie, dlatego musi wygene-
rować maszynę stanową w taki sposób, aby mogła ona wstrzymywać i wznawiać pracę, jeśli
zadanie nie będzie ukończone.

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;
}

Później zmienne lokalne są przekształcane w pola. Inaczej niż w przypadku rzeczy-


wistych zmiennych lokalnych kompilator ponownie wykorzystuje tymczasowe zmienne
tego samego typu zapisane na stosie i generuje tylko tyle pól, ile jest niezbędnych.
To kończy objaśnianie wszystkich pól maszyny stanowej. Teraz trzeba przyjrzeć
się metodzie MoveNext() — na razie tylko na poziomie koncepcyjnym.

6.1.3. Metoda MoveNext() (ogólny opis)


Nie zamierzam prezentować zdekompilowanego kodu metody MoveNext() z listingu 6.1,
ponieważ jest długi i straszny1. Gdy będziesz wiedział, jak wygląda przepływ sterowa-
nia w tej metodzie, łatwiej przyjdzie Ci ją zrozumieć. Dlatego tu jest ona opisana na
abstrakcyjnym poziomie.
Po każdym wywołaniu metody MoveNext() maszyna stanowa wykonuje kolejny krok.
Za każdym razem, gdy dotrze do wyrażenia await, kontynuuje pracę, jeśli oczekiwana
wartość jest już dostępna, a w przeciwnym razie wstrzymuje działanie. Metoda Move
Next() zwraca sterowanie po wystąpieniu jednego z następujących warunków:
 maszyna stanowa musi wstrzymać pracę w oczekiwaniu na niegotową wartość,
 wykonanie dochodzi do końca metody lub instrukcji return,
 wyjątek w metodzie asynchronicznej jest zgłaszany, ale nie jest przechwytywany.

Warto zauważyć, że w ostatnim z tych scenariuszy metoda MoveNext() nie zgłasza


wyjątku. Zamiast tego zadanie powiązane z wywołaniem asynchronicznym jest oznaczane
jako zakończone niepowodzeniem. Jeśli jest to dla Ciebie zaskoczeniem, w punkcie 5.6.5
znajdziesz przypomnienie obsługi wyjątków w metodach asynchronicznych.
Na rysunku 6.2 pokazany jest ogólny schemat blokowy działania metody asynchro-
nicznej. Najważniejsza jest tu metoda MoveNext(). Na rysunku nie przedstawiłem obsługi
wyjątków, ponieważ na schematach blokowych nie da się przedstawić bloków try/catch.
Z obsługą tych bloków zapoznasz się, gdy przyjrzysz się kodowi. Nie pokazałem tu
też, jak wywoływana jest metoda SetStateMachine, ponieważ przedstawiony schemat
blokowy i tak jest wystarczająco skomplikowany.
1
Gdyby film Ludzie honoru poświęcony był asynchroniczności, znany cytat brzmiałby: „Chcesz
MoveNext? Nie zniósłbyś MoveNext”.

87469504f326f0d7c1fcda56ef61bd79
8
6.1. Struktura wygenerowanego kodu 239

Rysunek 6.2. Schemat blokowy metody asynchronicznej

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

6.1.4. Metoda SetStateMachine i taniec


z opakowywaniem maszyny stanowej
Przedstawiłem już implementację metody SetStateMachine. Jest ona prosta:
void IAsyncStateMachine.SetStateMachine(
IAsyncStateMachine stateMachine)
{
this.builder.SetStateMachine(stateMachine);
}

Implementacja w wersji produkcyjnej zawsze wygląda w ten sposób. W wersjach


diagnostycznych, gdzie maszyna stanowa jest klasą, implementacja jest pusta. Łatwo
jest objaśnić ogólne przeznaczenie tej metody, jednak szczegóły są skomplikowane.
Gdy maszyna stanowa wykonuje pierwszy krok, jest zapisana na stosie jako zmienna
lokalna metody kontrolnej. Po wstrzymaniu trzeba ją opakować w obiekt (na stosie), aby
wszystkie informacje były dostępne po wznowieniu pracy. Po opakowaniu maszyny
stanowej metoda SetStateMachine jest wywoływana dla opakowanej wartości (ta wartość
jest podawana jako argument). Oznacza to, że gdzieś w głębi infrastruktury działa kod,
który wygląda mniej więcej tak:
void BoxAndRemember<TStateMachine>(ref TStateMachine stateMachine)
where TStateMachine : IStateMachine
{
IStateMachine boxed = stateMachine;
boxed.SetStateMachine(boxed);
}

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

rozdziału. We wszystkich innych kontekstach i celach pokazany tu kod jest przede


wszystkim ciekawostką.
Z tych komponentów składa się maszyna stanowa. Większość reszty rozdziału dotyczy
metody MoveNext() i jej działania w różnych sytuacjach. Zaczniemy od prostego przy-
padku, a potem zostanie on rozbudowany.

6.2. Prosta implementacja metody MoveNext()


Zaczniemy od prostej metody asynchronicznej pokazanej na listingu 6.1. Jest ona prosta
nie dlatego, że jest krótka (choć jest to pomocne), ale dlatego, że nie zawiera żadnych
pętli, instrukcji try ani instrukcji using. Przepływ sterowania w tej metodzie jest nie-
skomplikowany, dzięki czemu powstaje stosunkowo prosta maszyna stanowa. Pora
przejść do analizy.

6.2.1. Kompletny konkretny przykład


Przedstawię tu kompletną metodę, która jest punktem wyjścia. Nie oczekuj, że od razu
ją zrozumiesz. Poświęć jednak kilka minut na jej prześledzenie. Dzięki temu kon-
kretnemu przykładowi łatwiej jest zrozumieć ogólną strukturę, ponieważ zawsze możesz
sprawdzić, jak każdy fragment tej struktury wygląda w przykładowym kodzie. Zary-
zykuję tym, że Cię zanudzę, i jeszcze raz przytoczę listing 6.1, aby przypomnieć dane
wejściowe dla kompilatora:
static async Task PrintAndWait(TimeSpan delay)
{
Console.WriteLine("Przed pierwszą przerwą");
await Task.Delay(delay);
Console.WriteLine("Między przerwami");
await Task.Delay(delay);
Console.WriteLine("Po drugiej przerwie");
}

Na listingu 6.3 pokazana jest wersja zdekompilowanego kodu zmodyfikowana nieco


w celu zwiększenia czytelności. Naprawdę, tak wygląda bardziej czytelna wersja.

Listing 6.3. Zdekompilowana metoda MoveNext() z listingu 6.1

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

6.2.2. Ogólna struktura metody MoveNext()


Dotarliśmy do następnej warstwy asynchronicznej cebuli. Metoda MoveNext() jest ser-
cem asynchronicznej metody stanowej, a jej złożoność przypomina, jak trudno jest pisać
poprawny kod asynchroniczny. Im bardziej złożona jest maszyna stanowa, tym wię-
cej powodów do wdzięczności za to, że to kompilator języka C# generuje kod za
programistę.

UWAGA. Aby zachować zwięzłość, pora wprowadzić dodatkową terminologię. W każdym


wyrażeniu await oczekiwana wartość może być już gotowa lub nie. Jeśli jest już gotowa,
maszyna stanowa kontynuuje pracę. Nazywam ten scenariusz szybką ścieżką. Jeśli wartość
nie jest jeszcze gotowa, maszyna stanowa planuje wykonanie kontynuacji i wstrzymuje pracę.
Jest to wolna ścieżka.

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.

Listing 6.4. Pseudokod metody MoveNext()

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

MethodStart: Kod przed pierwszym


wyrażeniem await.
Konfigurowanie pierwszego awaitera.
Label0A:
Kod wznawiający pracę po kontynuacji
Label0B:
Połączenie ścieżek szybkiej i wolnej.
Reszta kodu z pozostałymi etykietami, awaiterami itd.
}
catch (Exception e)
{
this.state = -2; Przekazywanie z użyciem buildera wszystkich wyjątków.
builder.SetException(e);
return;
}
this.state = -2;
Przekazywanie z użyciem buildera informacji o ukończeniu pracy metody.
builder.SetResult();
}

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.

Skomplikowanym aspektem, o którym warto pamiętać, jest różnica między instrukcją


return w maszynie stanowej a instrukcją return w oryginalnym kodzie asynchronicznym.
W maszynie stanowej instrukcja return jest używana, gdy maszyna stanowa zostaje
wstrzymana po zaplanowaniu kontynuacji dla awaitera. Natomiast każda instrukcja
return z pierwotnego kodu zostaje przeniesiona do końcowej części maszyny stanowej,
poza blok try/catch, w którym builder przekazuje informacje o ukończeniu metody.

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.

6.2.3. Zbliżenie na wyrażenia await


Pomyśl ponownie, co musi się stać za każdym razem po dojściu do wyrażenia await
w trakcie wykonywania metody asynchronicznej. Zakładamy, że operand został już
przetworzony i uzyskano obiekt awaitable:
1. Za pomocą wywołania GetAwaiter() awaiter jest pobierany z obiektu awaitable.
Awaiter jest następnie zapisywany na stosie.
2. Kod sprawdza, czy zadanie powiązane z awaiterem zostało ukończone. Jeśli tak
jest, można przeskoczyć bezpośrednio do pobierania wyniku (krok 9.). Jest to
szybka ścieżka.
3. Wygląda na to, że kod znajduje się na wolnej ścieżce. No cóż. W polu ze stanem
należy zapamiętać, dokąd kod dotarł.
4. Awaiter jest zapamiętywany w polu.
5. Planowana jest kontynuacja powiązana z awaiterem. Kod upewnia się, że gdy
kontynuacja będzie wykonywana, program wróci do właściwego stanu (w razie
potrzeby wykonywany jest taniec z opakowywaniem).
6. Zwrócenie sterowania z metody MoveNext() albo do pierwotnej jednostki wywo-
łującej (jeśli jest to pierwsze wstrzymanie), albo do jednostki, która zaplanowała
kontynuację.
7. Po uruchomieniu kontynuacji stan jest ustawiany na wykonywana (wartość -1).
8. Kopiowanie awaitera z pola na stos i opróżnienie pola, aby pomóc w pracy
mechanizmowi przywracania pamięci. Teraz można wrócić na szybką ścieżkę.
9. Pobieranie wyniku z awaitera, który teraz — niezależnie od używanej ścieżki —
znajduje się na stosie. Trzeba wywołać metodę GetResult() nawet wtedy, jeśli nie
istnieje wartość wyniku. Pozwala to awaiterowi w razie potrzeby przekazać błędy.
10. Kontynuowanie udanego wykonywania programu i przetwarzanie reszty pier-
wotnego kodu z użyciem wartości wyniki, jeśli jest ona dostępna.

W tym kontekście przyjrzyj się pokazanemu na listingu 6.5 fragmentowi listingu 6.3
odpowiadającemu pierwszemu wyrażeniu await.

Listing 6.5. Fragment listingu 6.3 odpowiadający jednemu 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.

Wywołanie builder.AwaitUnsafeOnCompleted(ref awaiter1, ref this) odpowiada za taniec


z opakowywaniem i wywołaniem SetStateMachine (jeśli jest potrzebne; wykonywane
jest tylko raz dla każdej maszyny stanowej) oraz planowanie kontynuacji. W niektórych
sytuacjach używane jest wywołanie AwaitOnCompleted zamiast AwaitUnsafeOnCompleted.
Różnią się one tylko obsługą kontekstu wykonania. Więcej szczegółów opisanych jest
w podrozdziale 6.5.
Aspektem, który może być niejasny, jest używanie zmiennej lokalnej num. Wartość
zawsze jest przypisywana do niej w tym samym momencie, co do pola state, jednak
odczyt zawsze dotyczy tej zmiennej, a nie pola. Pierwotna wartość jest kopiowana z pola,
ale jest to jedyna sytuacja, gdy odczyt dotyczy pola. Podejrzewam, że jest to związane
wyłącznie z optymalizacją. Dlatego zawsze, gdy wczytujesz zmienną num, możesz
traktować ją jak wartość this.state.
Na listingu 6.5 znajduje się 16 wierszy kodu odpowiadających jednej pierwotnej
instrukcji:
await Task.Delay(delay);

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

6.3. Jak przepływ sterowania


wpływa na metodę MoveNext()?
Przykład omawiany do tej pory to tylko sekwencja wywołań metod, a jedynym złożonym
aspektem jest operator await. Sytuacja się komplikuje, gdy chcesz pisać rzeczywisty
kod, z wszystkimi standardowymi instrukcjami przepływu sterowania, do których jesteś
przyzwyczajony.
W tym podrozdziale prezentuję tylko dwa elementy przepływu sterowania: pętle
i instrukcje try/finally. To omówienie nie ma być kompletne, ma jednak zapewnić
wystarczający obraz manipulacji przepływem sterowania, jakie kompilator musi wyko-
nać, aby pomóc Ci w razie potrzeby zrozumieć inne scenariusze.

6.3.1. Przepływ sterowania między wyrażeniami await jest prosty


Przed przejściem do skomplikowanych aspektów prezentuję przykład, w którym prze-
pływ sterowania nie zwiększa złożoności generowanego kodu w większym stopniu niż
w kodzie synchronicznym. Na listingu 6.6 w przykładowej metodzie znajduje się pętla,
dlatego tekst Między przerwami wyświetlany jest trzykrotnie, a nie tylko raz.

Listing 6.6. Dodanie pętli między wyrażeniami await

static async Task PrintAndWaitWithSimpleLoop(TimeSpan delay)


{
Console.WriteLine("Przed pierwszą przerwą");
await Task.Delay(delay);
for (int i = 0; i < 3; i++)
{
Console.WriteLine("Między przerwami");
}
await Task.Delay(delay);
Console.WriteLine("Przed drugą przerwą");
}

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();

przyjmuje następującą postać:


GetFirstAwaitResult:
awaiter1.GetResult();
for (int i = 0; i < 3; i++)
{
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.

6.3.2. Oczekiwanie w pętli


W omawianym przykładzie na razie znajdują się dwa wyrażenia await. Aby ułatwić
zrozumienie kodu po dodaniu nowych komplikacji, warto usunąć jedno z nich. Na
listingu 6.7 pokazana jest metoda asynchroniczna, która zostanie zdekompilowana
w tym punkcie.

Listing 6.7. Oczekiwanie w pętli

static async Task AwaitInLoop(TimeSpan delay)


{
Console.WriteLine("Przed pętlą");
for (int i = 0; i < 3; i++)
{
Console.WriteLine("Przed wyrażeniem await w pętli");
await Task.Delay(delay);
Console.WriteLine("Po wyrażeniu await w pętli");
}
Console.WriteLine("Po pętli");
}

Wywołania Console.WriteLine służą tu za znaki drogowe w zdekompilowanym kodzie,


pomagające powiązać go z pierwotnym listingiem.
Co kompilator generuje na podstawie nowego listingu? Nie prezentuję tu komplet-
nego kodu, ponieważ w większości jest podobny do kodu omówionego wcześniej.
Znajdziesz jednak kompletny przykład w kodzie źródłowym powiązanym z książką.
Metoda kontrolna i maszyna stanowa są prawie identyczne jak we wcześniejszych
przykładach, przy czym w maszynie stanowej używane jest dodatkowe pole odpowia-
dające licznikowi pętli — i. Ciekawa jest natomiast metoda MoveNext().
Wygenerowany kod można bezpośrednio odzwierciedlić w C#, ale bez używania
pętli. Problem polega na tym, że gdy maszyna stanowa wznowi działanie po wstrzymaniu
pracy spowodowanym wywołaniem Task.Delay, należy przeskoczyć do wnętrza pierwotnej
pętli. W C# nie można użyć do tego instrukcji goto. Język nie zezwala na podawanie
etykiety w instrukcji goto, jeśli ta instrukcja nie znajduje się w zasięgu danej etykiety.
Nie jest to jednak problemem. Można zaimplementować pętlę for z dużą liczbą
instrukcji goto bez wprowadzania dodatkowych zasięgów. W ten sposób można łatwo
przeskakiwać w dowolne miejsce. Na listingu 6.8 pokazana jest duża część zdekom-
pilowanego kodu ciała metody MoveNext(). Zamieściłem tu fragment z bloku try, ponie-
waż to on jest najważniejszy w tym kontekście. Reszta to prosty szablonowy kod.

87469504f326f0d7c1fcda56ef61bd79
8
6.3. Jak przepływ sterowania wpływa na metodę MoveNext()? 249

Listing 6.8. Zdekompilowana pętla bez używania pętli

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.

6.3.3. Oczekiwanie w bloku try/finally


Warto przypomnieć, że używanie wyrażenia await w bloku try zawsze było dozwolone,
jednak w C# 5 niedopuszczalne było stosowanie tego wyrażenia w blokach catch
i finally. W C# 6 to ograniczenie zniesiono, choć nie będę tu prezentował kodu,
w którym wykorzystano tę zmianę.
UWAGA. Istnieje zbyt wiele możliwości, aby omówić je wszystkie w tym miejscu. Celem tego
rozdziału jest pokazanie, jak kompilator języka C# obsługuje mechanizm async/await. Nie ma
to być kompletna lista przekształceń.

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.

Listing 6.9. Oczekiwanie w bloku try

static async Task AwaitInTryFinally(TimeSpan delay)


{
Console.WriteLine("Przed blokiem try");
await Task.Delay(delay);
try
{
Console.WriteLine("Przed wyrażeniem await");
await Task.Delay(delay);
Console.WriteLine("Po wyrażeniu await");
}
finally
{
Console.WriteLine("W bloku finally");
}
Console.WriteLine("Po bloku finally");
}

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

Listing 6.10. Zdekompilowane wyrażenie await w bloku try/finally

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");

Obiecuję, że to ostatnia dekompilacja w tym rozdziale. Chciałem pokazać ten poziom


złożoności, aby pomóc Ci zrozumieć wygenerowany kod, jeśli kiedyś będziesz musiał
to zrobić. Nie chcę przez to powiedzieć, że nie będziesz musiał zastanowić się nad
takim kodem — zwłaszcza jeśli wziąć pod uwagę liczne transformacje, jakie kompilator
może wykonać, aby uprościć kod w stosunku do pokazanej tu wersji. Wcześniej
wspomniałem, że w miejscach, gdzie zawsze używałem instrukcji switch typu „prze-

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.

6.4. Kontekst wykonania i przekazywanie kontekstu


W punkcie 5.2.2 opisałem konteksty synchronizacji, używane do zarządzania wątkiem,
w którym kod działa. Jest to tylko jeden z wielu kontekstów używanych w platformie
.NET, choć zapewne najlepiej znany. Kontekst służy do automatycznego zachowywania
informacji. Na przykład kontekst typu SecurityContext zapisuje podmiot zabezpieczeń
i ustawienia mechanizmu CAS. Nie musisz jawnie przekazywać tych informacji. Są one
dostępne razem z kodem i w prawie wszystkich sytuacjach daje to pożądany efekt. Do
zarządzania wszystkimi innymi kontekstami służy jedna klasa — ExecutionContext.

Zawiłe i trudne zagadnienia


Mało brakowało, a pominąłbym ten podrozdział. Dotarłem w nim do samych granic mojej
wiedzy na temat asynchroniczności. Gdybyś kiedyś chciał dokładnie opanować szczegóły
asynchroniczności, powinieneś wiedzieć na ten temat znacznie więcej, niż opisałem w tym
miejscu.
Dodałem ten podrozdział tylko dlatego, że w innej sytuacji nie wyjaśniłbym, po co stoso-
wać metody AwaitOnCompleted i AwaitUnsafeOnCompleted w builderze lub dlaczego w awaiterach
zwykle implementowany jest interfejs ICriticalNotifyCompletion.

W ramach przypomnienia warto wspomnieć, że typy Task i Task<T> zarządzają kon-


tekstem synchronizacji wszystkich zadań, na które program oczekuje. Jeśli kod działa
w interfejsie użytkownika i oczekuje na zadanie, kontynuacja metody asynchronicznej
także będzie wykonywana w interfejsie użytkownika. Możesz to zmienić za pomocą
wywołania Task.ConfigureAwait. Jest ono potrzebne, aby bezpośrednio stwierdzić:
„Wiem, że nie potrzebuję, by reszta metody działała w tym samym kontekście synchro-
nizacji”. Konteksty wykonania działają inaczej. Gdy metoda asynchroniczna kontynuuje
pracę, prawie zawsze należy zachować ten sam kontekst wykonania, nawet jeśli metoda
będzie działać w innym wątku.
To zachowywanie kontekstu wykonania jest nazywane przekazywaniem. Kontekst
wykonania jest przekazywany między wyrażeniami await, co oznacza, że cały kod działa
w tym samym kontekście wykonania. Co jest tego gwarancją? No cóż, typ AsyncTask
MethodBuilder zawsze gwarantuje przekazywanie kontekstu, ale typ TaskAwaiter robi
to tylko w niektórych sytuacjach. W tym miejscu sytuacja się komplikuje.
INotifyCompletion.OnCompleted jest zwykłą metodą. Każdy obiekt może ją wywołać.
Z kolei metoda ICriticalNotifyCompletion.UnsafeOnCompleted jest opatrzona modyfi-
katorem [SecurityCritical]. To oznacza, że może być wywoływana tylko przez zaufany
kod, np. w klasie AsyncTaskMethodBuilder platformy.

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ń.

6.5. Jeszcze o niestandardowych typach zadań


Na listingu 6.11 przypominam kod buildera z listingu 5.10, gdzie po raz pierwszy poka-
zane były niestandardowe typy zadań. Teraz po przyjrzeniu się wielu zdekompilowa-
nym maszynom stanowym ten zestaw metod może wyglądać dużo bardziej znajomo.
Ten podrozdział możesz potraktować jako przypomnienie sposobu wywoływania
metod typu AsyncTaskMethodBuilder, ponieważ kompilator traktuje wszystkie buildery
w ten sam sposób.

Listing 6.11. Przykładowy niestandardowy builder zadań

public class CustomTaskBuilder<T>


{
public static CustomTaskBuilder<T> Create();
public void Start<TStateMachine>(ref TStateMachine stateMachine)
where TStateMachine : IAsyncStateMachine;
public CustomTask<T> Task { get; }

public void AwaitOnCompleted<TAwaiter, TStateMachine>


(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : INotifyCompletion
where TStateMachine : IAsyncStateMachine;

87469504f326f0d7c1fcda56ef61bd79
8
Podsumowanie 255

public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>


(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : INotifyCompletion
where TStateMachine : IAsyncStateMachine;
public void SetStateMachine(IAsyncStateMachine stateMachine);

public void SetException(Exception exception);


public void SetResult(T result);
}

Pogrupowałem tu metody w standardowym chronologicznym porządku ich wywoływania.


Metoda kontrolna wywołuje metodę Create, aby utworzyć instancję buildera
w ramach nowo utworzonej maszyny stanowej. Następnie wywoływana jest metoda Start,
aby wykonać pierwszy krok w maszynie stanowej i zwrócić wynik właściwości Task.
W maszynie stanowej każde wyrażenie await odpowiada wygenerowanemu wywo-
łaniu AwaitOnCompleted lub AwaitUnsafeOnCompleted, co zostało opisane w poprzednim
podrozdziale. Jeśli używany jest podobny projekt jak w zadaniach, pierwsze tego rodzaju
wywołanie spowoduje uruchomienie metody IAsyncStateMachine.SetStateMachine, która
z kolei wywoła metodę SetStateMachine buildera, aby opakowywanie było obsługiwane
w spójny sposób. Jeśli chcesz przypomnieć sobie szczegóły, zajrzyj do punktu 6.1.4.
Na zakończenie maszyna stanowa wywołuje metodę SetException lub SetResult
buildera, aby poinformować, że operacja asynchroniczna ukończyła pracę. Końcowy
stan należy przekazać do niestandardowego zadania, które zostało wcześniej zwrócone
przez metodę kontrolną.
Ten rozdział jest zdecydowanie najbardziej szczegółowy w tej książce. Nigdzie
indziej nie analizuję tak dogłębnie kodu wygenerowanego przez kompilator języka C#.
Dla wielu programistów cała zawartość tego rozdziału może okazać się zbyteczna.
W praktyce nie potrzebujesz tych informacji, aby pisać poprawny kod asynchroniczny
w C#. Mam jednak nadzieję, że rozdział ten był pouczający dla ciekawskich progra-
mistów. Możliwe, że nigdy nie będziesz musiał dekompilować wygenerowanego kodu.
Jednak wiedza o tym, co dzieje się na zapleczu, może być przydatna. Ponadto jeśli
kiedykolwiek będziesz musiał szczegółowo przeanalizować to, co dzieje się w kodzie,
mam nadzieję, że ten rozdział pomoże Ci to zrozumieć.
Przeznaczyłem dwa rozdziały na omówienie najważniejszego mechanizmu wprowa-
dzonego w C# 5. W następnym krótkim rozdziale omawiam dwa pozostałe mechanizmy.
Po szczegółach związanych z asynchronicznością poczujesz trochę ulgi.

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

 Interfejsy INotifyCompletion i ICriticalNotifyCompletion pomagają zarządzać


przekazywaniem kontekstu wykonywania kodu.
 Kompilator języka C# wywołuje metody niestandardowych builderów zadań.

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

Gdyby C# został zaprojektowany z myślą o autorach książek, ten rozdział w ogóle by


nie powstał lub miałby standardową długość. Mógłbym napisać, że chciałem dodać
bardzo krótki rozdział w ramach przepłukania podniebienia po daniu w postaci asyn-
chroniczności z C# 5, a przed deserem w formie C# 6. Jednak tak naprawdę dwie
dodatkowe zmiany z C# 5, które trzeba omówić, nie zmieściłyby się w rozdziałach
poświęconych asynchroniczności. Pierwsza ze zmian to nie tyle mechanizm, co poprawka
wcześniejszego błędu w projekcie języka.

7.1. Przechwytywanie zmiennych w pętlach foreach


Przed C# 5 pętle foreach były opisane w specyfikacji języka tak, jakby w każdej pętli
deklarowana była jedna zmienna iteracyjna, przeznaczona tylko do odczytu w pierwot-
nym kodzie, ale przyjmująca różną wartość w każdej iteracji pętli. Na przykład w C# 3
pętla foreach dla kolekcji typu List<string> wygląda tak:

87469504f326f0d7c1fcda56ef61bd79
8
258 ROZDZIAŁ 7. Dodatkowe mechanizmy z C# 5

foreach (string name in names)


{
Console.WriteLine(name);
}

Jest to w przybliżeniu odpowiednik następującego kodu:


string name; Deklaracja jednej zmiennej iteracyjnej.
using (var iterator = names.GetEnumerator()) Niewidoczna zmienna iteratora.
{
while (iterator.MoveNext())
{ Przypisywanie nowej wartości do zmiennej
name = iterator.Current; iteracyjnej w każdej iteracji.
Console.WriteLine(name); Pierwotne ciało pętli foreach.
}
}

UWAGA. W specyfikacji znajduje się wiele szczegółowych informacji na temat konwersji


kolekcji i elementów, nie są one jednak powiązane z omawianą tu zmianą. Ponadto zasięg
zmiennej iteracyjnej jest ograniczony do pętli. Możesz sobie wyobrazić, że dookoła całego kodu
znajduje się dodatkowa para nawiasów klamrowych.

W C# 1 takie rozwiązanie było akceptowalne, jednak w C# 2, wraz z wprowadzeniem


metod anonimowych, zaczęło powodować problemy. W C# 2 po raz pierwszy zmienną
można było przechwycić i znacznie zmienić jej czas życia. Zmienna jest przechwytywana,
gdy jest używana w funkcji anonimowej, a kompilator musi wykonać na zapleczu ope-
racje pozwalające w naturalny sposób korzystać z takiej zmiennej. Choć metody ano-
nimowe w C# 2 były użyteczne, mam wrażenie, że dopiero w C# 3 dzięki wprowadze-
niu wyrażeń lambda i technologii LINQ programiści zostali zachęceni do częstszego
korzystania z delegatów.
Na czym polega problem z wcześniejszym sposobem rozwijania pętli foreach
z użyciem jednej zmiennej iteracyjnej? Gdyby ta zmienna została przechwycona w funk-
cji anonimowej wiązanej z delegatem, to po każdym wywołaniu delegata używałby on
aktualnej wartości tej jednej zmiennej. Na listingu 7.1 pokazany jest konkretny przykład.

Listing 7.1. Przechwytywanie zmiennej iteracyjnej w pętli foreach

List<string> names = new List<string> { "x", "y", "z" };


var actions = new List<Action>();
foreach (string name in names) Iterowanie po liście nazw.
{
actions.Add(() => Console.WriteLine(name)); Tworzenie delegata przechwytującego
} zmienną name.
foreach (Action action in actions)
{
Wykonywanie wszystkich delegatów.
action();
}

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

W C# 5 specyfikacja pętli foreach została zmodyfikowana, aby w każdej iteracji pętli


wprowadzana była nowa zmienna. Dokładnie ten sam kod w C# 5 i późniejszych wer-
sjach daje oczekiwany wynik x, y i z.
Warto zauważyć, że opisana zmiana dotyczy tylko pętli foreach. Gdybyś zamiast niej
użył zwykłej pętli for, nadal przechwytywana byłaby jedna zmienna. Listing 7.2 jest
prawie identyczny z listingiem 7.1; zmiany zostały wyróżnione pogrubieniem.

Listing 7.2. Przechwytywanie zmiennej iteracyjnej w pętli for

List<string> names = new List<string> { "x", "y", "z" };


var actions = new List<Action>();
for (int i = 0; i < names.Count; i++) Iterowanie po liście nazw.
{
actions.Add(() => Console.WriteLine(names[i])); Tworzenie delegata, który
} przechwytuje zmienne names oraz i.
foreach (Action action in actions)
{
Wykonywanie wszystkich delegatów.
action();
}

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.

7.2. Atrybuty z informacjami o jednostce wywołującej


Niektóre mechanizmy są ogólne. Dotyczy to np. wyrażeń lambda, zmiennych lokalnych
z typowaniem niejawnym, typów generycznych itd. Inne są bardziej specyficzne. LINQ
służy do tworzenia zapytań o dane, choć obsługuje wiele źródeł danych. Ostatni z nowych
mechanizmów z C# 5 jest wysoce specyficzny. Ma dwa istotne zastosowania (jedno
oczywiste, drugie mniej) i nie spodziewam się, by był często używany w innych sce-
nariuszach.

7.2.1. Podstawowe działanie


W .NET 4.5 wprowadzono trzy nowe atrybuty:
 CallerFilePathAttribute,
 CallerLineNumberAttribute,
 CallerMemberNameAttribute.

Wszystkie one znajdują się w przestrzeni nazw System.Runtime.CompilerServices. Gdy


je podajesz, to — podobnie jak w przypadku innych atrybutów — możesz pominąć

87469504f326f0d7c1fcda56ef61bd79
8
260 ROZDZIAŁ 7. Dodatkowe mechanizmy z C# 5

przyrostek Attribute. Ponieważ jest to najczęstszy sposób używania atrybutów, w dal-


szym tekście odpowiednio skracam ich nazwy.
Trzy podane atrybuty można stosować tylko do parametrów i są przydatne wyłącz-
nie wtedy, gdy używa się ich do parametrów opcjonalnych odpowiednich typów. Pomysł
jest prosty — jeśli w miejscu wywołania nie podano argumentu, kompilator używa jako
argumentu bieżącego pliku, numeru wiersza lub nazwy składowej, zamiast stosować
normalną wartość domyślną. Jeżeli jednostka wywołująca poda argument, kompilator
używa go.

UWAGA. Typy parametrów w standardowych zastosowaniach to prawie zawsze int lub


string. Można też stosować inny typy, jeśli dostępne są odpowiednie konwersje. Jeśli jesteś
zainteresowany tym tematem, szczegółowe informacje znajdziesz w specyfikacji. Byłbym
jednak zaskoczony, gdybyś kiedykolwiek ich potrzebował.

Na listingu 7.3 pokazano przykład zastosowania wszystkich trzech omawianych atrybu-


tów oraz połączenie wartości podawanych przez kompilator i przez użytkownika.

Listing 7.3. Prosta ilustracja atrybutów z informacjami o jednostce wywołującej

static void ShowInfo(


[CallerFilePath] string file = null,
[CallerLineNumber] int line = 0,
[CallerMemberName] string member = null)
{
Console.WriteLine("{0}:{1} - {2}", file, line, member);
}

static void Main()


{ Kompilator podaje na podstawie kontekstu wszystkie
ShowInfo(); trzy argumenty.
ShowInfo("LiesAndDamnedLies.java", -10); Kompilator podaje na podstawie kontekstu
} tylko nazwę składowej.

Na moim komputerze dane wyjściowe z listingu 7.3 wyglądają tak:


C:\Users\jon\Projects\CSharpInDepth\Chapter07\CallerInfoDemo.cs:20 - Main
LiesAndDamnedLies.java:-10 – Main

Zwykle programiści nie podają fałszywych wartości żadnego z tych argumentów,


jednak przydatna jest możliwość bezpośredniego przekazywania wartości, zwłaszcza
jeśli ktoś chce zapisać w dzienniku jednostkę wywołującą bieżącą metodę, używając
tych samych atrybutów.
Nazwa składowej działa dla wszystkich składowych w oczywisty sposób. Wartości
domyślne atrybutów zwykle są nieistotne, jednak w punkcie 7.2.4 przejdziemy do
ciekawych przypadków brzegowych. Najpierw jednak przyjrzyjmy się dwóm wspo-
mnianym wcześniej standardowym scenariuszom. Najbardziej uniwersalny z nich
dotyczy rejestrowania informacji w dzienniku.

87469504f326f0d7c1fcda56ef61bd79
8
7.2. Atrybuty z informacjami o jednostce wywołującej 261

7.2.2. Rejestrowanie informacji w dzienniku


Najbardziej oczywista sytuacja, w której przydatne są informacje o jednostce wywo-
łującej, dotyczy zapisywania danych w pliku dziennika. Wcześniej w takiej sytuacji
zwykle tworzony był ślad stosu (np. z użyciem typu System.Diagnostics.StackTrace), aby
ustalić źródło wywołania. W platformach do zapisu danych w dziennikach takie infor-
macje są zwykle ukryte, jednak są dostępne — choć w nieatrakcyjnej postaci. To podej-
ście może też prowadzić do spadku wydajności i jest narażone na błędy z powodu
wewnątrzwierszowego rozwijania kodu przez kompilator JIT.
Łatwo jest dostrzec, w jaki sposób platforma do zapisu danych w dziennikach może
wykorzystać omawiany nowy mechanizm do rejestrowania niskim kosztem wyłącznie
informacji o jednostce wywołującej. Można nawet uzyskać numery wierszy i nazwy
składowych w wersji kodu z usuniętymi informacjami diagnostycznymi, a nawet z zasto-
sowanym zaciemnianiem kodu. Nowe atrybuty nie są oczywiście pomocne, gdy celem
jest zapisanie pełnego śladu stosu, jednak nie uniemożliwiają tego.
Na podstawie przeprowadzonych pod koniec 2017 r. szybkich analiz próbek kodu
wygląda na to, że omawiany mechanizm na razie nie jest powszechnie stosowany1.
Przede wszystkim nie widzę oznak, by był używany w interfejsie ILogger wykorzy-
stywanym w wielu miejscach w ASP.NET Core. Bardzo sensowne byłoby pisanie wła-
snych metod rozszerzających interfejs ILogger, korzystających z tych atrybutów i two-
rzących zapisywany w dzienniku odpowiedni obiekt ze stanem.
Nierzadko zdarza się, że w projektach stosowane są samodzielnie pisane proste
platformy zapisu danych w dzienniku, gdzie też można wykorzystać opisywane atry-
buty. W platformach, które są używane do zapisu danych w dziennikach i specyficzne
dla projektu, mniej istotne jest uwzględnianie wersji platformy .NET, które nie udo-
stępniają tych atrybutów.
UWAGA. Brak wydajnej systemowej platformy do zapisu danych w dziennikach to złożony
temat. Jest ważny zwłaszcza dla programistów bibliotek klas, którzy zamierzają udostępniać
mechanizmy zapisu danych w dzienniku, ale nie chcą dodawać zależności od zewnętrznych
narzędzi i nie wiedzą, z jakich platform zapisu danych w dzienniku korzystać będą użytkow-
nicy bibliotek.

Choć zapis danych w dzienniku wymaga współdziałania ze strony platform, zastosowanie


atrybutów w drugim scenariuszu jest znacznie prostsze.

7.2.3. Upraszczanie implementacji interfejsu


INotifyPropertyChanged
Mniej oczywiste zastosowanie tylko jednego z tych atrybutów, [CallerMemberName], może
stać się bardziej zrozumiałe, jeśli często implementujesz interfejs INotifyProperty
Changed. Jeśli nie znasz tego interfejsu, to wiedz, że jest on często używany w aplikacjach
z rozbudowanym klientem (w odróżnieniu od aplikacji sieciowych), aby umożliwiać

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

interfejsowi użytkownika reagowanie na zmiany w modelu lub widoku. Ten interfejs


znajduje się w przestrzeni nazw System.ComponentModel, dlatego nie jest powiązany z żadną
konkretną technologią tworzenia interfejsów użytkownika. Jest używany np. w technolo-
giach Windows Forms, WPF i Xamarin Forms. Jest to prosty interfejs z jednym zdarze-
niem typu PropertyChangedEventHandler. Jest to typ delegata o następującej sygnaturze:
public delegate void PropertyChangedEventHandler(
Object sender, PropertyChangedEventArgs e)

Typ PropertyChangedEventArgs ma jeden konstruktor:


public PropertyChangedEventArgs(string propertyName)

Typowa implementacja interfejsu INotifyPropertyChanged w wersjach starszych niż C# 5


mogła wyglądać tak jak na listingu 7.4.

Listing 7.4. Implementowanie interfejsu INotifyPropertyChanged w dawnym stylu

class OldPropertyNotifier : INotifyPropertyChanged


{
public event PropertyChangedEventHandler PropertyChanged;
private int firstValue;
public int FirstValue
{
get { return firstValue; }
set
{
if (value != firstValue)
{
firstValue = value;
NotifyPropertyChanged("FirstValue");
}
}
}

// Inne właściwości implementowane za pomocą tego samego wzorca.

private void NotifyPropertyChanged(string propertyName)


{
PropertyChangedEventHandler handler = PropertyChanged;
if (handler != null)
{
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
}

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.

Listing 7.5. Używanie informacji o jednostce wywołującej do implementowania


interfejsu INotifyPropertyChanged

if (value != firstValue)
{
firstValue = value; Zmiany w setterze właściwości.
NotifyPropertyChanged();
}

void NotifyPropertyChanged([CallerMemberName] string propertyName = null)


{
To samo ciało metody co wcześniej.
}

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.

7.2.4. Przypadki brzegowe dotyczące atrybutów z informacjami


o jednostce wywołującej
W prawie wszystkich scenariuszach oczywiste jest, jaką wartość kompilator powinien
podać dla atrybutów z informacjami o jednostce wywołującej. Warto jednak przyjrzeć
się miejscom, gdzie nie jest to oczywiste. Powinienem podkreślić, że omawiam to zagad-
nienie głównie jako ciekawostkę i w celu zwrócenia uwagi na projekt języka, a nie na
problemy wpływające na codzienne prace programistyczne. Zacznijmy od drobnego
ograniczenia.
DYNAMICZNIE WYWOŁYWANE SKŁADOWE
Infrastruktura związana z dynamicznym typowaniem pod wieloma względami próbuje
wymuszać w czasie wykonywania programu te same reguły, co zwykły kompilator
w czasie kompilacji. Jednak informacje o jednostce wywołującej nie są zachowywane
na potrzeby tej infrastruktury. Jeśli wywoływana składowa ma opcjonalny parametr

87469504f326f0d7c1fcda56ef61bd79
8
264 ROZDZIAŁ 7. Dodatkowe mechanizmy z C# 5

z atrybutem z informacjami o jednostce wywołującej, ale w wywołaniu odpowiedni


argument nie jest podany, wartość domyślna parametru jest używana tak, jakby tego
atrybutu nie było.
Aby to zmienić, kompilator przede wszystkim musiałby zachowywać wszystkie infor-
macje o numerach wierszy na potrzeby każdej dynamicznie wywoływanej składowej
(na wypadek, gdyby te informacje były potrzebne). Zwiększałoby to wielkość wyni-
kowego podzespołu, a w 99,9% sytuacji nie przynosiłoby korzyści. Ponadto w czasie
wykonywania programu potrzebne byłyby dodatkowe analizy, aby sprawdzić, czy infor-
macje o jednostce wywołującej są wymagane. Może to zakłócać działanie pamięci
podręcznej. Podejrzewam, że gdyby zespół projektowy odpowiedzialny za C# uznał,
iż jest to często występujący i ważny scenariusz, znalazłby odpowiednie rozwiązanie.
Ponadto uważam, że zupełnie zrozumiałe jest, iż projektanci języka uznali, że lepiej
będzie poświęcić czas na dużo przydatniejsze mechanizmy. Musisz jedynie pamiętać
o działaniu omawianego mechanizmu i je zaakceptować. W niektórych scenariuszach
dostępne są rozwiązania problemu.
Jeśli przekazujesz argument metody, który jest dynamiczny, ale nie musi taki być,
możesz zrzutować go na odpowiedni typ. Wtedy metoda będzie wywoływana w stan-
dardowy sposób bez dynamicznego typowania2. Jeśli naprawdę potrzebujesz dynamicz-
nego działania kodu, ale wiesz, że dla wywoływanej składowej używane są atrybuty
z informacjami o jednostce wywołującej, możesz jawnie wywołać metodę pomocniczą
używającą takiego atrybutu do zwracania wartości. Nie jest to eleganckie rozwiązanie,
ale w końcu chodzi o przypadek brzegowy. Na listingu 7.6 pokazany jest problem i oba
rozwiązania.

Listing 7.6. Atrybuty z informacjami o jednostce wywołującej i typowanie dynamiczne

static void ShowLine(string message,


[CallerLineNumber] int line = 0)
{ Metoda używająca numeru wiersza,
którą programista chce wywołać.
Console.WriteLine("{0}: {1}", line, message);
}

static int GetLineNumber(


[CallerLineNumber] int line = 0)
{ Metoda pomocnicza z drugiego rozwiązania.
return line;
}

static void Main() Proste wywołanie


{ dynamiczne Rozwiązanie pierwsze
dynamic message = "Jakiś komunikat"; — numer wiersza — rzutowanie wartości
to 0. w celu wyeliminowania
ShowLine(message);
typowania dynamicznego.
ShowLine((string) message);
ShowLine(message, GetLineNumber()); Rozwiązanie drugie — jawne podawanie numeru
} wiersza za pomocą metody pomocniczej.

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.

Pierwsze cztery sytuacje są zależne od implementacji. To od kompilatora zależy, jak


traktowane są takie przypadki. Piąty scenariusz (z inicjalizatorami) w ogóle nie jest opi-
sany w specyfikacji. W ostatnim przypadku (indeksery) zgodnie ze specyfikacją używana
jest nazwa Item, chyba że do indeksera zastosowano atrybut IndexerNameAttribute.
W kompilatorze Roslyn w pierwszych czterech scenariuszach używane są nazwy
występujące w kodzie pośrednim: .ctor, .cctor, Finalize i nazwy operatorów (np.
op_Addition). Jeśli chodzi o inicjalizatory, kompilator Roslyn używa nazw inicjowanych
pól, zdarzeń lub właściwości.
W kodzie źródłowym powiązanym z książką znajdziesz kompletny przykład ilustru-
jący wszystkie scenariusze. Nie prezentuję tu tego kodu, ponieważ wyniki są ciekawsze
niż sam kod. Używane są oczywiste nazwy i byłbym zaskoczony, gdyby w innym kom-
pilatorze podjęto inne decyzje. Odkryłem jednak różnicę między kompilatorami
w innym aspekcie — ustalaniu, kiedy kompilator w ogóle powinien podawać wartości
na podstawie atrybutów z informacjami o jednostce wywołującej.
WYWOŁANIA NIEJAWNEGO KONSTRUKTORA
Zgodnie ze specyfikacją języka C# 5 informacje o jednostce wywołującej powinny
być używane tylko wtedy, gdy funkcja jest jawnie wywoływana w kodzie źródłowym.
Wyjątkiem są tu wyrażenia reprezentujące zapytania, z natury powodujące rozwijanie
składni. Inne konstrukty języka C# oparte na wzorcach nie są powiązane z metodami
z parametrami opcjonalnymi, jednak w inicjalizatorach konstruktorów takie parametry

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

są istotne. Dekonstrukcja to mechanizm z C# 7; omówienie znajdziesz w podroz-


dziale 12.2. W specyfikacji języka konstruktory są podane jako przykład, w którym
informacje o składowej jednostki wywołującej nie są podawane przez kompilator, chyba
że wywołanie jest jawne. Na listingu 7.7 pokazana jest jedna abstrakcyjna klasa bazowa
(z konstruktorem używającym informacji o składowej jednostki wywołującej) i trzy
klasy pochodne.

Listing 7.7. Informacje o jednostce wywołującej w konstruktorze

public abstract class BaseClass


{ W konstruktorze klasy bazowej używane są atrybuty
protected BaseClass( z informacjami o jednostce wywołującej.
[CallerFilePath] string file = "Nieokreślony plik",
[CallerLineNumber] int line = -1,
[CallerMemberName] string member = "Nieokreślona składowa")
{
Console.WriteLine("{0}:{1} - {2}", file, line, member);
}
}

public class Derived1 : BaseClass { } Konstruktor bezparametrowy


jest dodawany niejawnie.
public class Derived2 : BaseClass
{
public Derived2() { } Konstruktor z niejawnym
} wywołaniem base().

public class Derived3 : BaseClass


{
public Derived3() : base() {} Jawne wywołanie base().
}

W kompilatorze Roslyn tylko w klasie Derived3 wyświetlane są rzeczywiste informacje


o jednostce wywołującej. W klasach Derived1 i Derived2, w których wywołanie konstruk-
tora klasy BaseClass jest niejawne, używane są podane w parametrach wartości domyślne
zamiast nazwy pliku, numeru wiersza i nazwy składowej.
Jest to zgodne ze specyfikacją języka C# 5, uważam jednak, że jest to błąd projek-
towy. Moim zdaniem większość programistów oczekiwałaby, że trzy pokazane klasy
pochodne są swoimi odpowiednikami. Ciekawe jest to, że kompilator Mono (mcs)
obecnie wyświetla dla każdej z tych klas pochodnych te same dane wyjściowe. Trzeba
poczekać, aby się przekonać, czy zmieni się specyfikacja języka lub kompilator Mono,
czy może w przyszłości nadal będzie występować niespójność.
WYWOŁANIA WYRAŻEŃ REPREZENTUJĄCYCH ZAPYTANIA
Wcześniej wspomniałem, że w specyfikacji języka wyrażenia reprezentujące zapytania
są wymienione jako miejsce, w którym informacje o jednostce wywołującej są poda-
wane przez kompilator nawet w sytuacji, gdy wywołanie jest niejawne. Wątpię, żeby
ta możliwość była często wykorzystywana. Jednak w kodzie źródłowym powiązanym

87469504f326f0d7c1fcda56ef61bd79
8
7.2. Atrybuty z informacjami o jednostce wywołującej 267

z książką przedstawiam kompletny przykład. Wymaga on za dużo kodu, aby można go


było w całości zamieścić w tym miejscu, jednak używanie takiego rozwiązania wygląda
tak jak na listingu 7.8.

Listing 7.8. Informacje o jednostce wywołującej w wyrażeniach reprezentujących


zapytania

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.

Choć ten kod zawiera zwykłe wyrażenie reprezentujące zapytanie, zastosowałem tu


nowe metody rozszerzające (z tej samej przestrzeni nazw co przykładowy kod, dlatego
są one znajdowane przed metodami z przestrzeni nazw System.Linq), które zawierają
atrybuty z informacjami o jednostce wywołującej. Dane wyjściowe ilustrują, że infor-
macje o jednostce wywołującej są przechwytywane w zapytaniu razem z danymi:
Dane:
QUICK, BROWN, JUMPED, OVER, LAZY
CallerInfo:
CallerInfoLinq.cs:91 - Main
CallerInfoLinq.cs:92 – Main

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.

Listing 7.9. Klasa atrybutu przechowująca informacje o jednostce wywołującej

[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;
}

public string File { get; }


public int Line { get; }
public string Member { get; }

public override string ToString() =>


$"{Path.GetFileName(File)}:{Line} - {Member}";
}

Aby zachować zwięzłość, w tej klasie używanych jest kilka mechanizmów z C# 6.


Jednak w tym miejscu ważne jest to, że dla parametrów konstruktora używane są atry-
buty z informacjami o jednostce wywołującej.
Co się stanie, gdy zastosujesz nowy atrybut MemberDescriptionAttribute? Na
listingu 7.10 jest on używany do klasy i różnych aspektów metody, aby zobaczyć uzy-
skany efekt.

Listing 7.10. Stosowanie atrybutu do klasy i metody

using MDA = MemberDescriptionAttribute; Sprawia, że kod używający


refleksji jest krótki.
[MemberDescription]
class CallerNameInAttribute Stosowanie atrybutu do klasy.
{
[MemberDescription]
public void Method<[MemberDescription] T>( Stosowanie atrybutu do metody na różne sposoby.
[MemberDescription] int parameter) { }

static void Main()


{
var typeInfo = typeof(CallerNameInAttribute).GetTypeInfo();
var methodInfo = typeInfo.GetDeclaredMethod("Method");
var paramInfo = methodInfo.GetParameters()[0];
var typeParamInfo =
methodInfo.GetGenericArguments()[0].GetTypeInfo();
Console.WriteLine(typeInfo.GetCustomAttribute<MDA>());
Console.WriteLine(methodInfo.GetCustomAttribute<MDA>());
Console.WriteLine(paramInfo.GetCustomAttribute<MDA>());

87469504f326f0d7c1fcda56ef61bd79
8
7.2. Atrybuty z informacjami o jednostce wywołującej 269

Console.WriteLine(typeParamInfo.GetCustomAttribute<MDA>());
}
}

W metodzie Main refleksja jest używana do pobrania atrybutu z wszystkich miejsc,


w których go zastosowano. Atrybut MemberDescriptionAttribute można też stosować
w innych miejscach: polach, właściwościach, indekserach itp. Możesz swobodnie eks-
perymentować z kodem źródłowym, aby zobaczyć, co dokładnie się stanie. Dla mnie
ciekawe jest to, że kompilator we wszystkich sytuacjach rejestruje numer wiersza
i ścieżkę do pliku, jednak nie używa nazwy klasy jako nazwy składowej. Dlatego dane
wyjściowe wyglądają tak:
CallerNameInAttribute.cs:36 - Nieokreślona składowa
CallerNameInAttribute.cs:39 - Method
CallerNameInAttribute.cs:40 - Method
CallerNameInAttribute.cs:40 – Method

Jest to zgodne ze specyfikacją języka C# 5, ponieważ określono w niej, jak program


ma działać, gdy atrybut jest stosowany do składowych w postaci funkcji (takich jak
metoda, właściwość, zdarzenie itd.), a nie do typu. Prawdopodobnie bardziej przydatne
byłoby uwzględnienie także typów. Są one zdefiniowane jako składowe przestrzeni
nazw, dlatego można przyjąć, że nazwą składowej może być także nazwa typu.
Warto powtórzyć, że ten punkt dodałem po to, aby omówienie było kompletne.
Opisane są tu ciekawe decyzje dotyczące języka. Kiedy w projekcie można zaakceptować
ograniczenia, aby uniknąć kosztów implementowania rozwiązania? Kiedy dopuszczalne
jest, by decyzja projektowa była sprzeczna z oczekiwaniami użytkownika? Kiedy sen-
sowne jest, aby w specyfikacji decyzja projektowa była jawnie przekształcana w wybór
z obszaru implementacji? O ile więcej czasu zespół projektujący język musiałby poświę-
cić na opracowanie specyfikacji przypadków brzegowych mało popularnego mecha-
nizmu (jest to zagadnienie z ogólnego poziomu)? Przed zakończeniem rozdziału należy
jeszcze omówić jeden praktyczny aspekt — jak umożliwić stosowanie omawianych
atrybutów w wersjach platformy, w których są one niedostępne?

7.2.5. Używanie atrybutów z informacjami o jednostce wywołującej


w starszych wersjach platformy .NET
Mam nadzieję, że obecnie większość czytelników używa już wersji .NET 4.5 i nowszych
lub specyfikacji .NET Standard 1.0 i nowszych. W obu przypadkach dostępne są atry-
buty z informacjami o jednostce wywołującej. Jednak w niektórych sytuacjach można
używać nowego kompilatora, ale kod musi działać w starszych wersjach platformy .NET.
W takich scenariuszach możesz używać atrybutów z informacjami o jednostce
wywołującej, musisz jednak udostępnić te atrybuty kompilatorowi. Najprościej użyć do
tego pakietu NuGet Microsoft.Bcl, który udostępnia te atrybuty i wiele innych mechani-
zmów wprowadzonych w nowszych wersjach platformy .NET.
Jeśli z jakiegoś powodu nie możesz użyć wspomnianego pakietu NuGet, możesz
samodzielnie udostępnić omawiane atrybuty. Są to proste atrybuty bez parametrów
i właściwości, dlatego możesz skopiować ich deklarację bezpośrednio z dokumentacji
interfejsu API. Muszą one jednak znaleźć się w przestrzeni nazw System.Runtime.Compiler

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

C # 6 to jedna z moich ulubionych wersji. Wprowadzono w niej wiele mechani-


zmów, które jednak są w większości niezależne od siebie, można je w prosty sposób obja-
śnić, a także łatwo zastosować w istniejącym kodzie. Pod niektórymi względami mogą
wydawać się mało interesujące, pozwalają jednak w znacznym stopniu poprawić czy-
telność kodu. Gdybym kiedykolwiek musiał pisać kod z użyciem starszych wersji języka
C#, to właśnie rozwiązań wprowadzonych w C# 6 brakowałoby mi najbardziej.
Choć w każdej wcześniejszej wersji C# zmieniano sposób myślenia o kodzie
(z powodu typów generycznych, technologii LINQ, typowania dynamicznego i mecha-
nizmu async/await), w C# 6 skupiono się bardziej na dopracowywaniu kodu, który już
istnieje.
Mechanizmy z tej wersji podzieliłem na trzy rozdziały, w których omawiam:
funkcje związane z właściwościami, funkcje związane z łańcuchami znaków i pozostałe
mechanizmy, przy czym podział ten jest dość arbitralny. Zachęcam do czytania tych
rozdziałów w takiej kolejności, w jakiej są prezentowane, przy czym — w odróżnieniu
od omówienia technologii LINQ — kolejne fragmenty nie wymagają ściśle informacji
z wcześniejszych.
Ponieważ mechanizmy wprowadzone w C# 6 są łatwe do stosowania w istniejącym
kodzie, zachęcam do eksperymentowania z nimi w trakcie lektury. Jeśli odpowiadasz
za projekt ze starszym kodem, którego od długiego czasu nie modyfikowałeś, może się
on okazać dobrym miejscem do przeprowadzenia refaktoryzacji z użyciem 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

Niektóre wersje języka C# obejmują jeden nadrzędny mechanizm wspomagany przez


prawie wszystkie pozostałe funkcje. Na przykład w C# 3 wprowadzono technologię
LINQ, a w C# 5 — asynchroniczność. W C# 6 jest inaczej, występuje tu jednak nad-
rzędny motyw. Prawie wszystkie mechanizmy sprzyjają powstawaniu bardziej przej-
rzystego, prostego i czytelnego kodu. W C# 6 nie chodzi o to, aby robić coś więcej.
Ważne jest, by wykonywać te same zadania z użyciem mniejszej ilości kodu.
Mechanizmy z tego rozdziału dotyczą właściwości i innych prostych fragmentów
kodu. Gdy logika jest prosta, wyeliminowanie nawet drobnych elementów składni — np.
nawiasów klamrowych i instrukcji return — może spowodować dużą różnicę. Choć
omawiane tu mechanizmy mogą nie robić dużego wrażenia, byłem zaskoczony ich
wpływem na rzeczywisty kod. Zaczniemy od właściwości, a następnie przejdziemy do
metod, indekserów i operatorów.

87469504f326f0d7c1fcda56ef61bd79
8
274 ROZDZIAŁ 8. Odchudzone właściwości i składowe z ciałem w postaci wyrażenia

8.1. Krótka historia właściwości


Właściwości były dostępne od pierwszej wersji języka C#. Choć podstawy ich działania
się nie zmieniły, z czasem uproszczono ich zapisywanie w kodzie źródłowym i zwięk-
szono dostępne możliwości. Właściwości pozwalają zróżnicować sposób modyfikowania
stanu i dostępu do niego w interfejsie API, a także sposób implementowania stanu.
Załóżmy, że chcesz przedstawić punkt w przestrzeni dwuwymiarowej. Możesz użyć
do tego publicznych pól, tak jak na listingu 8.1.

Listing 8.1. Klasa Point z publicznymi polami

public sealed class Point


{
public double X;
public double Y;
}

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.

Listing 8.2. Klasa Point z właściwościami w C# 1

public sealed class Point


{
private double x, y;
public double X { get { return x; } set { x = value; } }
public double Y { get { return y; } set { y = value; } }
}

Można stwierdzić, że wiele właściwości początkowo służy jedynie do odczytu i zapisu


pól, bez dodatkowego sprawdzania poprawności, przeprowadzania obliczeń i wykony-
wania podobnych zadań, a następnie nie zmienia się przez całą historię użytkowania

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.

UWAGA. Podobnie jak w prawie wszystkich dobrych praktycznych regułach, występują


tu wyjątki. W niektórych sytuacjach uzasadnione jest bezpośrednie udostępnianie pól. Cie-
kawą sytuację tego rodzaju zobaczysz w rozdziale 11., gdy przyjrzysz się udostępnianym
w C# 7 krotkom.

W C# 2 jedynym usprawnieniem właściwości było umożliwienie stosowania różnych


modyfikatorów dostępu dla gettera i settera — np. utworzenie publicznego gettera
i prywatnego settera. Nie jest to jedyna możliwa kombinacja, ale zdecydowanie najczę-
ściej używana.
Następnie w C# 3 dodano automatycznie implementowane właściwości. Pozwalają
one zmodyfikować listing 8.2 w prostszy sposób, pokazany na listingu 8.3.

Listing 8.3. Klasa Point z właściwościami w C# 3

public sealed class Point


{
public double X { get; set; }
public double Y { get; set; }
}

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.

Listing 8.4. Klasa Point z ręcznie zaimplementowanymi właściwościami przeznaczonymi


tylko do odczytu — wersja w C# 3

public sealed class Point


{ Deklarowanie pól przeznaczonych
private readonly double x, y; tylko do odczytu.
public double X { get { return x; } } Deklarowanie właściwości przeznaczonych
public double Y { get { return y; } } tylko do odczytu, zwracających wartości pól.

public Point(double x, double y)


{

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.

Listing 8.5. Klasa Point z publicznymi właściwościami tylko do odczytu automatycznie


zaimplementowanymi z użyciem prywatnych setterów — wersja w C# 3

public sealed class Point


{
public double X { get; private set; }
public double Y { get; private set; }

public Point(double x, double y)


{
X = x;
Y = y;
}
}

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.

8.2. Usprawnienia automatycznie


implementowanych właściwości
W C# 6 wprowadzono dwie nowe cechy automatycznie implementowanych właściwości.
Obie są proste do objaśnienia i w użyciu. W poprzednim punkcie skupiłem się na
znaczeniu udostępniania właściwości zamiast pól publicznych i trudnościach ze zwię-
złym implementowaniem niemodyfikowalnych typów. Prawdopodobnie domyślasz
się, jak działa pierwsza z omawianych nowych funkcji języka C# 6, ale wyeliminowano
też kilka innych ograniczeń.

8.2.1. Automatycznie implementowane właściwości


przeznaczone tylko do odczytu
C# 6 umożliwia proste tworzenie właściwości przeznaczonych tylko do odczytu powią-
zanych z polami tylko do odczytu. Wystarczy utworzyć pusty getter i pominąć setter,
co pokazane jest na listingu 8.6.

87469504f326f0d7c1fcda56ef61bd79
8
8.2. Usprawnienia automatycznie implementowanych właściwości 277

Listing 8.6. Klasa Point z automatycznie implementowanymi właściwościami


przeznaczonymi tylko do odczytu

public sealed class Point


{
public double X { get; } Deklarowanie automatycznie implementowanych właściwości
public double Y { get; } przeznaczonych tylko do odczytu.

public Point(double x, double y)


{
X = x;
Y = y; Inicjalizowanie właściwości w konstruktorze.
}
}

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?

8.2.2. Inicjalizowanie automatycznie


implementowanych właściwości
Do wersji C# 6 inicjalizowanie automatycznie implementowanych właściwości musiało
odbywać się w konstruktorze. Nie można było zainicjalizować właściwości w miejscu
deklaracji. Załóżmy, że w C# 2 używana jest klasa Person pokazana na listingu 8.7.

Listing 8.7. Klasa Person z ręcznie zaimplementowaną właściwością w C# 2

public class Person


{
private List<Person> friends = new List<Person>(); Deklarowanie i inicjalizowanie pola.
public List<Person> Friends Udostępnianie właściwości
{ na potrzeby odczytu i zapisu pola.
get { return friends; }
set { friends = value; }
}
}

87469504f326f0d7c1fcda56ef61bd79
8
278 ROZDZIAŁ 8. Odchudzone właściwości i składowe z ciałem w postaci wyrażenia

Aby zmodyfikować ten kod i zastosować automatycznie implementowane właściwości,


trzeba było przenieść inicjalizację do konstruktora, choć wcześniej w ogóle nie używano
jawnie zadeklarowanych konstruktorów. Ostateczny efekt to kod taki jak na listingu 8.8.

Listing 8.8. Klasa Person z automatycznie implementowaną właściwością w C# 3

public class Person


{
public List<Person> Friends { get; set; } Deklarowanie właściwości
(inicjalizator jest niedozwolony).
public Person()
{
Friends = new List<Person>(); Inicjalizowanie właściwości w konstruktorze.
}
}

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.

Listing 8.9. Klasa Person z automatycznie implementowaną właściwością do odczytu


i zapisu w C# 6

public class Person


{
public List<Person> Friends { get; set; } = Deklarowanie i inicjalizowanie automatycznie
new List<Person>(); implementowanej właściwości do odczytu i zapisu.
}

Ten mechanizm możesz oczywiście stosować także do automatycznie implementowa-


nych właściwości tylko do odczytu. Często stosowany wzorzec polega na tym, że wła-
ściwość tylko do odczytu udostępnia modyfikowalną kolekcję. Dzięki temu jednostka
wywołująca może dodawać i usuwać elementy kolekcji, nigdy jednak nie można zmo-
dyfikować właściwości w taki sposób, aby wskazywała inną kolekcję (lub zawierała
referencję null). Jak może się domyślasz, wystarczy w tym celu usunąć setter (zobacz
listing 8.10).

Listing 8.10. Klasa Person z automatycznie implementowaną właściwością tylko


do odczytu w C# 6

public class Person


{
public List<Person> Friends { get; } = Deklarowanie i inicjalizowanie automatycznie
new List<Person>(); implementowanych właściwości tylko do odczytu.
}

Rzadko miałem poczucie, że to konkretne ograniczenie ze starszych wersji języka C#


stanowi poważny problem. Wynikało to z tego, że zwykle i tak inicjalizuję właściwości
z użyciem parametrów konstruktora. Jednak wprowadzona zmiana z pewnością jest
wartościowym usprawnieniem. Następne ograniczenie, które wyeliminowano, okazuje się
ważniejsze w kontekście automatycznie implementowanych właściwości tylko do odczytu.

87469504f326f0d7c1fcda56ef61bd79
8
8.2. Usprawnienia automatycznie implementowanych właściwości 279

8.2.3. Automatycznie implementowane właściwości w strukturach


Do wersji C# 6 zawsze uważałem, że automatycznie implementowane właściwości
powodują pewne problemy w strukturach. Wynikało to z dwóch przyczyn:
 Zawsze pisałem niemodyfikowalne struktury, dlatego brak automatycznie imple-
mentowanych właściwości tylko do odczytu rodził problemy.
 Mogłem przypisywać w konstruktorze wartość automatycznie implementowanej
właściwości tylko w łańcuchu wywołań obejmującym także inny konstruktor.
Wynikało to z reguły przypisania określonej wartości (ang. definite assignment rule).
UWAGA. Reguły przypisania określonej wartości są związane ze śledzeniem przez kompi-
lator tego, które zmienne mają przypisaną wartość w poszczególnych miejscach kodu nie-
zależnie od sposobu dojścia do danego miejsca. Te reguły są istotne przede wszystkim dla
zmiennych lokalnych i mają gwarantować, że kod nie próbuje wczytywać zmiennej lokalnej,
do której nie przypisano jeszcze wartości. Tu omawiane jest nieco inne zastosowanie tych
samych reguł.

Na listingu 8.11 oba te zagadnienia są przedstawione w strukturze analogicznej do


wcześniej pokazanej klasy Point. Już samo wpisywanie tego kodu budzi we mnie nie-
przyjemne odczucia.

Listing 8.11. Struktura Point w C# 5 zawierająca automatycznie implementowane


właściwości

public struct Point


{
public double X { get; private set; } Właściwości z publicznymi getterami
public double Y { get; private set; } i prywatnymi setterami.

public Point(double x, double y) : this() Łączenie w łańcuch


{ z konstruktorem domyślnym.
X = x;
Inicjalizowanie właściwości
Y = y;
}
}

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

W C# 5 bez wywołania konstruktora domyślnego naruszone zostaną obie te reguły.


Ustawianie wartości właściwości X i Y jest traktowane jak używanie wartości struktury,
dlatego jest to niedozwolone. Ustawianie wartości właściwości nie jest uważane za
przypisywanie wartości do pól, dlatego nie można zwrócić sterowania z konstruktora.
Prowizorycznym rozwiązaniem jest wywołanie w łańcuchu konstruktora domyślnego,
ponieważ wartości wszystkich pól zostają przypisane przed wykonaniem ciała kon-
struktora. Następnie można ustawić właściwości i na końcu zwrócić sterowanie, ponie-
waż kompilator stwierdza, że wartości wszystkich pól zostały przypisane.
W C# 6 język i kompilator lepiej wykrywają zależności między automatycznie
implementowanymi właściwościami i podstawowymi polami:
 Dozwolone jest ustawianie wartości automatycznie implementowanej właści-
wości przed zainicjalizowaniem wszystkich pól.
 Ustawianie wartości automatycznie implementowanej właściwości jest uznawane
za zainicjalizowanie pola.
 Można wczytywać wartość automatycznie implementowanej właściwości przed
zainicjalizowaniem pozostałych pól, pod warunkiem jednak, że ta wartość została
wcześniej ustawiona.

Można myśleć o tym zagadnieniu inaczej i przyjąć, że w konstruktorze automatycznie


implementowane właściwości są traktowane jak pola.
Po wprowadzeniu w C# 6 nowych reguł i prawdziwych automatycznie imple-
mentowanych właściwości tylko do odczytu można utworzyć strukturę Point pokazaną
na listingu 8.12. Jest ona prawie identyczna z klasą Point z listingu 8.6. Jedyna różnica
to zadeklarowanie struktury zamiast klasy z modyfikatorem sealed.

Listing 8.12. Struktura Point w C# 6 zawierająca automatycznie implementowane


właściwości

public struct Point


{
public double X { get; }
public double Y { get; }

public Point(double x, double y)


{
X = x;
Y = y;
}
}

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

Wszystko, co opisałem do tej pory, sprawia, że łatwiej jest korzystać z automatycznie


implementowanych właściwości i często można ograniczyć ilość szablonowego kodu.
Jednak nie wszystkie właściwości są automatycznie implementowane. Misja elimino-
wania zbędnej składni w kodzie nie kończy się w tym miejscu.

8.3. Składowe z ciałem w postaci wyrażenia


Zdecydowanie nie zamierzam zalecać jednego konkretnego stylu pisania kodu w C#.
Po części wynika to z tego, że w różnych dziedzinach problemowych lepiej sprawdzają
się inne podejścia. Zdarzało mi się widzieć typy, w których znajduje się mnóstwo
prostych metod i właściwości. C# 6 pomaga w tym obszarze, udostępniając składowe
z ciałem w postaci wyrażenia. Zacznijmy od właściwości, ponieważ były tematem
poprzedniego podrozdziału. Dalej zobaczysz, jak zastosować tę samą technikę do innych
składowych w postaci funkcji.

8.3.1. Jeszcze prostsze obliczanie właściwości tylko do odczytu


Niektóre właściwości są bardzo proste. Jeśli implementacja z użyciem pól jest zgodna
z logicznym stanem typu, właściwość może bezpośrednio zwracać wartość pola. To tego
służą automatycznie implementowane właściwości. Inne właściwości wymagają obliczeń
opartych na innych polach lub właściwościach. Aby zilustrować problem rozwiązywany
w C# 6, na listingu 8.13 do klasy Point dodana została nowa właściwość — Distance
FromOrigin. Wykorzystano tu w prosty sposób twierdzenie Pitagorasa do zwrócenia
odległości punktu od początku układu współrzędnych.
UWAGA. Nie martw się, jeśli obliczenia matematyczne są tu dla Ciebie niezrozumiałe. Szcze-
góły nie mają tu znaczenia. Ważne jest użycie właściwości tylko do odczytu używającej
właściwości X i Y.

Listing 8.13. Dodanie właściwości DistanceFromOrigin do klasy Point

public sealed class Point


{
public double X { get; }
public double Y { get; }

public Point(double x, double y)


{
X = x;
Y = y;
}

public double DistanceFromOrigin


{ Właściwość tylko do odczytu
get { return Math.Sqrt(X * X + Y * Y); } obliczająca odległość.
}
}

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.

Rysunek 8.1. Opisana deklaracja


właściwości z pokazanymi ważnymi
aspektami

W C# 6 można zapisać to w dużo bardziej przejrzysty sposób:


public double DistanceFromOrigin => Math.Sqrt(X * X + Y * Y);

Tu operator => oznacza użycie składowej z ciałem w postaci wyrażenia. Tu tą składową


jest właściwość tylko do odczytu. Nie trzeba używać nawiasów klamrowych ani dodat-
kowych słów kluczowych. Zarówno to, że właściwość jest przeznaczona tylko do odczytu,
jak i to, że wyrażenie służy do zwracania wartości, jest wykrywane automatycznie.
Porównaj to z rysunkiem 8.1, a zobaczysz, że składnia z ciałem w postaci wyrażenia
zawiera same przydatne elementy (z nowym sposobem podawania, że właściwość służy
tylko do odczytu) i żadnych nadmiarowych. Doskonale!

Nie, nie jest to wyrażenie lambda


To prawda, zetknąłeś się już z użytym tu elementem składni. Wyrażenia lambda
wprowadzono w C# 3 jako zwięzły sposób deklarowania delegatów i drzew wyrażeń.
Oto przykład:
Func<string, int> stringLength = text => text.Length;
Dla składowych w ciałem w postaci wyrażenia używana jest składnia =>, jednak nie są to
wyrażenia lambda. Z pokazaną wcześniej deklaracją właściwości DistanceFromOrigin nie są
związane żadne delegaty ani drzewa wyrażeń. Ta deklaracja nakazuje jedynie kompi-
latorowi utworzenie właściwości tylko do odczytu, która oblicza dane wyrażenie i zwraca
wynik.
Gdy mówię o tej składni, zwykle nazywam operator => grubą strzałką.

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

 LocalTime — sam czas bez komponentu reprezentującego datę,


 LocalDateTime — połączenie daty i czasu.

Nie przejmuj się szczegółami inicjalizowania i podobnymi kwestiami. Pomyśl jedynie,


czego oczekiwałbyś od tych trzech typów. Oczywiste jest, że data powinna mieć wła-
ściwości reprezentujące rok, miesiąc i dzień, a czas powinien reprezentować godziny,
minuty, sekundy itd. A co z typem łączącym datę i czas? Wygodna jest możliwość
pobrania osobno komponentów reprezentujących datę i czas, jednak często przydatne
są także podkomponenty. Implementacje właściwości LocalDate i LocalTime są starannie
zoptymalizowane, a nie chciałbym powielać logiki z tych właściwości we właściwości
LocalDateTime. Dlatego właściwości reprezentujące podkomponenty są właściwościami
pośrednimi, delegującymi zadania do właściwości reprezentujących komponenty daty
i czasu. Implementacja pokazana na listingu 8.14 jest dzięki temu bardzo przejrzysta.

Listing 8.14. Delegowanie właściwości w bibliotece Noda Time

public struct LocalDateTime


{
public LocalDate Date { get; } Właściwość reprezentująca komponent daty.
public int Year => Date.Year;
public int Month => Date.Month; Właściwości delegujące zadania do podkomponentów daty.
public int Day => Date.Day;

public LocalTime TimeOfDay { get; } Właściwość reprezentująca komponent czasu.


public int Hour => TimeOfDay.Hour;
public int Minute => TimeOfDay.Minute; Właściwości delegujące zadania
public int Second => TimeOfDay.Second; do podkomponentów czasu.
Inicjalizowanie, inne właściwości i składowe.
}

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.

8.2.2. Metody, indeksery i operatory z ciałem w postaci wyrażenia


Oprócz właściwości z ciałem w postaci wyrażenia można pisać metody, indeksery
tylko do odczytu i operatory (w tym konwersji zdefiniowanych przez użytkownika)
z takim ciałem. Operator => wszędzie jest używany w ten sam sposób — bez nawiasu
klamrowego wokół wyrażenia i bez jawnie podanej instrukcji return.
Na przykład prosta metoda Add i analogiczny operator służące do dodawania obiektów
typu Vector (z oczywistymi właściwościami X i Y) do obiektów typu Point mogą w C# 5
wglądać tak jak na listingu 8.15.

Listing 8.15. Proste metody i operatory w C# 5

public static Point Add(Point left, Vector right)


{
return left + right; Oddelegowanie operacji do operatora.
}

public static Point operator+(Point left, Vector right)


{
return new Point(left.X + right.X, Proste wywołanie konstruktora w celu
left.Y + right.Y); zaimplementowania operatora +.
}

W C# 6 kod może wyglądać prościej, ponieważ metodę i operator można zaimple-


mentować jako składowe z ciałem w postaci wyrażenia. Ilustruje to listing 8.16.
Listing 8.16. Metody i operatory z ciałem w postaci wyrażenia w C# 6

public static Point Add(Point left, Vector right) => left + right;

public static Point operator+(Point left, Vector right) =>


new Point(left.X + right.X, left.Y + right.Y);

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.

Przyjrzyj się prostej metodzie rejestrującej informacje:


public static void Log(string text)
{
Console.WriteLine("{0:o}: {1}", DateTime.UtcNow, text)
}

Można ją zapisać jako metodę z ciałem w postaci wyrażenia:


public static void Log(string text) =>
Console.WriteLine("{0:o}: {1}", DateTime.UtcNow, text);

Korzyści są tu mniej odczuwalne, jednak do metod, w których deklaracja i ciało miesz-


czą się w jednym wierszu, warto stosować tę technikę. W rozdziale 9. zobaczysz, jak
jeszcze bardziej zwiększyć przejrzystość kodu, stosując literały tekstowe z interpolacją.
Oto ostatni przykład. Ilustruje on metody, właściwość i indekser. Wyobraź sobie,
że chcesz utworzyć własną implementację interfejsu IReadOnlyList<T>, aby zapewnić
widok w trybie tylko do odczytu dla dowolnej listy typu IList<T>. Oczywiście takie
rozwiązanie jest dostępne w typie ReadOnlyCollection<T>, jednak zaimplementowane są
tam też modyfikowalne interfejsy IList<T> i ICollection<T>. Czasami możesz chcieć
precyzyjnie określić możliwości kolekcji za pomocą implementowanych interfejsów.
Dzięki składowym z ciałem w postaci wyrażenia implementacja potrzebnego typu
nakładkowego jest naprawdę krótka, co ilustruje listing 8.17.

Listing 8.17. Implementacja interfejsu IReadOnlyList<T> z użyciem składowych


z ciałem w postaci wyrażenia

public sealed class ReadOnlyListView<T> : IReadOnlyList<T>


{
private readonly IList<T> list;

public ReadOnlyListView(IList<T> list)


{
this.list = list;
}
Indekser delegujący zadania
public T this[int index] => list[index]; do indeksera listy.
public int Count => list.Count; Właściwość delegująca zadania do właściwości listy.
public IEnumerator<T> GetEnumerator() => Metoda delegująca zadania
list.GetEnumerator(); do metody listy.
IEnumerator IEnumerable.GetEnumerator() => Metoda delegująca zadania
GetEnumerator(); do innej metody GetEnumerator.
}

87469504f326f0d7c1fcda56ef61bd79
8
286 ROZDZIAŁ 8. Odchudzone właściwości i składowe z ciałem w postaci wyrażenia

Jedyny nowy mechanizm na tym listingu to składnia indekserów z ciałem w postaci


wyrażenia. Mam nadzieję, że ta składnia jest na tyle podobna do składni innych skła-
dowych, iż nawet nie zauważyłeś, że jest nowa.
Czy coś zwróciło Twoją uwagę w tym kodzie? Coś Cię zaskoczyło? Konstruktor
wygląda nieciekawie, prawda?

8.3.3. Ograniczenia dotyczące składowych z ciałem


w postaci wyrażenia w C# 6
Zwykle w takim miejscu zwracam uwagę na to, jak rozwlekły jest kod, i przekazuję
dobrą wiadomość o następnej funkcji wprowadzonej w C#, aby usprawnić język.
Jednak tym razem niestety jest inaczej — przynajmniej w C# 6.
Choć konstruktor zawiera tylko jedną instrukcję, w C# 6 nie istnieje coś takiego jak
konstruktor z ciałem w postaci wyrażenia. Dotyczy to nie tylko konstruktorów. Ciała
w postaci wyrażenia nie mogą mieć:
 konstruktory statyczne,
 finalizatory,
 konstruktory instancji,
 właściwości do odczytu i zapisu lub tylko do zapisu,
 indeksery do odczytu i zapisu lub tylko do zapisu,
 zdarzenia.

Ż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.

Listing 8.18. Inne składowe z ciałem w postaci wyrażenia w C# 7

public class Demo


{
static Demo() =>
Console.WriteLine("Wywołano konstruktor statyczny"); Konstruktor statyczny.
~Demo() => Console.WriteLine("Wywołano finalizator"); Finalizator.

private string name;


private readonly int[] values = new int[10];

public Demo(string name) => this.name = name; Konstruktor.

private PropertyChangedEventHandler handler;

87469504f326f0d7c1fcda56ef61bd79
8
8.3. Składowe z ciałem w postaci wyrażenia 287

public event PropertyChangedEventHandler PropertyChanged


{
add => handler += value; Zdarzenie z niestandardowymi
remove => handler -= value; akcesorami.
}

public int this[int index]


{
get => values[index]; Indekser do odczytu i zapisu.
set => values[index] = value;
}

public string Name


{
get => name; Właściwość do odczytu i zapisu.
set => name = value;
}
}

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?

8.3.4. Wskazówki dotyczące używania składowych


z ciałem w postaci wyrażenia
Zgodnie z moim doświadczeniem składowe z ciałem w postaci wyrażenia są przydatne
zwłaszcza przy pisaniu operatorów, konwersji, porównań, kodu do sprawdzania równości
i metod ToString. Takie komponenty zwykle zawierają prosty kod, jednak w niektórych

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

public ZonedDateTime InZone(


DateTimeZone zone,
ZoneLocalMappingResolver resolver) =>
Preconditions.CheckNotNull(zone)
.ResolveLocal(
this,
Preconditions.CheckNotNull(resolver);

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

Operator nameof to nowa funkcja w C# 6, nie ma ona jednak dużego znaczenia.


Umożliwia jedynie pobranie identyfikatora, który już jest dostępny w kodzie, przy czym
pozwala otrzymać go w formie łańcucha znaków w czasie wykonywania programu.
Operator nameof (w odróżnieniu od technologii LINQ lub mechanizmu async/await) nie
zmienia modelu pracy, jednak pomaga uniknąć literówek i pozwala na podejmowanie
dodatkowych działań przez narzędzia do refaktoryzacji. Zanim zaprezentuję nowe
mechanizmy, warto przypomnieć to, co już zapewne wiesz.

9.1. Przypomnienie technik formatowania


łańcuchów znaków w .NET
Prawie na pewno wiesz już wszystko, co jest opisane w tym podrozdziale. Zapewne
korzystasz z łańcuchów znaków od lat — niemal z pewnością tak długo, jak długo posłu-
gujesz się językiem C#. Jednak aby zrozumieć działanie literałów tekstowych z inter-
polacją w C# 6, najlepiej jest przypomnieć sobie informacje na temat łańcuchów
znaków. Zachęcam do wytrwania przy lekturze omówienia podstaw formatowania łań-
cuchów znaków w platformie .NET. Obiecuję, że wkrótce przejdę do nowych technik.

9.1.1. Proste formatowanie łańcuchów znaków


Jeśli jesteś podobny do mnie, to lubisz eksperymentować z nowymi językami, pisząc
proste aplikacje konsolowe, które nie robią niczego przydatnego, ale zapewniają Ci
pewność siebie i solidne podstawy pozwalające przejść do bardziej zaawansowanych
rozwiązań. Ja już nie pamiętam, w ilu językach pisałem pokazany poniżej kod, który
wyświetla prośbę o podanie imienia, a następnie wyświetla pozdrowienie:
Console.Write("Jak masz na imię? ");
string name = Console.ReadLine();
Console.WriteLine("Witaj, {0}!", name);

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.

9.1.2. Niestandardowe formatowanie


z użyciem łańcuchów znaków formatowania
Na początku zaznaczę, że ten punkt dodałem w równym stopniu dla samego siebie, jak
i dla Ciebie, drogi czytelniku. Gdyby serwis MSDN wyświetlał, ile razy odwiedziłem
poszczególne strony, liczba wizyt na stronach dotyczących złożonych łańcuchów zna-
ków formatowania byłaby przerażająca. Ciągle zapominam, co dokładnie należy umie-

87469504f326f0d7c1fcda56ef61bd79
8
9.1. Przypomnienie technik formatowania łańcuchów znaków w .NET 293

ścić w różnych miejscach i jakich wyrażeń należy używać. Stwierdziłem, że jeśli


umieszczę te informacje tutaj, możliwe, że lepiej je zapamiętam. Mam nadzieję, że
okażą się one równie przydatne Tobie.
Każdy element formatujący w złożonym łańcuchu znaków formatowania określa
indeks formatowanego argumentu, a ponadto może obejmować następujące opcje
dotyczące formatowania wartości:
 Wyrównanie określające minimalną szerokość oraz to, czy wartość powinna być
wyrównana do lewej, czy do prawej strony. Wartość dodatnia oznacza wyrów-
nanie do prawej strony, a wartość ujemna powoduje wyrównanie do lewej strony.
 Łańcuch znaków formatowania dla danej wartości. Prawdopodobnie najczęściej
jest on używany dla dat, czasu i liczb. Na przykład, aby sformatować datę zgod-
nie ze specyfikacją ISO-8601, można zastosować łańcuch znaków formatowania
yyyy-MM-dd. Aby sformatować liczbę na potrzeby prezentacji kwoty pieniędzy,
można użyć łańcucha znaków formatowania C. Znaczenie łańcucha znaków
formatowania zależy od typu formatowanej wartości, dlatego by wybrać właściwy
łańcuch znaków tego typu, należy zajrzeć do dokumentacji.

Na rysunku 9.1 pokazane są wszystkie człony złożonego łańcucha znaków formatowania,


który można wykorzystać do wyświetlenia ceny.

Rysunek 9.1. Złożony łańcuch znaków formatowania


z elementem formatującym służącym do wyświetlania ceny

Wyrównanie i łańcuch znaków formatowania są opcjonalne i niezależne od siebie.


Możesz podać dowolny z tych elementów, oba lub nie stosować żadnego. Przecinek
w elemencie formatującym oznacza wyrównanie, a dwukropek określa łańcuch znaków
formatowania. Jeśli potrzebujesz przecinka w łańcuchu znaków formatowania, nie sta-
nowi to problemu. Język nie obsługuje drugiej wartości określającej wyrównanie.
W ramach konkretnego przykładu, który później zostanie rozwinięty, kod z rysunku 9.1
używany jest w szerszym kontekście. Wyświetlane są tu dane o różnej długości, aby
zilustrować sposób wyrównania. Listing 9.1 wyświetla cenę (95,25 dolara), napiwek
(19,05 dolara) i sumę (114,30 dolara), przy czym etykiety są wyrównane do lewej, a war-
tości do prawej.

Listing 9.1. Wyświetlanie ceny, napiwku i sumy z wyrównaniem wartości

decimal price = 95.25m;


decimal tip = price * 0.2m; Napiwek w wysokości 20%.
Console.WriteLine("Cena: {0,9:C}", price);
Console.WriteLine("Napiwek: {0,9:C}", tip);
Console.WriteLine("Suma: {0,9:C}", price + tip);

87469504f326f0d7c1fcda56ef61bd79
8
294 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków

Dane wyjściowe na komputerze, gdzie używane są ustawienia regionalne Angielski


(Stany Zjednoczone), wyglądają tak:
Cena: $95.25
Napiwek: $19.05
Suma: $114.30

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.

W platformie .NET najważniejszym typem w kontekście lokalizacji jest CultureInfo.


Odpowiada on za preferencje regionalne w zakresie języka (np. angielskiego), języka
w określonym regionie (np. francuskiego w Kanadzie) lub odmiany języka w danym
regionie (np. uproszczony chiński w Tajwanie). Te preferencje regionalne wpływają na
stosowanie różnych tłumaczeń (np. słów używanych dla dni tygodnia), określają spo-
sób sortowania tekstu i formatowania liczb (np. czy używać kropki, czy przecinka jako
separatora dziesiętnego) itd.

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);

Tu d to standardowy specyfikator formatu daty i czasu oznaczający krótką datę. W usta-


wieniach regionalnych Angielski (Stany Zjednoczone) oznacza to format miesiąc/dzień/rok.
Moja data urodzenia zostanie więc sformatowana jako 6/19/1976. Przy ustawieniach
Angielski (Wielka Brytania) krótka data ma postać dzień/miesiąc/rok, dlatego ta sama
data zostanie sformatowana jako 19/06/1976. Warto zauważyć, że nie tylko kolejność
elementów jest tu różna. W formatowaniu brytyjskim miesiąc jest dopełniony cyfrą
0 (aby składał się z dwóch cyfr).
Dla innych ustawień regionalnych stosowane może być zupełnie odmienne forma-
towanie. Pouczające może być sprawdzenie, jak różne mogą okazać się wyniki for-
matowania tej samej wartości przy różnych ustawieniach regionalnych. Możesz np.
sformatować tę samą datę we wszystkich ustawieniach regionalnych znanych platfor-
mie .NET. Ilustruje to listing 9.2.

Listing 9.2. Formatowanie jednej daty we wszystkich ustawieniach regionalnych

var cultures = CultureInfo.GetCultures(CultureTypes.AllCultures);


var birthDate = new DateTime(1976, 6, 19);
foreach (var culture in cultures)
{
string text = string.Format(
culture, "{0,-15} {1,12:d}", culture.Name, birthDate);
Console.WriteLine(text);
}

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);

Jednak zwykle nie należy samodzielnie bezpośrednio formatować danych na potrzeby


komunikacji między maszynami. Zachęcam do unikania konwersji łańcuchów znaków,
jeśli tylko jest to możliwe. Często takie konwersje oznaczają „smrodek” wskazujący na
to, że nie używasz biblioteki lub platformy we właściwy sposób, albo na problemy
w projekcie danych (np. zapisywanie daty w bazie w formie tekstu, a nie przy użyciu
natywnego typu dla daty i czasu). Mimo to może się okazać, że będziesz ręcznie gene-
rował łańcuchy znaków tego rodzaju częściej, niż masz na to ochotę. Zwróć wtedy
uwagę na ustawienia regionalne, jakie należy stosować.
W porządku, to było długie wprowadzenie. Jednak dzięki temu, że ciągle myślisz
o tych wszystkich informacjach na temat formatowania, a mało eleganckie przykłady
nie dają Ci spokoju, z radością przywitasz wprowadzone w C# 6 literały tekstowe
z interpolacją. Wszystkie te wywołania string.Format wydają się niepotrzebnie długie,
a ponadto irytująca jest konieczność przeskakiwania między łańcuchem znaków for-
matowania a listą argumentów w celu zobaczenia, które dane znajdą się w poszcze-
gólnych miejscach. Z pewnością możliwe jest pisanie bardziej przejrzystego kodu.

9.2. Wprowadzenie do literałów tekstowych z interpolacją


Dodane w C# 6 literały tekstowe z interpolacją umożliwiają formatowanie danych
w dużo prostszy sposób. Nadal występuje tu łańcuch znaków formatowania i argumenty,
jednak w literałach tekstowych z interpolacją wartości i informacje na temat formatowania
są podawane wewnątrzwierszowo. Dzięki temu kod jest dużo bardziej czytelny. Jeśli
przyjrzysz się kodowi i znajdziesz wiele wywołań string.Format z zapisanymi na stałe łań-
cuchami znaków formatowania, staniesz się fanem literałów tekstowych z interpolacją.
Interpolacja w łańcuchach znaków nie jest nowym pomysłem. Technika ta jest od
dawna dostępna w wielu językach programowania, jednak moim zdaniem nigdzie nie
została tak zgrabnie wprowadzona jak w C#. Robi to duże wrażenie zwłaszcza w kon-
tekście tego, że dodawanie nowych funkcji do już dojrzałego języka jest trudniejsze niż
w jego pierwszej wersji.
W tym podrozdziale przyjrzysz się prostym przykładom, a następnie zapoznasz
się z dosłownymi literałami tekstowymi z interpolacją. Dowiesz się, jak stosować loka-
lizację z użyciem typu FormattableString, a dalej dokładniej przyjrzysz się temu, w jaki
sposób kompilator traktuje literały tekstowe z interpolacją. Ten podrozdział kończy się
wyjaśnieniem, w jakich sytuacjach interpolacja jest najbardziej przydatna, a także
omówieniem jej ograniczeń.

87469504f326f0d7c1fcda56ef61bd79
8
298 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków

9.2.1. Prosta interpolacja


Najprostszy sposób na zilustrowanie literałów tekstowych z interpolacją w C# 6 polega
na pokazaniu odpowiednika wcześniejszego przykładu, w którym kod wyświetla prośbę
o podanie imienia. Różnice w wyglądzie obu wersji nie są duże. Jedynie w ostatnim
wierszu pojawiła się zmiana.

Formatowanie w dawnym stylu z C# 5 Literał tekstowy z interpolacją z C# 6

Console.Write("Jak masz na imię? "); Console.Write("Jak masz na imię? ");


string name = Console.ReadLine(); string name = Console.ReadLine();
Console.WriteLine("Witaj, {0}!", Console.WriteLine($"Witaj, {name}!");
name);

Literał tekstowy z interpolacją został wyróżniony pogrubieniem. Zaczyna się on od


znaku $ przed otwierającym cudzysłowem. To ten znak jest dla kompilatora informacją,
że tworzony jest literał tekstowy z interpolacją zamiast zwykłego literału. W tym literale
elementem formatującym jest {name} zamiast {0}. Tekst w nawiasie klamrowym to
wyrażenie, które jest przetwarzane, a następnie formatowane w łańcuchu znaków. Ponie-
waż podane zostały wszystkie potrzebne informacje, drugi argument metody WriteLine
nie jest już potrzebny.
UWAGA. Nieco skłamałem, aby uprościć przykład. Nowa wersja nie działa identycznie jak
pierwotny kod. W pierwotnym kodzie wszystkie argumenty były przekazywane do odpowied-
niej przeciążonej wersji metody Console.WriteLine, która formatowała tekst. Teraz za
formatowanie odpowiada wywołanie string.Format i używana jest przeciążona wersja metody
Console.WriteLine przyjmująca tylko jeden parametr. Efekt jest jednak taki sam.

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ą.

9.2.2. Łańcuchy znaków formatowania


w literałach tekstowych z interpolacją
Dobra wiadomość — nie musisz uczyć się niczego nowego! Jeśli chcesz dodać wyrów-
nanie lub sformatować łańcuch znaków w literałach tekstowych z interpolacją, możesz
zrobić to w taki sam sposób jak w zwykłych złożonych łańcuchach znaków formato-
wania. Należy podać przecinek przed wyrównaniem i dwukropek przed łańcuchem
znaków formatowania. Wcześniejszy przykład ze złożonym formatowaniem należy
zmodyfikować w oczywisty sposób pokazany na listingu 9.3.

87469504f326f0d7c1fcda56ef61bd79
8
9.2. Wprowadzenie do literałów tekstowych z interpolacją 299

Listing 9.3. Wyrównywanie wartości w literałach tekstowych z interpolacją

decimal price = 95.25m;


decimal tip = price * 0.2m; Napiwek w wysokości 20%.
Console.WriteLine($"Cena: {price,9:C}");
Console.WriteLine($"Napiwek: {tip,9:C}"); Wyrównanie cen do prawej
z użyciem dziewięciu pozycji.
Console.WriteLine($"Suma: {price + tip,9:C}");

Warto zauważyć, że w ostatnim wierszu argument interpolowanego łańcucha znaków


nie jest prostą zmienną — kod dodaje napiwek do ceny. Można tu użyć dowolnego
wyrażenia obliczającego wartość. Nie można jednak np. wywołać metody zwracającej
void. Jeśli typ danej wartości zawiera implementację interfejsu IFormattable, wywo-
ływana jest metoda ToString(string, IFormatProvider) z tego typu. W przeciwnym razie
stosowana jest metoda System.Object.ToString().

9.2.3. Dosłowne literały tekstowe z interpolacją


Bez wątpienia zetknąłeś się już z dosłownymi literałami tekstowymi z interpolacją.
Zaczynają się od znaku @, po którym następuje cudzysłów. W takim literale lewe uko-
śniki i znaki przełamania wiersza są wyświetlane w łańcuchu znaków. Na przykład
w dosłownym literale tekstowym @"c:\Windows" lewy ukośnik naprawdę jest lewym
ukośnikiem, a nie znakiem rozpoczynającym sekwencję ucieczki. Jedyną sekwencją
ucieczki w dosłownym literale tekstowym są dwa cudzysłowy jeden po drugim, dające
w wynikowym łańcuchu znaków jeden cudzysłów. Dosłowne literały tekstowe są zwykle
używane do:
 łańcuchów znaków podzielonych na wiele wierszy,
 wyrażeń regularnych (gdzie lewe ukośniki oznaczają sekwencję ucieczki, co
wymagałoby specjalnego traktowania w zwykłych literałach tekstowych prze-
twarzanych przez kompilator C#),
 zapisanych na stałe nazw plików z systemu Windows.
UWAGA. W wielowierszowych łańcuchach znaków zachowaj ostrożność w związku z tym, jakie
dokładnie znaki znajdą się w łańcuchu. Choć w większości kodu różnica między „znakiem
powrotu karetki” a sekwencją „znak powrotu karetki plus znak wysuwu wiersza” jest nieistotna,
ma ona znaczenie w dosłownych literałach tekstowych.

Poniżej pokazany jest krótki przykład ilustrujący wszystkie trzy sytuacje:


string sql = @"
Kod w SQL-u jest bardziej
SELECT City, ZipCode czytelny, gdy zostanie
FROM Address podzielony na kilka wierszy.
WHERE Country = 'US'"; Lewe ukośniki często występują
Regex lettersDotDigits = new Regex(@"[a-z]+\.\d+"); w wyrażeniach regularnych.
string file = @"c:\users\skeet\Test\Test.cs"
Nazwa pliku z systemu Windows

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.

Listing 9.4. Wyrównane wartości wyświetlane z użyciem jednego dosłownego literału


tekstowego z interpolacją

decimal price = 95.25m;


decimal tip = price * 0.2m; Napiwek w wysokości 20%.
Console.WriteLine($@"Cena: {price,9:C}
Napiwek: {tip,9:C}
Suma: {price + tip,9:C}");

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.

WSKAZÓWKA. Kolejność symboli ma znaczenie. $@"Tekst" to poprawny dosłowny literał


tekstowy z interpolacją, jednak zapis @$"Tekst" jest nieprawidłowy. Przyznaję, że nie wymy-
śliłem dobrej mnemotechniki, aby zapamiętać właściwą kolejność. Dlatego spróbuj zasto-
sować sekwencję, która wydaje Ci się poprawna, i zmień kolejność, jeśli kompilator zgłosi
zastrzeżenia.

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.

9.2.4. Obsługa literałów tekstowych z interpolacją przez kompilator


(część 1.)
Przekształcenie wprowadzane przez kompilator jest proste. Kompilator zamienia literał
tekstowy z interpolacją w wywołanie metody string.Format, pobiera wyrażenia z ele-
mentów formatujących i przekazuje je jako argumenty po złożonym łańcuchu znaków
formatowania. Wyrażenie jest zastępowane odpowiednim indeksem, dlatego pierwszy
element formatujący jest zamieniany na {0}, drugi — na {1} itd.
Aby omówienie było przejrzyste, warto rozważyć prosty przykład. Tym razem dla
przejrzystości formatowanie zostało oddzielone od danych wejściowych:
int x = 10;
int y = 20;
string text = $"x={x}, y={y}";
Console.WriteLine(text);

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.

9.3. Lokalizacja z użyciem typu FormattableString


W punkcie 9.1.3 pokazałem, że przy formatowaniu łańcuchów znaków można wyko-
rzystać do obsługi lokalizacji innych dostawców formatowania — zwykle z użyciem
typu CultureInfo. Wszystkie przedstawione do tego miejsca literały tekstowe z inter-
polacją byłby przetwarzane z użyciem domyślnych ustawień regionalnych z wątku
wykonawczego. Dlatego dotyczące cen przykłady z punktów 9.1.2 i 9.2.2 mogą na
Twoim komputerze dać zupełne inne dane wyjściowe niż w książce.
Aby zastosować formatowanie zgodnie z określonymi ustawieniami regionalnymi,
potrzebne są trzy porcje informacji:
 złożony łańcuch znaków formatowania, obejmujący zapisany na stałe tekst i ele-
menty formatujące (miejsca na rzeczywiste wartości),
 same wartości,
 ustawienia regionalne, jakie mają zostać użyte do sformatowania łańcucha znaków.

Możesz nieco zmodyfikować pierwszy przykład formatowania z użyciem ustawień


regionalnych, zapisując każdy element w odrębnej zmiennej, a na końcu wywołując
metodę string.Format:
var compositeFormatString = "Jon urodził się {0:d}";
var value = new DateTime(1976, 6, 19);
var culture = CultureInfo.GetCultureInfo("en-US");
var result = string.Format(culture, compositeFormatString, value);

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.

9.3.1. Obsługa literałów tekstowych z interpolacją przez kompilator


(część 2.)
Inaczej niż we wcześniejszych fragmentach książki tym razem warto najpierw opisać
obsługę typu FormattableString przez kompilator, a dalej szczegółowo przeanalizować
zastosowania tego typu. Literały tekstowe z interpolacją w czasie kompilacji są typu
string. Nie istnieją konwersje z typu string na typ FormattableString lub interfejs
IFormattable (implementowany w typie FormattableString), jednak możliwe jest prze-
kształcanie wyrażeń reprezentujących literał tekstowy z interpolacją na typy Format
tableString i IFormattable.
Różnica między konwersją z wyrażenia na typ a konwersją z typu na inny typ jest
subtelna, ale nie jest ona niczym nowym. Rozważ np. literał liczbowy 5. Jego typ to
int, dlatego jeśli zadeklarujesz zmienną var x = 5, typem zmiennej x będzie int. Możesz
jednak użyć tej wartości także do zainicjalizowania zmiennej typu byte. Na przykład
wyrażenie byte y = 5; jest w pełni poprawne. Wynika to z tego, że w specyfikacji języka
określono, iż stałe wyrażenia całkowitoliczbowe (w tym literały liczbowe) o wartościach
z przedziału odpowiadającego typowi byte mogą być niejawnie przekształcane na typ
byte. Jeśli rozumiesz to rozwiązanie, możesz zastosować dokładnie ten sam pomysł do
dosłownych literałów tekstowych.
Gdy kompilator ma przekształcić literał tekstowy z interpolacją na typ Formattable
String, wykonuje w większości te same kroki, co przy konwersji na typ string. Jednak
zamiast metody string.Format wywołuje statyczną metodę Create klasy System.Runtime.
CompilerServices.FormattableStringFactory. Ta klasa to następny typ wprowadzony
w tym samym czasie co typ FormattableString. Wróćmy do wcześniejszego przykładu.
Załóżmy, że kod źródłowy wygląda tak:

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);

FormattableString to klasa abstrakcyjna ze składowymi pokazanymi na listingu 9.5.

Listing 9.5. Składowe w klasie FormattableString

public abstract class FormattableString : IFormattable


{
protected FormattableString();
public abstract object GetArgument(int index);
public abstract object[] GetArguments();
public static string Invariant(FormattableString formattable);
string IFormattable.ToString
(string ignored, IFormatProvider formatProvider);
public override string ToString();
public abstract string ToString(IFormatProvider formatProvider);
public abstract int ArgumentCount { get; }
public abstract string Format { get; }
}

Wiesz już, kiedy i jak tworzone są instancje typu FormattableString. Teraz zobacz, co
można z nimi robić.

9.3.2. Formatowanie obiektu typu FormattableString


z użyciem określonych ustawień regionalnych
Zdecydowanie najczęstszym zastosowaniem typu FormattableString jest formatowa-
nie tekstu z użyciem bezpośrednio podanych ustawień regionalnych (zamiast przy
użyciu domyślnych ustawień wątku). Myślę, że najczęściej używane są niezmienne
ustawienia regionalne. Są one stosowane tak często, że utworzono powiązaną z nimi
metodę statyczną Invariant. Wywołanie tej metody to odpowiednik przekazania argu-
mentu CultureInfo.InvariantCulture do metody ToString(IFormatProvider), której
działania zapewne się domyślasz. Jednak utworzenie metody Invariant jako statycznej
sprawia, że łatwiej jest ją wywoływać (w kontekście szczegółów języka opisanych
w punkcie 9.3.1). Ponieważ metoda ta przyjmuje jako parametr obiekt typu Formattable
String, literał tekstowy z interpolacją można wykorzystać jako argument, a kompi-
lator będzie wiedział, że ma zastosować odpowiednią konwersję. Nie trzeba posługiwać
się ani rzutowaniem, ani odrębną zmienną.
Przyjrzyjmy się konkretnemu przykładowi, aby jasno objaśnić tę technikę. Załóżmy,
że dostępna jest wartość typu DateTime. Chcesz sformatować tylko datę, używając
formatu ISO-8601, aby podać ją jako parametr zapytania w adresie URL na potrzeby

87469504f326f0d7c1fcda56ef61bd79
8
304 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków

komunikacji między maszynami. Chcesz zastosować niezmienne ustawienia regionalne,


by uniknąć nieoczekiwanych skutków użycia ustawień domyślnych.

UWAGA. Nawet jeśli zastosujesz niestandardowy łańcuch znaków formatowania do daty


i czasu, aby uwzględnić w formatowaniu tylko cyfry, ustawienia regionalne i tak będą istotne.
Największe znaczenie ma to, że wartość jest reprezentowana w kalendarzu domyślnym dla
danych ustawień regionalnych. Jeśli sformatujesz datę 21 października 2016 r. z kalendarza
gregoriańskiego według ustawień regionalnych ar-SA (język arabski z Arabii Saudyjskiej),
otrzymasz wynik z rokiem 1438.

Formatowanie można przeprowadzić na cztery sposoby (wszystkie zostały zaprezen-


towane na listingu 9.6). Każde z pokazanych podejść daje dokładnie ten sam efekt,
a przedstawiłem je po to, aby zademonstrować, w jaki sposób kilka mechanizmów języka
może współdziałać w celu zapewnienia ostatecznego przejrzystego rozwiązania.

Listing 9.6. Formatowanie daty z użyciem niezmiennych ustawień regionalnych

DateTime date = DateTime.UtcNow;

string parameter1 = string.Format(


CultureInfo.InvariantCulture, Dawne formatowanie z użyciem
"x={0:yyyy-MM-dd}", wywołania string.Format.
date);

string parameter2 = Rzutowanie na typ FormattableString


((FormattableString)$"x={date:yyyy-MM-dd}") i wywołanie metody
.ToString(CultureInfo.InvariantCulture); ToString(IFormatProvider).

string parameter3 = FormattableString.Invariant( Zwykłe wywołanie metody


$"x={date:yyyy-MM-dd}"); FormattableString.Invariant.

string parameter4 = Invariant($"x={date:yyyy-MM-dd}"); Skrócone wywołanie metody


FormattableString.Invariant.

Główna ciekawa różnica dotyczy inicjalizatorów zmiennych parameter2 i parameter3.


Aby mieć pewność, że dla zmiennej parameter2 użyty zostanie typ FormattableString
zamiast typu string, trzeba zrzutować literał tekstowy z interpolacją na pierwszy z tych
typów. Inna możliwość to zadeklarowanie odrębnej zmiennej lokalnej typu Formattable
String, jednak kod będzie wtedy podobnie długi. Porównaj to z inicjalizacją zmiennej
parameter3, gdzie używana jest metoda Invariant przyjmująca parametr typu Format
tableString. Takie wywołanie umożliwia kompilatorowi wywnioskowanie, że pro-
gramista chce zastosować jawną konwersję z literału tekstowego z interpolacją na typ
FormattableString, ponieważ jest to jedyna poprawna postać wywołania.
W przypadku zmiennej parameter4 trochę oszukiwałem. Zastosowałem tu mecha-
nizm, którego jeszcze nie opisałem. Chodzi tu o udostępnianie metod statycznych
typu za pomocą dyrektywy using static. Możesz później przejść do szczegółowego
opisu tej techniki (punkt 10.1.1) lub zaufać mi na razie i uwierzyć, że jest to poprawne
rozwiązanie. Trzeba jedynie dodać using static System.FormattableString do listy
dyrektyw using.

87469504f326f0d7c1fcda56ef61bd79
8
9.3. Lokalizacja z użyciem typu FormattableString 305

FORMATOWANIE Z UŻYCIEM ZMIENNYCH USTAWIEŃ REGIONALNYCH


Jeśli chcesz sformatować obiekt typu FormattableString z użyciem ustawień regional-
nych innych niż niezmienne, musisz użyć jednej z wersji metody ToString. W więk-
szości sytuacji należy bezpośrednio wywołać wersję ToString(IFormatProvider). Ten
przykład jest nieco krótszy od wcześniejszych. Kod ten formatuje aktualną datę i czas
z użyciem ustawień dla języka angielskiego ze Stanów Zjednoczonych oraz standar-
dowego łańcucha znaków formatowania "g" oznaczającego datę z krótkim zapisem czasu:
FormattableString fs = $"Aktualna data i czas: {DateTime.Now:g}";
string formatted = fs.ToString(CultureInfo.GetCultureInfo("en-US"));

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.

9.3.3. Inne zastosowania typu FormattableString


Nie sądzę, aby typ FormattableString był często używany do czegoś innego niż kon-
trolowanie ustawień regionalnych, co opisano w punkcie 9.3.2. Warto jednak rozważyć,
co można zrobić za pomocą tego typu. Opisywany przykład jest łatwy do zrozumienia
i na swój sposób elegancki, jednak nie posunąłbym się do zalecania stosowania takiego
kodu. Pomijając to, że brakuje tu sprawdzania poprawności i niektórych innych mecha-
nizmów, ten kod może zostać mylnie odczytany przez przypadkowego czytelnika
(i niektóre narzędzia do statycznej analizy kodu). Z pewnością warto przyjrzeć się
omawianemu pomysłowi, jednak należy zachować odpowiednią ostrożność.
Większość programistów zna lukę bezpieczeństwa umożliwiającą ataki przez
wstrzykiwanie kodu w SQL-u. Wielu zna też standardowe rozwiązanie w postaci spa-
rametryzowanych instrukcji w SQL-u. Na listingu 9.7 pokazane jest, jakich rozwiązań
nie należy stosować. Jeśli użytkownik wpisze wartość zawierającą apostrof, może zyskać
znaczną kontrolę nad bazą danych. Wyobraź sobie, że masz bazę danych z wpisami,
do których użytkownik może dodawać tagi. Dane w bazie są podzielone na podstawie
identyfikatorów użytkowników. Próbujesz wyświetlać wszystkie wpisy na podstawie
tagu podanego przez użytkownika.

Listing 9.7. Uwaga, uwaga! Nie należy stosować tego kodu!

var tag = Console.ReadLine(); Wczytywanie dowolnych danych od użytkownika.


using (var conn = new SqlConnection(connectionString))
{
conn.Open();

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ść przypadków podatności na wstrzykiwanie kodu w SQL-u, jakie widziałem


w C#, dotyczyła złączania łańcuchów znaków zamiast ich formatowania, nie jest to
jednak duża różnica. Chodzi o łączenie kodu (w SQL-u) z danymi (wartością wpro-
wadzoną przez użytkownika) w niebezpieczny sposób.
Zakładam, że wiesz, jak rozwiązałbyś ten problem w przeszłości, odpowiednio uży-
wając sparametryzowanych instrukcji w SQL-u i wywołania command.Parameters.Add(…).
Pozwala to we właściwy sposób oddzielić kod od danych i życie znów staje się piękne.
Niestety, taki poprawny kod nie wygląda równie atrakcyjnie jak rozwiązanie z listingu 9.7.
A co by było, gdyby można było uzyskać korzyści z obu technik; gdybyś mógł pisać kod
w SQL-u, który w oczywisty sposób określa, co chcesz uzyskać, a przy tym jest bez-
pieczny dzięki parametrom? Dzięki typowi FormattableString jest to możliwe.
Zaczniemy od końca, od kodu oczekiwanego z perspektywy programisty, a potem
cofniemy się do implementacji, która umożliwia napisanie go. Na listingu 9.8 poka-
zany jest — niedługo bezpieczny — odpowiednik listingu 9.7.

Listing 9.8. Bezpieczna parametryzacja kodu w SQL-u z użyciem typu FormattableString

var tag = Console.ReadLine(); Wczytywanie dowolnych danych od użytkownika.


using (var conn = new SqlConnection(connectionString))
{
conn.Open();
using (var command = conn.NewSqlCommand(
$@"SELECT Description FROM Entries Generowanie instrukcji w SQL-u z użyciem
WHERE Tag={tag:NVarChar} literału tekstowego z interpolacją.
AND UserId={userId:Int}"))
{ Bezpieczne wykonywanie
using (var reader = command.ExecuteReader()) kodu w SQL-u.
{
// Używanie danych. 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}

Chcesz natomiast uzyskać kod w SQL-u, który wygląda tak:


SELECT Description FROM Entries
WHERE Tag=@p0 AND UserId=@p1

Ł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.

Listing 9.9. Implementowanie bezpiecznego formatowania instrukcji w SQL-u

public static class SqlFormattableString


{
public static SqlCommand NewSqlCommand(
this SqlConnection conn,FormattableString formattableString)
{
SqlParameter[] sqlParameters = formattableString.GetArguments()
.Select((value, position) =>
new SqlParameter(Invariant($"@p{position}"), value))
.ToArray();
object[] formatArguments = sqlParameters
.Select(p => new FormatCapturingParameter(p))
.ToArray();
string sql = string.Format(formattableString.Format,
formatArguments);
var command = new SqlCommand(sql, conn);
command.Parameters.AddRange(sqlParameters);
return command;
}

private class FormatCapturingParameter : IFormattable


{

87469504f326f0d7c1fcda56ef61bd79
8
308 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków

private readonly SqlParameter parameter;

internal FormatCapturingParameter(SqlParameter parameter)


{
this.parameter = parameter;
}
public string ToString(string format, IFormatProvider formatProvider)
{
if (!string.IsNullOrEmpty(format))
{
parameter.SqlDbType = (SqlDbType) Enum.Parse(
typeof(SqlDbType), format, true);
}
return parameter.ParameterName;
}
}
}

Jedyna publiczna część tego kodu to statyczna klasa SqlFormattableString zawierająca


metodę NewSqlCommand. Cała reszta to ukryte szczegóły implementacji. Dla każdej
zmiennej tymczasowej z łańcucha znaków formatowania tworzony jest obiekt typu
SqlParameter i powiązany obiekt FormatCapturingParameter. Ten ostatni służy do forma-
towania nazw parametrów w SQL-u jako @p0, @p1 itd. Wartość przekazywana do metody
ToString jest zapisywana w obiekcie typu SqlParameter. Ponadto jeśli użytkownik podał
typ parametru w łańcuchu znaków formatowania, kod ustawia ten typ.
Na typ etapie musisz zdecydować, czy chciałbyś używać takiego rozwiązania
w produkcyjnym kodzie bazowym. Chętnie zaimplementowałbym tu dodatkowe mecha-
nizmy (np. podawanie długości w łańcuchu znaków formatowania; nie można wykorzy-
stać wyrównania z łańcucha znaków formatowania, ponieważ metoda string.Format
sama przetwarza tę wartość), jednak z pewnością rozwiązanie to można doprowadzić
do poziomu kodu produkcyjnego. Czy jednak rozwiązanie to nie jest zanadto wymyślne?
Czy chcesz objaśniać ten kod każdemu nowemu programiście w projekcie i mówić:
„Tak, wiem, że wygląda to jak poważna podatność na wstrzykiwanie kodu w SQL-u,
jednak kod jest bezpieczny, gwarantuję”?
Niezależnie od tego konkretnego przykładu możesz natrafić na podobne sytuacje,
w których można wykorzystać pobieranie danych przez kompilator i oddzielanie ich
od tekstu z literału tekstowego z interpolacją. Zawsze dobrze się zastanów, czy rozwią-
zanie tego rodzaju naprawdę daje jakieś korzyści, czy tylko pozwala Ci poczuć się
bardziej inteligentnym.
Wszystkie te techniki są przydatne, jeśli kod ma działać w platformie .NET 4.6.
Co jednak zrobić, jeśli musisz używać starszych wersji tej platformy? Nawet jeśli
używasz kompilatora języka C# 6, nie oznacza to jeszcze, że docelowo kod ma działać
w nowej wersji platformy. Na szczęście kompilator języka C# nie jest powiązany z kon-
kretną wersją platformy. Konieczne jest jedynie to, by odpowiednie typy były w jakiś
sposób dostępne.

87469504f326f0d7c1fcda56ef61bd79
8
9.3. Lokalizacja z użyciem typu FormattableString 309

9.3.4. Używanie typu FormattableString


w starszych wersjach platformy .NET
Podobnie jak w przypadku atrybutów metod rozszerzających i atrybutów z informacjami
o jednostce wywołującej, kompilator języka C# nie określa ściśle, w jakim podzespole
mają się znajdować potrzebne typy FormattableString i FormattableStringFactory. Dla
kompilatora ważne są przestrzenie nazw. Ponadto kompilator oczekuje, że w typie
FormattableStringFactory dostępna będzie odpowiednia statyczna metoda Create —
i to wszystko. Jeśli chcesz skorzystać z zalet typu FormattableString, ale kod ma działać
w starszych wersjach platformy, możesz samodzielnie zaimplementować oba wymie-
nione typy.
Zanim pokażę potrzebny kod, chcę podkreślić, że tę technikę należy stosować
w ostateczności. Gdy w przyszłości zaktualizujesz środowisko i zaczniesz używać plat-
formy .NET 4.6, powinieneś natychmiast usunąć utworzone typy, aby uniknąć ostrzeżeń
ze strony kompilatora. Choć możesz używać własnej implementacji nawet wtedy, gdy
kod działa w platformie .NET 4.6, starałbym się unikać takich sytuacji. Według mojego
doświadczenia występowanie tego samego typu w różnych podzespołach może skutkować
trudnymi do zdiagnozowania problemami.
Po przedstawieniu wszystkich tych zastrzeżeń można przejść do implementacji.
Jest ona prosta. Oba potrzebne typy są pokazane na listingu 9.10. Pominąłem tu spraw-
dzanie poprawności, typ FormattableString utworzyłem jako konkretny, aby zachować
zwięzłość kodu, a ponadto obie klasy napisałem jako wewnętrzne. Jednak dla kompi-
latora te zmiany nie mają znaczenia. Oba typy są wewnętrzne, aby inne podzespoły nie
korzystały z tej implementacji. Trudno stwierdzić, czy to rozwiązanie będzie odpo-
wiednie dla Ciebie, jednak dobrze się zastanów przed publicznym udostępnieniem
tych typów.

Listing 9.10. Implementowanie typu FormattableString od podstaw

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;

internal FormattableString(string format, object[] arguments)


{
Format = format;

87469504f326f0d7c1fcda56ef61bd79
8
310 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków

this.arguments = arguments;
}

public object GetArgument(int index) => arguments[index];


public object[] GetArguments() => arguments;
public int ArgumentCount => arguments.Length;
public static string Invariant(FormattableString formattable) =>
formattable?.ToString(CultureInfo.InvariantCulture);
public string ToString(IFormatProvider formatProvider) =>
string.Format(formatProvider, Format, arguments);
public string ToString(
string ignored, IFormatProvider formatProvider) =>
ToString(formatProvider);
}
}

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ć?

9.4. Zastosowania, wskazówki i ograniczenia


Literały tekstowe z interpolacją, podobnie jak składowe z ciałem w postaci wyrażenia,
umożliwiają bezpieczne eksperymentowanie. Możesz dostosować kod zgodnie z ocze-
kiwaniami swoimi lub zespołu. Jeśli później zmienisz zdanie i zechcesz wrócić do
starszej wersji kodu, możesz to zrobić w bardzo prosty sposób. Nie oznacza to, że
powinieneś wszędzie stosować takie literały. W tym podrozdziale opisuję, gdzie warto
posługiwać się literałami tekstowymi z interpolacją, gdzie nie należy ich stosować
i gdzie nie możesz ich użyć, nawet gdybyś chciał.

9.4.1. Programiści i maszyny, ale raczej nie użytkownicy końcowi


Na początek dobra wiadomość — prawie wszędzie tam, gdzie już stosujesz formato-
wanie za pomocą zapisanych na stałe złożonych łańcuchów znaków z formatowaniem
lub zwykłe złączanie łańcuchów znaków, możesz wykorzystać także łańcuchy znaków
z interpolacją. Zwykle pozwala to poprawić czytelność kodu.
Ważna jest tu informacja o „zapisaniu na stałe”. Literały tekstowe z interpolacją nie
są dynamiczne. Złożony łańcuch znaków formatowania znajduje się w kodzie źródło-
wym. Kompilator jedynie przekształca go, aby zastosować zwykłe elementy formatujące.
Jest to odpowiednie, jeśli z góry znasz tekst i formatowanie łańcucha znaków. Technika
ta nie daje jednak dużo swobody.
Łańcuchy znaków można pokategoryzować m.in. według tego, kto (lub co) będzie
z nich korzystać. Na potrzeby tego podrozdziału przyjmijmy trzy kategorie:
 łańcuchy znaków przeznaczone do parsowania w innym kodzie,
 komunikaty dla innych programistów,
 komunikaty dla użytkowników końcowych.

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

wspomniałem, że ustawienia regionalne mogą wpływać na wyświetlane dane, ponieważ


w różnych częściach świata używane są inne kalendarze. Dobrze się zastanów, czy
chcesz używać niezmiennych ustawień regionalnych, aby wymusić stosowanie kalenda-
rza gregoriańskiego. Na przykład kod zgłaszający wyjątek w reakcji na błędny argument
może wyglądać tak:
throw new ArgumentException(Invariant(
$"Rok z daty początkowej {start:yyyy-MM-dd} nie może być wcześniejszy niż 2000."))

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.

9.4.2. Sztywne ograniczenia literałów tekstowych z interpolacją


Każdy mechanizm ma swoje ograniczenia, a literały tekstowe z interpolacją nie są tu
wyjątkiem. Czasem te ograniczenia można obejść, co pokazuję przed podaniem ogólnej
wskazówki, by nie stosować takich literałów w opisanych tu scenariuszach.

87469504f326f0d7c1fcda56ef61bd79
8
9.4. Zastosowania, wskazówki i ograniczenia 313

BRAK DYNAMICZNEGO FORMATOWANIA


Wiesz już, że nie można modyfikować większości złożonego łańcucha znaków forma-
towania tworzącego literał tekstowy z interpolacją. Wydaje się, że jeden z elementów —
pojedyncze łańcuchy znaków formatowania — powinien umożliwiać dynamiczne poda-
wanie, jest jednak inaczej. Wykorzystajmy fragment z wcześniejszego przykładu:
Console.WriteLine($"Cena: {price,9:C}");

Tu przy wyrównywaniu używanych jest 9 znaków, ponieważ wiadomo, że formatowane


wartości łatwo zmieszczą się w takim obszarze. Co jednak zrobić, jeśli wiesz, że czasem
wszystkie formatowane wartości będą krótkie, a w innych sytuacjach mogą być długie?
Wygodnie byłoby podawać liczbę znaków (tu 9) dynamicznie, nie istnieje jednak prosty
sposób, aby to zrobić. Najbardziej zbliżona technika polega na użyciu literału tekstowego
z interpolacją jako danych wejściowych metody string.Format lub odpowiedniej wersji
metody Console.WriteLine:
int alignment = GetAlignmentFromValues(allTheValues);
Console.WriteLine($"Cena: {{0,{alignment}:C}}", price);

Podane są po dwa początkowe i końcowe nawiasy klamrowe, aby uzyskać sekwencję


ucieczki, ponieważ wynikiem literału tekstowego z interpolacją ma być łańcuch znaków
w postaci "Cena: {0,9}", gotowy do sformatowania z użyciem zmiennej price dla ele-
mentu formatującego. Nie jest to kod, jaki chciałbym pisać lub czytać.
BRAK PONOWNEGO PRZETWARZANIA WYRAŻEŃ
Kompilator zawsze przekształca literał tekstowy z interpolacją w kod, który natych-
miast przetwarza wyrażenia w elementach formatujących i używa wyników do wyge-
nerowania obiektu typu string lub FormattableString. Tego przetwarzania nie da się
odroczyć ani powtórzyć. Rozważ krótki przykład z listingu 9.11. Ten kod dwukrotnie
wyświetla tę samą wartość, choć programista może oczekiwać, że kod będzie wykony-
wany w leniwy sposób.

Listing 9.11. Nawet typ FormattableString powoduje zachłanne wykonywanie kodu

string value = "Przed";


FormattableString formattable = $"Aktualna wartość: {value}";
Console.WriteLine(formattable); Wyświetla tekst "Aktualna wartość: Przed".

value = "Po";
Console.WriteLine(formattable); Także wyświetla tekst "Aktualna wartość: Przed".

Jeśli jesteś zdesperowany, możesz znaleźć rozwiązanie. Jeżeli zastosujesz tu wyrażenie


lambda, które przechwytuje zmienną value, możesz nadużyć tej techniki i przetwarzać
tekst przy każdym formatowaniu. Choć samo wyrażenie lambda jest natychmiast prze-
kształcane w delegat, wynikowy delegat przechwyci zmienną value, a nie jej aktualną
wartość. Można więc wymusić przetwarzanie delegata przy każdym formatowaniu
obiektu typu FormattableString. Jest to na tyle zły pomysł, że choć w kodzie źródłowym
powiązanym z książką zamieściłem przykład zastosowania go, nie zamierzam kalać nim
kart tej książki. (Przyznaję jednak, że jest to ciekawe nadużycie wyrażeń lambda).

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"}");

Można go łatwo naprawić, dodając nawias wokół wyrażenia ?:.


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.

9.4.3. Kiedy można stosować literały tekstowe z interpolacją,


ale nie należy tego robić?
Kompilator nie będzie narzekał, jeśli zaczniesz nadużywać literałów tekstowych z inter-
polacją, jednak Twoi współpracownicy mogą być mniej wyrozumiali. Są dwa podsta-
wowe powody, dla których nie należy stosować takich literałów nawet wtedy, gdy jest to
dozwolone.
ODRACZANIE FORMATOWANIA ŁAŃCUCHÓW ZNAKÓW,
KTÓRE MOGĄ NIE ZOSTAĆ UŻYTE
Czasem warto przekazać łańcuch znaków formatowania i formatowane argumenty do
metody, która wykorzysta te elementy lub nie. Na przykład jeśli używana jest metoda
sprawdzająca poprawność, możesz chcieć przekazać warunek do sprawdzenia, a także
format i argumenty komunikatu wyjątku generowanego wtedy i tylko wtedy, gdy
warunek nie jest spełniony. Łatwo można napisać kod tego rodzaju:
Preconditions.CheckArgument(
start.Year < 2000,
Invariant($"Rok w dacie początkowej {start:yyyy-MM-dd} ma być nie mniejszy niż 2000."));

Możesz też korzystać z platformy do zapisu danych w dzienniku, rejestrującej infor-


macje tylko wtedy, gdy w czasie wykonywania kodu odpowiednio skonfigurowano poziom
rejestrowanych danych. Możliwe, że chcesz zapisywać długość żądania otrzymanego
przez serwer:
Logger.Debug("Otrzymano żądanie o długości {0} bajtów", request.Length);

Niewykluczone, że poczujesz pokusę zastosowania literału tekstowego z interpolacją


i zmienisz kod w następujący sposób:
Logger.Debug($"Otrzymano żądanie o długości {request.Length} bajtów");

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();
}))()}!");

Gorąco zachęcam do uruchamiania — najpierw uruchom ten kod, aby udowodnić, że


działa, a następnie uruchom mięśnie, by jak najszybciej od niego uciec. A w trakcie
odpoczynku zapoznaj się z następnym mechanizmem z języka C# 6 powiązanym
z łańcuchami znaków.

9.5. Dostęp do identyfikatorów


za pomocą operatora nameof
Operator nameof można bardzo łatwo opisać. Przyjmuje on wyrażenie reprezentujące
składową lub zmienną lokalną, a zwraca stały na etapie kompilacji łańcuch znaków z pro-
stą nazwą tej składowej lub zmiennej. Jego działanie jest aż tak proste. Za każdym razem,
gdy zapisujesz na stałe nazwę klasy, właściwości lub metody, lepiej użyć operatora nameof.
Kod będzie dzięki temu bardziej odporny na błędy w danym momencie i w obliczu
zmian.

9.5.1. Pierwsze przykłady stosowania operatora nameof


Jeśli chodzi o składnię, operator nameof działa podobnie jak operator typeof. Różnica
polega na tym, że identyfikator w nawiasie nie musi być typem. Na listingu 9.12 poka-
zany jest krótki przykład z użyciem kilku rodzajów składowych.

Listing 9.12. Wyświetlanie nazw klas, metod, pól i parametrów

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));
}
}

Wynik jest zapewne zgodny z Twoimi oczekiwaniami:


SimpleNameof
Main
args
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.

Listing 9.13. Prosta metoda, w której parametr jest używany dwukrotnie

static void RenameDemo(string oldName)


{
Console.WriteLine($"{nameof(oldName)} = {oldName}");
}

Jeśli w środowisku Visual Studio umieścisz kursor na dowolnym z trzech wystąpień


nazwy oldName i wciśniesz klawisz F2, by otworzyć okno dialogowe Zmień nazwę, zmo-
dyfikowane zostaną wszystkie trzy wystąpienia. Ilustruje to rysunek 9.2.

Rysunek 9.2. Zmienianie nazwy identyfikatora w środowisku Visual Studio

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?

9.5.2. Standardowe zastosowania operatora nameof


Nie twierdzę, że pokazane tu przykłady ilustrują jedyne sensowne zastosowania ope-
ratora nameof. Są to jedynie przykłady, z którymi najczęściej się spotykałem. Dotyczą one
głównie miejsc, w których przed wersją C# 6 występowały albo zapisane na stałe nazwy,
albo drzewa wyrażeń używane jako prowizoryczne rozwiązanie, które ułatwia refakto-
ryzację, ale jest złożone.
SPRAWDZANIE POPRAWNOŚCI ARGUMENTÓW
W rozdziale 8., gdy pokazywałem zastosowanie wywołania Preconditions.CheckNotNull
w bibliotece Noda Time, nie prezentowałem kodu naprawdę używanego w tej biblio-
tece. W rzeczywistym kodzie zwracana jest nazwa parametru o wartości null, dzięki
czemu rozwiązanie jest dużo bardziej przydatne. Metoda InZone wygląda tak:
public ZonedDateTime InZone(
DateTimeZone zone,
ZoneLocalMappingResolver resolver)
{
Preconditions.CheckNotNull(zone, nameof(zone));
Preconditions.CheckNotNull(resolver, nameof(resolver));
return zone.ResolveLocal(this, resolver);
}

Inne metody do sprawdzania warunków są używane w podobny sposób. Jest to zde-


cydowanie najczęstsze ze znanych mi zastosowań operatora nameof. Jeśli nie sprawdzasz
poprawności argumentów metod publicznych, gorąco zachęcam do tego, by zacząć to
robić. Operator nameof sprawia, że można łatwiej niż kiedykolwiek wcześniej dodać
niezawodne sprawdzanie poprawności z użyciem komunikatów z informacjami.
POWIADAMIANIE O ZMIANACH W OBLICZANYCH WŁAŚCIWOŚCIACH
W podrozdziale 7.2 pokazane zostało, że atrybut CallerMemberNameAttribute umożliwia
łatwe zgłaszanie zdarzeń w implementacjach interfejsu INotifyPropertyChanged po
modyfikacji danej właściwości. Co się jednak stanie, jeśli zmiana wartości jednej właści-
wości ma wpływ na inną właściwość? Załóżmy, że używasz klasy Rectangle z właści-
wościami Height i Width do odczytu i zapisu oraz właściwością Area tylko do odczytu.
Przydatna jest wtedy możliwość zgłaszania zdarzenia związanego z właściwością Area
i bezpiecznego podawania nazwy właściwości. Ilustruje to listing 9.14.

Listing 9.14. Używanie operatora nameof w powiadomieniach o modyfikacji


właściwości

public class Rectangle : INotifyPropertyChanged


{
public event PropertyChangedEventHandler PropertyChanged;

private double width;

87469504f326f0d7c1fcda56ef61bd79
8
9.5. Dostęp do identyfikatorów za pomocą operatora nameof 319

private double height;

public double Width


{
get { return width; }
set
{
if (width == value) Unikanie zgłaszania zdarzeń, jeśli wartość się nie zmienia.
{
return;
}
width = value;
RaisePropertyChanged(); Zgłaszanie zdarzenia dotyczącego właściwości Width
RaisePropertyChanged(nameof(Area)); Zgłaszanie zdarzenia
} dotyczącego właściwości Area.
}

public double Height { ... } Implementacja taka jak dla właściwości Width.

public double Area => Width * Height; Obliczana właściwość.

private void RaisePropertyChanged( Powiadamianie o zmianie


[CallerMemberName] string propertyName = null) { ... } (tak jak w podrozdziale 7.2).
}

Większość tego kodu wygląda identycznie jak w wersji C# 5, jednak w C# 5 wiersz


wyróżniony pogrubieniem musiałby mieć postać RaisePropertyChanged("Area") lub
RaisePropertyChanged(() => Area). To drugie podejście byłoby złożone (z powodu wywo-
łania RaisePropertyChanged) i niewydajne (ponieważ drzewo wyrażeń jest tworzone
tylko po to, by sprawdzić w nim nazwę). Rozwiązanie z operatorem nameof jest dużo bar-
dziej przejrzyste.
ATRYBUTY
Czasem w atrybutach używane są inne składowe, aby określić relacje między tymi skła-
dowymi. Gdy podajesz typ, możesz użyć operatora typeof do podania relacji. Ta tech-
nika nie działa jednak dla składowych innych rodzajów. Oto konkretny przykład —
platforma NUnit umożliwia parametryzację testów za pomocą wartości pobieranych
z pól, właściwości lub metod. Używany jest do tego atrybut TestCaseSource. Operator
nameof umożliwia bezpieczne podawanie potrzebnych składowych. Na listingu 9.15
pokazany jest kolejny przykład z biblioteki Noda Time. Ten kod sprawdza, czy wszyst-
kie strefy czasowe z bazy Time Zone Database (TZDB, obecnie zarządzanej przez orga-
nizację IANA) działają poprawnie dla czasu początkowego i końcowego.

Listing 9.15. Podawanie źródła przypadku testowego z użyciem operatora nameof

static readonly IEnumerable<DateTimeZone> AllZones = Pole używane do pobrania wszystkich


DateTimeZoneProviders.Tzdb.GetAllZones(); stref czasowych z bazy TZDB.

[Test]
[TestCaseSource(nameof(AllZones))] Podawanie pola z użyciem operatora nameof.

87469504f326f0d7c1fcda56ef61bd79
8
320 ROZDZIAŁ 9. Mechanizmy związane z łańcuchami znaków

public void AllZonesStartAndEnd(DateTimeZone zone) Metoda testowa wywoływana


{ po kolei dla każdej strefy czasowej.
... Ciało metody testowej (tu pominięte).
}

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 { ... }

Metoda zgłaszająca zdarzenie może przechowywać w buforze strukturę danych infor-


mującą, że po każdej zmianie właściwości Width należy dodatkowo zgłosić powiadomie-
nie o zmianie właściwości Area.
Podobnie w technologiach ORM, takich jak Entity Framework, dość często w klasie
używane są dwie właściwości — jedna z kluczem zewnętrznym i druga z obiektem
reprezentowanym przez ten klucz. Ilustruje to poniższy przykład:
public class Employee
{
[ForeignKey(nameof(Employer))]
public Guid EmployerId { get; set; }
public Company Employer { get; set; }
}

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ń.

9.5.3. Sztuczki i kruczki związane z używaniem operatora nameof


Możliwe, że nigdy nie będziesz potrzebować szczegółów opisanych w tym punkcie. Te
informacje są przedstawione głównie na potrzeby sytuacji, w których działanie operatora
nameof Cię zdziwi. Zwykle jest to dość prosty mechanizm, jednak kilka jego aspektów
może Cię zaskoczyć.
WSKAZYWANIE SKŁADOWYCH Z INNYCH TYPÓW
Często przydatna jest możliwość podawania składowych jednego typu w kodzie innego
typu. Wróćmy do atrybutu TestCaseSource. Oprócz nazwy można podać także typ, w któ-
rym platforma ma szukać tej nazwy. Jeśli używasz źródła informacji, które może być
potrzebne w wielu testach, warto zapisać je w jednym miejscu. Aby użyć do tego
operatora nameof, należy podać także typ. Wynikiem jest prosta nazwa:
[TestCaseSource(typeof(Cultures), nameof(Cultures.AllCultures))]

Jest to odpowiednik poniższego kodu, zapewniający wszystkie standardowe korzyści


stosowania operatora nameof:

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.

Listing 9.16. Wszystkie poprawne sposoby dostępu do nazw składowych w innych


typach

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".

Nie ma znaczenia, jak wywołasz tę metodę. Zarówno Method<Guid>(), jak i Method<But


ton>() dadzą wynik "T".
UŻYWANIE ALIASÓW
Używanie dyrektyw z aliasami typów lub przestrzeni nazw zwykle nie ma wpływu na
program w czasie jego wykonywania. Aliasy to jedynie inne sposoby podawania tego
samego typu lub przestrzeni nazw. Wyjątkiem od tej reguły jest operator nameof. Dane
wyjściowe z listingu 9.17 to GuidAlias, a nie Guid.

Listing 9.17. Używanie aliasu w operatorze nameof

using System;

using GuidAlias = System.Guid;

class Test
{
static void Main()
{
Console.WriteLine(nameof(GuidAlias));
}
}

PREDEFINIOWANE ALIASY, TABLICE I TYPY BEZPOŚREDNIE


PRZYJMUJĄCE WARTOŚĆ NULL
Operatora nameof nie można stosować do żadnych predefiniowanych aliasów (int, char,
long itd.), z przyrostkiem ? oznaczającym typ bezpośredni przyjmujący wartość null, ani
do typów tablicowych. Dlatego poniższe wywołania są nieprawidłowe:
Predefiniowany alias
nameof(float) dla typu System.Single.
nameof(Guid?) Skrótowy zapis typu Nullable<Guid>.
nameof(String[]) Tablica.

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

W podpunkcie poświęconym typom generycznym wspomniałem, że nazwą typu


Nullable<T> i tak zawsze będzie Nullable.

NAZWA, PROSTA NAZWA I TYLKO NAZWA


Operator nameof jest pod niektórymi względami kuzynem mitycznego operatora infoof,
który nigdy nie był widziany poza salą obrad zespołu projektującego języka C#. Więcej
informacji na temat operatora infoof znajdziesz na stronie http://mng.bz/6GVe. Jeśli
zespół kiedykolwiek zdoła schwytać i poskromić operator infoof, możliwe, że będzie
można go używać do zwracania referencji do obiektów typów MethodInfo, EventInfo,
PropertyInfo itd. Jednak do tej pory infoof okazał się nieuchwytny, a liczne sztuczki,
którymi posługuje się w celu uniknięcia schwytania, są niedostępne dla prostszego ope-
ratora nameof. Chcesz pobrać nazwę przeciążonej metody? W porządku — przecież
i tak wszystkie jej wersje mają tę samą nazwę. Nie da się łatwo określić, czy podana jest
właściwość, czy typ? Jeśli nazwa w obu przypadkach jest taka sama, nie ma znaczenia,
czego użyjesz. Choć operator infoof mógłby oferować więcej korzyści niż nameof, gdyby
kiedyś został sensownie zaprojektowany, operator nameof jest znacznie prostszy i okazuje
się przydatny w wielu tych samych scenariuszach.
Warto wspomnieć o tym, jakie dane są zwracane. Jest to prosta nazwa, czyli —
używając mniej formalnej terminologii — „kawałek z końca”. Nie ma znaczenia, czy
w klasie importującej przestrzeń nazw System użyjesz wywołania nameof(Guid), czy nameof
(System.Guid). W obu sytuacjach zwracana jest tylko nazwa "Guid".
PRZESTRZENIE NAZW
Nie omawiałem szczegółowo wszystkich składowych, do jakich można stosować opera-
tor nameof, ponieważ można się domyślić, które to są składowe — w zasadzie wszystkie
oprócz finalizatorów i konstruktorów. Jednak ponieważ zwykle myślimy o składowych
w kategoriach typów i zawartych w nich składowych, możesz być zaskoczony, że można
pobrać także nazwę przestrzeni nazw. Tak, przestrzenie nazw także są składowymi —
innych takich przestrzeni.
Jeśli jednak wziąć pod uwagę wcześniejszą regułę, zgodnie z którą zwracana jest
tylko prosta nazwa, pobieranie nazw przestrzeni nazw nie jest specjalnie przydatne.
Jeżeli użyjesz wywołania nameof(System.Collections.Generic), podejrzewam, że ocze-
kujesz wyniku System.Collections.Generic. Jednak zwracany jest tylko człon Generic.
Nigdy nie natrafiłem na typ, w którym byłoby to przydatne rozwiązanie. Jednak i tak
rzadko potrzebna jest znajomość przestrzeni nazw jako stałej z czasu kompilacji.

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

 Typ FormattableString zapewnia przed formatowaniem dostęp do wszystkich


informacji potrzebnych do formatowania łańcucha znaków.
 Typ FormattableString jest dostępny w platformie .NET 4.6 i specyfikacji .NET
Standard 1.3, jednak kompilator użyje go także w starszych wersjach platformy,
jeśli udostępnisz własną implementację.
 Operator nameof zapewnia ułatwiający refaktoryzację i chroniący przed literów-
kami dostęp do nazw w kodzie w C#.

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.

10.1. Dyrektywa using static


Pierwsza z omawianych tu funkcji umożliwia prostsze podawanie statycznych składo-
wych typu, w tym metod rozszerzających.

87469504f326f0d7c1fcda56ef61bd79
8
326 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu

10.1.1. Importowanie składowych statycznych


Wzorcowym przykładem wykorzystania tej funkcji jest System.Math — klasa statyczna
zawierająca tylko składowe statyczne. W tym punkcie napiszesz metodę, która prze-
kształca współrzędne biegunowe (kąt i odległość) na współrzędne kartezjańskie [znany
model (x, y)] z użyciem bardziej zrozumiałych dla ludzi stopni zamiast radianów do
zapisu kąta. Na rysunku 10.1 pokazany jest konkretny przykład tego, jak jeden punkt
jest reprezentowany w obu układach współrzędnych. Nie martw się, jeśli nie w pełni
rozumiesz obliczenia matematyczne. Jest to jedynie przykład zastosowania wielu skła-
dowych statycznych w krótkim fragmencie kodu.

Rysunek 10.1. Przykładowe współrzędne


biegunowe i kartezjańskie

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.

Listing 10.1. Konwersja współrzędnych biegunowych na kartezjańskie w C# 5

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

Listing 10.2. Konwersja współrzędnych biegunowych na kartezjańskie w C# 6

using static System.Math;

...
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);
}

Widać tu, że składnia dyrektywy using static jest prosta:


using static nazwa-typu-lub-alias;

Po zastosowaniu tej dyrektywy wszystkie wymienione niżej składowe są bezpośrednio


dostępne za pomocą prostych nazw, dzięki czemu nie trzeba stosować kwalifikatora
w postaci typu:
 pola i właściwości statyczne,
 metody statyczne,
 wartości typów wyliczeniowych,
 typy zagnieżdżone.

Możliwość bezpośredniego używania wartości typów wyliczeniowych jest przydatna


przede wszystkim w instrukcjach switch i wszędzie tam, gdzie podawanych jest kilka
takich wartości. Poniższe przykłady zestawione jeden obok drugiego pokazują, jak
pobrać wszystkie pola typu za pomocą refleksji. Pogrubieniem wyróżniony jest kod,
który można usunąć dzięki odpowiedniej dyrektywnie using static.

Kod w C# 5 Po użyciu using static w C# 6

using System.Reflection; using static System.Reflection.BindingFlags;


... ...
var fields = type.GetFields( var fields = type.GetFields(
BindingFlags.Instance | Instance | Static | Public | NonPublic);
BindingFlags.Static |
BindingFlags.Public |
BindingFlags.NonPublic)

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:

Kod w C# 5 Po użyciu using static w C# 6

using System.Net; using static System.Net.HttpStatusCode;


... ...
switch (response.StatusCode) switch (response.StatusCode)
{ {
case HttpStatusCode.OK: case OK:
... ...
case HttpStatusCode.TemporaryRedirect: case TemporaryRedirect:

87469504f326f0d7c1fcda56ef61bd79
8
328 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu

Kod w C# 5 Po użyciu using static w C# 6


— ciąg dalszy — ciąg dalszy

case HttpStatusCode.Redirect: case Redirect:


case HttpStatusCode.RedirectMethod: case RedirectMethod:
... ...
case HttpStatusCode.NotFound: case NotFound:
... ...
default: default:
... ...
} }

W ręcznie pisanym kodzie typy zagnieżdżone są używane stosunkowo rzadko. Częściej


występują w kodzie generowanym. Jeśli je stosujesz — choćby od czasu do czasu —
możliwość bezpośredniego importowania ich w C# 6 pozwala znacznie zwiększyć
przejrzystość kodu. Na przykład moja napisana w C# implementacja platformy do
obsługi serializacji Protocol Buffers Google a generuje typy zagnieżdżone, które repre-
zentują zagnieżdżone komunikaty zadeklarowane pierwotnie w pliku .proto. Ciekawostką
jest tu to, że w C# typy są dwukrotnie zagnieżdżone, aby uniknąć kolizji nazw. Załóżmy,
że pierwotny plik .proto zawiera następujący komunikat:
message Outer {
message Inner {
string text = 1;
}

Inner inner = 1;
}

Wygenerowany kod ma przedstawioną niżej strukturę i zawiera, co oczywiste, znacznie


więcej składowych:
public class Outer
{
public static class Types
{
public class Inner
{
public string Text { get; set; }
}
}

public Types.Inner Inner { get; set; }


}

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" } };

We wszystkich tych sytuacjach składowe, które są dostępne dzięki importowi statycz-


nych elementów, są uwzględniane w czasie wyszukiwania składowych dopiero po

87469504f326f0d7c1fcda56ef61bd79
8
10.1. Dyrektywa using static 329

sprawdzeniu wszystkich innych składowych. Na przykład, jeśli zaimportowałeś statyczne


elementy z klasy System.Math, ale w Twojej klasie także zadeklarowana jest metoda Sin,
wywołanie Sin() będzie dotyczyło Twojej metody, a nie wersji z klasy Math.

Importowany typ nie musi być statyczny


Człon static w dyrektywie using static nie oznacza, że importowany typ musi być sta-
tyczny. W przykładach prezentowanych do tej pory używane były typy statyczne, jednak
można też importować zwykłe typy. Pozwala to na dostęp do składowych statycznych
z tych typów bez kwalifikatora:
using static System.String;
...
string[] elements = { "a", "b" }; Dostęp do metody String.Join
Console.WriteLine(Join(" ", elements)); za pomocą prostej nazwy.

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.

10.1.2. Metody rozszerzające i dyrektywa using static


Jedną z cech języka C# 3, której nigdy nie lubiłem, był sposób wykrywania metod
rozszerzających. Importowanie przestrzeni nazw i importowanie metod rozszerzających
odbywało się za pomocą jednej dyrektywy using. Nie dało się zaimportować przestrzeni
nazw bez metod rozszerzających i nie można było zaimportować metod rozszerzających
z tylko jednego typu. W C# 6 sytuacja się poprawiła, choć niektórych nielubianych
przeze mnie aspektów nie dało się usprawnić bez powodowania niezgodności ze star-
szymi wersjami.
Dwa ważne sposoby interakcji metod rozszerzających i dyrektywy using static
w C# 6 łatwo jest opisać, mają one jednak złożone implikacje:
 Metody rozszerzające z jednego typu można zaimportować za pomocą dyrek-
tywy using static dotyczącej tego typu bez importowania metod rozszerzających
z reszty przestrzeni nazw.
 Metody rozszerzające zaimportowane z typu nie są dostępne w taki sam spo-
sób, jak zwykłe metody statyczne (np. Math.Sin). Trzeba wywoływać je tak, jakby
były metodami instancji rozszerzanego typu.

Pierwszy punkt zilustruję za pomocą najczęściej używanego zestawu metod rozsze-


rzających we wszystkich platformach .NET — metod z technologii LINQ. Klasa System.
Linq.Queryable zawiera metody rozszerzające interfejs IQueryable<T> przyjmujące
drzewa wyrażeń, a klasa System.Linq.Enumerable obejmuje metody rozszerzające inter-
fejs IEnumerable<T> przyjmujące delegaty. Ponieważ interfejs IQueryable<T> dziedziczy
po IEnumerable<T>, zwykła dyrektywa using System.Linq pozwala używać metod rozsze-
rzających przyjmujących delegaty w interfejsie IQueryable<T>, choć zwykle jest to niepo-
żądane. Na listingu 10.3 pokazane jest, że dyrektywa using static System.Linq.Queryable
nie powoduje udostępniania metod rozszerzających dla typu System.Linq.Enumerable.

87469504f326f0d7c1fcda56ef61bd79
8
330 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu

Listing 10.3. Selektywne importowanie metod rozszerzających

using static System.Linq.Queryable;


...
var query = new[] { "a", "bc", "d" }.AsQueryable(); Tworzenie obiektu typu
IQueryable<string>.
Expression<Func<string, bool>> expr =
x => x.Length > 1; Tworzenie delegata i drzewa wyrażenia.
Func<string, bool> del = x => x.Length > 1;

var valid = query.Where(expr); Poprawne — użycie metody Queryable.Where.


var invalid = query.Where(del); Niepoprawne — w zasięgu nie ma metody
Where przyjmującej delegata.

Warto zauważyć, że jeśli przypadkowo zaimportujesz przestrzeń nazw System.Linq za


pomocą zwykłej dyrektywy using, aby umożliwić jawne określanie typu zmiennej query,
automatycznie spowoduje to, że ostatni wiersz będzie poprawny.
Autorzy bibliotek powinni dobrze przemyśleć wpływ tej zmiany. Jeśli chcesz udo-
stępniać metody rozszerzające i umożliwiać użytkownikom jawne ich pobieranie, zachę-
cam do umieszczania tych metod w odrębnej przestrzeni nazw. Dobra wiadomość jest
taka, że teraz możesz mieć pewność, iż wszyscy użytkownicy — a przynajmniej ci
używający wersji C# 6 — mogą selektywnie importować metody rozszerzające, a pro-
gramista nie musi w tym celu tworzyć wielu przestrzeni nazw. Na przykład w bibliotece
Noda Time 2.0 dodałem przestrzeń nazw NodaTime.Extensions z metodami rozsze-
rzającymi dla wielu typów. Spodziewam się, że niektórzy użytkownicy będą chcieli
zaimportować tylko podzbiór tych metod, dlatego podzieliłem deklaracje tych metod
między kilka klas. Każda z tych klas zawiera metody rozszerzające jeden typ. W innych
sytuacjach możesz podzielić metody rozszerzające w odmienny sposób. Ważne jest, że
należy dobrze przemyśleć dostępne możliwości.
To, że metod rozszerzających nie można wywoływać w taki sam sposób jak zwy-
kłych metod statycznych, także można łatwo zademonstrować z użyciem technologii
LINQ. Na listingu 10.4 pokazane jest to na przykładzie wywołania metody Enumerable.
Count dla sekwencji łańcuchów znaków: raz w poprawny sposób jako metody roz-
szerzającej (jakby była to metoda instancji zadeklarowana w typie IEnumerable<T>) i raz
jako zwykłej metody statycznej.

Listing 10.4. Próba wywołania metody Enumerable.Count na dwa sposoby

using System.Collections.Generic;
using static System.Linq.Enumerable;
...
IEnumerable<string> strings = new[] { "a", "b", "c" };

int valid = strings.Count(); Poprawnie — wywołanie Count jak metody instancji.


int invalid = Count(strings); Niepoprawnie — metody rozszerzające nie są importowane
jako zwykłe metody statyczne.

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

(w wyniku użycia modyfikatora this dla pierwszego parametru) w przeszłości było


zmianą niepowodującą niezgodności. W C# 6 taka zmiana powoduje niezgodność ze
starszym kodem. Jednostki wywołujące, które importowały daną metodę za pomocą
dyrektywy using static, nie skompilują się po przekształceniu tej metody w metodę
rozszerzającą.

UWAGA. Metody rozszerzające wykryte za pomocą statycznego importu nie są preferowane


względem metod rozszerzających wykrytych za pomocą importu przestrzeni nazw. Jeśli
wywołanie metody nie jest przetwarzane w standardowy sposób, a dostępnych jest kilka metod
rozszerzających z importowanych przestrzeni nazw lub klas, wybór wersji takiej przeciążonej
metody odbywa się w zwykły sposób.

Inicjalizatory obiektów i kolekcji, podobnie jak metody rozszerzające, zostały dodane


do języka głównie jako element większego mechanizmu w postaci technologii LINQ.
Te inicjalizatory, także podobnie jak metody rozszerzające, zostały w C# 6 wzboga-
cone o nowe możliwości.

10.2. Usprawnienia inicjalizatorów obiektów i kolekcji


Warto przypomnieć, że inicjalizatory obiektów i kolekcji zostały wprowadzone w C# 3.
Inicjalizatory obiektów służą do ustawiania właściwości (i, choć rzadziej, pól) w nowo
tworzonych obiektach. Inicjalizatory kolekcji pozwalają dodawać do nowo tworzo-
nych kolekcji elementy za pomocą metod Add obsługiwanych przez dany typ kolekcji.
Poniższy prosty przykład pokazuje inicjowanie obiektu typu Button z technologii
Windows Forms za pomocą tekstu i koloru tła, a także inicjowanie kolekcji typu
List<int> z użyciem trzech wartości:
Button button = new Button { Text = "Przejdź", BackColor = Color.Red };
List<int> numbers = new List<int> { 5, 10, 20 };

W C# 6 wzbogacono obie te funkcje i zwiększono ich elastyczność. Te usprawnienia nie


są tak powszechnie przydatne jak inne mechanizmy z C# 6, ale stanowią mile widziane
dodatki. Oba omawiane rodzaje inicjalizatorów rozbudowano o obsługę nowych skła-
dowych. Inicjalizatory obiektów mogą obecnie korzystać z indekserów, a inicjalizatory
kolekcji — z metod rozszerzających.

10.2.1. Indeksery w inicjalizatorach obiektów


Do wersji C# 6 inicjalizatory obiektów mogły tylko wywoływać settery właściwości lub
bezpośrednio ustawiać wartości pól. W C# 6 można wywoływać także settery indek-
serów. Służy do tego składnia [indeks] = wartość przeznaczona do wywoływania indek-
serów w zwykłym kodzie.
Aby przedstawić te mechanizmy w prosty sposób, posłużę się typem StringBuilder.
Będzie to dość nietypowe zastosowanie tego typu, a zalecane praktyki są opisane w dal-
szej części rozdziału. Przykładowy kod inicjalizuje obiekt typu StringBuilder na pod-
stawie istniejącego łańcucha znaków ("Ten tekst wymaga przycięcia"), przycina łańcuch
do podanej długości i zmienia ostatni znak w wielokropek z formatowania UTF (…).

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.

Listing 10.5. Używanie indeksera w inicjalizatorze obiektu typu StringBuilder

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.
[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.

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.

Listing 10.6. Dwa sposoby inicjalizowania słownika

var collectionInitializer = new Dictionary<string, int> Zwykły inicjalizator kolekcji


{ z C# 3.
{ "A", 20 },
{ "B", 30 },
{ "B", 40 }
};

87469504f326f0d7c1fcda56ef61bd79
8
10.2. Usprawnienia inicjalizatorów obiektów i kolekcji 333

var objectInitializer = new Dictionary<string, int> Inicjalizator obiektu


{ z indekserem z C# 6.
["A"] = 20,
["B"] = 30,
["B"] = 40
};

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.

Kiedy więc należy używać indeksera w inicjalizatorze obiektów zamiast inicjalizatora


kolekcji? Warto to robić w kilku dość oczywistych scenariuszach. Oto wybrane z nich:
 Gdy nie można użyć inicjalizatora kolekcji, ponieważ typ nie zawiera implemen-
tacji interfejsu IEnumerable lub nie ma odpowiedniej metody Add (jednak można
wtedy dodać własne metody Add jako metody rozszerzające; jest to opisane
w następnym punkcie). Na przykład typ ConcurrentDictionary<,> nie udostępnia
metod Add, ale ma indekser. Udostępnia wprawdzie metody TryAdd i AddOrUpdate,
ale nie są one używane w inicjalizatorze kolekcji. W inicjalizatorze kolekcji nie
musisz martwić się jednoczesnymi aktualizacjami słownika, ponieważ wyłącznie
wątek inicjalizujący kolekcję wie o nowym słowniku.
 Gdy indekser i metoda Add obsługują powtarzające się klucze w ten sam sposób.
To, że w słownikach stosowany jest wzorzec „zgłaszaj wyjątek w metodzie Add,
zastępuj wartości w indekserze”, nie oznacza, że trzeba go przestrzegać we
wszystkich typach.

87469504f326f0d7c1fcda56ef61bd79
8
334 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu

 Gdy chcesz zastępować elementy, zamiast je dodawać. Możliwe, że tworzysz


jeden słownik oparty na innym, a następnie zastępujesz wartość odpowiadającą
określonemu kluczowi.

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

public sealed class SchemalessEntity


: IEnumerable<KeyValuePair<string, object>>
{
private readonly IDictionary<string, object> properties =
new Dictionary<string, object>();

public string Key { get; set; }


public string ParentKey { get; set; }

public object this[string propertyKey]


{
get { return properties[propertyKey]; }
set { properties[propertyKey] = value; }
}

public void Add(string propertyKey, object value)


{
properties.Add(propertyKey, value);
}

public IEnumerator<KeyValuePair<string, object>> GetEnumerator() =>


properties.GetEnumerator();

IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();


}

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.

Listing 10.8. Dwa sposoby inicjalizowania typu SchemalessEntity

SchemalessEntity parent = new SchemalessEntity { Key = "klucz-nadrzędny" };


SchemalessEntity child1 = new SchemalessEntity Podawanie właściwości dotyczących
{ danych z użyciem inicjalizatora kolekcji.
{ "name", "Jon Skeet" },
{ "location", "Reading, UK" }
};

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;

SchemalessEntity child2 = new SchemalessEntity


{
Key = "klucz-encji",
Podawanie właściwości dotyczących klucza w inicjalizatorze obiektu.
ParentKey = parent.Key,
["name"] = "Jon Skeet",
Podawanie właściwości dotyczących danych za pomocą indekserów.
["location"] = "Reading, UK"
};

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.

10.2.2. Używanie metod rozszerzających w inicjalizatorach kolekcji


Druga zmiana w C# 6 związana z inicjalizatorami obiektów i kolekcji dotyczy metod
dostępnych w inicjalizatorach kolekcji. Warto przypomnieć, że jeśli chcesz używać
inicjalizatora kolekcji dla danego typu, spełnione muszą być dwa warunki:
 W typie zaimplementowany musi być interfejs IEnumerable. Dla mnie jest to
irytujące ograniczenie. Czasem implementuję ten interfejs wyłącznie po to, aby
móc używać danego typu w inicjalizatorze kolekcji. Tak już jednak jest. W C# 6
to ograniczenie pozostaje takie samo.
 Dla każdego elementu z inicjalizatora kolekcji dostępna musi być odpowiednia
metoda Add. Język przyjmuje, że elementy, które nie znajdują się w nawiasie
klamrowym, odpowiadają jednoargumentowym wywołaniom metod Add. Gdy
potrzebnych jest wiele argumentów, trzeba je podać w nawiasie klamrowym.

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

metod” z drugiego ograniczenia obejmuje obecnie także metody rozszerzające. Pod


niektórymi względami upraszcza to wprowadzanie zmian w kodzie. Oto deklaracja
z użyciem inicjalizatora kolekcji:
List<string> strings = new List<string>
{
10,
"witaj",
{ 20, 3 }
};

Ta deklaracja to odpowiednik następującego kodu:


List<string> strings = new List<string>();
strings.Add(10);
strings.Add("witaj");
strings.Add(20, 3);

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));
}
}

Po dodaniu tej metody pierwsze i ostatnie wywołanie Add z wcześniejszego kodu


spowoduje uruchomienie nowej metody rozszerzającej. Na liście znajdzie się pięć
elementów ("10", "hello", "20", "20", "20"), ponieważ ostatnie wywołanie Add doda
trzy elementy. Jest to nietypowa metoda rozszerzająca, pomaga jednak pokazać trzy
zagadnienia:
 Metody rozszerzające można stosować w inicjalizatorach kolekcji, co jest głów-
nym tematem tego fragmentu książki.
 Ta metoda rozszerzająca nie jest generyczna. Działa tylko dla typu List<string>.
Metoda wykonuje wyspecjalizowaną operację, której nie można zastosować do
typu List<T>. Generyczne metody rozszerzające są dopuszczalne, ale możliwe
musi być wywnioskowanie argumentów określających typ.
 W metodach rozszerzających można używać parametrów opcjonalnych. Pierwsze
wywołanie Add zostanie skompilowane jako Add(10, 1) z powodu wartości domyśl-
nej drugiego parametru.

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

TWORZENIE NOWYCH WERSJI METODY ADD DO OGÓLNEGO UŻYTKU


Jedną z technik, które były dla mnie przydatne w trakcie pracy nad technologią Proto-
col Buffers, było tworzenie metod Add przyjmujących kolekcje. Ten proces przypomina
używanie metody AddRange, ale metody Add można stosować w inicjalizatorach kolekcji.
Jest to przydatne zwłaszcza w inicjalizatorach obiektów, gdy inicjalizowana właściwość
jest przeznaczona tylko do odczytu, ale chcesz dodać wyniki zapytania w technologii
LINQ.
Przyjrzyj się klasie Person z właściwością tylko do odczytu Contacts, którą chcesz
zapełnić wszystkimi osobami z innej listy mieszkającymi w Reading. W technologii
Protocol Buffers właściwość Contacts byłaby typu RepeatedField<Person>, a typ Repeated
Field<T> ma odpowiednią metodę Add, co pozwala użyć inicjalizatora kolekcji:
Person jon = new Person
{
Name = "Jon",
Contacts = { allContacts.Where(c => c.Town == "Reading") }
};

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.

Listing 10.9. Udostępnianie implementacji interfejsu z użyciem metod rozszerzających

static class ListExtensions


{
public static void Add<T>(this List<T> list, IEnumerable<T> collection)
{
list.AddRange(collection);
}
}

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

Możesz utworzyć klasę pochodną od Dictionary<string, Person> i dodać do niej


metodę Add(Person). Dla mnie nie jest to atrakcyjne rozwiązanie, ponieważ nie tworzysz
wyspecjalizowanej wersji słownika, a jedynie ułatwiasz korzystanie z niego.
Możesz też utworzyć bardziej ogólną klasę z implementacją interfejsu IDictionary
<TKey, TValue>, która przyjmuje delegata obsługującego odwzorowania z typu TValue
na TKey, i zaimplementować ten mechanizm z użyciem kompozycji. Takie rozwiązanie
mogłoby być przydatne, ale jest zanadto rozbudowane, jeśli chcesz wykonać tylko jedno
opisane tu zadanie. Ostatnia możliwość to utworzenie metody rozszerzającej dla tego
jednego specjalnego przypadku. Ilustruje to listing 10.10.

Listing 10.10. Dodawanie do słowników metody Add dla konkretnego argumentu


określającego typ

static class PersonDictionaryExtensions


{
public static void Add(
this Dictionary<string, Person> dictionary, Person person)
{
dictionary.Add(person.Name, person);
}
}

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.

PONOWNE UDOSTĘPNIANIE METOD „UKRYTYCH”


W WYNIKU JAWNEJ IMPLEMENTACJI INTERFEJSU
W punkcie 10.2.1 użyłem typu ConcurrentDictionary<,>, aby pokazać, gdzie można
posłużyć się indekserem zamiast inicjalizatorem kolekcji. Bez dodatkowej pomocy nie
możesz użyć inicjalizatora kolekcji, ponieważ żadna metoda Add nie jest dostępna. Jed-
nak typ ConcurrentDictionary<,> udostępnia metodę Add, przy czym użyto tu jawnej

87469504f326f0d7c1fcda56ef61bd79
8
10.2. Usprawnienia inicjalizatorów obiektów i kolekcji 339

implementacji interfejsu do zaimplementowania metody IDictionary<,>.Add. Zwykle jeśli


chcesz uzyskać dostęp do składowej z jawnej implementacji interfejsu, musisz zrzuto-
wać obiekt na typ interfejsu. W inicjatorze kolekcji nie jest to jednak możliwe. Zamiast
tego możesz udostępnić metodę rozszerzającą, co jest pokazane na listingu 10.11.

Listing 10.11. Udostępnianie jawnej implementacji interfejsu z użyciem metod


rozszerzających

public static class DictionaryExtensions


{
public static void Add<TKey, TValue>(
this IDictionary<TKey, TValue> dictionary,
TKey key, TValue value)
{
dictionary.Add(key, value);
}
}

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.

10.2.3. Kod testów a kod produkcyjny


Prawdopodobnie zwróciłeś uwagę na liczne zastrzeżenia w tym podrozdziale. Oma-
wiane mechanizmy związane są z tylko nielicznymi oczywistymi sytuacjami, w których
można stwierdzić: „bez wątpienia należy użyć tu tej techniki”. Większość wad, na które
zwróciłem uwagę, dotyczy obszarów, gdzie mechanizm jest wygodny w jednym frag-
mencie kodu, ale nie powinien być dostępny w innych.

87469504f326f0d7c1fcda56ef61bd79
8
340 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu

Według mojego doświadczenia inicjalizatory obiektów i kolekcji zwykle są używane


w dwóch miejscach:
 w statycznych inicjalizatorach kolekcji, które po inicjalizacji nigdy nie są mody-
fikowane,
 w kodzie testów.

Kwestie związane z dostępnością i poprawnością kodu dotyczą inicjalizatorów statycz-


nych, ale w kodzie testów są dużo mniej istotne. Jeśli stwierdzisz, że w podzespole
z testami wygodnie będzie mieć metody rozszerzające Add, które upraszczają używanie
inicjalizatorów kolekcji, możesz dodać takie metody. W żaden sposób nie wpłynie to
na kod produkcyjny. Podobnie jeśli używasz indekserów w inicjalizatorach kolekcji na
potrzeby testów i przypadkowo wykorzystasz dwukrotnie ten sam klucz, bardzo praw-
dopodobne jest, że testy zakończą się niepowodzeniem. Także tu nie stanowi to dużego
problemu.
Rozróżnienie na testy i kod produkcyjny dotyczy nie tylko omawianych tu funkcji.
Kod testów powinien być wysokiej jakości, jednak ocena tej jakości i skutki stosowania
różnych rozwiązań są inne w kodzie testów i w kodzie produkcyjnym. Dotyczy to przede
wszystkim publicznych interfejsów API.
Dodanie metod rozszerzających w technologii LINQ zachęciło do płynnego łącze-
nia wielu operacji. W wielu sytuacjach idiomatycznym rozwiązaniem jest łączenie wielu
wywołań metod w jedną instrukcję zamiast stosowania odrębnych poleceń. Tak działają
zapytania w technologii LINQ, jednak podejście to stało się idiomatycznym wzorcem
także w interfejsach API takich jak LINQ to XML. Może to prowadzić do tego samego
problemu, który występuje od dawna w łańcuchach wywołań właściwości — napo-
tkanie wartości null skutkuje błędem. W C# 6 można wtedy bezpiecznie zakończyć
działanie łańcucha wywołań, zamiast kończyć pracę kodu zgłoszeniem wyjątku.

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ń.

10.3.1. Proste i bezpieczne dereferencje właściwości


W ramach roboczego przykładu przyjmijmy, że używany jest typ Customer z właści-
wością Profile, która zawiera właściwość DefaultShippingAddress obejmującą właściwość
Town. Teraz załóżmy, że chcesz znaleźć w kolekcji wszystkich klientów (Customer), dla
których domyślny adres wysyłki (DefaultShippingAddress) pochodzi z miasta (Town)
Reading. Gdybyś nie musiał uwzględniać wartości null, mógłbyś zastosować nastę-
pujący kod:

87469504f326f0d7c1fcda56ef61bd79
8
10.3. Operator ?. 341

var readingCustomers = allCustomers


.Where(c => c.Profile.DefaultShippingAddress.Town == "Reading");

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.

Zakładam, że wszystkie elementy kolekcji allCustomers to referencje różne od null. Jeśli


chcesz uwzględnić możliwość występowania elementów null w tym miejscu, możesz
zastosować zapis c?.Profile na początku. W ten sposób uwzględniony zostaje pierwszy
punkt. Operator == obsługuje operandy null, dlatego nie musisz przejmować się ostat-
nim punktem.

10.3.2. Szczegółowe omówienie operatora ?.


W tym krótkim przykładzie opisane zostały tylko właściwości. Jednak operator ?. można
stosować także do metod, pól i indekserów. Podstawowa reguła jest taka, że po napo-
tkaniu operatora ?. kompilator dodaje sprawdzanie wartości null dla elementu wystę-
pującego po lewej stronie operatora ?.. Jeśli wartość tego elementu to null, przetwarza-
nie kończy się, a wynik całego wyrażenia to null. W przeciwnym razie przetwarzanie
jest kontynuowane z użyciem właściwości, metody, pola lub indeksu po prawej stronie

87469504f326f0d7c1fcda56ef61bd79
8
342 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu

operatora ?. bez ponownego wykonywania pierwszej części wyrażenia. Jeśli typem


całego wyrażenia jest typ bezpośredni nieprzyjmujący wartości null i bez operatora ?.,
typ ten zmieniany jest na jego odpowiednik przyjmujący wartość null, jeżeli gdzieś
w sekwencji wywołań występuje operator ?..
Całym wyrażeniem (którego przetwarzanie jest kończone po napotkaniu wartości
null) jest tu sekwencja dostępu do właściwości, pól, indekserów i metod. Inne opera-
tory, np. porównania, powodują przerwanie sekwencji z powodu reguł pierwszeństwa
wykonywania operatorów. Aby to zrozumieć, przyjrzyj się warunkowi w argumencie
metody Where z punktu 10.3.1. Wyrażenie lambda wygląda tak:
c => c.Profile?.DefaultShippingAddress?.Town == "Reading"

Kompilator traktuje to wyrażenie mniej więcej jak następujący kod:


string result;
var tmp1 = c.Profile;
if (tmp1 == null)
{
result = null;
}
else
{
var tmp2 = tmp1.DefaultShippingAddress;
if (tmp2 == null)
{
result = null;
}
else
{
result = tmp2.Town;
}
}
return result == "Reading";

Zauważ, że dostęp do każdej właściwości (kod wyróżniony pogrubieniem) odbywa się


tylko raz. W kodzie dla wersji starszych niż C# 6, ze sprawdzaniem wartości null,
właściwość c.Profile jest potencjalnie obliczana trzykrotnie, a c.Profile.DefaultShip
pingAddress — dwukrotnie. Gdyby te obliczenia zależały od danych modyfikowanych
w innych wątkach, mogłoby to oznaczać problemy. Kod mógłby przejść dwa pierwsze
testy pod kątem wartości null, a i tak zakończyć działanie zgłoszeniem wyjątku Null
ReferenceException. Kod w wersji C# 6 jest bezpieczniejszy i wydajniejszy, ponieważ
wszystkie elementy są przetwarzane jednokrotnie.

10.3.3. Obsługa porównań logicznych


Obecnie porównanie na końcu jest wykonywane z użyciem operatora ==. Nie jest ono
pomijane, jeśli któraś z wcześniejszych wartości to null. Załóżmy, że zamiast tego
operatora chcesz użyć metody Equals:
c => c.Profile?.DefaultShippingAddress?.Town?.Equals("Reading")

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.

Zwykle warto sprowadzić te trzy możliwości do dwóch, przekształcając trzeci wynik


w true lub false. Można to zrobić za pomocą dwóch technik: porównania ze stałą typu
bool lub użycia operatora ??.

Wybory projektowe związane z porównaniami logicznymi


uwzględniającymi wartość null
Działanie typu bool? w porównaniach z elementami nieprzyjmującymi wartości null było
problemem dla projektantów wersji C# 2. To, że wyrażenia x == true i x != false są
prawdziwe, ale oznaczają coś innego, gdy x jest zmienną typu bool?, może zaskakiwać.
Gdy x to null, wyrażenie x == true ma wartość false, a x != false ma wartość true.
Czy była to właściwa decyzja projektowa? Możliwe. Często wszystkie rozwiązania mają
jakieś wady. Podjęte decyzje nie zostaną jednak zmienione, dlatego najlepiej pamiętać
o dokonanych wyborach i pisać kod w sposób jak najbardziej przejrzysty dla osób, które
mogą mieć mniejszą wiedzę.

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.

Tabela 10.1. Możliwości porównań logicznych z użyciem operatora ?.

Nie chcesz wykonywać ciała instrukcji, Chcesz wykonywać ciało instrukcji,


jeśli name to null jeśli name to null

if (name?.Equals("X") ?? false) if (name?.Equals("X") ?? true)


if (name?.Equals("X") == true) if (name?.Equals("X") != false)

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

10.3.4. Indeksery i operator ?.


Wcześniej wspomniałem, że operator ?. działa dla indekserów, a także dla pól, wła-
ściwości i metod. Składnia polega tylko na dodaniu znaku zapytania, jednak tu jest on
umieszczany przed otwierającym nawiasem kwadratowym (czyli tu jest to operator ?[]).
Ta technika działa dla tablic, a także dla indekserów zdefiniowanych przez użytkownika.
Wynikowy typ akceptuje wartości null niezależnie od tego, czy akceptuje je pierwotny
typ. Oto prosty przykład:
int[] array = null;
int? firstElement = array?[0];

To prawie wszystko, co trzeba wiedzieć o działaniu operatora ?. dla indekserów. Ta


technika jest aż tak prosta. Operator ?. nie jest tak przydatny dla indekserów jak dla
właściwości i metod, jednak warto wiedzieć, że jest dostępny — choćby ze względu
na zapewnienie spójności kodu.

10.3.5. Skuteczne używanie operatora ?.


Zobaczyłeś już, że operator ?. jest przydatny, gdy używasz modelu obiektowego z wła-
ściwościami, które mogą przyjmować wartość null. Istnieją jednak także inne atrakcyjne
zastosowania tego operatora. Dwa z nich są opisane w tym miejscu, nie jest to jednak
kompletna lista i możesz wymyślić inne nowatorskie zastosowania.
BEZPIECZNE I WYGODNE ZGŁASZANIE WYJĄTKÓW
Wzorzec bezpiecznego zgłaszania zdarzeń nawet przy używaniu wielu wątków jest
dobrze znany od wielu lat. Na przykład aby zgłosić przypominające pole zdarzenie Click
typu EventHandler, należy napisać kod w następującej formie:
EventHandler handler = Click;
if (handler != null)
{
handler(this, EventArgs.Empty);
}

Ważne są tu dwa aspekty:


 Kod nie wywołuje zdarzenia Click(this, EventArgs.Empty), ponieważ zdarzenie
Click może mieć wartość null. Jest tak, jeśli do zdarzenia nie przypisano żadnej
metody jego obsługi.
 Kod kopiuje wartość pola Click do zmiennej lokalnej, dlatego nawet jeśli pole
zostanie zmodyfikowane w innym wątku po sprawdzeniu wartości null, nadal
dostępna będzie referencja różna od null. Możliwe, że wywołasz nieco „prze-
starzałą” metodę obsługi zdarzenia (której subskrypcję właśnie usunięto), jest to
jednak uzasadniona sytuacja wyścigu.

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.

Listing 10.12. Zarys interfejsu API do zapisu danych w dzienniku zgodnego


z operatorem ?.

public interface ILogger Interfejs zwracany przez metody GetLog i podobne.


{
IActiveLogger Debug { get; }
IActiveLogger Info { get; }
IActiveLogger Warning { get; } Właściwości zwracające null, gdy dziennik jest wyłączony.
IActiveLogger Error { get; }
}

public interface IActiveLogger Interfejs reprezentujący aktywne


{ ujście danych dla dziennika.
void Log(string message);
}

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

używasz XML-owego elementu z opcjonalnym elementem <author>, który może zawie-


rać atrybut name, ale nie musi go obejmować. Możesz łatwo pobrać nazwisko autora za
pomocą dowolnej z dwóch poniższych instrukcji:
string authorName = book.Element("author")?.Attribute("name")?.Value;
string authorName = (string) book.Element("author")?.Attribute("name");

W pierwszej z tych instrukcji operator ?. jest używany dwukrotnie — raz do dostępu do


atrybutu elementu i raz do dostępu do wartości tego atrybutu. W drugiej instrukcji
wykorzystana została obsługa wartości null w operatorach jawnej konwersji przez
technologię LINQ to XML.

10.3.6. Ograniczenia operatora ?.


Oprócz tego, że czasem trzeba używać typów bezpośrednich przyjmujących wartość
null, podczas gdy wcześniej stosowane były same typy nieprzyjmujące wartości null,
z operatorem ?. związanych jest niewiele nieprzyjemnych niespodzianek. Jedynym, co
może Cię zaskoczyć, jest to, że wynik wyrażenia zawsze jest wartością, a nie zmienną.
Skutek tego jest taki, że nie można używać operatora ?. po lewej stronie przypisania.
Na przykład wszystkie poniższe instrukcje są nieprawidłowe:
person?.Name = "";
stats?.RequestCount++;
array?[index] = 10;

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#.

10.4. Filtry wyjątków


Ostatni mechanizm omawiany w tym rozdziale związany jest z nieco wstydliwą kwe-
stią — próbą dorównania językowi Visual Basic. Tak, w Visual Basicu filtry wyjątków
były dostępne od zawsze, jednak w C# wprowadzono je dopiero w C# 6. Możliwe, że
rzadko będziesz z nich korzystać, warto jednak zajrzeć do wnętrza środowiska CLR.
Główne założenie jest takie, że teraz można pisać bloki catch, które przechwytują
wyjątek tylko czasami, na podstawie tego, czy wyrażenie filtrujące zwróci true, czy
false. Zwrócenie wartości true pozwala przechwycić wyjątek. Zwrócona wartość false
powoduje zignorowanie bloku catch.
Wyobraź sobie, że wykonujesz operację sieciową i wiesz, że serwer, z którym się
łączysz, czasem jest wyłączony. Jeśli nie da się nawiązać z nim połączenia, masz inne
możliwości, jednak błędy innego rodzaju powinny skutkować standardowym przeka-
zywaniem wyjątków. Przed wersją C# 6 trzeba było przechwycić wyjątek i ponownie
go zgłosić, jeśli nie miał odpowiedniego stanu:

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.

10.4.1. Składnia i semantyka filtrów wyjątków


Pierwszy przykład (pokazany na listingu 10.13) jest prosty. Kod w pętli pobiera zestaw
komunikatów i zgłasza wyjątek dla każdego z nich. Używany jest filtr wyjątków, który
przechwytuje wyjątki tylko wtedy, gdy komunikat zawiera słowo przechwycić. Filtr
wyjątków jest wyróżniony pogrubieniem.

Listing 10.13. Zgłaszanie trzech wyjątków i przechwytywanie dwóch z nich

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

throw new Exception(message); Zgłaszanie wyjątku (za każdym


} razem z innym komunikatem).
catch (Exception e) Przechwytywanie wyjątku tylko wtedy,
when (e.Message.Contains("przechwycić")) gdy zawiera słowo "przechwycić".
{
Console.WriteLine($"Przechwycono wyjątek: '{e.Message}'"); Wyświetlanie komunikatu
} z przechwyconego
} wyjątku.

Dane wyjściowe to dwa wiersze z przechwyconymi wyjątkami:


Przechwycono wyjątek: 'Ten można przechwycić'
Przechwycono wyjątek: 'Ten też można przechwycić'

Dane wyjściowe dla nieprzechwyconego wyjątku to komunikat Ten nie zostanie


przechwycony. To, jak dokładnie będzie on wyglądał, zależy od sposobu uruchomienia
kodu. Jest to jednak zwykły nieprzechwycony wyjątek.
Składniowo to już wszystko, co trzeba wiedzieć o filtrach wyjątków. Wystarczy zasto-
sować kontekstowe słowo kluczowe when, a następnie wyrażenie w nawiasie, w którym
można użyć zmiennej wyjątku zadeklarowanej w klauzuli catch i które musi zwracać
wartość logiczną. Jednak semantyka tej techniki może być inna, niż oczekujesz.
DWUPRZEBIEGOWY MODEL OBSŁUGI WYJĄTKÓW
Prawdopodobnie wiesz już, że środowisko CLR w trakcie przekazywania wyjątku
rozwija stos do momentu przechwycenia wyjątku. Bardziej zaskakujący jest jednak
dokładny przebieg tego procesu. Jest on bardziej skomplikowany, niż zapewne się spo-
dziewasz, i opiera się na modelu dwuprzebiegowym1. W tym modelu występują nastę-
pujące kroki:
 Wyjątek jest zgłaszany i rozpoczyna się pierwszy przebieg.
 Środowisko CLR sprawdza stos i próbuje ustalić, który blok catch obsługuje dany
wyjątek. Nazywam go tu blokiem catch obsługującym wyjątek, nie jest to jednak
oficjalna terminologia.
 Uwzględniane są tylko bloki catch z kompatybilnymi typami wyjątków.
 Jeśli blok catch zawiera filtr wyjątków, kod używa tego filtru. Jeśli filtr zwraca
wartość false, dany blok catch nie obsługuje wyjątku.
 Blok catch bez filtra wyjątków jest odpowiednikiem bloku z filtrem wyjątków
zwracającym true.
 Po ustaleniu bloku catch obsługującego wyjątek rozpoczyna się drugi przebieg.
 Środowisko CLR rozwija stos od miejsca zgłoszenia wyjątku do ustalonego bloku
catch.
 Wszystkie bloki finally napotkane w trakcie rozwijania stosu są wykonywane.
Nie dotyczy to bloku finally powiązanego z blokiem catch obsługującym wyjątek.
 Wykonywany jest blok catch obsługujący wyjątek.

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

 Wykonywany jest blok finally powiązany z blokiem catch obsługującym wyją-


tek (jeśli taki blok finally istnieje).

Na listingu 10.14 pokazany jest przykład obejmujący wszystkie te kroki. Istotne są tu


trzy metody: Bottom (dolna), Middle (środkowa) i Top (górna). Metoda Bottom wywołuje
metodę Middle, a metoda Middle wywołuje metodę Top, dzięki czemu stos nie wymaga
objaśnień. Metoda Main wywołuje metodę Bottom, aby rozpocząć cały proces. Długość
kodu nie powinna Cię przerażać — nie dzieje się tu nic specjalnie skomplikowanego.
Filtry wyjątków są wyróżnione pogrubieniem. Metoda LogAndReturn to wygodny sposób
na śledzenie wykonywania kodu. Jest używana przez filtry wyjątków do zapisu wykonania
określonej metody, a następnie zwraca określoną wartość z informacją, czy wyjątek
należy przechwycić.

Listing 10.14. Trzypoziomowa ilustracja filtrowania wyjątków

static bool LogAndReturn(string message, bool result)


{
Console.WriteLine(message); Metoda pomocnicza wywoływana
przez filtry wyjątków.
return result;
}

static void Top()


{
try
{
throw new Exception();
}
finally
{ Blok finally (bez bloku catch)
Console.WriteLine("Finally w metodzie Top"); wykonywany w drugim przebiegu.
}
}

static void Middle()


{
try
{
Top(); Filtr wyjątków, który nigdy
} nie jest wywoływany
catch (Exception e) (podano niewłaściwy
typ wyjątku).
when (LogAndReturn("Filtr w metodzie Middle", false))
{
Console.WriteLine("Przechwycono w metodzie Middle"); Ten tekst nigdy nie jest
} wyświetlany, ponieważ
finally filtr zwraca false.
{ Blok finally wykonywany
Console.WriteLine("Finally w metodzie Middle"); w drugim przebiegu.
}
}

static void Bottom()


{
try

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.

static void Main()


{
Bottom();
}

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.

Wpływ modelu dwuprzebiegowego na bezpieczeństwo


Czas wykonywania bloków finally wpływa także na instrukcje using i lock. Ma to poważny
wpływ na to, do czego można używać bloków try/finally i using, jeśli piszesz program,
który może być wykonywany w środowisku mogącym obejmować niebezpieczny kod.
Jeżeli metoda może być wywoływana przez kod, któremu nie ufasz, a dopuszczasz prze-
kazywanie wyjątków z tej metody, w jednostce wywołującej można posłużyć się filtrem
wyjątków do wykonywania kodu przed uruchomieniem Twojego bloku finally.
Wszystko to oznacza, że nie należy używać bloku finally do wykonywania operacji wyma-
gających bezpieczeństwa. Na przykład, jeśli blok try przechodzi w stan z większymi upraw-
nieniami i polegasz na tym, że blok finally przywróci niższe uprawnienia, inny kod może
zostać wykonany w czasie, gdy uprawnienia wciąż będą wysokie. W dużej ilości kodu nie
trzeba przejmować się takimi kwestiami, ponieważ program zawsze działa w bezpiecznych
warunkach. Jednak z pewnością trzeba pamiętać o możliwym ryzyku. Jeśli masz obawy,
możesz użyć pustego bloku catch z filtrem, aby przywracać niższe uprawnienia i zwracać
false (aby wyjątek nie był przechwytywany). Nie jest to jednak technika, z której chciałbym
regularnie korzystać.

87469504f326f0d7c1fcda56ef61bd79
8
10.4. Filtry wyjątków 351

Rysunek 10.2. Proces


wykonywania programu
z listingu 10.14

WIELOKROTNE PRZECHWYTYWANIE WYJĄTKÓW TEGO SAMEGO TYPU


W przeszłości podawanie tego samego typu wyjątków w wielu blokach catch powią-
zanych z jednym blokiem try zawsze było błędem. Nie miało to sensu, ponieważ kod
nigdy nie dochodził wtedy do drugiego bloku przechwytującego dany wyjątek. Jednak
gdy używane są filtry wyjątków, takie rozwiązanie może być przydatne.
Aby to zilustrować, należy rozbudować początkowy przykład z wyjątkami typu Web
Exception. Załóżmy, że pobierasz informacje z sieci na podstawie adresu URL
podanego przez użytkownika. Możliwe, że chcesz obsługiwać błąd połączenia w jeden
sposób, błąd określania nazwy w inny sposób, a pozostałe wyjątki przekazywać do
bloku catch na wyższym poziomie stosu. Dzięki filtrom wyjątków możesz to zrobić
w prosty sposób:
try
{
... Próba wykonania operacji w sieci.
}
catch (WebException e)

87469504f326f0d7c1fcda56ef61bd79
8
352 ROZDZIAŁ 10. Szwedzki stół z funkcjami do pisania zwięzłego kodu

when (e.Status == WebExceptionStatus.ConnectFailure)


{
... Obsługa błędu połączenia.
}
catch (WebException e)
when (e.Status == WebExceptionStatus.NameResolutionFailure)
{
… Obsługa błędu określania nazwy.
}

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.

10.4.2. Ponawianie operacji


Wraz ze wzrostem powszechności chmur obliczeniowych programiści stają się bardziej
świadomi tego, jakie operacje mogą się nie powieść, i wiedzą, że trzeba zastanowić
się nad wpływem niepowodzeń na kod. W przypadku operacji wykonywanych zdalnie
(np. wywołań usług sieciowych i operacji na bazach danych) błędy czasem są tymcza-
sowe i można bezpiecznie ponowić próbę wykonania operacji.

Analizowanie polityki ponawiania prób


Choć możliwość ponawiania prób jest przydatna, warto wiedzieć, które warstwy kodu mogą
próbować ponawiać nieudane operacje. Jeśli używasz wielu warstw abstrakcji, a każda
z nich próbuje uprzejmie i automatycznie ponawiać próby po awarii, która może być tym-
czasowa, zarejestrowanie rzeczywistego błędu może zostać znacznie opóźnione. Krótko
można opisać to tak: ten wzorzec nie łączy się dobrze sam ze sobą.
Jeśli kontrolujesz cały stos technologiczny aplikacji, powinieneś się zastanowić, na którym
poziomie chcesz ponawiać próby. Jeżeli odpowiadasz za tylko jeden aspekt aplikacji,
powinieneś rozważyć umożliwienie konfigurowania procesu ponawiania prób, aby pro-
gramista kontrolujący cały stos mógł określić, czy to w Twojej warstwie należy ponawiać
próby.

Obsługa ponawiania prób w środowisku produkcyjnym jest dość skomplikowana.


Możliwe, że będziesz potrzebować złożonych heurystyk, aby ocenić, kiedy i jak długo
należy ponawiać próby. Przydatny może być też czynnik losowy dotyczący opóźnień
między próbami, aby uniknąć synchronizacji między klientami ponawiającymi próby. Na
listingu 10.15 pokazana jest bardzo uproszczona wersja takiego mechanizmu2, która nie
powinna odwracać Twojej uwagi od filtrów wyjątków.

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

W kodzie potrzebne są tylko następujące informacje:


 jaką operację próbujesz wykonać,
 ile razy chcesz ponawiać próby jej wykonania.

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.

Listing 10.15. Prosta pętla do ponawiania prób wykonania operacji

static T Retry<T>(Func<T> operation, int attempts)


{
while (true)
{
try
{
attempts--;
return operation();
}
catch (Exception e) when (attempts > 0)
{
Console.WriteLine($"Błąd: {e}");
Console.WriteLine($"Pozostałe próby: {attempts}");
Thread.Sleep(5000);
}
}
}

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;
};

var result = Retry(temporamentalCall, 3);


Console.WriteLine(result);

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

na początku minuty, wywołania kilka razy zakończą się niepowodzeniem, przechwy-


ceniem wyjątku i zapisaniem go, przy czym po trzecim niepowodzeniu wyjątek nie
zostanie już przechwycony.

10.4.3. Zapis danych w dzienniku jako efekt uboczny


Drugi przykład pokazuje zapis informacji o wyjątku w trakcie przekazywania go. Zdaję
sobie sprawę, że posługuję się zapisem danych w dzienniku do ilustrowania wielu
mechanizmów z C# 6. Jest to jednak zbieg okoliczności. Nie uważam, by zespół roz-
wijający C# koncentrował się w tej wersji na zapisie danych w dzienniku. Jednak obsługa
dobrze się sprawdza, ponieważ jest znanym scenariuszem.
Kwestia tego, jak i gdzie warto rejestrować wyjątki, jest wysoce dyskusyjna. Nie
mam zamiaru uczestniczyć tu w tej debacie. Zamiast tego stwierdzę, że — przynajm-
niej czasem — warto jest zarejestrować wyjątek w wywołaniu jednej metody, nawet jeśli
zostanie przechwycony (i prawdopodobnie zarejestrowany po raz wtóry) w innym
miejscu stosu.
Filtry wyjątków można wykorzystać do zapisywania wyjątków w sposób, który nie
zakłóca pracy programu. Wystarczy zastosować filtr wyjątków, który wywołuje metodę
zapisującą wyjątek, a następnie zwraca false, aby poinformować, że program nie ma
w tym miejscu przechwytywać tego wyjątku. Na listingu 10.16 pokazane jest to
w metodzie Main, która powoduje zakończenie procesu poprzez zgłoszenie kodu błędu,
ale dopiero po zapisaniu wyjątku i znacznika czasu.

Listing 10.16. Zapis danych w dzienniku za pomocą filtra

static void Main()


{
try
{
UnreliableMethod();
}
catch (Exception e) when (Log(e))
{
}
}

static void UnreliableMethod()


{
throw new Exception("Bach!");
}

static bool Log(Exception e)


{
Console.WriteLine($"{DateTime.UtcNow}: {e.GetType()} {e.Message}");
return false;
}

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

10.4.4. Pojedyncze, specyficzne filtry wyjątków


Oprócz tych ogólnych rozwiązań w specjalnym kodzie biznesowym czasem trzeba
przechwytywać jedne wyjątki, a inne przekazywać dalej. Jeśli masz wątpliwości, czy
taka technika jest kiedykolwiek przydatna, zastanów się, czy zawsze przechwytujesz
wyjątki typu Exception, czy może częściej przechwytujesz specyficzne wyjątki, np. typów
IOException lub SqlException. Rozważ następujący blok:
catch (IOException e)
{
...
}

Możesz potraktować go jak odpowiednik następującego kodu:


catch (Exception tmp) when (tmp is IOException)
{
IOException e = (IOException) tmp;
...
}

Filtry wyjątków z C# 6 są uogólnieniem tej techniki. Informacje o problemie zawiera


często nie sama nazwa typu, ale inne komponenty. Na przykład typ SqlException zawiera
właściwość Number określającą źródło problemu. Zrozumiałe jest, że programista może
chcieć niektóre błędy SQL-owe obsługiwać w jeden sposób, a pozostałe — w inny.
Pobieranie stanu żądania HTTP z wyjątku typu WebException jest dość skomplikowane
z powodu działania dostępnego interfejsu API, jednak możliwe, że chcesz inaczej obsłu-
giwać odpowiedź 404 (nie znaleziono), a inaczej 500 (błąd wewnętrzny).
Słowo ostrzeżenia — zdecydowanie odradzam filtrowanie na podstawie komuni-
katów wyjątków (chyba że w celach edukacyjnych, tak jak na listingu 10.13). Komuni-
katy wyjątków zwykle nie muszą pozostawać niezmienne między kolejnymi wersjami
języka. Możliwe też, że stosowana jest dla nich lokalizacja (zależy to od kodu źródło-
wego). Kod, którego działanie zależy od komunikatu wyjątku, jest podatny na błędy.

10.4.5. Dlaczego po prostu nie zgłaszać wyjątków?


Możliwe, że zastanawiasz się, o co tyle hałasu. W końcu zawsze można było ponownie
zgłosić wyjątek. Kod używający filtra wyjątków:
catch (Exception e) when (condition)
{
...
}

nie różni się zanadto od następującego zapisu:


catch (Exception e)
{
if (!condition)
{
throw;
}
...
}

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

 C# 7.0 (w marcu 2017 r. wraz z Visual Studio 2017 w wersji 15.0),


 C# 7.1 (w sierpniu 2017 r. wraz z Visual Studio 2017 w wersji 15.3),
 C# 7.2 (w grudniu 2017 r. wraz z Visual Studio 2017 w wersji 15.5),
 C# 7.3 (w maju 2018 r. wraz z Visual Studio 2017 w wersji 15.7).

W większości podwersji rozbudowano nowe mechanizmy dodane we wcześniejszych


wersjach C# 7.x, zamiast wprowadzać zupełnie nowe rozwiązania (choć opisane
w rozdziale 13. mechanizmy dotyczące referencji znacznie wzbogacono w C# 7.2).
O ile mi wiadomo, nie ma planów wprowadzenia wersji C# 7.4, choć nie wyklu-
czam takiej możliwości. Udostępnianie wielu wersji sprawdziło się całkiem dobrze
i spodziewam się, że podobnie będzie wyglądał proces wprowadzania C# 8.
C# 7 wymaga dłuższego omówienia niż C# 6, ponieważ wprowadzone tu mecha-
nizmy są bardziej złożone. Krotki związane są z ciekawą różnicą w traktowaniu typów
przez kompilator i przez środowisko CLR. Metody lokalne fascynują mnie w kontekście
porównywania ich implementacji z implementacją wyrażeń lambda. Dopasowywanie
wzorców jest stosunkowo łatwe do zrozumienia, jednak wymaga starannego przemy-
ślenia, aby optymalnie wykorzystać ten mechanizm. Funkcje związane z referencjami
są z natury skomplikowane, nawet jeśli pozornie wydają się proste (piszę tu o was,
parametry in).
Podejrzewam, że dla większości programistów liczne mechanizmy wprowadzone
w C# 6 są przydatne w codziennej pracy. Może się jednak okazać, że niektóre funkcje
dodane w C# 7 w ogóle nie będą dla Ciebie użyteczne. W swoim kodzie rzadko korzy-
stam z krotek, ponieważ zwykle piszę kod przeznaczony na platformy, w których krotki

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

W C# 3 technologia LINQ zrewolucjonizowała sposób pisania kodu do obsługi kolekcji


danych. Między innymi umożliwiła zapisywanie wielu operacji w kategoriach tego,
co należy zrobić z każdym pojedynczym elementem: jak przekształcić go z jednej repre-
zentacji na inną, jak przefiltrować elementy w wynikach lub jak posortować kolekcję na
podstawie określonego aspektu każdego elementu. Mimo to w technologii LINQ nie
pojawiło się wiele nowych narzędzi do pracy z danymi innymi niż kolekcje.
Typy anonimowe umożliwiają jeden sposób łączenia danych, ale z poważnym
ograniczeniem, ponieważ są przydatne tylko w ramach bloku kodu. Nie można np.
zadeklarować, że metoda zwraca wartość typu anonimowego. Wynika to z tego, że nie
można podać nazwy typu zwracanej wartości.
W C# 7 dodano obsługę krotek, aby ułatwić łączenie danych, a także rozdzielanie
typu złożonego na poszczególne komponenty. Jeśli mówisz sobie teraz, że w C# już
dostępne są krotki w postaci typów System.Tuple, do pewnego stopnia masz rację. Te typy

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.

11.1. Wprowadzenie do krotek


Krotki umożliwiają utworzenie jednej złożonej wartości na podstawie wielu odrębnych
wartości. Są narzędziem do szybkiego łączenia danych bez dodatkowej hermetyzacji.
Przydają się, gdy wartości są powiązane ze sobą, ale programista nie chce kłopotać się
tworzeniem nowego typu. W C# 7 wprowadzono nową składnię, aby uprościć pracę
z krotkami.
Załóżmy np., że dostępna jest sekwencja liczb całkowitych i chcesz znaleźć wartość
minimalną oraz maksymalną w jednym przebiegu. Wydaje się, że taki kod powinno
dać się umieścić w jednej metodzie. Jaki jednak ma być typ zwracanej przez nią warto-
ści? Mógłbyś zwracać wartość minimalną i użyć parametru out dla wartości maksy-
malnej. Możesz też zastosować dwa parametry out. Oba te rozwiązania wydają się
jednak dość niezgrabne. Mógłbyś też utworzyć odrębny typ nazwany, jednak oznacza
to dużo pracy jak na tylko jeden przykład. Jeszcze inna możliwość to zwrócenie war-
tości typu Tuple<int, int> z użyciem wprowadzonej w .NET 4 klasy Tuple<,>, nie da
się wtedy łatwo ustalić, która wartość to minimum, a która maksimum. Ponadto trzeba
utworzyć obiekt tylko po to, aby zwrócić dwie wartości. Możesz też użyć krotek z C# 7
i zadeklarować następującą metodę:
static (int min, int max) MinMax(IEnumerable<int> source)

Następnie możesz ją wywoływać tak:


int[] values = { 2, 7, 3, -5, 1, 0, 10 }; Wywołanie metody obliczającej minimum
var extremes = MinMax(values); i maksimum oraz zwracającej te wartości jako krotki.
Console.WriteLine(extremes.min); Wyświetlanie wartości minimalnej (-5).
Console.WriteLine(extremes.max); Wyświetlanie wartości maksymalnej (10).

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. Literały i typy krotek


Krotki możesz traktować jak wprowadzenie niektórych typów do środowiska CLR
i jako lukier składniowy ułatwiający używanie tych typów — zarówno ich podawanie
(na potrzeby zmiennych itd.), jak i tworzenie wartości. Zaczynam od objaśnienia wszyst-
kich kwestii z perspektywy języka C#, bez przejmowania się powiązaniami ze śro-
dowiskiem CLR. Później cofam się, aby wyjaśnić wszystko to, co kompilator robi na
zapleczu.

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.

Listing 11.1. Reprezentowanie wartości minimalnej i maksymalnej z sekwencji


za pomocą krotki

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

Dobre nazwy elementów krotek pasują do dobrych nazw zmiennych


Czy to przypadek, że nazwy zmiennych użyte w literale pasują do nazw używanych w typie
wartości zwracanej przez metodę? Dla kompilatora jest to całkowity przypadek. Nie byłoby
dla niego żadnym problemem, gdybyś zadeklarował, że metoda zwraca wartość (waffle:
int, iceCream: int).
Jednak dla człowieka pasujące nazwy nie są zbiegiem okoliczności. Informują one, że war-
tości w zwracanej krotce oznaczają to samo co w metodzie. Jeśli zauważysz, że używasz
zupełnie innych nazw, sprawdź, czy w kodzie nie występuje błąd lub czy nie można
wybrać lepszych nazw.

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.

11.2.2. Wnioskowanie nazw elementów w literałach krotek (C# 7.1)


W C# 7.0 nazwy elementów krotek trzeba jawnie podawać w kodzie. Często prowadzi
to do powstawania kodu, który wygląda na nadmiarowy. Nazwy podane w literale krotki
odpowiadają nazwom właściwości lub zmiennych lokalnych określających wartości.
W najprostszej postaci kod może wyglądać tak:
var result = (min: min, max: max);

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 };

Dalej pokazane są krotki z jawnie podanymi nazwami elementów:


from emp in employees
join dept in departments on emp.DepartmentId equals dept.Id
select (name: emp.Name, title: emp.Title, departmentName: dept.Name);

87469504f326f0d7c1fcda56ef61bd79
8
364 ROZDZIAŁ 11. Łączenie danych z użyciem krotek

Na końcu używane są wywnioskowane nazwy elementów z C# 7.1:


from emp in employees
join dept in departments on emp.DepartmentId equals dept.Id
select (emp.Name, emp.Title, DepartmentName: dept.Name);

Powoduje to zmianę wielkości liter w nazwach elementów krotki w porównaniu


z poprzednim przykładem, jednak pozwala osiągnąć cel, jakim jest utworzenie krotek
o przydatnych nazwach z użyciem zwięzłego kodu.
Choć omawiany mechanizm zaprezentowałem tu w kontekście zapytania w tech-
nologii LINQ, można go stosować wszędzie tam, gdzie używane są literały krotek. Na
przykład gdy dana jest lista elementów, możesz utworzyć krotkę z liczbą wartości, mini-
mum i maksimum, wykorzystując wnioskowanie nazw elementów dla liczby wartości:
ist<int> list = new List<int> { 5, 1, -6, 2 };
var tuple = (list.Count, Min: list.Min(), Max: list.Max());
Console.WriteLine(tuple.Count);
Console.WriteLine(tuple.Min);
Console.WriteLine(tuple.Max);

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ć?

11.2.3. Krotki jako zbiory zmiennych


Następne zdanie może Cię zszokować, więc koniecznie się przygotuj: typy krotek są
typami bezpośrednimi z publicznymi polami do odczytu i zapisu. To chyba nie może
być prawda?! Zwykle zdecydowanie odradzam używanie modyfikowalnych typów
bezpośrednich i zawsze sugeruję, że pola powinny być prywatne. Przeważnie obstaję
przy tych rekomendacjach, jednak krotki są nieco odmienne.
Większość typów nie jest tylko danymi. Typy nadają danym znaczenie. Czasem
sprawdzają też poprawność danych. Zdarza się, że różne elementy danych łączy okre-
ślona relacja. Zazwyczaj dostępne są operacje, które mają sens tylko dzięki znaczeniu
nadanemu danym.
Krotek nie dotyczy żaden z tych punktów. Działają one jak zbiory zmiennych. Jeśli
masz dwie zmienne, możesz modyfikować je niezależnie od siebie. Nie są one z natury
połączone ze sobą i nie jest wymuszana relacja między nimi. Krotki pozwalają zrobić
dokładnie to samo, ale zapewniają coś jeszcze — umożliwiają przekazywanie całego
zbioru zmiennych w jednej wartości. Jest to ważne przede wszystkim w metodach,
ponieważ można w nich zwracać tylko jedną wartość.
Na rysunku 11.3 pokazane jest to w formie graficznej. Po lewej stronie widoczny
jest kod i model umysłowy związany z deklarowaniem trzech niezależnych zmiennych

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.

Listing 11.2. Odczyt i zapis elementów krotek za pomocą nazw i pozycji

var tuple = (x: 5, 10);


Console.WriteLine(tuple.x);
Console.WriteLine(tuple.Item1); Wyświetlanie pierwszego elementu z użyciem nazwy i pozycji.
Console.WriteLine(tuple.Item2); Drugi element nie ma nazwy; można używać tylko pozycji.

tuple.x = 100; Modyfikowanie pierwszego elementu z użyciem nazwy.


Console.WriteLine(tuple.Item1); Wyświetlanie pierwszego elementu
z użyciem pozycji (wyświetla 100).

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. Używanie krotki zamiast dwóch zmiennych w metodzie MinMax

static (int min, int max) MinMax(IEnumerable<int> source)


{
using (var iterator = source.GetEnumerator())
{
if (!iterator.MoveNext())
{
throw new InvalidOperationException(
"Nie można znaleźć minimum i maksimum dla pustej sekwencji");
}
var result = (min: iterator.Current, Tworzenie krotki, w której pierwszy element
max: iterator.Current); jest ustawiany jako maksimum i minimum.
while (iterator.MoveNext())
{
result.min = Math.Min(result.min, iterator.Current); Modyfikowanie osobno
result.max = Math.Max(result.max, iterator.Current); każdego pola krotki.
}
return result; Bezpośrednie zwracanie krotki.
}
}

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

Listing 11.4. Ponowne przypisywanie wartości krotki result w jednej instrukcji


w metodzie MinMax

static (int min, int max) MinMax(IEnumerable<int> source)


{
using (var iterator = source.GetEnumerator())
{
if (!iterator.MoveNext())
{
throw new InvalidOperationException(
"Nie można znaleźć minimum i maksimum w pustej sekwencji");
}
var result = (min: iterator.Current, max: iterator.Current);
while (iterator.MoveNext())
{
result = (Math.Min(result.min, iterator.Current), Przypisywanie nowej wartości
Math.Max(result.max, iterator.Current)); do całej zmiennej result.
}
return result;
}
}

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.

Listing 11.5. Generowanie ciągu Fibonacciego bez krotek

static IEnumerable<int> Fibonacci()


{
int current = 0;
int next = 1;
while (true)
{
yield return current;
int nextNext = current + next;
current = next;
next = nextNext;
}
}

W trakcie iterowania należy śledzić bieżący i następny element sekwencji. W każdej


iteracji kod przechodzi od pary reprezentującej elementy „bieżący i następny” do pary
„następny i jeszcze następny”. Potrzebna jest do tego zmienna tymczasowa. Nie wystar-
czy bezpośrednio przypisać nowych wartości do zmiennych current i next jedna po
drugiej, ponieważ po pierwszym przypisaniu utracone zostaną informacje potrzebne
w drugim przypisaniu.
2
Dwa pierwsze elementy tego ciągu to 0 i 1. Następnie każdy element ciągu jest sumą dwóch
wcześniejszych.

87469504f326f0d7c1fcda56ef61bd79
8
368 ROZDZIAŁ 11. Łączenie danych z użyciem krotek

Krotki umożliwiają wykonanie jednego przypisania modyfikującego oba elementy.


Zmienna tymczasowa nadal jest używana w kodzie pośrednim, jednak wynikowy kod
źródłowy pokazany na listingu 11.6 jest moim zdaniem piękny.
Listing 11.6. Implementowanie ciągu Fibonacciego z użyciem krotek

static IEnumerable<int> Fibonacci()

{
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.

Listing 11.7. Podział zadań związanych z generowaniem ciągu Fibonacciego

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);

Podobny efekt można oczywiście uzyskać z użyciem typów anonimowych, a nawet


typów nazwanych. Rozwiązanie nie byłoby jednak równie eleganckie. Czytelnicy mający
doświadczenie w posługiwaniu się innymi językami programowania mogą nie być pod
dużym wrażeniem tego kodu. Nie jest tak, że w C# 7 wprowadzono zupełnie nowy
paradygmat programowania. Ekscytująca jest jednak możliwość pisania tak pięknego
kodu w C#.

87469504f326f0d7c1fcda56ef61bd79
8
11.3. Typy krotek i konwersje 369

Znasz już podstawy działania krotek. Pora przejść do bardziej zaawansowanych


zagadnień. W następnym podrozdziale omawiane są przede wszystkim konwersje,
wyjaśniam też jednak, w których miejscach nazwy elementów są ważne, a w których nie.

11.3. Typy krotek i konwersje


Do tego miejsca starannie unikałem szczegółowego objaśniania typów literałów krotek.
Dzięki zachowaniu ogólności mogłem pokazać sporo kodu, co pozwoliło Ci zobaczyć,
jak używać krotek. Teraz pora uzasadnić człon „od podszewki” z tytułu książki. Najpierw
zastanów się nad deklaracjami z użyciem słowa var i literałów krotek.

11.3.1. Typy literałów krotek


Niektóre literały krotek mają typ, natomiast inne nie mają. Obowiązuje tu prosta
reguła — literał krotki ma typ, gdy wszystkie wyrażenia reprezentujące elementy tej
krotki mają typ. Wyrażenie bez typu nie jest niczym nowym w C#. Wyrażenia lambda,
grupy metod i literał null to wyrażenia niemające typu. Literałów krotek bez typu
(podobnie jak innych wymienionych wyrażeń) nie można używać do przypisywania
wartości do zmiennych lokalnych z niejawnie określanym typem. Na przykład poniższy
kod jest poprawny, ponieważ 10 i 20 to wyrażenia mające typ:
var valid = (10, 20);

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));

W pierwszym przykładzie pokazane jest, że nazwy elementów są przenoszone z lite-


rałów krotek do typów krotek. W ostatnim przykładzie widać, że wnioskowanie typów
działa także w skomplikowanych scenariuszach; typ zmiennej input umożliwia, by typ
zmiennej x w wyrażeniu lambda został określony na stałe jako string. Pozwala to odpo-
wiednio powiązać wyrażenie x.Length. Powstaje więc literał krotki z elementami typów
string i int, tak więc typ wartości zwracanej przez wyrażenie lambda jest określany
w wyniku wnioskowania jako (string, int). Podobne wnioskowanie pokazane jest na
listingu 11.7 w implementacji tworzenia ciągu Fibonacciego za pomocą metody gene-
rującej sekwencje. Tam jednak nie skupiałem się na używanych typach.

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.

Parametry wyrażeń lambda mogą wyglądać jak krotki


Wyrażenia lambda z jednym parametrem są oczywiste, jeśli jednak używasz dwóch para-
metrów, takie wyrażenia mogą przypominać krotki. Przyjrzyj się np. przydatnej metodzie,
która używa jednej z wersji metody Select z technologii LINQ. Ta metoda zwraca projek-
cję z użyciem wartości i indeksu elementu. Często przydatne jest przekazywanie indeksu
do innych operacji, dlatego oba te komponenty można umieścić w krotce. To oznacza, że
należy utworzyć następującą metodę:
static IEnumerable<(T value, int index)> WithIndex<T>
(this IEnumerable<T> source) =>
source.Select((value, index) => (value, index));
Przyjrzyj się teraz następującemu wyrażeniu lambda:
(value, index) => (value, index)
Pierwsze wystąpienie pary (value, index) nie jest literałem krotki. Jest to sekwencja
parametrów wyrażenia lambda. Drugie wystąpienie jest literałem krotki. Jest to wynik
wyrażenia lambda.
Nie jest to żaden problem. Nie chcę jedynie, abyś był zaskoczony, gdy zetkniesz się
z podobnym kodem.

11.3.2. Konwersje z literałów krotek na typy krotek


Podobnie jak jest w wielu innych obszarach języka C#, istnieją konwersje jawne i nie-
jawne z literałów krotek na typy krotek. Sądzę, że rzadko będziesz potrzebować kon-
wersji jawnych, co objaśniam dalej. Gdy już zrozumiesz przebieg konwersji niejawnych,
konwersje jawne zapewne i tak uznasz za zbędne.
KONWERSJE NIEJAWNE
Literał krotki można niejawnie przekształcić na typ krotki, jeśli spełnione są oba
wymienione tu warunki:
 literał i typ mają tę samą arność,
 każde wyrażenie w literale można niejawnie przekształcić na powiązany typ
elementu.

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

(byte, object) tuple = (5, "tekst");

Musisz przyjrzeć się wyrażeniom reprezentującym każdy element źródłowego literału


krotki (5, "tekst") i sprawdzić, czy istnieje niejawna konwersja na powiązany typ
elementu z docelowym typem krotki (byte, object). Jeśli można przekształcić każdy
element, konwersja jest prawidłowa:

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");

Także teraz spróbuj przeprowadzić niejawną konwersję kolejnych elementów:

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

WSKAZÓWKA. Każda konwersja niejawna w C# jest też uważana za konwersję jawną,


co jest nieco mylące. Aby warunek był bardziej zrozumiały, możesz przyjąć, że oznacza on:
„dla każdego elementu musi istnieć konwersja jawna lub niejawna”.

Wróćmy do konwersji wartości (300, "tekst"). Można ją jawnie przekształcić na typ


krotki (byte, string). Wymaga to jednak użycia kontekstu niekontrolowanego, ponie-
waż kompilator wykrywa, że stała 300 znajduje się poza zakresem typu byte. W bardziej
realistycznym przykładzie można użyć pochodzącej z innego miejsca zmiennej typu int:
int x = 300;
var tuple = ((byte, string)) (x, "tekst");

W rzutowaniu ((byte, string)) na pozór używanych jest więcej nawiasów, niż to


konieczne. Jednak wszystkie te nawiasy są niezbędne. Wewnętrzne oznaczają typ krotki,
natomiast zewnętrzne reprezentują rzutowanie. Na rysunku 11.4 jest to pokazane
w formie graficznej.
Bardzo nie podoba mi się ten zapis, jednak miło mieć
możliwość wykonania takiej konwersji. W wielu sytuacjach
prostszym rozwiązaniem jest użycie odpowiedniego rzu-
towania we wszystkich wyrażeniach reprezentujących
elementy w literale krotki. Wtedy nie tylko konwersja
krotki jest poprawna, ale też wywnioskowany typ literału
jest zgodny z oczekiwaniami programisty. Wcześniejszy
Rysunek 11.4. Objaśnienie przykład zapewne zapisałbym w następujący sposób:
nawiasów w jawnej konwersji
krotki int x = 300;
var tuple = ((byte) x, "tekst");

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

Dalej instrukcja return wyglądała tak:


return (min, max);

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);

Widoczne są tu wszystkie możliwe kombinacje nazw elementów w następującej


kolejności:
1. W docelowym typie i w literale krotki podana jest ta sama nazwa elementu.
2. W docelowym typie i w literale krotki podane są różne nazwy elementu.
3. W docelowym typie określona jest nazwa elementu, jednak nie podano jej
w literale krotki.
4. W docelowym typie nie podano nazwy elementu, jednak jest ona określona
w literale krotki.
5. Nazwa elementu nie jest podana ani w docelowym typie, ani w literale krotki.

Drugi i czwarty scenariusz skutkują oczywiście ostrzeżeniami w czasie kompilacji. Oto


wynik kompilacji takiego kodu:
warning CS8123: The tuple element name 'wrong' is ignored because a different
name is specified by the target type '(int a, int b, int c, int, int)'.
warning CS8123: The tuple element name 'pointless' is ignored because a
different name is specified by the target type '(int a, int b, int c, int, int)'

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.

11.3.3. Konwersja między typami krotek


Po zrozumieniu konwersji literałów krotek niejawne i jawne konwersje typów krotek są
stosunkowo proste, ponieważ przebiegają podobnie. Nie trzeba się tu przejmować
wyrażeniami, ponieważ używane są tylko typy. Istnieje niejawna konwersja ze źró-
dłowego typu krotki na typ docelowy o tej samej arności, jeśli możliwa jest niejawna
konwersja każdego elementu z typu źródłowego na powiązany element w typie doce-
lowym. Podobnie istnieje jawna konwersja ze źródłowego typu krotki na typ docelowy
o tej samej arności, jeżeli obsługiwana jest jawna konwersja każdego elementu z typu
źródłowego na powiązany element w typie docelowym. Oto przykład ilustrujący różne
konwersje typu źródłowego (int, string):
var t1 = (300, "tekst"); Typ zmiennej t1 zostaje wywnioskowany
jako (int, string).
(long, string) t2 = t1; Poprawna niejawna konwersja
Błąd — brak niejawnej z typu (int, string) na (long, string).
(byte, string) t3 = t1; konwersji z typu int na byte.

(byte, string) t4 = ((byte, string)) t1; Poprawna jawna konwersja z typu


(int, string) na (byte, string).
(object, object) t5 = t1; Poprawna niejawna konwersja z typu
(int, string) na (object, object).
(string, string) t6 = ((string, string)) t1; Błąd — brak konwersji z typu int na string.

Tu jawna konwersja z typu (int, string) na (byte, string) w wierszu 4. sprawi, że


wartością elementu t4.Item1 będzie 44. Jest to wynik jawnej konwersji wartości 300
typu int na typ byte.
Inaczej niż w przypadku konwersji literałów krotek tu niedopasowanie nazw ele-
mentów nie skutkuje ostrzeżeniami. Mogę przedstawić to na przykładzie podobnym
do konwersji literałów krotek o pięciu elementach. Wystarczy zapisać wartość krotki
w zmiennej, aby przeprowadzić konwersję z typu na typ zamiast z literału na typ:
var source = (a: 10, wrong: 20, 30, pointless: 40, 50);
(int a, int b, int c, int, int) tuple = source;

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

KONWERSJE TOŻSAMOŚCIOWE TYPÓW KROTEK


Konwersje tożsamościowe występują w C# od czasu powstania tego języka, choć
z czasem zostały rozbudowane. Do wersji C# 7 reguły tych konwersji wyglądały tak:
 Istnieje konwersja tożsamościowa z danego typu na ten sam typ.
 Istnieje konwersja tożsamościowa między typami object i dynamic.
 Istnieje konwersja tożsamościowa między dwoma typami tablicowymi, jeśli
możliwa jest konwersja tożsamościowa elementów tych tablic. Występuje np. kon-
wersja tożsamościowa między typami object[] i dynamic[].
 Konwersje tożsamościowe są rozszerzane na skonstruowane typy generyczne,
gdy istnieje konwersja tożsamościowa między odpowiadającymi sobie argumen-
tami określającymi typ. Na przykład istnieje konwersja tożsamościowa między
typami List<object> i List<dynamic>.

Krotki wprowadzają dodatkowy rodzaj konwersji tożsamościowej — między typami


krotek o tej samej arności, gdy istnieje konwersja tożsamościowa między parami odpo-
wiadających sobie elementów (nazwy elementów nie są tu istotne). Oznacza to, że ist-
nieją konwersje tożsamościowe (w obu kierunkach — konwersja tożsamościowa zawsze
jest symetryczna) między następującymi typami:
 (int x, object y),
 (int a, dynamic d),
 (int, object).

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)>.

W kontekście krotek konwersje tożsamościowe są najważniejsze, gdy używane są typy


skonstruowane. Irytujące byłoby, gdybyś mógł łatwo przekształcić typ (int, int) na
(int x, int y), ale już nie typ IEnumerable<(int, int)> na IEnumerable<(int x, int y)>
lub na odwrót.
Konwersje tożsamościowe są też istotne dla wersji przeciążonych metod. W taki sam
sposób, jak dwie wersje metody nie mogą różnić się tylko typem zwracanej wartości,
tak nie mogą się różnić tylko typami parametrów umożliwiającymi konwersję tożsamo-
ściową. Nie można np. umieścić w jednej klasie dwóch następujących metod:
public void Method((int, int) tuple) {}
public void Method((int x, int y) tuple) {}

Próba utworzenia takich metod spowoduje błąd kompilacji:


error CS0111: Type 'Program' already defines a member called 'Method' with
the same parameter types

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.

11.3.4. Zastosowania konwersji


Wiesz już, jakie możliwości są dostępne. Możliwe, że zastanawiasz się teraz, kiedy
warto wykorzystać konwersje krotek. W dużym stopniu zależy to od tego, jak w ogóle
stosujesz krotki. Krotki używane w jednej metodzie lub zwracane w metodach prywat-
nych i wykorzystywane w tej samej klasie rzadko wymagają konwersji. Wystarczy od
początku wybrać odpowiedni typ i ewentualnie zrzutować typy w literale krotki
w momencie tworzenia początkowej wartości.
Konwersja z jednego typu krotki na inny z większym prawdopodobieństwem będzie
potrzebna, gdy używasz metod wewnętrznych lub publicznych przyjmujących albo
zwracających krotki. Wtedy masz mniejszą kontrolę nad typami elementów. Im szersze
jest zastosowanie danego typu krotki, z tym mniejszym prawdopodobieństwem będzie
to dokładnie ten typ, jaki jest potrzebny w konkretnych sytuacjach.

11.3.5. Sprawdzanie nazw elementów przy dziedziczeniu


Choć w konwersjach nazwy elementów nie są istotne, kompilator jest wymagający przy
ich używaniu w procesie dziedziczenia. Gdy typ krotki występuje w składowej, którą albo
przesłaniasz (a przesłaniana składowa znajduje się w klasie bazowej), albo implemen-
tujesz na potrzeby interfejsu, użyte nazwy elementów muszą pasować do tych z pier-
wotnej definicji. Ponadto jeśli w pierwotnej definicji jakaś nazwa nie występuje, nie
można jej zastosować w implementacji. Typy elementów w implementacji muszą umoż-
liwiać konwersję tożsamościową na typy elementów z pierwotnej definicji.
W ramach przykładu rozważ poniższy interfejs ISample i kilka metod, które mają
być implementacją metody ISample.Method (każda z tych wersji powinna się oczywiście
znaleźć w odrębnej klasie z implementacją):

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.

Poprawnie! Nieodpowiednia arność typu krotki.

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!

UWAGA. Pod niektórymi względami jest to niespójne rozwiązanie, ponieważ kompilator


nigdy wcześniej nie uwzględniał zmiany nazw parametrów metody przez autora klasy, który
przesłaniał metodę lub implementował interfejs. Możliwość podawania nazw argumentów
oznacza, że mogą wystąpić problemy, gdy kod jednostki wywołującej zmieni się i użyty zosta-
nie interfejs zamiast implementacji lub na odwrót. Podejrzewam, że gdyby projektanci języka
C# zaczynali pracę od początku, zakazaliby modyfikowania nazw argumentów w tym kontekście.

W C# 7.3 do krotek dodano nowy mechanizm języka — porównywanie ich za pomocą


operatorów == i !=.

11.3.6. Operatory równości i nierówności (C# 7.3)


W punkcie 11.4.5 zobaczysz, że w środowisku CLR od początku można było porów-
nywać krotki za pomocą metody Equals. W tym środowisku nie istniały jednak prze-
ciążone wersje operatorów == i != dla krotek. W C# 7.3 kompilator udostępnia
implementacje operatorów == i != do porównywania krotek, jeśli istnieje konwersja
tożsamościowa między typami krotek obu operandów. Oznacza to, że nazwy elemen-
tów nie są tu istotne.
Kompilator rozwija operatory == i != jako porównania par wartości odpowiadających
elementów za pomocą operatorów == i !=. Prawdopodobnie najprościej przedstawić to
za pomocą przykładu (zobacz listing 11.8).

Listing 11.8. Operatory równości i nierówności

var t1 = (x: "x", y: "y", z: 1);


var t2 = ("x", "y", 1);

Console.WriteLine(t1 == t2); Operator równości.


Console.WriteLine(t1.Item1 == t2.Item1 &&
t1.Item2 == t2.Item2 && Analogiczny kod generowany przez kompilator.
t1.Item3 == t2.Item3);

87469504f326f0d7c1fcda56ef61bd79
8
378 ROZDZIAŁ 11. Łączenie danych z użyciem krotek

Console.WriteLine(t1 != t2); Operator nierówności.


Console.WriteLine(t1.Item1 != t2.Item1 &&
t1.Item2 != t2.Item2 && Analogiczny kod generowany przez kompilator.
t1.Item3 != t2.Item3);

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.

11.4. Krotki w środowisku CLR


Choć w teorii język C# nie jest powiązany z platformą .NET, w praktyce autorzy każdej
implementacji, jaką znam, przynajmniej próbują w jakimś zakresie upodobnić ją do
zwykłej platformy .NET (nawet jeśli stosowana jest kompilacja AOT i kod działa
w urządzeniu innym niż komputer PC). Specyfikacja języka C# stawia określone wyma-
gania środowisku docelowemu. M.in. dostępne muszą być ustalone typy. W czasie,
gdy powstaje ta książka, nie istnieje specyfikacja języka C# 7. Podejrzewam jednak, że
gdy się pojawi, będzie wymagała dostępności opisanych w tym podrozdziale typów,
aby móc używać krotek.
W odróżnieniu od typów anonimowych, gdzie każda unikatowa sekwencja nazw
właściwości w podzespole powoduje wygenerowanie przez kompilator nowego typu,
krotki nie wymagają generowania przez kompilator dodatkowych typów. Zamiast tego
używany jest nowy zestaw typów z platformy. Pora się z nimi zapoznać.

11.4.1. Wprowadzenie do typów System.ValueTuple<…>


Krotki w C# 7 są implementowane przy użyciu rodziny typów System.ValueTuple. Te
typy znajdują się w podzespole System.ValueTuple.dll, który jest częścią specyfikacji
.NET Standard 2.0, ale nie występuje w żadnych starszych wersjach platformy .NET.
Aby używać tych typów w starszych platformach, należy dodać zależność w postaci
pakietu NuGet System.ValueTuple.

87469504f326f0d7c1fcda56ef61bd79
8
11.4. Krotki w środowisku CLR 379

Istnieje dziewięć struktur ValueTuple o generycznej arności od 0 do 8:


 System.ValueTuple (niegeneryczny)
 System.ValueTuple<T1>
 System.ValueTuple<T1, T2>
 System.ValueTuple<T1, T2, T3>
 System.ValueTuple<T1, T2, T3, T4>
 System.ValueTuple<T1, T2, T3, T4, T5>
 System.ValueTuple<T1, T2, T3, T4, T5, T6>
 System.ValueTuple<T1, T2, T3, T4, T5, T6, T7>
 System.ValueTuple<T1, T2, T3, T4, T5, T6, T7, TRest>

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?

11.4.2. Obsługa nazw elementów


Kompilator języka C# ignoruje nazwy w kontekście odwzorowywania typów krotek
z C# na typy ValueTuple<…> ze środowiska CLR. Choć w języku C# (int, int) i (int x,
int y) to odmienne typy, oba są odwzorowywane na typ ValueTuple<int, int>. Następ-
nie kompilator odwzorowuje wszystkie przypadki użycia nazw elementów na odpo-
wiednie nazwy ItemN. Na rysunku 11.5 pokazane jest, jak kod C# z literałem krotki jest
przekształcany na kod C# używający tylko typów ze środowiska CLR.

Rysunek 11.5. Dokonywane


przez kompilator przekształcenia
typu krotki na typ ValueTuple

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?

11.4.3. Implementacje konwersji krotek


Typy z rodziny ValueTuple nie obsługują żadnych konwersji w środowisku CLR. Takie
konwersje byłyby niemożliwe. Konwersje dostępne w języku C# nie mogą być zapisane
w informacjach o typie. Zamiast tego kompilator języka C# tworzy nową wartość, gdy
jest to konieczne, i przeprowadza odpowiednie konwersje każdego elementu. Poniżej
pokazane są dwie przykładowe konwersje: jedna niejawna (z wykorzystaniem nie-
jawnej konwersji z typu int na long) i druga jawna (z użyciem jawnej konwersji z typu
int na byte):
(int, string) t1 = (300, "tekst");
(long, string) t2 = t1;
(byte, string) t3 = ((byte, string)) t1;

Kompilator generuje tu taki kod, jakbyś użył następujących instrukcji:


var t1 = new ValueTuple<int, string>(300, "tekst");
var t2 = new ValueTuple<long, string>(t1.Item1, t1.Item2);
var t3 = new ValueTuple<byte, string>((byte) t1.Item1, t1.Item2));

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.

11.4.4. Tekstowe reprezentacje krotek


Tekstowa reprezentacja krotki wygląda podobnie do literału krotki z kodu źródłowego
w języku C#. Wyświetlana jest sekwencja wartości rozdzielonych przecinkami i umiesz-
czonych w nawiasie. Niedostępne są mechanizmy do precyzyjnego kontrolowania takich
danych wyjściowych. Jeśli np. używasz krotki typu (DateTime, DateTime) do zapisywania
przedziału czasu, nie możesz przekazać łańcucha znaków formatowania, aby określić, że
elementy mają być formatowane jako daty. Metoda ToString() krotki wywołuje metodę
ToString() każdego elementu różnego od null (dla null używany jest pusty łańcuch
znaków).

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.

Console.WriteLine(tuple.ToString()); Zapisywanie wartości krotki w konsoli.

Oto dane wyjściowe tego fragmentu kodu:


(, tekst, 10)

Metoda ToString() jest tu wywoływana bezpośrednio, aby udowodnić, że nie są wyko-


nywane żadne dodatkowe działania. Takie same dane wyjściowe uzyskasz po wywo-
łaniu Console.WriteLine(tuple).
Tekstowa reprezentacja krotek jest oczywiście przydatna w celach diagnostycznych,
jednak rzadko nadaje się do bezpośredniego wyświetlania w aplikacjach komunikujących
się z użytkownikiem końcowym. Zapewne zechcesz udostępnić dodatkowy kontekst,
podać informacje na temat formatowania niektórych typów i zapewne bardziej przej-
rzyście obsługiwać wartości null.

11.4.5. Standardowe porównania


na potrzeby sprawdzania równości i sortowania
Każdy typ ValueTuple<…> zawiera implementacje interfejsów IEquatable<T> i ICompara-
ble<T>, gdzie T to dany typ. Na przykład typ ValueTuple<T1, T2> zawiera implementacje
interfejsów IEquatable<ValueTuple<T1, T2>> i IComparable<ValueTuple<T1, T2>>.
W każdym typie w naturalny sposób zaimplementowany jest też niegeneryczny
interfejs IComparable i przesłonięta jest metoda object.Equals(object). Wywołanie
Equals(object) zwraca false, jeśli argumentem jest obiekt innego typu, a wywołanie
CompareTo(object) zgłasza w takiej sytuacji wyjątek typu ArgumentException. W innych
sytuacjach każda z tych metod deleguje zadanie do swojego odpowiednika z interfejsu
IEquatable<T> lub IComparable<T>.
Testy równości są wykonywane element po elemencie z użyciem domyślnego mecha-
nizmu sprawdzania równości z typu każdego elementu. Podobnie skróty elementów
są obliczane za pomocą domyślnego mechanizmu sprawdzania równości, po czym nastę-
puje łączenie tych skrótów w sposób zależny od implementacji, aby uzyskać ogólny
skrót dla krotki. Porównania krotek na potrzeby sortowania także odbywają się element
po elemencie, przy czym początkowe elementy są uznawane za ważniejsze niż dalsze.
Dlatego np. krotka (1, 5) jest uznawana za mniejszą niż (3, 2).
Te porównania sprawiają, że z krotek łatwo jest korzystać w technologii LINQ.
Załóżmy, że masz kolekcję krotek typu (int, int) reprezentujących współrzędne (x, y).
Możesz użyć znanych operacji z technologii LINQ, aby znaleźć na liście różne
punkty i je uporządkować. Ilustruje to listing 11.9.

87469504f326f0d7c1fcda56ef61bd79
8
11.4. Krotki w środowisku CLR 383

Listing 11.9. Znajdowanie i porządkowanie różnych punktów

var points = new[]


{
(1, 2), (10, 3), (-1, 5), (2, 1),
(10, 3), (2, 1), (1, 1)
};

var distinctPoints = points.Distinct();


Console.WriteLine($"Liczba różnych punktów: {distinctPoints.Count()}");
Console.WriteLine("Uporządkowane punkty:");
foreach (var point in distinctPoints.OrderBy(p => p))
{
Console.WriteLine(point);
}

Wywołanie Distinct() powoduje, że w danych wyjściowych krotka (2, 1) występuje


tylko raz. Jednak ponieważ równość jest sprawdzana element po elemencie, krotka (2, 1)
jest różna od (1, 2).
Ponieważ pierwszy element krotki jest uważany w czasie sortowania za najważ-
niejszy, punkty są sortowane według współrzędnej x. Jeśli kilka punktów ma tę samą
wartość współrzędnej x, punkty te są sortowane według współrzędnej y. Dlatego dane
wyjściowe wyglądają tak:
Liczba różnych punktów: 5
Uporządkowane punkty:
(-1, 5)
(1, 1)
(1, 2)
(2, 1)
(10, 3)

Zwykłe porównania nie umożliwiają zdefiniowania, jak należy porównywać poszcze-


gólne elementy. Oczywiście, możesz stosunkowo łatwo utworzyć własne niestandardowe
implementacje interfejsów IEqualityComparer<T> i IComparer<T> dla określonych typów
krotek, jednak wtedy warto rozważyć, czy nie lepiej byłoby zaimplementować kom-
pletny niestandardowy typ, który chcesz reprezentować, i całkowicie zrezygnować
wtedy z krotek. Inna możliwość to zastosowanie porównań strukturalnych, które w nie-
których sytuacjach są prostsze.

11.4.6. Strukturalne porównania


na potrzeby sprawdzania równości i sortowania
Oprócz standardowych interfejsów IEquatable i IComparable każda struktura ValueTuple
bezpośrednio implementuje interfejsy IStructuralEquatable i IStructuralComparable.
Te interfejsy istnieją od wersji .NET 4.0 i są implementowane w tablicach oraz nie-
modyfikowalnych klasach z rodziny Tuple. Wprawdzie sam nigdy nie używałem tych
interfejsów, nie oznacza to jednak, że nie można ich stosować i to w przydatny sposób.
Odzwierciedlają one zwykłe interfejsy API do porównywania i sortowania elementów,
jednak każda metoda przyjmuje obiekt porównujący przeznaczony dla poszczególnych
elementów:

87469504f326f0d7c1fcda56ef61bd79
8
384 ROZDZIAŁ 11. Łączenie danych z użyciem krotek

public interface IStructuralEquatable


{
bool Equals(Object, IEqualityComparer);
int GetHashCode(IEqualityComparer);
}

public interface IStructuralComparable


{
int CompareTo(Object, IComparer);
}

Omawiane interfejsy mają umożliwiać porównywanie złożonych obiektów na potrzeby


sprawdzania równości i sortowania w wyniku porównywania par elementów za pomocą
danego obiektu porównującego. Zwykłe generyczne porównania zaimplementowane
w typach ValueTuple są statycznie bezpieczne ze względu na typ, ale stosunkowo mało
elastyczne, ponieważ zawsze używany jest domyślny sposób porównywania elementów.
Porównania strukturalne są mniej bezpieczne ze względu na typ, ale zapewniają dodat-
kową swobodę. Na listingu 11.10 pokazane jest to z użyciem łańcuchów znaków i obiektu
porównującego ignorującego wielkość liter.

Listing 11.10. Porównania strukturalne z użyciem obiektu porównującego


ignorującego wielkość liter

static void Main()


{
var Ab = ("A", "b");
var aB = ("a", "B");
Nietypowe nazwy zmiennych odzwierciedlające wartości.
var aa = ("a", "a");
var ba = ("b", "a");

Compare(Ab, aB);
Compare(aB, aa); Wykonywanie wybranych interesujących porównań.
Compare(aB, ba);
}

static void Compare<T>(T x, T y)


where T : IStructuralEquatable, IStructuralComparable
{
var comparison = x.CompareTo(
y, StringComparer.OrdinalIgnoreCase); Sortowanie i sprawdzanie równości
var equal = x.Equals( bez uwzględniania wielkości liter.
y, StringComparer.OrdinalIgnoreCase);

Console.WriteLine(
$"{x} i {y} - porównanie: {comparison}; równość: {equal}");
}

Dane wyjściowe z listingu 11.10 pokazują, że porównania rzeczywiście są przepro-


wadzane parami bez uwzględniania wielkości liter:
(A, b) i (a, B) - porównanie: 0; równość: True
(a, B) i (a, a) - porównanie: 1; równość: False
(a, B) i (b, a) - porównanie: -1; równość: False

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ć.

11.4.7. Krotki jednowartościowe i duże krotki


Krotek jednowartościowych (ValueTuple<T1>), nazywanych przez zespół projektujący
C# womple, nie można tworzyć jako samodzielnych obiektów za pomocą składni dla
krotek. Takie krotki muszą być częścią innej krotki. Wcześniej opisane zostało, że
istnieją generyczne struktury typu ValueTuple przyjmujące tylko do ośmiu parametrów.
Co ma zrobić kompilator C#, gdy natrafi na literał krotki obejmujący więcej niż osiem
elementów? Używa wtedy typu ValueTuple<…> o arności 8, gdzie pierwszych siedem
argumentów odpowiada pierwszym siedmiu typom z literału krotki, a ostatni element to
zagnieżdżony typ krotki zawierający pozostałe elementy. Jeśli literał krotki ma dokład-
nie osiem elementów typu int, użyte zostaną następujące typy:
ValueTuple<int, int, int, int, int, int, int, ValueTuple<int>>

Występuje tu krotka jednowartościowa. Jest ona wyróżniona pogrubieniem. Typ


ValueTuple<…> o arności 8 jest zaprojektowany specjalnie w tym celu. Ostatni argu-
ment określający typ (TRest) ma ograniczenie wymagające użycia typu bezpośredniego.
Ponadto, o czym wspomniałem na początku punktu 11.4.1, nie istnieje pole Item8. Zamiast
tego występuje pole Rest.
Ważne jest to, że ostatni element w typie ValueTuple<…> o arności 8 zawsze powi-
nien być krotką wieloelementową, a nie pojedynczą wartością (aby uniknąć wielo-
znaczności). Spójrz na poniższy typ krotki:

87469504f326f0d7c1fcda56ef61bd79
8
386 ROZDZIAŁ 11. Łączenie danych z użyciem krotek

ValueTuple<A, B, C, D, E, F, G, ValueTuple<H, I>>

Zgodnie ze składnią języka C# można go traktować jak typ (A, B, C, D, E, F, G, H, I)


o arności 9 lub typ (A, B, C, D, E, F, G, (H, I)) o arności 8, gdzie ostatni element jest
typu krotki.
Programiści nie muszą się martwić tymi zagadnieniami, ponieważ kompilator C#
umożliwia używanie nazw ItemX dla wszystkich elementów krotki niezależnie od ich
liczby i tego, czy używasz składni dla krotek, czy bezpośrednio stosujesz typ ValueTuple.
Przyjrzyj się np. tej dość długiej krotce:
var tuple = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16);
Console.WriteLine(tuple.Item16);

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.

11.4.8. Niegeneryczna struktura ValueTuple


Jeśli krotki jednowartościowe wydają się zbędne, to krotki niegeneryczne niezawie-
rające żadnych elementów (ang. nuple) można uznać za zupełnie bezsensowne. Możliwe,
że oczekujesz, iż niegeneryczny typ ValueTuple jest klasą statyczną, podobną do niege-
nerycznej klasy Nullable. Jednak ValueTuple to struktura traktowana w innym kodzie
w taki sam sposób jak pozostałe struktury krotek, jednak niezawierająca żadnych danych.
Zaimplementowane są w niej wszystkie interfejsy opisane wcześniej w tym podroz-
dziale, ale każda wartość tego typu jest równa wszystkim pozostałym wartościom tego
typu (zarówno ze względu na równość, jak i na potrzeby sortowania), co jest zrozumiałe,
ponieważ nic nie różni takich krotek od siebie.
Omawiany typ udostępnia metody statyczne, które byłyby przydatne do tworzenia
wartości typu ValueTuple<…>, gdyby nie istniały literały krotek. Te metody są pomocne
zwłaszcza w sytuacji, jeśli chcesz używać typów krotek z C# 6 lub z innego języka bez
wbudowanej obsługi krotek oraz gdy chcesz, by typy elementów zostały wywniosko-
wane (pamiętaj, że w wywołaniu konstruktora zawsze musisz podać wszystkie argu-
menty określające typ, co bywa irytujące). Na przykład aby w C# 6 utworzyć krotkę typu
(int, int), używając wnioskowania typów, możesz posłużyć się następującym zapisem:
var tuple = ValueTuple.Create(5, 10);

Zespół odpowiedzialny za C# sugeruje, że w przyszłości krotki niegeneryczne bez


elementów mogą być przydatne do dopasowywania wzorców i dekompozycji. Jednak
obecnie nie mają one praktycznych zastosowań.

11.4.9. Metody rozszerzające


Klasa statyczna System.TupleExtensions jest dostępna w tym samym podzespole co typy
z rodziny System.ValueTuple. Zawiera ona metody rozszerzające dla typów System.Tuple
i System.ValueTuple. Są trzy rodzaje takich metod:

87469504f326f0d7c1fcda56ef61bd79
8
11.5. Alternatywy dla krotek 387

 Deconstruct — rozszerzająca typy Tuple,


 ToValueTuple — rozszerzająca typy Tuple,
 ToTuple — rozszerzająca typy ValueTuple.

Każda z tych przeciążonych metod ma 21 wersji związanych z arnością, opartych na


tym samym wzorcu, jaki poznałeś na potrzeby obsługi arności równej 8 i większej.
Metody Deconstruct są opisane w rozdziale 12., a metody ToValueTuple i ToTuple
działają dokładnie tak, jak możesz tego oczekiwać — przekształcają obiekty między
niemodyfikowalnymi referencyjnymi typami krotek z czasów platformy .NET 4.0
a nowymi modyfikowalnymi bezpośrednimi typami krotek. Uważam, że te typy są przy-
datne przede wszystkim do pracy ze starszym kodem, gdzie używane są typy Tuple.
Uff! To już wszystko, co moim zdaniem warto wiedzieć na temat typów używanych
do implementowania krotek w środowisku CLR. Dalej opisane zostaną inne możliwości.
Jeśli zastanawiasz się nad użyciem krotek, powinieneś wiedzieć, że są one tylko jednym
z narzędzi w Twoim przyborniku — i nie zawsze okazują się najbardziej odpowiednie.

11.5. Alternatywy dla krotek


Może wydać się to banalne, jednak każde rozwiązanie, które stosowałeś w przeszłości
do zbiorów zmiennych, nadal jest akceptowalne. Nie musisz wszędzie używać krotek
z C# 7. W tym podrozdziale pokrótce opisuję wady i zalety innych technik.

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.

11.5.2. Typy anonimowe


Typy anonimowe wprowadzono w ramach technologii LINQ. Według mojego doświad-
czenia nadal stosuje się je głównie w tej technologii. Można używać ich dla zwykłych
zmiennych w metodzie, jednak nie przypominam sobie, abym kiedykolwiek zetknął się
z takim rozwiązaniem w kodzie produkcyjnym.

87469504f326f0d7c1fcda56ef61bd79
8
388 ROZDZIAŁ 11. Łączenie danych z użyciem krotek

Większość zalet typów anonimowych (np. nazwane elementy, naturalne sprawdzanie


równości i przejrzysta reprezentacja tekstowa) jest dostępna także w krotkach w C# 7.
Główny problem z typami anonimowymi związany jest z ich anonimowością — war-
tości takich typów nie mogą być zwracane przez metody lub właściwości bez utraty
bezpieczeństwa ze względu na typ. Trzeba byłoby wtedy użyć typu object lub dynamic.
Informacje o typie byłyby dostępne w czasie wykonywania programu, jednak kompi-
lator nie miałby do nich dostępu. Krotki z C# 7 nie mają tej wady. Jak już zobaczyłeś,
dozwolone jest zwracanie krotek przez metody.
Dostrzegam jednak cztery zalety typów anonimowych w porównaniu z krotkami:
 W C# 7 inicjalizatory kolekcji pozwalają podać zarówno nazwę, jak i wartość za
pomocą jednego identyfikatora, są więc prostsze niż krotki. Porównaj np. zapisy
new { p.Name, p.Age } i (name: p.Name, age: p.Age). Problem ten został rozwią-
zany w C# 7.1, ponieważ nazwy elementów krotek mogą zostać wywniosko-
wane, co pozwala uzyskać zwięzłą reprezentację, np. (p.Name, p.Age).
 Używanie nazw w tekstowej reprezentacji typów anonimowych może być wygodne
na potrzeby diagnostyki.
 Typy anonimowe są obsługiwane przez zewnętrznych dostawców w technologii
LINQ (komunikujących się z bazami danych itd.). Literałów krotek nie można
obecnie używać w drzewach wyrażeń, co zmniejsza ich atrakcyjność.
 Typy anonimowe w niektórych sytuacjach mogą się okazać bardziej wydajne,
ponieważ w potoku operacji przekazywana jest tylko jedna referencja. Moim
zdaniem w większości scenariuszy nie jest to jednak istotne, a to, że krotki nie
powodują tworzenia obiektów, które musiałyby zostać usunięte przez mecha-
nizm przywracania pamięci, oznacza oczywiście plus po stronie krotek.

Sądzę, że w technologii LINQ to Objects krotki będą powszechnie używane — przede


wszystkim w połączeniu z wersją C# 7.1 i wnioskowaniem nazw elementów krotek.

11.5.3. Typy nazwane


Krotki to tylko zbiory zmiennych. Nie zapewniają hermetyzacji ani nie określają zna-
czenia zmiennych (sam decydujesz, do czego ich używasz). Czasem potrzebujesz wła-
śnie takiego rozwiązania, jednak uważaj, aby nie posunąć się za daleko. Rozważ typ
(double, double). Można go używać dla:

 współrzędnych kartezjańskich w układzie dwuwymiarowym (x, y),


 współrzędnych biegunowych w układzie dwuwymiarowym (promień, kąt),
 pary początek/koniec na linii jednowymiarowej,
 rozmaitych innych danych.

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.

UWAGA. Fantastycznie byłoby móc użyć analizatora kodu Roslyn do zautomatyzowania


takich zadań i wykrywać różne zastosowania krotek na podstawie nazw elementów. Niestety,
nie znam żadnego narzędzia, które to potrafi.

Po tym wprowadzeniu do różnych możliwości zakończmy rozdział szczegółowymi


zaleceniami na temat tego, gdzie krotki mogą być przydatne.

11.6. Zastosowania i rekomendacje


Przede wszystkim należy pamiętać, że obsługa krotek w języku jest nowością w C# 7.
Wszelkie sugestie z tego podrozdziału są wynikiem przemyśleń na temat krotek, a nie
długiego ich użytkowania. Analizy są wartościowe, jednak nie dają wglądu w praktykę.
W przeszłości zdarzało mi się mylnie zakładać, do czego wykorzystam nowe funkcje
języka w przyszłości, dlatego zachęcam do tego, by z dystansem traktować wszystko,
co tu opisuję. Mimo to mam nadzieję, że ten materiał przynajmniej zachęci Cię do
przemyśleń.

11.6.1. Niepubliczne interfejsy API i kod,


który można łatwo modyfikować
Dopóki cała społeczność nie nabierze większego doświadczenia w używaniu krotek
i dopóki nie powstaną dobre praktyki sprawdzone w boju, unikałbym stosowania krotek
w publicznych interfejsach API, w tym jako składowych chronionych w typach, które
mogą być klasami bazowymi w innych podzespołach. Jeśli masz to szczęście, że możesz
kontrolować (i dowolnie modyfikować) cały kod, który komunikuje się z Twoim kodem,
możesz pozwolić sobie na więcej eksperymentów. Unikaj jednak zwracania krotek
w metodach publicznych tylko dlatego, że jest to łatwe rozwiązanie; w przyszłości możesz
odkryć, że warto zapewnić większą hermetyzację zwracanych wartości. Typy nazwane
wymagają więcej pracy w zakresie projektowania i implementowania, jednak uzyskany
efekt zapewne nie będzie trudniejszy w użyciu dla jednostki wywołującej. Krotki są
wygodne głównie dla piszących je osób, a nie dla autorów wywołującego je kodu.
Obecnie preferuję jeszcze bardziej radykalne rozwiązanie i używam krotek tylko
jako szczegółu implementacji w typach. Bez obaw zwracam krotki w metodach prywat-
nych, ale nie robię tego w metodach wewnętrznych w kodzie produkcyjnym. Zwykle
im bardziej ograniczony jest zakres działania danego kodu, tym łatwiej jest zmienić
decyzję i wymaga to mniej zastanawiania się.

11.6.2. Zmienne lokalne


Krotki zaprojektowano głównie po to, aby umożliwić zwracanie przez metodę wartości
wielu typów bez używania parametrów out lub tworzenia specjalnego typu dla zwra-
canej wartości. Nie oznacza to jednak, że są to jedyne zastosowania krotek.

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ą.

UWAGA. Inna możliwość to użycie metody OrderByDescending(…).FirstOrDefault(), jed-


nak wymaga to sortowania danych, podczas gdy potrzebna jest tylko jedna wartość. Pakiet
MoreLinq udostępnia metodę MaxBy, która zapełnia opisaną lukę. Jeszcze inna możliwość
(oprócz przechowywania dwóch zmiennych) polega na utworzeniu jednej zmiennej highestGame
i używaniu w porównaniach jej właściwości Score. W bardziej złożonych scenariuszach takie
rozwiązanie może okazać się nieakceptowalne.

Listing 11.11. Wyświetlanie gracza, który do danej daty uzyskał najwyższy wynik

public void DisplayHighScoreForDate(LocalDate date)


{
var filteredGames = allGames.Where(game => game.Date == date);
string highestPlayer = null;
int highestScore = -1;
foreach (var game in filteredGames)
{
if (game.Score > highestScore)
{
highestPlayer = game.PlayerName;
highestScore = game.Score;
}
}
Console.WriteLine(highestPlayer == null
? "Nie rozegrano żadnej gry"
: $"Najwyższy wynik to {highestScore} osięgnięty przez {highestPlayer}");
}

Używane są tu cztery zmienne lokalne (włącznie z parametrem):


 date
 filteredGames
 highestPlayer
 highestScore

Przynajmniej dwie z tych zmiennych są ze sobą ściśle powiązane. Są razem inicjowane


i modyfikowane. To sugeruje, że mógłbyś rozważyć zastosowanie zmiennej w postaci
krotki, tak jak na listingu 11.12.

Listing 11.12. Refaktoryzacja w celu użycia zmiennej lokalnej w postaci krotki

public void DisplayHighScoreForDate(LocalDate date)


{
var filteredGames = allGames.Where(game => game.Date == date);
(string player, int score) highest = (null, -1);
foreach (var game in filteredGames)
{

87469504f326f0d7c1fcda56ef61bd79
8
11.6. Zastosowania i rekomendacje 391

if (game.Score > highest.score)


{
highest = (game.PlayerName, game.Score);
}
}
Console.WriteLine(highest.player == null
? "Nie rozegrano żadnej gry"
: $"Najwyższy wynik to {highest.score} osiągnięty przez {highest.player}");
}

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;

W dalszym kodzie można wtedy używać pól tailZone.start, tailZone.intervalMap itd.


Ponieważ zmienna tailZone jest zadeklarowana z modyfikatorem readonly, wartości
do jej elementów można przypisywać wyłącznie w konstruktorze. Występuje tu kilka
ograniczeń i zastrzeżeń:
 Wartości pojedynczych elementów krotki można przypisywać w konstruktorze,
jednak gdy zainicjalizujesz tylko niektóre elementy (a nie wszystkie), nie jest
zgłaszane ostrzeżenie. Na przykład jeśli w pierwotnym kodzie zapomnisz zaini-
cjalizować pole tailZoneStart, pojawi się ostrzeżenie. Nie zobaczysz jednak ana-
logicznego komunikatu, jeśli zapomnisz zainicjalizować pole tailZone.start.
 Albo wszystkie elementy pola z krotką są tylko do odczytu, albo żaden z tych
elementów nie ma tej cechy. Jeśli istnieje grupa powiązanych pól i jedne z nich

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.

Jednym z mniej oczywistych aspektów krotek jest ich współdziałanie z typowaniem


dynamicznym.

11.6.4. Krotki i typowanie dynamiczne


nie współdziałają dobrze ze sobą
Rzadko używam typu dynamic i podejrzewam, że przydatne zastosowania typowania
dynamicznego i właściwe zastosowania krotek pokrywają się w niewielkim stopniu.
Warto jednak poznać dwa problemy związane z dostępem do elementów.
DYNAMICZNY BINDER NIE ZNA NAZW ELEMENTÓW
Warto pamiętać, że nazwy elementy są uwzględniane głównie w czasie kompilacji. Jeśli
połączysz to z wiedzą, że wiązanie dynamiczne ma miejsce tylko w czasie wykonywania
programu, podejrzewam, że domyślisz się skutków. W ramach prostego przykładu roz-
waż następujący kod:
dynamic tuple = (x: 10, y: 20);
Console.WriteLine(tuple.x);

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'

Choć jest to niefortunne, zachowywanie informacji o nazwach elementów dla dynamicz-


nego bindera wymagałoby dużo zachodu. Nie spodziewam się zmian w tym obszarze.
Jeśli zmodyfikujesz ten fragment tak, aby wyświetlał element tuple.Item1, kod zadziała
poprawnie — a przynajmniej dla pierwszych siedmiu elementów.
DYNAMICZNY BINDER (OBECNIE)
NIE ZNA WYSOKICH NUMERÓW ELEMENTÓW
W punkcie 11.5.4 zobaczyłeś, w jaki sposób kompilator traktuje krotki mające więcej
niż siedem elementów. Kompilator używa typu ValueTuple<…> o arności 8, gdzie ostatni
element zawiera krotkę dostępną za pomocą pola Rest zamiast pola Item8. Oprócz
modyfikowania samego typu kompilator zmienia sposób dostępu do numerowanych
elementów. Na przykład jeśli w kodzie źródłowym używany jest element tuple.Item9,
w wygenerowanym kodzie pośrednim jest to element tuple.Rest.Item2.
W czasie, gdy powstaje ta książka, dynamiczny binder nie zna tych możliwości.
Dlatego wystąpi wyjątek, choć ten sam kod wiązany w czasie kompilacji działałby
poprawnie. W ramach przykładu możesz łatwo przetestować tę technikę i poekspe-
rymentować z nią:

87469504f326f0d7c1fcda56ef61bd79
8
Podsumowanie 393

var tuple = (1, 2, 3, 4, 5, 6, 7, 8, 9);


Console.WriteLine(tuple.Item9); Działa (używany jest element tuple.Rest.Item2).
dynamic d = tuple;
Console.WriteLine(d.Item9); Błąd w czasie wykonywania programu.

Ten problem (w odróżnieniu od poprzedniego) można rozwiązać, usprawniając dyna-


miczny binder. Jednak działanie programu będzie wtedy zależeć od używanej wersji
dynamicznego bindera. Zwykle wyraźnie określone jest, z której wersji kompilatora
korzystasz oraz jakich wersji podzespołu i platformy używasz. Wymóg stosowania
określonej wersji dynamicznego bindera nieco komplikuje sytuację.

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

kontekstach. Dostępnych jest kilka rodzajów wzorców, a w przyszłych wersjach języka


zapewne pojawią się nowe. Wykorzystamy tu wiedzę z rozdziału 11. i podzielimy
utworzone tam krotki.

12.1. Podział krotek


C# 7 udostępnia dwa sposoby podziału wartości: jeden dla krotek i drugi dla pozosta-
łych obiektów. W obu wersjach używane są ta sama składnia i te same ogólne mecha-
nizmy, jednak ich abstrakcyjne omówienie byłoby mało zrozumiałe. Najpierw opisany
jest podział krotek i wszystkie zagadnienia specyficzne dla tej techniki. W podroz-
dziale 12.2 zobaczysz, jak te same pomysły są stosowane do innych typów. Abyś miał
wyobrażenie o tym, co Cię czeka, na listingu 12.1 pokazanych jest kilka mechanizmów
podziału krotek. Wszystkie te mechanizmy zostaną szczegółowo omówione.

Listing 12.1. Przegląd podziału obiektów na przykładzie krotek

var tuple = (10, "tekst"); Tworzenie krotki typu (int, string).

var (a, b) = tuple; Niejawny podział na nowe zmienne a i b.

(int c, string d) = tuple; Jawny podział na nowe zmienne c i d.

int e;
string f; Podział na istniejące zmienne.
(e, f) = tuple;

Console.WriteLine($"a: {a}; b: {b}");


Console.WriteLine($"c: {c}; d: {d}"); Dowód, że podział krotek działa.
Console.WriteLine($"e: {e}; f: {f}");

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

Jedyne, co tu zrobiłeś, to zadeklarowałeś i zainicjowałeś sześć zmiennych (a, b, c, d, e


i f) w nowy sposób, który wymaga mniej kodu niż starsze techniki. Nie chcę przez to
bagatelizować przydatności tego mechanizmu, jednak tym razem nie trzeba omawiać
wielu skomplikowanych zagadnień. We wszystkich sytuacjach operacja jest prosta —
wystarczy skopiować wartość z krotki do zmiennej. Taka zmienna nie jest wiązana
z krotką. Późniejsza modyfikacja zmiennej nie zmienia wartości krotki i na odwrót.
Zacznijmy od szczegółowego omówienia dwóch pierwszych części przykładu, gdzie
zmienne są deklarowane i inicjalizowane w jednej instrukcji.

87469504f326f0d7c1fcda56ef61bd79
8
12.1. Podział krotek 397

Składnia deklarowania i podziału krotek


W specyfikacji języka podział krotek jest ściśle powiązany z innymi dotyczącymi ich
mechanizmami. Składnia podziału ma postać wyrażenia reprezentującego krotkę nawet
wtedy, gdy operacja nie dotyczy krotki (przekonasz się o tym w podrozdziale 12.2).
Prawdopodobnie nie musisz się tym przejmować, powinieneś jednak znać potencjalne
źródła nieporozumień. Rozważ te dwie instrukcje:
(int c, string d) = tuple;
(int c, string d) x = tuple;
W pierwszej w wyniku podziału deklarowane są dwie zmienne (c i d). Druga to deklaracja
jednej zmiennej (x) typu krotki (int c, string d). Moim zdaniem to podobieństwo nie jest
błędem projektowym, jednak trzeba się przyzwyczaić do tej składni — podobnie jak do
podobieństw między składowymi z ciałem w postaci wyrażenia i wyrażeniami lambda.

12.1.1. Podział na nowe zmienne


Zawsze możliwe było zadeklarowanie wielu zmiennych w jednej instrukcji, jednak
musiały one być tego samego typu. Aby poprawić czytelność, zwykle trzymam się dekla-
rowania jednej zmiennej w jednej instrukcji. Jednak gdy możesz zadeklarować i zaini-
cjalizować wiele zmiennych w jednej instrukcji, a wszystkie początkowe wartości pocho-
dzą z tego samego źródła, jest to zgrabne rozwiązanie. Przede wszystkim jeśli tym źródłem
jest wynik wywołania funkcji, możesz uniknąć deklarowania dodatkowej zmiennej tylko
po to, aby uniknąć wielu wywołań tej funkcji.
Składnia, którą zapewne najłatwiej jest zrozumieć, polega na jawnym podaniu typu
każdej zmiennej. Jest to ta sama składnia, której używa się dla list parametrów lub
typów krotek. Aby wyjaśnić wcześniejszą uwagę dotycząca dodatkowej zmiennej, na
listingu 12.2 pokazane jest, jak podzielić na trzy zmienne krotkę będącą wynikiem
wywołania metody.

Listing 12.2. Wywoływanie metody i podział wyniku na trzy zmienne

static (int x, int y, string text) MethodReturningTuple() => (1, 2, "t");

static void Main()


{
(int a, int b, string name) = MethodReturningTuple();
Console.WriteLine($"a: {a}; b: {b}; name: {name}");
}

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;

Console.WriteLine($"a: {a}; b: {b}; name: {name}");


}

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.

Rysunek 12.1. Łączenie


typowania niejawnego
i jawnego przy podziale
krotki

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.

Rysunek 12.2. Podział


krotki z użyciem
konwersji niejawnych

Jeśli odpowiada Ci niejawne określanie typu wszystkich zmiennych, w C# 7 dostępny


jest skrót, który pozwala łatwo uzyskać pożądany efekt. Wystarczy dodać słowo var przed
listą nazw:
var (a, b, name) = MethodReturningTuple();

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.

SPECJALNY IDENTYFIKATOR — ODRZUCANIE ELEMENTÓW


ZA POMOCĄ PODKREŚLENIA (_)
W C# 7 dostępne są trzy mechanizmy, które umożliwiają tworzenie zmiennych lokal-
nych w nowych miejscach:

87469504f326f0d7c1fcda56ef61bd79
8
12.1. Podział krotek 399

 podział (podrozdziały 12.1 i 12.2),


 wzorce (podrozdziały od 12.3 do 12.7),
 zmienne out (podrozdział 14.2).

We wszystkich tych technikach nazwa zmiennej w postaci _ (jedno podkreślenie) ma


specjalne znaczenie — pomijanie. Oznacza to: „Wynik mnie nie interesuje; w ogóle nie
chcę tej wartości jako zmiennej — pozbądź się jej”. Gdy używana jest ta technika, do
zasięgu nie jest dodawana nowa zmienna. Możesz kilkakrotnie użyć symbolu pomijania,
zamiast podawać różne nazwy dla wielu zmiennych, które nie są Ci potrzebne.
Oto przykład pomijania przy podziale krotki:
var tuple = (1, 2, 3, 4); Krotka z czterema elementami.
var (x, y, _, _) = tuple; Podział krotki z zachowaniem tylko dwóch pierwszych elementów.
Console.WriteLine(_); Błąd CS0103: nazwa '_' nie istnieje w bieżącym kontekście.

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.

12.1.2. Używanie podziału do przypisywania wartości


istniejącym zmiennym i właściwościom
W poprzednim punkcie opisana została większość przykładu z początkowego przeglądu.
W tym punkcie analizowany będzie następujący fragment:
var tuple = (10, "tekst");
int e;
string f;
(e, f) = tuple;

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().

Listing 12.3. Przypisywanie wartości do istniejących zmiennych w wyniku podziału

static (int x, int y, string text) MethodReturningTuple() => (1, 2, "t");

static void Main()


{
int a = 20;
int b = 30; Deklarowanie, inicjalizowanie
string name = "przed"; i używanie trzech zmiennych.
Console.WriteLine($"a: {a}; b: {b}; name: {name}");

(a, b, name) = MethodReturningTuple(); Przypisywanie wartości do wszystkich trzech


zmiennych w wyniku podziału krotki.

87469504f326f0d7c1fcda56ef61bd79
8
400 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców

Console.WriteLine($"a: {a}; b: {b}; name: {name}"); Wyświetlanie nowych wartości.


}

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.

Albo deklaracje, albo operacje przypisania — nie można ich łączyć


Podział umożliwia albo deklarowanie i inicjalizowanie zmiennych, albo wykonywanie
sekwencji operacji przypisania. Nie można łączyć tych operacji. Na przykład poniższy kod
jest błędny:
int x;
(x, int y) = (1, 2);
Dozwolone jest jednak przypisywanie wartości do różnych elementów: istniejących zmien-
nych lokalnych, pól, właściwości itd.

Oprócz wykonywania zwykłych operacji przypisania możesz też pomijać wybrane


elementy krotki (za pomocą identyfikatora _). Powoduje to zignorowanie wartości, jeśli
w zasięgu nie występuje element o nazwie _. Jeżeli jednak w zasięgu znajduje się zmienna
o nazwie _, podział powoduje przypisanie do niej wartości.

Używanie _ przy podziale — przypisanie czy pomijanie?


Początkowo może się to wydawać mylące — jeśli istnieje zmienna _, użycie podkreślenia
przy podziale powoduje zmianę wartości tej zmiennej, a w innych sytuacjach określona
wartość krotki jest odrzucana. Wątpliwości można rozwiać na dwa sposoby. Pierwszy
polega na przyjrzeniu się reszcie operacji podziału i sprawdzeniu, czy tworzone są nowe
zmienne (wtedy _ powoduje pominięcie wartości), czy wartości są przypisywane do istnie-
jących zmiennych (wtedy do zmiennej _ przypisywana jest nowa wartość — podobnie jak
do innych zmiennych).
Druga technika polega na unikaniu używania znaku _ jako nazwy zmiennej lokalnej.

Podejrzewam, że w praktyce przypisywanie za pomocą podziału prawie zawsze będzie


dotyczyć albo zmiennych lokalnych, albo pól i właściwości obiektu this. Dostępna
jest nawet wygodna technika, którą możesz wykorzystać w konstruktorach, aby dodat-
kowo zwiększyć przydatność wprowadzonych w C# 7 konstruktorów z ciałem w postaci
wyrażenia. Wiele konstruktorów przypisuje wartości do właściwości lub pól na pod-
stawie parametrów. Wszystkie te przypisania można wykonać w jednym wyrażeniu, jeśli
parametry zostaną najpierw zapisane w literale krotki. Ilustruje to listing 12.4.

Listing 12.4. Proste przypisania w konstruktorze z użyciem podziału i literału krotki

public sealed class Point


{
public double X { get; }

87469504f326f0d7c1fcda56ef61bd79
8
12.1. Podział krotek 401

public double Y { get; }

public Point(double x, double y) => (X, Y) = (x, y);


}

Bardzo podoba mi się zwięzłość tego rozwiązania. Uwielbiam przejrzystość odwzo-


rowania parametrów konstruktora na właściwości. Kompilator C# wykrywa tę technikę
jako wzorzec i nie tworzy wartości typu ValueTuple<double, double>. Niestety, kompilacja
tego kodu wymaga podzespołu System.ValueTuple.dll, co wystarcza, aby zniechęcić mnie
do stosowania pokazanego podejścia — chyba że używam krotek także w innych
miejscach projektu lub piszę kod przeznaczony na platformę, która już obejmuje typ
System.ValueTuple.

Czy jest to idiomatyczny kod w C#?


Objaśniłem już, że ta sztuczka ma wady i zalety. Jest to wyłącznie szczegół implementacji
konstruktora, który nie wpływa nawet na resztę ciała klasy. Jeśli zdecydujesz się zasto-
sować tę technikę, a potem uznasz, że jednak Ci ona nie odpowiada, rezygnacja z niej
będzie bardzo prosta. Jest jeszcze za wcześnie, aby pisać, czy ten styl się przyjmie. Mam
jednak nadzieję, że tak będzie. Uważałbym jednak na nią, gdyby literał krotki musiał
zawierać coś więcej niż dokładne wartości parametrów. Nawet dodanie jednego warunku
wstępnego przeważa moim zdaniem szalę na rzecz zwykłej sekwencji operacji przypisania.

Przypisywanie w wyniku podziału ma dodatkową wadę w porównaniu z deklarowa-


niem zmiennych za pomocą podziału. Chodzi tu o kolejność operacji. Podział z opera-
cjami przypisania obejmuje trzy etapy:
1. Przetworzenie docelowych elementów operacji przypisania.
2. Przetworzenie prawej strony operatora przypisania.
3. Przypisanie wartości.

Te trzy etapy są wykonywane dokładnie w tej kolejności. Na każdym etapie przetwa-


rzanie odbywa się w standardowy sposób, od lewej do prawej. Kolejność przetwarzania
rzadko jest tu istotna, ale może się tak zdarzyć.

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

Listing 12.5. Podział, w którym kolejność przetwarzania ma znaczenie

StringBuilder builder = new StringBuilder("12345"); Zachowywanie referencji do pierwotnego


StringBuilder original = builder; obiektu na potrzeby analiz.

(builder, builder.Length) =
Przypisywanie w wyniku podziału.
(new StringBuilder("67890"), 3);

Console.WriteLine(original); Wyświetlanie zawartości dawnego


Console.WriteLine(builder); i nowego obiektu typu StringBuilder.

Komplikacje związane są ze środkowym wierszem. Najważniejsze pytanie dotyczy tego,


w którym obiekcie typu StringBuilder ustawiana jest właściwość Length — w obiekcie
wskazywanym początkowo przez zmienną builder, czy w nowym obiekcie tworzonym
w pierwszym punkcie podziału? Wcześniej wyjaśniłem, że najpierw przetwarzane są
wszystkie elementy docelowe. Dopiero potem przypisywane są wartości. Na listingu 12.6
pokazano to w rozwiniętej wersji tego samego kodu. Teraz podział jest wykonywany
ręcznie.

Listing 12.6. Podział w zwolnionym tempie w celu zilustrowania kolejności


przetwarzania kodu

StringBuilder builder = new StringBuilder("12345");


StringBuilder original = builder;

StringBuilder targetForLength = builder; Przetwarzanie docelowych elementów.

(StringBuilder, int) tuple =


Przetwarzanie literału krotki.
(new StringBuilder("67890"), 3);

builder = tuple.Item1; Przypisywanie wartości


targetForLength.Length = tuple.Item2; do docelowych elementów.

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

Po omówieniu tego zagadnienia pora przejść do ostatniej — i to dość przyjemnej —


ciekawostki związanej z tworzeniem krotek, jaką trzeba opisać przed przejściem do
podziału jednostek niebędących krotkami.

87469504f326f0d7c1fcda56ef61bd79
8
12.2. Podział typów innych niż krotki 403

12.1.3. Szczegóły podziału literałów krotek


W punkcie 11.1.3 opisałem, że nie wszystkie literały krotek mają typ. Na przykład
literał (null, x => x * 2) nie ma typu, ponieważ żadne z wyrażeń reprezentujących
jego elementy nie ma typu. Wiesz jednak, że ten literał można przekształcić na typ
(string, Func<int, int>), ponieważ każde z pierwotnych wyrażeń obsługuje konwer-
sję na odpowiedni typ.
Dobra wiadomość jest taka, że przy podziale krotek uwzględniana jest dokładnie
ta sama zgodność na poziomie przypisywania wartości poszczególnych elementów.
Obowiązuje ona zarówno przy podziale z deklaracją zmiennych, jak i przy podziale
z przypisywaniem wartości. Oto krótki przykład:
(string text, Func<int, int> func) = Podział z deklaracją
(null, x => x * 2); zmiennych text i func.
(text, func) = ("tekst", x => x * 3); Podział z przypisaniem wartości
do zmiennych text i func.

Ta technika działa także, gdy podział wymaga niejawnej konwersji z wyrażenia na


docelowy typ. Jeśli zastosować nasz ulubiony przykład ze „stałą typu int z zakresu
typu byte”, poniższy kod będzie poprawny:
(byte x, byte y) = (5, 10);

Podobnie jak w przypadku wielu przydatnych mechanizmów języka, mógłbyś się


spodziewać, że kod będzie działał właśnie tak. Trzeba jednak starannie opracować
projekt i specyfikację języka, aby to zapewnić. Po szczegółowym zapoznaniu się z po-
działem krotek podział typów innych niż krotki powinien okazać się stosunkowo prosty.

12.2. Podział typów innych niż krotki


W podziale typów innych niż krotki używane jest podejście oparte na wzorcu1 (tak jak
w mechanizmie async/await i w pętlach foreach). Podobnie jak można oczekiwać na
obiekt dowolnego typu z odpowiednią metodą (lub metodą rozszerzającą) GetAwaiter,
tak można podzielić obiekt dowolnego typu z odpowiednią metodą (lub metodą roz-
szerzającą) Deconstruct, używając takiej samej składni jak dla krotek. Zacznijmy od
podziału z użyciem zwykłych metod instancji.

12.2.1. Metody instancji odpowiedzialne za podział obiektów


Podział obiektów najłatwiej jest zademonstrować przy użyciu klasy Point, która poja-
wiła się już w kilku przykładach. Możesz dodać do niej metodę Deconstruct w nastę-
pujący sposób:
public void Deconstruct(out double x, out double y)
{
x = X;
y = Y;
}

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.

Listing 12.7. Podział obiektu typu Point na dwie zmienne

var point = new Point(1.5, 20); Tworzenie obiektu typu Point.


var (x, y) = point; Podział tego obiektu na dwie zmienne typu double.
Console.WriteLine($"x = {x}");
Wyświetlanie wartości obu zmiennych.
Console.WriteLine($"y = {y}");

Zadanie metody Deconstruct polega na zapełnieniu parametrów out wynikami podziału.


Tu kod dzieli obiekt na dwie wartości typu double. Metoda ta, zgodnie z nazwą, działa
jak odwrotność konstruktora.
Ale, ale — krotki umożliwiały zastosowanie wygodnej sztuczki i przypisanie w kon-
struktorze wartości parametrów do właściwości w jednej instrukcji. Czy tu też jest to
możliwe? Tak, jest to możliwe. Ja sam uwielbiam tę technikę. Oto konstruktor i metoda
Deconstruct, co pozwala dostrzec podobieństwa między nimi:
public Point(double x, double y) => (X, Y) = (x, y);
public void Deconstruct(out double x, out double y) => (x, y) = (X, Y);

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ł.

12.2.2. Odpowiedzialne za podział metody rozszerzające


a przeciążanie metod
We wprowadzeniu pokrótce opisałem, że kompilator wyszukuje metody Deconstruct
zgodne z odpowiednim wzorcem. Uwzględniane są także metody rozszerzające. Praw-
dopodobnie wyobrażasz sobie, jak wygląda metoda rozszerzająca używana do podziału
obiektów. Na listingu 12.8 pokazany jest konkretny przykład dla typu DateTime.

87469504f326f0d7c1fcda56ef61bd79
8
12.2. Podział typów innych niż krotki 405

Listing 12.8. Używanie metody rozszerzającej do podziału obiektów typu DateTime

static void Deconstruct(


this DateTime dateTime,
out int year, out int month, out int day) => Metoda rozszerzająca do podziału
obiektów typu DateTime.
(year, month, day) =
(dateTime.Year, dateTime.Month, dateTime.Day);

static void Main()


{
DateTime now = DateTime.UtcNow;
var (year, month, day) = now; Podział aktualnej daty na rok, miesiąc i dzień.
Console.WriteLine(
$"{year:0000}-{month:00}-{day:00}"); Wyświetlanie daty z użyciem trzech zmiennych.
}

Tu używana jest prywatna metoda rozszerzająca zadeklarowana w tej samej (statycznej)


klasie, w której jej używasz. Częściej jednak stosowane są metody publiczne lub
wewnętrzne (stanowią one większość metod rozszerzających).
Co zrobić, jeśli przy podziale obiektu typu DateTime chcesz uwzględnić więcej niż
datę? Przydatne jest tu przeciążanie. Możesz utworzyć dwie metody z różnymi listami
parametrów, a kompilator na podstawie liczby parametrów ustali, którą wersję zasto-
sować. Dodamy teraz nową metodę rozszerzającą do podziału obiektów typu DateTime
na czas i datę, a następnie użyjemy obu metod do podziału różnych wartości (listing 12.9).

Listing 12.9. Używanie przeciążonych wersji metody Deconstruct

static void Deconstruct(


this DateTime dateTime,
out int year, out int month, out int day) => Podział daty na rok, miesiąc i dzień.
(year, month, day) =
(dateTime.Year, dateTime.Month, dateTime.Day);

static void Deconstruct(


this DateTime dateTime,
out int year, out int month, out int day,
Podział daty na rok, miesiąc, dzień,
out int hour, out int minute, out int second) => godzinę, minutę i sekundę.
(year, month, day, hour, minute, second) =
(dateTime.Year, dateTime.Month, dateTime.Day,
dateTime.Hour, dateTime.Minute, dateTime.Second);

static void Main()


{
DateTime birthday = new DateTime(1976, 6, 19);
DateTime now = DateTime.UtcNow;
Użycie metody Deconstruct
var (year, month, day, hour, minute, second) = now; dla sześciu wartości.
(year, month, day) = birthday; Użycie metody Deconstruct dla trzech wartości.
}

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

Ograniczenia dotyczące metod rozszerzających Deconstruct w naturalny sposób


wypływają z ograniczeń metod instancji:
 Metoda musi być dostępna w wywołującym ją kodzie.
 Oprócz pierwszego parametru (określa on obiekt typu docelowego metody roz-
szerzającej) wszystkie parametry muszą mieć modyfikator out.
 Trzeba podać co najmniej dwa parametry out.
 Metoda może być generyczna, jednak tylko obiekt typu docelowego (pierwszy
parametr) może być uwzględniany we wnioskowaniu typu.

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.

12.2.3. Obsługa wywołań Deconstruct w kompilatorze


Gdy wszystko działa zgodnie z oczekiwaniami, nie musisz się zastanawiać, jak kompi-
lator wybiera używaną wersję metody Deconstruct. Jeśli jednak natrafisz na problemy,
przydatne może być wcielenie się w rolę kompilatora.
Czas wykonywania operacji opisany w kontekście podziału krotek dotyczy także
podziału obiektów z użyciem metod. Zacznę więc od samego wywołania metody.
Przyjrzymy się stosunkowo konkretnemu przykładowi i ustalimy, co kompilator robi
w momencie podziału obiektu:
(int x, string y) = target;

Stwierdziłem, że jest to stosunkowo konkretny przykład, ponieważ nie pokazałem,


jakiego typu jest target. To celowe, ponieważ wystarczy wiedzieć, że nie jest to typ
krotki. Kompilator rozwija taki kod do następującej postaci:
target.Deconstruct(out var tmpX, out var tmpY);
int x = tmpX;
string y = tmpY;

Następnie stosowane są standardowe reguły wywoływania metod, aby wybrać odpo-


wiednią wersję metody. Zdaję sobie sprawę, że nie zetknąłeś się wcześniej z parame-
trami out var. Są one opisane w podrozdziale 14.2. Na razie wystarczy wiedzieć, że
deklarowana jest tu zmienna z niejawnym typowaniem, a do wnioskowania typu uży-
wany jest typ parametru out.
Warto zauważyć, że typy zmiennych zadeklarowanych w pierwotnym kodzie nie
są używane w wywołaniu metody Deconstruct. To oznacza, że nie można ich używać
do wnioskowania typów. To wyjaśnia trzy rzeczy:
 Metody instancji Deconstruct nie mogą być generyczne, ponieważ niedostępne
są informacje potrzebne do wnioskowania typu.
 Metody rozszerzające Deconstruct mogą być generyczne, ponieważ kompilator
potrafi wywnioskować argumenty określające typ na podstawie obiektu target, jest
to jednak jedyny parametr, jaki jest przydatny w kategoriach wnioskowania typu.

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.

12.3. Wprowadzenie do dopasowywania wzorców


Dopasowywanie wzorców, podobnie jak wiele innych mechanizmów, jest nowością
w C#, ale istniało już w innych językach programowania. Przede wszystkim w językach
funkcyjnych często powszechnie korzysta się ze wzorców. Wzorce w C# 7.0 są przy-
datne w wielu tych samych zastosowaniach co w innych językach, ale w sposób zgodny
z resztą składni C#.
Podstawowy mechanizm działania wzorca to sprawdzanie określonego aspektu war-
tości i używanie wyniku testu do wykonywania innej operacji. To prawda, ten opis
przypomina instrukcję if, jednak wzorce są używane zwykle albo w celu określenia
dodatkowego kontekstu dla warunku, albo w celu zapewnienia dodatkowego kontekstu
dla operacji na podstawie tego wzorca. Mechanizm ten nie umożliwia robienia czego-
kolwiek nowego, a jedynie pozwala programistom bardziej przejrzyście zapisywać
swoje zamiary.
Nie chcę omawiać szczegółów bez przedstawiania przykładów. Nie martw się, jeśli
na razie opis wydaje się nieco dziwny. Chcę dać Ci przedsmak tej techniki. Załóżmy,
że istnieje klasa abstrakcyjna Shape, w której zdefiniowana jest abstrakcyjna właściwość
Area, oraz klasy pochodne Rectangle, Circle i Triangle. Niestety, w bieżącej aplikacji nie
potrzebujesz powierzchni figur. Potrzebny jest obwód. Możliwe, że nie możesz zmody-
fikować klasy Shape i dodać do niej właściwości Perimeter (niewykluczone, że w ogóle
nie masz kontroli nad kodem źródłowym tej klasy), jednak wiesz, jak obliczyć obwód
wszystkich klas, które Cię interesują. Do wersji C# 7 metoda Perimeter mogła wyglą-
dać tak jak na listingu 12.10.

Listing 12.10. Obliczanie obwodu bez używania wzorców

static double Perimeter(Shape shape)


{
if (shape == null)
throw new ArgumentNullException(nameof(shape));
Rectangle rect = shape as Rectangle;
if (rect != null)

87469504f326f0d7c1fcda56ef61bd79
8
408 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców

return 2 * (rect.Height + rect.Width);


Circle circle = shape as Circle;
if (circle != null)
return 2 * PI * circle.Radius;
Triangle triangle = shape as Triangle;
if (triangle != null)
return triangle.SideA + triangle.SideB + triangle.SideC;
throw new ArgumentException(
$"Nieznany obwód dla typu {shape.GetType()}", nameof(shape));
}

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.

Listing 12.11. Obliczanie obwodu z użyciem wzorców

static double Perimeter(Shape shape)


{
switch (shape)
{
case null:
Obsługa wartości null.
throw new ArgumentNullException(nameof(shape));
case Rectangle rect:
return 2 * (rect.Height + rect.Width);
case Circle circle: Obsługa każdego znanego typu.
return 2 * PI * circle.Radius;
case Triangle tri:
return tri.SideA + tri.SideB + tri.SideC;
default:
throw new ArgumentException(...); Jeśli nie wiadomo, co zrobić, należy zgłosić wyjątek.
}
}

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.

12.4. Wzorce dostępne w C# 7.0


W C# 7.0 wprowadzono trzy rodzaje wzorców: wzorce stałych, wzorce typów i wzo-
rzec var. Każdy z nich zostanie przedstawiony za pomocą operatora is, który jest jed-
nym z kontekstów stosowania wzorców.
Każdy wzorzec jest dopasowywany do danych wejściowych. Tymi danymi może być
dowolne wyrażenie niebędące wskaźnikiem. Dla uproszczenia w opisach wzorców to
wyrażenie ma postać zmiennej input, jednak może przyjmować też inne formy.

12.4.1. Wzorce stałych


Wzorzec stałych jest wzorcem mającym postać wyrażenia stałego na etapie kompilacji,
które jest później sprawdzane pod kątem równości z input. Jeśli i input, i stała to wyra-
żenia całkowitoliczbowe, są porównywane z użyciem operatora ==. W przeciwnym
razie wywoływana jest statyczna metoda object.Equals. Ważne jest, że wywoływana jest
metoda statyczna, ponieważ pozwala to na bezpieczne sprawdzanie wartości null.
Przykład z listingu 12.12 jest jeszcze mniej praktyczny niż inne fragmenty prezentowane
w książce, ilustruje jednak kilka interesujących kwestii.

Listing 12.12. Proste dopasowywanie stałych

static void Match(object input)


{
if (input is "witaj")
Console.WriteLine("Input to łańcuch znaków witaj");
else if (input is 5L)
Console.WriteLine("Input to liczba 5 typu long");
else if (input is 10)
Console.WriteLine("Input to liczba 10 typu int");
else
Console.WriteLine("Input nie pasuje do witaj, 5 typu long lub 10 typu int.");
}

static void Main()


{
Match("witaj");
Match(5L);
Match(7);
Match(10);
Match(10L);
}

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

Skoro liczby całkowite są porównywane z użyciem operatora ==, dlaczego ostatnie


wywołanie Match(10L) nie kończy się dopasowaniem wartości? Wynika to z tego, że
w czasie kompilacji input nie ma tu typu całkowitoliczbowego, tylko typ object. Dlatego
kompilator generuje kod analogiczny jak dla wywołania object.Equals(x, 10). Ta instruk-
cja zwraca false, gdy wartość x jest opakowana w obiekt typu Int64 zamiast Int32, tak
jak w ostatnim wywołaniu Match. Aby użyty został operator ==, potrzebny byłby kod
w następującej postaci:
long x = 10L;
if (x is 10)
{
Console.WriteLine("x to 10");
}

Ta technika nie jest przydatna w wyrażeniach is tego rodzaju. Bardziej prawdopodobne


byłoby zastosowanie jej w instrukcji switch, gdzie można sprawdzać stałe całkowito-
liczbowe (jak w instrukcjach switch przed wprowadzeniem dopasowywania wzorców),
a także inne wzorce. Wzorce typów są przydatne w bardziej oczywisty sposób.

12.4.2. Wzorce typów


Wzorzec typu składa się z typu i identyfikatora (przypomina nieco deklarację zmiennej).
Wzorzec zostaje dopasowany, jeśli input to wartość podanego typu, tak jak w zwykłym
operatorze is. Zaletą stosowania wzorca jest tworzenie mającej określony typ nowej
zmiennej ze wzorca, inicjalizowanej sprawdzaną wartością, jeśli ta wartość pasuje do
wzorca. Gdy wartość nie pasuje do wzorca, zmienna też istnieje, ale bez przypisanej
określonej wartości. Gdy wartość input to null, nie zostaje dopasowana do żadnego
typu. W punkcie 12.1.1 zostało opisane, że można posłużyć się identyfikatorem _. Ozna-
cza to pominięcie zmiennej (nie jest ona wtedy tworzona). Listing 12.13 to zmodyfiko-
wana wersja wcześniejszego zestawu instrukcji as i if (listing 12.10). W nowej wersji
stosowane jest dopasowywanie wzorców bez bardziej skrajnego rozwiązania, jakim jest
użycie instrukcji switch.

Listing 12.13. Używanie wzorców typów zamiast instrukcji as i if

static double Perimeter(Shape shape)


{
if (shape == null)
throw new ArgumentNullException(nameof(shape));
if (shape is Rectangle rect)
return 2 * (rect.Height + rect.Width);
if (shape is Circle circle)
return 2 * PI * circle.Radius;

87469504f326f0d7c1fcda56ef61bd79
8
12.4. Wzorce dostępne w C# 7.0 411

if (shape is Triangle triangle)


return triangle.SideA + triangle.SideB + triangle.SideC;
throw new ArgumentException(
$" Nieznany obwód dla typu {shape.GetType()}", nameof(shape));
}

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).

Listing 12.14. Działanie typów bezpośrednich nieprzyjmujących null we wzorcach


typów

static void Main()


{
CheckType<int?>(null);
CheckType<int?>(5);
CheckType<int?>("tekst");
CheckType<string>(null);
CheckType<string>(5);
CheckType<string>("tekst");
}

static void CheckType<T>(object value)


{
if (value is T t)
{
Console.WriteLine($"Tak! {t} jest typu {typeof(T)}");
}
else
{
Console.WriteLine($"Nie! {value ?? "null"} nie jest typu {typeof(T)}");
}
}

Dane wyjściowe wyglądają tak:


Nie! null nie jest typu System.Nullable`1[System.Int32]
Tak! 5 jest typu System.Nullable`1[System.Int32]
Nie! text nie jest typu System.Nullable`1[System.Int32]
Nie! null nie jest typu System.String
Nie! 5 nie jest typu System.String
Tak! text jest typu System.String

87469504f326f0d7c1fcda56ef61bd79
8
412 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców

Aby podsumować punkt dotyczący wzorców typów, warto wspomnieć o problemie


z C# 7.0, który został rozwiązany w C# 7.1. Jest to jeden ze scenariuszy, w których jeśli
projekt używa wersji C# 7.1 lub nowszej, mogłeś nawet nie zauważyć kłopotów. Opi-
suję to zagadnienie głównie po to, abyś nie był zaskoczony, jeśli skopiujesz kod pro-
jektu z wersji C# 7.1 do projektu w wersji C# 7.0 i stwierdzisz, że przestał działać.
W C# 7.0 wzorce typów o następującej postaci:
x is SomeType y

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.

Listing 12.15. Metoda generyczna używająca dopasowywania wzorców

static void DisplayShapes<T>(List<T> shapes) where T : Shape


{
foreach (T shape in shapes) Typ zmiennej to parametr określający typ (T).
{
switch (shape) Instrukcja switch oparta na tej zmiennej.
{
case Circle c: Próba użycia klauzul case opartych
Console.WriteLine($"Okrąg o promieniu {c.Radius}"); na typie do przekształcenia
break; zmiennej na konkretny typ figury.
case Rectangle r:
Console.WriteLine($"Prostokąt o wymiarach {r.Width} x {r.Height}");
break;
case Triangle t:
Console.WriteLine(
$"Trójkąt o bokach {t.SideA}, {t.SideB}, {t.SideC}");
break;
}
}
}

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;
}

Użycie operatora is jest poprawne, jednak rzutowanie jest tu niedozwolone. Niemoż-


ność bezpośredniego rzutowania parametrów określających typ od długiego czasu była
bolączką w C#. Standardowe rozwiązanie polegało na uprzednim zrzutowaniu na typ
object:
if (shape is Circle)
{
Circle c = (Circle) (object) 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.

12.4.3. Wzorzec var


Wzorzec var wygląda jak wzorzec typu, przy czym jako typ używane jest słowo var
(podane jest tylko to słowo i identyfikator):
someExpression is var x

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.

Listing 12.16. Używanie wzorca var do dodawania zmiennej po wystąpieniu błędu

static double Perimeter(Shape shape)


{
switch (shape ?? CreateRandomShape())
{
case Rectangle rect:
return 2 * (rect.Height + rect.Width);
case Circle circle:
return 2 * PI * circle.Radius;
case Triangle triangle:
return triangle.SideA + triangle.SideB + triangle.SideC;

87469504f326f0d7c1fcda56ef61bd79
8
414 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców

case var actualShape:


throw new InvalidOperationException(
$"Nieznany obwód figury typu {actualShape.GetType()}");
}
}

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.

12.5. Używanie wzorców razem z operatorem is


Operatora is można używać w dowolnym miejscu jako części zwykłego wyrażenia.
Prawie zawsze stosuje się go w instrukcjach if, jednak nie musi tak być. Do wersji C# 7
po prawej stronie operatora is trzeba było podawać typ. Obecnie można użyć dowol-
nego wzorca. Choć dozwolone jest używanie wzorców stałych i wzorców var, w prak-
tyce prawie zawsze używane są wzorce typów.
Zarówno wzorce var, jak i wzorce typów powodują dodanie nowej zmiennej. Do
wersji C# 7.3 obowiązywało dodatkowe ograniczenie — nie można było używać
wzorców w inicjalizatorach pól, właściwości i konstruktorów oraz w wyrażeniach repre-
zentujących zapytania. Na przykład ten kod był nieprawidłowy:
static int length = GetObject() is string text ? text.Length : -1;

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ść.

W ostatnim fragmencie tego rozdziału omówione są wzorce używane w instrukcjach


switch.

12.6. Używanie wzorców w instrukcjach switch


Specyfikacje często są pisane nie w kategoriach algorytmów, ale przypadków. Poniżej
wymienione są przykłady z dziedzin odległych od informatyki:
 Podatki i ulgi — to, do jakiego progu podatkowego zostaniesz przypisany, zależy
zapewne od Twoich dochodów i innych czynników.
 Bilety przejazdowe — możliwe, że obowiązują ulgi dla grup, a także inne ceny
dla dzieci, dorosłych i seniorów.
 Zamawianie jedzenia na wynos — w zależności od spełnienia określonych
kryteriów możesz otrzymać zniżkę.

W przeszłości istniały dwa sposoby określania, z którym przypadkiem powiązane są


określone dane wejściowe: instrukcje switch i instrukcje if. W instrukcjach switch można
było używać tylko prostych stałych. Nadal dostępne są tylko te dwie techniki, jednak (co
już zobaczyłeś) dzięki wzorcom instrukcje if stały się bardziej przejrzyste, a instrukcje
switch zyskały nowe możliwości.

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.

Instrukcje switch ze wzorcami są w dużym stopniu odpowiednikiem sekwencji instrukcji


if/else, jednak zachęcają do myślenia w kategoriach „ten rodzaj danych wejściowych
prowadzi do takich danych wyjściowych” zamiast w kategoriach kroków.
W podrozdziale 12.3 zobaczyłeś już przykład zastosowania wzorców w instruk-
cjach switch. Używałeś tam wzorców stałych do dopasowywania wartości null i wzorców
typów do dopasowywania figur różnego rodzaju. Oprócz prostego umieszczania wzorca
w klauzuli case należy przedstawić także nową składnię.

87469504f326f0d7c1fcda56ef61bd79
8
12.6. Używanie wzorców w instrukcjach switch 417

Wszystkie instrukcje switch można uznać za oparte na wzorcach


W tym podrozdziale instrukcje switch oparte na stałych i instrukcje switch oparte na wzor-
cach omawiam w taki sposób, jakby różniły się od siebie. Ponieważ wzorce stałych są wzor-
cami, każdą poprawną instrukcję switch można uznać za opartą na wzorcach i każda będzie
działać dokładnie tak samo. Różnice, jakie poznasz później, są związane z kolejnością wyko-
nywania operacji i wprowadzaniem nowych zmiennych oraz nie dotyczą wzorców stałych.
Uważam, że pomocne będzie — przynajmniej na razie — traktowanie obu wersji instrukcji
switch jak dwóch różnych konstruktów, które przypadkowo mają tę samą składnię. Moż-
liwe, że jednak dla Ciebie wygodniej będzie nie wprowadzać tego rozróżnienia. Można
bezpiecznie posługiwać się każdym z tych modeli. Oba pozwalają poprawnie przewidzieć
działanie kodu.

12.6.1. Klauzule zabezpieczające


Każda klauzula case może mieć klauzulę zabezpieczającą, która obejmuje wyrażenie:
case wzorzec when wyrażenie:

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

W rozdziale 11. zobaczyłeś, jak wygenerować ciąg Fibonacciego, używając krotek.


Jest to przejrzyste podejście, jeśli potraktować ten ciąg jak sekwencję liczb. Jeżeli
jednak uwzględnić samą funkcję generującą ten ciąg, pokazana definicja prowadzi do
listingu 12.17 — prostej instrukcji switch z użyciem wzorców i klauzuli zabezpieczającej.

Listing 12.17. Rekurencyjna implementacja ciągu Fibonacciego z użyciem wzorców

static int Fib(int n)


{
switch (n)
{ Przypadek rekurencyjny
Przypadek bazowy obsługiwany
case 0: return 0; z użyciem wzorców stałych. obsługiwany z użyciem
case 1: return 1; wzorca var i klauzuli
case var _ when n > 1: return Fib(n - 2) + Fib(n - 1); zabezpieczającej.
default: throw new ArgumentOutOfRangeException( Jeśli dane wejściowe nie pasują
nameof(n), "Input musi być liczbą nieujemną"); do żadnego wzorca, są nieprawidłowe.
}
}

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

Jest to wysoce niewydajna implementacja, której nigdy nie użyłbym w rzeczywistym


życiu. Jednak pokazuje, w jaki sposób bezpośrednio przekształcić specyfikację na kod.
W tym przykładzie klauzula zabezpieczająca nie wymaga użycia zmiennej ze
wzorca, dlatego zmienna jest pomijana za pomocą identyfikatora _. Gdy wzorzec powo-
duje dodanie nowej zmiennej, w wielu sytuacjach jest ona używana w klauzuli zabez-
pieczającej, a przynajmniej w ciele klauzuli case.
Gdy używasz klauzuli zabezpieczającej, ten sam wzorzec może być używany wie-
lokrotnie, ponieważ przy pierwszym dopasowaniu wzorca klauzula zabezpieczająca
może mieć wartość false. Oto przykład z narzędzia używanego do generowania doku-
mentacji w bibliotece Noda Time:
private string GetUid(TypeReference type, bool useTypeArgumentNames)
{
switch (type)
{
case ByReferenceType brt:
return $"{GetUid(brt.ElementType, useTypeArgumentNames)}@";
case GenericParameter gp when useTypeArgumentNames:
return gp.Name;
case GenericParameter gp when gp.DeclaringType != null:
return $"`{gp.Position}";
case GenericParameter gp when gp.DeclaringMethod != null:
return $"``{gp.Position}";
case GenericParameter gp:
throw new InvalidOperationException(
"Nieobsługiwany parametr generyczny");
case GenericInstanceType git:
return "(Ta część rzeczywistego kodu jest długa i nieistotna)";
default:
return type.FullName.Replace('/', '.');
}
}

Używane są tu cztery wzorce do sprawdzania parametru generycznego na podstawie


wartości parametru useTypeArgumentNames i tego, czy generyczny parametr określający
typ został podany w metodzie, czy w typie. Klauzula case zgłaszająca wyjątek działa tu
dla parametrów generycznych prawie jak klauzula default i informuje, że wystąpiła
sytuacja, której jeszcze nie uwzględniłem. To, że używam nazwy zmiennej ze wzorca
(gp) w wielu klauzulach case, prowadzi do kolejnego naturalnego pytania: jaki jest zasięg
zmiennych ze wzorca dodanych w klauzulach case?

12.6.2. Zasięg zmiennej ze wzorca w klauzulach case


Jeśli zadeklarujesz zmienną lokalną bezpośrednio w ciele klauzuli case, zasięgiem tej
zmiennej będzie cała instrukcja switch, w tym ciało innych klauzul case. Nadal tak jest
(jest to, moim zdaniem, niefortunne rozwiązanie), jednak nie dotyczy to zmiennych
deklarowanych w etykietach case. Zasięg takich zmiennych to tylko ciało powiązane
z daną etykietą case. Dotyczy to zmiennych ze wzorca deklarowanych we wzorcu, zmien-
nych ze wzorca deklarowanych w klauzuli zabezpieczającej i zmiennych out (zobacz
podrozdział 14.2) deklarowanych w klauzuli zabezpieczającej.

87469504f326f0d7c1fcda56ef61bd79
8
12.6. Używanie wzorców w instrukcjach switch 419

Prawie na pewno jest to zgodne z oczekiwaniami programistów i przydatne, ponie-


waż można używać tych samych zmiennych ze wzorca w wielu klauzulach case obsłu-
gujących podobne sytuacje, co ilustruje kod narzędzia z biblioteki Noda Time. Jest
jednak pewne zastrzeżenie — podobnie jak w zwykłych instrukcjach switch, tak
i w instrukcjach ze wzorcem można utworzyć kilka etykiet case powiązanych z tym
samym ciałem. Wtedy zmienne deklarowane we wszystkich etykietach case powiązanych
z danym ciałem muszą mieć różne nazwy (ponieważ należą do tej samej przestrzeni
deklaracji). Jednak w ciele klauzuli case żadna z tych zmiennych nie ma przypisanej
określonej wartości, ponieważ kompilator nie potrafi ustalić, która etykieta case została
dopasowana. Tworzenie tych zmiennych może być przydatne, ale przede wszystkim
w celu używania ich w klauzulach zabezpieczających.
Załóżmy, że dopasowujesz dane wejściowe typu object i chcesz mieć pewność, że
jeśli są to dane liczbowe, znajdują się w określonym przedziale, przy czym ten prze-
dział zależy od typu. Możesz wtedy zastosować po jednym wzorcu typu dla każdego typu
liczbowego i odpowiednie klauzule zabezpieczające. Na listingu 12.18 pokazano, jak
zrobić to dla typów int i long. Możesz jednak rozwinąć tę technikę na potrzeby innych
typów.

Listing 12.18. Używanie wielu etykiet case ze wzorcami dla jednego ciała

static void CheckBounds(object input)


{
switch (input)
{
case int x when x > 1000:
case long y when y > 10000L:
Console.WriteLine("Wartość jest za wysoka");
break;
case int x when x < -1000:
case long y when y < -10000L:
Console.WriteLine("Wartość jest za niska");
break;
default:
Console.WriteLine("Wartość znajduje się w przedziale");
break;
}
}

Zmienne ze wzorca mają przypisaną określoną wartość w klauzulach zabezpieczają-


cych, ponieważ program dociera do tych klauzul, tylko jeśli wzorzec został dopaso-
wany. Zmienne te pozostają w zasięgu w ciele klauzul case, jednak nie mają tam
przypisanej określonej wartości. Mógłbyś przypisać do zmiennych nowe wartości i uży-
wać ich, jednak podejrzewam, że takie rozwiązanie rzadko będzie przydatne.
Oprócz tego, że dopasowywanie wzorców jest nową techniką odmienną od starszego
podejścia, instrukcje switch oparte na stałych i instrukcje switch oparte na wzorcu
różnią się jedną ważną kwestią: kolejność etykiet case jest ważna w sposób, który wcze-
śniej był nieistotny.

87469504f326f0d7c1fcda56ef61bd79
8
420 ROZDZIAŁ 12. Podział krotek i dopasowywanie wzorców

12.6.3. Kolejność przetwarzania w instrukcjach switch


opartych na wzorcu
W prawie wszystkich scenariuszach etykiety case w instrukcjach switch opartych na
stałych można swobodnie przestawiać bez zmian w działaniu programu3. Wynika to
z tego, że każda etykieta case pasuje do jednej stałej, a wszystkie stałe w takich instruk-
cjach switch muszą być różne. Dlatego dane wejściowe mogą pasować najwyżej do
jednej etykiety case. Gdy używasz wzorców, nie jest to prawdą.
Logiczną kolejność przetwarzania w instrukcji switch opartej na wzorcu można
podsumować w prosty sposób:
 Każda etykieta case jest przetwarzana zgodnie z kolejnością w kodzie źródłowym.
 Ciało etykiety default jest wykonywane dopiero po przetworzeniu wszystkich
etykiet case. Nie ma znaczenia, w którym miejscu instrukcji switch znajduje się
ta etykieta.

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.

Czasem kolejność etykiet nie ma znaczenia. Na przykład w metodzie obliczającej ciąg


Fibonacciego jedyne przypadki to wartości 0, 1 i większe od 1, dlatego można je
swobodnie przestawiać. Jednak w kodzie narzędzia z biblioteki Noda Time występują
cztery przypadki, które trzeba sprawdzać w odpowiedniej kolejności:
case GenericParameter gp when useTypeArgumentNames:
return gp.Name;
case GenericParameter gp when gp.DeclaringType != null:
return $"`{gp.Position}";
case GenericParameter gp when gp.DeclaringMethod != null:
return $"``{gp.Position}";
case GenericParameter gp:
throw new InvalidOperationException(...);

Tu nazwa generycznego parametru określającego typ jest potrzebna, zawsze gdy


useTypeArgumentNames ma wartość true (jest to pierwszy przypadek), niezależnie od pozo-
stałych klauzul case. Druga i trzecia klauzula case wzajemnie się wykluczają (przy czym
Ty o tym wiesz, jednak kompilator nie potrafi tego stwierdzić), dlatego ich kolejność jest
bez znaczenia. Ostatnia klauzula case musi znajdować się na końcu, ponieważ wyjątek
ma być zgłaszany tylko w sytuacji, jeśli dane wejściowe są typu GenericParameter, ale nie
zostały obsłużone.
Kompilator jest tu pomocny. Ostatnia klauzula case nie obejmuje klauzuli zabez-
pieczającej, dlatego zawsze będzie prawidłowa, jeśli wzorzec typu zostanie dopasowany.

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.

12.7. Przemyślenia na temat zastosowań


opisanych mechanizmów
Ten podrozdział zawiera wstępne przemyślenia na temat tego, jak najlepiej korzystać
z mechanizmów omawianych w tym rozdziale. Obie opisane techniki prawdopodobnie
będą wciąż rozwijane, a może nawet zostaną połączone ze wzorcem podziału obiektów.
Wpływ na miejsce stosowania tych technik mogą mieć też inne potencjalnie powiązane
rozwiązania, np. składnia metod z ciałem w postaci wyrażenia, których wynik jest
zależny od instrukcji switch opartej na wzorcach. Niektóre potencjalne mechanizmy
języka C# 8 są opisane w rozdziale 15.
Dopasowywanie wzorców to zagadnienie z obszaru implementacji. Oznacza to, że
nie musisz się przejmować, jeśli kiedyś stwierdzisz, że za często stosowałeś tę technikę.
Możesz wrócić do starszego stylu pisania kodu, jeśli uznasz, że wzorce nie zapewniają
Ci oczekiwanej poprawy czytelności. To samo dotyczy w pewnym zakresie podziału
obiektów. Jeśli jednak w wielu miejscach interfejsu API dodasz publiczne metody
Deconstruct, późniejsze usunięcie ich naruszy zgodność z istniejącym kodem.
Moim zdaniem większość typów nie nadaje się w naturalny sposób do podziału
(podobnie jak większość typów nie ma naturalnej implementacji interfejsu IComparable
<T>). Metodę Deconstruct warto dodawać tylko wtedy, jeśli kolejność komponentów jest
oczywista i jednoznaczna. Jest tak w przypadku współrzędnych, danych hierarchicznych

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.

12.7.1. Wykrywanie możliwości podziału obiektów


Najprostszy rodzaj podziału dotyczy krotek. Jeśli wywołujesz metodę, która zwraca
krotkę, a nie chcesz przechowywać wartości z krotki w grupie, rozważ jej podział. Na
przykład używając metody MinMax z rozdziału 11., prawie zawsze natychmiast dzieliłbym
dane, zamiast przechowywać zwróconą wartość jako krotkę:
int[] values = { 2, 7, 3, -5, 1, 0, 10 };
var (min, max) = MinMax(values);
Console.WriteLine(min);
Console.WriteLine(max);

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ć.

12.7.2. Wykrywanie możliwości dopasowywania wzorców


Stosowanie dopasowywania wzorców powinieneś rozważyć w dwóch oczywistych
miejscach:
 Wszędzie tam, gdzie używane są operatory is lub or oraz kod wykonywany
warunkowo na podstawie wartości o specyficznym typie.
 Wszędzie tam, gdzie występuje sekwencja if/else-if/else-if/else, a we wszyst-
kich warunkach sprawdzana jest ta sama wartość, dlatego można posłużyć się
instrukcją switch.

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

wewnętrznych szczegółów — jest trudniejsza do dostrzeżenia. Czasem potrzebne może


być odejście od dziedziczenia. Czasem logikę obliczeń można bardziej przejrzyście
zapisać w jednym miejscu, gdzie uwzględniane są wszystkie odmienne przypadki, niż
w typach reprezentujących poszczególne z tych przypadków. Takim przykładem jest
obliczanie obwodu figur z podrozdziału 12.3, a podobne podejście można zastosować
w wielu scenariuszach biznesowych. W tym kontekście w C# bardziej popularne
zapewne staną się typy unii.
Wspomniałem już, że są to tylko wstępne przemyślenia. Jak zawsze zachęcam do
eksperymentów i starannych analiz. Rozważ, jakie masz możliwości w kodzie, a jeśli
spróbujesz czegoś nowego, zastanów się potem nad wadami i zaletami zastosowanego
rozwiązania.

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

Wydawało mi się, że będą one przydatne tylko w specyficznych sytuacjach związanych


z dużymi typami bezpośrednimi, które zdarzają się rzadko. Oczekiwałem, że mecha-
nizmy te będą użyteczne wyłącznie w usługach działających w czasie zbliżonym do
rzeczywistego i w grach.
W C# 7.2 pojawił się kolejny zestaw mechanizmów związanych z referencjami.
Były to parametry in, referencyjne zmienne lokalne i referencyjne zwracane wartości
tylko do odczytu, struktury tylko do odczytu i struktury referencyjne. Stanowiły one
uzupełnienie rozwiązań z C# 7.0, jednak nadal miałem wrażenie, że komplikują one
język, a przyniosą korzyści tylko małej grupie użytkowników.
Obecnie jestem przekonany, że choć wielu programistów może nie dostrzegać
bezpośrednio większej ilości kodu z użyciem referencji w swoich projektach, to nowe
techniki dają korzyści, ponieważ w platformie udostępniane są wydajniejsze mecha-
nizmy. W czasie, gdy powstaje ta książka, jest jeszcze za wcześnie, aby wypowiadać
się na temat tego, na ile rewolucyjne okażą się wprowadzone zmiany. Uważam jednak,
że zapewne będą one istotne.
Wyższa wydajność często osiągana jest kosztem spadku czytelności. Nadal wierzę,
że dotyczy to także wielu mechanizmów omawianych w tym rozdziale. Spodziewam się,
że będą one używane rzadko — w sytuacjach, w których wydajność jest na tyle ważna,
aby uzasadniać ponoszone koszty. Jednak zupełnie inną sprawą są zmiany, jakie dzięki
nowym rozwiązaniom można było wprowadzić w platformie. Dzięki tym zmianom
stosunkowo łatwe powinno być ograniczenie liczby alokacji obiektów oraz zmniejszenie
ilości pracy mechanizmów przywracania pamięci i nieużytków bez spadku czytel-
ności kodu.
Poruszam te kwestie, ponieważ możesz mieć podobne odczucia. W trakcie lektury
tego rozdziału w pełni zrozumiałe będzie podjęcie decyzji, że wolisz unikać większości
omawianych tu mechanizmów. Zachęcam jednak do lektury całego rozdziału. W ostatnim
podrozdziale, dotyczącym struktur referencyjnych, przedstawiony jest typ Span<T> (czyli
obszar). O obszarach można napisać dużo więcej, niż jest na to miejsca w tej książce.
Spodziewam się, że obszary i powiązane z nimi typy staną się w przyszłości ważną
częścią przybornika programistów.
Jeśli któraś z technik jest dostępna dopiero w jednej z podwersji C# 7, zwracam
na to uwagę. Podobnie jak w przypadku innych mechanizmów z poszczególnych pod-
wersji oznacza to, że jeśli używasz kompilatora C# 7, będziesz mógł korzystać z takich
rozwiązań tylko wtedy, gdy w ustawieniach projektu wybrana jest właściwa wersja języka.
Zachęcam do tego, aby w przypadku technik związanych z referencjami przyjąć podej-
ście zerojedynkowe i albo stosować wszystkie te mechanizmy (wraz z umożliwiającymi
to ustawieniami), albo nie używać żadnego z nich. Korzystanie tylko z funkcji z C# 7.0
daje mniej korzyści. Po tym wprowadzeniu zacznijmy od omówienia zastosowań słowa
kluczowego ref we wcześniejszych wersjach C#.

87469504f326f0d7c1fcda56ef61bd79
8
13.1. Przypomnienie — co wiesz o słowie kluczowym ref? 427

13.1. Przypomnienie — co wiesz o słowie kluczowym ref?


Musisz dobrze zrozumieć, jak parametry ref działają w C# 6 i starszych wersjach, aby
móc opanować techniki związane z referencjami wprowadzone w C# 7. To z kolei
wymaga dobrego zrozumienia różnicy między zmienną a jej wartością.
Poszczególni programiści w różny sposób myślą o zmiennych. Mój model umysłowy
zawsze oparty jest na kartce papieru i pokazany jest na rysunku 13.1. Na tej kartce
znajdują się trzy informacje:
 nazwa zmiennej,
 typ z czasu kompilacji,
 aktualna wartość.
Przypisywanie nowej wartości do zmiennej polega
na usunięciu bieżącej wartości i zapisaniu nowej. Gdy
zmienna jest typu referencyjnego, wartość zapisana na
kartce nigdy nie jest obiektem; zawsze zapisywana jest
referencja do obiektu. Taka referencja pozwala dotrzeć
do obiektu w taki sam sposób, jak adres pomaga dojść
do budynku. Dwie kartki papieru z tym samym adre-
sem dotyczą tego samego budynku; podobnie dwie
Rysunek 13.1. Przedstawianie zmienne z tą samą wartością referencji prowadzą do
zmiennej jako kartki papieru tego samego obiektu.

WSKAZÓWKA. Słowo kluczowe ref i referencje do obiektów to różne zagadnienia. Oczy-


wiście istnieją podobieństwa między nimi, jednak trzeba rozróżniać obie te kwestie. Na
przykład przekazywanie referencji do obiektu przez wartość nie jest tym samym co przeka-
zywanie zmiennej przez referencję. W tym podrozdziale podkreślam tę różnicę, stosując zwrot
referencja do obiektu zamiast tylko referencja.

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.

Rysunek 13.2. Przypisanie powoduje


skopiowanie wartości do nowej zmiennej

87469504f326f0d7c1fcda56ef61bd79
8
428 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych

Kopiowanie wartości w ten sposób dotyczy parametru przekazywanego przez wartość


w momencie wywołania metody. To wartość argumentu metody jest kopiowana na
nową kartkę papieru (do parametru). Ilustruje to rysunek 13.3. Argumentem nie musi
być zmienna. Można użyć dowolnego wyrażenia odpowiedniego typu.

Rysunek 13.3. Wywołanie metody z parametrami przekazywanymi przez wartość.


Parametry są nowymi zmiennymi, które początkowo mają wartości argumentów

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.

Rysunek 13.4. Parametry ref używają tej samej kartki papieru,


zamiast tworzyć nową z kopią wartości

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

static void Main()


{
int x = 5;
IncrementAndDouble(ref x, ref x);
Console.WriteLine(x);
}

static void IncrementAndDouble(ref int p1, ref int p2)


{
p1++;
p2 *= 2;
}

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.

13.2. Zmienne lokalne ref i referencyjne zwracane wartości


Wiele mechanizmów z C# 7 związanych z referencjami jest powiązanych ze sobą.
Dlatego trudniej jest zrozumieć korzyści oferowane przez te rozwiązania, gdy zapo-
znajesz się z nimi jedno po drugim. W opisie tych mechanizmów przykłady są jeszcze
bardziej naciągane niż zwykle, ponieważ za każdym razem chcę przedstawić tylko
jedną kwestię. Dwa pierwsze z omawianych rozwiązań zostały wprowadzone w C# 7.0,
choć także je wzbogacono w C# 7.2. Zacznijmy od zmiennych lokalnych ref.

87469504f326f0d7c1fcda56ef61bd79
8
430 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych

13.2.1. Zmienne lokalne ref


Wróćmy do przedstawionej wcześniej analogii. Parametry ref umożliwiają współdzie-
lenie kartki papieru przez zmienne z dwóch metod. Ta sama kartka papieru, jakiej
używa jednostka wywołująca, jest też używana dla parametru w metodzie. Zmienne
lokalne ref to rozwinięcie tego pomysłu. Umożliwiają one zadeklarowanie nowej zmien-
nej lokalnej, która współdzieli tę samą kartkę papieru z istniejącą zmienną.
Na listingu 13.2 pokazany jest prosty przykład tej techniki. Kod dwukrotnie inkre-
mentuje daną wartość, używając różnych zmiennych, a następnie wyświetla wynik.
Warto zauważyć, że słowo kluczowe ref trzeba podać zarówno w deklaracji, jak i w inicja-
lizatorze.

Listing 13.2. Dwukrotne inkrementowanie wartości za pomocą dwóch zmiennych

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.

Listing 13.3. Modyfikowanie elementów tablicy za pomocą zmiennych lokalnych ref

var array = new (int x, int y)[10];

for (int i = 0; i < array.Length; i++)


{
Inicjalizowanie tablicy za pomocą
array[i] = (i, i); wartości (0, 0), (1, 1) itd.
}

for (int i = 0; i < array.Length; i++)


{
ref var element = ref array[i]; Inkrementowanie x i podwajanie y
element.x++; w każdym elemencie tablicy.
element.y *= 2;
}

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;

static void Main()


{
var obj = new RefLocalField(); Tworzenie obiektu typu RefLocalField.
ref int tmp = ref obj.value; Deklaracja zmiennej lokalnej ref powiązanej
z polem pierwszego obiektu.
tmp = 10; Przypisywanie nowej wartości do zmiennej lokalnej ref.
Console.WriteLine(obj.value); Pokazuje, że kod zmodyfikował pole.

obj = new RefLocalField(); Ponowne przypisanie wartości do zmiennej obj,


by wskazywała drugi obiekt typu RefLocalField.
Console.WriteLine(tmp); Pokazuje, że zmienna tmp wciąż używa pola z pierwszego obiektu.
Console.WriteLine(obj.value); Pokazuje, że wartość pola
} drugiego obiektu to 0.
}

Oto dane wyjściowe:


10
10
0

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

Rysunek 13.6. W końcowej części


listingu 13.4 zmienna tmp prowadzi
do pola z pierwszego utworzonego
obiektu, natomiast wartość zmiennej
obj wskazuje inny obiekt

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ą.

Z używaniem zmiennych lokalnych ref związanych jest kilka ograniczeń. Większość


z nich jest oczywista i nie przeszkadza w pracy. Warto je jednak poznać, abyś nie musiał
eksperymentować, próbując sobie z nimi poradzić.
INICJALIZOWANIE — RAZ I TYLKO RAZ, W CZASIE DEKLARACJI
(PRZED WERSJĄ C# 7.3)
Zmienne lokalne ref zawsze trzeba inicjalizować w miejscu deklaracji. Na przykład
poniższy kod jest nieprawidłowy:
int x = 10;
ref int invalid;
invalid = ref int x;

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

for (int i = 0; i < array.Length; i++)


{
ref var element = ref array[i];
...
}

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.

Zachęcam do ostrożności w używaniu tej techniki. Jeśli potrzebujesz, aby ta sama


zmienna ref wskazywała w metodzie różne zmienne, zachęcam do próby refaktoryzacji
tej metody w celu jej uproszczenia.
NIEDOZWOLONE SĄ POLA REF I ZMIENNE LOKALNE,
KTÓRE ISTNIEJĄ PO ZAKOŃCZENIU PRACY METODY
Choć można zainicjalizować zmienną lokalną ref za pomocą pola, nie można zadeklaro-
wać pola przy użyciu słowa ref. Ten aspekt chroni przed utworzeniem zmiennej ref,
która jest aliasem innej zmiennej o krótszym czasie życia. Utworzenie obiektu z polem,
które jest aliasem zmiennej lokalnej metody, byłoby problemem. Co by się stało z tym
polem po zwróceniu sterowania przez daną metodę?
Ten sam problem z czasem życia dotyczy także zmiennych lokalnych w trzech
scenariuszach:
 bloki iteratora nie mogą zawierać zmiennych lokalnych ref,
 metody asynchroniczne nie mogą zawierać zmiennych lokalnych ref,
 zmienne lokalne ref nie mogą być przechwytywane w metodach anonimowych
ani metodach lokalnych (omówienie metod lokalnych zawiera rozdział 14.).

87469504f326f0d7c1fcda56ef61bd79
8
434 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych

We wszystkich tych sytuacjach zmienne lokalne mogą istnieć także po pierwotnym


wywołaniu metody. W niektórych przypadkach kompilator może udowodnić, że nie
spowoduje to problemów. Jednak reguły języka mają być proste. (Przykładem jest tu
metoda lokalna, którą można wywoływać tylko w zawierającej ją metodzie, a której nie
można używać w konwersjach grup metod).
NIEDOZWOLONE SĄ REFERENCJE DO ZMIENNYCH TYLKO DO ODCZYTU
Zmienne lokalne ref wprowadzone w C# 7.0 umożliwiają zapis. Możesz zapisać nową
wartość na kartce papieru. Powoduje to problem, jeśli próbujesz zainicjalizować zmienną
lokalną ref za pomocą kartki papieru, która zapisu nie umożliwia. Rozważ następującą
próbę naruszenia modyfikatora readonly:
class MixedVariables
{
private int writableField;
private readonly int readonlyField;

public void TryIncrementBoth()


{
ref int x = ref writableField; Tworzenie aliasu dla pola z możliwością zapisu.
ref int y = ref readonlyField; Próba utworzenia aliasu dla pola tylko do odczytu.

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.

UWAGA. Przypomnienie konwersji tożsamościowych znajdziesz w punkcie 11.3.3.

87469504f326f0d7c1fcda56ef61bd79
8
13.2. Zmienne lokalne ref i referencyjne zwracane wartości 435

Listing 13.5. Konwersja tożsamościowa w deklaracji zmiennej lokalnej ref

(int x, int y) tuple1 = (10, 20);


ref (int a, int b) tuple2 = ref tuple1;
tuple2.a = 30;
Console.WriteLine(tuple1.x);

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.

13.2.2. Instrukcja return ref


Zrozumienie instrukcji return ref pod niektórymi względami powinno być łatwe. Uży-
wając poprzedniego modelu, chodzi o to, że metoda może zwrócić kartkę papieru
zamiast wartości. Wymaga to dodania słowa kluczowego ref do typu zwracanej war-
tości i instrukcji return. Ponadto w wywołaniu często deklarowana jest zmienna lokalna
ref, gdzie zapisywana jest zwracana wartość. To oznacza, że musisz dość często używać
słowa kluczowego ref w kodzie, aby jednoznacznie określić, co próbujesz robić. Na
listingu 13.6 pokazane jest jedno z najprostszych zastosowań instrukcji return ref.
Metoda RefReturn zwraca tu każdą przekazaną do niej zmienną.

Listing 13.6. Najprostszy możliwy przykład zastosowania instrukcji return ref

static void Main()


{
int x = 10;
ref int y = ref RefReturn(ref x);
y++;
Console.WriteLine(x);
}

static ref int RefReturn(ref int p)


{
return ref p;
}

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;

Pokazana metoda jest w swej istocie funkcją tożsamościową i ma jedynie ilustrować


składnię. Można ją zapisać jako metodę z ciałem w postaci wyrażenia, chciałem jednak
uwidocznić instrukcję return.
Do tej pory wszystko jest proste. Omawiana technika związana jest jednak z wieloma
szczegółami — przede wszystkim dlatego, że kompilator upewnia się, iż każda zwracana
kartka papieru będzie istnieć, gdy metoda zwróci sterowanie. Nie można więc zwracać
kartki papieru utworzonej w metodzie.

87469504f326f0d7c1fcda56ef61bd79
8
436 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych

Ujmijmy to w kategoriach implementacji. Metoda nie może więc zwracać lokalizacji


w pamięci na stosie, ponieważ po zdjęciu elementów ze stosu ta lokalizacja nie będzie
już prawidłowa. W omówieniu działania języka C# Eric Lippert pisze, że stos to
szczegół implementacji (zobacz http://mng.bz/oVvZ). W przedstawianym scenariuszu jest
to szczegół implementacji, który przenika do języka. Obowiązujące tu ograniczenia
wynikają z tych samych powodów, dla których niedozwolone są pola ref. Dlatego jeśli
rozumiesz jeden z omawianych rodzajów ograniczeń, możesz zastosować tę samą logikę
do innych.
Nie zamierzam przedstawiać kompletnej listy rodzajów zmiennych, jakie mogą i nie
mogą być zwracane z użyciem instrukcji return ref. Wymieniam tylko najczęściej
spotykane przykłady.
DOZWOLONE
 Parametry ref i out.
 Pola typów referencyjnych.
 Pola struktur, gdy zmienną reprezentującą strukturę jest parametr ref lut out.
 Elementy tablic.
NIEDOZWOLONE
 Zmienne lokalne zadeklarowane w metodzie (w tym parametry przekazywane
przez wartość).
 Pola zmiennych reprezentujących strukturę zadeklarowanych w metodzie.

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.

Listing 13.7. Bezpośrednie inkrementowanie wartości zwracanej w instrukcji return ref

static void Main()


{
int x = 10;
RefReturn(ref x)++; Bezpośrednie inkrementowanie wartości zwracanej zmiennej.
Console.WriteLine(x);
}

static ref int RefReturn(ref int p)


{
return ref p;
}

87469504f326f0d7c1fcda56ef61bd79
8
13.2. Zmienne lokalne ref i referencyjne zwracane wartości 437

Jest to odpowiednik inkrementacji zmiennej x, dlatego wynik to 11. Oprócz modyfi-


kacji wynikowej zmiennej można podać ją jako argument ref innej metody. Aby nasz
czysto ilustracyjny przykład był jeszcze mniej sensowny, można wywołać metodę Ref
Return z argumentem w postaci wyniku tej metody — i to dwukrotnie:
RefReturn(ref RefReturn(ref RefReturn(ref x)))++;

Instrukcje return ref są dozwolone zarówno w metodach, jak i w indekserach. Jest to


przydatne najczęściej do zwracania elementów tablicy przez referencję. Ilustruje to
listing 13.8.

Listing 13.8. Indekser z instrukcją return ref zwracający elementy tablicy

class ArrayHolder
{
private readonly int[] array = new int[10];

public ref int this[int index] => ref array[index]; Indekser zwraca element tablicy
} przez referencję.

static void Main()


{
ArrayHolder holder = new ArrayHolder();
ref int x = ref holder[0]; Deklarowanie dwóch zmiennych lokalnych ref
ref int y = ref holder[0]; wskazujących ten sam element tablicy.
Modyfikowanie wartości danego elementu tablicy
x = 20; za pomocą zmiennej x.
Console.WriteLine(y); Uwidacznianie zmiany za pomocą zmiennej y.
}

Poznałeś już wszystkie nowe mechanizmy z C# 7.0. Jednak w późniejszych podwer-


sjach rozbudowano zestaw technik powiązanych z referencjami. Pierwszy z opisywanych
dalej mechanizmów frustrował mnie, gdy pisałem wstępną wersję tego rozdziału. Chodzi
tu o brak obsługi operatora warunkowego ?:.

13.2.3. Operator warunkowy ?: i wartości z modyfikatorem ref


(C# 7.2)
Operator warunkowy ?: jest dostępny od wersji C# 1.0 i jest znany z innych języków:
warunek ? wyrażenie1 : wyrażenie2

Ten operator sprawdza najpierw pierwszy operand (warunek), a następnie przetwarza


drugi lub trzeci operand, aby zwrócić ogólny wynik. Naturalne jest, że programiści
chcieliby móc uzyskać ten sam efekt dla wartości ref i wybierać jedną lub drugą
zmienną na podstawie warunku.
W C# 7.0 było to niemożliwe, jednak w C# 7.2 się to zmieniło. Obecnie w opera-
torze warunkowym można używać wartości ref dla drugiego i trzeciego operandu.
Wtedy wynikiem operatora warunkowego jest zmienna, której można używać razem
z modyfikatorem ref. W ramach przykładu na listingu 13.9 pokazana jest metoda, która
zlicza wartości parzyste i nieparzyste w sekwencji oraz zwraca wynik w postaci krotki.

87469504f326f0d7c1fcda56ef61bd79
8
438 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych

Listing 13.9. Zliczanie parzystych i nieparzystych elementów sekwencji

static (int even, int odd) CountEvenAndOdd(IEnumerable<int> values)


{
var result = (even: 0, odd: 0);
foreach (var value in values)
{
ref int counter = ref (value & 1) == 0 ? Wybieranie odpowiedniej zmiennej
ref result.even : ref result.odd; do inkrementacji.
counter++; Inkrementowanie zmiennej.
}
return result;
}

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?

13.2.4. Modyfikator ref readonly (C# 7.2)


Do tej pory wszystkie zmienne, dla których tworzyliśmy aliasy, umożliwiały zapis.
W C# 7.0 nie ma innych możliwości. Jednak ten model jest niewystarczający w dwóch
powiązanych scenariuszach:
 Gdy chcesz utworzyć alias pola tylko do odczytu, aby zwiększyć wydajność kodu
dzięki uniknięciu kopiowania.
 Gdy chcesz umożliwić dostęp tylko do odczytu za pomocą zmiennej ref.

Wprowadzenie modyfikatora ref readonly w C# 7.2 rozwiązuje oba te problemy.


Zarówno zmienne lokalne ref, jak i wartości z instrukcji return ref można teraz zade-
klarować z użyciem modyfikatora readonly, a wynik będzie przeznaczony tylko do odczytu
(podobnie jak pola tylko do odczytu). Do takiej zmiennej nie można przypisać nowej
wartości, a jeśli jest to zmienna typu strukturalnego, nie można modyfikować jej pól ani
wywoływać setterów właściwości.

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.10. Zmienne lokalne i zwracane wartości z modyfikatorem ref readonly

static readonly int field = DateTime.UtcNow.Second; Inicjalizowanie pola tylko do odczytu


za pomocą dowolnej wartości.
static ref readonly int GetFieldAlias() => ref field; Zwracanie przeznaczonego tylko
do odczytu aliasu pola.
static void Main()
{
ref readonly int local = ref GetFieldAlias(); Inicjalizowanie zmiennej lokalnej ref
Console.WriteLine(local); readonly z użyciem utworzonej metody.
}

Ta technika działa także dla indekserów i sprawia, że niemodyfikowalne kolekcje mogą


udostępniać dane bezpośrednio (bez kopiowania) i bez ryzykowania, że pamięć zosta-
nie zmodyfikowana. Warto zauważyć, że można zwracać wartość z użyciem modyfika-
tora ref readonly także wtedy, gdy sama zmienna nie jest przeznaczona tylko do odczytu.
Tworzony jest wtedy widok tylko do odczytu dotyczący tablicy. Podobnie dla kolekcji
elementów dowolnego typu działa typ ReadOnlyCollection, przy czym odczyt danych
z tego typu nie wymaga kopiowania. Na listingu 13.11 pokazana jest prosta implemen-
tacja tego podejścia.

Listing 13.11. Widok tablicy przeznaczony tylko do odczytu; odczyt nie wymaga tu
kopiowania

class ReadOnlyArrayView<T>
{
private readonly T[] values;

public ReadOnlyArrayView(T[] values) => Kopiowanie referencji do tablicy


this.values = values; bez klonowania jej zawartości.

public ref readonly T this[int index] => Zwracanie przeznaczonego tylko


ref values[index]; do odczytu aliasu elementu tablicy.
}
...
static void Main()
{
var array = new int[] { 10, 20, 30 };
var view = new ReadOnlyArrayView<int>(array);

ref readonly int element = ref view[0];


Console.WriteLine(element);
array[0] = 100; Modyfikacje tablicy są widoczne w zmiennej lokalnej.
Console.WriteLine(element);
}

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.

Pokazane zostało już, że modyfikator readonly można stosować do zmiennych lokalnych


i zwracanych wartości. A co z parametrami? Co powinieneś zrobić, jeśli istnieje zmienna
lokalna ref readonly i chcesz przekazać ją do metody, zamiast tylko kopiować wartość?
Możesz zakładać, że rozwiązaniem jest ponowne zastosowanie modyfikatora readonly,
tym razem do parametrów. Jednak rzeczywistość wygląda nieco inaczej. Przekonasz się
o tym na podstawie podrozdziału 13.3.

13.3. Parametry in (C# 7.2)


W C# 7.2 dodano nowy modyfikator in dla parametrów. Stosuje się go podobnie jak
modyfikatory ref i out, ale ma odmienne przeznaczenie. Gdy parametr jest opatrzony
modyfikatorem in, metoda nie zmienia wartości tego parametru, dlatego zmienną można
przekazać przez referencję, aby uniknąć kopiowania. W metodzie parametr in działa jak
zmienna lokalna ref readonly. Jest aliasem dla lokalizacji w pamięci przekazanej przez
jednostkę wywołującą. Dlatego ważne jest, by metoda nie modyfikowała takiej war-
tości. Jednostka wywołująca odczułaby wtedy zmianę, co jest sprzeczne z używaniem
parametru in.
Jest duża różnica między parametrami in a parametrami ref i out. W jednostce
wywołującej nie trzeba podawać modyfikatora in dla argumentu. Gdy modyfikator in
nie jest podany, kompilator przekazuje argument przez referencję, jeśli argumentem
jest zmienna, ale w razie potrzeby tworzy kopię wartości jako ukrytą zmienną lokalną
i przekazuje tę zmienną przez referencję. Jeżeli w jednostce wywołującej jawnie
podany jest modyfikator in, wywołanie jest poprawne tylko wtedy, gdy argument można
bezpośrednio przekazać przez referencję. Na listingu 13.12 pokazane są wszystkie te
możliwości.

87469504f326f0d7c1fcda56ef61bd79
8
13.3. Parametry in (C# 7.2) 441

Listing 13.12. Poprawne i niepoprawne sposoby podawania argumentów


dla parametrów in

static void PrintDateTime(in DateTime value) Deklarowanie metody z parametrami.


{
string text = value.ToString(
"yyyy-MM-dd'T'HH:mm:ss",
CultureInfo.InvariantCulture);

Console.WriteLine(text);
}

static void Main()


{ Zmienna jest niejawnie Zmienna jest jawnie
DateTime start = DateTime.UtcNow; przekazywana przez przekazywana przez
referencję. referencję (z powodu
PrintDateTime(start);
modyfikatora).
PrintDateTime(in start);
PrintDateTime(start.AddMinutes(1)); Wynik jest kopiowany do ukrytej zmiennej
PrintDateTime(in start.AddMinutes(1)); lokalnej przekazywanej przez referencję.
} Błąd kompilacji — argumentu nie można
przekazać przez referencję.

W wygenerowanym kodzie pośrednim taki parametr jest odpowiednikiem parametru


ref opatrzonego atrybutem [IsReadOnlyAttribute] z przestrzeni nazw System.Runtime.
CompilerServices. Ten atrybut został wprowadzony później niż InAttribute. Jest
dostępny w platformie .NET 4.7.1, ale nie ma go nawet w specyfikacji .NET Stan-
dard 2.0. Konieczność dodawania zależności lub samodzielnego deklarowania tego
atrybutu byłaby irytująca, dlatego kompilator automatycznie generuje go w podzespole,
jeśli potrzebny atrybut nie jest dostępny.
Dla atrybutu IsReadOnlyAttribute nie istnieje modyfikator modreq w kodzie pośred-
nim. Każdy kompilator C#, który nie obsługuje tego atrybutu, będzie traktował dany
parametr jak zwykły parametr ref. Także środowisko CLR nie musi znać tego atrybutu.
Jednostki wywołujące używające omawianej techniki i kompilowane ponownie przy uży-
ciu nowej wersji kompilatora nie skompilują się, ponieważ konieczny będzie modyfi-
kator in zamiast ref. Prowadzi to do bardziej obszernego zagadnienia — zgodności
wstecz.

13.3.1. Zgodność wstecz


To, że modyfikator in jest opcjonalny w miejscu wywołania, prowadzi do interesującej
sytuacji związanej ze zgodnością wstecz. Zmiana parametru metody z parametru prze-
kazywanego przez wartość (rozwiązanie domyślne bez modyfikatorów) na parametr in
jest zawsze kompatybilna ze względu na kod źródłowy (zawsze możliwa powinna być
ponowna kompilacja bez modyfikowania kodu wywołania), ale nigdy nie jest kompaty-
bilna ze względu na pliki binarne (istniejące skompilowane podzespoły, które wywo-
łują daną metodę, spowodują błąd w czasie wykonywania programu). Skutki tej sytu-
acji są zależne od scenariusza. Załóżmy, że chcesz przekształcić parametr metody
w parametr in w podzespole, który został już udostępniony:

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.

13.3.2. Zaskakująca modyfikowalność parametrów in


— zmiany zewnętrzne
Do pory mogłeś mieć wrażenie, że jeśli nie modyfikujesz parametru w metodzie, można
bezpiecznie przekształcić go w parametr in. Nie jest to prawdą, a takie podejście jest
niebezpieczne. Kompilator uniemożliwia metodzie modyfikację parametru, nie może
jednak zapobiec temu, by inny kod go zmodyfikował. Należy pamiętać, że parametr in
jest aliasem lokalizacji w pamięci, którą inny kod może zmienić. Przyjrzyj się najpierw
prostemu przykładowi, dzięki któremu może stać się to w pełni oczywiste (zobacz
listing 13.13).

Listing 13.13. Różnice między parametrami in i przekazywanymi przez wartość


w kontekście efektów ubocznych

static void InParameter(in int p, Action action)


{
Console.WriteLine("Początek metody InParameter");
Console.WriteLine($"p = {p}");
action();
Console.WriteLine($"p = {p}");
}

static void ValueParameter(int p, Action action)


{
Console.WriteLine("Początek metody ValueParameter ");
Console.WriteLine($"p = {p}");

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}");
}

static void Main()


{
int x = 10;
InParameter(x, () => x++);

int y = 10;
ValueParameter(y, () => y++);
}

Dwie pierwsze metody są identyczne z wyjątkiem wyświetlanego komunikatu i natury


parametru. W metodzie Main obie te metody są wywoływane w ten sam sposób. Kod
przekazuje jako argument zmienną lokalną o początkowej wartości 10 i operację inkre-
mentującą tę zmienną. Dane wyjściowe ilustrują różnicę w działaniu obu metod.
Początek metody InParameter
p = 10
p = 11
Początek metody ValueParameter
p = 10
p = 10

Widać tu, że w metodzie InParameter można zaobserwować zmianę spowodowaną


w wywołaniu action(), natomiast w metodzie ValueParameter ta zmiana jest niewidoczna.
Nie jest to zaskoczeniem. Parametry in służą do współdzielenia lokalizacji w pamięci,
natomiast parametry przekazywane przez wartość są przeznaczone do tworzenia kopii
wartości.
Problem polega na tym, że choć jest to oczywiste w tym konkretnym kodzie, ponie-
waż jest on tak krótki, inne przykłady mogą być trudniejsze do zrozumienia. Na przy-
kład parametr in może być aliasem pola z tej samej klasy. Wtedy modyfikacje danego
pola (albo bezpośrednio w metodzie, albo w innym kodzie wywoływanym przez tę
metodę) będą widoczne w momencie użycia tego parametru. Sytuacja może być nie-
oczywista także w kodzie wywołującym tę metodę i w samej metodzie. Przewidzenie
przebiegu zdarzeń jest jeszcze trudniejsze w kodzie wielowątkowym.
Celowo trochę panikuję, ponieważ uważam, że jest to poważny problem. Jesteśmy
przyzwyczajeni do oznaczania możliwości takiego działania kodu2 za pomocą para-
metrów ref, używając modyfikatora dla parametru i argumentu. Ponadto modyfikator
ref pośrednio określa, w jaki sposób widoczne są zmiany w parametrze. Z kolei mody-
fikator in związany jest z tym, że parametru nie należy modyfikować. W punkcie 13.3.4
przedstawiam wskazówki dotyczące użytkowania parametrów in. Jednak na razie
powinieneś jedynie pamiętać o ewentualnym ryzyku nieoczekiwanej zmiany wartości
parametrów.

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

13.3.3. Przeciążanie metod z użyciem parametrów in


Jednym z aspektów, których do tej pory jeszcze nie poruszyłem, jest przeciążanie
metod. Co zrobić, jeśli chcesz używać dwóch metod o tej samej nazwie i z parametrem
tego samego typu, przy czym jedna wersja ma przyjmować parametr in, a druga zwykły
parametr?
Pamiętaj, że dla środowiska CLR parametr in jest parametrem ref. Nie możesz
przeciążać metody na podstawie modyfikatorów ref, out i in. W środowisku CLR
wszystkie one wyglądają tak samo. Możesz jednak utworzyć wersje z parametrem in
i zwykłym parametrem przekazywanym przez wartość:
void Method(int x) { ... }
void Method(in int x) { ... }

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.

Te reguły umożliwiają dodawanie nowych wersji istniejących przeciążonych metod bez


obaw o zgodność, jeśli istniejące metody mają parametry przekazywane przez wartość,
a nowe metody mają parametry in.

13.3.4. Wskazówki dotyczące parametrów in


Będę całkowicie szczery — nie używałem jeszcze parametrów in w rzeczywistym kodzie.
Prezentowane tu wskazówki są czysto teoretyczne.
Pierwszą rzeczą, jaką warto zauważyć, jest to, że parametry in mają zwiększać wydaj-
ność kodu. A oto ogólna rada: nie zaczynaj wprowadzania w kodzie zmian w celu poprawy
wydajności, jeśli nie zmierzyłeś jej w sensowny i powtarzalny sposób oraz nie ustaliłeś
celów w tym obszarze. Jeśli nie zachowasz ostrożności, możesz skomplikować kod na
potrzeby optymalizacji, a potem odkryć, że choć znacznie zwiększyłeś wydajność jednej
lub dwóch metod, nie znajdują się one na ścieżce krytycznej w aplikacji. Stawiane cele
zależą od rodzaju pisanego kodu (gry, aplikacje sieciowe, biblioteki, aplikacje z dzie-
dziny internetu rzeczy itd.), a staranne pomiary są bardzo ważne. Na potrzeby prostych
testów zachęcam do używania projektu BenchmarkDotNet.
Zaletą parametrów in jest to, że zmniejszają ilość danych, jakie trzeba skopiować.
Jeśli używasz tylko typów referencyjnych lub prostych struktur, możesz nie odczuć
żadnej poprawy, ponieważ do metody i tak trzeba przekazać lokalizację pamięci, nawet
jeśli zapisana tam wartość nie jest kopiowana. Nie będę przedstawiał tu szczegółowych
porad, ponieważ kompilacja i optymalizacja JIT działają jak czarna skrzynka. Anali-
zowanie wydajności bez przeprowadzania testów to zły pomysł. Występuje tu tak wiele
złożonych czynników, że takie analizy mogą być w najlepszym razie hipotezą. Spodzie-
wam się jednak, że korzyści stosowania parametrów in będą tym większe, im większa
jest używana struktura.

87469504f326f0d7c1fcda56ef61bd79
8
13.3. Parametry in (C# 7.2) 445

Moim głównym zastrzeżeniem do parametrów in jest to, że mogą znacznie utrud-


niać analizowanie kodu. Możesz dwukrotnie wczytać wartość tego samego parametru
i uzyskać różne wyniki, nawet jeśli metoda nie wprowadziła żadnych zmian (zostało
to pokazane w punkcie 13.3.2). Utrudnia to pisanie poprawnego kodu i powoduje, że
łatwo jest napisać kod, który wydaje się być prawidłowy, ale jest błędny.
Istnieje jednak sposób na uniknięcie problemów i uzyskanie wielu korzyści ofero-
wanych przez parametry in. Polega on na starannym ograniczeniu lub wyeliminowaniu
możliwości modyfikowania ich. Jeśli masz publiczny interfejs API zaimplementowany
z użyciem głębokiej hierarchii wywołań metod prywatnych, możesz użyć w tym inter-
fejsie parametru przekazywanego przez wartość, a w metodach prywatnych zastosować
parametry in. Przykład pokazany jest na listingu 13.14 (choć ten kod nie przeprowadza
żadnych użytecznych obliczeń).

Listing 13.14. Bezpieczne używanie parametrów in

public static double PublicMethod(


Metoda publiczna z parametrami
LargeStruct first,
przekazywanymi przez wartość.
LargeStruct second)
{
double firstResult = PrivateMethod(in first);
double secondResult = PrivateMethod(in second);
return firstResult + secondResult;
}

private static double PrivateMethod(


Metoda prywatna z parametrem in.
in LargeStruct input)
{
double scale = GetScale(in input);
return (input.X + input.Y + input.Z) * scale;
}

private static double GetScale(in LargeStruct input) => Inna metoda z parametrem in.
input.Weight * input.Score;

To podejście pozwala zabezpieczyć się przed nieoczekiwaną zmianą. Wszystkie metody


są prywatne, dlatego można sprawdzać jednostki wywołujące, aby się upewnić, że nie
przekazują wartości mogących zmienić się w trakcie wykonywania danej metody.
W momencie wywołania metody PublicMethod tworzona jest jedna kopia każdej struk-
tury. Jednak potem tworzone są aliasy tych kopii przeznaczone do użytku w metodach
prywatnych. Izoluje to kod od zmian, jakie jednostka wywołująca może wprowadzać
w innych wątkach, a także od modyfikacji będących efektem ubocznym działania innych
metod. W niektórych sytuacjach możesz chcieć, aby parametr mógł się zmieniać, ale
w starannie udokumentowany i kontrolowany sposób.
Zastosowanie tej samej logiki do wywołań wewnętrznych też ma sens, ale wymaga
więcej dyscypliny, ponieważ istnieje wtedy więcej kodu, który może wywoływać metody.
Ja wolę jawnie używać modyfikatora in zarówno w miejscu wywołania, jak i w deklaracji
parametru, aby w trakcie lektury kodu było oczywiste, jak on działa.

87469504f326f0d7c1fcda56ef61bd79
8
446 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych

Oto podsumowanie wszystkich tych rozważań na krótkiej liście zaleceń:


 Stosuj parametry in tylko wtedy, jeśli daje to mierzalne i znaczące korzyści
w zakresie wydajności. Najczęściej jest to prawdą, gdy używane są duże struktury.
 Unikaj stosowania parametrów in w publicznych interfejsach API, chyba że
metoda potrafi działać poprawnie nawet wtedy, gdy wartości parametrów dowol-
nie się zmieniają w trakcie jej pracy.
 Rozważ zastosowanie metody publicznej jako bariery chroniącej przed zmianami
i użycie parametrów in w prywatnej implementacji, aby uniknąć kopiowania
danych.
 Rozważ jawne używanie modyfikatora in w wywołaniach metody, która przyj-
muje parametry in, chyba że celowo wykorzystujesz możliwość przekazywania
ukrytej zmiennej lokalnej przez referencję przez kompilator.

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.

Przydatność omawianych parametrów zależy od tego, na ile w rzeczywistości ograni-


czone zostaje kopiowanie. Ocena tego nie jest tak prosta, jak może się wydawać.
Wspominałem już o tym zagadnieniu. Teraz pora przyjrzeć się dokładnie niejawnemu
kopiowaniu struktur przez kompilator i sposobom na uniknięcie tego.

13.4. Deklarowanie struktur tylko do odczytu (C# 7.2)


Parametry in mają poprawiać wydajność dzięki ograniczeniu kopiowania struktur.
Brzmi to bardzo atrakcyjnie, jeśli jednak nie zachowasz ostrożności, na przeszkodzie
stanie pewien zawiły aspekt języka C#. Najpierw opisany zostanie ten problem,
a następnie jego rozwiązanie w C# 7.2.

13.4.1. Wprowadzenie — niejawne kopiowanie


zmiennych tylko do odczytu
C# niejawnie kopiuje struktury od długiego czasu. Zagadnienie to jest udokumento-
wane w specyfikacji, nie byłem jednak świadomy go do momentu, gdy wykryłem zaska-
kujący wzrost wydajności biblioteki Noda Time po tym, jak przypadkowo zapomniałem
utworzyć pole jako tylko do odczytu.
Przyjrzyj się prostemu przykładowi. Zadeklarowana jest w nim struktura YearMonthDay
z trzema właściwościami tylko do odczytu: Year, Month i Day. Z przyczyn, które wkrótce
staną się jasne, wbudowany typ DateTime nie jest tu używany. Na listingu 13.15 poka-
zany jest kod typu YearMonthDay. Jest on naprawdę prosty (nie ma w nim sprawdzania
poprawności; jest to czysto ilustracyjny kod na potrzeby tego podrozdziału).

87469504f326f0d7c1fcda56ef61bd79
8
13.4. Deklarowanie struktur tylko do odczytu (C# 7.2) 447

Listing 13.15. Prosta struktura z rokiem, miesiącem i dniem

public struct YearMonthDay


{
public int Year { get; }
public int Month { get; }
public int Day { get; }

public YearMonthDay(int year, int month, int day) =>


(Year, Month, Day) = (year, month, day);
}

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);

public void CheckYear()


{
int readOnlyFieldYear = readOnlyField.Year;
int readWriteFieldYear = readWriteField.Year;
}
}

Kod pośredni generowany na potrzeby dostępu do każdej z tych dwóch właściwości


jest odmienny w subtelny, ale istotny sposób. Oto kod pośredni dla pola przeznaczonego
tylko do odczytu. Dla uproszczenia usunąłem tu przestrzenie nazw:
ldfld valuetype YearMonthDay ImplicitFieldCopy::readOnlyField
stloc.0
ldloca.s V_0
call instance int32 YearMonthDay::get_Year()

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.

Opisane zagadnienie dotyczy tylko typów bezpośrednich


W ramach przypomnienia warto wyjaśnić, że problemu nie powoduje tworzenie pól tylko
do odczytu typów referencyjnych i modyfikowanie w metodach danych ze wskazywanych
obiektów. Możesz np. utworzyć pole tylko do odczytu typu StringBuilder i zachować moż-
liwość dodawania do niego danych. Wartością takiego pola jest tylko referencja i to ona
nie może się zmieniać.
W tym podrozdziale omawiane są pola typów bezpośrednich, np. decimal lub DateTime. Nie
ma znaczenia, czy typ zawierający takie pole jest klasą, czy strukturą.

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()

Instrukcja ldobj kopiuje wartość o określonym adresie (parametru) na stos. Próbowałeś


uniknąć tworzenia jednej kopii przez jednostkę wywołującą, ale przy okazji wygenero-
wałeś trzy kopie w metodzie. Dokładnie taki sam efekt spowodują zmienne lokalne
readonly ref. Niedobrze! Jak zapewne się domyślasz, w C# 7.2 wprowadzono rozwią-
zanie tego problemu. Na ratunek przybywają struktury tylko do odczytu.

87469504f326f0d7c1fcda56ef61bd79
8
13.4. Deklarowanie struktur tylko do odczytu (C# 7.2) 449

13.4.2. Modyfikator readonly dla struktur


Oto podsumowanie: kompilator C# musi kopiować przeznaczone tylko do odczytu
zmienne typów bezpośrednich, aby uniknąć modyfikowania wartości tej zmiennej przez
kod z danego typu. Co się stanie, jeśli programista struktury obieca, że kod typu nie
będzie zmieniał wartości? W końcu większość struktur jest tak projektowana, aby były
niemodyfikowalne. W C# 7.2 można zastosować modyfikator readonly do deklaracji
struktury, aby uzyskać pożądany efekt.
Zmodyfikujmy teraz strukturę z rokiem, miesiącem i dniem, aby była przeznaczona
tylko do odczytu. Implementacja działa już w odpowiedni sposób, dlatego wystarczy
dodać modyfikator readonly:
public readonly struct YearMonthDay
{
public int Year { get; }
public int Month { get; }
public int Day { get; }

public YearMonthDay(int year, int month, int day) =>


(Year, Month, Day) = (year, month, day);
}

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()

Wreszcie udało się uniknąć nawet jednokrotnego kopiowania całej struktury.


W kodzie źródłowym dołączonym do książki znajdziesz to rozwiązanie w odrębnej
deklaracji struktury ReadOnlyYearMonthDay. Utworzenie takiej struktury było konieczne,
abym mógł zaprezentować przykłady ze starszą i nowszą wersją kodu. We własnym
kodzie możesz przekształcić istniejącą strukturę na wersję tylko do odczytu i nie naruszy
to zgodności ze względu na kod źródłowy lub pliki binarne. Jednak zmiana w drugim
kierunku jest podstępna i powoduje niezgodność. Jeśli zdecydujesz się usunąć mody-
fikator readonly i tak zmienić istniejącą składową, aby modyfikowała stan wartości,
wcześniej skompilowany kod oczekujący, że struktura jest tylko do odczytu, może
zmieniać wartość zmiennej tylko do odczytu w niebezpieczny sposób.
Modyfikator readonly można stosować tylko wtedy, jeśli struktura rzeczywiście jest
przeznaczona tylko do odczytu. Musi wtedy spełniać następujące warunki:
 Każde pole instancji i każda automatycznie implementowana właściwość instancji
musi być tylko do odczytu. Pola i właściwości statyczne mogą być do odczytu
i zapisu.
 Wartość this można ustawiać tylko w konstruktorze. Według specyfikacji this
jest traktowane w konstruktorze jak parametr out, w składowych zwykłych
struktur jak parametr ref, a w składowych struktur tylko do odczytu jak para-
metr in.

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.

13.4.3. Serializowane dane w XML-u


są z natury przeznaczone do odczytu i zapisu
Obecnie większość struktur w bibliotece Noda Time implementuje interfejs IXmlSeria
lizable. Niestety, serializacja danych w XML-u jest zdefiniowana w sposób bardzo
utrudniający pisanie struktur tylko do odczytu. Implementacja tego interfejsu w biblio-
tece Noda Time zwykle wygląda tak:
void IXmlSerializable.ReadXml(XmlReader reader)
{
var pattern = /* Wzorzec parsowania tekstu odpowiedni dla używanego typu */;
var text = /* Pobieranie tekstu z obiektu typu XmlReader */;
this = pattern.Parse(text).Value;
}

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

13.5. Metody rozszerzające z parametrami ref i in (C# 7.2)


W wersjach starszych niż C# 7.2 pierwszy parametr każdej metody rozszerzającej
musiał być przekazywany przez wartość. W C# 7.2 to ograniczenie częściowo znie-
siono, aby rozszerzyć zastosowania nowych parametrów działających podobnie jak
referencje.

13.5.1. Używanie parametrów ref i in w metodach rozszerzających,


aby uniknąć kopiowania
Załóżmy, że używasz dużej struktury i chcesz uniknąć jej kopiowania. Korzystasz też
z metody, która oblicza wynik — np. długość wektora w przestrzeni trójwymiarowej —
na podstawie wartości właściwości tej struktury. Jeśli ta struktura udostępnia potrzebną
metodę (lub właściwość), sprawa jest prosta, zwłaszcza jeśli struktura jest zadeklarowana
z modyfikatorem readonly. Dzięki temu możesz bez problemów uniknąć kopiowania.
Możliwe jednak, że chcesz wykonać bardziej złożone operacje, których autorzy struk-
tury nie przewidzieli. W przykładach w tym podrozdziale używana jest prosta struktura
tylko do odczytu Vector3D przedstawiona na listingu 13.17. Ta struktura jedynie udostęp-
nia właściwości X, Y i Z.

Listing 13.17. Prosta struktura Vector3D

public readonly struct Vector3D


{
public double X { get; }
public double Y { get; }
public double Z { get; }

public Vector3D(double x, double y, double z)


{
X = x;
Y = y;
Z = z;
}
}

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)

Nieprzyjemnie jest musieć wybierać między wydajnością a czytelnością. W C# 7.2


problem rozwiązano w dość przewidywalny sposób: można teraz pisać metody rozsze-
rzające z modyfikatorem ref lub in dla pierwszego parametru. Taki modyfikator może
występować albo przed modyfikatorem this, albo po nim. Jeśli tylko obliczasz wartość,

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.

Listing 13.18. Metody rozszerzające z modyfikatorami ref i in

public static double Magnitude(this in Vector3D vec) =>


Math.Sqrt(vec.X * vec.X + vec.Y * vec.Y + vec.Z * vec.Z);

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ść.

Listing 13.19. Wywoływanie metod rozszerzających z modyfikatorami ref i in

var vector = new Vector3D(1.5, 2.0, 3.0);


var offset = new Vector3D(5.0, 2.5, -1.0);

vector.OffsetBy(offset);

Console.WriteLine($"({vector.X}, {vector.Y}, {vector.Z})");


Console.WriteLine(vector.Magnitude());

Oto dane wyjściowe:


(6.5, 4.5, 2)
8.15475321515004

To pokazuje, że wywołanie metody OffsetBy zmodyfikowało zmienną vector w oczeki-


wany sposób.

UWAGA. Metoda OffsetBy sprawia, że niemodyfikowalna struktura Vector3D wydaje się


modyfikowalna. Omawiana technika jest na razie nowa, jednak podejrzewam, że dużo bardziej
komfortowe będzie dla mnie pisanie metod rozszerzających z pierwszym parametrem in niż
z pierwszym parametrem ref.

Metodę rozszerzającą z pierwszym parametrem in można wywoływać dla zmiennej do


odczytu i zapisu (tak jak w wywołaniu vector.Magnitude()), natomiast metody rozsze-
rzającej z pierwszym parametrem ref nie można wywoływać dla zmiennej tylko
do odczytu. Na przykład, jeśli utworzysz alias zmiennej vector przeznaczony tylko do
odczytu, nie będziesz mógł wywołać dla niego metody OffsetBy:

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.

Inaczej niż w zwykłych metodach rozszerzających tu występują ograniczenia dotyczące


rozszerzanego typu (typu pierwszego parametru) z modyfikatorami ref i in.

13.5.2. Ograniczenia dotyczące metod rozszerzających


z pierwszym parametrem ref lub in
Zwykłe metody rozszerzające mogą rozszerzać dowolny typ. Można w nich używać
zwykłego typu lub parametru określającego typ (zarówno z ograniczeniami, jak i bez nich):
static void Method(this string target)
static void Method(this IDisposable target)
static void Method<T>(this T target)
static void Method<T>(this T target) where T : IComparable<T>
static void Method<T>(this T target) where T : struct

Z kolei metody rozszerzające z parametrami ref i in zawsze rozszerzają typy bezpo-


średnie. Ponadto w metodach rozszerzających z parametrem in pierwszym parametrem
nie może być parametr określający typ. Poniższe metody są prawidłowe:
static void Method(this ref int target)
static void Method<T>(this ref T target) where T : struct
static void Method<T>(this ref T target) where T : struct, IComparable<T>
static void Method<T>(this ref int target, T other)
static void Method(this in int target)
static void Method(this in Guid target)
static void Method<T>(this in Guid target, T other)

Jednak te metody są niedozwolone:


static void Method(this ref string target) Typ referencyjny w parametrze ref.
static void Method<T>(this ref T target) Parametr określający typ w parametrze ref
where T : IComparable<T> (bez ograniczenia struct).
static void Method<T>(this in string target) Typ referencyjny w parametrze in.
static void Method<T>(this in T target) Parametr określający typ
where T : struct w parametrze in.

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

obecnie mogą przyjmować programiści używający C# i narzędzia. Założenie to


dotyczy tego, że wywołanie x.Method() (gdzie x to zmienna typu referencyjnego)
nigdy nie może sprawić, że x stanie się równe null.

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.

13.6. Struktury referencyjne (C# 7.2)


W C# 7.2 wprowadzono struktury referencyjne (ang. ref-like struct). Mają one istnieć
tylko na stosie. Możliwe, że nigdy nie będziesz musiał deklarować własnych struktur tego
rodzaju (podobnie jak niestandardowych typów zadań), sądzę jednak, że w najbliższych
latach w kodzie w C# pisanym z użyciem nowych platform będziesz często korzystał
z tego rodzaju struktur wbudowanych w platformy.
Najpierw zapoznaj się z podstawowymi regułami dotyczącymi struktur referencyj-
nych, a następnie zobacz, jak są one używane i w jaki sposób platforma je obsługuje.
Warto zauważyć, że opisana jest tu uproszczona wersja reguł. Uciążliwe szczegóły
znajdziesz w specyfikacji języka. Uważam, że niewielu programistów musi precyzyjnie
wiedzieć, w jaki sposób kompilator wymusza bezpieczeństwo omawianych struktur na
stosie. Ważne jest jednak, aby zrozumieć zasadę, jakiej kompilator próbuje przestrzegać:

Wartość struktur referencyjnych musi pozostawać na stosie — zawsze.


Zacznijmy od utworzenia struktury referencyjnej. Jej deklaracja wygląda tak samo jak
zwykłej struktury, ale z dodanym modyfikatorem ref:
public ref struct RefLikeStruct
{
Składowe struktury są podawane w standardowy sposób.
}

87469504f326f0d7c1fcda56ef61bd79
8
13.6. Struktury referencyjne (C# 7.2) 455

13.6.1. Reguły dotyczące struktur referencyjnych


Zamiast pokazywać, co można zrobić z tą strukturą, poniżej opisuję, do czego nie można
używać struktury RefLikeStruct, i dodaję krótkie objaśnienia:

 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.

Ponadto skomplikowane reguły3 określają, w jaki sposób można używać zmiennych


lokalnych ref, których typem są struktury referencyjne. Zachęcam do tego, aby zaufać
w tej kwestii kompilatorowi. Jeśli kod nie kompiluje się z powodu struktur referencyj-
nych, prawdopodobnie chcesz użyć jakiejś zmiennej w miejscu, w którym nie znajduje

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>.

13.6.2. Typ Span<T> i wywołanie stackalloc


Istnieje kilka sposobów dostępu do porcji pamięci w .NET. Najczęściej stosowane są
tablice, jednak używa się też typu ArraySegment<T> i wskaźników. Poważną wadą bez-
pośredniego posługiwania się tablicami jest to, że powiązana jest z nimi cała zajmo-
wana przez nie pamięć. Tablica nigdy nie jest częścią większego bloku pamięci. Nie
wydaje się to problemem do czasu, gdy zrozumiesz, jak często widziałeś sygnatury
metod o następującej postaci:
int ReadData(byte[] buffer, int offset, int length)

Zestaw parametrów „bufor, pozycja, długość” występuje w wielu miejscach platformy


.NET i jest zapachem kodu wskazującym, że niedostępna była odpowiednia abstrakcja.
Typ Span<T> i powiązane typy mają zapełnić tę lukę.

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.

Span<T> (obszar) to struktura referencyjna zapewniająca indeksowany dostęp do odczytu


i zapisu do fragmentu pamięci. Działa podobnie jak tablica, ale nie jest właścicielem
danego bloku pamięci. Obszar zawsze tworzony jest na podstawie innej jednostki —
wskaźnika, tablicy, a nawet danych zapisanych bezpośrednio na stosie. Gdy używasz
typu Span<T>, nie musisz przejmować się tym, gdzie pamięć jest zaalokowana. Można też
pobierać wycinki obszarów. Pozwala to utworzyć jeden obszar z fragmentu innego bez
kopiowania danych. W nowych wersjach platformy kompilator JIT zna typ Span<T>
i obsługuje go w wysoce zoptymalizowany sposób.
To, że typ Span<T> działa podobnie jak referencje, może wydawać się nieistotne,
ma jednak dwie ważne zalety:
 Obszar może wskazywać pamięć w ściśle kontrolowanym cyklu życia, ponieważ
obszaru nie można usunąć ze stosu. Kod alokujący pamięć może przekazać obszar
do innego kodu, a później zwolnić pamięć bez obaw o to, że pozostaną jakieś
obszary wskazujące zwolnioną obecnie pamięć.
 Możliwa jest niestandardowa jednorazowa inicjalizacja danych w obszarze bez
kopiowania i bez zagrożenia tym, że kod później zmodyfikuje dane.

Oba te zagadnienia są przedstawione w prosty sposób w metodzie generującej losowe


łańcuchy znaków. Choć często można posłużyć się w tym celu metodą Guid.NewGuid,
czasem programista może chcieć zastosować bardziej dostosowany do sytuacji kod,
używając innego zestawu znaków i łańcucha innej długości. Na listingu 13.20 pokazano
tradycyjny kod, jaki mógłbyś napisać w przeszłości.

87469504f326f0d7c1fcda56ef61bd79
8
13.6. Struktury referencyjne (C# 7.2) 457

Listing 13.20. Generowanie losowego łańcucha znaków z użyciem typu char[]

static string Generate(string alphabet, Random random, int length)


{
char[] chars = new char[length];

for (int i = 0; i < length; i++)


{
chars[i] = alphabet[random.Next(alphabet.Length)];
}
return new string(chars);
}

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.

Listing 13.21. Generowanie losowego łańcucha znaków za pomocą operatora


stackalloc i wskaźnika

unsafe static string Generate(string alphabet, Random random, int length)


{
char* chars = stackalloc char[length];
for (int i = 0; i < length; i++)
{
chars[i] = alphabet[random.Next(alphabet.Length)];
}
return new string(chars);
}

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

Listing 13.22. Generowanie losowego łańcucha znaków z użyciem operatora


stackalloc i typu Span<char>

static string Generate(string alphabet, Random random, int length)


{
Span<char> chars = stackalloc char[length];
for (int i = 0; i < length; i++)
{
chars[i] = alphabet[random.Next(alphabet.Length)];
}
return new string(chars);
}

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);

Początkowo obie te sygnatury mogą wyglądać dziwnie. Przeanalizujmy więc działanie


implementacji metody Create. Wykonuje ona następujące kroki:
1. Alokuje łańcuch znaków o żądanej długości.
2. Tworzy obszar używający pamięci tego łańcucha znaków.
3. Wywołuje delegata action, przekazując stan otrzymany przez metodę i obszar.
4. Zwraca łańcuch znaków.

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.

Listing 13.23. Generowanie losowego łańcucha znaków z użyciem metody string.Create

static string Generate(string alphabet, Random random, int length) =>


string.Create(length, (alphabet, random), (span, state) =>
{
var alphabet2 = state.alphabet;

87469504f326f0d7c1fcda56ef61bd79
8
13.6. Struktury referencyjne (C# 7.2) 459

var random2 = state.random;


for (int i = 0; i < span.Length; i++)
{
span[i] = alphabet2[random2.Next(alphabet2.Length)];
}
});

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;

Dlaczego nie można po prostu przechwytywać parametrów w wyrażeniu lambda? Jeśli


użyjesz zmiennych alphabet i random w wyrażeniu lambda, kod skompiluje się i będzie
działał poprawnie. Po co więc kłopotać się używaniem dodatkowego parametru state?
Pamiętaj o celu używania obszarów — chcesz ograniczyć liczbę alokacji na stercie,
a także kopiowanie. Gdy wyrażenie lambda przechwytuje parametr lub zmienną lo-
kalną, musi utworzyć instancję wygenerowanej klasy, aby delegat miał dostęp do
zmiennych. Wyrażenie lambda z listingu 13.23 nie musi niczego przechwytywać, dla-
tego kompilator może wygenerować metodę statyczną i zapisać w pamięci podręcznej
jedną instancję delegata używaną przy każdym wywołaniu Generate. Cały stan jest
przekazywany za pomocą parametrów do metody string.Create, a ponieważ krotki
z C# 7 są typu bezpośredniego, stan nie wymaga alokacji.
Na tym etapie prosta metoda do generowania łańcuchów znaków jest tak dobra,
jak to możliwe. Wymaga jednej alokacji pamięci na stercie, a dodatkowe kopiowanie
danych nie jest konieczne. Kod bezpośrednio zapisuje dane łańcucha znaków.
Jest to tylko jeden przykład ilustrujący możliwości typu Span<T>. Istnieją też powią-
zane typy. Najważniejsze z nich to ReadOnlySpan<T>, Memory<T> i ReadOnlyMemory<T>. Ich
kompletne omawianie wykracza poza zakres tej książki.
Ważne jest to, że optymalizacja metody Generate nie wymagała zmiany sygnatury.
Wprowadzona została tylko zmiana implementacji odizolowana od innego kodu. Z tego
właśnie wynika mój entuzjazm. Choć przekazywanie dużych struktur przez referencję
w kodzie bazowym pomaga uniknąć zbędnego kopiowania, jest to szeroko zakrojona
optymalizacja. Zdecydowanie preferuję optymalizacje, które można przeprowadzać
miejscowo w precyzyjny sposób.
Oprócz typu string także wiele innych typów zyskało nowe metody do używania
obszarów. Obecnie przyjmujemy za normę, że wszystkie operacje wejścia – wyjścia mają
w platformie wersję asynchroniczną. Oczekuję, że w przyszłości podobnie będzie
z obszarami — że będą one dostępne wszędzie tam, gdzie mogą być przydatne. Spo-
dziewam się też, że w niezależnych bibliotekach dostępne będą wersje przeciążonych
metod przyjmujące obszar.

87469504f326f0d7c1fcda56ef61bd79
8
460 ROZDZIAŁ 13. Zwiększanie wydajności dzięki częstszemu przekazywaniu danych

OPERATOR STACKALLOC I INICJALIZATORY (C# 7.3)


Skoro już jesteśmy przy alokacji pamięci na stosie, w C# 7.3 dodano dodatkową sztuczkę
związaną z inicjalizatorami. We wcześniejszych wersjach w operatorze stackalloc można
było podawać tylko wielkość alokowanej pamięci. W C# 7.3 można podać także zawar-
tość przydzielanej pamięci. Dotyczy to zarówno wskaźników, jak i obszarów:
Span<int> span = stackalloc int[] { 1, 2, 3 };
int* pointer = stackalloc int[] { 4, 5, 6 };

Nie wierzę, żeby zapewniało to istotny wzrost wydajności w porównaniu z alokacją


i ręcznym zapełnianiem pamięci, jednak nowa wersja jest z pewnością bardziej czytelna.
INSTRUKCJA FIXED OPARTA NA WZORCU (C# 7.3)
Warto przypomnieć, że instrukcja fixed służy do pobierania wskaźnika do pamięci,
co tymczasowo blokuje przenoszenie danych przez mechanizm odzyskiwania pamięci.
Przed wprowadzeniem wersji C# 7.3 tę technikę można było stosować tylko do tablic,
łańcuchów znaków i pobierania adresu zmiennej. W C# 7.3 mechanizm ten działa
dla każdego typu, który udostępnia metodę GetPinnableReference zwracającą referencję
do obiektu niezarządzanego typu. Na przykład jeśli metoda zwraca wartość typu ref
int, można ją wykorzystać w instrukcji fixed w następujący sposób:
fixed (int* ptr = value) Wywoływanie value.GetPinnableRefernce.
{
Kod używający wskaźnika.
}

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.

13.6.3. Reprezentacja struktur referencyjnych w kodzie pośrednim


Struktury referencyjne są opatrzone atrybutem [IsRefLikeAttribute] z przestrzeni nazw
System.Runtime.CompilerServices. Jeśli używasz wersji platformy, w której ten atrybut
jest niedostępny, zostanie on wygenerowany przez podzespół.
Inaczej niż w przypadku parametrów in kompilator nie używa modyfikatora modreq,
aby zażądać od narzędzi używających danego typu znajomości takich parametrów.
Zamiast tego kompilator dodaje do typu atrybut [ObsolateAttribute] z określonym
komunikatem. Każdy kompilator, który obsługuje atrybut [IsRefLikeAttribute], może
zignorować atrybut [ObsoleteAttribute], jeśli ten ostatni jest powiązany z odpowiednim
komunikatem. Jeżeli autor typu chce, aby dany typ nie był używany, może w standar-
dowy sposób zastosować atrybut [ObsoleteAttribute], a kompilator potraktuje dany typ
jak każdy inny nieaktualny typ.

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

W C# 7 wprowadzono rozbudowane mechanizmy, które zmieniają sposób pisania


kodu: krotki, podział obiektów i wzorce. Są one powiązane ze złożonymi, ale efektyw-
nymi rozwiązaniami, przeznaczonymi do użytku w sytuacjach wymagających wysokiej
wydajności. Wprowadzono też zestaw drobnych funkcji, które nieco upraszczają pisanie
kodu. Żaden z mechanizmów omawianych w tym rozdziale nie jest przełomowy.
Jednak każdy zmienia nieco sposób programowania, a w połączeniu wszystkie nowe
rozwiązania mogą prowadzić do powstawania cudownie zwięzłego i przejrzystego kodu.

14.1. Metody lokalne


Gdyby nie była to książka C# od podszewki, ten podrozdział byłby bardzo krótki: „moż-
liwe jest pisanie metod w metodach”. Oczywiście zagadnienie to jest bardziej złożone,
ale zacznijmy od prostego przykładu. Na listingu 14.1 pokazana jest prosta metoda
lokalna w zwykłej metodzie Main. Ta metoda lokalna wyświetla, a następnie inkremen-
tuje zmienną lokalną zadeklarowaną w Main, co pokazuje, że w metodach lokalnych
działa przechwytywanie zmiennych.

87469504f326f0d7c1fcda56ef61bd79
8
464 ROZDZIAŁ 14. Zwięzły kod w C# 7

Listing 14.1. Prosta metoda lokalna używająca zmiennej lokalnej

static void Main()


{
int x = 10; Deklarowanie zmiennej lokalnej używanej w metodzie.
PrintAndIncrementX();
Dwukrotne wywołanie metody lokalnej.
PrintAndIncrementX();
Console.WriteLine($"Po wywołaniach, x = {x}");

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

Wiele komplikacji związanych z metodami lokalnymi, zarówno w regułach języka,


jak i w implementacji, związanych jest z możliwością odczytu i zapisu zmiennych
przechwytywanych. Zacznijmy od omówienia reguł języka.

14.1.1. Dostęp do zmiennych w metodach lokalnych


Zobaczyłeś już, że możliwy jest odczyt i zapis zmiennych lokalnych z bloku zawiera-
jącego metodę lokalną. Związane są z tym jednak pewne niuanse. Obowiązuje tu wiele
szczegółowych reguł, jednak nie musisz uczyć się ich wszystkich. W większości sytuacji
nawet nie zwrócisz na nie uwagi, a jeśli kompilator zgłosi zastrzeżenia do kodu, który
wydaje Ci się prawidłowy, zawsze możesz wrócić do tego podrozdziału.
METODA LOKALNA MOŻE PRZECHWYTYWAĆ WYŁĄCZNIE ZMIENNE
DOSTĘPNE W ZASIĘGU
Nie można używać zmiennych lokalnych spoza zasięgu, czyli spoza bloku, w którym są
zadeklarowane. Załóżmy, że chcesz użyć w metodzie lokalnej zmiennej iteracyjnej
zadeklarowanej w pętli. Wtedy metoda lokalna sama musi być zadeklarowana w tej pętli.
Oto prosty przykład nieprawidłowego kodu:
static void Invalid()
{
for (int i = 0; i < 10; i++)
{
PrintI();
}

void PrintI() => Console.WriteLine(i); Nie można użyć zmiennej i, ponieważ


} nie znajduje się ona w zasięgu.

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.
}
}

METODA LOKALNA MUSI BYĆ ZADEKLAROWANA


PO DEKLARACJI WSZYSTKICH PRZECHWYTYWANYCH ZMIENNYCH
Podobnie jak nie można używać zmiennej przed jej deklaracją w zwykłym kodzie, tak
nie można przechwytywać niezadeklarowanych zmiennych w metodzie lokalnej. Ta
reguła wynika w większym stopniu ze spójności niż z konieczności. Na przykład można
byłoby dodać do języka wymóg, aby wszystkie wywołania metody znajdowały się po
deklaracji używanej zmiennej. Jednak prościej jest wymagać, aby dostęp do zmiennych
odbywał się po ich deklaracji. Oto następny prosty przykład nieprawidłowego kodu:

1
Ten kod może wyglądać dziwnie, ale jest prawidłowy.

87469504f326f0d7c1fcda56ef61bd79
8
466 ROZDZIAŁ 14. Zwięzły kod w C# 7

static void Invalid()


{
void PrintI() => Console.WriteLine(i); CS0841: nie można użyć zmiennej lokalnej
int i = 10; i przed jej zadeklarowaniem.
PrintI();
}

Wystarczy przenieść deklarację metody lokalnej pod deklarację zmiennej (przed


wywołanie PrintI() lub pod to wywołanie), aby wyeliminować błąd.
METODA LOKALNA NIE MOŻE PRZECHWYTYWAĆ PARAMETRÓW REF
METODY NADRZĘDNEJ
Metody lokalne, podobnie jak funkcje anonimowe, nie mogą używać parametrów ref
z metod nadrzędnych. Na przykład ten kod jest nieprawidłowy:
static void Invalid(ref int p)
{
PrintAndIncrementP();
void PrintAndIncrementP() =>
Console.WriteLine(p++); Nieprawidłowy dostęp do parametru ref.
}

W funkcjach anonimowych nie można używać parametrów ref, ponieważ utworzony


delegat może być używany dłużej niż przechwycona zmienna lokalna. Metod lokalnych
w większości sytuacji to nie dotyczy, jednak, co zobaczysz dalej, w takich metodach też
może wystąpić ten problem. Zwykle można sobie poradzić z omawianym ogranicze-
niem, deklarując dodatkowy parametr w metodzie lokalnej i ponownie przekazując
parametr ref przez referencję:
static void Valid(ref int p)
{
PrintAndIncrement(ref p);
void PrintAndIncrement(ref int x) => Console.WriteLine(x++);
}

Jeśli nie musisz modyfikować parametru w metodzie lokalnej, możesz przekazać go


przez wartość.
Dodatkowym aspektem tego ograniczenia (też podobnie jak w funkcjach anonimo-
wych) jest to, że metody lokalne deklarowane w strukturach nie mogą używać obiektu
this. Wyobraź sobie, że this to niejawnie przekazywany dodatkowy parametr z początku
listy parametrów każdej metody instancji. W metodach klasy jest to parametr przeka-
zywany przez wartość. W metodach struktur ten parametr jest przekazywany przez
referencję. Dlatego możesz przechwytywać this w metodach lokalnych klas, ale nie
w strukturach. Można zastosować tu to samo rozwiązanie co dla pozostałych parame-
trów ref.

UWAGA. Przykład znajdziesz w pliku LocalMethodUsingThisInStruct.cs w kodzie źródłowym


dołączonym do książki.

87469504f326f0d7c1fcda56ef61bd79
8
14.1. Metody lokalne 467

METODY LOKALNE SĄ POWIĄZANE Z PRZYPISANIEM


OKREŚLONEJ WARTOŚCI DO ZMIENNYCH
Reguły przypisania określonej wartości do zmiennych w C# są skomplikowane,
a w metodach lokalnych poziom złożoności jest jeszcze wyższy. Najprościej myśleć
o tym tak, jakby metoda była rozwijana wewnątrzwierszowo w każdym miejscu wywo-
łania. Wpływa to na przypisania w dwojaki sposób.
Po pierwsze, jeśli metoda wczytująca przechwytywaną zmienną jest wywoływana
przed przypisaniem do zmiennej określonej wartości, następuje błąd kompilacji. Oto
przykładowy kod, który próbuje wyświetlić wartość przechwytywanej zmiennej w dwóch
miejscach — raz przed przypisaniem wartości do zmiennej i raz po przypisaniu:
static void AttemptToReadNotDefinitelyAssignedVariable()
{
int i;
void PrintI() => Console.WriteLine(i);
PrintI(); CS0165 — użycie nieprzypisanej zmiennej lokalnej i.
i = 10;
PrintI(); Poprawny kod — i ma tu przypisaną określoną wartość.
}

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ę.
}

Na zakończenie należy jeszcze wyjaśnić kilka zagadnień związanych z metodami


i zmiennymi, jednak tym razem chodzi nie o przechwytywane zmienne, ale o pola.
METODY LOKALNE NIE MOGĄ PRZYPISYWAĆ WARTOŚCI DO PÓL
TYLKO DO ODCZYTU
Wartość pól tylko do odczytu można ustawiać wyłącznie w inicjalizatorach pól i w kon-
struktorach. W metodach lokalnych ta reguła wygląda tak samo, jest jednak bardziej
ścisła. Nawet jeśli metoda lokalna jest zadeklarowana w konstruktorze, nie jest trak-
towana jak część konstruktora w kontekście inicjalizowania pól. Ten kod jest niepra-
widłowy:

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.

14.1.2. Implementowanie metod lokalnych


Na poziomie środowiska CLR metody lokalne nie istnieją2. Kompilator C# prze-
kształca metody lokalne w zwykłe, przeprowadzając transformacje niezbędne do tego,
aby końcowy kod działał zgodnie z regułami języka. W tym punkcie znajdziesz przy-
kładowe transformacje wykonywane przez kompilator Roslyn (jest to kompilator C#
rozwijany przez Microsoft). Koncentruję się tu na obsłudze zmiennych przechwytywa-
nych, ponieważ jest to najbardziej skomplikowany aspekt transformacji.

Szczegóły implementacji — nie ma żadnych gwarancji


Ten punkt dotyczy tego, jak metody lokalne są implementowane w kompilatorze Roslyn
dla C# 7.0. W przyszłych wersjach kompilatora Roslyn ta implementacja może ulec zmia-
nie, a w innych kompilatorach C# stosowane mogą być inne implementacje. To oznacza,
że możesz natrafić tu na wiele szczegółów, które mogą być dla Ciebie mało interesujące.
Od używanej implementacji zależy wydajność, co może mieć wpływ na to, na ile swobod-
nie będziesz korzystać z metod lokalnych w kodzie, gdzie wydajność jest istotna. Jednak
podobnie jak we wszystkich kwestiach związanych z wydajnością, powinieneś w większym
stopniu opierać decyzje na starannych pomiarach niż na teorii.

Metody lokalne przypominają funkcje anonimowe w zakresie przechwytywania zmien-


nych lokalnych z zewnętrznego kodu. Jednak istotne różnice w implementacji obu tych
technik mogą sprawić, że w wielu sytuacjach metody lokalne są bardziej wydajne.
U źródła tych różnic leży czas życia używanych zmiennych lokalnych. Jeśli funkcja
anonimowa jest przekształcana w instancję delegata, ten delegat może być wywoływany

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).

Listing 14.2. Metoda lokalna modyfikująca zmienną lokalną

static void Main()


{
int i = 0;
AddToI(5);
AddToI(10);
Console.WriteLine(i);
void AddToI(int amount) => i += amount;
}

Co kompilator Roslyn zrobi z tą metodą? Utworzy prywatną modyfikowalną strukturę


z publicznymi polami, aby zapisać wszystkie zmienne lokalne z danego zasięgu prze-
chwytywane przez metody lokalne. Tu przechwytywana jest tylko zmienna i. Kom-
pilator tworzy zmienną lokalną w metodzie Main tej struktury i przekazuje tę zmienną
(oczywiście obok zadeklarowanego parametru amount) przez referencję do zwykłej
metody utworzonej na podstawie metody AddToI. Uzyskiwany jest więc kod podobny do
tego z listingu 14.3.

Listing 14.3. Co kompilator Roslyn robi z kodem z listingu 14.2

private struct MainLocals Wygenerowana modyfikowalna struktura do przechowywania


{ zmiennych lokalnych z metody Main.
public int i;
}

static void Main()


{
MainLocals locals = new MainLocals(); Tworzenie i używanie w metodzie
locals.i = 0; wartości typu wygenerowanej struktury.
AddToI(5, ref locals); Przekazywanie struktury przez referencję
AddToI(10, ref locals); do wygenerowanej metody.
Console.WriteLine(locals.i);
}

static void AddToI(int amount, ref MainLocals locals)


{ Wygenerowana metoda reprezentująca
locals.i += amount; pierwotną metodę lokalną.
}

87469504f326f0d7c1fcda56ef61bd79
8
470 ROZDZIAŁ 14. Zwięzły kod w C# 7

Kompilator jak zwykle generuje niewymawialne nazwy metody i struktury. Warto


zauważyć, że w tym przykładzie wygenerowana metoda jest statyczna. Dzieje się tak,
gdy metoda lokalna pierwotnie znajduje się w składowej statycznej lub gdy znajduje się
w składowej instancji, ale nie przechwytuje obiektu this (jawnie lub niejawnie, używając
składowych instancji w metodzie lokalnej).
Ważną kwestią związaną z generowaniem tej struktury jest to, że transformacje są
niemal bezkosztowe, jeśli chodzi o wydajność. Wszystkie zmienne lokalne, które pier-
wotnie znajdowałyby się na stosie, nadal są tam zapisywane. Zostają jedynie połączone
w strukturę, dzięki czemu można je przekazać przez referencję do wygenerowanej
metody. Przekazywanie struktury przez referencję ma dwie zalety:
 Umożliwia metodzie lokalnej modyfikowanie zmiennych lokalnych.
 Niezależnie od liczby przechwytywanych zmiennych lokalnych wywołanie metody
lokalnej jest mało kosztowne. Porównaj to z przekazywaniem takich zmiennych
przez wartość, co powodowałoby utworzenie drugiej kopii każdej zmiennej.

A wszystko to bez generowania nieużytków na stercie. Hura! Teraz przejdźmy do bar-


dziej skomplikowanych sytuacji.
PRZECHWYTYWANIE ZMIENNYCH W WIELU ZASIĘGACH
Jeśli w funkcji anonimowej przechwytywane są zmienne lokalne z różnych zasięgów,
generowanych jest wiele klas. W każdej klasie reprezentującej zasięg wewnętrzny znaj-
duje się pole z referencję do instancji klasy reprezentującej zasięg zewnętrzny. W poka-
zanym wcześniej opartym na strukturach podejściu stosowanym dla metod lokalnych
to nie zadziała, ponieważ konieczne jest kopiowanie. Zamiast tego kompilator generuje
jedną strukturę dla każdego zasięgu zawierającego przechwytywaną zmienną i dla
wszystkich tych zasięgów używa odrębnych parametrów. Na listingu 14.4 celowo
utworzone są dwa zasięgi, aby pokazać, jak kompilator je traktuje.

Listing 14.4. Przechwytywanie zmiennych z wielu zasięgów

static void Main()


{
DateTime now = DateTime.UtcNow;
int hour = now.Hour;
if (hour > 5)
{
int minute = now.Minute;
PrintValues();

void PrintValues() =>


Console.WriteLine($"godzina = {hour}; minuta = {minute}");
}
}

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

Listing 14.5. Co kompilator Roslyn robi z listingiem 14.4

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.
}

static void Main()


{
DateTime now = DateTime.UtcNow; Nieprzechwytywana zmienna lokalna.
OuterScope outer = new OuterScope(); Tworzenie i używanie struktury dla zmiennej
outer.hour = now.Hour; hour z zewnętrznego zasięgu.
if (outer.hour > 5)
{
InnerScope inner = new InnerScope(); Tworzenie i używanie struktury dla zmiennej
inner.minute = now.Minute; minute z wewnętrznego zasięgu.
PrintValues(ref outer, ref inner); Przekazywanie obu struktur przez referencję
} do wygenerowanej metody.
}

static void PrintValues(


Wygenerowana metoda reprezentująca
ref OuterScope outer, ref InnerScope inner) pierwotną metodę lokalną.
{
Console.WriteLine($"godzina = {outer.hour}; minuta = {inner.minute}");
}

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

 Metoda lokalna może być wywoływana w funkcji anonimowej, która z kolei


może być wywoływana (jako delegat) na długo po zakończeniu pracy pierwotnej
metody.
 Metoda lokalna może określać docelowy typ w konwersji grupy metod, co także
powoduje utworzenie delegata, który może istnieć po wykonaniu pierwotnej
metody.

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.

Listing 14.6. Konwersja grupy metod z użyciem metody lokalnej

static void Main()


{
Action counter = CreateCounter();
counter();
Wywołanie delegata po zakończeniu
counter(); pracy metody CreateCounter.
}

static Action CreateCounter()


{
int count = 0; Zmienna lokalna przechwytywana w metodzie Countcccc.
return Count; Konwersja grupy metod Count na typ delegata Action.
void Count() => Console.WriteLine(count++); Metoda lokalna.
}

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).
}

Pomaga to zrozumieć, w jaki sposób kompilator może zaimplementować metodę


lokalną — może zastosować dla niej podobne transformacje co dla wyrażenia lambda.
Ilustruje to listing 14.7.

Listing 14.7. Co kompilator Roslyn robi z listingiem 14.6

static void Main()


{
Action counter = CreateCounter();
counter();
counter();
}

87469504f326f0d7c1fcda56ef61bd79
8
14.1. Metody lokalne 473

static Action CreateCounter()


{
CountHolder holder = new CountHolder(); Tworzenie i inicjalizowanie obiektu
holder.count = 0; przechowującego przechwycone zmienne.
return holder.Count; Konwersja grupy metod oparta na metodzie instancji
} z obiektu holder.

private class CountHolder Klasa prywatna z przechwytywanymi


{ zmiennymi i metodą lokalną.
public int count; Przechwycona zmienna.

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ć.

14.1.3. Wskazówki dotyczące użytkowania


Są dwa podstawowe scenariusze, w których metody lokalne mogą być przydatne:
 ta sama logika powtarza się kilkakrotnie w metodzie,
 metoda prywatna jest używana w tylko jednej innej metodzie.
Druga sytuacja to specjalny przypadek pierwszej, w którym już poświęciłeś czas na
refaktoryzację powtarzającego się kodu. Pierwszy scenariusz może zachodzić, gdy stan
lokalny jest na tyle rozbudowany, że taka refaktoryzacja byłaby niewygodna. Metody
lokalne mogą znacznie zwiększyć atrakcyjność przenoszenia kodu do metody, ponieważ
potrafią przechwytywać zmienne lokalne.
Gdy refaktoryzujesz istniejącą metodę, przekształcając ją na metodę lokalną, zalecam
świadome zastosowanie podejścia dwuetapowego. W pierwszym kroku przenieś raz

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ę.

Listing 14.8. Implementowanie metody Select bez używania metod lokalnych

public static IEnumerable<TResult> Select<TSource, TResult>(


this IEnumerable<TSource> source,
Func<TSource, TResult> selector)
{
Preconditions.CheckNotNull(source, nameof(source));
Zachłanne sprawdzanie
Preconditions.CheckNotNull(
poprawności argumentów.
selector, nameof(selector));
return SelectImpl(source, selector); Delegowanie operacji do implementacji.
}

private static IEnumerable<TResult> SelectImpl<TSource, TResult>(


IEnumerable<TSource> source,
Func<TSource, TResult> selector)
{

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

foreach (TSource item in source)


{
Leniwe wykonywanie implementacji.
yield return selector(item);
}
}

Obecnie dzięki dostępności metod lokalnych możesz przenieść implementację do


metody Select. Ilustruje to listing 14.9.

Listing 14.9. Implementowanie metody Select z użyciem metody lokalnej

public static IEnumerable<TResult> Select<TSource, TResult>(


this IEnumerable<TSource> source,
Func<TSource, TResult> selector)
{
Preconditions.CheckNotNull(source, nameof(source));
Preconditions.CheckNotNull(selector, nameof(selector));
return SelectImpl(source, selector);

IEnumerable<TResult> SelectImpl(
IEnumerable<TSource> validatedSource,
Func<TSource, TResult> validatedSelector)
{
foreach (TSource item in validatedSource)
{
yield return validatedSelector(item);
}
}
}

Wyróżniłem tu ciekawy aspekt implementacji — parametry nadal są przekazywane


(bez sprawdzania poprawności) do metody lokalnej. Nie jest to konieczne. Mógłbyś utwo-
rzyć bezparametrową metodę lokalną i używać przechwyconych zmiennych source
i selector. Jest to poprawka zwiększająca wydajność, zmniejszająca liczbę potrzebnych
alokacji. Czy ta różnica w wydajności jest istotna? Czy wersja z przechwytywaniem
zmiennych będzie dużo bardziej czytelna? Odpowiedzi na oba te pytania zależą od
kontekstu i są po części subiektywne.
SUGESTIE Z OBSZARU CZYTELNOŚCI
Metody lokalne wciąż są dla mnie na tyle nowe, że używam ich ostrożnie. Na razie
wolę pozostawiać kod bez zmian niż refaktoryzować go, tworząc metody lokalne. Przede
wszystkim unikam stosowania dwóch następujących technik:
 Choć można zadeklarować metodę lokalną w zasięgu pętli lub innego bloku,
taki kod wygląda dziwnie. Wolę korzystać z metod lokalnych tylko wtedy, gdy
mogę je zadeklarować na końcu metody nadrzędnej. Nie mogę wtedy przechwy-
tywać żadnych zmiennych zadeklarowanych w pętlach, jednak nie przeszkadza
mi to.
 Można deklarować metody lokalne w innych metodach lokalnych, jest to jednak
droga w głąb króliczej nory i wolę się tam nie zapuszczać.

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.

14.2. Zmienne out


Przed wersją C# 7 korzystanie z parametrów out było dość trudne. Parametr out
wymagał, aby zmienna była zadeklarowana, by można ją było podać jako argument.
Ponieważ deklaracje to odrębne instrukcje, oznaczało to, że w niektórych miejscach,
gdzie programista chciał zastosować jedno wyrażenie (np. inicjalizację zmiennej), trzeba
było tak zmodyfikować kod, by obejmował kilka poleceń.

14.2.1. Wewnątrzwierszowe deklaracje zmiennych


na potrzeby parametrów out
W C# 7 wyeliminowano ten problem, ponieważ nowe zmienne można deklarować
w wywołaniach metod. W ramach prostego przykładu rozważ metodę, która przyjmuje
tekstowe dane wejściowe, próbuje przetworzyć je na liczbę całkowitą za pomocą wywo-
łania int.TryParse, a następnie zwraca albo przetworzoną wartość typu int przyjmu-
jącego null (jeśli przetwarzanie zakończyło się powodzeniem), albo wartość null
(w przypadku niepowodzenia). W C# 6 wymagało to użycia przynajmniej dwóch instruk-
cji — deklaracji zmiennej oraz wywołania int.TryParse, w którym nowo zadeklaro-
wana zmienna jest przekazywana jako parametr out:
static int? ParseInt32(string text)
{
int value;
return int.TryParse(text, out value) ? value : (int?) null;
}

W C# 7 zmienną value można zadeklarować w samym wywołaniu metody. To oznacza,


że możesz zastosować metodę z ciałem w postaci wyrażenia:
static int? ParseInt32(string text) =>
int.TryParse(text, out int value) ? value : (int?) null;

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;

W trzecim operandzie operatora warunkowego parametr value1 ma przypisaną okre-


śloną wartość (dlatego możesz go zwrócić, jeśli chcesz), ale value2 nie ma przypisanej
określonej wartości. Jeśli pierwsze wywołanie metody int.TryParse zwróciło false, nie
trzeba po raz drugi jej wywoływać. Wynika to ze skróconego przetwarzania stosowanego
w operatorze &&.

14.2.2. Zniesione w C# 7.3 ograniczenia dotyczące zmiennych out


i zmiennych generowanych we wzorcach
W podrozdziale 12.5 wspomniałem, że zmiennych generowanych we wzorcach nie
można używać do inicjalizowania pól i właściwości, w inicjalizatorach konstruktorów
[this(…) i base(…)] ani w wyrażeniach reprezentujących zapytania. Przed wprowadzeniem
wersji C# 7.3 te same ograniczenia (obecnie zniesione) dotyczyły zmiennych out. Na
listingu 14.10 pokazany jest kod, gdzie te ograniczenia nie obowiązują. Widać tu, że
zmienna out jest dostępna także w ciele konstruktora.

Listing 14.10. Używanie zmiennej out w inicjalizatorze konstruktora

class ParsedText
{
public string Text { get; }
public bool Valid { get; }

protected ParsedText(string text, bool valid)


{
Text = text;
Valid = valid;
}
}

class ParsedInt32 : ParsedText


{
public int? Value { get; }

public ParsedInt32(string text)


: base(text, int.TryParse(text, out int parseResult))
{

87469504f326f0d7c1fcda56ef61bd79
8
478 ROZDZIAŁ 14. Zwięzły kod w C# 7

Value = Valid ? parseResult : (int?) null;


}
}

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.

14.3. Usprawnienia w literałach liczbowych


Literały nie zmieniały się często w historii języka C#. Od wersji C# 1 nie wprowadzono
żadnych modyfikacji aż do wersji C# 6, gdzie pojawiły się literały tekstowe z inter-
polacją. Nie miało to jednak żadnego wpływu na liczby. W C# 7 dodano dwie techniki
związane z literałami liczbowymi. Obie mają poprawiać czytelność. Są to: dwójkowe
literały całkowitoliczbowe i separatory w postaci podkreślenia.

14.3.1. Dwójkowe literały całkowitoliczbowe


W odróżnieniu od literałów zmiennoprzecinkowych (typów float, double i decimal)
literały całkowitoliczbowe zawsze umożliwiały stosowanie dwóch podstaw: dziesiętnej
(bez przedrostka) i szesnastkowej (przedrostek 0x lub 0X)4. W C# 7 dodano też podstawę
dwójkową z przedrostkiem 0b lub 0B. Jest ona przydatna zwłaszcza w sytuacji, gdy
implementujesz protokół z ustalonym wzorcem bitów dla określonych wartości. Zapis
dwójkowy nie wpływa na działanie kodu w czasie wykonywania programu, może
jednak znacznie poprawiać czytelność. Na przykład który z poniższych wierszy inicjali-
zuje bajt z ustawionym pierwszym bitem i trzema ostatnimi?
byte b1 = 135;
byte b2 = 0x83;
byte b3 = 0b10000111;

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

14.3.2. Separatory w postaci podkreślenia


Przejdźmy od razu do separatorów w postaci podkreślenia, poprawiając wcześniejszy
przykład. Jeśli chcesz podać wszystkie bity bajta za pomocą zapisu dwójkowego, łatwiej
jest zobaczyć dwa półbajty niż zliczać wszystkich osiem bitów. Oto ten sam kod z czwar-
tym wierszem, gdzie użyłem podkreślenia do rozdzielenia półbajtów:
byte b1 = 135;
byte b2 = 0x83;
byte b3 = 0b10000111;
byte b4 = 0b1000_0111;

Uwielbiam to rozwiązanie! Teraz mogę błyskawicznie sprawdzić kod. Separatory


w postaci podkreślenia są dostępne nie tylko dla literałów dwójkowych, a nawet nie tylko
dla literałów całkowitoliczbowych. Możesz stosować je w dowolnych literałach liczbo-
wych i umieszczać w (prawie) dowolnym miejscu literału. W literałach dziesiętnych
zwykle podaje się je co trzy cyfry, tak jak separator dla tysięcy (przynajmniej w krajach
zachodnich). W literałach szesnastkowych separatory są zwykle najbardziej przydatne
co dwie, co cztery i co osiem cyfr, aby rozdzielać człony obejmujące po 8 bitów, 16
bitów lub 32 bity. Oto przykład:
int maxInt32 = 2_147_483_647;
decimal largeSalary = 123_456_789.12m;
ulong alternatingBytes = 0xff_00_ff_00_ff_00_ff_00;
ulong alternatingWords = 0xffff_0000_ffff_0000;
ulong alternatingDwords = 0xffffffff_00000000;

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;

Trzeba też pamiętać o kilku ograniczeniach:


 Nie można umieszczać podkreślenia na początku literału.
 Nie można umieszczać podkreślenia na końcu literału (także tuż przed przy-
rostkiem).
 Nie można umieszczać podkreślenia bezpośrednio przed kropką lub po kropce
w literałach zmiennoprzecinkowych.
 W C# 7.0 i C# 7.1 nie można podawać podkreślenia po specyfikatorze podstawy
(0x lub 0b) w literałach całkowitoliczbowych.

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

To już wszystko! Jest to prosta funkcja z nielicznymi niuansami. Następna technika


jest równie łatwa i pozwala w niektórych sytuacjach uprościć kod, gdy trzeba warun-
kowo zgłaszać wyjątek.

14.4. Wyrażenia throw


We wcześniejszych wersjach C# dostępna była instrukcja throw, ale nie można było
używać throw jako wyrażenia. Podejrzewam, że projektanci języka zakładali, że nikt nie
chciałby stosować throw jako wyrażenia, ponieważ zawsze skutkuje to zgłoszeniem
wyjątku. Okazuje się, że wraz z wprowadzaniem nowych technik wymagających uży-
wania wyrażeń to ograniczenie stawało się coraz większym problemem. W C# 7 można
stosować wyrażenia throw, choć tylko w niektórych miejscach:
 jako ciało wyrażenia lambda,
 jako ciało składowej z ciałem w postaci wyrażenia,
 jako drugi operand operatora ??,
 jako drugi lub trzeci operand operatora ?: (choć nie na obu tych miejscach w tym
samym wyrażeniu).

Cały poniższy kod jest prawidłowy:


public void UnimplementedMethod() =>
Składowa z ciałem w postaci wyrażenia.
throw new NotImplementedException();

public void TestPredicateNeverCalledOnEmptySequence()


{
int count = new string[0]
.Count(x => throw new Exception("Bang!")); Wyrażenie lambda.
Assert.AreEqual(0, count);
}

public static T CheckNotNull<T>(T value, string paramName) where T : class


=> value ?? Operator ?? (w metodzie z ciałem
throw new ArgumentNullException(paramName); w postaci wyrażenia).

public static Name =>


initialized
? data["name"] Operator ?: (we właściwości z ciałem w postaci wyrażenia).
: throw new Exception("...");

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"));

Zespół odpowiedzialny za C# umożliwia stosowanie wyrażeń throw tam, gdzie są one


użyteczne (zwykle w miejscach, gdzie można zapisać ten sam kod co wcześniej, ale
w bardziej zwięzły sposób), ale chroni programistów przed wyrządzeniem sobie
krzywdy w wyniku stosowania takich wyrażeń tam, gdzie byłoby to niedorzeczne.

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.

14.5. Literał default (C# 7.1)


Operator default(T) został dodany w C# 2.0 głównie na potrzeby typów generycz-
nych. Na przykład, aby zwracać wartość z listy, jeśli podany został poprawny indeks,
a w innych sytuacjach zwracać wartość domyślną, można napisać następującą metodę:
static T GetValueOrDefault<T>(IList<T> list, int index)
{
return index >= 0 && index < list.Count ? list[index] : default(T);
}

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.

Listing 14.11. Podawanie literału default jako argumentu metody

static void PrintValue(int value = 10) Wartość domyślna parametru to 10.


{
Console.WriteLine(value);
}

static void Main()


{
PrintValue(default); Argument metody to wartość domyślna typu int.
}

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.

14.6. Argumenty nazwane w dowolnym miejscu


listy argumentów (C# 7.2)
Opcjonalne parametry i argumenty nazwane wprowadzono jako pomocnicze mechani-
zmy w C# 4. Obie te techniki wymagają zachowania odpowiedniej kolejności dekla-
racji. Parametry opcjonalne muszą znajdować się po wszystkich parametrach wyma-
ganych (nie dotyczy to tablic parametrów), a argumenty nazwane trzeba umieszczać
po wszystkich argumentach pozycyjnych. Parametry opcjonalne działają tak samo jak
zawsze, jednak zespół odpowiedzialny za C# zauważył, że argumenty nazwane często
mogą być przydatne do zwiększania przejrzystości kodu — nawet jeśli znajdują się
w środku listy. Jest to prawdą zwłaszcza wtedy, gdy argument jest literałem (zwykle
liczbą, wartością logiczną, tekstem lub wartością null), a kontekst nie określa przezna-
czenia tego literału.

87469504f326f0d7c1fcda56ef61bd79
8
14.6. Argumenty nazwane w dowolnym miejscu listy argumentów (C# 7.2) 483

Oto ilustracja tego rozwiązania: niedawno pisałem przykłady na potrzeby biblio-


teki klienckiej BigQuery. Przy przesyłaniu pliku CSV do tej biblioteki można określić
schemat, pozwolić serwerowi ustalić schemat lub pobrać już istniejący schemat z tabeli.
W trakcie pisania przykładu ilustrującego automatyczne określanie schematu chciałem
jednoznacznie pokazać, że można podać referencję null jako parametr schema. Gdy
kod jest napisany w najprostszej (choć nie w pełni zrozumiałej) formie, znaczenie argu-
mentu null nie jest oczywiste:
client.UploadCsv(table, null, csvData, options);

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);

W niektórych sytuacjach może to pomóc w rozróżnianiu wersji przeciążonej metody,


gdy argument (zwykle null) z tej samej pozycji może być w poszczególnych wersjach
przekształcany w różny sposób.
Reguły dotyczące argumentów nazwanych w dowolnym miejscu listy argumentów
zostały starannie zaprojektowane, aby uniknąć wieloznaczności dalszych argumentów
pozycyjnych. Jeśli po argumencie nazwanym występują jakiekolwiek argumenty bez
nazw, argument nazwany musi odpowiadać temu samemu parametrowi jak w sytuacji,
gdyby był prostym argumentem pozycyjnym. Rozważ np. poniższą deklarację metody
i trzy jej wywołania:
void M(int x, int y, int z){}
Poprawne — końcowe argumenty
M(5, z: 15, y: 10); nazwane w odmiennej kolejności.
M(5, y: 10, 15); Poprawne — niekońcowy argument nazwany w pierwotnej kolejności.
M(y: 10, 5, 15); Niepoprawne — niekońcowy argument nazwany w odmiennej kolejności.

Pierwsze wywołanie jest poprawne, ponieważ występuje tu jeden argument pozycyjny,


po którym następują dwa argumenty nazwane. Oczywiste jest, że argument pozycyjny
odpowiada parametrowi x, a dwa pozostałe argumenty są nazwane. Sytuacja jest jed-
noznaczna.
Drugie wywołanie jest prawidłowe, bo choć występuje tu argument nazwany, po
którym znajduje się argument pozycyjny, to argument nazwany odpowiada temu samemu
parametrowi (y) co w zapisie pozycyjnym. Także tu oczywiste jest, jaką wartość powi-
nien przyjąć każdy parametr.
Trzecie wywołanie jest błędne. Pierwszy argument jest nazwany, ale odpowiada
drugiemu parametrowi (y). Czy drugi argument odpowiada pierwszemu parametrowi (x),
ponieważ jest to pierwszy argument bez nazwy? Choć reguły mogłyby działać w ten

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.

14.7. Dostęp private protected (C# 7.2)


Kilka lat temu dostęp private protected miał zostać dodany w C# 6 (a możliwe, że
zespół projektujący C# planował wprowadzić to rozwiązanie jeszcze wcześniej).
Jednak pojawił się problem z nazwą. Do czasu pojawienia się wersji C# 7.2 zespół
uznał, że nie znajdzie lepszej nazwy niż private protected. To połączenie modyfikato-
rów dostępu jest bardziej restrykcyjne niż protected lub internal. Składowe private
protected są dostępne tylko w kodzie, który znajduje się w tym samym podzespole
i jednocześnie w klasie pochodnej od klasy z deklaracją tej składowej (lub w tym samym
typie).
Porównaj to z modyfikatorem protected internal, który jest mniej restrykcyjny niż
protected lub internal. Dostęp do składowych protected internal jest możliwy w kodzie
z tego samego podzespołu lub w kodzie z klasy pochodnej od klasy z deklaracją tej
składowej (lub w tym samym typie).
To wszystko, co można napisać na ten temat. Nieuzasadnione jest nawet przedsta-
wianie przykładu. Warto mieć tę możliwość, aby język był kompletny, ponieważ dziwne
było, że istniał poziom dostępu możliwy do zastosowania w środowisku CLR, ale nie
w języku C#. We własnym kodzie zastosowałem ten poziom dostępu tylko raz i nie
spodziewam się, że w przyszłości okaże się on dla mnie dużo bardziej przydatny. Na
zakończenie rozdziału omawiam kilka technik, które nie pasowały do żadnego innego
podrozdziału.

14.8. Drobne usprawnienia z C# 7.3


W tym rozdziale i wcześniej w książce przekonałeś się już, że zespół projektujący
C# nie zakończył prac nad C# 7 w momencie udostępnienia C# 7.0. Wprowadzane
były dalsze usprawnienia, głównie w celu rozbudowania mechanizmów dodanych
w C# 7.0. Tam, gdzie było to możliwe, omówiłem nowe szczegóły razem z ogólnym
opisem funkcji. Jednak kilka technik z C# 7.3 nie pasowało do żadnego z tych opisów,
a także do ogólnego motywu tego rozdziału, czyli do ułatwiania pisania zwięzłego
kodu. Jednak pominięcie tych mechanizmów byłoby niewłaściwe.

14.8.1. Ograniczenia typów generycznych


Gdy pokrótce omawiałem ograniczenia typów w punkcie 2.1.5, pominąłem kilka z nich.
W wersjach sprzed C# 7.3 w ograniczeniu typu nie można było zapisać, że argument
określający typ musi dziedziczyć po typie Enum lub Delegate. Obecnie te restrykcje
zniesiono i dodano nowy rodzaj ograniczeń: unmanaged. Na listingu 14.12 pokazane są
przykłady podawania i używania takich ograniczeń.

87469504f326f0d7c1fcda56ef61bd79
8
14.8. Drobne usprawnienia z C# 7.3 485

Listing 14.12. Nowe ograniczenia w C# 7.3

enum SampleEnum {}
static void EnumMethod<T>() where T : struct, Enum {}
static void DelegateMethod<T>() where T : Delegate {}
static void UnmanagedMethod<T>() where T : unmanaged {}
...

EnumMethod<SampleEnum>(); Poprawne — wyliczeniowy typ bezpośredni.


EnumMethod<Enum>(); Niepoprawne — niezgodne z ograniczeniem struct.

DelegateMethod<Action>();
DelegateMethod<Delegate>(); Wszystkie poprawne (niestety).
DelegateMethod<MulticastDelegate>();

UnmanagedMethod<int>(); Poprawne — System.Int32 to typ niezarządzany.


UnmanagedMethod<string>(); Niepoprawne — System.String to typ zarządzany.

Użyłem tu ograniczenia where T : struct, Enum jako ograniczenia typu wyliczenio-


wego, ponieważ prawie zawsze to ograniczenie stosuje się w ten sposób. T musi wtedy
być prawdziwym typem wyliczeniowym — typem bezpośrednim pochodnym od Enum.
Ograniczenie struct wyklucza używanie samego typu Enum. Jeśli próbujesz napisać
metodę, która działa dla dowolnego typu wyliczeniowego, zwykle nie chcesz uwzględ-
niać Enum, ponieważ nie jest to tak naprawdę typ wyliczeniowy. Niestety, jest już zde-
cydowanie za późno, aby dodawać te ograniczenia do różnych metod przetwarzających
wyliczenia w platformie.
Dla delegatów niestety nie istnieje analogiczne ograniczenie. Nie ma sposobu na
zapisanie ograniczenia „tylko typy zadeklarowane jako delegaty”. Możesz zastosować
ograniczenie where T : MulticastDelegate, jednak jako argument określający typ można
zastosować sam typ MulticastDelegate.
Ostatnie ograniczenie dotyczy typów niezarządzanych. Wspomniałem już o nich
wcześniej. Typ niezarządzany to niegeneryczny typ bezpośredni nieprzyjmujący war-
tości null, którego pola nie mogą być typu referencyjnego (ten ostatni warunek dotyczy
rekurencyjnie samych pól). Większość typów bezpośrednich w platformie (np. Int32,
Double, Decimal, Guid) to typy niezarządzane. Przykładowy typ bezpośredni, który nie
należy do tej grupy, to ZonedDateTime z biblioteki Noda Time. Nie jest to typ niezarzą-
dzany, ponieważ zawiera referencję do obiektu typu DateTimeZone.

14.8.2. Usprawnienia w wyborze wersji przeciążonych metod


Reguły związane z wyborem wersji przeciążonych metod były stale modyfikowane,
zwykle w trudny do objaśnienia sposób. Jednak zmiana z C# 7.3 jest przydatna i prosta.
Kilka warunków, które były sprawdzane po zakończeniu wyboru wersji metody, jest
teraz badanych wcześniej. Niektóre wywołania uznawane w starszych wersjach za
wieloznaczne lub błędne są obecnie dozwolone. Oto wspomniane warunki:
 Argumenty określające typ w typach generycznych muszą być zgodne z ograni-
czeniami nałożonymi na parametry określające typ.

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.

W ramach przykładu obrazującego pierwszy scenariusz rozważ następujące wersje


metody:
static void Method<T>(object x) where T : struct => Metoda z ograniczeniem struct.
Console.WriteLine($"{typeof(T)} jest strukturą");

static void Method<T>(string x) where T : class => Metoda z ograniczeniem class.


Console.WriteLine($"{typeof(T)} jest typu referencyjnego");
...
Method<int>("tekst");

We wcześniejszych wersjach języka C# w trakcie wyboru wersji metody ograniczenia


dotyczące parametrów określających typ były ignorowane. Kompilator wybrałby drugą
wersję, ponieważ string jest bardziej specyficznym typem parametru niż object, a następ-
nie odkryłby, że podany argument określający typ (int) narusza ograniczenie typu.
W C# 7.3 ten kod kompiluje się bez błędów i wieloznaczności, ponieważ ograni-
czenie typu jest sprawdzane w ramach wyszukiwania prawidłowej metody. Inne testy
przebiegają podobnie — kompilator szybciej niż wcześniej odrzuca wersje, które
byłyby nieprawidłowe w danym wywołaniu. Przykłady ilustrujące wszystkie trzy sce-
nariusze znajdziesz w dołączonym do książki kodzie źródłowym.

14.8.3. Atrybuty pól powiązanych z automatycznie


implementowanymi właściwościami
Załóżmy, że chcesz utworzyć prostą właściwość powiązaną z polem, a dodatkowo musisz
zastosować do tego pola atrybut, aby umożliwić działanie innych technik. Przed wersją
C# 7.3 wymagało to zadeklarowania pola osobno i napisania prostej właściwości
z szablonowym kodem. Załóżmy, że chcesz dodać (fikcyjny) atrybut DemoAttribute
do pola powiązanego z właściwością typu string. Wcześniej wymagało to następują-
cego kodu:
[Demo]
private string name;
public string Name
{
get { return name; }
set { name = value; }
}

Jest to irytujące, ponieważ automatycznie implementowane właściwości wykonują


prawie wszystkie potrzebne działania. W C# 7.3 można bezpośrednio podać atrybut
pola w automatycznie implementowanej właściwości:
[field: Demo]
public string Name { get; set; }

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

W czasie, gdy powstaje ta książka, C# 8 wciąż jest na etapie projektowania. W repozy-


torium w serwisie GitHub można zobaczyć wiele potencjalnych mechanizmów, jednak
tylko część z nich znalazła się w publicznie dostępnych wersjach zapoznawczych kom-
pilatora. Ten rozdział zawiera hipotezy. Wszystko, co jest tu napisane, może się zmienić.
Prawie niemożliwe jest, by wszystkie omawiane tu mechanizmy znalazły się w C# 8,
a i tak ograniczyłem się tylko do rozwiązań, które moim zdaniem mają na to duże
szanse. Najbardziej szczegółowo opisałem funkcje dostępne w wersji zapoznawczej
w czasie, gdy powstaje ta książka. Nie oznacza to jednak, że w tych mechanizmach nie
pojawią się dodatkowe zmiany.

UWAGA. W czasie, gdy powstaje ta książka, tylko nieliczne mechanizmy z C# 8 są dostępne


w wersjach zapoznawczych kompilatora. Ponadto pojawiają się różne wersje z innymi funk-
cjami. Wersja z typami referencyjnymi przyjmującymi wartość null działa tylko w projektach
na kompletną platformę .NET (a nie w projektach dla .NET Core SDK). Dlatego trudniej jest

87469504f326f0d7c1fcda56ef61bd79
8
490 ROZDZIAŁ 15. C# 8 i kolejne wersje

eksperymentować z tym mechanizmem, jeśli wszystkie projekty tworzysz w nowym formacie


.NET Core SDK. Spodziewam się jednak, że w kolejnych wersjach te ograniczenia zostaną
wyeliminowane (prawdopodobnie stanie się tak do czasu, gdy będziesz czytał tę książkę).

Zacznijmy od typów referencyjnych przyjmujących wartość null.

15.1. Typy referencyjne przyjmujące wartość null


No tak, referencje null. Tak zwany kosztujący miliard dolarów błąd, który Tony Hoare
wprowadził w latach 60., za co przeprosił w 2009 r. Trudno jest znaleźć doświadczonego
programistę używającego C#, który nie został przynajmniej kilkakrotnie dotknięty
błędem NullReferenceException. Zespół odpowiedzialny za C# planuje okiełznać refe-
rencje null dzięki bardziej jednoznacznemu określaniu, gdzie należy się ich spodziewać.

15.1.1. Jaki problem rozwiązują typy referencyjne


przyjmujące wartość null?
W ramach przykładu, który będę rozwijał w tym podrozdziale, rozważmy klasy z listingu
15.1. Jeśli śledzisz kod źródłowy dołączony do książki, zobaczysz, że w każdym przy-
kładzie są one zadeklarowane jako odrębne klasy zagnieżdżone, ponieważ ich kod się
zmienia.

Listing 15.1. Początkowy model sprzed wersji C# 8

public class Customer


{
public string Name { get; set; }
public Address Address { get; set; }
}

public class Address


{
public string Country { get; set; }
}

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

Tabela 15.1. Przyjmowanie i nieprzyjmowanie wartości null w typach referencyjnych


i bezpośrednich w C# 7

Przyjmujące wartość null Nieprzyjmujące wartości null


Typy referencyjne Niejawnie Nieobsługiwane
Typy bezpośrednie Nullable<T> lub przyrostek ? Domyślnie

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ć?

15.1.2. Zmiana działania typów referencyjnych


w kontekście wartości null
Na ogólnym poziomie zabezpieczanie się przed wartością null ma wynikać z przyjęcia
założenia, że jeśli programista celowo rozróżnia typy referencyjne przyjmujące wartość
null i nieprzyjmujące jej, domyślnie używana jest wersja nieprzyjmująca null. Wpro-
wadzono nową składnię dla typów referencyjnych przyjmujących wartość null. Teraz
string to typ referencyjny nieprzyjmujący wartości null, a string? to jego odpowiednik
z obsługą wartości null. Tabelka wygląda teraz inaczej, co pokazane jest w tabeli 15.2.
Tabela 15.2. Przyjmowanie i nieprzyjmowanie wartości null w typach referencyjnych
i bezpośrednich w C# 8

Przyjmujące wartość null Nieprzyjmujące wartości null


Typy referencyjne Brak reprezentacji w środowisku CLR; Domyślnie, gdy dostępna jest obsługa
jako adnotacja używany jest przyrostek ? typów referencyjnych przyjmujących null
Typy bezpośrednie Nullable<T> lub przyrostek ? Domyślnie

Wydaje się, że jest to odwrotność ostrożnego podejścia — zmieniane jest znaczenie


całego kodu C# związanego z typami referencyjnymi! Włączenie omawianego mecha-
nizmu powoduje zmianę domyślnego traktowania typów z wersji przyjmującej null
na wersję nieprzyjmującą null. Wynika to z założenia, że istnieje dużo mniej miejsc,
w których referencje null są poprawne, a dużo więcej sytuacji, gdzie takie referencje nie
powinny występować.
Wróćmy do przykładu z klientami i adresami. Jeśli nie zmienisz kodu, kompilator
ostrzeże, że klasy Customer i Address dopuszczają niezainicjalizowanie właściwości nie-
przyjmujących null. Problem można rozwiązać, dodając konstruktory z parametrami
nieprzyjmującymi null. Ilustruje to listing 15.2.
1
Dzień przed napisaniem tego akapitu większość czasu poświęciłem na próby wykrycia źródła usterki
tego rodzaju. Ten problem jest bardzo odczuwalny.

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

public class Customer


{
public string Name { get; set; }
public Address Address { get; set; }

public Customer(string name, Address address) =>


(Name, Address) = (name, address);
}

public class Address


{
public string Country { get; set; }

public Address(string country) =>


Country = country;
}

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.

15.1.3. Poznaj typy referencyjne przyjmujące null


Składnia służąca do określania, że typ referencyjny może przyjmować wartość null,
została tak zaprojektowana, aby była od razu zrozumiała. Jest ona identyczna ze składnią
dla typów bezpośrednich przyjmujących null i wymaga dodania znaku zapytania po
nazwie typu. Tę technikę można stosować w większości miejsc, gdzie stosowane są
typy referencyjne. Rozważ np. następującą metodę:
string FirstOrSecond(string? first, string second) =>
first ?? second;

Z sygnatury tej metody wynika, że:


 first to łańcuch znaków przyjmujący null,
 second to łańcuch znaków nieprzyjmujący null,
 zwracany jest łańcuch znaków nieprzyjmujący null.

87469504f326f0d7c1fcda56ef61bd79
8
15.1. Typy referencyjne przyjmujące wartość null 493

Dalej kompilator wykorzystuje te informacje, aby ostrzegać przed próbami błędnego


użycia wartości, która może przyjąć wartość null. Ostrzeżenie może się pojawić np.
w następujących sytuacjach:
 przy próbie przypisania wartości, która może być równa null, do zmiennej lub
właściwości nieprzyjmującej null,
 przy próbie przekazania wartości, która może być równa null, jako argumentu
dla parametru nieprzyjmującego null,
 przy próbie dereferencji wartości, która może być równa null.

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.

Listing 15.3. Przekształcanie właściwości Address na postać przyjmującą null

public class Customer


{
public string Name { get; set; }
public Address? Address { get; set; } Adres jest teraz opcjonalny.

public Customer(string name) => Parametr address został usunięty z konstruktora.


Name = name;
}

Ś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.

Doskonale! Teraz kompilator identyfikuje pierwotny problem, który skutkował wyjąt-


kiem NullReferenceException. Jak rozwiązać ten problem? Pora przyjrzeć się działaniu
typów referencyjnych przyjmujących null, a nie tylko ich składni.

15.1.4. Działanie typów referencyjnych przyjmujących null


w czasie kompilacji i w czasie wykonywania kodu
Cenną cechą nowych typów jest to, że ich działanie nie zmieniło się w ukryty sposób.
Choć znaczenie kodu jest inne, ponieważ domyślnie zakłada się używanie typów nie-
przyjmujących null, działanie kodu pozostało takie samo. Jedyna różnica zachodzi

87469504f326f0d7c1fcda56ef61bd79
8
494 ROZDZIAŁ 15. C# 8 i kolejne wersje

w czasie kompilacji i dotyczy wyświetlanych ostrzeżeń. Nie są używane żadne nowe


typy. Środowisko CLR nie wykrywa różnicy między typami referencyjnymi przyjmu-
jącymi null i nieprzyjmującymi null. Używane są atrybuty do przekazywania infor-
macji o przyjmowaniu null — i to wszystko. Podobnie jest z dodatkowymi informacjami
na temat nazw elementów krotek, które nie są częścią typu w czasie wykonywania pro-
gramu. Przyjęte rozwiązanie ma dwa ważne skutki:
 Zalecaną praktyką pozostaje programowanie defensywne. W kodzie napisanym
do tej pory właściwość Name może być równa null, ponieważ użytkownik może
zignorować ostrzeżenia lub korzystać z kodu z innego projektu, gdzie stosowany
jest tylko C# 7. Wciąż ważne jest także sprawdzanie poprawności argumentów.
 Aby w pełni opanować omawianą funkcję, trzeba zrozumieć ostrzeżenia wyświe-
tlane przez kompilator. W żadnym razie nie powinieneś ich ignorować — są one
wyświetlane po to, aby pomóc Ci w pracy.

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);

Kompilator słusznie informuje, że jest to niebezpieczne wywołanie, ponieważ właści-


wość customer.Address może być równa null. Dalej opisane są trzy sposoby na popra-
wienie bezpieczeństwa kodu. Po pierwsze możesz zastosować razem operatory ?. i ??,
co jest pokazane na listingu 15.4.

Listing 15.4. Bezpieczne dereferencje z użyciem operatora ?.

Console.WriteLine(customer.Address?.Country ?? "(Adres nieznany)");

Jeśli właściwość customer.Address jest równa null, wyrażenie customer.Address?.Country


nie próbuje przetwarzać właściwości Country, a wynikiem wyrażenia jest null. Wtedy
operator ?? zwraca domyślnie wyświetlaną wartość. Kompilator wykrywa, że nie pró-
bujesz dereferencji wartości null, i przestaje wyświetlać ostrzeżenie.
Ten kod może na razie wydawać Ci się podejrzany. Jeśli nie zachowasz ostrożności,
łatwo zagubisz się w gąszczu znaków zapytania. Sądzę, że w przyszłości programiści
C# chętniej będą stosować podobny kod, jednak nie jest to jedyne dostępne rozwią-
zanie. Możesz też posłużyć się bardziej rozwlekłym, ale prostym do zrozumienia podej-
ściem, które pokazane jest na listingu 15.5.

Listing 15.5. Sprawdzanie referencji z użyciem zmiennej lokalnej

Address? address = customer.Address; Pobieranie adresu do nowej zmiennej lokalnej.


if (address != null)
{ Sprawdzanie wartości null. Dereferencja
Console.WriteLine(address.Country); jest wykonywana tylko dla wartości różnych od null.
}
else
{
Console.WriteLine("(Adres nieznany)");
}

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.

Listing 15.6. Sprawdzanie referencji z powtórzeniem dostępu do właściwości

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

15.1.5. Operator „a niech to”


Dostępna jest dodatkowa składnia, której do tej pory nie omawiałem: operator a niech
to (ang. dammit, damn it lub bang operator)2. Ma ona postać wykrzyknika na końcu
wyrażenia i informuje kompilator, że powinien zignorować wszystko, co wie na temat
wyrażenia, i potraktować je jako różne od null.
Jest to przydatne w dwóch sprzecznych sytuacjach:
 Czasem masz więcej informacji niż kompilator i wiesz, że wartość będzie różna
od null, choć kompilator może uznać, że jest inaczej.
 Czasem chcesz celowo przekazać wartość null, aby przetestować kod do spraw-
dzania poprawności argumentów.

Krótkie przykłady ilustrujące pierwszy z tych punktów są nieco naciągane, ponieważ


programiści zwykle starają się tak uporządkować kod, aby uniknąć takich sytuacji.
W krótkich przykładach prawie zawsze jest to możliwe, jednak w rzeczywistych apli-
kacjach może okazać się trudniejsze. Na listingu 15.7 pokazana jest metoda wyświe-
tlająca długość łańcucha znaków z danymi, które mogą być równe null.

Listing 15.7. Używanie operatora „a niech to” do uspokojenia kompilatora

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”.

Listing 15.8. Używanie operatora „a niech to” w testach jednostkowych

public class Customer


{
public string Name { get; }
public Address? Address { get; }

public Customer(string name, Address? address)


{
Name = name ?? throw new ArgumentNullException(nameof(name));
Address = address;
}
}

public class Address


{
public string Country { get; }

public Address(string country)


{
Country = country ??
throw new ArgumentNullException(nameof(country));
}
}

[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.
}

Na listingu 15.8 dla uproszczenia typy Customer i Address są niemodyfikowalne. Warto


zauważyć, że kompilator nie zgłasza żadnych ostrzeżeń w związku z samym sprawdza-
niem poprawności. Choć wie, że wartość nie powinna być równa null, nie wyświetla
ostrzeżenia, że kod sprawdza tę wartość. Próbuje jednak wymusić to, że gdy wywołasz
konstruktor w teście, pierwszy argument będzie różny od null. We wcześniejszych
wersjach języka C# wyrażenie lambda w teście wyglądałoby tak:
() => new Customer(null, address)

Ten kod generuje ostrzeżenie, co w prawie wszystkich sytuacjach jest wskazane.


Zmiana argument na null! uspokaja kompilator i test działa w oczekiwany sposób. To
rodzi pytanie o korzystanie w praktyce z typów referencyjnych przyjmujących null,
a przede wszystkim o stosowanie omawianego mechanizmu w istniejącym kodzie.

87469504f326f0d7c1fcda56ef61bd79
8
498 ROZDZIAŁ 15. C# 8 i kolejne wersje

15.1.6. Wrażenia z wprowadzania typów referencyjnych


przyjmujących null
Nie istnieje lepszy sposób na zrozumienie działania danego mechanizmu niż wypró-
bowanie go. Używałem wersji zapoznawczej C# 8 do biblioteki Noda Time, aby zoba-
czyć, ile pracy będę musiał włożyć, aby kompilator nie wyświetlał żadnych ostrzeżeń.
Chciałem też sprawdzić, czy kompilator znajdzie jakieś błędy w bibliotece. W tym
punkcie opisuję te doświadczenia, a także prezentuję wskazówki, do których sam się
stosuję. W swoim kodzie możesz natrafić na inne wyzwania, podejrzewam jednak, że
pojawi się wiele wspólnych punktów.
UŻYWANIE ATRYBUTÓW DO DOPUSZCZANIA WARTOŚCI NULL
W WERSJACH PRZED C# 8
Przez długi czas używałem w bibliotece Noda Time atrybutów (przynajmniej we wszyst-
kich metodach publicznych), aby informować, czy parametry typów referencyjnych
mogą przyjmować null i czy zwracane wartości mogą być równe null. Oto przykła-
dowa sygnatura metody z interfejsu IDateTimeZoneProvider:
[CanBeNull] DateTimeZone GetZoneOrNull([NotNull] string id);

To pokazuje, że argument odpowiadający parametrowi id nie może być równy null,


jednak sama metoda może zwracać referencję null. Zapisałem w ten sposób oczekiwania
co do wartości null, ale nie w sposób zrozumiały dla kompilatora C#. To oznacza, że
w pierwszym kroku muszę znaleźć w kodzie wszystkie miejsca, w których zapisałem
możliwość używania null, i zastosować w tych miejscach typy referencyjne przyjmu-
jące null.
Używam adnotacji obsługiwanych w narzędziu ReSharper firmy JetBrains. Dzięki
temu to narzędzie potrafi wykonywać testy tego samego rodzaju co C# 8 w języku
C#. Nie będę omawiał tu tych adnotacji, wspomnę tylko, że są dostępne. Jednak w ogóle
nie musisz stosować adnotacji niezależnych firm. Możesz łatwo przygotować własne
atrybuty i od razu zacząć ich używać. Nawet bez wsparcia ze strony narzędzi uprości Ci
to konserwację kodu i będziesz mógł łatwiej zastosować w przyszłości wprowadzone
w C# 8 typy referencyjne przyjmujące null.
NATURALNA JEST ITERACYJNA PRACA
Po tym pierwszym kroku zobaczyłem ok. 100 ostrzeżeń. Sprawdziłem je, poprawiłem
większość usterek i ponownie skompilowałem kod. Po tym drugim kroku pojawiło się
ok. 110 ostrzeżeń — więcej niż wcześniej! Poprawiłem większość usterek i jeszcze
raz skompilowałem kod. Po tym trzecim etapie nadal zgłoszonych zostało ok. 100 ostrze-
żeń. Sprawdziłem je, poprawiłem kod i ponownie skompilowałem bibliotekę.
Nie pamiętam, ile iteracji musiałem wykonać, nie oznacza to jednak, że coś jest nie
tak. Proces dostosowywania kodu bazowego do typów referencyjnych przyjmujących
null przypomina zabawę w chowanego. Decydujesz się zmienić traktowanie wartości
null w jednym miejscu, a może to spowodować ostrzeżenia wszędzie tam, gdzie dana
wartość jest używana. Wprowadzasz poprawki w tych miejscach, a problem pojawia
się znów gdzie indziej. Decyzje związane z wartościami null rozprzestrzeniają się po
kodzie i wymagają starannego sprawdzenia. Jest to zupełnie normalne.

87469504f326f0d7c1fcda56ef61bd79
8
15.1. Typy referencyjne przyjmujące wartość null 499

Jeśli jednak w jednym fragmencie kodu wartość ma przyjmować null, a w innym


miejscu null ma być nieakceptowalne, pojawia się problem. Ten kłopot nie został
wprowadzony w C# 8. Nowe możliwości jedynie go uwidoczniły. Sposób radzenia
sobie z nim zależy od kontekstu.
ZALECENIA DOTYCZĄCE UŻYWANIA OPERATORA „A NIECH TO”
Jeśli musisz korzystać z operatora „a niech to” w kodzie produkcyjnym, dodaj komen-
tarz wyjaśniający, dlaczego go używasz. Jeśli stosujesz wygodny format umożliwiający
wyszukiwanie (np. komentarze ze słowem NULLABLEREF), będziesz mógł później znaleźć
takie miejsca. Możliwe, że po kolejnych usprawnieniach narzędzi będziesz mógł zre-
zygnować z omawianego operatora. Nie chcę przez to powiedzieć, że korzystanie
z tego operatora jest niewłaściwe, jednak operator ten wymaga założenia, że masz
większą wiedzę niż kompilator, a ja wolę nie ufać sobie w aż takim stopniu.
Omawianego operatora używałem częściej w kodzie testów — przede wszystkim
do testowania sprawdzania poprawności w sposób pokazany w poprzednim punkcie.
Ponadto jeśli oczekuję, że wartość będzie różna od null (ponieważ w ten sposób
przygotowałem test), zwykle zmuszam kompilator do przyjęcia tego, zwłaszcza jeśli
wiem, że wartość będzie sprawdzana w później wywoływanym kodzie. Jeśli popełniłem
błąd, test powinien zakończyć się niepowodzeniem i zgłoszeniem wyjątku Argument
NullException lub NullReferenceException. Jest to właściwe, ponieważ i tak wiem
wtedy, że moje założenia były nieprawidłowe. Można stwierdzić, że kod testów powi-
nien zwykle być mniej defensywny od kodu produkcyjnego. Zamiast próbować obsłu-
giwać nieoczekiwane sytuacje w sprawny sposób, można pozwolić na niepowodzenie.
TYPY GENERYCZNE NIESPÓJNE ZE WZGLĘDU NA WARTOŚCI NULL
Stwierdziłem, że czymś dziwnym jest implementowanie w bibliotece Noda Time inter-
fejsu IEqualityComparer<T> w typach referencyjnych, ponieważ został on zdefiniowany
na długo przed wprowadzeniem typów referencyjnych przyjmujących null. Metody
Equals i GetHashCode są zdefiniowane z użyciem parametrów typu T, są jednak niespójne
ze względu na obsługę wartości null. Metoda Equals obsługuje wartości null, jednak
GetHashCode zgłasza dla nich wyjątek ArgumentNullException.
Nie jest jasne, jak zapisać to w implementacji. Jeśli chcę porównywać obiekty klasy
Period, powinienem zaimplementować interfejs IEqualityComparer<Period?>, aby dopu-
ścić argumenty równe null, czy może interfejs IEqualityComparer<Period> i zakazać
takich argumentów? W obu sytuacjach jednostki wywołujące mogą zostać zaskoczone
w czasie kompilacji lub wykonywania programu.
Nawet pomijając kwestie związane z implementacją, nie jest dla mnie jasne, jak
umożliwić bardziej precyzyjny opis traktowania wartości null w samym interfejsie.
Możliwe, że potrzebne będą dodatkowe prace nad projektem języka, aby umożliwić zapis
sposobu obsługi parametrów określających typ w typach generycznych. Samo użycie T?
w interfejsie wydaje się złym rozwiązaniem, ponieważ nie chciałbyś akceptować typu
Nullable<T>, gdy T jest typem bezpośrednim.

87469504f326f0d7c1fcda56ef61bd79
8
500 ROZDZIAŁ 15. C# 8 i kolejne wersje

Choć ja zetknąłem się z problemem w związku z interfejsem IEqualityComparer<T>,


podejrzewam, że kłopot będzie dotyczył także innych interfejsów, a nawet klas gene-
rycznych. Wspominam o tym tylko po to, abyś po natrafieniu na taką sytuację nie myślał,
że popełniłeś jakiś błąd.
EFEKT KOŃCOWY
Kod bazowy biblioteki Noda Time nie jest bardzo długi, ale nie jest też krótki. Cały
proces zajął mi ok. pięciu godzin, włącznie z diagnozowaniem błędu w wersji zapo-
znawczej kompilatora Roslyn. Ostatecznie znalazłem w bibliotece Noda Time błąd
(obecnie już naprawiony) związany z niespójną obsługą dziwnej sytuacji, w której
wywołanie TimeZoneInfo.Local zwraca wartość null w niektórych środowiskach z plat-
formą Mono. Natrafiłem też na kilka miejsc, gdzie brakowało adnotacji, i musiałem
jednoznacznie opisać przeznaczenie niektórych składowych wewnętrznych.
Byłem zadowolony z efektów. Świadomość tego, że kompilator sprawdza spójność
kodu, zwiększa moje przekonanie co do poprawności programu. Dodatkowo po opu-
blikowaniu wersji biblioteki Noda Time zbudowanej z użyciem kompilatora C# 8
każdy, kto używa biblioteki razem z tą wersją języka, może korzystać z dodatkowych
informacji. Dzięki temu więcej błędów stanie się widocznych już na etapie kompilacji,
a nie w czasie wykonywania programu, co zwiększa zaufanie użytkowników do tej biblio-
teki. Nowa wersja jest więc korzystna dla wszystkich.
Te doświadczenia dotyczyły wersji zapoznawczej z pierwszej połowy 2018 r. Nie
jest to jednak końcowy stan procesu projektowania i implementowania języka. Zajrzyjmy
spekulacyjnie w przyszłość.

15.1.7. Przyszłe usprawnienia


W czerwcu 2018 r. rozmawiałem na konferencjach i spotkaniach grup użytkowników
z Madsem Torgersenem, kierownikiem zespołu projektującego C#. Podróżowałem z listą
oczekiwanych funkcji i problemów opracowaną na podstawie moich doświadczeń
z biblioteką Noda Time. Odpowiedzi Madsa pozwalają mi wierzyć, że funkcje te staną
się w przyszłości dostępne.
Zespół rozwijający C# jest świadomy, że już dostępna wersja zapoznawcza języka
nie jest jeszcze gotowa do powszechnego wdrożenia. Kilka rzeczy wymaga dodatkowej
pracy, jednak udostępniona wersja pozwala zespołowi szybko otrzymać opinie. Wymie-
nione tu zmiany nie będą jedyne, jednak to one interesują mnie najbardziej.
ZAPEWNIANIE KOMPILATOROWI
DODATKOWYCH INFORMACJI SEMANTYCZNYCH
Gdy w punkcie 15.1.5 przedstawiałem operator „a niech to”, pokazałem, że kompilator
nie rozumie znaczenia wywołania string.IsNullOrEmpty. Nie potrafi wywnioskować,
że jeśli ta metoda zwraca false, dane wejściowe nie mogą być równe null. Nie jest to
jedyna sytuacja, w której powiązanie danych wejściowych z wyjściowymi powinno
pomóc kompilatorowi. Oto trzy przykłady, które na pozór powinny kompilować się bez
ostrzeżeń (dla kompletności ponownie używana jest metoda string.IsNullOrEmpty):

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.

Są to przykłady powiązań danych wejściowych z wyjściowymi, czego na razie nie da


się zapisać. Podejrzewam, że większości zastosowań operatora „a niech to” z wersji
zapoznawczej kompilatora można byłoby uniknąć, gdyby kompilator rozumiał wymie-
nione zależności. W jaki sposób kompilator mógłby zdobywać takie dodatkowe infor-
macje?
Jedną z technik, które mogłyby zadziałać w wymienionych przykładach, jest zapi-
sywanie na stałe informacji w kompilatorze. Byłoby to łatwe dla zespołu projektują-
cego C#, ale mało atrakcyjne pod innymi względami. Biblioteki platformy byłyby wtedy
traktowane inaczej niż biblioteki niezależnych producentów, co byłoby irytujące. Chciał-
bym móc zapisać omawiane zależności np. w bibliotece Noda Time, aby była wygod-
niejsza w użyciu.
Możliwe, że zespół rozwijający C# zaprojektuje cały nowy minijęzyk, który można
stosować w atrybutach, aby przekazywać kompilatorowi dodatkowe semantyczne infor-
macje potrzebne do trafniejszego oceniania, czy daną wartość należy uznać za z pew-
nością różną od null. Zaprojektowanie i zaimplementowanie tego rozwiązania będzie
wymagać dużo pracy, ale powstanie dzięki temu dużo bardziej kompletne rozwiązanie.
SZCZEGÓŁOWE PRZEMYŚLENIA NA TEMAT TYPÓW GENERYCZNYCH
Typy generyczne oznaczają interesujące wyzwania w zakresie projektowania obsługi
wartości null. O jednym przykładzie wspomniałem w kontekście implementowania
interfejsu IEqualityComparer<T>, jednak problem zdecydowanie wykracza poza to zagad-
nienie. Rozważ następującą prostą klasę, która jest poprawna w C# 7:

87469504f326f0d7c1fcda56ef61bd79
8
502 ROZDZIAŁ 15. C# 8 i kolejne wersje

public class Wrapper<T>


{
public T Value { get; set; }
}

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

Jedna z rozważanych technik ma pozwalać dodać wykrzyknik po nazwie parametru,


aby zaznaczyć, że kompilator powinien generować na początku metody kod spraw-
dzający, czy wartość tego parametru jest różna od null. Rozważ metodę, która obecnie
może wyglądać tak:
static void PrintLength(string text)
{
string validated =
text ?? throw new ArgumentNullException(nameof(text));
Console.WriteLine(validated.Length);
}

Zamiast tego mógłbyś zapisać ją w następujący sposób:


static void PrintLength(string text!) Automatycznie sprawdzanie,
{ czy parametr jest różny od null.
Console.WriteLine(text.Length);
}

Możliwe, że we właściwościach dostępne będzie takie samo automatyczne sprawdzanie


poprawności.
UMOŻLIWIANIE SPRAWDZANIA, CZY TYP PRZYJMUJE NULL
W wersji zapoznawczej, z której korzystałem, sprawdzanie, czy typ przyjmuje null,
było domyślnie aktywne. Choć możesz w standardowy sposób zablokować ostrzeżenia
związane z null, możliwe, że w ostatecznej wersji kompilator C# 8 będzie udostępniał
bardziej wyrafinowane ustawienia. Zarządzanie sprawdzaniem null wymaga uwzględ-
nienia wielu różnych scenariuszy.
Gdy programiści przechodzą na kompilator C# 8, zapewne nie chcą otrzymywać
żadnych nowych ostrzeżeń. Jest to ważne zwłaszcza wtedy, gdy w ustawieniach projektu
ostrzeżenia są traktowane jak błędy. Podejrzewam, że oznacza to, iż sprawdzanie przyj-
mowania null będzie domyślnie wyłączone (przynajmniej dla już istniejących projektów).
Nie wszystkie biblioteki klas zaczną jednocześnie obsługiwać C# 8. Ważne jest,
aby w kodzie używającym C# 8 i z włączonym sprawdzaniem przyjmowania null
można było korzystać z bibliotek ze starszych wersji języka. Prawdopodobnie będzie
to wymagało ograniczenia ilości zgłaszanych błędów. Na przykład kompilator mógłby
traktować wszystkie dane wejściowe biblioteki jako przyjmujące null, a wszystkie dane
wyjściowe z biblioteki jako nieprzyjmujące null. Ponadto potrzebny będzie sposób na
oznaczenie w bibliotece, że została dostosowana do nowej wersji języka.
Gdy programiści zdecydują się zmodyfikować projekt i zastosować typy referen-
cyjne przyjmujące null, mogą chcieć zrobić to w kilku etapach. Możliwe, że kod zawiera
wygenerowany kod, którego nie da się łatwo zmodyfikować pod kątem opisu, czy typy
przyjmują null. Z tego wynika, że przydatna byłaby możliwość zapisywania dla kon-
kretnych typów, czy programista określił w nich możliwość przyjmowania null.
Te rozważania są nowością w C#. Nigdy wcześniej nie dodano funkcji, która mia-
łaby tak duży wpływ na zgodność ze starszymi wersjami. Podejrzewam, że zespół kil-
kakrotnie zmodyfikuje ten mechanizm przed ostatecznym wprowadzeniem C# 8.

87469504f326f0d7c1fcda56ef61bd79
8
504 ROZDZIAŁ 15. C# 8 i kolejne wersje

Typy referencyjne przyjmujące null będą zapewne najważniejszą funkcją w C# 8,


jednak w wersji zapoznawczej dostępne są już także inne mechanizmy. Jednym z moich
ulubionych są wyrażenia switch.

15.2. Wyrażenia switch


Wyrażenia switch były dostępne w C# od samego początku. Jedyna ich modyfikacja
polegała na dodaniu obsługi dopasowywania wzorców w C# 7. Instrukcja switch pozo-
staje imperatywną strukturą sterowania przepływem. Jeśli jedna klauzula case pasuje
do danych, należy wykonać jedne operacje. Jeżeli inna klauzula case pasuje, urucha-
miane są inne działania. Jednak instrukcje switch często są stosowane w bardziej funk-
cyjnym stylu, gdzie w każdej klauzuli case określany jest wynik: jeśli do danych pasuje
jedna klauzula case, wynik to X; jeżeli do danych pasuje druga klauzula case, wynik
to Y. Jest to podejście często stosowane w językach funkcyjnych, gdzie wiele funkcji jest
zapisywanych z użyciem dopasowywania wzorców.
Wprowadzenie składowych z ciałem w postaci wyrażenia sprawiło, że tradycyjne
instrukcje switch stały się uciążliwe. Wiele metod można zaimplementować z użyciem
jednego wyrażenia, jeśli jednak chcesz zastosować strukturę switch/case, ciało metody
musi mieć postać bloku. Zwykle jest to tylko niedogodnością, jednak może być ona
uciążliwa.
W C# 8 wprowadzone zostały wyrażenia switch, które są alternatywą dla instrukcji
switch. Używana jest tu nieco odmienna składnia niż w instrukcjach switch, warto jed-
nak porównać obie te techniki. W rozdziale 12., gdzie przedstawiłem dopasowywanie
wzorców, znalazł się przykład ilustrujący obliczanie obwodu różnych figur z użyciem
instrukcji switch. Oto kod z rozdziału 12.:
static double Perimeter(Shape shape)
{
switch (shape)
{
case null:
throw new ArgumentNullException(nameof(shape));
case Rectangle rect:
return 2 * (rect.Height + rect.Width);
case Circle circle:
return 2 * PI * circle.Radius;
case Triangle triangle:
return triangle.SideA + triangle.SideB + triangle.SideC;
default:
throw new ArgumentException(
$"Nieznany obwód dla typu {shape.GetType()}",
nameof(shape));
}
}

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

Listing 15.9. Przekształcanie instrukcji switch na wyrażenie switch

static double Perimeter(Shape shape)


{
return shape switch
{
null =>throw new ArgumentNullException(nameof(shape)),
Rectangle rect =>2 * (rect.Height + rect.Width),
Circle circle =>2 * PI * circle.Radius,
Triangle triangle =>
triangle.SideA + triangle.SideB + triangle.SideC,
_ =>throw new ArgumentException(
$"Nieznany obwód dla typu {shape.GetType()}",
nameof(shape))
};
}

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:

 Składnia tworzenia wyrażenia switch to value switch zamiast switch (value).


 Między wzorcem a wynikiem, który kod zwraca, gdy wzorzec pasuje do danych,
znajduje się gruba strzałka =>. W instrukcji switch używany jest do tego dwu-
kropek.
 W wyrażeniach switch w ogóle nie używa się słowa kluczowego case. Po lewej
stronie symbolu => znajduje się tylko wzorzec z opcjonalną klauzulą zabezpie-
czającą po słowie kluczowym when.
 Po prawej stronie symbolu => umieszczone jest tylko wyrażenie. Słowo kluczowe
return nie jest używane, ponieważ każdy wzorzec albo zwraca wartość, albo zgłasza
wyjątek. W nowej wersji nigdzie nie ma też instrukcji break.
 Wzorce są rozdzielone przecinkami. Jeśli przekształcasz instrukcję switch
w wyrażenie switch, zwykle wymaga to przekształcenia średników na przecinki.
 Nie ma tu klauzuli default. Zamiast tego używany jest symbol podkreślenia _ do
dopasowania wszystkich niedopasowanych wcześniej wartości.

Ja najczęściej pisałem metody, które bezpośrednio zwracały wynik wyrażenia switch.


Możesz jednak korzystać z takich wyrażeń w taki sam sposób jak z dowolnych innych.
Możesz np. napisać następujący kod:
double circumference = shape switch
{
Ciało wyrażenia switch (takie jak wcześniej).
};

To poprawny kod, jednak — jak wspomniałem wcześniej — jednym z najbardziej


atrakcyjnych aspektów wyrażeń switch jest to, że można ich używać w metodach
z ciałem w postaci wyrażenia. Na listingu 15.10 pokazane jest, jak przekształcić kod
z listingu 15.9 w metodę z ciałem w postaci wyrażenia.

87469504f326f0d7c1fcda56ef61bd79
8
506 ROZDZIAŁ 15. C# 8 i kolejne wersje

Listing 15.10. Używanie wyrażenia switch do zaimplementowania metody z ciałem


w postaci wyrażenia

static double Perimeter(Shape shape) =>


shape switch
{
null => throw new ArgumentNullException(nameof(shape)),
Rectangle rect => 2 * (rect.Height + rect.Width),
Circle circle => 2 * PI * circle.Radius,
Triangle triangle =>
triangle.SideA + triangle.SideB + triangle.SideC,
_ => throw new ArgumentException(
$"Nieznany obwód dla typu {shape.GetType()}",
nameof(shape))
};

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.

15.3. Rekurencyjne dopasowywanie wzorców


Warto przypomnieć, że w C# 7 wprowadzone zostały następujące wzorce:
 wzorce typów (expression is Type t),
 wzorce stałych (expression is 10, expression is null itd.),
 wzorzec var (expression is var v).

W C# 8 wprowadzone zostaną wzorce rekurencyjne (czyli możliwe będzie zagnież-


dżanie wzorców w większych wzorcach), a także wzorce oparte na podziale. Najprostszy
sposób na objaśnienie wzorców rekurencyjnych to pokazanie ich działania. Do wzorców
opartych na podziale wrócimy dalej.

87469504f326f0d7c1fcda56ef61bd79
8
15.3. Rekurencyjne dopasowywanie wzorców 507

15.3.1. Dopasowywanie z użyciem właściwości we wzorcach


Aby dopasować właściwości z użyciem dodatkowych wzorców zapisanych w ogólnym
wzorcu, należy posłużyć się nawiasami klamrowymi zawierającymi rozdzieloną przecin-
kami listę wzorców odpowiadających właściwościom. Wzorce właściwości pozwalają
dopasować wartości właściwości do zagnieżdżonego wzorca z użyciem dowolnych
standardowych rodzajów wzorców. W ramach przykładu przyjrzyj się trzem wzorcom
używanym na listingu 15.10 do określania powierzchni prostokątów, kół i trójkątów:
Rectangle rect => 2 * (rect.Height + rect.Width),
Circle circle => 2 * PI * circle.Radius,
Triangle triangle => triangle.SideA + triangle.SideB + triangle.SideC,

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.

Listing 15.11. Dopasowywanie wzorców zagnieżdżonych

static double Perimeter(Shape shape) => shape switch


{
null => throw new ArgumentNullException(nameof(shape)),
Rectangle { Height: var h, Width: var w } => 2 * (h + w),
Circle { Radius: var r } => 2 * PI * r,
Triangle { SideA: var a, SideB: var b, SideC: var c } => a + b + c,
_ => throw new ArgumentException(
$"Nieznany obwód dla typu {shape.GetType()}", nameof(shape))
};

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.

15.3.2. Wzorce oparte na podziale


Z podziałem krotek zapoznałeś się w podrozdziale 12.1, a podział z użyciem metody
Deconstruct jest opisany w podrozdziale 12.2. Wzorce w C# 8 zostaną tak rozbudo-
wane, aby możliwy był podział z użyciem wzorców zagnieżdżonych. W ramach dość

87469504f326f0d7c1fcda56ef61bd79
8
508 ROZDZIAŁ 15. C# 8 i kolejne wersje

naciąganego przykładu możesz stwierdzić, że naturalny jest podział trójkąta (obiektu


typu Triangle) na trzy boki:
public void Deconstruct
(out double sideA, out double sideB, out double sideC) =>
(sideA, sideB, sideC) = (SideA, SideB, SideC);

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

można zastosować taki zapis:


Triangle (var a, var b, 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.

15.3.3. Pomijanie typów we wzorcach


Możliwość zaglądania do wnętrza obiektów sprawia, że wzorce są przydatne także
dla typów, które nie są typami bezpośrednimi. W tym kontekście podawanie typów
w ramach wzorca wydaje się zbędne. Wróćmy np. do przykładu z klientami i adresami
używanego do opisu typów referencyjnych przyjmujących null. Przyjrzyj się pierwszemu
modelowi danych, gdzie wszystkie elementy są modyfikowalne i przyjmują null:
public class Customer
{
public string Name { get; set; }
public Address Address { get; set; }
}

public class Address


{
public string Country { get; set; }
}

Teraz załóżmy, że chcesz pozdrawiać klientów w różny sposób w zależności od poda-


nego w adresie kraju. Dane wejściowe mogą być typu Customer, dzięki czemu nie trzeba
go podawać we wzorcu. Jeśli dopasujesz adres klienta we wzorcu na podstawie obiektu
typu Address, zawsze używany będzie wynik typu Address. Nie trzeba więc podawać także
tego typu.
Na listingu 15.12 pokazanych jest kilka wzorców, gdzie dopasowywani są różni
klienci. Używam tu także wzorca { }. Jest to specjalny rodzaj wzorca właściwości, gdzie
nie są podawane żadne właściwości do dopasowania. Ten wzorzec pasuje do każdej
wartości różnej od null.

87469504f326f0d7c1fcda56ef61bd79
8
15.4. Indeksy i przedziały 509

Listing 15.12. Zwięzłe dopasowywanie danych klientów do wielu wzorców

static void Greet(Customer customer)


{
string greeting = customer switch
{ Dopasowywanie kraju
Wielka Brytania.
{ Address: { Country: "Wielka Brytania" } } =>
"Witaj, kliencie z Wielkiej Brytanii!",
{ Address: { Country: "USA" } } => Dopasowywanie kraju USA.
"Witaj, kliencie z USA!",
{ Address: { Country: string country } } => Dopasowywanie dowolnego kraju,
$"Witaj, kliencie z {country}!", który jednak musi być podany.zzz.
{ Address: { } } => Dopasowywanie dowolnego adresu.
"Witaj, kliencie z adresem bez podanego kraju!",
{ } => Dopasowywanie dowolnego klienta (nawet z adresem null).
"Witaj, kliencie o nieznanym adresie!",
_ => Dopasowywanie czegokolwiek (nawet referencji null typu Customer).
"Witaj, null, mój stary przyjacielu!"
};
Console.WriteLine(greeting);
}

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.

15.4. Indeksy i przedziały


W porównaniu z typami referencyjnymi przyjmującymi null i usprawnionym działa-
niem wzorców nowe indeksy i przedziały — nawet wspólnie — wydają się mało istot-
nym mechanizmem. Podejrzewam jednak, że w przyszłości będziemy się zastanawiać,
dlaczego udostępniono je tak późno. Zanim przyjrzymy się szczegółom, na listingu 15.13
przedstawiam przedsmak możliwości.

Listing 15.13. Usuwanie pierwszego i ostatniego znaku z łańcucha za pomocą


przedziału

string quotedText = "'Ten tekst znajdował się między apostrofami'";


Console.WriteLine(quotedText);
Console.WriteLine(quotedText.Substring(1..^1)); Pobieranie podłańcucha z łańcucha
z użyciem literału reprezentującego
przedział.

87469504f326f0d7c1fcda56ef61bd79
8
510 ROZDZIAŁ 15. C# 8 i kolejne wersje

Oto dane wyjściowe:


'Ten tekst znajdował się między apostrofami'
Ten tekst znajdował się między apostrofami

Interesujące jest tu wyróżnione wyrażenie 1..^1. Aby zrozumieć ten kod, musisz poznać
dwa nowe typy.

15.4.1. Typy i literały Index i Range


Pomysł jest prosty — Index i Range to dwie struktury, które będą dostępne w platformie
(na razie trzeba je definiować we własnym kodzie):
 Index to liczba całkowita określająca odległość od początku lub od końca obiektu
umożliwiającego indeksowanie. Wartość indeksu nigdy nie jest ujemna.
 Range to para indeksów. Jeden określa początek, a drugi koniec przedziału.

Ważne są tu trzy elementy składni:


 Standardowa niejawna konwersja z typu int w celu tworzenia indeksu (obiektu
typu Index) określającego odległość od początku danych.
 Nowy operator jednoargumentowy ^, który można stosować razem z typem int
do tworzenia indeksu (obiektu typu Index) oznaczającego odległość od końca
danych. Wartość 0 oznacza element bezpośrednio za końcem danych, a wartość
1 to ostatni element3.
 Nowy operator „prawie dwuargumentowy” .. z opcjonalnymi operandami ozna-
czającymi początek i koniec przedziału. Służy on do tworzenia przedziałów
(obiektów typu Range).

Operator .. jest „prawie dwuargumentowy”, ponieważ może przyjmować zero ope-


randów, jeden operand lub dwa operandy. Na listingu 15.14 pokazane są przykłady
wszystkich trzech sytuacji. Indeksy i przedziały nie są tu stosowane do żadnych danych.
Tworzone są jedynie wartości nowych typów.

Listing 15.14. Literały reprezentujące indeksy i przedziały

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

Warto zwrócić uwagę na jeszcze jedną kwestię — punkty początkowy i końcowy


w przedziale mogą być dowolnymi indeksami. Możesz np. utworzyć przedział ^5..10
reprezentujący elementy od piątego od końca do dziesiątego od początku. Jest to nie-
typowy, ale prawidłowy przedział.
Ważna jest suma bezpośredniego wsparcia dostępnego w języku dla indeksów
i przedziałów. Gdy dodane zostanie wsparcie tych typów w platformie, staną się jeszcze
bardziej przydatne.

15.4.2. Stosowanie indeksów i przedziałów


Wszystkie przykłady z tego punktu wymagają metod rozszerzających i operatorów
rozszerzających dostępnych w wersji zapoznawczej C# 8. Używany tu interfejs API
może ulec zmianie, a rozszerzenia dostępne w wersji zapoznawczej działają tylko dla
ograniczonego zestawu typów. Wystarcza to jednak do zademonstrowania korzyści, jakie
dają nowe typy. Na listingu 15.13 pokazałem, jak użyć metody Substring z obiektem
typu Range. Zarówno indeksy, jak i przedziały będą najczęściej stosowane do typów
reprezentujących jakiegoś rodzaju sekwencję. Oto przykłady:
 tablice,
 obszary,
 łańcuchy znaków (jako sekwencje jednostek kodowych UTF-16).

Dla wszystkich tych typów obsługiwane są dwie operacje:


 pobieranie jednego elementu,
 tworzenie wycinka reprezentującego część sekwencji.

Operacja pobierania jednego elementu ma już standardową reprezentację — należy


użyć indeksera z parametrem typu int. Trudno jednak spójnie pobierać w ten sposób
ostatni element. Typ Index rozwiązuje ten problem, ponieważ umożliwia podawanie
indeksu od początku i od końca sekwencji. Operacja pobierania wycinka wcześniej
przyjmowała różne formy w zależności od używanego typu. Na przykład typ Span<T>
udostępnia metodę Slice, natomiast typ String zawiera metodę Substring.
Dzięki dodaniu wersji indeksera przyjmujących wartości typów Index i Range
można stosować spójną i wygodną składnię do wykonywania obu operacji we wszystkich
odpowiednich typach. Na listingu 15.15 pokazane są podobne wywołania działające
dla łańcucha znaków i obiektu typu Span<int>.

Listing 15.15. Używanie wersji indeksera przyjmującej indeks i przedział dla łańcucha
znaków i obszaru

string text = "Witaj, świecie!"; Dostęp do jednego znaku na podstawie indeksu,


Console.WriteLine(text[2]); licząc od początku.
Console.WriteLine(text[^3]); Dostęp do jednego znaku na podstawie indeksu, licząc od końca.
Console.WriteLine(text[2..7]) Pobieranie podłańcucha za pomocą przedziału.

Span<int> span = stackalloc int[] { 5, 2, 7, 8, 2, 4, 3 };


Console.WriteLine(span[2]); Dostęp do jednego elementu
na podstawie indeksu, licząc od początku.

87469504f326f0d7c1fcda56ef61bd79
8
512 ROZDZIAŁ 15. C# 8 i kolejne wersje

Console.WriteLine(span[^3]); Dostęp do jednego elementu


Span<int> slice = span[2..7]; na podstawie indeksu, licząc
Console.WriteLine(string.Join(", ", slice.ToArray())); od końca.

Oto dane wyjściowe: Tworzenie wycinka


za pomocą przedziału.
t
i
taj ś
7
2
7, 8, 2, 4, 3

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.

15.5. Lepsza integracja asynchroniczności


Gdy w C# 5 wprowadzono mechanizm async/await, dla wielu programistów używają-
cych C# była to rewolucja w obszarze asynchroniczności. Jednak niektóre funkcje
języka do tej pory pozostawały synchroniczne, dlatego trudno było pisać całkowicie
asynchroniczny kod. W tym podrozdziale opisane są następujące zagadnienia:
 asynchroniczne zwalnianie zasobów,
 asynchroniczne iteracje (w pętli foreach),
 asynchroniczne iteratory (z instrukcją yield return).

Te mechanizmy wymagają wsparcia zarówno ze strony platformy, jak i ze strony języka.


Nie jest dobrym rozwiązaniem, aby kompilator tworzył w przybliżeniu asynchroniczny
program, wykonując synchroniczny kod w odrębnym wątku. Zacznijmy od asynchro-
nicznego zwalniania zasobów. Jest to najprostszy z trzech omawianych mechanizmów.

15.5.1. Asynchroniczne zwalnianie zasobów


z użyciem instrukcji using await
Interfejs IDisposable z jego jedyną metodą Dispose z natury działają synchronicznie.
Jeśli ta metoda musi wykonać operacje wejścia – wyjścia, np. opróżnić zawartość stru-
mienia, może zablokować program, co niesie za sobą wszystkie standardowe konse-
kwencje.

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();
}

Nie ma wymogu mówiącego, że typ z implementacją IAsyncDisposable musi implemen-


tować także interfejs IDisposable. Podejrzewam jednak, że w wielu typach zaimple-
mentowane są oba te interfejsy.
Wsparcie zwalniania zasobów dostępne jest też w języku. Służy do tego instrukcja
using await, która działa zgodnie z oczekiwaniami — automatycznie wywołuje metodę
DisposeAsync i oczekuje na wynikowe zadanie. Na listingu 15.16 pokazany jest przykład
implementacji i zastosowania interfejsu IAsyncDisposable.

Listing 15.16. Implementacja interfejsu IAsyncDisposal i wywołanie go z użyciem


instrukcji using await

class AsyncResource : IAsyncDisposable


{
public async Task DisposeAsync()
{
Console.WriteLine("Asynchroniczne zwalnianie zasobów...");
await Task.Delay(2000);
Console.WriteLine("...gotowe");
}

public async Task PerformWorkAsync()


{
Console.WriteLine("Asynchroniczne wykonywanie pracy...");
await Task.Delay(2000);
Console.WriteLine("...gotowe");
}
}

async static Task Main()


{
using await (var resource = new AsyncResource())
{
await resource.PerformWorkAsync();
}
Console.WriteLine("Po instrukcji using await");
}

Dane wyjściowe ilustrują zwalnianie zasobów:


Asynchroniczne wykonywanie pracy...
...gotowe
Asynchroniczne zwalnianie zasobów...
...gotowe
Po instrukcji using await

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.

15.5.2. Asynchroniczne iteracje z użyciem instrukcji foreach await


Uwaga na spoiler — w tym punkcie musisz zapoznać się z dość dużą ilością tekstu,
zanim dotrzesz do nowej funkcji języka. Ten tekst jest konieczny do poprawnego omó-
wienia nowego mechanizmu i prowadzi do wniosku, że poniższy kod będzie prawidłowy
(kolekcja asyncSequence wymaga tu wykonania asynchronicznych zadań, aby elementy
stały się dostępne):
foreach await (var item in asyncSequence)
{
Używanie zmiennej item.
}

Interfejsy wprowadzone na potrzeby asynchronicznej iteracji nie są tak proste jak


interfejsy powiązane z asynchronicznym zwalnianiem zasobów. Pojawiły się dwa nowe
interfejsy odzwierciedlające w pewnym stopniu interfejsy IEnumerable<T> i IEnumerator<T>,
ale w nieoczywisty sposób:
public interface IAsyncEnumerable<out T>
{
IAsyncEnumerator<T> GetAsyncEnumerator();
}

public interface IAsyncEnumerator<out T>


{
Task<bool> WaitForNextAsync();
T TryGetNext(out bool success);
}

Interfejs IAsyncEnumerable<T> jest bliższy interfejsowi IEnumerable<T>, niż może Ci się


wydawać. Nie ma w nim nic asynchronicznego. Zamiast metody GetEnumerator() znajduje
się w nim metoda GetAsyncEnumerator(), która zwraca obiekt typu IAsyncEnumerator<T>,
przy czym robi to w synchroniczny sposób. Możliwe, że w niektórych implementa-
cjach będzie to sprawiać problemy, spodziewam się jednak, że dla większości asynchro-
nicznych sekwencji takie rozwiązanie będzie stosowane w naturalny sposób. W każdej
implementacji, gdzie w ramach prac przygotowawczych mają być wykonywane asyn-

87469504f326f0d7c1fcda56ef61bd79
8
15.5. Lepsza integracja asynchroniczności 515

chroniczne operacje, zapewne trzeba będzie odroczyć te prace do czasu rozpoczęcia


iteracyjnego pobierania wyników przez jednostkę wywołującą.
Interfejs IAsyncEnumerator<T> dużo bardziej różni się od IEnumerator<T> i odzwier-
ciedla typowy wzorzec z rzeczywistych implementacji. Asynchroniczność często jest
stosowana razem z operacjami wejścia – wyjścia, np. przy pobieraniu wyników z uży-
ciem sieci. Nieraz w naturalny sposób skutkuje to pobieraniem sekwencji w porcjach.
Możesz wykonać zapytanie i otrzymać razem dziesięć wyników, potem kolejnych sie-
dem, a następnie dowiedzieć się, że jest to kompletny zbiór wyników.
W trakcie iteracyjnego pobierania zbioru wyników zapisanych w buforze asyn-
chroniczność nie jest potrzebna. Choć pozwala ona na wydajne działanie kodu, zwią-
zana jest z kosztami, dlatego warto w miarę możliwości jej unikać. Zamiast tego możesz
synchronicznie iteracyjnie pobierać wyniki, jeśli istnieje sposób na stwierdzenie, że
dotarłeś do końca bieżącego zbioru wyników. Wtedy można asynchronicznie pobrać
następny zbiór wyników i ponownie synchronicznie iteracyjnie pobierać dane.
Interfejs IAsyncEnumerator<T> udostępnia ten wzorzec za pomocą dwóch metod:
 WaitForNextAsync to metoda asynchroniczna, która zwraca zadanie informujące,
czy pobranych jest więcej wyników, czy może kod dotarł do końca sekwencji.
 TryGetNext to metoda synchroniczna, która zwraca następny element. Do infor-
mowania, czy był dostępny następny element do zwrócenia, służy parametr out4.
Jeśli ten parametr ma wartość false, nie zawsze oznacza to dotarcie do końca
sekwencji. Wartość false oznacza tu konieczność ponownego wywołania metody
WaitForNextAsync.

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

public interface IGeoService


{
Task<ListCitiesResponse> ListCitiesAsync(ListCitiesRequest request);
}

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

public class ListCitiesRequest


{
public string PageToken { get; }
public ListCitiesRequest(string pageToken) =>
PageToken = pageToken;
}

public class ListCitiesResponse


{
public string NextPageToken { get; }
public List<string> Cities { get; }

public ListCitiesResponse(string nextPageToken, List<string> cities) =>


(NextPageToken, Cities) = (nextPageToken, cities);
}

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.

Listing 15.18. Nakładka na usługę RPC udostępniająca prostszy interfejs API

public class GeoClient


{ Tworzenie obiektu typu GeoClient
public GeoClient(IGeoService service) { ... } z użyciem usługi RPC.
public IAsyncEnumerable<string> ListCitiesAsync() { ... } Udostępnianie prostej
} asynchronicznej sekwencji
miast.

Gdy dostępny jest obiekt typu GeoClient, możesz wreszcie użyć instrukcji foreach await.
Ilustruje to listing 15.19.

Listing 15.19. Używanie instrukcji foreach await do obiektu typu GeoClient

var client = new GeoClient(service);

foreach await (var city in client.ListCitiesAsync())


{
Console.WriteLine(city);
}

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.

Może Cię zaskoczyć to, że w interfejsie IAsyncEnumerator<T> nie zaimplementowano


interfejsu IAsyncDisposable. Może się to zmienić do czasu udostępnienia nowej wersji
języka, jednak nawet jeśli tak się nie stanie, podejrzewam, że kompilator będzie zwal-
niał zasoby enumeratora, jeżeli w czasie wykonywania programu okaże się, że ten
ostatni implementuje interfejs IAsyncDisposable.

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?

15.5.3. Asynchroniczne iteratory


W C# 2 wprowadzone zostały iteratory z instrukcjami yield return i yield break, aby
ułatwić pisanie metod zwracających obiekty typów IEnumerable<T> i IEnumerator<T>.
W C# 8 ten sam mechanizm dostępny będzie dla sekwencji asynchronicznych. Ta
technika nie jest dostępna w wersji zapoznawczej kompilatora, jednak na listingu 15.20
pokazuję, jak moim zdaniem będzie ona działać.

Listing 15.20. Implementowanie metody ListCitiesAsync z użyciem iteratora

public async IAsyncEnumerable<string> ListCitiesAsync()


{
string pageToken = null;
do
{
var request = new ListCitiesRequest(pageToken);
var response = await service.ListCitiesAsync(request);
foreach (var city in response.Cities)
{
yield return city;
}
pageToken = response.NextPageToken;
} while (pageToken != null);
}

Trudno będzie zaimplementować powiązania między metodą z asynchronicznym itera-


torem a interfejsem IAsyncEnumerator<T> oraz połączyć aspekty asynchroniczne i syn-
chroniczne. Metoda asynchroniczna kontynuująca wykonywanie kodu może zakończyć
dane wywołanie na kilka sposobów:
 może oczekiwać na nieukończoną operację asynchroniczną,
 może dotrzeć do instrukcji yield return,
 może dotrzeć do instrukcji yield break,
 może dotrzeć do końca metody,
 może zgłosić wyjątek.

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.

15.6. Funkcje, które nie znalazły się


w wersji zapoznawczej
Jeśli się okaże, że w C# 8 wprowadzone zostaną tylko mechanizmy opisane do tego
miejsca, i tak będzie to bardzo wartościowa wersja. Pod niektórymi względami chciał-
bym, aby pojawiła się wersja z dodanymi tylko typami referencyjnymi przyjmującymi
null, a po roku przerwy (co pozwoliłoby na zaktualizowanie większości kodu bazowego
z użyciem tych typów) udostępnione zostały kolejne funkcje. Jednak C# 8 prawdo-
podobnie zostanie udostępniony z dłuższą listą funkcji niż te przedstawione do tego
miejsca.
W tym podrozdziale omawiam mechanizmy, które mają moim zdaniem największą
szansę znaleźć się w C# 8. Członkowie zespołu projektującego C# i zewnętrzni progra-
miści zaproponowali jeszcze więcej technik. Zespół projektujący C# używa serwisu
GitHub do śledzenia takich propozycji. Dzięki temu można łatwo zobaczyć, co dzieje
się w tym obszarze, i wnieść własny wkład (zobacz https://github.com/dotnet/csharplang).
Zacznijmy od funkcji zainspirowanej Javą.

15.6.1. Domyślne metody interfejsu


W C# wprowadzono metody rozszerzające na potrzeby technologii LINQ, natomiast
w Javie przyjęto inne podejście, aby zapewnić obsługę strumieni, mających w wielu
obszarach podobne zastosowania co technologia LINQ. W Javie 8 firma Oracle wpro-
wadziła w interfejsach metody domyślne. W interfejsie można zadeklarować metodę
i jej domyślną implementacją, przesłanianą później w konkretnych implementacjach.
W implementacji domyślnej nie można jednak deklarować stanu z użyciem pól. Stan
trzeba reprezentować za pomocą innych składowych interfejsu.
Metody rozszerzające i metody domyślne są w niektórych aspektach zbliżone, ponie-
waż umożliwiają zapis logiki w taki sposób, aby użytkownik interfejsu mógł wywoływać
określoną metodę nawet wtedy, gdy nie jest ona znana lub zaimplementowana w każ-
dej implementacji danego interfejsu. Oba podejścia mają wady i zalety:
 Metody rozszerzające mogą być dodawane przez każdego, nie tylko przez autora
danego interfejsu. Metod domyślnych nie możesz dodawać do interfejsów, któ-
rych nie kontrolujesz. Metody rozszerzające można też oczywiście dodawać do
klas i struktur.
 Metody domyślne można przesłaniać w klasach z implementacją, często w celu
ich zoptymalizowania. Metod rozszerzających nie można przesłaniać. Są to metody
statyczne z lukrem składniowym sprawiającym, że ich wywołania wyglądają
podobnie do wywołań zwykłych metod instancji.

87469504f326f0d7c1fcda56ef61bd79
8
15.6. Funkcje, które nie znalazły się w wersji zapoznawczej 519

Drugi punkt można łatwo docenić na przykładzie metody Enumerable.Count() z techno-


logii LINQ. Domyślnie zlicza ona elementy sekwencji, wywołując metodę GetEnume
rator(), a następnie licząc, ile wywołań metody MoveNext() dla danego enumeratora
zwraca wartość true.
Wiele implementacji interfejsu IEnumerable<T> udostępnia dużo wydajniejszy sposób
określania liczby elementów. Metoda Enumerable.Count() jest specjalnie zoptymali-
zowana pod kątem niektórych kolekcji, np. w implementacjach interfejsów ICollection
i ICollection<T>. Co jednak z kolekcjami, których autorzy nie zamierzają implemento-
wać żadnego z wymienionych interfejsów, ale chcą niskim kosztem udostępnić metodę
Count? Nie ma dla nich rozwiązania. Nie istnieje sposób na przekazanie metodzie
Enumerable.Count(), że można zaimplementować ten aspekt technologii LINQ w wydaj-
niejszy sposób. Gdyby jednak Count() była metodą z domyślną implementacją w inter-
fejsie IEnumerable<T>, w nowej kolekcji można byłoby po prostu przesłonić tę metodę.
Oto przykładowa deklaracja interfejsu IEnumerable<T> z domyślnymi metodami
interfejsu w C# 8:
public interface IEnumerable<T>
{
IEnumerator<T> GetEnumerator();

int Count()
{
using (var iterator = GetEnumerator())
{
int count = 0;
while (iterator.MoveNext())
{
count++;
}
}
}
return count;
}

Domyślne metody interfejsu umożliwiają też rozbudowanie w przyszłości interfejsu


w sposób ułatwiający wersjonowanie. Można dodawać nowe metody z domyślną imple-
mentacją, które albo udostępniają nowe mechanizmy z użyciem istniejących składowych,
albo zgłaszają wyjątek NotSupportedException. Dzięki temu starsze implementacje nadal
będą się kompilować, nawet jeśli nowej metody nie można poprawnie wywołać.
Wersjonowanie to skomplikowane zagadnienie (delikatnie mówiąc), a dostępność nowych
technik jest mile widziana. Opisane rozwiązanie w wielu sytuacjach pozwoliłoby upro-
ścić kod, który konserwuję.
Domyślne metody interfejsu okazują się być kontrowersyjnym zagadnieniem. Wyma-
gają one wsparcia ze strony środowiska CLR, dlatego trudniej jest eksperymentować
z tą funkcją bez pełnego zaangażowania się w jej rozwijanie. Jeśli ten mechanizm zosta-
nie dodany, ciekawie będzie obserwować, jak wielu programistów będzie go stosować.
Może on okazać się rzadko używany do czasu powszechnego wprowadzenia obsługują-
cych go wersji środowiska uruchomieniowego. Teraz przyjrzyjmy się funkcji, która
jest opisywana (i rozwijana w formie prototypów) już od dawna.

87469504f326f0d7c1fcda56ef61bd79
8
520 ROZDZIAŁ 15. C# 8 i kolejne wersje

15.6.2. Typy rekordowe


Pierwowzorem typów rekordowych był mechanizm nazywany konstruktorem pod-
stawowym, który początkowo miał zostać wprowadzony w C# 6. Zespół projektujący
język był jednak niezadowolony z pewnych niedoróbek pierwotnego projektu, dlatego
zdecydował się opóźnić wprowadzenie takich konstruktorów do czasu dopracowania
pomysłu.
Typy rekordowe mają umożliwiać łatwe tworzenie niemodyfikowalnych klas lub
struktur na podstawie danego zbioru właściwości. Zwykle myślę o takich typach jak
o typach anonimowych, ale z dodanymi wszystkimi potrzebnymi mechanizmami. Można
je zadeklarować w bardzo prosty sposób. Oto przykładowa kompletna deklaracja klasy:
public class Point(int X, int Y, int Z);

Powoduje to wygenerowanie zestawu składowych, przy czym nadal możesz dodawać


własne operacje. Generowane składowe to konstruktor, właściwości, metody do porów-
nywania wartości, metoda Deconstruct służąca do podziału obiektu i metoda With
o następującej postaci:
public Point With(int X = this.X, int Y = this.Y, int Z = this.Z) =>
new Point(X, Y, Z);

Obecnie nie jest to poprawna składnia podawania wartości domyślnych parametrów


opcjonalnych. Nie jest też jasne, czy poprawne będzie bezpośrednie pisanie tego rodzaju
kodu. Ten kod ilustruje jednak, jak metoda ma działać.
Metoda With ma współdziałać z nową składnią w postaci wyrażeń with. Pomysł
polega na tym, że i ta metoda, i proponowana składnia mają ułatwiać tworzenie nowej
instancji niemodyfikowalnego typu, która jest identyczna z istniejącą instancją, ale ma
inną wartość jednej lub wielu właściwości. Już teraz w typach niemodyfikowalnych
często udostępniane są metody WithFoo (gdzie Foo to nazwa właściwości w danym typie).
Na przykład w niemodyfikowalnej klasie Point z właściwościami X, Y i Z możesz zasto-
sować pokazany poniżej kod, aby utworzyć nowy punkt o tej samej wartości Z co
w poprzednim obiekcie, ale z nowymi wartościami X i Y:
var newPoint = oldPoint.WithX(10).WithY(20);

Każda metoda WithFoo wywołuje konstruktor i przekazuje do niego wszystkie istnie-


jące właściwości oprócz tej określonej w nazwie metody. Dla tej właściwości używana
jest nowa wartość podana jako parametr. Pisanie takich metod jest żmudne. Ponadto
mają one negatywny wpływ na wydajność. Aby zmodyfikować N właściwości, trzeba
wywołać metodę N razy, za każdym razem tworząc nowy obiekt.
Metoda With dla typów rekordowych ma działać inaczej. Przyjmuje ona jeden para-
metr dla każdej właściwości z danego typu i udostępnia nową składnię powodującą
użycie domyślnej wartości parametru, jeśli dany parametr nie został podany. Składnia
ta oznacza, że wartość niepodanego parametru należy pobrać z bieżącego obiektu. Przyj-
rzyj się metodzie With dla przykładowego typu Point. Możesz wywołać ją bezpośrednio:
var newPoint = oldPoint.With(X: 10, Y: 20);

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.

15.6.3. Krótki opis jeszcze innych funkcji


Choć niektóre proste mechanizmy z większym prawdopodobieństwem znajdą się
w C# 8, nie są one tak interesujące jak techniki, które omawiam w tym miejscu. Pamię-
taj, że zawsze możesz zajrzeć do serwisu GitHub, aby dowiedzieć się więcej na temat
rozwiązań, jakie mogą zostać dodane, i sprawdzić aktualny stan prac.
KLASY REPREZENTUJĄCE TYP (INNE NAZWY TO KONCEPCJE,
FORMY LUB OGRANICZENIA STRUKTURY TYPÓW GENERYCZNYCH)
Choć typy generyczne świetnie się sprawdzają w wielu sytuacjach, mają pewne braki.
Istnieją „formy” typów danych, których nie da się zapisać za pomocą typów generycz-
nych. Dotyczy to np. operatorów i konstruktorów w typach generycznych. Choć możesz
zażądać, aby typ podany jako argument określający typ udostępniał konstruktor bez-
parametrowy, nie możesz zapisać wymogu, że taki konstruktor ma mieć konkretną listę
parametrów. Ponadto zdarza się, że typy mogą mieć tę samą przydatną formę, ale nie
implementują żadnych wspólnych interfejsów ani nie mają wspólnych klas bazowych
innych niż System.Object. Klasy reprezentujące typy mają być nowym rodzajem typu
eliminującym opisane braki. Mają przypominać interfejsy, ale nie muszą być znane
w klasie z implementacją. Możliwe ma też być używanie klasy reprezentującej typ
w ograniczeniach parametrów typów generycznych.
To rozwiązanie może dawać duże możliwości, ale trudno je zrozumieć. Sam jestem
trochę sceptycznie nastawiony. Możliwe, że wydajne działanie takich typów będzie

87469504f326f0d7c1fcda56ef61bd79
8
522 ROZDZIAŁ 15. C# 8 i kolejne wersje

wymagało zmian w środowisku uruchomieniowym. Programiści używający C# (a przy-


najmniej ja) mogą też potrzebować trochę czasu, aby stwierdzić, w jakich sytuacjach
typy reprezentujące klasy są przydatne, a kiedy tylko utrudniają zrozumienie kodu.
Dodanie zupełnie nowego rodzaju typów na tym etapie ewolucji języka wydaje się
bardzo odważnym krokiem. Mimo wszystkich wymienionych zastrzeżeń ten mecha-
nizm z pewnością eliminuje pewne braki języka. Gdy potrzebujesz wymienionych tu
możliwości, obecne narzędzia nie zapewniają żadnego zgrabnego rozwiązania.
ROZSZERZENIA DLA WSZYSTKIEGO
W czasie, gdy powstaje ta książka, etap prac nad tym mechanizmem w serwisie GitHub
ma oznaczenie X.0. Nie będę jednak zaskoczony, jeśli w przyszłości ten mechanizm
zostanie potraktowany bardziej priorytetowo. Nazwa tej techniki dobrze ją objaśnia.
Pomysł zaczerpnięty z metod rozszerzających ma być zastosowany do innych składo-
wych, np. właściwości, konstruktorów i operatorów. Możliwe, że wprowadzone zostaną
też statyczne składowe rozszerzające, działające jak metody statyczne rozszerzanego
typu. Mógłbyś wtedy np. napisać w klasie StringExtensions metodę string.IsNullOrTabs,
która jest specyficzną wersją metody string.IsNullOrWhiteSpace.
Składnia metod rozszerzających nie nadaje się dla składowych innych rodzajów,
dlatego możliwe jest, że użyta zostanie zupełnie nowa składnia. Możliwe, że używany
będzie typ rozszerzający, służący wyłącznie do tworzenia wielu składowych rozsze-
rzających dla konkretnego rozszerzanego typu.
Typy rozszerzające nadal nie będą umożliwiały tworzenia nowego stanu. Wszystkie
właściwości rozszerzające zapewne będą tylko zwracać nowy widok istniejących wła-
ściwości. Mógłbyś np. utworzyć dla typu DateTime właściwość rozszerzającą Financial
Quarter, która obsługuje daty związane z raportami finansowymi Twojej firmy i używa
istniejących właściwości Year, Month i Day do wyznaczania kwartałów roku rozliczeniowego.
NEW DLA TYPU DOCELOWEGO
Niejawne typowanie z użyciem słowa var pomaga poprawić czytelność kodu, gdy
używane są długie nazwy typów. Nie jest jednak pomocne dla pól, ponieważ nie można
do nich stosować niejawnego typowania. Nadal powstaje więc kod o następującej postaci:
Dictionary<string, List<DateTime>> entryTimesByName =
new Dictionary<string, List<DateTime>>();

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

W mniej więcej równym stopniu kocham tę propozycję i jej nienawidzę. Powszechne


używanie tej techniki z pewnością może sprawić, że kod stanie się nieczytelny. Jednak
prawie każdą funkcję można stosować w niewłaściwy sposób. Natomiast bardzo podoba
mi się możliwość wyeliminowania powtórzeń w długich inicjalizacjach pól.
Spodziewam się, że ten mechanizm będzie budził jeszcze większe kontrowersje
niż domyślne metody interfejsów. Zobaczymy, co się stanie, a Ty też możesz wziąć
udział w dyskusjach na ten temat.

15.7. Udział w pracach


Proces projektowania języka C# jest bardziej otwarty niż kiedykolwiek wcześniej.
Choć wiele pracy odbywa się na spotkaniach LDM (ang. Language Design Meetings,
czyli spotkania poświęcone projektowi języka) w biurach Microsoftu, to wciąż pozostaje
bardzo dużo miejsca na zaangażowanie się społeczności. Należy zacząć od repozytorium
w serwisie GitHub (https://github.com/dotnet/csharplang). Znajdziesz tam uwagi ze spo-
tkań LDM, propozycje, dyskusje i specyfikacje. Możesz wziąć udział w pracach na
następujących poziomach:
 wypróbowywanie wersji zapoznawczej języka, aby sprawdzić, jak nowe funkcje
współdziałają z istniejącym kodem;
 dyskutowanie na temat aktualnie proponowanych mechanizmów;
 proponowanie nowych funkcji;
 tworzenie prototypów nowych funkcji kompilatora Roslyn;
 pomaganie w tworzeniu języka poprzez sprawdzanie specyfikacji nowych mecha-
nizmów;
 wykrywanie błędów w istniejącej specyfikacji (zdarzają się!).

Możesz uznać, że lepiej będzie poczekać do powstania ostatecznej wersji z kompletną


dokumentacją i dopracowaną implementacją. Nie ma w tym nic złego. Możesz swo-
bodnie zmienić zdanie w dowolnym momencie — choćby po to, aby zapoznać się
z zestawem funkcji proponowanych w danym etapie prac.
Otwarty proces projektowania języka jest stosunkowo nowy. Spodziewam się,
że z czasem zostanie on dopracowany. Byłbym zaskoczony, gdyby zespół projektujący
język kiedykolwiek wrócił do bardziej zamkniętego procesu. Choć zaangażowanie
społeczności w prace wymaga od zespołu dużo czasu, daje to bardzo duże korzyści,
ponieważ wiadomo, że udostępniane są te funkcje, których programiści naprawdę
potrzebują.

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.

Mechanizm Uwagi i wymagania Punkt

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

Mechanizm Uwagi i wymagania Punkt


Składnia aliasów przestrzeni nazw 2.5.4
używanych jako kwalifikator (::)
Alias globalnej przestrzeni nazw 2.5.4
Aliasy zewnętrzne 2.5.5
Bufory o stałym rozmiarze 2.5.6
Obsługa atrybutu 2.5.7
InternalsVisibleToAttribute

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

Mechanizm Uwagi i wymagania Punkt

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

Mechanizm Uwagi i wymagania Punkt


Zwracanie niestandardowych typów Wymagane wsparcie platformy (atrybuty) 5.8
zadań w metodach asynchronicznych
Dodatkowe rodzaje składowych z ciałem 8.3.3
w postaci wyrażenia

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

Mechanizm Uwagi i wymagania Punkt


Używanie zmiennych ze wzorców 14.2.2
i zmiennych out w inicjalizatorach pól,
właściwości i konstruktorów
Ponowne przypisywanie wartości 13.2.1
zmiennych lokalnych ref
Inicjalizatory w instrukcjach stackalloc 13.6.2
Oparte na wzorcu instrukcje fixed 13.6.2
z użyciem wywołania GetPinnableReference
Ograniczenia typów generycznych mogą 14.8.1
obecnie dotyczyć wyliczeń (Enum)
i delegatów (Delegate)
Nowe ograniczenie typów generycznych Typy i metody z ograniczeniem unmanaged 14.8.1
— unmanaged mogą być używane tylko przez
kompilatory na tyle nowe, że rozumieją
te ograniczenia. Wymagany jest też typ
wyliczeniowy UnmanagedType dostępny
od wersji .NET 1.1 i .NET Standard 1.1
Atrybuty pól powiązanych 14.8.3
z automatycznie implementowanymi
właściwościami

87469504f326f0d7c1fcda56ef61bd79
8
87469504f326f0d7c1fcda56ef61bd79
8
87469504f326f0d7c1fcda56ef61bd79
8

You might also like