191 70 9MB
Polish Pages 632 [630] Year 2012
Tytuł oryginału: C# in Depth, Second Edition Tłumaczenie: Janusz Grabis Projekt okładki: Studio Gravite / Olsztyn Obarek, Pokoński, Pazdrijowski, Zaprucki ISBN: 978-83-246-6458-0 Original edition copyright © 2011 by Manning Publications Co. All rights reserved. Polish edition copyright © 2012 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 Wydawnictwo HELION 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 Wydawnictwo HELION nie ponoszą również żadnej odpowiedzialności za ewentualne szkody wynikłe z wykorzystania informacji zawartych w książce. Wydawnictwo HELION ul. Kościuszki 1c, 44-100 GLIWICE tel. 32 231 22 19, 32 230 98 63 e-mail: [email protected] 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/cshop2_ebook Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję. Printed in Poland. • Poleć książkę na Facebook.com
• Księgarnia internetowa
• Kup w wersji papierowej
• Lubię to! » Nasza społeczność
• Oceń książkę
Pochwały dla pierwszego wydania Najlepsza książka o C# przeznaczona dla programistów średnio zaawansowanych oraz ekspertów. Doświadczeni programiści .NET, przekonani, że wiedzą już wszystko, co można wiedzieć na temat języka C#, niemal z pewnością nauczą się czegoś nowego po przeczytaniu tej książki. Jest to pozycja, która wciąga od samego początku aż po ostatnią stronę i jednocześnie służy jako doskonały materiał referencyjny. Polecam ją każdemu, kto chce zostać ekspertem w dziedzinie C#. — Alvin Ashcraft, DZone Mówiąc najprościej, C# od podszewki jest prawdopodobnie najlepszą książką informatyczną, jaką czytałem. — Craig Pelkie, System iNetwork Programuję w C# od samego początku, ale mimo to książka ta potrafiła mnie mile zaskoczyć. Byłem pod szczególnym wrażeniem doskonałego omówienia delegatów, metod anonimowych, kowariancji i kontrawariancji. Nawet jeśli jesteś doświadczonym programistą, C# od podszewki nauczy Cię czegoś nowego o języku C#… Ta książka faktycznie ma głębię, której nie sięga żadna inna książka poświęcona C#. — Adam J. Wolf, Southeast Valley .NET User Group Z przyjemnością przeczytałem całą książkę. Jest dobrze napisana — przykłady są łatwe do zrozumienia. Z łatwością wciągnąłem się w temat wyrażeń lambda i naprawdę spodobał mi się poświęcony im rozdział. — Jose Rolando Guay Paz, programista WWW, CSW Solutions Niniejsza książka zbiera ogromną wiedzę autora na temat niuansów C# i przekazuje ją czytelnikom w treściwy, dobrze napisany i użyteczny sposób. — Jim Holmes, autor Windows Developer Power Tools Każdy termin jest używany w odpowiedni sposób i w odpowiednim kontekście, każdy przykład jest zwięzły i zawiera najmniejszą możliwą ilość kodu potrzebną do pełnego zaprezentowania danej cechy… Rzadko można spotkać coś podobnego. — Franck Jeannin, recenzent Amazon UK Jeżeli programujesz w C# od dobrych kilku lat i chciałbyś się dowiedzieć, co tkwi we wnętrzu języka, to ta książka jest dla Ciebie. — Golo Roden, wykładowca i szkoleniowiec .NET oraz pokrewnych technologii
Więcej pochwał dla pierwszego wydania Jeżeli istnieje jedna obowiązkowa książka dla programistów C#, to jest nią ta pozycja. Jon Skeet mówi o wszystkim, co musisz wiedzieć o zawiłościach C#, a zwłaszcza o LINQ. Autor zdecydowanie zna tematykę, którą się zajmuje, i dzieli się z nami nieocenionymi informacjami na temat języka C#. — Luigi Zambetti, programista, Mediolan Jest to — na tę chwilę — najlepsze źródło wiedzy o C#, jakie można kupić. — Jan Van Ryswyck, ElegantCode.com Styl pisania Jona Skeeta jest treściwy, precyzyjny i pełen doskonałych przykładów. — Peter Kellner, bloger Doskonała książka na temat C#. — Teemu Keiski, ASP.NET MVP, AspInsider Stań się mistrzem C# 3! — Fabrice Marguerie, C# MVP, autor LINQ in Action Najlepsza książka poświęcona C#, jaką kiedykolwiek czytałem. — Chris Mullins, C# MVP Jasna i treściwa. — Robin Shahan, GoldMail.com Uczta! — Anil Radhakrishna, ASP.NET MVP Odkrywa funkcjonalne tajemnice C#. — Christopher Haupt, BuildingWebApps.com Tak dobra, że aż boli głowa. — J.D. Conley, Hive7 Inc. Wzbogaca początkującego i udoskonala eksperta. — Josh Cronemeyer, ThoughtWorks
Dla Holly, z wyrazami miłości
6
SPIS TREŚCI
Spis treści
7
Spis treści
Przedmowa Wstęp Podziękowania O książce
17 19 21 23
Część I
31
1.
Przygotowanie do wyprawy
Nieustająca metamorfoza C# 1.1.
1.2. 1.3. 1.4.
33
Na początek prosty typ danych ................................................................................................... 34 1.1.1. Typ Product w C# 1 ....................................................................................................... 35 1.1.2. Kolekcje o mocnym typie w C# 2 ................................................................................. 36 1.1.3. Właściwości implementowane automatycznie w C# 3 ................................................ 37 1.1.4. Argumenty nazwane w C# 4 ......................................................................................... 38 Sortowanie i filtrowanie .............................................................................................................. 39 1.2.1. Sortowanie produktów po nazwie .................................................................................. 40 1.2.2. Wyszukiwanie elementów w kolekcjach ....................................................................... 43 Obsługa braku danych ................................................................................................................. 44 1.3.1. Reprezentacja nieznanej ceny ........................................................................................ 45 1.3.2. Parametry opcjonalne i wartości domyślne ................................................................... 46 Wprowadzenie do LINQ ............................................................................................................. 46 1.4.1. Wyrażenia kwerendowe i zapytania wewnątrzprocesowe ........................................... 47 1.4.2. Wykonywanie zapytań na XML-u ................................................................................. 48 1.4.3. LINQ dla SQL-a ............................................................................................................. 49
8
SPIS TREŚCI
1.5. 1.6.
1.7.
1.8.
2.
Rdzeń systemu — C# 1 2.1.
2.2.
2.3.
2.4.
2.5.
Część II 3.
COM i typy dynamiczne .............................................................................................................. 50 1.5.1. Uproszczenie współpracy z COM ................................................................................. 50 1.5.2. Współpraca z językiem dynamicznym ........................................................................... 51 Analiza zawartości środowiska .NET ......................................................................................... 52 1.6.1. Język C# ......................................................................................................................... 53 1.6.2. Środowisko wykonania ................................................................................................... 53 1.6.3. Biblioteki środowiska ...................................................................................................... 54 Jak zmienić Twój kod w kod doskonały? ................................................................................... 54 1.7.1. Prezentacja pełnych programów w formie fragmentów kodu ...................................... 55 1.7.2. Kod dydaktyczny nie jest kodem produkcyjnym .......................................................... 56 1.7.3. Twój nowy najlepszy przyjaciel — specyfikacja języka ................................................ 56 Podsumowanie .............................................................................................................................. 57
Delegaty ........................................................................................................................................ 60 2.1.1. Przepis na prosty delegat ................................................................................................ 61 2.1.2. Łączenie i usuwanie delegatów ..................................................................................... 66 2.1.3. Dygresja na temat zdarzeń ............................................................................................. 67 2.1.4. Podsumowanie delegatów .............................................................................................. 68 Charakterystyka systemu typów ................................................................................................. 69 2.2.1. Miejsce C# w świecie systemów typów ........................................................................ 69 2.2.2. Kiedy system typów C# 1 jest niedostatecznie bogaty? .............................................. 73 2.2.3. Podsumowanie charakterystyki systemu typów ............................................................ 75 Typy wartościowe i referencyjne ............................................................................................... 76 2.3.1. Wartości i referencje w rzeczywistym świecie .............................................................. 76 2.3.2. Podstawy typów referencyjnych i wartościowych ........................................................ 77 2.3.3. Obalanie mitów ............................................................................................................... 79 2.3.4. Opakowywanie i odpakowywanie .................................................................................. 81 2.3.5. Podsumowanie typów wartościowych i referencyjnych ............................................... 82 Więcej niż C# 1 — nowe cechy na solidnym fundamencie .................................................... 83 2.4.1. Cechy związane z delegatami ........................................................................................ 83 2.4.2. Cechy związane z systemem typów ............................................................................... 85 2.4.3. Cechy związane z typami wartościowymi ..................................................................... 87 Podsumowanie .............................................................................................................................. 88
C# 2 — rozwiązanie problemów C# 1
Parametryzowanie typów i metod 3.1. 3.2.
3.3.
3.4.
59
89 91
Czemu potrzebne są typy generyczne? ...................................................................................... 92 Proste typy generyczne do codziennego użycia ........................................................................ 94 3.2.1. Nauka przez przykład — słownik typu generycznego ................................................. 94 3.2.2. Typy generyczne i parametry typów ............................................................................. 96 3.2.3. Metody generyczne i czytanie deklaracji generycznych ............................................ 100 Wkraczamy głębiej .................................................................................................................... 103 3.3.1. Ograniczenia typów ...................................................................................................... 104 3.3.2. Interfejs argumentów typu dla metod generycznych ................................................. 109 3.3.3. Implementowanie typów generycznych ...................................................................... 110 Zaawansowane elementy typów generycznych ....................................................................... 116 3.4.1. Pola i konstruktory statyczne ....................................................................................... 117 3.4.2. Jak kompilator JIT traktuje typy generyczne .............................................................. 119
Spis treści
3.5.
3.6.
4.
4.2.
4.3.
4.4. 4.5.
5.
3.4.3. Iteracja przy użyciu typów generycznych ................................................................... 121 3.4.4. Refleksja i typy generyczne .......................................................................................... 123 Ograniczenia typów generycznych C# i innych języków ...................................................... 128 3.5.1. Brak wariancji typów generycznych ............................................................................ 128 3.5.2. Brak ograniczeń operatorów lub ograniczeń „numerycznych” .................................. 133 3.5.3. Brak generycznych właściwości, indekserów i innych elementów typu ................... 135 3.5.4. Porównanie z C++ ...................................................................................................... 136 3.5.5. Porównanie z typami generycznymi Javy .................................................................... 137 Podsumowanie ............................................................................................................................ 139
Wyrażanie „niczego” przy użyciu typów nullowalnych 4.1.
5.4.
5.5.
5.6.
141
Co robisz, kiedy zwyczajnie nie masz wartości? ..................................................................... 142 4.1.1. Czemu zmienne typu wartościowego nie mogą zawierać null? ................................. 142 4.1.2. Metody reprezentacji wartości null w C# 1 ............................................................... 143 System.Nullable i System.Nullable ................................................................................. 145 4.2.1. Wprowadzenie Nullable ..................................................................................... 146 4.2.2. Opakowywanie i odpakowywanie Nullable ....................................................... 149 4.2.3. Równość instancji Nullable ................................................................................. 150 4.2.4. Wsparcie ze strony niegenerycznej klasy Nullable .................................................... 151 Składnia C# 2 dla typów nullowalnych ................................................................................... 152 4.3.1. Modyfikator ? ................................................................................................................ 152 4.3.2. Przypisania i porównania z null ................................................................................... 154 4.3.3. Konwersje i operatory nullowalne ............................................................................... 156 4.3.4. Logika typów nullowalnych ......................................................................................... 159 4.3.5. Stosowanie operatora as z typami nullowalnymi ........................................................ 161 4.3.6. Nullowy operator koalescencyjny ................................................................................ 161 Nowatorskie zastosowania typów nullowalnych ..................................................................... 164 4.4.1. Testowanie operacji bez parametrów zwrotnych ........................................................ 165 4.4.2. Bezbolesne porównania przy użyciu nullowalnego operatora koalescencyjnego ..... 167 Podsumowanie ............................................................................................................................ 169
Przyspieszone delegaty 5.1. 5.2. 5.3.
9
171
Pożegnanie z dziwaczną składnią delegatów .......................................................................... 173 Konwersja grupy metod ............................................................................................................ 174 Kowariancja i kontrawariancja ................................................................................................ 176 5.3.1. Kontrawariancja parametrów delegatów ..................................................................... 176 5.3.2. Kowariancja typu zwracanego delegata ....................................................................... 178 5.3.3. Małe ryzyko niekompatybilności ................................................................................. 179 Akcje delegatów tworzone w miejscu przy użyciu metod anonimowych ............................. 180 5.4.1. Rozpoczynamy prosto — operując na parametrach ................................................... 181 5.4.2. Zwracanie wartości z metod anonimowych ................................................................. 183 5.4.3. Ignorowanie parametrów typu ..................................................................................... 185 Przechwytywanie zmiennych w metodach anonimowych ..................................................... 186 5.5.1. Definicja domknięcia i różnych typów zmiennych ..................................................... 187 5.5.2. Analiza zachowania zmiennych przechwyconych ....................................................... 188 5.5.3. Jaki cel mają zmienne przechwycone? ........................................................................ 189 5.5.4. Przedłużone życie zmiennych przechwyconych ......................................................... 190 5.5.5. Instancje zmiennej lokalnej .......................................................................................... 192 5.5.6. Mieszanka zmiennych współdzielonych i odrębnych ................................................. 194 5.5.7. Wskazówki odnośnie do zmiennych przechwyconych i podsumowanie ................... 196 Podsumowanie ............................................................................................................................ 197
10
6.
SPIS TREŚCI
Implementowanie iteratorów w prosty sposób 6.1. 6.2.
6.3.
6.4. 6.5.
7.
C# 1 — udręka ręcznego pisania iteratorów ......................................................................... 201 C# 2 — proste iteratory z wyrażeniami yield ........................................................................ 204 6.2.1. Wprowadzenie do uproszczeń iteratorów i wyrażenia yield return .......................... 204 6.2.2. Wizualizacja toku wykonania iteratora ........................................................................ 206 6.2.3. Zaawansowany tok wykonania iteratora ...................................................................... 208 6.2.4. Dziwactwa w implementacji ........................................................................................ 211 Przykłady z życia ........................................................................................................................ 213 6.3.1. Iterowanie po datach w harmonogramie ..................................................................... 213 6.3.2. Iterowanie po wierszach pliku ..................................................................................... 214 6.3.3. Leniwe filtrowanie elementów z użyciem uproszczenia iteratora i predykatu ......... 217 Pseudosynchroniczny kod z użyciem biblioteki CCR ............................................................ 219 Podsumowanie ............................................................................................................................ 222
Pozostałe cechy C# 2 7.1.
7.2. 7.3. 7.4.
7.5. 7.6. 7.7.
7.8.
199
225
Typy częściowe ........................................................................................................................... 227 7.1.1. Tworzenie typu składającego się z kilku plików ......................................................... 227 7.1.2. Użycie typów częściowych ........................................................................................... 229 7.1.3. Metody częściowe — tylko w C# 3 ............................................................................. 231 Klasy statyczne ........................................................................................................................... 233 Niezależny poziom dostępu do getterów i setterów właściwości .......................................... 235 Aliasy przestrzeni nazw ............................................................................................................. 237 7.4.1. Kwalifikowanie aliasów przestrzeni nazw ................................................................... 238 7.4.2. Aliasy globalnej przestrzeni nazw ................................................................................ 239 7.4.3. Aliasy zewnętrzne ......................................................................................................... 240 Dyrektywy pragma ..................................................................................................................... 241 7.5.1. Pragmy ostrzeżeń .......................................................................................................... 242 7.5.2. Pragmy sum kontrolnych .............................................................................................. 243 Bufory o stałym rozmiarze w kodzie niezarządzanym ........................................................... 243 Udostępnianie wybranych elementów innym modułom ........................................................ 245 7.7.1. Zaprzyjaźnione moduły w prostym przypadku ........................................................... 246 7.7.2. Do czego warto używać InternalsVisibleTo? .............................................................. 247 7.7.3. InternalsVisibleTo i moduły podpisane ....................................................................... 247 Podsumowanie ............................................................................................................................ 248
Część III C# 3 — rewolucja w metodzie programowania
251
8.
253
Redukcja nadmiarowości przez zmyślny kompilator 8.1. 8.2.
8.3.
Właściwości implementowane automatycznie ........................................................................ 255 Zmienne lokalne o typie niejawnym ........................................................................................ 257 8.2.1. Zastosowanie var do deklarowania zmiennych lokalnych .......................................... 257 8.2.2. Ograniczenia w typach niejawnych ............................................................................. 259 8.2.3. Zalety i wady typów niejawnych .................................................................................. 260 8.2.4. Zalecenia ....................................................................................................................... 261 Uproszczona inicjalizacja .......................................................................................................... 262 8.3.1. Definicja prostych typów ............................................................................................. 262 8.3.2. Ustawianie prostych właściwości ................................................................................. 263 8.3.3. Ustawianie właściwości obiektów zagnieżdżonych .................................................... 265 8.3.4. Inicjalizatory kolekcji .................................................................................................... 266 8.3.5. Zastosowanie inicjalizatorów ........................................................................................ 269
Spis treści
8.4. 8.5.
8.6.
9.
Tablice o typie niejawnym ........................................................................................................ 270 Typy anonimowe ........................................................................................................................ 272 8.5.1. Pierwsze spotkania z gatunkiem anonimowym ........................................................... 272 8.5.2. Części składowe typów anonimowych ......................................................................... 274 8.5.3. Inicjalizatory odwzorowujące ...................................................................................... 275 8.5.4. Jaki to ma sens? ............................................................................................................. 276 Podsumowanie ............................................................................................................................ 277
Wyrażenia lambda i drzewa wyrażeń 9.1.
9.2. 9.3.
9.4.
9.5.
11
279
Wyrażenia lambda i delegaty ................................................................................................... 281 9.1.1. Przygotowanie — wprowadzenie delegatów typu Func .................................. 281 9.1.2. Pierwsze przekształcenie na wyrażenie lambda ......................................................... 282 9.1.3. Używanie pojedynczego wyrażenia jako ciała ............................................................. 283 9.1.4. Lista parametrów o typie niejawnym .......................................................................... 284 9.1.5. Skrót dla pojedynczego parametru .............................................................................. 284 Proste przykłady użycia List i zdarzeń ........................................................................... 286 9.2.1. Filtrowanie, sortowanie i operacje na listach .............................................................. 286 9.2.2. Logowanie w metodach obsługi zdarzeń ..................................................................... 288 Drzewa wyrażeń ......................................................................................................................... 289 9.3.1. Budowanie drzew wyrażeń w sposób programistyczny ............................................. 289 9.3.2. Kompilowanie drzew wyrażeń do postaci delegatów ................................................. 291 9.3.3. Konwersja wyrażeń lambda C# na drzewa wyrażeń .................................................. 292 9.3.4. Drzewa wyrażeń w sercu LINQ .................................................................................. 296 9.3.5. Drzewa wyrażeń poza LINQ ....................................................................................... 297 Zmiany we wnioskowaniu typów i rozwiązywaniu przeciążeń ............................................. 299 9.4.1. Powód do zmiany — usprawnienie wywołań metod generycznych .......................... 300 9.4.2. Wnioskowanie typu zwracanego funkcji anonimowych ............................................. 301 9.4.3. Dwufazowe wnioskowanie typu ................................................................................... 302 9.4.4. Wybieranie odpowiedniego przeciążenia metody ...................................................... 306 9.4.5. Podsumowanie wnioskowania typów i rozwiązywania przeciążeń ............................ 308 Podsumowanie ............................................................................................................................ 308
10. Metody rozszerzające
311
10.1. Życie przed metodami rozszerzającymi .................................................................................. 312 10.2. Składnia metod rozszerzających .............................................................................................. 315 10.2.1. Deklarowanie metod rozszerzających ......................................................................... 315 10.2.2. Wywoływanie metod rozszerzających ......................................................................... 316 10.2.3. Wykrywanie metod rozszerzających ............................................................................ 318 10.2.4. Wywołanie metody na pustej referencji ...................................................................... 319 10.3. Metody rozszerzające w .NET 3.5 ............................................................................................ 321 10.3.1. Pierwsze kroki z Enumerable ...................................................................................... 321 10.3.2. Filtrowanie z użyciem Where i spinania wywołań metod ......................................... 323 10.3.3. Antrakt: czy metody Where nie widzieliśmy już wcześniej? ..................................... 325 10.3.4. Projekcja przy użyciu metody Select i typów anonimowych ..................................... 326 10.3.5. Sortowanie przy użyciu OrderBy ................................................................................. 327 10.3.6. Przykłady logiki biznesowej z użyciem łańcuchowania ............................................. 328 10.4. Wskazówki i uwagi odnośnie do użycia ................................................................................... 330 10.4.1. „Rozszerzanie świata” i wzbogacanie interfejsów ....................................................... 330 10.4.2. Płynne interfejsy ........................................................................................................... 331 10.4.3. Rozważne użycie metod rozszerzających .................................................................... 332 10.5. Podsumowanie ............................................................................................................................ 334
12
SPIS TREŚCI
11. Wyrażenia kwerendowe i LINQ dla Obiektów
335
11.1. Wprowadzenie do LINQ ........................................................................................................... 336 11.1.1. Podstawowe koncepcje w LINQ ................................................................................. 336 11.1.2. Definiowanie przykładowego modelu danych ............................................................ 341 11.2. Prosty początek — wybieranie elementów .............................................................................. 343 11.2.1. Rozpoczynanie od źródła i kończenie na selekcji ....................................................... 343 11.2.2. Translacja kompilatora jako podstawa wyrażeń kwerendowych ................................ 344 11.2.3. Zmienne zakresu i projekcje nietrywialne .................................................................. 347 11.2.4. Cast, OfType i zmienne zakresu o typie jawnym ........................................................ 349 11.3. Filtrowanie i porządkowanie sekwencji .................................................................................. 351 11.3.1. Filtrowanie przy użyciu klauzuli where ...................................................................... 351 11.3.2. Zdegenerowane wyrażenia kwerendowe .................................................................... 352 11.3.3. Porządkowanie przy użyciu klauzuli orderby ............................................................. 353 11.4. Klauzule let i identyfikatory przezroczyste ............................................................................. 356 11.4.1. Wprowadzenie do wykonania pośredniego z użyciem let .......................................... 356 11.4.2. Identyfikatory przezroczyste ........................................................................................ 357 11.5. Złączenia ..................................................................................................................................... 359 11.5.1. Złączenia wewnętrzne korzystające z klauzul join ...................................................... 359 11.5.2. Złączenia grupowe z użyciem klauzul join … into ..................................................... 363 11.5.3. Złączenia krzyżowe i spłaszczanie sekwencji przy użyciu wielokrotnych klauzul from ..................................................................... 366 11.6. Grupowania i kontynuacje ........................................................................................................ 369 11.6.1. Grupowanie przy użyciu klauzuli group ... by ............................................................ 369 11.6.2. Kontynuacje zapytań .................................................................................................... 373 11.7. Wybór pomiędzy wyrażeniami kwerendowymi a notacją kropkową ................................... 375 11.7.1. Operacje wymagające notacji kropkowej .................................................................... 376 11.7.2. Wyrażenia kwerendowe, dla których prostszym rozwiązaniem może się okazać notacja kropkowa ............................................................................... 377 11.7.3. Gdzie wyrażenia kwerendowe lśnią? ........................................................................... 377 11.8. Podsumowanie ............................................................................................................................ 379
12. LINQ — nie tylko kolekcje
381
12.1. Odpytywanie bazy danych przez LINQ dla SQL-a ................................................................ 382 12.1.1. Zaczynamy od bazy danych i modelu .......................................................................... 383 12.1.2. Zapytania wstępne ........................................................................................................ 385 12.1.3. Zapytania wymagające złączeń .................................................................................... 388 12.2. Translacje przy użyciu IQueryable i IQueryProvider ........................................................... 390 12.2.1. Wprowadzenie do IQueryable i związanych z nim interfejsów ....................... 391 12.2.2. Prototyp — implementacja interfejsów wykonująca wpisy w dzienniku .................. 393 12.2.3. Spajanie wszystkiego razem — metody rozszerzające typu Queryable .................... 395 12.2.4. Udawany dostawca w działaniu ................................................................................... 397 12.2.5. Podsumowanie IQueryable .......................................................................................... 398 12.3. Interfejsy zaprzyjaźnione z LINQ i LINQ dla XML-a .......................................................... 399 12.3.1. Rdzenne typy LINQ dla XML-a .................................................................................. 399 12.3.2. Konstrukcja deklaratywna ............................................................................................ 401 12.3.3. Zapytania na pojedynczych węzłach ............................................................................ 404 12.3.4. Operatory zapytań spłaszczonych ................................................................................ 406 12.3.5. Praca w harmonii z LINQ ............................................................................................ 407
Spis treści
13
12.4. Zastąpienie LINQ dla Obiektów Równoległym LINQ .......................................................... 408 12.4.1. Kreślenie zbioru Mandelbrota przez pojedynczy wątek ............................................ 409 12.4.2. Wprowadzenie ParallelEnumerable, ParallelQuery i AsParallel ............................... 410 12.4.3. Podkręcanie zapytań równoległych ............................................................................. 411 12.5. Odwrócenie modelu zapytania przy użyciu LINQ dla Rx ..................................................... 413 12.5.1. IObservable i IObserver ............................................................................ 414 12.5.2. Zaczynamy (ponownie) łagodnie .................................................................................. 416 12.5.3. Odpytywanie obiektów obserwowalnych .................................................................... 417 12.5.4. Jaki to wszystko ma sens? ............................................................................................. 419 12.6. Rozszerzanie LINQ dla Obiektów ........................................................................................... 420 12.6.1. Wytyczne odnośnie do projektu i implementacji ....................................................... 421 12.6.2. Proste rozszerzenie — wybieranie losowego elementu ............................................. 422 12.7. Podsumowanie ............................................................................................................................ 424
Część IV C# 4 — dobra współpraca z innymi interfejsami
427
13. Małe zmiany dla uproszczenia kodu
429
13.1. Parametry opcjonalne i argumenty nazwane .......................................................................... 430 13.1.1. Parametry opcjonalne ................................................................................................... 430 13.1.2. Argumenty nazwane ..................................................................................................... 437 13.1.3. Złożenie dwóch cech w całość ..................................................................................... 441 13.2. Usprawnienia we współpracy z COM ...................................................................................... 446 13.2.1. Horror automatyzacji Worda przed C# 4 ................................................................... 446 13.2.2. Zemsta parametrów opcjonalnych i argumentów nazwanych .................................... 447 13.2.3. Kiedy parametr ref nie jest parametrem ref? .............................................................. 448 13.2.4. Wywoływanie indekserów nazwanych ........................................................................ 449 13.2.5. Łączenie głównych bibliotek COM-owych ................................................................. 451 13.3. Wariancja typów generycznych dla interfejsów i delegatów ................................................ 453 13.3.1. Typy wariancji: kowariancja i kontrawariancja ........................................................... 454 13.3.2. Użycie wariancji w interfejsach ................................................................................... 455 13.3.3. Zastosowanie wariancji w delegatach .......................................................................... 458 13.3.4. Złożone sytuacje ............................................................................................................ 459 13.3.5. Restrykcje i uwagi ......................................................................................................... 461 13.4. Mikroskopijne zmiany w blokadach i zdarzeniach w formie pól .......................................... 465 13.4.1. Solidne blokowanie ....................................................................................................... 465 13.4.2. Zmiany w zdarzeniach w formie pól ............................................................................ 467 13.5. Podsumowanie ............................................................................................................................ 467
14. Dynamiczne wiązanie w języku statycznym
469
14.1. Co? Kiedy? Dlaczego? Jak? ....................................................................................................... 471 14.1.1. Czym są typy dynamiczne? .......................................................................................... 471 14.1.2. Kiedy typy dynamiczne są użyteczne i dlaczego? ....................................................... 472 14.1.3. W jaki sposób C# zapewnia typy dynamiczne? ......................................................... 474 14.2. Pięciominutowy przewodnik po typie dynamic ...................................................................... 474 14.3. Przykłady użycia typów dynamicznych ................................................................................... 477 14.3.1. COM w ogólności i Microsoft Office w szczególności ............................................... 477 14.3.2. Języki dynamiczne, takie jak IronPython .................................................................... 479 14.3.3. Typy dynamiczne w kodzie całkowicie zarządzanym ................................................. 484 14.4. Zaglądamy pod maskę ............................................................................................................... 490 14.4.1. Wprowadzenie do DLR ............................................................................................... 491 14.4.2. Fundamentalne koncepcje DLR .................................................................................. 493
14
SPIS TREŚCI
14.4.3. Jak kompilator C# obsługuje słowo dynamic? ........................................................... 496 14.4.4. Kompilator C# staje się jeszcze sprytniejszy ............................................................. 500 14.4.5. Ograniczenia kodu dynamicznego ............................................................................... 503 14.5. Implementacja zachowania dynamicznego ............................................................................. 506 14.5.1. Użycie klasy ExpandoObject ....................................................................................... 506 14.5.2. Użycie klasy DynamicObject ....................................................................................... 511 14.5.3. Implementacja IDynamicMetaObjectProvider .......................................................... 518 14.6. Podsumowanie ............................................................................................................................ 522
15. Jaśniejsze wyrażanie kodu przy użyciu kontraktów kodu
523
15.1. Życie przed kontraktami kodu .................................................................................................. 525 15.2. Wprowadzenie do kontraktów kodu ........................................................................................ 527 15.2.1. Warunki wstępne .......................................................................................................... 529 15.2.2. Warunki końcowe ......................................................................................................... 530 15.2.3. Inwarianty ..................................................................................................................... 531 15.2.4. Asercje i założenia ........................................................................................................ 533 15.2.5. Kontrakty legacyjne ...................................................................................................... 534 15.3. Przepisywanie kodu binarnego przez ccrewrite i ccrefgen ................................................... 536 15.3.1. Proste przepisywanie .................................................................................................... 536 15.3.2. Dziedziczenie kontraktów ............................................................................................ 538 15.3.3. Referencyjne moduły kontraktów ................................................................................ 541 15.3.4. Zachowanie w przypadku porażki ................................................................................ 543 15.4. Sprawdzanie statyczne .............................................................................................................. 545 15.4.1. Wprowadzenie do analizy statycznej ........................................................................... 545 15.4.2. Zobowiązania niejawne ................................................................................................ 548 15.4.3. Selektywne włączanie opcji .......................................................................................... 551 15.5. Dokumentowanie kodu przy użyciu ccdocgen ........................................................................ 554 15.6. Kontrakty w praktyce ................................................................................................................ 556 15.6.1. Filozofia — czym jest kontrakt? ................................................................................... 557 15.6.2. Jak mam zacząć? ........................................................................................................... 558 15.6.3. Opcje, wszędzie opcje .................................................................................................. 559 15.7. Podsumowanie ............................................................................................................................ 562
16. Dokąd teraz? 16.1. 16.2. 16.3. 16.4.
565
C# — połączenie tradycji z nowoczesnością .......................................................................... 566 Spotkanie .NET z informatyką ................................................................................................. 567 Świat informatyki ....................................................................................................................... 568 Pożegnanie .................................................................................................................................. 569
Dodatki
571
A
573
Standardowe operatory kwerendowe LINQ A.1. A.2. A.3. A.4. A.5. A.6. A.7. A.8. A.9.
Agregacja .................................................................................................................................... 574 Konkatenacja .............................................................................................................................. 575 Konwersja ................................................................................................................................... 575 Operatory jednoelementowe .................................................................................................... 577 Równość ...................................................................................................................................... 578 Generacja .................................................................................................................................... 579 Grupowanie ................................................................................................................................ 580 Złączenia ..................................................................................................................................... 580 Partycjonowanie ......................................................................................................................... 582
Spis treści
A.10. A.11. A.12. A.13. A.14.
B
B.3. B.4. B.5. B.6.
B.7.
C
Projekcja ..................................................................................................................................... 582 Kwantyfikatory ........................................................................................................................... 583 Filtrowanie .................................................................................................................................. 584 Operatory bazujące na zbiorach .............................................................................................. 584 Sortowanie .................................................................................................................................. 585
Kolekcje generyczne w .NET B.1. B.2.
C.3.
C.4. C.5.
C.6.
587
Interfejsy ..................................................................................................................................... 588 Listy ............................................................................................................................................. 590 B.2.1. List ........................................................................................................................ 590 B.2.2. Tablice ........................................................................................................................... 591 B.2.3. LinkedList ............................................................................................................ 592 B.2.4. Collection, BindingList, ObservableCollection i KeyedCollection ............................................................................. 593 B.2.5. ReadOnlyCollection i ReadOnlyObservableCollection ........................... 594 Słowniki ....................................................................................................................................... 594 B.3.1. Dictionary ........................................................................................ 594 B.3.2. SortedList i SortedDictionary .......................... 595 Zbiory .......................................................................................................................................... 596 B.4.1. HashSet ................................................................................................................ 597 B.4.2. SortedSet (.NET 4) .............................................................................................. 597 Queue i Stack ........................................................................................................... 598 B.5.1. Queue ................................................................................................................... 598 B.5.2. Stack ..................................................................................................................... 598 Kolekcje konkurencyjne (.NET 4) ............................................................................................ 598 B.6.1. IProducerConsumerCollection i BlockingCollection .............................. 599 B.6.2. ConcurrentBag, ConcurrentQueue, ConcurrentStack ................... 600 B.6.3. ConcurrentDictionary .................................................................... 600 Podsumowanie ............................................................................................................................ 600
Podsumowanie wersji środowisk .NET C.1. C.2.
15
603
Główne wersje dystrybucyjne środowiska typu desktop ....................................................... 604 Cechy języka C# ........................................................................................................................ 605 C.2.1. C# 2.0 ............................................................................................................................ 605 C.2.2. C# 3 ............................................................................................................................... 605 C.2.3. C# 4.0 ............................................................................................................................ 606 Biblioteki środowiska ................................................................................................................. 606 C.3.1. .NET 2.0 ........................................................................................................................ 606 C.3.2. .NET 3.0 ........................................................................................................................ 606 C.3.3. .NET 3.5 ........................................................................................................................ 607 C.3.4. .NET 4 ........................................................................................................................... 607 Cechy środowiska uruchomieniowego (CLR) ......................................................................... 608 C.4.1. CLR 2.0 ......................................................................................................................... 608 C.4.2. CLR 4.0 ......................................................................................................................... 609 Inne rodzaje środowiska uruchomieniowego .......................................................................... 609 C.5.1. Compact Framework .................................................................................................... 609 C.5.2. Silverlight ...................................................................................................................... 610 C.5.3. Micro Framework ......................................................................................................... 611 Podsumowanie ............................................................................................................................ 611
Skorowidz
613
16
SPIS TREŚCI
Przedmowa
Są dwa rodzaje pianistów. Są pianiści, którzy grają nie z zamiłowania, ale dlatego, że zostali do tego przymuszeni przez swoich rodziców. Są również tacy, którzy grają na fortepianie ze względu na radość, jaką przynosi im tworzenie muzyki. Ich nie trzeba zmuszać do grania. Wręcz przeciwnie — czasami mogliby grać bez końca. Wśród pianistów drugiego typu są tacy, dla których jest to jedynie hobby, i tacy, którzy grają zawodowo. Wymaga to zupełnie innego stopnia zaangażowania, umiejętności i talentu. Profesjonalni pianiści mają pewną swobodę wyboru gatunku i stylu granej przez nich muzyki. Jednak ostatecznie wybory te muszą odpowiadać potrzebom pracodawcy i gustom muzycznym odbiorców. Wśród pianistów drugiego typu są tacy, którzy robią to głównie ze względu na pieniądze. Są również tacy, którzy pragną grać publicznie, nawet jeśli nie wiąże się z tym żaden zysk materialny. Czerpią radość z tego, że mogą wykorzystać swój talent i umiejętności do tworzenia muzyki dla innych. Możliwość zarobienia podczas wykonywania pracy, którą lubią, stanowi podwójną radość. Wśród pianistów drugiego typu są tacy, którzy nauczyli się grać samodzielnie, grają ze słuchu, którzy być może mają wielki talent i umiejętności, ale nie potrafią przekazać tej intuicyjnej wiedzy innym inaczej niż tylko przez samą muzykę. Są również pianiści, którzy zostali wyszkoleni zarówno praktycznie, jak i teoretycznie. Potrafią wytłumaczyć, jakich technik użył kompozytor do wydobycia odpowiednich emocji, i wykorzystują tę wiedzę w trakcie własnej interpretacji danego utworu. Wśród pianistów drugiego typu są tacy, którzy nigdy nie zaglądali do wnętrza swojego instrumentu. Są również tacy, których fascynują zmyślne uchwyty podnoszące filcowe tłumiki na ułamek sekundy przed uderzeniem młotków w struny. Mają własny zestaw
18
PRZEDMOWA
narzędzi do strojenia. Są dumni z faktu, że rozumieją mechanizm instrumentu składającego się z pięciu do dziesięciu tysięcy ruchomych części. Wśród pianistów drugiego typu są tacy, dla których źródłem samorealizacji jest fakt opanowania swojego fachu i wykorzystania go w sposób przynoszący radość i wymierny zysk. Są również tacy, którzy są nie tylko artystami, teoretykami i technikami — pośród swoich licznych zajęć znajdują czas, aby jako mentorzy przekazywać swoją wiedzę innym. Nie mam pojęcia, czy Jon Skeet zalicza się do jednej, czy drugiej kategorii pianistów. Jednak na podstawie przeprowadzonych z nim rozmów, a jest on przecież długoletnim profesjonalistą C# i jednym z bardziej cenionych fachowców w gronie użytkowników tego języka, a także na podstawie przynajmniej trzykrotnej lektury każdej jego książki jest dla mnie jasne, że Jon jest tym drugim typem programisty — entuzjastą o dużej wiedzy, analitykiem oraz człowiekiem utalentowanym i dociekliwym, a zatem prawdziwym nauczycielem. C# jest językiem pragmatycznym, który szybko ewoluuje. Mam nadzieję, że dzięki dodaniu takich cech jak wyrażenia w formie zapytań, bogatszy interfejs typów, zwięzła składnia typów anonimowych i innych udostępniliśmy zupełnie nowy styl programowania, pozostając jednocześnie w zgodzie z ideą języka modularnego z systemem statycznych typów — będącym podstawą sukcesu tego języka. Wiele z tych nowych stylistycznie elementów języka wydaje się, paradoksalnie, bardzo starych (wyrażenia lambda sięgają korzeniami do początków technologii informacyjnej w pierwszej połowie XX wieku), ale jednocześnie są odczuwane jako nowe i obce przez programistów przyzwyczajonych do bardziej współczesnej, zorientowanej obiektowo metody. Jon rozumie to wszystko. Niniejsza książka jest idealną pozycją dla profesjonalnych programistów, którzy odczuwają potrzebę zrozumienia tego, co znalazło się w najnowszej wersji C# oraz jak to coś działa. Jest ona również przeznaczona dla programistów, którzy potrafią lepiej zrozumieć język przez poznanie jego podstawowych zasad projektowych. Chęć skorzystania z tej nowej, niezwykle użytecznej funkcjonalności będzie wymagać przyjęcia nowego sposobu myślenia o danych, funkcjach i ich wzajemnym związku. Tę zmianę można przyrównać do próby zagrania jazzu po długich latach nauki gry klasycznej lub odwrotnie. Tak czy inaczej, z nadzieją wyczekuję rozwiązań, jakie niewątpliwie wprowadzą do języka kolejne pokolenia programistów C#. Życzę udanych kompozycji programowych i dziękuję za wybranie C# do tego celu. Eric Lippert starszy inżynier oprogramowania, Microsoft
Wstęp
Mam wrażenie, jakby upłynęło bardzo dużo czasu od chwili, kiedy pisałem wstęp do pierwszego wydania tej książki, ale było to zaledwie dwa i pół roku temu. W tym czasie zaszło wiele zmian zarówno w moim życiu, jak i w samej technologii. Jeśli chodzi o moje życie osobiste, mogę z dumą oświadczyć, że jestem obecnie inżynierem oprogramowania w Google — jedynym minusem tej sytuacji jest to, że nie poświęcam już w pracy tak dużo czasu językowi C#, jak miało to miejsce kiedyś. Znalazłem sobie nową używkę — forum dyskusyjne poświęcone programistom o nazwie Stack Overflow. Moi najmłodsi synowie mieli zaledwie dwa lata, kiedy ukazała się pierwsza edycja tej książki, teraz zaczynają szkołę. Miałem przywilej wygłoszenia wykładów poświęconych C# w Londynie, Oslo, Kopenhadze i wielu innych miejscach. Była to dla mnie wielka przyjemność, chociaż nie ukrywam, że czasem wydarzenia następowały lawinowo. Świat technologii zmienił się równie mocno. Niniejszy wstęp piszę na netbooku — kiedy pracowałem nad pierwszą edycją, słowo „netbook” nie miało większego znaczenia. Najlepsze smartfony, według dzisiejszych standardów, były całkiem prymitywne, a mój obecny laptop mógłby służyć jako nadzwyczajnie wydajna stacja robocza. Rozwój w dziedzinie oprogramowania był moim zdaniem mniej spektakularny, chociaż wiele projektów (takich jak programowanie równoległe), które w tamtym czasie zaczynały dopiero raczkować, teraz wchodzi do kanonu programowania. Silverlight dopiero co dokonał transformacji na język w pełni zarządzany, ASP.NET MVC był nadal zupełnie młodą technologią, oczekującą na wypuszczenie w wersji 1.0. Oczywiście zmienił się także sam język C# — w przeciwnym razie nie byłoby powodu do napisania drugiej wersji tej książki.
20
WSTĘP
Pomimo wszystkich tych zmian cel tej książki się nie zmienił. Pragnę pomagać ludziom, którzy odczuwają swego rodzaju więź z językiem C#. Wraz z pojawianiem się coraz nowszych frameworków i koniecznością coraz szybszego się ich uczenia posiadanie mocnych podstaw jest niezbędne do dalszego rozwoju. Powinniśmy przynajmniej umieć stwierdzić, kiedy mamy do czynienia ze standardowym wywołaniem metody, odwołaniem do właściwości, konwersją wyrażenia lambda na delegat itd. Uważam, że ogólny poziom wiedzy o języku podnosi się. Byłem wręcz mile zaskoczony, widząc, jak dobrze programiści wydają się rozumieć LINQ — chociaż dla niektórych jest to wciąż czarna magia. Przyznaję, że ten pogląd jest trochę stronniczy, gdyż bazuje na obserwacji aktywności wybranej przeze mnie grupy programistów na forum Stack Overflow, ale mimo to myślę, że są powody do optymizmu. Mam nadzieję, że pierwsza część książki odegrała jakąś niewielką rolę w tym procesie i że ta część również się do tego przyczyni. Staram się nauczyć nowego języka. Bawiłem się trochę F# i Pythonem. Obiecałem sobie, że nauczę się języków Erlang i Haskell. Chcę w końcu sprawdzić, skąd bierze się to całe zamieszanie wokół Ruby… Za każdym razem jednak wciąga mnie C#. Nie jest doskonały, ale w większości przypadków pozwala mi wyrazić to, co mam na myśli, w prosty i przejrzysty sposób. Spośród wszystkich cech, jakie może posiadać język programowania, ta jest chyba najważniejsza. Pewnego dnia być może uda mi się oddalić wystarczająco daleko, aby nauczyć się myśleć w zupełnie innym języku, ale zanim to nastąpi, mam nadzieję, że przekażę Ci trochę mojej pasji do C#.
Podziękowania
Wydawałoby się, że napisanie drugiego wydania książki jest prostsze od pisania pierwszego. Wystarczy dodać kilka rozdziałów, wprowadzić modyfikacje do istniejących i gotowe — prawda? Jeżeli takie podejście wydaje Ci się przekonywające, zestaw ze sobą dla porównania pisanie kodu dla zupełnie nowego projektu i próbę zmodyfikowania istniejącej aplikacji. Teraz wyobraź sobie, że robisz to bez kompilatora i testów jednostkowych. Oczywiście istnieje kilka kosztownych metod sprawdzania jakości książki. Ja mam szczęście, że dołączyłem do zespołu wspaniałych ludzi, którzy mnie wspierali i pilnowali prawidłowego rozwoju tej pozycji. Na pierwszym miejscu jest to moja rodzina, która trwała przy mnie, kiedy poświęcałem kolejne wieczory na pisanie, przepisywanie, poprawianie, indeksowanie i ogólnie spędzanie z nimi o wiele mniejszej ilości czasu, niż bym chciał. Nie zniechęciło to moich synów do pracy z komputerem, a mój najstarszy syn, Tom, dorósł już niemal do rozpoczęcia przygody z programowaniem. Będzie wspaniale, jeśli chociaż częściowo zrozumie, czym jego ojciec zajmuje się cały dzień w pracy. Kiedy zaczynałem wieczorne pisanie, moje dzieci były już na ogół w swoich łóżkach, zatem cała ta sytuacja spadała przede wszystkim na moją żonę — Holly. Pomimo wielu moich zobowiązań moja rodzina pozostaje dla mnie na pierwszym miejscu i jestem jej ogromnie wdzięczny, że wsparła mnie w tym i innych przedsięwzięciach. Formalni recenzenci książki są wymienieni w dalszej kolejności. Ja chciałbym wyrazić swoje osobiste podziękowania wszystkim tym, którzy zamówili kopie wczesnej wersji drugiego wydania, znaleźli w nim literówki i zasugerowali zmiany… oraz bez ustanku wypytywali, kiedy książka będzie dostępna na rynku. Sam fakt, że znaleźli się czytelnicy, którzy chcieli dostać końcową wersję książki, był dla mnie ogromnym wsparciem.
22
PODZIĘKOWANIA
Miałem zawsze dobre relacje z zespołem pracującym w wydawnictwie Manning. Prawdziwą przyjemnością była dla mnie praca z osobami, które znałem z pierwszej edycji książki, jak i pracownikami, których spotkałem po raz pierwszy. Cały proces decyzyjny dotyczący tego, co pozostawić z pierwszej wersji, a co zmienić, nadzorowali płynnie Mike Stephens i Jeff Bleiel. To oni złożyli to dzieło w jedną całość. Benjamin Berg i Katie Tennant przeprowadzili odpowiednio skład redakcyjny i korektę. Nigdy nie usłyszałem z ich ust słowa krytyki odnośnie do moich umiejętności językowych, doboru słów czy ogólnego nieładu w tekście. Pracujący w tle zespół produkcyjny (w składzie: Dottie Marsico, Janet Vail, Marija Tudor i Mary Piergies) jak zwykle dokonał cudów. Jestem im równie wdzięczny. W końcu chciałbym podziękować wydawcy — Marjanowi Bace’owi — za umożliwienie mi stworzenia drugiego wydania, a także przedstawienie szeregu opcji na przyszłość. Przegląd książki przez recenzentów jest niezwykle ważny. Nie chodzi wyłącznie o skorygowanie detali technicznych, ale również o wypracowanie pewnego balansu i tonu książki. Czasami uwagi, które otrzymywałem, wpływały na całkowity obraz książki. Innym razem w odpowiedzi na komentarz dokonywałem jedynie bardzo specyficznych zmian. Oto lista recenzentów, którym dziękuję za ulepszenie tej książki dla nas wszystkich: Michael Caro, Austin Ziegler, Dave Corun, Amos Bannister, Lester Lobo, Marc Gravel, Nikander Bruggeman, Margriet Bruggeman, Joe Albahari, Tyson S. Maxwell, Horaci Macias, Eric Lippert, Kirill Osenkov, Stuart Caborn, Sean Reilly, Aleksy Nudelman, Keith Hill, Josh Heyer i Jared Parsons. W pierwszym wydaniu podziękowałem całemu zespołowi C#, tym razem poszerzam swoje podziękowania na wszystkich ludzi z branży. Zawsze zadziwia mnie fakt, jak życzliwi byli dla mnie różni inżynierowie oprogramowania i menedżerowie projektów, kiedy nachodziłem ich ze swoimi specyficznymi pytaniami lub prośbami przejrzenia fragmentu książki. Niektóre z osób, które wymieniam dalej, mogą nawet nie wiedzieć, że brały udział w pracy nad tą książką, niemniej mają swój udział: Todd Apley, Mike Barnett, Chris Burrows, Wes Dyer, Manuel Fahndrich, Neal Gafter, Eric Lippert, Francesco Logozzo, Erik Meijer, Sam Ng, Kirll Osenkov, Alexandra Rusina, Chris Sells, Mads Torgersen, Stephen Toub i Jeffrey Van Gogh. Na tej liście chciałbym zwrócić szczególną uwagę na Erika Lipperta, który po raz kolejny dokonał korekty merytorycznej, a także napisał przedmowę. Rozmawiałem z Erikiem wielokrotnie od czasu pierwszego wydania książki. Ostatecznie spotkaliśmy się dwukrotnie, a każde z tych spotkań było dla mnie zachwycające. Pozostaję w całkowitym podziwie zarówno w odniesieniu do ogromnej wiedzy Erika, jak i do drobiazgowego, lecz czytelnego sposobu przekazywania tej wiedzy światu na jego blogu oraz na forum Stack Overflow. Nie mogłem sobie wymarzyć lepszego recenzenta i czekam na kolejną możliwość komunikacji z nim na temat wszelkich możliwych kwestii związanych z C#.
O książce
Ta książka jest poświęcona językowi C# od wersji 2 w przód — nic dodać, nic ująć. C# 1 jest zaledwie wspomniany. O bibliotekach środowiska .NET oraz środowisku wykonawczym (CLR) wspominam jedynie, kiedy mają one związek z językiem. Jest to celowa decyzja, w wyniku której powstała książka zupełnie odmienna od innych pozycji poświęconych C# i .NET, jakie widziałem. Zakładając dostateczną ilość wiedzy na temat C# 1, unikam poświęcania setek stron na opisywanie materiału, który moim zdaniem większość Czytelników już rozumie. W ten sposób zyskuję znacznie więcej miejsca na szczegóły dotyczące C# 2, 3 i 4 — zakładam, że właśnie z tego powodu czytasz tę pozycję. Uważam, że wielu programistów byłoby mniej sfrustrowanych w odniesieniu do swojej pracy, gdyby odczuwali głębszy związek z językiem, w którym piszą. Wiem, że pisanie o „związku” z językiem programowania brzmi dziwnie, ale to najlepszy sposób, w jaki potrafię się wyrazić. Niniejsza książka jest moją próbą pomocy Czytelnikowi w osiągnięciu tego samego sposobu pojmowania lub pogłębienia go. Sama książka nie wystarczy — powinna ona stanowić jedynie wsparcie podczas pisania kodu, prezentować ciekawostki do samodzielnego analizowania, a także wyjaśniać, dlaczego kod zachowuje się w taki, a nie inny sposób.
Do kogo jest kierowana ta książka Byłem bardzo mile zaskoczony reakcją środowiska programistów na pierwsze wydanie książki. Widziałem jej rekomendacje w różnych miejscach, w szczególności na forum Stack Overflow (przyznaję, że moja obecność na tym forum mogła mieć pewien wpływ
24
O KSIĄŻCE
na tę sytuację). Książka jest polecana w szczególności tym, którzy chcą wiedzieć, co dokładnie dzieje się w ich kodzie. Czasem jednak sugerowana jest również jako podręcznik dla początkujących. Uważam, że jest to trochę niefortunna sugestia. Chociaż możliwe jest przebrnięcie przez treść książki z jednoczesnym wyszukiwaniem koncepcji stojących u podstaw C# 1, uważam, że o wiele lepsze będzie przeczytanie tej książki w połączeniu z inną, która zaczyna od zupełnych podstaw. Jeżeli nie dokonałeś swojego wyboru takiej książki, warto wspomnieć, że na rynku można znaleźć wiele dobrych książek poświęconych C# — wiele z nich stanowi doskonałe uzupełnienie pozycji, którą trzymasz w ręku. Dotarło do mnie wiele recenzji czytelników, którzy rozpoczęli swoją przygodę od książek pozbawionych jakichkolwiek założeń początkowych, a następnie stopniowo zagłębiali się w tę książkę. Jako jedną z możliwych pozycji polecam C# 4.0 in a Nutshell (O’Reilly, 2010) — opisuje ona C# od podstaw, a także zagłębia się w jądro środowiska .NET. Nie twierdzę, że lektura tej książki uczyni z Ciebie wyśmienitego programistę. Inżynieria oprogramowania to coś więcej niż tylko znajomość składni języka, którego w danej chwili używasz. Mimo że w kolejnych rozdziałach staram się udzielać pewnych wskazówek (w drugim wydaniu jest ich więcej niż w pierwszym), to w ostatecznym rozrachunku — chociaż nie wszyscy są skłonni to przyznać — programowanie opiera się w dużym stopniu na własnym przeczuciu. Twierdzę, że po przeczytaniu i zrozumieniu tej książki powinieneś osiągnąć poziom komfortu w C#, który pozwoli Ci bez większych lęków podążyć za własnym instynktem. Nie chodzi tutaj bynajmniej o pisanie kodu, którego nikt nie zrozumie ze względu na zastosowane niuanse i szczególne przypadki użycia. Chodzi o pewność co do opcji oferowanych przez język i umiejętność wybrania takich ścieżek programowania, które zapewniają największą szansę sukcesu.
Jak jest podzielona ta książka Struktura książki jest prosta. Składają się na nią cztery części i trzy dodatki. Pierwsza część stanowi wprowadzenie. Przypominam w niej zagadnienia z języka C# 1 — istotne dla zrozumienia C# 2 i 3 — które często są mylnie interpretowane. Druga część opisuje nowe cechy języka C# 2, a trzecia nowe cechy C# 3. Jako ćwiczenie intelektualne pozostawiam użytkownikowi odgadnięcie zawartości czwartej części książki. Z powodu takiej organizacji materiału do niektórych zagadnień będziemy powracać kilkakrotnie. Na przykład delegaty zostały poprawione w C# 2, a potem ponownie w C# 3. Zapewniam jednak, że w tym szaleństwie jest metoda. Przypuszczam, że pewna część użytkowników będzie korzystać z różnych wersji języka w zależności od projektu. Taka sytuacja może mieć miejsce na przykład, kiedy w pracy jesteś zobowiązany do używania C# 3, natomiast w domu eksperymentujesz z C# 4. Z tego powodu warto objaśnić, co można znaleźć w każdej z wersji języka. Inny powód to możliwość pokazania kontekstu historycznego oraz ewolucji narzędzia — przekonasz się, jak język rozrastał się wraz z upływem czasu. W rozdziale 1. ustanawiamy pewne warunki początkowe. Weźmiemy prosty fragment kodu w C# 1 i zaczniemy go przekształcać, przyglądając się jednocześnie, jak kolejne wersje języka zwiększają czytelność i sprawność kodu. Spojrzymy na kontekst historyczny, w którym rozwijał się język, a także na kontekst techniczny, w którym C#
O książce
25
współdziała jako element kompletnej platformy (C# jako język bazuje na bibliotekach środowiska .NET oraz wydajnym środowisku wykonania — dzięki temu może zamienić abstrakcyjny kod w rzeczywisty program). W rozdziale 2. dokonamy refleksji na temat C# 1 oraz trzech konkretnych cech: delegatów, charakterystyki systemu typów oraz różnic pomiędzy typami wartościowymi i referencyjnymi. Te trzy tematy, często rozumiane przez programistów C# 1 na poziomie „zaledwie wystarczającym”, zmieniały się intensywnie wraz z rozwojem języka. Potrzebna jest ugruntowana wiedza, aby móc w pełni skorzystać z tych nowych cech języka. Rozdział 3. podejmuje temat największej i prawdopodobnie najtrudniejszej do zrozumienia cechy języka C# — programowania generycznego. Typy i metody mogą być pisane w sposób ogólny, z użyciem parametrów w miejsce faktycznych typów danych. Potrzebny typ jest specyfikowany dopiero w momencie użycia konkretnego typu lub metody. Początkowo możesz uznać tę ideę za równie niejasną jak ten opis. Kiedy jednak zrozumiesz, na czym polega programowanie generyczne, będziesz zdziwiony, jak wcześniej mogłeś sobie radzić bez niego. Jeżeli kiedykolwiek chciałeś użyć wartości null, rozdział 4. jest dla Ciebie. W nim przedstawiam typy nullowalne — cechę, która bazuje na metodach i typach generycznych oraz wykorzystuje wsparcie ze strony języka, biblioteki oraz środowiska wykonania. Rozdział 5. prezentuje poprawki wprowadzone do delegatów w C# 2. Być może do tej pory używałeś delegatów jedynie do obsługi takich zdarzeń jak kliknięcie myszką. C# 2 w znacznym stopniu ułatwia tworzenie delegatów, a wsparcie ze strony biblioteki sprawia, że są one o wiele bardziej użyteczne w sytuacjach innych niż obsługa zdarzeń. W rozdziale 6. przyjrzymy się iteratorom, a także sposobom łatwego ich tworzenia przy użyciu C# 2. Uproszczenia iteratorów są rzadko używane przez programistów. Z czasem ta sytuacja powinna ulec stopniowej poprawie, ponieważ iteratory są podstawą, na której bazuje mechanizm LINQ dla Obiektów. Kluczową cechą LINQ jest również „leniwa” natura wykonania iteratorów. Rozdział 7. prezentuje szereg mniejszych cech języka wprowadzonych w drugiej wersji. Każda z nich w małym stopniu ułatwia życie programisty. Projektanci języka wygładzili rogi w kilku miejscach C# 1, pozwalając na bardziej elastyczną współpracę z typami generycznymi, ulepszając klasy narzędziowe, sposób dostępu do właściwości i nie tylko. Rozdział 8. również jest poświęcony prostym cechom języka, ale tym razem w C# 3. Niemal cała nowa składnia języka jest nakierowana na jeden wspólny cel — LINQ — chociaż jej poszczególne składniki mogą być z powodzeniem stosowane samodzielnie. C# 3 — poprzez takie cechy jak typy anonimowe, właściwości implementowane automatycznie, zmienne lokalne o typie niejawnym czy w dużym stopniu zmodernizowany sposób inicjalizowania obiektów — oferuje o wiele bogatszy język, dzięki któremu kod może lepiej wyrazić swoje zachowanie. W rozdziale 9. przyglądamy się pierwszemu istotnemu rozszerzeniu języka C# 3 — wyrażeniom lambda. Projektanci języka nie do końca zadowoleni ze zwięzłości składni, jaką widzieliśmy w rozdziale piątym, jeszcze bardziej uprościli tworzenie delegatów (w porównaniu do metody obowiązującej w C# 2). Wyrażenia lambda mają większe możliwości — mogą być przekształcane w drzewa wyrażeń (co stanowi doskonały sposób na przedstawianie kodu jako danych).
26
O KSIĄŻCE
W rozdziale 10. przeanalizujemy metody rozszerzające, które umożliwiają „przekonanie” kompilatora, że metody zadeklarowane w jednym typie w rzeczywistości należą do innego typu. Na pierwszy rzut oka może się wydawać, że jest to zupełne nieporozumienie z punktu widzenia czytelności kodu. Po dokładnym przeanalizowaniu przekonasz się jednak, że mechanizm ten jest niesamowicie funkcjonalny i jednocześnie absolutnie niezbędny dla LINQ. Rozdział 11. łączy trzy poprzednie, aby sformułować wyrażenia kwerendowe — spójną, ale funkcjonalną metodę pobierania danych. Początkowo koncentrujemy się na LINQ dla Obiektów, ale zobaczysz również, jak wzorzec wyrażeń kwerendowych umożliwia płynne podłączanie innych źródeł danych. Rozdział 12. oprowadza w skrócie po różnych metodach użycia LINQ. W pierwszej kolejności zobaczymy zysk wynikający z połączenia wyrażeń kwerendowych z drzewami wyrażeń, czyli jak LINQ dla SQL-a jest w stanie przetworzyć to, co na pierwszy rzut oka wydaje się zwykłem kodem C#, w zapytania SQL. Następnie przyjrzymy się sposobowi projektowania bibliotek w taki sposób, aby integrowały się dobrze z LINQ. Za przykład posłuży nam LINQ dla XML-a. Równoległy LINQ i biblioteka Reactive Extensions pokażą nam dwie alternatywy dla zapytań wewnątrzprocesowych. Całą wycieczkę zakończymy dyskusją, jak — poprzez własne operatory LINQ — możesz rozszerzyć LINQ. Omawianie C# 4 rozpoczniemy w rozdziale 13. od spojrzenia na argumenty nazwane, parametry opcjonalne, ulepszenia na styku z technologią COM i wariancje typów generycznych. Do pewnego stopnia są to cechy bardzo niezależne, ale argumenty nazwane i parametry opcjonalne mają swój udział przy współpracy środowiska .NET z COM, a także w bardziej specyficznych mechanizmach dostępnych jedynie podczas pracy z obiektami COM-owymi. Rozdział 14. opisuje jedyną dużą cechę, która pojawiła się w C# 4 — typy dynamiczne. Możliwość bindowania pól w sposób dynamiczny podczas wykonania programu zamiast statycznego bindowania w czasie kompilacji stanowi ogromną odmianę w języku C#. Typy dynamiczne mają jednak charakter selektywny — są wykonywane wyłącznie w tych fragmentach kodu, w których zostały użyte wartości dynamiczne. Rozdział 15. nie opisuje nowych cech języka, lecz skupia się na jednej z możliwych przyszłości C#. Przyjrzymy się kontraktom kodu — nowemu zestawowi bibliotek i narzędzi firmy Microsoft, który ma umożliwić Ci wyrażenie w sposób o wiele bardziej szczegółowy (w porównaniu do tradycyjnego systemu typów danych), czego oczekują Twoje metody i co gwarantują. Ta idea jest jeszcze bardzo młoda, ale kto wie, czy za kilka lat nie będziesz sobie w stanie wyobrazić pisania nowego kodu bez określenia jego kontraktu. W rozdziale 16. dokonamy podsumowania. Zastanowimy się, skąd przyszliśmy, w jakim obecnie stanie znajduje się nasza branża i co może nas czekać w przyszłości. Dodatki na końcu książki to głównie materiał referencyjny. W dodatku A opisuję standardowe operatory LINQ z przykładami ich użycia. Dodatek B jest poświęcony podstawowym klasom kolekcji generycznych i interfejsom, włączając w to nowe kolekcje konkurencyjne wchodzące w skład .NET 4. Dodatek C to pobieżne spojrzenie na różne wersje środowiska .NET, włączając w to odmiany typu Compact Framework i Silverlight.
O książce
27
Terminologia i typografia Większość terminów użytych w książce jest wyjaśniana w miarę postępu materiału. Jest jednak kilka definicji, które chciałbym podkreślić już teraz. Numeruję wersje języka we w miarę oczywisty sposób — C# 1, C# 2, C# 3 i C# 4. W innych książkach lub witrynach można znaleźć określenia wersji w postaci C# 1.0, C# 2.0, C# 3.0 i C# 4.0. Dla mnie dodatkowe zero jest nadmiarowe, dlatego je pomijam — mam nadzieję, że znaczenie pozostaje jasne dla Czytelnika. Przywłaszczyłem sobie dwa pojęcia pochodzące z książki poświęconej C# autorstwa Marka Michaelisa. W celu uniknięcia błędnego interpretowania wyrażenia „czasu wykonania” (ang. runtime) jako odnoszącego się do środowiska wykonania, takiego jak CLR (ang. Common Language Runtime), lub do konkretnego punktu w czasie (jak w zdaniu „nadpisanie ma miejsce w czasie wykonania”) Mark używa odmiennych nazw dla obu tych przypadków1. Pozwoliłem sobie podążyć za jego przykładem. Regularnie odnoszę się do specyfikacji języka lub (prościej) do specyfikacji. O ile nie zaznaczę inaczej, mam na myśli specyfikację języka C#. Ze względu na mnogość wersji języka, a także sam proces standaryzacji istnieje wiele wersji tej specyfikacji. Wszelkie odwołania do konkretnych numerów rozdziałów odnoszą się do specyfikacji C# 4 firmy Microsoft. Książka zawiera liczne fragmenty kodu drukowane czcionką o stałej szerokości. W taki sam sposób są przedstawione wyniki ich wykonania. Niektórym listingom towarzyszą objaśnienia. Fragmenty kodu ważne z punktu widzenia omawianej treści zostały wytłuszczone. Niemal wszystkie listingi mają charakter fragmentaryczny, dzięki czemu nie zajmują dużo miejsca i mogą być uruchamiane po umieszczeniu w odpowiednim środowisku. Tym środowiskiem jest Snippy — narzędzie, które zostanie wprowadzone w podrozdziale 1.7. Snippy, a także fragmenty kodu z całej książki (w formie częściowych listingów lub pełnych projektów Visual Studio) można pobrać z witryny książki — www.helion.pl/ksiazki/cshop2.htm.
Kod źródłowy Kod źródłowy dla wszystkich przykładów z tej książki można pobrać z witryny wydawcy — www.helion.pl/ksiazki/cshop2.htm.
1
W tekście oryginalnym autor stosuje termin runtime w odniesieniu do środowiska wykonania programu oraz execution time w odniesieniu do konkretnego punktu w czasie wykonania programu. W tekście tłumaczonym rozróżnienie to powinno być łatwo zauważalne. Mówiąc na przykład o CLR, będziemy używać pojęcia środowisko czasu wykonania (lub w skrócie środowisko wykonawcze). Opisując konkretne zdarzenia w czasie pracy programu, powiemy, że mają one miejsce w czasie wykonania programu (lub prościej w czasie wykonania) — przyp. tłum.
28
O KSIĄŻCE
O autorze Trzeba uczciwie przyznać, że nie jestem typowym programistą C#. W ciągu ostatnich dwóch lat poświęcałem swój czas językowi C# niemal wyłącznie dla zabawy — można powiedzieć, że jest to pewnego rodzaju obsesyjne hobby. Zawodowo, jako członek zespołu Google Mobile w Londynie, zajmowałem się programowaniem po stronie serwera w Javie. Na tej podstawie mogę powiedzieć, że nic nie pozwala docenić nowych cech języka tak jak programowanie w innym języku, który tych cech nie posiada, ale jest wystarczająco podobny, aby o tym nieustannie przypominać. Starałem się na bieżąco śledzić to, co inni programiści uważają za trudne w C#. Przyglądałem się uważnie tematom na forum Stack Overflow, publikowałem ciekawostki na swoim blogu i od czasu do czasu prowadziłem wykłady dotyczące C# i tematów pokrewnych w każdym miejscu, w którym tylko znaleźli się ludzie gotowi do wysłuchania mnie. Podsumowując — C# pozostaje nadal w kręgu moich ścisłych zainteresowań. Pomimo mojego, wciąż zaskakującego, statusu mikrocelebryty (w związku z działalnością na forum Stack Overflow) pod wieloma względami pozostaję zwykłym programistą. Piszę sporo kodu, z którego nie jestem dumny, kiedy wracam do niego po jakimś czasie. Moje testy jednostkowe nie zawsze stoją na pierwszym miejscu… a często wręcz nie istnieją. Bardzo często robię „o jeden błąd za dużo”. Część specyfikacji języka C# poświęcona interfejsowi typów nadal sprawia mi trudność. Są również pewne aspekty Javy, które sprawiają, że czasem mam ochotę zwyczajnie się poddać. Jestem ułomnym programistą i tak powinno pozostać. Przez kilkaset kolejnych stron będę udawał, że jest inaczej: będę prezentował najlepsze praktyki, tak jakbym je zawsze stosował, i przestrzegał przed brzydkimi skrótami, tak jakbym nigdy nie miał nawet ochoty spróbować ich zastosować. Nie dajcie się zwieść. W rzeczywistości jestem prawdopodobnie taki jak Wy. Zrządzeniem losu wiem odrobinę więcej na temat tego, jak działa C#, i to wszystko, ale nawet ten stan rzeczy będzie trwał tylko do chwili, kiedy skończycie lekturę tej książki.
Okładka książki Na okładce książki znajduje się „Muzyk”. Ilustracja pochodzi z kolekcji kostiumów zawartych w dziele Imperium Osmańskie, wydanym 1 stycznia 1802 roku przez Williama Millera z Old Bond Street w Londynie. W kolekcji brakuje strony tytułowej, dlatego nie byliśmy w stanie ustalić dokładnej daty wydania. Spis treści opisuje postacie zarówno w języku angielskim, jak i francuskim. Każda ilustracja jest opatrzona nazwiskami dwóch artystów, którzy ją opracowali i którzy byliby zapewne zaskoczeni faktem użycia ich dzieł na okładce książki poświęconej programowaniu komputerów… dwieście lat później. Kolekcja została zakupiona przez jednego z redaktorów wydawnictwa Manning na pchlim targu antykwariuszy w „Garażu” na West 26th Street na Manhattanie. Sprzedawcą był Amerykanin mieszkający na stałe w Ankarze (Turcja), a sama transakcja została zawarta w chwili, kiedy sprzedawca pakował już swoje rzeczy przed opuszczeniem targu. Redaktor nie miał przy sobie odpowiedniej ilości gotówki potrzebnej do dokonania
O książce
29
zakupu, natomiast sprzedający uprzejmie odmówił transakcji przy użyciu karty kredytowej i czeku. Dramatyzmu sytuacji dodał fakt, iż sprzedawca zamierzał jeszcze w tym dniu wylecieć samolotem z powrotem do Ankary. Jak została rozwiązana ta sytuacja? Transakcja została zawarta przez tradycyjną umowę ustną, przypieczętowaną uściskiem dłoni. Sprzedawca zaproponował po prostu, aby pieniądze zostały przesłane do niego przelewem. Kupujący wyszedł z targu, trzymając w ręku notatkę z numerem konta bankowego i kolekcję ilustracji pod pachą. Pieniądze zostały przesłane następnego dnia. My byliśmy niezmiernie wdzięczni i niezwykle zaskoczeni zaufaniem, jakim obdarzyła nas zupełnie obca osoba. Takie zachowanie kojarzy się z czymś, co mogło się wydarzyć jedynie bardzo dawno temu.
30
O KSIĄŻCE
Część I
Przygotowanie do wyprawy �
��
�� � �
ażdy Czytelnik podejdzie do tej innym poziomem doświadczei1 i ocze kiwaiL Być może jesteś ekspert � swojej dziedzinie, szukającym materiału do uzupełnienia drobnych l swojej wiedzy. Może uważasz siebie za prze ciętnego programistę z odrobin�mdczenia w typach generycznych oraz wyraże niach lambda i chęcią pogłęb edzy. Może, wreszcie, masz doskonalą wiedzę na temat C# 2 i 3, ale żad e d �adczenia w C# 4. Jako autor nie jeste st�nie sprawić, aby każdy Czytelnik był taki sam, co wię� n et wtedy, gdybym mógł. Mam jednak nadzieję, że wszyscy cej, nie chciałbym � . Czytelnicy mają � wspólne cechy: pragnienie dogłębnego poznania języka C# ' i przynajmniej,,;�,1Ódstawową wiedzę na jego temat. Jeśli masz te dwie rzeczy, ja posta ram się zape�esztę. Głównym powodem, dla którego powstała ta część książki, jest potencjalnie duża rozbieżność umiejętności Czytelników. Równie prawdopodobne jest to, że wiesz już, czego można oczekiwać od kolejnych wersji języka, jak i to, że będzie to dla Ciebie zupełnie nowa rzecz. Możesz mieć ugruntowaną wiedzę na temat C# l lub czuć się nadal niepewnie, przynajmniej jeśli chodzi o pewne aspekty języka, które jednak będą nabierać coraz większego znaczenia wraz z poznawaniem C# 2, 3 i 4. Koileząc lekturę tej części, będziesz gotowy do rozpoczęcia pracy z materiałem przedstawionym w dal szej części. W pierwszych dwóch rozdziałach będziemy patrzeć zarówno wstecz, jak i w przód. Jednym z kluczowych elementów tej książki jest ewolucja. Zanim do języka C# zo stanie wprowadzona nowa cecha, zespół projektowy szczegółowo ją analizuje pod ką tem obecnej postaci języka, a także ogólnych celów dalszego rozwoju. Takie podejście sprawia, że język, nawet w środku przemian, jest spójny. Żeby zrozumieć, jak i dla czego język ewoluuje, musimy zrozumieć, skąd przyszliśmy i dokąd zmierzamy.
K
� �-
..
Nasze pierwsze zmiany (pokazane na list�1...L2) rozprawią się z dwoma pierwszymi ich skład wejdzie najważniejsza nowa problemami wypunktowanymi przed c cecha C# 2-typy generyczne. Zm � fragmenty kodu zostały wytłuszczone.
t::'j��
decimal price: public decimal Price { get { return price: } private set { price= value; }
public Product(string name. decimal price) {
Name
name:
=
Price
=
price:
public static List GetSampleProducts() {
List list= new List();
Na początek prosty typ danych
l
37
list. Add(new Product("Ogniem i mieczem". 9. 99m)); list. Add(new Product("Potop", 14. 99m)) ; list. Add(new Product("Krzyżacy", 13. 99m)) ; list. Add(new Product("Faraon". lO. 99m)) ; return list ;
public override string ToString() { return string. Format("{O}: {l}". name. price) ;
Mamy teraz właściwości z prywatnymi setterami (i używamy ich w konstruktorze). Nietrudno się również domyślić, że L i st informuje kompilator o tym, że lista zawiera obiekty klasy Product. Próba dodania innego typu do listy skmkzyłaby się błę dem kompilatora. Nie musimy się również martwić rzutowan� obiektu na odpowiedni �zech wspomnianych typ po wyciągnięciu go z listy. W C# 2 tylko jeden pro wcześniej pozostal nierozwiązany. W pozbyciu siy go am C# 3.
�� �
� ��
1.1.3. Właściwości implementowane a
ł!...
cznie w C# 3
Zaczniemy od prostych innowacji wprowad o y C# 3. Do takich zaliczają się poka rnentowane automatycznie oraz uprosz zane na następnym listingu (1.3) właściwośct czona inicjalizacja. Obie te cechy są wz �i proste w porównaniu do takich wynalaz ków jak wyrażenia lambda i im podo � pozwalają w dużym stopniu uprościć kod. ·
�
•
{ get; private set; } Price { get; private set; } decimal price) Name
name ;
=
Price
=
price ;
Product() { } public static List GetSampleProducts() {
return new List() { new Produet { Name= "Ogniem i mieczem", Price= 9.99m } , new Produet { Name= "Potop", Price= 14.99m } , new Produet { Name= "Krzyżacy", Price= 13.99m } , new Produet { Name= "Faraon", Price= 10.99m } };
38
l
ROZDZIAŁ 1
Nieustająca metamorfoza C# public override string ToString() { return string. Format("{O}: {l}". Name. Price):
Teraz właściwości nie mają żadnego związanego z nimi kodu lub widocznych zmien nych. Zupełnie zmieniliśmy sposób budowania zakodowanej na sztywno listy produk tów. Ze względu na brak zmiennych name i pri c e w całej klasie używamy właściwości, dzięki czemu poprawiamy też spójność kodu. Dodaliśmy prywatny konstruktor bezpa rametrowy, aby móc dokonać inicjalizacji opartej na właściwościach. W tym przykładzie moglibyśmy zupełnie zrezygnować z konstruktora dwuparametrowego, ale wtedy żaden kod zewnętrzny nie byłby w stanie utworzyć instancji produktów. 1.1.4. Argumenty nazwane w C# 4
wl�d �
Dla zaprezentowania C# 4 przywrócimy konstmktor i z pierwszego przy enie, by instancje klasy kładu. Jednym z powodów takiego działania mogłoby �ć sp były niezmienne (chociaż typ posiadający wyłączn atne settery nie może być publicznie modyfikowany; będzie to wyraźniej wid � � nie będzie również wewnętrz nej-prywatnej - możliwości modyfikacji)1�iMty nie istnieje skrót dla właści wości tylko do odczytu, ale C# 4 pozw � � eślić nazwy argumentów podczas wywoływania konstruktora-pokazuje to .4. Uzyskujemy w ten sposób przejrzy stość podobną do inicjalizatorów z C# 3�nocześnie klasę niemodyfikowalną.
��
�
A�
�� � �� �
using System. Collections. Ge public class Product {
readonly str n
:
.
·
public strin
{ get { return name: } }
� �
readonly � mal price: Price { get { return price: } }
public
public Product(string name. decimal price) { this. name
name:
=
this. price
=
price:
public static List GetSampleProducts() { return new List() { new Product(name: "Ogniem i mieczem", price: 9.99m). new Product(name: "Potop", price: 14.99m). new Product(name: "Krzyżacy", price: 13.99m).
1 Moglibyśmy powrócić do klasy całkowicie niezmodyfikowanej. Nie zrobiliśmy tego dla uproszczenia zmian w C# 2i3.
Sortowanie ifiltrowanie
l
39
new Product(name: "Faraon", price: 10.99m) };
public override string ToString() { return string. Format("{O}: {l}". name. price):
Zysk w tym konkretnym przykładzie jest niewielki, ale jeśli metoda lub konstruktor mają wiele parametrów, użycie parametrów nazwanych może znacznie poprawić czy telność kodu. Ma to szczególne znaczenie, kiedy parametry mają ten sam typ lub gdy wartość null jest przekazywana dla większej liczby parametrów. Decyzję o zastosowa niu argumentów nazwanych podejmujesz samodzielnie, kiedy ich użycie może przyczy nić się do zwiększenia czytelności kodu. Rysunek 1.1 pokazuje dotychczasową ewolucję naszeg �ypu Product. Taki sam diagram przedstawię po każdym zadaniu, dzięki czemu �)iz mógł zobaczyć, jak ewolucja C# wpłynęła na poprawę kodu.
..'ś-
•
(#1
Właściwości tyl ko do odczytu
t---�
Kolekcje o słabym typie
�0 (#3
.__,..1
Właściwości i
autom
pl
Popraw· na o b·
Rysunek 1.1. Ewolucj
i łatwiejszą inicjalizację
t
wi
entowane • ie
(#4
1-------.1
jalizacja
kolekcji
Argumenty nazwane
dla bardziej przejrzystych wywołań metod i konstruktorów
Produet - widać lepszą enkaps u l ację, mocniejszy system typów olejnych wersjach języka
Do tej pory zmiany były niewielkie. Dodanie typów generycznych (składni
L i st) jest prawdopodobnie najważniejszą cechą C# 2, chociaż widzieliśmy zaledwie część możliwości tej funkcjonalności. Nie pojawiło się jeszcze nic, co przyspie szyłoby tętno przeciętnemu programiście, ale to dopiero początek. Naszym następnym zadaniem jest posortowanie listy produktów w porządku alfabetycznym.
1.2.
Sortowanie i filtrowanie W tej sekcji nie będziemy zmieniać typu Product, zamiast tego weźmiemy listę przy kładowych produktów i posortujemy je po nazwie, a następnie znajdziemy najdroższy z nich. Oba zadania nie są szczególnie trudne, chcemy się jednak przekonać, jak uprościło się ich wykonanie w kolejnych odsłonach języka.
40
l
1.2.1.
Sortowanie produktów po nazwie
ROZDZIAŁ 1
Nieustająca metamorfoza C#
Najprostszym sposobem wyświetlenia listy w określonym porządku jest posortowanie jej, a następnie przejście kolejno przez wszystkie jej elementy. W środowisku .NET 1.1 wymaga to użycia Arrayl l st. Sort, a w naszym konkretnym przypadku dodatkowo implementacji interfejsu ICompa rer. Mogliśmy zaimplementować ten interfejs bezpo średnio w typie Product, ale wtedy moglibyśmy zdefiniować wyłącznie jeden rodzaj sortowania-rozwiązanie nie do koilCa akceptowalne, gdyż łatwo można sobie wyobra zić konieczność sortowania produktów zarówno po nazwie, jak i po cenie. Kolejny listing (1.5) implementuje interfejs IComparer, a następnie sortuje i wyświetla listę. Listing 1.5. Sortowanie listy typu Arraylist IComparer (C#
1)
użyciem implementacji interfejsu
z
elass ProduetNameComparer : IComparer
{
publle lnt Compare(objeet x. objeet y)
{
Produet first
�
Produet seeond
(Product)x ; =
"-
return flrst. Name. CompareTo(seeond. Name):
•
� � �
Produet. GetSampl Arrayllst produets produets. Sort(new ProduetNameComparer =
r
�
�� ��
(Produet)y:
�
():
�
oreaeh (Produet produet ln produ Console. Wrltellne(produet)
�
0
}
Pierwszą rzeczą godną u gi �a listingu 1.5 jest to, że musieliśmy wprowadzić dodatowaniu. To jeszcze nic strasznego, ale jeśli sortowanie ma kowy typ do pomocy być wykonane tylk , zużyliśmy na nie sporo kodu. Kolejna rzecz to rzutowanie typu w metodzie Co� �� - rutowanie to nic innego jak poinformowanie kompilatora, że wiemy więcej niż on ąże się z tym jednak pewne ryzyko popełnienia błędu przy określaniu spodziewanego typu. Jeżeli lista zwrócona przez metodę GetSampleProducts zawieralaby łaiiCuchy, program wyleciałby przy próbie rzutowania łai1cucha na typ Product. Rzutowanie musi zostać użyte również podczas wyświetlania elementów listy. Nie widać tego na pierwszy rzut oka, ponieważ kompilator wstawia odpowiedni kod auto matycznie, ale pętla foreach rzutuje niejawnie każdy element na typ Product. Jest to jeszcze jedno miejsce, gdzie rzutowanie może się nie udać podczas wykonywania pro gramu. Kolejny raz z pomocą przychodzą typy generyczne zawarte w C# 2. Listing 1.6 przedstawia poprzedni kod z wyłącznie jedną zmianą, polegającą na użyciu typu generycznego.
�
Listing 1.6. Sortowanie listy typu List przy użyciu IComparer (C# 2) elass ProduetNameComparer :
{
IComparer
publle lnt Compare(Product x. Produet y)
{
1.2.
Sortowanie i filtrowanie
41
return x.Name.CompareTo(y.Name); } } ... List products = Product.GetSampleProducts(); products.Sort(new ProductNameComparer()); foreach (Product product in products) { Console.WriteLine(product); }
Kod komparatora na listingu 1.6 jest prostszy, ponieważ na wejściu metody mamy produkty. Nie trzeba dokonywać rzutowania. Również niewidoczne rzutowanie w pętli foreach przestało istnieć. Kompilator nadal musi przeprowadzić konwersję z typu źródłowego kolekcji na typ zmiennej, ale tym razem wie, że w obu przypadkach jest to typ Product, i w związku z tym nie musi wstrzykiwać kodu do przeprowadzenia rzutowania. Widać poprawę, ale byłoby miło, gdybyśmy mogli posortować produkty przez zwykłe wskazanie porównania, jakie ma zostać wykonane — bez konieczności implementowania w tym celu interfejsu. Taki cel realizuje kolejny listing (1.7). Metoda Sort zostaje poinformowana o sposobie sortowania obiektów przy użyciu delegata. Listing 1.7. Sortowanie listy typu List przy użyciu Comparison (C# 2) List products = Product.GetSampleProducts(); products.Sort(delegate(Product x, Product y) { return x.Name.CompareTo(y.Name); } ); foreach (Product product in products) { Console.WriteLine(product); }
Dostrzegasz zapewne brak typu ProductNameComparer. Wytłuszczone wyrażenie tworzy instancję delegata, którą przekazujemy metodzie Sort w celu wykonania porównań. Więcej na temat tej cechy języka (metod anonimowych) dowiemy się w rozdziale 5. Poprawiliśmy już wszystkie rzeczy, które nie podobały nam się w wersji C# 1. Nie oznacza to wcale, że C# 3 nie potrafi wykonać tego zadania jeszcze lepiej. W pierwszej kolejności wymieńmy metodę anonimową na jeszcze bardziej zwięzłą metodę tworzenia instancji delegata, tak jak pokazuje to poniższy listing (1.8). Listing 1.8. Sortowanie z wykorzystaniem Comparison przy użyciu wyrażenia lambda (C# 3) List products = Product.GetSampleProducts(); products.Sort((x, y) => x.Name.CompareTo(y.Name)); foreach (Product product in products) { Console.WriteLine(product); }
Otrzymaliśmy jeszcze dziwniejszą składnię (wyrażenie lambda), która nadal tworzy delegata Comparison, tak jak było w przypadku listingu 1.7, ale tym razem przy użyciu mniejszej ilości kodu. Do jego stworzenia nie musieliśmy używać słowa
42
ROZDZIAŁ 1
Nieustająca metamorfoza C#
kluczowego delegate ani nawet wskazywać typu parametrów wejściowych. To jeszcze nie wszystko — używając C# 3, możemy z łatwością wypisać nazwy w porządku alfabetycznym bez modyfikowania oryginalnej listy produktów. Następny przykład (listing 1.9) prezentuje takie rozwiązanie przy użyciu metody OrderBy. Listing 1.9. Sortowanie listy typu List przy użyciu metody rozszerzającej (C# 3) List products = Product.GetSampleProducts(); foreach (Product product in products.OrderBy(p => p.Name)) { Console.WriteLine(product); }
Wygląda to tak, jakbyśmy wywoływali metodę OrderBy na liście. Jeśli jednak zajrzysz do MSDN-a, zobaczysz, że typ List nie udostępnia takiej metody. Jesteśmy w stanie ją wywołać dzięki obecności metody rozszerzającej, której szczegóły poznamy w rozdziale 10. Od tego momentu nie stosujemy już „miejscowego” sortowania listy, ale otrzymujemy jej zawartość w określonym porządku. Czasem zajdzie potrzeba modyfikacji oryginalnej listy, innym razem potrzebna będzie lista z jej oryginalnym porządkiem elementów. Istotne jest jednak to, że końcowa składnia jest o wiele bardziej spójna i czytelna (oczywiście pod warunkiem, że będziesz rozumiał jej znaczenie). Chcieliśmy dostać listę posortowaną według nazw i dokładnie takie znaczenie ma nasz kod. Kod nie mówi, że sortowanie odbywa się przez porównanie nazwy produktu z inną nazwą, jak miało to miejsce w przypadku C# 2, lub poprzez inny typ, który wie, jak porównać ze sobą dwa produkty. Mówi wyłącznie: posortuj po nazwie. Ta łatwość wyrażania jest jedną z kluczowych korzyści, jakie daje C# 3. Dzięki zupełnej prostocie elementów związanych z dostępem do danych i ich manipulacją duże transformacje zawarte w jednym fragmencie kodu mogą pozostać zwięzłe i czytelne. To z kolei promuje metodę patrzenia na świat z punktu widzenia danych. W tej części widzieliśmy już o wiele więcej „mocy” języków C# 2 i 3 z dużą ilością (jak na razie) niewyjaśnionej składni. Jednak nawet bez rozumienia szczegółów widzimy postęp w kierunku czystszego i prostszego kodu. Całą przemianę pokazuje rysunek 1.2.
Rysunek 1.2. Cechy języka związane z ułatwianiem sortowania w C# 2 i 3
Na tym kończymy temat sortowania2. Zajmijmy się teraz inną formą manipulowania danymi — wyszukiwaniem.
2
Mówiąc o C# 4, przedstawimy cechę — wariancję typów generycznych — która może mieć związek z sortowaniem, ale w tej chwili pokazanie przykładu jej wykorzystania wymagałoby zbyt wiele tłumaczenia. Szczegóły możesz znaleźć pod koniec rozdziału 13.
1.2.
Sortowanie i filtrowanie
43
1.2.2. Wyszukiwanie elementów w kolekcjach Naszym następnym zadaniem jest znalezienie wszystkich elementów listy, które spełniają określone kryterium — ich cena jest większa niż 10 złotych. Kolejny listing (1.10) pokazuje realizację tego zadania w C# 1 — przechodzimy przez wszystkie elementy listy, sprawdzamy każdy z nich i wyświetlamy, jeśli został spełniony warunek. Listing 1.10. Przejście w pętli, sprawdzenie i wyświetlenie (C# 1) ArrayList products = Product.GetSampleProducts(); foreach (Product product in products) { if (product.Price > 10m) { Console.WriteLine(product); } }
Kod jest trywialny. Warto jednak zapamiętać, jak przeplatają się ze sobą wszystkie trzy operacje — przejście w pętli foreach, sprawdzenie kryterium z użyciem if i wyświetlanie wyniku przez Console.WriteLine. Wzajemna zależność jest nieunikniona ze względu na zagnieżdżenie. Następny listing (1.11) pokazuje, jak C# 2 pozwala spłaszczyć niektóre elementy. Listing 1.11. Oddzielenie sprawdzenia warunku od wyświetlania elementu (C# 2) List products = Product.GetSampleProducts(); Predicate test = delegate(Product p) { return p.Price > 10m; } ; List matches = products.FindAll(test); Action print = Console.WriteLine; matches.ForEach(print);
Zmienna test jest inicjalizowana przy użyciu metody anonimowej, jaką widzieliśmy w poprzedniej części. Inicjalizacja zmiennej print korzysta z nowej cechy C# 2, zwanej konwersją grupy metod, która umożliwia tworzenie delegatów z istniejących metod. Nie zamierzam twierdzić, że ten kod jest prostszy niż kod w C# 1, ale jest o wiele bardziej funkcjonalny3. Przede wszystkim pozwala na bardzo łatwą zmianę warunku, jaki sprawdzamy, i akcji, jaką podejmujemy na pasującym elemencie — w sposób niezależny od siebie. Zmienne typu delegatowego (test i print) mogą być przekazane do metody — w ten sposób ta sama metoda mogłaby sprawdzać radykalnie odmienne warunki i podejmować zupełnie inne działania na pasujących elementach. W takiej sytuacji nie moglibyśmy umieścić sprawdzenia warunku i wyświetlenia w jednym wyrażeniu, tak jak przedstawia to kolejny listing (1.12). Listing 1.12. Odseparowanie sprawdzenia od wyświetlenia, druga wersja (C# 2) List products = Product.GetSampleProducts(); products.FindAll(delegate(Product p) { return p.Price > 10;}) .ForEach(Console.WriteLine); 3
Jest to małe oszustwo. Mogliśmy zdefiniować odpowiednie delegaty w C# 1 i wywołać je wewnątrz pętli. Metody FindAll i ForEach w .NET 2.0 zachęcają jedynie do rozważenia odseparowania tych dwóch kwestii.
44
ROZDZIAŁ 1
Nieustająca metamorfoza C#
Pod pewnymi względami takie rozwiązanie jest lepsze, ale delegate(Product p) oraz nawiasy zaciemniają kod, przez co jest on trudniejszy do czytania. Jeżeli operacja sprawdzająca i wykonywana czynność mają być zawsze takie same, nadal wolę wersję C# 1. (Chociaż dla niektórych może to być oczywiste, warto przypomnieć, że nic nie powstrzymuje nas od używania kodu w wersji C# 1 w połączeniu z nowszą wersją kompilatora. Próba użycia nowej składni na siłę przypomina trochę próbę zabicia muchy z armaty). Kolejny listing (1.13) pokazuje, w jaki sposób C# 3 zdecydowanie poprawia sytuację przez usunięcie niemal całego bałaganu otaczającego właściwą logikę delegata. Listing 1.13. Sprawdzenie warunku za pomocą wyrażenia lambda (C# 3) List products = Product.GetSampleProducts(); foreach (Product product in products.Where(p => p.Price > 10)) { Console.WriteLine(product); }
Użycie wyrażenia lambda, które dokonuje sprawdzenia w doskonałym miejscu, i dobrze nazwanej metody pozwala niemal przeczytać kod na głos i zrozumieć go bez głębszego zastanowienia. Nadal zachowujemy elastyczność, jaką wniósł C# 2 — argument w metodzie Where może być zmienną, a zapisane na sztywno wywołanie Console.WriteLine możemy zastąpić przez Action. To zadanie podkreśliło to, czego się dowiedzieliśmy przy okazji sortowania, czyli że metody anonimowe ułatwiają tworzenie delegatów, a wyrażenia lambda pozwalają na jeszcze większą zwięzłość kodu. W obu przypadkach taka skrótowość pozwala na umieszczenie zapytania lub operacji sortującej w pierwszej części pętli foreach bez utraty przejrzystości kodu. Rysunek 1.3 podsumowuje zmiany, które do tej pory widzieliśmy. C# 4 nie oferuje nic, co pozwoliłoby nam jeszcze bardziej uprościć to zadanie.
Rysunek 1.3. Metody anonimowe i wyrażenia lambda pomagają odseparować zadania i poprawiają czytelność w C# 2 i 3
Teraz, kiedy wyświetliliśmy już naszą przefiltrowaną listę, rozważmy zmianę początkowych założeń odnośnie do danych. Co się stanie, jeśli nie zawsze będziemy znali cenę produktu? Jak możemy sobie poradzić z taką sytuacją w naszej klasie Product?
1.3.
Obsługa braku danych Przyjrzymy się dwóm różnym formom braku danych. W pierwszej kolejności prześledzimy scenariusz, w którym nie posiadamy danych, a następnie zobaczymy, jak w sposób jawny możemy usunąć dane z wywołań metod, używając w zamian wartości domyślnych.
1.3.
Obsługa braku danych
45
1.3.1. Reprezentacja nieznanej ceny Tym razem nie będę pokazywał zbyt wiele kodu, ale jestem pewny, że z łatwością rozpoznasz problem, zwłaszcza jeśli pracujesz z bazami danych. Wyobraźmy sobie, że nasza lista produktów zawiera nie tylko te przeznaczone na sprzedaż, ale również takie, które są jeszcze niedostępne. W niektórych przypadkach możemy nawet nie znać ich ceny. Gdyby decimal był typem referencyjnym, moglibyśmy użyć wartości null do przedstawienia nieznanej ceny — niestety jest to typ wartościowy, więc nie możemy tego zrobić. Jak poradziłbyś sobie z tym problemem w C# 1? Istnieją trzy powszechnie znane rozwiązania. Można: stworzyć typ referencyjny, który opakuje typ decimal; utrzymywać oddzielną flagę typu boolowskiego, której wartość będzie określać,
czy cena jest znana; przedstawić nieznaną cenę przy użyciu „magicznej wartości” (np. decimal.MinValue). Mam nadzieję, że zgodzisz się co do dyskusyjności tych rozwiązań. Czas na trochę magii: cały problem możemy rozwiązać poprzez dodanie jednego znaku w deklaracjach zmiennych i właściwości. Platforma .NET 2.0 ułatwia całą sprawę przez wprowadzenie struktury Nullable, natomiast C# 2 dorzuca jeszcze poprawki w składni, które pozwalają nam zmienić deklarację właściwości na następujący blok kodu: decimal? price; public decimal? Price { get { return price; } private set { price = value; } }
Również parametr konstruktora zmienił się na decimal? i od teraz możemy jako argument przekazać null lub użyć wyrażenia Price = null wewnątrz klasy. Takie podejście jest o wiele bardziej czytelne niż którekolwiek z przedstawionych wcześniej. Pozostały kod działa bez zmian — ze względu na sposób traktowania wartości nullowalnych w porównaniach „większy niż” produkt z nieznaną ceną będzie traktowany jako tańszy niż 10 złotych. Możemy sprawdzić, czy cena jest znana, przez porównanie jej z wartością null lub użycie właściwości HasValue. Produkty z nieznaną ceną można by zaprezentować w C# 3 w taki sposób, jak zostało to pokazane na listingu 1.14. Listing 1.14. Wyświetlanie produktów z nieznaną ceną (C# 3) List products = Product.GetSampleProducts(); foreach (Product product in products.Where(p => p.Price == null)) { Console.WriteLine(product.Name); }
Kod w C# 2 byłby bardzo podobny do listingu 1.12, z tą różnicą, że w ciele funkcji anonimowej znalazłoby się wyrażenie return p.Price == null;. C# 3 nie oferuje żadnych zmian w tym względzie, natomiast C# 4 ma cechę, która przynajmniej częściowo jest związana z tym problemem.
46
ROZDZIAŁ 1
Nieustająca metamorfoza C#
1.3.2. Parametry opcjonalne i wartości domyślne Czasami nie chcesz mówić metodzie wszystkiego, co powinna wiedzieć. Przykładem takiej sytuacji jest używanie w większości przypadków tej samej wartości parametru. Tradycyjnym rozwiązaniem tego problemu jest przeciążenie metody, ale C# oferuje prostsze rozwiązanie w postaci parametrów opcjonalnych. Nasz typ Product w C# 4 posiada konstruktor przyjmujący nazwę i cenę. Możemy zmienić cenę na nullowalną, tak jak robiliśmy to w C# 2 i 3, ale załóżmy na moment, że większość naszych produktów nie ma ceny. W takiej sytuacji dobrze byłoby zainicjalizować produkt następująco: Product p = new Product("Produkt przed wypuszczeniem");
Przed C# 4 w tym celu musielibyśmy wprowadzić nowy konstruktor przeciążony. C# 4 pozwala na zdefiniowanie wartości domyślnej (w naszym przypadku null) dla parametru price: public Product(string name, decimal? price = null) { this.name = name; this.price = price; }
Deklarując parametr opcjonalny, należy zawsze użyć wartości stałej. W naszym przypadku jest to null, ale równie dobrze może być to inna wartość. Wyjątkiem są typy referencyjne inne niż łańcuchy, dla których wybór jest ograniczony do null — jedynej dostępnej wartości stałej. Podsumowanie powyższych rozważań stanowi rysunek 1.4.
Rysunek 1.4. Możliwości dostępne podczas pracy z „brakującymi” danymi
Do tej pory cechy, które widzieliśmy, były użyteczne, ale nie do tego stopnia, aby się nimi zachwycać. Teraz przyjrzymy się czemuś bardziej ekscytującemu — mowa o LINQ.
1.4.
Wprowadzenie do LINQ LINQ (ang. Language Integrated Query — zapytania zintegrowane z językiem) to sedno języka C# 3. Jak sama nazwa wskazuje, centralnym punktem LINQ są zapytania. Celem jest umożliwienie tworzenia prostych zapytań w odniesieniu do różnych źródeł danych przy użyciu spójnej składni i w czytelny sposób, tworzący przejrzystą kompozycję. O ile celem C# 2 było raczej naprawienie pewnych dokuczliwości języka C# 1 niż wywołanie kompletnej rewolucji, to w C# 3 niemal wszystkie nowości budują fundament dla LINQ, a osiągnięty wynik jest niesamowity. Widziałem w innych językach cechy mające rozwiązywać niektóre z problemów, dla których powstał LINQ. Nie spotkałem jednak nic, co byłoby na równi kompletne i elastyczne.
1.4.
Wprowadzenie do LINQ
47
1.4.1. Wyrażenia kwerendowe i zapytania wewnątrzprocesowe Jeżeli miałeś już wcześniej styczność z LINQ, prawdopodobnie wiesz o istnieniu wyrażeń kwerendowych, które pozwalają na deklaratywny styl podczas tworzenia zapytań dla różnych źródeł danych. Powodem, dla którego nie były one używane do tej pory w przykładach, jest to, że łatwiej było zachować ich prostotę bez stosowania tej dodatkowej składni. Nie znaczy to wcale, że nie mogliśmy ich wtedy użyć. Dla przykładu listing 1.15 jest odpowiednikiem listingu 1.13. Listing 1.15. Pierwsze kroki z wyrażeniami kwerendowymi — filtrowanie kolekcji List products = Product.GetSampleProducts(); var filtered = from Product p in products where p.Price > 10 select p; foreach (Product product in filtered) { Console.WriteLine(product); }
Poprzedni listing wydaje mi się bardziej czytelny — jedyny zysk płynący z wyrażenia kwerendowego to prostsza klauzula where. Przy okazji wprowadziłem tutaj jedną dodatkową cechę — zmienne lokalne o typie niejawnym, deklarowane przy użyciu słowa kluczowego var. Taki rodzaj deklaracji pozwala kompilatorowi na wywnioskowanie typu zmiennej na podstawie przypisywanej do niej wartości. W naszym przypadku typem zmiennej filtered jest IEnumerable. W dalszej części rozdziału w miarę często będę stosował deklaracje var. Są one szczególnie użyteczne w książkach, w których listingi muszą się zmieścić na określonej szerokości strony. Jeśli jednak wyrażenia kwerendowe są niedobre, skąd wokół nich i wokół samego LINQ tyle szumu? Przede wszystkim warto zaznaczyć, że choć wyrażenia kwerendowe nie przynoszą szczególnego zysku w przypadku prostych operacji, są bardzo dobrym mechanizmem w bardziej skomplikowanych sytuacjach, gdy zapis tych samych operacji przy użyciu standardowych wywołań metod byłby mało czytelny. Skomplikujmy trochę sprawę przez wprowadzenie kolejnego typu — Supplier. Każdy dostawca ma swoją nazwę (Name typu string) i identyfikator (SupplierID typu int). Dodałem również SupplierID jako właściwość w klasie Product i odpowiednio dostosowałem przykładowe dane. Taki sposób przydzielania dostawcy każdemu produktowi odbiega trochę od programowania obiektowego, gdyż przypomina bardziej reprezentację danych w bazie, ale w tej chwili bardzo się przyda, bo dzięki niemu zaprezentuję nową cechę języka. Później (w rozdziale 12.) przekonamy się, że LINQ pozwala również na używanie bardziej naturalnego modelu. Przyjrzyjmy się teraz listingowi 1.16, który łączy przykładowe produkty z przykładowymi dostawcami (w oparciu o identyfikator dostawcy), stosuje taki sam filtr cenowy do produktów jak poprzednio, sortuje po nazwie producenta, a następnie po nazwie produktu, a na końcu wyświetla każdą pasującą parę dostawca – produkt. Jak widać, jest tego sporo, a napisanie wszystkich elementów przy użyciu poprzednich wersji C# byłoby koszmarem. W LINQ całe zadanie jest trywialnie proste.
48
ROZDZIAŁ 1
Nieustająca metamorfoza C# Listing 1.16. Łączenie, filtrowanie, sortowanie i wyświetlanie (C# 3) List products = Product.GetSampleProducts(); List suppliers = Supplier.GetSampleSuppliers(); var filtered = from p in products join d in suppliers on p.SupplierID equals d.SupplierID where p.Price > 10 orderby d.Name, p.Name select new { SupplierName = d.Name, ProductName = p.Name }; foreach (var f in filtered) { Console.WriteLine("Dostawca={0}; Produkt={1}", f.SupplierName, f.ProductName); }
Część Czytelników zauważyła zapewne, że powyższy kod do złudzenia przypomina SQL. Wrażenie jest do tego stopnia realne, że niektórzy programiści na widok LINQ (ale przed dokładnym przestudiowaniem go) odrzucają ten mechanizm jako zwykłą próbę wstawienia zapytań SQL do języka na potrzeby komunikacji z bazami danych. Na szczęście LINQ jedynie zapożyczył składnię i niektóre idee z języka SQL, a do jego użycia, jak widzieliśmy, nie była nam potrzebna żadna baza danych. Do tej pory ani jeden napisany przez nas kod nie miał styczności z bazą danych. Co więcej, dane możemy czerpać z dowolnego źródła, np. z pliku XML.
1.4.2. Wykonywanie zapytań na XML-u Załóżmy, że zamiast produktów i dostawców zapisanych na sztywno w kodzie użyliśmy następującego pliku XML:
Plik wydaje się prosty, ale jak najlepiej wydobyć z niego dane? Jak mamy wykonać zapytanie na takim pliku? Jak dokonać złączenia? Z pewnością będzie to trudniejsze niż na listingu 1.16. Listing 1.17 pokazuje nakład pracy, jaki musimy włożyć, stosując LINQ dla XML-a. Zadanie nie jest zbyt proste, ponieważ musimy poinformować system, jak powinien rozumieć dane (w sensie typów danych kryjących się w poszczególnych atrybutach),
1.4.
Wprowadzenie do LINQ
49
Listing 1.17. Przetwarzanie pliku XML z użyciem LINQ dla XML-a (C# 3) XDocument document = XDocument.Load("dane.xml"); var filtered = from p in document.Descendants("Product") join d in document.Descendants("Supplier") on (int)p.Attribute("SupplierID") equals (int)d.Attribute("SupplierID") where (decimal)p.Attribute("Price") > 10 orderby (string)d.Attribute("Name"), (string)p.Attribute("Name") select new { SupplierName = (string)d.Attribute("Name"), ProductName = (string)p.Attribute("Name") }; foreach (var f in filtered) { Console.WriteLine("Dostawca={0}; Produkt={1}", f.SupplierName, f.ProductName); }
ale nie jest też szczególnie skomplikowane. Widać między innymi oczywisty związek pomiędzy każdą z części obu listingów. Gdyby nie ograniczenia związane z szerokością strony, zobaczyłbyś dokładny związek pomiędzy dwoma zapytaniami. Czy już jesteś pod wrażeniem? Jeszcze nie? Umieśćmy zatem dane tam, gdzie bardziej można się ich spodziewać — w bazie danych.
1.4.3. LINQ dla SQL-a Poinformowanie LINQ dla SQL-a, jakich tabel oraz jakich zawartości tabel może się spodziewać, wymaga wykonania pewnej pracy. Jest ona jednak w miarę prosta (i może być w dużym stopniu zautomatyzowana). Przejdziemy bezpośrednio do kodu wykonującego zapytania przedstawionego na kolejnym listingu (1.18). Jeżeli interesują Cię szczegóły klasy LinqDemoDataContext, znajdziesz je w kodach źródłowych do pobrania. Listing 1.18. Zastosowanie wyrażenia kwerendowego na bazie danych SQL (C# 3) using (LinqDemoDataContext db = new LinqDemoDataContext()) { var filtered = from p in db.Products join d in db.Suppliers on p.SupplierID equals d.SupplierID where p.Price > 10 orderby d.Name, p.Name select new { SupplierName = d.Name, ProductName = p.Name }; foreach (var f in filtered) { Console.WriteLine("Dostawca={0}; Produkt={1}", f.SupplierName, f.ProductName); } }
50
ROZDZIAŁ 1
Nieustająca metamorfoza C#
Ten przykład powinien wyglądać całkiem znajomo. Cała zawartość poniżej wyrażenia join została bez zmian skopiowana z listingu 1.16. Całość jest imponująca, ale jeśli masz świadomość kontekstu wydajnościowego, możesz się zastanawiać, dlaczego wyciągamy wszystkie dane z bazy, a następnie stosujemy do nich wyrażenia pytające i sortujące .NET. Dlaczego nie pozostawiamy tego zadania samej bazie danych? Przecież na tym polega jej rola, czyż nie? Tak właśnie jest i właśnie to robi LINQ dla SQL-a. Kod z listingu 1.18 wysyła do bazy danych zapytanie, które jest po prostu wyrażeniem kwerendowym przetłumaczonym na SQL. Mimo że wyraziliśmy je w C#, zostało ono wykonane jako SQL. Później przekonamy się, że istnieje inny, bardziej relacyjny sposób radzenia sobie z tego typu złączeniem, kiedy schemat bazy i jego encje „wiedzą” o związku pomiędzy dostawcami i towarami. Wynik pozostaje ten sam i pokazuje, jak duże może być podobieństwo pomiędzy LINQ dla Obiektów (czyli LINQ działającym w pamięci na kolekcjach) i LINQ dla SQL-a. LINQ jest mechanizmem niezwykle elastycznym — możesz tworzyć własnych dostawców komunikujących się z usługami sieciowymi lub tłumaczących zapytanie na Twoją własną reprezentację. O tym, jak bardzo szerokim pojęciem jest LINQ i jak bardzo może wykraczać poza Twoje wyobrażenie odpytywania kolekcji danych, przekonasz się w rozdziale 13.
1.5.
COM i typy dynamiczne Ostatnie cechy, jakie chcę zaprezentować, są specyficzne dla C# 4. W C# 3 największą uwagę przyciągnął LINQ, w C# 4 natomiast najważniejszym celem jest współpraca pomiędzy różnymi systemami. Temat ten obejmuje zarówno pracę ze starą technologią COM, jak i z zupełnie nowymi językami dynamicznymi, wykonywanymi przez środowisko DLR (ang. Dynamic Language Runtime). Zaczniemy od wyeksportowania naszej listy produktów do arkusza Excela.
1.5.1. Uproszczenie współpracy z COM Istnieje wiele sposobów pozwalających na udostępnianie danych programowi Excel — najbardziej elastycznym i oferującym największe możliwości kontrolowania procesu jest COM. Niestety, poprzednie wersje C# w znacznym stopniu utrudniały pracę z tym interfejsem (znacznie lepiej radził sobie Visual Basic). C# 4 zdecydowanie poprawia tę sytuację. Poniższy listing (1.19) przedstawia kod zapisujący nasze dane do nowego arkusza danych. Listing 1.19. Zapis danych do Excela przy użyciu COM (C# 4) var app = new Application { Visible = false; } Workbook workbook = app.Workbooks.Add(); Worksheet worksheet = app.ActiveSheet; int row = 1; foreach (var product in Product.GetSampleProducts() .Where(p => p.Price != null))
1.5.
COM i typy dynamiczne
51
{ worksheet.Cells[row, 1].Value = product.Name; worksheet.Cells[row, 2].Value = product.Price; row++; } workbook.SaveAs(Filename: "demo.xls", FileFormat: XlFileFormat.xlWorkbookNormal); app.Application.Quit();
Być może kod nie wygląda tak dobrze, jakbyśmy się tego spodziewali, ale i tak jest o wiele lepszy niż w poprzednich wersjach języka. Przyglądając się dokładnie, dostrzeżesz niektóre z cech C# 4, które poznaliśmy wcześniej, ale są również zupełnie nowe i nie tak oczywiste. Oto ich pełna lista: Metoda SaveAs używa argumentów nazwanych. Niektóre z wywołań pomijają część argumentów, bazując na parametrach opcjonalnych — przykładem jest metoda SaveAs posiadająca 10 argumentów wywołania. W C# 4 możemy osadzić poszczególne części PIA (ang. Primary Interop
Assembly) wewnątrz kodu — nie ma konieczności dystrybuowania PIA oddzielnie. Ponieważ właściwość ActiveSheet jest reprezentowana przez typ object, w C# 3 przypisanie do arkusza (worksheet) wymagałoby rzutowania. Stosując osadzone cechy PIA, właściwość ActiveSheet otrzymuje typ dynamic, co prowadzi nas
do zupełnie nowego mechanizmu. Dodatkowo podczas pracy z COM C# 4 oferuje indeksy nazwane — cechę, która nie została zademonstrowana w tym przykładzie. Wspomniałem już naszą ostatnią cechę — typy dynamiczne tworzone przy użyciu nowego typu dynamic.
1.5.2. Współpraca z językiem dynamicznym Typy dynamiczne są tak obszernym tematem, że poświęciłem mu osobny (raczej długi) rozdział pod koniec książki. Teraz pokażę zaledwie jeden mały przykład możliwości tej cechy. Załóżmy, że nasze produkty nie są przechowywane w bazie danych ani też w pliku XML lub pamięci komputera. Są one dostępne poprzez pewnego rodzaju usługę sieciową, ale Ty masz do niej dostęp jedynie poprzez kod Pythona. Kod ten korzysta z dynamicznej natury Pythona do budowania wyników bez deklarowania typów właściwości, do których chcesz się dostać. Zamiast tego pozwala Ci zapytać się o dowolną właściwość, a następnie usiłuje w trakcie wykonania wywnioskować, co dokładnie miałeś na myśli. W języku takim jak Python nie ma w tym nic dziwnego. Jak zatem dostaniemy się do tych wyników z poziomu C#? Odpowiedzią jest nowy typ danych4 — dynamic. Jeżeli wyrażenie jest typu dynamic, możesz używać go do wywoływania metod, odwoływania się do właściwości, przekazywać jako argument metody itd. Cały proces łączenia zostaje przeniesiony z czasu 4
Nie jest to do końca typ danych; dynamic można nazwać typem danych wyłącznie w odniesieniu do kompilatora C#; środowisko CLR nie wie o jego istnieniu.
52
ROZDZIAŁ 1
Nieustająca metamorfoza C#
kompilacji do czasu wykonania. Możesz dokonać niejawnej konwersji typu dynamicznego na dowolny inny typ (dzięki tej możliwości zadziałał nasz kod rzutujący obiekt na typ Worksheet na listingu 1.19), a także wykonywać inne ciekawe operacje. Ta właściwość może być użyteczna nawet wewnątrz kodu C#, gdzie nie zachodzi kooperacja pomiędzy różnymi systemami. Łatwiej nam będzie jednak udowodnić jej użyteczność, kiedy zastosujemy ją w połączeniu z dynamicznym językiem. Listing 1.20 pokazuje sposób pobrania i wyświetlenia listy produktów przy użyciu IronPythona, włączając w to cały kod potrzebny do inicjalizacji skryptu Pythona. Listing 1.20. Wykonanie skryptu IronPythona i wydobycie właściwości w sposób dynamiczny ScriptEngine engine = Python.CreateEngine(); ScriptScope scope = engine.ExecuteFile("FindProducts.py"); dynamic products = scope.GetVariable("products"); foreach (dynamic product in products) { Console.WriteLine("{0}: {1}", product.Name, product.Price); }
Zmienne products i product są zadeklarowane jako dynamiczne. Dzięki temu kompilator pozwala nam iterować po liście produktów i wypisywać ich właściwości, mimo że nie wie, czy taka konstrukcja ma szansę działać. Gdybyśmy popełnili błąd i napisali na przykład product.ProductName zamiast product.Name, zostałby on wykryty dopiero podczas wykonywania. Takie zachowanie jest zupełnie odmienne od pozostałej części C#, która posługuje się statycznym systemem typów. Dynamiczne typy mają szansę działać tylko w przypadku obecności wyrażeń zawierających słowo kluczowe dynamic. Zatem większość kodu C# będzie nadal korzystać ze statycznego systemu typów. Jeśli czujesz duszność, weź głęboki oddech — przez pozostałą część książki wydarzenia będą się odgrywały znacznie wolniej. Będę wyjaśniał szczególne przypadki, dokładnie opowiem, dlaczego niektóre z cech zostały wprowadzone do języka, i udzielę kilku rad odnośnie do ich faktycznego zastosowania. Do tej pory skupiałem się na cechach języka C#. Niektóre z nich są jednocześnie cechami biblioteki, a także środowiska wykonania. Zamierzam często posługiwać się tego typu terminami, dlatego wyjaśnijmy teraz, co dokładnie mam na myśli.
1.6.
Analiza zawartości środowiska .NET Wprowadzona po raz pierwszy nazwa .NET służyła jako określenie na cały szereg technologii pochodzących z firmy Microsoft. Na przykład Windows Live ID było określane mianem paszportu .NET (ang. .NET Passport), chociaż nie istniał wyraźny związek pomiędzy tym mechanizmem a środowiskiem .NET, jakie znamy dzisiaj. Na szczęście od tego czasu sytuacja uległa wyklarowaniu. W tym podrozdziale przyjrzymy się poszczególnym częściom środowiska .NET. W różnych miejscach książki będę się odnosił do trzech różnych typów cech: cech C# jako języka, cech środowiska wykonawczego, które stanowi silnik .NET, oraz cech
1.6.
Analiza zawartości środowiska .NET
53
bibliotek środowiska .NET. Ta książka jest poświęcona w szczególności językowi C#, zatem większość objaśnień dotyczących środowiska wykonawczego oraz bibliotek będzie dotyczyć związku tych elementów z pewnymi cechami języka. Będzie to możliwe tylko wtedy, gdy wyraźnie odróżnimy każdy z elementów. Oczywiście, czasem pewne elementy będą na siebie zachodzić, istotne jest jednak zrozumienie istoty podziału.
1.6.1. Język C# Język C# jest zdefiniowany przez swoją specyfikację, która opisuje formalne wymogi kodu źródłowego C#, włączając w to zarówno składnię, jak i zachowanie. Specyfikacja, pomijając kilka kluczowych punktów, nie definiuje platformy, na której wygenerowany program będzie działał. Dla przykładu język C# wymaga typu o nazwie System.IDis ´posable, który zawiera metodę o nazwie Dispose. Te dwa elementy są potrzebne do zdefiniowania wyrażenia using. Inny wymóg to konieczność wspierania (w takiej czy innej formie) zarówno typów wartościowych, jak i referencyjnych wraz z odśmiecaniem pamięci. Teoretycznie każda platforma posiadająca wymagane cechy może mieć kompilator C# generujący zgodny z nią kod wynikowy. Kompilator C# mógłby zupełnie zgodnie ze sztuką generować wynik w formie innej niż język IL (ang. Intermediate Language), który na etapie pisania tej książki jest typowym wynikiem kompilacji C#. Zamiast kompilacji typu JIT5 środowisko wykonawcze mogłoby interpretować wynik kompilacji C# lub konwertować go bezpośrednio na postać natywną danej platformy. Chociaż tego typu rozwiązania nie są szczególnie popularne, można je spotkać w praktyce. Przykładami mogą być Mini Framework i Mono stosujące interpretację oraz NGen i MonoTouch (http://monotouch.net) — platforma do budowy aplikacji dla iPhone’ów6 — stosujące wczesną kompilację.
1.6.2. Środowisko wykonania Środowisko wykonawcze platformy .NET to względnie mała ilość kodu odpowiedzialna za wykonanie programów w języku IL zgodnie z wytycznymi specyfikacji CLI (ang. Common Language Infrastructure), zawartymi w partycjach od I do III. Część wykonawcza CLI jest nazywana środowiskiem uruchomieniowym wspólnego języka (ang. Common Language Runtime, CLR). Kiedy w pozostałej części książki odnoszę się do CLR, mam na myśli implementację tego środowiska w wykonaniu firmy Microsoft. Niektóre elementy języka nigdy nie pojawiają się na poziomie wykonania, inne można tam zauważyć. Przykładami elementów niewidocznych na poziomie wykonania są enumeracje lub interfejs IDisposable. Dla odmiany tablice i delegaty są ważnym elementem środowiska wykonawczego.
5
JIT (ang. Just-In-Time compilation) — kompilacja kodu tuż przed jego wykonaniem — przyp. tłum.
6
Oraz iPodów Touch i iPadów.
54
ROZDZIAŁ 1
Nieustająca metamorfoza C#
1.6.3. Biblioteki środowiska Biblioteki dostarczają kod, który może zostać wykorzystany w naszych programach. Biblioteki środowiska .NET są w większości zbudowane jako zbiory w języku IL. Kod natywny jest używany wyłącznie tam, gdzie jest to absolutnie niezbędne. W ten sposób objawia się siła samego środowiska — nasz kod nie ma statusu niższej klasy i może dostarczyć rozwiązania mierzone — pod względem funkcjonalności i wydajności — na równi z bibliotekami, których używa. Ilość kodu znajdująca się w bibliotekach jest znacznie większa od tej zawartej w środowisku wykonawczym — tak jak sam samochód zajmuje znacznie więcej miejsca niż jego silnik. Biblioteki środowiska są częściowo ustandaryzowane. IV partycja specyfikacji opisuje różne profile (kompaktowe i kernelowe) oraz biblioteki. Jej treść dzieli się na dwie części: ogólny opis bibliotek, mówiący między innymi o tym, jakie biblioteki są wymagane w danym profilu, i detale techniczne bibliotek w formacie XML. Jest to taki sam format dokumentacji, jakiego używasz podczas tworzenia komentarzy XML-owych w języku C#. Środowisko .NET wyrasta ponad biblioteki bazowe. Jeśli napiszesz swój program w taki sposób, że będzie korzystać wyłącznie z bibliotek zawartych w specyfikacji i będzie to robić w sposób prawidłowy, nie powinieneś mieć żadnych problemów z jego uruchomieniem na dowolnej implementacji środowiska — Mono, .NET lub innej. W praktyce niemal każdy program dowolnego rozmiaru używa bibliotek, które nie są standaryzowane. Przykładem mogą być Windows Forms lub ASP.NET. Projekt Mono ma swoje własne biblioteki, które z kolei nie należą do .NET — do całej gamy bibliotek niestandaryzowanych zalicza się między innymi GTK#. Termin .NET odnosi się do środowiska wykonawczego, bibliotek, a także do kompilatora C# i VB.NET. Jest to kompletna platforma programistyczna zbudowana na bazie Windowsa. Każdy element .NET jest oddzielnie wersjonowany, co może wprowadzać w pewne zakłopotanie. Krótkie podsumowanie wypuszczonych wersji środowiska .NET wraz z zawartymi w nich elementami i ich wersją można znaleźć w dodatku C. Skoro wyjaśniliśmy sobie wszystko odnośnie do platformy .NET, została mi już tylko jedna rzecz do omówienia, zanim przejdziemy do poznawania tajników C#.
1.7.
Jak zmienić Twój kod w kod doskonały? Przepraszam za mylący tytuł. Przeczytanie tej sekcji rozdziału nie spowoduje, że Twój kod stanie się doskonały. Nie zbliżysz się nawet do takiego stanu. Przeczytanie tej sekcji pomoże Ci jak najlepiej skorzystać z treści tej książki — użyłem prowokacyjnego tytułu wyłącznie po to, by przyciągnąć Twoją uwagę. Więcej na temat treści książki można znaleźć na samym jej początku (przed pierwszym rozdziałem). Wiem jednak, że wielu czytelników pomija wstęp i przechodzi prosto do treści książki. Rozumiem takie podejście, dlatego postaram się maksymalnie skrócić tę część.
1.7.
Jak zmienić Twój kod w kod doskonały?
55
1.7.1. Prezentacja pełnych programów w formie fragmentów kodu Jednym z wyzwań stojących przed autorem podczas pisania książki poświęconej programowaniu komputerów (może z wyjątkiem języków skryptowych) jest bardzo szybkie przyrastanie treści kompletnych programów, które czytelnik może skompilować i uruchomić, korzystając wyłącznie ze źródeł zawartych w treści. Chciałem znaleźć rozwiązanie tego problemu, tak abyś mógł otrzymać do ręki kod, który z łatwością wpiszesz, uruchomisz i użyjesz do własnych eksperymentów. Uważam, że napisanie czegokolwiek ma znacznie większy walor edukacyjny niż zwykłe czytanie. Możesz osiągnąć całkiem sporo pod względem kodu, jeśli tylko będziesz posiadał referencje do odpowiednich bibliotek i dobierzesz właściwe dyrektywy using. To, co sprawia, że zadanie takie staje się mało interesujące, to konieczność napisania tych dyrektyw, następnie zadeklarowania klasy, później metody Main — i to wszystko, zanim jeszcze zaczniesz pisać użyteczny kod. Moje przykłady w większości przypadków mają charakter fragmentów pozbawionych wszelkiego „bezproduktywnego” kodu, który zasłania właściwą treść małych programów. Fragmenty te mogą być wykonane bezpośrednio przy użyciu małego narzędzia mojej produkcji o nazwie Snippy. Jeżeli kod nie zawiera wielokropka (…), można go w całości traktować jako ciało metody Main. Jeśli pojawia się wielokropek, poprzedzająca go część stanowi deklaracje metod i typów zagnieżdżonych, natomiast część za nim jest treścią metody Main. Weźmy pod uwagę następujący fragment kodu: static string Reverse(string input) { char[] chars = input.ToCharArray(); Array.Reverse(chars); return new string(chars); } ... Console.WriteLine(Reverse("!eiceiwś ,jatiW"));
Snippy przekształci ten kod do postaci: using System; public class Snippet { static string Reverse(string input) { char[] chars = input.ToCharArray(); Array.Reverse(chars); return new string(chars); } [STAThread] static void Main() { Console.WriteLine(Reverse("!eiceiwś ,jatiW")); } }
56
ROZDZIAŁ 1
Nieustająca metamorfoza C#
W praktyce Snippy dołącza znacznie więcej dyrektyw using, ale ponieważ pełna wersja byłaby bardzo długa, pominąłem je. Klasa zewnętrzna będzie zawsze nosić nazwę Snippet, a typy deklarowane w ramach przykładu będą zawarte w jej wnętrzu. Przykłady użycia programu w formie fragmentów kodu i kompletnych projektów Visual Studio można znaleźć na stronie książki (www.helion.pl/ksiazki/cshop2.htm). Warto również wspomnieć tutaj o programie LINQPad (http://www.linqpad.net) — to stworzone przez Joe’a Albahariego narzędzie jest podobne do Snippy i ma kilka użytecznych cech dotyczących eksploracji LINQ. Przyjrzyjmy się teraz, co jest nie tak z powyższym kodem.
1.7.2. Kod dydaktyczny nie jest kodem produkcyjnym Byłoby świetnie, gdybyś mógł wziąć przykłady z tej książki i bez zastanowienia zastosować je w swoich aplikacjach. Radzę jednak, żebyś tak nie robił. Przykłady służą udowodnieniu pewnej tezy i na tym kończy się zakres ich użyteczności. Większość z nich jest pozbawiona walidacji argumentów, modyfikatorów dostępu, testów jednostkowych czy jakiejkolwiek dokumentacji. Za przykład weźmy ciało pokazanej wcześniej metody służącej do odwracania łańcuchów. Tego kodu używam w książce kilkakrotnie. char[] chars = input.ToCharArray(); Array.Reverse(chars); return new string(chars);
Pomijając walidację parametrów, powyższy kod z powodzeniem odwraca sekwencję znaków UTF-16 w łańcuchu. Czasem takie zachowanie jest niewystarczające. Jeśli na przykład glif składa się z „e” i kolejnego znaku ustalającego odpowiedni akcent, nie chcesz, żeby te dwa znaki zostały odwrócone. Jeśli tak się stanie, akcent wyląduje po złej stronie znaku. W innej sytuacji Twój łańcuch może zawierać znaki, które nie należą do ogólnej tablicy znaków międzynarodowych, składającej się z par wartości — zmiana porządku znaków w takiej sytuacji doprowadzi do powstania wadliwego łańcucha UTF-16. Naprawienie tych problemów wymaga stworzenia o wiele bardziej skomplikowanego kodu, który odwracałby uwagę od demonstrowanej tezy. Zachęcam do korzystania z kodu zawartego w tej książce, ale bardzo proszę o zapamiętanie tej uwagi — o wiele lepsze będzie zaczerpnięcie inspiracji z kodu niż jego dosłowne skopiowanie z założeniem, że będzie on działał zgodnie z Twoimi oczekiwaniami. I w końcu ostatnia rzecz. O wiele lepiej zrozumiesz tę książkę, jeśli zaopatrzysz się w jeszcze jedną pozycję — dostępną w internecie.
1.7.3. Twój nowy najlepszy przyjaciel — specyfikacja języka Starałem się bardzo unikać wszelkich nieścisłości w książce, ale byłbym wielce zaskoczony, gdyby była ona pozbawiona błędów — aktualną erratę można znaleźć na stronie książki pod adresem www.helion.pl/ksiazki/cshop2.htm. Jeżeli uważasz, że znalazłeś błąd, będę wdzięczny, jeśli wyślesz do mnie e-mail ([email protected]). Jeżeli nie chcesz czekać na odpowiedź lub Twoje pytanie wykracza poza zakres książki, odwołaj się do specyfikacji, która stanowi ostateczny wyznacznik spodziewanego zachowania C#.
1.8.
Podsumowanie
57
Specyfikacja ma dwie formy. Mowa tu o międzynarodowym standardzie ECMA oraz specyfikacji Microsoftu. W chwili pisania tej książki, mimo że istniała już czwarta edycja języka, standard ECMA obejmował zaledwie C# 2. Trudno przewidzieć, czy i kiedy specyfikacja ta zostanie uaktualniona. Na szczęście specyfikacja Microsoftu jest kompletna i dostępna za darmo. Odwołując się do rozdziałów i sekcji specyfikacji w dalszej części książki, będę miał na myśli specyfikację C# 4 autorstwa firmy Microsoft (nawet jeśli będzie mowa o wcześniejszej wersji języka). Polecam pobranie tej wersji specyfikacji i trzymanie jej pod ręką, tak abyś mógł szybko odwołać się do niej w razie napotkanych nieścisłości. W większości przypadków starałem się wyeliminować specyfikację, przekształcając ją na treść przystępną dla programisty i opisującą wszystko, czego możesz się spodziewać w trakcie codziennego pisania kodu. Oznacza to między innymi dorzucenie sporej części szczegółów potrzebnych projektantom kompilatora. Mimo to specyfikacje są w miarę przejrzystymi dokumentami, które nie powinny sprawiać trudności podczas czytania. Jeśli ich treść Cię zainteresuje, możesz poszukać wersji z komentarzami dla C# 3. Znajdziesz tam fascynujące uwagi od zespołu C# i innych osób związanych z językiem (w drodze jest już wersja C# 4).
1.8.
Podsumowanie W tym rozdziale pokazałem (bez wyjaśniania) cechy języka, które będziemy szczegółowo zgłębiać w dalszej części książki. Nie jest to oczywiście pełen zbiór możliwości języka C#. Omawiane cechy mają szereg składników, a oprócz tego istnieje cała masa elementów języka, które nie zostały przedstawione. Mam nadzieję, że to, co zobaczyłeś w tym rozdziale, wzbudzi Twój apetyt na pozostałą część książki. Większość rozdziału była poświęcona omówieniu cech języka C#, ale wspomniałem też o specyfikacji, programie Snippy i LINQPad — rzeczach, które powinny pomóc Ci w wydobyciu jak najwięcej z tej książki. Wyjaśniłem, co mam na myśli, mówiąc o języku, środowisku wykonania i bibliotekach, a także opisałem sposób prezentacji kodu w książce. Przed rozpoczęciem omawiania nowych cech, jakie znalazły się w C# 2, musimy poświęcić trochę czasu C# 1. Jako autor nie mam pojęcia co do stanu Twojej wiedzy na temat C# 1, ale mam pewne pojęcie co do obszarów języka C# 1, które często stwarzają problemy koncepcyjne. Niektóre z tych obszarów mają istotne znaczenie dla zrozumienia późniejszych wersji języka. Właśnie dlatego poświęcimy im trochę uwagi w następnym rozdziale.
58
ROZDZIAŁ 1
Nieustająca metamorfoza C#
Rdzeń systemu — C# 1
Ten rozdział omawia: ♦ delegaty, ♦ charakterystykę systemu typów, ♦ typy wartościowe i referencyjne.
60
ROZDZIAŁ 2
Rdzeń systemu — C# 1
Na samym początku wyjaśnijmy jedną rzecz. Ten rozdział nie jest powtórką z całości C# 1. Chcąc pomieścić taki temat w jednym rozdziale, musiałbym zrezygnować z omawiania jakiegokolwiek innego w całej książce. Bazuję na założeniu, że Czytelnik posiada już pewną wiedzę i doświadczenie w programowaniu w C# 1. Oczywiście, te pojęcia są do pewnego stopnia subiektywne. W moim odczuciu jest to wiedza wystarczająca przynajmniej do ubiegania się o stanowisko młodszego inżyniera oprogramowania i, co za tym idzie — umiejętność odpowiedzenia na pytania techniczne właściwe dla tego typu stanowiska. Spodziewam się, że wielu Czytelników będzie posiadać bogatsze doświadczenie. W tym rozdziale skupimy się na trzech obszarach C#, które mają istotne znaczenie dla zrozumienia cech pojawiających się w kolejnych wersjach języka. Poznanie ich lepiej pozwoli na poczynienie odrobinę większych założeń odnośnie do Twojej wiedzy w dalszej części książki. Biorąc pod uwagę fakt, że obszary te są najmniejszym wspólnym mianownikiem wiedzy, może się okazać, że koncepcje opisywane w tym rozdziale są dla Ciebie zupełnie zrozumiałe. W takiej sytuacji możesz pominąć cały rozdział. Nic nie stoi na przeszkodzie, aby powrócić do niego później, jeśli okaże się, że czegoś jednak nie rozumiesz. Sprawdź pobieżnie podsumowanie każdego rozdziału, gdzie wymienione są najistotniejsze informacje. Jeśli jakiejś z nich nie będziesz rozumiał, przeczytaj cały podrozdział. Zaczniemy od delegatów, a następnie zastanowimy się, jak system typów C# ma się do innych rozwiązań. Na końcu omówimy różnice pomiędzy typami wartościowymi i referencyjnymi. Opiszę zamysł każdego z tych mechanizmów oraz ich zachowanie, a także określę pewne warunki, z których będę mógł skorzystać później. Po przekonaniu się, jak działa C# 1, dokonamy szybkiego przeglądu nowych cech, wprowadzonych w późniejszych wersjach języka, które odnoszą się do tematyki rozdziału.
2.1.
Delegaty Jestem pewny, że masz już instynktowne przeczucie odnośnie do koncepcji delegatów, chociaż nie do końca jesteś w stanie je wyartykułować. Jeżeli znasz język C i miałbyś opisać delegaty innemu programiście tego języka, zapewne w rozmowie użyłbyś pojęcia wskaźnik funkcji. Opisując to w największym skrócie — delegaty wprowadzają pewien poziom abstrakcji: zamiast wskazywać bezpośrednio sposób zachowania, można go „zawrzeć” w pewnym obiekcie. Taki obiekt może być używany tak jak każdy inny, a wśród oferowanych przez niego możliwości jest wykonanie osadzonej w środku akcji. Inaczej mówiąc, możesz traktować delegat jako interfejs składający się z pojedynczej metody, a instancję delegata jako obiekt implementujący ten interfejs. Dla lepszego zrozumienia posłużmy się przykładem. Jest on odrobinę z innego świata, ale dobrze oddaje sedno delegatów. Załóżmy, że spisałeś swój testament. Jest to zbiór instrukcji, na przykład: „zapłacić rachunki, przekazać środki na cele dobroczynne, pozostałą część majątku przekazać mojemu kotu”. Piszesz to przed śmiercią i pozostawiasz w odpowiednio bezpiecznym miejscu. Zakładasz, że po Twojej śmierci reprezentujący Cię adwokat wykona wszystkie instrukcje. Delegat ma takie samo znaczenie jak Twój testament w rzeczywistym świecie — jest sekwencją operacji przeznaczonych do wykonania w określonym czasie. Typowa
2.1.
Delegaty
61
sytuacja użycia delegatów ma miejsce, kiedy kod, który ma wykonać pewne operacje, nie zna ich dokładnej treści. Dla przykładu klasa Thread może wykonać zadanie w nowym wątku po starcie, ponieważ przekazałeś jej w konstruktorze instancję delegata typu ThreadStart lub ParameterizedThreadStart. Wycieczkę po delegatach zaczniemy od całkowitych podstaw, bez których cała pozostała część nie miałaby sensu.
2.1.1. Przepis na prosty delegat Aby delegaty mogły cokolwiek zrobić: musi zostać zadeklarowany typ delegatowy, musi istnieć metoda zawierająca kod do wykonania, musi zostać utworzona instancja delegata, instancja delegata musi zostać wywołana.
Prześledźmy kolejno każdy z tych czterech kroków. Deklaracja typu delegatowego Typ delegatowy to lista typów parametrów i typ zwracany. Określa on, jakiego rodzaju operacje mogą być reprezentowane przez instancje typu. Weźmy za przykład delegat o następującej deklaracji: delegate void StringProcessor(string input);
Ten kod mówi, że jeśli chcemy stworzyć instancję delegata StringProcessor, będziemy potrzebować metody z jednym parametrem typu string i typem zwracanym void (metoda nie zwraca wyniku). Należy sobie uzmysłowić, że StringProcessor jest typem wywodzącym się z typu System.MulticastDelegate, który z kolei wywodzi się z System. ´Delegate. Typ ten ma swoje metody, można tworzyć jego instancje, przekazywać referencje do nich — robić wszystko, na co pozwala typ danych. Delegaty mają oczywiście pewne cechy specjalne, jeśli jednak kiedykolwiek utkniesz, zastanawiając się nad możliwym zachowaniem delegata w określonej sytuacji, pomyśl, jak zachowałby się w takiej chwili zwykły typ referencyjny. NIEJEDNOZNACZNOŚĆ OKREŚLENIA DELEGAT. Słowo delegat opisuje zarówno typ delegatowy, jak i instancję typu delegatowego, co może prowadzić do problemów interpretacyjnych. Różnica między nimi jest dokładnie taka sama jak w przypadku typu i instancji typu, na przykład typ string jest czym innym niż pewna kolekcja znaków. Aby uniknąć jakichkolwiek nieporozumień co do treści, którą mam na myśli, w dalszej części tego rozdziału będę stosował określenia typ delegatowy i instancja typu delegatowego (dla uproszczenia instancja delegata). Naszego typu delegatowego — StringProcessor — użyjemy podczas omawiania kolejnego kroku.
62
ROZDZIAŁ 2
Rdzeń systemu — C# 1
Wyszukiwanie odpowiedniej metody dla instancji typu delegatowego Naszym kolejnym krokiem jest znalezienie (lub, oczywiście, napisanie) metody, która wykonuje interesujące nas operacje i posiada taką samą sygnaturę jak używany przez nas typ delegatowy. Musimy mieć pewność, że kiedy wywołamy instancję delegata, użyte przez nas parametry będą zgodne i będziemy w stanie skorzystać z wartości zwróconej (jeśli taka jest) w sposób przez nas oczekiwany — tak jak ma to miejsce w przypadku wywołania zwykłej metody. Przyjrzyj się sygnaturom pięciu metod jako kandydatom instancji typu StringProcessor: void PrintString(string x) void PrintInteger(int x) void PrintTwoStrings(string x, string y) int GetStringLength(string x) void PrintObject(object x)
Pierwsza metoda jest całkowicie zgodna i możemy jej użyć do stworzenia instancji typu delegatowego. Druga metoda ma jeden parametr wejściowy, ale nie jest to string, zatem jest ona niezgodna z typem StringProcessor. Trzecia metoda ma dobry pierwszy parametr, ale ma również parametr drugi, zatem jest niezgodna. Czwarta metoda ma dobrą listę parametrów wejściowych, ale nieprawidłowy typ zwracany, którego zgodność jest równie istotna jak zgodność parametrów wejściowych. Interesująca jest piąta metoda — każde wywołanie instancji StringProcessor moglibyśmy zastąpić wywołaniem metody PrintObject, ponieważ string wywodzi się z typu object. Użycie jej jako instancji delegata StringProcessor wydaje się sensowne, niestety C# 1 ogranicza delegaty wyłącznie do typów w pełni zgodnych z deklaracją1. C# 2 zmienia zasady — więcej na ten temat dowiesz się w rozdziale 5. Czwarta metoda w jakimś sensie jest bliska ideału — moglibyśmy przecież umówić się, że ignorujemy wartość zwracaną. Jak na razie jednak różnica pomiędzy pustym i niepustym typem zwracanym jest traktowana jako niekompatybilność. Jest to częściowo związane z faktem, iż inne elementy systemu (z kompilatorem JIT w szczególności) muszą wiedzieć, czy wykonanie metody zakończy się położeniem na stosie wartości zwracanej2. Załóżmy, że mamy ciało dla metody zgodnej z sygnaturą typu delegatowego (PrintString), i przejdźmy do kolejnego składnika — samej instancji delegata. Tworzenie instancji delegata Mamy typ delegatowy i metodę z właściwą sygnaturą. Możemy przystąpić do tworzenia instancji tego typu, wskazując, aby wybrana przez nas metoda została wykonana w odpowiedzi na wywołanie instancji delegata. Nie ma oficjalnej terminologii opisującej ten element kodu. Ja zamierzam w dalszej części książki określać go mianem akcji instancji delegata. Konkretna forma wyrażenia tworzącego instancję delegata zależy 1
Oprócz typów parametrów trzeba również zachować zgodność z modyfikatorami in (domyślny), out i ref. Obecność dwóch ostatnich w delegatach należy do rzadkości.
2
Celowo stosuję tutaj ogólne pojęcie stosu, aby uniknąć wdawania się w szczegóły techniczne, które w tym kontekście są nieistotne. Więcej informacji na ten temat możesz znaleźć na blogu Erica Lipperta „The void is invariant” („Pustka jest niezmienna”) — http://mng.bz/4g58.
2.1.
Delegaty
63
od tego, czy akcją ma być metoda, czy też funkcja statyczna. Przyjmijmy, że PrintString jest funkcją statyczną zawartą w typie o nazwie StaticMethods, a także metodą w typie InstanceMethods. Dla tak zdefiniowanych elementów przykład utworzenia dwóch instancji typu StringProcessor mógłby wyglądać następująco: StringProcessor proc1, proc2; proc1 = new StringProcessor(StaticMethods.PrintString); InstanceMethods instance = new InstanceMethods(); proc2 = new StringProcessor(instance.PrintString);
Kiedy akcją jest metoda statyczna, wystarczy wskazać nazwę jej typu. W przypadku metody potrzebna jest instancja typu (lub typu potomnego), do którego metoda ta należy — tak jak w przypadku klasycznego wywołania metody obiektu. Instancja taka jest nazywana celem akcji. W chwili wywołania instancji delegata metoda będzie wywołana właśnie na tym obiekcie. Jeżeli akcja znajduje się wewnątrz tej samej klasy (jak ma to często miejsce, szczególnie kiedy piszesz obsługę zdarzeń elementów interfejsu użytkownika), jesteś zwolniony z konieczności kwalifikowania nazw — dla metod jest niejawnie przyjmowana referencja this3. Powtórzę to jeszcze raz — obowiązują takie same reguły jak w przypadku bezpośredniego wywołania metody. PROBLEM Z ODŚMIECANIEM PAMIĘCI. Warto mieć świadomość, że jeśli instancja delegata nie może zostać usunięta z pamięci (przez garbage collector), to samo dotyczy jej celu. Może to prowadzić do wycieków pamięci, szczególnie kiedy „krótkotrwały” obiekt podłącza się do zdarzenia obiektu „długowiecznego”. W takiej sytuacji obiekt długowieczny — w sposób pośredni — trzyma referencję do obiektu krótkotrwałego, przedłużając jego życie. Tworzenie instancji delegata nie ma większego sensu, jeśli nie zamierzamy go wywołać. Właśnie tym zajmiemy się w ostatnim kroku. Wywołanie instancji delegata Ta część jest bardzo prosta4 i sprowadza się do wywołania metody na instancji delegata. Metoda nazywa się Invoke i jest zawsze dostępna w typie delegatowym z takimi samymi parametrami wejściowymi i typem zwracanym, jakie zostały wymienione w deklaracji. W naszym przypadku ma ona postać: void Invoke(string input)
Wywołanie Invoke spowoduje wykonanie akcji instancji delegata z przekazaniem jej wszelkich argumentów, jakie zostały podane metodzie Invoke, i zwróceniem wartości wyniku akcji (o ile typ zwracany jest różny od void). C# upraszcza ten mechanizm jeszcze bardziej — jeśli masz zmienną5 typu delegatowego, możesz ją traktować tak, jakby ona sama była metodą. Najłatwiej można to 3
Oczywiście, jeśli akcją jest metoda obiektu, a Ty próbujesz utworzyć instancję delegata wewnątrz funkcji statycznej, nadal jesteś zobowiązany wskazać referencję do celu.
4
Chodzi o zmienną służącą do wywołań synchronicznych. Możesz również użyć metod BeginInvoke i EndInvoke, aby uruchomić instancję delegata w sposób asynchroniczny, ale ten temat wykracza poza zakres tego rozdziału.
5
Lub dowolne inne wyrażenie, chociaż na ogół jest to zmienna.
64
ROZDZIAŁ 2
Rdzeń systemu — C# 1
zrozumieć, patrząc na cały proces jak na ciąg następujących po sobie zdarzeń — patrz rysunek 2.1. Rysunek 2.1. Przetwarzanie wywołania instancji delegata z użyciem skróconej składni C#
Wszystkie składniki dodane, piec ustawiony na 200 stopni, tak więc mieszamy składniki i patrzymy, co wyjdzie. Kompletny przykład i trochę motywacji Najprościej będzie zobaczyć całość na pełnym przykładzie — na czymś, co można będzie w końcu uruchomić! Ponieważ w programie dzieje się wiele rzeczy, tym razem zrezygnowałem z fragmentarycznego kodu i umieściłem pełne źródło. Poniższy listing (2.1) nie powala na kolana — nie oczekuj, że znajdziesz w nim coś zupełnie niespodziewanego. Chcę wyłącznie zaprezentować pewien materiał do dyskusji. Listing 2.1. Wiele prostych sposobów wykorzystania delegatów using System; delegate void StringProcessor(string input); Deklaracja typu delegatowego class Person { string name; Deklaracja metody public Person(string name) { this.name = name; } o sygnaturze public void Say(string message) zgodnej z typem { delegatowym Console.WriteLine("{0} mówi: {1}", name, message); } } class Background { public static void Note(string note) { Console.WriteLine("({0})", note); } } class SimpleDelegateUse { static void Main()
Deklaracja funkcji statycznej o sygnaturze zgodnej z typem delegatowym
2.1.
Delegaty
65
{ Person jon = new Person("Jon"); Person tom = new Person("Tom"); StringProcessor jonsVoice, tomsVoice, background; jonsVoice = new StringProcessor(jon.Say); tomsVoice = new StringProcessor(tom.Say); background = new StringProcessor(Background.Note); jonsVoice("Cześć, synku."); tomsVoice.Invoke("Cześć, tato!"); background("Przelatuje samolot."); }
Utworzenie trzech instancji delegatów
Wywołanie instancji delegatów
}
Zaczynamy od deklaracji typu delegatowego ( ). Następnie tworzymy dwie funkcje ( i ) — obie zgodne z typem delegatowym. Jedna z nich jest metodą klasy (Person.Say), a druga funkcją statyczną (Background.Note). Dzięki nim możemy pokazać różnice w sposobie tworzenia instancji delegatów ( ). Stworzyliśmy dwie instancje klasy Person, dzięki czemu możemy zobaczyć różnicę w wykonaniu celu delegata. Wywołany jonsVoice ( ) powoduje wykonanie metody Say klasy Person z imieniem Jon, podobnie tomsVoice używa obiektu o imieniu Tom. W przykładzie zastosowałem obie metody wywołania instancji delegata — wywołanie z jawnym użyciem metody Invoke oraz użycie skróconej składni C#. W praktyce użylibyśmy za każdym razem składni skróconej. Wynik działania programu z listingu 2.1 jest oczywisty: Jon mówi: Cześć, synku. Tom mówi: Cześć, tato! (Przelatuje samolot.)
Szczerze mówiąc, listing 2.1 zużywa bardzo dużo kodu tylko po to, żeby wyświetlić trzy linijki tekstu. Nawet gdybyśmy chcieli skorzystać z klas Person i Background, można to zrobić bez pomocy delegatów. O co zatem chodzi? Odpowiedź tkwi w naszym pierwszym przykładzie z adwokatem wykonującym ostatnią wolę zmarłego — to, że czegoś chcesz, nie znaczy wcale, że zawsze będziesz w stanie pojawić się w odpowiednim miejscu i czasie, aby dopilnować, żeby tak się stało. Czasem musisz zostawić swoje instrukcje i zrzucić odpowiedzialność za ich wykonanie na delegat. Spieszę dodać, że w naszym świecie oprogramowania nie chodzi bynajmniej o obiekty, które mają pozostawiać instrukcje do wykonania przed swoim „odejściem”. Często obiekt tworzący instancję delegata nadal rezyduje w pamięci i ma się całkiem dobrze, kiedy jest wywoływana instancja delegata. Chodzi raczej o wyspecyfikowanie pewnego kodu do wykonania w ściśle określonym czasie, kiedy Ty sam nie jesteś w stanie (lub nie chcesz) zmieniać kodu wykonywanego w danej chwili. Kiedy chcę, aby coś zdarzyło się w chwili kliknięcia przycisku, moim celem nie jest zmiana kodu tego przycisku. Chcę jedynie powiedzieć przyciskowi, aby wykonał jedną z moich metod, która podejmie odpowiednią akcję. Jest to kwestia wprowadzenia pewnego poziomu pośrednictwa, którym cechuje się programowanie zorientowane obiektowo. W praktyce oznacza to zwiększenie stopnia skomplikowania kodu (zwróć uwagę na ilość kodu potrzebną do wyprodukowania tak małego wyniku programu!), ale również zwiększenie jego elastyczności.
66
ROZDZIAŁ 2
Rdzeń systemu — C# 1
Teraz, kiedy rozumiemy już proste delegaty, przyjrzymy się pobieżnie łączeniu delegatów w celu wykonania całego zbioru akcji zamiast tylko jednej z nich.
2.1.2. Łączenie i usuwanie delegatów Do tej pory przyglądaliśmy się instancjom delegatów posiadających pojedynczą akcję. Rzeczywistość nie jest taka prosta — instancja delegata trzyma listę związanych z nią akcji, określaną mianem listy wywołań. Za tworzenie nowych instancji delegata, poprzez łączenie list wywołań niezależnych instancji delegata lub usuwanie listy wywołań jednej instancji z innej, są odpowiedzialne metody, odpowiednio, Combine i Remove. DELEGATY SĄ NIEZMIENNE. Po utworzeniu instancji delegata jego natura nie może zostać zmieniona. Dzięki temu możliwe jest przekazywanie referencji instancji i łączenie jej z innymi instancjami, bez obawy zaburzenia ich spójności, bezpieczeństwa wątków lub umożliwienia komukolwiek zmiany wykonywanych akcji. Występuje tutaj podobieństwo do łańcuchów, które również są niezmienne. Wspominam o tym, ponieważ Delegate.Combine przypomina metodę String.Concat — obie łączą istniejące instancje razem, aby stworzyć nową, bez jakiejkolwiek modyfikacji już istniejących obiektów. W przypadku instancji delegatów łączone są oryginalne listy wywołań. Zauważ, że jeśli kiedykolwiek spróbujesz złączyć null z instancją delegata, wartość null będzie traktowana jako instancja delegata z pustą listą wywołań. Rzadko będziesz miał okazję zobaczyć jawne wywołanie metod klasy Delegate. Zamiast Combine najczęściej są używane operatory + i +=. Proces tłumaczenia — wykonywany w całości przez kompilator C# — został pokazany na rysunku 2.2, gdzie x i y są zmiennymi tego samego (lub kompatybilnego) typu delegatowego. Rysunek 2.2. Proces transformacji stosowany w przypadku łączenia instancji delegatów za pomocą skróconej składni C#
Jak widzisz, transformacja nie jest skomplikowana, a mimo to pozwala na tworzenie o wiele bardziej schludnego kodu. Instancje delegatów można również rozdzielić, stosując metodę Delegate.Remove. W C# są dostępne skróty dla tej metody w postaci operatorów – i -=. Delegate.Remove(source, value) tworzy nową instancję delegata przez usunięcie z instancji delegata przekazanego jako parametr source listy wywołań zawartej w instancji przekazanej jako parametr value. Jeżeli wynikiem operacji jest pusta lista wywołań, zwracana jest wartość null.
2.1.
Delegaty
67
Wywołanie instancji delegata prowadzi do wykonania kolejno wszystkich akcji. Jeżeli sygnatura delegata ma niepusty typ zwracany, Invoke zwraca wartość będącą wynikiem wykonania ostatniej akcji. W praktyce rzadko spotyka się instancje delegata z niepustym typem zwracanym i więcej niż jedną akcją. Jak nietrudno sobie wyobrazić, w takiej sytuacji wartości zwracane przez wszystkie pozostałe akcje są niewidoczne (ignorowane), o ile kod, który dokonuje wywołania, nie przejmie kontroli i nie będzie wykonywał po jednej akcji naraz (używając do tego celu Delegate.GetInvocationList). Rzucenie wyjątku przez którąkolwiek z akcji powoduje wstrzymanie wykonywania dalszych akcji. Jeśli na przykład instancja delegata ma listę wywołań [a, b, c], a akcja b rzuci wyjątek, zostanie on niezwłocznie rozpropagowany, co uniemożliwi wykonanie akcji c. Łączenie i usuwanie instancji delegatów jest szczególnie przydatne w zdarzeniach. Skoro wiemy już, na czym polegają te dwie operacje, możemy porozmawiać o zdarzeniach.
2.1.3. Dygresja na temat zdarzeń Przypuszczam, że posiadasz pewne ogólne pojęcie odnośnie do całościowej idei zdarzeń, zwłaszcza jeśli kiedykolwiek parałeś się programowaniem elementów interfejsu użytkownika. Ideą tą jest umożliwienie programowi zareagowania na pewną sytuację, np. zapisanie pliku, kiedy kliknięto odpowiedni przycisk. W tym wypadku zdarzeniem jest kliknięcie odpowiedniego przycisku, a reakcją (lub akcją) — zapisanie pliku. Pojmowanie przyczyny istnienia tej koncepcji jest czymś zupełnie innym od rozumienia, w jaki sposób C# definiuje zdarzenia w kontekście języka programowania. Programiści często mylą pojęcia zdarzeń i instancji delegatów lub zdarzeń i pól zadeklarowanych razem z typem delegatowym. Ta różnica jest ważna: zdarzenia nie są polami. I znów powodem niezrozumienia jest dostępność w C# skróconej składni, która ma postać zdarzeń w formie pól. Wrócimy do nich za chwilę, ale najpierw sprawdźmy, z czego się składają zdarzenia, patrząc na to pod kątem kompilatora C#. Myśląc o zdarzeniach, warto je traktować jak właściwości. Jedne i drugie są deklarowane z użyciem pewnego typu, którym w przypadku zdarzeń musi być typ delegatowy. Kiedy korzystasz z właściwości, na pierwszy rzut oka wygląda to tak, jakbyś pobierał wartości z pól lub bezpośrednio je przypisywał, ale w rzeczywistości wywołujesz metody (gettery i settery). Chociaż wiadomo, że najbardziej powszechną implementacją są proste odwołania do pól klasy, czasem z dodatkową walidacją w setterze i kodem zapewniającym bezpieczeństwo wątków, wewnątrz metod dających dostęp do właściwości (setterów i getterów) może się pojawić dowolna implementacja. Podobnie jest, kiedy podłączasz się do zdarzenia lub się od niego odłączasz — wygląda to tak, jakbyś używał pola typu delegatowego w połączeniu z operatorami += i –=. W rzeczywistości jednak wywołujesz metody (dodające lub usuwające)6. Twoja praca 6
Nazwy tych metod są dosyć złożone (gdyby miały zawsze tę samą nazwę, możliwe byłoby dodanie wyłącznie jednego zdarzenia dla danego typu). Za ich wygenerowanie odpowiada kompilator, który dba o to, aby nazwy się nie powtarzały, a także generuje dodatkowe metadane. Dzięki tym danym inne typy mogą się dowiedzieć, że istnieje zdarzenie o określonej nazwie, a także jak nazywają się jego metody służące do dodawania i usuwania instancji delegatów.
68
ROZDZIAŁ 2
Rdzeń systemu — C# 1
sprowadza się wyłącznie do tych dwóch operacji — podłączenia do zdarzenia (dodania metody obsługi zdarzenia) lub odłączenia od zdarzenia (usunięcia metody obsługi zdarzenia). Za wykonanie jakiejkolwiek użytecznej pracy są odpowiedzialne metody samego zdarzenia. Powód do posiadania zdarzeń jest porównywalny z powodem do posiadania właściwości — poprzez implementację wzorca nadawca i odbiorca wprowadzają dodatkową warstwę kapsułkującą. Tak jak nie chcesz, aby obcy kod był w stanie ustawiać wartości pól z pominięciem choćby najprostszej walidacji, tak samo nie chcesz, aby kod poza klasą mógł zmieniać (lub uruchamiać) metody odpowiedzialne za wywoływanie metod obsługi zdarzeń. Oczywiście, klasa może sama z siebie udostępnić taką możliwość — na przykład w celu wyczyszczenia listy metod obsługujących zdarzenie lub jawnego wymuszenia obsługi (inaczej mówiąc, jawnego wywołania metod obsługujących zdarzenia). Przykładowo metoda BackgroundWorker.OnProgressChanged wywołuje metody obsługi zdarzenia ProgressChanged. Ponieważ udostępniasz jednak tylko samo zdarzenie, kod poza tą klasą otrzymuje jedynie możliwość dodawania i usuwania metod jego obsługi7. Zdarzenia w formie pól pozwalają na łatwe objęcie wzrokiem implementacji całego procesu — jedna deklaracja i gotowe. Kompilator zmienia deklarację w zdarzenie z domyślnymi implementacjami metod dodających i usuwających metody obsługi oraz dodaje prywatne pole tego samego typu. Pole jest widoczne dla kodu wewnątrz klasy, a kod zewnętrzny widzi jedynie zdarzenie. Daje to wrażenie, jakbyś mógł wywołać zdarzenie, ale to, co faktycznie robisz, aby wywołać metody obsługi zdarzenia, jest wywołaniem instancji delegata zapisanego w tym polu. Szczegóły implementacyjne zdarzeń wykraczają poza zakres tego rozdziału. Moją intencją było jedynie zwrócenie uwagi na różnicę pomiędzy instancjami delegatów i zdarzeniami. Unikniemy w ten sposób nieporozumień w dalszej części książki. Dodam jeszcze, że zdarzenia nie uległy znacznym zmianom w późniejszych wersjach języka C#8.
2.1.4. Podsumowanie delegatów Podsumujmy to, czego się dowiedzieliśmy o delegatach: Delegaty kapsułkują wywołanie z określoną liczbą parametrów i typem zwracanym. Można je porównać do interfejsów z pojedynczą metodą. Sygnatura typu opisana przez deklarację typu delegatowego określa, które
metody mogą zostać użyte do utworzenia instancji delegata oraz jak wygląda ich wywołanie. Utworzenie instancji delegata wymaga metody (w przypadku obiektów) i celu,
na którym metoda zostanie wykonana. Instancje delegatów są niezmienne. Każda instancja delegata zawiera listę wywołań — listę akcji. 7
Nie ma możliwości wywołania obsługi zdarzeń na zewnątrz klasy BackgroundWorker — przyp. tłum.
8
W C# 4 wprowadzono małe zmiany do zdarzeń w formie pól. Więcej na ten temat znajdziesz w podrozdziale 13.4.
2.2.
Charakterystyka systemu typów
69
Instancje delegatów mogą być łączone i usuwane z siebie nawzajem. Zdarzenia nie są instancjami delegatów, są jedynie parami metod dodających
i usuwających (analogicznie do setterów i getterów właściwości). Delegaty są jedną ze specyficznych cech C# i .NET — detalem w wielkim projekcie. Dwa pozostałe podrozdziały, których zadaniem jest odświeżenie wiedzy, dotyczą znacznie szerszych tematów. W pierwszej kolejności rozważymy, czym charakteryzuje się statyczny system typów w C# i jakie są tego konsekwencje.
2.2.
Charakterystyka systemu typów Niemal każdy język programowania ma jakiś system typów. Z upływem czasu systemy te zostały uszeregowane jako mocne/słabe, bezpieczne/niebezpieczne oraz statyczne/dynamiczne. Nietrudno sobie wyobrazić, jakie znaczenie ma wiedza na temat systemu typów, z jakim przyszło nam pracować. Możemy również oczekiwać, że znajomość kategorii, do której zalicza się wybrany przez nas język, będzie stanowić dużą pomoc w pracy. Ponieważ jednak ludzie używają różnych terminów do określenia cech języka związanych z systemem typów, powstaje pewien chaos komunikacyjny. Ze swojej strony postaram się zredukować te nieścisłości, precyzując znaczenie każdej z tych cech. Zwracam uwagę, iż ten podrozdział ma zastosowanie wyłącznie do kodu „bezpiecznego”, czyli sytuacji, w której kod C# nie jest jawnie zdefiniowany jako niebezpieczny. Jak sam się domyślasz na podstawie nazwy, kod niebezpieczny może wykonywać operacje, które są zabronione dla kodu bezpiecznego i które mogą naruszać pewne reguły bezpieczeństwa typów, chociaż sam system typów pod wieloma względami pozostaje bezpieczny. Z dużą dozą pewności można przyjąć, że większość programistów nigdy nie spotka się z koniecznością pisania kodu niebezpiecznego. Ograniczmy się zatem wyłącznie do kodu bezpiecznego, w którym system typów jest prostszy i łatwiejszy do zrozumienia. Opiszemy, jakie ograniczenia nakłada (lub jakich nie nakłada C# 1), i zdefiniujemy pewne pojęcia opisujące to zachowanie. Następnie zobaczymy kilka rzeczy, których nie można wykonać w C# 1 — w pierwszej kolejności zajmiemy się tym, czego nie możemy powiedzieć kompilatorowi, a następnie tym, czego chcielibyśmy nie musieć mówić kompilatorowi. Zacznijmy od opisania, jak zachowuje się C# 1 i jaką terminologią można opisać to zachowanie.
2.2.1. Miejsce C# w świecie systemów typów Najprościej będzie zacząć od stwierdzenia, a następnie wytłumaczyć jego faktyczne znaczenie i możliwe alternatywy: System typów w C# 1 jest statyczny, jawny i bezpieczny. Być może oczekiwałeś, że na tej liście pojawi się słowo mocny. Przyznam, że kusiło mnie, aby je dołączyć. Chociaż większość ludzi jest w stanie zgodzić się, że język ma wymienione cechy, określenie, że język posiada mocny system typów, ze względu na
70
ROZDZIAŁ 2
Rdzeń systemu — C# 1
duży rozdźwięk w stosowanych definicjach może doprowadzić do bardzo gorącej dyskusji. Niektóre definicje (niedopuszczające jawnych i niejawnych konwersji) jednomyślnie odrzuciłyby C#, podczas gdy inne, bliskie (lub niemal pasujące dokładnie) statycznemu systemowi typów, uwzględniłyby C#. Większość przeczytanych przeze mnie książek i artykułów, które opisują C# jako język o mocnym systemie typów, używa tego określenia w znaczeniu statycznego systemu typów. Przeanalizujmy po kolei słowa używane w definicji i wyjaśnijmy sobie ich znaczenie. Typy statyczne kontra typy dynamiczne C# 1 ma statyczny system typów, a zatem każda zmienna9 jest określonego typu i typ ten jest znany na etapie kompilacji. Dozwolone są jedynie operacje zgodne z wybranym typem. Ta reguła jest wymuszana przez kompilator. Przyjrzyj się następującemu wymuszeniu: object o = "cześć"; Console.WriteLine(o.Length);
Jako programiści patrzący na ten kod wiemy, że wartość o jest łańcuchem i że typ string ma właściwość Length. Jednak z punktu widzenia kompilatora o jest typu object. Jeżeli chcemy pobrać właściwość Length, musimy powiedzieć kompilatorowi, że o jest faktycznie łańcuchem: object o = "cześć"; Console.WriteLine(((string)o).Length);
Na tej podstawie kompilator jest w stanie znaleźć właściwość Length typu System.String. Tej wiedzy używa do sprawdzenia, czy wywołanie jest poprawne, wstrzyknięcia odpowiedniego kodu IL, a także wypracowania typu całego wyrażenia. Typ wyrażenia czasu kompilacji jest również określany mianem typu statycznego — możemy zatem powiedzieć: „statycznym typem o jest System.Object”. SKĄD WZIĘŁA SIĘ NAZWA „TYPY STATYCZNE”? Słowo statyczny jest używane do opisania tego systemu typów, ponieważ analiza dostępnych operacji jest przeprowadzana w oparciu o niezmienne dane — typy wyrażeń czasu kompilacji. Załóżmy, że zadeklarowanym typem zmiennej jest Stream — nie ulegnie on zmianie, nawet jeśli wartość będzie się faktycznie odnosić do typu MemoryStream, FileStream lub pustej referencji (reprezentowanej przez null). Oczywiście nawet w statycznym systemie typów dopuszczalne jest pewne dynamiczne zachowanie. Przykładem są metody wirtualne — tutaj wykonywany kod (implementacja) zależy od rzeczywistej wartości zmiennej. Chociaż idea niezmiennej informacji jest również fundamentem dla modyfikatora static, prościej jest myśleć o statycznych elementach jak o elementach należących do typu, a nie do konkretnej jego implementacji. W praktyce oba użycia tych słów nie są ze sobą związane. 9
Odnosi się to również do wyrażeń, chociaż nie do wszystkich. Niektóre wyrażenia, takie jak wywołania metod ze zwracanym typem void, nie mają swojego typu. Nie wpływa to jednak na statyczny charakter typów C# 1. W dalszej części rozdziału ograniczę się do używania słowa zmienna, mając na myśli zmienne i wyrażenia.
2.2.
Charakterystyka systemu typów
71
Przeciwieństwem typów statycznych są typy dynamiczne, które mogą występować w wielu przebraniach. Sednem typów dynamicznych jest to, że zmienne mają po prostu swoje wartości, bez ograniczenia co do ich typu. Kompilator, dla odmiany, nie ma możliwości przeprowadzenia jakiejkolwiek weryfikacji poprawności kodu pod tym względem. Ciężar odpowiedzialności zostaje przeniesiony na czas wykonania, kiedy następuje sprawdzenie poprawności wyrażeń pod kątem zastosowanych wartości. Gdyby na przykład C# 1 był językiem dynamicznym, moglibyśmy zrobić rzecz następującą: object o = "cześć"; Console.WriteLine(o.Length); o = new string[] {"czołem", "witamy"}; Console.WriteLine(o.Length);
Co się stanie?
Taki kod podczas dynamicznej analizy typów spowodowałby w trakcie wykonania odwołanie do dwóch zupełnie ze sobą niezwiązanych właściwości Length: String.Length i Array.Length. W różnych systemach typów dynamicznych istnieją różne poziomy kontroli. Niektóre języki pozwalają na specyfikowanie typów w dowolnym miejscu kodu, prawdopodobnie traktując je jako dynamiczne wszędzie oprócz miejsca przypisania, ale jednocześnie zezwalają na stosowanie zmiennych bez typu. Chociaż w tej części odnosiłem się bezpośrednio do C# 1, C# w całości posługuje się statycznym systemem typów aż do wersji trzeciej włącznie. W dalszej części książki przekonamy się, że C# 4 wprowadza pewne elementy dynamicznego systemu typów, chociaż przeważająca większość aplikacji C# 4 będzie nadal oparta na typach statycznych. Typy jawne kontra typy niejawne Rozróżnienie pomiędzy typami jawnymi i typami niejawnymi ma znaczenie tylko w przypadku języków ze statycznym systemem typów. W pierwszym przypadku typ każdej zmiennej musi być jawnie określony w deklaracji. W przypadku typów niejawnych pozwalamy kompilatorowi na wykrycie typu zmiennej w oparciu o sposób jej użycia. Na przykład język mógłby narzucać wymuszanie typu zmiennej typem wyrażenia służącego do ustalenia wartości początkowej. Załóżmy, że istnieje hipotetyczny język10 używający słowa kluczowego var do wskazania wnioskowania typu zmiennej. Tabela 2.1 pokazuje, jak kod w takim języku mógłby być napisany w C# 1. Kod w lewej kolumnie jest niedozwolony w C# 1, ale odpowiadający mu kod w prawej kolumnie jest poprawny. Tabela 2.1. Przykład pokazujący różnice pomiędzy jawnymi i niejawnymi typami Typy niejawne — kod niedozwolony w C# 1
Typy jawne — kod dozwolony w C# 1
var s = "cześć";
string s = "cześć";
var x = x.Length;
int x = s.Length;
var twiceX = x * 2;
int twiceX = x * 2;
10
Dobrze, przyznaję się — nie jest on hipotetyczny. W podrozdziale 8.2 znajdziesz opis zmiennych o typie niejawnym dostępnych w C# 3.
72
ROZDZIAŁ 2
Rdzeń systemu — C# 1
Mam nadzieję, że powód, dla którego to rozróżnienie ma sens wyłącznie w statycznym systemie typów, jest jasny: w przypadku jawnego i niejawnego nadawania typu musi on być znany w czasie kompilacji, nawet jeśli nie jest jawnie wskazany w kodzie. W kontekście dynamicznym zmienna nie ma typu czasu kompilacji, który mógłby być jawnie wyrażony lub wywnioskowany. Bezpieczny i niebezpieczny system typów Najprostszy sposób zdefiniowania bezpiecznego systemu typów to opisanie jego przeciwieństwa. Niektóre języki (myślę szczególnie o C i C++) pozwalają na przeprowadzanie naprawdę przebiegłych operacji. W odpowiednich warunkach dają one wielkie możliwości, jak jednak mówi przysłowie: „Z wielką siłą przychodzi wielka odpowiedzialność”, a odpowiednie warunki są rzadkością. Jeśli popełnisz chociaż najmniejszy błąd, niektóre z tych operacji mogą się okazać strzałem we własną stopę. Przykładem może tu być nadużywanie systemu typów. Stosując odpowiednie sztuczki, możesz przekonać te języki, aby (bez jakiejkolwiek konwersji) traktowały wartość określonego typu tak, jakby była typem zupełnie innym. Nie mam tutaj na myśli tylko wywoływania metody, która przypadkiem ma taką samą nazwę — jak w naszym przykładzie z typami dynamicznymi. Mam na myśli kod, który przetwarza „gołe” bajty danych wewnątrz wartości i interpretuje je w „zły” sposób. Poniższy przykład w C (listing 2.2) obrazuje, co mam na myśli. Listing 2.2. Demonstracja systemu z niebezpiecznym systemem typów #include int main(int argc, int** argv) { char* first_arg = argv[1]; int* first_arg_as_int = (int *)first_arg; printf("%d", *first_arg_as_int); }
Jeśli skompilujesz listing 2.2 i uruchomisz go z prostym argumentem "hello", na ekranie zobaczysz wartość 1819043176 — zakładając, że jesteśmy na platformie o architekturze little-endian, używamy kompilatora, który traktuje int jako wartość 32-bitową, char jako 8 bitów, a tekst jest reprezentowany przez znaki ASCII lub UTF-8. Kod traktuje wskaźnik na char jak wskaźnik na int. Dereferencja prowadzi zatem do pobrania pierwszych czterech znaków łańcucha i potraktowania ich jako liczby. Ten przykład jest w miarę „łagodny” w porównaniu do innych możliwych nadużyć. Dla przykładu rzutowanie pomiędzy zupełnie niezwiązanymi ze sobą typami może doprowadzić do naprawdę nieprzewidywalnych sytuacji. Nie zdarzają się one często, ale z drugiej strony pewne elementy systemu typów C wymagają wręcz poinformowania kompilatora, jak ma się zachować, tym samym nie pozostawiając mu innej możliwości, jak tylko zaufać Tobie, nawet w czasie wykonania. Na szczęście nic takiego nie ma miejsca w C#. Istnieje wiele możliwych konwersji, ale nie możesz „udawać”, że dane pewnego specyficznego typu są w praktyce danymi zupełnie innego typu. Możesz spróbować dodać rzutowanie i dać w ten sposób kompilatorowi dodatkowy fragment informacji. Jeśli jednak kompilator zauważy, że podane przez Ciebie rzutowanie jest niemożliwe do przeprowadzenia, zgłosi błąd kompilacji.
2.2.
Charakterystyka systemu typów
73
Jeśli zaś rzutowanie okaże się teoretycznie dopuszczalne, ale niemożliwe do przeprowadzenia w trakcie wykonania programu, środowisko CLR rzuci odpowiedni wyjątek. Teraz, kiedy wiemy już coś na temat pozycji C# 1 w świecie systemów typów, chciałbym wspomnieć o kilku negatywnych skutkach podjętych decyzji projektowych. Nie mówię, że te wybory były złe, ale z pewnością w pewnych aspektach były ograniczające. Często projektanci języka muszą wybierać pomiędzy różnymi ścieżkami, które wprowadzają jakieś ograniczenia lub wywołują nieoczekiwane skutki. Zacznę od sytuacji, w której chcesz przekazać kompilatorowi więcej informacji, ale nie ma sposobu, który by pozwolił osiągnąć ten cel.
2.2.2. Kiedy system typów C# 1 jest niedostatecznie bogaty? Istnieją dwie znane sytuacje, kiedy chciałbyś udostępnić więcej informacji właścicielowi wywołania metody lub być może zmusić go do ograniczenia tego, co przekazuje w argumentach. Pierwsza z nich dotyczy kolekcji, a druga dziedziczenia, przeciążania metod lub implementowania interfejsów. Zajmiemy się nimi po kolei. Mocne i słabe kolekcje Starałem się unikać określeń mocny i słaby w odniesieniu do systemu typów C#. Teraz będę ich jednak używał, omawiając kolekcje, które niemal wszędzie są stosowane w tym kontekście, co nie pozostawia miejsca na niejednoznaczność. Ogólnie mówiąc, środowisko .NET 1.1 posiada trzy typy kolekcji: tablice o mocnym typie wbudowane zarówno w język, jak i środowisko wykonania, kolekcje o słabym typie zawarte w przestrzeni nazw System.Collections, kolekcje o mocnym typie zawarte w przestrzeni nazw System.Collections.Specialized.
Tablice mają mocny typ11, zatem w czasie kompilacji nie możesz ustawić elementu typu string[], używając na przykład typu FileStream. Tablice o typie referencyjnym umożliwiają również kowariancję, która zapewnia niejawną konwersję pomiędzy jednym typem tablicy a innym — o ile tylko istnieje konwersja pomiędzy typami elementów. Sprawdzenie jest dokonywane w czasie wykonania, dlatego warto się upewnić, że przypadkiem nie zapisujmy złego typu referencyjnego, tak jak pokazuje to poniższy listing (2.3). Listing 2.3. Demonstracja tablicy kowariancyjnej i weryfikacji typu w czasie wykonania string[] strings = new string[5]; object[] objects = strings; objects[0] = new Button();
Zastosowanie konwersji kowariancyjnej Próba zapisania referencji typu Button
Kiedy uruchomisz listing 2.3, przekonasz się, że został rzucony wyjątek ArrayTypeMis ´matchException ( ). Dzieje się tak, ponieważ pomimo rzutowania string[] na object[] ( ) pod spodem jest to wciąż ta sama tablica łańcuchów (inaczej mówiąc, strings 11
Język pozwala tablicom na posiadanie mocnego typu, ale możesz również użyć typu Array w połączeniu ze słabym typem.
74
ROZDZIAŁ 2
Rdzeń systemu — C# 1
i objects wskazują na tę samą tablicę). Tablica wie, że jej elementami są łańcuchy, i odrzuci wszelkie próby przypisania referencji do obiektów innych niż łańcuchy. Czasem kowariancja jest przydatna, ale jej użycie odbywa się kosztem przeniesienia bezpieczeństwa typu z czasu kompilacji na czas wykonania. Porównajmy ten przypadek z sytuacją, w jakiej stawia nas użycie kolekcji o słabym typie, takich jak ArrayList i Hashtable. Typem kluczy i wartości używanym przez te kolekcje jest object. Kiedy piszesz metodę przyjmującą jako argument tablicę ArrayList, nie istnieje metoda pozwalająca się przekonać w czasie kompilacji, że kod wywołujący ją faktycznie przekaże na przykład tablicę łańcuchów. Możesz to udokumentować, a mechanizm bezpieczeństwa typu wymusi sprawdzenie w czasie wykonania (kiedy będziesz rzutować elementy tablicy na łańcuchy). W czasie kompilacji nie będzie jednak zapewnionego bezpieczeństwa typu. Podobnie kiedy zwrócisz tablicę ArrayList, możesz wskazać w dokumentacji, że jej zawartość to łańcuchy. Kod wywołujący Twoją metodę będzie musiał jednak zaufać, że mówisz prawdę, i wstawić odpowiednie rzutowanie podczas dostępu do elementów tablicy. Trzecia opcja to kolekcje o mocnym typie, takie jak StringCollection. Zapewniają one interfejs programisty o mocnym typie, możesz więc być pewny, że kiedy otrzymasz StringCollection jako wartość zwracaną, w jej środku będą wyłącznie łańcuchy i nie będziesz musiał przeprowadzać żadnych konwersji. Brzmi idealnie, ale wiążą się z tym dwa problemy. Po pierwsze, kolekcja implementuje IList, możesz więc nadal próbować dodawać obiekty niebędące łańcuchami (chociaż polegniesz w trakcie wykonania). Po drugie, kolekcja radzi sobie tylko z łańcuchami. Istnieją inne specjalizowane kolekcje, ale ich zakres jest ograniczony. Możesz skorzystać z typu CollectionBase i zbudować swoją własną kolekcję o mocnym typie, ale oznacza to tworzenie zupełnie nowego typu kolekcji dla każdego typu elementów, jaki chciałbyś przechowywać — rozwiązanie dalekie od ideału. Przekonaliśmy się, jakie problemy występują w przypadku kolekcji. Teraz zajmiemy się trudnościami podczas przeciążania metod i implementowania interfejsów. Ma to związek z ideą kowariancji, którą poznaliśmy przy okazji omawiania tablic. Brak kowariancyjnych typów zwracanych ICloneable jest jednym z najprostszych interfejsów środowiska. Składa się z jednej metody — Clone. Powinna ona zwrócić kopię obiektu, na którym została wywołana. Pomijając pytanie, czy powinna być to kopia płytka, czy głęboka, przyjrzyjmy się sygnaturze metody Clone: object Clone()
Bez wątpienia jest to prosta sygnatura, ale — tak jak powiedziałem — powinna ona zwrócić kopię obiektu, na którym została wywołana. Oznacza to, że powinna zwrócić obiekt tego samego typu lub przynajmniej typu kompatybilnego (zależnie od konkretnego typu, z jakim mamy do czynienia). Możliwość nadpisania tej metody za pomocą sygnatury, która trafniej opisuje wynik jej działania, wydaje się rozsądnym rozwiązaniem. Na przykład w przypadku klasy Person miło byłoby zaimplementować ICloneable z metodą: public Person Clone()
2.2.
Charakterystyka systemu typów
75
Niczego nie zepsujemy — kod oczekujący starego typu będzie nadal działał. Ta cecha nosi nazwę kowariancji typu zwracanego, ale na nasze nieszczęście nie jest ona obsługiwana w przypadku implementacji interfejsów i nadpisywania metod. W zamian, aby osiągnąć ten sam efekt, możemy się posłużyć pewnego rodzaju obejściem dla interfejsów, zwanym jawną implementacją interfejsów: public Person Clone() { [Tutaj miejsce na implementację] } public ICloneable.Clone() { Wywołanie metody poza interfejsem return Clone(); }
Każdy kod wywołujący Clone() na wyrażeniu o statycznym typie Person wywoła metodę na górze. Jeżeli typem wyrażenia jest wyłącznie ICloneable, zostanie wywołana metoda na dole. Takie rozwiązanie, chociaż bardzo brzydkie, działa. Analogiczna sytuacja ma miejsce dla parametrów, dla których w przypadku interfejsu lub metody wirtualnej o sygnaturze, dajmy na to, void Process(string x), chcielibyśmy nadpisać metodę o sygnaturze mniej wymagającej, takiej jak void Process(object x). Ten mechanizm nazywa się kontrawariancją typu parametru i, podobnie jak kowariancja typu zwracanego, nie jest obsługiwany. Obejście problemu wygląda tak samo jak dla interfejsów i normalnego przeciążania metod wirtualnych. Te utrudnienia nie stanowią przeszkody, której nie da się pokonać, ale są irytujące. Oczywiście, programiści C# 1 radzą sobie z tymi problemami od długiego czasu, a programiści Javy jeszcze dłużej. Chociaż bezpieczeństwo typu na poziomie kompilacji jest wspaniałym mechanizmem, nie pamiętam zbyt wielu rzeczywistych błędów polegających na umieszczeniu złego typu elementu w kolekcji. Jestem w stanie funkcjonować z obejściem dla braku kowariancji i kontrawariancji. Istnieje jeszcze jednak coś takiego jak estetyka i jasność wyrażania swoich myśli w kodzie, możliwie bez dodawania komentarzy wyjaśniających. Nawet jeśli błędy nie stanowią poważnego problemu, wymuszenie udokumentowanego kontraktu, iż kolekcja musi zawierać (dla przykładu) wyłącznie łańcuchy, może być bardzo kruche w obliczu zmiennych kolekcji. Oczekujemy raczej, aby tego typu kontrakt był faktycznie wymuszany przez system typów. Zobaczymy później, że również C# 2, chociaż wprowadza znaczne ulepszenia, nie jest idealny pod tym względem. Jeszcze więcej zmian pojawia się w C# 4, ale nawet w tej wersji brakuje kowariancji typów i kontrawariancji parametrów12.
2.2.3. Podsumowanie charakterystyki systemu typów W tej części poznaliśmy kilka różnic w systemach typów, a także dowiedzieliśmy się, które cechy dotyczą C# 1: C# 1 ma statyczny system typów — kompilator wie, jakich pól i metod możesz używać. C# 1 wymaga typów jawnych — musisz zadeklarować typ każdej zmiennej. 12
C# 4 wprowadza ograniczoną generyczną kowariancję i kontrawariancję, ale to niezupełnie to samo.
76
ROZDZIAŁ 2
Rdzeń systemu — C# 1 C# 1 jest bezpieczny — o ile nie istnieje dopuszczalna konwersja, nie możesz
traktować jednego typu tak, jakby był zupełnie innym typem. Statyczny system typów nadal nie pozwala pojedynczej kolekcji na bycie „listą
łańcuchów” lub „listą liczb całkowitych” bez nadmiarowej duplikacji kodu dla każdego typu elementu. Przeciążanie metod i implementacja interfejsów nie pozwalają na kowariancję
i kontrawariancję. Następny podrozdział opisuje najbardziej fundamentalne aspekty systemu typów C# — różnicę pomiędzy strukturami i klasami.
2.3.
Typy wartościowe i referencyjne Śmiało można powiedzieć, że ten podrozdział opisuje bardzo ważne zagadnienie. Wszystko, co robisz w środowisku .NET, wiąże się z użyciem typów wartościowego lub referencyjnego. Co ciekawe, możliwe jest programowanie przez dłuższy czas jedynie z powierzchowną wiedzą na temat różnicy między tymi dwoma typami. Dodatkowe zamieszanie wprowadzają mity. Niefortunnie można wyrazić krótkie, ale niewłaściwe stwierdzenie, które jest na tyle bliskie prawdy, że może zostać uznane za wiarygodne, ale jednocześnie wprowadzać w błąd. Trudno jest w takiej sytuacji znaleźć stwierdzenie, które jest zwięzłe i jednocześnie bezbłędne. W tym podrozdziale nie będziemy się zajmować kompletną analizą sposobu obsługi typów, zarządzaniem typami pomiędzy aplikacjami, współpracą z kodem natywnym itp. Zamiast tego rzucimy okiem na absolutne podstawy (w sensie C# 1), które są niezbędne do zrozumienia późniejszych wersji C#. Zaczniemy od sprawdzenia, jak różnice pomiędzy typami wartościowymi i referencyjnymi objawiają się w rzeczywistym świecie, a także w środowisku .NET.
2.3.1. Wartości i referencje w rzeczywistym świecie Wyobraźmy sobie, że czytasz coś fantastycznego i chcesz, aby Twój przyjaciel również to przeczytał. Nie chcemy zostać oskarżeni o propagowanie piractwa, dodamy zatem, że dokument ten nie jest ograniczony żadnymi prawami autorskimi. Co musisz dać swojemu przyjacielowi, aby mógł on również przeczytać ten dokument? Zależy to w całości od tego, co czytasz. Na początek załóżmy, że czytasz papierową gazetę. Chcąc dać ją swojemu przyjacielowi, musiałbyś zrobić kopię każdej strony. W tym momencie będzie on miał swoją kompletną kopię dokumentu. Mamy tutaj do czynienia z zachowaniem podobnym do typu wartościowego. Cała informacja jest w Twoich rękach, więc nie musisz się nigdzie udawać, aby ją uzyskać. Twoja kopia jest również zupełnie niezależna od kopii Twojego przyjaciela. Na swojej kopii mógłbyś umieścić jakieś notatki i byłyby one widoczne tylko u Ciebie. Porównaj tę sytuację z czytaniem strony internetowej. Tym razem wszystko, co musisz dać swojemu przyjacielowi, to adres URL strony. Jest to zachowanie podobne do typu referencyjnego, w którym adres URL pełni rolę referencji. Żeby móc faktycznie
2.3.
Typy wartościowe i referencyjne
77
przeczytać dokument, musisz umieścić adres w przeglądarce internetowej i wysłać żądanie załadowania strony z serwera. Jeśli strona uległa zmianie (na przykład jest to strona wiki i właśnie dodałeś do niej jakieś notatki), zarówno Ty, jak i Twój przyjaciel zobaczycie tę samą wersję strony po następnym załadowaniu. Różnice, które zobaczyliśmy w realnym świecie, formułują sedno rozróżnienia pomiędzy typami wartościowymi i referencyjnymi w C# i .NET. Większość typów w .NET to typy referencyjne. Również programista podczas pracy z tym językiem ma szansę utworzyć znacznie więcej typów referencyjnych niż wartościowych. Najczęstszymi typami tworzonymi są klasy (deklarowane słowem kluczowym class) — będące typami referencyjnymi, oraz struktury (deklarowane słowem kluczowym struct) — będące typami wartościowymi. Inne typy, z jakimi możesz mieć do czynienia, to: Tablice — są typami referencyjnymi, nawet jeśli przechowują elementy typu wartościowego (zatem int[] jest typem referencyjnym, mimo że int jest typem
wartościowym). Enumeracje (deklarowane słowem kluczowym enum) — są typami wartościowymi. Typy delegatowe (deklarowane słowem kluczowym delegate) — są typami
referencyjnymi. Interfejsy (deklarowane słowem kluczowym interface) — są typami
referencyjnymi, ale mogą być implementowane przez typy wartościowe. Znamy już podstawową koncepcję typów referencyjnych i wartościowych, przyjrzyjmy się zatem kilku najważniejszym szczegółom.
2.3.2. Podstawy typów referencyjnych i wartościowych Kluczową umiejętnością, którą trzeba posiąść w odniesieniu do typów wartościowych i referencyjnych, jest rozumienie typów poszczególnych wyrażeń. Posłużymy się zmiennymi jako najbardziej powszechnymi przykładami wyrażeń (te same zasady odnoszą się do właściwości, wywołań metod, indekserów i innych wyrażeń). W sekcji 2.2.1 wspomnieliśmy, że większość wyrażeń posiada typ statyczny. Wartością wyrażenia typu wartościowego jest sama wartość — to jasne i proste. Na przykład wartością wyrażenia „2+3” jest 5. Wartością wyrażenia typu referencyjnego jest referencja. Nie jest to obiekt, do którego odnosi się referencja. Zatem wartością wyrażenia String.Empty nie jest pusty łańcuch, a referencja do pustego łańcucha. Podczas codziennych dyskusji, a nawet w dokumentacji zdarza nam się zatrzeć to rozróżnienie. Dla przykładu możemy powiedzieć, że String.Concat zwraca „łańcuch, który jest złączeniem wszystkich parametrów”. Użycie w tym miejscu precyzyjnej terminologii byłoby czasochłonne i rozpraszające. Taki opis działania jest do zaakceptowania, jeśli tylko wszyscy zainteresowani rozumieją, że zwracana jest jedynie referencja. Pójdźmy dalej i rozważmy typ Punkt, który przechowuje dwie liczby: x i y. Ten typ mógłby mieć konstruktor przyjmujący dwie wartości. Przy takich założeniach typ ten może być zaimplementowany jako struktura lub klasa. Rysunek 2.3 pokazuje wynik wykonania dwóch następujących linii kodu: Punkt p1 = new Punkt(10, 10); Punkt p2 = p1;
78
ROZDZIAŁ 2
Rdzeń systemu — C# 1 Rysunek 2.3. Porównanie zachowania typów wartościowych i referencyjnych, w szczególności pod względem przypisań
Lewa część rysunku 2.3 przedstawia sytuację, gdy Punkt jest klasą (typem referencyjnym), a prawa sytuację, gdy Punkt jest strukturą (typem wartościowym). W obu przypadkach p1 i p2 mają taką samą wartość po drugim przypisaniu. Kiedy Punkt jest typem referencyjnym, tą wartością jest referencja (p1 i p2 odnoszą się do tego samego obiektu). Kiedy Punkt jest typem wartościowym, wartość p1 stanowią wszystkie dane punktu — wartości x i y. Przypisanie wartości p1 do p2 powoduje skopiowanie wszystkich danych. Wartości zmiennych są przechowywane tam, gdzie zostały zadeklarowane. Wartości zmiennych lokalnych są przechowywane zawsze na stosie13, wartości zmiennych instancji są przechowywane tam, gdzie jest zapisana sama instancja. Instancje typów referencyjnych (obiektów) są przechowywane zawsze na stercie, podobnie jak zmienne statyczne. Kolejną różnicą pomiędzy typami referencyjnymi i wartościowymi jest to, że te drugie nie mogą tworzyć typów potomnych. Konsekwencją tego jest brak konieczności przechowywania dodatkowej informacji na temat swojego rzeczywistego typu. Dla porównania instancje typów referencyjnych przechowują na samym początku blok danych identyfikujący między innymi rzeczywisty typ obiektu. Typ obiektu nie może być zmieniony — kiedy wykonujesz proste rzutowanie, środowisko wykonania bierze referencję, sprawdza, czy wskazywany przez nią obiekt jest poprawny w odniesieniu do typu docelowego, i zwraca oryginalną referencję lub — w przeciwnym wypadku — rzuca wyjątek. Referencja sama w sobie nie zna typu obiektu, w związku z czym ta sama wartość referencji może być użyta w wielu zmiennych różnego typu. Zwróć uwagę na następujący kod: Stream stream = new MemoryStream(); MemoryStream memoryStream = (MemoryStream) stream;
Pierwsza linia tworzy obiekt klasy MemoryStream i ustawia wartość zmiennej stream na referencję do tego nowego obiektu. Druga linia sprawdza, czy wartość zmiennej stream odnosi się do obiektu MemoryStream (lub potomnego), i przypisuje zmiennej memoryStream taką samą wartość, jaką ma stream. Kiedy zrozumiesz te zupełnie podstawowe zasady, możesz zastosować je podczas analizowania niektórych legend krążących na temat typów wartościowych i referencyjnych. 13
Jest to całkowicie prawdziwe jedynie w C# 1. Później zobaczymy, że w określonych warunkach zmienne lokalne mogą zostać umieszczone również na stercie.
2.3.
Typy wartościowe i referencyjne
79
2.3.3. Obalanie mitów W miarę regularnie pojawiają się różnego rodzaju mity. Jestem pewien, że u podstaw dezinformacji prawie nigdy nie stoi zamierzona złośliwość lub nawet świadomość wprowadzania nieścisłości. Niemniej przekazywane w ten sposób informacje są szkodliwe. W tej części rozprawię się z najbardziej znanymi mitami, wyjaśniając przy okazji faktyczny stan rzeczy. Mit 1. „Struktury to lżejsze wersje klas” Ten mit pojawia się w wielu formach. Niektórzy twierdzą, że typy wartościowe nie mogą lub nie powinny posiadać metod lub jakiejkolwiek znaczącej logiki — powinny służyć wyłącznie jako proste typy do transferu danych, posiadające jedynie publiczne pola lub właściwości trywialne. Dobrym kontrprzykładem tego twierdzenia jest wartościowy typ DateTime. Istnieje bardzo dobry powód, dla którego jest to typ wartościowy: stanowi on fundamentalną jednostkę, podobną do liczby czy znaku. Równie uzasadniona jest możliwość wykonywania przez ten typ obliczeń odnoszących się do przechowywanych przezeń wartości. Patrząc z drugiej strony, czasami jest wręcz wskazane, aby typy służące do transferu danych były typami referencyjnymi — decyzja o wyborze typu powinna być podejmowana w oparciu o semantykę danego typu wartościowego lub referencyjnego, a nie jego prostotę. Inni uważają, że typy wartościowe są „lżejsze” od typów referencyjnych pod względem wydajnościowym. Prawda jest taka, że niektóre typy wartościowe są bardziej wydajne — dla przykładu nie wymagają sprzątania pamięci, o ile nie są opakowane typem referencyjnym, nie posiadają narzutu związanego z identyfikacją typu ani nie wymagają dereferencji. Pod innymi względami typy referencyjne są bardziej wydajne — przekazywanie parametrów, przypisywanie wartości do zmiennych, zwracanie wartości i inne podobne operacje wymagają skopiowania jedynie od 4 do 8 bajtów (w zależności od tego, czy operacja jest wykonywana w 32-bitowym, czy też 64-bitowym środowisku uruchomieniowym), w porównaniu do kopiowania wszystkich danych dla typów wartościowych. Wyobraź sobie typ ArrayList będący w jakiś sposób typem „czysto” wartościowym — przekazanie wyrażenia tego typu do metody wymagałoby przekopiowania wszystkich danych listy! W większości przypadków wydajność i tak nie jest zdeterminowana tego typu decyzjami. Wąskie gardła prawie nigdy nie pojawiają się tam, gdzie się ich spodziewasz. Zanim więc podejmiesz decyzję projektową związaną z wydajnością, powinieneś dokonać szczegółowych pomiarów dla różnych wariantów. Dwa powyższe poglądy połączone razem są równie mało warte co każdy z nich oddzielnie. Nie ma znaczenia, ile metod posiada dany typ (klasa lub struktura) — nie wpłynie to na ilość pamięci zajętą przez każdą instancję. (Zaistnieje dodatkowy koszt w postaci miejsca zajętego przez sam kod typu, ale jest to wydatek jednorazowy). Mit 2. „Typy referencyjne mieszkają na stercie, typy wartościowe na stosie” Ten mit jest często spowodowany opieszałością po stronie powtarzającej go osoby. Pierwsza część jest prawdziwa — instancja typu referencyjnego jest zawsze tworzona na stercie. Problem sprawia druga część. Wspomniałem już wcześniej, że wartość zmiennej
80
ROZDZIAŁ 2
Rdzeń systemu — C# 1
jest zapisywana tam, gdzie została zadeklarowana — jeśli zatem masz klasę z instancją zmiennej typu int, wartość tej zmiennej dla każdego obiektu będzie przechowywana tam, gdzie są pozostałe dane obiektu, czyli na stercie. Tylko zmienne lokalne (zmienne deklarowane w kontekście metod) i parametry metod są umieszczane na stosie. W C# 2 i kolejnych wersjach nawet niektóre zmienne lokalne nie pojawiają się tak naprawdę na stosie. Przekonamy się o tym podczas analizy metod anonimowych w rozdziale 5. CZY TE ROZWAŻANIA SĄ ISTOTNE? Można się spierać, czy w przypadku pisania kodu zarządzanego (bezpiecznego) nie powinniśmy pozostawić środowisku wykonawczemu problemów związanych z najlepszym wykorzystaniem pamięci. W rzeczywistości specyfikacja języka nie daje żadnych gwarancji co do miejsca zapisu danego elementu języka. W przyszłości środowisko może zacząć tworzyć obiekty na stosie, jeśli uzna, że takie rozwiązanie ma sens, lub być może kompilator C# zacznie generować kod, który prawie w ogóle nie będzie korzystał ze stosu. Kolejny mit jest na ogół jedynie problemem przyjętej terminologii. Mit 3. „W C# obiekty są przekazywane domyślnie przez referencje” To chyba najbardziej rozpowszechniony mit. Kolejny raz osoby, które składają tego typu oświadczenia, często (chociaż nie zawsze) wiedzą, jak C# właściwie się zachowuje, ale nie wiedzą, co właściwie znaczy „przekazywanie przez referencję”. Niestety takie twierdzenie dezinformuje osoby, które dokładnie wiedzą, co to znaczy. Formalna definicja przekazywania przez referencję jest dosyć złożona. Pojawiają się w niej terminy z teorii informatyki, takie jak l-wartości i inne. Najważniejszą rzeczą jest jednak to, że kiedy przekazujesz zmienną przez referencję, wywoływana metoda może zmienić wartość zmiennej przekazanej z wywołującego ją kodu przez modyfikację wartości swojego parametru. Przypomnij sobie teraz, że wartością zmiennej typu referencyjnego jest referencja, a nie sam obiekt. Możesz zmienić zawartość obiektu, do którego odnosi się parametr, bez konieczności przekazywania tego parametru przez referencję. Dla przykładu metoda poniżej zmienia zawartość obiektu typu StringBuilder (przekazanego jako parametr), ale wyrażenie w kodzie wywołującym będzie się nadal odnosić do tego samego obiektu co przedtem: void AppendHello(StringBuilder builder) { builder.Append("Cześć"); }
Podczas wywołania tej metody wartość parametru (referencja do obiektu StringBuilder) jest przekazywana przez wartość. Gdybym zmienił wartość zmiennej builder wewnątrz metody — zastępując ją na przykład wyrażeniem builder = null; — nie byłoby to widoczne z punktu widzenia kodu wywołującego metodę. Zgodnie z mitem jest dokładnie odwrotnie. Warto zauważyć, że błędne przekonanie dotyczy zarówno fragmentu „przez referencję”, jak i fragmentu „obiekty są przekazywane”. Obiekty jako takie nigdy nie są
2.3.
Typy wartościowe i referencyjne
81
przekazywane ani przez wartość, ani przez referencję. Kiedy mamy do czynienia z typem referencyjnym, przekazywana jest zmienna przez referencję lub wartość argumentu (referencja) przez wartość. To daje nam odpowiedź na to, co się dzieje, kiedy argumentowi (przekazywanemu jako wartość) przypiszemy null. Gdyby przekazywane były obiekty, stanowiłoby to problem, ponieważ nie istniałby obiekt do przekazania! Zamiast tego null jest przekazywane przez wartość, tak samo jak każda inna referencja. Jeśli po tych wyjaśnieniach jesteś lekko oszołomiony, zapraszam do zapoznania się z artykułem na mojej głównej stronie poświęconej C# (http://mng.bz/otVt), który omawia cały problem bardziej szczegółowo. Wymienione mity nie są jedynymi krążącymi w środowisku. Sporą część nieporozumień wprowadzają mechanizmy opakowywania i rozpakowywania. Postaram się wyjaśnić je w następnej kolejności.
2.3.4. Opakowywanie i odpakowywanie Czasami nie chcesz posługiwać się wartością typu wartościowego. Z pewnych powodów potrzebujesz typu referencyjnego. C# i środowisko .NET posiadają mechanizm zwany opakowywaniem (ang. boxing), który pozwala stworzyć obiekt z typu wartościowego, a następnie używać referencji do tego obiektu. Zanim przejdziemy do przykładu, zacznijmy od przypomnienia dwóch istotnych faktów: Wartością typu referencyjnego jest zawsze referencja. Wartością typu wartościowego jest zawsze wartość tego typu.
W świetle tych dwóch stwierdzeń trzy poniższe linie kodu nie mają większego sensu: int i = 5; object o = i; int j = (int) o;
Mamy dwie zmienne: i jest zmienną typu wartościowego, o jest zmienną typu referencyjnego. Jaki sens ma przypisanie wartości i do o? Wartość o musi być referencją, a liczba 5 nią nie jest — jest liczbą całkowitą. To, co dzieje się w tej linii, to opakowywanie: środowisko wykonawcze tworzy obiekt (na stercie — jak w przypadku każdego normalnego obiektu), który zawiera wartość (5). Wartością o staje się następnie referencja do tego obiektu. Wartość w obiekcie jest kopią wartości oryginalnej — zmiana wartości i nie doprowadzi do zmiany wartości wewnątrz obiektu. Trzeci wiersz przeprowadza operację odwrotną, a więc odpakowywanie (ang. unboxing). Musimy powiedzieć kompilatorowi, jakiego typu ma użyć przy odpakowywaniu wartości. Jeżeli użyjemy złego typu (gdy w obiekcie jest zapisany typ uint, long lub w ogóle nie ma tam opakowanej wartości), zostanie rzucony wyjątek klasy Invalid ´CastException. Odpakowywanie ponownie kopiuje wartość zapisaną w obiekcie — po operacji przypisania związek pomiędzy j i obiektem przestaje istnieć. To właściwie wszystko na ten temat. Jedyny problem, jaki pozostaje, to zrozumienie, kiedy obie operacje zachodzą. Odpakowywanie jest na ogół oczywiste, ponieważ w kodzie jest widoczne rzutowanie. Opakowywanie jest bardziej subtelne. Widzieliśmy prosty przykład, ale opakowywanie może się również pojawić, kiedy wywołujesz metody
82
ROZDZIAŁ 2
Rdzeń systemu — C# 1 ToString, Equals lub GetHashCode na wartości typu, który ich nie nadpisuje14, lub jeśli
używasz wartości w wyrażeniu interfejsowym — przypisując ją do zmiennej, której typ jest interfejsem, bądź przekazując ją jako parametr do typu interfejsowego. Dla przykładu wyrażenie IComparable x = 5; powoduje opakowanie wartości 5. Warto wiedzieć, które wyrażenia w Twoim kodzie prowadzą do operacji opakowywania i odpakowywania, ponieważ mogą one mieć wpływ na spadek wydajności. Pojedyncza operacja odpakowania wiąże się z niewielkim kosztem, ale przy powieleniu jej setki lub tysiące razy trzeba uwzględnić nie tylko koszt odpakowania, ale również dużą liczbę utworzonych obiektów, które będą musiały być posprzątane. Powtórzę jeszcze raz — narzut wydajnościowy nie jest duży, ale warto o nim pamiętać, by w razie potrzeby podjąć odpowiednie środki zaradcze.
2.3.5. Podsumowanie typów wartościowych i referencyjnych W tej sekcji przeanalizowaliśmy różnice pomiędzy typami wartościowymi i referencyjnymi, a także rozprawiliśmy się z pewnymi związanymi z nimi mitami. Oto kluczowe informacje: Wartością wyrażenia typu referencyjnego (na przykład zmiennej) jest referencja, a nie obiekt. Referencje są jak adresy URL — stanowią mały fragment danych, który pozwala dotrzeć do prawdziwej informacji. Wartością typu wartościowego są rzeczywiste dane. Istnieją sytuacje, w których typy wartościowe są bardziej wydajne od typów
referencyjnych i na odwrót. Obiekty typów referencyjnych są zawsze umieszczane na stercie, wartości typów
wartościowych, w zależności od kontekstu, mogą się pojawić zarówno na stosie, jak i na stercie. Kiedy typ referencyjny zostanie użyty do utworzenia parametru metody, argument
jest przekazywany domyślnie przez wartość, ale wartością tą jest referencja. Wartości typów wartościowych są opakowywane, kiedy wymagane jest zachowanie typu referencyjnego. Odpakowywanie jest procesem odwrotnym. Teraz, kiedy przyjrzeliśmy się wszystkim elementom C# 1, z którymi musisz się czuć komfortowo, nadszedł czas, aby wybiec nieco naprzód i zobaczyć, jak każdy z tych elementów zostanie ulepszony w kolejnych wersjach języka.
14
Opakowywanie pojawi się zawsze, kiedy wywołasz GetType() na wartości zmiennej, ponieważ metoda ta nie może być nadpisana. W przypadku formy odpakowanej powinieneś się dokładnie orientować, z jakim typem masz do czynienia.
2.4.
2.4.
Więcej niż C# 1 — nowe cechy na solidnym fundamencie
83
Więcej niż C# 1 — nowe cechy na solidnym fundamencie Trzy zagadnienia omówione w tym rozdziale mają kluczowe znaczenie we wszystkich wersjach C#. Niemal wszystkie nowe cechy mają związek przynajmniej z jednym z tych zagadnień i wszystkie zmieniają sposób używania języka. Przyjrzyjmy się, jak nowe cechy języka mają się do cech już istniejących. Nie będę wnikał w szczegóły (głównie ze względu na niechęć wydawcy do sześciusetstronicowych sekcji rozdziałów), chcę jedynie wskazać kierunki rozwoju, zanim przejdziemy do sedna sprawy. Wybierzemy tę samą kolejność, w jakiej omawialiśmy tematy wcześniej, a zatem zaczniemy od delegatów.
2.4.1. Cechy związane z delegatami Wszelkiego rodzaju delegaty zostają „podrasowane” w C# 2, a następnie jeszcze bardziej ulepszone w C# 3. Nie są to nowości w środowisku wykonawczym, a jedynie zmyślne triki kompilatora, które pozwalają na bardziej płynne użycie delegatów przez język. Zmiany dotyczą nie tylko składni, której możemy używać, ale również wyglądu i sposobu budowania wyrażeń kodu w C#. Wraz z upływem czasu C# zyskuje bardziej funkcjonalne podejście. C# 1 ma bardzo niezdarną składnię służącą do tworzenia instancji delegatów. Z jednej strony, nawet jeśli chcesz osiągnąć bardzo prosty efekt, musisz napisać całą niezależną metodę tylko po to, by móc utworzyć dla niej instancję delegata. C# 2 naprawia ten problem, oferując metody anonimowe, a także wprowadza prostszą składnię dla tych przypadków, w których nadal chcesz używać zwykłych metod w celu dostarczenia akcji dla delegata. Możesz również tworzyć instancje delegatów, używając metod z kompatybilną sygnaturą — sygnatura nie musi od tej pory pasować dokładnie do deklaracji delegata. Wszystkie te usprawnienia demonstruje poniższy kod (listing 2.4). Listing 2.4. Ulepszenia w tworzeniu instancji delegatów wprowadzone w C# 2 static void HandleDemoEvent(object sender, EventArgs e) { Console.WriteLine ("Obsłużone przez HandleDemoEvent"); } ... Określenie typu delegata EventHandler handler; i jego metody handler = new EventHandler(HandleDemoEvent); handler(null, EventArgs.Empty); handler = HandleDemoEvent; handler(null, EventArgs.Empty);
Niejawna konwersja na instancję delegata
handler = delegate(object sender, EventArgs e) { Console.WriteLine("Obsłużone anonimowo"); }; handler(null, EventArgs.Empty);
Utworzenie komórki widoku tabeli
84
ROZDZIAŁ 2
Rdzeń systemu — C# 1 handler = delegate { Console.WriteLine("Obsłużone ponownie anonimowo"); }; handler(null, EventArgs.Empty); MouseEventHandler mouseHandler = HandleDemoEvent; mouseHandler (null, new MouseEventArgs(MouseButtons.None, 0, 0, 0, 0));
Użycie skrótu metody anonimowej
Użycie kontrawariancji delegata
Pierwsza część głównego kodu ( ) to zwykły kod C# 1, pokazany tutaj dla porównania. Pozostałe delegaty używają nowych cech C# 2. Konwersja grupy metod ( ) sprawia, że kod subskrybujący zdarzenie jest znacznie przyjemniejszy w czytaniu — takie wiersze jak saveButton.Click += SaveDocument; są proste, gdyż nie zawierają zbędnego i rozpraszającego kodu. Składnia metod anonimowych ( ) jest odrobinę niewygodna, ale pozwala jasno wyrazić akcję w punkcie tworzenia, zamiast odsyłać Cię do innej metody w celu zrozumienia, co właściwie jest jej treścią. Istnieje skrócona wersja metod anonimowych ( ), ale można z niej skorzystać wyłącznie wtedy, kiedy nie potrzebujesz parametrów. Metody anonimowe posiadają również inne użyteczne cechy, ale o nich powiemy sobie później. Ostatnia utworzona instancja delegata ( ) jest instancją typu MouseEventHandler, a nie — jak wcześniej — typu EventHandler. Dzięki kontrawariancji, która określa kompatybilność parametrów jako akcji, nadal możemy użyć metody HandleDemoEvent. Kowariancja określa zgodność typu zwracanego. Obu tym zagadnieniom będziemy się bliżej przyglądać w rozdziale 5. Prawdopodobnie największymi beneficjentami tych możliwości są zdarzenia (w tym świetle dużego sensu nabierają wytyczne firmy Microsoft, aby wszystkie delegaty stosowane w zdarzeniach przestrzegały tych samych zasad). W C# 1 nie miało znaczenia, czy dwie różne metody obsługi zdarzeń wyglądały całkiem podobnie — musiałeś mieć metodę z identyczną sygnaturą, aby móc utworzyć instancję delegata. W C# 2 ta sama metoda może posłużyć do obsługi różnych zdarzeń, w szczególności jeśli cel metody jest względnie niezależny od samego zdarzenia (przykładem może być logowanie do dziennika zdarzeń). C# 3 wprowadza specjalną składnię do tworzenia instancji typów delegatowych, używającą wyrażeń lambda. Do zademonstrowania tej składni użyjemy nowego typu delegata. Wraz ze wzbogaceniem środowiska wykonawczego w mechanizmy generyczne w wersji .NET 2.0 pojawiły się typy delegatów generycznych mające zastosowanie w interfejsach programistycznych kolekcji. .NET 3.5 rozwinął tę ścieżkę jeszcze bardziej, wprowadzając grupy delegatów generycznych o nazwie Func, z których wszystkie przyjmują listę parametrów określonego typu i zwracają wartość innego określonego typu. Poniższy przykład (listing 2.5) pokazuje użycie typu Func i wyrażeń lambda. Listing 2.5. Wyrażenia lambda, podobne do poprawionych metod anonimowych Func func = (x, y) => (x * y).ToString(); Console.WriteLine(func(5, 20));
Func jest typem delegata, który przyjmuje dwie liczby i zwraca łańcuch. Wyrażenie lambda na listingu 2.5 mówi, że instancja delegata (zapisana w func) powinna pomnożyć dwie liczby całkowite, a następnie wywołać metodę ToString().
Składnia jest o wiele prostsza niż w przypadku metod anonimowych, a ponadto kompi-
2.4.
Więcej niż C# 1 — nowe cechy na solidnym fundamencie
85
lator wykonuje więcej pracy związanej z wnioskowaniem typów wyrażeń. Wyrażenia lambda mają kluczowe znaczenie dla LINQ, dlatego powinieneś zacząć powoli dojrzewać do umiejscowienia tej technologii w samym środku swoich umiejętności językowych. Wyrażenia lambda nie powstały jedynie na użytek LINQ, każdy przypadek metody anonimowej w C# 2 może zostać przekształcony na wyrażenie lambda w C# 3, co w konsekwencji niemal zawsze doprowadzi do powstania prostszego kodu. Podsumowując, lista nowych cech związanych z delegatami wygląda następująco: generyczne typy delegatów — C# 2, wyrażenia tworzące instancje delegatów — C# 2, metody anonimowe — C# 2, kowariancja i kontrawariancja delegatów — C# 2, wyrażenia lambda — C# 3.
Dodatkowo C# 4 umożliwia generyczną kowariancję i kontrawariancję delegatów, która wykracza poza materiał pokazany wyżej. Wprowadzenie typów generycznych należy uznać za jedno z najważniejszych usprawnień systemu typów, i to właśnie jemu przyjrzymy się w następnej kolejności.
2.4.2. Cechy związane z systemem typów Główną cechą dotyczącą systemu typów wprowadzoną w C# są typy generyczne. Mechanizm ten w przeważającym stopniu rozwiązuje problemy omówione w sekcji 2.2.2, a dotyczące kolekcji o mocnych typach, chociaż sprawdza się również w innych sytuacjach. Typy generyczne są eleganckie, rozwiązują rzeczywisty problem i, pomimo kilku niedociągnięć, doskonale sprawdzają się w praktyce. Przykłady tego widzieliśmy już w kilku miejscach, a kompletny opis znajdziemy w następnym rozdziale, w związku z czym nie będziemy teraz wnikać dalej w szczegóły. Będzie to jednak krótkie wytchnienie — typy generyczne są chyba jedną z najważniejszych cech języka C# 2 pod względem systemu typów. Ich obecność będzie zauważalna niemal na każdym kroku w tej książce. C# 2 nie rozwiązuje problemu kowariancji typu zwracanego i kontrawariancji parametrów podczas nadpisywania metod i implementowania interfejsów. W określonych warunkach poprawia jednak sytuację podczas tworzenia instancji delegatów, tak jak widzieliśmy to w sekcji 2.4.1. C# 3 wprowadza całe bogactwo nowych koncepcji w systemie typów. Na szczególną uwagę zasługują typy anonimowe, zmienne lokalne deklarowane niejawnie i metody rozszerzające. Typy anonimowe są obecne głównie ze względu na LINQ, w którym bardzo przydatna jest możliwość tworzenia typów do transferu danych z listą właściwości tylko do odczytu, bez konieczności nadmiernego rozpisywania się w kodzie. Oczywiście, nic nie stoi na przeszkodzie, aby wykorzystać je również poza LINQ, co znacznie ułatwia pracę podczas pisania programów demonstracyjnych. Listing 2.6 pokazuje obie cechy w akcji. W dwóch pierwszych wierszach widać użycie typów niejawnych (użycie słowa kluczowego var) oraz inicjalizatorów obiektów anonimowych (fragmenty new {...}), które tworzą instancje obiektów anonimowych.
86
ROZDZIAŁ 2
Rdzeń systemu — C# 1 Listing 2.6. Demonstracja typów anonimowych i niejawnych var jon = new {Name = "Jon", Age = 33 }; var tom = new {Name = "Tom", Age = 4 }; Console.WriteLine ("{0} ma {1} lata", jon.Name, jon.Age); Console.WriteLine ("{0} ma {1} lata", tom.Name, tom.Age);
Na długo przed tym, nim dotrzemy do szczegółów, wspomnijmy o dwóch rzeczach, które często stanowią źródło niepotrzebnych zmartwień. Po pierwsze, C# 3 jest językiem o typach statycznych. Zmienne jon i tom mają określony typ — nie ma tutaj żadnego dynamicznego bindowania typu w zależności od kontekstu. Do tego używamy najzwyklejszych właściwości obiektu. Jedyna rzecz, która ma miejsce, to wygenerowanie za nas typu przez kompilator, ponieważ my, a właściwie nasz kod, nie jesteśmy w stanie powiedzieć, jaki typ ma zostać użyty w czasie deklarowania zmiennej. Właściwości mają również typ statyczny — Age jest typu int, Name typu string. Druga uwaga to to, że nie utworzyliśmy tutaj dwóch różnych typów anonimowych. Ponieważ do wygenerowania typu kompilator używa nazw właściwości, ich typów i kolejności występowania w kodzie, obie zmienne — jon i tom — mają dokładnie taki sam typ danych. Granicą rozróżnienia typów jest moduł (ang. assembly), co bardzo ułatwia życie pod względem możliwości przypisywania sobie wartości zmiennych (np. w poprzednim kodzie moglibyśmy wykonać przypisanie jon = tom) i innych podobnych operacji. Metody rozszerzające istnieją na potrzeby LINQ, chociaż samodzielnie też mogą być użyteczne. Przypomnij sobie wszystkie te chwile, kiedy marzyłeś o tym, aby pewien typ implementował określoną metodę (nie musiałbyś wtedy pisać swojej własnej statycznej funkcji użytkowej do tego samego celu). Aby na przykład stworzyć nowy łańcuch przez odwrócenie istniejącego, musiałeś prawdopodobnie napisać funkcję taką jak StringUtil.Reverse. Metody rozszerzające pozwalają wywołać tę statyczną funkcję, tak jakby istniała ona w kontekście typu łańcuchowego: string x = "!eiceiwś ,jatiW".Reverse();
Metody rozszerzające pozwalają stworzyć wrażenie dodania metod z implementacją do interfejsów — na tym mechanizmie bardzo mocno bazuje LINQ, który pozwala wywoływać na interfejsie IEnumerable wszelkiego typu nigdy wcześniej nieistniejące metody. C# 4 dodaje dwie cechy związane z systemem typów. Względnie małą modyfikacją jest kontrawariancja generycznych delegatów i interfejsów. Mechanizm taki był dostępny w środowisku wykonawczym od ukazania się .NET 2.0, ale dopiero wprowadzenie C# 4 (i uaktualnienie typów generycznych w bibliotece) umożliwiło jego wykorzystanie programistom C#. Znacznie bardziej rozbudowaną cechą języka — chociaż wielu programistów prawdopodobnie nigdy z niej nie skorzysta — są typy dynamiczne w C#. Pamiętasz moje wprowadzenie do typów statycznych, w którym próbowałem używać właściwości Length tablicy i łańcucha poprzez tę samą zmienną? W C# 4 ten przykład zadziała… kiedy będziesz tego chciał. Poniższy listing (2.7) pokazuje ten sam kod z jednym wyjątkiem — została zmieniona deklaracja zmiennej. Przez zadeklarowanie zmiennej o jako statycznego typu dynamic (to nie jest błąd w druku) pozwalamy kompilatorowi na zupełnie inne traktowanie tej zmiennej, przenosząc wszystkie wiążące decyzje (takie jak faktyczne znaczenie Length) do czasu wykonania kodu.
2.4.
Więcej niż C# 1 — nowe cechy na solidnym fundamencie
87
Listing 2.7. Typy dynamiczne w C# 4 dynamic o = "cześć"; Console.WriteLine(o.Length); o = new string[] {"czołem", "witamy"}; Console.WriteLine(o.Length);
Nie ulega wątpliwości, że przyjrzymy się typom dynamicznym z większą uwagą. Teraz chcę jedynie zaznaczyć, że C# 4 w przeważającej większości jest nadal językiem o statycznym systemie typów. O ile nie używasz typu dynamic, który działa jako typ statyczny zapowiadający dynamiczną wartość, nie widać żadnych zmian w zachowaniu języka. Większość programistów C# bardzo rzadko będzie potrzebować typów dynamicznych, a podczas swojej normalnej pracy mogą zwyczajnie ignorować ich dostępność. W sytuacjach, w których typy dynamiczne mają zastosowanie, ta cecha języka okazuje się bardzo poręczna (pozwala między innymi na zabawę z kodem napisanym w innych językach dynamicznych, wykonywanych przez środowisko DLR — ang. Dynamic Language Runtime). Doradzam jednak, aby nie używać C# jako języka „skryptowego”. Jeżeli potrzebujesz takiego narzędzia, wybierz IronPythona lub inny podobny język, który od samego początku został zaprojektowany do wspierania typów dynamicznych, dzięki czemu prawdopodobnie ma mniej zawiłości mogących wyprowadzić Cię w pole. Oto podsumowanie nowych cech związanych z systemem typów C#: typy generyczne — C# 2, ograniczona wariancja/kontrawariancja delegatów — C# 2, typy anonimowe — C# 3, typy niejawne — C# 3, metody rozszerzające — C# 3, ograniczona wariancja/kontrawariancja typów generycznych — C# 4, typy dynamiczne — C# 4. Po tym w miarę zróżnicowanym zestawie cech dodanych do systemu typów w ogólności przyjrzyjmy się cechom dodanym wyłącznie do typu wartościowego.
2.4.3. Cechy związane z typami wartościowymi Są to jedynie dwie cechy, a obie zostały wprowadzone w C# 2. Pierwsza ponownie odnosi się do typów generycznych, a dokładniej mówiąc, do kolekcji. Jedną z częstych uwag odnośnie do używania typów wartościowych w kolekcjach będących częścią .NET 1.1 było to, że dodawanie instancji typów wartościowych do kolekcji wymuszało ich opakowywanie, a wyciąganie — operację odwrotną, czyli odpakowywanie. Ma to związek z uogólnionym interfejsem programistycznym kolekcji, który posługuje się parametrami typu object. Operacja opakowywania jest w miarę tania dla pojedynczego wywołania, ale w sytuacji częstych odwołań do kolekcji z dużą liczbą elementów może się przyczynić do istotnego spadku wydajności. Nie bez znaczenia jest też wzrost pamięci związany z dodatkowym narzutem wnoszonym przez każdy dodany element. Typy generyczne naprawiają zarówno problem wydajnościowy, jak i pamięciowy przez zastąpienie typu ogólnego rzeczywistym typem kolekcji. Gdybyśmy mieli na przykład .NET 1.1,
88
ROZDZIAŁ 2
Rdzeń systemu — C# 1
moglibyśmy posunąć się do szaleństwa i załadować plik bajt po bajcie do tablicy Array ´List. W .NET 2.0 załadowanie pliku do listy typu List nie byłoby już tak szalone. Druga cecha rozwiązuje kolejny ogólny problem z językiem, który ma szczególne znaczenie przy pracy z bazami danych — mowa tu o niemożności przypisania null do zmiennej typu wartościowego. Dla przykładu nie istnieje koncepcja wartości typu int równej null, mimo że pole tego typu w bazie danych może być nullowalne. Taki stan rzeczy może znacznie utrudnić stworzenie modelu tabeli w klasie posługującej się wyłącznie typami statycznymi, bez wprowadzania „brzydkich” obejść. Typy nullowalne są częścią .NET 2.0, a C# 2 oferuje nową składnię, która umożliwia ich łatwe użycie. Poniższy listing (2.8) pokazuje prosty przykład. Listing 2.8. Demonstracja różnorodności cech typów nullowalnych int? x = null; x = 5; if (x != null) { int y = x.Value; Console.WriteLine(y); } int z = x ?? 10;
Deklaracja i inicjalizacja zmiennej nullowalnej Wywołanie metody poza interfejsem Test obecności „rzeczywistej” wartości
Użycie operatora koalescencyjnego
Listing 2.8 pokazuje szereg cech typów nullowalnych oraz składnię C# pozwalającą na pracę z nimi. Każdy z detali tego listingu zostanie omówiony w rozdziale 4., teraz zwrócimy jedynie uwagę na fakt zdecydowanie większej przejrzystości i jasności tego kodu w porównaniu do obejść stosowanych wcześniej. Lista nowych cech języka jest tym razem krótsza, ale są one ważne zarówno pod względem wydajnościowym, jak i elegancji wyrażeń w kodzie: typy generyczne — C# 2, typy nullowalne — C# 2.
2.5.
Podsumowanie Ten rozdział niemal w całości był poświęcony powtórce z C# 1. Naszym celem nie było opisanie danego tematu od A do Z, a jedynie zbudowanie wspólnych podstaw do prezentacji dalszego materiału książki. Wszystkie omówione tematy stanowią centralną część C# i .NET. Mimo to widuję wiele nieporozumień z nimi związanych, które są powielane w środowisku programistów. Chociaż ten rozdział nie omawiał zagadnień w sposób bardzo dogłębny, mam nadzieję, że udało mi się rozjaśnić wszelkie niejasności, które mogłyby wpłynąć negatywnie na zrozumienie pozostałej części książki. Trzy główne cechy, które pokrótce omówiliśmy w tym rozdziale, zostały znacząco poprawione od czasów C# 1. Trzeba dodać, że niektóre z nich mają wpływ na wiele aspektów języka. Przykładem są typy generyczne, które wypływały przy okazji niemal każdej cechy omawianej w tym rozdziale. Jest to prawdopodobnie najszerzej stosowana cecha języka C# 2. Teraz, kiedy skończyliśmy już nasze przygotowania, możemy przejść do prawdziwych szczegółów, zaczynając od następnego rozdziału.
Część II C# 2 — rozwiązanie problemów C# 1
W
części pierwszej przyjrzeliśmy się pokrótce kilku nowym cechom C# 2. Teraz nadeszła pora, aby zająć się nimi dogłębnie. Zobaczymy, jak C# 2 naprawia różnorakie problemy napotykane przez programistów podczas pracy z C# 1 oraz sprawia, że istniejące cechy języka są bardziej użyteczne. Nie jest to wyczyn byle jaki — programowanie z użyciem C# 2 jest naprawdę znacznie przyjemniejsze niż z C# 1. Nowe cechy w C# 2 są do pewnego stopnia niezależne. Nie można powiedzieć, że są w pełni niezależne, ponieważ wiele z nich bazuje na flagowym elemencie języka C# 2, którym są typy generyczne, lub przynajmniej z nim współpracuje. Tak czy inaczej cechy, które będziemy omawiać w kolejnych pięciu rozdziałach, nie tworzą czegoś, co moglibyśmy nazwać supercechą języka. Pierwsze cztery rozdziały tej części opisują najistotniejsze cechy. Przyjrzymy się kolejno: Typom generycznym — najważniejszemu nowemu elementowi w C# 2
(a także w całym środowisku wykonawczym .NET 2.0). Typy generyczne pozwalają na parametryzowanie typów i metod. Typom nullowalnym — typy takie jak int i DateTime nie znają koncepcji „braku
wartości”. Typ nullowalny pozwala na uzupełnienie tej luki. Delegatom — chociaż delegaty nie zmieniły się na poziomie środowiska
wykonawczego, C# 2 znacznie ułatwił ich użycie w kodzie. Oprócz kilku prostych skrótów składniowych zostały wprowadzone metody anonimowe, rozpoczynające marsz w kierunku bardziej funkcyjnego stylu programowania. Trend ten jest kontynuowany w C# 3.
Iteratorom — pętla foreach od początku umożliwia korzystanie z iteratorów
w prosty sposób, jednak próba ich samodzielnej implementacji w C# 1 nie jest już tak łatwa. C# 2 bez problemu zbuduje dla Ciebie maszynę stanową wewnątrz programu, ukrywając tym samym sporo złożonego kodu. Poświęcając oddzielny rozdział na analizę każdej dużej i złożonej cechy języka C# 2, dotrzemy do rozdziału siódmego, w którym omówimy kilka prostszych cech języka, co wcale nie oznacza, że są one mniej użyteczne. Dla przykładu typy częściowe są niezbędne do lepszej pracy narzędzi projektowych w Visual Studio 2005 i wersjach późniejszych. Również inne narzędzia generujące kod w sposób automatyczny czerpią korzyści z typów częściowych. Wielu programistów korzysta dzisiaj z właściwości o publicznym getterze i prywatnym setterze, nie zastanawiając się w ogóle, iż możliwość taka została wprowadzona w C# 2. W chwili publikowania pierwszej wersji tej książki wielu programistów nie miało jeszcze styczności z C# 2. Według mojej subiektywnej oceny w 2010 roku trudno było znaleźć kogoś, kto korzystał z C# i przynajmniej nie spróbował C# 2, a nawet 3. Opisane w tej części tematy są kluczowe dla zrozumienia działania C# 2 i 3. Weźmy za przykład LINQ — opanowanie tej technologii bez wiedzy na temat typów generycznych i iteratorów byłoby bardzo trudne. Jeżeli używasz C# 2 już od jakiegoś czasu, prezentowane dalej koncepcje mogą się okazać znajome — pozwolę sobie jednak na sugestię, że nawet w takiej sytuacji możesz odnieść korzyści, poznając prezentowane szczegóły zachowania języka.
Parametryzowanie typów i metod
Ten rozdział omawia: ♦ typy i metody generyczne, ♦ interfejs typów dla metod generycznych, ♦ ograniczenia typów, ♦ refleksję i typy generyczne, ♦ zachowanie środowiska CLR, ♦ ograniczenia typów generycznych, ♦ porównanie z innymi językami.
92
ROZDZIAŁ 3
Parametryzowanie typów i metod
Oto prawdziwa historia1: pewnego dnia ja i moja żona robiliśmy cotygodniowe zakupy. Tuż przed naszym wyjazdem usłyszałem pytanie, czy mam listę. Potwierdziłem, że mam listę, i pojechaliśmy. Dopiero kiedy byliśmy już w sklepie, na jaw wyszła nasza pomyłka. Moja żona pytała o listę zakupów, podczas gdy ja wziąłem ze sobą listę zmyślnych cech C# 2. Sprzedawca był trochę zdziwiony, kiedy zapytaliśmy go, czy nie ma przypadkiem do sprzedania trochę metod anonimowych. Gdybyśmy tylko mogli wyrazić się trochę jaśniej! Gdyby tylko moja żona mogła w jakiś sposób przekazać mi, że chce, abym wziął ze sobą listę rzeczy, które chcemy kupić! Gdybyśmy tylko mieli typy generyczne... Dla większości programistów typy generyczne stanowią najważniejszą nową cechę C# 2. Poprawiają wydajność, sprawiają, że kod jest bardziej ekspresyjny, oraz przenoszą sporą dawkę zabezpieczeń z czasu wykonania do czasu kompilacji. W największym skrócie typy generyczne pozwalają na parametryzowanie typów i metod. Tak jak zwyczajne wywołania metod posiadają parametry wyrażające wartości przekazywane do środka, tak typy generyczne posiadają parametry określające, jakich typów użyć do ich konstrukcji. Brzmi to trochę mgliście — jeśli do tej pory nie miałeś do czynienia z typami generycznymi, być może będziesz się musiał dłużej zatrzymać przy tym temacie. Mogę się jednak założyć, że kiedy zrozumiesz podstawową ideę, pokochasz typy generyczne. W tym rozdziale zajmiemy się użyciem typów i metod generycznych oferowanych przez środowisko lub biblioteki innych dostawców, a także omówimy, jak napisać własne. Po drodze dowiemy się, jak typy generyczne współdziałają z wywołaniami refleksyjnymi interfejsu programistycznego, oraz poznamy kilka detali odnośnie do sposobu obsługi typów generycznych przez środowisko wykonawcze. Na koniec rozdziału wspomnę o kilku najczęściej spotykanych ograniczeniach typów generycznych z możliwymi obejściami oraz porównam typy generyczne C# z podobnymi mechanizmami w innych językach programowania. Na początku musimy jednak zrozumieć problemy, jakie stały się podstawą do opracowania typów generycznych.
3.1.
Czemu potrzebne są typy generyczne? Jeżeli posiadasz w swoich zasobach kod napisany przy użyciu C# 1, przyjrzyj mu się i policz rzutowania — szczególnie w kodzie, który intensywnie korzysta z kolekcji. Nie zapominaj, że pod niemal każdą pętlą foreach kryje się niejawne rzutowanie. Kiedy używasz typów zaprojektowanych do pracy z wieloma różnymi typami danych, nieuniknioną konsekwencją jest duża liczba rzutowań — cichego sposobu informowania kompilatora, aby się nie przejmował, założył, że wszystko jest w najlepszym porządku, i potraktował napotkane wyrażenie tak, jakby miało ten szczególny typ danych. Wykorzystanie dowolnego interfejsu programistycznego, który używa typu object do przekazywania parametrów wejściowych lub zwracania wyniku, prędzej czy później będzie
1
Prawdę mówiąc, jest to „historia na potrzeby wprowadzenia do rozdziału” — niekoniecznie całkowicie prawdziwa.
3.1.
Czemu potrzebne są typy generyczne?
93
wymagać rzutowania. Pewnym ułatwieniem jest wprowadzenie hierarchii klas bazującej na typie object. Podstawowy problem pozostanie ten sam — typ object jest zupełnie prosty, a więc żeby móc wykonać jakąkolwiek użyteczną pracę, trzeba w pierwszej kolejności dokonać jego rzutowania. Czy rzutowanie nie jest „be”? Nie, nie należy rozumieć, że nigdy nie powinno się tego robić (rzutowanie przydaje się do pracy ze strukturami mutowalnymi i publicznymi polami klas), ale trzeba je traktować jako zło konieczne. Rzutowanie wskazuje, że jesteś zmuszony do przekazania kompilatorowi dodatkowej wiedzy i wskazania, aby ten zaufał Ci w czasie kompilacji i przeniósł sprawdzenie poprawności założeń do czasu wykonania. Jeśli musisz dać do zrozumienia, że masz więcej wiedzy niż kompilator i dlatego chcesz dokonać rzutowania, takiej samej wiedzy będzie potrzebować osoba czytająca Twój kod. Możesz pozostawić komentarz w miejscu rzutowania, ale nie będzie to szczególnie użyteczne. Znacznie lepszym miejscem na taką informację jest deklaracja metody lub zmiennej. Jest to szczególnie ważne, jeśli dostarczasz typ lub metodę, z której inni użytkownicy będą korzystać bez dostępu do Twojego kodu. Typy generyczne umożliwiają dostawcom bibliotek zablokowanie wywołań z wnętrza bibliotek z użyciem nieprawidłowych parametrów. W C# 1 musieliśmy polegać na ręcznie napisanej dokumentacji, która (jak każda zduplikowana informacja) szybko staje się niekompletna i nieaktualna. Kiedy możemy taką informację zawrzeć bezpośrednio w kodzie jako część deklaracji metody lub typu, praca wszystkich stron staje się bardziej produktywna. Kompilator może dokonać więcej sprawdzeń, środowisko IDE może zaoferować dodatkowe informacje poprzez mechanizm IntelliSense (na przykład podczas przeglądania elementów listy łańcuchów może pokazać pola i metody typu string), użytkownicy metod będą pewniejsi co do poprawności swojego kodu pod względem przekazanych argumentów i zwróconych wartości, a czytelnicy Twojego kodu będą mogli lepiej zrozumieć, co miałeś na myśli, kiedy pisałeś dany fragment. CZY TYPY GENERYCZNE ZREDUKUJĄ LICZBĘ TWOICH BŁĘDÓW? Wszystkie opisy typów generycznych, jakie czytałem (włączając w to moje własne), podkreślają znaczenie przeniesienia sprawdzenia poprawności typów z czasu wykonania na czas kompilacji. Powierzę Ci mały sekret: nie przypominam sobie, abym kiedykolwiek musiał poprawiać błąd w wypuszczonym kodzie spowodowany brakiem sprawdzenia typu. Inaczej mówiąc, rzutowania, które umieszczaliśmy w C# 1, zawsze działały. Umieszczenie rzutowania w kodzie działa trochę jak znak ostrzegawczy, dzięki czemu zwracamy większą uwagę na poprawność typów. Typy generyczne być może nie zmniejszą radykalnie błędów związanych z bezpieczeństwem typów, ale wprowadzona przez nie czytelność kodu wpłynie pozytywnie na redukcję błędów związanych z użyciem kodu przez jego odbiorców. Prosty kod ma większą szansę być dobrze zrozumiany. O wiele łatwiej jest napisać dobry kod, który musi być odporny na nieprzemyślane zachowania programistów, kiedy odpowiednie gwarancje co do typu argumentów zapewnia sam system typów. To, co do tej pory powiedzieliśmy o typach generycznych, przemawia wystarczająco na ich korzyść, ale są też jeszcze korzyści pod względem wydajnościowym. Po pierwsze, skoro kompilator może wykonać więcej sprawdzeń na etapie kompilacji, pozostaje
94
ROZDZIAŁ 3
Parametryzowanie typów i metod
mniej rzeczy do weryfikacji w czasie wykonania. Po drugie, kompilator JIT może traktować typy wartościowe w sprytniejszy sposób, dzięki czemu w wielu sytuacjach udaje się uniknąć opakowywania i odpakowywania wartości. W pewnych przypadkach może to zdecydowanie wpłynąć zarówno na wydajność, jak i zużycie pamięci. Wiele z korzyści, jakie dają typy generyczne, może do złudzenia przypominać te płynące z przewagi statycznego systemu typów nad systemem dynamicznym. Mowa tu o lepszej weryfikacji typów na poziomie kompilacji, większej liczbie informacji zawartych bezpośrednio w kodzie, lepszym wsparciu ze strony środowiska IDE i lepszej wydajności. Powód jest prosty: kiedy korzystasz z ogólnego interfejsu programistycznego (na przykład z klasy ArrayList), który nie jest w stanie wykryć różnic pomiędzy różnymi typami, znajdujesz się — jeśli chodzi o dostęp do tego interfejsu — w sytuacji dynamicznego systemu typów. Warto przy okazji wspomnieć, że sytuacja odwrotna — przewaga dynamicznego systemu typów nad systemem statycznym — ma miejsce w nielicznych przypadkach (korzyści płynące z zastosowania dynamicznych języków rzadko są bowiem związane z koniecznością dokonania wyboru pomiędzy interfejsem generycznym i niegenerycznym). Kiedy możesz skorzystać z typów generycznych, decyzja o ich użyciu jest oczywista. To wszystko czeka na nas w C# 2 — teraz przyszła pora na rzeczywiste wykorzystanie typów generycznych.
3.2.
Proste typy generyczne do codziennego użycia Jeżeli chcesz wiedzieć wszystko o typach generycznych, musisz mieć świadomość, że mają one wiele skomplikowanych elementów. Specyfikacja języka C# przedstawia cały szereg szczegółowych informacji, aby opisać zachowanie w niemal każdej możliwej sytuacji. My nie musimy jednak znać wszystkich przypadków szczególnych, aby wykorzystać typy generyczne w sposób produktywny. (W rzeczywistości ta sama zasada obowiązuje dla dowolnego obszaru języka. Dla przykładu nie musisz znać wszystkich zasad rządzących przypisaniami, wystarczy, że będziesz umiał naprawić kod w przypadku błędu kompilacji). W tym podrozdziale opiszemy wszystko, co powinieneś wiedzieć o typach generycznych, aby móc zastosować je w codziennej pracy — zarówno w sensie użytkownika interfejsu stworzonego przez innych programistów, jak i producenta własnych interfejsów. Jeżeli utkniesz na tym etapie, proponuję, abyś się skoncentrował na wiedzy potrzebnej do korzystania z typów i metod generycznych zawartych w środowisku i innych bibliotekach. Ta wiedza jest przydatna o wiele częściej niż umiejętność samodzielnego tworzenia typów i metod generycznych. Zaczniemy od przyjrzenia się jednej z klas kolekcji wprowadzonych w .NET 2.0 — Dictionary.
3.2.1. Nauka przez przykład — słownik typu generycznego Użycie typów generycznych jest proste, o ile nie napotkasz przypadkiem jakiegoś ograniczenia i nie zaczniesz się zastanawiać, czemu Twoje rozwiązanie nie chce działać. Bez jakiejkolwiek wiedzy teoretycznej możesz z łatwością przewidzieć zachowanie
3.2.
Proste typy generyczne do codziennego użycia
95
kodu, po prostu go analizując. Korzystając z metody prób i błędów, będziesz w stanie napisać własny działający kod. (Jednym z udogodnień typów generycznych jest to, że spora część weryfikacji kodu odbywa się w czasie kompilacji, jest zatem spora szansa, że Twój kod będzie działał, kiedy doprowadzisz do pomyślnej kompilacji — takie zachowanie jeszcze bardziej upraszcza eksperymentowanie z kodem). Oczywiście, celem tego rozdziału jest wyposażenie Cię w wiedzę, dzięki której nie będziesz musiał zgadywać — będziesz dokładnie wiedział, co się dzieje na każdym kroku. Teraz przyjrzyjmy się fragmentowi kodu, który wygląda w miarę prosto, nawet jeśli składnia jest nieznajoma. Listing 3.1 używa typu Dictionary (w przybliżeniu generycznego odpowiednika niegenerycznej klasy Hashtable) do zliczenia częstotliwości występowania słów w zadanym tekście. Listing 3.1. Użycie Dictionary do zliczenia słów w tekście static Dictionary CountWords(string text) { Dictionary frequencies; frequencies = new Dictionary(); string[] words = Regex.Split(text, @"\W+"); foreach (string word in words) { if (frequencies.ContainsKey(word)) { frequencies[word]++; } else { frequencies[word] = 1; } } return frequencies;
Utworzenie nowego słownika „słowo – liczba wystąpień” Utworzenie nowego słownika „słowo – liczba wystąpień”
Dodanie do słownika lub jego uaktualnienie
} ... string text = @"Chodzi lisek koło drogi, Cichuteńko stawia nogi, Cichuteńko się zakrada, Nic nikomu nie powiada."; Dictionary frequencies = CountWords(text); foreach (KeyValuePair entry in frequencies) { string word = entry.Key; int frequency = entry.Value; Console.WriteLine("{0}: {1}", word, frequency); }
Utworzenie komórki widoku tabeli
Metoda CountWords tworzy pusty słownik dla par łańcuch (string) – liczba (int) ( ). Jej zadaniem będzie zliczanie wystąpień każdego słowa w zadanym tekście. Następnie używamy wyrażenia regularnego ( ) do podzielenia tekstu na słowa. Wyrażenie nie jest szczególnie wyszukane — ze względu na kropkę na końcu zdania wśród słów pojawia się pusty łańcuch, a te same słowa pisane wielką i małą literą są traktowane jako różne.
96
ROZDZIAŁ 3
Parametryzowanie typów i metod
Problemy te można łatwo rozwiązać — nie zrobiłem tego tylko dlatego, aby uzyskać jak najprostszy kod dla tego przykładu. Sprawdzamy, czy dane słowo jest już w naszym słowniku. Jeśli jest, zwiększamy licznik, w przeciwnym razie tworzymy wartość początkową licznika ( ). Zwróć uwagę, że kod dokonujący inkrementacji nie musi wykonywać rzutowania na typ int. Wiemy, że otrzymana wartość jest typu int już na etapie kompilacji. Krok wykonujący inkrementację tak naprawdę pobiera indekser słownika, zwiększa wartość, a następnie ustawia indekser z powrotem. Niektórzy programiści wolą wykonać ten krok w sposób jawny, używając wyrażenia frequencies[word] = frequen ´cies[word] + 1;. Ostatnia część listingu wygląda znajomo: enumeracja przez instancję typu Hashtable daje podobną (niegeneryczną) parę DictionaryEntry z właściwościami Key i Value dla każdego elementu kolekcji ( ). Różnica polega na tym, że w C# 1 klucz i wartość zostałyby zwrócone jako typ object, co zmusiłoby nas do rzutowania zarówno słowa, jak i częstotliwości. Dodatkowo częstotliwość (typ wartościowy) zostałaby opakowana. Trzeba przyznać, że nie musimy przypisywać słowa i częstotliwości do zmiennych lokalnych — moglibyśmy użyć pojedynczego wywołania Console.WriteLine i przekazać mu entry.Key i entry.Value. Sporne zmienne zostały przeze mnie użyte wyłącznie po to, by podkreślić brak konieczności rzutowania. Teraz, kiedy widzieliśmy już przykład, przyjrzyjmy się, czym właściwie jest Dictio ´nary, do czego odnoszą się symbole TKey i TValue oraz jakie znaczenie mają ostre nawiasy wokół nich.
3.2.2. Typy generyczne i parametry typów W C# występują dwie formy generycznych elementów języka: typy generyczne (włączając w to klasy, interfejsy, delegaty i struktury — nie ma enumeracji generycznych) oraz metody generyczne. Obie formy służą do wyrażania interfejsu programistycznego (w postaci pojedynczej metody generycznej lub całego typu generycznego) w taki sposób, iż w niektórych miejscach, gdzie spodziewasz się zobaczyć normalny typ, widzisz w zamian parametr typu. Parametr typu jest jedynie rezerwacją miejsca na typ rzeczywisty. Parametry pojawiają się w ostrych nawiasach wewnątrz deklaracji typu generycznego i są oddzielone od siebie przecinkami. Zatem w deklaracji Dictionary parametrami typu są TKey i TValue. Chcąc użyć typu lub metody generycznej, należy określić rzeczywiste typy — nazywane argumentami typu — jakimi chcemy się posługiwać. Dla przykładu na listingu 3.1 argumentami typu są string (dla TKey) i int (dla TValue). ALERT ŻARGONOWY! Typy generyczne niosą ze sobą sporą dawkę szczegółowej terminologii. Umieściłem ją w książce w celach informacyjnych i dlatego, że czasem ułatwia ona rozmowę, w której poruszane są detale implementacyjne. Dla Ciebie terminologia ta może się okazać przydatna, jeśli będziesz miał zamiar zajrzeć do specyfikacji języka. Jest mało prawdopodobne, abyś musiał się nią posługiwać w codziennej pracy z typami generycznymi. Proszę zatem, abyś zniósł dzielnie tę dawkę terminologii. Wiele z używanych tutaj terminów znajdziesz w podrozdziale 4.4 specyfikacji C# 4 — proponuję, abyś zajrzał tam, jeśli chcesz znaleźć więcej informacji na ten temat.
3.2.
Proste typy generyczne do codziennego użycia
97
Forma, w której żadnemu z parametrów typu nie przekazano argumentu typu, nazywana jest niezwiązanym typem generycznym (ang. unbound generic type). Kiedy argumenty typu są określone, mówimy o typie skonstruowanym (ang. constructed type). Niezwiązane typy generyczne można określić mianem planów konstrukcyjnych dla typów skonstruowanych, podobnie jak typy (generyczne lub nie) można uważać za plany konstrukcyjne obiektów. Jest to pewnego rodzaju dodatkowa warstwa abstrakcji. Rysunek 3.1 pokazuje ją w formie schematu graficznego.
Rysunek 3.1. Niezwiązane typy generyczne stanowią plany konstrukcyjne dla typów skonstruowanych, które z kolei służą jako plany konstrukcyjne dla rzeczywistych obiektów (na takiej samej zasadzie jak typy niegeneryczne)
Typy mogą być otwarte lub zamknięte. Typ otwarty nadal wymaga parametru typu (na przykład w postaci argumentu typu lub tablicy elementów typu), podczas gdy typ zamknięty jest przeciwieństwem typu otwartego — każdy element typu jest szczegółowo określony. Cały kod wykonuje się w kontekście zamkniętych typów skonstruowanych. Jedyny przypadek, kiedy masz szansę zobaczyć niezwiązany typ generyczny w kodzie C# (wyłączając deklarację), ma miejsce podczas użycia operatora typeof, z którym spotkamy się w sekcji 3.4.4. Idea parametru typu „odbierającego” informację i argumentu typu „dostarczającego” informację — patrz przerywane linie na rysunku 3.1 — jest dokładnie taka sama jak w przypadku parametrów i argumentów metod, chociaż przy argumentach typu mamy do czynienia z typami, a nie arbitralnymi wartościami. Argument typu musi być znany w czasie kompilacji, ale w odpowiednim kontekście może nim być również parametr typu. O typie zamkniętym możesz myśleć jak o interfejsie programowania typu otwartego, ale z parametrami typu zastąpionymi przez odpowiadające im argumenty typu2. 2
Taki model nie zawsze jest gwarantowany — istnieją przypadki szczególne, które nie będą działać po zastosowaniu tej prostej reguły — ale umożliwia proste pojmowanie typów generycznych, sprawdzające się w przeważającej większości przypadków.
98
ROZDZIAŁ 3
Parametryzowanie typów i metod
Tabela 3.1 pokazuje deklaracje niektórych metod i właściwości publicznych otwartego typu Dictionary oraz jego odpowiedniki w typie zamkniętym Dictionary ´. Tabela 3.1. Przykłady sygnatur metod typu generycznego z symbolami rezerwującymi miejsce na typy i te same metody po podstawieniu argumentów typu Sygnatura metody w typie generycznym
Sygnatura metody po podstawieniu argumentów typu
void Add(TKey key, TValue value)
void Add(string key, int value)
TValue this[TKey key] {get; set; }
int this[string key] {get; set; }
bool ContainsValue(TValue value)
bool ContainsValue(int value)
bool ContainsKey(TKey key)
bool ContainsKey(string key)
Zwracam uwagę, że żadna z metod w tabeli 3.1 nie jest w rzeczywistości metodą generyczną. Są to „zwyczajne” metody wewnątrz typu generycznego, które przypadkiem używają parametrów będących częścią deklaracji typu. Metodom generycznym przyjrzymy się w następnym podrozdziale. Teraz, kiedy wiesz już, co znaczą symbole TKey oraz TValue i do czego służą ostre nawiasy, możemy sprawdzić, jak deklaracje z tabeli 3.1 wyglądałyby w deklaracji klasy. Oto, jak mógłby wyglądać kod dla Dictionary (w poniższym przykładzie brakuje faktycznych implementacji metod i w rzeczywistości jest ich więcej): namespace System.Collections.Generic { public class Dictionary : IEnumerable { public Dictionary() { ... } public void Add(TKey key, TValue value) { ... } public TValue this[TKey key] { get { ... } set { ... } }
Deklaracja klasy generycznej Implementacja interfejsu generycznego
Deklaracja metody z użyciem parametrów typu
public bool ContainsValue(TValue value) { ... } public bool ContainsKey(TKey key) { ... } [...pozostałe metody...] } }
Zauważ, jak Dictionary implementuje interfejs generyczny IEnumerable ´ (i wiele innych interfejsów w rzeczywistym wykonaniu). Jakiekolwiek argumenty typu wyspecyfikujesz dla klasy, takie same zostaną zastosowane do interfejsu w miejscach, gdzie zostały użyte jednakowe parametry typu. A zatem w naszym przykładzie Dictionary zaimplementuje IEnumerable
3.2.
Proste typy generyczne do codziennego użycia
99
´. Jest to swego rodzaju podwójny interfejs generyczny — interfejs IEnumerable z argumentem typu w postaci struktury KeyValuePair. Właśnie dzięki implementacji tego interfejsu przykład z listingu 3.1 był w stanie przejść przez wszystkie pary wartość-klucz w taki, a nie inny sposób. Warto zauważyć, że konstruktor nie zawiera listy parametrów typu w nawiasach ostrych. Parametry typu należą raczej do typu niż do danego konstruktora i właśnie tam są deklarowane. Członkowie typu deklarują wyłącznie nowo wprowadzane parametry typu, mogą to jednak robić jedynie metody. WYMOWA TYPÓW GENERYCZNYCH. Jeśli kiedykolwiek będziesz potrzebował opisać typ generyczny swojemu koledze lub koleżance, wymieniaj parametry lub argumenty typu w taki sposób, jak zrobiłbyś to dla zwykłej deklaracji. Na przykład dla List możesz powiedzieć „lista typu T”. W Visual Basicu taka wymowa jest sugerowana przez składnię języka: ten sam typ zostałby zapisany jako List(Of T). W przypadku kilku parametrów typu moim zdaniem dobrze jest oddzielić je słowem lub zdaniem sugerującym całościowe znaczenie typu. Zatem Dictionary opisałbym jako „słownik łańcuchów w liczby” — podkreślając w ten sposób mapujący charakter typu. Typy generyczne mogą być przeciążane w odniesieniu do liczby parametrów typu. Mógłbyś zatem zdefiniować MyType, MyType, MyType, MyType, itd. — wszystkie w ramach tej samej przestrzeni nazw. Nazwy typów parametrów są w takiej sytuacji nieistotne, ważna jest jedynie ich liczba. Typy te, poza nazwą, nie są ze sobą związane — oznacza to między innymi, że nie istnieje między nimi domyślna konwersja. Ta sama zasada obowiązuje dla metod generycznych: dwie metody mogą mieć dokładnie taką samą sygnaturę z wyjątkiem liczby parametrów typu. Chociaż brzmi to trochę jak przepis na katastrofę, takie rozwiązanie może być przydatne, jeśli chcesz skorzystać z wnioskowania typów generycznych, w którym kompilator jest w stanie wywnioskować niektóre z argumentów typu za Ciebie. Wrócimy do tego zagadnienia w sekcji 3.3.2. NAZEWNICTWO STOSOWANE DLA PARAMETRÓW TYPU. Możesz stworzyć typ, posługując się takimi parametrami jak T, U i V. Takie nazwy będą jednak mówić bardzo mało o swoim przeznaczeniu lub sposobie użytkowania. Dla porównania weźmy typ Dictionary, w którym jasno widać, że TKey reprezentuje typ kluczy, a TValue — typ wartości. W przypadku pojedynczego parametru typu, którego przeznaczenie jest w miarę oczywiste, zgodnie z konwencją stosowana jest nazwa T (dobrym przykładem jest List). Parametry typu występujące w liczbie mnogiej są zwykle nazywane zgodnie ze swoim przeznaczeniem i poprzedzane przedrostkiem T, aby wskazać, że chodzi o parametr typu. Mimo tych reguł czasem natrafisz na typ z kilkoma parametrami w postaci pojedynczych liter (przykładem jest SynchronizedKeyed ´Collection). Staraj się unikać tworzenia podobnych konstrukcji. Wiemy już, jakie zadania spełniają typy generyczne. Zajmijmy się teraz metodami generycznymi.
100
ROZDZIAŁ 3
Parametryzowanie typów i metod
3.2.3. Metody generyczne i czytanie deklaracji generycznych Zdążyłem już kilkakrotnie wspomnieć o metodach generycznych, ale do tej pory nie spotkaliśmy jeszcze żadnej z nich. W porównaniu do typów generycznych idea metod generycznych może się dla Ciebie okazać bardziej zawiła — wydają się one mniej naturalne dla naszego mózgu, chociaż podstawowa zasada działania pozostaje bez zmian. Jesteśmy przyzwyczajeni, że parametry wejściowe oraz wartości zwracane przez metody mają zawsze ściśle określony typ. Widzieliśmy również, jak typ generyczny używa parametrów typu do deklarowania swoich metod. Metody generyczne posuwają się jeszcze dalej — w ramach skonstruowanego typu mogą posiadać własne parametry typu. Nie przejmuj się, jeśli nie rozumiesz w tej chwili, o co dokładnie chodzi — myślę, że doznasz nagłego olśnienia po przeanalizowaniu kilku przykładów. Dictionary nie posiada metod generycznych, ale jego bliski krewny — List — tak. Jak wiadomo, List jest listą elementów określonego typu. Pamiętając, że T jest parametrem typu dla całej klasy, dokonajmy szczegółowej analizy deklaracji metody generycznej. Rysunek 3.2 pokazuje znaczenie poszczególnych części deklaracji metody ConvertAll3.
Rysunek 3.2. Anatomia deklaracji metody generycznej
Patrząc na deklarację typu generycznego — bez znaczenia, czy jest to typ, czy też metoda — możesz mieć problem ze zrozumieniem jej znaczenia, szczególnie jeśli napotkasz typy generyczne wewnątrz typów generycznych, jak widzieliśmy to w przykładzie interfejsu implementowanego przez słownik. Najważniejsze to nie panikować, tylko przyjąć całość spokojnie, a następnie przeanalizować na przykładzie. Weź inny typ dla każdego parametru typu i odpowiednio je zastosuj. W tym przypadku zacznijmy od zastąpienia parametru typu będącego właścicielem metody (część typu List). Będziemy się trzymać przykładu listy łańcuchów i we wszystkich miejscach deklaracji wstawimy string zamiast T: List ConvertAll(Converter conv)
Jest trochę lepiej, ale dalej musimy się jeszcze uporać z TOutput. Widać, że jest to parametr typu metody (z góry przepraszam za mylącą terminologię), ponieważ występuje w ostrych nawiasach zaraz za jej nazwą. Spróbujmy użyć innego znanego nam typu — Guid — jako argumentu typu w miejsce TOutput. Ponownie zastępujemy wszystkie miejsca wystąpienia TOutput. Usuwamy część informującą o parametrze typu i od tego momentu możemy traktować metodę jako niegeneryczną: List ConvertAll(Converter conv) 3
Parametr converter został skrócony do conv, dzięki czemu deklaracja mieści się w jednej linii. Pozostała część ściśle odpowiada dokumentacji.
3.2.
Proste typy generyczne do codziennego użycia
101
Teraz, kiedy wszystko jest wyrażone przy użyciu typów konkretnych, łatwiej będzie nam myśleć. Chociaż metoda jest faktycznie generyczna, przez moment będziemy ją traktować tak, jakby nią nie była. W ten sposób lepiej zrozumiemy jej znaczenie. Idąc przez elementy deklaracji od lewej strony, widzimy, że metoda: zwraca wynik typu List, nazywa się ConvertAll, ma pojedynczy parametr Converter o nazwie conv.
Teraz musimy się tylko dowiedzieć, czym jest Converter, i będziemy mieć wszystko rozpracowane. Nie będzie chyba zaskoczeniem, jeśli dowiesz się, że Converter jest skonstruowanym delegatem typu generycznego (jego wersją niezwiązaną jest Converter), który konwertuje łańcuch na GUID. Mamy zatem metodę, która operuje na liście łańcuchów i używa konwertera do wyprodukowania listy GUID-ów. Skoro rozumiemy już sygnaturę metody, łatwiej będzie nam zrozumieć dokumentację, która potwierdza, że metoda tworzy nową listę typu List, konwertuje każdy element z oryginalnej listy do typu docelowego i dodaje go do listy, którą zwraca na samym końcu. Myślenie o sygnaturze w konkretnych kategoriach daje nam przejrzysty model mentalny i ułatwia odgadnięcie faktycznego przeznaczenia metody. Chociaż technika ta może się wydawać trochę prymitywna, ja nadal uważam ją za bardzo użyteczną podczas analizy skomplikowanych metod. Prawdziwymi „potworami” pod tym względem są niektóre czteroparametrowe sygnatury metod należące do LINQ. Zastosowanie przedstawionej analizy pozwala poradzić sobie również z nimi. Aby udowodnić, że cały ten wywód nie był tylko zwodzeniem, przyjrzyjmy się naszej metodzie w działaniu. Listing 3.2 przedstawia konwersję listy liczb całkowitych na listę liczb zmiennoprzecinkowych w taki sposób, że każdy element listy wynikowej jest pierwiastkiem kwadratowym odpowiadającej mu wartości z listy pierwotnej. Po wykonaniu konwersji wyświetlamy zawartość całej listy. Listing 3.2. Metoda List.ConvertAll w działaniu static double TakeSquareRoot(int x) { return Math.Sqrt(x); } ... List integers = new List(); Utworzenie i wypełnienie integers.Add(1); listy liczb całkowitych integers.Add(2); integers.Add(3); Utworzenie instancji integers.Add(4); delegata Converter converter = TakeSquareRoot; List doubles; doubles = integers.ConvertAll(converter); Konwersja listy foreach (double d in doubles) przy użyciu metody ConvertAll { Console.WriteLine(d); }
102
ROZDZIAŁ 3
Parametryzowanie typów i metod
Utworzenie i wypełnienie listy jest banalnie proste ( ) — korzystamy z listy liczb całkowitych o mocnym typie. W punkcie ( ) używamy mechanizmu delegatowego (konwersji grupy metod) — wprowadzonego w C# 2 — który będziemy szczegółowo omawiać w podrozdziale 5.2. Chociaż nie lubię się posługiwać elementami języka przed ich pełnym opisem, tym razem byłem zmuszony. Ten wiersz kodu nie zmieściłby się tutaj, gdybyśmy posłużyli się składnią C# 1. W punkcie ( ) wywołujemy metodę generyczną, określając jej argument typu, podobnie jak robimy to dla typów generycznych. W tym miejscu moglibyśmy skorzystać z wnioskowania typu, ale nie chcę wprowadzać zbyt wielu cech jednocześnie. Wypisanie zawartości listy na końcu jest już zupełnie proste. Po uruchomieniu programu zgodnie z oczekiwaniami zobaczysz wartości 1, 1.414…, 1.732… i 2. Ktoś mógłby zapytać, jaki to ma sens. Czy nie moglibyśmy zwyczajnie użyć pętli foreach, żeby przejść po wszystkich wartościach całkowitych i bezpośrednio wypisać wynik pierwiastka? Oczywiście, że tak. Przykład demonstruje jednak jeszcze inną rzecz — konwersję listy jednego typu na listę innego typu z użyciem pewnej logiki. Kod wykonujący taką operację „ręcznie” jest równie prosty, ale wersja wykonująca tę operację w pojedynczym wierszu jest czytelniejsza. Jest to typowa cecha metod generycznych — wykonują one w prostszy sposób operacje, które wcześniej mogłeś z powodzeniem wykonać dłuższą metodą. Przed wprowadzeniem metod generycznych operację podobną do ConvertAll można było wykonać na typie ArrayList, dokonując konwersji z typu object na object, ale efekt był o wiele mniej satysfakcjonujący. Dodatkowe usprawnienie niosą metody anonimowe (zobacz podrozdział 5.4) — przy ich użyciu moglibyśmy określić logikę konwersji w miejscu, unikając konieczności tworzenia w tym celu dodatkowej metody. Duże uproszczenie pod tym względem wprowadzają LINQ i wyrażenia lambda, o czym przekonamy się w części trzeciej. Warto dodać, że metody generyczne mogą występować również w typach niegenerycznych. Listing 3.3 pokazuje metodę generyczną zadeklarowaną i użytą wewnątrz niegenerycznej klasy. Listing 3.3. Implementacja metody generycznej wewnątrz niegenerycznego typu static List MakeList(T first, T second) { List list = new List(); list.Add(first); list.Add(second); return list; } ... List list = MakeList("Wiersz 1", "Wiersz 2"); foreach (string x in list) { Console.WriteLine(x); }
Metoda generyczna MakeList potrzebuje tylko jednego parametru typu (T). Jej działanie jest zupełnie proste i polega na utworzeniu listy z wartości przekazanych jako argumenty. Warto jednak zauważyć, że w jej wnętrzu możemy użyć T jako argumentu
3.3.
Wkraczamy głębiej
103
typu do stworzenia obiektu typu List. Implementację możesz traktować mniej więcej jako zastąpienie wszystkich wystąpień T przez string. Wywołując metodę, używamy tej samej składni, jaką widzieliśmy wcześniej podczas specyfikowania argumentów typu. Do tej pory wszystko jasne? Na tym etapie powinieneś już móc tworzyć samodzielnie proste typy i metody generyczne. Przed nami jeszcze jeden stopień skomplikowania do pokonania, ale jeśli rozumiesz podstawową ideę typów generycznych, najtrudniejszy etap masz już za sobą. Nie przejmuj się zbytnio, jeśli nie wszystko jest dla Ciebie zupełnie klarowne, zwłaszcza jeśli chodzi o terminy „otwarty”, „zamknięty”, „niezwiązany” i „skonstruowany”. Zanim przystąpisz do lektury dalszej części materiału, możesz wykonać kilka eksperymentów z typami generycznymi, aby zobaczyć ich działanie w praktyce. Jeżeli do tej pory nie używałeś kolekcji generycznych, polecam zajrzeć do dodatku B, który opisuje dostępne elementy języka. Kolekcje stanowią dobry punkt startowy do zabawy z typami generycznymi, a ponadto są powszechnie stosowane w niemal każdym nietrywialnym programie .NET. Podczas eksperymentów przekonasz się, że trudno będzie zatrzymać implementację pomysłu w połowie. Kiedy zamienisz jakiś interfejs na generyczny, prawdopodobnie będziesz musiał poprawić pozostały kod, zamieniając go również na generyczny, lub dodać odpowiednie rzutowania, wymagane przez nowe — mocniejsze pod względem typu — wywołania. Alternatywnym rozwiązaniem jest użycie typów generycznych o silnym typie „pod maską”, ale pozostawienie interfejsu o słabym typie na zewnątrz. Tak jak zawsze, wyczucie, kiedy należy użyć typów generycznych, przychodzi z czasem.
3.3.
Wkraczamy głębiej Proste sposoby wykorzystania typów generycznych, jakie widzieliśmy do tej pory, pozwolą na pracę z nimi w dłuższej perspektywie czasu. Są jednak jeszcze inne cechy, które mogą usprawnić naszą pracę. Zaczniemy od przeanalizowania ograniczeń typów, które dają możliwość większej kontroli nad argumentami typów (zwłaszcza kiedy tworzysz własne typy i metody generyczne). Zrozumienie ograniczeń typów ma również znaczenie z punktu widzenia znajomości opcji, jakie oferuje środowisko. W następnym kroku zajmiemy się wnioskowaniem typów — poręcznym trikiem kompilatora, który pozwala na pominięcie jawnych argumentów typu podczas pracy z metodami generycznymi. Można się obyć bez tego mechanizmu, chociaż jego obecność przy odpowiednim zastosowaniu wpływa na uproszczenie kodu i sprawia, że jest on bardziej czytelny. W trzeciej części książki przekonamy się, że kompilator może teraz częściej wnioskować pewne informacje w oparciu o Twój kod przy jednoczesnym zachowaniu bezpieczeństwa i statyczności języka4. Ostatnia część tego podrozdziału będzie poświęcona pozyskiwaniu wartości domyślnej dla parametru typu, a także porównaniom dostępnym podczas pisania kodu generycznego. Rozważania zakończymy przykładem — w postaci użytecznej klasy — demonstrującym większość z omówionych cech.
4
Z wyłączeniem C# 4, w którym pozwolono na jawne użycie typów dynamicznych.
104
ROZDZIAŁ 3
Parametryzowanie typów i metod
Chociaż wchodzimy głębiej w szczegóły typów generycznych, nie ma przed nami nic naprawdę trudnego. Jest sporo rzeczy do zapamiętania, ale wszystkie cechy mają swój cel, o czym przekonasz się, kiedy dojrzejesz do ich wykorzystania. Zaczynajmy.
3.3.1. Ograniczenia typów Do tej pory wszystkie parametry typów, z jakimi się zetknęliśmy, akceptowały dowolne typy bez wyjątku. Mogliśmy tworzyć różne typy, takie jak List czy Dictionary ´ — cokolwiek przyszło nam do głowy. Nie ma w tym nic niebezpiecznego, dopóki mamy do czynienia z kolekcjami, które nie muszą współdziałać z przechowywaną zawartością. Jednak nie wszystkie kolekcje mają tyle szczęścia. Zdarzają się sytuacje, w których chcesz wywoływać metody na instancjach typu wskazanego przez parametr typu, tworzyć takie instancje lub ograniczyć zawartość listy wyłącznie do typów referencyjnych (lub tylko typów wartościowych). Innymi słowy, chcesz nakreślić reguły decydujące o poprawności argumentu typu dla swojego typu lub metody generycznej. W C# 2 do tego celu służą ograniczenia. Dostępne są cztery rodzaje ograniczeń. Ich podstawowa składnia jest jednakowa. Ograniczenia pojawiają się na końcu deklaracji metody lub typu generycznego. Do ich wprowadzenia służy kontekstowe słowo kluczowe where. Później przekonamy się również, że ograniczenia mogą być łączone. Zaczniemy od przedstawienia kolejno każdego rodzaju ograniczenia. Ograniczenie typu referencyjnego Pierwszy rodzaj ograniczenia, wyrażony jako T: class i występujący zawsze jako pierwszy dla tego parametru typu, zapewnia, że użyty argument typu jest typem referencyjnym. Może to być klasa, interfejs, tablica, delegat lub inny parametr typu, o którym już wiadomo, że jest typem referencyjnym. Weźmy na przykład następującą deklarację: struct RefSample where T : class
Poprawne typy zamknięte dla tej deklaracji to między innymi: RefSample, RefSample, RefSample.
Niepoprawne typy zamknięte to na przykład: RefSample, RefSample.
Celowo zadeklarowałem RefSample jako strukturę (i tym samym typ wartościowy), aby pokazać różnicę pomiędzy parametrem typu, który podlega ograniczeniu, a typem deklarowanym. RefSample jest nadal typem wartościowym i podlega semantyce wartościowej, tyle że wszystkie wystąpienia parametru T zostały zastąpione typem string. Kiedy parametr typu jest ograniczony w taki sposób, możesz porównywać referencje (łącznie z null), używając operatorów == i !=. Bądź jednak świadomy, że jeżeli nie ma innych ograniczeń, porównywane będą jedynie referencje, nawet jeśli dany typ prze-
3.3.
Wkraczamy głębiej
105
ciąża te operatory (ma to miejsce w przypadku typu string). Do pojawienia się „gwarantowanych przez kompilator” przeciążeń operatorów == i != możesz doprowadzić przez zastosowanie opisanego dalej ograniczenia konwersji typu, jednak sytuacja, w której będzie to potrzebne, zdarza się bardzo rzadko. Ograniczenie typu wartościowego To ograniczenie, wyrażone jako T: struct, wymusza użycie typu wartościowego jako argumentu typu, włączając w to enumeracje. Niedozwolone są również typy nullowalne, o których będziemy mówić w rozdziale czwartym. Przyjrzyjmy się przykładowej deklaracji: class ValSample where T : struct
Poprawnymi typami zamkniętymi dla tej deklaracji są: ValSample, ValSample.
Niepoprawne typy zamknięte to między innymi: ValSample, ValSample.
Tym razem ValSample jest typem referencyjnym, w którym T musi być typem wartościowym. Zauważ, że zarówno System.Enum, jak i System.ValueType są typami referencyjnymi i jako takie nie mogą być poprawnymi argumentami typu dla ValSample. Kiedy parametr typu jest ograniczony wyłącznie do typów wartościowych, zabronione są porównania przy użyciu operatorów == i !=. Osobiście rzadko znajduję zastosowanie dla ograniczeń typów wartościowych i referencyjnych. W następnym rozdziale zobaczymy jednak, że mają one istotne znaczenie dla typów nullowalnych. Dwa pozostałe ograniczenia mogą się okazać bardziej użyteczne dla Ciebie, kiedy zaczniesz tworzyć własne typy generyczne. Ograniczenie typu konstruktora Trzeci typ ograniczenia wyrażany jest jako T: new() i musi występować zawsze jako ostatnie ograniczenie dla danego parametru typu. Jego zadaniem jest sprawdzenie, czy argument dla danego parametru typu posiada konstruktor bezparametrowy, który może zostać użyty do stworzenia instancji typu. Dotyczy to dowolnego typu wartościowego, dowolnej niestatycznej i nieabstrakcyjnej klasy bez jawnie zadeklarowanych konstruktorów, a także dowolnej nieabstrakcyjnej klasy z jawnym publicznym konstruktorem bezparametrowym. C# KONTRA STANDARDY CLI. Istnieje pewna rozbieżność pomiędzy C# i standardem CLI pod względem typów wartościowych i konstruktorów. Specyfikacja C# mówi, że wszystkie typy wartościowe posiadają domyślny konstruktor bezparametrowy i że wywołania konstruktorów jawnych i bezparametrowych korzystają z tej samej składni — za skonstruowanie prawidłowego wywołania jest odpowiedzialny konstruktor. Specyfikacja CLI nie stawia takiego wymogu, za to dostarcza specjalną instrukcję do stworzenia wartości domyślnej
106
ROZDZIAŁ 3
Parametryzowanie typów i metod
bez specyfikowania jakichkolwiek parametrów. Rozbieżność tę możesz zobaczyć w działaniu, kiedy użyjesz refleksji do znalezienia konstruktora typu wartościowego (nie znajdziesz konstruktora bezparametrowego). Przyjrzyjmy się prostemu przykładowi, tym razem w formie metody. Aby zaprezentować użyteczność tego ograniczenia, dodam również ciało metody: public T CreateInstance where T : new() { return new T(); }
Ta metoda zwraca nową instancję dowolnego typu, pod warunkiem że typ ten posiada konstruktor bezparametrowy. Dozwolone są zatem wywołania CreateInstance() i CreateInstance, ale nie CreateInstance(), ponieważ typ string nie posiada konstruktora bezparametrowego. Nie istnieje metoda pozwalająca na zmuszenie parametru typu do posiadania konstruktora o określonej sygnaturze — dla przykładu nie można nałożyć ograniczenia, iż konstruktor powinien posiadać jeden parametr typu string. Jest to trochę frustrujące, ale nie możemy nic na to poradzić. Problemowi temu przyjrzymy się bliżej, kiedy będziemy omawiać rozmaite ograniczenia typów generycznych .NET w podrozdziale 3.5. Ograniczenie typu konstruktora może się okazać przydatne, kiedy zachodzi potrzeba skorzystania z wzorca fabryki klas, w której pewien obiekt wytwarza inne obiekty na żądanie. Często zachodzi wymóg, aby fabryka produkowała obiekty o określonym interfejsie, i to właśnie w tym miejscu do gry wchodzi nasz ostatni typ ograniczenia. Ograniczenie typu konwersji Ostatni (najbardziej skomplikowany) typ ograniczenia pozwala na wskazanie innego typu, do którego argument typu powinien dać się zrzutować poprzez identyczność, referencję lub operację opakowania. Możesz wskazać, aby określony argument typu był konwertowany na inny argument typu — jest to tak zwane ograniczenie parametru typu. Jego obecność utrudnia zrozumienie deklaracji, ale ograniczenie to może być bardzo przydatne w wielu sytuacjach. Tabela 3.2 pokazuje kilka deklaracji typu generycznego z ograniczeniami typu konwersji i towarzyszące im poprawne oraz niepoprawne przykłady typów skonstruowanych. Trzeci wiersz, zawierający T: IComparable, jest jednym z przykładów użycia typu generycznego jako ograniczenia. Dozwolone są również inne warianty, takie jak T : List (gdzie U jest kolejnym parametrem typu) i T: IList. Możesz wyspecyfikować wiele interfejsów, ale tylko jedną klasę. Poniższy przykład jest poprawny (i jednocześnie bardzo restrykcyjny): class Sample where T : Stream, IEnumerable, IComparable
Ten przykład jest niepoprawny: class Sample where T : Stream, ArrayList, IComparable
3.3.
Wkraczamy głębiej
107
Tabela 3.2. Przykłady ograniczeń typów konwersji Deklaracja
Przykłady skonstruowanych typów
class Sample where T : Stream
Poprawny: Sample (rzutowanie przez identyczność) Niepoprawny: Sample
struct Sample where T : IDisposable
Poprawny: Sample (rzutowanie przez referencję) Niepoprawny: Sample
class Sample where T : IComparable
Poprawny: Sample (opakowanie) Niepoprawny: Sample
class Sample where T : U
Poprawny: Sample (rzutowanie przez referencję) Niepoprawny: Sample
Żaden typ nie może dziedziczyć bezpośrednio po więcej niż jednej klasie, zatem takie ograniczenie byłoby niemożliwe do zrealizowania lub jego część byłaby nadmiarowa (na przykład wymuszenie, aby typ był potomkiem zarówno typu Stream, jak i Memory ´Stream). Istnieje jeszcze jedno obostrzenie: wskazany typ nie może być typem wartościowym, klasą zamkniętą (jaką jest na przykład string) lub jednym z następujących typów „specjalnych”: System.Object, System.Enum, System.ValueType, System.Delegate.
SPOSÓB NA BRAK OGRANICZEŃ DOTYCZĄCYCH ENUMERACJI I DELEGATÓW. Wydaje się, że brak możliwości wskazania wymienionych wyżej typów w ograniczeniu typu wynika z jakiegoś ograniczenia w samym środowisku wykonawczym, ale tak nie jest. Być może dzieje się tak z powodów czysto historycznych (restrykcje zostały wprowadzone, kiedy dopiero pracowano nad typami generycznymi). Jeżeli jednak skonstruujesz odpowiedni kod bezpośrednio w języku IL, będzie on działał. Specyfikacja CLI wymienia nawet te typy jako przykłady i wyjaśnia, które deklaracje byłyby prawidłowe, a które nie. Jest to troszeczkę denerwujące, ponieważ z łatwością można sobie wyobrazić wiele metod generycznych, które dobrze byłoby ograniczyć wyłącznie do typów delegatowych lub enumeracji. Prowadzę projekt open source o nazwie Unconstrained Melody („Nieograniczona melodia”, http://mng.bz/s9Ca), który — przy użyciu pewnych trików — buduje bibliotekę klas posiadającą ograniczenia na różnorodnych metodach użytkowych. Chociaż kompilator nie pozwoli Ci na zadeklarowanie tego typu ograniczeń, nie będzie zgłaszał żadnych zastrzeżeń, kiedy wywołasz odpowiednią metodę z biblioteki. Być może w przyszłych wersjach C# zakaz stosowania wymienionych typów zostanie zniesiony. Ograniczenia typu konwersji są chyba najbardziej użyteczne, gdyż pozwalają wymusić użycie wyłącznie konkretnych typów jako instancji parametrów typu. Jednym ze szczególnie poręcznych przykładów tego ograniczenia jest T : IComparable. Jego
108
ROZDZIAŁ 3
Parametryzowanie typów i metod
zastosowanie daje pewność, że możesz w bezpośredni i znaczący sposób porównać dwie instancje typu T. Przykład takiego zachowania, a także innych form porównań znajduje się w sekcji 3.3.3. Łączenie ograniczeń Wspomniałem już, że istnieje możliwość istnienia wspólnie kilku ograniczeń. Przykład widzieliśmy podczas omawiania ograniczeń typu konwersji. To, czego jeszcze nie widzieliśmy, to łączenie razem różnych typów ograniczeń. Jasne jest, że żaden typ nie może być jednocześnie typem referencyjnym i wartościowym, zatem to połączenie odpada. Każdy typ wartościowy posiada konstruktor bezparametrowy, w związku z czym nie można użyć ograniczenia typu konstruktora, kiedy zostało użyte ograniczenie wymuszające typ wartościowy (nadal jednak możliwe jest stosowanie new T() wewnątrz metod, jeśli T zostało ograniczone do typów wartościowych). Jeśli posiadasz wiele ograniczeń typów konwersji i jednym z nich jest klasa, musi się ona pojawić przed interfejsami — każdy interfejs może wystąpić wyłącznie jeden raz. Każdy parametr typu posiada własne, niezależne ograniczenia, wprowadzane z użyciem słowa kluczowego where. Spójrzmy na kilka poprawnych i niepoprawnych przykładów: Poprawne: class class class class
Sample Sample Sample where T : Stream where U : IDisposable
Niepoprawne: class class class class class class class
Sample where T Sample where T Sample where T Sample where T Sample where T Sample where Sample where
: : : : : T T
class, struct Stream, class new(), Stream IDisposable, Stream XmlReader, IComparable, IComparable : struct where U : class, T : Stream, U : IDisposable
Ostatnie przykłady na obu listach demonstrują, jak łatwo można z wersji poprawnej stworzyć niepoprawną wersję ograniczenia. W takiej sytuacji błąd zgłoszony przez kompilator zupełnie nie pomaga. Warto w takim momencie przypomnieć sobie, że każda lista ograniczeń parametru wymaga własnego słowa wprowadzającego — where. Interesujący jest trzeci poprawny przykład — jeśli U jest typem wartościowym, jakim cudem może dziedziczyć po T, który jest typem referencyjnym? Odpowiedź: T mógłby być typem object lub interfejsem implementowanym przez U. Trzeba przyznać, że jest to dość paskudny przypadek. TERMINOLOGIA UŻYWANA W SPECYFIKACJI. Specyfikacja dzieli ograniczenia na kategorie w trochę inny sposób. Wyróżnia ograniczenia podstawowe, drugorzędne i ograniczenia konstruktora. Pierwszy rodzaj dotyczy ograniczeń typu referencyjnego, wartościowego oraz ograniczeń konwersji przy użyciu klas. Drugi rodzaj wiąże się z ograniczeniami typu z użyciem interfejsów lub innego parametru typu. Nie uważam powyższej klasyfikacji za szczególnie użyteczną, chociaż ułatwia ona zdefiniowanie gramatyki ograniczeń: ograniczenie podstawowe
3.3.
Wkraczamy głębiej
109
jest opcjonalne, ale może istnieć tylko jedno, ograniczeń drugorzędnych może być dowolnie wiele, a ograniczenie konstruktora jest opcjonalne (o ile nie występuje ograniczenie typu wartościowego, w przypadku którego jest ono zabronione). Skoro posiadasz już całą wiedzę potrzebną do czytania deklaracji typów generycznych, przyjrzyjmy się wspomnianemu wcześniej interfejsowi argumentów typu. Na listingu 3.2 wskazaliśmy jawnie argumenty typu dla List.ConvertAll. Podobnie zrobiliśmy na listingu 3.3 dla naszej własnej metody MakeList. Spróbujmy teraz poprosić kompilator o wypracowanie wartości tych argumentów, a tym samym — uproszczenie wywołań metod generycznych.
3.3.2. Interfejs argumentów typu dla metod generycznych Określanie argumentów typu podczas wywoływania metod generycznych wydaje się często zupełnie nadmiarowe. W większości przypadków oczywiste jest, że argumenty typu powinny odpowiadać typom argumentów metody. Dla ułatwienia życia, poczynając od wersji drugiej C#, pozwolono kompilatorowi na samodzielność w ściśle określonych przypadkach, dzięki czemu możesz wywoływać metody bez jawnego wskazywania argumentów typu. Zanim przejdziemy dalej, chcę zaznaczyć, że dotyczy to wyłącznie metod generycznych. Mechanizm nie działa dla typów generycznych. Skoro wyjaśniliśmy to sobie, przyjrzyjmy się odpowiednim wierszom na listingu 3.3 i zobaczmy, jak można uprościć nasz kod. Oto wiersze, które deklarują metodę, a następnie ją wywołują: static List MakeList(T first, T second) ... List list = MakeList("Wiersz 1", "Wiersz 2");
Przyjrzyj się argumentom; w obu przypadkach są to łańcuchy. Każdy z zadeklarowanych parametrów tej metody jest typu T. Nawet gdybyśmy nie mieli części pomiędzy nazwą metody a listą jej argumentów, byłoby w miarę oczywiste, że zamierzamy wywołać ją z typem string jako argumentem dla parametru typu T. Kompilator pozwala na ominięcie tej części: List list = MakeList ("Wiersz 1", "Wiersz 2");
Czy tak nie wygląda lepiej? Na pewno jest krócej. Oczywiście, nie znaczy to wcale, że kod w takiej formie będzie zawsze czytelniejszy — w pewnych sytuacjach czytelnikowi kodu może być trudno dojść do tego, jakie argumenty typów miałeś na myśli, nawet jeśli kompilator jest w stanie zrobić to z łatwością. Proponuję, abyś każdy przypadek traktował indywidualnie. Ja pozwalam kompilatorowi na wywnioskowanie typu argumentów w większości możliwych przypadków. Zauważ, że kompilator z całą pewnością wie, iż używamy typu string, ponieważ przypisanie do listy jest akceptowane, a mimo to argument typu dla listy pozostaje na miejscu (i musi pozostać). To przypisanie nie ma wpływu na proces wnioskowania parametru typu. Jeżeli kompilator wywnioskuje argument dla parametru typu w sposób błędny, najprawdopodobniej zostanie zgłoszony błąd kompilacji. Jak to możliwe, że kompilator jest w stanie się pomylić? Załóżmy, że jako argumentu chcielibyśmy użyć typu object. Parametry naszej metody są nadal poprawne,
110
ROZDZIAŁ 3
Parametryzowanie typów i metod
ale ponieważ oba parametry są łańcuchami, kompilator myśli, że chcemy użyć typu string. Wymuszenie typu na jednym z parametrów przez jawne rzutowanie sprawi, że wnioskowanie typu nie zadziała — jeden z argumentów metody będzie sugerował, że T to object, a drugi, że string. W takiej sytuacji kompilator mógłby uznać, że wybranie typu object jest satysfakcjonujące, a wybranie typu string nie. Niestety specyfikacja dopuszcza tylko ograniczoną liczbę kroków algorytmu wyboru. Ten mechanizm jest już całkiem złożony w C# 2, a C# 3 komplikuje sprawy jeszcze bardziej. Nie będę wnikał w szczegóły działania algorytmu w C# 2, ograniczę się jedynie do przedstawienia podstawowych kroków: 1. Dla każdego argumentu metody (mówimy o zwykłych argumentach, w nawiasach okrągłych) spróbuj, używając nieskomplikowanych technik, wywnioskować niektóre z argumentów typu metody generycznej. 2. Sprawdź, czy wszystkie wyniki z pierwszego kroku są spójne — inaczej mówiąc, jeśli jeden z argumentów zasugerował dany argument typu dla pewnego parametru typu, a inny argument zasugerował odmienny argument typu dla tego samego parametru typu, wnioskowanie zawodzi dla tego wywołania metody. 3. Sprawdź, czy wszystkie parametry typów potrzebne dla wywołania metody generycznej zostały wywnioskowane. Nie można określić samodzielnie części parametrów i pozostawić resztę do „odgadnięcia” kompilatorowi. Obowiązuje zasada: „wszystko albo nic”. Jest jedna rzecz, którą można zrobić, aby uniknąć nauki wszystkich reguł (nie polecam tego, o ile nie jesteś szczególnie zainteresowany detalami tego mechanizmu): spróbuj i zobacz, co się stanie. Jeśli myślisz, że kompilator jest w stanie wywnioskować wszystkie argumenty typu, wywołaj metodę, nie wskazując żadnego z nich. Kiedy się nie uda, wstaw jawnie wszystkie typy. Nie tracisz niczego poza chwilą na dodatkową kompilację i nie musisz mieć w głowie całego algorytmu postępowania. W celu łatwiejszego użycia typów generycznych wnioskowanie typu może zostać połączone z ideą przeciążania nazw typów w oparciu o liczbę parametrów typu. Przykład takiego działania zobaczymy wkrótce, kiedy poskładamy wszystko w całość.
3.3.3. Implementowanie typów generycznych Chociaż jest bardziej prawdopodobne, że spędzisz więcej czasu, używając metod i typów generycznych, niż pisząc je samodzielnie, jest kilka rzeczy, o których powinieneś wiedzieć, na wypadek gdyby przyszło Ci kiedyś stworzyć własną implementację. W większości przypadków możesz przyjąć, że T (lub dowolna inna nazwa, jaką upatrzyłeś dla swojego parametru) jest nazwą typu, i zacząć pisać kod w taki sposób, jakbyś nie miał do czynienia z typem generycznym. Powinieneś jednak mieć świadomość istnienia kilku dodatkowych czynników. Wyrażenia wartości domyślnych Pracując ze znanym sobie typem, znasz jego wartość domyślną — jest to na przykład wartość, jaką posiadałoby niezainicjalizowane pole tego typu. Kiedy nie wiesz, z jakim typem masz do czynienia, nie ma możliwości bezpośredniego wskazania wartości domyśl-
3.3.
Wkraczamy głębiej
111
nej. Nie możesz użyć null, ponieważ nie ma gwarancji, że będzie to typ referencyjny, ani też zera, ponieważ może to być typ nienumeryczny. Chociaż potrzeba posiadania wartości domyślnej nie należy do częstych sytuacji, czasem jej obecność może się przydać. Dobrym przykładem jest Dictionary — typ ten ma metodę TryGetValue, która zachowuje się nieco podobnie do obecnych w typach numerycznych metod TryParse, to znaczy używa parametru wyjściowego do zwrócenia wyniku działania i zwraca wartość boolowską, informującą, czy jej działanie zakończyło się sukcesem. Oznacza to, że metoda ta musi mieć pewną wartość typu TValue, którą wypełni parametr wyjściowy (pamiętasz zapewne, że parametrowi wyjściowemu trzeba przypisać wartość przed powrotem z metody). WZORZEC TRYXXX. Kilka wzorców projektowych środowiska .NET można łatwo zidentyfikować poprzez zaangażowane w nie metody. Przykładowo BeginXXX i EndXXX sugerują operację asynchroniczną. Wzorzec TryXXX jest jednym z kilku, których użycie zostało rozszerzone pomiędzy .NET 1.1 i 2.0. Został zaprojektowany dla sytuacji, które w zwykłych warunkach można byłoby traktować jako błędne (w sensie braku możliwości wykonania przez metodę jej podstawowego celu), ale w których porażka mogłaby nastąpić bez wskazywania wyraźnego błędu lub traktowania go jako wyjątku. Przykładowo użytkownicy często popełniają błędy podczas próby wpisania wartości numerycznej, zatem użyteczna byłaby możliwość przetłumaczenia pewnego fragmentu tekstu bez konieczności łapania i przetwarzania wyjątków. Takie rozwiązanie nie tylko poprawia wydajność w przypadku porażki, ale również oszczędza wyjątki dla poważniejszych błędów, kiedy coś jest nie tak z samym systemem (niezależnie od tego, jak szeroko chcesz traktować to pojęcie). Jest to wzorzec, który warto posiadać w swoim „arsenale” projektanta biblioteki. C# 2 dostarcza wyrażenie wartości domyślnej, które zaspokaja tę potrzebę. Chociaż w specyfikacji wyrażenie to nie jest opisywane jako operator, możesz o nim myśleć podobnie jak o operatorze typeof, tyle że zwracającym inną wartość. Zostało to zobrazowane na przykładzie metody generycznej na kolejnym listingu (3.4). Znajdziemy w nim również użycie wnioskowania typu i ograniczenia typu konwersji. Listing 3.4. Porównywanie wartości z wartością domyślną w sposób generyczny static int CompareToDefault(T value) where T : IComparable { return value.CompareTo(default(T)); } ... Console.WriteLine(CompareToDefault("x")); Console.WriteLine(CompareToDefault(10)); Console.WriteLine(CompareToDefault(0)); Console.WriteLine(CompareToDefault(-10)); Console.WriteLine(CompareToDefault(DateTime.MinValue));
Listing 3.4 pokazuje metodę generyczną użytą z trzema różnymi typami: string, int i DateTime. CompareToDefault narzuca konieczność użycia jej wyłącznie z typami implementującymi interfejs IComparable, co pozwala nam wywołać metodę CompareTo(T) na wartości przekazanej do środka. Drugim elementem porównania jest wartość domyślna
112
ROZDZIAŁ 3
Parametryzowanie typów i metod
typu. Dla referencyjnego typu string wartością domyślną jest null, natomiast dokumentacja metody CompareTo mówi, że dla typów referencyjnych „cokolwiek” jest większe niż null. Stąd wartością pierwszego wyrażenia jest 1. Kolejne trzy wiersze pokazują porównanie z domyślną wartością int, wynoszącą 0. Wynikiem ostatniej linii jest zero, co wskazuje, że DateTime.MinValue jest wartością domyślną dla typu DateTime. Oczywiście metoda z listingu 3.4 nie zadziała, jeśli jako argument przekażesz null — wiersz wywołujący CompareTo rzuci wyjątek NullReferenceException. Nie przejmuj się tym — jak za chwilę pokażemy, istnieje alternatywa w postaci interfejsu IComparer. Porównania bezpośrednie Chociaż listing 3.4 pokazał, w jaki sposób można dokonać porównania, nie zawsze jesteśmy skłonni do wymuszenia na naszych typach implementacji IComparable lub siostrzanego interfejsu IEquatable, który dostarcza metodę o mocnym typie — Equals ´(T) — uzupełniającą istniejącą w każdym typie metodę Equals(object). Bez dodatkowej informacji, do jakiej dostęp dają nam te interfejsy, nie jesteśmy w stanie zrobić wiele więcej ponad wywołanie Equals(object), co w przypadku typów wartościowych spowoduje opakowanie wartości, z którą chcemy porównać wartość w bieżącym kontekście. (W praktyce istnieje kilka typów, które mogą nam pomóc w pewnych sytuacjach — dojdziemy do nich za moment). Kiedy parametr typu jest nieograniczony (nie zostały użyte żadne ograniczenia wobec niego), możesz stosować operatory == i !=, ale wyłącznie do porównywania wartości tego typu z null. Nie możesz porównać dwóch wartości typu T ze sobą nawzajem. Kiedy argumentem typu jest typ referencyjny, zostanie zastosowane zwykłe porównanie referencji. W przypadku kiedy argumentem podstawionym pod T jest nienullowalny typ wartościowy, wynikiem porównania z null będzie zawsze nierówność (w związku z czym porównanie może być usunięte przez kompilator JIT). Kiedy argumentem jest nullowalny typ wartościowy, porównanie będzie zachowywać się w tradycyjny sposób — nastąpi porównanie z wartością nullową danego typu5. (Nie przejmuj się, jeśli nie widzisz w tym jeszcze sensu — wszystko wyklaruje się, kiedy przeczytasz następny rozdział. Niestety niektóre cechy przeplatają się ze sobą tak mocno, że nie jestem w stanie opisać którejkolwiek z nich w sposób kompletny bez odwoływania się do innej). Kiedy parametr typu jest ograniczony do typów wartościowych, porównania == i != są zabronione. W przypadku ograniczenia do typów referencyjnych rodzaj wykonywanej operacji porównania zależy dokładnie od tego, do czego ograniczony został parametr typu. Jeżeli jest to tylko typ referencyjny, wykonywane są proste porównania referencyjne. Jeżeli występuje dodatkowe ograniczenie w postaci konieczności wydziedziczenia z typu przeciążającego operatory == i !=, w porównaniach są używane te przeciążone operatory. Uważaj jednak, bo dodatkowe przeciążenia, które przypadkiem stały się dostępne poprzez argument typu wyspecyfikowany w kodzie wywołującym, nie są stosowane. Dokumentuje to kolejny listing (3.5) z prostym typem referencyjnym i argumentem w formie typu string. 5
W chwili pisania tej książki kod generowany przez kompilator JIT dla porównań nieograniczonych wartości parametrów typu z null jest niesamowicie wolny dla wartościowych typów nullowalnych. Jeśli ograniczysz parametr T do typów nienullowalnych, a następnie porównasz wartość typu T? z null, czas wykonania tej operacji będzie znacznie krótszy. Jest to pole do dalszej optymalizacji JIT.
3.3.
Wkraczamy głębiej
113
Listing 3.5. Porównania referencyjne z wykorzystaniem operatorów == i != static bool AreReferencesEqual(T first, T second) where T : class Porównanie { referencji return first == second; } ... string name = "Jan"; Porównanie przy użyciu string intro1 = "Mam na imię " + name; przeciążonego operatora string intro2 = "Mam na imię " + name; porównania łańcuchów Console.WriteLine(intro1 == intro2); Console.WriteLine(AreReferencesEqual(intro1, intro2));
Chociaż string przeciąża operator == (co demonstruje ( ), wyświetlając True), to przeciążony operator nie jest używany w porównaniu ( ). Mówiąc prościej, kiedy kompilowany jest typ AreReferencesEqual, kompilator nie wie, że dostępne będą przeciążenia — zachowuje się trochę tak, jakby przekazane parametry były typu object. To zachowanie nie jest wyłączną domeną operatorów — kiedy kompilator napotka w trakcie kompilacji typ generyczny, wyszukuje wszystkie przeciążenia metod dla typu niezwiązanego. Nie ma mowy o rozważaniu możliwych wywołań metod dla specyficznych przeciążeń w trakcie wykonania. Na przykład wyrażenie Console.WriteLine(default(T)); zostanie zawsze rozwinięte do wywołania Console.WriteLine(object value) — nie będzie wywołania WriteLine(string value), kiedy T przypadkiem będzie typu string. Jest to podejście zbliżone do zwykłego przeciążania, które jest rozwiązywane w trakcie kompilacji, a nie w trakcie wykonania, chociaż Czytelnicy mający pewne doświadczenie we wzorcach C++ mogą dać się jeszcze zaskoczyć6. W przypadku porównywania wartości dwiema niezwykle użytecznymi klasami są EqualityComparer i Comparer — obie w przestrzeni nazw System.Collections. ´Generic. Implementują one, odpowiednio, IEqualityComparer i IComparer. Właściwość Default zwraca implementację, która na ogół wykonuje to, co trzeba, dla odpowiedniego typu. GENERYCZNE INTERFEJSY PORÓWNUJĄCE. Istnieją cztery podstawowe interfejsy do implementacji porównań. Dwa z nich — IComparer i ICompara ´ble — służą do porządkowania (sprawdzają, czy pierwsza wartość jest mniejsza, równa, czy większa od drugiej), a pozostałe dwa — IEqualityComparer ´ i IEquatable — porównują, stosując pewne kryteria, elementy pod względem równości oraz wykonują ich skróty (w sposób zgodny z tą samą ideą równości obiektów). Szeregując je w inny sposób, IComparer i IEqualityComparer są implementowane przez typy zdolne do porównania dwóch różnych wartości, podczas gdy instancje IComparable i IEquatable są zdolne do porównania samych siebie z inną wartością.
6
W rozdziale 14. zobaczymy, że typy dynamiczne dają możliwość rozwiązywania przeciążeń w czasie wykonania programu.
114
ROZDZIAŁ 3
Parametryzowanie typów i metod
Dowiedz się więcej na temat tych interfejsów, czytając dokumentację, i rozważ użycie ich (i innych podobnych typów — jak StringComparer) podczas wykonywania operacji porównywania. W naszym następnym przykładzie użyjemy interfejsu IEqualityCompa ´rer. Pełny przykład z porównywaniem — reprezentacja pary wartości Na koniec naszych rozważań na temat implementacji typów i metod generycznych (można z całą śmiałością powiedzieć, że był to poziom dla średnio zaawansowanych) prezentujemy kompletny przykład. Implementuje on użyteczny typ generyczny — Pair, który przechowuje dwie wartości, podobnie jak para klucz-wartość, ale bez żadnych oczekiwań co do związku pomiędzy nimi. .NET 4 I KROTKI. Wiele z funkcjonalności naszego przykładu można znaleźć w gotowych rozwiązaniach oferowanych przez .NET 4, w tym również struktury obsługujące różne ilości parametrów typu. Szukaj klas Tuple, Tuple itd. w przestrzeni nazw System. Oprócz implementacji właściwości dających dostęp do samych wartości nadpiszemy również metody Equals i GetHashCode, aby pozwolić instancjom naszego typu na prawidłowe zachowanie w sytuacji, kiedy zostaną one użyte jako klucze słownika. Kompletny przykład znajduje się na poniższym listingu (3.6). Listing 3.6. Klasa generyczna reprezentująca parę wartości using System; using System.Collections.Generic; public sealed class Pair : IEquatable { private static readonly IEqualityComparer FirstComparer = EqualityComparer.Default; private static readonly IEqualityComparer SecondComparer = EqualityComparer.Default; private readonly T1 first; private readonly T2 second; public Pair(T1 first, T2 second) { this.first = first; this.second = second; } public T1 First { get { return first; } } public T2 Second { get { return second; } } public bool Equals(Pair other) { return other != null && FirstComparer.Equals(this.First, other.First) && SecondComparer.Equals(this.Second, other.Second); }
3.3.
Wkraczamy głębiej
115
public override bool Equals(object o) { return Equals(o as Pair); } public override int GetHashCode() { return FirstComparer.GetHashCode(first) * 37 + SecondComparer.GetHashCode(second); } }
Listing 3.6 jest prosty. W polach odpowiedniego typu są przechowywane składniki klasy. Dostęp do nich dają proste właściwości tylko do odczytu. Implementujemy IEquatable, dzięki czemu udostępniamy na zewnątrz interfejs programistyczny o mocnym typie i unikamy niepotrzebnych sprawdzeń w czasie wykonywania programu. Kod sprawdzający równość i generujący skrót korzysta z domyślnych instancji porównujących dla naszych parametrów typu, dzięki czemu mamy automatycznie obsłużone wartości null, co upraszcza implementację7. Instancje interfejsów porównujących T1 i T2 zostały umieszczone w zmiennych statycznych głównie ze względu na ograniczenia w formatowaniu kodu narzucane przez rozmiar drukowanej strony. Będą one jednak dobrym punktem odniesienia do następnej sekcji. Gdybyśmy chcieli wyposażyć naszą klasę w funkcję sortowania, moglibyśmy zaimplementować interfejs IComparer i założyć w implementacji, że pierwszy element występuje przed drugim. Tego rodzaju typ jest dobrym przykładem pokazującym, jakiej funkcjonalności moglibyśmy potrzebować, bez konieczności implementowania jej do momentu, kiedy faktycznie będzie potrzebna. Mamy już naszą klasę Pair. A jak skonstruujemy jej instancję? Chwilowo możemy skorzystać z czegoś takiego: Pair pair = new Pair(10, "wartość");
Nie wygląda to szczególnie ładnie. Dobrze byłoby skorzystać z wnioskowania typów, ale ten mechanizm działa tylko dla metod, a my żadnej nie posiadamy. Jeżeli umieścimy metodę generyczną wewnątrz typu generycznego, nadal będziemy zmuszeni zacząć od wskazania argumentów typu. Rozwiązaniem jest użycie niegenerycznej klasy pomocniczej z metodą generyczną w środku, jak pokazuje to poniższy listing (3.7). Listing 3.7. Użycie typu niegenerycznego w połączeniu z metodą generyczną w celu skorzystania z wnioskowania typu public static class Pair { public static Pair Of(T1 first, T2 second) {
7
Formuła użyta do obliczenia skrótu, oparta na dwóch wynikach częściowych, pochodzi z książki Java. Efektywne Programowanie. Wydanie II Joshuy Blocha (Helion, Gliwice 2009). Nie gwarantuje ona dobrego rozkładu skrótów, ale moim zdaniem jest znacznie lepsza niż użycie XOR-a bit po bicie. Więcej szczegółów na ten temat, a także inne użyteczne ciekawostki znajdziesz we wspomnianej książce.
116
ROZDZIAŁ 3
Parametryzowanie typów i metod return new Pair(first, second); } }
Jeżeli czytasz tę książkę po raz pierwszy, zignoruj fakt, iż klasa została zadeklarowana jako statyczna. Dlaczego tak się stało, dowiemy się w rozdziale 7. Ważniejsza rzeczą jest to, że mamy niegeneryczną klasę z metodą generyczną. Dzięki temu możemy zamienić nasz poprzedni przykład na znacznie przyjemniejszy dla oka: Pair pair = Pair.Of(10, "wartość");
W C# 3 moglibyśmy nawet obyć się bez jawnego wskazywania typów zmiennej pair, ale nie uprzedzajmy faktów. Użycie tego typu niegenerycznych klas (lub klas częściowo generycznych, jeśli masz co najmniej dwa parametry typu i chcesz wywnioskować niektóre z nich, a pozostałe wskazać jawnie) jest użytecznym trikiem. W tym momencie zakończyliśmy analizę cech „pośrednich”. Zdaję sobie sprawę, że wszystko to wydaje się trochę skomplikowane na pierwszy rzut oka, ale zachęcam do wytrwałości. Ten dodatkowy stopień skomplikowania jest niczym w porównaniu do korzyściami, jakie osiągniemy. Z czasem użycie tych cech stanie się Twoją drugą naturą. Teraz nadszedł dobry moment, abyś przyjrzał się swojej własnej bibliotece klas i sprawdził, czy nie ma tam przypadkiem wzorców, które wciąż od nowa implementujesz wyłącznie ze względu na konieczność używania innych typów. Każdy obszerny temat sprawia, że nieustannie pojawiają się nowe elementy do nauczenia. Kolejny podrozdział przeprowadzi Cię przez jedno z najważniejszych zagadnień zaawansowanej wiedzy o typach generycznych. Jeśli uważasz, że jest to nieco ponad Twoje siły, możesz pominąć tę część i przejść bezpośrednio do podrozdziału 3.5, gdzie omawiamy niektóre z ograniczeń8 typów i metod generycznych. Materiał zawarty w następnym podrozdziale i tak warto zrozumieć. Jeśli jednak wszystko, co do tej pory czytałeś, jest dla Ciebie nowe, nie zaszkodzi go w tej chwili pominąć.
3.4.
Zaawansowane elementy typów generycznych Być może spodziewasz się, że w pozostałej części tego rozdziału zajmiemy się każdym możliwym aspektem typów i metod generycznych, o którym nie wspomnieliśmy do tej pory. Istnieje tak wiele zaułków i ciemnych korytarzy związanych z typami generycznymi, że jest to zwyczajnie niemożliwe. Ja w każdym razie nie chciałbym czytać o nich wszystkich, nie wspominając już o ich opisywaniu. Na nasze szczęście dobrzy ludzie z Microsoftu i ECMA zapisali wszystkie detale w specyfikacji języka, więc jeśli kiedykolwiek będziesz potrzebował zweryfikować jakąś niejasność, powinieneś zacząć od tych opracowań. Niestety, nie jestem w stanie wskazać jednej konkretnej części specyfikacji, która mówiłaby o typach generycznych. Ich obecność objawia się niemal wszędzie. Z drugiej strony warto się zastanowić, czy doprowadzenie swojego kodu do takiego stanu, że jego zrozumienie będzie wymagać analizy przypadków szczególnych w specyfikacji języka, ma jakikolwiek sens. Wskazane jest raczej dokonanie refaktory-
8
W tym miejscu chodzi o ograniczenia typów i metod generycznych jako całości, a nie o opisywaną wcześniej składnię języka pozwalającą wpływać na postać konkretnych parametrów typu — przyp. tłum.
3.4.
Zaawansowane elementy typów generycznych
117
zacji kodu do prostszej postaci, w przeciwnym wypadku każdy kolejny inżynier przypisany do utrzymywania tego projektu będzie musiał rozpoczynać swoją pracę od lektury najmniej przyjemnych części specyfikacji. Moim celem w tym podrozdziale jest zapoznanie Cię z wszystkimi detalami, które prawdopodobnie chciałbyś znać. Będę mówił więcej na temat środowiska wykonania niż na temat samej składni języka C# 2, chociaż wszystko to będzie miało oczywiście związek z programowaniem w C#. Zaczniemy od rozważenia statycznych elementów typów generycznych, włączając w to inicjalizację typu. Dalej zajmiemy się analizą tego, co właściwie dzieje się „pod maską”, chociaż będę się starał traktować w miarę lekko wszelkie szczegóły dotyczące istotnych efektów podjętych decyzji projektowych. Zobaczymy, co się dzieje, kiedy enumerujesz kolekcję generyczną, używając foreach w C# 2. Na koniec przyjrzymy się, jaki wpływ mają typy generyczne na mechanizm refleksji w .NET.
3.4.1. Pola i konstruktory statyczne Pola instancji typu należą do instancji, a pola statyczne do typu, w którym zostały zadeklarowane. Jeśli zadeklarujesz statyczne pole x w klasie SomeClass, niezależnie od tego, ile instancji tej klasy stworzysz lub ile klas potomnych utworzysz w oparciu o SomeClass, będzie istnieć dokładnie jedno pole SomeClass.x9. Ten scenariusz obowiązuje w C# 1, a jak ma się sprawa w przypadku typów generycznych? Odpowiedź brzmi: każdy z zamkniętych typów ma swój własny zestaw pól statycznych. Widzieliśmy to już na listingu 3.6, kiedy w polach statycznych umieściliśmy domyślne instancje interfejsów porównujących dla T1 i T2, ale ponownie przyjrzyjmy się temu bliżej, używając innego przykładu. Listing 3.8 implementuje typ generyczny ze statycznym polem. Ustawiamy wartość tego pola dla różnych typów zamkniętych, a następnie wyświetlamy ich zawartość, aby pokazać, że faktycznie są od siebie odseparowane. Listing 3.8. Dowód na to, że różne typy zamknięte posiadają niezależne pola statyczne class TypeWithField { public static string field; public static void PrintField() { Console.WriteLine(field + ": " + typeof(T).Name); }
} ... TypeWithField.field = "Pierwszy"; TypeWithField.field = "Drugi"; TypeWithField.field = "Trzeci";
9
Ściśle rzecz biorąc, będzie istnieć jedna instancja na aplikację. Na potrzeby tej sekcji założymy, że mamy do czynienia z pojedynczą aplikacją. W przypadku wielu aplikacji wykonywanych jednocześnie takie same zasady obowiązują typy generyczne i niegeneryczne. Spod obowiązujących reguł w obu przypadkach są wyłączone zmienne deklarowane z użyciem [ThreadStatic].
118
ROZDZIAŁ 3
Parametryzowanie typów i metod TypeWithField.PrintField(); TypeWithField.PrintField(); TypeWithField.PrintField();
Każdemu polu przypisujemy inną wartość, a następnie wyświetlamy ją wraz z nazwą argumentu użytego do stworzenia tego konkretnego typu zamkniętego. Oto wynik działania programu z listingu 3.8: Pierwszy: Int32 Drugi: String Trzeci: DateTime
Zatem podstawową zasadą jest: „jedno pole statyczne na każdy typ zamknięty”. To samo dotyczy statycznych inicjalizatorów i konstruktorów. Istnieje jednak możliwość posiadania jednego typu generycznego wewnątrz innego, a także typów z wieloma parametrami typów. Wydaje się, że jest to zdecydowanie bardziej skomplikowane, ale tak naprawdę proces ten przebiega właśnie tak, jak prawdopodobnie sobie to wyobrażasz. Przykład takiego zachowania pokazuje poniższy listing (3.9). Listing 3.9. Konstruktory statyczne z zagnieżdżonymi typami generycznymi public class Outer { public class Inner { static Inner() { Console.WriteLine("Outer.Inner", typeof(T).Name, typeof(U).Name, typeof(V).Name); } public static void DummyMethod() { } } } ... Outer.Inner.DummyMethod(); Outer.Inner.DummyMethod(); Outer.Inner.DummyMethod(); Outer.Inner.DummyMethod(); Outer.Inner.DummyMethod(); Outer.Inner.DummyMethod();
Pierwsze wywołanie DummyMethod() dla dowolnego typu spowoduje zainicjalizowanie typu. W tym momencie konstruktor statyczny wypisuje pewne informacje diagnostyczne. Każda unikalna lista argumentów typu liczy się jako inny typ zamknięty, zatem wynik działania kodu z listingu 3.9 wygląda następująco: Outer.Inner Outer.Inner Outer.Inner
3.4.
Zaawansowane elementy typów generycznych
119
Outer.Inner Outer.Inner
Tak jak w przypadku typów niegenerycznych, konstruktor statyczny dowolnego typu zamkniętego jest wywoływany tylko raz. Właśnie z tego powodu ostatni wiersz z listingu 3.9 nie tworzy szóstego wiersza wyniku — statyczny konstruktor dla Outer. ´Inner został wykonany wcześniej i wyprodukował drugi wiersz wyniku. Dla ścisłości: gdybyśmy mieli niegeneryczną klasę PlainInner w klasie Outer, nadal istniałby jeden możliwy typ Outer.PlainInner na każdy zamknięty typ Outer, zatem Outer.PlainInner różniłby się od Outer.PlainInner i każdy z nich posiadałby swój zestaw pól statycznych. Wiemy już, co wpływa na ukonstytuowanie się oddzielnego zestawu pól statycznych. Zastanówmy się, jakie mogą być efekty takiego działania z punktu widzenia wygenerowanego kodu natywnego. Dodam, że nie jest tak źle, jak może Ci się wydawać...
3.4.2. Jak kompilator JIT traktuje typy generyczne Zadaniem kompilatora JIT jest przekonwertowanie kodu IL typu generycznego na kod natywny, który może być uruchomiony na danej platformie. Do pewnego stopnia nie powinno nas interesować, jak dokładnie kompilator wykonuje swoje zadanie — nie stanowiłoby dla nas wielkiej różnicy, pomijając pamięć i czas procesora, gdyby JIT przyjął najprostszą z możliwych strategii i wygenerował oddzielny kod natywny dla każdego możliwego typu, tak jakby każdy z nich nie miał nic wspólnego z dowolnym innym typem. Warto jednak sprawdzić, co się dokładnie dzieje, choćby po to, aby się przekonać, jak pomysłowi byli autorzy kompilatora JIT. Zacznijmy od prostej sytuacji z pojedynczym parametrem typu — dla wygody niech będzie to List. Kompilator JIT tworzy oddzielny kod natywny dla każdego zamkniętego typu, którego argumentem typu jest typ wartościowy — int, long, Guid itp. Kod natywny jest za to współdzielony dla wszystkich typów zamkniętych, które używają typów referencyjnych, takich jak string, Stream i StringBuilder. Może tak zrobić, ponieważ wszystkie referencje mają taki sam rozmiar (jest on zależny od platformy — CLR-32 lub CLR-64, ale w ramach tej samej platformy niezmienny). Lista referencji będzie miała zawsze taki sam rozmiar, niezależnie od tego, na co wskazują te referencje. Ilość pamięci potrzebna na stosie do zapamiętania referencji również będzie taka sama. Można zastosować takie same mechanizmy optymalizacji rejestrów, niezależnie od użytego typu. Tę listę moglibyśmy ciągnąć jeszcze bardzo długo. Tak jak powiedzieliśmy wcześniej (w sekcji 3.4.1), każdy typ nadal posiada własne pola statyczne, ale korzysta ze wspólnego kodu. Oczywiście JIT wykazuje się pewnym „lenistwem” — kod dla List jest generowany dopiero wtedy, kiedy jest faktycznie potrzebny, a po wygenerowaniu jest zapamiętywany dla przypadków użycia List w przyszłości. Teoretycznie możliwe jest wykorzystanie tego samego kodu dla niektórych typów wartościowych. Kompilator JIT musiałby być w takich sytuacjach niezwykle ostrożny, nie tylko ze względu na rozmiar instancji w pamięci, ale również z powodu mechanizmu odśmiecania — musiałaby istnieć możliwość szybkiego identyfikowania obszarów struktury będących aktywnymi referencjami. Uwzględniając te warunki, typy wartościowe o tym samym rozmiarze i takim samym obrazie w pamięci (z punktu widzenia odśmiecania) mogłyby współdzielić kod. Na etapie powstawania niniejszej książki
120
ROZDZIAŁ 3
Parametryzowanie typów i metod
możliwość ta miała tak niski priorytet, że nie została zaimplementowana, i prawdopodobnie sytuacja ta nieprędko się zmieni. Tak duży poziom szczegółowości jest domeną akademicką, ale ma on również mały wpływ na wydajność w związku z większą ilością kodu, jaką musi kompilować JIT. Z drugiej strony korzyści wydajnościowe oferowane przez typy generyczne mogą być niewyobrażalne, a wszystko to właśnie dzięki możliwościom tworzenia kodu dla różnych typów przez ten kompilator. Weźmy dla przykładu List. W .NET 1.1 dodawanie pojedynczych bajtów do kolekcji typu ArrayList wymagało opakowania każdego z nich, a następnie zapamiętania referencji do opakowanej wartości. Użycie List nie nakłada takich wymogów — List ma pole typu T[], będące odpowiednikiem object[] w ArrayList. Dzięki temu tablica ma odpowiedni typ i zajmuje właściwą ilość miejsca w pamięci. Zatem List przechowuje swoje elementy w tablicy byte[] (co pod wieloma względami upodabnia ją do typu MemoryStream). Rysunek 3.3 pokazuje instancje dwóch typów: ArrayList i List, z których każda zawiera sześć jednakowych wartości. Obie tablice pozwalają na dokładanie elementów, obie mają pewien bufor i w miarę możliwości mogą go powiększyć.
Rysunek 3.3. Przedstawienie w sposób graficzny, dlaczego List zajmuje o wiele mniej miejsca niż ArrayList
W tym konkretnym przypadku różnica w wydajności jest niesamowita. Przyjrzyjmy się najpierw tablicy ArrayList, zakładając, że jesteśmy na platformie CLR-3210. Każdy z bajtów będzie wymagać 8-bajtowego narzutu związanego z przechowującym go obiektem plus 4 bajty (1 bajt zaokrąglony do granicy słowa) na dane. Do tego dochodzą wszystkie referencje, z których każda zajmuje 4 bajty. Za przechowanie pojedynczego bajta „płacimy” szesnastoma bajtami. Pozostaje jeszcze miejsce zajmowane przez referencje w buforze. 10
Ta sama sytuacja na platformie CLR-64 zwiększa narzut pamięciowy.
3.4.
Zaawansowane elementy typów generycznych
121
Porównaj ten stan rzeczy z typem List. Każdy bajt w liście zajmuje pojedynczy bajt w tablicy elementów. Pozostaje pamięć zarezerwowana na potrzeby nowych elementów, ale zużywamy tylko po jednym bajcie na każdy taki element. Zyskujemy nie tylko miejsce, ale również szybkość wykonania. Nie potrzebujemy czasu na przydzielenie miejsca „pudełku”, sprawdzanie typu związanego z operacjami opakowywania i odpakowywania czy zbieranie śmieci pozostałych po odpakowaniu wartości. Nie musimy schodzić do poziomu środowiska wykonania, aby znaleźć rzeczy, które dzieją się w sposób niezauważalny dla nas. C# od zawsze ułatwiał życie przez skróty syntaktyczne. W kolejnej sekcji przyjrzymy się znanemu już przykładowi — iteracji przy użyciu foreach — ale tym razem z domieszką typów generycznych.
3.4.3. Iteracja przy użyciu typów generycznych Jedną z najczęściej wykonywanych operacji na kolekcjach jest iterowanie po wszystkich jej elementach. Najprostszym sposobem wykonania tej operacji jest użycie pętli foreach. W pierwszej wersji C# wymagało to implementowania przez kolekcję interfejsu System.Collections.IEnumerable lub posiadania podobnej metody — GetEnumerator()— która zwracała typ z metodą MoveNext() i właściwością Current. Właściwość Current nie musiała być typu object i właśnie z tego powodu obowiązywały wszystkie powyższe reguły, które na pierwszy rzut oka wydają się dziwne. Jak widać, posiadając odpowiedni typ danych, nawet w C# 1 można było uniknąć operacji opakowywania i odpakowywania podczas iteracji. W C# 2 zadanie zostało uproszczone przez wprowadzenie dodatkowych reguł dla foreach. Zezwolono na użycie interfejsu System.Collections.Generic.IEnumerable razem z jego partnerem IEnumerator. Są to odpowiedniki starszych interfejsów iteratora, a ich użycie jest preferowane w stosunku do niegenerycznych wersji. Oznacza to, że jeśli iterujesz po generycznej kolekcji typu wartościowego — niech będzie to List — operacja opakowywania nie jest wykonywana. W przypadku starego interfejsu, nawet gdybyśmy uniknęli kosztu opakowywania podczas zapisywania elementów w liście, nadal musielibyśmy opakowywać przy dostępie do nich w pętli foreach! Wszystkie te operacje są wykonywane „pod maską” — od Ciebie wymaga się jedynie użycia pętli foreach i odpowiedniego typu dla zmiennej iteracyjnej. To jednak nie koniec historii. W rzadkich przypadkach, kiedy będziesz musiał zaimplementować iterację po swoim własnym typie, przekonasz się, że IEnumerable rozszerza interfejs IEnumerable, co oznacza, że będziesz musiał zaimplementować dwie różne metody: IEnumerator GetEnumerator(); IEnumerator GetEnumerator();
Czy widzisz problem? Metody różnią się jedynie typem zwracanym, a zasady przeciążania metod w C# nie pozwalają w normalnych warunkach na napisanie dwóch takich metod. Podobną sytuację spotkaliśmy w sekcji 2.2.2 i teraz możemy użyć podobnego obejścia. Jeżeli zaimplementujesz IEnumerable poprzez jawną implementację interfejsu, nie będziesz mógł w „normalny” sposób zaimplementować IEnumerable. Na szczęście, ponieważ IEnumerator rozszerza IEnumerator, możesz użyć tej samej wartości w obu metodach i zaimplementować wersję niegeneryczną przez wywołanie wersji
122
ROZDZIAŁ 3
Parametryzowanie typów i metod
generycznej. Teraz jednak musisz zaimplementować IEnumerator i szybko trafiasz na problem, tym razem z właściwością Current. Poniższy listing (3.10) przedstawia kompletny przykład implementacji klasy enumerowalnej, która zawsze iteruje po wartościach typu całkowitego od 0 do 9. Listing 3.10. Iterator generyczny dla liczb od 0 do 9 class CountingEnumerable : IEnumerable { public IEnumerator GetEnumerator() { return new CountingEnumerator(); }
Niejawna implementacja IEnumerable
Jawna implementacja IEnumerable
IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); } } class CountingEnumerator : IEnumerator { int current = -1; public bool MoveNext() { current++; return current < 10; } public int Current { get { return current; } }
Niejawna implementacja IEnumerator.Current
object IEnumerator.Current { get { return Current; } }
Jawna implementacja IEnumerator.Current
public void Reset() { throw new NotSupportedException(); } public void Dispose() { } } ... CountingEnumerable counter = new CountingEnumerable(); foreach (int x in counter) { Console.WriteLine(x); }
Dowód na działanie typu
3.4.
Zaawansowane elementy typów generycznych
123
Oczywiście użyteczność tego przykładu, z punktu widzenia zwracanego wyniku, jest znikoma, ale pokazuje on pewne „wyboje”, przez które trzeba przejść, aby poprawnie zaimplementować iterację w sposób generyczny — przynajmniej wtedy, kiedy robisz to długą metodą i bez rzucania wyjątków przy próbach odwołania się do Current w nieodpowiednim momencie. Jeśli uważasz, że listing 3.10 jest trochę duży jak na wypisywanie liczb od 0 do 9, nie mogę zrobić nic więcej ponad przyznanie Ci racji — gdybyśmy chcieli iterować po bardziej użytecznych wartościach, byłoby jeszcze więcej kodu. Na szczęście w rozdziale 6. zobaczymy, że w pewnych sytuacjach C# 2 potrafi odsunąć od nas sporą część pracy związaną z iteratorami. Pokazałem tutaj pełny przykład po to, abyś mógł zobaczyć malutkie niedociągnięcia, celowo wprowadzone przez projektantów przy rozszerzaniu IEnumerable przez IEnumerable. Nie sugeruję bynajmniej, że była to zła decyzja — pozwala ona na przekazanie dowolnej wartości IEnume ´rable do metody napisanej w C# 1 poprzez parametr IEnumerable. Dzisiaj nie ma to już takiego wielkiego znaczenia jak jeszcze w 2005 roku, ale nadal stanowi użyteczną ścieżkę przekazywania danych z nowszej wersji kodu do starszej. Potrzebujemy jedynie dwukrotnie skorzystać z triku jawnej implementacji interfejsu — pierwszy raz dla IEnumerable.GetEnumerator ( ) i drugi dla IEnumerator. ´Current ( ). Oba przypadki wywołują swoje generyczne odpowiedniki (odpowiednio ( ) i ( )). IEnumerator rozszerza IDisposable, zatem musimy dostarczyć implementację metody Dispose. Wyrażenie foreach w C# 1 wywołuje Dispose na iteratorze, jeżeli ten implementuje IDisposable. W C# 2, jeśli kompilator wykryje, że zaimplementowałeś IEnumerable, stworzy bezwarunkowe wywołanie Dispose na końcu pętli (w bloku finally). Wiele iteratorów nie musi w praktyce niczego zwalniać, ale warto wiedzieć, że kiedy zwalnianie jest potrzebne, najczęściej stosowana metoda przechodzenia po kolekcji — foreach ( ) — automatycznie dokłada odpowiednie wywołanie. Ten mechanizm jest wykorzystywany najczęściej do zwalniania zasobów po zakończonej iteracji — przykładem może być iterator chodzący po wierszach pliku, który na końcu zwalnia uchwyt do pliku. Przejdziemy teraz od wydajności czasu kompilacji do elastyczności w czasie wykonywania. Naszym ostatnim tematem jest refleksja. Mechanizm ten potrafi być podchwytliwy nawet w .NET 1.0/1.1, a po wprowadzeniu typów generycznych sytuacja staje się jeszcze ciekawsza. Chociaż środowisko dostarcza wszystkiego, czego potrzebujemy (z odrobiną pomocnej składni języka C# 2), zrozumienie tego tematu może być trudne. Proponuję zabrać się do niego bez zbędnego pośpiechu.
3.4.4. Refleksja i typy generyczne Refleksja jest wykorzystywana przez programistów do różnych celów. W czasie wykonania możesz jej użyć do introspekcji obiektów i przeprowadzenia prostej formy bindowania danych. Możesz dokonać inspekcji katalogu pełnego modułów w celu znalezienia implementacji interfejsu plug-inu. Możesz napisać plik dla środowiska „odwracania kontrolki” (zobacz http://mng.bz/xc3J) w celu załadowania i dynamicznej konfiguracji komponentów Twojej aplikacji. Ze względu na tak dużą różnorodność sposobów wykorzystania refleksji nie będę się skupiał na żadnym konkretnym zastosowaniu, ale postaram się dostarczyć ogólne wytyczne odnośnie do tych zadań. Zaczniemy od rozszerzeń operatora typeof.
124
ROZDZIAŁ 3
Parametryzowanie typów i metod
Używanie typeof z typami generycznymi Refleksja sprowadza się do analizowania obiektów i sprawdzania ich typów. Zatem pierwszym krokiem, jaki trzeba wykonać, jest uzyskanie referencji do obiektu typu System.Type, który daje dostęp do wszystkich informacji na temat typu. Do uzyskania takiej informacji dla typów znanych w czasie kompilacji można użyć operatora typeof, który swoim zasięgiem obejmuje również typy generyczne. Istnieją dwa sposoby użycia typeof w połączeniu z typami generycznymi — pierwszy wydobywa definicję typu generycznego (inaczej mówiąc, niezwiązany typ generyczny), a drugi konkretny typ skonstruowany. Aby uzyskać definicję typu generycznego — typu bez wskazywania jakichkolwiek argumentów dla parametrów typu — weź deklarację typu i usuń z niej wszystkie parametry typu, pozostawiając jedynie przecinki. Dla typu skonstruowanego musisz wskazać argumenty typu w taki sam sposób, jakbyś deklarował zmienną typu generycznego. Oba przypadki zostały zademonstrowane na listingu 3.11. Przykład korzysta z metody generycznej, dzięki czemu widzimy użycie typeof w połączeniu z parametrem typu (podobną rzecz widzieliśmy na listingu 3.8). Listing 3.11. Użycie operatora typeof w połączeniu z parametrami typu static internal void DemonstrateTypeof() { Console.WriteLine(typeof(X));
Wyświetlenie parametru typu metody
Console.WriteLine(typeof(List)); Console.WriteLine(typeof(Dictionary)); Console.WriteLine(typeof(List)); Console.WriteLine(typeof(Dictionary));
Wyświetlenie typów zamkniętych (pomimo użycia parametru typu)
Wyświetlenie typów zamkniętych Console.WriteLine(typeof(List)); Console.WriteLine(typeof(Dictionary));
} ... DemonstrateTypeof();
Większość kodu z listingu 3.11 zachowuje się zgodnie z Twoimi oczekiwaniami, ale warto zwrócić uwagę na dwie rzeczy. Zobacz, w jaki sposób jest pobierana definicja typu generycznego dla Dictionary. Przecinek w ostrych nawiasach jest potrzebny, aby powiedzieć kompilatorowi, żeby szukał typu z dwoma parametrami typu (pamiętaj, że może istnieć wiele typów o tej samej nazwie, pod warunkiem że każdy z nich ma inną liczbę tych parametrów). Według tej samej zasady definicję typu generycznego dla MyClass otrzymasz, używając typeof(MyClass). Liczba parametrów jest określona w kodzie IL (oraz w pełnych nazwach typów środowiska) przez umieszczenie znaku lewego apostrofu po pierwszej części nazwy typu, a następnie liczby parametrów. Parametry typu są następnie wymienione w nawiasach kwadratowych, w przeciwieństwie do ostrych nawiasów, jakich używamy w kodzie. Dla przykładu drugi wiersz kończy się wyrażeniem List`1[T], co świadczy o tym, że jest jeden parametr typu, a wiersz trzeci wyrażeniem Dictionary`2[TKey,TValue].
3.4.
Zaawansowane elementy typów generycznych
125
Poza tym za każdym razem, kiedy w kodzie zostaje użyty parametr typu (X), w czasie wykonania w jego miejsce jest podstawiany argument typu. Dlatego wiersz ( ) wyświetla System.Int32, a nie, czego mogłeś oczekiwać, List`1[X]11. Innymi słowy, typ otwarty w czasie kompilacji może zostać zamknięty w czasie wykonania. Takie zachowanie jest bardzo mylące. Powinieneś o tym pamiętać, kiedy otrzymywane wyniki różnią się od tego, czego oczekujesz. Wydobycie otwartego, skonstruowanego typu w czasie wykonania wymaga trochę więcej wysiłku. Przykład znajdziesz w MSDN-owym opisie właściwości Type.IsGenericType (http://mng.bz/9W6O). Oto wynik działania programu z listingu 3.11: System.Int32 System.Collections.Generic.List`1[T] System.Collections.Generic.Dictionary`2[TKey,TValue] System.Collections.Generic.List`1[System.Int32] System.Collections.Generic.Dictionary`2[System.String,System.Int32] System.Collections.Generic.List`1[System.Int64] System.Collections.Generic.Dictionary`2[System.Int64,System.Guid]
Mając dostęp do obiektu reprezentującego typ generyczny, otwiera się przed nami wiele możliwych dróg dalszego działania. Nadal są dostępne operacje, które wykonywaliśmy wcześniej (wyszukiwanie elementów składowych typu, tworzenie instancji itd.) — chociaż niektóre z nich nie mają zastosowania dla definicji typów generycznych — ale pojawiają się również nowe, które pozwalają odkryć generyczną naturę typu. Metody i właściwości typu System.Type System.Type ma zbyt wiele właściwości i metod, abyśmy mogli przyjrzeć się im wszystkim szczegółowo, jednak dwie z nich są szczególnie istotne: GetGenericTypeDefinition i MakeGenericType. Ich działanie jest zupełnie przeciwne — pierwsza operuje na skonstruowanym typie, pobierając z niego definicję typu generycznego, druga na definicji typu generycznego i zwraca skonstruowany typ. Być może lepiej by było, gdyby druga z metod została nazwana ConstructType, MakeConstructedType lub jeszcze inaczej, ale ze słowem construct lub constructed w nazwie. Niestety utknęliśmy z taką nazwą, jaką mamy. Tak jak w przypadku zwykłych typów, istnieje tylko jeden obiekt klasy Type dla danego typu, zatem dwukrotne wywołanie MakeGenericType z tymi samymi argumentami spowoduje dwukrotne zwrócenie tej samej referencji. Na tej samej zasadzie wywołanie GetGenericTypeDefinition na obiektach stworzonych w oparciu o tę samą definicję typu generycznego da ten sam wynik, nawet jeśli skonstruowane typy są różne (np. List i List). Kolejna metoda, istniejąca już w środowisku .NET 1.1, którą warto poznać, to Type. ´GetType(string). Jest ona związana z metodą Assembly.GetType(string). Obie stanowią dynamiczny odpowiednik operatora typeof. Mógłbyś się spodziewać, że wynik działania każdego wiersza z listingu 3.11 można wstawić do metody GetType na odpowiednim module (ang. assembly), niestety życie nie jest takie proste. Nie ma problemu
11
Z pełną świadomością odstąpiłem w tym miejscu od konwencji nazywania parametrów typu literą T, abyś mógł zobaczyć różnicę pomiędzy T w deklaracji List i X w deklaracji naszej metody.
126
ROZDZIAŁ 3
Parametryzowanie typów i metod
w przypadku zamkniętych typów skonstruowanych — wtedy wystarczy umieścić argument w nawiasach kwadratowych. Dla definicji typów generycznych trzeba usunąć nawiasy kwadratowe, w przeciwnym wypadku GetType będzie sądzić, że masz na myśli typ tablicowy. Wszystkie te operacje pokazuje w działaniu listing 3.12. Listing 3.12. Różne metody pobierania typu obiektów na podstawie typów generycznych i skonstruowanych string listTypeName = "System.Collections.Generic.List`1"; Type defByName = Type.GetType(listTypeName); Type closedByName = Type.GetType(listTypeName + "[System.String]"); Type closedByMethod = defByName.MakeGenericType(typeof(string)); Type closedByTypeof = typeof(List); Console.WriteLine(closedByMethod == closedByName); Console.WriteLine(closedByName == closedByTypeof); Type defByTypeof = typeof(List); Type defByMethod = closedByName.GetGenericTypeDefinition(); Console.WriteLine(defByMethod == defByName); Console.WriteLine(defByName == defByTypeof);
Wynikiem działania listingu 3.12 jest czterokrotne wyświetlenie prawdy (True), co stanowi dowód na to, że niezależnie od tego, jak uzyskamy referencję do danego typu obiektu, będzie to zawsze jeden i ten sam obiekt. Tak jak wspomniałem wcześniej, Type oferuje wiele metod i właściwości, takich jak GetGenericArguments, IsGenericTypeDefinition czy IsGenericType. Najlepszym sposobem na dalsze zgłębianie tego tematu jest przyjrzenie się dokumentacji właściwości IsGenericType. Refleksja metod generycznych Metody generyczne mają również podobny, chociaż mniejszy zestaw właściwości i metod. Demonstruje to listing 3.13, w którym metoda generyczna jest wywoływana przez refleksję. Listing 3.13. Pobieranie i wywoływanie metody generycznej przez refleksję public static void PrintTypeParameter() { Console.WriteLine (typeof(T)); } ... Type type = typeof(Snippet); MethodInfo definition = type.GetMethod("PrintTypeParameter"); MethodInfo constructed; constructed = definition.MakeGenericMethod(typeof(string)); constructed.Invoke(null, null);
3.4.
Zaawansowane elementy typów generycznych
127
W pierwszej kolejności wydobywamy definicję metody generycznej, a następnie tworzymy ją, używając metody MakeGenericMethod. Podobnie jak w przypadku typów, moglibyśmy użyć innego sposobu, ale w przeciwieństwie do Type.GetType, w wywołaniu metody Type.GetMethods nie ma możliwości wyspecyfikowania skonstruowanej metody. Poza tym środowisko ma problem, jeśli istnieją metody przeciążone wyłącznie przez liczbę parametrów typu. Żeby obejść ten problem, musiałbyś wywołać Type.GetMethods, a następnie znaleźć tę metodę, której szukasz, przeszukując wszystkie z nich. Po otrzymaniu skonstruowanej metody uruchamiamy ją. Ponieważ wywołujemy metodę statyczną, która nie ma żadnych normalnych argumentów wywołania, w naszym przykładzie są nimi dwie wartości null. Zgodnie z naszymi oczekiwaniami wynikiem działania jest System.String. Zwróć uwagę, że metody wydobyte z definicji typów generycznych nie mogą być wywołane bezpośrednio — musisz pobrać je z typu skonstruowanego. Odnosi się to zarówno do metod generycznych, jak i niegenerycznych. RATUNEK ZE STRONY C# 4. Jeżeli to zachowanie wydaje Ci się niechlujne, zgadzam się z Tobą. Na szczęście w wielu przypadkach pomoc niosą typy dynamiczne C#, redukując narzut pracy związany z refleksją typów generycznych. Pomoc nie jest dostępna we wszystkich przypadkach, dlatego warto mieć świadomość funkcjonowania przedstawionego powyżej kodu, jednak tam, gdzie jest dostępna, daje doskonałe wyniki. Typom dynamicznym przyjrzymy się z bliska w rozdziale czternastym. MethodInfo oferuje całe mnóstwo metod i właściwości — ich lekturę proponuję rozpocząć od właściwości IsGenericMethod w dokumentacji MSDN. Zakładam, że informacje zawarte w tej sekcji są wystarczające, abyś mógł samodzielnie podjąć zgłębianie tego zagadnienia, oraz wskazują dodatkowy stopień skomplikowania, którego prawdopodobnie nie spodziewałeś się, rozpoczynając zabawę z dostępem do typów i metod generycznych poprzez refleksję. Na tym kończymy część poświęconą cechom zaawansowanym. Przypominam, że przedstawiony materiał w żadnym przypadku nie wyczerpuje tematu, jednak większość programistów najprawdopodobniej nie potrzebuje wnikać głębiej w tajniki refleksji. Biorąc pod uwagę fakt, że wraz z wchodzeniem w coraz większe szczegóły czytanie dokumentacji staje się trudniejsze, mam nadzieję, że dla swojego własnego dobra zaliczasz się do tego obozu. Pamiętaj, że o ile nie programujesz samodzielnie i wyłącznie dla siebie, nie tylko Ty będziesz pracował nad swoim kodem. Jeśli będziesz potrzebować rozwiązań bardziej złożonych niż prezentowane tutaj, możesz z dużą pewnością założyć, że ktokolwiek będzie czytał Twój kod, nie zrozumie go bez Twojej pomocy. Z drugiej strony, jeśli zauważysz, że Twoi koledzy nie znają niektórych z przedstawionych do tej pory tematów, wyślij ich, proszę, do najbliższej księgarni... Ostatnia część tego rozdziału omawia ograniczenia typów generycznych w C#, a także podobne cechy dostępne w innych językach programowania.
128
ROZDZIAŁ 3
Parametryzowanie typów i metod
3.5.
Ograniczenia typów generycznych C# i innych języków Bez wątpienia typy generyczne składają się w dużej mierze na łatwość wyrażania, bezpieczeństwo typów oraz wydajność języka C#. Cecha ta została zaprojektowana, aby radzić sobie z większością zadań, do których programiści na ogół stosowali wzorce, ale bez towarzyszących im skutków ubocznych. Nie oznacza to wcale, że typy generyczne nie mają żadnych ograniczeń. Istnieją pewne problemy, które wzorce C++ rozwiązują z łatwością, a których nie da się rozwiązać przy użyciu typów generycznych C#. Podobnie w Javie, której typy generyczne są ogólnie mniej funkcjonalne od typów generycznych C#, istnieją pewne koncepcje, które można wyrazić w Javie, a nie da się tego zrobić w C#. W tej sekcji omówimy najczęściej spotykane słabości, a ja postaram się dokonać skrótowego porównania implementacji typów generycznych C#/.NET z wzorcami C++ i typami generycznymi Javy. Warto zwrócić uwagę, że wymienione słabości języka w żadnym wypadku nie wskazują, iż można było ich uniknąć. Moją intencją nie jest stwierdzenie, że można było zrobić coś lepiej! Projektanci języka i platformy musieli wyważyć funkcjonalność języka ze stopniem jego skomplikowania (uwzględniając po drodze ograniczenie czasowe na zaprojektowanie i implementację całości). Pracując z typami generycznymi, z bardzo dużym prawdopodobieństwem nie napotkasz problemów, a jeśli tak się zdarzy, będziesz w stanie pokonać trudności dzięki wskazówkom z tego podrozdziału. Zaczniemy od odpowiedzi na pytanie, które wcześniej czy później stawia każdy programista: dlaczego nie mogę przekonwertować List na postać List?
3.5.1. Brak wariancji typów generycznych W sekcji 2.2.2 oglądaliśmy kontrawariancję tablic — możliwość przeglądania tablicy typu referencyjnego jako tablicy swojego typu bazowego lub tablicy implementowanych interfejsów. Ta idea ma dwie możliwe postacie, określane jako kowariancja i kontrawariancja lub wspólnie wariancja. Typy generyczne nie dają takiej możliwości — są inwariancyjne. Ma to na celu zachowanie bezpieczeństwa typu, ale czasem potrafi być irytujące. Na początku pragnę wyjaśnić jedną rzecz — C# 4 poprawia do pewnego stopnia wariancję typów generycznych. W mocy pozostaje jednak nadal wiele z ograniczeń, o których będziemy mówić za chwilę. Tę sekcję można traktować jako wprowadzenie do idei wariancji. Na czym polega pomoc C# 4, dowiemy się w rozdziale 13., ale wiele z najbardziej wyrazistych przykładów wariancji typów generycznych bazuje na nowych cechach C# 3, włączając w to LINQ. Wariancja sama w sobie jest całkiem złożonym zagadnieniem, warto zatem poczekać z jej analizą do momentu, kiedy będziesz się czuł komfortowo z pozostałą częścią C# 2 i 3. Dla zachowania czytelności nie będę w tej sekcji wskazywał każdego miejsca, które różni się jedynie odrobinę od wersji C# 4... Wszystko stanie się jasne w rozdziale 13.
3.5.
Ograniczenia typów generycznych C# i innych języków
129
Dlaczego typy generyczne nie obsługują kowariancji? Załóżmy, że mamy dwie klasy, Turtle i Cat, obie będące potomkami abstrakcyjnej klasy Animal. W poniższym przykładzie kod tablicy (po lewej stronie) jest poprawny w C# 2, a kod generyczny (po prawej) — nie: Kod poprawny (w czasie kompilacji) Animal[] animals = new Cat[5]; animals[0] = new Turtle();
Kod niepoprawny List animals = new List(); animals.Add(new Turtle());
Kompilator nie ma problemu z drugim wierszem w obu przypadkach, ale pierwszy z nich po prawej stronie spowoduje następujący błąd kompilatora: error CS0029: Cannot implicitly convert type 'System.Collections.Generic.List' to 'System.Collections.Generic.List'
Takie zachowanie zostało celowo wprowadzone przez projektantów środowiska i języka. Na usta ciśnie się pytanie, dlaczego zostało to zabronione? Odpowiedź jest w drugim wierszu. Nie ma w nim nic, co powinno wzbudzić nasze podejrzenia. Przecież List ´ posiada metodę o sygnaturze void Add(Animal value) — powinna zatem istnieć możliwość dodania referencji klasy Turtle do dowolnej listy zwierząt. W rzeczywistości zmienna animals odnosi się jednak do Cat[] (w kodzie po lewej stronie) lub List (po prawej) i w obu przypadkach do środka można wstawiać wyłącznie referencje klasy Cat lub klasy potomnej. Chociaż wersja z tablicą skompiluje się, nie będzie działać po uruchomieniu. Takie zachowanie zostało uznane przez projektantów typów generycznych za zachowanie gorsze niż błąd podczas kompilacji, co ma sens — głównym celem typów statycznych jest znajdowanie błędów, zanim kod zostanie uruchomiony. DLACZEGO TABLICE SĄ KOWARIANCYJNE? Skoro odpowiedzieliśmy, dlaczego typy generyczne są inwariancyjne, następne oczywiste pytanie brzmi: „Dlaczego tablice są kowariancyjne?”. Zgodnie z Common Language Infrastructure Annotated Standard (J.S. Miller i S. Ragsdale, Addison-Wesley Professional, Indianapolis 2003) w pierwszej edycji projektanci chcieli sięgnąć do jak najszerszej rzeszy użytkowników, co oznaczało umożliwienie kompilacji kodu pochodzącego z języka Java. Innymi słowy, .NET ma tablice kowariancyjne, ponieważ Java ma tablice kowariancyjne — mimo że ten element jest uznawany za wadę Javy. Wiesz już, dlaczego jest tak, jak jest. Dlaczego powinieneś się tym przejmować i jak możesz ominąć to ograniczenie? Gdzie mogłaby się przydać kowariancja? Podany przykład z listą jest najwyraźniej problematyczny. Możemy dodawać elementy do listy, ale w tym momencie tracimy bezpieczeństwo typu. Operacja dodania jest przykładem użycia wartości jako danych wejściowych do interfejsu programistycznego (kod wywołujący dostarcza wartości). Co by się stało, gdybyśmy ograniczyli się wyłącznie do pobierania wartości? Oczywistym przykładem tego jest IEnumerator i (przez
130
ROZDZIAŁ 3
Parametryzowanie typów i metod
skojarzenie) IEnumerable. W rzeczy samej, są to niemal kanoniczne przykłady kowariancji typów generycznych. Razem opisują sekwencję wartości — wszystko, co o nich wiemy, to to, że każda z nich będzie kompatybilna z T w taki sposób, iż będziesz mógł napisać: T currentValue = iterator.Current;
Jest to wykorzystanie zwykłej idei kompatybilności — byłoby zupełnie zrozumiałe, gdyby na przykład IEnumerator zwracał referencje instancji typu Cat lub Turtle. Ponieważ nie istnieje sposób na „wepchnięcie” wartości o nieprawidłowym typie do konkretnej kolekcji, chcielibyśmy traktować IEnumerator jako IEnumerator. Oto przykład, gdzie takie zachowanie może się okazać użyteczne. Załóżmy, że istnieje stworzony przez nas typ reprezentujący kształt w formie interfejsu (IShape). Przyjmijmy również, że istnieje inny interfejs, IDrawing, reprezentujący rysunek stworzony z kształtów. Chcemy stworzyć dwa konkretne typy rysunków — MondrianDrawing (zbudowany z prostokątów) i SeuratDrawing (zbudowany z okręgów)12. Hierarchie klas używane w przykładzie zostały przedstawione na rysunku 3.4.
Rysunek 3.4. Schemat hierarchii klas z naszego przykładu
Oba interfejsy muszą implementować interfejs IDrawing, zatem muszą udostępnić właściwość o sygnaturze: IEnumerable Shapes { get; }
Jednocześnie byłoby łatwiej, gdyby każdy typ rysunku mógł utrzymywać wewnętrznie listę o mocnym typie. Dla przykładu SeuratDrawing mógłby posiadać pole typu List ´. Taki typ będzie dla niego wygodniejszy niż List, ponieważ pozwoli na manipulację okręgami bez konieczności rzutowania referencji na właściwy typ. Gdybyśmy mieli kolekcję List, moglibyśmy zwrócić ją bezpośrednio lub przynajmniej opakować przez ReadOnlyCollection, aby uniemożliwić osobie wywołującej nasz kod popsucie jej przez rzutowanie. Tak czy inaczej, implementacja samej właściwości wydaje się prosta i tania. Niestety, nie możemy tego zrobić, kiedy nasze typy nie pasują do siebie... nie możemy przekonwertować IEnumerable na IEnumerable. Co zatem możemy zrobić?
12
Jeżeli nazwy klas nic Ci nie mówią, sprawdź ich znaczenie w Wikipedii (http://pl.wikipedia.org/wiki/ Piet_Mondrian i http://pl.wikipedia.org/wiki/Georges_Seurat). Mają one dla mnie znaczenie z różnych powodów. Mondrian jest nazwą używanego w Google narzędzia do recenzji kodu, natomiast od imienia Seurata pochodzi imię głównego bohatera wspaniałego musicalu Stephena Sondheima, Sunday in the Park with George.
3.5.
Ograniczenia typów generycznych C# i innych języków
131
Jest kilka możliwości. Możemy: zmienić typ pola na List i przyzwyczaić się do rzutowania, choć nie jest to rozwiązanie zadowalające, gdyż pod wieloma względami zaprzecza idei używania typów generycznych; użyć nowych cech oferowanych przez C# 2, które zobaczymy w rozdziale szóstym, do implementacji iteratorów; jest to rozwiązanie racjonalne wyłącznie dla tego przypadku (kiedy mamy do czynienia z IEnumerable); zmusić każdą implementację właściwości Shapes do stworzenia nowej kopii listy, najlepiej — dla prostoty — z użyciem List.ConvertAll; stworzenie niezależnej kopii kolekcji jest często wskazanym działaniem, ale wymaga wiele kopiowania, co czasem może się okazać mało wydajne; zmienić interfejs IDrawing na generyczny, wskazując typ kształtów używanych przez rysunek; w ten sposób MondrianDrawing mógłby implementować IDrawing, a SeuratDrawing — IDrawing, jednak jest to
opłacalne tylko wtedy, kiedy jesteś właścicielem interfejsu; stworzyć klasę pomocniczą do adaptacji jednego typu interfejsu IEnumerable na inny: class EnumerableWrapper : IEnumerable where TOriginal : TWrapper
Ponieważ ta konkretna sytuacja (IEnumerable) jest wyjątkowa, moglibyśmy poradzić sobie, nawet jeśli zastosowalibyśmy metodę pomocniczą. .NET 3.5 dostarcza dwie użyteczne metody, które mogą się nam przydać: Enumerable. ´Cast i Enumerable.OfType. Są one częścią LINQ i będziemy je analizować w rozdziale 11. Chociaż mówimy o przypadku szczególnym, jest to prawdopodobnie najbardziej powszechna forma kowariancji generycznej, z jaką będziesz miał do czynienia. Kiedy natkniesz się na problemy związane z kowariancją, być może będziesz musiał rozważyć wymienione wyżej możliwości oraz inne, które przyjdą Ci do głowy. Wszystko zależy od konkretnej sytuacji, w jakiej się znajdziesz. Niestety kowariancja nie jest jedynym problemem, z którym musisz sobie poradzić. Istnieje jeszcze odwrotność kowariancji — kontrawariancja. Gdzie mogłaby się przydać kontrawariancja? Kontrawariancja wydaje się trochę mniej intuicyjna niż kowariancja, ale ma swój sens. Poprzez kowariancję chcieliśmy przekonwertować SomeType na SomeType (używając w naszym przykładzie IEnumerable). Kontrawariancja dotyczy konwersji w drugą stronę — z SomeType na SomeType. Jak takie przejście może być bezpieczne? Kowariancja jest bezpieczna, kiedy SomeType opisuje wyłącznie operacje zwracające parametr typu, natomiast kontrawariancja jest bezpieczna, kiedy SomeType opisuje wyłącznie operacje akceptujące parametr typu13.
13
W rozdziale 13. przekonamy się, że jest jeszcze coś, co możemy dodać do tej ogólnej zasady.
132
ROZDZIAŁ 3
Parametryzowanie typów i metod
Najprostszym przykładem typu, który używa swojego parametru typu wyłącznie na wejściu danych, jest IComparer, stosowany powszechnie do sortowania kolekcji. Rozwińmy nasz pusty do tej pory interfejs IShape, dokładając do niego właściwość Area. W ten sposób można teraz łatwo napisać implementację IComparer, która porównuje dwa kształty po ich polu powierzchni. Chcielibyśmy następnie móc napisać następujący kod:
Kod nieprawidłowy
IComparer areaComparer = new AreaComparer(); List circles = new List(); circles.Add(new Circle(Point.Empty, 20)); circles.Add(new Circle(Point.Empty, 10)); circles.Sort(areaComparer);
To jednak nie zadziała, ponieważ metoda Sort na typie List wymaga w praktyce typu IComparer. Fakt, że nasz AreaComparer potrafi porównywać dowolny kształt, a nie tylko okręgi, nie robi żadnego wrażenia na kompilatorze. Z jego punktu widzenia IComparer i IComparer to dwa zupełnie różne typy. Czyż nie jest to szalone? Byłoby lepiej, gdyby metoda Sort miała następującą sygnaturę: void Sort(IComparer comparer) where T : S
Na nasze nieszczęście to nie tylko nie jest sygnatura metody Sort, ale również nie może nią być — ograniczenie jest błędne, ponieważ dotyczy S, a nie T. Chcemy ograniczenia typu konwersji, ale w przeciwnym kierunku, oczekując, że S będzie gdzieś wyżej w drzewie dziedziczenia w stosunku do T, a nie niżej. Wiedząc, że jest to niemożliwe, co możemy zrobić? Tym razem mamy mniej możliwości. Po pierwsze, moglibyśmy rozważyć ponownie ideę stworzenia pomocniczej klasy generycznej, jak pokazuje listing 3.14. Listing 3.14. Obejście braku kontrawariancji przy użyciu klasy pomocniczej class ComparisionHelper : IComparer where TDerived : TBase Poprawne ograniczenie parametru typu { private readonly IComparer comparer; Zapamiętanie public ComparisionHelper(IComparer comparer) { this.comparer = comparer; } public int Compare(TDerived x, TDerived y) { return comparer.Compare(x, y); }
oryginalnego komparatora
Użycie niejawnej konwersji w celu wywołania komparatora
}
Ponownie jest to praktyczne wykorzystanie wzorca adaptera, tym razem jednak ze zmiennym typem elementów do porównania. Zapamiętujemy oryginalny komparator, dostarczający faktyczną logikę do porównywania elementów typu bazowego ( ), a następnie wywołujemy go, kiedy zostaniemy poproszeni o porównanie elementów typu potomnego ( ). Fakt, iż po drodze nie stosujemy żadnych rzutowań (nawet ukrytych),
3.5.
Ograniczenia typów generycznych C# i innych języków
133
daje nam pewność, że ta klasa zapewnia bezpieczeństwo typu. Dzięki dostępności niejawnej konwersji z typu TDerived na TBase, którą wymusiliśmy ograniczeniem typu ( ), jesteśmy w stanie wywołać komparator instancji klasy bazowej. Drugą możliwością jest uczynienie klasy porównującej powierzchnie klasą generyczną z ograniczeniem typu w taki sposób, aby mogła porównać dwie dowolne wartości tego samego typu, o ile tylko typ ten implementuje interfejs IShape. Dla zachowania prostoty w sytuacji, kiedy tak naprawdę nie potrzebujesz tej funkcjonalności, mógłbyś zastosować klasę niegeneryczną, zmuszając ją jedynie do dziedziczenia po klasie generycznej: class AreaComparer : IComparer where T : IShape class AreaComparer : AreaComparer
Oczywiście, możesz tak zrobić tylko w sytuacji, kiedy jesteś w stanie zmodyfikować klasę porównującą. Rozwiązanie może być funkcjonalne, ale nadal wydaje się nienaturalne. Dlaczego jesteś zmuszony do konstruowania komparatora w różny sposób dla różnych typów, w sytuacji kiedy ich zachowanie nie będzie różne? Dlaczego jesteś zmuszony stosować dziedziczenie po klasie dla uproszczenia kodu, kiedy Twoim celem nie jest jego specjalizacja? Zauważ, że różne warianty dla kowariancji i kontrawariancji używają dużej ilości typów generycznych oraz ograniczeń w celu wyrażenia interfejsu w sposób bardziej ogólny lub dostarczenia generycznej klasy pomocniczej. Wiem, że dodanie ograniczenia sprawia wrażenie zawężenia ogólności, ale ogólność ta jest dodana w pierwszej kolejności przez uczynienie typu generycznym. Kiedy napotkasz podobny problem, przekształcenie typu w generyczny i dodanie pewnych ograniczeń powinno być pierwszym krokiem, jaki rozważysz. W takiej sytuacji — ze względu na wnioskowanie typu, sprawiające, że wariancja jest niewidoczna gołym okiem — często bardziej pomocne w porównaniu do typów są metody generyczne. Jest to szczególnie prawdziwe w przypadku C# 3, który posiada większe możliwości wnioskowania typu w porównaniu do C# 2. To ograniczenie jest źródłem bardzo częstych pytań na forach dyskusyjnych C#. Pozostałe problemy mają raczej charakter akademicki lub dotyczą niewielkiej części społeczności programistów. Następna sytuacja dotyczy głównie osób, które w swojej codziennej pracy wykonują duże ilości obliczeń (naukowych lub związanych z zagadnieniami finansowymi).
3.5.2. Brak ograniczeń operatorów lub ograniczeń „numerycznych” C# nie jest pozbawiony minusów, jeśli chodzi o kod mocno obciążony obliczeniami matematycznymi. Środowiska naukowe jako główne bariery przy adaptowaniu C# dla swoich celów podają niemożność wykonania jakichkolwiek operacji matematycznych poza zupełnie podstawową arytmetyką bez jawnego użycia klasy Math, a także brak możliwości łatwego definiowania i modyfikowania danych na przestrzeni całego kodu przy użyciu mechanizmu podobnego do synonimów typów uzyskiwanych na przykład w języku C przez słowo kluczowe typedef. Prawdopodobnie typy generyczne nie zostały
134
ROZDZIAŁ 3
Parametryzowanie typów i metod
wymyślone po to, aby w pełni rozwiązać te problemy. Jest jednak pewien niuans uniemożliwiający im niesienie pomocy choćby w zadowalającym stopniu. Przyjrzyj się poniższej (generycznej) metodzie: public T FindMean(IEnumerable data) { T sum = default(T); int count = 0; foreach (T element in data) { sum += element; count++; } return sum / count; }
Łatwo można zauważyć, że ten kod nie ma szans działać dla wszystkich typów danych. Co miałoby dla przykładu oznaczać dodanie jednego obiektu typu Exception do drugiego? Ten przypadek prosi się o wprowadzenie jakiegoś ograniczenia. Czegoś, co pozwoliłoby nam wyrazić nasz zamiar: dodanie dwóch instancji T, a następnie podzielenie instancji T przez liczbę całkowitą. Gdyby istniała taka możliwość, nawet przy ograniczeniu wyłącznie do typów wbudowanych, moglibyśmy pisać działające algorytmy generyczne, niezależnie od tego, czy typem danych jest int, long, double, decimal, czy jakikolwiek inny. Ograniczenie wyłącznie do typów wbudowanych byłoby rozczarowujące, ale lepsze to niż nic. Idealne rozwiązanie musiałoby pozwalać typom zdefiniowanym przez użytkownika zachowywać się tak jak wyrażenia matematyczne — mógłbyś wtedy na przykład zdefiniować typ Complex do obsługi liczb zespolonych14. Taka reprezentacja liczby zespolonej mogłaby następnie przechowywać każdy ze swoich składników w sposób generyczny. W ten sposób byłoby możliwe stworzenie liczb typu Complex, Complex itd. Można wyobrazić sobie dwa powiązane ze sobą rozwiązania. Jednym byłoby umożliwienie stosowania ograniczeń na operatorach, dzięki którym mógłbyś zapisać rzecz następującą: where T : T operator+ (T, T), T operator/ (T, int)
Taka definicja wymagałaby od T posiadania operacji, których użyliśmy wcześniej. Drugim rozwiązaniem byłoby zdefiniowanie kilku operatorów i być może konwersji, które musiałyby być obsługiwane przez typ, aby mógł on sprostać narzuconym ograniczeniom — takie ograniczenie moglibyśmy nazwać „ograniczeniem numerycznym” i zapisać jako where T: numeric. Problem z jednym i drugim rozwiązaniem jest taki, że nie mogą być one wyrażone jako zwykłe interfejsy, ponieważ przeciążanie operatorów jest wykonywane na elementach statycznych, których nie można użyć do zaimplementowania interfejsów. W takiej sytuacji atrakcyjna wydaje się idea interfejsów statycznych, czyli deklarujących jedynie statyczne elementy składowe (metody, operatory i konstruktory). Tego typu statyczne interfejsy byłyby użyteczne jedynie w ograniczeniach typów, ale oferowałyby 14
Oczywiście, pod warunkiem że nie używasz środowiska .NET 4, które posiada już taki typ o nazwie System.Numerics.ComplexNumber.
3.5.
Ograniczenia typów generycznych C# i innych języków
135
możliwość dostępu do statycznych elementów z zachowaniem bezpieczeństwa typu. Oczywiście jest to tylko bujanie w obłokach (http://mng.bz/3Rk3) — nic mi nie wiadomo o jakichkolwiek planach udostępnienia takiej możliwości w przyszłych wersjach języka C#. Dwa najsprytniejsze obejścia tego problemu wymagają najnowszej wersji środowiska .NET. Pierwsze z nich zostało opracowane przez Marca Gravella (http://mng.bz/9m8i) i używa drzew wyrażeń (z którymi spotkamy się w rozdziale 9.) w celu zbudowania metod dynamicznych. Drugie to zastosowanie dynamicznych cech języka C# 4. Przykład zobaczymy w rozdziale 14. Już z samego opisu możesz jednak wywnioskować, że oba rozwiązania korzystają z mechanizmów dynamicznych — o ich pomyślnej współpracy z danym typem przekonasz się dopiero w czasie wykonywania programu. Istnieje jeszcze kilka obejść korzystających nadal z typów statycznych, ale mają one inne niedoskonałości (i, co może być zaskakujące, potrafią być wolniejsze w porównaniu do kodu dynamicznego). Dwa ograniczenia typów generycznych, którym się do tej pory przyglądaliśmy, mają charakter całkiem praktyczny — masz szansę spotkać się z nimi w trakcie swojej pracy deweloperskiej. Jeśli jesteś osobą ciekawą świata, tak jak ja, możesz się zastanawiać, czy istnieją inne ograniczenia, które nie wpływają na tempo pracy, ale stanowią swego rodzaju intelektualne ciekawostki. Na przykład dlaczego „generyczność” ogranicza się jedynie do metod i typów?
3.5.3. Brak generycznych właściwości, indekserów i innych elementów typu Widzieliśmy typy generyczne (klasy, struktury, delegaty i interfejsy), a także metody generyczne. Istnieje jeszcze całe mnóstwo innych elementów, które mogłyby być sparametryzowane. Nie ma jednak generycznych właściwości, indekserów, operatorów, konstruktorów, metod finalizujących ani zdarzeń. Zacznijmy od wyjaśnienia, co mamy tutaj na myśli. Indekser może posiadać typ zwracany będący parametrem typu — bezspornym przykładem jest List. KeyValuePair jest podobnym przykładem dla właściwości. To, czego nie można mieć, to indekser lub właściwość, a także każdy inny element z powyższej listy, które by posiadały dodatkowe parametry typu. Odkładając chwilowo na bok prawdopodobną deklarację, zobaczmy, jak takie elementy musiałyby być używane w kodzie: SomeClass instance = new SomeClass("x"); int x = instance.SomeProperty; byte y = instance.SomeIndexer["key"]; instance.Click += ByteHandler; instance = instance + instance;
Zgodzisz się chyba ze mną, że te przykłady wyglądają trochę niepoważnie. Metod finalizujących nie da się wywołać jawnie z poziomu kodu C#, stąd nie ma ich w powyższym przykładzie. Według mojej oceny fakt, iż nie możemy wykonać żadnej z przedstawionych operacji, nie spowoduje większych problemów w kodzie. Jest to czysto akademicki problem, o którym warto wiedzieć.
136
ROZDZIAŁ 3
Parametryzowanie typów i metod
Jedynym wyjątkiem mógłby być tutaj konstruktor. W tym przypadku podobny efekt można jednak osiągnąć, stosując statyczną metodę generyczną wewnątrz klasy, chociaż składnia z dwoma listami argumentów typu wygląda straszliwie. Nie są to w żadnym wypadku jedyne ograniczenia typów generycznych C#, ale moim zdaniem te masz największą szansę spotkać na swojej drodze — w trakcie codziennej pracy, prowadząc dyskusję z innymi programistami lub w wolnej chwili analizując całościowo jakąś cechę języka. W dwóch kolejnych sekcjach przekonamy się, jak dwa inne języki, najczęściej porównywane z typami generycznymi C#, radzą sobie z podobnym problemem. Są to C++ (z wzorcami) i Java (z typami generycznymi w wersji Java 5). Zaczniemy od C++.
3.5.4. Porównanie z C++ Wzorce C++ można traktować trochę jak makra posunięte do granic możliwości. Są niezwykle funkcjonalne, ale wprowadzają pewien koszt zarówno pod względem skomplikowania kodu, jak i łatwości jego zrozumienia. Kiedy w C++ zostanie użyty wzorzec, kod jest kompilowany dla określonego zbioru argumentów, tak jakby argumenty wzorca znajdowały się w kodzie źródłowym. Oznacza to, że nie ma większej potrzeby wprowadzania ograniczeń, ponieważ kompilator i tak sprawdzi, czy wolno Ci zrobić z danym typem to, co chcesz, podczas kompilowania kodu źródłowego dla określonych argumentów wzorca. Mimo to komitet standaryzacji C++ uznał, że ograniczenia mogą być nadal przydatne. Miały one trafić do wersji C++0x (kolejnej wersji C++), ale tak się nie stało. Być może pewnego dnia ujrzą jeszcze światło dzienne pod nazwą konceptów. W C++ kompilator jest wystarczająco mądry, aby skompilować kod wyłącznie raz dla dowolnego zestawu argumentów wzorca, ale nie jest w stanie współdzielić kodu w sposób, jaki robi to CLR dla typów referencyjnych. Ten brak współdzielenia ma swoje zalety — pozwala na optymalizację pod względem typów, na przykład przez tworzenie wywołań inline dla pewnych parametrów typu, ale nie dla wszystkich pochodzących z tego samego wzorca. Oznacza to również, że rozstrzyganie przeciążeń w C++ może być wykonane niezależnie dla każdego zestawu typu parametrów w porównaniu do wyłącznie jednego wspólnego przebiegu dla całego kodu w C#, wynikającego z niepełnej wiedzy kompilatora C# spowodowanej obecnością ograniczeń typów. Nie zapominaj, że w przypadku C++ ma miejsce tylko jeden rodzaj kompilacji, podczas gdy w modelu .NET mamy do czynienia z „kompilacją do postaci IL”, a następnie „kompilacją JIT do postaci natywnej”. Program C++ używający standardowego wzorca na dziesięć różnych sposobów będzie zawierał w programie 10 kopii kodu wzorca. Podobny program w C#, używający typu generycznego ze środowiska na dziesięć różnych sposobów, nie będzie w ogóle zawierał kodu typu generycznego — będzie się do niego odwoływał, a kompilator JIT będzie kompilował tyle różnych wersji, ile będzie potrzebnych (mówiliśmy o tym w sekcji 3.4.2) w czasie wykonania. Jedyną znaczącą przewagą, jaką C++ posiada nad typami generycznymi C#, jest to, że argumenty wzorca nie muszą być nazwami typu. Równie dobrze mogą to być nazwy zmiennych, funkcji, a także wyrażenia stałe. Znanym przykładem jest typ bufora, którego rozmiar jest jednym z argumentów wzorca — buffer będzie zawsze buforem 20 zmiennych całkowitych, a buffer będzie zawsze buforem 35
3.5.
Ograniczenia typów generycznych C# i innych języków
137
liczb typu double. Ta zdolność ma kluczowe znaczenie przy metaprogramowaniu z użyciem wzorców (zobacz http://mng.bz/c1G0) — zaawansowanej techniki C++, która dla mnie jest przerażająca już w samej idei, ale w rękach eksperta może się okazać bardzo produktywna. Wzorce C++ są również bardziej elastyczne pod innymi względami. Nie „cierpią” z powodów opisanych w sekcji 3.5.2 i cechuje je mniejsza liczba ograniczeń: możesz stworzyć klasę potomną w oparciu o parametr typu i wyspecjalizować wzorzec dla określonego zestawu argumentów. Druga cecha pozwala autorowi wzorca na stworzenie ogólnego kodu, w sytuacji kiedy brakuje mu szczegółowej wiedzy na temat danego zagadnienia, ale również kodu wyspecjalizowanego (często zoptymalizowanego) dla określonych typów. We wzorcach C++ istnieją te same problemy z wariancją, z jakimi borykają się generyki .NET — przykład podany przez Bjarne’a Stroustrupa15 mówi, że nie ma niejawnej konwersji pomiędzy vector i vector. Powód braku takiej możliwości jest podobny — w tym przykładzie dozwolone byłoby włożenie kwadratu w miejsce kółka. Chętnym do głębszego poznania wzorców C++ polecam książkę autorstwa Stroustrupa Język C++ (WNT, Warszawa 2002). Nie jest to książka łatwa w lekturze, ale sam rozdział poświęcony wzorcom jest w miarę przystępny (pod warunkiem wcześniejszego przyswojenia sobie terminologii i składni C++). Dalsze szczegóły na temat porównania generyków .NET znajdziesz we wpisie zamieszczonym na stronie zespołu Visual C++ (http://mng.bz/En13). Drugim oczywistym językiem do porównania z C# pod względem typów generycznych jest Java, która wprowadziła tę cechę do głównego nurtu języka w wersji 1.516 kilka lat po tym, jak inne projekty doprowadziły do powstania języków podobnych do Javy i obsługujących ten mechanizm.
3.5.5. Porównanie z typami generycznymi Javy Tam, gdzie C++ — w porównaniu do C# — dołączał więcej wzorców w wygenerowanym kodzie, Java dołącza mniej. Prawdę mówiąc, środowisko wykonania Javy nie ma pojęcia o istnieniu typów generycznych. Kod bajtowy Javy (mniej więcej odpowiednik kodu IL pod względem terminologii) zawiera dodatkowe metadane, aby wskazać, iż chodzi o kod generyczny, jednak po kompilacji nie pozostaje niemal żaden ślad wskazujący na generyczność kodu. Instancja typu generycznego z całą pewnością zna tylko swoją niegeneryczną naturę. Dla przykładu instancja HashSet nie wie, czy została utworzona jako HashSet, czy HashSet. Kompilator zajmuje się rzutowaniem w miejscach, gdzie jest to wymagane, a także wykonuje więcej sprawdzeń odnośnie do poprawności kodu. Oto przykład — najpierw generyczny kod Javy: ArrayList strings = new ArrayList(); strings.add("cześć"); String entry = strings.get(0); strings.add(new Object()); 15
Autora C++.
16
Lub 5.0, w zależności od tego, którego systemu numerowania używasz.
138
ROZDZIAŁ 3
Parametryzowanie typów i metod
a teraz odpowiednik powyższego kodu w formie niegenerycznej: ArrayList strings = new ArrayList(); strings.add("cześć"); String entry = (String) strings.get(0); strings.add(new Object());
Oba fragmenty doprowadziłyby do powstania tego samego kodu bajtowego z wyjątkiem ostatniej linii, która jest poprawna w kodzie niegenerycznym, ale spowodowałaby błąd kompilatora w wersji generycznej. Typu generycznego możesz użyć jako „surowego” typu, będącego odpowiednikiem java.lang.Object dla każdego z argumentów typu. Takie przypisanie i utratę informacji nazywa się wykreślaniem typu (ang. type erasure). Java nie posiada definiowanych przez użytkownika typów wartościowych, ale nie ma to znaczenia, ponieważ jako argumentów typu nie możesz użyć nawet typów wbudowanych. Zamiast tego jesteś zmuszony do korzystania z wersji opakowanej, na przykład ArrayList w przypadku listy liczb całkowitych. Możesz bez żalu przyznać, że taka funkcjonalność jest odrobinę rozczarowująca w porównaniu do typów generycznych oferowanych przez C#. Należy jednak dodać, że Java ma też jaśniejsze strony generyków: Maszyna wirtualna nie ma pojęcia o typach generycznych, zatem możesz używać skompilowanego kodu opartego na typach generycznych w starszych wersjach środowiska (pod warunkiem że nie stosujesz klas i metod nieistniejących w tych wersjach). Wersjonowanie środowiska .NET jest o wiele bardziej restrykcyjne — musisz wskazać, czy wersja każdego modułu, do którego się odnosisz, ma dokładnie pasować. Ponadto kod zbudowany do uruchomienia na wersji 2.0 środowiska CLR nie będzie działał w środowisku .NET 1.1. Nie musisz poznawać nowego zestawu klas, aby móc skorzystać z typów generycznych w Javie. Tam, gdzie „niegeneryczny” programista użyłby ArrayList, programista „generyczny” zastosuje ArrayList. Istniejące klasy mogą być w sposób rozsądny wykorzystane w wersji generycznej. Poprzednia cecha została całkiem efektywnie wykorzystana w systemie refleksji — klasa java.lang.Class (odpowiednik System.Type) jest generyczna, co pozwala
na rozciągnięcie systemu bezpieczeństwa typu czasu kompilacji na wiele sytuacji, w których zachodzi refleksja. Jednak w pozostałych przypadkach może już nie być tak różowo. Java obsługuje wariancję typów generycznych poprzez wieloznaczniki. Na przykład ArrayList