W YD A N IE J U B IL E U SZOW E Z OKAZJI RO C ZN IC Y P IER WSZEJ EDYCJI Wydanie II Pragmatyczny programista Od czeladnika do mistrza Tytuł oryginału: The Pragmatic Programmer: Your Journey To Mastery, 20th Anniversary Edition (2nd Edition) Tłumaczenie: Radosław Meryk na podstawie Pragmatyczny programista. Od czeladnika do mistrza w przekładzie Mikołaja Szczepaniaka ISBN: 978-83-283-7140-8 Authorized translation from the English language edition, entitled THE PRAGMATIC PROGRAMMER: YOUR JOURNEY TO MASTERY, 20TH ANNIVERSARY EDITION, 2nd Edition by DAVID THOMAS; ANDREW HUNT, published by Pearson Education, Inc, publishing as Addison-Wesley Professional, Copyright © 2020 Pearson Education, Inc. 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 Pearson Education, Inc. Polish language edition published by Helion SA, Copyright © 2021. 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. Autorzy oraz Helion SA dołożyli wszelkich starań, by zawarte w tej książce informacje były kompletne i rzetelne. Nie biorą jednak żadnej odpowiedzialności ani za ich wykorzystanie, ani za związane z tym ewentualne naruszenie praw patentowych lub autorskich. Autorzy oraz Helion SA nie ponoszą również żadnej odpowiedzialności za ewentualne szkody wynikłe z wykorzystania informacji zawartych w książce. Helion SA ul. Kościuszki 1c, 44-100 Gliwice tel. 32 231 22 19, 32 230 98 63 e-mail: helion@helion.pl WWW: http://helion.pl (księgarnia internetowa, katalog książek) Drogi Czytelniku! Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres http://helion.pl/user/opinie/pragp2_ebook Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję. Poleć książkę na Facebook.com Księgarnia internetowa Kup w wersji papierowej Lubię to! » Nasza społeczność Oceń książkę 4337ebf6db5c7cc89e4173803ef3875a 4 Opinie na temat drugiego wydania książki Pragmatyczny programista Niektórzy twierdzą, że w książce Pragmatyczny programista Andy i Dave schwytali błyskawicę do butelki. Jest mało prawdopodobne, aby ktoś wkrótce napisał książkę, która poruszy branżę w taki sposób, w jaki oni to zrobili. Czasami jednak błyskawica uderza dwa razy. Ta książka jest dowodem, że to możliwe. Dzięki zaktualizowanej treści pozostanie ona na szczycie list najlepszych publikacji dotyczących rozwoju oprogramowania przez 20 kolejnych lat. To miejsce jej się należy. VM (Vicky) Brasseur Dyrektor strategii open source, Juniper Networks Jeśli chcesz, aby Twoje oprogramowanie było łatwe do aktualizacji i utrzymania, miej pod ręką egzemplarz książki Pragmatyczny programista. Jest ona pełna praktycznych porad, zarówno technicznych, jak i zawodowych, które będą służyły Tobie i Twoim projektom przez długie lata. Andrea Goulet Dyrektor naczelny firmy Corgibytes; założyciel witryny LegacyCode.Rocks Pragmatyczny programista to książka, która całkowicie zmieniła tor mojej kariery w tworzeniu oprogramowania i wskazała kierunek do sukcesu. Jej lektura otworzyła mój umysł na możliwości bycia rzemieślnikiem, a nie tylko trybikiem w wielkiej maszynie. To jedna z najważniejszych książek w moim życiu. Obie Fernandez Autor książki The Rails Way Czytelnicy, którzy sięgają po tę książkę po raz pierwszy, mogą spodziewać się fascynującego wprowadzenia do nowoczesnego świata praktyk tworzenia oprogramowania — świata, w którego kształtowaniu pierwsze wydanie tej książki odegrało ważną rolę. Czytelnicy pierwszej edycji na nowo odkryją trafne spostrzeżenia i praktyczną mądrość, dzięki którym ta publikacja stała się tak ważna. Drugie wydanie zostało fachowo uzupełnione i uaktualnione dużą ilością nowego materiału. David A. Black Autor książki The Well-Grounded Rubyist 4337ebf6db5c7cc89e4173803ef3875a 4 Mam na półce starą, papierową kopię pierwszego wydania Pragmatycznego programisty. Książkę tę czytałem wiele razy i stale do niej wracam. Dawno temu zmieniła ona wszystko w sposobie, w jaki podchodziłem do zawodu programisty. W nowym wydaniu zmieniło się wszystko, a jednocześnie nie zmieniło się nic: teraz czytam ją na moim iPadzie, a w przykładach kodu wykorzystano nowoczesne języki programowania, ale opisywane pojęcia, pomysły i sposoby postępowania są ponadczasowe i mają uniwersalne zastosowanie. Dwadzieścia lat po pierwszym wydaniu książka jest tak samo aktualna, jak kiedyś. Jestem szczęśliwy, że obecni i przyszli programiści będą mieć taką samą okazję uczenia się od Andy’ego i Dave’a ich głębokich spostrzeżeń, jaką miałem ja czytając pierwsze wydanie. Sandy Mamoli Trener Agile, autor książki How Self-Selection Lets People Excel Dwadzieścia lat temu pierwsze wydanie Praktycznego programisty całkowicie zmieniło trajektorię mojej kariery. Niniejsza nowa edycja może zrobić to samo dla Was. Mike Cohn Autor książek Succeeding with Agile, Agile Estimating and Planning oraz User Stories Applied 4337ebf6db5c7cc89e4173803ef3875a 4 Dla Julii i Ellie, Zachary’ego i Elizabeth, Henry’ego i Stuarta 4337ebf6db5c7cc89e4173803ef3875a 4 4337ebf6db5c7cc89e4173803ef3875a 4 Spis treści SŁOWO WSTĘPNE PRZEDMOWA DO Z 9 DRUGIEGO WYDANIA PRZEDMOWY DO PIERWSZEGO WYDANIA To jest Twoje życie .......................................................................... 26 Kot zjadł mój kod źródłowy ............................................................. 27 Entropia oprogramowania .............................................................. 30 Zupa z kamieni i gotowane żaby ..................................................... 33 Odpowiednio dobre oprogramowanie .............................................. 36 Portfolio wiedzy .............................................................................. 39 Komunikuj się!............................................................................... 45 2. POSTAWA PRAGMATYCZNA 8. 9. 10. 11. 12. 13. 14. 15. 53 Istota dobrego projektu .................................................................. 54 DRY — przekleństwo powielania..................................................... 56 Ortogonalność ................................................................................ 65 Odwracalność ................................................................................ 74 Pociski smugowe ............................................................................ 78 Prototypy i karteczki samoprzylepne............................................... 84 Języki dziedzinowe ......................................................................... 88 Szacowanie .................................................................................... 94 3. PODSTAWOWE NARZĘDZIA 16. 17. 18. 19. 20. 21. 22. 19 25 1. FILOZOFIA PRAGMATYCZNA 1. 2. 3. 4. 5. 6. 7. 13 101 Potęga zwykłego tekstu................................................................. 103 Powłoki ........................................................................................ 107 Efektywna edycja ......................................................................... 109 Kontrola kodu źródłowego ............................................................ 112 Debugowanie ............................................................................... 117 Operowanie na tekście ................................................................. 127 Dzienniki inżynierskie .................................................................. 130 4337ebf6db5c7cc89e4173803ef3875a 4 8 Spis treści 4. PRAGMATYCZNA PARANOJA 23. 24. 25. 26. 27. 133 Projektowanie kontraktowe........................................................... 134 Martwe programy nie kłamią ........................................................ 143 Programowanie asertywne ............................................................ 145 Jak zrównoważyć zasoby .............................................................. 149 Nie prześcigaj swoich świateł ........................................................ 156 5. ZEGNIJ LUB ZŁAM 28. 29. 30. 31. 32. 161 Eliminowanie sprzężeń ................................................................. 162 Żonglerka realnym światem.......................................................... 170 Programowanie transformacyjne................................................... 180 Podatek od dziedziczenia .............................................................. 191 Konfiguracja................................................................................. 199 6. WSPÓŁBIEŻNOŚĆ 33. 34. 35. 36. 37. 203 Wszystko jest współbieżne ............................................................ 203 Eliminowanie związków czasowych............................................... 204 Współdzielony stan jest zły ........................................................... 209 Aktorzy i procesy.......................................................................... 216 Czarne tablice .............................................................................. 222 7. KIEDY KODUJEMY… 38. 39. 40. 41. 42. 43. 44. 45. 227 Słuchaj swojego jaszczurczego mózgu........................................... 228 Programowanie przez koincydencję............................................... 233 Szybkość algorytmu ..................................................................... 239 Refaktoryzacja.............................................................................. 245 Kod łatwy do testowania............................................................... 250 Testowanie na podstawie właściwości ........................................... 261 Pozostań w bezpiecznym miejscu.................................................. 267 Nazewnictwo ................................................................................ 275 8. PRZED PROJEKTEM 46. 47. 48. 49. 281 Kopalnia wymagań ....................................................................... 282 Rozwiązywanie niemożliwych do rozwiązania łamigłówek .............. 290 Praca zespołowa ........................................................................... 294 Istota zwinności ........................................................................... 297 9. PRAGMATYCZNE PROJEKTY 50. 51. 52. 53. 54. 303 Pragmatyczne zespoły................................................................... 304 Nie próbuj przecinać kokosów ...................................................... 310 Zestaw startowy pragmatyka ........................................................ 314 Wpraw w zachwyt użytkowników .................................................. 322 Duma i uprzedzenie ..................................................................... 324 POSŁOWIE 326 BIBLIOGRAFIA 329 MOŻLIWE ODPOWIEDZI DO ĆWICZEŃ 331 4337ebf6db5c7cc89e4173803ef3875a 4 Słowo wstępne Pamiętam, kiedy Dave i Andy po raz pierwszy ogłosili na Twitterze informację o nowym wydaniu tej książki. To był wielki news. Obserwowałem entuzjastyczną reakcję społeczności programistów. Efekt spełnił oczekiwania. Po dwudziestu latach książka Pragmatyczny programista jest tak samo aktualna, jak jej pierwsze wydanie. To, że książka z taką historią spotkała się z taką reakcją, jest bardzo znaczące. Miałem przywilej czytania nieopublikowanej kopii po to, by napisać słowo wstępne i zrozumiałem, dlaczego ta publikacja wywołała takie poruszenie. Chociaż jest to książka techniczna, nazywanie jej w taki sposób robi jej niedźwiedzią przysługę. Książki techniczne często odstraszają czytelników. Są wypełnione po brzegi wielkimi słowami, niejasnymi terminami, zawiłymi przykładami. Możesz czuć się zagubiony. Im bardziej doświadczony autor, tym łatwiej jest mu zapomnieć jak to jest, kiedy uczysz się nowych pojęć jako początkujący. Pomimo dziesięcioleci doświadczeń w programowaniu Dave i Andy podołali trudnemu wyzwaniu pisania z emocjami właściwymi ludziom, którzy dopiero niedawno poznali temat. Nie traktują czytelników „z góry”. Nie zakładają, że jesteś ekspertem. Nie zakładają nawet, że czytałeś pierwsze wydanie. Traktują Cię tak jak powinni — jak programistę, który po prostu chce być lepszy. Poświęcili karty tej książki, aby Ci pomóc takim się stać — krok po kroku. Szczerze mówiąc, zrobili już to wcześniej. Pierwsze wydanie było pełne konkretnych przykładów, nowych pomysłów i praktycznych wskazówek, które budują Twoją „programistyczną muskulaturę” i rozwijają Twój umysł — te przykłady są nadal aktualne. Ale w niniejszej, zaktualizowanej edycji wprowadzono dwa ulepszenia. Pierwsze jest oczywiste: usunięto niektóre starsze odnośniki, przestarzałe przykłady i zastąpiono je świeżą, nowoczesną zawartością. Nie znajdziesz tu przykładów niezmienników pętli lub maszyn budowania. Dave i Andy zaktualizowali wspaniałą zawartość pierwszego wydania i zadbali o to, aby opisywane zagadnienia były wolne od starych przykładów. Odkurzono w niej stare pomysły, 4337ebf6db5c7cc89e4173803ef3875a 4 10 Słowo wstępne takie jak zasada DRY (don’t repeat yourself — dosłownie: nie powtarzaj się) i nałożono na nie świeżą warstwę farby, tak by na nowo zyskały połysk. Ale to dzięki drugiemu ulepszeniu ta edycja jest naprawdę ekscytująca. Po napisaniu pierwszego wydania autorzy mieli okazję zastanowić się nad tym, co starali się powiedzieć, co chcieli, aby ich czytelnicy wyciągnęli z tej lektury oraz jaki był tego odbiór. Na tej podstawie wyciągnęli właściwe wnioski. Zobaczyli, co nie było najlepiej przekazane, co potrzebowało modyfikacji, jakie treści nie zostały właściwie zrozumiane. W ciągu dwudziestu lat, przez które książka przeszła przez ręce i serca programistów na całym świecie, Dave i Andy studiowali przekazywane im opinie i formułowali nowe pomysły oraz nowe koncepcje. Nauczyli się znaczenia sprawczości i doszli do przekonania, że programiści mają w sobie większe możliwości sprawcze w porównaniu z większością innych profesjonalistów. Zaczynają tę książkę od prostego, ale głębokiego komunikatu: „To jest Twoje życie”. To przypomina o możliwościach tkwiących w naszej bazie kodu, w naszej pracy oraz w naszych karierach. To zdanie ustawia ton dla pozostałej treści tej książki. Przypomina o tym, że jest ona czymś więcej niż tylko kolejną książką techniczną wypełnioną przykładami kodu. Tym, co naprawdę wyróżnia tę publikację spośród innych książek technicznych jest to, że ta książka pozwala zrozumieć, co to znaczy być programistą. Programowanie to próba doprowadzenia do tego, aby przyszłość stała się mniej uciążliwa. Chodzi w nim o to, aby ułatwiało wykonywanie pracy naszym kolegom. Chodzi o to, aby można było poprawić to, co jest robione źle. O tworzenie dobrych nawyków. O zrozumienie zestawu narzędzi, jakim się posługujemy. Kodowanie to tylko fragment świata programistów. Ta książka ten świat odkrywa. Poświęciłem dużo czasu na myślenie o mojej „podróży” po krainie kodowania. Nie wyssałem kodowania z mlekiem matki i nie nauczyłem się go na studiach. Nie zajmowałem się zgłębianiem nauk technicznych, kiedy byłem nastolatkiem. Wkroczyłem do świata kodowania kiedy byłem po dwudziestce i musiałem nauczyć się, co to znaczy być programistą. Ta społeczność bardzo różni się od innych, których byłem częścią. Obowiązuje w niej unikatowe poświęcenie się nauce i praktyce; w obu przypadkach jest to zarówno odświeżające, jak i zatrważające. Dla mnie wejście do społeczności programistów to tak, jakby wejść do nowego świata. Albo przynajmniej zamieszkać w nowym mieście. Musiałem poznać sąsiadów, znaleźć ulubiony sklep spożywczy, dowiedzieć się, gdzie są najlepsze kawiarnie. Poznanie nowego krajobrazu, znalezienie najbardziej efektywnych dróg, aby uniknąć zakorkowanych ulic, poznanie godzin, w których ruch osiąga szczyt, zajęło mi trochę czasu. W nowym mieście pogoda jest inna, więc potrzebowałem nowej szafy. Pierwsze tygodnie — a nawet miesiące — w nowym mieście mogą być zatrważające. Czy nie byłoby wspaniale mieć przyjaźnie nastawionego, znajomego sąsiada, który mieszka tam już od jakiegoś czasu? Kogoś, kto mógłby zabrać Cię na wycieczkę i pokazać najlepsze kawiarnie? Kto mieszka tam na tyle długo, że 4337ebf6db5c7cc89e4173803ef3875a 4 Słowo wstępne 11 zna kulturę, rozumie puls miasta. Dzięki komu mógłbyś nie tylko czuć się tam tak, jak u siebie, ale mógłbyś także stać się aktywnym członkiem społeczności mieszkańców? Dave i Andy są właśnie takimi sąsiadami. Nowicjusza może łatwo przytłaczać nie sam akt programowania, ale proces stawania się programistą. Musi nastąpić zmiana sposobu myślenia, a także nawyków, zachowań i oczekiwań. Proces stawania się lepszym programistą nie dzieje się dlatego, że wiesz, jak kodować; musisz świadomie dążyć do stosowania dobrych praktyk. Ta książka jest przewodnikiem wiodącym do wydajnego stawania się lepszym programistą. Niech Cię to jednak nie zmyli — nie znajdziesz tu odpowiedzi na pytanie o to, jakie powinno być programowanie. Ta książka nie pokazuje filozofii programowania, nie przedstawia też tego rodzaju opinii. Powie Ci zaledwie, w prosty i czytelny sposób, kim jest pragmatyczny programista — jak działa oraz jakie stosuje podejście do kodu. Do Ciebie należy decyzja, czy chcesz zostać takim programistą. Jeśli czujesz, że To nie jest dla Ciebie, nikt Cię do niczego nie zmusi. Ale jeśli uznasz, że autorzy tej książki są Twoimi przyjaźnie nastawionymi sąsiadami, to oni pokażą Ci drogę. Saron Yitbarek Założyciel i dyrektor wykonawczy firmy CodeNewbie Gospodarz podcastu „Command Line Heroes” 4337ebf6db5c7cc89e4173803ef3875a 4 12 Słowo wstępne 4337ebf6db5c7cc89e4173803ef3875a 4 Przedmowa do drugiego wydania W latach dziewięćdziesiątych pracowaliśmy z firmami, które miały problemy ze swoimi projektami. Mówiliśmy wszystkim to samo: może powinniście przetestować kod przed opublikowaniem? Dlaczego kod buduje się tylko na maszynie Marii? Dlaczego nikt nie zapytał o zdanie użytkowników? Aby zaoszczędzić czas spędzony z nowymi klientami, zaczęliśmy robić notatki. Z tych notatek powstała książka Pragmatyczny programista. Ku naszemu zaskoczeniu okazała się strzałem w dziesiątkę. Przez 20 lat od jej wydania nadal jest popularna. Ale 20 lat to w przypadku oprogramowania wiele życiorysów. Weźcie programistę z roku 1999 i włączcie go do współczesnego zespołu — będzie czuć się nieswojo w tym dziwnym, nowym świecie. Ale świat lat dziewięćdziesiątych jest równie obcy dla dzisiejszych programistów. Odniesienia w książce do takich technologii, jak CORBA, do narzędzi CASE oraz indeksowanych pętli byłyby w najlepszym razie jedynie ciekawostką, a co bardziej prawdopodobne, byłyby mylące. Jednocześnie trzeba przyznać, że 20 minionych lat nie miało w ogóle wpływu na sposób zdroworozsądkowego myślenia. Być może zmieniła się technologia, ale nie zmienili się ludzie. Praktyki, które były dobre wtedy, pozostają dobre także dziś. Ten aspekt książki dobrze się starzeje. Kiedy nadszedł czas, aby przygotować niniejsze jubileuszowe wydanie, musieliśmy podjąć decyzję. Mogliśmy przejrzeć książkę i zaktualizować technologie, do których się odwoływaliśmy. Drugim podejściem mogło być ponowne przyjęcie założeń dotyczących zalecanych praktyk w świetle dwóch dekad nowych doświadczeń. 4337ebf6db5c7cc89e4173803ef3875a 4 14 Przedmowa do drugiego wydania Ostatecznie zrobiliśmy i jedno, i drugie. W rezultacie ta książka jest czymś w rodzaju statku Tezeusza1. Mniej więcej jedna trzecia tematów w niej jest całkowicie nowa. Większość z reszty została przepisana — częściowo lub całkowicie. Naszym zamiarem było przedstawienie tematów w sposób bardziej czytelny, wyraźniejszy i — mamy nadzieję — bardziej ponadczasowy. Podjęliśmy kilka trudnych decyzji. Usunęliśmy dodatek „Zasoby”, zarówno dlatego, że utrzymanie jego aktualności byłoby niemożliwe, jak i z tego powodu, że łatwiej jest szukać tego, czego potrzebujemy. Zreorganizowaliśmy i przepisaliśmy tematy dotyczące współbieżności. Wzięliśmy przy tym pod uwagę obecną obfitość współbieżnego sprzętu, jak i brak dobrych sposobów jego obsługi. Dodaliśmy treści uwzględniające zmianę postaw i środowisk — począwszy od ruchu Agile, do którego upowszechnienia się przyczyniliśmy, do rosnącej akceptacji idiomów programowania funkcyjnego oraz coraz większej potrzeby uwzględniania aspektów prywatności i bezpieczeństwa. Co ciekawe, było między nami znacznie mniej dyskusji co do zawartości tego wydania, w porównaniu z pierwszą edycją. Obaj czuliśmy, że łatwiej jest teraz zidentyfikować tematy, które są ważne. Niniejsza książka jest rezultatem naszej pracy. Życzymy przyjemnej lektury. Być może warto zacząć stosować kilka nowych praktyk. Być może stwierdzicie, że niektóre rzeczy, które proponujemy, są złe. Zachęcamy do zaangażowania się w rozwój naszego rzemiosła. Podzielcie się z nami Waszymi opiniami. Ale najważniejsze jest to, abyście mieli z tego frajdę. 36 Jak zorganizowano tę książkę Ta książka jest zbiorem stosunkowo krótkich podrozdziałów. Każdy podrozdział jest autonomiczny i dotyczy konkretnego zagadnienia. Poszczególne podrozdziały zawierają też liczne odwołania, które znacznie ułatwiają postrzeganie prezentowanych zagadnień w szerszym kontekście. Zachęcamy do swobodnej lektury tych podrozdziałów w dowolnej kolejności — tej książki nie trzeba czytać od pierwszej do ostatniej strony. Od czasu do czasu można natrafić na ramkę oznaczoną tytułem Wskazówka nr… (na przykład Wskazówka nr 2, „Należy myśleć o tym, co się robi”). Oprócz zwracania szczególnej uwagi na pewne sugestie mamy wrażenie, że wskazówki w tej 1 Jeżeli w ciągu wielu lat napraw wadliwych części wymieniono wszystkie oryginalne elementy statku, to czy w dalszym ciągu jest to ten sam statek? 4337ebf6db5c7cc89e4173803ef3875a 4 Co to z naczy? 15 formie żyją własnym życiem — sami uwzględniamy je w codziennej pracy. Podsumowanie wszystkich wskazówek można znaleźć na wyciąganej karcie na końcu tej książki. Wszędzie tam, gdzie to było możliwe, proponowaliśmy dodatkowe ćwiczenia i wyzwania. O ile odpowiedzi dla ćwiczeń w większości są stosunkowo proste, wyzwania wymagają głębszego zastanowienia. Aby lepiej zilustrować nasz sposób myślenia, odpowiedzi do ćwiczeń zawarliśmy w dodatku, jednak chcemy podkreślić, że tylko niewielka część tych zadań ma tylko jedno poprawne rozwiązanie. Wyzwania mogą stanowić podstawę dla dyskusji w szerszym gronie lub być tematami rozpraw zadawanych słuchaczom zaawansowanych kursów programowania. Zamieściliśmy także krótką bibliografię, w której wymieniliśmy książki i artykuły, do których wyraźnie się odwołujemy. 37 Co to znaczy? „Kiedy używam jakiegoś słowa — powiedział Humpty Dumpty dość nonszalanckim tonem — oznacza ono tyle, co moim zdaniem ma oznaczać, ani więcej, ani mniej”. Lewis Carroll, Alicja po tamtej stronie lustra W rozmaitych miejscach tej książki można znaleźć najróżniejsze przykłady żargonu — niektóre z nich są zupełnie prawidłowymi słowami języka polskiego, którym całkiem niedawno nadano jakieś techniczne znaczenie, inne są raczej przerażającymi zlepkami wyrazów wymyślonymi przez informatyków, którzy zdają się nie zważać na piękno swojego języka. Kiedy po raz pierwszy używamy każdego z tych określeń, staramy się je zdefiniować lub przynajmniej dać jakieś wskazówki co do jego znaczenia. Mimo to jesteśmy pewni, że część niejasnych określeń niezauważenie przedostała się przez to sito, a inne, jak „obiekt” czy „relacyjna baza danych”, są na tyle popularne, że ich definiowanie byłoby po prostu nudne. W razie napotkania nieznanego terminu w żadnym razie nie należy go ignorować. Warto poświęcić trochę czasu na odnalezienie jego znaczenia, czy to w internecie, czy w innej książce informatycznej. Zachęcamy też do informowania nas o podobnych niedopatrzeniach, tak aby w następnym wydaniu nie zabrakło odpowiednich definicji. Po tym wstępie możemy przyznać, że postanowiliśmy zrewanżować się informatykom. Istnieją doskonałe określenia stosowane w żargonie informatyków i dobrze opisujące pewne pojęcia czy zjawiska, a mimo to zdecydowaliśmy o ich ignorowaniu. Dlaczego? Ponieważ istniejący żargon zwykle jest kojarzony z konkretną dziedziną problemu lub fazą wytwarzania. Jednym z najważniejszych założeń, które przyświecało nam podczas pisania tej książki, było proponowanie możliwie uniwersalnych technik — na przykład podział na moduły ma 4337ebf6db5c7cc89e4173803ef3875a 4 16 Przedmowa do drugiego wydania zastosowanie w kodzie, projekcie, dokumentacji i organizacji zespołu. Kiedy próbowaliśmy używać typowego słowa żargonowego w szerszym kontekście, nasze intencje stawały się niejasne — nie mogliśmy poradzić sobie z bagażem oryginalnego kontekstu. W każdym takim przypadku postanawialiśmy zwiększyć swój wkład w upadek języka i wymyślaliśmy własne terminy. 38 Kod źródłowy i inne zasoby Większość kodu źródłowego prezentowanego w tej książce pochodzi z plików źródłowych gotowych do kompilacji, które są dostępne do pobrania pod adresem: ftp://ftp.helion.pl/przyklady/pragp2.zip Znajdziecie także łącza do zasobów, które uważamy za przydatne, a także aktualizacje książki i wiadomości dotyczące innych przedsięwzięć związanych z książką Pragmatyczny programista. 39 Prześlij nam opinię Będziemy wdzięczni za przesyłanie opinii na temat książki. Można wysłać do nas e-mail pod adres redakcja@helion.pl. 40 Podziękowania do wydania drugiego W ciągu ostatnich 20 lat przeprowadziliśmy dosłownie tysiące ciekawych rozmów o programowaniu. Spotykaliśmy ludzi na konferencjach, na kursach, a czasami nawet na pokładach samolotów. Każda z tych rozmów poprawiła nasze zrozumienie procesu programowania i przyczyniła się do aktualizacji wprowadzonych w tym wydaniu. Dziękujemy Wam wszystkim (a jeśli jesteśmy w błędzie, dalej mówcie nam o tym). Dziękujemy uczestnikom procesu beta książki. Wasze pytania i komentarze pomogły nam lepiej wyjaśnić niektóre zagadnienia. Zanim doszliśmy do wersji beta, udostępniliśmy książkę kilku osobom z prośbą o opinie. Dziękujemy VM (Vicky) Brasseur, Jeffowi Langrowi i Kim Shrier za szczegółowe komentarze, a także José Valimowi i Nickowi Cuthbertowi za opinie techniczne. Dziękujemy Ronowi Jeffriesowi za pozwolenie skorzystania z przykładu Sudoku. Jesteśmy wdzięczni pracownikom Wydawnictwa Pearson, którzy zgodzili się, abyśmy stworzyli tę książkę na swój sposób. 4337ebf6db5c7cc89e4173803ef3875a 4 Podziękowan ia do wydania drugiego 17 Specjalne podziękowania kierujemy dla nieocenionej Janet Furlow, która w mistrzowski sposób kieruje wszystkim, co robi, i która dopilnowała, żebyśmy dotrzymali terminu. Na koniec dziękujemy wszystkim pragmatycznym programistom, dzięki którym programowanie w ciągu ostatnich dwudziestu lat stało się lepsze dla wszystkich. Mamy nadzieję, że niniejsze wydanie zapoczątkuje kolejne takie dwadzieścia lat. 4337ebf6db5c7cc89e4173803ef3875a 4 18 Przedmowa do drugiego wydania 4337ebf6db5c7cc89e4173803ef3875a 4 Z przedmowy do pierwszego wydania Ta książka pomoże czytelnikowi zostać lepszym programistą. Nie ma znaczenia, czy czytelnik jest wolnym strzelcem, członkiem wielkiego zespołu projektowego, czy konsultantem równocześnie współpracującym z wieloma klientami. Ta książka pomoże każdemu w lepszym wykonywaniu swojej pracy. Ta książka nie jest zbiorem teorii — koncentrujemy się raczej na tematach praktycznych, na efektywnym wykorzystywaniu własnych doświadczeń do podejmowania lepszych decyzji. Słowo pragmatyczny pochodzi od łacińskiego wyrazu pragmaticus („sprawny w działaniu”), który z kolei pochodzi od greckiego słowa pragmatikós oznaczającego „do zrobienia”. Ta książka jest właśnie o robieniu. Programowanie jest rzemiosłem. W najprostszej postaci sprowadza się do zmuszania komputera do robienia tego, czego chcemy (lub czego chce użytkownik). Jako programiści jesteśmy po części słuchaczami, po części doradcami, po części tłumaczami i po części dyktatorami. Próbujemy gromadzić ulotne, trudne do sformułowania wymagania i znajdować sposoby ich wyrażania w sposób zrozumiały dla zwykłej maszyny. Staramy się tak dokumentować naszą pracę, aby inni mogli ją zrozumieć, i jednocześnie próbujemy stosować metody inżynierskie, tak aby na bazie naszych dokonań inni mogli budować własne rozwiązania. Co więcej, próbujemy robić to wszystko wbrew bezlitosnym wskazówkom zegara bieżącego projektu. Każdego dnia dokonujemy małych cudów. To trudna praca. Wiele osób oferuje nam pomoc. Twórcy narzędzi przekonują o niewiarygodnych możliwościach swoich produktów. Specjaliści od metodyk obiecują, że ich techniki gwarantują doskonałe efekty. Każdy twierdzi, że jego język programowania 4337ebf6db5c7cc89e4173803ef3875a 4 20 Z przedmowy do pierwszego wydania jest najlepszy i że jego system operacyjny jest pierwszą skuteczną odpowiedzią na wszystkie znane choroby. Żadne z tych zapewnień oczywiście nie jest prawdziwe. Nie istnieją proste odpowiedzi. Nie istnieje jedno najlepsze rozwiązanie, czy to w formie narzędzia, języka programowania bądź systemu operacyjnego. Mogą co najwyżej istnieć systemy, które w konkretnych okolicznościach sprawdzają się lepiej od konkurencyjnych produktów. Właśnie tutaj potrzebny jest pragmatyzm. Nie powinniśmy wiązać swojej kariery z żadną konkretną technologią — musimy raczej dbać o stały rozwój swojej wiedzy i gromadzenie doświadczeń niezbędnych do wybierania właściwych rozwiązań w różnych sytuacjach. Nasza wiedza wynika z rozumienia podstawowych zasad informatyki, zaś nasze doświadczenie bierze się z wielu różnych praktycznych projektów. O naszej sile decyduje połączenie teorii i praktyki. Musimy dostosowywać swoje postępowanie do bieżących okoliczności i środowiska, w którym aktualnie pracujemy. Musimy rozstrzygać względne znaczenie wszystkich czynników wpływających na projekt i wybierać najwłaściwsze rozwiązania na podstawie swoich doświadczeń. Co więcej, musimy robić to nieustannie wraz z postępem prac nad projektem. Pragmatyczni programiści doprowadzają sprawy do końca i robią to dobrze. 36 Kto powinien przeczytać tę książkę? Książka jest kierowana do programistów zainteresowanych poprawą swojej efektywności i produktywności. Część programistów jest sfrustrowana wrażeniem niepełnego wykorzystania swojego potencjału. Inni programiści z zazdrością obserwują kolegów po fachu, którzy sprawiają wrażenie, jakby korzystali z narzędzi zapewniających wyższą wydajność. Jeszcze inni używają obecnie starszych technologii i chcą wiedzieć, jak nowe rozwiązania i koncepcje sprawdziłyby się w ich pracy. Nie udajemy, że znamy wszystkie (ani nawet większość) odpowiedzi. Nie twierdzimy też, że nasze pomysły sprawdzają się we wszystkich sytuacjach. Możemy za to zagwarantować, że postępowanie według naszych zaleceń pozwoli błyskawicznie zdobywać nowe doświadczenia, podniesie produktywność programisty i umożliwi lepsze rozumienie całego procesu wytwarzania. Czytelnik będzie też pisał lepsze oprogramowanie. 4337ebf6db5c7cc89e4173803ef3875a 4 Co decyduje o byciu pragmatycznym programistą? 37 21 Co decyduje o byciu pragmatycznym programistą? Każdy programista jest inny, ma własne mocne strony i słabości, preferencje i uprzedzenia. Z czasem każdy programista wypracowuje także własne środowisko pracy. Wspomniane środowisko odzwierciedla indywidualne cechy programisty równie mocno jak jego hobby, ubiór czy fryzura. Pragmatyczni programiści mają jednak pewne cechy wspólne (a przynajmniej większość z wymienionych poniżej): Szybkie sprawdzanie nowinek, błyskawiczne dostosowywanie warsztatu. Pragmatyczni programiści instynktownie poszukują nowych technologii i technik — wprost uwielbiają eksperymentować z nowinkami. Kiedy tylko trafi w ich ręce coś nowego, potrafią błyskawicznie opanować nowe rozwiązania i zintegrować je z resztą swojej wiedzy. Ocena poszczególnych nowości wynika z doświadczenia. Dociekliwość. Pragmatyczni programiści zadają pytania. To ciekawe — jak to zrobiłeś? Miałeś jakieś problemy z tą biblioteką? Czym właściwie jest ten BeOS, o którym tyle słyszałem? Jak zaimplementowano dowiązania symboliczne? Pragmatyczny programista jest prawdziwym kolekcjonerem faktów — każda taka informacja może wpłynąć na jego decyzję wiele lat po jej zdobyciu. Krytyczne myślenie. Pragmatyczni programiści rzadko akceptują otrzymywane informacje bez zapoznania się z faktami. Kiedy nasi koledzy mówią „tak to jest zrobione i już” lub kiedy jakiś producent obiecuje rozwiązanie wszystkich naszych problemów, od razu wiemy, że trzeba to dobrze sprawdzić. Realizm. Pragmatyczni programiści próbują zrozumieć naturę każdego problemu, z którym muszą się zmierzyć. Realizm pozwala nam dość dobrze szacować trudność poszczególnych zadań i — tym samym — czas trwania planowanych czynności. Świadomość poziomu złożoności procesu i czasu potrzebnego do jego zakończenia pozwala nam wytrwale dążyć do celu. Gotowość do nowych wyzwań. Pragmatyczni programiści starają się poznawać najróżniejsze technologie i środowiska. Robią, co w ich mocy, aby na bieżąco poznawać nowe technologie i modele wytwarzania. Nawet jeśli aktualny projekt wymaga specjalizacji w określonej dziedzinie, pragmatyczni programiści zawsze są gotowi do pracy w odmiennych obszarach i przyjmowania nowych wyzwań. Najbardziej podstawowe cechy zostawiliśmy sobie na koniec. Te dwie cechy łączą wszystkich pragmatycznych programistów. Są na tyle proste, że można je wyrazić w formie wskazówek: 4337ebf6db5c7cc89e4173803ef3875a 4 22 Z przedmowy do pierwszego wydania WSKAZÓWKA NR 1 Należy dbać o swoje rzemiosło. Uważamy, że tworzenie oprogramowania nie ma najmniejszego sensu, jeśli programista nie dba o jakość swoich produktów. WSKAZÓWKA NR 2 Należy myśleć o tym, co się robi. Warunkiem bycia pragmatycznym programistą jest ustawiczne myślenie o tym, co się robi, przede wszystkim w trakcie tych czynności. Nie chodzi o jednorazowy audyt bieżących praktyk — powinniśmy raczej krytycznie oceniać każdą podejmowaną decyzję w codziennej pracy i podczas wszystkich czynności związanych z wytwarzaniem. Nigdy nie możemy pozwolić sobie na lot z włączonym autopilotem. Musimy stale myśleć i krytycznie oceniać swoją pracę w czasie rzeczywistym. Stare motto korporacyjne obowiązujące w firmie IBM, MYŚL!, jest też mantrą pragmatycznych programistów. Jeśli ustawiczne ocenianie własnych decyzji wydaje nam się trudne, możemy być niemal pewni, że spełniamy warunek realizmu. Myślenie rzeczywiście będzie wymagało trochę cennego czasu — czasu, który już teraz jest przedmiotem poważnych nacisków. Nagrodą będzie jeszcze większe zaangażowanie w pracę, którą kochamy, świadomość świetnej znajomości coraz większej liczby zagadnień oraz przyjemne uczucie ciągłego doskonalenia umiejętności. Z czasem zainwestowany czas zwróci się z nawiązką, kiedy my i nasz zespół staniemy się bardziej efektywni, tworzony przez nas kod będzie łatwiejszy w konserwacji, a my sami będziemy tracili dużo mniej czasu na nudnych spotkaniach. 38 Pojedynczy pragmatycy, wielkie zespoły Niektórzy sądzą, że w wielkich zespołach lub podczas realizacji złożonych projektów nie ma miejsca na indywidualności. „Tworzenie oprogramowania to zadanie typowo inżynierskie, którego realizacja jest niemożliwa, jeśli poszczególni członkowie zespołu sami podejmują decyzje”. To nieprawda. Budowa oprogramowania rzeczywiście powinna być przedsięwzięciem inżynierskim. Inżynierski charakter projektu nie wyklucza jednak rzemiosła członków zespołu projektowego. Warto przywołać przykład wielkich katedr budowanych w średniowiecznej Europie. Każda z nich wymagała tysięcy roboczolat, a budowa 4337ebf6db5c7cc89e4173803ef3875a 4 To proces bez końca 23 jednego obiektu nierzadko zajmowała wiele dekad. Lekcje z kolejnych etapów były wykorzystywane przez następne zastępy budowniczych, których osiągnięcia stopniowo przyczyniały się do rozwoju dziedziny mechaniki konstrukcji. Stolarze, kamieniarze, rzeźbiarze i szklarze byli jednak rzemieślnikami, którzy na swój sposób interpretowali wymagania inżynierskie, aby na tej podstawie stworzyć pewną całość — dzieło nieporównanie ciekawsze od czysto mechanicznego aspektu konstrukcji. O sukcesie całych projektów decydowała wiara budowniczych w znaczenie ich indywidualnego wkładu: My, którzy wydobywamy zwykłe kamienie, zawsze musimy mieć przed oczami katedry. W ramach ogólnej struktury projektu zawsze istnieje przestrzeń dla indywidualności i rzemiosła. Możliwości w tym względzie są szczególnie widoczne na bieżącym etapie rozwoju inżynierii oprogramowania. Nawet jeśli za sto lat nasze współczesne techniki będą wyglądały równie archaicznie co metody stosowane przez średniowiecznych budowniczych katedr w oczach współczesnych inżynierów budownictwa, nasze rzemiosło wciąż będzie doceniane. 39 To proces bez końca Turysta zwiedzający angielski Eton College zapytał ogrodnika, jak to możliwe, że trawa jest zawsze tak równo skoszona. — To proste — odpowiedział — wystarczy lekko podlewać codziennie rano, kosić co drugi dzień i walcować raz w tygodniu. — To wszystko? — zapytał zdziwiony turysta. — Oczywiście — odrzekł ogrodnik. — Rób tak przez 500 lat, a też będziesz miał piękny trawnik. Piękne trawniki wymagają prostych codziennych, choćby niewielkich, nakładów pracy — tak samo jest ze świetnymi programistami. Konsultanci zajmujący się zarządzaniem lubią mówić o ciągłym doskonaleniu (jap. kaizen). Kaizen to japoński termin określający ustawiczne wprowadzanie drobnych udoskonaleń. Uważa się, że właśnie ta filozofia jest jednym z powodów ogromnego wzrostu produktywności i jakości japońskiego przemysłu, stąd jej powszechne powielanie na całym świecie. Filozofia kaizen ma zastosowanie także w przypadku jednostek. Wystarczy codziennie pracować nad doskonaleniem swoich dotychczasowych umiejętności i uzupełniać swój warsztat o nowe narzędzia. Inaczej niż w przypadku trawników w Eton, pierwsze efekty będą widoczne już w ciągu kilku dni. Po latach ze zdziwieniem odkryjemy wprost niewiarygodny wzrost swojego doświadczenia i poprawę umiejętności. 4337ebf6db5c7cc89e4173803ef3875a 4 24 Z przedmowy do pierwszego wydania 4337ebf6db5c7cc89e4173803ef3875a 4 Rozdział 1. Filozofia pragmatyczna Ta książka jest o Tobie. Nie popełnij błędu. Tu chodzi o Twoją karierę, a co ważniejsze, to jest Twoje życie. Należy do Ciebie. Czytasz tę książkę, bo wiesz, że możesz stać się lepszym programistą, a także pomagać innym, aby również stali się lepszymi. Możesz stać się pragmatycznym programistą. Co wyróżnia pragmatycznych programistów? Czujemy, że pragmatyzm to postawa, styl, filozofia postrzegania i rozwiązywania problemów. Pragmatyczni programiści wykraczają myślami poza bieżące, aktualnie rozwiązywane problemy, stale próbując sytuować te problemy w szerszym kontekście, aby dysponować pełnym obrazem analizowanej rzeczywistości. Czy bez świadomości tego szerszego kontekstu w ogóle możemy być pragmatyczni? Jak w takiej sytuacji mielibyśmy wypracowywać inteligentne kompromisy i podejmować świadome decyzje? Innym kluczem do sukcesu pragmatycznych programistów jest gotowość do brania odpowiedzialności za wszystko, co robią — to zagadnienie zostanie omówione w podrozdziale „Kot zjadł mój kod źródłowy”. Odpowiedzialność oznacza, że pragmatyczni programiści nie siedzą bezczynnie, obserwując, jak ich projekty zmierzają ku nieuchronnej klęsce. W podrozdziale „Entropia oprogramowania” zostaną omówione sposoby dbania o nieskazitelność projektów. Większość ludzi z trudem akceptuje zmiany — niechęć do zmian w pewnych przypadkach jest w pełni uzasadniona, ale nierzadko wynika ze zwykłego marazmu. W podrozdziale „Zupa z kamieni i gotowane żaby” przeanalizujemy strategię inspirowania zmian i przedstawimy (dla równowagi) pouczającą opowieść o płazie, który ignorował niebezpieczeństwa związane ze stopniowymi zmianami. Jedną z korzyści wynikających ze znajomości i rozumienia kontekstu, w którym pracujemy, jest łatwiejsza ocena tego, na ile dobre musi być tworzone przez nas oprogramowanie. W pewnych przypadkach jedynym akceptowanym stanem jest jakość bliska perfekcji, ale często możliwe są daleko idące kompromisy. 4337ebf6db5c7cc89e4173803ef3875a 4 26 Rozdział 1. Filozofia pragmatyczna Tym zagadnieniem zajmiemy się w podrozdziale „Odpowiednio dobre oprogramowanie”. Zapanowanie nad tym wszystkim wymaga, oczywiście, szerokiej wiedzy i sporego doświadczenia. Uczenie się jest typowym przykładem procesu ciągłego, który nigdy się nie kończy. W podrozdziale „Portfolio wiedzy” omówimy pewne strategie zachowywania właściwego tempa zdobywania wiedzy i umiejętności. I wreszcie, nikt z nas nie pracuje w próżni. Wszyscy spędzamy znaczną część swojego czasu na interakcji ze współpracownikami. W podrozdziale „Komunikuj się!” zostaną omówione sposoby doskonalenia zasad współpracy. Programowanie pragmatyczne rozciąga się od filozofii do myślenia pragmatycznego. W tym rozdziale skoncentrujemy się na podstawach filozofii. 1 36 To jest Twoje życie Nie żyję w tym świecie, aby sprostać Waszym oczekiwaniom, a Wy nie żyjecie w nim po to, by sprostać moim. Bruce Lee To jest Twoje życie. Należy do Ciebie. Ty sam je przeżywasz. Ty sam je tworzysz. Rozmawiamy z wieloma sfrustrowanymi programistami. Mają różne punkty widzenia. Niektórzy odczuwają, że w ich pracę wkradła się stagnacja, inni — że technologia przeszła obok nich. Ludzie czują się niedostatecznie doceniani albo niedostatecznie opłacani, albo że ich zespoły są toksyczne. Niektórzy chcieliby przenieść się do Azji lub Europy, albo pracować z domu. Nasza odpowiedź jest zawsze taka sama. „Dlaczego tego nie zmienisz?”. Wytwarzanie oprogramowania powinno znaleźć się blisko szczytu listy karier, nad którymi masz kontrolę. Nasze umiejętności są poszukiwane, nasza wiedza przekracza granice geograficzne, możemy pracować zdalnie. Jesteśmy dobrze opłacani. Naprawdę możemy zrobić niemal wszystko, czego chcemy. Ale z jakiegoś powodu programiści są niechętni zmianom. Zamykają się w sobie w nadziei, że sytuacja się poprawi. Biernie godzą się na to, że ich umiejętności stają się przestarzałe i skarżą się, że ich firmy nie wysyłają ich na szkolenia. Jadąc autobusem przyglądają się ogłoszeniom z egzotycznych miejsc, a następnie wychodzą z autobusu na chłód i deszcz i brną do pracy. Oto najważniejsza wskazówka w tej książce. 4337ebf6db5c7cc89e4173803ef3875a 4 Kot zjadł mój kod źródłowy 27 WSKAZÓWKA NR 3 Masz w sobie możliwości sprawcze. Denerwuje Cię środowisko, w którym pracujesz? Twoja praca jest nudna? Spróbuj temu zaradzić. Ale nie próbuj w nieskończoność. Jak mówi Martin Fowler: „Można zmienić firmę lub zmienić swoją firmę”1. Jeśli wydaje Ci się, że technologia przechodzi obok Ciebie, znajdź czas (w swoim czasie wolnym) na studiowanie nowych zagadnień, które wydają się interesujące. Inwestujesz w siebie, więc robienie tego w czasie wolnym jest bardzo rozsądne. Chcesz pracować zdalnie? A czy zapytałeś? Jeśli powiedzą Ci „nie”, to znajdź kogoś, kto powie Ci „tak”. Ta branża daje bardzo wiele szans. Bądź proaktywny i z nich skorzystaj. Pokrewne podrozdziały 2 37 Temat 4., „Zupa z kamieni i gotowane żaby”. Temat 6., „Portfolio wiedzy”. Kot zjadł mój kod źródłowy Największą słabością jest strach przed wyglądaniem na słabego. J.B. Bossuet, Politics from Holy Writ, 1709 Jednym z największych elementów filozofii pragmatycznej jest idea brania odpowiedzialności zarówno za siebie, jak i za skutki podejmowanych przez siebie działań (w wymiarze całej kariery, bieżącego projektu i codziennej pracy). Pragmatyczny programista bierze we własne ręce losy swojej kariery i nie boi się przyznać do braku wiedzy czy popełnionego błędu. Przyznawanie się do usterek z pewnością nie jest najprzyjemniejszym aspektem programowania, ale błędy są nieodłączną częścią tej pracy (nawet w najlepszych projektach). Błędy zdarzają się mimo gruntownych testów, dobrej dokumentacji i właściwie zaplanowanej automatyzacji. Dotrzymanie terminu bywa niewykonalne. Programiści napotykają nieprzewidywalne problemy techniczne. Wspomniane zjawiska po prostu się zdarzają, a rolą programistów jest możliwie profesjonalne radzenie sobie w trudnych sytuacjach. W tym przypadku profesjonalizm wymaga uczciwego i bezpośredniego stawiania sprawy. Mimo dumy ze swoich umiejętności musimy mieć odwagę uczciwego przyznawania się do słabszych stron, w tym braku wiedzy oraz popełnianych błędów. 1 http://wiki.c2.com/?ChangeYourOrganization 4337ebf6db5c7cc89e4173803ef3875a 4 28 Rozdział 1. Filozofia pragmatyczna Zaufanie zespołu Przede wszystkim Twój zespół musi być w stanie Ci zaufać i na Tobie polegać. Ty także powinieneś móc polegać na każdym członku Twojego zespołu. Według literatury naukowej2 zaufanie w zespole jest absolutnie niezbędne dla zapewnienia kreatywności i współpracy. W zdrowym, opartym na zaufaniu środowisku, można bezpiecznie mówić to, co się myśli, przedstawiać swoje pomysły i polegać na członkach zespołu, którzy mogą z kolei polegać na Tobie. Co by było, gdyby nie było zaufania? Cóż… Wyobraźmy sobie, że tajny zespół high-tech ninja pracuje zawzięcie nad infiltracją siedziby złoczyńcy. Po miesiącach planowania i prób uruchamiania kodu wreszcie wszystko jest gotowe. Teraz Twoja kolej, aby skonfigurować siatkę naprowadzania laserowego. Mówisz: „Przepraszam was, ale nie mam lasera. Kot bawił się czerwoną kropką i zostawiłem laser w domu”. Tego rodzaju nadużycie zaufania może być trudne do naprawienia. Bierz odpowiedzialność Odpowiedzialność to cecha powszechnie uznawana za pożądaną. Mimo zaangażowania i dbałości o możliwie najlepszą realizację zadania nie zawsze mamy pełną kontrolę nad wszystkimi aspektami naszej pracy. Oprócz jak najlepszego wykonywania własnych działań musimy więc analizować sytuację pod kątem czynników ryzyka wykraczających poza naszą kontrolę. Odrzucenie odpowiedzialności jest usprawiedliwione tylko wtedy, gdy sytuacja uniemożliwia nam prawidłową realizację zadania lub gdy czynniki ryzyka są zbyt poważne. Ocena należy do samego programisty i zależy od jego zasad etycznych i osądu sytuacji. Jeśli bierzemy na siebie odpowiedzialność za efekt podejmowanych działań, musimy być przygotowani na wszelkie konsekwencje. W razie popełnienia błędu (a wszyscy je popełniamy) lub błędnej oceny sytuacji musimy uczciwie przyznać się do porażki i zaproponować rozwiązanie. Nie należy winić za własne niedociągnięcia współpracowników ani innych czynników. Nie powinniśmy też szukać usprawiedliwień. Nie należy zrzucać winy za wszystkie problemy na producentów narzędzi, język programowania, przełożonych ani współpracowników. Każdy z tych elementów mógł, oczywiście, przyczynić się do powstałej sytuacji, jednak rolą programisty nie jest szukanie usprawiedliwień, tylko tworzenie rozwiązań. Jeśli istnieje ryzyko niedostarczenia niezbędnych składników przez kogoś innego, należy przygotować odpowiedni plan awaryjny. Jeśli wskutek awarii dysku programista traci cały kod źródłowy i jeśli nie dysponuje kopią zapasową, wina 2 Wartościową metaanalizę dotyczącą zaufania i wydajności zespołów można znaleźć w artykule: A meta-analysis of main effects, moderators, and covariates, http://dx.doi. org/10.1037/apl0000110. 4337ebf6db5c7cc89e4173803ef3875a 4 Kot zjadł mój kod źródłowy 29 leży wyłącznie po jego stronie. Problemu nie da się rozwiązać, wmawiając szefowi, że kod źródłowy został zjedzony przez kota. WSKAZÓWKA NR 4 Proponuj rozwiązania, zamiast posługiwać się kiepskimi wymówkami. Zanim udamy się do kogokolwiek, aby tłumaczyć, dlaczego wykonanie jakiejś czynności jest niemożliwe, dlaczego nie możemy dotrzymać terminu lub dlaczego coś nie spełnia początkowych wymagań, warto zatrzymać się na chwilę i wsłuchać się we własne wyjaśnienia. Warto porozmawiać z gumową kaczką na monitorze, kotem lub czymkolwiek innym. Czy te wyjaśnienia brzmią logicznie, czy po prostu głupio? Jak będą brzmiały dla naszego przełożonego? Warto przeprowadzić tę rozmowę we własnej głowie. Jakiej odpowiedzi spodziewamy się po rozmówcy? Czy zapyta: „Próbowałeś tego? Nie pomyślałeś o tym”? Jak wtedy odpowiemy? Czy możemy zrobić coś jeszcze, zanim udamy się do przełożonego ze złymi nowinami? W pewnych przypadkach z góry wiadomo, co powie przełożony, zatem warto oszczędzić mu zmartwień. Zamiast usprawiedliwień należy raczej przygotować propozycje rozwiązań. Nie należy mówić o tym, czego nie można zrobić — powinniśmy raczej koncentrować się na tym, co zrobić, aby wyjść z kłopotliwej sytuacji. Czy porzucenie dotychczasowego kodu rzeczywiście jest konieczne? Warto przedstawić rozmówcy zalety refaktoryzacji (patrz temat 40., „Refaktoryzacja”). Czy planujemy poświęcić czas na przygotowanie prototypu, który ułatwi nam wypracowanie najlepszego rozwiązania (patrz temat 13., „Prototypy i karteczki samoprzylepne”)? Czy w przyszłości będzie można uniknąć podobnych sytuacji, jeśli zostaną wdrożone lepsze procedury testowania (patrz temat 41., „Kod łatwy do testowania” i podrozdział „Bezlitosne testy”) lub rozwiązania w zakresie automatyzacji. Być może właściwym rozwiązaniem będzie zapewnienie dodatkowych zasobów. A może trzeba poświęcić więcej czasu użytkownikom? A może wszystko leży w Twoich rękach: być może powinieneś dokładniej nauczyć się jakiejś techniki lub technologii? Może trzeba sięgnąć po książkę lub zapisać się na kurs? Programista nie powinien obawiać się zadawania pytań ani przyznawania do tego, że potrzebuje pomocy. Warto próbować eliminować kiepskie wymówki jeszcze przed ich głośnym wypowiedzeniem. A jeśli odczuwamy nieodpartą potrzebę ich wyrażenia, przedstawmy te usprawiedliwienia raczej swojemu kotu. Skoro mały Mruczek jest skłonny wziąć winę na siebie, dlaczego nie skorzystać z tej okazji… Pokrewne podrozdziały Temat 49., „Pragmatyczne zespoły”. 4337ebf6db5c7cc89e4173803ef3875a 4 30 Rozdział 1. Filozofia pragmatyczna Wyzwania 3 38 Jak reagujemy na kiepskie wymówki stosowane przez innych (pracowników banku, mechaników samochodowych, urzędników itp.) podczas rozmowy z nami? Jaki jest wpływ tych wymówek na naszą ocenę rozmówców i organizacji, w których pracują? Gdy kiedykolwiek znajdziesz się w sytuacji, że będziesz zmuszony odpowiedzieć: „Nie wiem”, pamiętaj, aby dodać „ale się dowiem”. To świetny sposób, aby się przyznać, że czegoś się nie wie, a następnie by przyjąć na siebie odpowiedzialność, tak jak powinien postąpić profesjonalista. Entropia oprogramowania Chociaż wytwarzanie oprogramowania jest odporne na niemal wszystkie prawa fizyki, akurat zjawisko entropii jest dla programistów aż nadto odczuwalne. Entropia to termin zaczerpnięty z fizyki i opisujący stopień nieokreśloności, chaotyczności w systemie. Prawa termodynamiki mówią, niestety, że entropia we wszechświecie zmierza do osiągnięcia pewnego maksimum. Kiedy zjawisko chaosu zaczyna nasilać się w świecie oprogramowania, mamy do czynienia ze zjawiskiem określanym mianem rozkładu oprogramowania (ang. software rot). Niektórzy to samo zjawisko określają bardziej optymistycznym terminem „dług techniczny”, z domniemanym założeniem, że pewnego dnia go spłacą. Prawdopodobnie im się to nie uda. Niezależnie od nazwy, zarówno dług techniczny, jak i rozkład oprogramowania, mogą nasilać się w niekontrolowany sposób. Istnieje wiele czynników, które mogą się składać na rozkład oprogramowania. Bodaj najważniejszym czynnikiem są cechy psychologiczne (lub kulturowe) osób zaangażowanych w realizację projektu. Nawet w przypadku jednoosobowego „zespołu” psychologia projektu może okazać się bardzo delikatna. Nawet najlepsze plany i najlepsi ludzie nie wystarczą do zabezpieczenia projektu przed ruiną i upadkiem w całym czasie życia. Co ciekawe, istnieją projekty, które wbrew licznym utrudnieniom i mimo pasma niepowodzeń zadziwiająco skutecznie radzą sobie z naturalnym dążeniem do nieporządku i doskonale znoszą upływ czasu. Co decyduje o tej różnicy? W centrach miast tylko część budynków jest pięknych i zadbanych, podczas gdy pozostałe to rozpadające się rudery. Dlaczego? Badacze zjawiska przestępczości i degradacji życia społecznego w miastach odkryli bardzo ciekawy mechanizm, który może sprawić, że czysty, zadbany, zamieszkany budynek błyskawicznie pustoszeje i zaczyna niszczeć3. 3 Patrz The police and neighborhood safety [WH82]. 4337ebf6db5c7cc89e4173803ef3875a 4 Entropia oprogramowania 31 Wystarczy jedna wybita szyba. Jedna wybita szyba pozostawiona nienaprawiona przez dłuższy czas powoduje, że mieszkańcy zaczynają traktować swój dom jako opuszczone, porzucone miejsce, które nie może liczyć na właściwą opiekę wyznaczonych do tego podmiotów. Niedługo potem zostaje zbita kolejna szyba. Ludzie zaczynają pozostawiać śmieci w przypadkowych miejscach. Na murach pojawia się graffiti. Po jakimś czasie budynek jest już narażony na poważne uszkodzenia strukturalne. W stosunkowo krótkim okresie budynek znajduje się w stanie, w którym przywrócenie dawnej świetności traci sens ekonomiczny, zatem pierwotne wrażenie porzucenia staje się rzeczywistością. Dlaczego ta jedna wybita szyba stwarza tak wielką różnicę? Z badań psychologów4 wynika, że pokazanie stanu beznadziejności może być zaraźliwe. Weźmy za przykład rozprzestrzenianie się wirusa grypy w dużych skupiskach ludzkich. Ignorowanie wyraźnie nieprawidłowej sytuacji wzmacnia pogląd, że prawdopodobnie nic nie można naprawić, że nikogo to nie interesuje, wszystko jest przesądzone. Pojawiają się negatywne myśli, które mogą rozprzestrzeniać się między członkami zespołu, tworząc błędne koło. WSKAZÓWKA NR 5 Nie akceptuj żadnej wybitej szyby. Nie należy pozostawiać nienaprawionej żadnej wybitej szyby (złego projektu, niewłaściwej decyzji, kiepskiego kodu itp.). Każdą taką „szybę” należy wymienić zaraz po odkryciu problemu. Jeśli brakuje czasu na wstawienie nowej szyby, należy wstawić w jej miejsce choćby dyktę. W większości przypadków można sobie pozwolić na umieszczenie problematycznego kodu w komentarzu, wyświetlenie komunikatu „Do zaimplementowania” lub celowe wstawienie błędnych danych, które na pewno zwrócą uwagę programistów. Należy też podjąć działania nie tylko na rzecz ograniczenia ryzyka wywołania dalszych szkód przez wykrytą usterkę, ale też w celu dowiedzenia, że panujemy nad sytuacją. Każdy z nas widział piękne, w pełni funkcjonalne systemy, które rozsypywały się jak domek z kart, kiedy tylko pojawiły się „wybite szyby”. Istnieją też inne czynniki prowadzące do rozkładu oprogramowania (wrócimy do nich w dalszej części tej książki), ale nic tak nie przyspiesza tego zjawiska jak zaniedbania. Część programistów sądzi, że konsekwentne sprzątanie rozbitego szkła i wstawianie nowych szyb jest niewykonalne — że nikt nie ma na to czasu podczas realizacji projektu. Programiści, którzy podzielają ten pogląd, powinni albo zaopatrzyć się w duży kubeł na śmieci, albo zmienić sąsiedztwo. Nie pozwólmy entropii wygrać. 4 Patrz Contagious depression: Existence, specificity to depressed symptoms, and the role of reassurance seeking [Joi94]. 4337ebf6db5c7cc89e4173803ef3875a 4 32 Rozdział 1. Filozofia pragmatyczna Po pierwsze nie szkodzić Andy miał kiedyś znajomego, który był nieprzyzwoicie bogaty. Jego dom był pełen bezcennych antyków, dzieł sztuki itp. Pewnego dnia zapalił się gobelin zawieszony zbyt blisko kominka w salonie. Wezwano na pomoc straż pożarną, aby zapobiec katastrofie. Mimo szalejącego pożaru strażacy poprzedzili rozwinięcie swoich wielkich, brudnych węży starannym rozłożeniem mat pomiędzy drzwiami wejściowymi a źródłem ognia. W ten sposób zabezpieczyli dywan przed niepotrzebnym zabrudzeniem. Opisany scenariusz z pewnością jest mocno przesadzony. Zapewne najważniejszym priorytetem straży pożarnej powinno być ugaszenie pożaru, bez względu na inne szkody. Jednak straż pożarna najwyraźniej właściwie oceniła sytuację. Strażacy byli przekonani o zdolności do zapanowania nad ogniem i zastosowali środki ostrożności w celu uniknięcia niepotrzebnych zniszczeń w budynku. W podobny sposób należy postępować z oprogramowaniem: nie należy powodować dodatkowych szkód tylko dlatego, że powstał jakiś kryzys. Jedna wybita szyba to o jedna za dużo. Jedna wybita szyba (źle zaprojektowany fragment kodu, błędna decyzja kierownictwa utrudniająca pracę zespołu w całym okresie realizacji projektu itp.) w zupełności wystarczy do rozpoczęcia procesu degradacji. Programista zaangażowany w projekt, w którym aż roi się od takich „wybitych szyb”, nie może pogodzić się z sytuacją i przyjąć postawy: „Skoro cała reszta kodu nadaje się do kosza, po co mój kod miałby się czymś wyróżniać?”. Jakość projektu do momentu, w którym trafia do rąk programisty, nie powinna mieć żadnego znaczenia. W oryginalnym projekcie, który doprowadził do powstania teorii wybitej szyby, porzucony samochód początkowo stał nietknięty przez tydzień. Wystarczyło jednak wybicie jednej szyby, aby w ciągu zaledwie paru godzin samochód został rozebrany niemal do gołej karoserii. Podobnie, programista dołączający do zespołu realizującego projekt, którego kod jest przykładem nieskazitelnego piękna (jest wyjątkowo przejrzysty, doskonale zaprojektowany i elegancki), najprawdopodobniej będzie robił wszystko, aby niczego nie zepsuć (jak strażacy w przytoczonej anegdocie). Nawet w warunkach szalejącego pożaru (zbliżającego się terminu, nadchodzącej daty wydania, planowanej prezentacji itp.) nikt nie chce być pierwszą osobą, która zakłóci dotychczasową harmonię. Po prostu sobie powiedz: „nie ma przyzwolenia na wybite szyby”. Pokrewne podrozdziały Temat 10., „Ortogonalność”. Temat 40., „Refaktoryzacja”. Temat 44., „Nazewnictwo”. 4337ebf6db5c7cc89e4173803ef3875a 4 Zupa z kamieni i gotowane żaby 33 Wyzwania 4 39 Warto pomóc we wzmocnieniu swojego zespołu poprzez dokonanie gruntownego przeglądu otoczenia, w którym pracuje. Należy wybrać dwie lub trzy „wybite szyby”, po czym omówić ze współpracownikami źródła poszczególnych problemów i możliwe rozwiązania. Czy potrafimy wskazać szybę, która została wybita jako pierwsza? Jaka była nasza reakcja po jej pierwszym wykryciu? Jeśli ta „wybita szyba” wynika z decyzji kogoś innego lub polecenia kierownictwa, jak możemy temu zaradzić? Zupa z kamieni i gotowane żaby Trzej głodni żołnierze wracali z wojny. Kiedy zobaczyli na swojej drodze wioskę, byli pełni nadziei — byli przekonani, że mieszkańcy osady zaproszą ich na posiłek. Po dotarciu na miejsce odkryli jednak, że wszystkie drzwi i okna są pozamykane. Po wieloletniej wojnie mieszkańcy wsi dysponowali niewielką ilością jedzenia i pilnie strzegli swoich zapasów. Zdeterminowani żołnierze zagotowali kocioł wody i ostrożnie włożyli do wrzątku trzy kamienie. Zaskoczeni wieśniacy zaczęli wychodzić ze swoich domów i obserwować poczynania żołnierzy. — To jest zupa z kamieni — wyjaśnili żołnierze. — To wszystko, co do niej włożycie? — dopytywali mieszkańcy osady. — Oczywiście, chociaż są tacy, którzy twierdzą, że zupa jest jeszcze lepsza z kilkoma marchewkami. — Jeden z wieśniaków natychmiast pobiegł do swojego domu, by po chwili przynieść kosz pełen marchwi. Kilka minut później wieśniacy znowu zaczęli dopytywać: — To naprawdę wszystko? — Cóż — odpowiedzieli żołnierze — kilka ziemniaków na pewno by nie zaszkodziło. — Po chwili inny wieśniak przybiegł z workiem ziemniaków. Po godzinie żołnierze dysponowali składnikami, które w zupełności wystarczyły do przyrządzenia wymarzonej zupy: wołowiną, porem, solą i ziołami. Za każdym razem inny mieszkaniec wsi plądrował własną spiżarnię w poszukiwaniu składnika wskazanego przez żołnierzy. Ostatecznie udało się ugotować całkiem spory kocioł doskonałej zupy. Żołnierze wyjęli z zupy kamienie, po czym usiedli wraz z mieszkańcami wsi, aby wspólnie celebrować pierwszy pełnowartościowy posiłek od wielu miesięcy. 4337ebf6db5c7cc89e4173803ef3875a 4 34 Rozdział 1. Filozofia pragmatyczna Z historii o zupie z kamieni płynie wiele wniosków. Wieśniacy zostali oszukani przez żołnierzy, którzy wykorzystali ich ciekawość do zdobycia niezbędnych składników. Ważniejsze jest jednak coś zupełnie innego — żołnierze zadziałali jak katalizator skupiający społeczność wokół jednego celu i umożliwiający ugotowanie posiłku, którego poszczególni mieszkańcy wsi nie mogliby sporządzić na bazie własnych zapasów. Mamy więc do czynienia z pożądanym skutkiem synergii. Ostatecznie wszyscy odnieśli korzyści. Warto więc spróbować zastosować metodę tych żołnierzy we własnym środowisku. Możemy znaleźć się w sytuacji, w której doskonale wiemy, co należy zrobić i jak to osiągnąć. Wyobraźmy sobie, że mamy w głowie kompletną koncepcję systemu, o której z całą pewnością wiemy, że jest słuszna. Przyjmijmy jednak, że musimy uzyskać zgodę na to przedsięwzięcie i że spotykamy się z niechęcią i brakiem zrozumienia. Musimy przekonać rozmaite komitety, zadbać o akceptację zaproponowanego budżetu — projekt już na tym etapie bardzo się komplikuje. Każdy zazdrośnie strzeże swoich zasobów. Opisane zjawisko bywa określane mianem próby sił na początku działalności (ang. start-up fatigue). To dobry moment, by sięgnąć po kamienie. Należy dobrze przemyśleć, o które składniki warto prosić. Warto dobrze wykorzystać każdy otrzymany składnik. Po otrzymaniu półproduktu należy zaprezentować efekt wszystkim zainteresowanym i pozwolić, by zachwycali się otrzymanym dziełem. Kiedy powiedzą: „byłoby jeszcze lepsze, gdyby dodać…”, można udawać, że proponowane ulepszenia są nieistotne. Wystarczy teraz rozsiąść się wygodnie i czekać, aż sami zaczną sugerować dodawanie funkcji, które od początku chcieliśmy zaimplementować w tym systemie. Wielu ludziom łatwiej przychodzi dołączanie do udanego projektu niż praca na niepewny sukces od podstaw. Warto więc tak opisać perspektywy projektu, aby byli przekonani o jego świetlanej przyszłości5. WSKAZÓWKA NR 6 Bądź katalizatorem zmian. Mieszkańcy wsi Jeśli spojrzymy na historię zupy z kamieni z punktu widzenia wieśniaków, otrzymamy opowieść o subtelnym i postępującym oszustwie. To historia o zbyt wąskiej perspektywie. Mieszkańcy osady są tak zaintrygowani kamieniami, że zapominają o bożym świecie. To samo zdarza się każdemu, codziennie. Pewne rzeczy potrafią zadziwiająco skutecznie przykuwać naszą uwagę. 5 Pewnym pokrzepieniem mogą być słowa przypisywane kontradmirał dr Grace Hopper: „Łatwiej prosić o wybaczenie, niż uzyskać pozwolenie”. 4337ebf6db5c7cc89e4173803ef3875a 4 Zupa z kamieni i gotowane żaby 35 Opisane symptomy są nam doskonale znane. Projekty powoli, ale nieuchronnie wymykają nam się z rąk. Większość katastrof w świecie oprogramowania rozpoczyna się od zjawisk tak niepozornych, że wręcz trudnych do dostrzeżenia. Co więcej, większość projektów nie jest realizowana w terminie. Systemy oddalają się od swoich oryginalnych specyfikacji funkcja po funkcji. Co więcej, kolejne łatki eliminujące usterki powodują, że z czasem produkt nie ma nic wspólnego z oryginałem. Na spadek morale i rozpad zespołu często składa się wiele drobnych zjawisk, tyle że kumulowanych w dłuższym okresie. WSKAZÓWKA NR 7 Pamiętaj o szerszym kontekście. Nigdy tego nie próbowaliśmy. Naprawdę. Mówi się, że żywa żaba wrzucona do gotującej się wody natychmiast wyskoczy z garnka. Jeśli jednak wrzucimy żabę do zimnej wody i zaczniemy tę wodę stopniowo nagrzewać, żaba nie zwróci uwagi na powolny wzrost temperatury i pozostanie w garnku, aż zostanie ugotowana. Łatwo zauważyć, że w przypadku żaby mamy do czynienia z zupełnie innym problemem od tego, który omówiono we wcześniejszym podrozdziale na przykładzie zbitej szyby. Zgodnie z teorią wybitej szyby ludzie tracą zainteresowanie walką z entropią, kiedy odkrywają, że byliby w tej walce osamotnieni — że otaczająca ich społeczność bagatelizuje problem. Żaba nawet nie dostrzega zmiany. Nie możemy upodabniać się do tej żaby. Musimy mieć na uwadze szerszy obraz sytuacji. Powinniśmy stale obserwować to, co dzieje się w otaczającym nas świecie, zamiast koncentrować się tylko na swoich zadaniach. Pokrewne podrozdziały Temat 1., „To jest Twoje życie”. Temat 38., „Programowanie przez koincydencję”. Wyzwania Podczas recenzowania wstępnej wersji tej książki John Lakos zwrócił uwagę na ciekawe zjawisko. Żołnierze stopniowo oszukiwali mieszkańców wioski, ale powodowana przez to oszustwo zmiana postawy wieśniaków była dla wszystkich korzystna. Stopniowe oszukiwanie żaby ostatecznie prowadzi do jej skrzywdzenia. Czy potrafimy stwierdzić, kiedy przyspieszanie zmian odbywa się metodą zupy z kamieni, a kiedy zupy z żaby? Czy ta ocena będzie subiektywna, czy obiektywna? Odpowiedz szybko, bez spoglądania, ile lamp jest na suficie nad Tobą? Ile jest ich wszystkich w pokoju? Ile osób w nim przebywa? Czy jest coś spoza kontekstu — coś, co wygląda jakby nie należało do wystroju? Jest to ćwiczenie na świadomość sytuacyjną — technikę praktykowaną zarówno 4337ebf6db5c7cc89e4173803ef3875a 4 36 Rozdział 1. Filozofia pragmatyczna przez skautów, jak i oddziały marynarki wojennej Stanów Zjednoczonych. Nabierz nawyku obserwowania otoczenia i spostrzegania tego, co się w nim znajduje. Następnie stosuj ten sam nawyk w swoim projekcie. 5 40 Odpowiednio dobre oprogramowanie Kto lepsze goni, często w gorsze wpadnie. Król Lear, akt I, scena IV Istnieje stary dowcip o amerykańskiej firmie, która zamówiła 100 tysięcy układów scalonych u japońskiego producenta. Specyfikacja zawierała błąd, który powodował usterkę w jednym na 10 tysięcy układów. Kilka tygodni po złożeniu zamówienia do firmy dotarło wielkie pudło z tysiącami układów scalonych oraz małe pudełko z zaledwie dziesięcioma układami. W pudełku znajdowała się kartka z napisem: „Tutaj są te układy z błędami”. Możemy, oczywiście, tylko pomarzyć o podobnej kontroli nad jakością swoich produktów. Świat nigdy nie pozwoli nam tworzyć naprawdę doskonałego, w pełni wolnego oprogramowania. Przeciwko nam sprzysięgły się takie siły jak czas, technologia i temperament. Nasza sytuacja wcale nie musi być frustrująca. Jak napisał Ed Yourdon w swoim artykule w „IEEE Software”, When good-enough software is best [You95], przy odrobinie samodyscypliny możemy pisać oprogramowanie, które będzie wystarczająco dobre — wystarczająco dobre dla użytkowników, dla osób w przyszłości odpowiedzialnych za jego konserwację i dla naszego świętego spokoju. Szybko odkryjemy, że taki model pracy zapewnia nam maksymalną produktywność i w pełni satysfakcjonuje użytkowników. Co więcej, z zadowoleniem stwierdzimy, że nasze programy są lepsze także dzięki skróconemu procesowi produkcji. Zanim przystąpimy do dalszych rozważań, musimy doprecyzować, co naprawdę oznaczają te propozycje. Określenie „wystarczająco dobre” nie oznacza niechlujnego czy źle opracowanego kodu. W przypadku każdego systemu warunkiem sukcesu jest zgodność z wymaganiami użytkowników. W tym podrozdziale proponujemy koncepcję, w której użytkownicy końcowi mają szansę udziału w procesie podejmowania decyzji o tym, kiedy nasze dzieło jest wystarczająco dobre. Angażowanie użytkowników w rozstrzyganie o jakości W typowych okolicznościach piszemy oprogramowanie z myślą o innych użytkownikach. Często pamiętamy nawet o potrzebie uzyskania wymagań od tych użytkowników6. Jak często pytamy użytkowników, na ile dobrego oprogramo6 To miał być żart! 4337ebf6db5c7cc89e4173803ef3875a 4 Odpowiednio dobre oprogramowanie 37 wania oczekują? W pewnych przypadkach taki wybór oczywiście jest niemożliwy. Jeśli pracujemy nad rozrusznikiem serca, promem kosmicznym lub niskopoziomową biblioteką używaną później przez tysiące innych programistów, wymagania będą bardziej restrykcyjne, a nasze pole manewru stosunkowo niewielkie. Jeśli jednak pracujemy nad zupełnie nowym produktem, podlegamy całkiem innym ograniczeniom. Pracownicy działu marketingu składają przyszłym użytkownikom pewne obietnice, a sami użytkownicy mogą mieć plany związane z zapowiedzianym terminem wydania. Co więcej, nasza firma na pewno podlega ograniczeniom związanym z przepływem środków finansowych. Ignorowanie wymagań użytkowników w zakresie implementacji nowych funkcji lub tylko nieznacznego udoskonalenia gotowego kodu byłoby dalece nieprofesjonalne. Nie oznacza to jednak, że należy panicznie bać się negatywnej oceny użytkownika końcowego — równie nieprofesjonalne jest składanie nierealnych obietnic co do terminu i rezygnowanie z kolejnych elementów produktu wyłącznie z myślą o dotrzymaniu tych nieprzemyślanych deklaracji. Zakres i jakość tworzonego systemu powinny być precyzyjnie określone w wymaganiach tego systemu. WSKAZÓWKA NR 8 Jakość powinna być uwzględniona w wymaganiach. W wielu przypadkach nie da się uniknąć wyboru części funkcji kosztem innych. Co ciekawe, wielu użytkowników jest skłonnych zgodzić się na korzystanie z okrojonej wersji już dzisiaj, zamiast czekać na przykład rok na wersję uzupełnioną o elementy multimedialne. Wiele działów IT dysponujących skromnym budżetem zapewne się z tym zgodzi. Dobre oprogramowanie dzisiaj często jest lepsze niż doskonałe oprogramowanie jutro. Jeśli z odpowiednim wyprzedzeniem przekażemy użytkownikom produkt, z którym będą mogli swobodnie eksperymentować, ich opinie i wskazówki najprawdopodobniej umożliwią nam wypracowanie lepszego produktu docelowego (patrz temat 12., „Pociski smugowe”). Warto wiedzieć, kiedy przestać Programowanie pod pewnymi względami przypomina malowanie. W obu przypadkach praca rozpoczyna się od pustego płótna i kilku prostych materiałów. Dopiero połączenie nauki, sztuki i rzemiosła pozwala prawidłowo używać dostępnych środków. Należy zacząć od naszkicowania ogólnego kształtu, by następnie namalować otoczenie i wreszcie wypełnić szczegóły. Powinniśmy też stale odchodzić parę kroków od obrazu, by z większej odległości spojrzeć krytycznym okiem na dotychczasowe dokonania. Od czasu do czasu musimy nawet wyrzucić całe płótno do kosza i zacząć wszystko od nowa. 4337ebf6db5c7cc89e4173803ef3875a 4 38 Rozdział 1. Filozofia pragmatyczna Każdy artysta potwierdzi jednak, że cała ta ciężka praca nie ma najmniejszego sensu, jeśli twórca nie wie, kiedy przestać. Jeśli bez końca nanosimy kolejne warstwy i domalowujemy kolejne szczegóły, artystyczna wartość obrazu ginie gdzieś pod nadmiarem farby. Nie należy niszczyć dobrego programu przesadną liczbą upiększeń i wyrafinowanych dodatków. Powinniśmy raczej pozwolić, by nasz kod sprawdził się w działaniu bez naszego udziału. Być może nie jest doskonały. Nie ma jednak powodu do zmartwień — i tak nigdy taki by nie był. (W rozdziale 7. „Kiedy kodujemy…” omówimy filozofie tworzenia kodu w niedoskonałym świecie). Pokrewne podrozdziały Temat 45., „Kopalnia wymagań”. Temat 46., „Rozwiązywanie niemożliwych do rozwiązania łamigłówek”. Wyzwania Warto przyjrzeć się producentom narzędzi i systemów operacyjnych, których sami używamy. Czy potrafimy wskazać dowody sugerujące, że te firmy oferują oprogramowanie, o którym same wiedzą, że jest niedoskonałe? Czy jako użytkownicy wolelibyśmy raczej (1) czekać na wydanie oprogramowania pozbawionego wszystkich błędów, (2) otrzymać złożone oprogramowanie przy akceptacji pewnych niedoróbek, czy (3) korzystać z prostszego oprogramowania z nieco większą liczbą usterek? Zastanówmy się nad skutkami ewentualnej modularyzacji procesu dostarczania oprogramowania. Czy uzyskanie monolitycznego bloku oprogramowania w określonej jakości zajęłoby więcej, czy mniej czasu niż w przypadku projektowania systemu w formie zbioru modułów? Czy potrafimy wskazać jakieś komercyjne przykłady? Czy potrafisz wskazać popularne oprogramowanie, którego wadą jest nadmiar funkcjonalności? Oznacza to oprogramowanie zawierające znacznie więcej funkcji niż kiedykolwiek będziemy używać. Każda funkcjonalność wprowadza więcej możliwości popełnienia błędów i luk w zabezpieczeniach i sprawia, że trudniej jest znaleźć to, czego potrzebujemy, a także zarządzać funkcjonalnościami. Czy istnieje niebezpieczeństwo, że sam wpadniesz w tego rodzaju pułapkę? 4337ebf6db5c7cc89e4173803ef3875a 4 Portfolio wiedzy 6 41 39 Portfolio wiedzy Inwestycja w wiedzę zawsze przynosi największe zyski. Benjamin Franklin Jak widać, zawsze można liczyć na celne i zwięzłe wskazówki starego, dobrego Bena Franklina. Czy do zostania doskonałymi programistami wystarczy wczesne chodzenie spać i wstawanie o świcie? Ranny ptaszek może, oczywiście, pierwszy dopaść dorodnego robaka, ale co na rannym wstawaniu zyskuje robak? W tym przypadku Ben trafił w sedno. Wiedza i doświadczenie to zdecydowanie najważniejsze atuty w naszej profesji. Okazuje się jednak, że wymienione aktywa nie są wieczne7. Nasza wiedza dezaktualizuje się wraz z powstawaniem nowych technik, języków i środowisk. Zmieniające się warunki rynkowe mogą powodować, że nasze dotychczasowe doświadczenia stają się wręcz bezwartościowe. Zważywszy na tempo zmian w erze internetu, opisane zjawiska mogą zachodzić wyjątkowo szybko. Wraz ze spadkiem wartości naszej wiedzy spada wartość nas samych z perspektywy pracodawcy lub klienta. Naturalnym rozwiązaniem jest więc dążenie do zapobieżenia temu spadkowi. Twoja zdolność do uczenia się nowych rzeczy, to najważniejszy zasób o strategicznym znaczeniu. Ale w jaki sposób dowiedzieć się, jak się uczyć oraz skąd wiedzieć, czego się uczyć? Portfolio wiedzy Wszyscy lubimy postrzegać wszystkie znane programiście fakty o przetwarzaniu komputerowym, wszystkie dziedziny, w których pracował ten programista, oraz jego łączne doświadczenie jako tzw. portfolio wiedzy programisty. Zarządzanie portfolio wiedzy pod wieloma względami przypomina zarządzanie portfelem instrumentów finansowych: 1. Poważni inwestorzy inwestują regularnie (to dla nich swoisty nawyk). 2. Dywersyfikacja jest kluczem do sukcesu w dłuższym terminie. 3. Najlepsi inwestorzy właściwie równoważą swoje portfele, dzieląc inwestycje na bezpieczne, konserwatywne oraz ryzykowne, ale dające szansę szybkiego pomnożenia kapitału. 4. Inwestorzy starają się kupować tanio i sprzedawać drogo, aby osiągać maksymalny zwrot z inwestycji. 7 Tzw. aktywa wygasające (ang. expiring assets) to takie, których wartość maleje w czasie. Innymi przykładami takich aktywów są magazyny pełne bananów czy bilet na mecz koszykówki. 4337ebf6db5c7cc89e4173803ef3875a 4 40 Rozdział 1. Filozofia pragmatyczna 5. Portfele powinny być poddawane analizie i korygowane w regularnych odstępach czasu. Aby osiągnąć sukces w karierze, musimy zarządzać portfolio swojej wiedzy, postępując według tych samych wskazówek. Dobra wiadomość jest taka, że zarządzanie tego rodzaju inwestycjami jest taką samą umiejętnością, jak każda inna — można się jej nauczyć. Sztuką jest, aby zacząć to robić, a następnie stworzyć w sobie taki nawyk. Stwórz rutynę i postępuj zgodnie z nią tak długo, aż Twój mózg uzna ją za swoją. Od tej pory będziesz automatycznie „zasysał” nową wiedzę. Budowa własnego portfolio wiedzy Regularne inwestowanie. Tak jak w świecie inwestycji finansowych, musimy inwestować w swoje portfolio wiedzy możliwie regularnie. Nawet jeśli przedmiotem inwestycji są niewielkie kwoty, odpowiedni nawyk jest równie ważny jak inwestowane sumy. Kilka przykładowych celów zostanie opisanych w następnym podrozdziale. Różnorodność. Im więcej różnych zagadnień znamy, tym większa jest nasza wartość. Absolutnym minimum jest znajomość podstawowych cech technologii, której aktualnie używamy w swojej pracy. Nie możemy jednak na tym poprzestać. Świat komputerów zmienia się na tyle gwałtownie, że technologia bijąca dzisiaj rekordy popularności jutro może być niemal bezużyteczna (a przynajmniej skazana na zapomnienie). Im więcej technologii opanujemy, tym łatwiej będziemy mogli dostosowywać się do zachodzących zmian. Zarządzanie ryzykiem. Z technologiami wiążą się bardzo różne czynniki ryzyka — istnieją zarówno technologie cechujące się dużym zyskiem przy małym ryzyku, jak i rozwiązania oferujące stosunkowo niewiele w warunkach wysokiego ryzyka. Inwestowanie wszystkich środków w ryzykowne udziały, które mogą z dnia na dzień okazać się bezwartościowe, z pewnością nie byłoby rozsądnym posunięciem. Nie należy też inwestować wszystkich zasobów w najbardziej bezpieczne, zachowawcze rozwiązania, ponieważ można w ten sposób przegapić najlepsze okazje. Nie powinniśmy więc umieszczać wszystkich technicznych jajek w jednym koszyku. Kupuj tanio, sprzedawaj drogo. Nauka nowych technologii jeszcze przed zyskaniem większej popularności bywa równie trudna jak odnajdywanie niedoszacowanych papierów wartościowych, jednak potencjalna nagroda w obu przypadkach będzie bardzo kusząca. Nauka Javy zaraz po jej pierwszym wydaniu być może była ryzykowna, ale zapewne zwróciła się z nawiązką pionierom tej technologii, którzy mają dzisiaj status najlepiej opłacanych ekspertów. Przeglądy i korekty. Mamy do czynienia z branżą podlegającą wyjątkowo dynamicznym zmianom. Popularna technologia, której używamy zaledwie od miesiąca, już jutro może okazać się całkowitym przeżytkiem. Być może 4337ebf6db5c7cc89e4173803ef3875a 4 Portfolio wiedzy 41 warto wrócić do technologii bazy danych, której nie używaliśmy od jakiegoś czasu. Niewykluczone, że uzyskamy nieporównanie lepszą propozycję pracy, jeśli spróbujemy jeszcze opanować język… Spośród wszystkich tych wskazówek najważniejsza jest ta, która wydaje się najprostsza: WSKAZÓWKA NR 9 Regularnie inwestuj w swoje portfolio wiedzy. Cele Skoro dysponujemy już pewnymi wskazówkami, jak i kiedy uzupełniać nasze portfolio wiedzy, warto zastanowić się nad najlepszymi sposobami pozyskiwania kapitału intelektualnego niezbędnego do wypełnienia tego portfolio. Poniżej przedstawiono kilka sugestii. 8 Warto uczyć się przynajmniej jednego nowego języka rocznie. Różne języki programowania pozwalają rozwiązywać te same problemy na różne sposoby. Stałe poznawanie nowych rozwiązań ułatwia szersze postrzeganie rozwiązywanych problemów i zmniejsza ryzyko wybierania utartych sposobów postępowania. Co więcej, uczenie się nowych języków jest teraz dużo prostsze dzięki materiałom i oprogramowaniu dostępnym za darmo w internecie. Należy czytać jedną książkę techniczną na miesiąc. Chociaż w internecie znajduje się mnóstwo krótkich artykułów oraz można w nim znaleźć wartościowe odpowiedzi na nurtujące nas pytania, dokładne zrozumienie tematu wymaga przeczytania książki. Warto poszukać w księgarniach książek technicznych poświęconych zagadnieniom w ten czy inny sposób związanym z aktualnie realizowanym projektem8. Kiedy już nabierzemy odpowiednich przyzwyczajeń, powinniśmy sięgać po nową książkę przynajmniej raz w miesiącu. Po opanowaniu aktualnie używanych technologii warto poświęcić trochę czasu na poznawanie rozwiązań niezwiązanych z aktualnie realizowanym projektem. Powinniśmy też sięgać po książki inne niż techniczne. Musimy pamiętać, że komputery są używane przez ludzi — ludzi, których potrzeby próbujemy zaspokoić, tworząc odpowiednie oprogramowanie. Nie wolno nam zapominać o stronie równania, po której występuje żywy człowiek. Powinniśmy brać udział w szkoleniach. Warto sprawdzić, czy lokalne uczelnie nie organizują wartościowych kursów. Niewykluczone, że cenną wiedzę będzie można zdobyć na przykład podczas zbliżających się targów. Być może to tylko nasze zdanie, ale wartościowa lista znajduje się pod adresem https://praprog.com. 4337ebf6db5c7cc89e4173803ef3875a 4 42 Rozdział 1. Filozofia pragmatyczna Należy zaangażować się w funkcjonowanie lokalnych grup użytkowników. Nie wystarczy tylko iść na spotkanie i słuchać, co inni mają do powiedzenia — chodzi o aktywny udział. Izolacja jest śmiertelnym zagrożeniem dla kariery. Warto więc szukać kontaktów z osobami pracującymi poza naszą firmą. Należy eksperymentować z różnymi środowiskami. Jeśli pracujemy wyłącznie w systemie Windows, warto spróbować poznać system Unix w domu (wprost doskonałym wyborem będzie któraś z darmowych dystrybucji Linuksa). Jeśli do tej pory korzystaliśmy tylko z plików makefile i zwykłego edytora, koniecznie powinniśmy sprawdzić możliwości środowisk IDE (i odwrotnie). Należy trzymać rękę na pulsie. Warto czytać artykuły i posty online dotyczące technologii innych niż te, których używasz w swoim bieżącym projekcie. W ten sposób można się dowiedzieć, jakie doświadczenia mają inni z tymi technologiami, jakiego używają słownictwa itp. Niezwykle ważne jest ustawiczne szukanie i pogłębianie wiedzy. Kiedy uznajemy, że opanowaliśmy nowy język lub technologię w dostatecznym stopniu, powinniśmy iść dalej. To doskonały moment, by nauczyć się czegoś nowego. Nie ma znaczenia, czy kiedykolwiek używaliśmy którejś z tych technologii w ramach jakiegoś projektu ani nawet czy wspominaliśmy o tej technologii w swoich CV. Proces uczenia się poszerza nasze horyzonty myślowe, stwarzając nowe możliwości i wskazując nowe drogi osiągania celów. W tym fachu bardzo ważne jest umiejętne łączenie wiedzy z różnych źródeł. Warto więc próbować wykorzystywać nowe umiejętności już w trakcie bieżącego projektu. Nawet jeśli ten projekt jest realizowany w innej technologii, być może istnieje możliwość zastosowania przynajmniej niektórych pomysłów. Wystarczy opanować na przykład programowanie obiektowe, aby nieco zmienić styl programowania. Zrozumienie paradygmatu programowania funkcyjnego pozwoli pisać kod obiektowy inaczej. Okazje do nauki Przyjmijmy, że łapczywie sięgamy po wszystkie dostępne materiały i doskonale orientujemy się w nowinkach w naszej dziedzinie (co nie jest proste). Załóżmy, że nagle ktoś zadaje nam jakieś pytanie. Nie mamy zielonego pojęcia, jak na nie odpowiedzieć, do czego od razu przyznajemy się swojemu rozmówcy. Nie możemy na tym poprzestać. Powinniśmy raczej wykorzystać sytuację i traktować znalezienie odpowiedzi jako swoiste wyzwanie. Możemy zapytać innych, poszukać w internecie — warto czytać także artykuły naukowe, nie tylko opinie użytkowników. Jeśli sami nie możemy znaleźć odpowiedzi, powinniśmy poszukać osoby, która poradzi sobie z tym zadaniem. Nie wolno nam tak tego zostawić. Rozmowy z innymi ułatwią nam budowę sieci osobistych relacji. W ten sposób nierzadko 4337ebf6db5c7cc89e4173803ef3875a 4 Portfolio wiedzy 43 można odnajdywać — ku własnemu zdziwieniu — rozwiązania innych problemów (niezwiązanych z początkowym tematem rozmów). Wszystko to sprawia, że nasze portfolio stale jest powiększane… Lektura wszystkich tych materiałów i poszukiwanie wiedzy z natury rzeczy wymaga czasu. Właśnie czas jest tutaj zasobem deficytowym. Oznacza to, że musimy planować swoje poczynania z wyprzedzeniem. Warto zawsze mieć pod ręką coś do przeczytania na wypadek, aby nigdy nie siedzieć bezczynnie. Czas spędzany w poczekalniach u lekarzy lub dentystów to wprost doskonała okazja do nadrobienia zaległości w czytaniu. Nie możemy jednak liczyć na innych — jeśli nie mamy ze sobą interesujących nas materiałów, możemy skończyć, trzymając w dłoniach pomięty miesięcznik z 1973 roku pełen artykułów o Papui-Nowej Gwinei. Krytyczne myślenie Ostatnim ważnym punktem jest krytyczna ocena tego, co czytamy i słyszymy. Musimy mieć pewność, że wiedza, która trafia do naszego portfolio, jest prawidłowa i nie została zniekształcona przez marketingowy przekaz producenta czy mediów. Należy wystrzegać się fanatyków dogmatycznie przywiązanych do swoich racji — ich poglądy mogą, ale nie muszą potwierdzić się w naszym przypadku (i w ramach realizowanego projektu). Nigdy nie powinniśmy lekceważyć siły komercjalizacji. To, że wyszukiwarka internetowa wyświetla coś na pierwszym miejscu, nie oznacza jeszcze, że trafiliśmy na najlepszą stronę; być może jej właściciel po prostu zapłacił za wyższą pozycję w wynikach. To, że księgarnia wystawia jakąś książkę na witrynie, nie oznacza jeszcze, że książka jest dobra (ani nawet popularna); być może jej wydawca zapłacił za eksponowanie swojego tytułu. WSKAZÓWKA NR 10 Patrz krytycznym okiem na to, co czytasz i słyszysz. Krytyczne myślenie to oddzielna dyscyplina. Zachęcamy do przeczytania i przestudiowania wszystkiego, co uda Ci się zdobyć na ten temat. Zanim rozpoczniesz lekturę, oto lista z kilkoma pytaniami, które warto sobie zadać i przeanalizować odpowiedzi. Zadawaj pytania: „pięć razy dlaczego”. Ulubiona sztuczka konsultingowa: zadaj pytanie „dlaczego?” co najmniej pięć razy. Zadaj pytanie i uzyskaj odpowiedź. Przeanalizuj problem dokładniej, dalej pytając „dlaczego?”. Powtarzaj to pytanie tak, jakbyś był czterolatkiem (ale grzecznym). Być może w ten sposób uda Ci się zbliżyć do poznania głównej przyczyny problemu. 4337ebf6db5c7cc89e4173803ef3875a 4 44 Rozdział 1. Filozofia pragmatyczna Kto na tym skorzysta? Choć może się to wydawać cyniczne, podążanie za pieniędzmi może być bardzo pomocną ścieżką do analizy. Korzyści kogoś innego lub innej organizacji mogą być tożsame z Twoimi własnymi — bądź nie. Jaki jest kontekst? Weźmy za przykład artykuł lub książkę reklamującą „najlepsze praktyki”. Dobre pytanie do rozważenia brzmi: „najlepsze dla kogo?”. Jakie są warunki wstępne, jakie są konsekwencje, zarówno krótko-, jak i długoterminowe? Kiedy i w jakich warunkach rozwiązanie się sprawdza? Zastanów się nad okolicznościami. Czy jest za późno? A może za wcześnie? Nie poprzestawaj na myśleniu wyłącznie o następnym kroku (co wydarzy się za chwilę), ale myśl także długoterminowo (co zdarzy się w dalszej kolejności). Dlaczego to jest problem? Czy istnieje model bazowy? W jaki sposób ten model działa? Praktyka pokazuje, że prostych odpowiedzi jest bardzo niewiele. Odpowiednio bogate portfolio i krytyczne spojrzenie na otaczającą nas masę publikacji powinny jednak umożliwić nam wypracowywanie odpowiedzi nawet na najtrudniejsze pytania. Pokrewne podrozdziały Temat 1., „To jest Twoje życie”. Temat 22., „Dzienniki inżynierskie”. Wyzwania 9 Naukę nowego języka powinniśmy rozpocząć już w tym tygodniu. Od zawsze programujesz w tym samym, starym języku? Spróbuj więc napisać coś w językach Clojure, Elixir, Elm, F#, Go, Haskell, Python, R, ReasonML, Ruby, Rust, Scala, Swift, TypeScript lub dowolnym innym, który zwrócił Twoją uwagę i (lub), który Ci się spodobał9. Sięgnijmy po nową książkę (ale dopiero po skończeniu tego wydania). Jeśli aktualnie zajmujemy się szczegółową implementacją i kodowaniem, powinniśmy przeczytać książkę o projektowaniu i architekturze. Jeśli natomiast pracujemy nad wysokopoziomowym projektem, dla odmiany powinniśmy sięgnąć po książkę o technikach kodowania. Nigdy nie słyszałeś o żadnym z tych języków? Pamiętaj, że wiedza, podobnie jak popularna technologia, jest wygasającym atutem. Lista nowych i eksperymentalnych języków była zupełnie inna w pierwszym wydaniu i prawdopodobnie będzie inna w chwili, gdy będziesz czytać tę książkę. Tym bardziej masz powód, aby nie przestawać się uczyć. 4337ebf6db5c7cc89e4173803ef3875a 4 Komunikuj się! 7 42 45 Powinniśmy wyjść i porozmawiać o technologiach z osobami, które nie są zaangażowane w bieżący projekt lub które w ogóle nie pracują w naszej firmie. Warto zawierać nowe znajomości w firmowej kafejce lub poszukać entuzjastów podobnych do nas na lokalnym spotkaniu grupy użytkowników. Komunikuj się! Wierzę, że lepiej być przedmiotem krytycznego osądu niż niezauważonym. Mae West, Piękność lat dziewięćdziesiątych, 1934 Być może możemy się czegoś nauczyć od pani West. Nie chodzi tylko o to, co mamy, ale też o to, jak to zapakujemy. Nawet najlepsze pomysły, najdoskonalszy kod i najbardziej pragmatyczne myślenie będą jałowe, jeśli nie nauczymy się komunikacji z innymi ludźmi. Bez efektywnej komunikacji nawet dobra idea staje się sierotą. Jako programiści musimy komunikować się na wielu poziomach. Spędzamy całe godziny na spotkaniach, podczas których słuchamy i mówimy. Pracujemy z użytkownikami końcowymi, próbując zrozumieć ich potrzeby. Piszemy kod, którego zadaniem jest zarówno komunikowanie naszych intencji maszynie (komputerowi), jak i dokumentowanie naszych przemyśleń przyszłym pokoleniom programistów. Piszemy propozycje i notatki dołączane do wniosków o zasoby i wyjaśniających ich stosowanie, raportujące postępy prac oraz sugerujące nowe kierunki. Co więcej, codziennie pracujemy z naszymi zespołami, próbując przekonywać je do naszych pomysłów, zmieniać dotychczasowe praktyki ich członków i sugerować nowe rozwiązania. Ponieważ znaczną część czasu pracy poświęcamy właśnie na komunikację, musimy robić to naprawdę dobrze. Traktuj angielski (lub dowolny język naturalny, którym się posługujesz) jak specjalny język programowania. Pisz w języku naturalnym tak, jak pisze się kod, stosuj zasady DRY, ETC, narzędzia automatyzacji itp. (zasady projektowania DRY i ETC omówimy w następnym rozdziale). WSKAZÓWKA NR 11 Język naturalny to po prostu kolejny język programowania. W tym podrozdziale sporządzimy listę sugestii, które mogą być pomocne podczas doskonalenia umiejętności komunikacyjnych. 4337ebf6db5c7cc89e4173803ef3875a 4 46 Rozdział 1. Filozofia pragmatyczna Poznaj swoich odbiorców Komunikujesz się tylko wtedy, gdy przekazujesz to, co chcesz przekazać, samo mówienie nie wystarczy. Aby to robić, musisz rozumieć potrzeby, zainteresowania i możliwości swoich odbiorców. Wszyscy uczestniczyliśmy w spotkaniach, w czasie których jakiś maniakalny programista z błyskiem w oczach wygłaszał wiceprezesowi ds. marketingu długi monolog na temat meritum jakiejś tajemnej technologii. To nie jest komunikacja: to tylko monolog, i do tego denerwujący10. Załóżmy, że chcesz zmienić zdalny system monitorowania, aby używać zewnętrznego brokera komunikatów do publikowania powiadomień o stanie. W zależności od odbiorców informacje można przedstawić na wiele różnych sposobów. Użytkownicy końcowi docenią, że ich systemy będą teraz współdziałać z innymi usługami korzystającymi z brokera. Twój dział marketingu będzie mógł wykorzystać ten fakt, aby zwiększyć sprzedaż. Menedżerowie działów programowania i operacyjnego będą zadowoleni, ponieważ utrzymanie tej części systemu będzie odtąd problemem kogoś innego. Wreszcie programiści mogą korzystać z doświadczeń związanych z posługiwaniem się nowymi interfejsami API, a może nawet będą w stanie znaleźć nowe zastosowania dla brokera komunikatów. Dzięki odpowiedniemu sposobowi dotarcia do każdej grupy, członkowie każdej z nich będą podekscytowani Twoim projektem. Podobnie jak w przypadku wszystkich form komunikacji, sztuką jest zebranie opinii. Nie czekaj biernie na pytania: poproś o ich stawianie. Zwracaj uwagę na język ciała i mimikę odbiorców. Jednym z założeń programowania neurolingwistycznego jest zasada „Sensem Twojej komunikacji jest odpowiedź, którą otrzymujesz”. Gdy się komunikujesz, stale poszerzaj wiedzę o swoich odbiorcach. Należy wiedzieć, co powiedzieć Bodaj najtrudniejszym aspektem oficjalnych form komunikacji obowiązujących w biznesie jest precyzyjne określanie, co naprawdę chcemy powiedzieć. O ile autorzy beletrystyki mogą pozwolić sobie na szczegółowe planowanie swoich dzieł przed przystąpieniem do ich tworzenia, ludzie odpowiedzialni za tworzenie dokumentacji technicznej nierzadko muszą w jednej chwili usiąść przed komputerem i niezwłocznie zacząć spisywać (począwszy od „Rozdział 1. Wprowadzenie”) wszystko, co przyjdzie im do głowy. Musimy zaplanować, co chcemy powiedzieć. Powinniśmy przygotować szkic przyszłej wypowiedzi. Warto też odpowiedzieć sobie na pytanie, czy planowana wypowiedź rzeczywiście wyraża to, co chcemy przekazać rozmówcom. Należy doskonalić plan wypowiedzi tak długo, aż odpowiedź na to pytanie będzie satysfakcjonująca. 10 Angielskie słowo annoy (denerwujący) pochodzi od starofrancuskiego enui, które oznacza również „przynudzać”. 4337ebf6db5c7cc89e4173803ef3875a 4 Komunikuj się! 47 Proponowane rozwiązania nie sprawdzają się podczas pisania dokumentów. Przed pójściem na ważne spotkanie lub odbyciem rozmowy telefonicznej z ważnym klientem warto zanotować sobie pomysły, które mamy zakomunikować, i zaplanować kilka strategii ich prezentacji. Teraz, gdy już wiesz, czego chcą Twoi odbiorcy, spróbuj spełnić ich oczekiwania. Należy wybrać właściwy moment Jest piątek, godzina 18. Wszyscy uczestnicy spotkania mają za sobą trudny tydzień. Najmłodsze dziecko szefa jest w szpitalu, na zewnątrz leje jak z cebra, a powrót do domu w piątkowy wieczór będzie prawdziwym koszmarem. Prawdopodobnie nie jest to najlepszy moment na rozmowę z szefem o konieczności rozbudowy komputera. Aby dobrze zrozumieć, co powinni usłyszeć nasi rozmówcy, musimy zidentyfikować ich priorytety. Na przykład pomysły dotyczące repozytoriów kodu źródłowego najlepiej zaprezentować menedżerowi, który właśnie odbył przykrą rozmowę z własnym przełożonym po utracie części kodu źródłowego — mamy wówczas spore szanse, że nasze propozycje trafią na podatny grunt. Zarówno treść naszych propozycji, jak i moment ich prezentacji muszą odpowiadać na bieżące problemy firmy. Nierzadko wystarczy po prostu zadać sobie pytanie: Czy to dobry moment, aby o tym rozmawiać? Należy wybrać odpowiedni styl Styl przekazu należy dostosować do charakteru jego odbiorców. Część ludzi oczekuje formalnych prezentacji ograniczających się wyłącznie do faktów. Inni wolą poprzedzać właściwe rozmowy biznesowe długimi pogawędkami na najróżniejsze tematy. Podobnie jest w przypadku dokumentów pisanych — niektórzy wolą długie raporty z szerokimi wyjaśnieniami, inni oczekują raczej krótkich notatek lub zwięzłych wiadomości poczty elektronicznej. W razie wątpliwości warto zapytać. Należy jednak pamiętać, że sami stanowimy połowę tej swoistej transakcji komunikacyjnej. Jeśli ktoś oczekuje akapitu opisującego jakiś aspekt i jeśli wiemy, że do opisania tego złożonego aspektu będziemy potrzebowali kilku stron, należy o tym po prostu powiedzieć. Musimy pamiętać, że także reakcje na propozycje same w sobie stanowią formę komunikacji. Należy zadbać o warstwę estetyczną Nasze pomysły są ważne. Zasługują więc na odpowiednią oprawę, aby lepiej trafić do odbiorców. 4337ebf6db5c7cc89e4173803ef3875a 4 48 Rozdział 1. Filozofia pragmatyczna Zbyt wielu programistów (wraz ze swoimi menedżerami) koncentruje się wyłącznie na treści tworzonych przez siebie dokumentów. Uważamy to za błąd. Każdy dobry kucharz (lub widz programów kulinarnych) wie, że można zamknąć się w kuchni na wiele godzin, by następnie w jednej chwili zepsuć cały efekt wskutek kiepskiej prezentacji. W dzisiejszych czasach nic nie może usprawiedliwić kiepskiego wyglądu drukowanych dokumentów. Współczesne edytory tekstu umożliwiają tworzenie wprost doskonałych dokumentów niezależnie od tego, czy piszesz używając notacji Markdown, czy stosujesz procesor tekstu. Wystarczy opanować zaledwie kilka prostych poleceń. Jeśli używany przez nas edytor obsługuje arkusze stylów, koniecznie powinniśmy skorzystać z tej możliwości. (Być może nasza firma zdefiniowała już arkusze stylów, których możemy używać w swoich dokumentach). Warto nauczyć się ustawiania nagłówków i stopek stron. Jeśli brakuje nam pomysłów i koncepcji układu dokumentu, wystarczy zajrzeć do przykładowych dokumentów dołączonych do danego edytora. Należy jeszcze sprawdzić pisownię (najpierw przy użyciu automatycznego narzędzia, a następnie ręcznie). Istnieją kończ obłędy, których nie morze wychwyci żadne automatyczny mechanizmu. Należy zaangażować odbiorców Nierzadko odkrywamy, że proces pracy nad dokumentem jest pod wieloma względami cenniejszy niż sam dokument. Jeśli to możliwe, warto zaangażować przyszłych czytelników w prace już nad wczesnymi wersjami dokumentu. Warto zebrać ich opinie i skorzystać z ich rad. Opisany tryb przygotowywania materiałów pozwoli nie tylko zbudować lepsze relacje ze współpracownikami, ale też tworzyć lepsze dokumenty. Należy słuchać innych Istnieje prosta technika, którą musimy stosować, jeśli chcemy być słuchani przez innych — musimy sami ich słuchać. Nawet w sytuacji, w której dysponujemy wszystkimi informacjami, nawet jeśli uczestniczymy w formalnym spotkaniu z dwudziestoma wysoko postawionymi menedżerami — jeśli nie słuchamy innych, oni nie będą słuchali nas. Warto zachęcać ludzi do mówienia, zadając im pytania lub prosząc o streszczenie tego, o czym sami mówiliśmy. Przekształcenie spotkania w dialog znacznie podniesie efektywność naszego przekazu. Kto wie, być może nawet czegoś się nauczymy. Należy wracać do rozmówców Kiedy zadajemy komuś pytanie, brak jakiejkolwiek odpowiedzi traktujemy jako przejaw złego wychowania. Czy jednak sami nie ignorujemy wiadomości poczty elektronicznej lub notatek otrzymywanych od osób proszących o jakieś infor- 4337ebf6db5c7cc89e4173803ef3875a 4 Komunikuj się! 49 macje lub oczekujących jakichś czynności z naszej strony? W dzisiejszym świecie łatwo o tym zapomnieć. Zawsze powinniśmy odpowiadać na wiadomości poczty elektronicznej i nagrania na automatycznej sekretarce, nawet jeśli ta odpowiedź będzie brzmiała „wrócę do tego później”. Informowanie innych o zainteresowaniu podnoszonymi problemami przekłada się na dużo większą wyrozumiałość w przypadku sporadycznych błędów i upewnia współpracowników w przekonaniu o tym, że ktoś pamięta o ich sprawach. WSKAZÓWKA NR 12 Ważne jest nie tylko to, co mówimy, ale też to, jak to mówimy. Jeśli nie pracujemy w próżni, musimy opanować sztukę komunikacji. Im bardziej efektywna będzie nasza komunikacja ze współpracownikami, tym większy będzie nasz wpływ na otaczającą nas rzeczywistość. Dokumentacja Wreszcie pozostaje kwestia komunikowania się za pośrednictwem dokumentacji. Zazwyczaj programiści nie poświęcają dokumentacji zbyt wiele czasu. W najlepszym razie jest to nieprzyjemna konieczność; w najgorszym przypadku jest traktowana jako zadanie o niskim priorytecie, a opracowujący dokumentację mają nadzieję, że kierownictwo o niej zapomni pod koniec projektu. Pragmatyczni programiści uwzględniają dokumentację jako integralną część całego cyklu życia projektu. Jej pisanie można ułatwić. Nie trzeba powielać pracy lub marnować czasu. Dokumentacja może być cały czas pod ręką — w kodzie źródłowym. WSKAZÓWKA NR 13 Buduj dokumentację wraz z projektem zamiast ją do niego „przytwierdzać”. Dobrze wyglądającą dokumentację można łatwo stworzyć na podstawie komentarzy w kodzie źródłowym. Zachęcamy do dodawania komentarzy do modułów i eksportowanych funkcji, aby ułatwić innym programistom korzystanie z naszego kodu. Nie oznacza to jednak, że zgadzamy się z osobami, które mówią, że każda funkcja, struktura danych, deklaracja typu itp., potrzebuje osobnego komentarza. Pisanie tego rodzaju mechanicznych komentarzy faktycznie utrudnia utrzymanie kodu: po wprowadzeniu zmian są dwie rzeczy do aktualizacji — zarówno kod, jak i komentarze. Zatem ogranicz komentarze niezwiązane z API do wyjaśniania dlaczego coś zostało zrobione — jaki jest tego cel i przeznaczenie. To kod powinien pokazywać jak zadanie zostało wykonane, więc zbyt szczegółowe jego komentowanie jest zbędne i stanowi naruszenie zasady DRY. 4337ebf6db5c7cc89e4173803ef3875a 4 50 Rozdział 1. Filozofia pragmatyczna Komentowanie kodu źródłowego daje doskonałą okazję do udokumentowania tych nieuchwytnych fragmentów projektu, których nie można udokumentować nigdzie indziej: kompromisów inżynierskich, powodów podejmowania decyzji, odrzuconych alternatyw itp. Podsumowanie Należy wiedzieć, co powiedzieć. Należy wiedzieć coś o rozmówcach. Należy wybrać właściwy moment. Należy wybrać odpowiedni styl. Należy zadbać o warstwę estetyczną. Należy zaangażować odbiorców. Należy słuchać innych. Należy wracać do rozmówców. Należy utrzymywać kod razem z dokumentacją. Pokrewne podrozdziały Temat 15., „Szacowanie”. Temat 18., „Efektywna edycja”. Temat 45., „Kopalnia wymagań”. Temat 49., „Pragmatyczne zespoły”. Wyzwania Istnieje wiele dobrych książek, których fragmenty poświęcono komunikacji w ramach zespołów projektowych. Należą do nich The Mythical Man-Month: Essays on Software Engineering [Bro96] oraz Peopleware: Productive Projects and Teams [DL13]. Warto przeczytać te pozycje w ciągu najbliższych 18 miesięcy. W książce Trudni współpracownicy [BR89] dodatkowo omówiono problem bagażu emocjonalnego, który każdy z nas wnosi do swojego środowiska pracy. Kiedy przy najbliższej okazji będziemy przygotowywali prezentację lub pisali notatkę przekonującą odbiorcę do jakiegoś stanowiska, powinniśmy raz jeszcze przestudiować przedstawione w tym rozdziale rady. Należy wyraźnie zidentyfikować odbiorców oraz to, co chcemy im przekazać. Jeśli to możliwe, warto porozmawiać z odbiorcami po prezentacji, aby dowiedzieć się, na ile słuszne były założenia dotyczące ich potrzeb i oczekiwań. 4337ebf6db5c7cc89e4173803ef3875a 4 Komunikuj się! Komunikacja online Wszystko, co do tej pory napisano o komunikacji przy użyciu dokumentów, ma zastosowanie także w przypadku poczty elektronicznej, postów w mediach społecznościowych, blogach itp. W szczególności poczta elektroniczna osiągnęła status podstawowej platformy komunikacji w ramach korporacji i pomiędzy korporacjami. Poczta elektroniczna służy dzisiaj do negocjowania kontraktów i prowadzenia sporów, a nierzadko stanowi ważny dowód w sądzie. Okazuje się jednak, że z jakiegoś powodu ludzie, którzy nigdy nie wysłaliby papierowego dokumentu z najmniejszym niedociągnięciem, beztrosko wysyłają w świat niechlujne, wręcz odpychające wiadomości poczty elektronicznej. Wskazówki dotyczące poczty elektronicznej są dość proste: Należy przeczytać tekst przed kliknięciem przycisku Wyślij. Należy sprawdzić pisownię. Należy zachować prosty format. Należy ograniczać liczbę cytatów. Nikt nie lubi otrzymywać w całości własnej stuwierszowej wiadomości z lakoniczną odpowiedzią „Masz rację”! Jeśli już cytujemy cudzą wiadomość poczty elektronicznej, koniecznie powinniśmy odpowiednio wyróżnić cytat i umieścić go w tekście (nie w załączniku). To samo dotyczy cytowania w mediach społecznościowych. Nie należy przeklinać; w przeciwnym razie nasze przekleństwa będą nas jeszcze długo prześladowały. Przed wysłaniem wiadomości warto jeszcze raz sprawdzić listę adresatów. Byłoby niezręcznie, gdybyś skrytykował swojego przełożonego w wiadomości rozesłanej do pracowników własnego działu, zapominając, że na liście odbiorców jest także krytykowany szef. Jeszcze lepiej będzie, jeśli w ogóle nie będziemy krytykować szefa w wysyłanym e-mailu. Jak przekonało się wielu pracowników licznych korporacji oraz polityków, wiadomości poczty elektronicznej i posty w mediach społecznościowych są wieczne. Należy przykładać do nich taką samą wagę jak do tradycyjnych notatek i raportów. 4337ebf6db5c7cc89e4173803ef3875a 51 4 52 Rozdział 1. Filozofia pragmatyczna 4337ebf6db5c7cc89e4173803ef3875a 4 Rozdział 2. Postawa pragmatyczna Istnieją pewne wskazówki i zabiegi, które można z powodzeniem stosować na wszystkich poziomach wytwarzania oprogramowania. Pewne koncepcje mają niemal status aksjomatów, a niektóre procesy są wręcz uniwersalne. Co ciekawe, wspomniane rozwiązania rzadko są prezentowane w ten sposób — w większości przypadków są zapisywane raczej w formie dziwacznych, niezrozumiałych przemyśleń w ramach szerszego omawiania projektowania, zarządzania projektami czy kodowania. Pierwszy i być może najważniejszy podrozdział: „Istota dobrego projektu” opisuje sedno wytwarzania oprogramowania. Od projektu wszystko się zaczyna. Dwa kolejne podrozdziały, zatytułowane „DRY — Przekleństwo powielania” i „Ortogonalność”, są ze sobą ściśle powiązane. Pierwszy z nich zawiera ostrzeżenie przed powielaniem wiedzy w całych systemach; w drugim podrozdziale przestrzegamy przed dzieleniem jednego elementu wiedzy pomiędzy wiele komponentów systemu. Coraz większe tempo zmian powoduje, że dotrzymywanie pierwotnych założeń dotyczących tworzonych aplikacji jest coraz trudniejsze. W podrozdziale „Odwracalność” omówimy pewne techniki ułatwiające zabezpieczenie projektów przed wpływem zmieniającego się otoczenia. Także dwa kolejne podrozdziały są ze sobą powiązane. W podrozdziale „Pociski smugowe” omówimy styl programowania umożliwiający jednoczesne gromadzenie wymagań, testowanie projektów i implementowanie właściwego kodu. To jedyny sposób dotrzymania tempa dzisiejszemu światu. W podrozdziale „Prototypy i karteczki samoprzylepne” opisano, jak efektywnie używać prototypów do testowania architektur, algorytmów, interfejsów i pomysłów. W nowoczesnym świecie kluczowe znaczenie ma testowanie pomsyłów i zbieranie opinii przed pełnym zaangażowaniem się w wykorzystywanie wybranej koncepcji. 4337ebf6db5c7cc89e4173803ef3875a 4 54 Rozdział 2. Postawa pragmatyczna Informatyka to dziedzina, która powoli dojrzewa. Programiści tworzą swoje aplikacje w językach coraz wyższego poziomu. Chociaż nie wymyślono jeszcze kompilatora, który działałby na podstawie lakonicznego polecenia „zrób to czy tamto”, w podrozdziale „Języki dziedzinowe” można znaleźć kilka prostych rozwiązań, które każdy programista może zaimplementować samodzielnie. I wreszcie, wszyscy pracujemy w warunkach braku czasu i ograniczonych zasobów. Okazuje się, że z oboma problemami można radzić sobie nieporównanie sprawniej (wprawiając przy okazji w zachwyt przełożonych), jeśli tylko potrafimy trafnie przewidywać czas potrzebny do realizacji poszczególnych zadań (patrz podrozdział „Szacowanie”). Konsekwentne przestrzeganie tych prostych zasad podczas programowania pozwala pisać lepszy, szybszy i bardziej niezawodny kod. Co więcej, proponowane rozwiązania mogą sprawić, że nasz kod będzie nawet wyglądał na łatwiejszy. 8 36 Istota dobrego projektu Świat jest pełen guru i ekspertów. Wszyscy oni są chętni do przekazywania swojej z trudem zdobytej wiedzy o tym, jak projektować oprogramowanie. Istnieją akronimy, listy (zwykle złożone z pięciu elementów), wzorce, wykresy, filmy, rozmowy oraz (jak to w internecie) seria różnych wyjaśnień prawa Demeter. A my, skromni autorzy tej książki, także jesteśmy trochę temu winni. Chcielibyśmy jednak, w ramach zadośćuczynienia, wyjaśnić coś, co dotarło do nas całkiem niedawno. Najpierw ogólna reguła: WSKAZÓWKA NR 14 Dobry projekt jest łatwiejszy do modyfikacji niż zły. Przedmiot jest dobrze zaprojektowany, jeśli dostosowuje się do osób, które z niego korzystają. W przypadku kodu oznacza to konieczność dostosowania się do zmian. Dlatego stosujemy zasadę ETC (ang. Easier to Change — dosłownie: łatwiejsze do zmiany). Zasada ETC. To jest to! Naszym zdaniem każda zasada projektowania opisana w tej książce jest szczególnym przypadkiem zasady ETC. Dlaczego eliminowanie sprzężeń jest dobre? Ponieważ dzięki wyizolowaniu przedmiotów zainteresowania kod staje się łatwiejszy do zmiany. ETC. Dlaczego zasada pojedynczej odpowiedzialności jest przydatna? Ponieważ zmiana wymagań może być zaimplementowana przez zmianę tylko w jednym module. ETC. 4337ebf6db5c7cc89e4173803ef3875a 4 Istota dobrego projektu 55 Dlaczego jest ważne odpowiednie nazewnictwo? Ponieważ dzięki stosowaniu odpowiednich nazw kod staje się łatwiejszy do czytania, a żeby zmieniać kod, trzeba go czytać. ETC! ETC jest wartością, a nie regułą Wartości to elementy, które pomagają Ci podjąć decyzje: czy powinienem zrobić to, czy tamto? W przypadku oprogramowania zasada ETC jest przewodnikiem, który pomaga Ci wybrać właściwą ścieżkę. Podobnie jak wszystkie inne wartości, powinna być stosowana podświadomie, w subtelny sposób prowadzić Cię we właściwym kierunku. Ale jak sprawić, aby tak było? Z naszego doświadczenia wynika, że wymaga to pewnego wstępnego „uzbrojenia” świadomości. Być może powinieneś poświęcić tydzień na to, by celowo zadawać sobie pytanie: „Czy to, co właśnie zrobiłem, sprawi, że cały system stanie się łatwiejszy do zmiany, czy trudniejszy?”. Zadaj sobie to pytanie podczas zapisywania pliku. Zadawaj je podczas pisania testu. Zadawaj również wtedy, gdy poprawiasz błąd. W zasadzie ETC istnieje pewna niejawna przesłanka. Zakłada ona, że człowiek potrafi odpowiedzieć na pytanie o to, która z wielu dostępnych ścieżek pozwoli w przyszłości na łatwiejsze wprowadzanie zmian. W większości przypadków wystarczy zastosować zasady zdroworozsądkowe, dzięki którym można w inteligentny sposób odgadnąć właściwą ścieżkę. Czasem jednak nie mamy żadnych wskazówek. To jest w porządku. Uważamy, że w takich przypadkach można zrobić dwie rzeczy. Po pierwsze, biorąc pod uwagę, że nie jesteśmy pewni, jaką formę będą miały zmiany, zawsze możemy zastosować złotą ścieżkę „łatwości wprowadzania zmian”: postaraj się, aby kod, który piszesz, można było zastąpić. Dzięki temu, niezależnie od tego, co się stanie w przyszłości, ten fragment kodu nie będzie przeszkodą. Choć stosowanie tej zasady wydaje się być czymś ekstremalnym, w rzeczywistości podczas pisania kodu należy ją stosować zawsze. W praktyce sprowadza się to do eliminowania sprzężeń z kodu i dbania o jego spójność. Po drugie, potraktuj stosowanie zasady ETC jako sposób na rozwijanie intuicji. Opisz sytuację w swoim notatniku inżynierskim: wymień opcje, które masz do wyboru, oraz kilka możliwości przyszłych zmian. Oznacz kod źródłowy tagiem. Następnie, gdy będziesz później zmuszony do wprowadzenia zmian w kodzie, będziesz mógł zajrzeć do notatek i przekazać dla siebie opinię. Może ona pomóc Ci następnym razem, gdy będziesz zmuszony do podjęcia podobnej decyzji. W pozostałych podrozdziałach w tym rozdziale opisano konkretne poglądy dotyczące projektowania, ale motywacją dla nich wszystkich jest wymieniona w tym podrozdziale zasada ETC. 4337ebf6db5c7cc89e4173803ef3875a 4 56 Rozdział 2. Postawa pragmatyczna Pokrewne podrozdziały Temat 9., „DRY — Przekleństwo powielania”. Temat 10., „Ortogonalność”. Temat 11., „Odwracalność”. Temat 14., „Języki dziedzinowe”. Temat 28., „Eliminowanie sprzężeń”. Temat 30., „Programowanie transformacyjne”. Temat 31., „Podatek od dziedziczenia”. Wyzwania Pomyśl o zasadzie projektowania, którą regularnie stosujesz. Czy ta zasada przyczynia się do łatwości wprowadzania zmian? Pomyśl również o językach i paradygmatach programowania (OO, FP, Programowanie reaktywne i tak dalej). Czy którykolwiek z tych języków lub paradygmatów ma określone plusy bądź minusy związane z pisaniem kodu ETC? Czy któryś z języków bądź paradygmatów ma zarówno plusy, jak i minusy? Co możesz zrobić podczas kodowania, aby wyeliminować negatywy i zaakcentować pozytywy1? 9 37 Wiele edytorów zawiera wsparcie (wbudowane albo poprzez rozszerzenia) do uruchamiania poleceń podczas zapisywaniu pliku. Skonfiguruj swój edytor w taki sposób, aby przypominał Ci o zasadzie ETC za każdym razem, gdy zapisujesz plik2. Stosuj to przypomnienie jako wskazówkę do zastanowienia się nad kodem, który właśnie napisałeś. Czy będzie on łatwy do zmiany? DRY — przekleństwo powielania Dostarczanie komputerowi dwóch sprzecznych elementów wiedzy było ulubionym sposobem kapitana Jamesa T. Kirka radzenia sobie z wrogą sztuczną inteligencją. Okazuje się, niestety, że ten sam schemat może uniemożliwić prawidłowe działanie naszego kodu. Jako programiści gromadzimy, organizujemy, pielęgnujemy i wykorzystujemy swoją wiedzę. Wiedzę dokumentujemy też w specyfikacjach, ożywiamy ją, pisząc działający kod, oraz używamy jej do opracowywania mechanizmów sprawdzających na etapie testowania. 1 Parafrazując stary utwór Arlena i Mercera (Ac-Cent-Tchu-Ate the Positive)… 2 Albo żeby zachować zdrowie psychiczne, na przykład co 10 godzin… 4337ebf6db5c7cc89e4173803ef3875a 4 DRY — przekleństwo powielania 57 Okazuje się jednak, że wiedza nie jest stabilna. Wiedza podlega zmianom, które nierzadko mają gwałtowny charakter. Nasze rozumienie jakiegoś wymagania może ulec zmianie po spotkaniu z klientem. Zmianom podlegają regulacje prawne. Pewne elementy logiki biznesowej z czasem okazują się nieaktualne. Testy mogą wykazać, że wybrany algorytm nie zdaje egzaminu. Cała ta niestabilność oznacza, że znaczną część swojego czasu pracujemy w trybie konserwacji, próbując reorganizować i ponownie wyrażać wiedzę w swoich systemach. Większość ludzi zakłada, że konserwacja rozpoczyna się w momencie wydania aplikacji i że sprowadza się do naprawiania błędów i rozbudowy dotychczasowych funkcji. To przekonanie jest naszym zdaniem błędne. Programiści stale pracują w trybie konserwacji. Nasze rozumienie otoczenia zmienia się praktycznie codziennie. Nowe wymagania pojawiają się w trakcie projektowania lub kodowania oprogramowania. Nierzadko zmiany dotyczą także otaczającego nas środowiska. Niezależnie od przyczyn, konserwacja nie jest czynnością dyskretną, tylko rutynowym elementem całego procesu wytwarzania oprogramowania. Podczas wykonywania czynności konserwacyjnych musimy odnajdywać i zmieniać reprezentacje pewnych elementów wiedzy — swoistych kapsułek wiedzy osadzonych w kodzie aplikacji. Problem w tym, że wiedzę można łatwo powielać w tworzonych specyfikacjach, procesach i programach, a każde takie powielenie jest jak zaproszenie do koszmaru konserwacji — problem ujawnia się jeszcze przed dostarczeniem aplikacji. Wydaje nam się, że jedynym sposobem tworzenia niezawodnego oprogramowania, który w dodatku ułatwia współpracującym z nami programistom rozumienie i konserwację kodu, jest przestrzeganie zasady DRY: Każdy wycinek wiedzy musi mieć dokładnie jedną, jednoznaczną i oficjalną reprezentację w ramach systemu. Dlaczego mówi się o zasadzie DRY? WSKAZÓWKA NR 15 Nie powtarzaj się (ang. Don’t Repeat Yourself — DRY). Alternatywnym rozwiązaniem jest wyrażanie tej samej wiedzy w co najmniej dwóch miejscach. Zmiana reprezentacji w jednym miejscu oznacza, że musimy pamiętać o zmianie we wszystkich pozostałych wystąpieniach; w przeciwnym razie, wzorem komputerów obcych, sprzeczność zawarta w systemie uniemożliwi jego działanie. To, czy będziemy o tym pamiętać, nie budzi najmniejszych wątpliwości — powinniśmy się raczej zastanowić, kiedy o tym zapomnimy. Zasada DRY będzie jeszcze wielokrotnie przywoływana w tej książce, często w kontekstach, które nie mają nic wspólnego z kodowaniem aplikacji. Mamy wrażenie, że właśnie ta zasada należy do najważniejszych narzędzi w zestawie pragmatycznego programisty. 4337ebf6db5c7cc89e4173803ef3875a 4 58 Rozdział 2. Postawa pragmatyczna W tym podrozdziale wprowadzimy problemy związane z powielaniem wiedzy i zasugerujemy ogólne strategie ich rozwiązywania. DRY to więcej niż kod Na początek coś wyjaśnijmy. W pierwszym wydaniu tej książki niezbyt dobrze opisaliśmy, co mamy na myśli pisząc o zasadzie DRY. Wiele osób odnosiło tę zasadę wyłącznie do kodu. Zrozumieli oni, że DRY oznacza „nie kopiuj i nie wklejaj wierszy kodu źródłowego”. Istotnie, to jest część zasady DRY, ale niewielka i dość trywialna. Zasada DRY dotyczy powielania wiedzy i zamiarów. Dotyczy wyrażania tego samego w dwóch różnych miejscach, czasami na dwa zupełnie różne sposoby. Oto „papierek lakmusowy”: czy kiedy jakiś pojedynczy aspekt kodu musi się zmienić, musisz uwzględnić tę zmianę w wielu miejscach oraz w wielu różnych formatach? Czy musisz zmienić kod i dokumentację lub schemat bazy danych i jej strukturę, czy też nie musisz tego robić? Jeśli odpowiedziałeś twierdząco na to pytanie, to Twój kod nie spełnia zasady DRY. Zatem przyjrzyjmy się kilku typowym przykładom powielania. Powielanie w kodzie Być może to stwierdzenie jest trywialne, ale powielanie kodu jest bardzo powszechne. Oto przykład: def print_balance(account) printf "Obciążenia: %10.2f\n", account.debits printf "Uznania: %10.2f\n", account.credits if account.fees < 0 printf "Opłaty: %10.2f-\n", -account.fees else printf "Opłaty: %10.2f\n", account.fees end printf " ———-\n" if account.balance < 0 printf "Saldo: %10.2f-\n", -account.balance else printf "Saldo: %10.2f\n", account.balance end end Na razie zignorujemy fakt, że popełniamy błąd typowy dla nowicjuszy: zapisywanie waluty w zmiennych typu float. Zamiast tego sprawdź, czy potrafisz dostrzec w tym kodzie symptomy powielania (my widzimy co najmniej trzy takie oznaki, ale być może jest ich więcej). Co udało Ci się dostrzec? Oto nasza lista. 4337ebf6db5c7cc89e4173803ef3875a 4 DRY — przekleństwo powielania 59 Po pierwsze wyraźnie widać stosowanie techniki kopiuj i wklej przy obsłudze liczb ujemnych. Możemy to poprawić poprzez dodanie nowej funkcji: def format_amount(value) result = sprintf("%10.2f", value.abs) if value < 0 result + "-" else result + " " end end def print_balance(account) printf "Obciążenia: %10.2f\n", account.debits printf "Uznania: %10.2f\n", account.credits printf "Opłaty: %s\n", format_amount(account.fees) printf " ———-\n" printf "Saldo: %s\n", format_amount(account.balance) end Innym objawem powielania jest powtarzanie szerokości pola we wszystkich wywołaniach funkcji printf. Możemy rozwiązać ten problem poprzez wprowadzenie stałej i przekazanie jej do każdego wywołania, ale możemy także użyć istniejącej funkcji. def format_amount(value) result = sprintf("%10.2f", value.abs) if value < 0 result + "-" else result + " " end end def print_balance(account) printf "Obciążenia: %s\n", format_amount(account.debits) printf "Uznania: %s\n", format_amount(account.credits) printf "Opłaty: %s\n", format_amount(account.fees) printf " ———-\n" printf "Saldo: %s\n", format_amount(account.balance) end Czy coś jeszcze? A co się stanie, jeśli klient prosi o dodatkową spację pomiędzy etykietami a liczbami? Musielibyśmy wprowadzić zmiany w pięciu wierszach. Usuńmy to powtórzenie: def format_amount(value) result = sprintf("%10.2f", value.abs) if value < 0 result + "-" else result + " " end end def print_line(label, value) printf "%-9s%s\n", label, value end 4337ebf6db5c7cc89e4173803ef3875a 4 60 Rozdział 2. Postawa pragmatyczna def report_line(label, amount) print_line(label + ":", format_amount(amount)) end def print_balance(account) report_line("Obciążenia", account.debits) report_line("Uznania", account.credits) report_line("Opłaty", account.fees) print_line("", "———-") report_line("Saldo", account.balance) end Jeśli będziemy zmuszeni do zmiany formatowania kwot, zmienimy funkcję format_amount. Jeśli będziemy chcieli zmienić format etykiety, zmienimy funkcję report_line. W tym kodzie nadal istnieje niejawne naruszenie zasady DRY: liczba kresek w wierszu separatora jest związana z szerokością pola amount. Ale nie jest to dokładne dopasowanie: wiersz separatora jest o jeden znak krótszy, więc wszystkie końcowe znaki minus wykraczają poza kolumnę. Taka była intencja klienta, która różni się od zasad prawidłowego formatowania kwot. Nie każde powielenie kodu jest powieleniem wiedzy W aplikacji online do zamawiania wina pobierasz i weryfikujesz wiek użytkownika oraz zamawianą ilość butelek. Według wymagań właściciela witryny, obie te wartości powinny być liczbami i obie powinny być większe od zera. Oto kod walidacji wprowadzanych wartości: def validate_age(value): validate_type(value, :integer) validate_min_integer(value, 0) def validate_quantity(value): validate_type(value, :integer) validate_min_integer(value, 0) Podczas przeglądu kodu, wszystkowiedzący recenzent odrzucił ten kod, twierdząc, że jest to naruszenie zasady DRY: treść obu funkcji jest identyczna. Nie ma racji. Kod jest taki sam, ale wiedza, którą reprezentują funkcje, różni się. Te dwie funkcje sprawdzają poprawność dwóch oddzielnych rzeczy, dla których w pewnym momencie obowiązują takie same zasady poprawności. To zbieg okoliczności, a nie powielanie. Powielanie w dokumentacji W jakiś sposób powstał mit, że należy komentować wszystkie funkcje. Osoby, które popierają to szaleństwo, tworzą coś takiego: # Oblicza opłaty dla tego konta. # # * Każdy zwrócony czek kosztuje 20 PLN 4337ebf6db5c7cc89e4173803ef3875a 4 DRY — przekleństwo powielania 61 # * Jeśli na rachunku występuje debet dłużej niż 3 dni, # nalicza opłatę 10 PLN za każdy dzień # * Jeśli średnie saldo rachunku jest większe niż 2000 PLN, # zmniejsza opłaty o 50% def fees(a) f = 0 if a.returned_check_count > 0 f += 20 * a.returned_check_count end if a.overdraft_days > 3 f += 10*a.overdraft_days end if a.average_balance > 2_000 f /= 2 end f end Istota działania tej funkcji została podana dwa razy: raz w komentarzu i ponownie w kodzie. Jeśli klient zmieni opłatę, aktualizację trzeba wprowadzić w dwóch miejscach. Z biegiem czasu z dużą dozą pewności można założyć, że komentarz przestanie być zgodny z kodem. Zadaj sobie pytanie, jaką wartość dodaje komentarz do kodu. Z naszego punktu widzenia komentarz jedynie rekompensuje niezbyt dobre nazewnictwo i układ. A gdyby tak funkcja miała następującą treść: def calculate_account_fees(account) fees = 20 * account.returned_check_count fees += 10 * account.overdraft_days if account.overdraft_days > 3 fees /= 2 if account.average_balance > 2_000 fees end Nazwa mówi, co robi funkcja, a jeśli ktoś potrzebuje szczegółów, są one wyjaśnione w kodzie źródłowym. Ten kod spełnia zasadę DRY! Naruszenia zasady DRY w danych Struktury danych reprezentują wiedzę. Jeśli są nieprawidłowo skonstruowane, mogą doprowadzić do konfliktu z zasadą DRY. Przyjrzyjmy się poniższej klasie reprezentującej odcinek: class Line { public: Point start; Point end; double length; }; Na pierwszy rzut oka klasa w tej formie wydaje się rozsądna. Odcinek, oczywiście, ma swój początek i koniec oraz zawsze ma jakąś długość (nawet jeśli ta długość wynosi zero). Okazuje się jednak, że mamy tutaj do czynienia z powieleniem informacji. Długość jest uzależniona od położenia punktu początkowego 4337ebf6db5c7cc89e4173803ef3875a 4 62 Rozdział 2. Postawa pragmatyczna i punktu końcowego — przesunięcie któregoś z tych punktów może powodować zmianę długości. W tej sytuacji lepszym rozwiązaniem będzie przekształcenie długości w pole wyliczane: class Line { public: Point start; Point end; double length() { return start.distanceTo(end); } }; Na późniejszych etapach procesu wytwarzania oprogramowania możemy świadomie podjąć decyzję o naruszeniu zasady DRY z myślą o poprawie wydajności. Taka sytuacja często ma miejsce wtedy, gdy chcemy przechowywać w pamięci podręcznej jakieś dane, aby uniknąć konieczności powtarzania kosztownych obliczeń. Cała sztuka polega wówczas na właściwej ocenie skutków tej decyzji. Naruszenie zasady DRY nie powinno być widoczne z zewnątrz — za zachowanie spójności powinny odpowiadać wyłącznie metody wchodzące w skład danej klasy. class Line { private double length; private Point start; private Point end; public Line(Point start, Point end) { this.start = start; this.end = end; calculateLength(); } // metody publiczne void setStart(Point p) { this.start = p; calculateLength(); } void setEnd(Point p) { this.end = p; calculateLength(); } Point getStart() { return start; } Point getEnd() { return end; } double getLength() { return length; } private void calculateLength() { this.length = start.distanceTo(end); } }; Przykład ten ilustruje również ważną kwestię: zawsze gdy moduł ujawnia strukturę danych, sprzęgamy cały kod, który używa tej struktury z implementacją modułu. Wszędzie, gdzie to możliwe, do odczytu i zapisu atrybutów obiektów należy używać funkcji akcesorów. Dzięki temu dodawanie funkcjonalności będzie w przyszłości łatwiejsze. Korzystanie z funkcji akcesorów wiąże się z zasadą jednorodnego dostępu Meyera, opisaną w książce Object-Oriented Software Construction [Mey97]. Zasada ta stanowi, że wszystkie usługi oferowane przez moduł powinny być dostępne za pośrednictwem jednorodnej notacji, która nie zdradza, czy są one realizowane poprzez składowanie, czy przez obliczenia. 4337ebf6db5c7cc89e4173803ef3875a 4 DRY — przekleństwo powielania 63 Powielanie na poziomie reprezentacji Twój kod komunikuje się ze światem zewnętrznym: innymi bibliotekami za pośrednictwem API, innymi usługami za pośrednictwem zdalnych wywołań, danymi z zewnętrznych źródeł i tak dalej. I prawie za każdym razem, gdy to robisz, wprowadzasz jakieś naruszenie zasady DRY: Twój kod musi mieć świadomość, że jest obecny w kodzie zewnętrznym. Musi on znać API, schemat, znaczenie kodów błędów lub inne szczegóły. Powielanie w tym przypadku ujawnia się w tym, że dwa elementy (kod i komponent zewnętrzny) muszą posiadać wiedzę dotyczącą reprezentacji swoich interfejsów. Jeśli zmienimy go w jednym komponencie, drugi może przestać działać. To powielenie jest nieuniknione, ale można złagodzić skutki jego występowania. Oto kilka strategii. Powielanie za pośrednictwem wewnętrznych interfejsów API W przypadku wewnętrznych interfejsów API, należy szukać narzędzi, które pozwalają określić interfejs API w jakiejś neutralnej formie. Narzędzia te zazwyczaj generują dokumentację, makiety API, testy funkcjonalne i klienty API — te ostatnie w wielu różnych językach. W idealnym przypadku takie narzędzie powinno przechowywać wszystkie API w centralnym repozytorium, co umożliwia ich współdzielenie między zespołami. Powielanie za pośrednictwem zewnętrznych interfejsów API Publiczne interfejsy API coraz częściej są dokumentowane formalnie przy użyciu mechanizmów podobnych do OpenAPI3. Pozwala to na importowanie specyfikacji API do lokalnych narzędzi i ich bardziej niezawodne integrowanie z usługą. Jeśli nie ma takiej specyfikacji, należy rozważyć jej utworzenie i opublikowanie. Taka specyfikacja nie tylko przyda się użytkownikom, ale także poprawi możliwości utrzymania interfejsu. Powielanie za pośrednictwem źródeł danych Wiele źródeł danych pozwala na introspekcję ich schematu danych. Mechanizmy introspekcji można wykorzystać do usunięcia sporej części powielania pomiędzy źródłami danych a kodem. Zamiast ręcznego tworzenia kodu zawierającego składowane dane, można wygenerować kontenery bezpośrednio na podstawie schematu. Te uciążliwe operacje może za nas wykonać wiele frameworków utrwalania danych. Istnieje także inna opcja, którą preferujemy. Zamiast pisania kodu, który reprezentuje zewnętrzne dane w stałej strukturze (na przykład egzemplarzu struktury lub klasy), można utrzymywać je w formacie klucz-wartość (w języku 3 https://github.com/OAI/OpenAPI-Specification 4337ebf6db5c7cc89e4173803ef3875a 4 64 Rozdział 2. Postawa pragmatyczna programowania taka konstrukcja może nazywać się mapą, tablicą asocjacyjną, słownikiem lub nawet obiektem). Takie postępowanie, bez stosowania innych mechanizmów, może być ryzykowne: pogarsza bezpieczeństwo, ponieważ nie wiemy, z jakimi danymi pracujemy. Z tego powodu zalecamy dodanie drugiej warstwy do tego rozwiązania: prostego zestawu reguł walidacyjnych konfigurowanych przez tablicę, który sprawdza, czy utworzona mapa zawiera potrzebne dane w odpowiednim formacie. Taką tablicę mogą generować niektóre narzędzia do tworzenia dokumentacji API. Powielanie wśród wielu programistów Z zupełnie inną sytuacją mamy do czynienia w przypadku zjawiska powielania zachodzącego wśród wielu różnych programistów zaangażowanych w projekt — tu wykrycie i wyeliminowanie problemu jest szczególnie trudne. Całe zbiory funkcji mogą być przypadkowo powielane i te powielenia mogą pozostawać niewykryte całymi latami, prowadząc do poważnych problemów związanych z konserwacją. Słyszeliśmy kiedyś od dobrze poinformowanej osoby historię o jednym z amerykańskich stanów, którego rządowy komputer był poddany badaniom pod kątem odporności na problem 2000 roku. Audyt wykazał istnienie ponad 10 tys. programów, z których każdy stosował własną wersję weryfikacji numerów ubezpieczenia społecznego (SSN). Na wysokim poziomie należy radzić sobie z tym problemem poprzez stworzenie zespołu, który dobrze ze sobą współpracuje. Na poziomie modułów wspomniany problem okazuje się jednak bardziej podstępny. Często potrzebne funkcje czy dane, które trudno jednoznacznie skojarzyć z konkretnym obszarem odpowiedzialności, mogą być implementowane wiele razy w ramach tego samego projektu. Wydaje się, że najlepszym sposobem radzenia sobie z tym problemem jest zachęcanie członków zespołu do aktywnej i możliwie częstej komunikacji. Warto przeprowadzać codzienne spotkanie stand-up. Można stworzyć fora (np. kanały Slack) umożliwiające swobodną dyskusję o typowych problemach. Dzięki temu możliwa jest nieinwazyjna komunikacja, także z udziałem programistów zatrudnionych w różnych miejscach, z możliwością trwałego zapisywania formułowanych wniosków). Warto wyznaczyć jednego członka zespołu do roli swoistego bibliotekarza projektu, którego zadaniem będzie wspieranie procesu wymiany wiedzy. Dobrym pomysłem jest też wyznaczenie centralnego miejsca w drzewie kodu źródłowego, w którym będzie można zapisywać procedury i skrypty użytkowe. Należy też wypracować procedury czytania cudzego kodu źródłowego i dokumentacji (albo w sposób nieformalny, albo w ramach przeglądów kodu). Nie chodzi o wtykanie nosa w nie swoje sprawy — naszym celem jest nauka. Musimy przy tym pamiętać 4337ebf6db5c7cc89e4173803ef3875a 4 Ortogonalność 65 o zasadzie wzajemności — nie możemy zazdrośnie strzec własnego kodu przed wzrokiem współpracowników. WSKAZÓWKA NR 16 Należy dbać o możliwość wielokrotnego stosowania kodu. Naszym celem jest stworzenie środowiska, w którym znajdowanie i ponowne wykorzystywanie istniejących rozwiązań będzie łatwiejsze niż samodzielne pisanie analogicznego kodu. Jeśli to nie będzie dostatecznie łatwe, nikt nie będzie tego robił. A jeśli istniejące rozwiązania nie są ponownie używane, ryzykujemy powielanie wiedzy. Pokrewne podrozdziały 10 38 Temat 8., „Istota dobrego projektu”. Temat 28., „Eliminowanie sprzężeń”. Temat 32., „Konfiguracja”. Temat 38, „Programowanie przez koincydencję”. Temat 40., „Refaktoryzacja”. Ortogonalność Ortogonalność jest podstawową koncepcją dla każdego programisty zainteresowanego tworzeniem systemów łatwych do zaprojektowania, skonstruowania, przetestowania i rozwijania. Okazuje się jednak, że idea ortogonalności rzadko jest bezpośrednio proponowana studentom. Ortogonalność nierzadko ma postać niejawnej, ukrytej cechy rozmaitych metod i technik, które poznajemy w trakcie studiów. To błąd. Kiedy programista opanuje sztukę bezpośredniego stosowania zasady ortogonalności, od razu zauważy poprawę jakości tworzonych przez siebie systemów. Czym jest ortogonalność? Ortogonalność to pojęcie zaczerpnięte z geometrii. Dwie proste są ortogonalne, jeśli przecinają się pod kątem prostym (tak jest na przykład w przypadku osi układu współrzędnych). W przypadku wektorów mówi się o liniowej niezależności. Kiedy wartość 1 na wykresie przesunie się w kierunku północnym, nie ma to wpływu na to, jak daleko jest ona w kierunkach wschodnim lub zachodnim. Wartość 2 przemieszcza się w kierunku wschodnim, ale nie w północnym lub południowym. 4337ebf6db5c7cc89e4173803ef3875a 4 66 Rozdział 2. Postawa pragmatyczna W świecie komputerów interesujący nas termin oznacza rodzaj niezależności lub izolacji. Mówimy, że co najmniej dwa elementy są ortogonalne, jeśli zmiana jednego z nich nie wpływa na pozostałe. W dobrze zaprojektowanym systemie kod bazy danych jest ortogonalny względem interfejsu użytkownika — oznacza to, że możemy zmienić ten interfejs bez wpływu na bazę danych oraz wymienić system bazy danych na inny bez wpływu na interfejs. Zanim przeanalizujemy korzyści wynikające ze stosowania systemu ortogonalnego, przeanalizujemy system, który nie jest ortogonalny. System nieortogonalny Odbywamy turystyczny lot helikopterem w Wielkim Kanionie, gdy nagle pilot, który nierozważnie przejadł się rybą podczas lunchu, blednie i traci przytomność. Na szczęście, w ostatnim odruchu udało mu się ustabilizować maszynę kilkadziesiąt metrów nad ziemią. Na szczęście poprzedniego wieczoru czytałeś stronę Wikipedii poświęconą helikopterom. Wiesz, że do sterowania helikopterem służą cztery podstawowe przyrządy. W prawej dłoni pilot trzyma drążek sterowy (do tzw. sterowania okresowego). Zmiana położenia tego drążka powoduje ruch helikoptera w odpowiednim kierunku. W lewej ręce pilot trzyma dźwignię skoku. Pociągnięcie tej dźwigni do góry powoduje zwiększenie kąta natarcia wszystkich łopat wirnika i — tym samym — wygenerowanie większej siły nośnej. Na końcu dźwigni skoku znajduje się przyrząd do sterowania obrotami silnika. I wreszcie, pilot dysponuje dwoma pedałami sterującymi kątem natarcia wirnika ogonowego i ułatwiającymi obrót śmigłowca wokół osi pionowej. „Łatwe” — myślisz sobie — delikatne obniżenie położenia dźwigni skoku spowoduje powolne zbliżanie się do ziemi. Czujesz się bohaterem. Kiedy jednak próbujesz to zrobić, szybko odkrywasz, że życie jest bardziej skomplikowane. Nos helikoptera pochylił się, a cała maszyna zaczęła coraz szybciej obracać się w lewo. Nagle odkryłeś, że w systemie sterowania śmigłowcem każdy ruch przyrządem prowadzi do jakichś skutków ubocznych. Obniżenie dźwigni trzymanej lewą ręką wymaga korekty położenia drążka trzymanego w prawej dłoni i lekkiego dociśnięcia prawego pedału. Co więcej, każda z tych zmian ponownie 4337ebf6db5c7cc89e4173803ef3875a 4 Ortogonalność 67 wpływa na wszystkie pozostałe przyrządy. Żonglujemy więc niewiarygodnie złożonym systemem, w którym każda, nawet najdrobniejsza modyfikacja wpływa na wszystkie pozostałe elementy. Obciążenie, jakiemu jesteśmy poddawani, jest wprost niewiarygodne — nasze dłonie i stopy stale zmieniają położenie przyrządów, próbując reagować na te wszystkie wzajemnie oddziałujące siły. Sterowanie helikopterem z pewnością nie jest systemem ortogonalnym. Zalety ortogonalności Jak pokazuje przykład helikoptera, systemy nieortogonalne są z natury rzeczy bardziej wymagające zarówno w kontekście zmian, jak i zwykłego sterowania. Kiedy komponenty systemu są powiązane silnymi, wzajemnymi zależnościami, nie może być mowy o takich rozwiązaniach jak lokalne poprawki. WSKAZÓWKA NR 17 Należy eliminować wzajemny wpływ niepowiązanych elementów. Chcemy projektować autonomiczne komponenty, czyli niezależne byty tworzone z myślą o jednym, precyzyjnie zdefiniowanym celu (Yourdon i Constantine określają tę cechę mianem spójności [YC79]). Jeśli poszczególne komponenty są od siebie odizolowane, możemy być pewni, że zmiana jednego z nich nie będzie wymagała troski o pozostałe. Dopóki nie zmieniamy interfejsów zewnętrznych naszych komponentów, możemy być pewni, że wprowadzane modyfikacje nie spowodują problemów ujawniających się w różnych częściach systemu. Pisanie ortogonalnych systemów ma dwie podstawowe zalety: poprawia produktywność i ogranicza ryzyko. Wyższa produktywność Zmiany są ściśle związane z konkretnymi miejscami, zatem czas wytwarzania i testowania można znacznie skrócić. Pisanie stosunkowo niewielkich, autonomicznych komponentów jest nieporównanie prostsze od tworzenia jednego wielkiego bloku kodu. Proste komponenty po zaprojektowaniu, zakodowaniu i poddaniu testom jednostkowym można po prostu zapomnieć — nie ma potrzeby ustawicznego modyfikowania istniejącego kodu przy okazji dodawania nowych elementów. Model ortogonalny dodatkowo zwiększa możliwości wielokrotnego stosowania tych samych rozwiązań. Jeśli komponenty mają przypisane konkretne, precyzyjnie zdefiniowane obszary odpowiedzialności, można je z powodzeniem łączyć z nowymi komponentami, stosując techniki, o których twórcy oryginału nawet nie pomyśleli. Im luźniejsze będą związki w naszych systemach, tym prostsze będzie ich ponowne konfigurowanie i rekonstruowanie. 4337ebf6db5c7cc89e4173803ef3875a 4 68 Rozdział 2. Postawa pragmatyczna Produktywność nieznacznie wzrasta także w przypadku łączenia ortogonalnych komponentów. Przypuśćmy, że jeden komponent wykonuje M zadań, a inny komponent wykonuje N odrębnych zadań. Jeśli oba komponenty są ortogonalne i jeśli je łączymy, otrzymujemy M×N zadań. Jeśli jednak oba komponenty nie są ortogonalne, część ich funkcji będzie się pokrywała, zatem w wyniku tego połączenia otrzymamy mniejszą liczbę rozwiązań. Okazuje się więc, że łączenie ortogonalnych elementów pozwala uzyskać większą liczbę funkcji w przypadku tych samych nakładów. Mniejsze ryzyko Model ortogonalny pozwala ograniczyć ryzyko związane z wytwarzaniem każdego kodu. Problematyczne sekcje kodu są izolowane. Jeśli jakiś moduł jest źle zaprojektowany lub zaimplementowany, prawdopodobieństwo występowania symptomów tych niedociągnięć w całym systemie będzie mniejsze. Właściwie odizolowany kod można też nieporównanie łatwiej dzielić i przenosić do nowych, prawidłowo zaprojektowanych modułów. Utworzony w ten sposób system jest mniej wrażliwy na zmiany. Drobne zmiany i poprawki są wprowadzane w konkretnym obszarze, a ewentualne problemy wynikające z tych zmian dotyczą tylko tego obszaru. Ortogonalny system prawdopodobnie zostanie też lepiej przetestowany, ponieważ projektowanie i wykonywanie testów na komponentach takich systemów jest dużo prostsze. Ortogonalność eliminuje ścisłe zależności z tym czy innym producentem, produktem lub platformą, ponieważ interfejsy łączące nasz system z komponentami zewnętrznymi są odizolowane i mają postać drobnych elementów składowych ogólnego procesu wytwarzania. Przeanalizujmy teraz inne sposoby stosowania zasady ortogonalności w codziennej pracy. Projekt Większość programistów doskonale rozumie potrzebę projektowania ortogonalnych systemów, mimo że używają nieco innych określeń do opisywania tego procesu (mówią o systemach modułowych, komponentowych czy wielowarstwowych). Systemy powinny się składać ze zbioru współpracujących modułów, z których każdy implementuje funkcje niezależne od mechanizmów oferowanych przez pozostałe moduły. W pewnych przypadkach komponenty organizuje się w ramach warstw, z których każda udostępnia jeden poziom abstrakcji. Model wielowarstwowy jest jednym z najlepszych sposobów projektowania systemów ortogonalnych. Ponieważ każda warstwa używa tylko abstrakcji udostępnianych przez warstwy znajdujące się pod nią, możemy pozwolić sobie na daleko idącą elastyczność podczas modyfikowania implementacji bez wpływu na kod 4337ebf6db5c7cc89e4173803ef3875a 4 Ortogonalność 69 w pozostałych warstwach. Podział na warstwy ogranicza ryzyko powstawania przypadkowych zależności pomiędzy modułami. Podział na warstwy często wyraża się w formie diagramów podobnych do tego z rysunku poniżej. Istnieje pewien prosty test ortogonalności projektu. Po rozplanowaniu komponentów warto zadać sobie następujące pytanie: na ile modułów wpłynęłaby zasadnicza zmiana wymagań dotyczących jednej funkcji? W ortogonalnym systemie odpowiedź zawsze powinna brzmieć „jeden”4. Przeniesienie przycisku na panelu graficznego interfejsu użytkownika nie powinno wymagać zmiany schematu bazy danych. Dodanie pomocy kontekstowej nie powinno wymagać modyfikacji podsystemu generowania faktur. Przeanalizujmy przykład złożonego systemu do monitorowania pracy ciepłowni i sterowania jej funkcjonowaniem. Przyjmijmy, że oryginalne wymagania obejmowały opracowanie graficznego interfejsu użytkownika i że z czasem w miejsce tego interfejsu zażądano stworzenia systemu odpowiedzi głosowej i mechanizmu sterowania za pośrednictwem klawiatury telefonu. W systemie zaprojektowanym zgodnie z zasadą ortogonalności zmiana tego wymagania wymagałaby modyfikacji tylko modułów ściśle powiązanych z interfejsem użytkownika — znajdująca się poniżej logika sterowania pracą ciepłowni powinna pozostać niezmieniona. W praktyce odpowiednio zaprojektowana struktura systemu powinna umożliwić nawet obsługę obu interfejsów bez najmniejszych zmian wewnętrznej bazy kodu. Warto też sprawdzić, na ile nasz projekt jest odporny na zmiany zachodzące w rzeczywistym świecie. Czy używamy na przykład numeru telefonu w roli identyfikatora klienta? Co będzie, jeśli operator telekomunikacyjny zmieni sposób przypisywania numerów kierunkowych? Nie powinniśmy uzależniać naszego systemu od właściwości, na które nie mamy wpływu. 4 W rzeczywistości takie założenie okazuje się jednak dość naiwne. Jeśli nie mamy wyjątkowego szczęścia, większość typowych zmian wymagań będzie wpływała na wiele funkcji naszego systemu. Jeśli jednak analizujemy skutki zmian na poziomie funkcji, powinniśmy pamiętać, że w idealnych warunkach modyfikacja powinna wpływać tylko na jeden moduł. 4337ebf6db5c7cc89e4173803ef3875a 4 70 Rozdział 2. Postawa pragmatyczna Zestawy narzędzi i biblioteki O zachowanie ortogonalności systemu należy dbać także w trakcie dodawania zestawów narzędzi i bibliotek innych producentów. Powinniśmy rozważnie dobierać stosowane technologie. Kiedy rozważamy użycie jakiegoś zestawu narzędzi (a nawet biblioteki autorstwa innego członka naszego zespołu), powinniśmy zadać sobie pytanie, czy nowe rozwiązania będą wymagały wprowadzenia nieuzasadnionych zmian w dotychczasowym kodzie. Jeśli schemat utrwalania obiektów jest transparentny z perspektywy pozostałego kodu, mamy do czynienia z mechanizmem ortogonalnym. Jeśli jednak ten schemat wymaga utworzenia specjalnych obiektów dostępu, z pewnością nie jest ortogonalny. Izolowanie tego rodzaju szczegółów od własnego kodu ma też tę zaletę, że ułatwia zmianę dostawcy dodatkowych rozwiązań w przyszłości. Ciekawym przykładem ortogonalności jest system Enterprise Java Beans (EJB). W większości systemów transakcyjnych kod aplikacji musi wprost wskazywać początek i koniec każdej transakcji. W technologii EJB informacje tego typu wyraża się deklaratywnie w formie metadanych, a więc poza właściwym kodem. Ten sam kod aplikacji można wykonywać w różnych środowiskach transakcyjnych technologii EJB bez konieczności wprowadzania jakichkolwiek zmian. Opisany model najprawdopodobniej będzie powielany w wielu przyszłych środowiskach. W pewnym sensie EJB jest przykładem wzorca Dekorator: polega na dodawaniu funkcjonalności do kodu bez jego zmieniania. Ten styl programowania może być stosowany w niemal każdym języku programowania i niekoniecznie wymaga frameworka lub biblioteki. Jedyne, co jest potrzebne, to po prostu nieco dyscypliny podczas programowania. Kodowanie Za każdym razem, gdy piszemy jakiś kod, musimy liczyć się z ryzykiem ograniczenia ortogonalności naszej aplikacji. Jeśli stale nie monitorujemy zarówno naszych bieżących poczynań, jak i szerszego kontekstu budowy naszej aplikacji, możemy przypadkowo powielić funkcje zaimplementowane już w jakimś innym module lub dwukrotnie wyrazić istniejącą wiedzę. Istnieje wiele technik, których można z powodzeniem używać do zachowywania ortogonalności: Należy dbać o izolację kodu. Powinniśmy pisać skromny kod, czyli moduły, które bez potrzeby nie udostępniają wszystkich swoich funkcji pozostałym modułom, ale też przesadnie nie korzystają z implementacji innych modułów. Warto zapoznać się z prawem Demeter [LH89], które zostanie omówione w temacie 28., „Eliminowanie sprzężeń”. Jeśli musimy zmienić stan jakiegoś obiektu, powinniśmy doprowadzić do sytuacji, w której inte- 4337ebf6db5c7cc89e4173803ef3875a 4 Ortogonalność 71 resujący nas stan zostanie zmieniony przez sam obiekt. W ten sposób możemy zachować izolację naszego kodu względem implementacji zawartych w cudzym kodzie i — tym samym — zwiększyć szanse zachowania dotychczasowej ortogonalności systemu. Należy unikać danych globalnych. Za każdym razem, gdy nasz kod odwołuje się do danych globalnych, jest wiązany z pozostałymi komponentami używającymi tych danych. Nawet dane globalne, które w założeniu mają być tylko odczytywane, mogą powodować poważne problemy (jeśli na przykład nagle będziemy musieli przystosować nasz kod do pracy wielowątkowej). Ogólnie nasz kod jest bardziej zrozumiały i łatwiejszy w konserwacji, jeśli wprost przekazujemy cały niezbędny kontekst do swoich modułów. W aplikacjach obiektowych kontekst często jest przekazywany w formie parametrów konstruktorów obiektów. W kodzie pozostałych aplikacji możemy tworzyć struktury reprezentujące kontekst i przekazywać referencje do tych struktur. Wzorzec projektowy Singleton opisany w książce Wzorce projektowe [GHJV95] gwarantuje nam, że będzie istniał tylko jeden egzemplarz obiektu określonej klasy. Wielu programistów używa tych singletonowych obiektów w roli swoistych zmiennych globalnych (szczególnie w takich językach jak Java, gdzie obsługa danych globalnych w inny sposób jest niemożliwa). Obiekty singletonowe należy stosować rozważnie — także one mogą rodzić zbędne powiązania. Należy unikać podobnych funkcji. W swojej pracy często spotykamy zbiory funkcji, które wyglądają bardzo podobnie — zdarza się, że kod na ich początku i końcu jest identyczny, a różnice sprowadzają się do stosowania odmiennych algorytmów centralnych. Powielony kod zawsze należy traktować jako symptom problemów strukturalnych. Warto więc zapoznać się ze wzorcem Strategia opisanym w książce Wzorce projektowe i podjąć próbę opracowania lepszej implementacji. Należy opanować sztukę krytycznej oceny własnego kodu. Warto poszukiwać wszelkich okazji do reorganizacji kodu z myślą o poprawie jego struktury i ortogonalności i wykorzystywać je. Odpowiedni proces, który określa się mianem refaktoryzacji, jest na tyle istotny, że poświęcimy mu sporo uwagi w dalszej części tej książki (patrz temat 40., „Refaktoryzacja”). Testowanie Systemy projektowane i implementowane zgodnie z zasadą ortogonalności są dużo łatwiejsze do testowania. Ponieważ interakcje pomiędzy komponentami systemu są sformalizowane i ograniczone, znaczną część testów można wykonać na poziomie pojedynczych modułów. Takie rozwiązanie jest o tyle korzystne, że testy na poziomie modułu (jednostki) są nieporównanie prostsze do definiowania i wykonywania niż testy integracyjne. Sugerujemy nawet opracowanie osobnego testu jednostkowego dla każdego modułu i umieszczenie go w kodzie 4337ebf6db5c7cc89e4173803ef3875a 4 72 Rozdział 2. Postawa pragmatyczna tego modułu. Tak zdefiniowane testy powinny być wykonywane automatycznie w ramach procesu regularnego kompilowania kodu (patrz temat 41., „Kod łatwy do testowania”). Budowa testów jednostkowych sama w sobie jest dość ciekawym testem ortogonalności systemu. Co należy zrobić, aby przygotować test jednostkowy i połączyć go z systemem? Czy kompilacja lub łączenie tego testu wymaga dostępu do znacznej części pozostałych składników systemu? Jeśli tak, właśnie odkryliśmy moduł, który nie jest dobrze odizolowany od reszty systemu. Dobrym momentem oceny ortogonalności systemu jest także usuwanie znalezionych błędów. Po napotkaniu problemu warto przeanalizować zasięg oddziaływania usterki. Czy wystarczy zmienić tylko jeden moduł, czy zmiany dotyczą całego systemu? Czy jedna zmiana wystarczy do usunięcia problemu, czy raczej powoduje tajemnicze pojawienie się innych problemów? To wprost doskonała okazja do zastosowania automatycznych rozwiązań. Jeśli posługujemy się systemem kontroli wersji (po lekturze tematu 19., „Kontrola kodu źródłowego” to raczej pewne), możemy oznaczać usunięte błędy przy okazji zwracania kodu do repozytorium po zakończeniu testów. W takim przypadku warto skorzystać z możliwości generowania comiesięcznych raportów ilustrujących trendy w zakresie liczby plików źródłowych objętych procedurami usuwania usterek. Dokumentacja Co ciekawe, zasada ortogonalności znajduje zastosowanie także w dokumentacji. W tym przypadku funkcje osi pełni treść i prezentacja. Naprawdę ortogonalna dokumentacja powinna umożliwiać zasadniczą zmianę wyglądu bez konieczności modyfikowania treści. Współczesne edytory tekstu oferują obsługę arkuszy stylów i makr, które znacznie ułatwiają budowę ortogonalnych dokumentów. Osobiście preferujemy stosowanie systemu znaczników, takich jak Markdown: podczas pisania skupiamy się tylko na treści, a prezentację pozostawiamy narzędziu wykorzystywanemu do renderowania5. Życie z ortogonalnością Ortogonalność jest ściśle związana z zasadą DRY, wprowadzoną we wcześniejszej części tego rozdziału. Zasada DRY ma na celu zminimalizowanie zjawiska powielania w systemie, natomiast ortogonalność pozwala ograniczyć wzajemne zależności łączące komponenty tego systemu. Być może trudno w to uwierzyć, ale konsekwentne łączenie zasady ortogonalności z zasadą DRY pozwala tworzyć systemy nieporównanie bardziej elastyczne, zrozumiałe oraz łatwiejsze do diagnozowania, testowania i konserwowania. 5 Ta książka została napisana z wykorzystaniem systemu Markdown i złożona bezpośrednio ze źródła Markdown. 4337ebf6db5c7cc89e4173803ef3875a 4 Ortogonalność 73 Jeśli przystępujemy do prac nad projektem, w którym wszelkie zmiany budzą niechęć i opór pozostałych członków zespołu, gdzie każda modyfikacja może uniemożliwić funkcjonowanie pięciu innych elementów, warto przypomnieć sobie koszmar sterowania helikopterem. Projekt prawdopodobnie nie został zaprojektowany i nie jest kodowany zgodnie z zasadą ortogonalności. Najwyższy czas przystąpić do refaktoryzacji. A jeśli pilotujemy śmigłowiec, powinniśmy unikać jedzenia ryb… Pokrewne podrozdziały Temat 3., „Entropia oprogramowania”. Temat 8., „Istota dobrego projektu”. Temat 11., „Odwracalność”. Temat 28., „Eliminowanie sprzężeń”. Temat 31., „Podatek od dziedziczenia”. Temat 33., „Eliminowanie związków czasowych”. Temat 34., „Współdzielony stan jest zły”. Temat 36., „Czarne tablice”. Wyzwania Warto przestudiować różnice dzielące wielkie narzędzia z graficznym interfejsem użytkownika (tworzone zwykle dla systemu Windows) i niewielkie, ale złożone narzędzia uruchamiane z poziomu wiersza poleceń. Które programy są bardziej ortogonalne? Dlaczego? Który rodzaj programów jest łatwiejszy w użyciu z perspektywy osoby zainteresowanej oryginalnym przeznaczeniem tych produktów? Który zbiór programów można łatwiej łączyć z innymi narzędziami z myślą o nowych wyzwaniach? Język C++ obsługuje wielokrotne dziedziczenie, natomiast Java oferuje możliwość implementowania wielu interfejsów przez jedną klasę. Jaki jest wpływ tych rozwiązań na ortogonalność kodu, w którym są stosowane? Czy wpływ stosowania wielokrotnego dziedziczenia jest inny niż wpływ implementowania wielu interfejsów? Czy w tym kontekście stosowanie delegacji różni się od stosowania dziedziczenia? Ćwiczenia 1. Napisz program, który czyta informacje z pliku wiersz po wierszu. Każdy wiersz należy podzielić na pola. Która z poniższych klas napisanych w pseudokodzie w większym stopniu spełnia warunek ortogonalności? Ta: class Split1 { constructor(fileName) # otwiera plik do odczytu def readNextLine() # przejście do następnego wiersza 4337ebf6db5c7cc89e4173803ef3875a 4 74 Rozdział 2. Postawa pragmatyczna def getField(n) # zwraca n-te pole w bieżącym wierszu } czy ta: class Split2 { constructor(line) def getField(n) } # dzieli wiersze # zwraca n-te pole w bieżącym wierszu 2. Jakie są różnice w ortogonalności pomiędzy kodem napisanym w języku obiektowym a analogicznym kodem napisanym w języku funkcyjnym? Czy te różnice wynikają z charakterystyki samych języków, czy też ze sposobu, w jaki są wykorzystywane? 11 39 Odwracalność Nie ma niczego bardziej niebezpiecznego niż idea, jeśli to wszystko, co mamy. Emil-Auguste Chartier, Propos sur la religion, 1938 Inżynierowie lubują się w prostych, pojedynczych rozwiązaniach problemów. Testy matematyczne, które umożliwiają stwierdzenie z całą pewnością, że x = 2, są dużo wygodniejsze i bardziej atrakcyjne od nieścisłych, mętnych rozpraw na temat niezliczonych przyczyn rewolucji francuskiej. Menedżerowie w tej kwestii wykazują zadziwiającą zgodność z inżynierami — pojedyncze, proste odpowiedzi dużo lepiej prezentują się w arkuszach kalkulacyjnych i planach projektów. Gdyby tylko rzeczywistość zechciała z nami współpracować! W praktyce nierzadko okazuje się, że x, które dzisiaj ma wartość 2, jutro może mieć wartość 5, by za tydzień mieć na przykład wartość 3. Nic nie jest wieczne — jeśli zbyt mocno uzależniamy działanie naszego systemu od jakiegoś warunku, możemy być niemal pewni, że warunek ten ulegnie zmianie. Zawsze istnieje więcej niż jeden sposób implementacji zaplanowanych mechanizmów. Co więcej, przeważnie istnieje wielu producentów oferujących gotowe rozwiązania. Jeśli przystępujemy do prac nad projektem, który przez krótkowzroczność lidera może być zrealizowany tylko w jeden sposób, może nas spotkać niemiła niespodzianka. Członkom wielu zespołów projektowych oczy otwiera dopiero niekorzystny rozwój wypadków: „Mówiłeś przecież, że użyjemy bazy danych XYZ! Mamy już gotowe 85% kodu projektu, więc nie możemy teraz zmienić bazy danych!” — zaprotestował programista. „Przykro mi, ale nasza firma zdecydowała, że we wszystkich projektach ma być stosowana standardowa baza danych PDQ. Decyzja nie należy do mnie. Musimy po prostu dostosować się do tej decyzji. Wszyscy będziecie pracowali w weekendy do odwołania”. 4337ebf6db5c7cc89e4173803ef3875a 4 Odwracalność 75 Zmiany, oczywiście, nie muszą być tak dramatyczne ani nie muszą wymagać natychmiastowej interwencji. Z czasem, wraz z postępem projektu, możemy znaleźć się w sytuacji, w której dalsze podążanie w dotychczasowym kierunku będzie niemożliwe. Każda krytyczna decyzja oznacza, że zespół projektowy wyznacza mniejszy cel — węższą wersję rzeczywistości, która z natury rzeczy oferuje mniej opcji. Z czasem liczba podjętych krytycznych decyzji jest na tyle duża, a cel na tyle mały, że każdy jego ruch (zmiana kierunku wiatru, trzepotanie skrzydłami przez motyla w Tokio lub cokolwiek innego) uniemożliwia nam trafienie tego celu6. Być może nawet popełniliśmy jakieś zasadnicze błędy. Problem w tym, że krytyczne decyzje nie mogą być łatwo odwracane. W momencie, w którym decydujemy się użyć bazy danych określonego producenta, pewnego wzorca architekturalnego lub konkretnego modelu wdrażania, w praktyce obieramy kierunek działania, którego nie można następnie zmienić, chyba że bardzo dużym kosztem. Odwracalność Wiele zagadnień omówionych w tej książce ma na celu ułatwienie tworzenia elastycznego oprogramowania. Postępowanie zgodnie z proponowanymi tutaj zaleceniami — w szczególności z zasadą DRY (patrz wcześniejsza część tego rozdziału), z zapewnieniem właściwej izolacji (więcej w rozdziale 5.) oraz przy użyciu zewnętrznej konfiguracji (tamże) — pozwala ograniczyć liczbę niezbędnych krytycznych, nieodwracalnych decyzji. Mniejsza liczba takich decyzji jest o tyle pożądana, że nie zawsze podejmujemy najlepsze decyzje za pierwszym razem. Nierzadko decydujemy się na określoną technologię tylko dlatego, że nie możemy sobie pozwolić na zatrudnienie odpowiednio wielu pracowników dysponujących niezbędnymi umiejętnościami. W ten sposób ryzykujemy uzależnienie od konkretnego producenta na przykład bezpośrednio przed jego przejęciem przez konkurenta. Wymagania, użytkownicy i sprzęt zmieniają się szybciej, niż jesteśmy w stanie tworzyć nasze oprogramowanie. Przypuśćmy, że na wczesnym etapie prac nad projektem decydujemy się użyć relacyjnej bazy danych producenta A. Po dłuższym czasie, już w trakcie testów wydajności, odkrywamy, że wybrana wcześniej baza danych jest po prostu zbyt wolna. Istnieje za to obiektowa baza danych producenta B, która jest nieporównanie szybsza. W przypadku większości typowych projektów byłaby to niemal katastrofa. W większości przypadków odwołania do oprogramowania zewnętrznych producentów wymagają zapisania w wielu miejscach kodu. Gdyby 6 Warto spróbować wprowadzić drobną zmianę w jednym z parametrów wejściowych nieliniowego, chaotycznego systemu. Wyniki uzyskiwane wskutek tej zmiany często są nieprzewidywalne. Przysłowiowy motyl trzepoczący skrzydłami w Tokio może zapoczątkować łańcuch zdarzeń, który skończy się powstaniem tornada w Teksasie. Czy właśnie tak wyglądały wszystkie projekty, w których braliśmy udział? 4337ebf6db5c7cc89e4173803ef3875a 4 76 Rozdział 2. Postawa pragmatyczna jednak udało nam się wyabstrahować bazę danych (przynajmniej do poziomu, w którym ta baza miałaby status usługi odpowiedzialnej za utrwalanie danych), moglibyśmy dużo swobodniej zmieniać ten produkt na dowolnym etapie prac. Podobnie, załóżmy, że projekt początkowo jest realizowany jako aplikacja webowa oparta na przeglądarce, ale później, na zaawansowanym etapie projektu, pracownicy działu marketingu doszli do wniosku, że lepsza byłaby aplikacja mobilna. Na ile trudna będzie realizacja tego wymagania? W idealnej sytuacji ta zmiana nie powinna wywierać na nas zbyt dużego wpływu, przynajmniej po stronie serwerowej. Powinno wystarczyć usunięcie mechanizmów renderowania HTML i zastąpienie ich przez API. Błąd popełniamy już na początku, kiedy przyjmujemy, że każda decyzja powinna być trwale wyryta w kamieniu — początkowo nie jesteśmy gotowi na wszystkie ewentualności, które mogą wymagać korekty pierwotnych założeń. Zamiast utrwalać decyzje w kamieniu, powinniśmy raczej zapisywać je patykiem na piasku na najbliższej plaży. Wystarczy większa fala, aby całkowicie zmyć nasze wcześniejsze założenia. WSKAZÓWKA NR 18 Nie istnieją ostateczne decyzje. Elastyczna architektura Wielu programistów podejmuje co prawda próby pisania elastycznego kodu, jednak dbałość o elastyczność nie może ograniczać się tylko do tego obszaru — musi dotyczyć także architektury, wdrażania i integracji z innymi produktami. Piszemy tę książkę w roku 2019. Od przełomu XX i XXI wieku obserwowaliśmy następujące „najlepsze praktyki” architektur serwerowych: Serwery fizyczne o dużej mocy obliczeniowej. Federacje mocnych serwerów fizycznych. Klastry serwerów z mechanizmami równoważenia obciążenia. Maszyny wirtualne w chmurze obliczeniowej serwujące aplikacje. Maszyny wirtualne w chmurze obliczeniowej serwujące usługi. Kontenerowe wersje usług i aplikacji. Bazujące na chmurze obliczeniowej aplikacje działające w trybie bezserwerowym. Oraz, co nieuniknione, powrót do serwerów fizycznych w przypadku niektórych zadań. Śmiało dodaj do tej listy najnowsze i najlepsze trendy mody, a następnie traktuj je z podziwem: to cud, że cokolwiek kiedykolwiek działało. 4337ebf6db5c7cc89e4173803ef3875a 4 Odwracalność 77 W jaki sposób możesz zaplanować tego rodzaju zmienność architektoniczną? Otóż nie możesz. Możesz jedynie zadbać o to, aby projekt można było łatwo zmienić. Ukryj zewnętrzne interfejsy API za własnymi warstwami abstrakcji. Podziel swój kod na komponenty: nawet jeśli ostatecznie wdrożysz kod na pojedynczym, monolitycznym serwerze, takie podejście jest dużo łatwiejsze od stworzenia monolitycznej aplikacji i prób jej późniejszego dzielenia (wiemy, o czym mówimy, znamy to z doświadczenia). I choć nie jest to problem specyficznie dotyczący odwracalności, zapamiętaj jedną, końcową radę. WSKAZÓWKA NR 19 Przewiduj przyszłe trendy. Nikt nie wie, co przyniesie nam przyszłość, a już na pewno nie wiedzą tego programiści! Warto więc przygotowywać kod na wszystkie ewentualności — na automatyczne uzupełnianie o nowe wymagania oraz na równie sprawne eliminowanie wymagań, które okazują się nietrafione. Pokrewne podrozdziały Temat 8., „Istota dobrego projektu”. Temat 10., „Ortogonalność”. Temat 19., „Kontrola kodu źródłowego”. Temat 28., „Eliminowanie sprzężeń”. Temat 45., „Kopalnia wymagań”. Temat 51., „Zestaw startowy pragmatyka”. Wyzwania Warto poświęcić chwilę mechanice kwantowej i eksperymentowi myślowemu z kotem Schrödingera. Przypuśćmy, że zamykamy kota w pojemniku z cząstką radioaktywną. Prawdopodobieństwo rozszczepienia tej cząstki wynosi dokładnie 50%. Jeśli to nastąpi, kot zginie. W przeciwnym razie kot zachowa życie. Czy ten hipotetyczny kot jest żywy, czy martwy? Według Schrödingera, do momentu otwarcia pojemnika obie odpowiedzi są prawidłowe. Za każdym razem, gdy ma miejsce wspomniana reakcja subjądrowa, która może prowadzić do jednego z dwóch wyników, wszechświat jest klonowany. W jednej kopii interesujące nas zdarzenie miało miejsce, w drugiej kopii to zdarzenie nie wystąpiło. Kot jest więc żywy w jednym wszechświecie i martwy w drugim. Dopiero otwarcie pojemnika pozwala stwierdzić, w którym wszechświecie się znajdujemy. 4337ebf6db5c7cc89e4173803ef3875a 4 78 Rozdział 2. Postawa pragmatyczna Trudno się dziwić, że kodowanie z myślą o przyszłych wymaganiach jest takie trudne. Gdybyśmy jednak postrzegali ewolucję kodu jako pudło pełne kotów Schrödingera, każda nasza decyzja musiałaby skutkować inną wersją przyszłości. Ile możliwych scenariuszy może obsługiwać nasz kod? Który z tych scenariuszy jest bardziej prawdopodobny? Na ile trudna będzie obsługa tych scenariuszy w przyszłości, kiedy wreszcie nastąpią? Czy odważymy się otworzyć to pudełko? 12 40 Pociski smugowe Przygotuj broń, cel, pal… — Anon Kiedy tworzymy oprogramowanie, często mówimy o trafianiu w cele. W istocie nie celujemy do niczego na strzelnicy, ale nadal jest to bardzo użyteczna i obrazowa metafora. W szczególności warto zastanowić się, jak trafić w cel w złożonym i zmieniającym się świecie. Odpowiedź oczywiście zależy od rodzaju urządzenia, do którego celujesz. W przypadku wielu masz tylko jedną szansę, aby wycelować, a następnie możesz się przekonać, czy trafiłeś w dziesiątkę, czy nie. Istnieje jednak lepszy sposób. Znacie te wszystkie filmy, programy telewizyjne i gry wideo, w których występujące postacie strzelają z karabinów maszynowych? W tych scenach często można zobaczyć drogę, jaką pokonują kule w powietrzu — co jakiś czas widać jasne smugi. Te ślady pochodzą od pocisków smugowych. Pociski smugowe ładuje się wśród standardowej amunicji na taśmie nabojowej w stałych odstępach. Fosfor spalany w wystrzelonym pocisku pozostawia smugę widoczną od karabinu strzelca do punktu, w który ten pocisk trafił. Jeśli pociski smugowe trafiają w cel, można przyjąć, że robią to także zwykłe kule. Ta sama zasada dotyczy projektów, szczególnie jeśli tworzymy rozwiązania, których nikt wcześniej nie stworzył. Używamy terminu wytwarzanie oprogramowania w stylu pocisków smugowych, aby wizualnie zilustrować potrzebę uzyskania natychmiastowej informacji zwrotnej w rzeczywistych warunkach z ruchomym celem. Podobnie jak strzelcy, próbujemy trafić cel w ciemnościach. Ponieważ nasi użytkownicy nigdy wcześniej nie widzieli podobnego systemu, ich oczekiwania i wymagania mogą być niejasne. Ponieważ możemy używać algorytmów, technik, języków i bibliotek, których nie opanowaliśmy w dostatecznym stopniu, musimy się liczyć z dużą liczbą niewiadomych. Co więcej, ponieważ realizacja 4337ebf6db5c7cc89e4173803ef3875a 4 Pociski smugowe 79 projektu wymaga czasu, możemy być niemal pewni, że środowisko, w którym pracujemy, zmieni się przed zakończeniem prac. Klasyczną reakcją na taką sytuację jest próba opracowania wyczerpującej specyfikacji. Próbujemy tworzyć sterty papierów rozkładających na części pierwsze każde wymaganie, rozprawiających o każdej niewiadomej i szczegółowo opisujących docelowe środowisko. Jesteśmy jak strzelcy, którzy przed oddaniem pierwszego strzału prowadzą żmudne obliczenia. Najpierw długie obliczenia — potem strzał w nadziei na trafność kalkulacji. Pragmatyczni programiści wolą jednak używać pocisków smugowych. Kod rozświetlający mrok Pociski smugowe sprawdzają się w praktyce, ponieważ są używane w tych samych warunkach i z uwzględnieniem tych samych ograniczeń co normalne pociski. Ponieważ docierają do celu bardzo szybko, strzelec natychmiast uzyskuje wskazówki dotyczące skuteczności ostrzału. Co więcej, z praktycznego punktu widzenia pociski smugowe są stosunkowo tanim rozwiązaniem. Aby uzyskać ten sam efekt w kodzie implementowanego systemu, potrzebujemy czegoś, co możliwie szybko, jednoznacznie i powtarzalnie będzie prowadziło od wymagania do odpowiedniego aspektu ostatecznego systemu. Szukaj ważnych wymagań — takich, które definiują system. Szukaj obszarów, co do których masz wątpliwości oraz takich, dla których dostrzegasz największe zagrożenia. Następnie ustal priorytety w programowaniu tak, aby tymi obszarami zająć się najpierw. WSKAZÓWKA NR 20 Należy znajdować cel za pomocą pocisków smugowych. Biorąc pod uwagę złożoność konfiguracji dzisiejszych projektów, ze względu na mnóstwo zależności i zewnętrznych narzędzi, pociski smugowe stają się jeszcze ważniejsze. Dla nas, pierwszy pocisk smugowy to utworzenie projektu „Witaj, świecie!” i zadbanie o to, aby się skompilował i uruchomił. Następnie powinniśmy poszukać obszarów niepewności w całej aplikacji i dodać szkielet niezbędny do tego, by wszystko działało. Przyjrzyjmy się poniższemu rysunkowi. Ten system składa się z pięciu warstw architektonicznych. Mamy pewne obawy co do sposobu ich integracji, więc szukamy prostych funkcji, które pozwolą nam je wspólnie testować. Linia przecinająca wykres pokazuje drogę, jaką obejmuje funkcjonalność w kodzie. Aby wszystko działało, musimy zaimplementować tylko zacienione obszary w każdej warstwie: elementy oznaczone falowanymi liniami będą wykonane później. 4337ebf6db5c7cc89e4173803ef3875a 4 80 Rozdział 2. Postawa pragmatyczna Przejęliśmy kiedyś projekt polegający na opracowaniu złożonego systemu marketingowego z bazą danych w architekturze klient-serwer. Jedno z wymagań mówiło o możliwości wpisywania i wykonywania tymczasowych zapytań. Serwery wchodzące w skład systemu miały obejmować zarówno standardowe, relacyjne bazy danych, jak i specjalistyczne bazy danych. Graficzny interfejs użytkownika warstwy klienta, który napisano w losowym języku A, korzystał z bibliotek napisanych w innym języku, implementujących interfejs dostępu do tych serwerów. Zapytania użytkowników były przechowywane po stronie serwera w notacji przypominającej kod Lispa. Dopiero z tego formatu były konwertowane na zoptymalizowany kod języka SQL i ostatecznie wykonywane. Istniało wiele niewiadomych i wiele różnych środowisk. Nikt nie mógł z całą pewnością stwierdzić, jak ten graficzny interfejs użytkownika powinien działać. Była to wprost doskonała okazja do użycia kodu smugowego. Opracowaliśmy framework dla interfejsu użytkownika, biblioteki na potrzeby reprezentowania zapytań oraz strukturę niezbędną do konwersji przechowywanych zapytań na zapytania właściwe określonej bazie danych. Połączyliśmy następnie wszystkie te składniki i sprawdziliśmy, czy działają. W tej wstępnej wersji funkcje systemu ograniczały się do możliwości wysłania zapytania zwracającego wszystkie wiersze pewnej tabeli. To wystarczyło jednak do wykazania, że nasz interfejs potrafi komunikować się z bibliotekami, że same biblioteki prawidłowo zapisują i odczytują zapytania oraz że serwer potrafi wygenerować prawidłowy kod języka SQL na podstawie tych zapytań. W kolejnych miesiącach stopniowo rozbudowywaliśmy tę podstawową strukturę, dodając nowe funkcje i równolegle rozszerzając poszczególne komponenty kodu smugowego. Wskutek dodawania nowych typów zapytań przez interfejs użytkownika biblioteka zaczęła się rozrastać, a mechanizm generowania kodu języka SQL stawał się coraz bardziej skomplikowany. 4337ebf6db5c7cc89e4173803ef3875a 4 Pociski smugowe 81 Kod smugowy nie ma jednorazowego charakteru — raz napisany powinien być zachowany na przyszłość. Kod smugowy zawiera wszystkie niezbędne mechanizmy sprawdzania błędów, odpowiednią strukturę, dokumentację i rozwiązania samosprawdzające charakterystyczne dla kodu produkcyjnego. Jedyną wadą tego kodu jest niepełna funkcjonalność. Okazuje się jednak, że po osiągnięciu docelowych związków łączących komponenty systemu możemy sprawdzić, na ile nasze dzieło jest zgodne z założeniami, i — w razie konieczności — wprowadzić niezbędne poprawki. Kiedy już mamy cel na muszce, dodawanie nowych funkcji jest bardzo proste. Koncepcja tworzenia kodu smugowego jest w pełni zgodna z przekonaniem o tym, że projekt nigdy się nie kończy — zawsze będą istniały jakieś wymagane zmiany i funkcje do dodania. Tworzenie oprogramowania jest procesem przyrostowym. Konwencjonalną alternatywą jest model znany z wielkich przedsięwzięć inżynieryjnych, gdzie kod jest dzielony na moduły implementowane niezależnie od pozostałych składników projektu. Moduły łączy się w większe podzespoły, które z kolei są dalej łączone, aż pewnego dnia dysponujemy kompletną, bardzo złożoną aplikacją. Dopiero wówczas aplikację można w całości zaprezentować użytkownikowi i poddać testom. Kod smugowy ma wiele ważnych zalet: Użytkownicy mogą sprawdzić działający system już na wczesnych etapach projektu. Jeśli potrafimy skutecznie prezentować nasze poczynania (patrz temat 52., „Wpraw w zachwyt użytkowników”), nasi użytkownicy będą mieli świadomość, że mają do czynienia z niedojrzałym produktem, którego ostateczny kształt będzie nieco inny. W takim przypadku użytkownicy nie będą zawiedzeni brakiem pewnych funkcji — będą raczej zadowoleni z możliwości obserwowania postępu prac nad systemem. Udział w pracach nad projektem będzie stopniowo zwiększał zaangażowanie użytkowników. Ci sami użytkownicy najprawdopodobniej będą potrafili stwierdzić, na ile blisko celu znajdują się poszczególne iteracje. Sami programiści przygotowują strukturę, w której będą pracowali. Najbardziej zniechęcającym dokumentem jest pusta kartka papieru. Jeśli sami opracowywaliśmy i rozwijaliśmy wszystkie interakcje naszej aplikacji oraz jeśli zaimplementowaliśmy te interakcje w kodzie, członkowie naszego zespołu nie będą musieli tracić czasu na zgłębianie cudzych rozwiązań. Oznacza to, że wszyscy będą bardziej produktywni, a cały system będzie cechował się większą spójnością. Dysponujemy platformą integracji. Skoro struktura systemu jest kompletna, dysponujemy środowiskiem, które możemy uzupełniać o nowe fragmenty kodu bezpośrednio po poddaniu ich testom jednostkowym. Zamiast próbować integrować wielki system po opracowaniu wszystkich komponentów, integrujemy nasz produkt codziennie (a często wiele razy w ciągu dnia). Skutki wprowadzenia każdej zmiany są łatwiejsze do przewidzenia, a zakres interakcji jest ograniczony, zatem diagnozowanie i testowanie całego systemu przebiega szybciej i prowadzi do bardziej precyzyjnych wyników. 4337ebf6db5c7cc89e4173803ef3875a 4 82 Rozdział 2. Postawa pragmatyczna Dysponujemy czymś, co możemy zademonstrować. Podmioty finansujące projekt i członkowie kierownictwa organizacji zwykle oczekują demonstracji tworzonych systemów w najmniej odpowiednich momentach. Kod smugowy powoduje, że zawsze dysponujemy czymś gotowym do pokazania. Sami dysponujemy pełniejszą wiedzą o postępach prac. W modelu wytwarzania z uwzględnieniem kodu smugowego programiści analizują i rozstrzygają kolejne przypadki użycia. Po gruntownym przestudiowaniu jednego przypadku użycia programiści przystępują do analizy kolejnego. W takim przypadku mierzenie wydajności i demonstrowanie postępu użytkownikowi jest nieporównanie prostsze. Ponieważ każde zadanie programistyczne jest mniejsze, możemy uniknąć tworzenia monolitycznych bloków kodu, które całymi tygodniami są opisywane jako gotowe w 95 procentach. Pociski smugowe nie zawsze trafiają w cel Pociski smugowe pokazują, gdzie trafiamy. Nie we wszystkich przypadkach trafiany punkt jest naszym celem. Jeśli nie, musimy tak długo korygować sposób celowania, aż osiągniemy zamierzony efekt. Właśnie po to używa się tych pocisków. Dokładnie to samo dotyczy kodu smugowego. Opisywana technika jest przydatna w sytuacjach, w których nie możemy być pewni na 100 procent, że obrana droga jest słuszna. Nie powinniśmy być zaskoczeni, jeśli w pierwszych kilku próbach chybimy celu — jeśli użytkownik stwierdzi „nie do końca o to mi chodziło”, jeśli niezbędne dane nie będą dostępne od razu lub jeśli wystąpią jakieś problemy z wydajnością. Powinniśmy raczej wskazać sposób zmiany dotychczasowych rozwiązań, tak aby zbliżyć się do celu, i jednocześnie docenić możliwość stosowania tak wygodnej metodyki. Małe fragmenty kodu cechują się niewielką bezwładnością i jako takie mogą być łatwo i szybko zmieniane. Mamy możliwość uzyskiwania cennej wiedzy o naszej aplikacji i jednocześnie tworzenia nowej, lepszej (celniejszej) wersji szybciej i mniejszym kosztem niż w przypadku jakiejkolwiek innej metody. Co więcej, ponieważ każdy ważny komponent aplikacji jest reprezentowany w naszym kodzie smugowym, użytkownicy mogą być pewni, że to, co widzą, zaimplementowano na bazie rzeczywistych rozwiązań, nie papierowej specyfikacji. Kod smugowy kontra prototypy Na pierwszy rzut oka wydaje się, że idea kodu smugowego nie jest niczym więcej niż formą tworzenia prototypów, tyle że pod bardziej atrakcyjną nazwą. Istnieje pewna różnica. Prototyp służy do zbadania tylko wybranych, konkretnych aspektów docelowego systemu. Prawdziwy prototyp rozwiewa wszelkie wątpliwości dotyczące analizowanej koncepcji czy założeń — po jego analizie możemy przystąpić do ponownego kodowania odpowiedniego składnika z uwzględnieniem ewentualnych poprawek. 4337ebf6db5c7cc89e4173803ef3875a 4 Pociski smugowe 83 Przypuśćmy na przykład, że pracujemy nad aplikacją, która ma ułatwić firmie spedycyjnej określanie sposobu rozmieszczania przesyłek o nietypowych rozmiarach w kontenerach. Do najważniejszych problemów należy zaprojektowanie odpowiednio intuicyjnego interfejsu użytkownika oraz zaimplementowanie bardzo skomplikowanych algorytmów określania optymalnego rozmieszczenia przesyłek. Prototyp interfejsu użytkownika można opracować (z myślą o użytkownikach końcowych) za pomocą narzędzi do projektowania graficznych interfejsów użytkownika. Kod takiego prototypu ogranicza się do rozwiązań zapewniających właściwe reagowanie interfejsu na czynności użytkownika. Po uzyskaniu pozytywnej oceny układu elementów należy ten prototyp wyrzucić i od nowa zaimplementować odpowiednie rozwiązanie, tym razem z uwzględnieniem odpowiedniej logiki biznesowej i w docelowym języku programowania. Podobnie, możemy zdecydować się na opracowanie prototypów wielu algorytmów odpowiedzialnych za właściwe rozmieszczanie paczek w kontenerze. Testy funkcjonalne można zakodować na wysokim poziomie, korzystając z takich języków jak Perl, natomiast niskopoziomowe testy wydajnościowe warto zaimplementować w języku bliższym instrukcji maszynowych. W każdym przypadku po podjęciu dotyczącej prototypu decyzji należy przystąpić do ponownego kodowania odpowiednich atrybutów w ich środowisku docelowym, gdzie będą narażone na oddziaływanie nieco innych czynników. Właśnie na tym polega tworzenie prototypów. Model z kodem smugowym ma na celu rozwiązywanie nieco innego problemu. Tworząc kod smugowy, chcemy dowiedzieć się, jak nasza aplikacja będzie funkcjonowała jako całość. Chcemy pokazać naszym użytkownikom, jak opisywane interakcje będą wyglądały w praktyce. Chcemy też udostępnić programistom szkielet architektury, w ramach którego będą implementowali swój kod. W tym przypadku moglibyśmy skonstruować kod smugowy składający się na przykład z uproszczonego algorytmu rozmieszczenia przesyłek (być może w kolejności dodawania do kontenera) oraz prostym, ale działającym interfejsem użytkoonwnika. Po połączeniu wszystkich komponentów aplikacji w jedną całość dyspujemy frameworkiem, który możemy prezentować zarówno użytkownikom, jak i programistom zaangażowanym w projekt. Z czasem uzupełniamy ten framework o nowe funkcje oraz wypełniamy stosowane wcześniej namiastki procedur. Warto jednak pamiętać, że sam framework pozostaje niezmieniony, zatem możemy być pewni, że system będzie zachowywał się tak jak w momencie ukończenia prac nad pierwszym kodem smugowym. Wspomniane rozróżnienie jest na tyle ważne, że warto je powtórzyć. W wyniku tworzenia prototypu powstaje tymczasowy, jednorazowy kod. Kod smugowy jest uproszczony, ale kompletny, zatem stanowi fragment szkieletu docelowego systemu. Tworzenie prototypów jest jak rozpoznanie, próba zebrania cennych danych wywiadowczych przed wystrzeleniem pierwszego pocisku smugowego. 4337ebf6db5c7cc89e4173803ef3875a 4 84 Rozdział 2. Postawa pragmatyczna Pokrewne podrozdziały 13 41 Temat 13., „Prototypy i karteczki samoprzylepne”. Temat 27., „Nie prześcigaj swoich świateł”. Temat 40., „Refaktoryzacja”. Temat 49., „Pragmatyczne zespoły”. Temat 50., „Nie próbuj przecinać kokosów”. Temat 51., „Zestaw startowy pragmatyka”. Temat 52., „Wpraw w zachwyt użytkowników”. Prototypy i karteczki samoprzylepne Wiele różnych branż używa prototypów do sprawdzania przyszłych rozwiązań w najróżniejszych obszarach; tworzenie prototypów jest nieporównanie tańsze od produkcji w pełnej skali. Na przykład producenci samochodów nierzadko budują wiele odmiennych prototypów przed wyborem ostatecznego projektu nowego modelu. Każdy prototyp jest projektowany z myślą o przetestowaniu konkretnego aspektu samochodu — parametrów aerodynamicznych, stylistyki, właściwości konstrukcyjnych itd. Model z gliny być może jest budowany z myślą o testach w tunelu aerodynamicznym, natomiast model z drewna balsy i mocnej taśmy samoprzylepnej ma trafić do działu stylistyki itd. Niektórzy producenci samochodów idą jeszcze krok dalej i ograniczają się do modelowania swoich aut w komputerze, dodatkowo ograniczając koszty. W ten sposób ryzykowne lub niesprawdzone elementy można łatwo wypróbować bez konieczności konstruowania właściwego produktu. Prototypy oprogramowania buduje się w ten sam sposób i z tych samych powodów — z myślą o analizie i ujawnieniu czynników ryzyka oraz o stworzeniu dodatkowej szansy wprowadzenia korekt znacznie mniejszym kosztem. Tak jak producenci samochodów możemy stworzyć prototyp z myślą o przetestowaniu jednego lub wielu konkretnych aspektów realizowanego projektu. Technikę tworzenia prototypów zwykle utożsamiamy z pisaniem jakiegoś kodu, jednak prototypy nie zawsze muszą mieć postać programów. Tak jak producenci samochodów możemy konstruować swoje prototypy z różnych materiałów. Na przykład karteczki samoprzylepne doskonale sprawdzają się w roli budulca prototypów tak dynamicznych aspektów jak przepływ pracy czy logika aplikacji. Prototyp interfejsu użytkownika można narysować na tablicy, opracować w programie graficznym jako pozbawiony funkcji szkic lub zbudować przy użyciu programu do konstruowania interfejsów. Prototypy projektuje się z myślą o uzyskiwaniu odpowiedzi na zaledwie kilka pytań, zatem z natury rzeczy są tańsze i szybsze w tworzeniu niż aplikacje trafiające do środowiska produkcyjnego. Kod może ignorować nieistotne szczegóły 4337ebf6db5c7cc89e4173803ef3875a 4 Prototypy i karteczki samoprzylepne 85 (nieważne w danej chwili, ale być może bardzo ważne dla użytkownika właściwego produktu w przyszłości). Jeśli na przykład tworzymy prototyp graficznego interfejsu użytkownika, możemy całkowicie pominąć problem nieprawidłowych wyników czy danych. Jeśli jednak przedmiotem analizy są aspekty obliczeniowe i wydajnościowe, możemy zrezygnować z atrakcyjnego graficznego interfejsu użytkownika (a być może nawet z jakiegokolwiek interfejsu tego typu). Jeśli znajdujemy się w środowisku, w którym nie możemy ani na moment pominąć żadnego szczegółu, powinniśmy odpowiedzieć sobie na pytanie, czy konstruowanie prototypu w ogóle ma sens. W takim przypadku być może lepszym rozwiązaniem będzie przyjęcie modelu programowania analogicznego do pocisków smugowych (patrz temat 12., „Pociski smugowe”). Co może być przedmiotem prototypu Jakiego rodzaju aspekty warto wybierać do zbadania przy użyciu prototypu? Wszystko, z czym wiąże się jakieś ryzyko. Wszystko, czego wcześniej nie próbowano lub co ma krytyczne znaczenie dla ostatecznego systemu. Wszystko, co jest niedowiedzione, eksperymentalne lub wątpliwe. Wszystko, co budzi nasz dyskomfort. Przedmiotem prototypu mogą być: architektura; nowe funkcje w istniejącym systemie; struktura lub treść danych zewnętrznych; narzędzia lub komponenty zewnętrznych producentów; problemy związane z wydajnością; projekt interfejsu użytkownika. Tworzenie i badanie prototypów jest typowym procesem uczenia się. Wartość prototypu nie leży w jego kodzie, tylko we wnioskach, które sformułowaliśmy na jego podstawie. Właśnie te wnioski są istotą idei posługiwania się prototypami. WSKAZÓWKA NR 21 Prototypy należy tworzyć z myślą o nauce. Jak używać prototypów Które szczegóły można zignorować podczas konstruowania prototypu? Poprawność. W pewnych sytuacjach można z powodzeniem posługiwać się nieprawidłowymi, wręcz głupimi danymi. Kompletność. Prototyp może obejmować swoim działaniem bardzo ograniczony aspekt funkcjonowania docelowego produktu, być może tylko wybrany wcześniej zestaw danych wejściowych i jeden element menu. 4337ebf6db5c7cc89e4173803ef3875a 4 86 Rozdział 2. Postawa pragmatyczna Niezawodność. Mechanizm sprawdzania błędów może być niekompletny lub w ogóle może nie istnieć. W razie zboczenia z ustalonej wcześniej ścieżki prototyp może bez żadnych konsekwencji ulec awarii i spłonąć, dając przy okazji wspaniały pokaz fajerwerków. To zupełnie naturalne. Styl. Kod prototypu w większości przypadków nie wymaga komentarzy ani dokumentacji (choć w wyniku doświadczeń przeprowadzonych z użyciem prototypu można opracować całe sterty dokumentów). Ponieważ prototyp powinien ukrywać szczegóły i koncentrować się na konkretnych aspektach przyszłego systemu, do implementowania prototypów warto używać języków bardzo wysokiego poziomu (wyższych niż w przypadku reszty projektu, na przykład takich języków jak Python lub Ruby). Możesz kontynuować tworzenie oprogramowania w tym samym języku, w którym stworzyłeś prototyp, lub możesz go zmienić — ostatecznie prototyp, gdy spełni swoją funkcję, przestaje być potrzebny. Do stworzenia prototypów interfejsów użytkownika skorzystaj z narzędzi, które pozwolą Ci skupić się na wyglądzie i (lub) na interakcjach, ale bez koncentrowania się na kodzie lub zestawie znaczników. Języki skryptowe doskonale sprawdzają się w roli spoiwa łączącego niskopoziomowe fragmenty kodu w ramach nowych kombinacji. Stosując prototypy można błyskawicznie wiązać istniejące komponenty w nowe konfiguracje i sprawdzać, jak działają poszczególne kombinacje. Tworzenie prototypów architektury Wiele prototypów konstruuje się z myślą o modelowaniu całych systemów. W przeciwieństwie do pocisków smugowych żaden z modułów wchodzących w skład prototypowego systemu nie musi być szczególnie funkcjonalny. W praktyce opracowanie prototypu architektury nie wymaga nawet kodowania — taki prototyp można z powodzeniem stworzyć na tablicy, na karteczkach samoprzylepnych lub na fiszkach. Interesuje nas sposób funkcjonowania systemu jako całości, zatem szczegółowe rozstrzygnięcia możemy odłożyć na później. Poniżej wymieniono kilka konkretnych obszarów, w których warto rozważyć opracowanie prototypu architektury: Czy zakres odpowiedzialności najważniejszych komponentów jest dobrze zdefiniowany i przemyślany? Czy odpowiednio precyzyjnie zdefiniowano zasady współpracy głównych komponentów? Czy udało się zminimalizować zjawisko powiązań? Czy potrafimy zidentyfikować potencjalne źródła powielania? Czy definicje i ograniczenia interfejsu są możliwe do zaakceptowania? 4337ebf6db5c7cc89e4173803ef3875a 4 Prototypy i karteczki samoprzylepne 87 Czy każdy moduł dysponuje ścieżką dostępu do danych niezbędnych w czasie wykonywania? Czy ma dostęp do tych danych w czasie, gdy ich potrzebuje? Ostatni aspekt bywa źródłem wyjątkowo wielu niespodzianek i jednocześnie najcenniejszych wniosków z eksperymentów przeprowadzanych przy użyciu prototypów. Jak nie używać prototypów Zanim zdecydujemy się zaprezentować jakikolwiek prototyp w formie oprogramowania, koniecznie musimy poinformować wszystkich, że demonstrowany kod ma jednorazowy charakter i nie będzie wykorzystywany w docelowym produkcie. Prototypy mogą wydać się atrakcyjne osobom, które nie wiedzą, że mają do czynienia właśnie z prototypami. Musimy możliwie jednoznacznie zaznaczyć, że prezentowany kod jest jednorazowy, niekompletny i niemożliwy do dokończenia w tej formie. Pozorna kompletność demonstrowanego prototypu bardzo łatwo może stać się źródłem nieporozumień, które w skrajnych przypadkach mogą prowadzić do nacisków ze strony sponsorów projektu i kierownictwa firmy, aby wdrożyć właśnie prototyp (lub jego potomka). Musimy pamiętać, że chociaż istnieje możliwość konstruowania doskonałego prototypu nowego samochodu z drzewa balsy i mocnej taśmy samoprzylepnej, nikt o zdrowych zmysłach nie będzie próbował przebić się tym „autem” przez miasto w godzinach szczytu. Jeśli wydaje nam się, że w określonym środowisku lub kulturze ryzyko błędnej interpretacji przeznaczenia kodu prototypu jest szczególnie duże, być może powinniśmy całkowicie zrezygnować z tej drogi na rzecz pocisków smugowych. W ten sposób opracujemy solidny framework, na którym będziemy mogli budować właściwy system. Właściwie użyty prototyp może nam oszczędzić mnóstwo czasu, pieniędzy, kłopotów i trudności związanych z identyfikacją i eliminowaniem potencjalnych problemów już na wczesnym etapie cyklu wytwarzania, kiedy usuwanie usterek jest jednocześnie tanie i łatwe. Pokrewne podrozdziały Temat 12., „Pociski smugowe”. Temat 14., „Języki dziedzinowe”. Temat 17., „Powłoki”. Temat 27., „Nie prześcigaj swoich świateł”. Temat 37., „Słuchaj swojego jaszczurczego mózgu”. Temat 45., „Kopalnia wymagań”. Temat 52., „Wpraw w zachwyt użytkowników”. 4337ebf6db5c7cc89e4173803ef3875a 4 88 Rozdział 2. Postawa pragmatyczna Ćwiczenia 3. Pracownicy działu marketingu chcą się z nami spotkać i przeprowadzić burzę mózgów poświęconą kilku projektom strony internetowej. Zastanawiają się między innymi nad możliwością użycia map obrazów z możliwością klikania, które będą przenosiły użytkowników na inne strony. Nie potrafią jednak zdecydować, który model obrazu będzie najlepszy — czy powinien przedstawiać samochód, telefon, czy dom. Dysponujemy listą stron docelowych i ich treścią; warto więc opracować i przedstawić kilka prototypów. A, bylibyśmy zapomnieli — mamy na to 15 minut. Jakich narzędzi należałoby użyć? 14 42 Języki dziedzinowe Ograniczenia naszego języka są ograniczeniami naszego świata. Ludwig Wittgenstein Języki komputerowe wpływają zarówno na sposób postrzegania przez nas problemów, jak i na sposób, w jaki myślimy o komunikacji. Każdy język oferuje pewną listę funkcji — zwykle są to takie slogany jak statyczna lub dynamiczna kontrola typów, wczesne lub późne wiązanie, modele dziedziczenia (pojedynczego, wielokrotnego lub żadnego). Każdy z tych elementów może sugerować pewne rozwiązania lub zniechęcać do tych rozwiązań. Projektowanie rozwiązania z myślą o C++ doprowadzi do innych wyników niż projektowanie rozwiązania z myślą o języku Haskell (i odwrotnie). Można na tę kwestię spojrzeć także z drugiej strony (naszym zdaniem jeszcze ważniejszej) — język dziedziny problemu może być źródłem sugestii dotyczących rozwiązań programistycznych. Zawsze staramy się pisać kod, stosując słownictwo z dziedziny danej aplikacji (patrz podrozdział „Utrzymywanie glosariusza”). W pewnych przypadkach możemy nawet wejść na wyższy poziom i programować przy użyciu słownictwa, składni i semantyki (tylko w praktyce języka) właściwych danej dziedzinie. WSKAZÓWKA NR 22 Programuj z zachowaniem ścisłego związku z dziedziną problemu. Przykłady języków dziedzinowych Spójrzmy na kilka przykładów języków dziedzinowych. 4337ebf6db5c7cc89e4173803ef3875a 4 Języki dziedzinowe 89 RSpec RSpec7 to biblioteka obsługi testów dla języka Ruby. Biblioteka RSpec zainspirowała wersje dla większości innych współczesnych języków programowania. Test w RSpec ma odzwierciedlać oczekiwane zachowanie kodu. describe BowlingScore do it "totals 12 if you score 3 four times" do score = BowlingScore.new 4.times { score.add_pins(3) } expect(score.total).to eq(12) end end Cucumber Cucumber8 to niezależna od języka programowania notacja specyfikowania testów. Testy są uruchamiane przy użyciu wersji języka Cucumber odpowiedniej dla używanego języka. W celu zapewnienia wsparcia dla składni przypominającej język naturalny, trzeba także zastosować określone mechanizmy dopasowujące, które pozwolą na rozpoznawanie fraz i wyodrębnianie parametrów dla testów. Feature: Scoring Background: Given an empty scorecard Scenario: bowling a lot of 3s Given I throw a 3 And I throw a 3 And I throw a 3 And I throw a 3 Then the score should be 12 Testy pisane w Cucumber miały być przeznaczone do czytania przez użytkowników oprogramowania (chociaż w praktyce rzadko są wykorzystywane w tej roli, co wyjaśniono w poniższej ramce). Trasy Phoenix Wiele frameworków webowych obsługuje mechanizm routingu — mapowania przychodzących żądań HTTP na funkcje ich obsługi w kodzie. Oto przykład użycia frameworka Phoenix9. scope "/", HelloPhoenix do pipe_through :browser # Użyj stosu domyślnej przeglądarki get "/", PageController, :index resources "/users", UserController end 7 https://rspec.info 8 https://cucumber.io/ 9 https://phoenixframework.org/ 4337ebf6db5c7cc89e4173803ef3875a 4 90 Rozdział 2. Postawa pragmatyczna Dlaczego niezbyt wielu użytkowników biznesowych czyta specyfikacje w notacji Cucumber? Jednym z powodów, dla których nie sprawdza się klasyczny model: zbieranie wymagań, projektowanie, kodowanie, wdrażanie jest to, że model ten bazuje na założeniu, że wiemy, jakie są wymagania. Jednak w praktyce rzadko tak jest. Użytkownicy biznesowi mają mgliste pojęcie o tym, co chcą osiągnąć, ale nie znają szczegółów ani się nimi nie przejmują. Na tym polega część wartości pragmatycznych programistów: intuicyjnie wyczuwamy zamiar użytkownika i przekształcamy go na kod. W związku z tym, kiedy staramy się skłonić użytkownika biznesowego, aby zatwierdził dokument określający wymagania lub zgodził się na zbiór wymagań zapisanych w notacji Cucumber, robimy coś, co można porównać do nakłonienia użytkownika do sprawdzenia pisowni w eseju napisanym w języku sumeryjskim. W konsekwencji użytkownik biznesowy wprowadza kilka losowych zmian, aby zachować twarz, i podpisuje wymagania, aby pozbyć się nas ze swojego biura. Jeśli jednak damy mu kod, który działa, będzie mógł go wypróbować. Na tej podstawie będzie mógł określić swoje rzeczywiste potrzeby. Powyższa specyfikacja trasy mówi, że żądania zaczynające się od znaku / będą przechodziły przez zbiór filtrów odpowiednich dla przeglądarek. Żądanie adresu / zostanie obsłużone przez funkcję index w module PageController. Moduł Users Controller implementuje funkcje potrzebne do zarządzania zasobami dostępnymi za pomocą adresu url /users. Ansible Ansible10 jest narzędziem, które konfiguruje oprogramowanie, zazwyczaj na kilku zdalnych serwerach. Do tego celu wykorzystuje dostarczoną specyfikację. Następnie, zgodnie z nią wykonuje odpowiednie działania na serwerach. Specyfikacja może być zapisana w notacji YAML11 — języku, który buduje struktury danych na podstawie tekstowych opisów: --- name: install nginx apt: name=nginx state=latest - name: ensure nginx is running (and enable it at boot) service: name=nginx state=started enabled=yes - name: write the nginx config file template: src=templates/nginx.conf.j2 dest=/etc/nginx/nginx.conf notify: - restart nginx Powyższa specyfikacja określa, że na serwerach zostanie zainstalowana najnowsza wersja nginx, która zostanie domyślnie uruchomiona i wykorzysta dostarczony plik konfiguracyjny. 10 https://www.ansible.com/ 11 https://yaml.org/ 4337ebf6db5c7cc89e4173803ef3875a 4 Języki dziedzinowe 91 Cechy języków dziedzinowych Przyjrzyjmy się tym przykładom nieco bliżej. RSpec i router Phoenix są napisane za pomocą języków-gospodarzy (Ruby i Elixir). Korzystają one z dość sprytnego kodu, włącznie z technikami metaprogramowania i makrami, ale ostatecznie są kompilowane i uruchamiane tak jak zwykły kod. Testy Cucumber i konfiguracje Ansible są napisane we własnych językach. Testy Cucumber są przekształcane na kod do uruchomienia albo na strukturę danych, natomiast specyfikacje Ansible są zawsze przekształcane na strukturę danych, która jest uruchamiana przez sam system Ansible. W rezultacie kod RSpec i kod routera są osadzone w kodzie, który uruchamiamy: są one rzeczywistymi rozszerzeniami słownictwa naszego kodu. Specyfikacje Cucumber i Ansible są odczytywane za pomocą kodu i przekształcane w jakąś formę możliwą do wykorzystania przez kod. Kody RSpec i routera Phoenix są wywoływane za pomocą wewnętrznych języków dziedzinowych, natomiast Cucumber i Ansible korzystają z języków zewnętrznych. Kompromisy pomiędzy językami wewnętrznymi a zewnętrznymi Ogólnie rzecz biorąc, wewnętrzny język dziedzinowy może skorzystać z funkcjonalności swojego języka-gospodarza: tworzony język dziedzinowy ma większe możliwości, które uzyskujemy „za darmo”. Na przykład możemy użyć kodu Ruby, aby automatycznie stworzyć kilka testów RSpec. W tym przypadku możemy przetestować wyniki, w sytuacji, gdy nie ma trafień spare lub strike: describe BowlingScore do (0..4).each do |pins| (1..20).each do |throws| target = pins * throws it "totals #{target} if you score #{pins} #{throws} times" do score = BowlingScore.new throws.times { score.add_pins(pins) } expect(score.total).to eq(target) end end end end Właśnie napisałeś 100 testów. Przez pozostałą część dnia możesz mieć wolne. Wadą wewnętrznych języków dziedzinowych jest ich powiązanie ze składnią i semantyką języka. Chociaż niektóre języki są niezwykle elastyczne w tym zakresie, to nadal jesteśmy zmuszeni do kompromisu między językiem pożądanym a tym, który możemy zaimplementować. 4337ebf6db5c7cc89e4173803ef3875a 4 92 Rozdział 2. Postawa pragmatyczna Trzeba pamiętać, że stworzony kod musi być poprawny pod względem składni języka docelowego. Języki z obsługą makr (takie jak Elixir, Clojure i Crystal) dają nieco większą elastyczność, ale ostatecznie składnia jest składnią. Języki zewnętrzne nie wprowadzają takich ograniczeń. Wystarczy tylko napisać odpowiedni parser dla języka. Czasami można użyć parsera napisanego przez kogoś innego (tak, jak w przypadku Ansible i YAML), ale w takiej sytuacji jesteśmy zmuszeni do podejmowania kompromisów. Napisanie parsera zwykle oznacza dodawanie do aplikacji nowych bibliotek i ewentualnie narzędzi. Napisanie dobrego parsera nie jest zadaniem trywialnym. Nie trzeba się jednak tego obawiać. Można skorzystać z generatorów parserów, takich jak bison lub ANTLR, oraz z frameworków parsowania, takich jak wiele parserów PEG. Nasza sugestia jest dość prosta: w opracowanie parsera nie należy wkładać zbyt wiele wysiłku. Powinien on być proporcjanalny do osiąganych dzięki temu zysków. Pisanie języka dziedzinowego wiąże się z pewnymi dodatkowymi kosztami projektu. Trzeba mieć pewność, że poniesienie tych kosztów przyniesie (w dłuższej perspektywie) jakieś oszczędności. Ogólnie rzecz biorąc, należy używać, o ile to możliwe, gotowych języków zewnętrznych (takich jak YAML, JSON lub CSV). Jeśli nie ma takiej możliwości, należy korzystać z języków wewnętrznych. Zalecamy używanie języków zewnętrznych jedynie w tych przypadkach, gdy Twój język będzie napisany przez użytkowników aplikacji. Tani wewnętrzny język dziedzinowy Tworzenie wewnętrznych języków dziedzinowych jest niepotrzebne, jeśli nie przeszkadza nam składnia języka-gospodarza. Nie warto intensywnie korzystać z metaprogramowania. Zamiast tego wystarczy napisać funkcje, które wykonają potrzebne zadania. W ten sposób działa specyfikacja RSpec: describe BowlingScore do it "totals 12 if you score 3 four times" do score = BowlingScore.new 4.times { score.add_pins(3) } expect(score.total).to eq(12) end end W tym kodzie describe, it, expect, to i eq to po prostu metody języka Ruby. „Za kulisami” potrzeba trochę dodatkowych mechanizmów związanych z przekazywaniem obiektów, ale wszystko to jest po prostu kodem. Omówimy to trochę dokładniej w ćwiczeniach. 4337ebf6db5c7cc89e4173803ef3875a 4 Języki dziedzinowe 93 Pokrewne podrozdziały Temat 8., „Istota dobrego projektu”. Temat 13., „Prototypy i karteczki samoprzylepne”. Temat 32., „Konfiguracja”. Wyzwania Czy część wymagań aktualnie realizowanego projektu można by wyrazić w języku właściwym danej dziedzinie? Czy byłoby możliwe napisanie kompilatora lub translatora, który generowałby większość wymaganego kodu? Jeśli decydujemy się na stosowanie minijęzyków jako sposobu programowania bliżej dziedziny problemu, godzimy się na dodatkowe koszty związane z implementacją tych języków. Czy framework opracowany z myślą o jednym projekcie będzie można ponownie wykorzystać w innych projektach? Ćwiczenia 4. Chcemy zaimplementować minijęzyk sterujący działaniem prostego pakietu do rysowania (na przykład systemem tzw. grafiki żółwia — ang. turtle graphics). Język składa się z jednoliterowych poleceń. Po niektórych poleceniach należy podać pojedynczą liczbę. Na przykład poniższe dane wejściowe spowodowałyby narysowanie prostokąta. P D W N E S U 2 2 1 2 1 # # # # # # # wybierz drugie pióro opuść pióro narysuj odcinek o długości 2 cm na zachód teraz 1 cm na północ 2 cm na wschód i jeszcze 1 cm na południe unieś pióro 5. W poprzednim ćwiczeniu zaimplementowaliśmy parser dla języka rysowania — był to zewnętrzny język dziedzinowy. Teraz zaimplementuj go ponownie jako język wewnętrzny. Nie rób niczego nadzwyczajnego: po prostu napisz funkcję dla każdego polecenia. Być może trzeba będzie zmienić nazwy poleceń na pisane małymi literami oraz być może opakować polecania, aby dostarczyć pewien kontekst. 6. Zaprojektuj gramatykę BNF z myślą o analizie składniowej specyfikacji godziny. Gramatyka powinna akceptować wszystkie poniższe przykłady. 4pm, 7:38pm, 23:42, 3:16, 3:16am 7. Zaimplementuj analizator składniowy dla gramatyki BNF z poprzedniego ćwiczenia za pomocą wybranego generatora parserów PEG w wybranym języku. Wynik powinien być liczbą całkowitą oznaczającą liczbę minut, które upłynęły od północy. 8. Zaimplementuj parser godziny w języku skryptowym z wykorzystaniem wyrażeń regularnych. 4337ebf6db5c7cc89e4173803ef3875a 4 94 15 43 Rozdział 2. Postawa pragmatyczna Szacowanie Biblioteka Kongresu w Waszyngtonie obecnie obejmuje około 75 terabajtów informacji cyfrowych w internecie. Szybko! Ile czasu zajmie przesłanie tych wszystkich informacji w sieci 1 Gbps? Ile przestrzeni dyskowej potrzeba do przechowywania miliona nazwisk i adresów? Ile czasu zajmie skompresowanie 100 megabajtów tekstu? Ile miesięcy zajmie realizacja danego projektu? Na pewnym poziomie wszystkie te pytania są o tyle nieistotne, że dotyczą brakujących, nieznanych informacji. Okazuje się jednak, że można na te pytania odpowiedzieć, pod warunkiem dysponowania odpowiednim modelem szacowania. Co więcej, przy okazji szacowania czasu trwania projektu możemy lepiej zrozumieć otoczenie, w którym będą funkcjonowały nasze programy. Kiedy opanujemy sztukę szacowania, kiedy rozwiniemy umiejętność oceny czasu trwania projektu w stopniu niezbędnym do prawidłowego określania rzędu wielkości, będziemy potrafili w magiczny sposób określać wykonalność zleceń. Kiedy ktoś powie, że będzie wysyłał kopie zapasowe przez sieć do usługi S3, będziemy potrafili intuicyjnie stwierdzić, czy taka koncepcja jest praktyczna. Podczas kodowania będziemy od razu wiedzieli, które podsystemy wymagają optymalizacji, a które można pozostawić w dotychczasowej formie. WSKAZÓWKA NR 23 Szacowanie pozwala unikać przykrych niespodzianek. Na końcu tego podrozdziału zaproponujemy jedną prawidłową odpowiedź, której można udzielać każdemu, kto oczekuje od nas jakichś szacunków. Kiedy dokładne jest wystarczająco dokładne? W pewnym sensie każda odpowiedź jest szacunkiem. Część tych szacunków jest po prostu bardziej dokładna, trafna niż pozostałe. W tej sytuacji pierwsze pytanie, które powinniśmy sobie zadać przed rozważeniem żądania oszacowania czegokolwiek, powinno dotyczyć kontekstu użycia ewentualnej odpowiedzi. Czy odbiorca oczekuje dużej dokładności, czy raczej zadowoli się wskazaniem rzędu wielkości? Jednym z interesującym aspektów szacowania jest to, że stosowane jednostki istotnie wpływają na interpretację wyników. Kiedy przewidujemy, że realizacja jakiegoś zadania zajmie około 130 dni roboczych, większość rozmówców uzna, że termin ukończenia prac jest dość bliski. Jeśli jednak powiemy, że ten sam projekt wymaga około 6 miesięcy, rozmówca uzna, że termin jest dość odległy i nadejdzie dopiero za pięć do siedmiu miesięcy. Co ciekawe, obie wartości reprezentują ten sam okres, ale 130 dni sugeruje większy stopień precyzji, czyli 4337ebf6db5c7cc89e4173803ef3875a 4 Szacowanie 95 trafniejszą prognozę. Zalecamy stosowanie następujących reguł dla jednostek wyrażania oszacowań: Czas Jednostka oszacowania 1 – 15 dni dni 3 – 8 tygodni tygodnie 8 – 20 tygodni miesiące 20 + tygodni warto się dobrze zastanowić, czy szacowanie w ogóle ma sens Jeśli po przeprowadzeniu wszystkich niezbędnych analiz stwierdzamy, że jakiś projekt zajmie 125 dni roboczych (25 tygodni), być może warto oszacować ten czas jako „około sześć miesięcy”. To samo dotyczy szacunków każdej innej wielkości — zawsze powinniśmy wybierać jednostki dobrze odzwierciedlające precyzję, którą chcemy zakomunikować odbiorcy. Skąd się biorą oszacowania? Wszystkie oszacowania formułuje się na podstawie modeli problemu. Zanim jednak zajmiemy się technikami budowania modeli, warto zwrócić uwagę na prosty zabieg, który zawsze prowadzi do prawidłowych odpowiedzi — wystarczy spytać kogoś, kto już to robił. Zanim zbyt mocno zaangażujemy się w konstruowanie modelu, powinniśmy rozejrzeć się dookoła w poszukiwaniu kogoś, kto w przeszłości znalazł się w podobnej sytuacji. Warto sprawdzić, jak oni rozwiązali ten problem. Znalezienie identycznego scenariusza jest co prawda mało prawdopodobne, ale możliwości korzystania z cudzych doświadczeń w wielu przypadkach są zaskakująco duże. Należy zrozumieć przedmiot pytania Pierwszym etapem każdego procesu szacowania powinno być dobre rozumienie przedmiotu pytania. Oprócz omówionych przed chwilą kwestii dokładności musimy jeszcze dysponować pewną wiedzą o zakresie danej dziedziny. Informacje na ten temat zwykle są pośrednio zawarte w pytaniu, ale powinniśmy odruchowo analizować zasięg przed przystąpieniem do właściwego szacowania. Wybrany przez nas zasięg nierzadko decyduje o istotnym fragmencie naszej odpowiedzi: „Jeśli przyjmiemy, że nie będzie korków spowodowanych wypadkiem drogowym i że samochód jest zatankowany, powinniśmy dotrzeć na miejsce w 20 minut”. Należy zbudować model systemu To jeden z przyjemniejszych aspektów szacowania. Na podstawie tego, jak rozumiemy zadane pytanie, możemy zbudować przemyślany model pamięciowy. Jeśli przedmiotem szacowania jest czas odpowiedzi, model może obejmować serwer 4337ebf6db5c7cc89e4173803ef3875a 4 96 Rozdział 2. Postawa pragmatyczna i jakieś odwzorowanie ruchu przychodzącego. W przypadku projektu model może obejmować kroki używane przez naszą organizację w czasie wytwarzania oraz bardzo ogólny obraz potencjalnej implementacji systemu. Budowa modelu może być nie tylko twórczym doświadczeniem, ale też czynnością o wymiernych korzyściach praktycznych. Procedura konstruowania modelu często prowadzi do odkryć wzorców i procesów, które w pierwszej chwili nie były widoczne. W pewnych przypadkach budowa modelu może doprowadzić nas do wniosku o konieczności przebudowy oryginalnego pytania: „Prosiłeś o oszacowanie czasu trwania projektu X. Wydaje się jednak, że projekt Y, czyli pewien wariant projektu X, można zrealizować blisko dwukrotnie szybciej, rezygnując z zaledwie jednej funkcji”. Budowanie modelu wprowadza do procesu szacowania pewne nieścisłości. Brak dokładności na tym etapie jest nie tylko nieunikniony, ale też korzystny. Poświęcamy precyzję oszacowania na rzecz prostoty modelu. Co ciekawe, dwukrotne wydłużenie prac nad projektem może doprowadzić do zaledwie niewielkiego wzrostu trafności oszacowania. Z czasem nabierzemy doświadczenia, które pozwoli nam stwierdzić, w którym momencie należy zakończyć doskonalenie modelu. Należy podzielić model na komponenty Kiedy już będziemy dysponowali modelem, możemy podzielić go na komponenty. Będziemy musieli odkryć reguły matematyczne opisujące wzajemne interakcje tych komponentów. W pewnych przypadkach komponent wpływa na powstawanie pojedynczej wartości dodawanej do zbiorczego wyniku. Niektóre komponenty mogą dostarczać współczynniki używane w operacjach mnożenia, inne mogą być bardziej złożone (mogą na przykład odpowiadać za symulowanie ruchu przychodzącego do jakiegoś węzła). Szybko odkryjemy, że każdy komponent ma parametry wpływające na jego udział w funkcjonowaniu całego modelu. Na tym etapie wystarczy tylko zidentyfikować te parametry. Każdemu parametrowi należy nadać wartość Po wyodrębnieniu wszystkich parametrów możemy przystąpić do przypisywania każdemu z nich konkretnej wartości. W tym kroku musimy liczyć się z możliwością popełnienia błędów. Cała sztuka polega na wskazaniu parametrów, które będą miały największy wpływ na wynik, i skoncentrowaniu wysiłków na prawidłowym zdefiniowaniu ich wartości. Parametry, których wartości są dodawane do końcowego wyniku, zwykle są mniej ważne od tych, przez które wynik jest mnożony lub dzielony. Dwukrotne zwiększenie przepustowości łącza może spowodować dwukrotny wzrost ilości danych otrzymywanych w ciągu godziny, ale na przykład dodanie opóźnienia przesyłania na poziomie 5 ms może nie mieć istotnego wpływu na funkcjonowanie systemu. 4337ebf6db5c7cc89e4173803ef3875a 4 Szacowanie 97 Powinniśmy wypracować przemyślany schemat wyznaczania tych krytycznych parametrów. W przypadku mechanizmu kolejkowania być może powinniśmy mierzyć rzeczywistą liczbę transakcji przychodzących do istniejącego systemu lub znaleźć podobny system, dla którego można by zgromadzić odpowiednie statystyki. Podobnie, możemy albo mierzyć aktualny czas potrzebny do obsługi pojedynczego żądania, albo użyć technik opisanych w tym podrozdziale szacowania tego czasu. W praktyce często musimy opierać jedno oszacowanie na innych podoszacowaniach. Właśnie takie łączenie oszacowań zwykle prowadzi do największych błędów. Należy obliczyć odpowiedzi Tylko w najprostszych przypadkach wynikiem szacowania jest pojedyncza odpowiedź. Oczywiście, chcielibyśmy powiedzieć: „Spacer przez pięć przecznic zajmie mi 15 minut”. Ponieważ jednak systemy są coraz bardziej złożone, nasze odpowiedzi muszą uwzględniać przyjęte założenia. Powinniśmy przeprowadzić wiele obliczeń, zmieniając wartości najważniejszych parametrów, aż wypracujemy kombinacje najbardziej zbliżające nasz model do rzeczywistości. Sporym ułatwieniem mogą być arkusze kalkulacyjne. Warto następnie sformułować odpowiedź na podstawie tych parametrów. „Czas odpowiedzi wyniesie w przybliżeniu trzy czwarte sekundy, jeśli system będzie korzystał z magistrali SCSI i 64 MB pamięci, lub jedną sekundę, jeśli system będzie używał 48 MB pamięci”. (Należy pamiętać, że „trzy czwarte sekundy” robi na odbiorcy zupełnie inne wrażenie precyzji niż wartość 750 milisekund). Na etapie obliczeń możemy uzyskiwać odpowiedzi, które wydadzą nam się dziwne. Nie powinniśmy jednak lekkomyślnie rezygnować z tych wartości. Jeśli przyjęte obliczenia arytmetyczne są prawidłowe, być może problem tkwi w błędnym rozumieniu analizowanej kwestii lub niewłaściwym modelu. Taka informacja jest wyjątkowo cenna. Należy śledzić własne umiejętności szacowania Uważamy, że rejestrowanie własnych szacunków z myślą o ich porównywaniu z rzeczywistymi wartościami jest wprost doskonałym pomysłem. Jeśli łączne oszacowanie wymagało obliczenia podoszacowań, warto śledzić także te wartości składowe. W wielu przypadkach będziemy z satysfakcją stwierdzali, że nasze oszacowania były całkiem trafne — w rzeczywistości po jakimś czasie dość precyzyjne oszacowania będą dla nas czymś oczywistym. Jeśli nasze oszacowanie okaże się błędne, w żadnym razie nie powinniśmy wzruszyć ramionami i przejść nad tym do porządku dziennego. Powinniśmy raczej sprawdzić, skąd wzięła się różnica dzieląca rzeczywistość od naszych przewidywań. Być może wybraliśmy parametry, które nie odzwierciedlały rzeczywistego charakteru problemu. A może przyjęliśmy błędny model. Niezależnie od powodu warto poświęcić trochę czasu na analizę przyczyn tego stanu rzeczy. Jeśli to zrobimy, nasze następne oszacowanie będzie lepsze. 4337ebf6db5c7cc89e4173803ef3875a 4 98 Rozdział 2. Postawa pragmatyczna Szacowanie harmonogramów projektów Zwykle słyszymy prośby o oszacowanie czasu, jaki zajmie nam wykonanie jakiejś czynności. Jeśli ta czynność jest złożona, podanie dokładnych szacunków może okazać się bardzo trudne. W tym podrozdziale zajmiemy się dwoma technikami zmniejszania niepewności szacowania. Malowanie pocisku — Jak długo zajmie pomalowanie domu? — Cóż, jeśli wszystko pójdzie dobrze, a ta farba pokrywa tak, jak podano na opakowaniu, to być może uda się skończyć w 10 godzin. To jednak jest mało prawdopodobne. Przypuszczam, że bardziej realistyczna ocena to około 18 godzin. Oczywiście, jeśli pogoda zmieni się na gorszą, czas może wydłużyć się do 30 lub więcej godzin. Tak szacują ludzie w prawdziwym świecie. Nie podają jednej liczby (chyba że ich do tego zmusimy), ale prezentują różne scenariusze. Kiedy w US Navy planowano projekt łodzi podwodnej Polaris, przyjęto styl szacowania z wykorzystaniem metodologii określanej jako PERT (ang. Program Evaluation Review Technique — dosłownie: technika przeglądów szacowania programu). Dla każdego zadania PERT wyznaczane są oceny: optymistyczna, najbardziej prawdopodobna i pesymistyczna. Zadania są organizowane w sieć zależności, a następnie wykorzystywanych jest kilka prostych obliczeń statystycznych w celu zidentyfikowania prawdopodobnego, najlepszego i najgorszego czasu realizacji całego projektu. Użycie takiego zakresu wartości to świetny sposób uniknięcia jednego z najczęstszych powodów błędu szacowania: zwiększenia szacunku ze względu na brak pewności. Zamiast tego, obliczenia statystyczne stosowane w metodzie PERT eliminują niepewność, co przyczynia się do uzyskania lepszych szacunków dla całego projektu. Nie jesteśmy jednak wielkimi fanami tej metody. Ludzie mają tendencję do wytwarzania zajmujących całą ścianę wykresów wszystkich zadań w projekcie i na tej podstawie wierzą, że skoro skorzystali z obliczeń według wzoru, to mają dokładne oszacowanie. Istnieje ryzyko, że szacunki są niedokładne, ponieważ nie robiliśmy tego nigdy wcześniej. Jedzenie słonia Często okazuje się, że jedynym sposobem określenia harmonogramu prac nad projektem jest gromadzenie doświadczenia w realizacji tego projektu. Wspomniane zjawisko wcale nie musi być paradoksem, jeśli tylko stosujemy metodykę 4337ebf6db5c7cc89e4173803ef3875a 4 Szacowanie 99 przyrostowego wytwarzania oprogramowania i konsekwentnie powtarzamy następujące kroki: weryfikacja wymagań; analiza ryzyka; projektowanie, implementacja i integracja; weryfikacja z użytkownikami. Początkowo możemy mieć mgliste pojęcie o liczbie niezbędnych interakcji bądź o czasie zajmowanym przez te interakcje. Niektóre metody wymagają od nas pełnego szacowania już na etapie przygotowywania początkowego planu, jednak poza najprostszymi projektami taki model pracy jest chybiony. Jeśli nie pracujemy nad aplikacją zbliżoną do naszego ostatniego produktu, jeśli nie pracujemy w tym samym zespole i jeśli nie korzystamy z dokładnie tej samej technologii, szacowanie czasu trwania projektu jest zwykłym zgadywaniem. Warto więc zakończyć kodowanie i testowanie kilku podstawowych funkcji, po czym oznaczyć tak opracowany fragment systemu jako pierwszą iterację. Na podstawie doświadczeń zebranych w trakcie tej iteracji możemy poprawić początkowe przypuszczenia dotyczące liczby iteracji i elementów składających się na poszczególne iteracje. Kolejne korekty powinny być coraz bliższe rzeczywistości, powodując stopniową poprawę trafności całego harmonogramu. Jest to zgodne ze starym przysłowiem, które mówi, jak można zjeść słonia: kęs po kęsie. WSKAZÓWKA NR 24 Harmonogram i kod powinny powstawać iteracyjnie. Proponowane rozwiązanie nie jest zbyt popularne wśród menedżerów, którzy zwykle oczekują pojedynczej, precyzyjnej liczby jeszcze przed przystąpieniem do realizacji projektu. Powinniśmy pomóc przełożonym w zrozumieniu, że o harmonogramie prac decyduje zespół, jego produktywność i otoczenie, w którym pracuje. Przyjęcie formalnych reguł i stopniowe doskonalenie harmonogramu w ramach każdej iteracji powinno nam umożliwić dostarczanie możliwie precyzyjnych oszacowań. Co odpowiedzieć na prośbę o oszacowanie Wystarczy powiedzieć: „Jeszcze do tego wrócę”. Niemal zawsze możemy uzyskać lepsze wyniki, jeśli spowolnimy cały proces szacowania i poświęcimy trochę czasu na czynności opisane w tym podrozdziale. Szacunki wymyślone naprędce przy automacie z kawą mogą się na nas szybko zemścić (podobnie jak sama kawa). 4337ebf6db5c7cc89e4173803ef3875a 4 100 Rozdział 2. Postawa pragmatyczna Pokrewne podrozdziały Temat 7., „Komunikuj się!”. Temat 39., „Szybkość algorytmu”. Wyzwania Należy zacząć prowadzić rejestr własnych szacunków. Dla każdego oszacowania należy śledzić trafność w zestawieniu z rzeczywistością. Jeśli błąd oszacowania przekracza 50%, warto sprawdzić, gdzie popełniono błąd. Ćwiczenia 9. Zadano nam pytanie: „Kiedy przepustowość jest większa — w przypadku linii komunikacyjnej o szybkości 1 Gb/s czy w przypadku osoby przenoszącej pomiędzy dwoma komputerami nośnik pamięci o pojemności 1 TB?”. Jakie ograniczenia należałoby uwzględnić w odpowiedzi, aby mieć pewność, że jej zasięg jest prawidłowy? (Możemy na przykład zastrzec, że nie uwzględniamy czasu potrzebnego do uzyskania dostępu do danych na taśmie). 10. W którym modelu przepustowość jest większa? 4337ebf6db5c7cc89e4173803ef3875a 4 Rozdział 3. Podstawowe narzędzia Każdy rzemieślnik rozpoczyna pracę od skompletowania podstawowego zestawu narzędzi odpowiednio wysokiej jakości. Stolarz najprawdopodobniej będzie potrzebował miarek, kilku pił, dobrego hebla, precyzyjnych dłut, wierteł i świdrów, młotków i imadeł. Sam wybór narzędzi sprawi rzemieślnikowi mnóstwo radości. Każde narzędzie będzie z powodzeniem służyło do konkretnych czynności (obszar działania nieznacznie będzie pokrywał się z innymi narzędziami). Prawdziwą sprawność wspomniane narzędzia osiągną dopiero w fachowych dłoniach stolarza. Właśnie dlatego po wyborze narzędzi następuje proces ich poznawania i dostosowywania do potrzeb. Każde narzędzie ma własną osobowość i dziwactwa, zatem wymaga specjalnego traktowania. Każde narzędzie musi być ostrzone (a często także trzymane) w niepowtarzalny sposób. Z czasem po każdym narzędziu widać pewne oznaki zużycia — na każdym uchwycie widać ślady dłoni stolarza. Powierzchnie tnące doskonale dopasowują się do kąta, pod którym stolarz tnie drewno. Na tym etapie narzędzia zyskują status przedłużenia umysłu rzemieślnika w kontakcie z końcowym produktem — stanowią teraz tylko rozszerzenia gołych dłoni stolarza. Z czasem stolarz uzupełnia swój zestaw o nowe narzędzia, jak foremki do wycinania wzorów, ukośnice czy frezarki do wczepów — wszystkie osiągnięcia współczesnej technologii. Nie ma jednak wątpliwości, że najwięcej radości będzie sprawiało stolarzowi korzystanie z oryginalnych narzędzi, kiedy będzie miał poczucie bezpośredniego obcowania z drewnem. Narzędzia pozwalają nam rozwinąć talent. Im lepsze są nasze narzędzia i im lepiej potrafimy ich używać, tym większa może być nasza produktywność. Warto zacząć od podstawowego zbioru uniwersalnych narzędzi. Kiedy nabierzemy doświadczenia i kiedy dojdziemy do specjalnych wymagań, będziemy mogli rozważyć poszerzenie tego podstawowego zestawu. Tak jak rzemieślnicy, musimy być przygotowani na systematyczne uzupełnianie swojego zestawu narzędzi. 4337ebf6db5c7cc89e4173803ef3875a 4 102 Rozdział 3. Podstawowe narzędzia Musimy stale poszukiwać sposobów doskonalenia swojego warsztatu. Jeśli znajdziemy się w sytuacji, w której stwierdzimy, że nasze narzędzia nie wystarczą do przecięcia jakiegoś elementu, koniecznie powinniśmy zanotować sobie konieczność poszukania mocniejszego narzędzia, które poradziłoby sobie z tym wyzwaniem. O dodaniu nowych narzędzi powinny decydować faktyczne potrzeby. Wielu niedoświadczonych programistów popełnia błąd polegający na stosowaniu od początku pojedynczego, bardzo rozbudowanego narzędzia, na przykład zintegrowanego środowiska wytwarzania (IDE), i bardzo szybko przyzwyczaja się do jego wygodnego interfejsu. To naprawdę poważny błąd. Musimy opanować sztukę radzenia sobie z narzędziami pozbawionymi udogodnień oferowanych przez środowiska IDE. Jedynym sposobem osiągnięcia tego celu jest skompletowanie i utrzymywanie w gotowości odpowiedniego zestawu podstawowych narzędzi. W tym rozdziale omówimy kwestie związane z inwestowaniem w taki podstawowy zestaw narzędzi. Jak w przypadku każdej dyskusji poświęconej narzędziom, zaczniemy (w podrozdziale „Potęga zwykłego tekstu”) od analizy naszych podstawowych materiałów, swoistego surowca, oraz produktów, które chcemy stworzyć. Zaraz potem zajmiemy się kwestią środowiska pracy, w tym przypadku kwestią komputera. Jak możemy użyć swojego komputera do uzyskania możliwie najwyższej produktywności oferowanej przez stosowane narzędzia? Omówimy ten problem w podrozdziale „Powłoki”. Skoro dysponujemy już materiałem i odpowiednio przygotowanym miejscem pracy, możemy skoncentrować się na narzędziu, którego prawdopodobnie będziemy używali częściej niż innych. W podrozdziale „Efektywna edycja” zaproponujemy sposoby poprawy naszej produktywności. Aby wyeliminować ryzyko utraty cennych efektów pracy, zawsze powinniśmy korzystać z systemu kontroli kodu źródłowego (patrz podrozdział „Kontrola kodu źródłowego”) nawet dla takich zasobów jak osobista książka adresowa! A ponieważ pan Murphy w rzeczywistości był optymistą, musimy mieć świadomość, że doskonałym programistą nie może zostać ktoś, kto nie opanował do perfekcji diagnozowania kodu (patrz podrozdział „Debugowanie”). Będziemy jeszcze potrzebowali jakiegoś spoiwa, które zwiąże wszystkie te magiczne elementy. Pewne możliwości, w tym języki awk, Perl i Python, omówimy w podrozdziale „Operowanie na tekście”. Wreszcie, najbardziej blady atrament jest lepszy niż najlepsza pamięć. Należy prowadzić dziennik swoich przemyśleń oraz zapisywać w nim historię projektu. Sposób prowadzenia takiego dziennika opisano w podrozdziale „Dzienniki inżynierskie”. Wystarczy poświęcić trochę czasu na naukę technik korzystania z tych narzędzi, aby pewnego dnia ze zdziwieniem odkryć, że nasze palce niemal automatycznie naciskają właściwe klawisze, modyfikując tekst bez konieczności jakichkolwiek świadomych przemyśleń. Dopiero wówczas narzędzia zyskują status przedłużeń naszych rąk. 4337ebf6db5c7cc89e4173803ef3875a 4 Potęga zwykłego tekstu 16 36 103 Potęga zwykłego tekstu Dla pragmatycznego programisty podstawowym materiałem do obróbki jest nie drewno czy żelazo, tylko wiedza. Gromadzimy wymagania w formie wiedzy, po czym wyrażamy tę wiedzę pod postacią projektów, implementacji, testów i dokumentów. Wierzymy też, że najlepszym formatem trwałego przechowywania wiedzy jest zwykły tekst. Właśnie zwykły tekst umożliwia nam przetwarzanie wiedzy (zarówno ręcznie, jak i programowo) przy użyciu niemal każdego dostępnego narzędzia. Problem z większością formatów binarnych polega na tym, że kontekst niezbędny do zrozumienia danych jest oddzielony od samych danych. W sztuczny sposób rozdzielamy dane od ich znaczenia. Równie dobrze dane można zaszyfrować. Bez logiki aplikacji pozwalającej przeanalizować dane są one całkowicie niezrozumiałe. Natomiast za pomocą zwykłego tekstu można uzyskać samoopisujący się strumień danych, niezależny od aplikacji, w której dane zostały stworzone. Czym jest zwykły tekst? Zwykły tekst składa się ze znaków drukowalnych w formie możliwej do odczytania i zrozumienia przez człowieka. Może to być coś tak prostego, jak lista zakupów: * mleko * sałata * kawa lub tak złożonego, jak tekst źródłowy tej książki (tak, pisaliśmy go zwykłym tekstem, pomimo protestów redaktora, który chciał, żebyśmy używali procesora tekstu). Aspekt informacyjny jest bardzo ważny. Poniższy fragment nie jest przydatnym zwykłym tekstem: hlj;uijn bfjxrrctvh jkni'pio6p7gu;vh bjxrdi5rgvhj Podobnie niezrozumiały jest tekst: Field19=467abe Czytelnik nie ma pojęcia, co może oznaczać wartość 467abe. Dużo lepszym rozwiązaniem jest stosowanie zapisów zrozumiałych dla ludzi. WSKAZÓWKA NR 25 Wiedzę należy zapisywać zwykłym tekstem. 4337ebf6db5c7cc89e4173803ef3875a 4 104 Rozdział 3. Podstawowe narzędzia Potęga tekstu Skoro użytkownicy rzadko wyrażają w swoich wymaganiach oczekiwanie otrzymania większych i wolniejszych rozwiązań, skąd pomysł, by utrudniać sobie życie, stosując zwykły tekst? Jakie są korzyści stosowania zwykłego tekstu? Trwałe bezpieczeństwo zamiast starzenia się. Szerokie możliwości stosowania. Łatwiejsze testowanie. Trwałe bezpieczeństwo zamiast starzenia się Formy danych czytelne dla ludzi, które nie wymagają dodatkowego opisu, okazują się dużo trwalsze od innych rodzajów danych i aplikacji, które je tworzą. Kropka. Skoro cykl użycia danych jest dłuższy, będziemy mieli możliwość ich użycia w przyszłości, być może na długo po rezygnacji z oryginalnej aplikacji, która te dane wytworzyła. Do przetwarzania tego rodzaju plików wystarczy szczątkowa znajomość ich formatu; z zupełnie inną sytuacją mamy do czynienia w przypadku plików binarnych, gdzie skuteczna analiza składniowa wymaga znajomości wszystkich szczegółów stosowanego formatu. Przeanalizujmy przykład danych z pewnego przestarzałego systemu1. Naszym zadaniem jest operowanie na jego danych. Nasza wiedza o oryginalnej aplikacji jest dość ograniczona — z naszego punktu widzenia najważniejsza jest zapisana przez ten system lista numerów ubezpieczenia społecznego (SSN) klientów, którą musimy odczytać. Wśród zgromadzonych danych odnajdujemy następujące elementy: <FIELD10>123-45-6789</FIELD10> ... <FIELD10>567-89-0123</FIELD10> ... <FIELD10>901-23-4567</FIELD10> Po rozpoznaniu charakterystycznego formatu numerów SSN możemy bez trudu napisać prosty program wyodrębniający te dane, nawet jeśli nie dysponujemy żadnymi informacjami o pozostałych wartościach zapisanych w tym pliku. Wyobraźmy sobie teraz, że przetwarzany plik został sformatowany w następujący sposób: A.C.27123456789B11P ... XY43567890123QTYL ... 6T2190123456788AM 1 Każde oprogramowanie staje się przestarzałe niemal natychmiast po napisaniu. 4337ebf6db5c7cc89e4173803ef3875a 4 Potęga zwykłego tekstu 105 Tym razem rozpoznanie znaczenia tych liczb nie jest już takie proste. Właśnie na tym polega różnica dzieląca tekst czytelny dla człowieka od tekstu zrozumiałego dla człowieka. Warto przy tej okazji wspomnieć, że także łańcuch FIELD10 niewiele nam mówi. Zapis w tej formie: <SOCIAL-SECURITY-NO>123-45-6789</SOCIAL-SECURITY-NO> spowodowałby, że zadanie identyfikacji numerów SSN byłoby dziecinnie proste. Co więcej, mielibyśmy pewność, że dane w tej formie zachowają wartość dużo dłużej niż jakikolwiek projekt, który je utworzył. Filozofia systemu Unix System operacyjny Unix jest znany z tego, że został zaprojektowany zgodnie z koncepcją licznych, niewielkich, szybkich narzędzi, z których każde ma realizować jedno zadanie. Realizacja tej filozofii jest możliwa dzięki stosowaniu wspólnego formatu danych — zwykłych plików tekstowych z podziałem na wiersze. Bazy danych używane przez administratora systemu (a więc listy użytkowników i haseł, konfiguracja sieci itp.) bez wyjątku są zapisywane w zwykłych plikach tekstowych. (Niektóre systemy dodatkowo utrzymują pewne bazy danych w formie binarnej, aby poprawić wydajność. W każdym takim przypadku wersja w postaci zwykłego tekstu pełni funkcję swoistego interfejsu wersji binarnej). W razie awarii systemu możemy stanąć przed koniecznością użycia minimalnego środowiska do odtworzenia zapisanych zasobów (możemy na przykład stracić dostęp do sterowników graficznych). W takich przypadkach zapewne docenimy prostotę zwykłego tekstu. Zwykły tekst jest również łatwiejszy do przeszukiwania. Jeśli nie możesz zapamiętać, który plik konfiguracyjny jest odpowiedzialny za kopie zapasowe systemu, wystarczy, że wpiszesz polecenie grep -r backup /etc, żeby się tego dowiedzieć. Szerokie możliwości stosowania Praktycznie każde narzędzie w świecie przetwarzania komputerowego, od systemów zarządzania kodem źródłowym, przez środowiska kompilatorów, po edytory i autonomiczne filtry, może operować na zwykłym tekście. Przypuśćmy na przykład, że dysponujemy wdrożeniem produkcyjnym obejmującym jakąś wielką aplikację ze skomplikowanym plikiem konfiguracyjnym w formacie właściwym tylko tej aplikacji (warto przypomnieć sobie choćby przykład programu sendmail). Jeśli taki plik zawiera zwykły tekst, możemy umieścić go w systemie kontroli kodu źródłowego (patrz temat 19., „Kontrola kodu źródłowego”), tak aby pełna historia zmian była utrzymywana automatycznie. Narzędzia do porównywania plików, jak diff czy fc, umożliwiają nam błyskawiczne identyfikowanie wprowadzonych zmian, natomiast narzędzie sum umożliwia nam generowanie sum kontrolnych z myślą o monitorowaniu pliku pod kątem przypadkowych lub złośliwych modyfikacji. 4337ebf6db5c7cc89e4173803ef3875a 4 106 Rozdział 3. Podstawowe narzędzia Łatwiejsze testowanie Jeśli zwykły tekst jest używany do tworzenia danych syntetycznych na potrzeby testów systemu, zmiany scenariuszy testowych będą sprowadzały się do dodawania, aktualizowania lub modyfikowania tych danych testowych (bez konieczności tworzenia jakichkolwiek specjalnych narzędzi). Podobnie, dane wynikowe w formie zwykłego tekstu generowane przez testy regresyjne można bez trudu analizować (na przykład za pomocą polecenia diff) bądź przekazywać do dalszego przetwarzania przez polecenia powłoki lub proste skrypty. Najmniejszy wspólny mianownik Nawet w przyszłych inteligentnych agentach operujących na danych w formacie XML i samodzielnie przemierzających dziki i niebezpieczny świat internetu (negocjując przy tym wymianę danych między sobą) zwykłe pliki tekstowe zachowają swoją ważną pozycję. W praktyce w środowiskach heterogenicznych zalety zwykłego tekstu nierzadko przewyższają wady tego formatu. Musimy zagwarantować możliwość wzajemnego komunikowania się przez składniki tych środowisk przy użyciu jednego wspólnego standardu. Takim standardem jest właśnie zwykły tekst. Pokrewne podrozdziały Temat 17., „Powłoki”. Temat 21., „Operowanie na tekście”. Temat 32., „Konfiguracja”. Wyzwania Zaprojektuj bazę danych niewielkiej książki adresowej (obejmującej nazwisko, numer telefonu itp.), stosując prostą reprezentację binarną dostępną w stosowanym języku programowania. Zrób to przed zapoznaniem się z dalszą częścią tego zadania. 1. Przetłumacz ten format na zwykły tekst, stosując format XML lub JSON. 2. Dla obu wersji dodaj nowe pole zmiennej długości nazwane „kierunek”, w którym będą umieszczane wskazówki, jak dojechać do domu danej osoby. Jakie problemy można napotkać w kontekście zarządzania wersjami i rozszerzalności? Który format był łatwiejszy do zmodyfikowania? Jak przebiegała konwersja istniejących danych? 4337ebf6db5c7cc89e4173803ef3875a 4 Powłoki 17 37 107 Powłoki Każdy stolarz potrzebuje dobrego, solidnego blatu, na którym będzie mógł trzymać tworzone przedmioty na wygodnej dla siebie wysokości. Stół do pracy staje się centralnym elementem warsztatu stolarskiego. Rzemieślnik wielokrotnie wraca w to miejsce podczas nadawania właściwego kształtu swojemu dziełu. Z perspektywy programisty operującego na plikach tekstowych funkcję stołu pełni powłoka poleceń. Właśnie z poziomu wiersza poleceń możemy wywoływać narzędzia z naszego zestawu, stosując przy tym potoki umożliwiające łączenie narzędzi w sekwencje, które nawet nie śniły się twórcom tych narzędzi. Za pośrednictwem powłoki możemy uruchamiać aplikacje, debugery, przeglądarki, edytory i inne narzędzia użytkowe. Możemy szukać plików, wykonywać zapytania dotyczące statusu systemu i filtrować uzyskiwane dane wynikowe. Co więcej, programując powłokę, możemy konstruować makra złożone z wielu poleceń, aby ułatwić wielokrotne wykonywanie tych sekwencji. Programistom, którzy dorastali w otoczeniu graficznych interfejsów użytkownika (GUI) i zintegrowanych środowisk programisty (IDE), proponowane rozwiązania mogą wydać się ekstremalne. Czy nie można by tych wszystkich czynności wykonać równie łatwo, wybierając i klikając odpowiednie opcje za pomocą myszy? Odpowiedź jest prosta: nie. Graficzne interfejsy użytkownika są wspaniałe i rzeczywiście bywają szybsze i bardziej wygodne podczas wykonywania pewnych prostych czynności. Przenoszenie plików, odczytywanie wiadomości poczty elektronicznej z kodowaniem MIME czy pisanie listów to typowe operacje, które każdy użytkownik woli wykonywać w środowisku graficznym. Jeśli jednak wykonujemy całą pracę za pomocą graficznych interfejsów użytkownika, tracimy z oczu pełen potencjał środowiska, z którego korzystamy. Nie będziemy mogli automatyzować typowych czynności ani korzystać ze wszystkich funkcji dostępnych narzędzi. Co więcej, nie będziemy mogli łączyć tych narzędzi, tworząc własne, niestandardowe makra. Niewątpliwą zaletą graficznych interfejsów użytkownika jest model WYSIWYG (od ang. what you see is what you get) — to, co widzisz, to to, co otrzymasz. Wadą tych interfejsów jest zasada WYSIAYG (od ang. what you see is all you get) — to, co widzisz, to wszystko, co otrzymasz. Możliwości środowisk z graficznym interfejsem użytkownika zwykle są ograniczone do tego, co zaplanowali ich twórcy. Jeśli z jakiegoś powodu musimy wyjść poza model opracowany przez projektanta takiego środowiska, najczęściej musimy poszukać innych rozwiązań (w takich przypadkach rezygnacja z rozszerzenia ograniczonego modelu rzadko jest możliwa). Pragmatyczni programiści nie ograniczają się do budowy kodu, projektowania modeli obiektowych, pisania dokumentacji czy automatyzacji procesu kompilacji — musimy wykonywać wszystkie te czynności. Potencjał każdego narzędzia zwykle jest ograniczony do zadań, które to narzędzie miało wykonywać w założeniu swoich twórców. Przypuśćmy na przykład, że musimy zintegrować jakiś preprocesor kodu (z myślą o implementacji paradygmatu projektowania kontraktowego, 4337ebf6db5c7cc89e4173803ef3875a 4 108 Rozdział 3. Podstawowe narzędzia przetwarzania wieloprocesorowego itp.) z naszym środowiskiem IDE. Jeśli projektant środowiska IDE wprost nie udostępnił punktów dołączania tego rodzaju mechanizmów, nasze możliwości są bardzo niewielkie. WSKAZÓWKA NR 26 Należy korzystać z potencjału poleceń powłoki. Wystarczy dobrze opanować umiejętność korzystania z powłoki, a błyskawicznie zauważymy wzrost produktywności. Musimy stworzyć listę unikatowych nazw pakietów bezpośrednio importowanych przez nasz kod Javy? Poniższe polecenie zapisuje odpowiednią listę w pliku nazwanym list: sh/packages.sh grep '^import ' *.java | sed -e's/.*import *//' -e's/;.*$//' | sort -u >list Jeśli nie poświęciliśmy dość dużo czasu na analizę możliwości powłoki poleceń systemu, w którym pracujemy, jej obsługa może wydawać nam się zniechęcająca. Wystarczy zainwestować trochę energii w lepsze poznanie tej powłoki, a szybko odkryjemy, że wszystko działa wyjątkowo sprawnie. Warto poeksperymentować z powłoką poleceń — po pewnym czasie czytelnik będzie zaskoczony osiągniętym wzrostem produktywności. Twoja własna powłoka Tak samo, jak stolarz dostosowuje swoje miejsce pracy do własnych potrzeb, tak programista powinien dostosować swoją powłokę. Zazwyczaj obejmuje to również zmianę konfiguracji używanego programu terminala. Oto często wykonywane zmiany: Ustawianie motywów kolorów. Wypróbowanie każdego motywu, który jest dostępny online dla konkretnej powłoki, może zająć bardzo wiele godzin. Konfigurowanie „symbolu zachęty”. Symbol zachęty (ang. prompt), który informuje, że jesteś gotowy do wpisania komendy, można skonfigurować w taki sposób, aby wyświetlał dowolne informacje, które są Ci potrzebne (i różne rzeczy, które nie są potrzebne). Wszystko zależy od osobistych preferencji: my preferujemy proste symbole zachęty ze skróconą nazwą bieżącego katalogu oraz stanem systemu kontroli wersji i godziną. Aliasy i funkcje powłoki. Warto uprościć swój przepływ pracy przez przekształcenie często używanych komend na proste aliasy. Być może regularnie aktualizujesz Linuksa, ale nie pamiętasz, czy najpierw korzystasz z polecenia update, a potem upgrade, czy odwrotnie. Stwórz alias: alias apt-up='sudo apt-get update && sudo apt-get upgrade' 4337ebf6db5c7cc89e4173803ef3875a 4 Efektywna edycja 109 Być może zdarzyło Ci się przypadkowo usunąć pliki za pomocą polecenia rm o jeden raz za dużo. Napisz alias, aby w przyszłości próba uruchomienia polecenia rm zawsze powodowała wyświetlenie pytania o potwierdzenie: alias rm ='rm -iv' Uzupełnianie poleceń. Większość powłok automatycznie uzupełnia nazwy komend i plików: wpisz kilka pierwszych znaków, wciśnij Tab, a powłoka uzupełni nazwę. Ale ten mechanizm można wykorzystać znacznie bardziej. Możesz skonfigurować powłokę tak, aby rozpoznawała wpisywane polecenia i uzupełniała nazwy w zależności od kontekstu. Niektórzy nawet dostosowują uzupełnianie nazw w zależności od bieżącego katalogu. Z powłoką będziesz pracować przez znaczną część czasu. Bądź jak krab i uczyń ją swoim domem. Pokrewne podrozdziały Temat 13., „Prototypy i karteczki samoprzylepne”. Temat 16., „Potęga zwykłego tekstu”. Temat 21., „Operowanie na tekście”. Temat 30., „Programowanie transformacyjne”. Temat 51., „Zestaw startowy pragmatyka”. Wyzwania 18 38 Czy aktualnie wykonujesz jakieś czynności ręcznie za pośrednictwem graficznego interfejsu użytkownika? Czy kiedykolwiek opisywałeś znajomym procedury obejmujące wiele kroków typu „kliknij ten przycisk”, „zaznacz tę pozycję” itp.? Czy można było zautomatyzować te procedury? Za każdym razem, kiedy zaczynasz pracę w nowym środowisku, koniecznie sprawdź, jakie powłoki masz do dyspozycji. Warto wiedzieć, czy można przenieść do tego środowiska także dotychczas stosowaną powłokę. Sprawdź alternatywne rozwiązania dla aktualnie używanej powłoki. W razie znalezienia problemu, którego nie można rozwiązać przy użyciu bieżącej powłoki, sprawdź, czy alternatywna powłoka nie poradziłaby sobie z tym problemem lepiej. Efektywna edycja Wspomnieliśmy wcześniej o narzędziach jako o przedłużeniu naszych rąk. Takie postrzeganie narzędzi pasuje do edytorów bardziej niż do jakichkolwiek innych narzędzi programowych. Musimy mieć możliwość możliwie wygodnej pracy z tekstem, ponieważ tekst jest dla programisty podstawowym surowcem. 4337ebf6db5c7cc89e4173803ef3875a 4 110 Rozdział 3. Podstawowe narzędzia W pierwszym wydaniu tej książki zalecaliśmy używanie jednego edytora do wszystkiego: kodu, dokumentacji, notatek, administrowania systemem i tak dalej. W tym wydaniu złagodziliśmy trochę to stanowisko. Można korzystać z dowolnej liczby edytorów. Zalecamy jednak, by dążyć do uzyskania płynności każdego z nich. WSKAZÓWKA NR 27 Postaraj się o uzyskanie płynności edytora. Dlaczego to jest takie ważne? Czy mówiliśmy już, że pozwala to zaoszczędzić dużo czasu? Właściwie tak: jeśli zwiększysz wydajność swojego edytora tylko o 4%, a korzystasz z niego przez 20 godzin tygodniowo, to w ciągu roku możesz zyskać dodatkowy tydzień. Ale to nie na tym polega prawdziwa korzyść. Największy zysk z zapewnienia płynności edytora polega na tym, że gdy uda Ci się ją osiągnąć, nie będziesz zmuszony myśleć o mechanice edycji. Dystans pomiędzy chwilą, gdy o czymś pomyślisz, a momentem, kiedy to znajdzie się w buforze edytora, znacznie się skróci. Twoje myśli będą płynąć do edytora, co wpłynie korzystnie na skuteczność Twojego programowania (jeśli kiedykolwiek uczyłeś kogoś prowadzenia pojazdów, z pewnością rozumiesz różnicę pomiędzy osobą, która musi myśleć o każdej wykonywanej czynności, a bardziej doświadczonym kierowcą, który kontroluje samochód instynktownie). Co to znaczy „płynny”? Co wpływa na „płynność” edytora? Oto lista cech: Możliwość zaznaczania w tekście pojedynczych znaków, słów, wierszy i akapitów. Poruszanie się po kodzie z wykorzystaniem różnych jednostek składniowych (pasujących separatorów, funkcji, modułów). Modyfikowanie wcięć w kodzie po wprowadzeniu zmian. Komentowanie bloków kodu i usuwanie znaczników komentarza za pomocą jednego polecenia. Cofanie zmian i powtarzanie wykonanych operacji. Podział okna edytora na kilka paneli i poruszanie się pomiędzy nimi. Przechodzenie do wiersza o określonym numerze. Sortowanie zaznaczonych wierszy. Wyszukiwanie zarówno ciągów znaków, jak i wyrażeń regularnych, oraz powtarzanie poprzednich operacji wyszukiwania. Tymczasowe utworzenie wielu kursorów na podstawie zaznaczenia lub dopasowania wzorców i równoległe edytowanie zawartego w nich tekstu. 4337ebf6db5c7cc89e4173803ef3875a 4 Efektywna edycja Wyświetlanie błędów kompilacji w bieżącym projekcie. Uruchamianie testów w bieżącym projekcie. 111 Czy możesz wykonać wyżej wymienione działania bez użycia myszki (trackpada)? Być może Twój obecny edytor nie pozwala na wykonanie niektórych z tych operacji. A może nadszedł czas, by go zmienić? Zmierzanie w kierunku płynności Wątpimy, by istniało wiele osób, które znają wszystkie polecenia w rozbudowanym edytorze. Od Ciebie też tego nie oczekujemy. Zamiast tego sugerujemy bardziej pragmatyczne podejście: naucz się poleceń, które ułatwią Ci życie. Przepis na to jest dość prosty. Po pierwsze obserwuj, co robisz podczas edycji. Za każdym razem, gdy powtarzasz jakieś działania, spróbuj pomyśleć: „Musi istnieć lepszy sposób”. Następnie postaraj się ten sposób znaleźć. Kiedy odkryjesz nową, przydatną funkcję, musisz zainstalować ją w swojej „pamięci mięśniowej” tak, abyś mógł z niej skorzystać bez zastanowienia. Jedynym sposobem, aby to osiągnąć, jest powtarzanie. Świadomie szukaj możliwości korzystania z nowego udogodnienia — najlepiej wiele razy dziennie. Mniej więcej po tygodniu będziesz korzystać z nowej funkcji bez zastanowienia. Rozbudowa edytora Większość zaawansowanych edytorów kodu jest zbudowanych wokół podstawowego rdzenia, który jest następnie rozbudowywany przez rozszerzenia. Wiele z nich jest dostarczanych razem z edytorem, natomiast inne można dodawać później. Kiedy napotkasz jakieś widoczne ograniczenie edytora, którego używasz, postaraj się znaleźć odpowiednie rozszerzenie. Są szanse, że nie tylko Ty potrzebujesz nowej funkcji. Jeśli masz szczęście, być może ktoś inny opublikował odpowiednie rozwiązanie. Nie poprzestawaj na tym. Przestudiuj język rozszerzeń edytora. Dowiedz się, jak z niego korzystać w celu zautomatyzowania niektórych wykonywanych przez Ciebie powtarzających się zadań. Często wystarczy jeden lub dwa wiersze kodu. Czasami można pójść jeszcze dalej i napisać rozbudowane rozszerzenie. Jeśli to zrobisz, opublikuj je. Jeśli było potrzebne Tobie, inni także z niego skorzystają. Pokrewne podrozdziały Temat 7., „Komunikuj się!”. 4337ebf6db5c7cc89e4173803ef3875a 4 112 Rozdział 3. Podstawowe narzędzia Wyzwania Nigdy więcej autopowtarzania. Każdy to robi: musisz usunąć ostatnie wpisane słowo, więc naciskasz klawisz Backspace i czekasz, aż uaktywni się mechanizm autopowtarzania. Możemy się założyć, że Twój mózg wykonywał tę czynność tak wiele razy, że dokładnie wiesz, kiedy możesz zwolnić klawisz. Wyłącz autopowtarzanie. Zamiast z niego korzystać, naucz się kombinacji klawiszy do przemieszczania się po tekście, zaznaczania i usuwania tekstu według znaków, słów, wierszy i bloków. Ta czynność może być uciążliwa. Przestań korzystać z myszy (trackpada). Przez cały tydzień korzystaj z edytora wyłącznie za pomocą klawiatury. Odkryjesz wiele operacji, które możesz wykonać bez wskazywania i klikania, więc nadszedł czas, aby się ich nauczyć. Rób notatki (zalecamy starą szkołę z użyciem ołówka i papieru) — zapisuj kombinacje klawiszy, których się uczysz. W ciągu kilku dni zwiększysz produktywność. Kiedy nauczysz się wykonywać działania bez przemieszczania rąk z pozycji wyjściowej, przekonasz się, że edytujesz szybciej i płynniej niż kiedykolwiek wcześniej. 19 39 Szukaj integracji. Podczas pisania tego rozdziału Dave zastanawiał się, czy można podejrzeć ostateczny układ dokumentu (plik PDF) w buforze edytora. Wystarczyło pobrać odpowiednie rozszerzenie, aby móc oglądać w edytorze oryginalny tekst razem z docelowym układem. Sporządź listę udogodnień, które chciałbyś wprowadzić w swoim edytorze, a następnie ich poszukaj. Coś bardziej ambitnego? Jeśli nie potrafisz znaleźć wtyczki lub rozszerzenia, które wykonują potrzebną operację, napisz je samodzielnie. Andy jest autorem wtyczki do Wiki dla swoich ulubionych edytorów. Jeśli nie możesz czegoś znaleźć, zbuduj to. Kontrola kodu źródłowego Postęp jest uzależniony od tego, co trwałe, nie od zmian. Ten, kto nie pamięta przeszłości, jest skazany na jej wielokrotne powtarzanie. George Santayana, Life of Reason Jedną z najważniejszych funkcji, której powinniśmy szukać w interfejsie użytkownika, jest przycisk Cofnij — pojedynczy przycisk służący do wybaczania naszych błędów. Jeszcze cenniejsza jest obsługa operacji wycofywania i powtarzania zmian na wielu poziomach, tak aby można było cofać i przywracać operacje, które miały miejsce na przykład przed kilkoma minutami. 4337ebf6db5c7cc89e4173803ef3875a 4 Kontrola kodu źródłowego 113 Co będzie, jeśli błąd miał miejsce tydzień temu i jeśli od tego czasu wyłączyliśmy i włączyliśmy komputer dziesięć razy? To jedna z wielu zalet stosowania systemu kontroli wersji kodu źródłowego (ang. Version Control System — VCS) — wielki klawisz Cofnij, czyli swoisty wehikuł czasu dla całego projektu, który może nas cofnąć do pradawnych dni z zeszłego tygodnia, kiedy skompilowaliśmy i uruchomiliśmy nasz kod. Dla wielu osób na tym kończą się zastosowania systemów VCS. Tacy ludzie nie korzystają z większego środowiska współpracy, potoków wdrażania, śledzenia problemów i — ogólnie rzecz biorąc — interakcji w zespołach. Spróbujmy zatem przyjrzeć się systemom VCS. Najpierw w roli repozytorium zmian, a następnie jako centralnemu miejscu spotkań dla członków Twojego zespołu i tworzonego przez nich kodu. Współdzielone katalogi to NIE jest system kontroli kodu źródłowego Nadal spotykamy zespoły, które udostępniają pliki źródłowe swojego projektu w sieci: wewnętrznie lub za pomocą jakiegoś magazynu w chmurze obliczeniowej. To nie jest dobry sposób. Zespoły, które to robią, ciągle napotykają na problemy związane z „wchodzeniem sobie w drogę” — powoduje to mieszanie się zmian wprowadzanych przez różnych członków zespołu, utratę zmian, wprowadzanie błędów i walki na pięści na parkingu. To tak, jak pisanie współbieżnego kodu ze współdzielonymi danymi bez mechanizmów synchronizacji. Zamiast tego należy użyć systemu kontroli wersji. Ale to nie wszystko! Niektórzy ludzie używają systemu kontroli wersji, ale przechowują główne repozytorium na dysku sieciowym lub w chmurze. Uważają, że to najlepsze połączenie obu światów: ich pliki są dostępne w dowolnym miejscu i (w przypadku przechowywania w chmurze) są archiwizowane na zewnątrz. Okazuje się, że to jest jeszcze gorszy sposób od współdzielenia katalogów, bo stwarza ryzyko utraty wszystkiego. Oprogramowanie systemów VCS korzysta ze zbioru oddziałujących ze sobą plików i katalogów. Wprowadzenie zmian w dwóch egzemplarzach jednocześnie może doprowadzić do uszkodzenia ogólnego stanu systemu i nie ma sposobu na stwierdzenie, jak wielkie będą szkody. A nikt przecież nie lubi widoku płaczących programistów. Wszystko zaczyna się od kodu źródłowego Systemy kontroli kodu źródłowego pozwalają śledzić każdą zmianę wprowadzaną w kodzie źródłowym i dokumentacji. Jeśli system kontroli wersji jest poprawnie skonfigurowany, zawsze można wrócić do poprzedniej wersji oprogramowania. Możliwości systemu kontroli kodu źródłowego nie ograniczają się jednak tylko do wycofywania pomyłek. Dobry system VCS umożliwia śledzenie zmian i odpowiada na następujące pytania: Kto wprowadził zmiany w tym wierszu kodu? 4337ebf6db5c7cc89e4173803ef3875a 4 114 Rozdział 3. Podstawowe narzędzia Jaka jest różnica pomiędzy bieżącą wersją a wersją sprzed tygodnia? Ile wierszy kodu zostało zmienionych w bieżącym wydaniu? Które pliki są zmieniane najczęściej? Tego rodzaju informacje są bezcenne w kontekście śledzenia błędów, prowadzenia audytów, badania wydajności i sprawdzania jakości. System VCS umożliwia też identyfikację wydań naszego oprogramowania. Po zidentyfikowaniu wydania zawsze będziemy mogli do niego wracać i ponownie je generować (niezależnie od zmian, które wprowadzono do tworzonego oprogramowania po tym wydaniu). Systemy kontroli kodu źródłowego mogą śledzić pliki utrzymywane w centralnym repozytorium — takie repozytorium jest wprost doskonałym kandydatem do archiwizacji. I wreszcie, systemy VCS oferują możliwość równoczesnej pracy wielu użytkowników na tym samym zbiorze plików, a nawet wprowadzanie równoległych zmian w tych samych plikach. Systemy te zarządzają następnie scalaniem tych zmian w momencie odsyłania plików do repozytorium. Chociaż opisany mechanizm sprawia wrażenie dość ryzykownego, wspomniane systemy zadziwiająco dobrze sprawdzają się w praktyce niezależnie od rozmiaru projektu. WSKAZÓWKA NR 28 Zawsze należy stosować system kontroli kodu źródłowego. Zawsze. Nawet jeśli stanowimy jednoosobowy zespół i jeśli cały projekt zajmuje zaledwie tydzień. Nawet jeśli pracujemy nad prototypem przeznaczonym do wyrzucenia. Nawet jeśli nie pracujemy nad kodem źródłowym. Kontroli kodu źródłowego powinno podlegać dosłownie wszystko — dokumentacja, listy numerów telefonów, notatki dla producentów, pliki Makefile, procedury kompilacji i wydań, a nawet prosty skrypt odpowiedzialny za porządkowanie logów — słowem, wszystko. Sami odruchowo korzystamy z systemu kontroli kodu źródłowego, kiedy tylko planujemy coś napisać (w tym tekst tej książki). Nawet kiedy nie pracujemy nad konkretnym projektem, efekty naszej codziennej pracy są zabezpieczane w repozytorium. Gałęzie kodu Działanie systemów kontroli wersji nie ogranicza się do utrzymywania pojedynczej historii Twojego projektu. Jedną z ich najpotężniejszych i najbardziej przydatnych funkcji jest możliwość wyizolowania „wysp wytwarzania oprogramowania” do postaci konstrukcji nazywanych gałęziami (ang. branches). Możesz utworzyć gałąź w dowolnym momencie w historii projektu, a wszelkie prace wykonane w tej gałęzi będą odizolowane od wszystkich innych. W pewnym momencie w przyszłości możesz scalić gałąź, nad którą pracujesz, z inną gałęzią, 4337ebf6db5c7cc89e4173803ef3875a 4 Kontrola kodu źródłowego 115 dzięki czemu gałąź docelowa będzie zawierać zmiany wprowadzone w Twojej. Nad jedną gałęzią może nawet pracować wiele osób: w pewnym sensie gałęzie są jak małe klony projektów. Jedną z korzyści stosowania gałęzi jest izolacja kodu. Jeśli pracujesz nad funkcją A w jednej gałęzi, a kolega z zespołu pracuje nad funkcją B w innej, nie będziecie sobie wzajemnie przeszkadzać. Drugą zaletą, która może być zaskakująca, jest to, że gałęzie często stanowią sedno przepływu pracy w zespole. Tutaj sprawy nieco się komplikują. Stosowanie gałęzi kodu źródłowego ma wiele wspólnego z organizacją testów. W obu przypadkach mamy do czynienia z wieloma osobami, które mówią nam, w jaki sposób należy to robić. Te rady w dużej mierze są bez znaczenia, ponieważ w istocie osoby te mówią „U mnie się to sprawdziło”. Zatem korzystaj z systemu kontroli wersji w projekcie, a kiedy natkniesz się na problemy związane z przepływem pracy, poszukaj możliwych rozwiązań. I pamiętaj, żeby po zdobyciu doświadczeń dokonać przeglądu i dostosować sposób swojego postępowania. Eksperyment dający do myślenia Rozlej filiżankę herbaty (zwykłą angielską, z odrobiną mleka) na klawiaturze swojego laptopa. Zanieś maszynę do serwisu i posłuchaj, co jego pracownik o Tobie myśli. Kup nowy komputer. Zabierz go do domu. Ile czasu zajmie Ci doprowadzenie maszyny do stanu sprzed zalania (ze wszystkimi kluczami SSH, konfiguracją edytora, konfiguracją powłoki, zainstalowanymi aplikacjami i tak dalej)? Taki problem niedawno dotknął jednego z nas. Prawie wszystko, co definiowało konfigurację i korzystanie z maszyny, było przechowywane w systemie kontroli wersji. Między innymi były w nim: Wszystkie ustawienia użytkownika i pliki konfiguracyjne. Konfiguracja edytora. Lista oprogramowania zainstalowanego za pomocą menedżera Homebrew. Skrypt Ansible używany do skonfigurowania aplikacji. Wszystkie bieżące projekty. Maszynę udało się przywrócić do poprzedniego stanu jeszcze tego samego popołudnia. 4337ebf6db5c7cc89e4173803ef3875a 4 116 Rozdział 3. Podstawowe narzędzia System kontroli wersji jako centralna część projektu Chociaż systemy VCS są niezwykle przydatne w projektach jednoosobowych, ich prawdziwa potęga ujawnia się podczas pracy zespołowej. Duża część tej wartości wynika ze sposobu przechowywania repozytorium. Obecnie wiele systemów kontroli wersji nie wymaga żadnego hostingu. Są one całkowicie zdecentralizowane, a wszyscy programiści współpracują ze sobą na zasadach partnerskich. Ale nawet w przypadku systemów niewymagających hostingu warto zastanowić się nad skorzystaniem z centralnego repozytorium. Pozwala to na korzystanie z wielu integracji oraz usprawnia przepływy pracy w projekcie. Wiele systemów repozytorium to systemy open source, zatem nie ma przeszkód, aby je zainstalować i uruchomić we własnej firmie. Utrzymywanie systemu kontroli wersji to jednak nie jest Twój biznes, zatem zalecamy, aby korzystać z oferty firm zewnętrznych. Dzięki temu możemy uzyskać następujące korzyści: Dobre zabezpieczenia i kontrola dostępu. Intuicyjny interfejs użytkownika. Możliwość wykonywania operacji z poziomu wiersza poleceń (co ułatwia automatyzację). Automatyczne kompilacje i testy. Ułatwienia w scalaniu gałęzi (tzw. operacje żądania ściągnięcia — ang. pull requests). Zarządzanie błędami (często zintegrowane z commitami i scaleniami) — co pozwala utrzymywać metryki. Dobre mechanizmy raportowania (widok w postaci tablicy Kanban nierozstrzygniętych problemów i zadań może być bardzo przydatny). Dobra komunikacja w zespole: e-maile lub inne powiadomienia o zmianach, wiki i tak dalej. Wiele zespołów konfiguruje używane systemy VCS w taki sposób, że przesłanie kodu do konkretnej gałęzi inicjuje automatyczne budowanie systemu, uruchomienie testów oraz — w przypadku ich powodzenia — wdrożenie nowego kodu do produkcji. Brzmi groźnie? Nie wtedy, gdy korzystasz z systemu kontroli wersji. Zawsze możesz się cofnąć. Pokrewne podrozdziały Temat 11., „Odwracalność”. Temat 49., „Pragmatyczne zespoły”. Temat 51., „Zestaw startowy pragmatyka”. 4337ebf6db5c7cc89e4173803ef3875a 4 Debugowanie 117 Wyzwania Świadomość, że za pomocą systemu VCS można przywrócić kod do dowolnego poprzedniego stanu to jedno, ale czy rzeczywiście potrafisz to zrobić? Czy znasz polecenia pozwalające na prawidłowe wykonanie tej czynności? Naucz się ich teraz, a nie wtedy, gdy nastąpi awaria i będziesz pod presją. Poświęć trochę czasu na przemyślenie sposobu na odzyskanie środowiska swojego laptopa w przypadku awarii. Co trzeba będzie odtworzyć? Wiele elementów, których potrzebujesz, to zwykłe pliki tekstowe. Jeśli nie są one przechowywane w VCS (są zapisane tylko na laptopie), znajdź sposób, aby dodać je do VCS. Następnie pomyśl o innych elementach: zainstalowanych aplikacjach, konfiguracji systemu i tak dalej. W jaki sposób można zapisać reprezentacje wszystkich tych komponentów w plikach tekstowych, tak aby można było je przechowywać w systemie VCS? Ciekawym eksperymentem, kiedy już zrobisz pewien postęp, jest znalezienie starego komputera, którego już nie używasz, i sprawdzenie, czy możesz wykorzystać nowy system do jego skonfigurowania. Świadomie przetestuj funkcje systemu VCS, z którego aktualnie korzystasz, oraz systemu oferowanego przez dostawcę, z którego oferty nie korzystasz. Jeśli Twój zespół nie korzysta z gałęzi, zrób eksperyment z ich wprowadzeniem. Zrób to samo dla funkcji żądań ściągnięcia (scalenia); ciągłej integracji; potoków budowania. Spróbuj zastosować system ciągłego wdrażania. Przyjrzyj się również narzędziom komunikacji w zespole: tablicom Kanban, wiki itp. Nie trzeba korzystać z nich wszystkich. Ale warto wiedzieć, do czego one służą, by móc świadomie podjąć decyzję o ich wykorzystaniu. 40 20 Korzystaj z systemu VCS także w odniesieniu do elementów, które nie są częścią projektu. Debugowanie To wielki ból, Widzieć twoją straszną dolę I wiedzieć, że jest ona wyłącznie twoim dziełem. Sofokles, Ajas Angielskie słowo bug jest używane w znaczeniu „czegoś przerażającego” już od czternastego wieku. Kontradmirał dr. Grace Hopper, twórczyni języka COBOL, przypisuje się odkrycie pierwszego błędu (robaka) komputerowego, a konkretnie ćmy w pierwszym systemie komputerowym. Technik zapytany o przyczyny działania komputera niezgodnie z oczekiwaniami odpowiedział, że w systemie pojawił się robak, po czym karnie wkleił jego szczątki do prowadzonego dziennika. 4337ebf6db5c7cc89e4173803ef3875a 4 118 Rozdział 3. Podstawowe narzędzia Okazuje się, że na tym przypadku nie zakończyło się występowanie „robaków” w systemach komputerowych, tyle że teraz nie są to stworzenia latające. Okazuje się jednak, że czternastowieczne znaczenie tego słowa (odnoszące się do strachu) jest dzisiaj dużo bardziej uzasadnione niż wówczas. Usterki w oprogramowaniu ujawniają się na rozmaite sposoby, od błędów powodowanych niewłaściwym rozumieniem wymagań po błędy popełnione na etapie kodowania. Okazuje się, że współczesne systemy komputerowe wciąż oferują dość ograniczone możliwości realizacji tego, co im zlecamy, i nie zawsze robią to w sposób, którego sobie życzymy. Nikt nie pisze doskonałego oprogramowania, zatem musimy pogodzić się z tym, że błędy będą miały spory udział w naszym czasie pracy. Przeanalizujmy teraz wybrane problemy związane z diagnozowaniem i usuwaniem błędów oraz kilka strategii odnajdywania trudnych do uchwycenia błędów. Psychologia debugowania oprogramowania Debugowanie oprogramowania dla wielu programistów jest tematem dość drażliwym i budzącym niemałe emocje. Zamiast traktować tę czynność jak układanki, łamigłówki, wielu programistów wypiera się pomyłek, zrzuca winę na współpracowników, posługuje się żałosnymi wymówkami lub po prostu popada w apatię. Warto więc przyzwyczaić się do myśli, że diagnozowanie to tylko rozwiązywanie problemów i jako takie wymaga stosownego nastawienia. Po znalezieniu cudzego błędu możemy, oczywiście, tracić czas i energię na potępianie winowajcy. W niektórych obszarach taki model jest częścią kultury i bywa oczyszczający. W świecie nowoczesnych technologii wolelibyśmy jednak koncentrować się na eliminowaniu problemów, nie na szukaniu winnych. WSKAZÓWKA NR 29 Należy rozwiązywać problemy, nie szukać winowajcy. Nie ma większego znaczenia to, czy błąd wynika z naszego przeoczenia, czy jest spowodowany cudzym niedopatrzeniem. To wciąż nasz problem. Właściwa postawa Najłatwiej okłamywać samego siebie Edward Bulwer-Lytton, The Disowned Zanim przystąpimy do debugowania, warto zadbać o odpowiednie nastawienie. Musimy wyzbyć się wielu spośród reakcji obronnych, które w codziennej pracy pozwalają nam chronić ego. Powinniśmy lepiej radzić sobie z presją i stworzyć 4337ebf6db5c7cc89e4173803ef3875a 4 Debugowanie 119 sobie warunki zapewniające odpowiedni komfort psychiczny. Najważniejsze jest pamiętanie o pierwszej zasadzie debugowania. WSKAZÓWKA NR 30 Nie panikuj. Nietrudno o panikę, szczególnie jeśli stoimy w obliczu trudnych do dotrzymania terminów bądź jeśli w czasie poszukiwania przyczyny błędu stoi nad nami zdenerwowany szef lub klient. Warto mimo wszystko zatrzymać się na moment i dobrze przemyśleć, co może powodować symptomy, które naszym zdaniem prowadzą do błędu. Jeśli naszą pierwszą reakcją na spostrzeżenie błędu lub zapoznanie się z raportem o błędzie jest okrzyk „to niemożliwe!”, z pewnością musimy zmienić swoje podejście. Nie ma sensu marnować aktywności choćby jednego neuronu na rozważania rozpoczynające się od wniosku „to nie mogło się zdarzyć”, ponieważ błąd nie tylko mógł wystąpić, ale po prostu wystąpił. Podczas debugowania należy wystrzegać się krótkowzroczności. Powinniśmy radzić sobie z pokusą eliminowania samych symptomów. Bardzo często faktyczny problem tkwi wiele kroków od miejsca, które obserwujemy, i może dotyczyć wielu różnych, pokrewnych aspektów. Zawsze należy podejmować próby odkrywania pierwotnego źródła problemu, zamiast koncentrować się na miejscach, w których ten problem się ujawnił. Od czego zacząć Zanim przystąpimy do analizy błędu, musimy upewnić się, że pracujemy nad kodem, który został pomyślnie skompilowany, czyli bez żadnych ostrzeżeń. Sami zawsze ustawiamy możliwie najwyższe poziomy ostrzegania przez kompilator. Poszukiwanie problemu nie ma najmniejszego sensu, jeśli może to za nas zrobić kompilator! Powinniśmy raczej koncentrować uwagę na rozwiązywaniu trudniejszych problemów. Każdą próbę rozwiązania problemu należy zacząć od zebrania wszystkich istotnych danych. Okazuje się, niestety, że raportowanie o błędach nie jest nauką ścisłą. Nietrudno o pomyłkę wskutek przypadkowej zbieżności zdarzeń, a przecież nie możemy sobie pozwolić na poświęcanie czasu na badanie takich zbieżności. Musimy w pierwszej kolejności zadbać o dokładność obserwacji. Zachowanie dokładności w raportach o błędach jest utrudnione także wskutek zaangażowania osób trzecich — w niektórych przypadkach uzyskanie niezbędnej wiedzy wymagałoby obserwowania działań użytkownika, który zgłosił błąd. Andy pracował kiedyś nad wielką aplikacją graficzną. Niedługo przed datą wydania testerzy zgłosili, że aplikacja ulega awarii za każdym razem, gdy rysują coś określonym pędzlem. Programista odpowiedzialny za tę część aplikacji 4337ebf6db5c7cc89e4173803ef3875a 4 120 Rozdział 3. Podstawowe narzędzia stwierdził, że nie dostrzega usterki, ponieważ jego próby malowania tym pędzlem przebiegały zupełnie normalnie. Spór ciągnął się jeszcze wiele dni, powodując w zespole coraz większe emocje. Ostatecznie zdecydowaliśmy się zaprosić obie strony na spotkanie w jednym pomieszczeniu. Tester wybrał narzędzie pędzla i namalował linię ciągnącą się od lewego górnego do prawego dolnego narożnika. Aplikacja momentalnie wyświetliła komunikat o błędzie. „Cóż” — powiedział programista cichym głosem, by wreszcie, nie kryjąc zakłopotania, przyznać, że testował swoje narzędzie, wykonując tylko ruchy pędzla od lewego dolnego do prawego górnego narożnika (wówczas błąd rzeczywiście nie występował). Z przytoczonej historii można wysnuć dwa wnioski. Sytuacja może zmusić nas do przedyskutowania błędu z użytkownikiem, który go zgłosił, aby uzyskać więcej informacji, niż początkowo sam nam przekazał. Sztuczne testy (na przykład kilka pociągnięć pędzlem z dołu do góry wykonanych przez programistę) nie weryfikują dostatecznie wielu aspektów działania aplikacji. Musimy brutalnie testować zarówno warunki graniczne, jak i realistyczne wzorce działania użytkownika końcowego. Musimy robić to możliwie systematycznie (patrz podrozdział „Bezlitosne testy” w rozdziale 8.). Strategie debugowania Kiedy już sami stwierdzimy, że wiemy, co się dzieje, warto podjąć próbę określenia, co o sytuacji „myśli” nasz program. Reprodukcja błędów Nie, nasze błędy nie rozmnażają się (chociaż niektóre z nich są wystarczająco stare, aby mogły to robić w pełni legalnie). Mówimy o zupełnie innym rodzaju reprodukcji. Wprost wymarzonym początkiem procedury usuwania błędu jest stworzenie warunków do jego odtwarzania. Gdybyśmy nie mogli odtworzyć sytuacji, w której występuje, skąd wiedzielibyśmy, czy został naprawiony? Naszym celem nie jest jednak tylko reprodukcja błędu poprzez wykonywanie pewnej długiej sekwencji kroków — chcemy raczej stworzyć możliwość jego odtwarzania za pomocą jednego polecenia. Naprawienie błędu jest dużo trudniejsze, jeśli dojście do punktu, w którym ten błąd się ujawnia, wymaga wykonania 15 kroków. W pewnych sytuacjach praca nad samym wyizolowaniem okoliczności, w których ma miejsce dany błąd, bardzo zbliża programistę do usunięcia usterki. 4337ebf6db5c7cc89e4173803ef3875a 4 Debugowanie 121 Oto najważniejsza reguła debugowania: WSKAZÓWKA NR 31 Przed naprawieniem kodu napisz test negatywny. Czasami zmuszenie się do wyizolowania okoliczności, które prowadzą do błędu, może dać nam wskazówkę, w jaki sposób można go naprawić. Na rozwiązanie naprowadza nas proces pisania testu. Programista we wrogim świecie W całym tym gadaniu o izolowaniu błędu jest jakiś sens. Zastanówmy się jednak co robi biedny programista, gdy zetknie się z 50 000 wierszy kodu i tykającym zegarem? Po pierwsze należy przyjrzeć się problemowi. Czy to na pewno awaria? Zawsze podczas kursów programowania, które prowadzimy, zaskakuje nas to, jak wielu programistów widząc wyświetlający się na czerwono komunikat o wyjątku, natychmiast zaczyna szukać przyczyny w kodzie. WSKAZÓWKA NR 32 Czytaj komunikaty o błędach. Złe wyniki A jeśli to nie była awaria? A jeśli to tylko zły wynik? Dostań się do odpowiedniego fragmentu kodu za pomocą debugera i wykorzystaj przygotowany wcześniej negatywny test w celu wywołania problemu. Zanim zrobisz cokolwiek innego, upewnij się, że w debugerze również widzisz nieprawidłową wartość. Obu nam zdarzało się zmarnować wiele godzin na próbach wyśledzenia błędu, by ostatecznie odkryć, że badany fragment kodu działał prawidłowo. Czasami problem jest oczywisty: zmienna interest_rate ma wartość 4,5, a powinna mieć 0,045. Częściej, aby dowiedzieć się, dlaczego wartość jest nieprawidłowa, trzeba spojrzeć głębiej. Sprawdź, czy potrafisz poruszać się w górę i w dół stosu wywołań i analizować środowisko lokalnego stosu. Z naszych doświadczeń wynika, że warto mieć pod ręką ołówek i kartkę papieru, aby móc robić notatki. Często mamy jakiś trop i staramy się go zbadać dokładniej, by po jakimś czasie dojść do wniosku, że to jednak nie to. Jeśli nie zanotowaliśmy miejsca, w którym byliśmy na początku, możemy stracić dużo czasu, by dotrzeć tam ponownie. 4337ebf6db5c7cc89e4173803ef3875a 4 122 Rozdział 3. Podstawowe narzędzia Czasami przeglądamy ślad stosu, który wydaje się nieskończony. W takim przypadku zwykle istnieje szybszy sposób na znalezienie problemu od badania wszystkich ramek po kolei: można zastosować wyszukiwanie binarne. Ale zanim je omówimy, przyjrzyjmy się dwóm innym popularnym scenariuszom błędów. Wrażliwość na dane wejściowe Z pewnością zetknąłeś się z taką sytuacją. Twój program doskonale działa ze wszystkimi danymi testowymi i bezproblemowo przetrwał swój pierwszy tydzień w produkcji. Następnie, przy zetknięciu z konkretnym zestawem danych, nagle uległ awarii. Można spróbować przyjrzeć się miejscu w kodzie, gdzie wystąpiła awaria, i stamtąd przeprowadzić analizę. Czasami jednak łatwiej rozpocząć od danych. Stwórz kopię zestawu danych i przetwórz ją za pomocą lokalnej kopii aplikacji. Sprawdź, czy program nadal ulega awarii. Następnie podziel dane za pomocą algorytmu wyszukiwania binarnego do czasu wyizolowania tych danych wejściowych, które prowadzą do awarii. Regresja w wydaniach Pracujesz w dobrym zespole i właśnie opublikowaliście oprogramowanie do produkcji. W pewnym momencie pojawia się błąd w kodzie, który tydzień wcześniej pracował poprawnie. Czy nie byłoby miło, gdyby można było zidentyfikować konkretną zmianę, która doprowadziła do powstania tego błędu? Zgadnijcie, z czego można skorzystać? Czas na wyszukiwanie binarne. Wyszukiwanie binarne Każdy student informatyki miał okazję pisać kod implementujący algorytm podziału binarnego (czasami nazywany wyszukiwaniem binarnym). Koncepcja jest bardzo prosta. Szukamy konkretnej wartości w posortowanej tablicy. Moglibyśmy po prostu przeglądać każdą wartość po kolei. Takie postępowanie doprowadziłoby jednak do konieczności przeglądania średnio połowy elementów tablicy. Trzeba by było przeglądać je do czasu znalezienia wartości szukanej albo większej od niej, co by oznaczało, że wartości, której szukamy, nie ma w tablicy. Szybsze jednak będzie zastosowanie podejścia dziel i rządź. Wybierz wartość w środku tablicy. Jeśli jest to wartość, której szukasz, zakończ pracę. W przeciwnym razie możesz podzielić tablicę na dwie części. Jeśli wybrana wartość jest większa od wartości docelowej, to znaczy, że cel musi znajdować się w pierwszej połowie tablicy, w przeciwnym razie jest on w drugiej połowie. Powtórz tę procedurę w odpowiedniej połówce tablicy, a w krótkim czasie znajdziesz szukaną wartość (jak dowiesz się z opisu notacji Duże O w rozdziale 7., wyszukiwanie liniowe ma złożoność O(n), natomiast wyszukiwanie binarne O(logn)). 4337ebf6db5c7cc89e4173803ef3875a 4 Debugowanie 123 Zatem dla większości problemów w rozsądnych rozmiarach wyszukiwanie binarne jest wielokrotnie szybsze od liniowego. Zobaczmy jak koncepcję wyszukiwania binarnego można zastosować do debugowania. Kiedy masz do przeanalizowania obszerny ślad stosu (ang. stack trace) i starasz się ustalić, która funkcja doprowadziła do błędu, możesz dokonać podziału poprzez wybranie ramki stosu gdzieś pośrodku i sprawdzenie, czy nie ma tam błędu. Jeśli go tam nie było, to wiesz, że należy się skupić na ramkach wcześniejszych, w przeciwnym razie problem występuje w ramkach późniejszych. Dokonaj kolejnego podziału. Nawet jeśli ślad stosu zawiera 64 ramki, to zastosowanie podziału binarnego pozwala znaleźć odpowiedź po co najwyżej sześciu próbach. Jeśli znajdziesz błędy, które występują tylko dla niektórych zestawów danych, możesz zrobić to samo. Podziel zbiór danych na dwie części i sprawdź, czy problem nadal występuje, jeśli przetworzysz w aplikacji jedną lub drugą część zbioru. Kontynuuj dzielenie danych aż do uzyskania minimalnego zestawu wartości, które w dalszym ciągu powodują problemy. Jeśli Twój zespół wprowadził błąd w którymś spośród kilku poprzednich wydań, możesz użyć podobnej techniki. Stwórz test, który powoduje awarię w bieżącym wydaniu. Następnie wybierz wydanie w połowie drogi pomiędzy wersją bieżącą, a ostatnią znaną działającą wersją. Uruchom test ponownie i zobacz, czy udało Ci się zawęzić wyszukiwanie. Możliwość wykonywania takich podziałów to tylko jedna z wielu korzyści stosowania w projektach dobrego systemu kontroli wersji. Wiele spośród nich usprawnia ten proces poprzez automatyzację wyboru wydań w zależności od wyniku testu. Logowanie i śledzenie Debugery zwykle koncentrują się na bieżącym stanie programu. W pewnych przypadkach to nie wystarczy — musimy obserwować stan programu lub zmiany jakiejś struktury danych w dłuższym czasie. Analiza stosu wywołań pozwala określić tylko to, jak trafiliśmy w miejsce, w którym jesteśmy. Nie można na tej podstawie stwierdzić, co działo się przed utworzeniem danego łańcucha wywołań (szczególnie w systemach operujących na zdarzeniach)2. Wyrażenia śledzące to wszystkie te drobne komunikaty diagnostyczne wyświetlane na ekranie lub zapisywane w plikach, jak „jestem tutaj” czy „wartość x = 2”. To dość prymitywna technika w porównaniu z mechanizmami diagnozowania kodu znanymi ze środowisk IDE, ale okazuje się wyjątkowo efektywna podczas lokalizowania wielu klas błędów, których nie wykrywają debugery. Śledzenie błędów jest bezcenne w każdym systemie, w którym czas sam w sobie jest istotnym czynnikiem — w przypadku procesów współbieżnych, systemów czasu rzeczywistego czy aplikacji działających w oparciu o zdarzenia. 2 Chociaż istnieje debuger przeznaczony dla języka Elm, pozwalający na debugowanie wcześniejszych stanów. 4337ebf6db5c7cc89e4173803ef3875a 4 124 Rozdział 3. Podstawowe narzędzia Za pomocą wyrażeń śledzących możemy coraz głębiej analizować nasz kod. Oznacza to, że możemy dodawać wyrażenia śledzące na przykład w trakcie schodzenia coraz niżej drzewa wywołań. Komunikaty ze śledzenia powinny cechować się standardowym, spójnym formatem — być może w przyszłości będziemy chcieli poddawać je automatycznej analizie składniowej. Jeśli na przykład musimy wyśledzić wyciek zasobów (spowodowany na przykład brakiem niezbędnych wyrażeń close dla wszystkich wyrażeń open), możemy zapisywać każdą operację otwierającą i zamykającą w pliku dziennika. Przetwarzając ten plik dziennika za pomocą Perla, możemy łatwo zidentyfikować problematyczną operację open, dla której nie istnieje odpowiednia operacja close. Przemawianie do gumowej kaczki Jedną z najprostszych, a jednocześnie wyjątkowo przydatną techniką odnajdywania przyczyny problemu jest zwykłe wyjaśnianie badanego aspektu komuś innemu. Druga osoba powinna stale patrzeć na monitor przez ramię programisty i potakiwać głową (tak jak gumowa kaczka kołysze się na wodzie w wannie). Nie musi wypowiadać choćby jednego słowa. Chodzi raczej o wyjaśnianie jej krok po kroku, co analizowany kod powinien robić — okazuje się, że w ten sposób zadziwiająco często można odkrywać źródła problemów3. Idea wydaje się dziecinnie prosta, ale praktyka pokazuje, że wyjaśnianie problemu komuś innemu zmusza nas do omawiania wprost, na głos kolejnych założeń zapisanych w kodzie, które dotąd wydawały nam się oczywiste. Konieczność takiego przedstawiania swoich przemyśleń nierzadko prowadzi do zupełnie nowych wniosków na temat analizowanego problemu. A jeśli nie masz z kim porozmawiać, możesz powiedzieć to do gumowej kaczki, pluszowego misia lub kwiatka w doniczce. Proces eliminacji W większości projektów diagnozowany kod może być mieszaniną kodu aplikacji napisanego przez nas samych i przez innych członków zespołu projektowego, produktów zewnętrznych (baz danych, interfejsów, bibliotek graficznych, wyspecjalizowanych mechanizmów komunikacji lub algorytmów itp.) oraz środowiska właściwego danej platformie (systemu operacyjnego, bibliotek systemowych i kompilatorów). 3 Skąd wzięła się ta analogia do gumowej kaczki? Jeszcze jako student londyńskiego Imperial College Dave bardzo dużo czasu spędzał w towarzystwie asystenta Grega Pugha, jednego z najlepszych programistów, jakich kiedykolwiek spotkał. Przez wiele miesięcy Greg nosił przy sobie niewielką, żółtą gumową kaczkę, którą zawsze kładł na terminalu na czas kodowania. Minęło sporo czasu, zanim Dave zdecydował się spytać o tę kaczkę… 4337ebf6db5c7cc89e4173803ef3875a 4 Debugowanie 125 Nie można wykluczyć sytuacji, w której błąd tkwi w systemie operacyjnym, kompilatorze lub produkcie zewnętrznym. Nie powinniśmy jednak zaczynać od tego rodzaju założeń. Znacznie bardziej prawdopodobne jest występowanie błędu w kodzie tworzonej aplikacji. Ogólnie, korzystniejszym rozwiązaniem jest przyjęcie założenia, zgodnie z którym to kod aplikacji nieprawidłowo wywołuje jakąś bibliotekę, zamiast przyjmować, że sama biblioteka działa nieprawidłowo. Nawet jeśli problem rzeczywiście tkwi w rozwiązaniach zewnętrznych, przed wysłaniem raportu o błędzie i tak musimy wyeliminować własny kod z kręgu podejrzeń. Pracowaliśmy kiedyś nad projektem, w którym starszy inżynier był przekonany o błędnym działaniu wywołania systemowego select w systemie Unix. Żadne techniki perswazji ani logicznego tłumaczenia nie mogły zmienić tego przekonania (to, że we wszystkich innych aplikacjach sieciowych wspomniane wywołanie działało bez zarzutu, nie miało żadnego znaczenia). Całymi tygodniami pisał kod, który miał obejść „wadliwe” wywołanie, ale z jakiegoś powodu nie udawało mu się wyeliminować problemu. Kiedy wreszcie został zmuszony do zapoznania się z dokumentacją wywołania select, szybko odkrył problem i w parę minut usunął usterkę. Od tej pory używamy zdania „select nie działa” do delikatnego zwracania uwagi na każdą próbę zrzucania odpowiedzialności za błąd na system w sytuacji, gdy bardziej prawdopodobna jest pomyłka we własnym kodzie. WSKAZÓWKA NR 33 Wywołanie select działa. Musimy pamiętać, że widząc ślady kopyt, powinniśmy w pierwszej chwili myśleć o koniach, nie o zebrach. System operacyjny prawdopodobnie działa prawidłowo. Także baza danych najprawdopodobniej działa bez zarzutu. Jeśli „zmieniono tylko jedną rzecz” i jeśli system przestał działać, właśnie ta zmiana najprawdopodobniej odpowiada (pośrednio lub bezpośrednio) za problemy niezależnie od tego, jak niewinnie wygląda. W pewnych przypadkach nie mamy wpływu na wprowadzoną zmianę — mogły zostać wydane nowe wersje systemu operacyjnego, kompilatora, bazy danych lub innego oprogramowania zewnętrznego, powodując prawdziwe spustoszenie w kodzie, który wcześniej działał w pełni prawidłowo. Nowe wersje mogą zawierać nowe błędy. Być może usunięto błędy, które udało nam się obejść — w takich przypadkach istnieje ryzyko nieprawidłowego działania naszego obejścia. Zmieniają się interfejsy API i zmieniają się dostępne funkcje. Krótko mówiąc, zasady gry stale są modyfikowane, a naszym zadaniem jest ponowne przetestowanie systemu w nowych warunkach. Warto więc mieć na uwadze harmonogram realizacji projektu przed podjęciem decyzji o aktualizacji do nowej wersji — być może powinniśmy wstrzymać się na przykład do następnego wydania. 4337ebf6db5c7cc89e4173803ef3875a 4 126 Rozdział 3. Podstawowe narzędzia Element zaskoczenia Kiedy z zaskoczeniem odkrywamy jakiś błąd (być może nawet mamrocząc po cichu „to niemożliwe”), musimy ponownie przeanalizować założenia, które przyjmowaliśmy za pewnik. Czy w przypadku procedury przetwarzającej listę jednokierunkową (tę, którą uważaliśmy za w pełni bezpieczną i która w założeniu nie mogła powodować błędów) sprawdziliśmy wszystkie warunki graniczne? A może należałoby sprawdzić fragment kodu, którego używamy od lat — przecież nie mógł zawierać błędu, prawda? Czy na pewno? Oczywiście, że mógł. Poziom zaskoczenia odczuwanego po odkryciu błędu jest wprost proporcjonalny do poziomu zaufania i wiary w niezawodność problematycznego kodu. Właśnie dlatego po napotkaniu „zaskakującego” błędu musimy zdać sobie sprawę z konieczności weryfikacji co najmniej jednego założenia. Nie wolno nam pomijać w testach żadnej procedury czy fragmentu kodu tylko dlatego, że „jesteśmy pewni” jego prawidłowego działania. Powinniśmy raczej dowieść prawidłowości kodu. Musimy udowodnić jego poprawność w określonym kontekście, z bieżącymi danymi i w określonych warunkach granicznych. WSKAZÓWKA NR 34 Nie należy niczego zakładać — należy to udowodnić. Kiedy natrafiamy na zaskakujący błąd, oprócz jego zwykłego usunięcia musimy podjąć próbę określenia, dlaczego usterka nie została wykryta wcześniej. Należy zastanowić się, czy nie należałoby poprawić testów jednostkowych lub innych testów, tak aby w przyszłości wykrywały podobne błędy. Jeśli błąd wynika z nieprawidłowych danych, które były propagowane przez wiele poziomów, zanim doprowadziły do ostatecznej eksplozji, warto sprawdzić, czy lepszy mechanizm weryfikacji parametrów w odpowiednich procedurach nie pozwoliłby zidentyfikować tego błędu wcześniej (patrz materiał poświęcony wczesnym awariom i asercjom w rozdziale 4.). Warto przy tej okazji sprawdzić, czy na ten sam błąd nie są narażone inne miejsca kodu. To najlepszy moment, aby znaleźć i naprawić te miejsca. Cokolwiek się stało, musimy mieć pewność, że to się nie powtórzy. Jeśli naprawienie danego błędu zajęło nam dużo czasu, warto zadać sobie pytanie dlaczego. Czy można w jakiś sposób ułatwić naprawianie tego błędu w razie jego wystąpienia w przyszłości? Być może istnieje możliwość skonstruowania lepszych punktów zaczepienia dla naszych testów lub opracowania analizatora pliku logu. I wreszcie, jeśli błąd wynika z błędnych założeń innego programisty, warto omówić ten problem z całym zespołem; skoro jedna osoba coś źle zrozumiała, niewykluczone, że błędne założenie podzielają też inni. 4337ebf6db5c7cc89e4173803ef3875a 4 Operowanie na tekście 127 Wystarczy wykonać wszystkie te kroki, aby uniknąć przykrych niespodzianek w przyszłości. Lista kontrolna debugowania Czy zgłoszony problem ma postać bezpośredniego wyniku jakiegoś błędu, czy tylko symptomu? Czy błąd rzeczywiście występuje w kompilatorze? Czy błąd występuje w systemie operacyjnym? A może problem tkwi w naszym kodzie? Gdybyśmy mieli szczegółowo wyjaśnić ten problem współpracownikowi, co byśmy powiedzieli? Jeśli podejrzany kod przechodzi swoje testy jednostkowe, czy te testy są dostatecznie kompletne? Co dzieje się, kiedy te same testy jednostkowe są wykonywane dla tych samych danych? Czy warunki, które spowodowały ten błąd, występują w jakimś innym miejscu systemu? Pokrewne podrozdziały Temat 24., „Martwe programy nie kłamią”. Wyzwania 21 41 Debugowanie jest dostatecznie trudnym wyzwaniem. Operowanie na tekście Pragmatyczni programiści pracują z tekstem tak samo, jak stolarze kształtują drewno. W dotychczasowych podrozdziałach omawialiśmy konkretne narzędzia używane przez programistów: powłoki, edytory i debugery. Wspomniane narzędzia mają swoje odpowiedniki w warsztacie stolarskim: dłuta, piły i heble, czyli narzędzia stworzone z myślą o jednej czy dwóch czynnościach. Jednak co jakiś czas musimy podejmować działania, których nie da się łatwo realizować za pomocą podstawowego zestawu narzędzi. Potrzebujemy więc uniwersalnego narzędzia do pracy z tekstem. Języki do przetwarzania tekstu są dla programowania tym, czym frezarki są w pracy stolarza. Są głośne, skomplikowane i stosują brutalną siłę. Wystarczy popełnić drobny błąd, a cały materiał (tekst lub blok drewna) będzie można wyrzucić do kosza. Niektórzy zarzekają się, że nie mają już miejsca w swoim zestawie narzędzi. W dobrych rękach zarówno frezarki, jak i języki do przetwarzania tekstu mogą mieć wprost niewiarygodny potencjał i oferować niespotykaną wszechstronność. Możemy błyskawicznie nadawać drewnu kształt, 4337ebf6db5c7cc89e4173803ef3875a 4 128 Rozdział 3. Podstawowe narzędzia tworzyć zaprojektowane profile i wycinać dowolne figury. Prawidłowo stosowane narzędzia oferują zaskakującą finezję i subtelność. Dobre opanowanie tych narzędzi wymaga jednak sporo czasu. Liczba dobrych języków do przetwarzania tekstu stale rośnie. Programiści aplikacji dla systemu Unix często korzystają z ogromnego potencjału powłok poleceń, które dodatkowo można rozszerzać o takie narzędzia jak awk czy sed. Programiści, którzy preferują narzędzia o lepiej zdefiniowanej strukturze, częściej wybierają takie języki, jak Python i Ruby. Wymienione języki są ważnymi ogniwami otwierającymi drogę do innych technologii. Za pomocą tych języków możemy błyskawicznie tworzyć niezbędne narzędzia pomocnicze i zrealizować w praktyce koncepcję prototypów — w konwencjonalnych językach te same zadania zajęłyby pięć lub dziesięć razy więcej czasu. Właśnie ten mnożnik ma zasadnicze znaczenie dla prowadzonych przez nas eksperymentów. Poświęcenie 30 minut na sprawdzenie jakiegoś szalonego pomysłu brzmi dużo lepiej niż poświęcenie na tę samą czynność na przykład pięciu godzin. Poświęcenie jednego dnia na automatyzację ważnych komponentów projektu można zaakceptować, ale już poświęcenie temu zadaniu tygodnia byłoby trudne do usprawiedliwienia. W książce Inżynieria oprogramowania [KP99] Kernighan i Pike napisali ten sam program w pięciu różnych językach. Wersja opracowana w Perlu była najkrótsza (wymagała tylko 17 wierszy, podczas gdy wersja napisana w C zajęła aż 150 wierszy). W skryptach Perla możemy przetwarzać tekst, implementować interakcję z innymi programami, komunikować się za pośrednictwem sieci, generować strony internetowe, wykonywać obliczenia arytmetyczne dowolnej precyzji i pisać programy przypominające popularnego Snoopy’ego. WSKAZÓWKA NR 35 Warto opanować jeden język przetwarzania tekstu. Aby zademonstrować szerokie możliwości stosowania języków operujących na tekście, poniżej opisano przykłady kilku aplikacji napisanych w Ruby i w Pythonie w związku z pisaniem niniejszej książki. Pisanie książki System budowania książek z cyklu „Biblioteczka praktyka” jest napisany w Ruby. Autorzy, redaktorzy, specjaliści od składu oraz personel pomocniczy używali zadań Rake do koordynowania budowania dokumentów PDF i ebooków. Przykłady kodu i wyróżnianie składni Uważamy, że jest bardzo ważne, aby każdy kod zaprezentowany w książce został wcześniej przetestowany. Większość znajdującego się w niej kodu została przetestowana. Jednak zgodnie z zasadą DRY (patrz temat 9., „DRY — Przekleństwo powielania”) nie chcieliśmy kopiować i wklejać 4337ebf6db5c7cc89e4173803ef3875a 4 Operowanie na tekście 129 wierszy kodu z testowanych programów do tekstu książki. Oznaczałoby to powielanie kodu, co w praktyce mogłoby doprowadzić do tego, że zapomnielibyśmy zaktualizować przykład po zmodyfikowaniu odpowiedniego programu. W przypadku niektórych przykładów nie chcieliśmy także zanudzać czytelników kodem frameworka potrzebnym do skompilowania i uruchomienia naszego przykładu. Skorzystaliśmy z języka Ruby. Podczas formatowania książki wywołujemy stosunkowo prosty skrypt. Wyodrębnia on identyfikowany przez nazwę segment pliku źródłowego, wykonuje podświetlanie składni i konwertuje wynik na język, którego używamy do składu tekstu. Aktualizacja strony WWW Mamy prosty skrypt, który wykonuje częściowe budowanie książki, wyodrębnia spis treści, a następnie przesyła go na stronę książki w naszej witrynie internetowej. Mamy również skrypt, który wydziela fragmenty książki i przesyła je jako przykłady. Wzory matematyczne Dysponujemy prostym skryptem Pythona, który konwertuje znaczniki wzorów matematycznych w formacie LaTeX na estetycznie sformatowany tekst. Generowanie zestawień Większość zestawień tworzymy w postaci odrębnych dokumentów (co sprawia, że ich utrzymanie w przypadku zmian w dokumencie jest trudne). Nasze zestawienia są oznaczone w samym tekście, a skrypt Ruby zbiera i formatuje wpisy. I tak dalej. Książki z cyklu „Biblioteczka praktyka” są tworzone za pomocą narzędzi do przetwarzania tekstu. Jeśli skorzystasz z naszej porady, aby używać do wszystkiego formatu zwykłego tekstu, będziesz mógł skorzystać z języków przetwarzania tekstu, co przynosi cały szereg korzyści. Pokrewne podrozdziały Temat 16., „Potęga zwykłego tekstu”. Temat 17., „Powłoki”. Ćwiczenia 11. Przepisujesz aplikację, która w roli języka konfiguracji używała formatu YAML. Twoja firma standardowo korzysta z formatu JSON, więc masz zbiór plików .yaml, które muszą zostać przekształcone na format .json. Napisz skrypt, który pobiera katalog i konwertuje wszystkie pliki .yaml z tego katalogu na odpowiedni plik .json (tak, aby plik database.yaml stał się plikiem database.json w poprawnym formacie JSON). 4337ebf6db5c7cc89e4173803ef3875a 4 130 Rozdział 3. Podstawowe narzędzia 12. Twój zespół początkowo zdecydował się używać nazw zmiennych w formacie camelCase, ale potem zmieniono zdanie i zdecydowano, że będzie stosowany format snake_case. Napisz skrypt, który skanuje wszystkie pliki źródłowe, wyszukuje w nich nazwy w formacie camelCase i je wyświetla. 13. Bazując na poprzednim ćwiczeniu, dodaj możliwość automatycznej zamiany tych nazw zmiennych w co najmniej jednym pliku. Pamiętaj, aby zachować kopię zapasową oryginałów w przypadku, gdyby coś poszło źle. 22 42 Dzienniki inżynierskie Dave pracował kiedyś dla małego producenta komputerów, co oznaczało współpracę z inżynierami elektronikami i czasami inżynierami mechanikami. Wielu z nich chodziło z papierowym notatnikiem, zazwyczaj z wpiętym długopisem. Co jakiś czas podczas rozmów ci inżynierowie sięgali po notatnik i coś zapisywali. W końcu Dave zadał oczywiste pytanie. Okazało się, że przeszkolono ich, aby prowadzili inżynierskie dzienniki, rodzaj pamiętnika, w którym rejestrowali to, co zrobili, czego się dowiedzieli; wpisywali w nim szkice pomysłów, odczyty urządzeń pomiarowych: w zasadzie wszystko, co miało związek z ich pracą. Po zapełnieniu notatnika zapisywali zakres dat na grzbiecie, a następnie odkładali na półkę obok poprzednich dzienników. Między inżynierami występowała łagodna rywalizacja dotycząca tego, czyje dzienniki zajmą najwięcej miejsca na półce. Używamy dzienników do różnych rzeczy: do sporządzania notatek na spotkaniach, aby zanotować, nad czym pracujemy, w celu zapisania wartości zmiennych podczas debugowania, aby zidentyfikować miejsca, w których skończyliśmy pracę, aby zarejestrować dzikie pomysły, a czasem po prostu, żeby sobie pobazgrać4. Prowadzenie takiego dziennika przynosi trzy główne korzyści: 4 Dziennik jest bardziej niezawodny niż pamięć. Ktoś może zadać pytanie: „Jaka jest nazwa firmy, z której w zeszłym tygodniu dzwoniono w sprawie problemów z zasilaczem?”. Wtedy możesz otworzyć notatnik i znaleźć w nim nazwę i numer telefonu. Notatnik jest miejscem do przechowywania pomysłów, które nie są bezpośrednio istotne dla zadania, które wykonujesz. W ten sposób możesz nadal być skoncentrowany na tym, co robisz, wiedząc, że świetne pomysły nie wylecą Ci z głowy. Istnieją pewne dowody na to, że bazgranie pomaga się skupić i poprawia zdolności poznawcze, przykłady można znaleźć w książce What does doodling do? [And10]. 4337ebf6db5c7cc89e4173803ef3875a 4 Dzienniki inżynierskie 131 Dziennik spełnia rolę gumowej kaczki (opisanej wcześniej w rozdziale). Kiedy zatrzymasz się, żeby coś zapisać, Twój mózg może „przełączyć bieg”, niemal tak, jakbyś z kimś rozmawiał — to olbrzymia szansa na refleksję. Możesz zacząć sporządzać notatkę, a potem nagle zdać sobie sprawę, że to, co właśnie zrobiłeś — temat Twojej notatki — było po prostu błędem. Jest też dodatkowa korzyść. Co jakiś czas możesz spojrzeć wstecz na to, co robiłeś wiele lat wcześniej, i pomyśleć o ludziach, projektach lub o okropnych ubraniach i fryzurach. Warto zatem prowadzić tego rodzaju inżynierski dziennik. Używaj notatnika papierowego — nie korzystaj z pliku lub wiki: w akcie fizycznego pisania jest coś szczególnego, nieporównywalnego z „wklepywaniem” do komputera. Poświęć na prowadzenie notatnika miesiąc i przekonaj się, czy masz z tego jakieś korzyści. Jeśli nie uzyskasz niczego więcej, to będziesz mógł wykorzystać umiejętność notowania do spisywania wspomnień, gdy będziesz bogaty i sławny. Pokrewne podrozdziały Temat 6., „Portfolio wiedzy”. Temat 37., „Słuchaj swojego jaszczurczego mózgu”. 4337ebf6db5c7cc89e4173803ef3875a 4 132 Rozdział 3. Podstawowe narzędzia 4337ebf6db5c7cc89e4173803ef3875a 4 Rozdział 4. Pragmatyczna paranoja WSKAZÓWKA NR 36 Pisanie doskonałego oprogramowania jest niemożliwe. Czy to bolało? Nie powinno. Należy się z tym pogodzić jako z jednym z aksjomatów. Należy to wykorzystać. Należy to celebrować. Skoro doskonałe oprogramowanie nie istnieje, nikt w krótkiej historii przetwarzania komputerowego nie mógł napisać doskonałego fragmentu oprogramowania. To mało prawdopodobne, abyśmy właśnie my jako pierwsi to osiągnęli. Jeśli nie pogodzimy się z tym faktem, będziemy tracili cenny czas i energię na próby realizacji nierealnego marzenia. Jak w takim razie pragmatyczny programista może zmienić tę dość przygnębiającą rzeczywistość w zaletę? Właśnie tym zagadnieniem zajmiemy się w tym rozdziale. Każdy uważa, że jest jedynym naprawdę dobrym kierowcą na Ziemi. Reszta świata zostaje daleko w tyle — nie zatrzymuje się na znaku STOP, nie potrafi trzymać się swojego pasa, nie używa kierunkowskazów, rozmawia przez telefon, czyta gazety i ogólnie nie trzyma naszych standardów. W tej sytuacji musimy prowadzić bardzo ostrożnie. Staramy się przewidywać problemy, zanim nastąpią. Spodziewamy się niespodziewanego i konsekwentnie unikamy pułapek, z których nie moglibyśmy się wyplątać. Analogia z kodowaniem jest dość oczywista. Stale mamy kontakt z kodem autorstwa innych programistów — kodem, który nie zawsze odpowiada naszym wysokim standardom — i musimy radzić sobie z danymi wejściowymi, które mogą, ale nie muszą być w pełni poprawne. Sytuacja zmusza nas więc do ostrożnego kodowania. W razie jakichkolwiek wątpliwości weryfikujemy wszystkie otrzymywane informacje. Do wykrywania błędnych danych używamy asercji. 4337ebf6db5c7cc89e4173803ef3875a 4 134 Rozdział 4. Pragmatyczna paranoja Sprawdzamy spójność, definiujemy ograniczenia dla kolumn bazy danych i cieszymy się dobrym samopoczuciem w przekonaniu o spełnionym obowiązku. Pragmatyczni programiści muszą jednak iść krok dalej. Nie ufają nawet samym sobie. Skoro nikt nie pisze doskonałego kodu, także oni, pragmatyczni programiści, kodują swoje rozwiązania, zabezpieczając się również przed własnymi pomyłkami. Pierwsze techniki obronne omówimy w podrozdziale „Projektowanie kontraktowe” — klienci i dostawcy muszą uzgodnić i precyzyjnie opisać swoje prawa i obowiązki. W podrozdziale „Martwe programy nie kłamią” zajmiemy się sposobami unikania uszkodzeń przy okazji usuwania błędów. Próbujemy możliwie często sprawdzać poprawiany system i przerywać wykonywanie programu natychmiast po wykryciu działania niezgodnego z oczekiwaniami. W podrozdziale „Programowanie asertywne” omówimy prostą metodę sprawdzania poprawności niejako przy okazji właściwego programowania — technikę pisania kodu, który aktywnie sprawdza nasze założenia. Kiedy nasze programy zyskują na dynamice, szybko odkrywamy, że coraz trudniej zapanować nad zasobami systemowymi — pamięcią, plikami, urządzeniami itp. W podrozdziale „Jak zrównoważyć zasoby” zasugerujemy sposoby eliminowania ryzyka zaniedbania któregoś z tych aspektów. A co najważniejsze, zgodnie z tym, co opisano w podrozdziale „Nie prześcigaj swoich świateł”, zawsze należy poruszać się małymi krokami, tak aby nie spaść z krawędzi urwiska. W świecie niedoskonałych systemów, absurdalnych harmonogramów, śmiesznych narzędzi i niewykonalnych wymagań warto przynajmniej zadbać o bezpieczeństwo. Jak powiedział Woody Allen: Kiedy wszyscy na ciebie dybią, paranoja jest czymś zupełnie naturalnym. 23 36 Projektowanie kontraktowe Nic nie szokuje ludzi równie mocno jak zdrowy rozsądek i jasne reguły. Ralph Waldo Emerson, Eseje Praca z systemami komputerowymi jest trudna. Praca z ludźmi jest jeszcze trudniejsza. Nasza natura sprawia, że rozumienie zależności i relacji pomiędzy ludźmi zajmuje nam najwięcej czasu. Pewne wyzwania, z którymi ludzkość mierzy się od tysięcy lat, dotyczą także pisania oprogramowania. Jednym z najlepszych rozwiązań zapewniających jasne reguły jest kontrakt. Kontrakt definiuje nasze prawa i obowiązki oraz prawa i obowiązki drugiej strony. Dodatkowo obejmuje uzgodnienia dotyczące skutków ewentualnego niedotrzymania zapisów zawartych w umowie. 4337ebf6db5c7cc89e4173803ef3875a 4 Projektowanie kontraktowe 135 Kontrakt może dotyczyć na przykład zatrudnienia i określać godziny pracy oraz reguły obowiązujące na danym stanowisku. Za wypełnianie takiego kontraktu pracodawca wpłaca pracownikowi wynagrodzenie i premie. Obie strony wypełniają swoje obowiązki i wszyscy na tym zyskują. Idea kontraktów (zarówno formalnych, jak i nieformalnych) jest z powodzeniem stosowana na całym świecie i ułatwia relacje międzyludzkie. Czy możemy wykorzystać tę koncepcję także do ułatwienia implementowania interakcji pomiędzy modułami oprogramowania? Odpowiedź jest prosta: tak. Projektowanie kontraktowe (DBC) Bertrand Meyer (Object-Oriented Software Construction [Mey97]) stworzył koncepcję projektowania kontraktowego (ang. design by contract — DBC) dla języka programowania Eiffel1. To stosunkowo prosta, ale też niezwykle skuteczna technika koncentrująca się na dokumentowaniu (i uzgadnianiu) praw i obowiązków modułów oprogramowania, tak aby gwarantowały prawidłowe działanie całego programu. Czym właściwie jest prawidłowy program? To taki, który nie robi ani więcej, ani mniej, niż oczekują jego odbiorcy. Dokumentowanie i weryfikowanie tych oczekiwań jest sercem projektowania kontraktowego (DBC). Każda funkcja i metoda systemu oprogramowania odpowiada za konkretne działanie. Zanim przystąpi do tego działania, procedura lub metoda może wyrażać pewne oczekiwania dotyczące stanu świata. Procedura lub metoda może też przyjmować jakieś założenia odnośnie stanu świata w momencie końca swojej pracy. Meyer opisał te oczekiwania i wnioski w następujący sposób: 1 Warunki wstępne. Te warunki muszą być spełnione, aby procedura w ogóle została wywołana. Są to wymagania tej procedury. Procedura nigdy nie powinna być wywołana w sytuacji, gdy jej warunki wstępne są naruszone. Za przekazywanie właściwych danych odpowiada kod wywołujący (patrz ramka „Kto jest odpowiedzialny” w dalszej części tego podrozdziału). Warunki końcowe. Te warunki określają, do czego dana procedura ma doprowadzić — opisują stan świata po zakończeniu jej działania. Istnienie warunku końcowego procedury oznacza, że nie są dopuszczalne na przykład nieskończone pętle. Niezmienniki klasy. Klasa gwarantuje, że ten warunek jest zawsze spełniony z perspektywy kodu wywołującego. W czasie wewnętrznego przetwarzania kodu procedury niezmiennik może nie być zachowany, ale już w momencie kończenia przetwarzania i zwracania sterowania do kodu wywołującego inwariant musi być spełniony. (Należy pamiętać, że klasa nie może udostępniać nieograniczonego dostępu do zapisu do żadnej składowej danych wchodzącej w skład niezmiennika). Język powstał po części na podstawie wcześniejszych rozwiązań między innymi autorstwa Dijkstry, Floyda, Hoare’a i Wirtha. 4337ebf6db5c7cc89e4173803ef3875a 4 136 Rozdział 4. Pragmatyczna paranoja Kontrakt pomiędzy procedurą a dowolnym potencjalnym kodem wywołującym można zatem sformułować w następujący sposób: Jeżeli kod wywołujący spełni wszystkie warunki wstępne, procedura, po jej zakończeniu powinna zagwarantować spełnienie wszystkich warunków końcowych i niezmienników. Jeżeli którakolwiek ze stron nie wywiąże się z warunków umowy, zostanie wywołany środek zaradczy (uzgodniony wcześniej) — może to być zgłoszenie wyjątku albo zakończenie programu. Niezależnie od tego, co się stanie, nie należy mylić niespełnienia kontraktu z błędem. Taki scenariusz nigdy nie powinien się wydarzyć, dlatego nie należy wykorzystywać warunków wstępnych do realizacji takich zadań, jak sprawdzanie poprawności danych wprowadzanych przez użytkownika. Niektóre języki mają lepsze wsparcie dla tych pojęć niż inne. Na przykład Clojure obsługuje warunki wstępne i końcowe, a także bardziej kompleksowe oprzyrządowanie dostarczone przez specyfikacje. Oto przykład funkcji aplikacji bankowej do dokonywania wpłaty z wykorzystaniem prostych warunków wstępnych i końcowych: (defn accept-deposit [account-id amount] { :pre [ (> amount 0.00) (account-open? account-id) ] : post [ (contains? (account-transactions account-id) %) ] } "Przyjęcie depozytu i zwrócenie identyfikatora transakcji" ;; Tutaj znajdą się inne obliczenia... ;; Zwrócenie nowo utworzonej transakcji: (create-transaction account-id :deposit amount)) Dla funkcji accept-deposit istnieją dwa warunki wstępne. Pierwszy określa, że kwota jest większa od zera, a drugi to, że rachunek jest otwarty i aktywny, o czym decyduje wynik funkcji o nazwie account-open?. Jest również warunek końcowy: funkcja gwarantuje, że wśród transakcji dla tego rachunku jest nowa transakcja (wartość zwracana z tej funkcji, reprezentowana tutaj przez symbol %). Jeśli wywołamy funkcję accept-deposit z dodatnią wartością kwoty depozytu i dla aktywnego rachunku, funkcja przejdzie do utworzenia transakcji odpowiedniego typu i wykonania innych obliczeń. Jednak jeśli wystąpi błąd w programie i w jakiś sposób przekażemy ujemną kwotę depozytu, zostanie zgłoszony wyjątek fazy wykonywania programu: Exception in thread "main"... Caused by: java.lang.AssertionError: Assert failed: (> amount 0.0) Funkcja ta także wymaga, aby podany rachunek był otwarty i aktywny. Jeśli nie spełnia tych warunków, zostanie zgłoszony wyjątek: Exception in thread "main"... Caused by: java.lang.AssertionError: Assert failed: (account-open? account-id) 4337ebf6db5c7cc89e4173803ef3875a 4 Projektowanie kontraktowe 137 W innych językach istnieją mechanizmy, które pomimo że nie są specyficzne dla programowania kontraktowego, również są skuteczne. Na przykład w języku Elixir do rozsyłania wywołań funkcji do kilku dostępnych bloków kodu są wykorzystywane klauzule strażników (ang. guard clauses): defmodule Deposits do def accept_deposit(account_id, amount) when (amount > 100000) do # Zawołaj menedżera! end def accept_deposit(account_id, amount) when (amount > 10000) do # Specjalne wymagania dotyczące raportowania # Obliczenia... end def accept_deposit(account_id, amount) when (amount > 0) do # Obliczenia... end end Wywołanie funkcji accept_deposit w przypadku odpowiednio wysokiej kwoty może wymagać dodatkowych kroków i osobnego przetwarzania. Jeśli jednak spróbujesz wywołać tę funkcję z kwotą mniejszą lub równą zeru, zostanie zgłoszony wyjątek informujący, że to niedozwolone: ** (FunctionClauseError) no function clause matching in Deposits.accept_deposit/2 To lepsze rozwiązanie niż sprawdzanie wprowadzonych danych. W tym przypadku, jeśli argumenty są poza zakresem, po prostu nie można wywołać funkcji. WSKAZÓWKA NR 37 Należy projektować z uwzględnieniem kontraktów. W podrozdziale „Ortogonalność” w rozdziale 2. zasugerowaliśmy pisanie skromnego kodu. Tym razem kładziemy nacisk raczej na leniwy kod — należy precyzyjnie określić, co będzie akceptowane na początku, i obiecać możliwie niewiele na wyjściu. Musimy pamiętać, że jeśli z kontraktu wynika, że akceptujemy dosłownie wszystko i obiecujemy w zamian wszystkie skarby świata, będziemy musieli napisać mnóstwo kodu! W dowolnym języku programowania, niezależnie od tego, czy jest to język funkcyjny, obiektowy, czy proceduralny, stosowanie DBC zmusza do myślenia. Niezmienniki klas i języki funkcyjne To kwestia nazewnictwa. Eiffel to obiektowy język programowania, więc Meyer użył nazwy „niezmiennik klas”. Jednak pojęcie niezmienników jest bardziej ogólne. W gruncie rzeczy chodzi o niezmienność stanu. W językach obiektowych stan jest powiązany z egzemplarzami klas. Ale w innych językach pojęcie stanu również występuje. 4337ebf6db5c7cc89e4173803ef3875a 4 138 Rozdział 4. Pragmatyczna paranoja W języku funkcyjnym zazwyczaj przekazujemy stan do funkcji, a w wyniku otrzymujemy stan zaktualizowany. W takich okolicznościach pojęcie niezmienników jest równie przydatne. Projektowanie kontraktowe a technika TDD Czy technika programowania kontraktowego jest potrzebna w świecie, w którym programiści stosują testy jednostkowe, mechanizmy wytwarzania oprogramowania sterowanego testami (TDD), testowanie oparte na właściwościach lub programowanie defensywne? Krótka odpowiedź brzmi „tak”. DBC i testowanie to różne podejścia do szerszego tematu poprawności programu. Oba podejścia mają swoją wartość i oba mają zastosowanie w różnych sytuacjach. DBC ma kilka zalet w stosunku do określonych metod testowania: DBC nie wymaga żadnej konfiguracji ani tworzenia obiektów-makiet (ang. mocks). DBC definiuje parametry dla sukcesu lub niepowodzenia we wszystkich przypadkach, podczas gdy testy każdorazowo mogą badać tylko jeden przypadek. TDD i inne metody testowania są stosowane tylko „w fazie testowania”, w ramach cyklu budowania oprogramowania. Z kolei DBC i asercje są stosowane w całym cyklu życia oprogramowania: podczas projektowania, tworzenia, wdrażania i utrzymania. Techniki TDD nie koncentrują się na sprawdzaniu wewnętrznych niezmienników w testowanym kodzie — skupiają się raczej na testach typu „czarna skrzynka” publicznego interfejsu. DBC jest techniką bardziej wydajną (i lepiej spełnia zasadę DRY) niż programowanie defensywne, ponieważ wszystkie elementy kodu muszą sprawdzać poprawność danych. TDD jest świetną techniką, ale tak jak w przypadku wielu technik, może skłaniać do koncentrowania się na „szczęśliwej ścieżce”, a nie na prawdziwym świecie pełnym niepoprawnych danych, złych aktorów, niewłaściwych wersji i nieprawidłowych specyfikacji. Implementacja koncepcji projektowania kontraktowego Proste zapisywanie na etapie projektowania, w jakim przedziale muszą mieścić się dane wejściowe, jakie są warunki graniczne oraz co dana procedura obiecuje zwrócić (i — co jeszcze ważniejsze — czego nie obiecuje zwrócić), to ważny krok w kierunku pisania lepszego oprogramowania. Rezygnacja z wyrażania i zapisywania tych aspektów cofa nas do programowania przez koincydencję (patrz rozdział 6.), czyli modelu, od którego zaczyna się wiele projektów, na którym te projekty się kończą i przez które ostatecznie upadają. 4337ebf6db5c7cc89e4173803ef3875a 4 Projektowanie kontraktowe 139 W językach, które nie obsługują projektowania kontraktowego na poziomie kodu, należy skorzystać przynajmniej z tych możliwości, które mamy — nawet wówczas efekt będzie zadowalający. Projektowanie kontraktowe jest — jak sama nazwa wskazuje — techniką projektową. Nawet bez automatycznego sprawdzania możemy umieścić kontrakt w kodzie (w formie komentarzy) i nadal uzyskiwać wymierne korzyści. Asercje O ile samo dokumentowanie tych założeń jest doskonałym punktem wyjścia, dużo większe korzyści możemy osiągnąć, zmuszając kompilator do automatycznego sprawdzania zgodności kodu z zapisami kontraktu. W niektórych językach można to działanie przynajmniej częściowo emulować za pomocą asercji: sprawdzeń w czasie wykonywania programu lub warunków logicznych (patrz temat 25., „Programowanie asertywne”). Dlaczego tylko częściowo? Czy za pomocą asercji nie można osiągnąć wszystkich celów projektowania kontraktowego? Niestety nie. Po pierwsze w językach obiektowych nie jest obsługiwane propagowanie asercji w dół hierarchii dziedziczenia. Oznacza to, że w razie nadpisania metody klasy bazowej, dla której zdefiniowano kontrakt, asercje implementujące ten kontrakt nie będą wywoływane prawidłowo (chyba że zostaną ręcznie powielone w nowym kodzie). Musimy pamiętać o konieczności ręcznego wywołania niezmiennika klasy (i wszystkich niezmienników klasy bazowej) przed opuszczeniem każdej metody. Podstawowym problemem jest więc brak mechanizmów automatycznego wymuszania stosowania kontraktu. W innych środowiskach wyjątki generowane przez asercje w stylu DBC mogą być globalnie wyłączone lub ignorowane w kodzie. Po drugie, nie istnieje wbudowany mechanizm zarządzania „starymi” wartościami, czyli wartościami istniejącymi w momencie wywołania metody. Jeśli do wymuszania stosowania zapisów kontraktów używamy asercji, musimy uzupełnić warunek wstępny o kod zapisujący wszelkie informacje niezbędne podczas sprawdzania warunku końcowego — o ile stosowany język na to pozwala. W języku Eiffel, z którego programowanie kontraktowe się wywodzi, można użyć konstrukcji old wyrażenie. I wreszcie po trzecie, system i biblioteki wykonawcze nie są projektowane z myślą o obsłudze kontraktów, zatem nasze wywołania nie podlegają weryfikacji. Problem jest o tyle poważny, że właśnie na granicy dzielącej nasz kod od używanych przez nas bibliotek zwykle wykrywa się najwięcej problemów (szczegółowe omówienie tego zagadnienia można znaleźć w temacie 24., „Martwe programy nie kłamią”). 4337ebf6db5c7cc89e4173803ef3875a 4 140 Rozdział 4. Pragmatyczna paranoja Projektowanie kontraktowe i wczesne wykrywanie usterek Projektowanie kontraktowe wprost doskonale wpisuje się w koncepcję wczesnego wykrywania błędów (patrz temat 24., „Martwe programy nie kłamią”). Dzięki zastosowaniu asercji lub mechanizmu DBC w celu sprawdzenia warunków wstępnych, końcowych i niezmienników, można sprowokować awarię na wczesnym etapie wykonywania kodu i zgłosić dokładne informacje dotyczące problemu. Przypuśćmy, że dysponujemy metodą obliczającą pierwiastki kwadratowe. Metoda wymaga zastosowania warunku wstępnego ograniczającego dziedzinę do liczb dodatnich. W językach wspierających DBC, jeśli do funkcji sqrt przekażemy liczbę ujemną, otrzymamy komunikat o błędzie sqrt_arg_must_be_positive i stos wywołań prezentujący łańcuch kolejno wywoływanych procedur. To lepsze niż alternatywne rozwiązanie w innych językach, na przykład Java, C i C++, gdzie przekazanie liczby ujemnej na wejściu funkcji sqrt powoduje zwrócenie wartości specjalnej NaN (od ang. Not a Number). Problem może pozostawać ukryty przez dłuższy czas, jeśli dopiero w dalszej części programu wykonujemy jakieś operacje matematyczne na wartości NaN (wówczas otrzymamy niespodziewany wynik). Dużo prostszym rozwiązaniem jest znalezienie i zdiagnozowanie tego problemu poprzez wczesne wygenerowanie błędu, kiedy usterkę można łatwo skojarzyć z konkretnym miejscem w kodzie. Niezmienniki semantyczne Za pomocą niezmienników semantycznych możemy wyrażać nienaruszalne wymagania, rodzaj kontraktu filozoficznego. Napisaliśmy kiedyś system zarządzający transakcjami kartami debetowymi. Zgodnie z najważniejszym wymaganiem użytkownik karty debetowej nigdy nie powinien dwukrotnie wykonywać na swoim koncie tej samej transakcji. Innymi słowy, niezależnie od awarii i usterek, które mogły mieć miejsce, błąd powinien prowadzić raczej do rezygnacji z przetwarzania transakcji, nigdy do dwukrotnego przetworzenia tej samej transakcji. Ta prosta zasada wynikająca bezpośrednio z wymagań okazała się bardzo pomocna podczas realizacji scenariuszy odtwarzania systemu po skomplikowanych błędach i wskazywała właściwe kierunki dla szczegółowych rozwiązań projektowych i implementacyjnych w wielu obszarach. Nigdy nie powinniśmy mylić wymagań, które wyrażają stałe i nienaruszalne zasady, z wymaganiami reprezentującymi zaledwie bieżącą politykę, które z natury rzeczy mogą ulec zmianie wraz z nastaniem nowego kierownictwa. 4337ebf6db5c7cc89e4173803ef3875a 4 Projektowanie kontraktowe 141 Kto jest odpowiedzialny? Co odpowiada za sprawdzanie warunku wstępnego: kod wywołujący czy wywoływana procedura? W przypadku implementacji wchodzących w skład języka programowania żadna odpowiedź nie jest prawidłowa — warunek wstępny jest testowany w tle zaraz po wywołaniu procedury przez kod wywołujący, ale przed wejściem w samą procedurę. Oznacza to, że jeśli jakiś parametr musi zostać bezpośrednio sprawdzony, takiego sprawdzenia powinien dokonać kod wywołujący, ponieważ sama procedura nigdy nie otrzyma parametrów naruszających jej warunki wstępne. (W przypadku języków pozbawionych wbudowanej obsługi projektowania kontraktowego programista musi otoczyć wywoływaną procedurę wstępem i zakończeniem odpowiedzialnymi za weryfikację asercji). Przeanalizujmy przykład programu odczytującego liczbę z konsoli, obliczającego jej pierwiastek kwadratowy (za pomocą wywołania funkcji sqrt) i wyświetlającego wynik. Funkcja sqrt ma zdefiniowany warunek wstępny — jej argument nie może być liczbą ujemną. Jeśli użytkownik wpisuje w konsoli liczbę ujemną, to kod wywołujący powinien wykluczyć możliwość przekazania tej wartości na wejściu funkcji sqrt. W takim przypadku kod wywołujący ma do dyspozycji wiele rozwiązań — może przerwać działanie programu, może wygenerować ostrzeżenie i odczytać inną liczbę lub może zamienić tę liczbę na dodatnią i dopisać jednostkę i do wyniku zwróconego przez funkcję sqrt. Niezależnie od wybranego rozwiązania z pewnością nie jest to problem samej funkcji sqrt. Wyrażenie dziedziny funkcji pierwiastka kwadratowego w formie warunku wstępnego procedury sqrt powoduje przeniesienie odpowiedzialności za poprawność przekazywanej liczby na kod wywołujący, skąd pochodzi ta wartość. Możemy następnie przystąpić do projektowania bezpiecznej procedury sqrt, ponieważ wiemy, że jej dane wejściowe będą mieściły się w akceptowanym przedziale. Właśnie dlatego posługujemy się terminem niezmienników semantycznych — taki warunek musi wskazywać znaczenie pozostałych rozwiązań, a więc nie może być narażony na żadne kaprysy (do tego służą raczej bardziej dynamiczne reguły biznesowe). W razie napotkania wymagania, które spełnia te kryteria, warto zapisać je w widocznym miejscu tworzonej dokumentacji (niezależnie od jej formy). Może to być wyróżniony trzykrotnie większą czcionką wpis na liście wymagań w dokumencie wielokrotnie prezentowanym wszystkim członkom zespołu lub po prostu wielka notatka na widocznej dla wszystkich białej tablicy. Należy wyrazić to wymaganie możliwie jasno i jednoznacznie. Na przykład dla systemu obsługi kart debetowych można by napisać: BŁĘDY NA KORZYŚĆ UŻYTKOWNIKA. To wyjątkowo jasne, zwięzłe i zrozumiałe stwierdzenie jest potem stosowane w wielu różnych obszarach tworzonego systemu. To swoisty kontrakt ze wszystkimi użytkownikami systemu. Gwarancja zachowania oprogramowania, którą twórcy dają użytkownikom. 4337ebf6db5c7cc89e4173803ef3875a 4 142 Rozdział 4. Pragmatyczna paranoja Kontrakty dynamiczne i agenty Do tej pory mówiliśmy o kontraktach jako o stałych, niezmiennych specyfikacjach. Okazuje się jednak, że w świecie autonomicznych agentów taka definicja nie zawsze ma sens. Przez autonomiczne agenty rozumiemy mechanizmy, które mogą swobodnie odrzucać żądania, których z tego czy innego powodu nie chcą obsłużyć. Takie agenty mogą dowolnie renegocjować kontrakt — „mogę zwrócić to, czego żądasz, ale jeśli dasz mi na wejściu to lub tamto, mogę zwrócić coś innego”. Każdy system korzystający z technologii autonomicznych agentów definiuje, oczywiście, pewne krytyczne zależności na potrzeby przyszłych kontraktów (nawet jeśli te kontrakty mają być generowane dynamicznie). Wyobraźmy to sobie — przy odpowiedniej liczbie komponentów i agentów, które mogłyby negocjować między sobą kontrakty z myślą o osiągnięciu celu, moglibyśmy raz na zawsze rozwiązać kryzys związany z produktywnością oprogramowania, pozwalając zrobić to samym programom. Jeśli jednak nie potrafimy ręcznie używać kontraktów, siłą rzeczy nie będziemy potrafili robić tego także automatycznie. Kiedy więc następnym razem będziemy projektowali jakieś oprogramowanie, koniecznie zaprojektujmy także jego kontrakt. Pokrewne podrozdziały Temat 24., „Martwe programy nie kłamią”. Temat 25., „Programowanie asertywne”. Temat 38., „Programowanie przez koincydencję”. Temat 42., „Testowanie na podstawie właściwości”. Temat 43., „Pozostań w bezpiecznym miejscu”. Temat 45., „Kopalnia wymagań”. Wyzwania Kwestie do przemyślenia: Skoro projektowanie kontraktowe jest takie korzystne, dlaczego nie zyskało szerokiej popularności? Czy postępowanie zgodnie z zapisami kontraktu jest trudne? Czy zmusza programistę do bieżącego rozwiązywania problemów, zamiast odkładać je na później? Czy zmusza nas do MYŚLENIA!? Nie ma wątpliwości, że to bardzo niebezpieczne narzędzie! Ćwiczenia 14. Zaprojektuj interfejs kuchennego blendera. Ostatecznie będzie to webowa wersja blendera IoT, ale na razie potrzebujemy tylko interfejsu, by nim sterować. Blender ma ustawienia szybkości pracy (0 oznacza, że jest wyłączony). 4337ebf6db5c7cc89e4173803ef3875a 4 Martwe programy nie kłamią 143 Blendera nie można obsługiwać, jeśli jest pusty, a prędkość każdorazowo można zmieniać tylko o jedną jednostkę (czyli z 0 na 1, z 1 na 2, a nie z 0 na 2). Poniżej zestawiono metody. Dodaj właściwe warunki wstępne i końcowe oraz niezmienniki. int getSpeed() void setSpeed(int x) boolean isFull() void fill() void empty() 15. Ile liczb jest w ciągu 0, 5, 10, 15, ..., 100? 24 37 Martwe programy nie kłamią Czy nigdy nie zdarzyła nam się sytuacja, w której inni zwrócili uwagę na nasze problemy szybciej niż my sami? To samo dotyczy cudzego kodu. Jeśli coś w naszych programach zaczyna działać niezgodnie z oczekiwaniami, nierzadko pierwszy sygnał o nietypowej sytuacji pochodzi z procedury należącej do biblioteki zewnętrznej. Być może zbłąkany wskaźnik spowodował nadpisanie uchwytu pliku jakimiś bezsensownymi danymi. Dopiero kolejne wywołanie operacji odczytu pozwoli wykryć usterkę. Być może przepełnienie bufora spowodowało zmianę wartości jakiegoś licznika, który zostanie zaraz użyty do określenia ilości pamięci do przydzielenia. Być może odpowiedni błąd zwróci dopiero wywołanie malloc. Błąd logiczny popełniony kilka milionów instrukcji temu może powodować, że selektor wyrażenia case nie ma już oczekiwanej wartości 1, 2 lub 3. W takim przypadku zostanie przypadek domyślny (to jeden z powodów, dla których każde wyrażenie case/switch powinno zawierać klauzulę przypadku domyślnego — chcemy wiedzieć, kiedy zdarzyło się „niemożliwe”). Nietrudno wpaść w pułapkę postawy „to nie mogło się stać”. Większość z nas napisała kiedyś kod, który nie sprawdzał, czy plik został prawidłowo zamknięty lub czy wyrażenie trace zapisało to, czego oczekiwaliśmy. A jeśli wszystko działało zgodnie z planem, najprawdopodobniej w ogóle nie musieliśmy tego sprawdzać — odpowiedni kod i tak działał prawidłowo (przynajmniej w normalnych okolicznościach). Staramy się jednak kodować nasze rozwiązania możliwie ostrożnie. Staramy się wychwytywać błędne wskaźniki, które w innych częściach programu mogą powodować uszkodzenia stosu. Sprawdzamy poprawność wersji bibliotek współdzielonych, które rzeczywiście są ładowane przez nasze systemy. Każdy błąd jest źródłem informacji. Można, oczywiście, wmawiać sobie, że błąd nie mógł mieć miejsca, i dalej ignorować zagrożenie. Pragmatyczni programiści postępują jednak inaczej — powtarzają sobie, że skoro wystąpił błąd, musiało zdarzyć się coś bardzo, bardzo złego. Nie zapomnij przeczytać tego przeklętego komunikatu o błędzie (patrz „Programista we wrogim świecie” w rozdziale 3.). 4337ebf6db5c7cc89e4173803ef3875a 4 144 Rozdział 4. Pragmatyczna paranoja Złów i wypuść to dobra praktyka podczas wędkowania Niektórzy programiści uznają za dobry styl przechwytywanie wszystkich możliwych wyjątków i ponowne ich zgłaszanie po wyświetleniu jakiegoś komunikatu. Ich kod jest pełen konstrukcji podobnych do poniższej (gdzie pojedyncza instrukcja raise ponownie zgłasza bieżący wyjątek): try do add_score_to_board(score); rescue InvalidScore Logger.error("Nie można dodać nieprawidłowej punktacji. Kończę pracę"); raise rescue BoardServerDown Logger.error("Nie można dodać punktacji: plansza nie odpowiada. Kończę pracę"); raise rescue StaleTransaction Logger.error("Nie można dodać punktacj: transakcja zdezaktualizowana. Kończę pracę"); raise end Oto jak pragmatyczny programista zapisałby taki fragment kodu: add_score_to_board(score); Wolimy taki styl z dwóch powodów. Po pierwsze kod aplikacji nie jest zaciemniony kodem obsługi błędów. Po drugie, i chyba ważniejsze, kod jest mniej sprzężony. W pierwszym przykładzie musimy wymienić wszystkie wyjątki, jakie może zgłosić metoda add_score_to_board. Jeśli autor tej metody doda kolejny wyjątek, nasz kod będzie subtelnie nieaktualny. W bardziej pragmatycznej drugiej wersji, nowy wyjątek będzie propagowany automatycznie. WSKAZÓWKA NR 38 Awarie powinny następować możliwie wcześnie. Awaria, nie śmiecenie Jedną z zalet możliwie szybkiego wykrywania problemów jest wcześniejsze występowanie awarii. Co ciekawe, w wielu przypadkach awaria programu jest najlepszym rozwiązaniem. Często jedyną alternatywą jest dalsza praca polegająca na zapisywaniu uszkodzonych danych w ważnej bazie danych lub wydaniu pralce po raz dwudziesty z rzędu polecenia wirowania. Taka filozofia jest stosowana w językach Erlang i Elixir. Często cytowany Joe Armstrong, twórca języka Erlang i autor książki Programming Erlang:Software for a Concurrent World [Arm07], powiedział „Programowanie defensywne to strata 4337ebf6db5c7cc89e4173803ef3875a 4 Programowanie asertywne 145 czasu, jeśli wystąpi błąd w programie, nie przeciwdziałajcie jego awarii!”. W wymienionych językach programy działają z uwzględnieniem możliwości awarii, ale te awarie są zarządzane przez nadzorców. Nadzorca jest odpowiedzialny za uruchomienie kodu i wie, co zrobić w przypadku, gdy kod ulegnie awarii — te działania mogą obejmować wykonywanie operacji porządkujących, zrestartowanie programu i tak dalej. Co się stanie, gdy nastąpi awaria w kodzie nadzorcy? Tym zdarzeniem zarządza jego nadzorca. Prowadzi to do konstrukcji złożonej z drzew nadzorców. Technika ta jest bardzo skuteczna i jest jednym z powodów, dla których języki Erlang i Eliksir są wykorzystywane w odpornych na awarie systemach o wysokiej dostępności. W innych środowiskach proste opuszczenie działającego programu nie byłoby, oczywiście, wystarczającym rozwiązaniem. Program mógł przecież zajmować zasoby, które w takim scenariuszu nie zostałyby zwolnione. Niewykluczone, że program powinien zapisać jakieś komunikaty w dzienniku, zamknąć otwarte transakcje lub obsłużyć komunikację z pozostałymi procesami. Podstawowa zasada pozostaje jednak ta sama — kiedy nasz kod odkrywa, że zdarzyło się coś, co wydawało się niemożliwe, dalsze działanie programu w tej formie jest po prostu niemożliwe. Wszystko, co nasz program zrobi od tego momentu, stanie się podejrzane. Należy więc przerwać wykonywanie programu tak szybko, jak to możliwe. Martwy program zwykle powoduje dużo mniejsze uszkodzenia niż kaleki program. Pokrewne podrozdziały 25 38 Temat 20,. „Debugowanie”. Temat 23., „Projektowanie kontraktowe”. Temat 25., „Programowanie asertywne”. Temat 26., „Jak zrównoważyć zasoby”. Temat 43., „Pozostań w bezpiecznym miejscu”. Programowanie asertywne W wyrzutach sumienia jest coś komfortowego. Kiedy winimy o coś sami siebie, mamy poczucie, że nikt inny nie ma prawa winić nas za to samo. Oscar Wilde, Portret Doriana Graya Wiele wskazuje na istnienie pewnej mantry, którą musi sobie wpoić każdy programista już na początku swojej kariery. To swoisty dogmat w świecie oprogramowania — zasada, którą od początku przygody z komputerami uczymy się stosować dla wymagań, projektów, kodu, komentarzy i wszystkich innych tworzonych rozwiązań. Brzmi ona tak: 4337ebf6db5c7cc89e4173803ef3875a 4 146 Rozdział 4. Pragmatyczna paranoja TO NIGDY SIĘ NIE WYDARZY… „Skoro ta aplikacja nie będzie używana za granicą, po co mielibyśmy ją umiędzynarodawiać?”. „Licznik nie może mieć wartości ujemnej”, „Logowanie nie może zawieść”. Pragmatyczny programista nie może sobie pozwolić na podobną postawę, szczególnie podczas kodowania. WSKAZÓWKA NR 39 Jeśli coś nie może się zdarzyć, należy użyć asercji do zagwarantowania, że rzeczywiście się nie zdarzy. Kiedy tylko odkryjemy, że nasza postawa niebezpiecznie zbliża się do filozofii „to nigdy nie nastąpi”, powinniśmy zakodować rozwiązania weryfikujące ten fakt. Najprostszym sposobem jest użycie asercji. W implementacji wielu języków istnieje jakaś forma asercji sprawdzających warunki logiczne2. Takie sprawdzenia bywają wprost bezcenne. Jeśli parametr lub wynik nigdy nie powinien mieć wartości NULL, należy to sprawdzić: assert(result != NULL); W implementacji w Javie można (i należy) dodać opisowy ciąg: assert result != null && result.size() > 0 : "Pusty wynik z XYZ"; Asercje przydają się także podczas sprawdzania działania algorytmów. Wyobraźmy sobie, że napisaliśmy pewien sprytny algorytm sortujący w funkcji o nazwie my_sort. Warto sprawdzić, czy rzeczywiście działa: books=my_sort(find(*scifi*) assert(is_sorted(books)) Asercji nie należy używać zamiast właściwych mechanizmów obsługi błędów. Asercja służą do sprawdzania zdarzeń, które nigdy nie powinny mieć miejsca — nie chcemy przecież pisać kodu podobnego do poniższego przykładu: puts("Wpisz 'T' lub 'N': "); ans = gets[0] # pobranie pierwszego znaku odpowiedzi assert((ch == 'T') || (ch == 'N')); # Kiepski pomysł! To, że wiele dostępnych makr assert kończy program w momencie napotkania niezgodności z jakąś asercją, nie oznacza jeszcze, że podobnie powinny działać nasze wersje. Jeśli musimy zwolnić zasoby, nasza asercja powinna wygenerować wyjątek, wykonać skok do punktu wyjścia i wywołać mechanizm obsługi 2 W C i C ++ są one zazwyczaj zaimplementowane jako makra. W Javie asercje są domyślnie wyłączone. Aby je włączyć, należy wywołać maszynę wirtualną Javy z flagą -enableassertions. 4337ebf6db5c7cc89e4173803ef3875a 4 Programowanie asertywne 147 błędów. Musimy tylko upewnić się, że kod wykonywany w tych krytycznych milisekundach nie korzysta z informacji, które przed momentem doprowadziły do błędu niezgodności z asercją. Asercje i skutki uboczne To żenujące, gdy kod dodany w celu wykrywania błędów w rzeczywistości tworzy nowe błędy. Może się to zdarzyć w przypadku asercji, jeśli sprawdzanie warunku ma skutki uboczne. Na przykład zastosowanie kodu takiego jak poniżej to zły pomysł: while (iter.hasMoreElements()) { assert(iter.nextElement() != null); Object obj = iter.nextElement(); // .... } Wywołanie .nextElement() wewnątrz asercji ma skutek uboczny polegający na przeniesieniu iteratora poza pobierany element. Z tego powodu pętla przetworzy jedynie połowę elementów w kolekcji. Lepiej byłoby napisać następujący kod: while (iter.hasMoreElements()) { Object obj = iter.nextElement(); assert(obj != null); // .... } Ten problem to rodzaj błędu typu Heisenbug3 — debugowanie, które zmienia zachowanie debugowanego systemu. (Uważamy również, że w dzisiejszych czasach, kiedy w większości języków jest dostępna przyzwoita obsługa iterowania kolekcji z wykorzystaniem funkcji, stosowanie tego rodzaju jawnej pętli jest niepotrzebne i w złym stylu). Pozostaw asercje włączone Istnieje dość powszechna, całkowicie błędna opinia na temat asercji. Brzmi ona mniej więcej tak: Asercje stanowią pewne dodatkowe obciążenie dla kodu. Ponieważ sprawdzają zjawiska, które nigdy nie powinny mieć miejsca, są wyzwalane tylko przez błędy w kodzie. W tej sytuacji po przetestowaniu i dostarczeniu kodu asercje stają się niepotrzebne i jako takie powinny być wyłączone, aby przyspieszyć działanie kodu. Asercje to w istocie mechanizmy debugowania kodu. 3 http://www.eps.mcgill.ca/jargon/jargon.html#heisenbug 4337ebf6db5c7cc89e4173803ef3875a 4 148 Rozdział 4. Pragmatyczna paranoja Takie rozumowanie opiera się na dwóch zupełnie nieuprawnionych założeniach. Po pierwsze, przyjmuje się, że testy pozwalają odnaleźć wszystkie błędy. W rzeczywistości w przypadku jakiegokolwiek złożonego programu jest mało prawdopodobne, by udało się przetestować choćby niewielki odsetek permutacji, które mogą wystąpić w kodzie. Po drugie, optymiści szerzący tę plotkę zdają się zapominać, że nasz program działa w niebezpiecznym świecie. W trakcie testów szczury prawdopodobnie nie będą podgryzały kabla sieciowego, nikt nie będzie grał w grę zajmującą wszystkie zasoby pamięciowe, a pliki dzienników nie zapełnią twardego dysku. Wszystkie te zdarzenia mogą jednak mieć miejsce po wdrożeniu programu w środowisku produkcyjnym. Pierwszą linią obrony jest sprawdzanie kodu pod kątem wszelkich możliwych błędów; drugą linią jest stosowanie asercji, które mają na celu wykrywanie błędów przeoczonych podczas testów. Wyłączenie asercji w momencie przekazywania programu do środowiska produkcyjnego jest jak spacer po wysoko zawieszonej linie bez zabezpieczenia (uzasadniony tym, że podczas prób udało się to raz zrobić). Taki krok, oczywiście, niesie ze sobą sporo dramaturgii, ale rodzinie śmiałka trudno byłoby uzyskać choćby grosz z ubezpieczenia na życie. Nawet jeśli rzeczywiście borykamy się z problemem niedostatecznej wydajności, powinniśmy wyłączyć tylko te asercje, które rzeczywiście powodują opóźnienia. Przytoczony powyżej przykład algorytmu sortującego istotnie może być krytycznym elementem aplikacji i jako taki może wymagać naprawdę szybkiego działania. Dodanie kodu weryfikującego powoduje dodatkowe przetworzenie danych, co w pewnych okolicznościach może być nie do zaakceptowania. Warto wówczas ustawić konkretną asercję jako opcjonalną, pozostawiając pozostałe asercje na swoich miejscach. Używaj asercji w kodzie produkcyjnym i wygraj duże pieniądze Były sąsiad Andy’ego założył mały startup zajmujący się produkcją urządzeń sieciowych. Jednym z sekretów sukcesu przedsięwzięcia była decyzja o pozostawieniu asercji w kodzie w wersjach produkcyjnych. Te asercje miały na celu zgłaszanie wszystkich istotnych danych prowadzących do awarii i były prezentowane końcowym użytkownikom za pośrednictwem estetycznego interfejsu użytkownika. Ten poziom informacji zwrotnej od użytkowników w rzeczywistych warunkach pozwolił programistom załatać luki i usunąć niejasne, trudne do zreprodukowania błędy, czego skutkiem było powstanie niezwykle stabilnego, odpornego na błędy oprogramowania. Ta mała, nieznana firma wyprodukowała tak solidny produkt, że wkrótce został on sprzedany za setki milionów dolarów. Warto o tym pamiętać. 4337ebf6db5c7cc89e4173803ef3875a 4 Jak zrównoważyć zasoby 149 Ćwiczenia 16. Krótki test z wiedzy o otaczającym nas świecie. Które z tych „niemożliwych” zjawisk mogą mieć miejsce? Miesiąc składający się z mniej niż 28 dni. Kod błędu z wywołania systemowego (brak dostępu do bieżącego katalogu). W języku C++: a = 2; b = 3; ale (a + b) nie jest równe 5. Trójkąt, którego suma kątów wewnętrznych jest różna od 180º. Minuta, która nie ma 60 sekund. (a + 1) <= a Pokrewne podrozdziały 26 39 Temat 23., „Projektowanie kontraktowe”. Temat 24., „Martwe programy nie kłamią”. Temat 42., „Testowanie na podstawie właściwości”. Temat 43., „Pozostań w bezpiecznym miejscu”. Jak zrównoważyć zasoby Zapalenie świecy powoduje rzucanie cienia. Ursula K. Le Guin, A Wizard of Earthsea Wszyscy przy okazji kodowania musimy zarządzać zasobami: pamięcią, transakcjami, wątkami, plikami, licznikami czasu — słowem, wszystkim, co jest dostępne w ograniczonych ilościach. Przez większość czasu poziom wykorzystania zasobów jest zgodny z przewidywalnym wzorcem — przydzielamy zasób, używamy go i zwalniamy. Wielu programistów nie postępuje jednak według spójnego planu przydzielania i późniejszego zwalniania zasobów. Warto przy tej okazji zaproponować prostą wskazówkę: WSKAZÓWKA NR 40 Należy kończyć to, co się zaczyna. W większości sytuacji stosowanie tej wskazówki nie stanowi żadnego problemu. Oznacza to tylko tyle, że procedura lub obiekt, które przydzielają jakiś zasób, powinny odpowiadać także za jego zwolnienie. Sprawdźmy teraz, jak to działa w praktyce, analizując przykład kiepskiego kodu — aplikacji otwierającej plik, odczytującej informacje o kliencie, aktualizującej jedno pole i zapisującej wynik. Aby uprościć ten przykład, wyeliminowaliśmy mechanizmy obsługi błędów. 4337ebf6db5c7cc89e4173803ef3875a 4 150 Rozdział 4. Pragmatyczna paranoja def read_customer @customer_file = File.open(@name + ".rec", "r+") @balance = BigDecimal(@customer_file.gets) end def write_customer @customer_file.rewind @customer_file.puts @balance.to_s @customer_file.close end def update_customer(transaction_amount) read_customer @balance = @balance.add(transaction_amount,2) write_customer end Na pierwszy rzut oka procedura update_customer sprawia wrażenie w pełni prawidłowej. Wydaje się, że poprawnie implementuje interesującą nas logikę — odczytuje rekord, aktualizuje saldo i zapisuje rekord z powrotem w pliku. Okazuje się jednak, że za tym starannym kodem kryje się poważny problem. Procedury read_customer i write_customer są ze sobą ściśle powiązane4 — obie korzystają z tej samej zmiennej globalnej customer_file. Procedura read_customer odczytuje plik i zapisuje wskaźnik do tego pliku w zmiennej customer_file, natomiast procedura write_customer używa tej referencji do zamknięcia pliku, kiedy nie jest już potrzebny. Wspomniana zmienna globalna nawet nie pojawia się w kodzie procedury update_customer. Dlaczego to rozwiązanie jest złe? Przeanalizujmy scenariusz, w którym pechowy programista odpowiedzialny za konserwację dowiaduje się o zmianie specyfikacji — od tej pory saldo powinno być aktualizowane tylko wtedy, gdy nowa wartość nie jest ujemna. Przegląda więc kod źródłowy i zmienia procedurę update_customer: def update_customer(transaction_amount) read_customer if (transaction_amount >= 0.00) @balance = @balance.add(transaction_amount,2) write_customer end end Testy nie wykazały niczego niepokojącego. Kiedy jednak kod trafił do środowiska produkcyjnego, już po kilku godzinach przestał działać, wyświetlając błąd o zbyt dużej liczbie otwartych plików. Ponieważ procedura write_customer w pewnych sytuacjach nie jest wywoływana, plik nie jest wówczas zamykany. Zaimplementowanie obsługi tego specjalnego przypadku w procedurze update_ customer byłoby wyjątkowo niepożądanym rozwiązaniem: 4 Szczegółowe omówienie zagrożeń wynikających z tego rodzaju związków w kodzie można znaleźć w temacie 28., „Eliminowanie sprzężeń”. 4337ebf6db5c7cc89e4173803ef3875a 4 Jak zrównoważyć zasoby 151 def update_customer(transaction_amount) read_customer if (transaction_amount >= 0.00) @balance += BigDecimal(transaction_amount, 2) write_customer else @customer_file.close # Zły pomysł! end end W ten sposób można co prawda usunąć samą usterkę (plik będzie teraz zamykany niezależnie od nowego salda), jednak poprawka oznacza też, że już trzy procedury są ze sobą powiązane za pośrednictwem zmiennej globalnej customer_ file. W ten sposób wpadamy w pułapkę — jeśli zachowamy ten kurs, cały kod będzie narażony na gwałtowny upadek. Ten kod nie jest zrównoważony! Zasada kończenia tego, co się zaczęło, mówi nam, że (w idealnych warunkach) procedury, które przydzielają jakiś zasób, powinny ten zasób także zwalniać. Możemy wprowadzić tę zasadę w życie, dokonując nieznacznej refaktoryzacji analizowanego kodu: def read_customer(file) @balance=BigDecimal(file.gets) end def write_customer(file) file.rewind file.puts @balance.to_s end def update_customer(transaction_amount) file=File.open(@name + ".rec", "r+") read_customer(file) @balance = @balance.add(transaction_amount,2) write_customer(file) file.close end # >-# | # | # | # <-- Zamiast przechowywania referencji do pliku zmieniliśmy kod w taki sposób, aby referencja ta była przekazywana jako parametr5. Tym razem cała odpowiedzialność za przetwarzany plik spada na procedurę update_customer. Wspomniana procedura nie tylko otwiera, ale też (po skończeniu pracy) zamyka ten plik przed zwróceniem sterowania. Procedura równoważy użycie pliku — operacje otwierania i zamykania pliku znajdują się w tym samym miejscu i wydaje się, że dla każdej operacji otwierania istnieje odpowiednia operacja zamykania. Refaktoryzacja pozwoliła też usunąć niepożądaną zmienną globalną. Istnieje także niewielkie, ale ważne usprawnienie, które możemy wprowadzić do tego kodu. W wielu współczesnych językach można ograniczyć czas życia zasobu do jakiegoś zamkniętego bloku. W Ruby dostępna jest odmiana operacji otwierania pliku, w której przekazujemy referencję do otwartego pliku do bloku kodu. Poniżej pokazano ten mechanizm pomiędzy instrukcjami do a end: 5 Zobacz wskazówkę „Język X nie obsługuje potoków” w rozdziale 5. 4337ebf6db5c7cc89e4173803ef3875a 4 152 Rozdział 4. Pragmatyczna paranoja def update_customer(transaction_amount) File.open(@name + ".rec", "r+") do |file| read_customer(file) @balance = @balance.add(transaction_amount,2) write_customer(file) end end # >-# | # | # | # <-- W tym przypadku na końcu bloku zmienna file wykracza poza zakres, a zewnętrzny plik jest zamykany. Kropka. Nie trzeba pamiętać o zamykaniu pliku i zwalnianiu źródła, to dzieje się automatycznie i jest zagwarantowane. W przypadku wątpliwości zawsze opłaca się ograniczyć zakres. WSKAZÓWKA NR 41 Korzystaj z lokalnego zasięgu. Zagnieżdżanie przydzieleń Podstawowy wzorzec przydzielania zasobów można rozszerzyć na potrzeby procedur, które potrzebują więcej niż jednego zasobu jednocześnie. Wystarczy postępować według dwóch dodatkowych sugestii: Należy zwalniać zasoby w kolejności odwrotnej, niż są przydzielane. W ten sposób unikniemy zjawiska osieroconych zasobów, jeśli jeden zasób zawiera odwołania do innego. W przypadku przydzielania tego samego zbioru zasobów w różnych miejscach kodu zawsze należy przydzielać w tej samej kolejności. W ten sposób można ograniczyć ryzyko występowania zakleszczeń. (Jeśli proces A dysponuje zasobem 1. i zaraz zażąda zasobu 2., natomiast proces B dysponuje zasobem 2. i próbuje uzyskać zasób 1., oba procesy będą czekały na żądane zasoby w nieskończoność). To, jakiego rodzaju zasobów używamy (transakcji, pamięci, plików, wątków, okien itp.), nie ma znaczenia — podstawowa zasada pozostaje niezmienna: ktokolwiek przydziela zasób, powinien odpowiadać także za jego zwolnienie. Okazuje się jednak, że w niektórych językach można ten model jeszcze rozwinąć. Obiekty i wyjątki Równowaga pomiędzy przydzieleniami i zwolnieniami przypomina trochę model konstruktorów i destruktorów klas. Klasa reprezentuje pewien zasób, konstruktor daje nam dostęp do obiektu typu tego zasobu, a destruktor usuwa ten obiekt z naszego zasięgu. 4337ebf6db5c7cc89e4173803ef3875a 4 Jak zrównoważyć zasoby 153 Równoważenie w czasie W tym podrozdziale przyglądamy się głównie efemerycznym zasobom wykorzystywanym przez uruchomiony proces. Warto jednak zastanowić się, jaki jeszcze inny bałagan możemy pozostawiać. Na przykład, w jaki sposób są obsługiwane pliki logów? Kiedy tworzysz dane, zużywasz pamięć masową. Czy istnieje mechanizm rotacji logów i ich porządkowania? A co z nieoficjalnymi plikami debugowania, które pozostawiasz? Jeśli dodajesz rekordy logowania do bazy danych, to czy istnieje proces zarządzający ich dezaktualizacją? W przypadku wszystkiego, co tworzysz, a co zajmuje skończony zasób, należy zastanowić się nad sposobem równoważenia tego zasobu. Czy jest coś jeszcze, o czym zapomniałeś? Jeśli programujemy w języku obiektowym, możemy rozważyć rozwiązanie polegające na zamykaniu zasobów w klasach. Za każdym razem, gdy będziemy potrzebowali określonego typu zasobu, utworzymy obiekt odpowiedniej klasy. Kiedy obiekt wychodzi poza zasięg naszego kodu lub jest zwalniany przez mechanizm odzyskiwania, destruktor tego obiektu automatycznie zwalnia opakowany zasób. Proponowane rozwiązanie jest szczególnie korzystne podczas pracy w takich językach, w których wyjątki mogą wpływać na proces zwalniania zasobów. Równoważenie i wyjątki Języki z obsługą wyjątków mogą nieco utrudniać konsekwentne zwalnianie zasobów. Jak w przypadku wygenerowania wyjątku zagwarantować, że wszystkie zasoby przydzielone przed tym wyjątkiem zostaną prawidłowo zwolnione? Odpowiedź po części zależy od języka programowania. Zwykle mamy do wyboru dwie opcje: 1. Użycie zakresu zmiennych (na przykład zmiennych stosu w językach C++ lub Rust). 2. Skorzystanie z klauzuli finally w bloku try...catch. Zgodnie ze zwykłymi zasadami dotyczącymi zakresów w takich językach jak C++ lub Rust, pamięć zmiennej zostanie odzyskana, gdy zmienna wyjdzie poza zakres za pośrednictwem instrukcji return, po wyjściu z bloku lub po zgłoszeniu wyjątku. Można jednak również „posprzątać” po zewnętrznych zasobach w destruktorze zmiennej. W poniższym przykładzie, przy wyjściu z zakresu zmienna Rust o nazwie accounts automatycznie zamknie skojarzony z nią plik: { let mut accounts = File::open("mydata.txt")?; // >-// korzystaj ze zmiennej 'accounts' 4337ebf6db5c7cc89e4173803ef3875a // | 4 154 Rozdział 4. Pragmatyczna paranoja ... // | } // <-// zmienna accounts jest teraz poza zakresem, co powoduje // automatyczne zamknięcie pliku Inną opcją, jeśli język ją obsługuje, jest użycie klauzuli finally. Gwarantuje ona uruchomienie zawartego wewnątrz niej bloku kodu niezależnie od tego, czy w bloku try...catch zostanie zgłoszony wyjątek, czy nie: try // działania, które mogą spowodować wyjątki catch // zgłoszono wyjątek finally // sprzątanie niezależnie od tego, czy wystąpił wyjątek Istnieje jednak pewien haczyk. Antywzorzec obsługi wyjątków Często spotykamy programistów, którzy piszą kod w następującej postaci: begin thing = allocate_resource() process(thing) finally deallocate(thing) end Czy widzisz w tym kodzie coś złego? Co się stanie, jeśli przydzielenie zasobu nie powiedzie się i spowoduje zgłoszenie wyjątku? Wyjątek zostanie przechwycony w klauzuli finally, gdzie nastąpi próba zwolnienia zasobu, który nigdy nie został przydzielony. Prawidłowy wzorzec obsługi zwolnienia zasobu w środowisku z wyjątkami jest następujący: thing = allocate_resource() begin process(thing) finally deallocate(thing) end Kiedy nie można zrównoważyć zasobów W pewnych sytuacjach stosowanie podstawowego wzorca przydzielania i zwalniania zasobów jest po prostu niemożliwe. Problem najczęściej dotyczy programów używających dynamicznych struktur danych. Jedna procedura przydziela obszar pamięci i wiąże go z jakąś większą strukturą, gdzie może być używany przez pewien czas. 4337ebf6db5c7cc89e4173803ef3875a 4 Jak zrównoważyć zasoby 155 Cała sztuka polega wówczas na ustanowieniu jakiegoś semantycznego niezmiennika na potrzeby alokacji pamięci. Musimy zdecydować, kto odpowiada za dane w skojarzonej strukturze danych. Co powinno się stać w chwili zwolnienia struktury najwyższego poziomu? Mamy do wyboru trzy główne rozwiązania: Struktura najwyższego poziomu dodatkowo odpowiada za zwalnianie wszelkich zawartych w sobie struktur podrzędnych. Same struktury podrzędne rekurencyjnie usuwają zawierane dane itd. Struktura najwyższego poziomu jest po prostu zwalniana. Wszystkie struktury, które wcześniej były wskazywane przez tę strukturę (i które nie są przedmiotem innych odwołań), stają się sierotami. Struktura najwyższego poziomu odmawia zwolnienia samej siebie, jeśli zawiera w sobie jakieś struktury podrzędne. Wybór zależy od okoliczności, w których stosujemy poszczególne struktury danych. Musimy jednak podjąć taką decyzję dla każdej struktury i konsekwentnie implementować wybrane rozwiązanie w całym kodzie. Implementacja każdej z tych opcji w języku proceduralnym (np. w języku C) jest o tyle trudna, że same struktury danych nie są aktywne. W takich przypadkach zalecamy napisanie dla każdej ważnej struktury danych osobnego modułu, który będzie definiował standardowe mechanizmy przydzielania i zwalniania tej struktury. (Moduł może dodatkowo udostępniać takie mechanizmy jak wyświetlanie komunikatów diagnostycznych, serializacja, deserializacja czy przeszukiwanie). Sprawdzanie równowagi Ponieważ pragmatyczni programiści nie ufają nikomu, nawet sobie, wydaje się, że zawsze dobrym rozwiązaniem jest budowa kodu sprawdzającego, czy zasoby rzeczywiście są prawidłowo zwalniane. W przypadku większości aplikacji weryfikacja równowagi sprowadza się do tworzenia opakowań dla wszystkich typów zasobów i stosowania tych opakowań do śledzenia wszystkich zdarzeń przydzielania i zwalniania. Na pewnych punktach w kodzie logika programu wymusza określone stany zasobów — wspomniane opakowania mogą nam posłużyć do sprawdzania tych stanów. Na przykład długo działający program, który obsługuje żądania, prawdopodobnie będzie zawierał jeden punkt na początku swojej głównej pętli przetwarzającej, w którym czeka na przyjście kolejnego żądania. To dobre miejsce dla mechanizmu sprawdzającego, czy poziom wykorzystania zasobów nie przekroczył akceptowanego progu od poprzedniej iteracji pętli. Na niższym, ale nie mniej ważnym poziomie możemy zainwestować w narzędzia, które będą (między innymi) monitorowały nasze programy pod kątem ewentualnych wycieków pamięci. 4337ebf6db5c7cc89e4173803ef3875a 4 156 Rozdział 4. Pragmatyczna paranoja Pokrewne podrozdziały Temat 24., „Martwe programy nie kłamią”. Temat 30., „Programowanie transformacyjne”. Temat 33., „Eliminowanie związków czasowych”. Wyzwania Mimo że nie istnieją w pełni niezawodne sposoby zapewniania zwalniania wszystkich zasobów, niektóre techniki projektowe (pod warunkiem konsekwentnego stosowania) mogą nam bardzo pomóc. W powyższym tekście omówiliśmy metodę ustanawiania niezmiennika semantycznego dla najważniejszych struktur danych jako sposób podejmowania właściwych decyzji o zwalnianiu pamięci. Warto zastanowić się, jak rozwiązania zaproponowane w podrozdziale „Projektowanie kontraktowe” we wcześniejszej części tego rozdziału mogłyby pomóc w udoskonaleniu tego modelu. Ćwiczenia 17. Niektórzy programiści języków C i C++ konsekwentnie przypisują wskaźnikom wartość NULL zaraz po zwolnieniu wskazywanych obszarów pamięci. Dlaczego takie rozwiązanie jest korzystne? 18. Niektórzy programiści Javy konsekwentnie przypisują zmiennym obiektów wartość NULL zaraz po zakończeniu korzystania z tych obiektów. Dlaczego takie rozwiązanie jest korzystne? 27 40 Nie prześcigaj swoich świateł Trudno jest prognozować, zwłaszcza jeśli chodzi o przyszłość. — Lawrence „Yogi” Berra, na podstawie duńskiego przysłowia Jest późna, ciemna noc, leje deszcz. Dwumiejscowy sportowy wóz jedzie krętą, wąską górską drogą, ledwo pokonując zakręty. Dojeżdża do „agrafki”, której kierowca nie zauważa. Uderza w skromną barierkę i spada w przepaść. Na miejsce wypadku przybywa policja. Starszy oficer smutno kręci głową. „Chyba przegonił swoje światła”. Czy to znaczy, że ten samochód jechał z prędkością większą od prędkości światła? Nie. Tej prędkości nie da się przekroczyć. Funkcjonariusz mówił o zdolności kierowcy do zatrzymania się lub zapanowania nad pojazdem na dystansie oświetlanym przez światła. Reflektory mają pewien ograniczony zakres określany jako odległość rzutu (ang. throw distance). Poza tym punktem, światło jest zbyt rozproszone, aby mogło być skuteczne. Ponadto reflektory świecą tylko w linii prostej i nie oświe- 4337ebf6db5c7cc89e4173803ef3875a 4 Nie prześcigaj swoich świateł 157 tlają niczego, co znajduje się poza osią, na przykład zakrętów, wzgórz lub fałd na drodze. Według National Highway Traffic Safety Administration, średnia odległość oświetlana przez światła mijania wynosi około 50 metrów. Niestety droga hamowania przy prędkości 60 km/h wynosi około 60 metrów, a przy prędkości 100 km/h już około 140 metrów6. Zatem rzeczywiście bardzo łatwo można prześcignąć swoje światła. W procesie wytwarzania oprogramowania jest podobnie. Nasze „reflektory” mają ograniczenia. Nie widzimy zbyt daleko w przyszłość, a im bardziej spoglądamy poza oś, tym jest ciemniej. W związku z tym pragmatyczni programiści konsekwentnie stosują zasadę: WSKAZÓWKA NR 42 Zawsze poruszaj się małymi krokami. Zawsze należy robić małe, przemyślane kroki, a przed podjęciem kolejnych sprawdzać informacje zwrotne i odpowiednio dostosowywać swoje działania. Tempo, w jakim otrzymujemy informacje zwrotne, to nasze ograniczenie prędkości. Nigdy nie rób zbyt dużego kroku lub nie wykonuj zadania, które jest „zbyt duże”. Co dokładnie mamy na myśli mówiąc o informacjach zwrotnych? Wszystko, co w niezależny sposób potwierdza słuszność lub brak słuszności Twoich działań. Na przykład: Wyniki w REPL dostarczają informację zwrotną dotyczącą rozumienia interfejsów API i algorytmów. Testy jednostkowe dostarczają informację zwrotną na temat ostatnich zmian w kodzie. Wersje demo dla użytkowników i rozmowy z nimi dostarczają informację zwrotną dotyczącą funkcji i użyteczności. Jakie zadanie jest za duże? Każde, które wymaga „wróżenia przyszłości”. Tak samo, jak reflektory samochodu mają ograniczony rzut, tak my możemy przewidywać przyszłość tylko o jeden bądź dwa kroki — być może tylko kilka godzin lub dni naprzód. Poza tym punktem łatwo możemy przejść od uzasadnionej wiedzą prognozy do dzikiej spekulacji. Zmierzasz w kierunku wróżenia przyszłości, kiedy wykonujesz jedną z wymienionych poniżej czynności: 6 Szacujesz daty ukończenia zadania oddalone o wiele miesięcy w przyszłość. Planujesz projekt pod kątem przyszłej możliwości utrzymania lub rozszerzania. Zgadujesz przyszłe potrzeby użytkowników. Zgadujesz przyszłe dostępne technologie. Według NHTSA, Droga zatrzymania = Odległość reakcji + Droga hamowania, przy założeniu czasu reakcji około 1,5 s oraz hamowania w tempie 5 m/s². 4337ebf6db5c7cc89e4173803ef3875a 4 158 Rozdział 4. Pragmatyczna paranoja Słyszymy Twoje protesty: czy nie powinniśmy projektować oprogramowania pod kątem możliwości utrzymywania go w przyszłości? Tak, ale tylko do pewnego momentu: tylko tak daleko, jak jesteśmy w stanie zobaczyć. Im większa konieczność przewidywania przyszłości, tym większe ryzyko popełnienia błędu. Zamiast tracić energię na projektowanie nieznanej przyszłości, lepiej projektować kod w taki sposób, aby można go było łatwo zastąpić. Zaprojektuj kod tak, aby można go było łatwo wyrzucić i zastąpić czymś, co lepiej nadaje się do wykonania zadania. Projektowanie kodu w taki sposób, by był wymienny, pomoże również zapewnić lepszą spójność, luźniejsze sprzężenia oraz ściślejsze stosowanie zasady DRY, co w konsekwencji doprowadzi do lepszego projektu w sensie ogólnym. Nawet jeśli masz poczucie, że przyszłość jest pewna, zawsze istnieje ryzyko, że za rogiem czeka jakiś czarny łabędź. Czarne łabędzie W książce The Black Swan: The Impact of the Highly Improbable [Tal10]7 Nassim Nicholas Taleb założył, że wszystkie istotne wydarzenia w historii wynikały z powodu specyficznych, trudnych do przewidzenia i rzadkich zdarzeń, wykraczających poza sferę zwykłych oczekiwań. Te odbiegające od normy zdarzenia, choć są statystycznie rzadkie, wywierają nieproporcjonalne skutki. Ponadto, z powodu naszych własnych uprzedzeń poznawczych, jesteśmy ślepi na zmiany, które powoli wkradają się do naszej pracy (patrz „Zupa z kamieni i gotowane żaby”). Mniej więcej w czasie, kiedy zostało opublikowane pierwsze wydanie „Pragmatycznego programisty”, w czasopismach komputerowych i na forach internetowych toczyła się debata dotycząca palącego problemu: „Kto wygra wojnę o dominację wśród bibliotek GUI dla środowisk komputerów desktop: Motif, czy OpenLook?”8. To było źle postawione pytanie. Być może nigdy nie słyszeliście o tych technologiach, ponieważ żadna z nich nie „wygrała”, a rynek szybko zdominowały aplikacje webowe. WSKAZÓWKA NR 43 Unikaj wróżenia przyszłości. 7 Wydanie polskie: „Czarny łabędź. Jak nieprzewidywalne zdarzenia rządzą naszym życiem”, Wydawnictwo Zysk i S-ka, 2020. 8 Motif i OpenLook były standardami GUI dla uniksowych stacji roboczych bazujących na systemie X-Window. 4337ebf6db5c7cc89e4173803ef3875a 4 Nie prześcigaj swoich świateł 159 W większości przypadków jutro bardzo przypomina dzisiaj. Nie należy jednak na to liczyć. Pokrewne podrozdziały Temat 12., „Pociski smugowe”. Temat 13., „Prototypy i karteczki samoprzylepne”. Temat 40., „Refaktoryzacja”. Temat 41., „Kod łatwy do testowania”. Temat 48., „Istota zwinności”. Temat 50., „Nie próbuj przecinać kokosów”. 4337ebf6db5c7cc89e4173803ef3875a 4 160 Rozdział 4. Pragmatyczna paranoja 4337ebf6db5c7cc89e4173803ef3875a 4 Rozdział 5. Zegnij lub złam Życie nie stoi w miejscu. To samo dotyczy pisanego przez nas kodu. Aby dotrzymać tempa galopującym zmianom, musimy robić, co w naszej mocy, aby pisany kod był możliwie elastyczny i pozbawiony ścisłych związków. W przeciwnym razie nasz kod szybko może okazać się nieaktualny lub zbyt kruchy do naprawy — będzie nadawał się raczej do kosza niż do rozwijania z myślą o przyszłości. W podrozdziale „Odwracalność” w rozdziale 2. omówiliśmy czynniki ryzyka związane z nieodwracalnymi decyzjami. W tym rozdziale skoncentrujemy się na sposobach podejmowania odwracalnych decyzji, tak aby nasz kod zachowywał elastyczność i potencjał dostosowywania do wymogów niepewnego świata. Musimy najpierw zająć się kwestią sprzężeń (ang. coupling), czyli zależności łączących moduły kodu. Operacja eliminowania sprzężeń (ang. decoupling) pokazuje, w jaki sposób można izolować odrębne pojęcia, co zmniejsza liczbę sprzężeń. Następnie zajmiemy się różnymi technikami, które można zastosować podczas „Żonglerki realnym światem”. Przeanalizujemy cztery różne strategie, które ułatwiają zarządzanie zdarzeniami i reagowanie na nie — kluczowy aspekt nowoczesnych aplikacji. Tradycyjne typy kodu — proceduralny i obiektowy — mogą być zbyt mocno sprzężone, aby mogły służyć naszym celom. W podrozdziale „Programowanie transformacyjne” wykorzystamy bardziej elastyczny i czystszy styl zaoferowany przez potoki funkcji, które można stosować nawet wtedy, gdy nasz język nie obsługuje ich bezpośrednio. Stosowanie popularnego stylu obiektowego może zwabić nas w inną pułapkę. Nie daj się w nią złapać, bo jeśli się tak stanie, będziesz zmuszony płacić wysoki „Podatek od dziedziczenia”. Przeanalizujemy lepsze alternatywy zapewnienia elastyczności kodu i łatwiejszego wprowadzania w nim zmian. 4337ebf6db5c7cc89e4173803ef3875a 4 162 Rozdział 5. Zegnij lub złam Oczywistym sposobem zapewnienia większej elastyczności jest pisanie mniejszej ilości kodu. Modyfikowanie kodu otwiera możliwość wprowadzania nowych błędów. W podrozdziale „Konfiguracja” wyjaśnimy, w jaki sposób całkowicie przesunąć szczegóły z kodu do miejsca, w którym można je zmieniać w bezpieczniejszy i łatwiejszy sposób. Wymienione techniki sprawią, że będziemy mogli pisać kod, który będzie się zginał, ale się nie złamie. 28 36 Eliminowanie sprzężeń Kiedy staramy się wybrać cokolwiek, zdajemy sobie sprawę, że jest to połączone ze wszystkim we Wszechświecie. John Muir, My First Summer in the Sierra W temacie 8., „Istota dobrego projektu” w rozdziale 2. powiedzieliśmy, że dzięki stosowaniu zasad dobrego projektu kod, który piszemy, będzie łatwiejszy do modyfikowania. Sprzężenia są wrogiem zmian, ponieważ wiążą ze sobą elementy, które muszą być zmieniane równolegle. To sprawia, że wprowadzanie zmian staje się trudniejsze: albo trzeba poświęcić czas na znalezienie wszystkich modułów, które wymagają zmian, albo na zastanawianie się, dlaczego niektóre komponenty przestały działać, skoro zmieniliśmy „tylko jedną rzecz”, a nie zmienialiśmy innych elementów. Gdy projektujemy coś, co powinno być sztywne — na przykład most lub wieżę — staramy się, aby poszczególne elementy były ze sobą sprzężone: Dzięki istnieniu sprzężeń konstrukcja nabiera sztywności. Porównajmy tę strukturę z tą, którą pokazano poniżej: 4337ebf6db5c7cc89e4173803ef3875a 4 Eliminowanie sprzężeń 163 Tutaj nie ma strukturalnej sztywności: kiedy zmieniają się poszczególne łącza, inne się dostosowują. Kiedy projektujesz most, chcesz, żeby utrzymał swój kształt — jego konstrukcja powinna być sztywna. Ale kiedy projektujesz oprogramowanie, w którym ma być zachowana możliwość wprowadzania zmian, chcesz czegoś dokładnie odwrotnego: ta konstrukcja powinna być elastyczna. Żeby to osiągnąć, poszczególne komponenty powinny być powiązane z jak najmniejszą liczbą innych składników. Na domiar złego sprzężenia są przechodnie: jeśli komponent A jest sprzężony z B i C, B jest sprzężony z M i N, a C z X i Y, to w efekcie A jest sprzężony z B, C, M, N, X i Y. Oznacza to, że należy postępować zgodnie z prostą zasadą: WSKAZÓWKA NR 44 Kod bez sprzężeń jest łatwiejszy do modyfikowania. Zastanówmy się, co oznacza eliminowanie sprzężeń z kodu, wziąwszy pod uwagę, że zwykle nie kodujemy z wykorzystaniem stalowych prętów i nitów? W tym podrozdziale omówimy następujące zagadnienia: Pociągi-wraki — łańcuchy wywołań metod. Globalizacja — niebezpieczeństwa związane ze statycznym kodem. Dziedziczenie — dlaczego tworzenie klas potomnych jest niebezpieczne. Do pewnego stopnia powyższa lista jest sztuczna: sprzężenia mogą wystąpić niemal zawsze, gdy dwa fragmenty kodu coś współdzielą, dlatego podczas lektury kolejnej części tego rozdziału zwróć uwagę na zaprezentowane wzorce, aby móc je zastosować podczas kodowania. Należy także zwrócić uwagę na niektóre popularne symptomy występowania sprzężeń: Głupie zależności pomiędzy niepowiązanymi ze sobą modułami lub bibliotekami. „Proste” zmiany w jednym module, które propagują się w systemie lub przyczyniają się do awarii w innych jego miejscach. 4337ebf6db5c7cc89e4173803ef3875a 4 164 Rozdział 5. Zegnij lub złam Programiści boją się wprowadzania zmian w kodzie, ponieważ nie są pewni, na jakie elementy systemu może to mieć wpływ. Spotkania, w których musi uczestniczyć szerokie grono osób, ponieważ nikt nie jest pewien, na kogo zmiana będzie miała wpływ. Pociągi-wraki Wszyscy widzieliśmy (i prawdopodobnie pisaliśmy) kod następującej postaci: public void applyDiscount(customer, order_id, discount) { totals = customer .orders .find(order_id) .getTotals(); totals.grandTotal = totals.grandTotal - discount; totals.discount = discount; } Pobieramy referencję do zamówień za pośrednictwem obiektu klienta i wykorzystujemy tę referencję do znalezienia określonego zamówienia. Następnie obliczamy dla tego zamówienia zbiór podsumowań. Korzystając z nich odejmujemy rabat od sumy zamówienia i jednocześnie aktualizujemy zamówienie stosując obliczoną zniżkę. Ten fragment kodu przechodzi przez pięć poziomów abstrakcji — od klienta do kwot zestawień. Ostatecznie kod najwyższego poziomu musi wiedzieć, że obiekt klienta daje dostęp do zamówień. Z kolei zamówienia udostępniają metodę find pobierającą identyfikator zamówienia i zwracającą obiekt zamówienia; ponadto obiekt zamówienia zawiera obiekt podsumowań z getterami i seterami dla podsumowań i rabatów. To bardzo dużo ukrytej wiedzy. Ale gorsze jest to, że, aby ten kod mógł dalej działać w przyszłości, nie może się zmienić bardzo wiele rzeczy. Wszystkie wagony w pociągu są ze sobą połączone, podobnie jak wszystkie metody i atrybuty we wraku pociągu. Wyobraźmy sobie, że firma zdecydowała, że dla żadnego zamówienia nie można udzielić rabatu w wysokości przekraczającej 40%. Gdzie należałoby umieścić kod, który wymusza stosowanie tej zasady? Można powiedzieć, że ten kod powinien znaleźć się w funkcji applyDiscount, którą właśnie napisaliśmy. To z pewnością część odpowiedzi. Ale w przypadku kodu w postaci takiej jak teraz, nie możemy powiedzieć, że to jest cała odpowiedź. Dowolny fragment kodu, w dowolnym miejscu, może ustawiać pola w obiekcie totals, a jeśli opiekun tego kodu nie dostanie informacji, to nie zapewni stosowania nowej reguły. Jednym ze sposobów spojrzenia na ten kod jest pomyślenie o nim z punktu widzenia odpowiedzialności. Z pewnością obiekt totals powinien być odpowiedzialny za zarządzanie podsumowaniami. A jednak nie jest za to odpowiedzialny: w istocie jest jedynie zbiorem pól, które każdy może aktualizować i o które każdy może pytać. 4337ebf6db5c7cc89e4173803ef3875a 4 Eliminowanie sprzężeń 165 Rozwiązaniem pokazanego problemu jest zastosowanie się do poniższej zasady: WSKAZÓWKA NR 45 Mów. Nie pytaj. Powyższa zasada mówi, że nie należy podejmować decyzji w oparciu o wewnętrzny stan obiektu, a następnie aktualizować ten obiekt. Postępowanie w ten sposób całkowicie niszczy korzyści wynikające z hermetyzacji i rozsiewa po kodzie wiedzę związaną z implementacją. Zatem pierwszą poprawką do naszego wraku pociągu jest przekazanie odpowiedzialności za rabaty do obiektu total: public void applyDiscount(customer, order_id, discount) { customer .orders .find(order_id) .getTotals() .applyDiscount(discount); } Ten sam rodzaj problemu TDA (od ang. tell, don’t ask — dosłownie: mów, nie pytaj) mamy z obiektem klienta i jego zamówieniami: nie powinniśmy jednocześnie pobierać listy zleceń i ich przeszukiwać. Zamiast tego powinniśmy pobrać potrzebne zamówienie bezpośrednio z obiektu klienta: public void applyDiscount(customer, order_id, discount) { customer .findOrder(order_id) .getTotals() .applyDiscount(discount); } To samo dotyczy obiektu order i zapisanych w nim podsumowań. Dlaczego świat zewnętrzny ma wiedzieć, że w implementacji obiektu reprezentującego zamówienie wykorzystano oddzielny obiekt do przechowywania podsumowań? public void applyDiscount(customer, order_id, discount) { customer .findOrder(order_id) .applyDiscount(discount); } W tym miejscu prawdopodobnie powinniśmy się zatrzymać. W tym momencie można by pomyśleć, że zgodnie z zasadą TDA metodę apply DiscountToOrder(order_id) należałoby dodać do obiektu klienta. Gdybyśmy niewolniczo stosowali tę zasadę, to tak by było. Ale TDA nie jest prawem natury; jest to po prostu wzorzec, który pomaga nam rozpoznawać problemy. W tym przypadku zdecydowaliśmy się na odsłonięcie faktu, że klient ma zamówienia i że możemy znaleźć jedno z nich za pośrednictwem obiektu klienta. To jest pragmatyczna decyzja. 4337ebf6db5c7cc89e4173803ef3875a 4 166 Rozdział 5. Zegnij lub złam W każdej aplikacji istnieją pewne pojęcia najwyższego poziomu, które są uniwersalne. W tej do tych pojęć należą klienci i zamówienia. Całkowite ukrywanie zamówień wewnątrz obiektów klientów nie ma sensu — zamówienia żyją własnym życiem. Nie ma więc przeciwwskazań, aby stworzyć interfejs API, który udostępnia obiekty zamówień. Prawo Demeter Często, gdy mówimy o sprzężeniach, powołujemy się na tzw. prawo Demeter (ang. Law of Demeter — LoD). LoD to zbiór wskazówek1 spisanych pod koniec lat osiemdziesiątych przez Iana Hollanda. Stworzył je, by pomóc programistom pracującym w projekcie tworzyć funkcje czystsze i z mniejszą liczbą sprzężeń. Prawo LoD mówi, ze metoda zdefiniowana w klasie C powinna wywoływać wyłącznie: inne metody egzemplarza klasy C, jej parametry, metody obiektów, które tworzy, zarówno na stosie, jak i na stercie, zmienne globalne. W pierwszym wydaniu tej książki poświęciliśmy trochę miejsca opisowi prawa LoD. W ciągu minionych 20 lat ta róża trochę przekwitła. Teraz nie podoba nam się klauzula „zmienne globalne” (ze względów, o których napiszemy w następnym podrozdziale). Odkryliśmy również, że trudno jest wykorzystać to prawo w praktyce: jego stosowanie przypomina konieczność brania go pod uwagę przy wywołaniu każdej metody. Sama zasada jest jednak nadal wartościowa. Zalecamy nieco prostszą regułę, która wyraża niemal to samo: WSKAZÓWKA NR 46 Nie łącz wywołań metod w łańcuchy. Gdy chcesz uzyskać dostęp do czegoś, staraj się nie korzystać z więcej niż jednej kropki. Dostęp do czegoś obejmuje także przypadki, w których korzystamy ze zmiennych pośrednich, tak jak w poniższym kodzie: # To jest kiepski styl amount = customer.orders.last().totals().amount; # ten styl też nie jest dobry… orders = customer.orders; last = orders.last(); totals = last.totals(); amount = totals.amount; 1 Zatem w rzeczywistości nie jest to prawo, ale raczej dobry pomysł Demeter. 4337ebf6db5c7cc89e4173803ef3875a 4 Eliminowanie sprzężeń 167 Jest jeden ważny wyjątek od reguły jednej kropki: zasada nie ma zastosowania, jeżeli prawdopodobieństwo zmian w komponentach wywoływanych w łańcuchu jest małe. W praktyce wszystko, co jest w aplikacji, należy rozważać jako możliwe do zmiany. Wszystko, co znajduje się w zewnętrznych bibliotekach, należy uznawać za ulotne — zwłaszcza wtedy, gdy dostawcy biblioteki często zmieniają API pomiędzy kolejnymi wersjami. Z kolei biblioteki standardowe dostarczane wraz z językiem można uznać za dość stabilne, zatem bez przeszkód można pisać taki oto kod: people .sort_by {|person| person.age } .first(10) .map {| person | person.name } Ten kod w Ruby działał kiedy pisaliśmy pierwsze wydanie tej książki 20 lat temu i prawdopodobnie nadal będzie działać, gdy za jakiś czas znajdziemy się w domu starców dla programistów (co może się zdarzyć lada dzień…). Łańcuchy i potoki W temacie 30., „Programowanie transformacyjne”, mówiliśmy o komponowaniu funkcji w potoki. Te potoki przekształcają dane, przekazując je z jednej funkcji do następnej. To nie jest to samo co pociągi-wraki wywołań metod, ponieważ w przypadku potoków nie polegamy na ukrytych szczegółach implementacji. Nie znaczy to, że potoki nie wprowadzają pewnych sprzężeń. Format danych zwracanych przez jedną funkcję w potoku musi być zgodny z formatem akceptowanym przez następną. Z naszego doświadczenia wynika, że ta forma sprzężenia jest znacznie mniejszą barierą dla zmian w kodzie niż forma wprowadzana przez pociągi-wraki. Zło globalizacji Dane dostępne globalnie to podstępne źródło sprzężeń pomiędzy komponentami aplikacji. Każdy element globalnych danych działa tak, jakby do każdej metody w aplikacji nagle został wprowadzony dodatkowy parametr — ostatecznie ten globalny element danych jest dostępny wewnątrz każdej metody. Zmienne globalne sprzęgają kod z wielu powodów. Najbardziej oczywistym jest fakt, że zmiana w implementacji globalnego elementu danych potencjalnie wpływa na cały kod w systemie. Oczywiście w praktyce ten wpływ jest dość ograniczony. Problem w istocie sprowadza się do konieczności zidentyfikowania wszystkich miejsc, które trzeba zmienić. Dane globalne tworzą również sprzężenia przeszkadzające w rozdzielaniu kodu. Powszechnie znane są zalety możliwości wielokrotnego wykorzystywania kodu. Z naszych doświadczeń wynika, że wielokrotne wykorzystywanie kodu prawdopodobnie nie powinno być głównym celem jego pisania, ale myślenie o możliwościach 4337ebf6db5c7cc89e4173803ef3875a 4 168 Rozdział 5. Zegnij lub złam płynących ze stosowania takiego podejścia powinno być częścią praktyki kodowania. Kiedy staramy się tworzyć kod wielokrotnego użytku, definiujemy w nim czytelne interfejsy, co pozwala oddzielić określony moduł od pozostałej części kodu. To pozwala wyodrębnić metodę lub moduł bez konieczności dołączania do nich innych elementów. Jeśli Twój kod wykorzystuje dane globalne, to oddzielenie jednego modułu od reszty staje się trudne. Problem ten jest widoczny podczas pisania testów jednostkowych kodu, który korzysta z danych globalnych. W takiej sytuacji tylko po to, aby uruchomić test, trzeba napisać mnóstwo kodu odpowiedzialnego za skonfigurowanie globalnego środowiska. WSKAZÓWKA NR 47 Unikaj globalnych danych. Singletony to też dane globalne W poprzednim punkcie zachowywaliśmy ostrożność — mówiliśmy o globalnych danych, a nie o globalnych zmiennych. To dlatego, że często spotykamy ludzi, którzy mówią nam „Popatrzcie! Nie mamy zmiennych globalnych. Wszystko opakowaliśmy jako dane instancji w obiekcie singletona lub globalnym module”. Przeczytaj to jeszcze raz. Tym razem powoli. Jeśli masz w aplikacji singleton zawierający zbiór zmiennych egzemplarza, to nadal są to dane globalne. Mają jedynie bardziej złożoną nazwę. Programiści biorą ten singleton i ukrywają wszystkie dane za metodami. Zamiast wywołań Config.log_level stosują wywołania Config.log_level() lub Config.getLog Level(). To nieco lepsze podejście, ponieważ dostęp do globalnych danych jest realizowany za pośrednictwem jakiejś warstwy abstrakcji. Jeśli zdecydujemy się zmienić reprezentację poziomów logowania, będziemy mogli zachować zgodność ze starym kodem poprzez mapowanie pomiędzy nową i starą wersją za pośrednictwem interfejsu API konfiguracji. Jednak w dalszym ciągu mamy tylko jeden zbiór danych konfiguracyjnych. Zewnętrzne zasoby to także dane globalne Wszelkie zmienne zasoby zewnętrzne to dane globalne. Jeśli Twoja aplikacja korzysta z bazy danych, magazynu danych, systemu plików, API usługi, to w dalszym ciągu jest narażona na wpadnięcie w pułapkę globalizacji. Również w tym przypadku rozwiązaniem jest opakowanie tych zasobów kodem, który możemy kontrolować. WSKAZÓWKA NR 48 Jeśli jakiś zasób jest na tyle ważny, aby był globalny, opakuj go interfejsem API. 4337ebf6db5c7cc89e4173803ef3875a 4 Eliminowanie sprzężeń 169 Dziedziczenie dodaje sprzężenia Nadużywanie dziedziczenia w taki sposób, że klasa dziedziczy stan i zachowanie z innej klasy, jest na tyle ważne, że zagadnienie to omówimy w osobnym punkcie — jest nim temat 31., „Podatek od dziedziczenia”, w dalszej części rozdziału. Najważniejsza jest możliwość wprowadzania zmian W kodzie zawierającym sprzężenia wprowadzanie zmian jest trudne: zmiany w jednym miejscu mogą powodować skutki uboczne w innych miejscach w kodzie, często w trudnych do znalezienia. Takie błędy mogą ujawnić się dopiero po wprowadzeniu kodu do produkcji — często za miesiąc lub po jeszcze dłuższym czasie od opublikowania kodu. Utrzymywanie skromnego kodu — takiego, który wchodzi w interakcje tylko z tymi komponentami, które bezpośrednio zna, pomaga wyeliminować z aplikacji sprzężenia, dzięki czemu wprowadzanie zmian w kodzie będzie łatwiejsze. Pokrewne podrozdziały 2 Temat 8., „Istota dobrego projektu”. Temat 9., „DRY— Przekleństwo powielania”. Temat 10., „Ortogonalność”. Temat 11., „Odwracalność”. Temat 29., „Żonglerka realnym światem”. Temat 30., „Programowanie transformacyjne”. Temat 31., „Podatek od dziedziczenia”. Temat 32., „Konfiguracja”. Temat 33., „Eliminowanie związków czasowych”. Temat 34., „Współdzielony stan jest zły”. Temat 35., „Aktorzy i procesy”. Temat 36., „Czarne tablice”. Zasadę TDA omówiliśmy w artykule The Art of Enbugging, opublikowanym w cyklu Software Construction w magazynie ☻IEEE Software” w 2003 roku2. https://media.pragprog.com/articles/jan_03_enbug.pdf 4337ebf6db5c7cc89e4173803ef3875a 4 170 29 37 Rozdział 5. Zegnij lub złam Żonglerka realnym światem Rzeczy nie dzieją się ot tak. Ktoś lub coś za tym stoi. John F. Kennedy W dawnych czasach, kiedy autorzy tej książki mieli jeszcze chłopięcy wygląd, komputery nie były szczególnie elastyczne. Zwykle organizowaliśmy interakcje z nimi na podstawie znanych ograniczeń. Dziś oczekujemy od komputerów więcej: chcemy, by zintegrowały się z realnym światem, a nie na odwrót, żeby świat zintegrował się z nimi. A w naszym świecie panuje bałagan: ciągle się coś dzieje, rzeczy zmieniają miejsce, ludzie zmieniają zdania… A aplikacje, które piszemy, muszą w jakiś sposób ustalić, co trzeba zrobić. Ten podrozdział dotyczy sposobów pisania responsywnych aplikacji. Zaczniemy od omówienia pojęcia zdarzenia. Zdarzenia Zdarzenie reprezentuje dostępność informacji. Może ona pochodzić ze świata zewnętrznego: użytkownik kliknął przycisk lub wprowadzono aktualizację notowania ceny akcji. Zdarzenie może pochodzić z wnętrza aplikacji: wynik obliczeń jest gotowy, zakończyło się wyszukiwanie. Może ono nawet dotyczyć czegoś tak trywialnego, jak pobranie następnego elementu z listy. Aplikacje, które reagują na zdarzenia i na ich podstawie dostosowują swoje działania, w świecie rzeczywistym działają lepiej od tych, które zdarzeń nie wykorzystują, niezależnie od źródła informacji. Są one bardziej interaktywne i w bardziej ekonomiczny sposób korzystają z zasobów. Ale jak można pisać takie aplikacje? Bez konkretnej strategii, wszystko szybko by się nam pomyliło, a nasze aplikacje stałyby się bezładnym zlepkiem ściśle sprzężonego kodu. Przyjrzyjmy się czterem strategiom, które mogą nam pomóc w pisaniu aplikacji obsługujących zdarzenia. 1. Maszyna stanów skończonych. 2. Wzorzec Obserwator. 3. Publikowanie i subskrypcje. 4. Programowanie reaktywne i strumienie. 4337ebf6db5c7cc89e4173803ef3875a 4 Żonglerka realnym światem 171 Maszyny stanów skończonych Dave twierdzi, że kod z wykorzystaniem maszyny stanów skończonych (ang. Finite State Machine — FSM) pisze niemal każdego tygodnia. Dość często implementacja maszyny FSM ma tylko kilka linijek kodu, ale te kilka linijek pomaga rozwikłać mnóstwo potencjalnego bałaganu. Korzystanie z FSM jest trywialnie proste, a jednak wielu programistów niechętnie je stosuje. Panuje przekonanie, że są one trudne, że mają zastosowanie tylko w przypadku pracy ze sprzętem, albo że wymagają skorzystania z trudnych do zrozumienia bibliotek. Nic z tych rzeczy. Anatomia pragmatycznej maszyny stanów skończonych Maszyna stanów w gruncie rzeczy jest jedynie specyfikacją sposobu obsługi zdarzeń. Składa się ze zbioru stanów, z których jeden jest stanem bieżącym. Dla każdego stanu określamy listę zdarzeń, które są dla niego istotne. Dla każdego z tych zdarzeń definiujemy nowy bieżący stan systemu. Na przykład możemy otrzymywać wieloczęściowe komunikaty z gniazda sieciowego. Pierwszy komunikat to nagłówek. Za nim następuje dowolna liczba komunikatów danych, a następnie komunikat końcowy. Taki system możemy zaprezentować za pomocą następującej maszyny stanów: Zaczynamy od stanu Initial. Jeśli odbierzemy nagłówek, przechodzimy do stanu Reading. Jeśli otrzymamy cokolwiek innego podczas gdy jesteśmy w stanie początkowym (linia oznaczona gwiazdką), to przechodzimy do stanu Error i kończymy działanie. Gdy jesteśmy w stanie Reading, możemy zaakceptować dowolne komunikaty danych. W tym przypadku kontynuujemy czytanie w tym samym stanie. Możemy też przyjąć komunikat końcowy (trailer), co spowoduje przejście do stanu Done. Dowolne inne dane spowodują przejście do stanu Error. 4337ebf6db5c7cc89e4173803ef3875a 4 172 Rozdział 5. Zegnij lub złam Interesującą cechą maszyn FSM, dzięki której są one bardzo „zgrabne”, jest możliwość zaprezentowania całej maszyny stanów wyłącznie za pomocą danych. Oto tabela reprezentująca nasz parser komunikatów: Stan Zdarzenia header data trailer inne Initial Reading Error Error Error Reading Error Reading Done Error Wiersze w tabeli reprezentują stany. Aby dowiedzieć się, co zrobić, gdy wystąpi zdarzenie, poszukaj wiersza reprezentującego bieżący stan, a następnie znajdź kolumnę reprezentującą zdarzenie. Zawartość tej komórki to nowy stan maszyny. Kod, który obsługuje maszynę stanów, jest równie prosty: event/simple_fsm.rb Line 1 TRANSITIONS = { initial: {header: :reading}, reading: {data: :reading, trailer: :done}, } 5 state = :initial while state != :done && state != :error msg = get_next_message() 10 state = TRANSITIONS[state][msg.msg_type] || :error end Kod implementujący przejścia pomiędzy stanami znajduje się w wierszu 10. Wykorzystano w nim odwołanie do tabeli TRANSITIONS przy użyciu indeksu o wartości bieżącego stanu, a następnie użyto indeksu przejścia dla tego stanu o wartości reprezentowanej przez typ komunikatu. W przypadku braku pasującego nowego stanu, maszyna przechodzi do stanu :error. Dodawanie akcji Czysta maszyna FSM, taka jak pokazana w powyższym przykładzie, to parser strumienia zdarzeń. Jego działanie sprowadza się do wyświetlania stanu końcowego. Możemy ją rozbudować poprzez dodanie akcji, które zostaną uruchomione przy niektórych przejściach. Na przykład możemy wyodrębnić wszystkie ciągi z pliku źródłowego. Ciąg jest tekstem ujętym w cudzysłów, ale lewy ukośnik w ciągu jest znakiem ucieczki dla następnego znaku. Zgodnie z tym ciąg "Ignoruj \"cudzysłów"" to jeden ciąg znaków. Oto maszyna stanów, która realizuje takie działania: 4337ebf6db5c7cc89e4173803ef3875a 4 Żonglerka realnym światem 173 Tym razem każde przejście jest identyfikowane przez dwie etykiety. Górna oznacza zdarzenie, które wyzwala przejście, a dolna reprezentuje działanie, jakie należy wykonać przy przejściu pomiędzy stanami. Tak jak poprzednim razem, zaprezentujemy tę maszynę stanów w tabeli. Jednak w tym przypadku każdy wpis w tabeli to dwuelementowa lista zawierająca następny stan oraz nazwę operacji: event/strings_fsm.rb TRANSITIONS = { # current new state action to take #--------------------------------------------------------look_for_string: { '"' => [ :in_string, :start_new_string ], :default => [ :look_for_string, :ignore ], }, in_string: { '"' => [ :look_for_string, :finish_current_string ], '\\' => [ :copy_next_char, :add_current_to_string ], :default => [ :in_string, :add_current_to_string ], }, copy_next_char: { :default => [ :in_string, :add_current_to_string ], }, } Dodaliśmy również możliwość określenia domyślnego przejścia, które jest realizowane w przypadku, gdy zdarzenie nie pasuje do żadnego z pozostałych przejść dla tego stanu. Teraz przyjrzyjmy się kodowi: event/strings_fsm.rb state = :look_for_string result = [] while ch = STDIN.getc state, action = TRANSITIONS[state][ch] || TRANSITIONS[state][:default] case action when :ignore when :start_new_string result = [] 4337ebf6db5c7cc89e4173803ef3875a 4 174 Rozdział 5. Zegnij lub złam when :add_current_to_string result << ch when :finish_current_string puts result.join end end Ten kod przypomina poprzedni przykład w tym sensie, że iterujemy po zdarzeniach (znakach na wejściu) i wyzwalamy przejścia. Ale ten kod robi więcej niż poprzedni. Wynikiem każdego przejścia jest zarówno nowy stan, jak i nazwa operacji do wykonania. Używamy nazwy akcji w celu wskazania kodu, który zostanie uruchomiony, zanim wrócimy do przetwarzania w pętli. Ten kod jest bardzo prosty, ale wykonuje potrzebne działania. Istnieje wiele innych wariantów: tabela przejść może używać dla akcji anonimowych funkcji lub wskaźników na funkcje; można opakować kod implementujący maszynę stanów w oddzielnej klasie, z własnym stanem i tak dalej. Nie trzeba dodawać, że nie jest konieczne zaimplementowanie wszystkich przejść stanów w tym samym czasie. Jeśli wykonujesz procedurę rejestracji użytkownika w aplikacji, prawdopodobnie maszyna stanów będzie zawierała kilka przejść, wykonywanych, kiedy użytkownik wprowadza swoje dane, potwierdza adres e-mail, akceptuje ostrzeżenia związane ze 107 różnymi aktami prawnymi i tak dalej. Utrzymanie stanu w pamięci zewnętrznej i wykorzystywanie jej do sterowania maszyną stanów to świetny sposób, aby obsłużyć wymagania tego rodzaju przepływów pracy. Maszyny stanów to dobry początek Maszyny stanów są niedostatecznie często wykorzystywane przez programistów. Zachęcamy do poszukiwania możliwości ich zastosowania. Trzeba jednak pamiętać, że zastosowanie maszyn stanów nie rozwiąże wszystkich problemów związanych ze zdarzeniami. Przejdźmy więc do omówienia innych sposobów obsługi problemów związanych z żonglerką zdarzeniami. Wzorzec Obserwator We wzorcu projektowym Obserwator mamy źródło zdarzeń zwane obiektem obserwowalnym oraz listę klientów — obserwatorów, zainteresowanych tymi zdarzeniami. Obserwator rejestruje swoje zainteresowanie obiektem obserwowalnym, zazwyczaj poprzez przekazanie referencji do funkcji, która ma być wywołana w odpowiedzi na zdarzenie. Gdy wystąpi zdarzenie, obiekt obserwowalny iteruje po liście swoich obserwatorów i wywołuje funkcje, które każdy z nich do niego przesłał. Zdarzenie jest przekazywane do wywołania w postaci parametru. Oto prosty przykład w Ruby. Do zakończenia działania aplikacji wykorzystywany jest moduł Terminator. Jednak przed zakończeniem aplikacji, moduł ten infor- 4337ebf6db5c7cc89e4173803ef3875a 4 Żonglerka realnym światem 175 muje wszystkich swoich obserwatorów, że aplikacja ma się zakończyć3. Mogą oni skorzystać z tego powiadomienia, aby posprzątać tymczasowe zasoby, zatwierdzić dane i tak dalej: event/observer.rb module Terminator CALLBACKS = [] def self.register(callback) CALLBACKS << callback end def self.exit(exit_status) CALLBACKS.each { |callback| callback.(exit_status) } exit!(exit_status) end end Terminator.register(-> (status) { puts "callback 1 widzi #{status}" }) Terminator.register(-> (status) { puts "callback 2 widzi #{status}" }) Terminator.exit(99) $ ruby event/observer.rb callback 1 widzi 99 callback 2 widzi 99 Stworzenie obiektu obserwowalnego nie wymaga zbyt wiele kodu: umieszczamy referencje do funkcji na liście, a następnie wywołujemy te funkcje, gdy wystąpi zdarzenie. Jest to dobry przykład sytuacji, kiedy nie należy korzystać z biblioteki. Wzorzec obserwator – obiekt obserwowalny jest z powodzeniem stosowany od dziesięcioleci. Szczególnie często jest używany w systemach z interfejsem użytkownika, gdzie funkcje wywołań zwrotnych są wykorzystywane do poinformowania aplikacji o działaniach użytkownika. Jednak z wzorcem Obserwator związany jest pewien problem: konieczność, aby każdy obserwator zarejestrował się w obiekcie obserwowalnym, wprowadza sprzężenia. Ponadto, ze względu na to, że w typowej implementacji wywołania zwrotne są obsługiwane przez obiekt obserwowalny w trybie inline synchronicznie, mogą powstawać wąskie gardła. Problemy te rozwiązuje kolejna strategia: publikuj – subskrybuj. Publikowanie i subskrypcje Wzorzec publikuj – subskrybuj (pubsub) uogólnia wzorzec Obserwator, a jednocześnie rozwiązuje problemy sprzężeń i wydajności. W modelu pubsub mamy wydawców i subskrybentów. Są ze sobą powiązani za pomocą kanałów. Kanały są zaimplementowane w odrębnym kodzie — czasami jest to biblioteka, czasami proces, a czasami infrastruktura rozproszona. Wszystkie te szczegóły implementacji nie są widoczne z kodu. 3 Tak, wiemy, że Ruby ma już taką możliwość zaimplementowaną za pomocą funkcji at_exit. 4337ebf6db5c7cc89e4173803ef3875a 4 176 Rozdział 5. Zegnij lub złam Każdy kanał ma nazwę. Subskrybenci rejestrują zainteresowanie co najmniej jednym z tych identyfikowanych przez nazwę kanałów, a wydawcy zapisują do nich zdarzenia. W odróżnieniu od wzorca Obserwator, komunikacja pomiędzy wydawcą a subskrybentem jest obsługiwana poza kodem i jest potencjalnie asynchroniczna. Chociaż można samodzielnie zaimplementować bardzo prosty system pubsub, to jednak zwykle się tego nie robi. Większość dostawców chmury obliczeniowej ma w swojej ofercie usługę pubsub, do której mogą się łączyć aplikacje na całym świecie. Każdy popularny język programowania ma co najmniej jedną bibliotekę pubsub. Pubsub jest dobrą technologią umożliwiającą rozdzielenie obsługi asynchronicznych zdarzeń. Pozwala na dodawanie i zastępowanie kodu — potencjalnie nawet wtedy, gdy aplikacja jest uruchomiona — bez zmiany istniejącego kodu aplikacji. Minusem jest to, że w systemie, który intensywnie korzysta z techniki pubsub, może być trudne zaobserwowanie co się dzieje: nie da się spojrzeć w kod wydawcy i natychmiast zobaczyć subskrybentów, którzy są zainteresowani określonym komunikatem. W porównaniu z wzorcem Obserwator, technika pubsub jest doskonałym przykładem zmniejszenia liczby sprzężeń poprzez wyabstrahowanie współdzielonego interfejsu (kanału). Jednak w gruncie rzeczy nadal jest to jedynie system przekazywania komunikatów. Tworzenie systemów, które reagują na kombinacje zdarzeń, będzie wymagało bardziej złożonego mechanizmu. Przyjrzyjmy się zatem sposobom wprowadzenia do przetwarzania zdarzeń wymiaru czasowego. Programowanie reaktywne, strumienie i zdarzenia Jeśli kiedykolwiek korzystałeś z arkusza kalkulacyjnego, to prawdopodobnie wiesz, czym jest programowaniem reaktywne. Jeżeli komórka zawiera formułę, która odwołuje się do innej komórki, to aktualizacja tej drugiej komórki powoduje również aktualizację pierwszej. Wartości komórek z formułami reagują na zmiany wartości używanych do ich wyliczania. Istnieje wiele frameworków, które mogą pomóc w obsłudze tego rodzaju reaktywności na poziomie danych: w świecie przeglądarek faworytami są obecnie React i Vue.js (ale ponieważ to jest JavaScript, to zanim ta książka ukaże się w druku, ta informacja może już być nieaktualna). To jasne, że zdarzenia mogą być również wykorzystywane do wyzwalania reakcji w kodzie, ale ich zastosowanie nie zawsze jest łatwe. W takich przypadkach możemy skorzystać ze strumieni. Strumienie pozwalają traktować zdarzenia tak, jakby były zbiorem danych. Mechanizm działa tak, jakbyśmy mieli listę zdarzeń, która wydłuża się wraz 4337ebf6db5c7cc89e4173803ef3875a 4 Żonglerka realnym światem 177 z napływem nowych zdarzeń. Elegancja takiego rozwiązania polega na tym, że możemy traktować strumienie podobnie, jak wszystkie inne kolekcje: możemy je przetwarzać, scalać, filtrować oraz wykonywać wszystkie inne dobrze znane operacje typowe dla kolekcji danych. Możemy nawet łączyć strumienie zdarzeń ze zwykłymi kolekcjami. Do tego strumienie mogą być asynchroniczne, co oznacza, że kod może reagować na zdarzenia w miarę ich napływu. Bieżący bazowy standard de facto dla reaktywnej obsługi zdarzeń zdefiniowano na stronie http://reactivex.io. Określono tam niezależny od języka zestaw zasad i udokumentowano kilka popularnych implementacji. W tym podrozdziale skorzystamy z biblioteki RxJs dla języka JavaScript. W pierwszym przykładzie pobierzemy dwa strumienie i połączymy je ze sobą: w wyniku otrzymamy nowy strumień, w którym każdy składnik zawiera jeden element z pierwszego strumienia wejściowego i jeden z drugiego. W tym przypadku, pierwszy strumień jest po prostu listą nazw pięciu zwierząt. Drugi strumień jest bardziej interesujący: jest to timer, który generuje zdarzenie co 500 ms. Ponieważ strumienie są ze sobą scalone, wynik jest generowany tylko wtedy, gdy dane są dostępne w obu strumieniach. Zatem strumień wynikowy emituje wartości co pół sekundy: event/rx0/index.js import * as Observable from 'rxjs' import { logValues } from "../rxcommon/logger.js" let animals = Observable.of("ant", "bee", "cat", "dog", "elk") let ticker = Observable.interval(500) let combined = Observable.zip(animals, ticker) combined.subscribe(next => logValues(JSON.stringify(next))) Powyższy kod wykorzystuje prostą funkcję logowania4, która dodaje elementy do listy wyświetlanej w oknie przeglądarki. Z każdym elementem jest powiązany stempel czasu zawierający czas w milisekundach, jaki upłynął od chwili uruchomienia programu. Oto zawartość okna przeglądarki dla naszego kodu: 4 https://media.pragprog.com/titles/tpp20/code/event/rxcommon/logger.js 4337ebf6db5c7cc89e4173803ef3875a 4 178 Rozdział 5. Zegnij lub złam Zwróćmy uwagę na stemple czasu: otrzymujemy jedno zdarzenie ze strumienia co 500 ms. Każde zdarzenie zawiera numer seryjny (utworzony przez interwał obiektu obserwowalnego) i nazwę następnego zwierzęcia z listy. Podczas oglądania działania kodu na żywo w przeglądarce, wiersze logu pojawiają się co pół sekundy. Strumienie zdarzeń są zwykle wypełniane w miarę występowania zdarzeń, co oznacza, że obiekty obserwowalne, które te strumienie wypełniają, mogą działać równolegle. Oto przykład kodu, który pobiera ze zdalnej witryny informacje o użytkownikach. Na potrzeby tego kodu użyjemy publicznej witryny https://reqres.in, która dostarcza otwarty interfejs REST. Za pomocą tego API, możemy pobrać dane na temat danego użytkownika (fałszywego), kierując żądanie GET do punktu końcowego users/<<id>>. Nasz kod pobiera użytkowników o identyfikatorach 3, 2 i 1: event/rx1/index.js import * as Observable from 'rxjs' import { mergeMap } from 'rxjs/operators' import { ajax } from 'rxjs/ajax' import { logValues } from "../rxcommon/logger.js" let users = Observable.of(3, 2, 1) let result = users.pipe( mergeMap((user) => ajax.getJSON(`https://reqres.in/api/users/${user}`)) ) result.subscribe( resp => logValues(JSON.stringify(resp.data)), err => console.error(JSON.stringify(err)) ) Wewnętrzne szczegóły kodu nie są zbyt ważne. Ekscytujący jest wynik, pokazany na poniższym zrzucie ekranu: Spójrzmy na stemple czasu: trzy żądania lub trzy oddzielne strumienie były przetwarzane równolegle. Obsługa pierwszego, dotycząca użytkownika o identyfikatorze 2, zajęła 82 ms, a dwa kolejne zostały obsłużone 50 i 51 ms później. Strumienie zdarzeń to kolekcje asynchroniczne W poprzednim przykładzie lista identyfikatorów użytkowników (w obserwowalnym obiekcie users) była statyczna. Ale nie musi taka być. Załóżmy, że chcemy zebrać te informacje, kiedy użytkownicy logują się w naszej witrynie. Wszystko, 4337ebf6db5c7cc89e4173803ef3875a 4 Żonglerka realnym światem 179 co musimy zrobić, to wygenerowanie obserwowalnego zdarzenia zawierający identyfikator użytkownika podczas tworzenia sesji i korzystanie z tego obserwowalnego obiektu zamiast statycznego. Następnie będziemy pobierać dane o użytkownikach w miarę odbierania identyfikatorów i przypuszczalnie gdzieś je przechowywać. Taka abstrakcja daje bardzo duże możliwości: nie musimy już myśleć o czasie jako o czymś, czym powinniśmy zarządzać. Strumienie zdarzeń unifikują przetwarzanie synchroniczne i asynchroniczne za wspólnym, wygodnym interfejsem API. Zdarzenia są wszechobecne Zdarzenia występują wszędzie. Niektóre z nich są oczywiste: kliknięcie przycisku, upływ interwału czasowego. Inne są mniej trywialne ktoś się loguje, wiersz w pliku pasuje do wzorca. Ale bez względu na ich źródło, kod, który jest wykonywany w odpowiedzi na zdarzenia, jest bardziej responsywny i lepiej oddzielony od swojego liniowego odpowiednika. Pokrewne podrozdziały Temat 28., „Eliminowanie sprzężeń”. Temat 36., „Czarne tablice”. Ćwiczenia 19. W podrozdziale poświęconym maszynom stanów skończonych wspomnieliśmy, że można przenieść generyczną implementację maszyny stanów do odrębnej klasy. Tę klasę można by zainicjować poprzez przekazanie tabeli przejść i stanu początkowego. Spróbuj zaimplementować w ten sposób aplikację do wyodrębniania ciągów znaków. 20. Zastosowanie której z opisanych technologii (być może kilku) byłoby dobrym rozwiązaniem w następujących sytuacjach: Jeśli w ciągu pięciu minut otrzymasz informacje o zdarzeniach niedostępności trzech interfejsów sieciowych, powiadom personel operacyjny. Jeśli po zachodzie słońca wykryjesz ruch na dole schodów, po czym wykryjesz ruch u szczytu schodów, włącz światła na piętrze. Chcesz powiadomić różne systemy raportowania, że obsługa zamówienia została zakończona. W celu ustalenia, czy klient kwalifikuje się do uzyskania kredytu na samochód, aplikacja musi wysłać żądania do trzech usług typu backend i poczekać na odpowiedzi. 4337ebf6db5c7cc89e4173803ef3875a 4 180 30 38 Rozdział 5. Zegnij lub złam Programowanie transformacyjne Jeśli nie potrafisz opisać tego, co robisz, w formie procesu, to nie wiesz, co robisz. W. Edwards Deming Wszystkie programy przekształcają dane — dokonują konwersji danych wejściowych na wyniki. A jednak, kiedy myślimy o projekcie, rzadko uwzględniamy tworzenie transformacji. Zamiast tego martwimy się o klasy i moduły, struktury danych i algorytmy, języki i frameworki. Uważamy, że ta koncentracja na kodzie sprawia, że gubimy sedno problemu: musimy wrócić do myślenia o programach jako mechanizmach przekształcających dane wejściowe na wyniki. Kiedy to zrobimy, wiele problemów, o które wcześniej się martwiliśmy, po prostu zniknie. Struktura stanie się czytelniejsza, obsługa błędów bardziej spójna, a sprzężenia spadną na dalszy plan. Aby rozpocząć naszą analizę, cofnijmy się w czasie do roku 1970. Poprosimy tam programistę Uniksa, aby napisał dla nas program, który wyświetla pięć najdłuższych plików w drzewie katalogów, gdzie „najdłuższy” oznacza „mający największą liczbę wierszy”. Moglibyście się spodziewać, że otworzy edytor tekstu i zacznie pisać kod w C. On jednak tego nie zrobi, bo myśli o rozwiązaniu problemu w kategoriach tego, co mamy (drzewo katalogów), i tego, co chcemy uzyskać (listę plików). Potem otworzy terminal i wpisze polecenie podobne do następującego: $ find . -type f | xargs wc -l | sort -n | tail -5 Powyższa komenda zawiera ciąg transformacji: find . -type f Wpisz do standardowego wyjścia listę wszystkich plików (-type f) z bieżącego katalogu (.) i jego podkatalogów. xargs wc -l Czytaj wiersze ze standardowego wejścia i przygotuj je w taki sposób, aby wszystkie zostały przekazane w formie argumentów do polecenia wc -l. Program wc z opcją -l zlicza wiersze w każdym z plików przekazanych jako argument i zapisuje każdy wynik na standardowym wyjściu jako licznik dla tego pliku. sort -n Komenda sort sortuje standardowe wejście zakładając, że każdy wiersz zaczyna się od liczby (-n) i zapisuje wynik na standardowym wyjściu. tail -5 Czyta standardowe wejście i zapisuje na standardowym wyjściu tylko ostatnich pięć wierszy. 4337ebf6db5c7cc89e4173803ef3875a 4 Programowanie transformacyjne 181 Po uruchomieniu tego polecenia w katalogu z plikami naszej książki otrzymaliśmy: 470 ./test_to_build.pml 487 ./dbc.pml 719 ./domain_languages.pml 727 ./dry.pml 9561 total Ostatni wiersz oznacza całkowitą liczbę wierszy we wszystkich plikach (nie tylko tych, które wyświetlono w wyniku), ponieważ właśnie w ten sposób działa polecenie wc. Możemy obciąć ten wiersz poprzez zażądanie jednego wiersza więcej z ogona, a następnie zignorowanie ostatniego wiersza: $ find . -type f | xargs wc -l | sort -n | tail -6 | head -5 470 ./debug.pml 470 ./test_to_build.pml 487 ./dbc.pml 719 ./domain_languages.pml 727 ./dry.pml Przyjrzyjmy się temu poleceniu z perspektywy danych, które przepływają pomiędzy poszczególnymi krokami. Nasze pierwotne wymaganie, „pierwszych 5 plików o największej liczbie wierszy”, zostaje przekształcone na cykl transformacji (pokazano je również na rysunku 5.1): Nazwa katalogu → lista plików → lista z numerami wierszy → lista posortowana → lista pięciu plików o najwyższej liczbie wierszy plus wiersz z sumą → lista pięciu plików o najwyższej liczbie wierszy Rysunek 5.1. Potok polecenia find jako ciąg transformacji 4337ebf6db5c7cc89e4173803ef3875a 4 182 Rozdział 5. Zegnij lub złam Procedura przypomina przemysłową linię produkcyjną: wprowadzamy surowe dane na jednym końcu, a z drugiej strony wychodzi gotowy produkt (informacje). Warto myśleć w ten sposób o każdym kodzie. WSKAZÓWKA NR 49 Programowanie dotyczy kodu, ale programy dotyczą danych. Wyszukiwanie transformacji Czasami najprostszym sposobem na znalezienie transformacji jest wyjście od wymagania i określenie dla niego wejść i wyjść. Potem należy zdefiniować funkcję reprezentującą cały program. Następnie trzeba znaleźć kroki, które doprowadzą Cię od wejścia do wyjścia. Jest to podejście góra-dół (ang. top-down). Załóżmy na przykład, że chcemy stworzyć stronę internetową dla ludzi grających w gry słowne, wyszukującą wszystkie słowa, które można stworzyć z zestawu liter. Nasze wejście to zbiór liter, a wyjściem jest lista słów trzyliterowych, czteroliterowych i tak dalej: "lvyin" zostaje przekształcone na 3 => ivy, lin, nil, yin 4 => inly, liny, viny 5 => vinyl (Tak, te wszystkie ciągi są słowami, przynajmniej zgodnie ze słownikiem systemu macOS). Idea całej aplikacji jest prosta: mamy słownik, który grupuje słowa według sygnatury dobranej w taki sposób, że wszystkie słowa zawierające te same litery mają taką samą sygnaturę. Najprostsza funkcja sygnatury to po prostu posortowana lista liter w słowie. Możemy następnie poszukać ciągu wejściowego poprzez wygenerowanie dla niego sygnatury, a potem zobaczyć, które słowa (jeśli takie istnieją) w słowniku mają taką samą sygnaturę. Zatem program do wyszukiwania anagramów można podzielić na cztery oddzielne transformacje: 4337ebf6db5c7cc89e4173803ef3875a 4 Programowanie transformacyjne 183 Krok Transformacja Przykładowe dane Krok 0. Początkowe dane wejściowe "ylvin" Krok 1. Wszystkie kombinacje trzech lub więcej liter vin, viy, vil, vny, vnl, vyl, iny, inl, iyl, nyl, viny, vinl, viyl, vnyl, inyl, vinyl Krok 2. Sygnatury kombinacji liter inv, ivy, ilv, nvy, lnv, lvy, iny, iln, ily, lny, invy, ilnv, ilvy, lnvy, ilny, ilnvy Krok 3. Lista wszystkich słów ze słownika, które pasują do dowolnej sygnatury ivy, yin, nil, lin, viny, liny, inly, vinyl Krok 4. Słowa pogrupowane według długości 3 => ivy, lin, nil, yin 4 => inly, liny, viny 5 => vinyl Kolejne transformacje Zacznijmy od przeanalizowania kroku 1., w którym pobieramy słowo i tworzymy listę wszystkich kombinacji złożonych z trzech lub więcej liter. Ten krok także można zaprezentować jako listę transformacji: Krok Transformacja Przykładowe dane Krok 1.0 Początkowe dane wejściowe "vinyl" Krok 1.1 Konwersja na znaki v, i, n, y, l Krok 1.2 Wyznaczenie wszystkich podzbiorów [], [v], [i], … [v,i], [v,n], [v,y], … [v,i,n], [v,i,y], …[v,n,y,l], [i,n,y,l], [v,i,n,y,l] Krok 1.3 Wybranie tych podzbiorów, które zawierają więcej niż trzy znaki [v,i,n], [v,i,y], … [i,n,y,l], [v,i,n,y,l] Krok 1.4 Ponowna konwersja na ciągi znaków [vin,viy, … inyl,vinyl] Dotarliśmy do miejsca, w którym możemy łatwo zaimplementować każdą transformację w kodzie (w tym przypadku w języku Elixir): function-pipelines/anagrams/lib/anagrams.ex defp all_subsets_longer_than_three_characters(word) do word |> String.codepoints() |> Comb.subsets() |> Stream.filter(fn subset -> length(subset) >= 3 end) |> Stream.map(&List.to_string(&1)) end 4337ebf6db5c7cc89e4173803ef3875a 4 184 Rozdział 5. Zegnij lub złam Co z operatorem |>? Eliksir, a także wiele innych języków funkcyjnych, ma operator potoku, czasami nazywany potokiem „w przód” lub po prostu potokiem5. Jego działanie sprowadza się do przyjmowania wartości po jego lewej stronie i wstawiania jej jako pierwszego parametru do funkcji po prawej stronie operatora. Tak więc zapis: "vinyl" |> String.codepoints |> Comb.subsets() jest równoważny zapisowi: Comb.subsets(String.codepoints("vinyl")) (W innych językach ta przekazywana przez potok wartość może być wstrzyknięta jako ostatni parametr kolejnej funkcji — w dużej mierze zależy to od stylu wbudowanych bibliotek). Można by pomyśleć, że jest to po prostu składniowy cukier. Ale operator potoku jest okazją do prawdziwie rewolucyjnej zmiany sposobu myślenia. Zastosowanie potoku sprawia, że automatycznie myślimy o programie w kategoriach przekształcania danych. Za każdym razem, gdy w kodzie widzimy operator |>, faktycznie widzimy miejsce, w którym dane przepływają pomiędzy jedną transformacją, a następną. Podobny operator istnieje w wielu językach. W Elm i F# jest operator |>, w Clojure są operatory -> i ->> (działające nieco inaczej), w R jest operator %>%. W Haskellu istnieje zarówno operator potoku, jak i mechanizmy pozwalające na dodawanie nowych takich operatorów. W czasie, gdy powstaje ta książka, toczy się dyskusja o dodaniu operatora |> do języka JavaScript. Jeśli Twój bieżący język obsługuje podobny operator, masz szczęście. Jeśli nie obsługuje, zajrzyj do ramki „Język X nie obsługuje potoków”. Tak czy inaczej, wróćmy do kodu. Dalsze transformacje Przyjrzyjmy się teraz krokowi 2. programu głównego, gdzie przekształcamy podzbiory znaków na sygnatury. Ponownie mamy do czynienia z prostą transformacją — na podstawie listy podzbiorów znaków tworzymy listę sygnatur: 5 Pierwsze użycie znaków |> jako oznaczenia potoku zanotowano w 1994 roku, w dyskusji na temat języka Isabelle/ML. Dyskusję tę zarchiwizowano pod adresem https://blogs. msdn.microsoft.com/dsyme/2011/05/17/archeological-semiotics-the-birth-of-the-pipelinesymbol-1994/. 4337ebf6db5c7cc89e4173803ef3875a 4 Programowanie transformacyjne Krok Transformacja Przykładowe dane Krok 2.0 Początkowe dane wejściowe vin, viy, … inyl, vinyl Krok 2.1 Konwersja na sygnatury inv, ivy … ilny, inlvy 185 Kod w języku Elixir na poniższym listingu jest równie prosty: function-pipelines/anagrams/lib/anagrams.ex defp as_unique_signatures(subsets) do subsets |> Stream.map(&Dictionary.signature_of/1) end Teraz przekształcamy tę listę sygnatur: każdą z nich mapujemy na listę znanych wyrazów o tej samej sygnaturze lub na wartość nil, jeśli nie ma takich słów. Następnie musimy usunąć wartości nil i spłaszczyć zagnieżdżone listy do jednego poziomu: function-pipelines/anagrams/lib/anagrams.ex defp find_in_dictionary(signatures) do signatures |> Stream.map(&Dictionary.lookup_by_signature/1) |> Stream.reject(&is_nil/1) |> Stream.concat(&(&1)) end Krok 4., pogrupowanie słów według długości, to kolejna prosta transformacja powodująca konwersję naszej listy na mapę, w której kluczami są długości wyrazów, a wartościami wszystkie słowa o tej długości: function-pipelines/anagrams/lib/anagrams.ex defp group_by_length(words) do words |> Enum.sort() |> Enum.group_by(&String.length/1) end Połączenie transformacji w całość Napisaliśmy kod dla każdej pojedynczej transformacji. Pozostało je wszystkie połączyć w głównej funkcji aplikacji: function-pipelines/anagrams/lib/anagrams.ex def anagrams_in(word) do word |> all_subsets_longer_than_three_characters() |> as_unique_signatures() |> find_in_dictionary() |> group_by_length() end Czy to działa? Spróbujmy: iex(1)> Anagrams.anagrams_in "lyvin" %{ 4337ebf6db5c7cc89e4173803ef3875a 4 186 Rozdział 5. Zegnij lub złam 3 => ["ivy", "lin", "nil", "yin"], 4 => ["inly", "liny", "viny"], 5 => ["vinyl"] } Język X nie obsługuje potoków Potoki istnieją od dłuższego czasu, ale tylko w niszowych językach. Znalazły się w głównym nurcie dopiero niedawno, a wiele popularnych języków nadal ich nie obsługuje. Dobrą wiadomością jest to, że myślenie w kategoriach transformacji nie wymaga składni konkretnego języka: jest to raczej filozofia projektowania. Jeśli język nie obsługuje potoków, nadal można konstruować kod w postaci transformacji, ale należy je zapisać w postaci ciągu operacji przypisania: const content = File.read(file_name); const lines = find_matching_lines(content, pattern) const result = truncate_lines(lines) To trochę bardziej uciążliwy sposób, ale pozwala on wykonać zadanie. Dlaczego potoki są takie wspaniałe? Przyjrzyjmy się jeszcze raz treści głównej funkcji: word |> all_subsets_longer_than_three_characters() |> as_unique_signatures() |> find_in_dictionary() |> group_by_length() Jest to po prostu ciąg transformacji niezbędnych do spełnienia naszego wymagania. Każda transformacja pobiera dane wejściowe z poprzedniej i przekazuje wynik do następnej. W ten sposób kod staje się bardzo bliski językowi naturalnemu. Ale jest coś ważniejszego. Jeśli znasz zasady programowania obiektowego, z pewnością przypominasz sobie wymaganie, aby ukryć dane, które powinny być hermetycznie zamknięte wewnątrz obiektów. Te obiekty następnie wzajemnie się ze sobą komunikują, przy okazji zmieniając sobie nawzajem stan. Wprowadza to sporo sprzężeń i sprawia, że modyfikowanie systemów obiektowych może być trudne. WSKAZÓWKA NR 50 Nie należy gromadzić stanów, tylko je przekazywać. W modelu transformacyjnym powinniśmy pamiętać o powyższej regule. Zamiast posługiwania się niewielkimi pulami danych rozsianych po całym systemie, powinniśmy myśleć o danych jak o potężnej rzece, która płynie. Dane stają się 4337ebf6db5c7cc89e4173803ef3875a 4 Programowanie transformacyjne 187 partnerem funkcjonalności: potok to sekwencja kod → dane → kod → dane… Dane nie są już przywiązane do konkretnej grupy funkcji, tak jak to ma miejsce w definicji klasy. Zamiast tego możemy swobodnie reprezentować postęp aplikacji przekształcającej dane wejściowe w wyniki. Dzięki temu możemy znacznie zmniejszyć liczbę sprzężeń: funkcję można wykorzystać (wielokrotnie) wszędzie tam, gdzie jej parametry pasują do wyników jakiejś innej funkcji. Oczywiście w dalszym ciągu występuje pewna liczba sprzężeń, ale z naszych doświadczeń wynika, że programowanie transformacyjne zapewnia łatwiejsze zarządzanie w porównaniu ze stylem obiektowym. A jeśli używasz języka z kontrolą typów, podczas próby połączenia dwóch niezgodnych ze sobą elementów, otrzymasz ostrzeżenia na etapie kompilacji. Co z obsługą błędów? Dotychczas nasze transformacje działały w świecie, w którym nic złego się nie działo. W jaki sposób możemy je wykorzystać w realnym świecie? Jak dodać całą logikę warunkową potrzebną do sprawdzania błędów w przypadku zbudowania liniowych łańcuchów wywołań funkcji? Istnieje wiele sposobów, aby to zrobić. Wszystkie bazują na prostej konwencji: nigdy nie przekazujemy surowych wartości pomiędzy transformacjami. Zamiast tego opakowujemy je w strukturę danych (lub typ), która jednocześnie informuje nas, czy opakowana wartość jest prawidłowa. Na przykład w Haskellu to opakowanie ma nazwę Maybe. W językach F# i Scala tę rolę pełni funkcja Option. Sposób wykorzystania tej koncepcji jest specyficzny dla języka. Na ogół jednak istnieją dwa podstawowe sposoby pisania kodu: można obsługiwać sprawdzanie błędów wewnątrz transformacji lub poza nimi. W języku Eliksir, którego używaliśmy do tej pory, nie ma takiej wbudowanej obsługi błędów. Dla naszych celów jest to dobre, ponieważ zaprezentujemy implementację od podstaw. Podobne rozwiązanie powinno działać w większości innych języków. Najpierw wybierz reprezentację Potrzebujemy dla naszego opakowania reprezentacji (struktury danych, która zawiera wartość lub informację o błędzie). Można użyć do tego struktur, ale Elixir stosuje do tego dość silną konwencję: funkcje zazwyczaj zwracają krotki zawierające albo {:ok, wartość}, albo {:error, powód}. Na przykład wywołanie File.open zwraca albo :ok i proces wejścia-wyjścia, albo :error i kod przyczyny błędu: iex(1)> File.open("/etc/passwd") {:ok, #PID<0.109.0>} iex(2)> File.open("/etc/wombat") {:error, :enoent} Podczas przekazywania danych przez potok użyjemy krotek :ok (:error). 4337ebf6db5c7cc89e4173803ef3875a 4 188 Rozdział 5. Zegnij lub złam Następnie obsłuż ją wewnątrz każdej transformacji Spróbujmy napisać funkcję, która zwraca wszystkie wiersze w pliku zawierające określony ciąg znaków, obcięte do pierwszych 20 znaków. Chcemy zapisać ją jako transformację, więc wejściem będą nazwa pliku i wyszukiwany ciąg, a wyjściem albo krotka :ok z listą wierszy, albo krotka :error z powodem błędu. Funkcja najwyższego poziomu powinna mieć postać podobną do następującej: function-pipelines/anagrams/lib/grep.ex def find_all(file_name, pattern) do File.read(file_name) |> find_matching_lines(pattern) |> truncate_lines() end Nie ma tu jawnej obsługi błędów, ale jeśli dowolny krok w potoku zwraca krotkę błędu, to potok zwróci ten błąd bez wykonywania funkcji, która występuje dalej w potoku6. Można to uzyskać za pomocą mechanizmu dopasowywania wzorców języka Elixir: function-pipelines/anagrams/lib/grep.ex defp find_matching_lines({:ok, content}, pattern) do content |> String.split(~r/\n/) |> Enum.filter(&String.match?(&1, pattern)) |> ok_unless_empty() end defp find_matching_lines(error, _), do: error # ---------defp truncate_lines({ :ok, lines }) do lines |> Enum.map(&String.slice(&1, 0, 20)) |> ok() end defp truncate_lines(error), do: error # ---------defp ok_unless_empty([]), do: error("nothing found") defp ok_unless_empty(result), do: ok(result) defp ok(result), do: { :ok, result } defp error(reason), do: { :error, reason } Spójrzmy na funkcję find_matching_lines. Jeśli jej pierwszym parametrem jest krotka :ok, funkcja używa zawartości tej krotki w celu znalezienia wierszy pasujących do wzorca. Jednak jeżeli pierwszym parametrem nie jest krotka :ok, uruchamia się druga wersja funkcji, która po prostu zwraca ten parametr. W ten sposób funkcja po prostu przekazuje błąd w dół potoku. To samo dotyczy funkcji truncate_lines. Możemy przetestować to zachowanie w konsoli: 6 Jest to dość swobodna interpretacja. Z technicznego punktu widzenia wywołujemy następne funkcje, ale nie wykonujemy w nich kodu. 4337ebf6db5c7cc89e4173803ef3875a 4 Programowanie transformacyjne 189 iex> Grep.find_all "/etc/passwd", ~r/www/ {:ok, ["_www:*:70:70:World W", "_wwwproxy:*:252:252:"]} iex> Grep.find_all "/etc/passwd", ~r/wombat/ {:error, "nothing found"} iex> Grep.find_all "/etc/koala", ~r/www/ {:error, :enoent} Można zauważyć, że błąd w dowolnym miejscu potoku natychmiast staje się wartością tego potoku. Albo obsłuż błędy w potoku Być może patrząc na funkcje find_matching_lines i truncate_lines pomyśleliście, że ciężar obsługi błędów został przeniesiony do transformacji. To prawda. W językach, które używają dopasowywania wzorców w wywołaniach funkcji, takich jak Elixir, efekt jest mniej widoczny, ale zachowanie wciąż nie jest zbyt eleganckie. Byłoby lepiej, gdyby Elixir miał wersję operatora potoku |>, która byłaby świadoma istnienia krotek ok: (:error) i która, w przypadku błędu, powodowałaby skrócone wykonanie potoku7. Jednak faktem jest, że Elixir nie pozwala nam dodać podobnego mechanizmu. Dotyczy to również szeregu innych języków. Problem, z którym mamy do czynienia, polega na tym, że w przypadku wystąpienia błędu nie chcemy uruchamiać kodu w dalszej części potoku i nie chcemy, aby kod wiedział, że coś podobnego się dzieje. Oznacza to, że musimy odłożyć uruchamianie funkcji w potoku do czasu uzyskania potwierdzenia, że wszystkie poprzednie kroki w potoku zakończyły się sukcesem. Aby to zrobić, musimy zastąpić wywołania funkcji wartościami funkcji, które mogą zostać wywołane później. Oto jedna z możliwych implementacji: function-pipelines/anagrams/lib/grep1.ex defmodule Grep1 do def and_then({ :ok, value }, func), do: func.(value) def and_then(anything_else, _func), do: anything_else def find_all(file_name, pattern) do File.read(file_name) |> and_then(&find_matching_lines(&1, pattern)) |> and_then(&truncate_lines(&1)) end defp find_matching_lines(content, pattern) do content |> String.split(~r/\n/) |> Enum.filter(&String.match?(&1, pattern)) |> ok_unless_empty() end 7 W rzeczywistości można dodać taki operator do języka Elixir, używając jego mechanizmu makr. Przykładem ich wykorzystania jest biblioteka Monad. Można również użyć konstrukcji with tego języka, ale wtedy stracilibyśmy większość sensu pisania transformacji i obsługi ich za pomocą potoku. 4337ebf6db5c7cc89e4173803ef3875a 4 190 Rozdział 5. Zegnij lub złam defp truncate_lines(lines) do lines |> Enum.map(&String.slice(&1, 0, 20)) |> ok() end defp ok_unless_empty([]), do: error("nothing found") defp ok_unless_empty(result), do: ok(result) defp ok(result), do: { :ok, result } defp error(reason), do: { :error, reason } end Funkcja and_then jest przykładem funkcji powiązania (ang. bind): przyjmuje wartość opakowaną w coś, a następnie stosuje funkcję do tej wartości, w celu zwrócenia nowej opakowanej wartości. Korzystanie z funkcji and_then w potoku wymaga trochę więcej znaków interpunkcyjnych, bo trzeba poinformować język Elixir, żeby dokonał konwersji wywołań funkcji na wartości funkcji. Ten dodatkowy wysiłek jest jednak rekompensowany przez fakt, że funkcje transformacji stają się proste: każda po prostu pobiera wartość (i wszelkie dodatkowe parametry) i zwraca {: ok, nowa_wartość} lub {:error, powód}. Transformacje zmieniają programowanie Myślenie o kodzie jako o ciągu (zagnieżdżonych) transformacji może doprowadzić do liberalizacji podejścia do programowania. Przyzwyczajenie się do tego sposobu myślenia zajmuje trochę czasu, ale kiedy już nabierzesz takiego nawyku, Twój kod stanie się czystszy, Twoje funkcje krótsze, a projekt bardziej płaski. Warto spróbować. Pokrewne podrozdziały Temat 8., „Istota dobrego projektu”. Temat 17., „Powłoki”. Temat 26., „Jak zrównoważyć zasoby”. Temat 28., „Eliminowanie sprzężeń”. Temat 35., „Aktorzy i procesy”. Ćwiczenia 21. Czy potrafisz wyrazić poniższe wymagania w postaci transformacji najwyższego poziomu? Oznacza to, że dla każdego z nich należy zidentyfikować wejście i wyjście. 1. Dodanie do zamówienia podatku od wysyłki i podatku VAT. 2. Ładowanie przez aplikację informacji o konfiguracji z pliku o podanej nazwie. 3. Logowanie użytkownika do aplikacji webowej. 4337ebf6db5c7cc89e4173803ef3875a 4 Podatek od dziedziczenia 191 22. Zidentyfikowałeś potrzebę walidacji pola wejściowego i jego konwersji z ciągu znaków na liczbę całkowitą o wartości od 18 do 150. Całkowitą transformację opisuje poniższy kod: field contents as string [walidacja i konwersja] {:ok, value} | {:error, reason} Napisz pojedyncze transformacje składające się na operację walidacja i konwersja. 23. W ramce „Język X nie obsługuje potoków” napisaliśmy następujący kod: const content = File.read(file_name); const lines = find_matching_lines(content, pattern) const result = truncate_lines(lines) Wiele osób pisze kod obiektowy łącząc wywołania metod. Takie osoby mogłyby ulec pokusie napisania kodu podobnego do poniższego: const result = content_of(file_name) . find_matching_lines(pattern) . truncate_lines() Jaka jest różnica pomiędzy tymi dwoma fragmentami kodu? Który, Twoim zdaniem, wolimy? 31 39 Podatek od dziedziczenia Chciałeś banana, a dostałeś goryla trzymającego banana i całą dżunglę. Joe Armstrong Czy programujesz w języku obiektowym? Czy korzystasz z dziedziczenia? Jeśli tak, to przestań to robić! Prawdopodobnie to nie jest mechanizm, którego chciałbyś używać. Zobaczmy dlaczego. Wprowadzenie Dziedziczenie w programowaniu po raz pierwszy pojawiło się w 1969 roku, w języku Simula 67. Było to eleganckie rozwiązanie problemu kolejkowania wielu typów zdarzeń na tej samej liście. W Simuli zastosowano podejście polegające na użyciu mechanizmu zwanego klasami przedrostkowymi (ang. prefix classes). Można było napisać następujący kod: link CLASS car; ... implementacja klasy car link CLASS bicycle; ... implementacja klasy bicycle 4337ebf6db5c7cc89e4173803ef3875a 4 192 Rozdział 5. Zegnij lub złam link jest klasą przedrostkową, która dodaje funkcjonalność jednokierunkowej listy. Dzięki temu możemy dodać zarówno samochody, jak i rowery do listy pojazdów oczekujących na (załóżmy) światła. Zgodnie z aktualną terminologią, klasę link nazwalibyśmy bazową. Model mentalny, stosowany przez programistów Simuli, polegał na dołączeniu danych egzemplarza i implementacji klasy link do implementacji klas car i bicycle. Część reprezentowana przez klasę link była postrzegana niemal jak kontener zawierający egzemplarze klas car i bicycle. W ten sposób uzyskano rodzaj polimorfizmu: obiekty klas car i bicycle implementowały interfejs klasy link, ponieważ oba zawierały kod tejże klasy. Po Simuli powstał Smalltalk. Alan Kay, jeden z twórców tego języka, w odpowiedzi udzielonej w witrynie Quora w 2019 roku8 napisał, dlaczego w Smalltalku było dziedziczenie: Kiedy zaprojektowałem Smalltalka-72 — był to język bardzo nowoczesny w porównaniu ze Smalltalkiem-71 — myślałem, że byłoby fajnie wykorzystać konstrukcje dynamiczne, podobne do stosowanych w Lispie, w celu wykonywania eksperymentów z „programowaniem różnicowym” (czyli różnymi sposobami osiągnięcia stanu dającego się określić słowami: „to działanie jest podobne do tego, z pewnym wyjątkiem”). To tworzenie klas potomnych służy wyłącznie dodawaniu zachowań. Te dwa rodzaje dziedziczenia (które faktycznie miały ze sobą sporo wspólnego) rozwijały się w ciągu kolejnych dziesięcioleci. Podejście języka Simula, w którym dziedziczenie pojawiło się po raz pierwszy, było sposobem łączenia typów, co było kontynuowane w takich językach, jak C++ i Java. Szkołę Smalltalka, gdzie dziedziczenie było dynamiczną organizacją zachowań, zastosowano w takich językach, jak Ruby i JavaScript. Obecnie mamy do czynienia z pokoleniami programistów obiektowych, którzy używają dziedziczenia z jednego z dwóch powodów: nie lubią pisania albo lubią typy. Ci, którzy nie lubią pisania, ratują swoje palce używając dziedziczenia, aby dodać wspólną funkcjonalność z klasy bazowej do klas potomnych: zarówno klasa User, jak i klasa Product są podklasami klasy ActiveRecord::Base. Ci, którzy lubią typy, używają dziedziczenia do wyrażenia relacji między klasami: Samochód jest rodzajem (relacja is-a) Pojazdu. Niestety, z obydwoma rodzajami dziedziczenia wiążą się problemy. 8 https://www.quora.com/What-does-Alan-Kay-think-about-inheritance-in-object-orientedprogramming 4337ebf6db5c7cc89e4173803ef3875a 4 Podatek od dziedziczenia 193 Problemy związane z używaniem dziedziczenia do współdzielenia kodu Dziedziczenie wprowadza sprzężenia. Nie tylko klasa potomna jest sprzężona z rodzicem, rodzicem rodzica i tak dalej, ale także kod, który wykorzystuje klasę potomną, jest sprzężony z wszystkimi klasami nadrzędnymi. Oto przykład: class Vehicle def initialize @speed = 0 end def stop @speed = 0 end def move_at(speed) @speed = speed end end class Car < Vehicle def info "Jestem samochodem i jadę z prędkością #{@speed}" end end # kod najwyższego poziomu my_ride = Car.new my_ride.move_at(30) Gdy w kodzie najwyższego poziomu zostanie użyte wywołanie my_car.move_at, faktycznie będzie wywołana metoda w klasie Vehicle, rodzicu klasy Car. Wyobraźmy sobie teraz, że programista odpowiedzialny za klasę Vehicle zmienił jej API tak, że metoda move_at stała się metodą set_velocity, a zmienna egzemplarza @speed zmieniła się na @velocity. Oczekuje się, że zmiana API spowoduje awarię działania klientów klasy Vehicle. Ale kod na najwyższym poziomie dalej będzie się kompilował, ponieważ będzie używał klasy Car. To, co robi klasa Car w swojej implementacji, nie jest problemem kodu na najwyższym poziomie, ale podczas próby wykonania tego kodu powstaje błąd. Podobnie nazwa zmiennej egzemplarza to wyłącznie wewnętrzny szczegół implementacji, ale kiedy zmieni się klasa Vehicle, to równocześnie (niezauważalnie) dochodzi do awarii klasy Car. To bardzo silne sprzężenie. Problemy związane z użyciem dziedziczenia do budowania typów Niektórzy postrzegają dziedziczenie jako sposób definiowania nowych typów. Ich ulubiony diagram projektowy pokazuje hierarchię klas. Postrzegają problemy w taki sposób, w jaki wiktoriańscy naukowcy postrzegali naturę: jak coś, co można podzielić na kategorie. 4337ebf6db5c7cc89e4173803ef3875a 4 194 Rozdział 5. Zegnij lub złam Niestety, tego rodzaju schematy szybko rozrastają się do monstrualnych rozmiarów. Dodawane są coraz to nowe warstwy w celu wyrażenia nawet najmniejszych niuansów i różnic występujących pomiędzy klasami. Ta dodatkowa złożoność może spowodować, że aplikacja stanie się bardziej krucha, a zmiany mogą wymagać ingerencji w wielu warstwach w górę i w dół. Jeszcze poważniejsze problemy występują przy dziedziczeniu wielokrotnym. Samochód może być rodzajem Pojazdu, ale również może być rodzajem Mienia, PrzedmiotuUbezpieczenia, ZastawuPożyczki itd. Prawidłowe zamodelowanie takich relacji wymagałoby wielokrotnego dziedziczenia. Język C++ w latach dziewięćdziesiątych ubiegłego wieku przyniósł wielokrotnemu dziedziczeniu złą sławę z powodu wątpliwej jakości semantyki mającej wyeliminować niejednoznaczności. W rezultacie w wielu językach obiektowych dziedziczenie wielokrotne nie jest dostępne. W związku z tym, nawet jeśli podobają Ci się złożone drzewa typów, i tak nie będziesz w stanie dokładnie zamodelować swojej domeny. WSKAZÓWKA NR 51 Nie płać podatku od dziedziczenia. Istnieją lepsze alternatywy Pozwólcie nam zasugerować trzy techniki, dzięki którym nigdy nie będziecie potrzebowali używać dziedziczenia: Interfejsy i protokoły. Delegacje. Domieszki (ang. mixins) i cechy (ang. traits). 4337ebf6db5c7cc89e4173803ef3875a 4 Podatek od dziedziczenia 195 Interfejsy i protokoły Większość języków obiektowych pozwala wskazać, że klasa implementuje co najmniej jeden zbiór zachowań. Można powiedzieć, na przykład, że klasa Car implementuje zachowanie Drivable i Locatable. Składnia dla wyrażenia tego stanu może być różna — w Javie może wyglądać następująco: public // // // } class Car implements Drivable, Locatable { Kod klasy Car. Ten kod musi zawierać zarówno funkcjonalność Drivable, jak i Locatable Drivable i Locatable to konstrukcje, które w Javie są nazywane interfejsami. W innych językach nazywa się je protokołami, a w jeszcze innych cechami — ang. traits (choć nie jest to ten mechanizm, który będziemy nazywali cechami w dalszej części tego rozdziału). Interfejsy definiujemy w następujący sposób: public interface Drivable { double getSpeed(); void stop(); } public interface Locatable() { Coordinate getLocation(); boolean locationIsValid(); } Powyższe deklaracje nie tworzą kodu: one jedynie informują, że każda klasa, która implementuje interfejs Drivable, musi implementować dwie metody: getSpeed i stop, a klasa, która implementuje interfejs Locatable, musi implementować metody getLocation i locationIsValid. To oznacza, że nasza poprzednia definicja klasy Car będzie prawidłowa tylko wtedy, gdy będzie zawierała wszystkie te cztery metody. Potęga interfejsów i protokołów wynika stąd, że możemy wykorzystać je jako typy, a każda klasa, która implementuje odpowiedni interfejs, będzie kompatybilna z tym typem. Jeśli zarówno klasa Car, jak i Phone implementują interfejs Locatable, będziemy mogli zapisać egzemplarze obu tych klas na liście obiektów Locatable: List<Locatable> items = new ArrayList<>(); items.add(new Car(...)); items.add(new Phone(...)); items.add(new Car(...)); // ... Następnie możemy przetwarzać tę listę mając świadomość, że każdy element ma metody getLocation i locationIsValid: void printLocation(Locatable item) { if (item.locationIsValid() { print(item.getLocation().asString()); 4337ebf6db5c7cc89e4173803ef3875a 4 196 Rozdział 5. Zegnij lub złam } // ... items.forEach(printLocation); WSKAZÓWKA NR 52 Wykorzystuj interfejsy do wyrażania polimorfizmu. Interfejsy i protokoły pozwalają korzystać z polimorfizmu bez dziedziczenia. Delegacje Dziedziczenie zachęca programistów do tworzenia klas, których obiekty mają dużą liczbę metod. Jeśli klasa bazowa zawiera 20 metod, a jej klasa potomna chce skorzystać tylko z dwóch spośród nich, jej obiekty nadal będą zawierały 18 zupełnie niepotrzebnych metod. Taka klasa traci kontrolę nad swoim interfejsem. Jest to częsty problem — w wielu frameworkach oferujących funkcjonalności utrwalania i obsługi interfejsów użytkownika, komponenty są potomkami pewnej klasy bazowej: class Account < PersistenceBaseClass end Klasa Account przenosi teraz całość interfejsu API utrwalania. Zamiast tego można skorzystać z alternatywy w postaci delegacji, tak jak w poniższym przykładzie: class Account def initialize(. . .) @repo = Persister.for(self) end def save @repo.save() end end Teraz żadna część interfejsu API frameworka nie jest ujawniona klientom klasy Account: to sprzężenie zostało wyeliminowane. Ale to nie wszystko. Teraz, kiedy nie jesteśmy już ograniczeni przez API używanego frameworka, możemy swobodnie utworzyć taki interfejs API, jakiego potrzebujemy. Co prawda mogliśmy to zrobić wcześniej, ale zawsze narażaliśmy się na ryzyko, że napisany interfejs można ominąć i zamiast niego użyć API utrwalania. Teraz mamy kontrolę nad wszystkim. WSKAZÓWKA NR 53 Stosuj delegacje do usług. Relacja has-a ma przewagę nad relacją is-a. W gruncie rzeczy możemy pójść o krok dalej. Dlaczego klasa Account powinna wiedzieć, jak powinna się utrwalać? Czy jej zadaniem nie jest znajomość i egzekwowanie reguł biznesowych dotyczących konta? 4337ebf6db5c7cc89e4173803ef3875a 4 Podatek od dziedziczenia class # end class # # end 197 Account wyłącznie kod dotyczący konta AccountRecord opakowuje konto możliwościami jego pobierania i zapisywania Teraz naprawdę wyeliminowaliśmy sprzężenia, ale stało się to pewnym kosztem. Trzeba napisać więcej kodu, a pewna jego część będzie musiała być napisana w wielu miejscach: na przykład jest wysoce prawdopodobne, że wszystkie nasze klasy rekordów będą potrzebowały metody find. Na szczęście ten problem można rozwiązać za pomocą domieszek i cech. Domieszki, cechy, kategorie, rozszerzenia protokołów… W naszej branży kochamy nadawać nazwy. Dość często nadajemy wiele nazw tej samej rzeczy. Im więcej, tym lepiej, prawda? Właśnie z takim zjawiskiem mamy do czynienia w przypadku domieszek. Podstawowa idea jest prosta: chcemy mieć możliwość rozszerzania klas i obiektów o nowe funkcje bez użycia dziedziczenia. W związku z tym tworzymy zbiór tych funkcji, nadajemy temu zbiorowi nazwę, a następnie w jakiś sposób rozszerzamy za ich pomocą klasę lub obiekt. W tym momencie utworzyliśmy nową klasę lub obiekt, które łączą w sobie możliwości oryginału i wszystkich jego domieszek. W większości przypadków będziemy w stanie tworzyć takie rozszerzenia, nawet jeśli nie mamy dostępu do kodu źródłowego rozszerzanej klasy. Implementacja i nazwa tej własności są różne w różnych językach. W tej książce będziemy nazywać ją domieszką (ang. mixin), ale chcielibyśmy, aby Czytelnicy myśleli o niej jak o własności niezależnej od języka. Ważną rzeczą są możliwości, jakie dają wszystkie te implementacje: łączenie funkcji pomiędzy kodem istniejącym i nowym. W ramach przykładu powróćmy do naszej klasy AccountRecord. Klasa Account Record w postaci, w jakiej ją pozostawiliśmy, musiała mieć świadomość istnienia zarówno kont, jak i frameworka utrwalania. Musiała również oddelegować wszystkie metody w warstwie utrwalania, które chciała udostępnić do świata zewnętrznego. Domieszki oferują nam rozwiązanie alternatywne. Po pierwsze, możemy napisać domieszkę, która implementuje (na przykład) dwie lub trzy standardowe metody wyszukiwania. Następnie możemy ją dodać do klasy AccountRecord. Możemy również dodać domieszkę do nowych klas, w celu dodania do nich funkcjonalności utrwalania: mixin CommonFinders { def find(id) { ... } def findAll() { ... } end 4337ebf6db5c7cc89e4173803ef3875a 4 198 Rozdział 5. Zegnij lub złam class AccountRecord extends BasicRecord with CommonFinders class OrderRecord extends BasicRecord with CommonFinders Możemy pójść znacznie dalej. Na przykład wszyscy wiemy, że obiekty biznesowe wymagają kodu walidacji, który jest potrzebny do zapobieżenia wykorzystywaniu w obliczeniach nieprawidłowych danych. Co jednak dokładnie mamy na myśli przez walidację? Na przykład w przypadku konta istnieje wiele różnych warstw walidacji, które można zastosować: Sprawdzenie, czy skrót hasła pasuje do hasła wprowadzonego przez użytkownika. Walidacja danych formularza wprowadzanych przez użytkownika podczas tworzenia konta. Walidacja danych formularza wprowadzanych przez administratora aktualizującego dane użytkownika. Walidacja danych dodanych do konta przez inne komponenty systemu. Walidacja danych pod kątem ich spójności przed utrwaleniem. Powszechnym (i naszym zdaniem dalekim od ideału) podejściem jest umieszczenie wszystkich walidacji w jednej klasie (obiekcie biznesowym lub obiekcie utrwalania), a następnie dodanie flagi decydującej o tym, jaki kod walidacji zostanie uruchomiony w jakich okolicznościach. Uważamy, że lepszym sposobem jest zastosowanie domieszek w celu stworzenia wyspecjalizowanych klas właściwych do zastosowania w odpowiednich sytuacjach: class AccountForCustomer extends Account with AccountValidations, AccountCustomerValidations class AccountForAdmin extends Account with AccountValidations, AccountAdminValidations Tutaj obie klasy potomne zawierają kod walidacji wspólny dla wszystkich obiektów reprezentujących konta. Wariant dla klienta zawiera również kod walidacji właściwy dla API wykorzystywanego na styku z klientem, natomiast wariant dla administratora zawiera (przypuszczalnie mniej restrykcyjny) kod walidacji funkcji administratora. Teraz, dzięki przekazywaniu egzemplarzy klas AccountForCustomer lub AccountFor Admin, nasz kod automatycznie zapewni zastosowanie odpowiednich mechanizmów walidacji. WSKAZÓWKA NR 54 Korzystaj z domieszek w celu współdzielenia funkcji. 4337ebf6db5c7cc89e4173803ef3875a 4 Konfiguracja 199 Dziedziczenie rzadko jest dobrym rozwiązaniem Zaprezentowaliśmy trzy alternatywy dla tradycyjnego dziedziczenia klas: Interfejsy i protokoły. Delegacje. Domieszki i cechy. Każdy z tych sposobów może się lepiej sprawdzać w różnych okolicznościach, w zależności od tego, czy naszym celem jest udostępnianie informacji o typach, dodawanie funkcjonalności, czy też udostępnianie metod. Tak jak zawsze w programowaniu, należy dążyć do stosowania techniki, która najlepiej wyraża nasze zamiary. Warto również zadbać o to, by nie ciągnąć za bananem całej dżungli. Pokrewne podrozdziały Temat 8., „Istota dobrego projektu”. Temat 10., „Ortogonalność”. Temat 28., „Eliminowanie sprzężeń”. Wyzwania 32 40 Gdy następnym razem zauważysz, że masz zamiar stosować dziedziczenie, poświęć chwilę, aby zbadać inne opcje. Czy to, czego chcesz, można osiągnąć za pomocą interfejsów, delegacji i (lub) domieszek? Czy dzięki temu możesz zmniejszyć poziom sprzężeń? Konfiguracja Niech wszystkie rzeczy mają swoje miejsce; niech każda część Twojej działalności ma swój czas. Benjamin Franklin, Trzynaście cnót, autobiografia Gdy Twój kod korzysta z wartości, które mogą ulec zmianie po przekazaniu aplikacji do produkcji, przechowuj te wartości na zewnątrz aplikacji. Gdy Twoja aplikacja będzie działać w różnych środowiskach i potencjalnie dla różnych klientów, przechowuj wartości specyficzne dla środowiska i dla klientów poza aplikacją. W ten sposób możesz sparametryzować swoją aplikację — kod dostosowuje się do miejsc, w których działa. WSKAZÓWKA NR 55 Parametryzuj aplikacje korzystając z zewnętrznej konfiguracji 4337ebf6db5c7cc89e4173803ef3875a 4 200 Rozdział 5. Zegnij lub złam Do danych, które zazwyczaj umieszczamy w konfiguracji, należą: Poświadczenia do zewnętrznych usług (bazy danych, zewnętrznych API, itp.). Poziomy logowania i miejsca docelowe. Port, adres IP, nazwa komputera oraz nazwy klastrów wykorzystywane przez aplikację. Parametry walidacji specyficzne dla środowiska. Zbiory zewnętrznych parametrów, na przykład stawki podatkowe. Szczegóły formatowania specyficzne dla lokalizacji. Klucze licencyjne. Ogólnie rzecz biorąc, poszukaj wszystkiego, o czym wiesz, że będzie musiało się zmieniać, a co można wyrazić na zewnątrz głównej treści kodu, i umieść to w zestawie konfiguracyjnym. Konfiguracja statyczna Wiele frameworków i sporo niestandardowych aplikacji przechowuje konfigurację w płaskich plikach lub tabelach bazy danych. Jeśli informacje są zapisane w płaskich plikach, istnieje trend do używania gotowych formatów tekstowych. Obecnie dużą popularnością w tego typu zastosowaniach cieszą się formaty YAML i JSON. Czasami aplikacje napisane w językach skryptowych używają specjalnych plików źródłowych, dedykowanych do przechowywania wyłącznie konfiguracji. Jeśli informacje mają strukturę i istnieje prawdopodobieństwo, że będą zmienione przez klienta (na przykład stawki podatku VAT), to lepszym rozwiązaniem może być zapisanie ich w tabeli bazy danych. Oczywiście można używać obu sposobów, dzieląc informacje o konfiguracji zgodnie z zastosowaniem. Niezależnie od zastosowanego formatu, konfiguracja jest wczytywana do aplikacji jako struktura danych, zwykle podczas uruchamiania aplikacji. Powszechnie ta struktura danych jest globalna. Uzasadnieniem jest to, że dzięki temu sięgnięcie do zapisanych w niej wartości z dowolnej części kodu będzie łatwiejsze. Nie zalecamy takiego postępowania. Zamiast tego lepiej opakować informacje konfiguracyjne jakąś cienką warstwą API. Taki zabieg pozwala oddzielić kod od szczegółów reprezentacji konfiguracji. Konfiguracja jako usługa Choć powszechnie stosowana jest konfiguracja statyczna, obecnie preferujemy inne podejście. Nadal chcemy przechowywać dane konfiguracyjne na zewnątrz aplikacji, ale zamiast trzymać je w płaskim pliku lub bazie danych, wolimy udostępniać je za pośrednictwem API usługi. Takie podejście daje szereg korzyści: 4337ebf6db5c7cc89e4173803ef3875a 4 Konfiguracja 201 Informacje o konfiguracji może współdzielić wiele aplikacji, a dzięki mechanizmom uwierzytelniania i kontroli dostępu można ograniczyć zakres widocznych danych. Zmiany w konfiguracji mogą być wprowadzane globalnie. Dane konfiguracji mogą być zarządzane za pośrednictwem wyspecjalizowanego interfejsu użytkownika. Dane konfiguracyjne stają się dynamiczne. Ostatni punkt, mówiący o tym, że dane konfiguracyjne powinny być dynamiczne, jest kluczowy w przypadku aplikacji o wysokiej dostępności. Pomysł, że powinniśmy zatrzymać i ponownie uruchomić aplikację, aby zmienić jeden parametr, jest beznadziejnie oderwany od współczesnych realiów. Dzięki wykorzystaniu usługi konfiguracji komponenty aplikacji mogą zarejestrować zainteresowanie powiadomieniami o aktualizacjach parametrów, których używają, a usługa może wysyłać do nich informacje zawierające nowe wartości w przypadku, gdy zostaną zmienione. Niezależnie od przyjętej formy, dane konfiguracyjne zarządzają zachowaniem aplikacji w czasie jej działania. Gdy zmieni się wartość w konfiguracji, nie ma potrzeby ponownego kompilowania kodu. Nie należy pisać kodu-dodo Bez zewnętrznej konfiguracji nasz kod nie jest odpowiednio elastyczny i nie może dostosowywać się do zmiennych warunków. Czy to źle? Cóż, w naszym świecie gatunki, które nie potrafią dostosowywać się do otoczenia, po prostu giną. Ptaki dodo nie potrafiły przystosować się do obecności ludzi i zwierząt domowych na wyspie Mauritius, co szybko doprowadziło do wymarcia całego gatunku9. Był to pierwszy udokumentowany przypadek zagłady całego gatunku rękami człowieka. Nie możemy pozwolić, aby nasz projekt (lub wręcz nasza kariera) podzielił los ptaków dodo. Pokrewne podrozdziały 9 Temat 9., „DRY— Przekleństwo powielania”. Temat 14., „Języki dziedzinowe”. Temat 16., „Potęga zwykłego tekstu”. Temat 28., „Eliminowanie sprzężeń”. Oczywiście, niemały wpływ na fatalny los tych łagodnych (choć głupich) ptaków miało ich zabijanie dla zabawy przez osadników. 4337ebf6db5c7cc89e4173803ef3875a 4 202 Rozdział 5. Zegnij lub złam Nie należy przesadzać W pierwszym wydaniu tej książki, podobnie jak tu, proponowaliśmy używanie zewnętrznej konfiguracji zamiast wpisywania jej w kod, ale najwyraźniej powinniśmy przekazywać instrukcje trochę bardziej szczegółowo. Każda porada może być rozumiana skrajnie lub wykorzystywana w niewłaściwy sposób. Oto kilka przestróg: Nie należy przesadzać. Jeden z naszych klientów podjął kiedyś decyzję, że każde pole w jego aplikacji powinno być konfigurowalne. W rezultacie wprowadzenie nawet najprostszej zmiany zajmowało tydzień, ponieważ trzeba było zaimplementować zarówno pole danych, jak i cały kod administracyjny potrzebny do jego zapisywania i edytowania. W konfiguracji było ponad 40 000 zmiennych, a kodowanie aplikacji stało się koszmarem. Nie należy umieszczać w konfiguracji danych wpływających na decyzje podejmowane w aplikacji tylko z powodu lenistwa. Jeśli jest prowadzona debata o tym, czy funkcjonalność powinna działać w ten lub inny sposób, decyzja powinna należeć do użytkowników. Trzeba wypróbować jeden sposób i uzyskać opinię na temat tego, czy podjęta decyzja była dobra. 4337ebf6db5c7cc89e4173803ef3875a 4 Rozdział 6. Współbieżność Abyśmy wszyscy „byli na tej samej stronie”, zacznijmy od kilku definicji: Współbieżność (ang. concurrency) jest wtedy, gdy dwa fragmenty kodu (lub większa ich liczba) działają tak, jakby były uruchomione w tym samym czasie. Przetwarzanie równoległe (ang. parallelism) zachodzi wtedy, gdy one rzeczywiście działają w tym samym czasie. Aby korzystać ze współbieżności, trzeba uruchomić kod w środowisku, w którym możemy przełączać wykonywanie pomiędzy różnymi częściami kodu podczas jego działania. Często implementujemy ten mechanizm za pomocą takich technik, jak włókna (ang. fibers), wątki i procesy. Do przetwarzania równoległego potrzebny jest sprzęt zdolny do realizacji dwóch operacji w tym samym czasie. Może to być wiele rdzeni w procesorze, wiele procesorów w komputerze lub wiele komputerów połączonych ze sobą. 36 Wszystko jest współbieżne Napisanie kodu systemu przyzwoitej wielkości, który nie miałby elementów współbieżnych, jest prawie niemożliwe. Może to być współbieżność wyrażona jawnie albo zaszyta wewnątrz biblioteki. Jeśli chcesz, aby Twoja aplikacja obsługiwała problemy realnego świata, współbieżność jest wymagana. Zdarzenia dzieją się asynchronicznie: użytkownicy komunikują się z aplikacją, pobierane są dane, wywoływane usługi zewnętrzne — wszystko wydarza się w tym samym czasie. Jeśli wymusisz, aby proces realizowany był szeregowo — najpierw dzieje się jedna rzecz, potem następna i tak dalej — Twój system będzie ospały i prawdopodobnie nie będzie w pełni wykorzystywał możliwości sprzętu, na którym działa. W tym rozdziale zajmiemy się współbieżnością i przetwarzaniem równoległym. 4337ebf6db5c7cc89e4173803ef3875a 4 204 Rozdział 6. Współbieżność Programiści często mówią o sprzężeniach występujących pomiędzy fragmentami kodu. Wspominają o zależnościach oraz o tym, dlaczego z powodu tych zależności wprowadzanie zmian jest trudne. Istnieje jednak inna forma sprzężeń. Związki czasowe występują w przypadku, gdy kod wymusza sekwencyjne wykonywanie działań, które nie jest wymagane, aby rozwiązać problem. Czy w Twoim kodzie operacja „tik” musi występować przed operacją „tak”? Nie może tak być, jeśli ma być elastyczna. Czy Twój kod sięga do wielu usług backendowych sekwencyjnie — jedna po drugiej? Nie może tego robić, jeśli chcesz utrzymać swoich klientów. Sposobom identyfikowania tego rodzaju sprzężeń przyjrzymy się w podrozdziale „Eliminowanie związków czasowych”. Dlaczego pisanie współbieżnego i równoległego kodu jest takie trudne? Jednym z powodów jest to, że programowania uczyliśmy się korzystając z systemów sekwencyjnych, a języki, którymi się posługujemy, mają cechy, które są stosunkowo bezpieczne, gdy są wykorzystywane sekwencyjnie, ale stają się trudne, kiedy dwie rzeczy mogą się zdarzyć w tym samym czasie. Jednym z największych winowajców jest „współdzielony stan”. Nie oznacza to tylko zmiennych globalnych: ze współdzielonym stanem mamy do czynienia za każdym razem, gdy co najmniej dwa fragmenty kodu odwołują się do tego samego fragmentu zmiennych danych. W podrozdziale „Współdzielony stan jest zły” opisaliśmy kilka obejść tego problemu, ale w gruncie rzeczy wszystkie one stwarzają ryzyko popełniania błędów. Jeśli to wprawia Cię w smutek, nil desperandum1! Są lepsze sposoby konstruowania współbieżnych aplikacji. Jednym z nich jest wykorzystanie modelu aktorów, w którym niezależne procesy, które nie współdzielą żadnych danych, komunikują się za pomocą kanałów, korzystając ze zdefiniowanej, prostej semantyki. Teorię i praktykę tego podejścia omówimy w podrozdziale „Aktorzy i procesy”. Na koniec przyjrzymy się czarnym tablicom. Są to systemy, które działają jak połączenie magazynu obiektów i inteligentnego brokera publikuj – subskrybuj. W swojej pierwotnej formie systemy te tak naprawdę nigdy się nie przyjęły. Obecnie jednak spotykamy coraz więcej implementacji warstw middleware o semantyce zbliżonej do czarnych tablic. Jeśli te typy systemów są prawidłowo stosowane, pozwalają w dużym stopniu wyeliminować sprzężenia. Zarówno kod współbieżny, jak i przetwarzanie równoległe były niegdyś egzotyczne. Dziś tego rodzaju kod jest koniecznością. 33 37 Eliminowanie związków czasowych Moglibyśmy zapytać: „czego dotyczą związki czasowe?”. Dotyczą wyłącznie czasu. 1 Z łacińskiego „nie rozpaczaj” — przyp. tłum. 4337ebf6db5c7cc89e4173803ef3875a 4 Eliminowanie związków czasowych 205 Czas jest często ignorowanym aspektem architektur oprogramowania. Kwe-stią czasu zajmujemy się na etapie przygotowywania harmonogramu prac oraz podczas monitorowania liczby dni pozostałych do terminu wydania — nie o to nam jednak chodzi w tym podrozdziale. Skoncentrujemy się raczej na roli czasu jako elementu projektowania samego oprogramowania. Z naszego punktu widzenia czas ma dwa ważne aspekty: współbieżność (czyli operacje wykonywane jednocześnie) i kolejność (czyli względne położenie operacji na osi czasu). Okazuje się, że zwykle nie postrzegamy programowania przez pryzmat któregokolwiek z tych aspektów. Kiedy przystępujemy do projektowania architek-tury lub pisania właściwego programu, wszystko wydaje nam się liniowe. Właśnie w ten sposób myśli większość ludzi — najpierw trzeba zrobić to, po-tem zrobi się tamto. Taki sposób postrzegania sekwencji działań prowadzi jednak do powstawania związków czasowych — wiązania działań w czasie. Metoda A zawsze musi być wywoływana przed metodą B; jednocześnie można generować tylko jeden raport; informacje o zdarzeniu kliknięcia przycisku otrzymamy dopiero po odświeżeniu ekranu. „Tik” musi wystąpić przed „ta-kiem”. Taki model jest nie tylko nieelastyczny, ale też niezbyt realistyczny. Musimy mieć na uwadze współbieżność i myśleć o eliminowaniu wszelkich zależności czasowych i kolejnościowych. W ten sposób możemy jednocześnie zyskać elastyczność i ograniczyć liczbę zależności czasowych w wielu obszarach analizy przepływu pracy, budowy architektury, projektowania i wdrażania. W rezultacie powstaną systemy, które są łatwiejsze do analizy, które potencjalnie szybciej reagują i działają bardziej niezawodnie. Poszukiwanie współbieżności W wielu projektach musimy modelować i analizować przepływ pracy użytkowników w ramach analizy wymagań. Chcemy określić, co może się dziać w tym samym czasie i co musi mieć miejsce w określonej kolejności. Jednym ze sposobów osiągnięcia tego celu jest opisanie przepływu pracy w odpowiedniej notacji, na przykład w formie diagramu czynności UML2. WSKAZÓWKA NR 56 Warto analizować przepływ pracy, aby na tej podstawie poprawiać współbieżność. 2 Chociaż UML stopniowo wychodzi z użycia, wiele schematów wchodzących w skład tej notacji nadal stosuje się w takiej czy innej postaci. Jednym z przykładów jest bardzo użyteczny diagram czynności. Aby uzyskać więcej informacji na temat wszystkich typów diagramów UML, zobacz UML Distilled: A Brief Guide to the Standard Object Modeling Language [Fow04]. 4337ebf6db5c7cc89e4173803ef3875a 4 206 Rozdział 6. Współbieżność Diagram czynności składa się ze zbioru czynności reprezentowanych przez prostokąty z zaokrąglonymi narożnikami. Strzałka wychodząca z pola czynności prowadzi albo do drugiej czynności (która może rozpocząć się dopiero po zakończeniu pierwszej czynności), albo do grubej linii nazywanej paskiem synchronizacji. Po zakończeniu wszystkich czynności prowadzących do jednego paska synchronizacji można przystąpić do dalszych czynności wskazywanych przez strzałki wychodzące z tego paska. Czynność, do której nie prowadzą żadne strzałki, można rozpocząć w dowolnej chwili. Diagramów czynności można używać do zapewniania jak największej równoległości projektowanego systemu poprzez identyfikację czynności, które nie są, a mogą być wykonywane równolegle. Na przykład na potrzeby projektu blendera użytkownicy mogą opisać swój dotychczasowy przepływ pracy w następujący sposób: 1. Otwórz blender. 2. Otwórz butelkę piña colada. 3. Przelej koktajl do blendera. 4. Odmierz połowę miarki białego rumu. 5. Wlej rum. 6. Dodaj dwie miarki lodu. 7. Zamknij blender. 8. Miksuj przez 2 minuty. 9. Otwórz blender. 10. Podaj szklanki. 11. Włóż do szklanek różowe parasolki. 12. Podaj napój gościom. Jednak barman straciłby pracę, gdyby wykonywał te czynności krok po kroku — jedną po zakończeniu drugiej. Mimo że użytkownicy opisują te czynności w formie sekwencji działań i mimo że rzeczywiście można je wykonywać w ten sposób, łatwo zauważyć, że wiele spośród tych punktów można równie dobrze wykonywać równolegle. Do odnajdywania miejsc, gdzie potencjalnie można zastosować współbieżność, wykorzystamy następujący diagram czynności. 4337ebf6db5c7cc89e4173803ef3875a 4 Eliminowanie związków czasowych 207 Możliwość obserwacji miejsc, w których zależności rzeczywiście występują, jest bardzo cenna. W tym przypadku wszystkie zadania najwyższego poziomu (1., 2., 4., 10. i 11.) można wykonywać równolegle od samego początku. Czynności 3., 5. i 6. mogą być wykonywane równolegle po wymienionych zadaniach. Gdybyśmy brali udział w zawodach polegających na przyrządzaniu drinków piña colada na czas, taka optymalizacja mogłaby sporo zmienić. Okazje do współbieżności Diagramy czynności pokazują potencjalne obszary zastosowania współbieżności, ale nie dają żadnych informacji na temat tego, czy te obszary są warte zbadania. W przykładzie z piña coladą barman musiałby mieć pięć rąk, aby móc jednocześnie wykonać wszystkie potencjalne zadania początkowe. Takie ograniczenia trzeba zidentyfikować na etapie projektowania. Kiedy spojrzymy na diagram aktywności, zauważymy, że czynność nr 8, miksowanie, zajmie minutę. W tym czasie nasz barman może wziąć szklankę i parasolki (czynności 10 i 11) i prawdopodobnie będzie miał jeszcze czas, aby obsłużyć innego klienta. 4337ebf6db5c7cc89e4173803ef3875a 4 208 Rozdział 6. Współbieżność Szybsze formatowanie Niniejsza książka jest napisana zwykłym tekstem. Aby zbudować wersję do druku, ebook lub cokolwiek innego, ten tekst jest przetwarzany przez potok narzędzi przetwarzających tekst. Niektóre wyszukują konkretnych konstrukcji (cytowań bibliografii, pozycji w skorowidzu, specjalnych znaczników wskazówek i tak dalej). Inne działają na dokumencie jako całości. Wiele narzędzi w potoku musi mieć dostęp do informacji zewnętrznych (odczyt z plików, zapisywanie do plików, potoki za pośrednictwem programów zewnętrznych). Wszystkie te stosunkowo wolno wykonywane zadania dają nam możliwość wykorzystania współbieżności: każdy krok w potoku jest w istocie wykonywany współbieżnie — czyta informacje z poprzedniego etapu i zapisuje do następnego. Ponadto niektóre etapy procesu wymagają stosunkowo intensywnego wykorzystania procesora. Jednym z nich jest przekształcanie wzorów matematycznych. Z różnych względów historycznych konwersja każdego równania może trwać do 500 ms. Aby przyspieszyć przetwarzanie, możemy skorzystać ze współbieżności. Ponieważ każdy wzór jest niezależny od innych, możemy dokonać konwersji każdego z nich w osobnym, współbieżnym procesie i zebrać wyniki, kiedy będą dostępne, a następnie wstawić je do książki. W rezultacie proces budowania książki przebiega znacznie szybciej na maszynach wielordzeniowych. (Trzeba też przyznać, że podczas pracy odkryliśmy w naszym potoku szereg błędów związanych ze współbieżnością…). Właśnie tego szukamy podczas projektowania z myślą o współbieżności. Mamy nadzieję znaleźć działania, które zajmują czas, ale nie jest to czas w naszym kodzie. Odpytywanie bazy danych, dostęp do zewnętrznej usługi, czekanie na wprowadzenie danych wejściowych przez użytkownika: wszystkie te działania powodują przestoje w naszym programie do czasu, kiedy się zakończą. To są właśnie możliwości wykonania działań w sposób bardziej produktywny niż pozwolenie procesorowi, aby był bezczynny. Okazje do przetwarzania równoległego Przypomnijmy różnicę: współbieżność jest mechanizmem programowym, natomiast przetwarzanie równoległe dotyczy sprzętu. Jeśli mamy wiele procesorów, lokalnie lub zdalnie, to jeśli uda nam się podzielić pracę pomiędzy te procesory, będziemy mogli skrócić ogólny czas wykonywania zadania. Idealnymi kandydatami do takiego podziału są te fragmenty pracy, które są stosunkowo niezależne — takie, w których jeden proces może kontynuować działanie nie oczekując niczego od innych. Powszechny wzorzec postępowania w takiej sytuacji to zidentyfikowanie dużego zadania, podział go na niezależne fragmenty, równoległe przetwarzanie każdego z nich, a następnie scalenie wyników. 4337ebf6db5c7cc89e4173803ef3875a 4 Współdzielony stan jest zły 209 Ciekawym przykładem takiego mechanizmu zastosowanego w praktyce jest sposób działania kompilatora języka Elixir. Kiedy rozpoczyna pracę, dzieli budowany projekt na moduły i po kolei kompiluje każdy z nich. Czasami jeden moduł zależy od innego — w takim przypadku jego kompilacja zatrzymuje się do czasu, aż staną się dostępne wyniki pracy innego modułu. Zakończenie kompilacji modułu najwyższego poziomu oznacza, że zostały skompilowane wszystkie zależności. Rezultatem jest szybki proces kompilacji, który korzysta ze wszystkich dostępnych rdzeni. Identyfikowanie możliwości to łatwa część Wróćmy do naszej aplikacji. Zidentyfikowaliśmy miejsca, w których będziemy korzystać ze współbieżności oraz przetwarzania równoległego. Teraz trudniejsze zadanie: trzeba znaleźć bezpieczny sposób implementacji. To będzie temat pozostałej części tego rozdziału. Pokrewne podrozdziały Temat 10., „Ortogonalność”. Temat 26., „Jak zrównoważyć zasoby”. Temat 28., „Eliminowanie sprzężeń”. Temat 36., „Czarne tablice”. Wyzwania 34 38 Ile czynności wykonujesz równolegle, gdy przygotowujesz się rano do pójścia do pracy? Czy potrafisz to zaprezentować za pomocą diagramu czynności UML? Czy potrafisz znaleźć sposób przyspieszenia swoich działań poprzez zwiększenie współbieżności? Współdzielony stan jest zły Jesteś w swojej ulubionej restauracji. Skończyłeś jeść danie główne i pytasz kelnera, czy jest jeszcze szarlotka. Patrzy przez ramię, widzi jeden kawałek w gablocie i odpowiada, że tak. Zamawiasz ją i wzdychasz z zadowoleniem. Tymczasem po drugiej stronie restauracji, inny klient zadaje innemu kelnerowi to samo pytanie. Tamten kelner także potwierdza, że jest jeden kawałek, a klient go zamawia. Jeden z klientów będzie niezadowolony. Zamień gablotę na wspólny rachunek bankowy, a następnie zastąp kelnerów punktami sprzedaży urządzeń. Ty i Twoja partnerka w tym samym czasie 4337ebf6db5c7cc89e4173803ef3875a 4 210 Rozdział 6. Współbieżność podjęliście decyzję o zakupie nowego telefonu, ale na rachunku pozostały środki wystarczające do zakupu tylko jednego urządzenia. Ktoś — bank, sklep lub Ty — będzie bardzo niezadowolony. WSKAZÓWKA NR 57 Współdzielony stan jest zły. Problemem jest współdzielony stan. Każdy z kelnerów w restauracji zaglądał do gabloty nie zwracając uwagi na innych. W każdym punkcie sprzedaży sprawdzano środki na rachunku niezależnie od innych. Nieatomowe aktualizacje Spójrzmy na nasz przykład z kelnerami w taki sposób, jak byśmy pisali kod: Dwaj kelnerzy pracują współbieżnie (a w rzeczywistości równolegle). Przyjrzyjmy się kodowi: if gablota.liczba_ciastek > 0 obiecaj_ciastko_klientowi() gablota.weź_ciastko() daj_ciastko_klientowi() end Kelner numer 1 pobiera aktualną liczbę ciastek i stwierdza, że jest jedno. Obiecuje ciastko klientowi. Ale w tym momencie pracuje kelner numer 2. On także widzi, że liczba ciastek wynosi jeden i składa taką samą obietnicę swojemu klientowi. Następnie jeden z dwóch kelnerów bierze ostatni kawałek ciastka, a drugi kelner wchodzi w jakiś stan błędu (co prawdopodobnie wiąże się z kajaniem się przed klientem). 4337ebf6db5c7cc89e4173803ef3875a 4 Współdzielony stan jest zły 211 Problemem w tym przypadku nie jest to, że dwa procesy mogą pisać do tej samej pamięci. Kłopot polega na tym, że żaden z procesów nie może zagwarantować, że jego widok pamięci jest spójny. W gruncie rzeczy, gdy kelner wykonuje metodę gablota.liczba_ciastek(), to kopiuje wartość z gabloty do własnej pamięci. Jeśli wartość w pamięci gabloty się zmieni, to pamięć kelnerów (której używają do podejmowania decyzji) jest już nieaktualna. To wszystko dlatego, że pobieranie i aktualizowanie informacji o liczbie ciastek nie jest operacją atomową: informacja ta może się zmienić w trakcie realizacji tych dwóch operacji. Co zatem zrobić, aby stała się atomowa? Semafory i inne formy wzajemnego wykluczania Semafor jest po prostu rzeczą, która w danym momencie może być w posiadaniu tylko jednej osoby. Możesz utworzyć semafor, a następnie użyć go do kontroli dostępu do jakiegoś zasobu. W naszym przykładzie, możemy stworzyć semafor do kontroli dostępu do gabloty z ciastkami i przyjąć konwencję, zgodnie z którą każdy, kto chce zaktualizować zawartość gabloty, może to zrobić tylko wtedy, kiedy ma semafor. Powiedzmy, że zarząd restauracji postanawia rozwiązać problem z wykorzystaniem fizycznego semafora. Kładą plastikowego krasnala na gablocie z ciastkami. Aby jakikolwiek kelner mógł sprzedać ciastko, musi trzymać figurkę w ręku. Po zakończeniu realizacji zamówienia (co oznacza dostarczenie ciastka na stół) powinien odłożyć figurkę krasnala na miejsce, tak aby krasnal mógł strzec skarbu w postaci ciastek podczas mediacji na temat następnego zamówienia. Przyjrzyjmy się realizacji tego scenariusza w kodzie. Historycznie operacja pobierania semafora nosiła nazwę P, a operacja jego zwalniania nazywała się V3. Dziś używamy takich określeń jak lock (unlock), claim (release) i tak dalej. case_semaphore.lock() if gablota.liczba_ciastek > 0 obiecaj_ciastko_klientowi() gablota.weź_ciastko() daj_ciastko_klientowi() end case_semaphore.unlock() W tym kodzie założono, że semafor został już stworzony i zapisany w zmiennej case_semaphore. 3 Nazwy P i V pochodzą od pierwszych liter słów holenderskich. Istnieją jednak różne opinie co do tego, o które dokładnie słowa chodzi. Twórca techniki, Edsger Dijkstra, zasugerował zarówno słowa passering, jak i prolaag dla operacji P oraz vrijgave i ewentualnie verhogen dla operacji V. 4337ebf6db5c7cc89e4173803ef3875a 4 212 Rozdział 6. Współbieżność Załóżmy, że obaj kelnerzy uruchamiają ten kod w tym samym czasie. Obaj starają się zablokować semafor, ale tylko jednemu z nich się to uda. Ten, który dostanie semafor, dalej działa w zwykły sposób. Ten, który nie dostaje semafora, jest zawieszony do czasu, aż semafor będzie dostępny (kelner czeka…). Kiedy pierwszy kelner zrealizuje zamówienie, odblokowuje semafor, a drugi kelner kontynuuje pracę. Widzi teraz, że nie ma ciastek w gablocie, przeprasza więc klienta. Z tym podejściem wiąże się kilka problemów. Prawdopodobnie najbardziej istotne jest to, że działa ono tylko wtedy, kiedy każdy, kto ma dostęp do gabloty, zgodzi się na konwencję posługiwania się semaforem. Jeśli ktoś o niej zapomni (czyli jakiś programista napisze kod, który nie przestrzega konwencji), ponownie powstanie chaos. Tworzenie zasobów transakcyjnych Nasz dotychczasowy projekt jest słaby, ponieważ deleguje odpowiedzialność za ochronę dostępu do gabloty z ciastkami do osób, które z niej korzystają. Zmieńmy go w taki sposób, by scentralizować tę kontrolę. Aby to zrobić, musimy zmienić API tak, aby kelnerzy mogli sprawdzić liczbę ciastek, a jednocześnie wziąć kawałek ciastka w tym samym wywołaniu: kawałek_ciasta = gablota.weź_ciastko_jeśli_dostępne() if kawałek_ciasta daj_ciastko_klientowi() end Aby to rozwiązanie mogło działać, musimy napisać metodę, która działa jako część samej gabloty: def weź_ciastko_jeśli_dostępne() #### if @kawałki.size > 0 # aktualizuj_dane_sprzedaży(:ciastko) # return @kawałki.shift # else # nieprawidłowy kod! false # end # end #### Ten kod ilustruje powszechnie stosowane błędne podejście do problemu. Co prawda przenieśliśmy operację dostępu do zasobów do centralnego miejsca, ale nasza metoda nadal może być wywoływana z wielu współbieżnych wątków, więc nadal musimy chronić ją semaforem: def weź_ciastko_jeśli_dostępne() @case_semaphore.lock() if @kawałki.size > 0 aktualizuj_dane_sprzedaży(:ciastko) return @kawałki.shift else false 4337ebf6db5c7cc89e4173803ef3875a 4 Współdzielony stan jest zły 213 end @case_semaphore.unlock() end Nawet taki kod może działać niepoprawnie. Jeśli wystąpi wyjątek w metodzie aktualizuj_dane_sprzedaży, to semafor nigdy nie zostanie odblokowany, a wszystkie kolejne próby dostępu do gabloty z ciastkami będą zawieszone w nieskończoność. Trzeba obsłużyć tę sytuację: def weź_ciastko_jeśli_dostępne() @case_semaphore.lock() try { if @kawałki.size > 0 aktualizuj_dane_sprzedaży(:ciastko) return @kawałki.shift else false end } ensure { @case_semaphore.unlock() } end Ze względu na powszechność tego błędu, w wielu językach istnieją biblioteki, które obsługują ten problem: def weź_ciastko_jeśli_dostępne() @case_semaphore.protect() { if @kawałki.size > 0 aktualizuj_dane_sprzedaży(:ciastko) return @kawałki.shift else false end } end Transakcje obejmujące wiele zasobów W naszej restauracji właśnie zainstalowano zamrażarkę do lodów. Jeżeli klient zamówi ciastko à la mode, kelner będzie musiał sprawdzić, czy są dostępne zarówno ciastko, jak i lody. Moglibyśmy zmienić kod działania kelnera w taki sposób, by przyjął następującą postać: kawałek_ciasta = gablota.weź_ciastko_jeśli_dostępne() gałka = zamrażarka.weź_loda_jeśli_dostępny() if kawałek_ciasta && gałka zrealizuj_zamówienie_klienta() end 4337ebf6db5c7cc89e4173803ef3875a 4 214 Rozdział 6. Współbieżność Takie rozwiązanie jednak nie zadziała. Co będzie, jeśli zażądamy kawałka ciasta, ale gdy spróbujemy wziąć gałkę lodów, dowiemy się, że żadna nie jest dostępna? Pozostaniemy z kawałkiem ciasta w ręku, z którym nie będziemy mogli nic zrobić (bo nasz klient chce dostać ciastko z lodami). A fakt, że trzymasz ciastko w ręku świadczy o tym, że nie ma go w gablocie, więc nie jest dostępne dla jakiegoś innego klienta, który (będąc purystą) nie chce ciastka razem z lodami. Możemy rozwiązać ten problem poprzez dodanie do gabloty metody pozwalającej zwrócić do niej ciastko. Musimy także dodać blok obsługi wyjątków, aby mieć pewność, że nie będziemy blokować zasobów, jeśli coś się nie powiedzie: kawałek_ciasta = gablota.weź_ciastko_jeśli_dostępne() if kawałek_ciasta try { gałka = zamrażarka.weź_loda_jeśli_dostępny() if gałka try { zrealizuj_zamówienie_klienta() } rescue { zamrażarka.oddaj(gałka) } end } rescue { gablota.oddaj(kawałek_ciasta) } end Także to rozwiązanie jest dalekie od idealnego. Kod jest wyjątkowo brzydki: wywnioskowanie co faktycznie robi, jest trudne: logika biznesowa jest powiązana z kodem porządkowania zasobów. Wcześniej naprawiliśmy podobny problem poprzez przeniesienie kodu obsługi zasobu do samego zasobu. W tym przypadku jednak mamy dwa zasoby. Czy powinniśmy umieścić kod w klasie gabloty, czy zamrażarki? Uważamy, że odpowiedź brzmi „nie” dla obu opcji. Zgodnie z pragmatycznym podejściem, „szarlotka à la mode” to oddzielny zasób. Należałoby przenieść ten kod do nowego modułu. Wtedy klient mógłby po prostu powiedzieć „Poproszę szarlotkę z lodami” i to zamówienie albo by się powiodło, albo nie. Oczywiście w realnym świecie istnieje wiele złożonych dań tego rodzaju. Nie chcielibyśmy zatem pisać nowych modułów dla każdej możliwej kombinacji. Zamiast tego trzeba by napisać reprezentację pozycji w menu, która zawierałaby referencje do jego komponentów. Następnie należałoby napisać generyczną metodę get_menu_item, która wykonuje obsługę zasobów. 4337ebf6db5c7cc89e4173803ef3875a 4 Współdzielony stan jest zły 215 Aktualizacje bez transakcji Wiele osób jako źródło problemów ze współbieżnością wskazuje współdzieloną pamięć. W rzeczywistości jednak problemy mogą pojawić się wszędzie, gdzie kod aplikacji współdzieli zmienne zasoby: pliki, bazy danych, zewnętrzne usługi i tak dalej. Zawsze, kiedy co najmniej dwa egzemplarze kodu mogą uzyskać dostęp do jakiegoś zasobu w tym samym czasie, mamy do czynienia z potencjalnym problemem. Czasami zasób nie jest czymś oczywistym. Podczas pisania tego wydania książki zaktualizowaliśmy zestaw narzędzi, aby wykonywać więcej zadań współbieżnie, z wykorzystaniem wątków. To spowodowało problemy z budowaniem aplikacji, choć działo się to w dziwaczny sposób i w przypadkowych miejscach. Częstą przyczyną przewijającą się przez wszystkie błędy był brak możliwości znalezienia plików lub katalogów, mimo że w rzeczywistości pliki były we właściwym miejscu. W wyniku analizy kodu znaleźliśmy w nim kilka miejsc, w których następowała tymczasowa zmiana bieżącego katalogu. W wersji bez współbieżności do naprawienia błędu wystarczyło, że kod przywrócił katalog. Ale w wersji współbieżnej, jeden wątek zmieniał katalog, a następnie, już po zmianie, mógł zacząć działać kolejny wątek. Ten wątek oczekiwał, że jest w oryginalnym katalogu, ale ponieważ bieżący katalog jest współdzielony między wątkami, tak nie było. Z natury tego problemu wynika kolejna wskazówka: WSKAZÓWKA NR 58 Losowe awarie często wynikają z problemów ze współbieżnością. Inne rodzaje uzyskania dostępu na wyłączność W większości języków są dostępne biblioteki dostarczające jakieś mechanizmy wyłącznego dostępu do współdzielonych zasobów. Czasami są nazywane muteksami (od ang. mutex — mutual exclusion, czyli dosłownie: wzajemne wykluczanie), monitorami lub semaforami. Mechanizmy te są zaimplementowane jako biblioteki. Jednak w niektórych językach wsparcie dla współbieżności jest wbudowane w samym języku. Na przykład w języku Rust istnieje pojęcie własności danych. Tylko jedna zmienna (lub parametr) może w danym czasie posiadać referencję do określonego elementu mutowalnych danych. Można też argumentować, że w językach funkcyjnych, z powodu ich skłonności do stosowania wszędzie niezmienności danych, implementacja współbieżności jest prostsza. Jednak w tych językach wciąż napotykamy na te same problemy, ponieważ w pewnym momencie jesteśmy zmuszeni do wkroczenia do prawdziwego, zmiennego świata. 4337ebf6db5c7cc89e4173803ef3875a 4 216 Rozdział 6. Współbieżność Panie doktorze, to boli… Jeśli po lekturze tego podrozdziału nie wyciągnąłeś żadnych innych wniosków, zapamiętaj jedno: implementacja współbieżności w środowisku ze współdzielonymi zasobami jest trudna, a samodzielne zarządzanie nią jest pełne wyzwań. Dlatego w roli puenty zacytujemy stary dowcip: — Panie doktorze, boli mnie, kiedy to robię. — To proszę tego nie robić. W kolejnych kilku podrozdziałach zasugerujemy alternatywne i bezproblemowe sposoby uzyskania korzyści ze współbieżności. Pokrewne podrozdziały 35 39 Temat 10., „Ortogonalność”. Temat 28., „Eliminowanie sprzężeń”. Temat 38., „Programowanie przez koincydencję”. Aktorzy i procesy Gdyby nie było pisarzy, nikt nie napisałby opowieści. Gdyby nie było aktorów, nikt nie powołałby opowieści do życia. Angie-Marie Delsante Wykorzystanie aktorów i procesów to ciekawy sposób implementacji współbieżności bez konieczności rozwiązywania problemów związanych z dostępem do współdzielonej pamięci. Zanim przejdziemy do szczegółów, zdefiniujemy co przez nie rozumiemy. Będzie to brzmiało w sposób dość akademicki. Nie obawiaj się, wkrótce wszystko się wyjaśni. Aktor to niezależny, wirtualny procesor z własnym lokalnym (i prywatnym) stanem. Każdy aktor ma swoją skrzynkę pocztową. Gdy w skrzynce pocztowej pojawi się wiadomość, a aktor jest w stanie spoczynku, uaktywnia się i ją przetwarza. Gdy zakończy przetwarzanie, przetwarza kolejną wiadomość w skrzynce pocztowej lub, jeżeli skrzynka jest pusta, wraca do stanu uśpienia. Podczas przetwarzania wiadomości aktor może tworzyć innych aktorów, wysyłać wiadomości do innych aktorów, których zna, oraz tworzyć nowy stan, który stanie się stanem aktualnym, gdy będzie przetwarzana następna wiadomość. 4337ebf6db5c7cc89e4173803ef3875a 4 Aktorzy i procesy 217 Proces jest zazwyczaj bardziej ogólnym wirtualnym procesorem, często implementowanym w systemie operacyjnym w celu ułatwienia współbieżności. Procesy mogą być ograniczone (zgodnie z konwencją) tak, by zachowywały się jak aktorzy. Właśnie takiego rodzaju procesami będziemy zajmowali się w tym podrozdziale. Aktorzy mogą działać wyłącznie współbieżnie Jest kilka elementów, których nie znajdziemy w definicji aktorów: Nie ma jednej rzeczy, która posiada sterowanie. Nic nie planuje tego, co będzie dalej, ani nie koordynuje przekazywania informacji z surowych danych do ostatecznego wyjścia. Jedyny stan, jaki istnieje w systemie, jest przechowywany w wiadomościach oraz w lokalnym stanie każdego z aktorów. Wiadomości nie mogą być analizowane inaczej niż poprzez ich odczytanie przez odbiorcę, a lokalny stan poza aktorem jest niedostępny. Wszystkie wiadomości są jednokierunkowe. Nie istnieje pojęcie odpowiedzi. Jeśli chcesz, aby aktor zwrócił odpowiedź, powinieneś dołączyć adres własnej skrzynki pocztowej w wysyłanej wiadomości, a aktor otrzymujący wiadomość (ostatecznie) odeśle odpowiedź jako kolejną wiadomość przekazaną do tej skrzynki pocztowej. Aktor przetwarza każdą wiadomość do końca i każdorazowo przetwarza tylko jedną wiadomość. W efekcie aktorzy działają równolegle, asynchronicznie i niczego nie współdzielą. Gdybyśmy mieli wystarczającą liczbę fizycznych procesorów, moglibyśmy uruchomić na każdym osobnego aktora. Jeśli masz tylko jeden procesor, to jakiś mechanizm zajmuje się przełączaniem kontekstu między aktorami. W każdym z tych przypadków kod uruchomiony wewnątrz aktorów jest taki sam. WSKAZÓWKA NR 59 Do obsługi współbieżności używaj aktorów bez współdzielenia stanu. Prosty aktor Spróbujmy zaimplementować system obsługi restauracji korzystając z aktorów. W naszym przypadku mamy trzech aktorów (klienta, kelnera i kawałek ciasta). Ogólny przepływ wiadomości będzie wyglądać następująco: My (jako pewnego rodzaju zewnętrzna istota — coś w rodzaju Boga) mówimy klientowi, że jest głodny. W odpowiedzi klient prosi kelnera o ciasto. 4337ebf6db5c7cc89e4173803ef3875a 4 218 Rozdział 6. Współbieżność Kelner prosi gablotę z ciastami o wydanie ciasta dla klienta. Jeśli w gablocie z ciastami jest dostępny kawałek ciasta, gablota prześle go klientowi oraz powiadomi kelnera, aby dodał ciasto do rachunku. Jeśli nie ma ciasta, gablota mówi o tym kelnerowi, a kelner przeprasza klienta. Zdecydowaliśmy, że zaimplementujemy ten kod w języku JavaScript, z wykorzystaniem biblioteki Nact4. Dodaliśmy do niej trochę kodu-opakowania, pozwalającego napisać aktorów jako proste obiekty, w których klucze oznaczają rodzaje odbieranych wiadomości, a wartości są funkcjami uruchamianymi po odebraniu określonej wiadomości (większość systemów korzystających z aktorów ma podobną strukturę, ale szczegóły zależą od języka gospodarza). Zacznijmy od klienta. Klient może odbierać trzy wiadomości. Masz ochotę na ciasto (przesyłana przez kontekst zewnętrzny). Na stole jest ciasto (przesyłana przez gablotę z ciastami). Przykro mi, nie ma ciasta (przesyłana przez kelnera). Oto kod: concurrency/actors/index.js const customerActor = { 'ochota na ciasto':(msg, ctx, state) => { return dispatch(state.waiter, { type: "zamówienie", customer: ctx.self, wants: 'ciasto' }) }, 'umieszczone na stole': (msg, ctx, _state) => console.log(`Klient ${ctx.self.name} widzi "${msg.food}" umieszczone na stole`), 'nie ma ciasta': (_msg, ctx, _state) => console.log(`${ctx.self.name} jest niezadowolony…`) } Interesującym przypadkiem jest otrzymanie wiadomości „masz ochotę na ciasto”, którą następnie wysyłamy do kelnera (sposób poinformowania klienta o istnieniu kelnera pokażemy wkrótce). Oto kod kelnera: concurrency/actors/index.js const waiterActor = { "zamówienie": (msg, ctx, state) => { if (msg.wants == "ciasto") { dispatch(state.pieCase, { type: "weź kawałek", customer: msg.customer, waiter: ctx.self }) } else { console.dir(`Nie wiem, jak zrealizować zamówienie ${msg.wants}`); 4 https://github.com/ncthbrt/nact 4337ebf6db5c7cc89e4173803ef3875a 4 Aktorzy i procesy 219 } }, "dodaj do zamówienia": (msg, ctx) => console.log(`Kelner dodaje ${msg.food} do zamówienia klienta ${msg.customer.name}`), "error": (msg, ctx) => { dispatch(msg.customer, { type: 'nie ma ciasta', msg: msg.msg }); console.log(`\nKelner przeprasza klienta ${msg.customer.name}: ${msg.msg}`) } }; Gdy kelner otrzyma od klienta wiadomość „zamówienie”, sprawdza, czy zamówienie dotyczy ciasta. Jeśli tak, to wysyła żądanie do gabloty z ciastami, przekazując referencje zarówno do samego siebie, jak i do klienta. Gablota z ciastami ma swój stan: tablicę zawierającą wszystkie kawałki ciasta, które się w niej znajdują (wkrótce zobaczymy, jak można to skonfigurować). Gdy gablota otrzyma od kelnera wiadomość „weź kawałek”, sprawdza, czy jakiś kawałek jeszcze w niej pozostał. Jeśli tak jest, to przekazuje kawałek ciasta klientowi, prosi kelnera o zaktualizowanie zamówienia i, na koniec, zwraca zaktualizowany stan zawierający o jeden kawałek mniej. Oto kod: concurrency/actors/index.js const pieCaseActor = { 'weź kawałek': (msg, context, state) => { if (state.slices.length == 0) { dispatch(msg.waiter, { type: 'error', msg: "nie ma ciasta", customer: msg.customer }) return state } else { var slice = "ciasto "+ state.slices.shift(); dispatch(msg.customer, { type: 'umieszczone na stole', food: slice }); dispatch(msg.waiter, { type: 'dodaj do zamówienia', food: slice, customer: msg.customer }); return state; } } } Chociaż często aktorzy są uruchamiani dynamicznie przez innych aktorów, w naszym przypadku, dla uproszczenia, będziemy uruchamiać aktorów ręcznie. Będziemy również przekazywać do każdego z nich jakiś stan początkowy: Gablota z ciastami otrzyma początkową listę ciast, które zawiera. Kelner otrzyma referencję do gabloty z ciastami. Klient otrzyma referencję do kelnera. 4337ebf6db5c7cc89e4173803ef3875a 4 220 Rozdział 6. Współbieżność concurrency/actors/index.js const actorSystem = start(); let pieCase = start_actor( actorSystem, 'gablota', pieCaseActor, { slices: ["szarlotka", "brzoskwiniowe", "wiśniowe"] }); let waiter = start_actor( actorSystem, 'kelner', waiterActor, { pieCase: pieCase }); let c1 = start_actor(actorSystem, 'klient1', customerActor, { waiter: waiter }); let c2 = start_actor(actorSystem, 'klient2', customerActor, { waiter: waiter }); I na koniec uruchamiamy aktorów. Nasi klienci są łakomi. Klient nr 1 prosi o trzy kawałki ciasta, a klient nr 2 prosi o dwa kawałki: concurrency/actors/index.js dispatch(c1, { type: 'ochota dispatch(c2, { type: 'ochota dispatch(c1, { type: 'ochota dispatch(c2, { type: 'ochota dispatch(c1, { type: 'ochota sleep(500) . then(() => { stop(actorSystem); }) na na na na na ciasto', ciasto', ciasto', ciasto', ciasto', waiter: waiter: waiter: waiter: waiter: waiter waiter waiter waiter waiter }); }); }); }); }); Gdy uruchomimy program, możemy zaobserwować komunikację pomiędzy aktorami5. Kolejność wyświetlania się komunikatów może być inna: $ node Klient Klient Klient Kelner Kelner Kelner index.js klient1 widzi klient2 widzi klient1 widzi dodaje ciasto dodaje ciasto dodaje ciasto "ciasto jabłkowe" umieszczone na stole "ciasto brzoskwiniowe" umieszczone na stole "ciasto wiśniowe" umieszczone na stole jabłkowe do zamówienia klienta klient1 brzoskwiniowe do zamówienia klienta klient2 wiśniowe do zamówienia klienta klient1 Kelner przeprasza klienta klient2: nie ma ciasta klient2 jest niezadowolony… Kelner przeprasza klienta klient1: nie ma ciasta klient1 jest niezadowolony… 5 Aby uruchomić ten kod, potrzebne są także funkcje pomocnicze, których tutaj nie zaprezentowano. Potrzebny kod można pobrać z witryny FTP wydawnictwa Helion. 4337ebf6db5c7cc89e4173803ef3875a 4 Aktorzy i procesy 221 Brak jawnej współbieżności W modelu aktorów nie ma potrzeby pisania kodu do obsługi współbieżności, ponieważ nie ma współdzielonego stanu. Nie ma też potrzeby jawnego kodowania logiki od końca do końca typu „zrób to, zrób tamto”, ponieważ aktorzy ustalają ją samodzielnie na podstawie wiadomości, które otrzymują. Nie ma też różnicy w działaniu w zależności od stosowanej architektury. Ten zestaw komponentów działa równie dobrze na jednym procesorze, na wielu rdzeniach, czy też na wielu komputerach połączonych w sieci. Erlang ustawia scenę Świetny przykład implementacji aktorów można znaleźć w języku Erlang i jego środowisku wykonawczym (choć twórcy Erlanga nawet nie czytali oryginalnego artykułu opisującego architekturę aktorów). W Erlangu aktorów nazywa się procesami, ale nie są to standardowe procesy w rozumieniu systemu operacyjnego. Podobnie jak aktorzy, których omówiliśmy, procesy Erlanga są lekkie (można uruchomić ich miliony na jednym komputerze), a komunikują się pomiędzy sobą poprzez wysłanie wiadomości. Każdy działa w izolacji od innych, więc nie ma współdzielonego stanu. Ponadto w środowisku wykonawczym Erlanga zaimplementowano system nadzorcy, który zarządza życiem procesów — potencjalnie restartuje proces lub zestaw procesów w przypadku awarii. Erlang oferuje również ładowanie kodu „na gorąco”: pozwala zastąpić kod w uruchomionym systemie bez jego zatrzymywania. Systemy w Erlangu należą do najbardziej niezawodnych na świecie, często ich dostępność osiąga poziom dziewięciu dziewiątek. Ale Erlang (i wywodzący się z niego Elixir) nie jest unikatowy. Implementacje aktorów istnieją dla większości języków. Warto zastanowić się nad ich użyciem do implementacji współbieżności. Pokrewne podrozdziały Temat 28., „Eliminowanie sprzężeń”. Temat 30., „Programowanie transformacyjne”. Temat 36., „Czarne tablice”. Wyzwania Czy obecnie wykorzystujesz kod, który stosuje wzajemne wykluczanie w celu ochrony współdzielonych danych? Zastanów się nad stworzeniem prototypu tego samego kodu z wykorzystaniem aktorów. Kod obsługi restauracji w architekturze aktorów ma wsparcie tylko dla zamawiania kawałków ciasta. Rozszerz go o obsługę zamówień ciast à la 4337ebf6db5c7cc89e4173803ef3875a 4 222 Rozdział 6. Współbieżność mode z oddzielnymi agentami zarządzającymi kawałkami ciasta i gałkami lodów. Zorganizuj kod w taki sposób, aby można było obsłużyć sytuacje, gdy zabraknie jednego lub drugiego składnika. 36 40 Czarne tablice Wszystko jest napisane na ścianie... Księga Daniela Zastanówmy się nad przypadkiem, w którym detektywi wykorzystują czarną tablicę do koordynowania i rozwiązywania śledztwa w sprawie morderstwa. Główny inspektor zaczyna od ustawienia dużej, czarnej tablicy w sali konferencyjnej. Na tej tablicy zapisuje pojedyncze pytanie: H. Domański (mężczyzna, żonaty): Wypadek? Morderstwo? Czy Domański naprawdę spadł, czy został popchnięty? Każdy detektyw może wnieść swój wkład do opowieści o tym potencjalnym morderstwie. Może wprowadzić fakty, zeznania świadków, dowolne uzyskane dowody kryminalistyczne i tak dalej. W miarę gromadzenia danych, detektyw może zauważyć związki i zapisać te obserwacje lub spekulacje na tablicy. Ten proces trwa i ewoluuje. Do czasu zamknięcia sprawy bierze w nim udział wiele różnych osób i agentów. Przykład czarnej tablicy pokazano na rysunku 2. Oto kilka kluczowych cech podejścia typu „czarna tablica”: Żaden z detektywów nie musi wiedzieć o istnieniu jakiegokolwiek innego detektywa — wszyscy oglądają tablicę pod kątem nowych informacji i dodają swoje odkrycia. Detektywi mogą być wyszkoleni w różnych dziedzinach, mogą mieć różne poziomy edukacji i wiedzy, a nawet działać w różnych strefach czasowych. Każdy z nich dąży do rozwiązania zagadki, ale to wszystko, co ich łączy. W trakcie procesu mogą przychodzić i odchodzić różni detektywi, mogą też pracować na różnych zmianach. Nie ma żadnych ograniczeń dotyczących tego, co może być umieszczone na tablicy. Mogą to być zdjęcia, zeznania, fizyczne dowody i tak dalej. Jest to forma współbieżności określana jako leseferyzm (od fr. laissez faire). Detektywi są niezależnymi procesami, agentami, aktorami i tak dalej. Niektórzy zapisują fakty na tablicy. Inni odczytują je z tablicy, starają się je łączyć lub przetwarzać, a także dodają do tablicy nowe informacje. Stopniowo zawartość tablicy pozwala im wyciągać wnioski. Komputerowe systemy czarnych tablic pierwotnie były wykorzystywane w systemach sztucznej inteligencji, gdzie problemy do rozwiązania są obszerne i złożone — rozpoznawanie mowy, systemy wnioskowania oparte na wiedzy i tak dalej. 4337ebf6db5c7cc89e4173803ef3875a 4 Czarne tablice 223 Rysunek 2. Ktoś znalazł związek pomiędzy długami hazardowymi Domańskiego a billingami telefonów. Być może Domański odbierał telefony z pogróżkami Jednym z pierwszych systemów typu „czarna tablica” była Linda Davida Gelerntera. Fakty w tym systemie były przechowywane w postaci typowanych krotek. Aplikacje mogły zapisywać nowe krotki do systemu Linda i odpytywać o istniejące krotki za pomocą swego rodzaju mechanizmu dopasowywania wzorców. Później pojawiły się rozproszone systemy typu „czarna tablica”, takie jak JavaSpaces i T Spaces. W tych systemach można było przechowywać na tablicy aktywne obiekty Javy — nie tylko dane — i pobierać je za pomocą częściowego dopasowywania pól (za pośrednictwem szablonów i symboli wieloznacznych) lub podtypów. Dla przykładu załóżmy, że mamy typ Author, który jest podtypem typu Person. Można przeszukiwać tablicę zawierającą obiekty Person przy użyciu szablonu Author dla pola lastName o wartości „Szekspir”. Dzięki temu w odpowiedzi znalazłby się William Szekspir, który był autorem, ale nie znalazłby się Fred Szekspir, który był ogrodnikiem. Systemy te nigdy się nie przyjęły. Naszym zdaniem po części dlatego, że jeszcze nie było zapotrzebowania na tego rodzaju współbieżne przetwarzanie. Czarna tablica w praktyce Załóżmy, że piszemy program, którego zadaniem jest przyjmowanie i przetwarzanie wniosków o kredyty hipoteczne lub pożyczki gotówkowe. Przepisy regulujące ten obszar są niezwykle złożone, swoje zdania wyrażają w nich instytucje federalne, stanowe i samorządowe. Kredytodawcy muszą udowodnić, że nie ujawnią pewnych informacji, muszą prosić o pewne informacje, ale nie mogą zadawać niektórych pytań i tak dalej, i tak dalej. Poza „wyziewami” z obowiązującego prawa, trzeba także rozwiązać następujące problemy: 4337ebf6db5c7cc89e4173803ef3875a 4 224 Rozdział 6. Współbieżność Odpowiedzi mogą nadchodzić w dowolnej kolejności. Na przykład zapytania do biura informacji kredytowej lub badanie stanu prawnego może zająć dużo czasu, natomiast takie dane, jak nazwisko lub adres, mogą być dostępne natychmiast. Zbieranie danych może być wykonywane przez różnych ludzi, rozproszonych w różnych biurach, a nawet w różnych strefach czasowych. Niektóre operacje gromadzenia danych mogą być wykonywane automatycznie przez inne systemy. Takie dane mogą również nadchodzić asynchronicznie. Niemniej jednak niektóre dane mogą nadal zależeć od innych. Na przykład możesz nie być w stanie rozpocząć badania stanu prawnego samochodu do czasu otrzymania dowodu jego własności lub ubezpieczenia. Nadejście nowych danych może inicjować nowe zapytania i strategie. Załóżmy, że z Biura Informacji Kredytowej nadchodzi raport daleki od doskonałego. Teraz trzeba dostarczyć pięć dodatkowych formularzy i być może jeszcze próbkę krwi. Można spróbować obsłużyć każdą możliwą kombinację i okoliczność za pomocą systemu bazującego na przepływach pracy. Istnieje wiele takich systemów, ale mogą być złożone i wymagają napisania obszernego kodu. Gdy zmienią się przepisy, trzeba zreorganizować przepływ pracy. Czasami także zmieniają się procedury, co powoduje konieczność przepisywania kodu. Eleganckim rozwiązaniem trudności, jakie tu występują, jest zastosowanie systemu typu „czarna tablica” łącznie z silnikiem przetwarzania reguł. Kolejność napływu danych jest bez znaczenia: opublikowanie faktu może inicjować odpowiednie reguły. Można również w łatwy sposób obsługiwać sprzężenia zwrotne: wyjście z dowolnego zestawu reguł może być publikowane na tablicy i powodować zadziałanie kolejnych reguł. WSKAZÓWKA NR 60 Korzystaj z systemów typu „czarne tablice” do koordynowania przepływów pracy. Systemy wymiany komunikatów mogą działać jak czarne tablice W czasie, kiedy piszemy drugie wydanie tej książki, wiele aplikacji konstruuje się w postaci niewielkich, odizolowanych od siebie usług, które komunikują się między sobą za pomocą jakiejś formy systemu przekazywania komunikatów. Te systemy wymiany komunikatów (takie jak Kafka i NATS) robią znacznie więcej niż wysyłanie danych z punktu A do B. W szczególności oferują one mechanizmy utrwalania (w postaci dziennika zdarzeń) oraz możliwość pobierania 4337ebf6db5c7cc89e4173803ef3875a 4 Czarne tablice 225 wiadomości za pomocą jakiejś formy mechanizmu dopasowywania wzorców. Dzięki temu można je wykorzystać zarówno jako system czarnych tablic, jak i platformę, na której można uruchomić kilka egzemplarzy aktorów. Ale to nie jest takie proste… Aktorzy i (lub) czarna tablica i (lub) mikroserwisy jako podejścia do architektury usuwają z aplikacji całą klasę potencjalnych problemów współbieżności. Ale uzyskanie tych korzyści wiąże się z kosztami. Wymienione podejścia są trudniejsze do analizy, ponieważ wiele działań jest wykonywanych pośrednio. Stosowanie tych podejść pomaga w utrzymaniu centralnego repozytorium formatów wiadomości i (lub) interfejsów API, szczególnie jeśli repozytorium może wygenerować kod i dokumentację. Potrzebne jest również dobre oprzyrządowanie pozwalające śledzić wiadomości i fakty podczas ich przekazywania w systemie (przydatną techniką jest dodanie unikatowego identyfikatora śledzenia, gdy zostanie zainicjowana określona funkcja biznesowa, i propagowanie tego identyfikatora do wszystkich aktorów, których to interesuje. Następnie można zrekonstruować to, co się dzieje, na podstawie logów). Te rodzaje systemów mogą być też bardziej kłopotliwe do wdrażania i zarządzania, gdyż istnieje w nich więcej „ruchomych części”. Do pewnego stopnia jest to równoważone przez fakt, że system jest bardziej ziarnisty i może być aktualizowany poprzez zastępowanie poszczególnych aktorów, a nie całego systemu. Pokrewne podrozdziały Temat 28., „Eliminowanie sprzężeń”. Temat 29., „Żonglerka realnym światem”. Temat 33., „Eliminowanie związków czasowych”. Temat 35., „Aktorzy i procesy”. Ćwiczenia 24. Czy system w stylu „czarna tablica” jest odpowiedni dla poniższych zastosowań? Dlaczego tak lub dlaczego nie? Przetwarzanie obrazów. Chcesz utworzyć szereg równoległych procesów do przechwytywania fragmentów obrazu, przetwarzania ich i zwracania przetworzonego fragmentu. Kalendarze grupowe. Zarządzasz pracownikami rozproszonymi po całym świecie, w różnych strefach czasowych i mówiących różnymi językami. Próbujesz zaplanować spotkanie. Narzędzie do monitorowania sieci. System zbiera statystyki wydajności i rejestruje raporty o błędach wykorzystywane przez agentów do wyszukiwania problemów w systemie. 4337ebf6db5c7cc89e4173803ef3875a 4 226 Rozdział 6. Współbieżność Wyzwania Czy używasz systemów typu „czarna tablica” w realnym świecie — tablicy wiadomości na lodówce lub dużej, białej tablicy w pracy? Co sprawia, że takie systemy są skuteczne? Czy wiadomości są publikowane w spójnym formacie? Czy ma to znaczenie? 4337ebf6db5c7cc89e4173803ef3875a 4 Rozdział 7. Kiedy kodujemy… Zgodnie z konwencjonalną wiedzą, od momentu wejścia projektu w fazę kodowania praca staje się w dużej mierze mechaniczna i sprowadza się do przepisywania projektu w formie wykonywalnych wyrażeń. Wydaje nam się, że właśnie to nieuzasadnione przekonanie jest jedną z głównych przyczyn powstawania brzydkich, nieefektywnych programów z fatalną strukturą i trudnych w konserwacji. Kodowanie nie jest czynnością mechaniczną. Gdyby było inaczej, wszystkie te narzędzia CASE, z którymi wiązano ogromne nadzieje na początku lat osiemdziesiątych ubiegłego wieku, dawno zastąpiłyby programistów. Pewne decyzje należy podejmować nawet co minutę — każda taka decyzja wymaga należytej ostrożności, rozwagi i oceny, czy tworzony program będzie cieszył się długim, szczęśliwym i produktywnym życiem. Nie wszystkie decyzje są podejmowane świadomie. Lepiej słuchać własnych instynktów i nieświadomych myśli. Opisaliśmy ten proces w podrozdziale „Słuchaj swojego jaszczurczego mózgu”. Opowiemy w nim, jak słuchać bardziej uważnie i szukać sposobów aktywnego reagowanie na te, czasami wyglądające na nieistotne, myśli. Ale słuchanie instynktów nie oznacza, że można po prostu lecieć na autopilocie. Programiści, którzy aktywnie nie myślą o swoim kodzie, w praktyce programują przez koincydencję — ich kod może działać, jednak trudno wskazać powód, dla którego ten pozytywny scenariusz miałby się urzeczywistnić. W podrozdziale „Programowanie przez koincydencję” będziemy przekonywali do większego zaangażowania w proces kodowania. O ile większość pisanego przez nas kodu jest wykonywana bardzo szybko, od czasu do czasu musimy pisać algorytmy, które mogą zajmować sporo czasu nawet na najszybszych procesorach. W podrozdziale „Szybkość algorytmu” omówimy sposoby szacowania szybkości wykonywania kodu i zaproponujemy kilka wskazówek, jak eliminować potencjalne problemy, zanim jeszcze wystąpią. 4337ebf6db5c7cc89e4173803ef3875a 4 228 Rozdział 7. Kiedy kodujemy… Pragmatyczni programiści są krytyczni dla każdego kodu, także własnego. Stale odkrywamy nowe możliwości doskonalenia naszych programów i projektów. W podrozdziale „Refaktoryzacja” omówimy techniki ułatwiające poprawianie istniejącego kodu (także w samym środku projektu). W testowaniu nie chodzi głównie o znajdowanie błędów, lecz o uzyskanie informacji zwrotnych o kodzie: aspektach projektu, API, sprzężeniach i tak dalej. Oznacza to, że główne korzyści z testów osiągamy wtedy, gdy wymyślamy testy i je piszemy, a nie tylko podczas ich uruchamiania. Tę koncepcję przeanalizujemy w podrozdziale „Kod łatwy do testowania”. Oczywiście podczas testowania własnego kodu można kierować się swoimi uprzedzeniami. W podrozdziale „Testowanie na podstawie własności” pokażemy, jak skłonić komputer do realizacji obszernych testów oraz jak radzić sobie z nieuniknionymi błędami, które się pojawią. Kluczowe znaczenie ma pisanie kodu, który jest czytelny i łatwy do analizy. Świat kodu jest brutalny, pełen złych aktorów, którzy aktywnie próbują włamać się do systemu i spowodować w nim szkody. W podrozdziale „Pozostań w bezpiecznym miejscu” omówimy kilka podstawowych technik i podejść, które pomogą Ci zachować bezpieczeństwo. Jedną z najtrudniejszych rzeczy w tworzeniu oprogramowania jest wymyślanie nazw. Musimy nazwać wiele rzeczy, a pod wieloma względami nazwy, które wybieramy, definiują tworzoną przez nas rzeczywistość. Podczas kodowania musimy mieć świadomość potencjalnego semantycznego dryfu. Większość z nas prowadzi samochód w sposób, który nie odbiega zbytnio od zachowania potencjalnego autopilota — nie wydajemy wprost naszej stopie polecenia, aby nacisnęła na przykład pedał hamulca, ani naszym ramionom, aby kręciły kierownicą — ograniczamy się raczej do myślenia: „zwolnij i skręć w prawo”. Dobrzy kierowcy, którzy prowadzą naprawdę bezpiecznie, stale oceniają sytuację na drodze, analizując potencjalne problemy i dbając o to, aby w razie nieoczekiwanych sytuacji znajdować się w możliwie dobrym miejscu. To samo dotyczy kodowania — mimo że wiele naszych czynności jest rutynowych, ciągłe rozważanie różnych wariantów może uchronić nas przed katastrofą. 37 36 Słuchaj swojego jaszczurczego mózgu Tylko ludzie potrafią bezpośrednio na coś spojrzeć, uzyskać wszystkie informacje potrzebne do dokładnego przewidywania, a czasami dokonać dokładnej prognozy, by następnie jej zaprzeczyć. Gavin de Becker, Dar strachu 4337ebf6db5c7cc89e4173803ef3875a 4 Słuchaj swojego jaszczurczego mózgu 229 Dziełem życia Gavina de Becker jest pomaganie ludziom, aby się zabezpieczali. Swój przekaz zamieścił w książce Dar strachu. Jak wykorzystywać sygnały o zagrożeniu, które ostrzegają nas przed przemocą i zapewniają przeżycie. Jednym z kluczowych poruszonych w niej tematów jest myśl, że ludzie, jako rozwinięte istoty, nauczyli się ignorować naszą bardziej zwierzęcą stronę: nasze instynkty oraz nasz jaszczurczy mózg. Gavin de Becker twierdzi, że większość ludzi, którzy są atakowani na ulicy, jeszcze przed atakiem odczuwa dyskomfort lub zdenerwowanie. Ci ludzie mówią sobie wtedy, że ich obawy są po prostu głupie. Za chwilę z ciemności wyłania się postać… Instynkty są odpowiedzią na wzorce załadowane do tej części naszego mózgu, którą nie sterujemy świadomie. Niektóre są wrodzone, inne wyuczone przez powtarzanie. W miarę zdobywania doświadczenia programisty, Twój mózg układa warstwy ukrytej wiedzy: technik, które działają, i takich, które nie działają, prawdopodobnych przyczyn błędów różnego typu — wszystkiego, co dostrzegasz podczas swojej pracy. To właśnie ta część Twojego mózgu każe nacisnąć przycisk Zapisz plik, kiedy zaczynasz z kimś rozmawiać, nawet jeśli nie zdajesz sobie sprawy z tego, że to robisz. Bez względu na źródło, instynkty mają jedną wspólną cechę: nie wymagają słów. Instynkty sprawiają, że czujesz, a nie myślisz. Kiedy zadziała instynkt, nie zobaczysz migającej żarówki z bannerem owiniętym wokół niej. Zamiast tego staniesz się nerwowy, poczujesz mdłości lub będziesz miał wrażenie, że po prostu masz zbyt dużo pracy. Sztuką jest najpierw zauważyć co się dzieje, a następnie wywnioskować dlaczego. Na początek przyjrzyjmy się kilku typowym sytuacjom, w których ukryty w Tobie jaszczur próbuje Ci coś powiedzieć. Następnie opowiemy, jak pozwolić wyjść temu instynktownie działającemu mózgowi z jego ochronnej skorupy. Obawa przed czystą kartką Wszyscy boimy się pustego ekranu, samotnego migającego kursora otoczonego całą masą niczego. Rozpoczynanie nowego projektu (lub nawet nowego modułu w istniejącym projekcie) może być niepokojącym doświadczeniem. Wielu z nas wolałoby odłożyć na później prace początkowe. Uważamy, że są dwa problemy, które powodują to zjawisko. Oba mają to samo rozwiązanie. Jednym z problemów jest to, że Twój jaszczurczy mózg próbuje Ci coś powiedzieć; istnieje jakaś wątpliwość, która czai się tuż pod powierzchnią percepcji. To bardzo ważne. Jako programista wypróbowałeś wiele rzeczy i wiesz, które się sprawdziły, a które nie. Gromadzisz swoje doświadczenia i wiedzę. Kiedy odczuwasz dokuczliwe wątpliwości lub doświadczasz pewnych oporów w obliczu zadania, może się okazać, że Twoje doświadczenie stara się do Ciebie mówić. Uważaj na to. 4337ebf6db5c7cc89e4173803ef3875a 4 230 Rozdział 7. Kiedy kodujemy… Może nie jesteś w stanie jednoznacznie stwierdzić, co dokładnie jest źle, ale daj sobie czas, a Twoje wątpliwości prawdopodobnie skrystalizują się w coś bardziej trwałego, w coś, czemu będziesz w stanie zaradzić. Niech Twoje instynkty mają wpływ na Twoją wydajność. Inny problem jest nieco bardziej prozaiczny: może po prostu boisz się, że popełnisz pomyłkę. To rozsądna obawa. My, programiści, wkładamy w nasz kod dużą część naszej osobowości; błędy popełnione podczas kodowania możemy odbierać jako zwierciadło naszych kompetencji. Być może istnieje w nas również element syndromu oszusta; możemy myśleć, że ten projekt wykracza poza nasze możliwości. Nie potrafimy dostrzec drogi do celu; dotarliśmy bardzo daleko, a następnie zostaliśmy zmuszeni do przyznania się, że się zgubiliśmy. Walka z samym sobą Czasami kod po prostu wypływa z naszego umysłu do edytora: pomysły stają się bitami pozornie bez wysiłku. Innym razem kodowanie odczuwamy jak spacer pod górę w błocie. Wykonanie każdego kolejnego kroku wymaga ogromnego wysiłku, a po każdych trzech krokach w górę, zsuwamy się o dwa w dół. Ale będąc profesjonalistą, zachowujesz się jak żołnierz — kroczysz po błotnistym szlaku, masz zadanie do wykonania. Niestety, jest to prawdopodobnie dokładne przeciwieństwo tego, co powinieneś robić. Twój kod próbuje Ci coś powiedzieć. Mówi, że to zadanie jest trudniejsze niż powinno. Być może struktura lub projekt są złe, może rozwiązujesz niewłaściwy problem, a może po prostu tworzysz mrowisko błędów. Niezależnie od powodu, Twój jaszczurczy mózg wykrywa wnioski płynące z kodu i rozpaczliwie próbuje skłonić Cię do słuchania. Jak rozmawiać z jaszczurem Dużo się mówi o słuchaniu instynktów, będącego poza naszą świadomością jaszczurczego mózgu. Techniki zawsze są takie same. WSKAZÓWKA NR 61 Słuchaj jaszczura, który jest wewnątrz Ciebie. Po pierwsze przestań robić to, co robisz. Daj sobie trochę czasu i miejsca, aby pozwolić Twojemu mózgowi się zorganizować. Przestań myśleć o kodzie i przez jakiś czas zacznij robić coś, co nie wymaga zbyt wiele myślenia, z dala od klawiatury. Pójdź na spacer, wyjdź na obiad, porozmawiaj z kimś. Być może się 4337ebf6db5c7cc89e4173803ef3875a 4 Słuchaj swojego jaszczurczego mózgu 231 prześpij. Pozwól, by pomysły same przeniknęły przez warstwy Twojego mózgu: nie możesz tego wymusić. W końcu, jak bańka z powietrzem, zdołają przedostać się do Twojej świadomości, a Ty przeżyjesz jeden ze słynnych momentów aha! Jeśli to nie zadziała, spróbuj uzewnętrznić problem. Zacznij bazgrać na kartce o kodzie, który piszesz, albo opowiedz o nim współpracownikowi (najlepiej takiemu, który nie jest programistą), albo po prostu gadaj do swojej gumowej kaczki. Przedstaw problem, który starasz się rozwiązać, różnym częściom Twojego mózgu i sprawdź, czy któraś z nich ma lepszy sposób na jego rozwiązanie. Niezliczoną liczbę razy zdarzało się nam, że jeden z nas wyjaśniając problem drugiemu, nagle wykrzyknął „No tak! Przecież to oczywiste!”. Po czym rozwiązał problem, który go dręczył. Ale być może próbowałeś wszystkich tych rzeczy i nadal tkwisz w martwym punkcie. To czas do działania. Musisz powiedzieć swojemu mózgowi, że to, co masz zamiar zrobić, nie ma znaczenia. Robimy to poprzez prototypowanie. Przedstawienie czas zacząć! Obaj (Andy i Dave) spędziliśmy wiele godzin gapiąc się na puste bufory edytora. Pisaliśmy jakiś kod, potem gapiliśmy się w sufit, potem braliśmy kolejnego drinka, po czym pisaliśmy trochę więcej kodu. Następnie zaczynaliśmy czytać zabawną historię o kocie z dwoma ogonami, po czym pisaliśmy jeszcze trochę więcej kodu, by w końcu wykonać operacje zaznacz wszystko i usuń, a potem zaczynaliśmy od nowa. I tak w kółko. Na przestrzeni lat udało nam się znaleźć sposób działania, który wydaje się sprawdzać. Powiedz sobie, że musisz wykonać prototyp. Jeśli widzisz przed sobą pusty ekran, poszukaj jakiegoś aspektu projektu, który chcesz przeanalizować. Być może używasz nowego frameworka i chcesz się dowiedzieć, w jaki sposób jest w nim realizowane wiązanie danych. A może jest to nowy algorytm, a Ty chcesz się dowiedzieć, jak on działa w przypadkach brzegowych. A może chcesz wypróbować kilka różnych stylów interakcji z użytkownikiem. Jeśli pracujesz na istniejącym kodzie, który Cię ogranicza, odłóż go na bok i stwórz prototyp czegoś podobnego. Wykonaj następujące działania. 1. Napisz na karteczce „tworzę prototyp” i przyklej ją z boku Twojego ekranu. 2. Przypomnij sobie, że prototypy nie muszą w pełni działać. Przypomnij sobie także, że prototypy się wyrzuca, nawet wtedy, gdy działają. Tworzenie prototypów to same korzyści. 3. W pustym buforze edytora stwórz komentarz opisujący jednym zdaniem, czego chcesz się dowiedzieć lub co zrobić. 4. Zacznij kodować. 4337ebf6db5c7cc89e4173803ef3875a 4 232 Rozdział 7. Kiedy kodujemy… Jeśli zaczynasz mieć wątpliwości, spójrz na karteczkę. Jeśli podczas kodowania te dręczące Cię wątpliwości nagle skrystalizują się w wyraźny problem, rozwiąż go. Jeśli dojdziesz do końca eksperymentu i nadal będziesz czuć się nieswojo, zacznij ponownie od spaceru, rozmowy i odpoczynku. Ale z naszych doświadczeń wynika, że w pewnym momencie podczas tworzenia pierwszego prototypu, zaskoczy Cię fakt, że zaczynasz nucić do muzyki, którą słyszysz, i zaczniesz odczuwać przyjemność z tworzenia kodu. Nerwowość wyparuje, a zastąpi ją poczucie pilnej potrzeby wykonania zadania! Na tym etapie wiesz już, co robić. Usuń cały kod prototypu, wyrzuć karteczkę i wypełnij pusty bufor edytora jasnym, błyszczącym, nowym kodem. Nie liczy się tylko Twój kod Duża część naszej pracy związana jest z korzystaniem z istniejącego kodu, często pisanego przez inne osoby. Ci ludzie mają inne instynkty niż Ty, a zatem podejmują także inne decyzje. Niekoniecznie są one gorsze — po prostu inne. Możesz czytać ich kod mechanicznie, przegryzać się przez niego i robić notatki dotyczące rzeczy, które wydają się ważne. To uciążliwa praca, ale przynosi efekty. Możesz także spróbować eksperymentu. Jeśli zauważysz, że coś zostało wykonane w sposób, który wydaje Ci się dziwny, zrób notatki. Rób tak przez jakiś czas i szukaj wzorców. Jeśli odkryjesz, co było powodem takiego pisania kodu, może się okazać, że zadanie zrozumienia go staje się dużo łatwiejsze. Będziesz mógł świadomie stosować wzorce, które inni programiści stosowali instynktownie. A przy okazji możesz nauczyć się czegoś nowego. Nie liczy się sam kod Ważną umiejętnością podczas kodowania jest zdolność słuchania własnego wnętrza. Ale odnosi się to również do szerszej perspektywy. Czasami nie podoba Ci się projekt albo jakieś wymaganie sprawia, że czujesz się nieswojo. Poświęć chwilę na przeanalizowanie tych odczuć. Jeśli jesteś w otoczeniu współpracowników, wyraź je na głos. Zbadaj je. Być może coś czai się w ciemnej bramie. Słuchaj swoich instynktów i unikaj problemów, zanim na Ciebie spadną. Pokrewne podrozdziały Temat 13., „Prototypy i karteczki samoprzylepne”. Temat 22., „Dzienniki inżynierskie”. Temat 46., „Rozwiązywanie niemożliwych do rozwiązania łamigłówek”. 4337ebf6db5c7cc89e4173803ef3875a 4 Programowanie przez koincydencję 233 Wyzwania 38 37 Czy jest coś, o czym wiesz, że powinieneś zrobić, ale to odkładasz, ponieważ Cię to przeraża lub uważasz, że jest zbyt trudne? Zastosuj techniki opisane w tym podrozdziale. Ustaw timer na godzinę, może dwie, i obiecaj sobie, że gdy zadzwoni dzwonek, usuniesz to, co przez ten czas zrobiłeś. Czego się nauczyłeś? Programowanie przez koincydencję Czy kiedykolwiek oglądałeś stare czarno-białe filmy wojenne? Zmęczeni żołnierze ostrożnie przedzierają się przez zarośla. Przed nimi jest polana: czy nie ma tam min i czy można przez nią przejść? Ewentualne pole minowe nie będzie w żaden sposób oznaczone — nie będzie ostrzeżeń, drutu kolczastego ani kraterów po wybuchach. Żołnierz ostrożnie wbija w ziemię bagnet i natychmiast upada, aby zminimalizować skutki eksplozji. Wybuch nie następuje. Żołnierz posuwa się więc naprzód, pokonuje parę metrów i ponownie sprawdza teren, nakłuwając ziemię bagnetem. Ostatecznie, przekonany o braku min oddział wstaje i dumnie podąża naprzód, aby za chwilę wejść prosto na ukryte kawałek dalej miny. Wstępne badanie terenu nie wykazało żadnych zagrożeń, ale przyszłość pokazała, że był to raczej przypadek. Początkowe szczęście doprowadziło do fałszywych wniosków, których efekt był katastrofalny. Jako programiści także pracujemy na polach minowych. Codziennie czyhają na nas setki najróżniejszych pułapek. Mając na uwadze przytoczoną historię żołnierzy, powinniśmy konsekwentnie unikać fałszywych wniosków. Powinniśmy unikać programowania przez koincydencję (opierania się na samym szczęściu i przypadkowych sukcesach) na rzecz umyślnego, przemyślanego programowania. Jak programować przez koincydencję Przypuśćmy, że Fred otrzymał zlecenie programistyczne. Napisał więc jakiś kod, po czym sprawdził program w działaniu — wydawało się, że wszystko działa prawidłowo. Fred napisał więcej kodu i sprawdził nowe rozwiązania — jego program wciąż działał. Po kilku tygodniach kodowania w ten sposób program nagle przestał działać. Co więcej, po wielogodzinnych próbach naprawiania kodu Fred wciąż nie wiedział, co jest źródłem problemu. Fred równie dobrze mógłby poświęcić wiele dni pracy na analizę i poprawianie swojego kodu, a mimo to nigdy nie byłby w stanie zlokalizować i usunąć usterki. Cokolwiek zrobi, program po prostu nie zadziała prawidłowo. 4337ebf6db5c7cc89e4173803ef3875a 4 234 Rozdział 7. Kiedy kodujemy… Fred nie wie, dlaczego jego kod nie działa, ponieważ nigdy nie wiedział, dlaczego ten kod wcześniej działał. Wydawało się, że program działa (przynajmniej podczas ograniczonych testów wykonanych przez Freda), ale praktyka pokazała, że był to raczej zbieg okoliczności, koincydencja. Na podstawie mylnego przeświadczenia o prawidłowości dotychczasowych rozwiązań Fred zmierzał wprost ku katastrofie. Większość inteligentnych ludzi słyszała podobne historie, a mimo to powiela ten błąd. Nie możemy uzależniać funkcjonowania naszych programów od zbiegu okoliczności, prawda? Okazuje się, że każdemu to się zdarza. W pewnych przypadkach nietrudno pomylić przypadek, łut szczęścia z przemyślanym planem. Przeanalizujmy kilka przykładów. Przypadkowa implementacja Przypadkowa implementacja to wszystkie rozwiązania wynikające wprost z niewłaściwego sposobu pisania własnego kodu. Taki tryb pracy zwykle kończy się powstaniem nieudokumentowanego błędu lub warunków granicznych. Przypuśćmy, że wywołujemy jakąś procedurę z błędnymi danymi. Procedura odpowiada wówczas w określony sposób, a nasz dalszy kod opiera się właśnie na tej odpowiedzi. Mimo to autor nigdy nie planował takiego działania wspomnianej procedury — co więcej, takie działanie nigdy nie było nawet rozważane. Po „naprawieniu” procedury może okazać się, że reszta kodu przestała działać. W skrajnych przypadkach wywołana przez nas procedura może nawet nie być projektowana z myślą o tych zastosowaniach, a mimo to będzie sprawiała wrażenie prawidłowej. Z podobnym problemem mamy do czynienia podczas wywoływania funkcji w niewłaściwej kolejności lub w błędnym kontekście. Tutaj wygląda na to, że Fred desperacko próbuje coś wyświetlić na ekranie za pomocą jakiegoś frameworka renderowania GUI: paint(); invalidate(); validate(); revalidate(); repaint(); paintImmediately(r); Okazuje się jednak, że te procedury nigdy nie były projektowane z myślą o wywoływaniu w ten sposób; chociaż wszystko zdaje się działać prawidłowo, w rzeczywistości mamy do czynienia z koincydencją. Jakby tego było mało, komponent ostatecznie został wyświetlony, powodując, że Fred nawet nie spróbuje wrócić do tego zagadnienia, aby wyeliminować te podejrzane zapytania. „Skoro wszystko już działa, lepiej tego nie ruszać…”. 4337ebf6db5c7cc89e4173803ef3875a 4 Programowanie przez koincydencję 235 Taka postawa może łatwo doprowadzić do błędnych założeń. Dlaczego mielibyśmy podejmować ryzyko zepsucia czegoś, co już działa? Cóż, przychodzi nam do głowy kilka powodów: Być może to wcale nie działa, a jedynie sprawia takie wrażenie. Warunek graniczny, na którym opieramy nasze rozwiązanie, może być po prostu dziełem przypadku. W różnych okolicznościach (na przykład w różnych rozdzielczościach ekranu) nasze rozwiązanie może działać zupełnie inaczej. Nieudokumentowane zachowania mogą się zmienić wraz z następnym wydaniem tej biblioteki. Dodatkowe i zbędne wywołania mogą spowolnić nasz kod. Dodatkowe wywołania zwiększają też ryzyko wprowadzenia nowych błędów. W przypadku kodu pisanego z myślą o wywołaniach przez innych programistów sporym ułatwieniem będzie konsekwentne stosowanie zasad podziału na moduły i ukrywania implementacji za niewielkimi, dobrze udokumentowanymi interfejsami. Precyzyjnie zdefiniowany kontrakt (patrz temat 23., „Projektowanie kontraktowe” w rozdziale 4.) może pomóc wyeliminować nieporozumienia. W przypadku procedur, które sami wywołujemy, powinniśmy korzystać wyłącznie z udokumentowanych zachowań. Jeśli z jakiegoś powodu to niemożliwe, powinniśmy przynajmniej dbać o jasne dokumentowanie przyjmowanych założeń. Blisko to nie znaczy dokładnie Kiedyś pracowaliśmy nad dużym projektem, którego celem było tworzenie raportów na podstawie danych pobieranych z bardzo dużej liczby rozproszonych urządzeń rozmieszczonych w terenie. Sprzęty te były rozmieszczone w różnych państwach i strefach czasowych, a z różnych względów logistycznych i historycznych w każdym z urządzeń był ustawiony czas lokalny1. Wskutek błędnych interpretacji stref czasowych i niespójności w strategii zmian czasu z letniego na zimowy i na odwrót, wyniki były prawie zawsze błędne, ale tylko o jedną godzinę. Programiści pracujący w projekcie nabrali nawyku dodawania lub odejmowania jedynki po to, aby uzyskać właściwą odpowiedź. Tłumaczyli, że wynik różni się o jeden tylko w tej jednej sytuacji. A potem kolejna funkcja sprawdzała, że wynik różni się o jeden, i zmieniała go ponownie. Ale fakt, że różnica od dokładnego wyniku wynosiła „tylko” jeden, raz na jakiś czas dowodził istnienia zbiegu okoliczności, który maskował o wiele głębszą i bardziej istotną wadę. Ze względu na brak prawidłowego modelu konwersji czasu, cała rozbudowana baza kodu zmieniła się w końcu w niedopuszczalną masę instrukcji +1 i –1. Ostatecznie żaden wynik nie był poprawny, w związku z czym projekt zamknięto. 1 Uwaga z doświadczenia: czas UTC istnieje nie bez powodu. Używaj go. 4337ebf6db5c7cc89e4173803ef3875a 4 236 Rozdział 7. Kiedy kodujemy… Wzorce-widma Ludzie mają tendencję do zauważania wzorców i przyczyn nawet wtedy, gdy jest to tylko zbieg okoliczności. Na przykład rosyjscy przywódcy zawsze są naprzemiennie łysi i owłosieni: łysy (lub w oczywisty sposób łysiejący) przywódca Rosji zwyciężał tego z włosami i odwrotnie, przez prawie 200 lat2. Ale o ile nikt raczej nie pokusi się o napisanie kodu, który zależy od tego, czy kolejny rosyjski lider będzie łysy czy owłosiony, o tyle w niektórych dziedzinach myślimy w ten sposób przez cały czas. Hazardziści wyobrażają sobie wzorce w loteriach liczbowych, grach w kości lub ruletkę, podczas gdy w rzeczywistości losowane wartości są zdarzeniami statystycznie niezależnymi. Na rynkach finansowych, w obrocie akcjami i obligacjami jest podobnie. Roi się tam od stosowania przypadkowych zamiast rzeczywistych i namacalnych wzorców. Plik logu, w którym widać okresowy błąd raz na 1000 żądań, może wskazywać na trudny do zdiagnozowania wyścig lub na zwykły stary błąd. Testy, które przechodzą na komputerze lokalnym, ale nie przechodzą na serwerze, mogą oznaczać różnicę pomiędzy tymi dwoma środowiskami, ale może to być jedynie zbieg okoliczności. Nie przyjmuj założeń, posługuj się dowodami. Przypadkowy kontekst Możemy też stanąć przed problemem przypadkowego kontekstu. Przypuśćmy, że piszemy jakiś kod pomocniczy. Czy to, że aktualnie pracujemy nad kodem dla środowiska z graficznym interfejsem użytkownika, uzasadnia uzależnianie tego modułu od obecności środowiska GUI? Czy tworzymy oprogramowanie tylko dla anglojęzycznych użytkowników? A może kierujemy swój produkt tylko do wykształconych ludzi? Jakie jeszcze założenia przyjęliśmy, mimo że nie mamy stosownych gwarancji? Czy polegasz na założeniu, że w bieżącym katalogu istnieje prawo zapisu? Czy zakładasz, że istnieją pewne zmienne środowiskowe lub pliki konfiguracyjne? Albo że czas na serwerze jest dokładny — jaką przyjmujesz tolerancję? Czy polegasz na dostępności sieci lub szybkości jej działania? Jeśli skopiowałeś kod z pierwszej odpowiedzi znalezionej w internecie, to czy jesteś pewien, że Twój kontekst jest taki sam, jak u autora tego kodu? A może budujesz kod w stylu „kultu cargo”, jedynie naśladując formę, ale bez treści3? Znalezienie odpowiedzi, która wygląda na właściwą, to nie to samo, co znalezienie odpowiedzi właściwej. 2 https://en.wikipedia.org/wiki/Correlation_does_not_imply_causation 3 Zobacz temat 50., „Nie próbuj przecinać kokosów”; znajdziesz go w rozdziale 9. 4337ebf6db5c7cc89e4173803ef3875a 4 Programowanie przez koincydencję 237 WSKAZÓWKA NR 62 Nie należy programować przez koincydencję. Ukryte założenia Przypadkowe zbiegi okoliczności mogą nas prowadzić w niewłaściwym kierunku na wszystkich poziomach — od generowania wymagań po testy. Fałszywe związki przyczynowo-skutkowe i przypadkowe wyniki szczególnie często występują na etapie testowania. Czasem bardzo łatwo ulec pokusie założenia, że to X powoduje Y, jednak, jak zasugerowaliśmy w rozdziale 3., w temacie 20., „Debugowanie”, nie należy niczego zakładać — należy to udowodnić. Na wszystkich poziomach ludzie operują na rozmaitych założeniach przyjmowanych we własnych umysłach — takie założenia są jednak rzadko dokumentowane i często są sprzeczne z założeniami przyjmowanymi przez innych ludzi. Założenia, które nie są oparte na sprawdzonej wiedzy, mogą doprowadzić do niepowodzenia każdego projektu. Jak programować celowo Chcemy poświęcać jak najmniej czasu na pracę z gotowym kodem — chcemy wykrywać i eliminować błędy na możliwie wczesnym etapie cyklu wytwarzania (i oczywiście tworzyć jak najmniej błędów). Sporym ułatwieniem będzie programowanie celowe (umyślne): Zawsze należy wiedzieć, co się robi. Fred dopuścił do sytuacji, w której stracił kontrolę nad swoim kodem — przez nieuwagę podzielił żaby z tematu „Zupa z kamieni i gotowane żaby” w rozdziale 1. Czy potrafisz szczegółowo wyjaśnić kod mniej doświadczonemu programiście z Twojego zespołu? Jeśli nie, to prawdopodobni bazujesz na zbiegach okoliczności. Nie należy kodować po omacku. Próba budowy aplikacji, której do końca nie rozumiemy, lub użycia technologii, której dobrze nie znamy, jest jak zaproszenie do nieporozumień wynikających z przypadkowych zbiegów okoliczności. Należy postępować według planu niezależnie od tego, czy jest to plan w naszej głowie, zapisany na serwetce lub na białej tablicy. Należy opierać się na tym, co niezawodne. Nie powinniśmy uzależniać naszych rozwiązań od przypadków ani założeń. Jeśli nie potrafimy rozstrzygnąć jakichś kwestii, powinniśmy zakładać najgorszy scenariusz. Należy dokumentować założenia. Propozycje zawarte w rozdziale 4., w temacie 23., „Projektowanie kontraktowe”, mogą nam ułatwić zarówno precyzyjne opisywanie założeń rodzących się w naszych umysłach, jak i komunikowanie tych założeń innym. 4337ebf6db5c7cc89e4173803ef3875a 4 238 Rozdział 7. Kiedy kodujemy… Samo testowanie kodu nie wystarczy — należy jeszcze testować przyjmowane założenia. Nie powinniśmy zgadywać, tylko sprawdzać. Należy zapisywać asercje niezbędne do testowania naszych założeń (patrz temat 25., „Programowanie asertywne”, mieszczący się w rozdziale 4.). Jeśli stosowane asercje będą prawidłowe, będziemy dodatkowo dysponowali lepszą dokumentacją swojego kodu. Jeśli w ten sposób odkryjemy, że jakieś założenie było błędne, możemy mówić o dużym szczęściu. Należy nadawać priorytety swoim wysiłkom. Warto poświęcić swój czas na najważniejsze aspekty, które najczęściej stanowią najtrudniejsze elementy tworzonego systemu. Jeśli fundamenty lub infrastruktura naszego projektu nie są prawidłowe, nawet najlepsze dodatki i dekoracje okażą się zupełnie nieistotne. Nie możemy być niewolnikami historii. Nie możemy pozwolić, aby istniejący kod dyktował nam, jak tworzyć kod w przyszłości. Cały dotychczasowy kod można zastąpić, jeśli przestał spełniać nasze oczekiwania. Nawet w ramach jednego programu nie powinniśmy dopuszczać do sytuacji, w której dotychczasowe rozwiązania wymuszają kierunki dalszych działań — musimy być gotowi na refaktoryzację (patrz temat 40., „Refaktoryzacja”). Ta decyzja może mieć istotny wpływ na harmonogram prac nad projektem. Zakładamy jednak, że jej negatywny wpływ na czas realizacji projektu będzie mniejszy niż koszt zaniechania zmian4. Kiedy więc następnym razem coś będzie sprawiało wrażenie prawidłowego, ale nie będziemy wiedzieli dlaczego, koniecznie powinniśmy upewnić się, że nie mamy do czynienia z koincydencją. Pokrewne podrozdziały Temat 4., „Zupa z kamieni i gotowane żaby”. Temat 9., „DRY — Przekleństwo powielania”. Temat 23., „Projektowanie kontraktowe”. Temat 34., „Współdzielony stan jest zły”. Temat 43., „Pozostań w bezpiecznym miejscu”. Ćwiczenia 25. Źródło danych od dostawcy zwraca tablicę krotek reprezentujących pary klucz-wartość. Kluczowi DepositAccount odpowiada ciąg znaków reprezentujący numer rachunku: [ ... {:DepositAccount, "564-904-143-00"} 4 W tej sprawie można też zabrnąć za daleko. Znaliśmy kiedyś programistę, który zdecydował się przepisać cały przekazany mu kod źródłowy, ponieważ był niezgodny z jego konwencjami nazewniczymi. 4337ebf6db5c7cc89e4173803ef3875a 4 Szybkość algorytmu 239 ... ] Kod działał idealnie, gdy był testowany na 4-rdzeniowym laptopie programisty oraz na 12-rdzeniowym serwerze budowania, ale na serwerach produkcyjnych działających w kontenerach, otrzymywane numery rachunków są nieprawidłowe. Co się dzieje? 26. Piszesz kod automatycznego dialera do powiadomień głosowych i zarządzasz bazą danych z informacjami kontaktowymi. Specyfikacja ITU określa, że numery telefonów nie powinny być dłuższe niż 15 cyfr, więc przechowujesz kontaktowy numer telefonu w polu numerycznym, które gwarantuje przechowywanie przynajmniej 15 cyfr. Dokładnie przetestowałeś kod dla całej Ameryki Północnej i wszystko wydaje się działać w porządku, ale nagle otrzymujesz wiele raportów o błędach z innych części świata. Dlaczego? 27. Napisałeś aplikację, która skaluje przepisy kulinarne dla jadłodajni na statku wycieczkowym, który może pomieścić 5000 pasażerów. Otrzymujesz jednak skargi, że konwersje nie są precyzyjne. Sprawdziłeś, że w kodzie wykorzystano formułę konwersji 16 szklanek na galon. Wydaje się, że to prawidłowa formuła. A może jednak nie? 39 38 Szybkość algorytmu W temacie 15., „Szacowanie”, mieszczącym się w rozdziale 2., omówiliśmy problem szacowania czasu rozmaitych czynności, w tym czasu potrzebnego do pieszego pokonania kilku ulic oraz do zakończenia projektu. Okazuje się jednak, że istnieje jeszcze inny rodzaj szacowania, który pragmatyczni programiści stosują niemal codziennie — szacowanie zasobów (czasu, procesora, pamięci itp.) używanych przez algorytmy. Ten rodzaj szacowania często jest bardzo ważny dla powodzenia realizowanych przedsięwzięć. Jeśli będziemy mieli wybór pomiędzy dwoma sposobami implementacji jakiegoś rozwiązania, który z nich wybierzemy? Skoro wiemy, ile czasu zajmie naszemu programowi przetworzenie tysiąca rekordów, czy potrafimy przeskalować te szacunki dla miliona rekordów? Które elementy kodu wymagają optymalizacji? Okazuje się, że na wiele podobnych pytań można odpowiedzieć, kierując się zdrowym rozsądkiem, wykonując pewne analizy oraz zapisując szacunki w tzw. notacji wielkiego O. Co właściwie rozumiemy przez szacowanie algorytmów? Większość nietrywialnych algorytmów obsługuje jakiś rodzaj zmiennych danych wejściowych — sortuje n łańcuchów, odwraca macierz m×n lub odszyfrowuje 4337ebf6db5c7cc89e4173803ef3875a 4 240 Rozdział 7. Kiedy kodujemy… wiadomość przy użyciu n-bitowego klucza. Rozmiar tych danych wejściowych zwykle wpływa na algorytm — im większe są te dane, tym dłużej trwa ich przetworzenie i tym więcej pamięci trzeba użyć. Gdyby ta relacja była zawsze liniowa (gdyby czas działania algorytmu był wprost proporcjonalny do wartości n), niniejszy podrozdział byłby w ogóle niepotrzebny. Okazuje się jednak, że najważniejsze algorytmy są nieliniowe. Niewątpliwym pocieszeniem jest to, że złożoność wielu spośród tych algorytmów jest mniejsza niż liniowa. Na przykład algorytm przeszukiwania binarnego nie musi analizować każdego kandydata podczas odnajdywania dopasowania. Mniej optymistyczna jest wieść o tym, że pozostałe algorytmy są istotnie wolniejsze od algorytmów liniowych — w ich przypadku czas wykonywania i wymagania pamięciowe rosną dużo szybciej niż n. Algorytm, któremu przetworzenie dziesięciu elementów zabiera minutę, może potrzebować dziesięcioleci do przetworzenia 100 elementów. Za każdym razem, gdy sami piszemy jakikolwiek kod zawierający pętle lub wywołania rekurencyjne, podświadomie sprawdzamy czas wykonywania i wymagania pamięciowe nowego kodu. Wspomniany proces rzadko ma formalny charakter — to raczej szybka weryfikacja wykonalności stosowanych rozwiązań w konkretnych okolicznościach. Zdarza się jednak, że wykonujemy bardziej szczegółowe analizy. Właśnie wtedy notacja O() jest nieocenionym ułatwieniem. Notacja O() Notacja O() to matematyczny sposób wyrażania i opisywania przybliżeń. Kiedy zapisujemy, że określona funkcja sortuje n rekordów w czasie O(n2), w rzeczywistości szacujemy, że w najgorszym przypadku czas sortowania będzie kwadratem liczby n. Wystarczy więc podwoić liczbę rekordów, aby czas sortowania wzrósł (w przybliżeniu) czterokrotnie. Zapis O należy traktować jako rząd wielkości. Notacja O() wyznacza tylko górną granicę dla mierzonej wartości (czasu, pamięci itp.). Kiedy mówimy, że jakaś funkcja wymaga O(n2) czasu, w rzeczywistości określamy, że górna granica czasu jej wykonywania nie będzie rosła szybciej niż n2. W niektórych przypadkach funkcje O() są dość złożone, ale ponieważ wyraz najwyższego rzędu dominuje wzrost zasobów używanych przez tę funkcję wraz ze wzrostem wartości n, zgodnie z konwencją wyrazy niższego rzędu należy pominąć i nie zawracać sobie głowy zapisywaniem wszystkich stałych współ2 2 czynników. Zapis O( n2 3n) jest więc równoważny zapisowi O( n2 ) , który z kolei odpowiada prostszemu zapisowi O(n2). To jedna ze słabości notacji O() — jeden algorytm o złożoności O(n2) może być na przykład tysiąc razy wolniejszy od innego algorytmu o złożoności O(n2), czego w żaden sposób nie można stwierdzić na podstawie tej notacji. 4337ebf6db5c7cc89e4173803ef3875a 4 Szybkość algorytmu 241 Na rysunku 3. pokazano kilka najbardziej typowych zapisów w notacji O() wraz z wykresem ilustrującym czasy wykonywania algorytmów w poszczególnych kategoriach. Jak łatwo zauważyć, sprawy szybko wymykają się spod kontroli, kiedy złożoność przekracza poziom O(n2). Rysunek 3. Czasy wykonywania różnych algorytmów Przypuśćmy na przykład, że dysponujemy funkcją, która przetwarza 100 rekordów w ciągu sekundy. Ile czasu zajmie jej przetworzenie 1000 rekordów? Jeśli złożoność naszego kodu wynosi O(1), przetworzenie 1000 rekordów nadal będzie zajmowało sekundę. W przypadku złożoności O(lg(n)) prawdopodobnie będziemy musieli poczekać 3 sekundy. Złożoność O(n) oznacza liniowy wzrost do 10 sekund, a wykonywanie algorytmu o złożoności O(n lg(n)) zajęłoby około 33 sekundy. Jeśli nie mamy tyle szczęścia i nasza funkcja cechuje się złożonością O(n2), musimy przygotować się na oczekiwanie przez około 100 sekund. A jeśli posługujemy się algorytmem o złożoności wykładniczej O(2n), możemy spokojnie przystąpić do parzenia kawy — nasza funkcja powinna zakończyć działanie po 10263 latach. Wydaje się, że ludzkość nie ma tyle czasu. 4337ebf6db5c7cc89e4173803ef3875a 4 242 Rozdział 7. Kiedy kodujemy… Notacja O() nie musi być stosowana tylko dla czasu — równie dobrze można jej używać do reprezentowania dowolnych innych zasobów używanych przez algorytm. Notacji O() często używa się do modelowania poziomu wykorzystania pamięci (patrz ćwiczenia na końcu tego podrozdziału). Szacowanie zdroworozsądkowe Przybliżoną złożoność wielu prostych algorytmów możemy szacować, posługując się wyłącznie intuicją i zdrowym rozsądkiem. Proste pętle. Jeśli prosta pętla wykonuje od 1 do n-tej iteracji, złożoność całego algorytmu najprawdopodobniej wynosi O(n) — czas jego wykonywania rośnie liniowo wraz z wartością n. Do typowych przykładów należy wyczerpujące wyszukiwanie, odnajdywanie wartości maksymalnej w tablicy oraz generowanie sum kontrolnych. Pętle zagnieżdżone. Jeśli zagnieżdżamy jedną pętlę w innej pętli, otrzymujemy algorytm o złożoności O(m×n), gdzie m oraz n to liczby iteracji obu pętli. Taka sytuacja często ma miejsce w prostych algorytmach sortujących (na przykład w algorytmie sortowania bąbelkowego), gdzie pętla zewnętrzna przeszukuje wszystkie elementy tablicy, a pętla wewnętrzna określa, gdzie należy umieszczać każdy z tych elementów w posortowanej tablicy wynikowej. Takie algorytmy sortujące zwykle mają złożoność O(n2). Przeszukiwanie dwudzielne. Jeśli nasz algorytm dzieli na pół zbiór elementów w każdym przebiegu pętli, złożoność tego kodu najprawdopodobniej jest logarytmiczna i wynosi O(lg(n)). Taką złożonością cechuje się wyszukiwanie binarne na posortowanej liście, chodzenie po drzewie binarnym oraz odnajdywanie pierwszego ustawionego bitu w słowie maszynowym. Dziel i zwyciężaj. Algorytmy, które dzielą swoje dane wejściowe i pracują niezależnie na dwóch połowach, po czym łączą wyniki, osiągają złożoność O(n lg(n)). Klasycznym przykładem jest algorytm sortowania szybkiego, który dzieli dane na dwie połowy i rekurencyjnie sortuje każdą z nich. Mimo że formalnie wciąż mamy do czynienia z algorytmem O(n2), w praktyce algorytm działa szybciej, kiedy otrzymuje posortowane dane wejściowe, zatem średnia złożoność sortowania szybkiego wynosi O(n lg(n)). Algorytmy kombinatoryczne. Każdy algorytm poszukujący permutacji oznacza ryzyko utraty kontroli nad czasami wykonywania. Problem w tym, że liczba permutacji jest równa silni liczby elementów (istnieje 5! = 5×4×3×2×1 = 120 permutacji cyfr od 1 do 5). Oznacza to, że czas potrzebny do przetworzenia przez algorytm kombinatoryczny pięciu elementów wydłuży się 6-krotnie w przypadku sześciu elementów oraz 42-krotnie w przypadku siedmiu elementów. Tego rodzaju algorytmy stosuje się do rozwiązywania trudnych obliczeniowo problemów, jak problem komiwojażera, problem optymalnego rozmieszczania zawartości kontenera, dzielenie zbioru 4337ebf6db5c7cc89e4173803ef3875a 4 Szybkość algorytmu 243 liczb tak, aby suma elementów w każdym podzbiorze była identyczna itp. Często stosuje się algorytmy heurystyczne, które pozwalają skrócić czas wykonywania tych algorytmów w konkretnych dziedzinach problemu. Szybkość algorytmu w praktyce Trudno oczekiwać, aby pisanie funkcji sortujących miało zająć istotną część naszej kariery. Rozwiązania zaimplementowane już w dostępnych bibliotekach prawdopodobnie oferują wyższą wydajność niż jakikolwiek kod, który moglibyśmy napisać w rozsądnym czasie. Okazuje się jednak, że podstawowe rodzaje algorytmów, które opisaliśmy wcześniej, w różnych formach przewijają się w pracy każdego programisty. Za każdym razem, gdy piszemy prostą pętlę, możemy być pewni, że złożoność tego algorytmu wyniesie O(n). Jeśli ta pętla zawiera pętlę wewnętrzną, mamy do czynienia z algorytmem o złożoności O(m×n). Sami powinniśmy zadawać sobie pytania, na jak duże wartości możemy sobie pozwolić. Jeśli dopuszczalne liczby wejściowe są ograniczone z góry, możemy od razu stwierdzić, ile czasu zajmie wykonywanie naszego kodu. Jeśli te wartości zależą od czynników zewnętrznych (na przykład liczby rekordów zwróconych przez plik wsadowy uruchamiany na noc czy liczby nazwisk na liście klientów), być może powinniśmy skoncentrować się raczej na maksymalnym akceptowanym czasie działania lub na maksymalnym dopuszczalnym poziomie wykorzystania pamięci. WSKAZÓWKA NR 63 Należy szacować rzędy wielkości algorytmów. Istnieją pewne techniki, które można z powodzeniem wykorzystywać do rozwiązywania potencjalnych problemów. Jeśli złożoność naszego algorytmu wynosi O(n2), warto podjąć próbę zastosowania techniki „dziel i zwyciężaj”, aby obniżyć tę złożoność do O(n lg(n)). Jeśli nie jesteśmy pewni, ile czasu potrzeba na wykonanie naszego kodu lub ile pamięci wykorzysta nasz kod, wystarczy to sprawdzić, przeprowadzając eksperymenty przy różnej liczbie rekordów wejściowych (lub różnych wartościach dowolnego innego parametru, który prawdopodobnie będzie miał wpływ na czas działania algorytmu). Warto następnie nanieść wyniki na wykres. Kształt tak tworzonego wykresu dość szybko powinien nam zasugerować faktyczną złożoność. Czy mamy do czynienia z krzywą rosnącą, prostą, czy może krzywą dążącą do jakiejś wartości stałej (przy rosnącej ilości danych wejściowych)? Trzy lub cztery pomiary powinny wystarczyć. Warto też testować rozwiązania stosowane w samym kodzie. Dla odpowiednio małych wartości n prosta pętla o złożoności O(n2) może działać dużo szybciej od skomplikowanej pętli o złożoności O(n lg(n)), szczególnie jeśli algorytm O(n lg(n)) zawiera kosztowną pętlę wewnętrzną. 4337ebf6db5c7cc89e4173803ef3875a 4 244 Rozdział 7. Kiedy kodujemy… Opisana teoria nie powinna przesłaniać nam praktycznych aspektów szybkości algorytmów. Wzrost czasu wykonywania może sprawiać wrażenie liniowego dla stosunkowo niedużych zbiorów danych wejściowych. Wystarczy jednak użyć tego samego kodu do przetworzenia milionów rekordów, aby czas działania wydłużył się do tego stopnia, że wydajność całego systemu będzie nie do zaakceptowania. Jeśli testujemy procedurę sortującą, która operuje na losowych kluczach wejściowych, możemy być pozytywnie zaskoczeni czasem działania w razie napotkania już uporządkowanych danych. Pragmatyczni programiści starają się pamiętać zarówno o podstawach teoretycznych, jak i ich wymiarze praktycznym. Po przeprowadzeniu wszystkich tych szacunków jedynym naprawdę istotnym wnioskiem jest szybkość naszego kodu wykonywanego w środowisku produkcyjnym na prawdziwych danych. W ten sposób dochodzimy do kolejnej wskazówki. WSKAZÓWKA NR 64 Należy testować swoje szacunki. Jeśli uzyskanie precyzyjnych szacunków jest zbyt trudne, warto użyć mechanizmów profilowania kodu do określenia liczby wykonań poszczególnych kroków algorytmu i nanieść te wartości na wykres uwzględniający ilość danych wejściowych. Najlepsze nie zawsze jest najlepsze Pragmatyzm musimy wykazywać także na etapie doboru właściwych algorytmów — najszybszy algorytm nie we wszystkich przypadkach jest najlepszy. Dla niewielkiego zbioru wejściowego proste sortowanie przez wstawianie będzie równie efektywne jak sortowanie szybkie, a jednocześnie będzie łatwiejsze do zaimplementowania i przetestowania. Warto też zachować daleko idącą ostrożność, jeśli interesujący algorytm wiąże się z dużymi kosztami na etapie inicjalizacji. W przypadku niedużych zbiorów wejściowych czas samej inicjalizacji może przekroczyć czas właściwego działania algorytmu (w takim przypadku należy szukać innych rozwiązań). Ważne jest także unikanie pochopnych decyzji o optymalizacji. Zawsze warto upewnić się, że interesujący nas algorytm rzeczywiście jest wąskim gardłem, zanim zdecydujemy się poświęcić swój cenny czas na doskonalenie tego algorytmu. Pokrewne podrozdziały Temat 15., „Szacowanie”. Wyzwania Każdy programista powinien wiedzieć, jak należy projektować i analizować algorytmy. Robert Sedgewick napisał serię książek wprowadzających te 4337ebf6db5c7cc89e4173803ef3875a 4 Refaktoryzacja 245 zagadnienia w wyjątkowo przejrzysty sposób (Algorithms [SW11], An Introduction to the Analysis of Algorithms [SF13] i inne). Zachęcamy do włączenia którejś z tych pozycji do własnej biblioteki oraz jej uważną lekturę. Czytelnicy, którzy szukają bardziej wyczerpujących materiałów na ten temat, powinni sięgnąć po książki Sztuka programowania Donalda Knutha, w których szczegółowo przeanalizowano najróżniejsze algorytmy Sztuka programowania, Tom I: Algorytmy podstawowe [Knu98a]. Sztuka programowania, Tom II: Algorytmy seminumeryczne [Knu98b]. Sztuka programowania, Tom III. Sortowanie i wyszukiwanie [Knu98c]. The Art of Computer Programming, Volume 4A: Combinatorial Algorithms, Part 1 [Knu11]. W ćwiczeniu 28. przyjrzymy się problemowi sortowania tablic długich liczb całkowitych. Jaki wpływ na szybkość działania algorytmu mają bardziej złożone klucze? Jaki wpływ na łączną wydajność algorytmu mają kosztowne operacje porównywania kluczy? Czy struktura klucza wpływa na efektywność algorytmów sortujących, czy też jeden algorytm zawsze jest najszybszy? Ćwiczenia 28. Opracowaliśmy kilka prostych funkcji sortujących w języku Rust5. Zachęcamy do uruchomienia tego kodu na różnych komputerach. Czy Twoje wykresy pasują kształtem do oczekiwanych krzywych? Do jakich wniosków można dojść na podstawie względnej szybkości testowych komputerów? Jaki wpływ na wyniki mają rozmaite ustawienia optymalizacji kompilatorów? 29. W punkcie „Szacowanie zdroworozsądkowe” stwierdziliśmy, że złożoność przeszukiwania dwudzielnego wynosi O(lg(n)). Czy potrafisz to udowodnić? 30. Na rysunku 3. można zauważyć, że złożoność O(lg n) jest taka sama, jak O(log10n) (lub złożoność logarytmiczna o dowolnej podstawie). Czy potrafisz wyjaśnić dlaczego? 40 39 Refaktoryzacja Wokół mnie tylko zmiany i zepsucie… H.F. Lyte, Abide With Me Ewolucja programu wymusza na nas ponowne przemyślenie wcześniejszych decyzji i przebudowę fragmentów istniejącego kodu. Proces poprawiania kodu jest czymś zupełnie naturalnym. Kod nigdy nie ma charakteru statycznego i jako taki musi ewoluować. 5 https://media-origin.pragprog.com/titles/tpp20/code/algorithm_speed/sort/src/main.rs 4337ebf6db5c7cc89e4173803ef3875a 4 246 Rozdział 7. Kiedy kodujemy… Okazuje się jednak, że wytwarzanie oprogramowania jest najczęściej porównywane do konstrukcji inżynieryjnych (Bertrand Meyer w słynnej książce Object-Oriented Software Construction [Mey97] używa nawet pojęcia „konstrukcja oprogramowania”). My, skromni autorzy niniejszej książki, redagowaliśmy kolumnę „Software construction” w magazynie „IEEE Software” na początku lat dwutysięcznych6. Konstrukcja inżynieryjna jako powszechnie stosowana metafora sugeruje, że cały proces wytwarzania składa się z następujących kroków: 1. Architekt rysuje projekt na papierze. 2. Wykonawca kopie fundamenty, buduje podstawową strukturę, kładzie przewody i rury oraz wykańcza budynek. 3. Od tego momentu lokatorzy mogą się wprowadzać i czerpać pełnymi garściami z dostępnych udogodnień, wzywając od czasu do czasu fachowców do ewentualnych usterek. Cóż, świat oprogramowania funkcjonuje nieco inaczej. Oprogramowanie przypomina bardziej ogród niż tradycyjny plac budowy — więcej tam elementów organicznych niż betonu. Sadzimy w naszym ogrodzie wiele roślin zgodnie z początkowym planem i panującymi warunkami. Część roślin kwitnie, inne muszą trafić na kompost. Część roślin szczególnie dobrze rozwija się w towarzystwie innych gatunków, które nie zasłaniają światła lub — wprost przeciwnie — zapewniają cień oraz osłonę przed wiatrem i deszczem. Przerośnięte rośliny są przycinane, a rośliny, których kolory odbiegają od projektowanego zabarwienia całości, trafiają w inne, bardziej odosobnione miejsca. Wyrywamy chwasty i nawozimy rośliny, które wymagają dodatkowej opieki. Stale monitorujemy zdrowie całego ogrodu, wprowadzając na bieżąco niezbędne poprawki (w glebie, w samych roślinach i w całym projekcie). Do ludzi biznesu dużo bardziej przemawia porównanie do konstruowania budowli, ponieważ wydaje się bardziej ścisłe od ogrodnictwa i bardziej powtarzalne. Co więcej, w budownictwie mamy do czynienia z surowymi zasadami składania przełożonym raportów o stanie prac. Naszym zadaniem nie jest jednak budowa drapaczy chmur — nie ograniczają nas tak sztywne prawa fizyki. Porównanie do ogrodnictwa jest więc dużo bliższe realiom wytwarzania oprogramowania. Być może jakaś funkcja zbyt mocno się rozrosła lub próbuje robić zbyt wiele — warto ją podzielić (rozsadzić) na dwie funkcje. Rozwiązania, które nie zdały egzaminu, należy wykopać lub przyciąć. Takie czynności jak przepisywanie, przebudowywanie czy modyfikowanie architektury kodu określa się wspólnym mianem restrukturyzacji. Istnieje jednak podzbiór tych działań, który określa się mianem refaktoryzacja. 6 Tak. Wyraziliśmy nasze zaniepokojenie tytułem. 4337ebf6db5c7cc89e4173803ef3875a 4 Refaktoryzacja 247 Martin Fowler w książce Refaktoryzacja [Fow19] definuje refaktoryzację jako zdyscyplinowaną technikę restrukturyzacji istniejącej treści kodu polegającą na zmianie jego wewnętrznej struktury bez zmiany zewnętrznego zachowania. Oto kluczowe elementy tej definicji: 1. Działania są zdyscyplinowane, a nie swobodne, dostępne dla wszystkich. 2. Zachowanie zewnętrzne nie zmienia się. Refaktoryzacja nie jest okazją do dodawania nowych funkcji. Refaktoryzacja nie powinna być specjalną ceremonią wykonywaną raz na jakiś czas, podobną do przekopania całego ogródka w celu posadzenia nowych roślin. Zamiast tego refaktoryzacja powinna być działaniem codziennym, w którym wykonujemy niewielkie kroki o niskim ryzyku, bardziej przypominające pielenie i grabienie. Refaktoryzacja nie jest masowym przepisywaniem całej bazy kodu. Jest to raczej ukierunkowane, precyzyjne podejście zmierzające do ułatwienia wprowadzania zmian w kodzie. Aby zagwarantować, że zewnętrzne zachowanie nie zmieni się, potrzebne są dobre, zautomatyzowane testy jednostkowe, które sprawdzają zachowanie kodu. Kiedy należy refaktoryzować? Refaktoryzujemy, kiedy się czegoś dowiemy; kiedy rozumiemy coś lepiej niż w zeszłym roku, poprzedniego dnia lub nawet tylko dziesięć minut temu. Kiedy napotykamy w swoim kodzie blok, który nie pasuje już do reszty programu, kiedy odkrywamy rozwiązania, które wymagają scalenia, lub kiedy trafiamy na cokolwiek innego, co wydaje nam się po prostu złe, nie powinniśmy unikać niezbędnych zmian. Właśnie teraz jest najlepszy moment na zmiany. O konieczności refaktoryzacji może decydować wiele różnych czynników i zjawisk: Powielanie. Odkryliśmy naruszenie zasady DRY. Nieortogonalny projekt. Odkryliśmy, że fragment naszego kodu lub projektu mógłby być bardziej ortogonalny. Zdezaktualizowana wiedza. Sytuacja się zmienia, wymagania ewoluują, a Twoja wiedza na temat problemu jest coraz szersza. Trzeba to uwzględnić w kodzie. Korzystanie z systemu. W czasie, gdy system jest wykorzystywany przez prawdziwych ludzi w rzeczywistych warunkach, zdajesz sobie sprawę, że niektóre własności są ważniejsze niż wcześniej sądzono, a funkcjonalności „niezbędne” być może okazały się nie być niezbędne. Wydajność. Być może musimy przenieść pewne funkcje z jednego obszaru systemu w inny, aby podnieść wydajność naszego kodu. 4337ebf6db5c7cc89e4173803ef3875a 4 248 Rozdział 7. Kiedy kodujemy… Testy przechodzą. Poważnie. Powiedzieliśmy, że refaktoryzacja powinna być działaniem na niewielką skalę, wspieraną przez dobre testy. Kiedy zatem dodaliśmy niewielką ilość kodu, a dodatkowe testy, które ten kod pokrywają, pomyślnie przechodzą, powstaje doskonała okazja, by zagłębić się w kod i uporządkować to, co właśnie napisaliśmy. Refaktoryzacja kodu, czyli przenoszenie pewnych elementów funkcjonalności i korygowanie wcześniejszych decyzji, jest w istocie sztuką radzenia sobie z bólem. Modyfikowanie kodu źródłowego bywa wyjątkowo kłopotliwe — wszystko niemal działało i nagle trzeba to całkowicie przebudować. Wielu programistów bardzo niechętnie ingeruje w swój kod, jeśli jedynym powodem takiej ingerencji miałyby być nieistotne błędy. Komplikacje występujące w praktyce Wyobraźmy sobie, że idziemy do swojego szefa lub klienta i mówimy: „Ten kod działa, ale potrzebuję jeszcze tygodnia na jego refaktoryzację”. Odpowiedź nie nadaje się do druku. Presja czasu często jest wykorzystywana jako wymówka usprawiedliwiająca rezygnację z refaktoryzacji. Trudno jednak przyjąć takie uzasadnienie — unikanie refaktoryzacji teraz oznacza konieczność dużo większych inwestycji w usunięcie problemu w przyszłości (kiedy będziemy musieli analizować i uwzględniać dodatkowe zależności). Czy wtedy będziemy mieli więcej czasu? Nasze doświadczenie sugeruje coś wprost przeciwnego. Podczas tłumaczenia tej zasady przełożonym warto sięgnąć po medyczną analogię: kod wymagający refaktoryzacji jest jak narośl. Jej usunięcie wymaga inwazyjnego zabiegu chirurgicznego. Narośl można wyciąć od razu, kiedy jest jeszcze stosunkowo mała. Alternatywnym rozwiązaniem jest odłożenie zabiegu — kiedy jednak narośl będzie większa, jej usunięcie będzie nie tylko bardziej kosztowne, ale też bardziej niebezpieczne. Jeszcze dłuższe oczekiwanie może oznaczać nawet śmierć pacjenta. WSKAZÓWKA NR 65 Refaktoryzację należy przeprowadzać możliwie wcześnie i jak najczęściej. Skumulowane uszkodzenia w kodzie z biegiem czasu mogą mieć tak samo zabójcze skutki (patrz temat 3., „Entropia oprogramowania”, mieszczący się w rozdziale 1.). Refaktoryzację, jak większość zmian, łatwiej jest przeprowadzić, gdy problemy są małe. Powinna być ona aktywnością wykonywaną na bieżąco podczas kodowania. Nie powinieneś potrzebować „tygodnia na refaktoryzację” fragmentu kodu — tyle powinno zająć przepisanie go od podstaw. Jeśli zmiana wymaga tak wielkiej skali czasowej, to może się zdarzyć, że nie będziemy w stanie wprowadzić jej od razu. W takim przypadku należy umieścić modyfikację 4337ebf6db5c7cc89e4173803ef3875a 4 Refaktoryzacja 249 w harmonogramie przyszłych prac. Użytkownicy tak oznaczonego kodu powinni wiedzieć, że w przyszłości będzie miała miejsce refaktoryzacja oraz jak ta refaktoryzacja wpłynie na ich kod. Jak refaktoryzować? Koncepcja refaktoryzacji zrodziła się w społeczności programistów języka Smalltalk i zaczęła zyskiwać szerszą akceptację w czasie, gdy pisaliśmy pierwsze wydanie tej książki. Stało się tak prawdopodobnie dzięki wydaniu pierwszej ważnej publikacji poświęconej temu tematowi: Refaktoryzacja. Ulepszanie struktury istniejącego kodu [Fow19]; obecnie doczekała się ona drugiego wydania. Istotą refaktoryzacji jest zmiana projektu. Wszystko, co sami zaprojektowaliśmy lub co zaprojektowali pozostali członkowie naszego zespołu, można przeprojektować z uwzględnieniem nowych faktów, głębszej wiedzy, zmienionych wymagań itd. Gdybyśmy jednak bezmyślnie przebudowywali lub wręcz wyrzucali do kosza istotne fragmenty naszego kodu, niewykluczone, że znaleźlibyśmy się w sytuacji gorszej niż sprzed tego procesu. Refaktoryzacja to bez wątpienia jedna z tych czynności, które należy poprzedzić uważną analizą i które wymagają namysłu i ostrożności. Martin Fowler proponuje następujące wskazówki dotyczące refaktoryzacji, które pozwolą osiągnąć pożądane rezultaty przy minimalnym ryzyku7: 1. Nie należy jednocześnie próbować refaktoryzacji i dodawania nowych funkcji. 2. Przed przystąpieniem do refaktoryzacji koniecznie należy przygotować dobre testy. Testy należy przeprowadzać tak często, jak to możliwe. Dzięki temu będziemy szybko wiedzieli, czy nasze zmiany niczego nie zepsuły. 3. Należy wykonywać krótkie, przemyślane kroki, jak przeniesienie jednego pola z jednej klasy do innej, podzielenie metody, czy też zmiana nazwy zmiennej. Refaktoryzacja często polega na wprowadzaniu wielu zmian o charakterze lokalnym, które składają się na jedną zmianę w większej skali. Jeśli stosujemy technikę drobnych kroków i sumiennie testujemy kod po każdym kroku, możemy uniknąć potrzeby czasochłonnego debugowania kodu8. 7 Po raz pierwszy sformułowane w książce UML Distilled: A Brief Guide to the Standard Object Modeling Language [Fow00]. 8 Jest to doskonała porada ogólna (patrz temat 27., „Nie prześcigaj swoich świateł”, mieszczący się w rozdziale 4.). 4337ebf6db5c7cc89e4173803ef3875a 4 250 Rozdział 7. Kiedy kodujemy… Refaktoryzacja automatyczna W pierwszym wydaniu tej książki stwierdziliśmy, że „wspomniana technologia jeszcze nie zyskała popularności poza językiem Smalltalk, ale sytuacja najprawdopodobniej zmieni się...”. I rzeczywiście tak się stało. Mechanizmy automatycznej refaktoryzacji są dostępne w wielu środowiskach IDE i dla większości głównych języków programowania. Te środowiska IDE pozwalają modyfikować nazwy zmiennych i metod, umożliwiają podzielenie długiej procedury na mniejsze, automatyczne propagowanie wymaganych zmian, technikę przeciągnij i upuść ułatwiającą poruszanie się po kodzie i tak dalej. Problemowi testowania na tym poziomie poświęcimy więcej uwagi w temacie 41., „Kod łatwy do testowania”. Do kwestii testowania w większej skali wrócimy w temacie „Bezlitosne testy” w rozdziale 9. Fowler sugeruje, że utrzymywanie dobrych testów regresji jest kluczem do bezpiecznej refaktoryzacji. W takim przypadku kompilacja starego kodu klienckiego powinna być niemożliwa. W ten sposób można szybko znaleźć wszystkie te moduły i wprowadzić zmiany dostosowujące ich kod do nowych rozwiązań. Kiedy więc następnym razem spotkamy fragment kodu, który nie będzie do końca odpowiadał naszym oczekiwaniom, powinniśmy zmienić zarówno ten fragment, jak i wszystkie moduły zależne. Należy rozsądnie walczyć z bólem — jeśli coś sprawia nam ból już teraz, a może być jeszcze bardziej bolesne w przyszłości, nie warto czekać na najgorsze. Pamiętajmy o lekcji z tematu 3., „Entropia oprogramowania” w rozdziale 1.: nigdy nie należy pozostawiać wybitych szyb. Pokrewne podrozdziały 41 40 Temat 3., „Entropia oprogramowania”. Temat 9., „DRY — Przekleństwo powielania”. Temat 12., „Pociski smugowe”. Temat 27., „Nie prześcigaj swoich świateł”. Temat 48., „Istota zwinności”. Kod łatwy do testowania Pierwsze wydanie tej książki pisaliśmy w bardziej prymitywnych czasach. Wtedy większość programistów nie pisała żadnych testów — po co się martwić, skoro świat i tak skończy się w 2000 roku? W tamtej książce był podrozdział poświęcony sposobom tworzenia kodu, który jest łatwy do testowania. To był podstępny sposób, by przekonać programistów do pisania testów. 4337ebf6db5c7cc89e4173803ef3875a 4 Kod łatwy do testowania 251 Dziś mamy bardziej oświecone czasy. Jeśli są jacyś programiści, którzy nadal nie piszą testów, to przynajmniej wiedzą, że powinni je pisać. Nadal jednak jest pewien problem. Gdy pytamy programistów, po co piszą testy, spoglądają na nas tak, jakbyśmy pytali ich, czy nadal kodują za pomocą kart perforowanych, i w końcu odpowiadają: „Aby upewnić się, że kod działa”, z niewypowiedzianym „głupku” na końcu. Uważamy, że to zły powód. A zatem, co uważamy w testowaniu za ważne? I w jaki sposób, naszym zdaniem, powinniśmy się do tego zabrać? Zacznijmy od śmiałego stwierdzenia: WSKAZÓWKA NR 66 W testowaniu nie chodzi o znajdowanie błędów. Uważamy, że główne korzyści z testów osiągamy podczas ich wymyślania i pisania, a nie podczas uruchamiania. Myślenie o testach Jest poniedziałek rano. Właśnie usiadłeś w fotelu, aby rozpocząć pracę nad jakimś nowym kodem. Musisz napisać kod, który odpytuje bazę danych w celu uzyskania listy osób oglądających ponad 10 filmów tygodniowo za pośrednictwem swojej ulubionej witryny wideo. Uruchomiłeś edytor i zaczynasz pisać funkcję, która wykonuje zapytanie: def return_avid_viewers do # ... hmmm ... end Stop! Skąd wiesz, że to, co masz zamiar zrobić, jest dobre? Odpowiedź jest taka, że nie możesz tego wiedzieć. Nikt nie może. Ale dzięki myśleniu o testach możemy sprawić, że prawdopodobieństwo wykonania prawidłowego kodu będzie większe. Oto wyjaśnienie, w jaki sposób to działa. Zacznijmy sobie wyobrażać, że właśnie skończyliśmy pisać funkcję i teraz musimy ją przetestować. W jaki sposób byśmy to robili? Trzeba by użyć jakichś danych testowych, co prawdopodobnie oznacza, że chcielibyśmy pracować z bazą danych, która byłaby pod naszą kontrolą. Obecnie istnieją frameworki, które potrafią to obsłużyć — uruchomić testy z wykorzystaniem testowej bazy danych. W naszym przypadku oznacza to jednak konieczność przekazania do funkcji egzemplarza testowej bazy danych zamiast używania globalnej. Dzięki temu bowiem możemy zmienić bazę danych podczas testowania: def return_avid_users(db) do 4337ebf6db5c7cc89e4173803ef3875a 4 252 Rozdział 7. Kiedy kodujemy… Następnie musimy pomyśleć o tym, w jaki sposób można by wypełnić takie testowe dane. Zgodnie z wymaganiem, chcemy stworzyć „listę osób, które oglądają ponad 10 filmów w tygodniu”. W związku z tym zaglądamy do schematu bazy danych w celu znalezienia pól, które mogą nam pomóc. Znaleźliśmy dwa pola w tabeli, które prawdopodobnie mogą nam odpowiedzieć na pytanie kto i co oglądał: opened_video i completed_video. Aby napisać nasze testowe dane, musimy wiedzieć, z jakiego pola skorzystać. Nie wiemy jednak, co oznacza nasze wymaganie, a nie mamy dostępu do kontaktu biznesowego. Spróbujmy wyszukać i przekazać nazwę pola (co pozwoli nam przetestować to, co mamy, i potencjalnie zmienić to później): def return_avid_users(db, qualifying_field_name) do Zaczęliśmy myśleć o testach i bez napisania nawet jednej linijki kodu dokonaliśmy dwóch odkryć, które wykorzystaliśmy do zmiany API naszej metody. Testy sterują kodowaniem W poprzednim przykładzie, dzięki myśleniu o testach udało nam się ograniczyć sprzężenia w naszym kodzie (dzięki przekazaniu połączenia do bazy danych zamiast używania bazy globalnej) oraz poprawiliśmy elastyczność (dzięki wykorzystaniu testowanego pola jako parametru). Myślenie o pisaniu testu dla naszej metody dało nam jeszcze jedną korzyść: udało nam się spojrzeć na kod z zewnątrz — tak, jak byśmy byli klientami kodu, a nie jego autorami. WSKAZÓWKA NR 67 Test jest pierwszym użytkownikiem naszego kodu. Naszym zdaniem to jest prawdopodobnie największa korzyść z testowania: dostarczenie sprzężenia zwrotnego, które jest przewodnikiem dla dalszego kodowania. Funkcja (lub metoda), która jest ściśle sprzężona z innym kodem, jest trudna do testowania, bo wymaga skonfigurowania wszystkich tych środowisk przed jej uruchomieniem. Zatem doprowadzenie kodu do stanu testowalności jednocześnie zmniejsza sprzężenia. Aby przetestować kod, trzeba go najpierw zrozumieć. Choć brzmi to niezbyt mądrze, to z pewnością wszystkim nam zdarzało się rozpoczynać kodowanie w oparciu o mgliste zrozumienie tego, co mieliśmy do zrobienia. Zazwyczaj utwierdzamy się w przekonaniu, że wszystko stanie się jasne w trakcie pracy. Aha, a kod, który ma obsługiwać warunki brzegowe, także dodamy później. O, i jeszcze dodamy obsługę błędów. W efekcie kod jest pięć razy dłuższy niż powinien być, ponieważ jest pełen logiki warunkowej oraz przypadków szczególnych. Wystarczy jednak rzucić światło na testy tego kodu i wszystko staje się jaśniejsze. Jeśli myślisz o testowaniu warunków brzegowych oraz w jaki 4337ebf6db5c7cc89e4173803ef3875a 4 Kod łatwy do testowania 253 sposób będzie działać kod przed rozpoczęciem kodowania, być może znajdziesz również wzorce w logice, dzięki którym będzie możliwe uproszczenie funkcji. Jeśli pomyślisz o warunkach błędów, które należy przetestować, to także nadasz swojej funkcji odpowiednią strukturę. Programowanie sterowane testami Istnieje szkoła programowania, która mówi, że ze względu na korzyści płynące z myślenia o testach przed kodowaniem, można pójść o krok dalej i napisać je przed napisaniem kodu. Praktykę tę określa się terminem programowania sterowanego testami (ang. Test-Driven Development — TDD. Znany jest również termin „najpierw test”9. Podstawowy cykl techniki TDD jest następujący: 1. Znajdź niewielki fragment funkcjonalności, który chcesz dodać. 2. Napisz test, który przejdzie, kiedy funkcjonalność zostanie zaimplementowana. 3. Uruchom wszystkie testy. Upewnij się, że jedyne testy, które nie przechodzą, to te, które właśnie napisałeś. 4. Napisz jak najmniejszą ilość kodu potrzebną do tego, aby test zaczął przechodzić i sprawdź, czy teraz wszystkie testy przechodzą bez problemu. 5. Przeprowadź refaktoryzację kodu: sprawdź, czy istnieje sposób poprawienia kodu, który właśnie napisałeś (testu lub funkcjonalności). Zadbaj o to, aby testy po refaktoryzacji nadal przechodziły. Chodzi o to, aby ten cykl był jak najkrótszy: powinien trwać najwyżej kilka minut, tak abyśmy ciągle pisali testy, a następnie doprowadzali system do stanu, w którym te testy przechodzą. Widzimy istotną korzyść ze stosowania TDD dla osób, które dopiero zaczynają uczyć się testowania. Jeśli będziesz przestrzegać przepływu pracy TDD, zyskasz gwarancję, że zawsze będziesz mieć testy dla kodu. A to oznacza, że zawsze będziesz myśleć o swoich testach. Spotykaliśmy jednak również osoby, które zachowywały się tak, jakby były niewolnikami TDD. Taka postawa objawia się na wiele sposobów: 9 Poświęcanie bardzo wiele czasu na zapewnienie stuprocentowego pokrycia testami. Niektórzy twierdzą, że podejście „najpierw test” i technika TDD to dwie różne rzeczy. Uzasadniają, że intencje tych dwóch podejść są różne. Jednak z historycznego punktu widzenia podejście „najpierw test” (które wywodzi się z nurtu programowania ekstremalnego — ang. eXtreme Programming) jest identyczne z techniką, którą dziś określamy jako TDD. 4337ebf6db5c7cc89e4173803ef3875a 4 254 Rozdział 7. Kiedy kodujemy… Wiele nadmiarowych testów. Na przykład przed napisaniem klasy po raz pierwszy, wielu zwolenników TDD najpierw pisze nieprzechodzący test, który po prostu odwołuje się do nazwy klasy. Test nie przechodzi, więc piszą pustą definicję klasy i test przechodzi. Ale teraz mamy test, który nie robi zupełnie niczego; kolejny napisany test również będzie odwoływać się do klasy, co sprawia, że ten pierwszy test jest zbyteczny. Jeśli nazwa klasy później ulegnie zmianie, będzie więcej rzeczy do zmiany. To bardzo trywialny przykład. Projekty są tworzone w stylu dół-góra (zobacz: „Dół-góra czy góra-dół. Jak powinien wyglądać projekt?”). Z całą pewnością należy stosować TDD. Ale jeśli to robisz, nie zapomnij, aby zatrzymać się co jakiś czas i przyjrzeć się większemu obrazowi. Łatwo może nas zwieść komunikat „testy przechodzą na zielono” — często oznacza on pisanie mnóstwa kodu, który w gruncie rzeczy nie przybliża Cię do rozwiązania. TDD: musisz wiedzieć dokąd zmierzasz W jednym ze starych dowcipów pojawia się pytanie: „Jak zjeść słonia?”. Odpowiedź brzmi „Kęs po kęsie”. Ten pomysł jest często reklamowany jako korzyść ze stosowania TDD. Kiedy nie jesteś w stanie zrozumieć problemu w całości, podejmuj małe kroki — po jednym teście na raz. Jednak takie podejście może wprowadzać Cię w błąd — zachęcać do skupiania się na nieustannym polerowaniu łatwych problemów i ignorowaniu prawdziwego powodu, dla którego kodujemy. Ciekawy przykład takiej postawy miał miejsce w 2006 roku, kiedy Ron Jeffries, czołowa postać w ruchu Agile, rozpoczął serię postów w blogu, które dokumentowały stosowanie podejścia TDD podczas kodowania aplikacji do rozwiązywania Sudoku10. Po pięciu postach Ron zmodyfikował reprezentację planszy i kilkakrotnie zrefaktoryzował kod, aż w końcu był zadowolony ze swojego modelu obiektowego. Ale potem porzucił projekt. Warto przeczytać posty w blogu po kolei i zaobserwować, jak mądrego człowieka mogą zwieść drobne detale, a blask przechodzących testów może go utwierdzać w przekonaniu o słuszności swojej postawy. Peter Norvig opisuje inne, alternatywne podejście11, które ma zupełnie odmienny charakter: zamiast sterowania kodu testami zaczyna od podstawowego zrozumienia, jak te rodzaje problemów są rozwiązywane tradycyjnie (przy użyciu propagacji ograniczeń), a następnie skupia się na udoskonalaniu algorytmu. Rozwiązuje on reprezentację planszy za pomocą kilkunastu linijek kodu, które wynikają bezpośrednio z jego opisu notacji. 10 https://ronjeffries.com/categories/sudoku. Wielkie „dziękuję” dla Rona za udostępnienie nam tej historii. 11 http://norvig.com/sudoku.html 4337ebf6db5c7cc89e4173803ef3875a 4 Kod łatwy do testowania 255 Dół-góra czy góra-dół. Jak powinien wyglądać projekt? W czasach, gdy informatyka była dziedziną młodą i beztroską, istniały dwie szkoły tworzenia projektów: góra-dół i dół-góra. Zwolennicy podejścia góra-dół twierdzili, że należy zacząć od ogólnego problemu, który próbujesz rozwiązać, i podzielić go na niewielką liczbę części. Następnie trzeba podzielić każdą z tych części na mniejsze kawałki i tak dalej, aż liczba kawałków będzie dostatecznie mała, aby wyrazić je w kodzie. Zwolennicy podejścia dół-góra budowali kod tak, jak buduje się dom. Zaczynali od dołu, tworząc warstwę kodu, który dostarczał pewną warstwę abstrakcji zbliżającą ich do problemu, który starali się rozwiązać. Potem dodawali kolejną warstwę, odpowiadającą wyższemu poziomowi abstrakcji. Postępowali w ten sposób tak długo, aż w końcu dodali końcową warstwę abstrakcji, która rozwiązywała problem. „Zrób to tak…”. Żadna z tych szkół faktycznie nie działa, ponieważ obie ignorują jeden z najważniejszych aspektów rozwoju oprogramowania: gdy zaczynamy, nie wiemy, co robimy. Zwolennicy podejścia góra-dół zakładają, że mogą wyrazić wszystkie wymagania z góry: otóż nie mogą. Zwolennicy podejścia dół-góra zakładają, że potrafią zbudować listę abstrakcji, które w końcu doprowadzą ich do jednego rozwiązania na najwyższym poziomie. W jaki sposób jednak mogą zdecydować o funkcjonalnościach w poszczególnych warstwach, gdy nie wiedzą, dokąd zmierzają? WSKAZÓWKA NR 68 Buduj oprogramowanie „od końca do końca”, a nie w stylu góra-dół czy dół-góra. Jesteśmy przekonani, że jedynym prawidłowym sposobem budowania oprogramowania jest tworzenie go przyrostowo. Buduj niewielkie kawałki funkcjonalności od końca do końca, ucząc się problemu w miarę postępów prac. Stosuj uzyskaną wiedzę podczas nadawania kształtu kodowi, na każdym kroku konsultuj się z klientem i pozwól mu sterować procesem. Testy z całą pewnością mogą pomóc w sterowaniu wytwarzaniem oprogramowania. Ale, tak jak z każdym napędem, jeśli nie mamy w głowie docelowego kierunku, możemy kręcić się w kółko. Wracamy do kodowania Tworzenie oprogramowania bazujące na komponentach jest od dawna szczytnym celem rozwoju oprogramowania12. Chodzi o stworzenie generycznych kom12 Próbowaliśmy tej techniki co najmniej od 1986 roku, kiedy Cox i Novobilski w swojej książce Object-Oriented Programming: An Evolutionary Approach [CN91] ukuli termin „oprogramowanie IC”. 4337ebf6db5c7cc89e4173803ef3875a 4 256 Rozdział 7. Kiedy kodujemy… ponentów oprogramowania, które będą łączone równie łatwo, jak są łączone układy scalone (IC). Takie podejście sprawdza się jednak tylko wtedy, gdy wykorzystywane elementy są niezawodne, stosują standardowe napięcia, sposoby połączeń, obsługę czasu i tak dalej. Czipy projektuje się z myślą o dalszych testach — nie tylko w fabryce czy miejscu ich instalacji, ale także w warunkach, w których zostaną wdrożone i będą używane. Bardziej złożone czipy i systemy nierzadko zawierają wbudowane funkcje samotestujące BIST (od ang. Built-In Self Test), które wewnętrznie wykonują podstawowe czynności diagnostyczne, lub mechanizm testowania dostępu (ang. Test Access Mechanism — TAM) umożliwiający badanie i gromadzenie statystyk odpowiedzi układu na żądanie z zewnątrz. To samo możemy osiągnąć z naszym oprogramowaniem. Tak jak nasi koledzy z branży układów scalonych, musimy od samego początku uwzględniać przyszłe testy w pisanym kodzie oraz dokładnie testować każdy fragment oprogramowania jeszcze przed jego połączeniem z resztą systemu. Testy jednostkowe Odpowiednikiem testów na poziomie sprzętowego czipu są testy jednostkowe oprogramowania, czyli testy poszczególnych modułów wykonywane bez udziału innych modułów i weryfikujące ich zachowanie. Testy w kontrolowanych (choćby wymyślonych) warunkach pozwalają nam lepiej zrozumieć reakcje modułu w rozmaitych sytuacjach. Test jednostkowy oprogramowania ma postać kodu sprawdzającego konkretny moduł. Test jednostkowy zwykle tworzy pewnego rodzaju sztuczne środowisko, po czym wywołuje z poziomu tego środowiska funkcje testowanego modułu. Test sprawdza otrzymane wyniki, porównując je albo ze znanymi wartościami, albo z wynikami uzyskanymi podczas wcześniejszych wykonań tego samego testu (w takim przypadku mówi się o tzw. testach regresji). Kiedy w przyszłości łączymy nasze programowe „układy scalone” w kompletny system, możemy być pewni, że poszczególne składniki tego systemu działają zgodnie z naszymi oczekiwaniami. Co więcej, możemy wykorzystać te same testy jednostkowe do sprawdzania systemu jako całości. Problemem weryfikacji systemu w większej skali zajmiemy się w podrozdziale „Bezlitosne testy” w rozdziale 9. Zanim jednak skupimy swoją uwagę na całym systemie, musimy zdecydować, co należy testować na poziomie jednostki. Programiści zwykle umieszczają w kodzie kilka losowo dobranych wartości, aby na ich przykładzie testować tworzony moduł. Okazuje się, że możemy uzyskać dużo lepsze rezultaty. 4337ebf6db5c7cc89e4173803ef3875a 4 Kod łatwy do testowania 257 Testowanie według kontraktu Lubimy myśleć o testach jednostkowych jako o sposobie testowania zgodności z kontraktem (patrz temat 23., „Projektowanie kontraktowe”, mieszczący się w rozdziale 4.). Chcemy pisać przypadki testowe, które dadzą nam pewność, że dana jednostka wypełnia swój kontrakt. W ten sposób uzyskujemy dwie informacje — to, czy nasz kod jest zgodny z kontraktem, oraz to, czy kontrakt rzeczywiście oznacza to, co o nim myślimy. Chcemy sprawdzić, czy nasz moduł dostarcza obiecane funkcje w szerokim zakresie przypadków testowych i warunków granicznych. Co to oznacza w praktyce? Zacznijmy od prostego przykładu funkcji obliczającej pierwiastek kwadratowy. Kontrakt dla tej funkcji jest dość prosty: warunki-wstępne: argument >= 0; warunki-końcowe: ((result * result) - argument).abs <= epsilon * argument; Na tej podstawie możemy łatwo stwierdzić, co należy przetestować: Należy przekazać ujemny argument i sprawdzić, czy zostanie odrzucony. Należy przekazać argument równy 0 i sprawdzić, czy zostanie zaakceptowany (to nasza wartość graniczna). Należy przekazać wartość z przedziału od 0 do maksymalnego argumentu możliwego do wyrażenia w ten sposób i sprawdzić, czy różnica dzieląca kwadrat wyniku od oryginalnego argumentu jest mniejsza niż pewien niewielki ułamek tego argumentu. Skoro dysponujemy kontraktem w tej formie i przyjmujemy, że nasza funkcja zawiera mechanizmy sprawdzania warunków początkowych i końcowych, możemy napisać prosty skrypt testowy, który sprawdzi funkcję pierwiastka kwadratowego. Możemy następnie wywołać tę funkcję, aby przetestować właściwą funkcję obliczającą pierwiastek kwadratowy: assertWithinEpsilon(my_sqrt(0), 0) assertWithinEpsilon(my_sqrt(2.0), 1.4142135624) assertWithinEpsilon(my_sqrt(64.0), 8.0) assertWithinEpsilon(my_sqrt(1.0e7), 3162.2776602) assertRaisesException fn => my_sqrt(-4.0) end Zaproponowany test jest dość prosty; w rzeczywistych rozwiązaniach każdy niebanalny moduł jest zależny od wielu innych modułów. Jak w takich przypadkach należałoby testować kombinacje tych modułów? Przypuśćmy, że moduł A używa modułów DataFeed i LinearRegression. Powinniśmy przetestować: 4337ebf6db5c7cc89e4173803ef3875a 4 258 Rozdział 7. Kiedy kodujemy… 1. Kontrakt dla modułu DataFeed (w całości). 2. Kontrakt dla modułu LinearRegression (w całości). 3. Kontrakt modułu A, którego działanie zależy od innych kontraktów, mimo że te kontrakty nie są wskazane wprost. Opisany styl testowania wymaga w pierwszej kolejności testowania komponentów w ramach samego modułu. Po ich sprawdzeniu można przystąpić do testów samego modułu. Jeśli testy modułów DataFeed i LinearRegression przebiegły prawidłowo, a mimo to test modułu A zakończył się niepowodzeniem, możemy być niemal pewni, że problem leży właśnie w tym module A lub sposobie korzystania przez ten moduł z niezbędnych komponentów. Proponowana technika jest wprost doskonałym sposobem ograniczania kosztów przyszłego debugowania kodu — możemy od razu skoncentrować się na prawdopodobnym źródle problemu w ramach modułu A, zamiast tracić czas na analizę jego komponentów. Dlaczego w ogóle poświęcamy czas temu zagadnieniu? Chcemy przede wszystkim uniknąć sytuacji, w której nasz kod będzie zawierał swoistą bombę z opóźnionym zapłonem — błędu, który pozostanie niezauważony w kodzie i ujawni się w najmniej oczekiwanym momencie na późniejszym etapie prac nad projektem. Kładąc nacisk na testy pod kątem zgodności z kontraktem, możemy wyjątkowo skutecznie unikać podobnych katastrof. WSKAZÓWKA NR 69 Należy projektować z myślą o testach. Testy ad hoc Testy ad hoc są wykonywane podczas wstępnego przeglądania kodu. Takie testy mogą ograniczać się do prostych wyrażeń console.log() bądź fragmentów kodu jednorazowo wpisywanych w debugerze, środowisku IDE lub REPL. Na końcu sesji debugowania powinniśmy nadać testom ad hoc formalny charakter. Jeśli nasz kod raz uległ awarii, najprawdopodobniej sytuacja kiedyś się powtórzy. Nie należy więc rezygnować z utworzonego testu — powinniśmy raczej dodać nowy kod sprawdzający do istniejącego testu jednostkowego. Budowa okna testowego Nawet najlepsze zestawy testów zwykle nie znajdują wszystkich błędów. W środowisku produkcyjnym często mamy do czynienia z warunkami, które nigdy nie występują w warsztacie programisty. Oznacza to, że często musimy testować nasze oprogramowanie już po wdrożeniu, kiedy w żyłach naszego systemu płyną rzeczywiste dane. W przeci- 4337ebf6db5c7cc89e4173803ef3875a 4 Kod łatwy do testowania 259 wieństwie do płytki drukowanej czy czipu nasze oprogramowanie nie oferuje pinów testowych. Istnieje jednak wiele innych sposobów zapewniania dostępu do wewnętrznego stanu modułu bez konieczności stosowania debugera (którego używanie w przypadku aplikacji produkcyjnych może być niewygodne lub wręcz niemożliwe). Jednym z takich mechanizmów są pliki logów zawierające stosowne komunikaty. Zapisy w pliku dziennika powinny cechować się standardowym, spójnym formatem — być może w przyszłości będziemy chcieli automatycznie je parsować, aby na tej podstawie określać czas przetwarzania lub wybierane przez program ścieżki logiczne. Wartość komunikatów diagnostycznych w nieprzemyślanym lub niespójnym formacie jest bardzo niewielka — są nie tylko trudne do odczytania, ale też niepraktyczne podczas parsowania. Alternatywnym mechanizmem zaglądania do działającego kodu jest specjalna sekwencja klawiszy. Po naciśnięciu odpowiedniej kombinacji klawiszy na ekranie jest wyświetlane okno diagnostyczne prezentujące na przykład komunikaty o stanie systemu. Tego rodzaju dane w normalnych warunkach nie powinny być udostępniane użytkownikom końcowym, ale mogą być bezcenne dla pracowników działu wsparcia technicznego. Bardziej ogólnym rozwiązaniem może być użycie przełącznika, który włącza dodatkowe mechanizmy diagnostyczne dla konkretnego użytkownika lub grupy użytkowników. Kultura testowania Każde pisane przez nas oprogramowanie zostanie przetestowane — jeśli nie przez nas ani nasz zespół, to przez użytkowników końcowych. Warto więc uważnie zaplanować niezbędne testy. Wystarczy odrobina przezorności, aby znacznie ograniczyć koszty konserwacji i liczbę telefonów do działu wsparcia. W istocie mamy do wyboru tylko kilka opcji: Testy najpierw. Testy w trakcie. Testy nigdy. Podejście „testy najpierw”, włącznie z techniką TDD, jest w większości przypadków najlepsze, ponieważ daje pewność wykonania testów. Czasami jednak nie jest ono zbyt wygodne lub przydatne. W związku z tym dobrą alternatywą może być podejście „testy w trakcie”, polegające na napisaniu fragmentu kodu, sprawdzeniu go, napisaniu dla niego zbioru testów, a następnie przejściu do następnego fragmentu. Najgorsze z podejść często jest określane jako „testy później”. To jednak jest jakiś żart. Podejście „testy później” w praktyce oznacza „testy nigdy”. 4337ebf6db5c7cc89e4173803ef3875a 4 260 Rozdział 7. Kiedy kodujemy… Kultura testowania wymaga, aby zawsze przechodziły wszystkie testy. Zignorowanie kilku testów, które „nigdy nie przechodzą” sprawia, że zbliżamy się do ignorowania wszystkich testów i zaczyna się spirala zepsucia (patrz temat 3., „Entropia oprogramowania”, mieszczący się w rozdziale 1.). Spowiedź Ja (Dave) jestem znany z mówienia innym, że przestałem pisać testy. Częściowo robię to po to, aby zachwiać wiarą tych, którzy z testowania zrobili religię. A częściowo dlatego, że jest to (w pewnym sensie) prawda. Koduję od 45 lat, a automatyczne testy pisałem od ponad 30. Myślenie o testowaniu jest wbudowane w sposób, w jaki podchodzę do kodowania. Czuję się z tym komfortowo. Moja intuicja podpowiada mi, że gdy w czymś zaczynam czuć się komfortowo, to powinienem przejść do czegoś innego. W tym przypadku postanowiłem przestać pisać testy na kilka miesięcy i zobaczyć, jaki to będzie miało wpływ na mój kod. Ku mojemu zaskoczeniu, odpowiedź brzmiała „niezbyt wielki”. W związku z tym poświęciłem trochę czasu, aby odpowiedzieć na pytanie dlaczego tak się stało. Uważam, że chodzi o to, że (przynajmniej dla mnie) większość korzyści z testowania pochodzi z myślenia o testach oraz ich wpływie na kod. Po pisaniu testów przez tak długi czas potrafię myśleć o testowaniu bez faktycznego pisania testów. Mój kod nadal był testowalny, a jedynie nie był testowany. Ale zignorowałem fakt, że testy są również sposobem komunikowania się z innymi programistami. Z tego powodu teraz znów piszę testy dla kodu, który współdzielę z innymi, lub takiego, który bazuje na osobliwościach zewnętrznych zależności. Andy mówi, że nie powinienem zamieszczać tej ramki. Obawia się, że będzie kusić niedoświadczonych programistów, by nie pisać testów. Oto mój kompromis: Czy trzeba pisać testy? Tak. Ale kiedy robisz to od 30 lat, możesz trochę poeksperymentować, aby zobaczyć, skąd pochodzą korzyści, jakie uzyskujesz. Traktuj kod testów z taką samą starannością, jak kod produkcyjny. Staraj się eliminować z niego sprzężenia, dąż do tego, by był czysty i odporny na awarie. Nie polegaj na informacjach, które są niewiarygodne (patrz temat 38., „Programowanie przez koincydencję”), takich jak bezwzględna pozycja widżetów w interfejsie GUI, dokładne znaczniki czasu w logu serwera lub dokładna treść komunikatów o błędach. Testowanie z wykorzystaniem tego rodzaju elementów doprowadzi do powstania kruchych testów. WSKAZÓWKA NR 70 Należy testować swoje oprogramowania; w przeciwnym razie zrobią to nasi użytkownicy. 4337ebf6db5c7cc89e4173803ef3875a 4 Testowanie na podstawie właściwości 261 Nie popełnij błędu, testowanie jest częścią programowania. Nie jest czymś, czym powinien zająć się inny dział lub wyznaczony personel. Testowanie, projektowanie, kodowanie — to wszystko jest programowanie. Pokrewne podrozdziały 42 41 Temat 27., „Nie prześcigaj swoich świateł”. Temat 51., „Zestaw startowy pragmatyka”. Testowanie na podstawie właściwości Доверяй, но проверяй (Ufaj, ale sprawdzaj) Przysłowie rosyjskie Zalecamy pisanie testów jednostkowych dla funkcji. W tym celu próbujemy znaleźć typowe problemy, na jakie może napotkać funkcja, na podstawie naszej znajomości przedmiotu testu. W tym podejściu tkwi jednak niewielki, ale potencjalnie istotny problem. Jeśli napiszesz kod, a następnie napiszesz dla niego testy, to czy istnieje możliwość, że w obu zostaną wyrażone nieprawidłowe założenia? Kod przechodzi testy, ponieważ robi to, co powinien w oparciu o nasze zrozumienie problemu. Jednym ze sposobów na poradzenie sobie z tym problemem jest pisanie testów i testowanego kodu przez różne osoby. To jednak nam się nie podoba. Jak powiedzieliśmy w temacie 41., „Kod łatwy do testowania”, jedną z największych zalet myślenia o testach jest wnioskowanie na temat pisania kodu na podstawie testów. Tracimy tę korzyść w przypadku, gdy praca nad testami jest oddzielona od pracy nad kodem. Zamiast tego zalecamy alternatywę polegającą na tym, że to komputer, który nie współdzieli Twoich uprzedzeń, wykonuje testowanie za Ciebie. Kontrakty, niezbędniki i właściwości W rozdziale 4., w temacie 23., „Projektowanie kontraktowe”, omawialiśmy koncepcję, zgodnie z którą kod spełnia określone kontrakty: spełnia warunki, gdy dostarczysz do niego dane wejściowe i sprawisz, że będą zapewnione określone gwarancje dotyczące generowanych wyników. Istnieją również niezmienniki kodu — założenia co do fragmentu jakiegoś stanu, które są prawdziwe, gdy przekażemy ten stan za pomocą funkcji. Na przykład, jeśli sortujesz listę, wynik będzie miał taką samą liczbę elementów, co oryginał — długość listy jest niezmiennikiem. 4337ebf6db5c7cc89e4173803ef3875a 4 262 Rozdział 7. Kiedy kodujemy… Gdy określimy kontrakty i niezmienniki (które będziemy wspólnie określać właściwościami), możemy je wykorzystać do zautomatyzowania testów. Taką technikę będziemy nazywali testowaniem w oparciu o właściwości. WSKAZÓWKA NR 71 Do weryfikacji założeń stosuj technikę testowania w oparciu o właściwości. W ramach wymyślonego przykładu spróbujmy stworzyć kilka testów dla naszej posortowanej listy. Ustaliliśmy już jedną właściwość: posortowaną listę, która ma taki sam rozmiar co lista wejściowa. Możemy również stwierdzić, że żaden element w wyniku nie może być większy niż ten, który następuje po nim. Teraz możemy wyrazić to w kodzie. W większości języków jest dostępny jakiś framework testowania w oparciu o właściwości. Prezentowany przykład napisany jest w Pythonie i korzysta z narzędzi hyphothesis i pytest, ale zasady w nim zastosowane są dość uniwersalne. Oto kompletny kod źródłowy testów: proptest/sort.py from hypothesis import given import hypothesis.strategies as some @given(some.lists(some.integers())) def test_list_size_is_invariant_across_sorting(a_list): original_length = len(a_list) a_list.sort() assert len(a_list) == original_length @given(some.lists(some.text())) def test_sorted_result_is_ordered(a_list): a_list.sort() for i in range(len(a_list) - 1): assert a_list[i] <= a_list[i + 1] Oto wyniki uruchomionych testów: $ pytest sort.py ============================= test session starts ============================= ... plugins: hypothesis-5.16.1 collected 2 items sort.py .. [100%] ============================== 2 passed in 2.53s ============================== W tym kodzie nie dzieje się nic specjalnego. Ale za kulisami, narzędzie Hypothesis uruchomiło oba nasze testy sto razy, za każdym razem przekazując do nich inną listę. Listy mają różne długości i różne zawartości. To tak, jakbyśmy uruchomili 200 pojedynczych testów z 200 losowymi listami. 4337ebf6db5c7cc89e4173803ef3875a 4 Testowanie na podstawie właściwości 263 Generowanie danych testowych Podobnie jak większość bibliotek testowania na podstawie właściwości, biblioteka Hypothesis oferuje minijęzyk do opisywania danych, które framework powinien generować. Język ten opiera się na wywołaniach funkcji w module hypothesis.strategies, dla którego zastosowaliśmy alias some, po prostu dlatego, że lepiej czyta się go w kodzie. Jeśli napiszemy: @given(some.integers()) nasza funkcja testowa uruchomiłaby się wiele razy. Za każdym razem w roli parametru zostałaby przekazana inna liczba całkowita. Jeśli zamiast tego napiszemy: @given(some.integers(min_value=5, max_value=10).map(lambda x: x * 2)) to do funkcji będą przekazywane liczby parzyste z przedziału od 10 do 20. Możemy także zastosować styl mieszany: @given(some.lists(some.integers(min_value=1), max_size=100)) Dzięki temu uzyskamy listę liczb naturalnych o długości co najwyżej 100 elementów. Niniejsza książka nie ma być podręcznikiem żadnego konkretnego frameworka, dlatego pominiemy szczegóły i zamiast nich zajmiemy się rzeczywistym przykładem. Znajdowanie złych założeń Napiszemy prosty system przetwarzania zamówień i zarządzania zapasami (ponieważ zawsze jest miejsce, by taki system napisać). System modeluje poziomy magazynowe za pomocą obiektu Warehouse. Możemy odpytać obiekt Warehouse, aby sprawdzić, czy towar jest w magazynie, żeby pobrać towary z magazynu i uzyskać aktualne stany magazynowe. Oto kod: proptest/stock.py class Warehouse: def __init__(self, stock): self.stock = stock def in_stock(self, item_name): return (item_name in self.stock) and (self.stock[item_name] > 0) def take_from_stock(self, item_name, quantity): if quantity <= self.stock[item_name]: self.stock[item_name] -= quantity 4337ebf6db5c7cc89e4173803ef3875a 4 264 Rozdział 7. Kiedy kodujemy… else: raise Exception("Wyprzedano {}".format(item_name)) def stock_count(self, item_name): return self.stock[item_name] Napisaliśmy podstawowy test jednostkowy, który przechodzi: proptest/stock.py def test_warehouse(): wh = Warehouse({"buty": 10, "kapelusze": 2, "parasole": 0}) assert wh.in_stock("buty") assert wh.in_stock("kapelusze") assert not wh.in_stock("parasole") wh.take_from_stock("buty", 2) assert wh.in_stock("buty") wh.take_from_stock("kapelusze", 2) assert not wh.in_stock("kapelusze") Następnie napisaliśmy funkcję, która przetwarza żądanie zamówienia towarów z magazynu. Funkcja zwraca krotkę, w której pierwszym elementem jest ciąg "ok" lub "niedostępny", a kolejnymi zamawiany towar i żądana ilość. Napisaliśmy też kilka testów, które przechodzą: proptest/stock.py def order(warehouse, item, quantity): if warehouse.in_stock(item): warehouse.take_from_stock(item, quantity) return ( "ok", item, quantity ) else: return ("niedostępny", item, quantity ) proptest/stock.py def test_order_in_stock(): wh = Warehouse({"buty": 10, "kapelusze": 2, "parasole": 0}) status, item, quantity = order(wh, "kapelusze", 1) assert status == "ok" assert item == "kapelusze" assert quantity == 1 assert wh.stock_count("kapelusze") == 1 def test_order_not_in_stock(): wh = Warehouse({"buty": 10, "kapelusze": 2, "parasole": 0}) status, item, quantity = order(wh, "parasole", 1) assert status == "niedostępny" assert item == "parasole" assert quantity == 1 assert wh.stock_count("parasole") == 0 def test_order_unknown_item(): wh = Warehouse({"buty": 10, "kapelusze": 2, "parasole": 0}) status, item, quantity = order(wh, "bajgiel", 1) assert status == "niedostępny" assert item == "bajgiel" assert quantity == 1 4337ebf6db5c7cc89e4173803ef3875a 4 Testowanie na podstawie właściwości 265 Na pierwszy rzut oka wszystko wygląda dobrze. Ale zanim wdrożymy kod do produkcji, dodajmy kilka testów właściwości. Wiemy — między innymi — że podczas całej transakcji towar nie może pojawiać się i znikać. Oznacza to, że jeśli weźmiemy kilka towarów z magazynu, to liczba, którą wzięliśmy plus liczba towarów aktualnie znajdujących się w magazynie powinna być taka sama, jak liczba towarów, które pierwotnie były w magazynie. W poniższym teście parametr item został wybrany losowo ze zbioru "kapelusze" lub "buty", a wybrana ilość wynosi od 1 do 4: proptest/stock.py @given(item = some.sampled_from(["kapelusze", "buty"]), quantity = some.integers(min_value=1, max_value=4)) def test_stock_level_plus_quantity_equals_original_stock_level(item, quantity): wh = Warehouse({"buty": 10, "kapelusze": 2, "parasole": 0}) initial_stock_level = wh.stock_count(item) (status, item, quantity) = order(wh, item, quantity) if status == "ok": assert wh.stock_count(item) + quantity == initial_stock_level Spróbujmy uruchomić testy: $ pytest stock.py ... stock.py:64: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ stock.py:69: in test_stock_level_plus_quantity_equals_original_stock_level (status, item, quantity) = order(wh, item, quantity) stock.py:35: in order warehouse.take_from_stock(item, quantity) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = <stock.Warehouse object at 0x00000269702D43A0>, item_name = 'kapelusze', quantity = 3 > E def take_from_stock(self, item_name, quantity): if quantity <= self.stock[item_name]: self.stock[item_name] -= quantity else: raise Exception("Wyprzedano {}".format(item_name)) Exception: Wyprzedano kapelusze stock.py:15: Exception --------------------------------- Hypothesis ---------------------------------Falsifying example: test_stock_level_plus_quantity_equals_original_stock_level( item='kapelusze', quantity=3, ) Test zgłasza wyjątek w funkcji warehouse.take_from_stock: staraliśmy się usunąć trzy kapelusze z magazynu, choć były w nim tylko dwie sztuki. 4337ebf6db5c7cc89e4173803ef3875a 4 266 Rozdział 7. Kiedy kodujemy… Nasze testy właściwości wykryły błędne założenie: funkcja in_stock sprawdza jedynie, czy istnieje co najmniej jedna pozycja w magazynie. Zamiast tego musimy sprawdzić, czy mamy wystarczającą ilość, aby spełnić zamówienie: proptest/stock1.py def in_stock(self, item_name, quantity): return (item_name in self.stock) and (self.stock[item_name] >= quantity) Zmodyfikujemy także funkcję order: proptest/stock1.py def order(warehouse, item, quantity): if warehouse.in_stock(item, quantity): warehouse.take_from_stock(item, quantity) return ( "ok", item, quantity ) else: return ("niedostępny", item, quantity ) Teraz nasze testy właściwości przechodzą. Testy właściwości często zaskakują W poprzednim przykładzie użyliśmy testu właściwości w celu sprawdzenia, czy stany magazynowe były prawidłowo aktualizowane. Test znalazł błąd, ale nie miał on nic wspólnego z aktualizacją stanu magazynowego. Zamiast tego, test znalazł błąd w funkcji in_stock. Testy bazujące na właściwościach mogą przynosić zarówno korzyści, jak i frustracje. Testy dają duże możliwości, ponieważ wystarczy skonfigurować pewne zasady generowania danych wejściowych oraz przyjąć kilka założeń co do weryfikacji wyników, a reszta dzieje się automatycznie. Nigdy nie wiadomo, co się wydarzy. Test może przejść. Asercja może się nie powieść. Kod może także ulec awarii z powodu braku możliwości obsługi danych wejściowych. Frustracja wynika z trudności w ustaleniu tego, co faktycznie zawiodło. Nasza propozycja jest taka, że gdy zwiodą testy bazujące na właściwościach, należy przyjrzeć się parametrom przekazywanym do funkcji testowej, a następnie wykorzystać te wartości w celu stworzenia osobnego, standardowego testu jednostkowego. Taki test jednostkowy daje nam dwie korzyści. Po pierwsze pozwala skupić się na problemie bez wykonywania w kodzie dodatkowych wywołań przez framework testów bazujących na właściwościach. Po drugie taki test jednostkowy spełnia rolę testu regresji. Ze względu na to, że testy bazujące na właściwościach generują losowe wartości, które są przekazywane do testu, nie ma gwarancji, że przy uruchomieniu testów następnym razem zostaną wykorzystane te same wartości. Stworzenie testu jednostkowego, który wymusza przekazywane wartości, daje pewność uniknięcia błędu wynikającego z losowości parametrów. 4337ebf6db5c7cc89e4173803ef3875a 4 Pozostań w bezpiecznym miejscu 267 Testy właściwości wspomagają projekt Kiedy mówiliśmy o testach jednostkowych, powiedzieliśmy, że jedną z głównych ich zalet jest skłonienie programistów do myślenia o kodzie: test jednostkowy jest pierwszym klientem Twojego API. Tak samo jest z testami właściwości, choć objawia się to w nieco inny sposób. Sprawiają one, że myślimy o kodzie w kontekście niezmienników i kontraktów; myślimy o tym, co nie może się zmienić, a co musi być prawdą. Ten dodatkowy wgląd ma magiczny wpływ na kod, powoduje usunięcie przypadków brzegowych i wyróżnienie funkcji, które pozostawiają dane w niespójnym stanie. Uważamy, że testy właściwości powinny być uzupełnieniem testów jednostkowych: każdy z typów testów rozwiązuje inne problemy i każdy przynosi inne korzyści. Jeśli jeszcze nie używasz testów właściwości, spróbuj zacząć to robić. Pokrewne podrozdziały Temat 23., „Projektowanie kontraktowe”. Temat 25., „Programowanie asertywne”. Temat 45., „Kopalnia wymagań”. Ćwiczenia 31. Przyjrzyj się ponownie przykładowi ze stanami magazynowymi. Czy znajdujesz jakieś inne właściwości, które można przetestować? 32. Twoja firma dostarcza maszyny. Każda z nich jest dostarczana w skrzyni, a każda skrzynia jest prostokątna. Skrzynie różnią się rozmiarami. Twoim zadaniem jest napisanie kodu, który pozwoli zapakować jak najwięcej skrzyń w jednej warstwie, która zmieści się w samochodzie dostawczym. Wynik Twojego kodu to lista wszystkich skrzyń. Dla każdej skrzyni na liście dodawane są informacje: lokalizacja w ciężarówce oraz szerokość skrzyni i jej wysokość. Jakie właściwości wyniku można przetestować? Wyzwania 43 42 Pomyśl o kodzie, nad którym aktualnie pracujesz. Jakie ma właściwości: kontrakty i niezmienniki? Czy możesz skorzystać z frameworka testowania właściwości, aby zbadać je automatycznie? Pozostań w bezpiecznym miejscu Dobre ogrodzenia sprawiają, że masz dobrych sąsiadów. Robert Frost, „Naprawianie muru” 4337ebf6db5c7cc89e4173803ef3875a 4 268 Rozdział 7. Kiedy kodujemy… Podczas omawiania sprzężeń w kodzie, w pierwszym wydaniu zamieściliśmy odważne i naiwne oświadczenie: „Nie chcemy popadać w paranoję właściwą szpiegom czy dysydentom”. Nie mieliśmy racji. W rzeczywistości codziennie powinniśmy być paranoikami. W czasie, kiedy piszemy te słowa, aktualności wypełnione są opowieściami o niszczących naruszeniach bezpieczeństwa danych, włamaniach do systemów i oszustwach cybernetycznych. Cyberprzestępcy każdorazowo kradną setki milionów rekordów, koszty odszkodowań i naprawy systemów sięgają wielu miliardów dolarów, a liczby te z roku na rok szybko rosną. W zdecydowanej większości przypadków to nie dlatego, że napastnicy byli wybitnie mądrzy albo kompetentni. To dlatego, że programiści byli nieostrożni. Pozostałe 90% Podczas kodowania zwykle przechodzimy przez kilka cykli „To działa!”, „Dlaczego to nie działa” oraz — od czasu do czasu — „To nie mogło się zdarzyć”13. Po kilku wzlotach i upadkach podczas tego marszu pod górę, łatwo jest sobie powiedzieć: „Uff, wszystko działa!” i ogłosić, że kod jest gotowy. Oczywiście nie jest jeszcze gotowy. Jest w 90% gotowy, ale pozostaje jeszcze drugie 90% do rozważenia. Następną rzeczą, którą powinieneś zrobić, jest przeanalizowanie tego, co może się nie udać, i uwzględnienie tych przypadków w zestawie testów. Trzeba wziąć pod uwagę takie rzeczy jak przekazywanie nieprawidłowych parametrów, przecieki zasobów lub ich niedostępność. W dawnych, dobrych czasach, taka ocena wewnętrznych błędów mogła być wystarczająca. Ale dzisiaj to dopiero początek, ponieważ oprócz błędów wynikających z przyczyn wewnętrznych trzeba rozważyć, w jaki sposób zewnętrzny napastnik może celowo zepsuć nasz system. Być może zaprotestujesz: „Przecież nikt nie dba o ten kod, to nie jest ważne, nikt nawet nie wie o tym serwerze…”. Świat jest wielki i w większości przypadków składa się z elementów, które są ze sobą połączone. Dzieciak, który nudzi się po drugiej stronie planety, terroryzm sponsorowany przez państwo, gangi, osoby zajmujące się korporacyjnym szpiegostwem, a nawet mściwy ex-partner — oni są w tym świecie i na Ciebie czyhają. Czas przeżycia systemów bez aktualizacji, przestarzałych lub działających w otwartej sieci jest mierzony w minutach lub nawet mniejszych jednostkach. Zabezpieczenie polegające na nieujawnianiu po prostu nie działa. 13 Patrz temat „Debugowanie”. 4337ebf6db5c7cc89e4173803ef3875a 4 Pozostań w bezpiecznym miejscu 269 Podstawowe zasady bezpieczeństwa Pragmatyczni programiści są zdrowymi paranoikami. Wiemy, że mamy wady i ograniczenia, że napastnicy zewnętrzni wykorzystają każdą lukę po to, by włamać się do naszego systemu. W każdym środowisku programistycznym i wdrożeniowym istnieją inne potrzeby w zakresie bezpieczeństwa. Istnieje jednak kilka podstawowych zasad, o których powinniśmy pamiętać: 1. Minimalizowanie przestrzeni ataku. 2. Zasada najmniejszego poziomu uprawnień. 3. Bezpieczne wartości domyślne. 4. Szyfrowanie wrażliwych danych. 5. Zarządzanie aktualizacjami zabezpieczeń. Rzućmy okiem na każdą z nich. Minimalizowanie przestrzeni ataku Przestrzeń ataku systemu jest sumą wszystkich punktów dostępowych, w których napastnik może wprowadzać dane, wydobywać je lub wywoływać usługi. Oto kilka przykładów: Złożoność kodu prowadzi do wektorów ataku. Złożoność kodu poszerza wektor ataku, stwarza więcej możliwości powstania nieoczekiwanych skutków ubocznych. Pomyśl o złożonym kodzie jako o kodzie, który ma bardziej porowatą powierzchnię i jest w większym stopniu otwarty na infekcję. Trzeba zapamiętać, że kod prosty i mniej rozbudowany jest lepszy. Mniej kodu oznacza mniej błędów, mniejsze ryzyko wystąpienia wyniszczającej luki w zabezpieczeniach. Prostszy, bardziej zwięzły i mniej skomplikowany kod jest łatwiejszy do analizy i łatwiej jest w nim znaleźć potencjalne słabości. Dane wejściowe są wektorem ataku. Nigdy nie ufaj danym wprowadzanym przez podmioty zewnętrzne. Zawsze „dezynfekuj je” przed przekazaniem do bazy danych, wyrenderowaniem widoku lub innym przetwarzaniem14. Niektóre języki mają mechanizmy, które mogą w tym pomóc. Na przykład w Ruby zmienne posiadające wejście zewnętrzne są uznawane za skażone, co ogranicza zakres operacji, jakie można na nich wykonywać. Na przykład poniższy kod korzysta z narzędzia wc w celu zwrócenia liczby znaków w pliku przekazanym do programu w czasie jego wykonywania: safety/taint.rb puts "Podaj nazwę pliku do zliczania znaków: " name = gets system("wc -c #{name}") 14 Pamiętaj także o naszych dobrych przyjaciołach, niepozornych tabelach Bobby’ego (https://xkcd.com/327). Podczas przypominania ich sobie zajrzyj na stronę https://bobbytables.com, gdzie znajdziesz sposoby odkażania danych przekazywanych do zapytań do bazy danych. 4337ebf6db5c7cc89e4173803ef3875a 4 270 Rozdział 7. Kiedy kodujemy… Złośliwy użytkownik może spowodować uszkodzenia w systemie za pośrednictwem tego kodu w następujący sposób: Podaj nazwę pliku do zliczania znaków: test.dat; rm -rf / Jednak ustawienie poziomu SAFE na1 spowoduje, że dane z zewnątrz będą uznane za skażone, co oznacza, że nie mogą być używane w niebezpiecznych kontekstach: $SAFE = 1 puts "Podaj nazwę pliku do zliczania znaków: " name = gets system("wc -c #{name}") Teraz, kiedy spróbujemy uruchomić ten kod, zostaniemy „złapani na gorącym uczynku”: $ ruby taint.rb Podaj nazwę pliku do zliczania znaków: test.dat; rm -rf / code/safety/taint.rb:5:in `system': Insecure operation - system (SecurityError) from code/safety/taint.rb:5:in `main Nieuwierzytelnione usługi są wektorem ataku. Nieuwierzytelnione usługi, ze względu na ich naturę, może uruchomić każdy użytkownik w dowolnym miejscu na świecie. W związku z tym, nawet nie biorąc po uwagę innego sposobu wykorzystania luki, pozostawiając dostęp do takich usług, natychmiast stwarzamy okazję co najmniej do ataku denial-of-service. Ostatnio zanotowano sporo naruszeń użycia publicznie dostępnych danych. Powodem tych naruszeń było nieumyślne pozostawienie przez programistów danych w nieuwierzytelnionych, publicznie dostępnych magazynach w chmurze. Uwierzytelnione usługi są wektorem ataku. Liczba użytkowników uprawnionych do korzystania z usług powinna być utrzymywana na poziomie absolutnego minimum. Należy pamiętać o wycofywaniu uprawnień nieużywanych, starych lub nieaktualnych usług, a także nieaktywnych użytkowników. Wiele urządzeń sieciowych ma ustawione proste hasła domyślne lub nieużywane i niezabezpieczone konta administracyjne. W przypadku uzyskania dostępu do konta z uprawnieniami wdrażania, zagrożony jest cały produkt. Dane wynikowe są wektorem ataku. Istnieje (prawdopodobnie apokryficzna) opowieść o systemie, który sumiennie zgłaszał komunikat o błędzie „Hasło jest używane przez innego użytkownika”. Nie należy ujawniać informacji o systemie. Zadbaj o to, aby dane przekazywane w komunikatach były odpowiednie do uprawnień użytkownika, który może je zobaczyć. Wyeliminuj z komunikatów potencjalnie ryzykowne informacje, takie jak numery ubezpieczenia społecznego lub inne numery identyfikacyjne. 4337ebf6db5c7cc89e4173803ef3875a 4 Pozostań w bezpiecznym miejscu 271 Informacje diagnostyczne są wektorem ataku. Nie ma nic tak cieszącego oko napastnika jak kompletny ślad stosu z danymi w lokalnym bankomacie, automacie na lotnisku lub witrynie internetowej, która uległa awarii. Informacje, które mają ułatwić debugowanie, mogą również ułatwiać włamanie do systemu. Zadbaj o to, aby wszystkie „okna testowe” (omówione wcześniej w rozdziale) i raporty o wyjątkach w czasie wykonywania programu były niedostępne dla wścibskich oczu15. WSKAZÓWKA NR 72 Zadbaj o prostotę kodu i zminimalizuj obszary ataku. Zasada najmniejszego poziomu uprawnień Inną kluczową zasadą jest udzielanie jak najmniejszych uprawnień na jak najkrótszy czas. Innymi słowy, nie udzielaj automatycznie najwyższego poziomu uprawnień właściwych dla użytkownika root lub Administrator. Jeśli taki wysoki poziom uprawnień jest potrzebny, wykonaj z ich wykorzystaniem minimalną ilość pracy, a następnie szybko obniż swoje uprawnienia w celu zmniejszenia ryzyka. Poniższa zasada sięga początku lat siedemdziesiątych ubiegłego wieku: Każdy program i każdy uprzywilejowany użytkownik systemu powinien działać przy użyciu jak najmniejszej ilości uprawnień niezbędnych do wykonania zadania — Jerome Saltzer, „Communications of the ACM”, 1974. Weźmy za przykład program login w systemach uniksowych. Początkowo jest on wykonywany z uprawnieniami użytkownika root. Jednak niezwłocznie po zakończeniu uwierzytelniania właściwego użytkownika, program ten obniża swoje uprawnienia do poziomu użytkownika, który się uwierzytelnił. Nie dotyczy to tylko poziomów uprawnień systemu operacyjnego. Czy w Twojej aplikacji zaimplementowano różne poziomy dostępu? Czy jest to tępe narzędzie, w którym obowiązują uprawnienia „administratora” albo „użytkownika”? Jeśli tak, zastanów się nad bardziej drobnoziarnistym podziałem, tak aby wrażliwe zasoby były podzielone na różne kategorie, a poszczególni użytkownicy mieli uprawnienia tylko do niektórych z tych kategorii. Dla tej techniki wykorzystywana jest taka sama koncepcja, jak podczas minimalizowania zakresu wektorów ataku, zarówno co do czasu, jak i poziomu uprawnień. W tym przypadku mniej w rzeczywistości oznacza więcej. 15 Technika ta okazała się skuteczna na poziomie chipów CPU, gdzie za pomocą znanych eksploitów atakowano mechanizmy diagnostyczne i administracyjne. Po ich złamaniu napastnik uzyskuje dostęp do całego systemu. 4337ebf6db5c7cc89e4173803ef3875a 4 272 Rozdział 7. Kiedy kodujemy… Bezpieczne wartości domyślne Domyślne ustawienia w Twojej aplikacji lub dla użytkowników w Twojej witrynie powinny być najbardziej bezpiecznymi wartościami. Nie zawsze powinny to być wartości najbardziej przyjazne dla użytkownika lub najbardziej wygodne, ale lepiej niech każdy indywidualnie decyduje o kompromisach pomiędzy bezpieczeństwem a wygodą. Na przykład domyślnym ustawieniem dla wprowadzania hasła może być ukrycie wprowadzanych znaków poprzez zastępowanie każdego z nich gwiazdką. Jeśli wprowadzasz hasło w zatłoczonym miejscu publicznym lub kiedy obserwuje Cię wiele osób, jest to sensowne ustawienie domyślne. Jednak niektórzy użytkownicy chcą zobaczyć napisane hasło. Jeśli istnieje niewielkie ryzyko, że ktoś patrzy przez ramię, udostępnienie takiej opcji dla takich użytkowników jest rozsądne. Szyfrowanie wrażliwych danych Nie należy pozostawiać danych osobowych, finansowych, haseł lub innych poświadczeń w postaci zwykłego tekstu, niezależnie od tego, czy przechowujemy je w bazie danych, czy też w jakimś innym zewnętrznym pliku. Jeśli trzeba udostępnić takie dane, dodatkowy poziom bezpieczeństwa zapewnia szyfrowanie. W temacie „Kontrola kodu źródłowego” w rozdziale 3. zaleciliśmy umieszczanie wszystkiego, co jest potrzebne do realizacji projektu, w systemie kontroli wersji. Cóż. Prawie wszystkiego. Oto jeden z głównych wyjątków od tej reguły: W systemie kontroli wersji nie należy umieszczać razem z kodem źródłowym tajemnic, kluczy API, kluczy SSH haseł szyfrowania lub innych poświadczeń. Klucze i tajemnice powinny być przechowywane oddzielnie, na ogół w plikach konfiguracyjnych lub za pośrednictwem zmiennych środowiskowych dostarczanych podczas budowania i wdrażania. Zarządzanie aktualizacjami zabezpieczeń Aktualizacja systemów komputerowych może być bardzo kłopotliwa. Poprawka zabezpieczeń jest potrzebna, ale efektem ubocznym jej zainstalowania mogą być problemy z działaniem części aplikacji. Niektórzy w takiej sytuacji decydują, by odłożyć aktualizację na później. To fatalny pomysł, bo do czasu zainstalowania poprawki system jest wrażliwy na działanie znanego eksploita. WSKAZÓWKA NR 73 Instaluj aktualizacje bezpieczeństwa jak najwcześniej. Ta porada dotyczy wszystkich urządzeń podłączonych do sieci, w tym telefonów, samochodów, urządzeń, osobistych laptopów, maszyn deweloperskich, maszyn używanych do budowania, serwerów produkcyjnych i obrazów w chmurze. 4337ebf6db5c7cc89e4173803ef3875a 4 Pozostań w bezpiecznym miejscu 273 Wszystkiego. A jeśli uważasz, że to naprawdę nie ma znaczenia, zapamiętaj, że największe naruszenia bezpieczeństwa danych w historii (do tej pory) były spowodowane przez systemy, które nie były aktualizowane. Nie pozwól, aby zdarzyło się to Tobie. Antywzorce haseł Jednym z podstawowych problemów związanych z bezpieczeństwem jest to, że często dobre zabezpieczenia pozostają w sprzeczności ze zdrowym rozsądkiem lub powszechnymi praktykami. Na przykład można by pomyśleć, że ścisłe wymagania dotyczące haseł zwiększą bezpieczeństwo Twojej aplikacji lub witryny. To nieprawda. W rzeczywistości ścisłe zasady dotyczące haseł faktycznie przyczyniają się do obniżenia bezpieczeństwa. Oto krótka lista bardzo złych pomysłów, wraz z kilkoma zaleceniami NIST:a Nie ograniczaj długości hasła do mniej niż 64 znaków. NIST zaleca 256 jako dobrą długość maksymalną. Nie obcinaj haseł wybranych przez użytkowników. Nie wprowadzaj ograniczeń dla znaków specjalnych, takich jak []();&%$# lub /. Zobacz uwagę na temat tabel Bobby’ego wcześniej w tym podrozdziale. Jeżeli z powodu znaków specjalnych w haśle dojdzie do złamania zabezpieczeń systemu, będziesz mieć większe problemy. NIST zaleca akceptowanie wszystkich drukowalnych znaków ASCII, znaku spacji oraz znaków Unicode. Nie udostępniaj wskazówek dotyczących haseł nieuwierzytelnionym użytkownikom i nie pytaj o określone rodzaje informacji (na przykład „Jak wabił się Twój pierwszy zwierzak?”). Nie blokuj funkcjonalności wklejania w przeglądarce. Blokowanie funkcjonalności przeglądarek i menedżerów haseł nie sprawi, że system stanie się bardziej bezpieczny. W rzeczywistości skłania ono użytkowników do tworzenia prostszych, krótszych haseł, które są o wiele łatwiejsze do złamania. Zarówno NIST w USA, jak i National Cyber Security Centre w Wielkiej Brytanii wymagają, by weryfikatory haseł pozwalały na stosowanie funkcjonalności wklejania właśnie z tego powodu. Nie nakładaj obowiązku stosowania innych zasad kompozycji haseł. Na przykład nie wymagaj żadnej konkretnej kombinacji dużych i małych liter, znaków numerycznych lub specjalnych, nie zakazuj powtarzania znaków i tak dalej. Nie wymagaj arbitralnie od użytkowników zmieniania ich haseł po pewnym czasie. Rób to tylko z ważnego powodu (np. jeśli doszło do naruszenia zabezpieczeń). Należy zachęcać do stosowania długich, losowych haseł, o wysokim stopniu entropii. Wprowadzanie sztucznych ograniczeń zmniejsza entropię i zachęca do złych nawyków związanych z hasłami, co sprawia, że konta użytkowników stają się podatne na przejęcia. a Publikacja specjalna NIST 800-63B: Digital Identity Guidelines: Authentication and Lifecycle Management, dostępna za darmo online pod adresem https://doi.org/10.6028/NIST.SP.800-63b. 4337ebf6db5c7cc89e4173803ef3875a 4 274 Rozdział 7. Kiedy kodujemy… Zdrowy rozsądek a kryptografia Warto pamiętać, że jeśli chodzi o sprawy kryptografii, zdrowy rozsądek może Cię zawieść. Oto pierwsza i najważniejsza zasada, jeśli chodzi o kryptografię: nigdy nie wymyślaj jej samodzielnie16. Nawet dla czegoś tak prostego jak hasła, powszechnie stosowane zasady mogą Cię zmylić (patrz ramka „Antywzorce haseł”). W świecie krypto nawet najmniejszy, najbardziej nieistotny z pozoru błąd, może sprowadzić na Twój system ogromne zagrożenie: złamanie Twojego mądrego, nowego algorytmu szyfrowania domowej roboty prawdopodobnie zajmie ekspertowi zaledwie kilka minut. Algorytmów szyfrowania nie należy wymyślać samodzielnie. Jak już wcześniej wspomniano, polegaj tylko na wiarygodnych komponentach: dobrze przetestowanych, właściwie utrzymywanych, często aktualizowanych bibliotekach i frameworkach (najlepiej open source). Oprócz prostych zadań szyfrowania, zwróć szczególną uwagę na inne funkcjonalności związane z bezpieczeństwem Twojej witryny lub aplikacji. Weźmy na przykład uwierzytelnianie. W celu implementacji własnego mechanizmu logowania z wykorzystaniem haseł lub uwierzytelniania biometrycznego, trzeba zrozumieć pojęcia skrótów (ang. hash) i ziaren (ang. salt), znać sposób wykorzystywania przez krakerów takich technik, jak tablice tęczowe, wiedzieć dlaczego nie należy używać algorytmów MD5 lub SHA1 oraz zdawać sobie sprawę z wielu innych problemów. A nawet jeśli się to wszystko uda, ostatecznie i tak będziesz odpowiedzialny za przechowywanie danych i zapewnienie ich bezpieczeństwa, z uwzględnieniem wszystkich nowych regulacji prawnych i innych przepisów. Alternatywnie możesz zastosować pragmatyczne podejście i skorzystać z usług zewnętrznego dostawcy uwierzytelniania, dzięki czemu ktoś inny będzie zmuszony martwić się wszystkimi wymienionymi wcześniej problemami. Może to być usługa zewnętrzna uruchomiona na komputerach lokalnych lub usługa w chmurze obliczeniowej. Usługi uwierzytelniania są często dostępne za pośrednictwem poczty elektronicznej, dostawców usług telefonicznych lub mediów społecznościowych. Należy je odpowiednio dopasować do konkretnej aplikacji. Trzeba pamiętać, że usługodawcy zewnętrzni przez cały czas dbają o bezpieczeństwo swoich systemów i są w tym lepsi od Ciebie. Pozostań w bezpiecznym miejscu. Pokrewne podrozdziały 16 Temat 23., „Projektowanie kontraktowe”. Temat 24., „Martwe programy nie kłamią”. Chyba że masz doktorat z kryptografii, a nawet wtedy powinieneś się zastanowić nad zapewnieniem przeglądu przez współpracowników, szeroko zakrojonych badań z wykorzystaniem programów „bug bounty” oraz budżetu na długoterminowe utrzymywanie. 4337ebf6db5c7cc89e4173803ef3875a 4 Nazewnictwo 44 43 Temat 25., „Programowanie asertywne”. Temat 38., „Programowanie przez koincydencję”. Temat 45., „Kopalnia wymagań”. 275 Nazewnictwo Początkiem mądrości jest nazywanie rzeczy po imieniu. Konfucjusz Czym jest nazwa? Jeśli chodzi o programowanie, odpowiedź brzmi „Wszystkim!”. Tworzymy nazwy dla aplikacji, podsystemów, modułów, funkcji i zmiennych — stale tworzymy nowe rzeczy i nadajemy im nazwy. Te nazwy są bardzo, bardzo ważne, gdyż ujawniają wiele na temat Twoich intencji i przekonań. Uważamy, że elementy kodu powinny być nazywane w zależności od roli, jaką w nim odgrywają. Oznacza to, że zawsze, kiedy coś tworzysz, powinieneś się zatrzymać i pomyśleć „Jaka jest moja motywacja, żeby to stworzyć?”. To bardzo ważne pytanie, ponieważ odwodzi Cię od myślenia o aktualnie rozwiązywanym problemie i sprawia, że patrzysz na szerszy obraz. Kiedy bierzesz pod uwagę rolę zmiennej lub funkcji, myślisz o tym, co jest w nich szczególnego w zakresie tego, co mogą one robić, i z jakimi elementami współdziałają. Często zdajemy sobie sprawę, że to, co mieliśmy zamiar zrobić, jest bez sensu — wszystko dlatego, że nie potrafiliśmy wymyślić odpowiedniej nazwy. Istnieje naukowe uzasadnienie koncepcji nadawania nazw mających głęboki sens. Okazuje się, że mózg potrafi czytać i rozumieć słowa bardzo szybko: znacznie szybciej niż potrafi wykonać wiele innych działań. Oznacza to, że kiedy staramy się coś zrozumieć, słowa mają pewien priorytet. Można to zademonstrować za pomocą efektu Stroopa17. Spójrzmy na poniższy panel. Zamieszczono na nim listę nazw kolorów lub odcieni, a każdy z nich jest pokazany w odpowiednim kolorze lub odcieniu. Ale nazwy i kolory nie zawsze do siebie pasują. Oto jedna część wyzwania — przeczytaj na głos nazwę każdego koloru w panelu18: 17 Studies of Interference in Serial Verbal Reactions [Str35]. 18 Mamy dwie wersje tego panelu. Jeden wykorzystuje różne kolory, a drugi odcienie szarości. Jeśli widzisz ten w czerni i bieli i chcesz wersję w kolorze, lub jeśli masz problemy z rozróżnieniem kolorów i chcesz spróbować wersji w skali szarości, odwiedź stronę https://pragprog.com/the-pragmatic-programmer/stroop-efekt. 4337ebf6db5c7cc89e4173803ef3875a 4 276 Rozdział 7. Kiedy kodujemy… Teraz powtórz ćwiczenie, ale zamiast odczytywania nazwy powiedz na głos kolor użyty do narysowania słowa. Prawda, że trudniej? Płynne czytanie jest łatwe, ale próba rozpoznawania kolorów jest znacznie trudniejsza. Twój mózg traktuje napisane słowa jako coś, czego należy przestrzegać. Musimy zadbać o to, aby używane przez nas nazwy były odpowiednie. Przyjrzyjmy się kilku przykładom: Uwierzytelniamy osoby, które wchodzą na naszą stronę, gdzie sprzedajemy biżuterię wykonaną ze starych kart graficznych: let użytkownik = uwierzytelnij(poświadczenia) Zmienna ma nazwę użytkownik ponieważ zawsze stosujemy nazwę użytkownik. Zastanówmy się jednak dlaczego? To nic nie znaczy. A może tak klient lub nabywca? W ten sposób podczas kodowania otrzymujemy stałe przypomnienia dotyczące roli osoby oraz co ona dla nas oznacza. Mamy metodę egzemplarza klasy, stosującą rabat do zamówienia: public void odejmijProcent(double kwota) // ... W tym kodzie na uwagę zasługują dwie rzeczy. Po pierwsze nazwa odejmij Procent informuje co metoda robi, ale nie dlaczego to robi. Z kolei nazwa parametru kwota jest w najlepszym przypadku myląca: czy to jest kwota bezwzględna, czy może procent? Być może taki nagłówek metody byłby lepszy: public void zastosujRabat(Procent rabat) // ... Teraz nazwa metody czytelnie identyfikuje jej przeznaczenie. Zmieniliśmy także typ parametru z double na Procent — typ, który zdefiniowaliśmy. Nie wiem jak Wy, ale gdy chodzi o procenty my nigdy nie wiemy, czy wartość ma mieścić się w zakresie od 0 do 100, czy od 0,0 do 1,0. Skorzystanie z własnego typu pozwala udokumentować, czego oczekuje funkcja. Mamy moduł, który robi ciekawe rzeczy z liczbami należącymi do ciągu Fibonacciego. Jedną z tych rzeczy jest obliczenie n-tego wyrazu w ciągu. Zatrzymaj się i pomyśl, jak nazwałbyś tę funkcję. 4337ebf6db5c7cc89e4173803ef3875a 4 Nazewnictwo 277 Większość osób, które pytaliśmy, nazwałaby ją fib. Wydaje się to rozsądne, ale należy pamiętać, że funkcja ta zazwyczaj będzie wywoływana w kontekście swojego modułu, więc wywołanie będzie wyglądało Fib.fib(n). A gdyby tak nazwać ją po prostu dla lub wyraz: Fib.dla(0) # => 0 Fib.wyraz(20) # => 4181 Gdy nadajemy nazwy elementom kodu, powinniśmy stale poszukiwać sposobów wyjaśniania, co one oznaczają. Ten akt wyjaśniania doprowadzi nas do lepszego zrozumienia kodu podczas jego pisania. Należy jednak pamiętać, że nie wszystkie nazwy muszą być kandydatami do nagrody literackiej. Wyjątek, który potwierdza regułę O ile należy dbać o przejrzystość w kodzie, o tyle nie wolno zapominać o budowaniu świadomości marki. Istnieje ugruntowana tradycja, zgodnie z którą projekty i zespoły projektowe powinny mieć niejasne, „sprytne” nazwy. Nazwy Pokémonów, superbohaterów Marvel, słodkich ssaków, postaci z „Władcy Pierścieni”. Należy je stosować dosłownie. Szanuj kulturę W informatyce są tylko dwie trudne rzeczy: unieważnianie zbuforowanych informacji i nadawanie rzeczom nazw. W większości podręczników do informatyki dla początkujących można znaleźć zalecenia, by nigdy nie używać pojedynczych liter jako nazw zmiennych, takich jak i, j lub k19. Nie zgadzamy się z tym. Oczywiście w pewnym sensie. W rzeczywistości wszystko zależy od kultury danego języka programowania lub środowiska. W języku programowania C i, j i k są tradycyjnie używane jako zmienne inkrementacji pętli, s oznacza łańcuch znaków i tak dalej. Jeśli programujesz w tym środowisku, zapewne przyzwyczaiłeś się do tego, a naruszenie tej normy byłoby wstrząsem (a więc czymś złym). Z drugiej strony, stosowanie 19 Czy wiesz, dlaczego litera i jest powszechnie stosowana jako zmienna pętli? Tradycja ma ponad 60 lat, pochodzi z czasów, gdy zmienne od I do N oznaczały w FORTRANIE liczby całkowite. Z kolei FORTRAN został opracowany na podstawie notacji obowiązujących w algebrze. 4337ebf6db5c7cc89e4173803ef3875a 4 278 Rozdział 7. Kiedy kodujemy… tej konwencji w innym środowisku, w którym jest ona nieoczekiwana, również jest złe. Nigdy nie należy zrobić czegoś tak haniebnego, jak w poniższym przykładzie kodu w Clojure, w którym przypisano ciąg do zmiennej i: (let [i "Witaj świecie "] (println i)) Społeczności niektórych języków preferują konwencję camelCase, z wielkimi literami oznaczającymi kolejne słowa, podczas gdy inni wolą konwencję snake_case, w której poszczególne słowa są oddzielone znakami podkreślenia. Z punktu widzenia samych języków stosowana metoda zapisu nie ma znaczenia, ale dowolne jej stosowanie nie jest dobre. Należy szanować lokalną kulturę. Niektóre języki pozwalają na stosowanie w nazwach podzbioru znaków Unicode. Warto zorientować się, czego oczekuje społeczność, zanim zastosujemy tak słodkie nazwy, jak ɹǝsn lub εξέρχεται. Spójność Emerson słynie z powiedzenia „Głupia spójność jest hobgoblinem małych umysłów…”, ale Emerson nie był w zespole programistów. Każdy projekt ma swoje słownictwo: żargonowe słowa, które mają szczególne znaczenie dla zespołu. „Porządek” oznacza co innego dla zespołu tworzącego sklep online, a coś zupełnie innego dla zespołu pracującego nad aplikacją tworzącą wykresy rodów dla grup religijnych. Ważne, aby wszystkie osoby w zespole wiedziały, co znaczą te słowa, i aby ich konsekwentnie używały. Jednym ze sposobów osiągnięcia tego celu jest zachęcenia do komunikacji. Jeśli w zespole stosowane jest programowanie w parach, a pary się często zmieniają, to żargon rozprzestrzeni się osmotycznie. Innym sposobem jest stworzenie słowniczka projektu, w którym wymieniono hasła mające dla zespołu szczególne znaczenie. Jest to nieformalny dokument, który może być utrzymywany w postaci wiki, ewentualnie w formie kart indeksowych rozwieszonych gdzieś na ścianie. Po pewnym czasie żargon projektu zacznie żyć własnym życiem. Gdy wszyscy przyzwyczają się do słownictwa, będzie można używać żargonu jako skrótu, pozwalającego wyrazić wiele znaczeń w sposób zwięzły i dokładny (dokładnie w taki sposób działają języki wzorców). 4337ebf6db5c7cc89e4173803ef3875a 4 Nazewnictwo 279 Zmiany nazw są jeszcze trudniejsze Istnieją dwa trudne problemy w informatyce: unieważnianie zbuforowanych elementów, nazywanie rzeczy oraz jeszcze pomyłki „o jeden”20. Bez względu na to, jak wiele wysiłku poświęcimy na przygotowanie projektu, rzeczywistość się zmienia. Kod podlega refaktoryzacji, zmieniają się użytkownicy, znaczenia ulegają subtelnym zmianom. Jeśli nie będziemy czujni co do aktualizacji nazw na bieżąco, szybko możemy osiągnąć koszmar znacznie gorszy od bezsensownych nazw: nazwy mylące. Czy kiedykolwiek słyszałeś, jak ktoś wyjaśniał niespójności w kodzie mówiąc: „Procedura nazywa się getData, ale tak naprawdę zapisuje dane do pliku archiwum”? Zgodnie z tym, co powiedzieliśmy w punkcie „Entropia oprogramowania”, gdy zauważysz problem, napraw go — tu i teraz. Kiedy zobaczysz nazwę, która już nie wyraża przeznaczenia nazwanej rzeczy, jest błędna lub niezrozumiała, napraw to. Z pewnością masz kompletne testy regresji, więc zdołasz dostrzec wszelkie nieścisłości. WSKAZÓWKA NR 74 Nadawaj właściwe nazwy i pamiętaj o ich zmienianiu, gdy zajdzie taka potrzeba. Jeśli z jakiegoś powodu nie możesz zmienić złej nazwy od razu, masz większy problem: naruszenie zasad ETC (patrz temat 8., „Istota dobrego projektu”, mieszczący się w rozdziale 2.). Napraw to naruszenie, a następnie zmień złą nazwę. Zadbaj o to, aby wprowadzanie zmian było łatwe, a potem często zmieniaj nazwy. W przeciwnym razie będziesz zmuszony wyjaśniać nowym osobom w zespole, że funkcja getData naprawdę zapisuje dane do pliku, i będziesz musiał to robić z kamienną twarzą. Pokrewne podrozdziały 20 Temat 3., „Entropia oprogramowania”. Temat 40., „Refaktoryzacja”. Temat 45., „Kopalnia wymagań”. https://pl.wikipedia.org/wiki/Off_by_one — przyp. tłum. 4337ebf6db5c7cc89e4173803ef3875a 4 280 Rozdział 7. Kiedy kodujemy… Wyzwania Gdy znajdziesz funkcję lub metodę, która ma nadmiernie generyczną nazwę, spróbuj zmienić tę nazwę w taki sposób, aby wyrażała wszystko, co naprawdę robi funkcja (metoda). Teraz jest ona łatwiejszym celem dla refaktoryzacji. W naszych przykładach proponowaliśmy używanie bardziej konkretnych nazw, takich jak nabywca zamiast bardziej tradycyjnej i generycznej nazwy użytkownik. Wskaż inne nazwy, którymi posługujesz się zwyczajowo, a które mogłyby być lepsze. Czy nazwy w Twoim systemie uwzględniają hasła stosowane przez użytkowników domeny? Jeśli nie, to dlaczego? Czy powoduje to dla zespołu dysonans poznawczy w stylu efektu Stroopa? Czy nazwy w Twoim systemie są trudne do zmiany? Co możesz zrobić, aby naprawić to konkretne wybite okno? 4337ebf6db5c7cc89e4173803ef3875a 4 Rozdział 8. Przed projektem Na samym początku projektu musimy określić wymagania. Samo wsłuchiwanie się w głos użytkowników nie wystarczy — więcej informacji na ten temat można znaleźć w temacie 45., „Kopalnia wymagań”. Zawarto tam także opis sposobów unikania powszechnych pułapek i zagrożeń. Konwencjonalną wiedzą i sposobami zarządzania ograniczeniami zajmiemy się w temacie 46., „Rozwiązywanie niemożliwych do rozwiązania łamigłówek”. W zależności od tego, czy pracujemy nad wymaganiami, analizą, kodowaniem, czy testami, możemy spodziewać się różnych problemów. W większości przypadków wspomniane problemy nie są takie trudne, na jakie początkowo wyglądają. A kiedy pojawi się projekt niemożliwy do zrealizowania, możemy sięgnąć do naszej tajnej broni: jest nią „Praca zespołowa”. Przez „pracę zespołową” nie rozumiemy jednak współdzielenia ogromnego dokumentu z wymaganiami, wysyłania e-maili z rozbudowaną listą CC lub ciągłych i niekończących się spotkań. Rozumiemy przez nią wspólne rozwiązywanie problemów podczas kodowania. Pokażemy, kogo będziesz potrzebować i od czego należy zacząć. Mimo że Manifest Agile zaczyna się od zdania „Ludzie i interakcje ponad procesy i narzędzia”, praktycznie wszystkie projekty „agile” rozpoczynają się od ironicznych dyskusji na temat stosowanego procesu i wykorzystywanych narzędzi. Ale bez względu na to, jak dobrze projekt jest przemyślany i niezależnie od zaproponowanych „najlepszych praktyk”, żaden sposób nie zastąpi myślenia. Nie potrzeba żadnego szczególnego procesu lub narzędzia. Potrzeba raczej „Istoty zwinności”. Jeśli uda się wyeliminować te krytyczne problemy przed przystąpieniem do właściwego projektu, będziemy mogli dużo skuteczniej unikać paraliżu analitycznego i sprawnie rozpocząć prace nad projektem. 4337ebf6db5c7cc89e4173803ef3875a 4 282 45 36 Rozdział 8. Przed projektem Kopalnia wymagań Doskonałość osiąga się nie wtedy, kiedy nie można już nic dodać, lecz gdy już niczego nie można ująć… Antoine de St. Exupéry, Ziemia, planeta ludzi, 1939 W wielu książkach i podręcznikach zbieranie wymagań jest prezentowane jako wczesna faza projektu. Samo słowo „zbieranie” sugeruje istnienie jakiejś grupy beztroskich analityków, którzy żywią się leżącymi wokół orzeszkami wiedzy przy pobrzmiewających cicho dźwiękach symfonii Pastoralnej. „Zbieranie” wskazuje na to, że wymagania już istnieją, a nasza rola sprowadza się tylko do ich odnalezienia, umieszczenia w koszyku i radosnego podążania naprzód. Rzeczywistość jest nieco inna. Wymagania rzadko są dostępne od ręki. Zwykle są raczej dobrze ukryte pod warstwami założeń, nieporozumień i decyzji politycznych. Co gorsza, często one w ogóle nie istnieją. WSKAZÓWKA NR 75 Nikt dokładnie nie wie, czego chce. Mit wymagań W pierwszych latach programowania komputery były droższe (biorąc pod uwagę zamortyzowany koszt godziny pracy) niż ludzie, którzy z nimi pracowali. Oszczędności uzyskiwano dzięki dążeniu do tworzenia programów „od razu dobrze”. Częścią tego procesu była próba dokładnego wyspecyfikowania, co maszyna ma dla nas zrobić. Zaczynaliśmy od uzyskania specyfikacji wymagań, przekształcaliśmy ją w dokument projektowy, następnie na schematy blokowe i pseudokod, a na końcu na kod. Jednak przed wprowadzeniem go do komputera poświęcaliśmy mu czas przy biurku, w celu dokładnego sprawdzenia. To kosztowało mnóstwo pieniędzy. Ze względu na te koszty próbowaliśmy automatyzować coś dopiero wtedy, gdy dokładnie wiedzieliśmy, czego chcemy. Ponieważ pierwsze komputery były dość ograniczone, zakres problemów, które pozwalały rozwiązać, również był ograniczony: całość problemu można było zrozumieć jeszcze przed przystąpieniem do realizacji projektu. Ale to nie jest prawdziwy świat. W prawdziwym świecie panuje bałagan, są konflikty i wiele niewiadomych. W tym świecie dokładne specyfikacje czegokolwiek są rzadkie, a czasami wręcz w ogóle niemożliwe. My, programiści, mamy tu do odegrania swoją rolę. Naszym zadaniem jest pomóc użytkownikom w zrozumieniu tego, czego chcą. Rzeczywiście, to jest prawdopodobnie nasz najcenniejszy atrybut. Warto powtórzyć: 4337ebf6db5c7cc89e4173803ef3875a 4 Kopalnia wymagań 283 WSKAZÓWKA NR 76 Programiści pomagają użytkownikom zrozumieć, czego użytkownicy chcą. Programowanie jako terapia Nazwijmy ludzi, którzy proszą nas o napisanie oprogramowania, naszymi klientami. Typowy klient przychodzi do nas z konkretnym zapotrzebowaniem. Może ono być strategiczne, ale jest równie prawdopodobne, że będzie to problem taktyczny: rozwiązanie bieżącego zadania. Potrzeba może dotyczyć zmiany w istniejącym systemie lub utworzenia czegoś nowego. Potrzeba czasami jest wyrażana w kategoriach biznesowych, a innym razem w kategoriach technicznych. Błędem, który często popełniają nowicjusze, jest opracowanie rozwiązania na podstawie sformułowania potrzeby przez klienta. Z naszego doświadczenia wynika, że to wstępne sformułowanie wymagań nie jest kompletne. Klient może nie zdawać sobie z tego sprawy, ale to sformułowanie problemu jest w gruncie rzeczy zaproszeniem do odkrywania wymagań. Weźmy prosty przykład. Pracujesz dla wydawcy książek w formie papierowej i elektronicznej. Otrzymałeś nowe wymaganie: Dla wszystkich zamówień na kwotę 200 PLN lub więcej dostawa powinna być darmowa. Zatrzymaj się na chwilę i wyobraź sobie siebie w sytuacji klienta. Co jest pierwszą rzeczą, która przychodzi Ci do głowy? Prawdopodobnie zadajesz sobie następujące pytania: Czy te 200 PLN zawiera podatek? Czy te 200 PLN obejmuje bieżące opłaty związane z wysyłką? Czy te 200 PLN dotyczy książek papierowych, czy też zamówienie może obejmować również e-booki? Jaki rodzaj wysyłki ma być oferowany? Priorytetowy? Zwykły? A co z zamówieniami zagranicznymi? Jak często limit 200 PLN będzie się w przyszłości zmieniać? Właśnie to robimy. Kiedy klient podaje wymaganie, które wydaje się proste, drażnimy go szukając przypadków brzegowych i zadajemy na ich temat pytania. 4337ebf6db5c7cc89e4173803ef3875a 4 284 Rozdział 8. Przed projektem Być może klient już pomyślał o części z tych problemów i założył, że zostaną uwzględnione w implementacji. Zadanie mu pytań pozwala wydobyć z niego dodatkowe informacje. Ale mogą też pojawić się inne pytania, takie, o których klient wcześniej nie pomyślał. Tutaj robi się ciekawie, a dobry programista powinien nauczyć się dyplomacji. Ty: Zastanawialiśmy się nad kwotą 200 PLN. Czy ta kwota obejmuje opłatę, którą normalnie naliczamy za wysyłkę? Klient: Oczywiście. To całkowita kwota, którą nasz klient nam płaci. Ty: To miłe i proste do zrozumienia dla klientów. Myślę, że będzie to dla nich atrakcyjne rozwiązanie. Ale widzę też ryzyko, że niektórzy pozbawieni skrupułów klienci będą próbowali przechytrzyć system. Klient: W jaki sposób? Ty: Załóżmy, że klient chce kupić książkę za 150 PLN, a następnie wybiera najdroższą opcję wysyłki w ciągu 24 godzin. Załóżmy, że ta wysyłka kosztuje około 60 PLN, dzięki czemu całe zamówienie wynosi 210 PLN. W związku z tym, że dostawa dla zamówień powyżej 200 PLN ma być za darmo, to w gruncie rzeczy klient płaci za książkę wartą 150 PLN, przesłaną w ciągu 24 godzin, jedynie 150 PLN. (W tym momencie doświadczony programista powinien się zatrzymać; przedstawiaj fakty i pozwól klientowi podejmować decyzje). Klient: Och. To z pewnością nie było to, co miałem na myśli. Takie zamówienia wygenerują nam straty. Jakie są inne opcje? Taka rozmowa rozpoczyna badanie tematu. Twoja rola polega na interpretowaniu tego, co klient mówi, i uświadamianiu mu implikacji. Jest to proces zarówno intelektualny, jak i twórczy: myślisz nad problemem klienta i wspólnie tworzycie rozwiązanie, które prawdopodobnie będzie lepsze niż to, które Ty albo klient znaleźlibyście oddzielnie. Wymagania to proces W poprzednim przykładzie programista przeanalizował wymaganie i wyjaśnił klientowi, jakie są jego konsekwencje. To zainicjowało proces rozpoznawania tematu. Podczas tego procesu prawdopodobnie zgłosisz więcej opinii na temat rozwiązań sugerowanych przez klienta. Oto realia procesu zbierania wymagań: WSKAZÓWKA NR 77 Wymagania poznajemy w pętli sprzężenia zwrotnego. Twoim zadaniem jest pomóc klientowi zrozumieć konsekwencje formułowanych przez niego wymagań. Robisz to poprzez generowanie informacji zwrotnych i pozwalasz wykorzystać te informacje w celu uściślenia wymagań. 4337ebf6db5c7cc89e4173803ef3875a 4 Kopalnia wymagań 285 W poprzednim przykładzie informacje zwrotne można było łatwo wyrazić słowami. Czasami jednak tak nie jest. A czasem po prostu masz niewystarczającą wiedzę na temat dziedziny problemu, aby móc wypowiadać się na jej temat. W takich przypadkach pragmatyczni programiści polegają na szkole pozyskiwania informacji zwrotnych poprzez zadawanie pytania „Czy właśnie to masz na myśli?”. Tworzymy makiety i prototypy, po czym dajemy je klientowi do wypróbowania. W idealnej sytuacji programy, które tworzymy, są na tyle elastyczne, że możemy je zmieniać w trakcie naszych rozmów z klientem, co pozwala nam reagować na opinie „Nie to miałem na myśli” za pomocą odpowiedzi „A może lepiej tak?”. Czasami te makiety wyrzuca się zaledwie po kilku godzinach. Jedynym ich przeznaczeniem jest zgrubne wyrażenie koncepcji. Realia są jednak takie, że wszystko, co robimy, jest w rzeczywistości jakąś formą makiety. Nawet po zakończeniu projektu nadal interpretujemy to, czego chce nasz klient. W rzeczywistości w tym momencie zwykle mamy więcej klientów: mogą być nimi użytkownicy zadający pytania i udzielający odpowiedzi, pracownicy działu marketingu, a czasami nawet grupy testowych klientów. W związku z tym pragmatyczny programista postrzega cały projekt jako zadanie gromadzenia wymagań. Dlatego preferujemy krótkie iteracje — takie, które kończą się bezpośrednim sprzężeniem zwrotnym od klienta. To utrzymuje nas „na ścieżce” i daje pewność, że jeśli podążymy w złym kierunku, ilość straconego czasu będzie minimalna. Spróbuj „wejść w buty” swojego klienta Istnieje prosta, choć rzadko stosowana, technika pozwalająca na zajrzenie do głów Twoich klientów: zostań swoim klientem. Piszesz system dla helpdesku? Poświęć kilka dni na monitorowanie telefonów z doświadczonym pracownikiem wsparcia technicznego. Automatyzujesz ręczny system kontroli stanów magazynowych? Popracuj przez tydzień w magazynie1. Główną korzyścią będzie uzyskanie wglądu w sposób, w jaki będzie wykorzystywany gotowy system. Zdziwisz się jednak także, jak bardzo prośba w stylu „Czy mogę posiedzieć z Tobą przez tydzień, i popatrzyć, jak pracujesz?” pomaga w budowaniu zaufania i ustanawia podstawy komunikacji z Twoimi klientami. Pamiętaj tylko, żeby nie przeszkadzać! 1 Tydzień to długo? Nie, zważywszy na to, że chodzi o poznanie procesów, które dla kierownictwa znaczą coś zupełnie innego niż dla pracowników. Przedstawiciele kierownictwa prezentują pewien obraz tego, jak działają niektóre mechanizmy, ale gdy przyjrzymy się im w praktyce, okazuje się, że rzeczywistość jest całkiem inna i trzeba się z nią oswoić. 4337ebf6db5c7cc89e4173803ef3875a 4 286 Rozdział 8. Przed projektem WSKAZÓWKA NR 78 Pracuj z użytkownikiem, aby myśleć jak użytkownik. Zbieranie informacji zwrotnych jest również czasem, w którym warto zacząć budować relacje z bazą Twoich klientów — uczyć się ich oczekiwań co do systemu, który budujesz. Więcej informacji na ten temat znajdziesz w temacie 52., „Wpraw w zachwyt użytkowników”. Wymagania a strategia Wyobraź sobie, że podczas omawiania systemu kadrowego klient mówi: „Dane pracownika mogą oglądać tylko pracownicy działu personalnego i ich przełożeni”. Czy to zdanie jest naprawdę wymaganiem? Być może dzisiaj tak, ale czyni strategię biznesową czymś kategorycznym. Strategia biznesowa? Wymaganie? Jest pomiędzy nimi stosunkowo niewielka różnica, ale jest ona bardzo istotna, ponieważ ma poważne konsekwencje dla programistów. Jeżeli wymaganie zostało sformułowane jako „Tylko pracownicy działu personalnego i ich przełożeni mogą przeglądać dane pracownika”, programista może zakodować jawny test za każdym razem, gdy aplikacja uzyska dostęp do tych danych. Jeśli jednak wymaganie brzmi „Tylko uprawnieni użytkownicy mogą uzyskać dostęp do danych pracownika”, programista prawdopodobnie zaprojektuje i zaimplementuje jakąś formę systemu kontroli dostępu. Gdy zmieni się strategia (co stanie się z pewnością), trzeba będzie zaktualizować tylko metadane dla tego systemu. W istocie zbieranie wymagań w ten sposób naturalnie prowadzi do systemu, który jest dobrze zaprojektowany do wspierania metadanych wsparcia. Oto ogólna zasada: WSKAZÓWKA NR 79 Strategia to metadane. Należy zaimplementować przypadek ogólny z informacjami dotyczącymi strategii, jako przykład typu informacji, które system powinien obsługiwać. Wymagania kontra rzeczywistość W artykule umieszczonym w magazynie „Wired”, w numerze ze stycznia 19992 roku, producent i muzyk Brian Eno opisał niesamowitą technologię — kompletną konsoletę. Wykonywała z dźwiękiem wszystko, co można z nim zro2 https://www.wired.com/1999/01/eno/ 4337ebf6db5c7cc89e4173803ef3875a 4 Kopalnia wymagań 287 bić. A jednak, zamiast umożliwić muzykom tworzenie lepszej muzyki lub realizowanie nagrań szybciej lub taniej, wchodziła im w drogę, zakłócając proces twórczy. Aby zrozumieć dlaczego, trzeba przyjrzeć się sposobom, w jaki pracują inżynierowie dźwięku. Tworzą harmonię dźwięków intuicyjnie. Przez lata doświadczeń rozwijają wrodzoną pętlę sprzężenia zwrotnego pomiędzy swoim słuchem, a palcami — poprzez przesuwanie suwaków, obracanie gałek itd. Jednak interfejs do nowego miksera nie uwzględniał tych zdolności. Zamiast tego zmuszał użytkowników do stukania na klawiaturze lub klikania myszką. Funkcje udostępniane przez system były obszerne, ale zostały zaimplementowane w nieznany i egzotyczny sposób. Funkcje potrzebne inżynierom były czasami ukryte za niejasnymi nazwami lub można je było osiągnąć za pośrednictwem nieintuicyjnych kombinacji podstawowych zadań. Przykład ten ilustruje również nasze przekonanie, że dobre narzędzia powinny dostosowywać się do rąk, które ich używają. Skuteczne zbieranie wymagań powinno to uwzględniać. Właśnie dlatego wcześnie uzyskane opinie, z wykorzystaniem prototypów lub „pocisków smugowych”, pozwalają klientom powiedzieć „Tak, ten system robi to, co chcę, ale nie tak, jak chcę”. Dokumentowanie wymagań Uważamy, że najlepszą, a czasami jedyną dokumentacją wymagań, jest działający kod. Nie znaczy to jednak, że można całkowicie obyć się bez dokumentowania Twojego zrozumienia tego, co chce klient. Oznacza jedynie to, że tworzone dokumenty nie są produktem: nie służą do tego, żebyś wręczył je klientowi do podpisania. Są one po prostu „drogowskazami”, które mają ułatwić proces implementacji. Dokumenty wymagań nie są przeznaczone dla klientów W przeszłości zarówno Andy, jak i Dave pracowali w projektach, w których tworzono bardzo szczegółowe wymagania. Te dość obszerne dokumenty były budowane na podstawie wstępnych dwuminutowych wyjaśnień klientów na temat tego, czego chcą. W efekcie powstawały grube opracowania pełne wykresów i tabel. Funkcjonalności były opisywane tak szczegółowo, że niemal nie było miejsca na niejasności w implementacji. Gdyby skorzystać z odpowiednich narzędzi, dokument wymagań mógłby w istocie być końcowym programem. Tworzenie tego rodzaju dokumentów było błędem z dwóch powodów. Po pierwsze, jak już powiedzieliśmy, klienci zazwyczaj nie wiedzą z góry, czego chcą. Kiedy więc na podstawie tego, co mówią, rozszerzymy opis do rozmiarów czegoś, co jest niemal dokumentem prawnym, zbudujemy niezwykle skomplikowany zamek na piasku. 4337ebf6db5c7cc89e4173803ef3875a 4 288 Rozdział 8. Przed projektem Można by powiedzieć: „Ale przecież prezentujemy dokument klientowi i uzgadniamy go z nim; zbieramy jego opinie”. To jednak prowadzi nas do drugiego problemu związanego z dokumentami specyfikacji: klient nigdy ich nie czyta. Klient zatrudnia programistów, ponieważ jest zmotywowany do rozwiązywania nieco mgliście określonego problemu na wysokim poziomie, ale to programiści są zainteresowani wszystkimi szczegółami i niuansami. Dokumenty wymagań są napisane dla programistów; zawierają informacje i subtelności, które są czasem niezrozumiałe, a często nudne dla klienta. Jeśli przedstawisz 200-stronicowy dokument wymagań klientowi, on prawdopodobnie oceni go pod kątem objętości — rzuci okiem, czy jest wystarczająco obszerny, aby mógł mieć znaczenie. Być może zapozna się z kilkoma pierwszymi akapitami (dlatego pierwsze dwa akapity są zawsze zatytułowane „Streszczenie dla kierownictwa”), a resztę jedynie przejrzy, czasem zatrzymując się, gdy natrafi na interesujący wykres. Tu nie chodzi o to, aby zbyć klienta. Ale przekazanie mu obszernego dokumentu technicznego jest jak przekazanie przeciętnemu programiście kopii „Iliady” Homera napisanej greką z prośbą o zakodowanie gry wideo na tej podstawie. Dokumenty wymagań służą do planowania Nie jesteśmy zatem zwolennikami monolitycznych, tak ciężkich dokumentów wymagań, że można by nimi ogłuszyć woła. Wiemy jednak, że wymagania trzeba spisać — po prostu dlatego, żeby programiści w zespole wiedzieli, co mają robić. Jaką formę powinien przyjąć taki dokument? Opowiadamy się za dokumentem, który można zmieścić na rzeczywistej (lub wirtualnej) karcie katalogowej. Te krótkie opisy są często nazywane historyjkami użytkowników (ang. user stories). Opisują, co powinien robić niewielki fragment aplikacji z punktu widzenia użytkownika funkcjonalności. Wymagania napisane w ten sposób mogą być umieszczone na tablicy i przekazywane w celu pokazania zarówno statusu, jak i priorytetów. Można by pomyśleć, że na pojedynczej karcie katalogowej nie da się zmieścić informacji niezbędnych do zaimplementowania komponentu aplikacji. To prawda. Ale to tylko fragment obrazu. Dzięki temu, że sformułowanie wymagań jest krótkie, zachęcamy programistów do zadawania pytań o wyjaśnienia. W ten sposób usprawniamy proces sprzężenia zwrotnego pomiędzy klientami a programistami przed tworzeniem każdego fragmentu kodu i podczas jego tworzenia. Zbyt duża liczba szczegółów Jednym z największych zagrożeń podczas sporządzania dokumentu z wymaganiami jest dążenie do zapisania zbyt wielu szczegółów. Dobre dokumenty o wymaganiach zachowują swoją abstrakcyjność. W przypadku wymagań naj- 4337ebf6db5c7cc89e4173803ef3875a 4 Kopalnia wymagań 289 prostsze stwierdzenia, które możliwie precyzyjnie wyrażają potrzeby biznesowe, sprawdzają się zdecydowanie najlepiej. Nie chodzi jednak o przesadną ogólnikowość — w naszych wymaganiach musimy uwzględnić niezmienniki semantyczne, a konkretne lub bieżące praktyki należy udokumentować raczej w formie polityki. Wymagania to nie architektura. Wymagania to nie projekt ani interfejs użytkownika. Wymagania to opis potrzeby. Jeszcze tylko jedna malutka funkcja… Wiele projektów kończy się niepowodzeniem wskutek niekontrolowanego rozszerzania zakresu prac, czyli zjawiska określanego mianem przerostu funkcji. Mamy tutaj do czynienia z pewnym aspektem syndromu gotowanej żaby z tematu 4., „Zupa z kamieni i gotowane żaby”. Co możemy zrobić, aby zapobiec wpadnięciu w pułapkę zbyt wielu wymagań? Odpowiedź (znowu) brzmi: sprzężenie zwrotne. Jeśli pracujemy z klientem iteracyjnie, stale zbierając jego opinie, to klient natychmiast doświadczy wpływu zjawiska „tylko jednej dodatkowej funkcji”. Zauważy dodatkową historyjkę na tablicy i pomoże wybrać inną kartę, aby przejść do następnej iteracji. Sprzężenie zwrotne działa w obie strony. Utrzymywanie glosariusza W momencie przystąpienia do rozmowy o wymaganiach użytkownicy i eksperci z danej dziedziny zaczynają używać pewnych terminów, które mają dla nich specyficzne znaczenie. Mogą na przykład odróżniać klienta od kupującego. W takim przypadku zamienne stosowanie obu słów w systemie byłoby niewłaściwe. Warto więc utworzyć i utrzymywać glosariusz na potrzeby projektu, czyli jedno miejsce, w którym będą definiowane wszystkie terminy i słownictwo używane w ramach projektu. Wszyscy uczestnicy projektu, od użytkowników końcowych po pracowników działu wsparcia, powinni posługiwać się tym glosariuszem, aby zachowywać spójność terminologii. Oznacza to, że glosariusz powinien być powszechnie dostępny — to jeden z argumentów przemawiających za dokumentacją udostępnianą online. WSKAZÓWKA NR 80 Należy stosować glosariusz projektu. Bardzo trudno pomyślnie zakończyć projekt, w którym użytkownicy i programiści stosują odmienne nazwy dla tych samych elementów czy zdarzeń lub — co gorsza — odwołują się do różnych aspektów, posługując się tą samą nazwą. 4337ebf6db5c7cc89e4173803ef3875a 4 290 Rozdział 8. Przed projektem Pokrewne podrozdziały Temat 5., „Odpowiednio dobre oprogramowanie”. Temat 7., „Komunikuj się!”. Temat 11., „Odwracalność”. Temat 13., „Prototypy i karteczki samoprzylepne”. Temat 23., „Projektowanie kontraktowe”. Temat 43., „Pozostań w bezpiecznym miejscu”. Temat 44., „Nazewnictwo”. Temat 46., „Rozwiązywanie niemożliwych do rozwiązania łamigłówek”. Temat 52., „Wpraw w zachwyt użytkowników”. Ćwiczenia 33. Które z poniższych zdań zasługują na miano pełnowartościowych wymagań? Spróbuj (jeśli to możliwe) inaczej wyrazić zdania, które nie spełniają warunków dobrych wymagań. 1. Czas odpowiedzi musi być krótszy niż 500 ms. 2. Okna dialogowe będą miały szary kolor tła. 3. Aplikacja zostanie zorganizowana jako pewna liczba procesów frontowych oraz jeden serwer wewnętrzny. 4. Jeśli użytkownik poda znaki nienumeryczne w polu numerycznym, system odtworzy dźwięk ostrzegawczy i odrzuci wprowadzoną wartość. 5. Kod i dane aplikacje nie mogą zajmować więcej niż 32 MB. Wyzwania 46 37 Czy używasz oprogramowania, które piszesz? Czy można dobrze zgromadzić i zrozumieć wymagania bez samodzielnego sprawdzenia oprogramowania? Wybierz jakiś problem niezwiązany z komputerami, który właśnie musisz rozwiązać. Opracuj wymagania dla rozwiązania tego problemu (bez użycia komputera). Rozwiązywanie niemożliwych do rozwiązania łamigłówek Gordios, król Frygii, zawiązał kiedyś węzeł, którego nikt nie potrafił rozsupłać. Mówiono, że ten, kto rozwiąże zagadkę węzła gordyjskiego, zdobędzie władzę nad Azją. Zagadkę rozwiązał dopiero Aleksander Wielki, który przeciął węzeł mieczem. Okazało się, że wystarczyła tylko inna interpretacja wymagań — to wszystko… i rzeczywiście Aleksander podbił znaczną część Azji. 4337ebf6db5c7cc89e4173803ef3875a 4 Rozwiązywanie niemożliwych do rozwiązania łamigłówek 291 Od czasu do czasu odkrywamy gdzieś w środku projektu, że nie potrafimy zrobić choćby kroku naprzód. Trafiamy na przeszkodę niemożliwą do rozwiązania, jak nieumiejętność radzenia sobie z jakąś technologią czy fragment kodu, który okazuje się dużo trudniejszy do napisania, niż początkowo zakładaliśmy. Być może problem rzeczywiście wydaje się niemożliwy do rozwiązania. Czy jednak rzeczywiście jest taki trudny, na jaki wygląda? Przeanalizujmy tradycyjne układanki — wszystkie te kłopotliwe kształty z drewna, stali lub plastiku, które tak często znajdujemy pod choinką lub na wyprzedażach niepotrzebnych rzeczy. Zwykle wystarczy przenieść okrągły kształt w inne miejsce, umieścić klocek w kształcie T w określonym miejscu itp. Przenosimy więc okrągły kształt lub próbujemy umieścić klocek w kształcie litery T w określonym miejscu, aby szybko odkryć, że to oczywiste rozwiązanie nie zdaje egzaminu. Układanek nie można rozwiązywać w ten sposób. To, że rozwiązanie nie jest oczywiste, nie powstrzymuje ludzi przed próbami wielokrotnego powtarzania tych samych czynności w przekonaniu, że łamigłówka musi mieć jakieś rozwiązanie. To oczywiste, że w ten sposób nie można dojść do rozwiązania. Rozwiązanie leży gdzie indziej. Sekretem układanki jest identyfikacja rzeczywistych (nie wyobrażonych) ograniczeń i znalezienie rozwiązania w ich ramach. Niektóre ograniczenia mają bezwzględny charakter; inne mają raczej postać nieuzasadnionych uprzedzeń. Ograniczenia bezwzględne muszą być przestrzegane niezależnie od tego, czy sprawiają wrażenie nielogicznych lub wręcz głupich. Istnieją też, co udowodnił Aleksander Wielki, pozorne ograniczenia, które nie mają nic wspólnego z rzeczywistością. Wiele problemów dotyczących oprogramowania ma równie przebiegły charakter. Stopnie swobody Popularne wyrażenie „wykraczać myślami poza schematy” (ang. thinking outside the box) zachęca nas do identyfikacji ograniczeń, które w naszym przypadku nie znajdują zastosowania, i do ich ignorowania. Przytoczona koncepcja nie jest jednak w pełni słuszna. Jeśli tym „schematem” jest warunek graniczny, problem polega raczej na znalezieniu schematu, który co najwyżej może być istotnie szerszy, niż początkowo sądzimy. Kluczem do rozwiązania układanki jest zarówno rozpoznanie krępujących nas ograniczeń, jak i stopni swobody, którymi dysponujemy — dopiero na tej podstawie możemy znaleźć wyjście z sytuacji. Właśnie dlatego układanki są takie kłopotliwe; często zbyt pochopnie rezygnujemy z potencjalnych rozwiązań. Czy potrafimy na przykład połączyć wszystkie punkty na poniższym rysunku i wrócić do punktu wyjścia, rysując zaledwie trzy proste odcinki (bez odrywania długopisu od papieru ani dwukrotnego rysowania odcinka łączącego te same punkty) Math Puzzles & Games [Hol92]? 4337ebf6db5c7cc89e4173803ef3875a 4 292 Rozdział 8. Przed projektem Musimy zmierzyć się ze wszystkimi przyjętymi z góry wyobrażeniami i ocenić, czy rzeczywiście reprezentują fizyczne ograniczenia. Problemem nie jest więc to, czy myślimy schematycznie, czy potrafimy wyjść poza schematy. Kluczem do rozwiązania jest raczej znalezienie schematu — identyfikacja faktycznych ograniczeń. WSKAZÓWKA NR 81 Nie należy wykraczać myślami poza schemat — należy raczej znaleźć ten schemat. W razie napotkania szczególnie kłopotliwego problemu warto zapisać sobie wszystkie możliwe ścieżki rozwiązania, które na tym etapie potrafimy dostrzec. Nie należy niczego pomijać, choćby wydawało się zupełnie niepraktyczne lub wręcz głupie. Dopiero po sporządzeniu tej listy warto ją uważnie przejrzeć i wyjaśnić, dlaczego ta czy inna ścieżka nie doprowadzi do szczęśliwego końca. Czy na pewno? Potrafimy to udowodnić? Przypomnijmy sobie historię konia trojańskiego — nowatorskiego rozwiązania problemu, który wydawał się niemożliwy do rozwiązania. Jak niepostrzeżenie przerzucić wojsko do dobrze ufortyfikowanego miasta? Jesteśmy pewni, że koncepcja „przez główną bramę” początkowo była odrzucana jako samobójcza. Warto przypisywać poszczególne ograniczenia do kategorii i nadawać im priorytety. Kiedy stolarze przystępują do projektu, zaczynają od cięcia najdłuższych fragmentów drewna, by następnie odpowiednio pociąć pozostałe fragmenty. W ten sam sposób chcemy najpierw identyfikować najbardziej krępujące ograniczenia i umieszczać pozostałe ograniczenia w ich ramach. Rozwiązanie zagadki czterech punktów łączonych trzema odcinkami można znaleźć na końcu książki. Musi istnieć prostszy sposób! Zdarza się, że pracujemy nad rozwiązaniem problemu, który sprawia wrażenie dużo trudniejszego, niż jest w rzeczywistości. Często sądzimy, że obraliśmy niewłaściwą drogę — musi przecież istnieć prostszy sposób osiągnięcia celu! Być może już teraz nie jesteśmy w stanie dotrzymać harmonogramu lub wręcz popadamy w rozpacz, tracąc wiarę w możliwość prawidłowego funkcjonowania systemu, ponieważ jakiś problem wydaje się „niemożliwy do rozwiązania”. 4337ebf6db5c7cc89e4173803ef3875a 4 Rozwiązywanie niemożliwych do rozwiązania łamigłówek 293 W takich przypadkach powinniśmy zatrzymać się na chwilę i przez jakiś czas zająć się czymś innym. Idź na spacer z psem, prześpij się z problemem. Twój świadomy umysł zdaje sobie sprawę z problemu, ale jest w istocie bardzo głupi (bez obrazy). Nadchodzi więc czas, aby Twojemu prawdziwemu mózgowi, tej niezwykłej asocjacyjnej sieci neuronowej, która działa poniżej Twojej świadomości, dać trochę przestrzeni. Będziesz zaskoczony, jak często odpowiedź pojawi się w Twojej głowie, gdy celowo odwrócisz swoją uwagę od problemu. Jeśli to brzmi dla Ciebie zbyt mistycznie, nie przejmuj się. Nie ma w tym niczego mistycznego. W artykule zamieszczonym w witrynie Psychology Today3 można znaleźć stwierdzenie: „Mówiąc krótko — osoby, które potrafiły odwrócić swoją uwagę od problemu, lepiej radziły sobie z rozwiązaniem niż osoby, które usiłowały rozwiązywać problem świadomie”. Jeśli nadal nie jesteś skłonny, aby odłożyć problem na jakiś czas, następną w kolejności najlepszą rzeczą jest znalezienie kogoś, kto potrafi Ci wytłumaczyć, na czym problem polega. Często odwrócenie uwagi od rozwiązania polegające na porozmawianiu o problemie doprowadzi Cię do oświecenia. Podczas rozmowy postaraj się znaleźć odpowiedzi na następujące pytania: Dlaczego rozwiązujesz problem? Jakie korzyści wynikają z jego rozwiązania? Czy rozwiązywany problem ma związek z przypadkami brzegowymi? Czy potrafisz je wyeliminować? Czy istnieje prostszy, podobny problem, który potrafisz rozwiązać? To kolejny praktyczny przykład zastosowania techniki „rozmowy z gumową kaczką”. Szczęście sprzyja przygotowanym umysłom Louis Pasteur miał powiedzieć: W dziedzinie obserwacji szczęście sprzyja umysłom przygotowanym. To twierdzenie jest prawdziwe również w odniesieniu do rozwiązywania problemów. Aby móc przeżywać chwile „eureka!”, Twoja podświadomość musi mieć dużo surowca — wcześniejszych doświadczeń, które mogą przyczynić się do znalezienia odpowiedzi. Świetnym sposobem dostarczenia pożywki dla Twojego mózgu jest przekazywanie mu podczas wykonywania codziennych zadań opinii na temat tego, co działa, a co nie działa. Świetnym sposobem, aby to robić, jest prowadzenie 3 https://www.psychologytoday.com/us/blog/your-brain-work/201209/stop-tryingsolve-problems 4337ebf6db5c7cc89e4173803ef3875a 4 294 Rozdział 8. Przed projektem dzienników inżynierskich (zobacz temat 22., „Dzienniki inżynierskie”). Zawsze także należy pamiętać o poradzie umieszczonej na okładce książki Autostopem przez galaktykę: NIE PANIKUJ! Pokrewne podrozdziały Temat 5., „Odpowiednio dobre oprogramowanie”. Temat 37., „Słuchaj swojego jaszczurczego mózgu”. Temat 45., „Kopalnia wymagań”. Andy napisał całą książkę na temat rozwiązywania problemów: Pragmatic Thinking and Learning: Refactor Your Wetware [Hun08]. Wyzwania 47 38 Spróbuj z dystansu spojrzeć na dowolny trudny problem, który właśnie próbujesz rozwiązać. Czy możesz po prostu przeciąć ten węzeł gordyjski? Zadaj sobie wymienione powyżej pytania, w szczególności: „Czy w ogóle musisz to robić?”. Czy zbiór ograniczeń był znany w momencie podpisywania kontraktu na bieżący projekt? Czy zdefiniowane wówczas ograniczenia wciąż są aktualne i czy ich interpretacja zachowała swoją wartość? Praca zespołowa Nigdy nie spotkałem człowieka, któremu chciałoby się czytać 17 000 stron dokumentacji, a gdybym go znalazł, to zabiłbym go, aby wyeliminować tego osobnika z puli genów. Joseph Costello, Prezes firmy Cadence To był jeden z tych „niemożliwych” projektów — słyszałeś o nich — takich, które sprawiają wrażenie zarówno radosnych, jak i przerażających. „Starożytny” system zbliżał się do końca swoich dni — sprzęt był fizycznie wycofywany. Trzeba było więc wykonać całkiem nowy system, taki, który dokładnie pasowałby do (często nieudokumentowanych) zachowań. Przez system miało przechodzić wiele setek milionów dolarów pieniędzy innych ludzi, a termin od początku projektu do jego wdrożenia miał nie przekroczyć kilku miesięcy. Wtedy właśnie Andy i Dave spotkali się po raz pierwszy. Niemożliwy projekt o śmiesznym terminie wykonania. Była tylko jedna rzecz, dzięki której projekt mógł przeistoczyć się w wielki sukces. Ekspert, który zarządzał tym systemem od lat, siedział w swoim biurze, po drugiej stronie korytarza naszego pokoju programistów o wielkości kantorka na miotły. Był stale gotowy do odpowiedzi na pytania, udzielania wyjaśnień, pomocy w podejmowaniu decyzji i oglądania prezentacji. 4337ebf6db5c7cc89e4173803ef3875a 4 Praca zespołowa 295 W tej książce zalecamy ścisłą współpracę z użytkownikami — powinni oni być częścią zespołu. W tym pierwszym projekcie stosowaliśmy technikę, którą teraz można by nazwać programowaniem w parach lub programowaniem mob: jedna osoba wpisuje kod, a co najmniej jeden spośród innych członków zespołu komentuje problemy, rozmyśla nad nimi i wspólnie z autorem kodu opracowuje rozwiązania. To doskonały sposób współpracy, który eliminuje potrzebę niekończących się spotkań, notatek i zatłoczonej dokumentacji technicznej ocenianej nie pod kątem przydatności, ale objętości. To jest to, co naprawdę uważamy za „wspólną pracę”: nie samo zadawanie pytań, prowadzenie dyskusji i robienie notatek, ale zadawanie pytań i dyskutowanie podczas kodowania. Prawo Conwaya W 1967 roku Melvin Conway w książce How do Committees Invent? [Con68] przedstawił pomysł, który jest dziś znany jako prawo Conwaya: Organizacje, które projektują systemy, ograniczają się do tworzenia projektów, które są kopiami struktur komunikacyjnych tych organizacji. Oznacza to, że struktury i ścieżki komunikacyjne zespołu i organizacji zostaną odzwierciedlone w aplikacji, witrynie lub tworzonym produkcie. Słuszność tego stwierdzenia wykazano w licznych badaniach. Byliśmy bezpośrednimi świadkami potwierdzenia słuszności tej koncepcji niezliczoną ilość razy — na przykład w zespołach, w których nikt z nikim nie rozmawiał, co skutkowało powstawaniem systemów typu silos (nazywanych również „systemami rur piecowych” — ang. stove pipe). Obserwowaliśmy to zjawisko także w zespołach, które były dzielone na dwie części — np. klient – serwer lub frontend – backend. Badania potwierdzają również słuszność odwrotnej zasady: strukturę zespołu można świadomie ustawić pod kątem tego, jak ma wyglądać kod. Na przykład zespoły rozproszone geograficznie wykazują tendencję do tworzenia bardziej modułowego, rozproszonego oprogramowania. Ale co najważniejsze, zespoły programistów, które zawierają użytkowników, tworzą oprogramowanie, które wyraźnie odzwierciedla zaangażowanie tychże, a w produktach zespołów, które rezygnują z udziału użytkowników, ten brak zaangażowania także jest widoczny. Programowanie w parach Programowanie w parach jest jedną z praktyk techniki eXtreme Programming, która stała się popularna poza metodologią XP. Technika ta polega na tym, że podczas programowania jeden programista operuje klawiaturą, a drugi siedzi obok. Oboje pracują nad problemem wspólnie i mogą się zmieniać przy klawiaturze, jeśli jest taka potrzeba. 4337ebf6db5c7cc89e4173803ef3875a 4 296 Rozdział 8. Przed projektem Programowanie w parach przynosi wiele korzyści. Różne osoby mają różną wiedzę i doświadczenia, stosują różne techniki rozwiązywania problemów i różne podejścia, a także różne poziomy koncentracji uwagi w odniesieniu do danego problemu. Programista operujący klawiaturą musi koncentrować się na niskopoziomowych szczegółach składni i stylu kodowania, a drugi programista ma swobodę potrzebną do koncentrowania się na kwestiach wyższego poziomu i szerszym zakresie. Mimo że ta różnica może wydawać się subtelna, należy pamiętać, że ludzie mają ograniczone pasmo mózgu. Koncentrowanie się na wpisywaniu ezoterycznych słów i symboli, które zaakceptuje kompilator, zajmuje znaczącą część mocy obliczeniowej naszego mózgu. Posiadanie w dyspozycji mózgu drugiego programisty podczas wykonywania zadania znacznie zwiększa nasze możliwości. Nieodłączna „presja” związana z obecnością drugiej osoby pomaga przezwyciężyć chwile słabości i wyeliminować złe nawyki w rodzaju nazywania zmiennych foo, a także uniknąć innych podobnych problemów. Gdy ktoś nas uważnie obserwuje, jesteśmy mniej skłonni do stosowania potencjalnie kłopotliwych skrótów, co również prowadzi do wyższej jakości oprogramowania. Programowanie mob Skoro dwie głowy są lepsze niż jedna, to może jeszcze lepszy byłby układ, w którym kilka osób wspólnie pracuje nad tym samym problemem, a tylko jedna z nich operuje klawiaturą. Programowanie mob, pomimo nazwy, nie polega na korzystaniu z pochodni i wideł. To rozszerzenie programowania parami, które obejmuje zaangażowanie więcej niż dwóch programistów. Zwolennicy tej techniki podkreślają jej wielkie zalety podczas rozwiązywania trudnych problemów. W zespole mob mogą się również znaleźć osoby, których zwykle nie traktuje się jako członków zespołu projektowego, w tym użytkownicy, sponsorzy projektu i testerzy. Podczas realizacji naszego pierwszego wspólnego „niemożliwego” projektu często zdarzało się, że jeden z nas operował klawiaturą, podczas gdy pozostali omawiali problem z naszym ekspertem biznesowym. Była to niewielka grupa mob złożona z trzech osób. Programowanie mob można interpretować jako stosowanie ścisłej współpracy z kodowaniem na żywo. Co powinienem zrobić? Jeśli obecnie programujesz wyłącznie solo, być może powinieneś spróbować programowania parami. Poświęć na to co najmniej dwa tygodnie — na początek w sesjach po kilka godzin — ponieważ początkowo będziesz czuć się dziwnie. W celu przeprowadzenia burzy mózgów dla nowych pomysłów lub zdiagnozowania kłopotliwych kwestii, spróbuj zastosować sesję programowania mob. 4337ebf6db5c7cc89e4173803ef3875a 4 Istota zwinności 297 Jeśli już praktykujesz programowanie w parach lub programowanie mob, zastanów się, z kim to robisz. Czy są to tylko programiści, czy też grupę tworzą członkowie rozszerzonego zespołu — użytkownicy, testerzy, sponsorzy? Podobnie jak w przypadku każdego rodzaju współpracy, trzeba uwzględnić zarówno aspekt ludzki, jaki i techniczny. Oto kilka wskazówek na początek: Buduj kod, a nie swoje ego. Nie chodzi tu o to, kto jest najbystrzejszy; wszyscy mamy chwile dobre i złe. Zacznij od pracy w niewielkiej grupie. Grupy mob powinny mieć 4 – 5 osób, możesz też na początek wyznaczyć tylko kilka par pracujących w krótkich sesjach. Krytykuj kod, a nie osoby. Uwaga „Spójrz na ten blok kodu” brzmi znacznie lepiej niż „Mylisz się”. Słuchaj i staraj się zrozumieć punkt widzenia innych. Różny nie znaczy zły. Przeprowadzaj częste retrospektywy, aby dążyć do ciągłej poprawy. Kodowanie we wspólnym biurze lub zdalnie, samotnie, w parach lub w grupach mob, to skuteczne sposoby współpracy w celu rozwiązywania problemów. Jeśli Ty i Twój zespół stosowaliście tylko jeden z tych sposobów, spróbujcie poeksperymentować z innym stylem. Nie należy jednak korzystać z naiwnego podejścia: dla każdego stylu programowania istnieją zasady, sugestie i wytyczne. Na przykład w przypadku programowania mob operator klawiatury powinien zmieniać się co 5 – 10 minut. Czytaj literaturę i studiuj — korzystaj zarówno z podręczników, jak i z raportów ze zdobytych doświadczeń. Staraj się wyszukiwać zalety i eliminować możliwe pułapki. Możesz zacząć od zakodowania prostego zadania zamiast podejmowania od razu próby rozwiązywania w ten sposób najtrudniejszych problemów związanych z tworzeniem kodu produkcyjnego. Niezależnie jednak od przyjętego sposobu, warto posłuchać następującej rady: WSKAZÓWKA NR 82 Nie staraj się tworzyć kodu w samotności. 48 39 Istota zwinności Ciągle używasz tego słowa. Nie sądzę, że znaczy ono to, co myślisz, że znaczy. Inigo Montoya, Narzeczona dla księcia 4337ebf6db5c7cc89e4173803ef3875a 4 298 Rozdział 8. Przed projektem Słowo zwinny (ang. agile) jest przymiotnikiem: określa sposób, w jaki coś robimy. Możesz być zwinnym programistą. Możesz być członkiem zespołu, który stosuje zwinne praktyki, zespołu, który reaguje na zmiany i błędy za pomocą zwinnego podejścia. Zwinność to Twój styl, a nie Ty. WSKAZÓWKA NR 83 Zwinny nie jest rzeczownikiem. Zwinny to sposób, w jaki rozwiązujesz problemy. Te słowa piszemy prawie 20 lat po ogłoszeniu Manifestu Agile4. Przez te lata spotykamy wielu programistów, którzy z powodzeniem stosują opisywane w nim wartości. Widzieliśmy wiele fantastycznych zespołów, które znalazły sposób, aby przyjąć te wartości i stosować je jako wytyczne do kierowania tym, co robią i jak zmieniają to, co robią. Ale widzimy też inną stronę zwinności. Spotykaliśmy zespoły i firmy skłonne do stosowania rozwiązań „z półki” Agile-in-a-Box. Spotykaliśmy także wielu konsultantów i liczne firmy; ci pierwsi pomyślnie sprzedawali drugim to, czego te firmy chciały. Spotykaliśmy przedsiębiorstwa stosujące więcej warstw zarządzania, bardziej formalne raportowanie, zatrudniające bardziej wyspecjalizowanych programistów i posiadające więcej fantazyjnych nazw stanowisk, które oznaczały po prostu „osoby z notatnikiem i stoperem”5. Uważamy, że wiele osób utraciło prawdziwe znaczenie słowa „zwinność” i chcielibyśmy spowodować powrót do podstaw. Przypomnijmy wartości z manifestu: Odkrywamy lepsze sposoby tworzenia oprogramowania, wykonując je i pomagając to robić innym. Dzięki tej pracy odkryliśmy następujące wartości: Ludzie i interakcje ponad procesy i narzędzia. Działające oprogramowanie ponad kompleksową dokumentację. Współpraca z klientami ponad negocjacje kontraktów. Reagowanie na zmiany ponad podążanie za planem. Choć zdajemy sobie sprawę, że istnieje wartość w elementach wymienionych po prawej stronie, bardziej cenimy sobie pozycje wymienione po stronie lewej. Każdy, kto sprzedaje Wam coś, co zwiększa ważność rzeczy po prawej stronie ponad rzeczy po stronie lewej, wyraźnie nie ceni tych samych elementów, które cenimy my i inni twórcy manifestu. 4 https://agilemanifesto.org 5 Więcej informacji o tym, jak złe może być to podejście, można znaleźć w książce The Tyranny of Metrics [Mul18]. 4337ebf6db5c7cc89e4173803ef3875a 4 Istota zwinności 299 A każdy, kto sprzedaje Wam „rozwiązanie w pudełku”, wyraźnie nie przeczytał wstępnego zdania. Wartości są motywowane i inspirowane przez ciągły akt odkrywania lepszych sposobów wytwarzania oprogramowania. Ten dokument nie jest statyczny. Są to raczej propozycje dla procesu twórczego. Nie istnieje coś takiego, jak proces Agile Gdy ktoś mówi „zrób to, a będziesz Agile”, jest w błędzie. Z definicji. To dlatego, że zwinność — zarówno w świecie fizycznym, jak i w branży rozwoju oprogramowania — polega na reagowaniu na zmiany, odpowiadaniu na niewiadome, które napotykasz po dostarczeniu produktu. Biegnąca gazela nie porusza się po linii prostej. Gimnastyk robi setki korekt na sekundę, ponieważ reaguje na zmiany w swoim otoczeniu i drobne błędy w ułożeniu swoich stóp. Tak samo jest z zespołami i pojedynczymi programistami. Podczas tworzenia oprogramowania nie ma jednego planu, którego można by przestrzegać. Mówią o tym trzy spośród czterech wartości wymienionych powyżej. Wszystkie one dotyczą gromadzenia opinii i reagowania na nie. Wartości nie mówią Ci, co masz robić. Wskazują, na co zwracać uwagę, kiedy sam decydujesz, co zrobisz. Te decyzje są zawsze podejmowane w kontekście: zależą od tego, kim jesteś, jaki jest charakter Twojego zespołu, Twojej aplikacji, Twojego oprzyrządowania, Twojej firmy, klienta, otoczenia zewnętrznego. To niezwykle obszerna lista czynników, z których niektóre są ważne, a inne trywialne. Żaden ustalony, statyczny plan nie jest w stanie sprostać tym niepewnościom. Co powinienem robić? Nikt nie może Ci powiedzieć, co masz robić. Uważamy jednak, że możemy powiedzieć coś o duchu, w jakim powinieneś to robić. Wszystko sprowadza się do sposobu, w jaki radzisz sobie z niepewnością. Manifest sugeruje, że należy to robić poprzez gromadzenie opinii i reagowanie na nie. Oto nasza recepta na pracę w zgodzie z duchem Agile: 1. Oceń, w jakim miejscu jesteś. 2. Zrób jak najmniejszy znaczący krok w kierunku, w którym chcesz podążać. 3. Oceń dokąd dotarłeś i napraw wszystko, co po drodze zepsułeś. Powtarzaj te czynności tak długo, aż wykonasz zadanie. Wykonuj je rekurencyjnie, na każdym poziomie wszystkiego, co robisz. Czasami nawet najbardziej błaha z pozoru decyzja staje się ważna, gdy zbierzesz informacje zwrotne. 4337ebf6db5c7cc89e4173803ef3875a 4 300 Rozdział 8. Przed projektem Teraz mój kod musi uzyskać dostęp do właściciela konta. let user = accountOwner(accountID); Hmmm... user to bezużyteczna nazwa. Zastosuję nazwę owner. let owner = accountOwner(accountID); Ale teraz kod wygląda na nieco nadmiarowy. Co ja właściwie próbuję tutaj zrobić? W historyjce użytkownika jest stwierdzenie, że wysyłam e-mail do tej osoby, więc muszę znaleźć jej adres e-mail. Może wcale nie potrzebuję wszystkich informacji o właścicielu konta? let email = emailOfAccountOwner(accountID); Dzięki zastosowaniu pętli sprzężenia zwrotnego na naprawdę niskim poziomie (nazewnictwa zmiennej) w istocie poprawiliśmy projekt całego systemu — zredukowaliśmy sprzężenia pomiędzy tym kodem a kodem obsługującym konta. Pętla sprzężenia zwrotnego ma również zastosowanie na najwyższym poziomie projektu. Nasze najbardziej udane prace wykonywaliśmy wtedy, kiedy zaczynaliśmy od analizy wymagań klienta — przeanalizowaliśmy jeden krok i zdaliśmy sobie sprawę z tego, że to, co mamy zamiar zrobić, nie było konieczne, że najlepsze rozwiązanie nawet nie dotyczyło oprogramowania. Pętla sprzężenia zwrotnego wykracza poza zakres jednego projektu. Zespoły powinny ją stosować do przeglądu swoich procesów i ich skuteczności. Zespół, który nie eksperymentuje ciągle ze swoim procesem, nie jest zespołem zwinnym. To steruje projektem W temacie 8., „Istota dobrego projektu” stwierdziliśmy, że miarą jakości projektu jest łatwość wprowadzania w nim zmian: dobry projekt tworzy coś, co jest łatwiejsze do zmiany niż zły projekt. Niniejszy opis zwinności wyjaśnia, dlaczego tak jest. Wprowadzasz zmianę i odkrywasz, że Ci się ona nie podoba. W kroku 3. na naszej liście powiedzieliśmy, że musimy być w stanie ustalić, co zepsuliśmy. Aby nasza pętla sprzężenia zwrotnego była wydajna, ta poprawka musi być jak najbardziej bezbolesna. Jeśli taka nie będzie, to będziemy podatni na pokusę, aby jej nie wprowadzać i pozostawić błąd bez naprawy. Mówiliśmy o tym efekcie w temacie 3., „Entropia oprogramowania”. Aby cała zwinność miała sens, musimy tworzyć dobre projekty, bo w dobrym projekcie można łatwo wprowadzać zmiany. A jeśli projekt jest łatwy do zmiany, bez wahania możemy go dostosować na każdym jego etapie. Na tym polega zwinność. 4337ebf6db5c7cc89e4173803ef3875a 4 Istota zwinności 301 Pokrewne podrozdziały Temat 27., „Nie prześcigaj swoich świateł”. Temat 40., „Refaktoryzacja”. Temat 50., „Nie próbuj przecinać kokosów”. Wyzwania Prosta pętla sprzężenia zwrotnego nie dotyczy wyłącznie oprogramowania. Pomyśl o innych decyzjach, które niedawno podjąłeś. Czy dowolną z nich mógłbyś poprawić dzięki pomyśleniu o tym, w jaki sposób można by cofnąć, to co zrobiłeś, gdyby okazało się, że posunąłeś się w niewłaściwym kierunku? Czy potrafisz znaleźć sposoby poprawy tego, co robisz, poprzez gromadzenie opinii i reagowanie na nie? 4337ebf6db5c7cc89e4173803ef3875a 4 302 Rozdział 8. Przed projektem 4337ebf6db5c7cc89e4173803ef3875a 4 Rozdział 9. Pragmatyczne projekty W momencie przystąpienia do projektu musimy zapomnieć o problemach związanych z filozofią pracy poszczególnych członków zespołu i sposobem kodowania na rzecz kwestii dotyczących całego przedsięwzięcia. Nie chcemy wchodzić w szczegóły zarządzania projektami — ograniczymy się do prezentacji kilku krytycznych obszarów, które mogą zdecydować o powodzeniu bądź porażce projektu. Jeśli tylko w realizację projektu jest zaangażowanych więcej niż jedna osoba, musimy uzgodnić podstawowe reguły współpracy i właściwie podzielić odpowiedzialność za poszczególne aspekty projektu. W podrozdziale „Pragmatyczne zespoły” pokażemy, jak osiągnąć te cele, zachowując pragmatyczną filozofię. Celem metodologii wytwarzania oprogramowania jest wspomaganie pracy zespołowej. Czy Ty i Twój zespół robicie to, co się dla Was dobrze sprawdza, czy też inwestujecie jedynie w trywialne, płytkie artefakty i nie uzyskujecie rzeczywistych korzyści, na które zasługujecie? Przekonamy się, dlaczego nie warto przecinać kokosów i zdradzimy prawdziwy sekret sukcesu. Oczywiście nic, co powiedzieliśmy, nie byłoby istotne, gdybyśmy nie potrafili dostarczyć oprogramowania w sposób konsekwentny i rzetelny. To podstawa magicznego trio kontroli wersji, testowania i automatyzacji: startera dla pragmatycznego programisty. Sukces jest rzeczą względną — zależy od oceny sponsora projektu. Liczy się przede wszystkim odbiór sukcesu; w podrozdziale „Wpraw w zachwyt użytkowników” zaproponujemy sposoby wprawiania w zachwyt sponsorów projektu. Ostatnia wskazówka zawarta w tej książce będzie wprost wynikała z wcześniejszych zaleceń. W podrozdziale „Duma i uprzedzenie” spróbujemy zachęcić czytelnika do brania odpowiedzialności za swoją pracę i do odczuwania dumy ze swoich osiągnięć. 4337ebf6db5c7cc89e4173803ef3875a 4 304 49 36 Rozdział 9. Pragmatyczne projekty Pragmatyczne zespoły W grupie L Stoffel nadzoruje pracę sześciu doskonałych programistów — to dla menedżera wyzwanie porównywalne do pilnowania grupy kotów. „The Washington Post Magazine”, 9 czerwca 1985 Nawet w 1985 roku żart o pilnowaniu grupy kotów był przestarzały. W czasie, gdy powstawało pierwsze wydanie tej książki, na przełomie XX i XXI wieku, był pozytywnie starożytny. Pomimo to przetrwał, ponieważ tkwi w nim ziarno prawdy. Programiści trochę przypominają koty: inteligentni, mający silną wolę, uparci, niezależni i często czczeni przez otoczenie. Do tej pory koncentrowaliśmy się na pragmatycznych technikach, które ułatwiają poszczególnym członkom zespołu stawanie się lepszymi programistami. Czy proponowane metody sprawdzają się także w przypadku zespołów złożonych ze zmotywowanych, niezależnych osób? Odpowiedź jest oczywista: tak! Bycie pragmatycznym programistą ma, oczywiście, swoje zalety, ale wszystkie te korzyści są wielokrotnie większe, jeśli taki programista dodatkowo pracuje w pragmatycznym zespole. Zespół, naszym zdaniem, jest niewielkim, zwykle stabilnym, odrębnym podmiotem. Pięćdziesiąt osób to nie jest zespół, sto osób to jest tłum1. Zespoły, których członkowie stale realizują różne zadania i się wzajemnie nie znają, nie są zespołami. Są oni jedynie grupą osób, które tymczasowo stoją na tym samym przystanku autobusowym podczas deszczu. Pragmatyczny zespół jest niewielki. Składa się maksymalnie z 10 – 12 osób. Jego członkowie rzadko się zmieniają. Wszyscy dobrze się znają, ufają sobie i wzajemnie od siebie zależą. WSKAZÓWKA NR 84 Utrzymuj niewielkie, stabilne zespoły. W tym podrozdziale krótko omówimy, jak te pragmatyczne techniki można stosować dla zespołów jako całości. Proponowane rozwiązania to zaledwie początek. Kiedy już zbierzemy grupę pragmatycznych programistów i zapewnimy im środowisko gwarantujące odpowiedni potencjał, szybko sami zaczną rozwijać i doskonalić dynamikę zespołu w sposób najlepiej pasujący do sytuacji. Spróbujmy z nieco innej strony (w kontekście zespołów) spojrzeć na zagadnienia przedstawione we wcześniejszych rozdziałach. 1 W miarę wzrostu liczby członków zespołu liczba ścieżek komunikacji rośnie w tempie O(n2), gdzie n oznacza liczbę członków zespołu. W przypadku większych zespołów, komunikacja staje się nieskuteczna. 4337ebf6db5c7cc89e4173803ef3875a 4 Pragmatyczne zespoły 305 Żadnych wybitych okien Jakość jest problemem całego zespołu. Nawet najlepszy programista zatrudniony w zespole, który nie wykazuje większego zainteresowania projektem, najprawdopodobniej straci entuzjazm potrzebny do badania i rozwiązywania problemów. Sytuację może dodatkowo pogarszać niechęć zespołu do pomysłów programisty, aby poświęcać czas eliminowaniu usterek. Zespoły jako całość nie powinny tolerować wybitych szyb — niewielkich, z pozoru nieważnych niedoskonałości, których zwykle nikt nie eliminuje. Zespół musi brać odpowiedzialność za jakość swojego produktu, wspierając programistów, którzy rozumieją filozofię naprawiania wybitych szyb (opisaną w podrozdziale „Entropia oprogramowania” w rozdziale 1.), i zachęcając do tego samego tych swoich członków, którzy jeszcze tego nie odkryli. Niektóre metodyki pracy zespołowej sugerują wskazanie jednego pracownika, który zajmuje się tylko jakością — to na jego barki spada cała odpowiedzialność za jakość końcowego produktu. Pomysł jest kuriozalny — warunkiem koniecznym zapewnienia właściwej jakości jest zaangażowanie wszystkich członków zespołu. Jakość powinna być wbudowana, a nie przykręcona śrubami. Ugotowane żaby W podrozdziale „Zupa z kamieni i gotowane żaby” w rozdziale 1. wspomnieliśmy o marnym losie żaby wrzuconej do garnka z wodą. Żaba nie zauważa stopniowej zmiany w swoim środowisku, co ostatecznie prowadzi do jej ugotowania. To samo dotyczy programistów, którzy nie wykazują dostatecznej czujności. W gorącej atmosferze towarzyszącej projektowi często trudno obserwować całe środowisko. Okazuje się, że ryzyko „ugotowania” całego zespołu jest jeszcze większe. Ludzie odruchowo przyjmują, że kto inny zajmie się problemem lub że lider zespołu zapewne zatwierdził zmianę, której domaga się jakiś użytkownik. Nawet najlepsze intencje członków zespołu mogą nie wystarczyć do odkrywania poważnych zmian w projektach. Musimy z tym walczyć. Musimy upewnić się, że każdy aktywnie monitoruje środowisko pod kątem ewentualnych zmian. Należy zachować czujność i być wyczulonym na rozszerzanie się zakresu prac, skracanie terminów, dodatkowe funkcje, nowe środowiska — słowem, wszystko, o czym nie było mowy w początkowych ustaleniach. Należy też mierzyć nowe wymagania2. Zespół nie musi odruchowo odrzucać wszystkich zmian — musimy jednak mieć świadomość tego, że te zmiany mają miejsce. W przeciwnym razie szybko odkryjemy, że jesteśmy w ukropie. 2 Do tego celu lepszy jest wykres burnup niż zwykły wykres burndown. Na podstawie wykresu burnup można wyraźnie zobaczyć, jak wprowadzanie dodatkowych funkcji zbliża nas do celu. 4337ebf6db5c7cc89e4173803ef3875a 4 306 Rozdział 9. Pragmatyczne projekty Zaplanuj portfolio swojej wiedzy W temacie 6., „Portfolio wiedzy”, przyjrzeliśmy się sposobom inwestowania w swój osobisty zasób wiedzy w odpowiednim czasie. Zespoły, które chcą odnieść sukces, powinny rozważać swoje inwestycje w wiedzę i umiejętności. Jeśli Twój zespół podchodzi poważnie do doskonalenia i innowacji, powinien je zaplanować. Próba realizacji tych inwestycji „w wolnej chwili” oznacza, że one nigdy nie będą zrealizowane. Niezależnie od rodzaju prowadzonego dziennika zadań do wykonania, nie rezerwuj w nim czasu wyłącznie na zadania programistyczne. Zespół nie powinien pracować wyłącznie nad nowymi funkcjonalnościami. Oto niektóre z możliwych przykładów innych zadań: Konserwacja starych systemów. Choć kochamy pracować nad „błyszczącym”, nowym systemem, istnieje prawdopodobieństwo, że trzeba będzie przeprowadzić pewne prace konserwacyjne w starym systemie. Spotykaliśmy zespoły, które próbowały zamieść te prace do kąta. Jeśli zespół jest odpowiedzialny za wykonywanie tych zadań, powinien je wykonywać naprawdę. Refleksje na temat procesu i jego usprawniania. Ciągła poprawa może następować tylko wtedy, gdy poświęcimy trochę czasu, aby się rozejrzeć i ustalić co działa, a co nie, a następnie dokonać zmian (patrz temat 48., „Istota zwinności”). Zbyt wiele zespołów jest tak zajętych wylewaniem wody z łodzi, że nikt nie poświęca czasu, aby naprawić źródło wycieku. Zaplanuj to. Potem napraw. Eksperymentowanie z nowymi technologiami. Nie korzystaj z nowych technologii, frameworków lub bibliotek tylko dlatego, że „wszyscy to robią” albo na podstawie czegoś, co obejrzałeś podczas konferencji lub przeczytałeś w internecie. Technologie kandydujące sprawdzaj w sposób przemyślany, poprzez tworzenie prototypów. Zaplanuj zadania wypróbowywania nowych rzeczy w harmonogramie i analizuj wyniki przeprowadzonych prób. Uczenie się i usprawnianie umiejętności. Osobiste uczenie się i doskonalenie to dobry początek, ale wiele umiejętności zdobywamy skuteczniej, gdy robimy to wraz z zespołem. Zaplanuj takie przedsięwzięcia — w formie nieformalnych sesji „brown bag”, czy też bardziej formalnych szkoleń. WSKAZÓWKA NR 85 Aby coś się wydarzyło, zaplanuj to. Komunikacja To dość oczywiste, że programiści pracujący w zespole muszą ze sobą rozmawiać. Pewne sugestie, jak poprawiać komunikację w ramach zespołu, można znaleźć w temacie 7., „Komunikuj się!”. Bardzo łatwo zapomnieć o tym, że 4337ebf6db5c7cc89e4173803ef3875a 4 Pragmatyczne zespoły 307 sam zespół funkcjonuje w ramach większej organizacji. Zespół jako jedna całość musi możliwie efektywnie komunikować się z resztą świata. Z perspektywy osób z zewnątrz najgorsze zespoły projektowe to takie, które sprawiają wrażenie nadętych i małomównych. Organizują spotkania pozbawione jakiegokolwiek planu. Co więcej, nikt na tych spotkaniach nie chce zabierać głosu. Tworzone przez nich dokumenty to prawdziwy koszmar: nigdy nie wyglądają tak samo, a w każdym stosuje się odmienną terminologię. Najlepsze zespoły projektowe mają wyróżniającą się osobowość. Ludzie wprost nie mogą doczekać się spotkań z tymi zespołami, ponieważ wiedzą, że mogą oczekiwać doskonale przygotowanego pokazu, który wszystkim poprawi nastrój. Tworzone przez nich dokumenty są zwięzłe, precyzyjne i spójne. Cały zespół mówi jednym głosem3. Takie zespoły nierzadko mają też świetne poczucie humoru. Istnieje prosta sztuczka marketingowa, która ułatwia zespołom komunikację — należy stworzyć markę. Przy okazji początku prac nad projektem warto wymyślić dla niego jakąś nazwę, najlepiej coś zaskakującego. (W przeszłości nazywaliśmy swoje projekty, czerpiąc inspiracje z tego, co powie gadająca papuga, z iluzji optycznych czy z nazw mitycznych miast). Warto też poświęcić pół godziny na stworzenie jakiegoś zabawnego logo, które będzie potem powielane na notatkach i raportach. Nazwę zespołu można następnie swobodnie wykorzystywać podczas rozmów z innymi. Proponowane rozwiązania mogą wydawać się niepoważne, jednak pozwalają zbudować tożsamość zespołu, a reszcie świata zapewniają coś, co można łatwo zapamiętać i w przyszłości kojarzyć z naszą pracą. Nie powtarzaj się W temacie 9., „DRY — przekleństwo powielania”, omówiliśmy rozmaite utrudnienia związane z powielaniem pracy przez członków tego samego zespołu. Powielanie tych samych czynności jest nie tylko stratą czasu, ale też znacznie utrudnia konserwację. W takich zespołach często dochodzi do powstawania systemów typu „silos”; kod jest współdzielony w niewielkim stopniu oraz występuje mnóstwo zdublowanych funkcjonalności. Kluczem do uniknięcia tych problemów jest dobra komunikacja. Przez słowo „dobra” mamy na myśli komunikację natychmiastową i bezproblemową. Powinieneś być w stanie zadać pytanie innym członkom zespołu i uzyskać niemal natychmiastową odpowiedź. Jeśli zespół pracuje w jednej lokalizacji, taka komunikacja może sprowadzać się do podejścia do biurka współpracownika w tym samym pokoju lub po drugiej stronie korytarza. Zdalne zespoły mogą korzystać z komunikatorów lub innych środków elektronicznych. 3 Zespół mówi jednym głosem tylko na zewnątrz. Gorąco zachęcamy do prowadzenia ożywionych sporów wewnątrz zespołu. Dobrzy programiści zwykle wykazują zaangażowanie emocjonalne w swoją pracę. 4337ebf6db5c7cc89e4173803ef3875a 4 308 Rozdział 9. Pragmatyczne projekty Jeśli musisz czekać tydzień do spotkania zespołu, aby na nim zadać swoje pytanie lub podzielić się swoim statusem, komunikacja jest znacznie utrudniona4. Bezproblemowa komunikacja oznacza, że zadawanie pytań jest łatwe i nie wymaga „ceremonii”. Możesz bez trudu podzielić się postępami swojej pracy, przekazać spostrzeżenia i odkrycia oraz zdobyć informacje o tym, co robią Twoi współpracownicy. Aby zachować zasadę DRY, bądź świadom, co robią inni. Pociski smugowe zespołu Zespół projektowy musi wykonywać wiele różnych zadań w różnych obszarach projektu, z wykorzystaniem wielu różnych technologii. Trzeba zrozumieć wymagania, zaprojektować architekturę, zająć się kodowaniem warstwy frontend i serwera, testowaniem — wszystko to musi się odbywać. Istnieje jednak błędne przekonanie, że te działania i zadania mogą być realizowane osobno, w izolacji od innych. Otóż nie mogą. W niektórych metodologiach wyznacza się w zespole różnego rodzaju role i stanowiska o specjalnych nazwach lub tworzy oddzielne wyspecjalizowane zespoły. Problem z tym podejściem polega jednak na tym, że wprowadza ono bariery między członkami zespołu i konieczność przekazywania pracy. Teraz zamiast sprawnego przepływu pracy od zespołu do wdrożenia, mamy sztuczne bariery, w których praca się zatrzymuje. Podczas przekazywania pracy pojawiają się opóźnienia. Oczekiwania na zatwierdzenia. Papierkowa robota. Zwolennicy metodologii Lean nazywają to marnotrawstwem i starają się aktywnie eliminować tego rodzaju zjawiska. Wszystkie te różnego rodzaju stanowiska i działania, to w rzeczywistości różne spojrzenia na ten sam problem. Sztuczne ich rozdzielanie może powodować wiele kłopotów. Na przykład w przypadku programistów, którzy są oddzieleni od rzeczywistych użytkowników i używanego przez nich kodu o dwa lub trzy poziomy, istnieje małe prawdopodobieństwo świadomości kontekstu, w jakim jest wykorzystywana ich praca. Takie osoby mogą być niezdolne do podejmowania świadomych decyzji. W temacie „Pociski smugowe” zalecaliśmy rozwijanie pojedynczych funkcjonalności, początkowo niewielkich i ograniczonych, które obejmują zakres „od końca do końca” przez cały system. Oznacza to, że członek zespołu powinien posiadać wszystkie umiejętności potrzebne do wykonywania prac: tworzenie frontendu, interfejsu użytkownika, dbanie o ergonomiczność interfejsu (ang. user experience — UX), tworzenie warstwy serwerowej, administracja bazą danych, zapewnienie jakości itd. Każdy członek zespołu powinien umieć swobodnie wykonywać te zadania, we współpracy z innymi. Dzięki stosowaniu 4 Andy spotkał się z zespołem, który przeprowadzał spotkania stand-up — które powinny odbywać się codziennie — w piątki. 4337ebf6db5c7cc89e4173803ef3875a 4 Pragmatyczne zespoły 309 podejścia pocisków smugowych, można bardzo szybko wdrożyć bardzo niewielkie fragmenty funkcjonalności i natychmiast uzyskać informacje zwrotne o tym, jak dobrze członkowie zespołu komunikują się ze sobą i dostarczają rozwiązania. W ten sposób tworzy się środowisko, w którym można szybko i łatwo wprowadzać zmiany oraz dostrajać zespół i proces. WSKAZÓWKA NR 86 Organizuj w pełni funkcjonalne zespoły. Buduj zespoły w taki sposób, aby mogły budować kod „od końca do końca”, stopniowo i iteracyjnie. Automatyzacja Doskonałym sposobem jednoczesnego zapewniania spójności i dokładności jest automatyzacja możliwie wielu czynności zespołu. Po co ręcznie dbać o układ kodu, skoro nasz edytor może to robić automatycznie w trakcie pisania? Po co wypełniać formularze z testów, skoro nocna kompilacja może obejmować automatyczne wykonanie testów? Po co wdrażać oprogramowanie ręcznie, jeśli dzięki automatyzacji można zrealizować wdrażanie za każdym razem w taki sam sposób i równie niezawodnie? Automatyzacja jest ważnym aspektem funkcjonowania każdego zespołu projektowego. Należy zadbać o to, aby zespół posiadał umiejętności tworzenia i wdrażania narzędzi automatyzujących operacje związane z programowaniem i wdrażaniem. Należy wiedzieć, kiedy przestać dodawać nowe warstwy farby Musimy pamiętać, że zespoły składają się z indywidualności. Każdy członek zespołu powinien mieć możliwość prezentacji umiejętności na własny sposób. Należy zadbać tylko o taką strukturę, która zapewni im niezbędne wsparcie, oraz o to, aby wnosili do projektu wartość. W pewnym momencie, tak jak malarz z tematu „Wystarczająco dobre oprogramowanie”, nie możemy ulegać pokusie dodawania farby. Pokrewne podrozdziały Temat 2., „Kot zjadł mój kod źródłowy”. Temat 7., „Komunikuj się!”. Temat 12., „Pociski smugowe”. 4337ebf6db5c7cc89e4173803ef3875a 4 310 Rozdział 9. Pragmatyczne projekty Temat 19., „Kontrola kodu źródłowego”. Temat 50., „Nie próbuj przecinać kokosów”. Temat 51., „Zestaw startowy pragmatyka”. Wyzwania 50 37 Przeanalizuj sposób działania zespołów osiągających sukcesy spoza świata wytwarzania oprogramowania. Co decyduje o ich sukcesach? Czy stosują któryś z procesów omówionych w tym podrozdziale? Przy okazji rozpoczynania kolejnego projektu spróbuj przekonać współpracowników do nadania mu marki. Daj swojej organizacji trochę czasu na przyzwyczajenie się do tej koncepcji, po czym dokonaj krótkiego przeglądu uzyskanych efektów (zarówno w ramach zespołu, jak i poza nim). Algebra zespołowa — w szkole często rozwiązywaliśmy podobne problemy: „Jeśli 4 robotników potrzebuje 6 godzin na wykopanie rowu, ile czasu ta sama czynność zajmie 8 robotnikom?”. Jakie dodatkowe czynniki wpływają na wynik w przypadku rzeczywistych projektów? Ile zajmie stworzenie aplikacji przez 8 programistów, jeśli 4 programiści mogą opracować tę samą aplikację w 6 miesięcy? W ilu scenariuszach rzeczywiście można skrócić ten czas? Przeczytaj książkę Fredericka Brooksa The Mythical Man Month [Bro96]. Aby uzyskać dodatkowe korzyści, kup dwa egzemplarze; dzięki temu cały zespół będzie mógł zapoznać się z nią dwa razy szybciej. Nie próbuj przecinać kokosów Rdzenni mieszkańcy wyspy nigdy nie widzieli wcześniej samolotu ani nie spotykali ludzi podobnych do tych, którzy nim przylecieli. W zamian za korzystanie z ziemi, obcy udostępnili mechaniczne ptaki, które latały na wyspę i do świata zewnętrznego, przywożąc na wyspę niesamowite bogactwa materialne. Obcy wspominali coś o wojnie i walkach w ich kraju. Pewnego dnia wojna się skończyła i wszyscy obcy opuścili wyspę, zabierając ze sobą ich dziwne bogactwa. Wyspiarze usilnie starali się odtworzyć swoje fortuny. Przy użyciu lokalnych materiałów: winorośli, łupin orzechów kokosowych, liści palmowych itp. zbudowali lotnisko, wieżę kontrolną oraz urządzenia. Ale z jakiegoś powodu, choć wszystko było na miejscu, samoloty nie przylatywały. Udało im się naśladować formę, ale nie treść. Antropolodzy nazywają to kultem cargo. Zbyt często zachowujemy się tak jak wyspiarze. Łatwo można wpaść w pułapkę kultu cargo: poprzez inwestycję w budowę dobrze widocznych artefaktów mamy nadzieję przyciągnąć działającą magię. 4337ebf6db5c7cc89e4173803ef3875a 4 Nie próbuj przecinać kokosów 311 Jednak, podobnie jak w przypadku pierwotnych kultów cargo w Melanezji5, sztuczne lotnisko wykonane z łupin orzechów kokosowych nie zastąpi prawdziwego. Na przykład spotykaliśmy zespoły, które twierdzą, że korzystają z metodologii Scrum. Jednak po bliższym przyjrzeniu się ich pracy okazało się, że codzienne spotkania stand-up są organizowane raz w tygodniu, iteracje zwykle trwają cztery tygodnie, a często przeciągają się do sześciu lub ośmiu tygodni. Członkowie zespołu uważali, że to jest w porządku, ponieważ stosowali popularne „zwinne” narzędzia planowania. W rzeczywistości inwestowali jedynie w powierzchowne artefakty, które często tylko z nazwy przypominały te prawdziwe techniki, tak jakby „stand-up” lub „iteracja” były jakimiś magicznymi zaklęciami dla przesądnych. Nic dziwnego, że im również nie udało się przywołać prawdziwej magii. Kontekst ma znaczenie Czy Ty lub Twój zespół wpadliście w taką pułapkę? Zadaj sobie pytanie, dlaczego korzystasz z tej konkretnej metodologii programowania. Albo z tego frameworka? Lub z tej techniki testowania? Czy ona rzeczywiście dobrze nadaje się do wykonywanych prac? Czy dobrze się sprawdza w Twoim przypadku? A może została zastosowana tylko dlatego, że była używana w projekcie znanym z internetu i przedstawiona jako najnowsza historia sukcesu? Obecnie panuje trend do przyjmowania strategii i procedur stosowanych przez firmy, które odniosły sukces, takie jak Spotify, Netflix, Stripe, GitLab i inne. Każda z tych firm stosuje własne, unikatowe podejście do rozwoju oprogramowania oraz sposobu zarządzania. Weźmy jednak pod uwagę kontekst: czy jesteś na tym samym rynku, z tymi samymi ograniczeniami i możliwościami, doświadczeniem i rozmiarem organizacji? Czy masz podobny styl zarządzania oraz podobną kulturę? Podobną bazę użytkowników i wymagania? Nie daj się nabrać. Konkretne artefakty, powierzchowne struktury, zasady, procesy i metody nie wystarczą. WSKAZÓWKA NR 87 Rób to, co się sprawdza, a nie to, co jest modne. Skąd wiadomo, że „coś się sprawdza”? Polegaj na najbardziej podstawowej pragmatycznej technice: Wypróbuj to. 5 Zobacz https://pl.wikipedia.org/wiki/Kulty_cargo 4337ebf6db5c7cc89e4173803ef3875a 4 312 Rozdział 9. Pragmatyczne projekty Stwórzcie z małym zespołem lub zbiorem zespołów rozwiązanie pilotażowe. Zachowaj dobre fragmenty, które wydają się dobrze działać, i odrzuć resztę jak odpady lub niepotrzebny balast. Nikt nie będzie degradować swojej organizacji tylko dlatego, że działa odmiennie od Spotify czy Netflix. Nawet te firmy w czasie, kiedy się rozwijały, nie postępowały zgodnie ze swoimi bieżącymi procesami. A za kilka lat, gdy dojrzeją i nadal się będą rozwijać, także będą robić coś innego. Właśnie w tym tkwi sekret ich sukcesu. Rozmiar uniwersalny nikomu dobrze nie pasuje Celem metodologii wytwarzania oprogramowania jest wspomaganie pracy zespołowej. Zgodnie z tym, o czym mówiliśmy w podrozdziale „Istota zwinności”, podczas tworzenia oprogramowania nie ma jednego planu, zgodnie z którym można by postępować — zwłaszcza jeśli to jest plan opracowany przez kogoś innego, w jakiejś innej firmie. Wiele programów certyfikacji działa jeszcze gorzej: są one przyznawane studentom, którzy potrafią zapamiętać reguły i ich przestrzegać. Nie tego jednak chcemy. Potrzebna nam jest zdolność postrzegania wykraczająca poza obowiązujące reguły i wykorzystywania możliwości uzyskania korzyści. To zupełnie inny sposób myślenia od „ale zgodnie z metodyką Scrum, Lean, Kanban, XP, Agile robi się to w ten sposób…” i tak dalej. Zamiast tego, powinniśmy wybrać najlepsze fragmenty z konkretnej metodologii i dostosować je do praktycznego wykorzystania. Żaden rozmiar nie pasuje wszystkim, a współczesne metody są dalekie od kompletnych, więc należy korzystać z większej liczby technik niż tylko te, które oferuje jedna popularna metodologia. Na przykład Scrum definiuje pewne praktyki zarządzania projektami, ale sam nie dostarcza wystarczająco dużo wskazówek na poziomie technicznym dla zespołów lub na poziomie zarządzania portfolio dla kierownictwa. Od czego więc należy zacząć? Być jak Oni! Często słyszymy, jak liderzy rozwoju oprogramowania mówią do swoich pracowników: „Powinniśmy działać jak Netflix” (lub jedna z innych czołowych firm). Oczywiście można to robić. Najpierw jednak trzeba dysponować kilku setkami tysięcy serwerów i mieć dziesiątki milionów użytkowników… 4337ebf6db5c7cc89e4173803ef3875a 4 Nie próbuj przecinać kokosów 313 Prawdziwy cel Cel oczywiście nie polega na „postępowaniu zgodnie ze Scrum”, „Agile”, „Lean”, lub co tam sobie wymyślimy. Celem jest wypracowanie metodologii pozwalającej na błyskawiczne dostarczanie działającego oprogramowania, które daje użytkownikom kilka nowych możliwości. Nie za wiele tygodni, miesięcy lub lat od teraz, ale natychmiast. Dla wielu zespołów i organizacji ciągłe dostawy brzmią jak wzniosły, nieosiągalny cel, zwłaszcza jeśli jesteśmy ograniczeni przez proces, który wymaga dostaw co kilka miesięcy lub tygodni. Ale tak jak w przypadku dążenia do każdego celu, kluczem jest podążanie we właściwym kierunku. Jeśli dostarczacie oprogramowanie w skali lat, spróbujcie skrócić cykl do kilku miesięcy. Kilka miesięcy spróbujcie zamienić na kilka tygodni. Ze sprintu trwającego cztery tygodnie spróbujcie przejść na dwutygodniowy. Z dwutygodniowego sprintu przełączcie się na jednotygodniowy. Następnie postarajcie się dostarczać oprogramowanie codziennie. W końcu dostarczajcie je na żądanie. Należy zauważyć, że zdolność dostarczania oprogramowania na żądanie nie oznacza, że jesteśmy zmuszeni do dostarczania go w każdej minucie każdego dnia. Dostarczacie je, gdy użytkownicy go potrzebują — wtedy, gdy ma to sens biznesowy. WSKAZÓWKA NR 88 Dostarczaj oprogramowanie wtedy, gdy użytkownicy go potrzebują. Aby zastosować taki styl ciągłego rozwoju, potrzeba solidnej infrastruktury. Omówimy ją w następnym temacie, „Zestaw startowy pragmatyka”. Oprogramowanie należy rozwijać na głównym pniu systemu kontroli wersji. Nie powinno się robić tego za pomocą gałęzi. Należy także korzystać z takich technik, jak przełączniki funkcji, aby selektywnie dostarczać testowe funkcjonalności użytkownikom. 4337ebf6db5c7cc89e4173803ef3875a 4 314 Rozdział 9. Pragmatyczne projekty Gdy mamy odpowiednią infrastrukturę, musimy zdecydować, jak zorganizować pracę. Początkujący mogą zacząć od wykorzystania do zarządzania projektami metodologii Scrum wzbogaconej technicznymi praktykami programowania ekstremalnego (XP). Bardziej zdyscyplinowane i doświadczone zespoły mogą skorzystać z technik Kanban i Lean, zarówno na poziomie zespołu, jak i na wyższych poziomach zarządzania. Nie należy jednak wierzyć nam na słowo. Powinieneś spróbować tych podejść i sprawdzić, jak działają w Twoich realiach. Trzeba jednak zachować ostrożność i uważać, żeby nie przesadzić. Nadmierne inwestowanie w konkretną metodologię może sprawić, że staniemy się ślepi na dostępne alternatywy. Łatwo jest się przyzwyczaić do jednej metody. Wkrótce będzie Ci trudno sobie wyobrazić, że można postępować w jakikolwiek inny sposób. Staniesz się zwapniały i stracisz zdolność szybkiego przystosowania się do nowych warunków. Równie dobrze mógłbyś używać łupin z kokosów. Pokrewne podrozdziały 51 38 Temat 12., „Pociski smugowe”. Temat 27., „Nie prześcigaj swoich świateł”. Temat 48., „Istota zwinności”. Temat 49., „Pragmatyczne zespoły”. Temat 51., „Zestaw startowy pragmatyka”. Zestaw startowy pragmatyka Postęp cywilizacji odbywa się poprzez rozszerzanie zbioru ważnych czynności, które możemy wykonywać bez myślenia. Alfred North Whitehead W czasach, gdy samochody były nowością, instrukcja do uruchomienia Forda modelu T miała ponad dwie strony. W nowoczesnych samochodach wystarczy wcisnąć przycisk — procedura startu jest automatyczna i niezawodna. Osoba wykonująca instrukcje z listy może zalać silnik, ale nie zrobi tego automatyczny starter. Chociaż branża wytwarzania oprogramowania jest wciąż na etapie modelu T, nie możemy sobie pozwolić, aby wielokrotnie wykonywać dwustronicowe instrukcje w celu realizacji jakiegoś popularnego działania. Niezależnie od tego, czy jest to procedura budowania i publikacji, testowania, tworzenia dokumentacji projektu lub innego powtarzalnego zadania w projekcie, musi ona być automatyczna i powtarzalna na dowolnym urządzeniu, na którym ma działać oprogramowanie. 4337ebf6db5c7cc89e4173803ef3875a 4 Zestaw startowy pragmatyka 315 Ponadto chcemy zapewnić w projekcie spójność i powtarzalność. Procedury manualne pozostawiają spójność przypadkowi; powtarzalność nie jest gwarantowana, szczególnie jeśli aspekty procedury są przedmiotem interpretacji przez różne osoby. Po napisaniu pierwszego wydania Pragmatycznego programisty, chcieliśmy stworzyć więcej książek, które mogłyby pomóc zespołom w tworzeniu oprogramowania. Doszliśmy do wniosku, że powinniśmy zacząć od początku: od opisywania najbardziej podstawowych, najważniejszych elementów, których potrzebuje każdy zespół, niezależnie od metodologii, języka lub stosu technologicznego. Tak zrodził się pomysł na Zestaw startowy pragmatyka, obejmujący następujące trzy kluczowe i powiązane ze sobą tematy: Kontrola wersji. Testy regresji. Pełna automatyzacja. Są to trzy podstawy, na których stoi każdy projekt. Oto ich opis. Sterowanie projektem za pomocą systemu kontroli wersji Zgodnie z tym, co napisaliśmy w temacie „Kontrola kodu źródłowego”, w systemie kontroli wersji należy przechowywać wszystko, co jest potrzebne do zbudowania projektu. Ta koncepcja staje się jeszcze bardziej istotna w kontekście samego projektu. Po pierwsze, pozwala korzystać z ulotnych maszyn budowania. Zamiast jednej „świętej”, stojącej w kącie biura skrzypiącej maszyny, której każdy boi się dotknąć6, maszyny budowania i (lub) klastry są tworzone na żądanie jako egzemplarze w chmurze. Konfiguracja wdrażania również jest zawarta w systemie kontroli wersji, dzięki czemu publikowanie systemu w celu wdrażania do produkcji może być wykonywane automatycznie. Oto ważne spostrzeżenie: kontrola wersji napędza proces budowania i publikowania na poziomie projektu. WSKAZÓWKA NR 89 Korzystaj z systemu kontroli wersji do zarządzania budowaniem systemu, jego testowaniem i publikowaniem. Oznacza to, że budowanie, testy i wdrażanie są wyzwalane poprzez commity lub przekazania kodu (ang. push) do systemu kontroli wersji, a budowanie 6 Widzieliśmy to na własne oczy więcej razy, niż mogłoby się wydawać. 4337ebf6db5c7cc89e4173803ef3875a 4 316 Rozdział 9. Pragmatyczne projekty odbywa się w kontenerze, w chmurze obliczeniowej. Publikowanie do środowiska próbnego (ang. staging) lub produkcyjnego jest określane w systemie kontroli wersji za pomocą tagowania. Publikowanie staje się zatem znacznie mniej celebrowaną, codzienną czynnością — osiąga się w ten sposób prawdziwy system ciągłego dostarczania, niepowiązany z żadną maszyną pojedynczą maszyną budowania lub maszyną programistyczną. Bezlitosne testy Większość programistów stara się testować oprogramowanie możliwie ostrożnie. Podświadomie wiedzą oni, gdzie kod może zawierać widoczne błędy, i unikają jego słabych punktów. Pragmatyczni programiści postępują inaczej. Naszym celem jest możliwie efektywne znalezienie błędów i nie możemy pozwolić na to, aby inni znajdowali nasze błędy w przyszłości. Znajdowanie błędów przypomina trochę łowienie ryb w sieć. Używamy delikatnych sieci z drobnymi oczkami (testów jednostkowych) do łapania minogów oraz wielkich, mocnych sieci (testów integracyjnych) do łapania rekinów ludojadów. Zdarza się, że jakiejś rybie uda się uciec, zatem stale naprawiamy wszystkie znalezione dziury w sieci w nadziei na złapanie coraz większej ilości obślizgłych usterek, które wciąż pływają w basenie naszego projektu. WSKAZÓWKA NR 90 Należy testować wcześnie. Należy testować często. Należy testować automatycznie. Chcemy przystąpić do testów tak szybko, jak tylko będziemy dysponowali jakimś kodem. Wszystkie te drobne minogi mają brzydki zwyczaj błyskawicznego wzrostu do rozmiarów gigantycznych rekinów ludojadów, a łapanie rekinów z natury rzeczy jest dużo trudniejsze. Dlatego piszemy testy jednostkowe, mnóstwo testów jednostkowych. W rzeczywistości dobry projekt może obejmować więcej kodu testowego niż kodu produkcyjnego. Czas potrzebny do utworzenia tego kodu testowego jest wart niezbędnych nakładów pracy. W dłuższej perspektywie taki kod jest dużo tańszy, ponieważ daje szansę stworzenia produktu z liczbą usterek bliską zeru. Co więcej, świadomość, że nasz kod przeszedł wszystkie testy, to także pewność, że dany fragment kodu jest już gotowy. WSKAZÓWKA NR 91 Kodowanie nie jest skończone, dopóki nie zostaną wykonane wszystkie testy. 4337ebf6db5c7cc89e4173803ef3875a 4 Zestaw startowy pragmatyka 317 Automatyczny proces budowania uruchamia wszystkie dostępne testy. Ważne jest, aby dążyć do „testów na serio” — innymi słowy, środowisko testowe powinno być bardzo zbliżone do produkcyjnego. Ewentualne luki to miejsca, gdzie przenikają błędy. Proces budowania może obejmować kilka głównych rodzajów testów oprogramowania: testy jednostkowe, integracyjne, walidację i weryfikację oraz testy wydajności. Powyższa lista w żadnym razie nie jest kompletna — specjalistyczne projekty na pewno będą wymagały rozmaitych innych typów testów. Przytoczona lista jest jednak dobrym punktem wyjścia. Testy jednostkowe Test jednostkowy oprogramowania ma postać kodu sprawdzającego pewien moduł. Zagadnienia związane z tymi testami omówiliśmy już w temacie 41., „Kod łatwy do testowania”. Testy jednostkowe stanowią podstawę dla wszystkich pozostałych form testowania, którymi zajmiemy się w dalszej części tego podrozdziału. Jeśli elementy składowe nie działają prawidłowo, najprawdopodobniej nie zadziałają także w ramach większej całości. Wszystkie używane przez nas moduły muszą przejść własne testy jednostkowe, zanim będziemy mogli kontynuować pracę. Dopiero kiedy wszystkie moduły przejdą swoje indywidualne testy, jesteśmy gotowi do następnego kroku. Musimy przetestować, jak wszystkie te moduły współpracują ze sobą w ramach systemu. Testy integracyjne Testy integracyjne wykazują, czy główne podsystemy składające się na projekt prawidłowo ze sobą współpracują. Odpowiednie, dobrze przetestowane kontrakty umożliwiają łatwe i szybkie wykrywanie problemów związanych z integracją. W przeciwnym razie integracja jest jak hodowanie błędów na urodzajnej glebie. W praktyce właśnie integracja jest często źródłem największej liczby błędów w systemie. Testy integracyjne są w istocie rozszerzeniem opisanych przed chwilą testów jednostkowych, z tą różnicą, że tym razem testujemy zgodność całych podsystemów z ich kontraktami. Walidacja i weryfikacja Kiedy tylko będziemy dysponowali wykonywalnym interfejsem użytkownika lub prototypem, będziemy musieli odpowiedzieć sobie na bardzo ważne pytanie: użytkownicy powiedzieli nam, czego oczekują, ale czy rzeczywiście tego potrzebują? 4337ebf6db5c7cc89e4173803ef3875a 4 318 Rozdział 9. Pragmatyczne projekty Czy wyrażone przez nich oczekiwania odpowiadają wymaganiom funkcjonalnym systemu? To także trzeba przetestować. Nawet bezbłędny system, który odpowiada na niewłaściwe pytanie, nie ma większej wartości. Musimy mieć świadomość wzorców dostępu użytkowników końcowych i różnic dzielących te wzorce od danych testowych stosowanych przez programistów (odpowiedni przykład można znaleźć w historii o ruchach pędzlem w podrozdziale „Debugowanie” w rozdziale 3.). Testy wydajnościowe Ważnym aspektem projektu mogą być także testy wydajnościowe lub testy obciążeniowe. Warto zadać sobie pytanie, czy nasze oprogramowanie spełnia wymagania w zakresie wydajności w rzeczywistych warunkach — podczas realizacji żądań oczekiwanej liczby użytkowników, przy oczekiwanej liczbie połączeń lub podczas wykonywania planowanej liczby transakcji na sekundę. Czy system jest skalowalny? Niektóre aplikacje wymagają zastosowania wyspecjalizowanego sprzętu lub oprogramowania testowego zdolnego do realistycznej symulacji obciążenia. Testowanie testów Skoro nie możemy pisać doskonałego oprogramowania, z natury rzeczy nie możemy też opracować doskonałego oprogramowania testowego. Musimy więc testować same testy. Zbiór pakietów testowych można postrzegać jako rozbudowany system zabezpieczeń zaprojektowany z myślą o uruchamianiu syreny alarmowej w momencie wykrycia błędu. Czy można lepiej przetestować system zabezpieczeń, niż wykonując próby jego złamania? Po napisaniu testu wykrywającego konkretny błąd warto celowo doprowadzić do występowania tego błędu i upewnić się, że test rzeczywiście wykaże usterkę. Dzięki temu będziemy wiedzieli, że test wykryje interesujący nas błąd w razie jego wystąpienia w rzeczywistości. WSKAZÓWKA NR 92 Do testowania testów należy stosować techniki sabotażu. Jeśli traktujemy naprawdę poważnie problem testowania tworzonego systemu, możemy wyznaczyć członka zespołu do roli sabotażysty. Zadaniem sabotażysty będzie utworzenie odrębnej kopii drzewa kodu źródłowego, celowe wprowadzanie błędów w tej kopii i sprawdzanie, czy istniejące testy prawidłowo sygnalizują usterki. Na wysokim poziomie możemy użyć mechanizmu podobnego do 4337ebf6db5c7cc89e4173803ef3875a 4 Zestaw startowy pragmatyka 319 stosowanej w firmie Netflix techniki Chaos Monkey7, polegającej na zakłócaniu (niszczeniu) usług i testowaniu odporności aplikacji na takie sytuacje. Kiedy tworzymy testy, musimy mieć pewność, że syrena alarmowa zabrzmi wtedy, kiedy to konieczne. Gruntowne testowanie Skoro jesteśmy już pewni, że nasze testy są prawidłowe i że wykrywają popełniane przez nas błędy, jak sprawdzić, czy przetestowaliśmy naszą bazę kodu źródłowego wystarczająco dokładnie? Odpowiedź jest prosta: to nie jest i nigdy nie będzie możliwe. Okazuje się jednak, że istnieją na rynku produkty, które mogą nam w tym pomóc. Narzędzia do analizy pokrycia testami obserwują nasz kod w trakcie testów i śledzą, które wiersze kodu są, a które nie są wykonywane. Narzędzia tego typu mogą co prawda wskazywać, na ile wyczerpujące są nasze testy, ale nigdy nie osiągniemy stuprocentowego pokrycia8. Nawet jeśli w trakcie testów zostanie wykonany każdy wiersz kodu, nie możemy być pewni wyczerpania wszystkich możliwości. Ważna jest raczej liczba stanów, w których może się znaleźć nasz program. Stany nie są równoważne wierszom kodu. Przypuśćmy na przykład, że dysponujemy funkcją otrzymującą na wejściu dwie liczby całkowite, z których każda może mieć wartość od 0 do 999: int test(int a, int b) { return a / (a + b); } W teorii ta funkcja złożona z zaledwie trzech wierszy kodu ma 1 000 000 logicznych stanów, z których 999 999 działa prawidłowo, a jeden powoduje błąd (kiedy wyrażenie a + b ma wartość 0). Sama świadomość wykonania tego wiersza kodu niewiele nam mówi — powinniśmy raczej zidentyfikować wszystkie możliwe stany programu. Okazuje się, niestety, że analiza tak rozumianego pokrycia stanowi naprawdę poważny problem. Prędzej Słońce zmieni się w zimną, twardą bryłę, niż ktokolwiek rozwiąże ten problem. WSKAZÓWKA NR 93 Należy testować pokrycie stanów, nie pokrycie kodu. Testowanie na podstawie właściwości Świetną metodą pozwalającą na zbadanie sposobu, w jaki kod obsługuje nieoczekiwane stany, jest zlecenie komputerowi wygenerowania tych stanów. 7 https://netflix.github.io/chaosmonkey 8 Interesujące wnioski na temat korelacji pomiędzy pokryciem testami a liczbą defektów można znaleźć w książce Mythical Unit Test Coverage [ADSS18]. 4337ebf6db5c7cc89e4173803ef3875a 4 320 Rozdział 9. Pragmatyczne projekty Skorzystaj z technik testowania na podstawie właściwości, aby wygenerować dane testowe zgodnie z kontraktami i niezmiennikami testowanego kodu. Ten temat szczegółowo omówiliśmy w podrozdziale „Testowanie na podstawie właściwości”. Zacieśnianie pętli Na koniec chcielibyśmy zwrócić uwagę na najważniejszą cechę dobrych testów. To tak oczywiste, że niemal w każdej książce można znaleźć odpowiednie zalecenia. Okazuje się jednak, że z jakiegoś powodu większość zespołów wciąż tego nie robi. Jeśli jakiś błąd uwolni się z sieci istniejących testów, musimy dodać nowy test, tak aby od tej pory wykrywać ten błąd. WSKAZÓWKA NR 94 Każdy błąd należy znajdować tylko raz. Kiedy żywy tester znajduje jakiś błąd, powinien to być ostatni przypadek, kiedy wykrycie tego błędu wymaga zaangażowania człowieka. Zautomatyzowane testy należy tak zmodyfikować, aby od tej pory za każdym razem, bez wyjątku, sprawdzały kod pod kątem zawierania tego błędu, nawet jeśli usterka wydaje się trywialna i mimo żarliwych zapewnień programisty, jakoby podobny błąd nigdy nie miał się powtórzyć. Każdy błąd prędzej czy później wystąpi ponownie. Nie mamy czasu na samodzielne ściganie błędów, które równie dobrze mogłyby być wykrywane przez zautomatyzowane testy. Powinniśmy raczej poświęcać nasz cenny czas na pisanie nowego kodu (z nowymi błędami). Pełna automatyzacja Zgodnie z tym, co powiedzieliśmy na początku tego podrozdziału, nowoczesne podejście do wytwarzania oprogramowania bazuje na automatycznych procedurach zaimplementowanych za pomocą skryptów. Niezależnie od tego, czy korzystasz z czegoś tak prostego, jak skrypty powłoki rsync i ssh, czy też z w pełni funkcjonalnych rozwiązań, takich jak Ansible, Puppet, Chef lub Salt, po prostu nie powinieneś bazować na jakichkolwiek interwencjach ręcznych. Mieliśmy kiedyś okazję obserwować dział IT naszego klienta, gdzie wszyscy programiści używali tego samego środowiska IDE. Administrator systemu dawał każdemu programiście zbiór instrukcji, jak zainstalować dodatkowe pakiety i moduły w tym środowisku. Instrukcje składały się z wielu stron pełnych zdań poleceń „kliknij tutaj”, „przewiń tam”, „przeciągnij to”, „dwukrotnie kliknij tamto” itp. 4337ebf6db5c7cc89e4173803ef3875a 4 Zestaw startowy pragmatyka 321 Trudno się więc dziwić, że komputer każdego programisty działał nieco inaczej. Drobne różnice w zachowaniu tej aplikacji ujawniały się w sytuacjach, gdy różni programiści uruchamiali ten sam kod. Błędy występowały na jednym komputerze, ale nie występowały na pozostałych. Analiza różnic w wersjach poszczególnych komponentów zwykle prowadziła do zaskakujących wyników. WSKAZÓWKA NR 95 Nie należy stosować ręcznych procedur. Ludzie po prostu nie są tak powtarzalni jak komputery. Trudno nawet tego oczekiwać. Skrypt powłoki czy plik wsadowy za każdym razem wykona te same operacje w tej samej kolejności. A jeśli dodatkowo umieścimy ten skrypt w systemie kontroli wersji, będziemy mogli śledzić zmiany w samej procedurze (unikając problemu „to przecież działało…”). Wszystko zależy od automatyzacji. Nie da się zbudować projektu na anonimowym serwerze w chmurze, jeśli jego proces budowania nie będzie w pełni automatyczny. Nie da się przeprowadzić automatycznego wdrażania, jeśli w procesie tego wdrażania występują jakieś etapy wykonywane ręcznie. A kiedy wprowadzimy ręczne czynności („tylko dla tej jednej części…”), wybijemy bardzo dużą szybę9. Dzięki opisanym trzem podstawom: kontroli wersji, bezwzględnemu testowaniu i pełnej automatyzacji, projekt będzie miał solidne fundamenty, niezbędne do tego, by można było skoncentrować się na tym, co trudne — wprawieniu użytkowników w zachwyt. Pokrewne podrozdziały Temat 11., „Odwracalność”. Temat 12., „Pociski smugowe”. Temat 17., „Powłoki”. Temat 19., „Kontrola kodu źródłowego”. Temat 41., „Kod łatwy do testowania”. Temat 49., „Pragmatyczne zespoły”. Temat 50., „Nie próbuj przecinać kokosów”. Wyzwania 9 Czy Twoje nocne kompilacje lub kompilacje wykonywane w ramach systemu ciągłej integracji są automatyczne, ale wdrażanie do produkcji nie jest takie? Dlaczego? Czym specjalnym charakteryzuje się ten serwer? Zawsze należy pamiętać o tym, o czym pisaliśmy w temacie „Entropia oprogramowania”. 4337ebf6db5c7cc89e4173803ef3875a 4 322 52 39 Rozdział 9. Pragmatyczne projekty Czy możesz automatycznie testować swój projekt? Wiele zespołów musi odpowiedzieć, że nie. Dlaczego? Czy zdefiniowanie możliwych do zaakceptowania wyników rzeczywiście jest takie trudne? Czyż takie testy nie ułatwiłyby nam wykazania przed sponsorami projektu, że jego realizacja jest zakończona? Czy testowanie logiki aplikacji w oderwaniu od jej graficznego interfejsu użytkownika jest zbyt trudne? O czym to świadczy (w kontekście interfejsu)? Może o zbyt ścisłych sprzężeniach? Wpraw w zachwyt użytkowników Kiedy chcesz zaczarować ludzi, Twoim celem nie powinno być zarabianie na nich pieniędzy lub skłonienie do tego, by robili to, czego chcesz, ale wprawienie ich w zachwyt. Guy Kawasaki Celem programistów powinno być wprawienie użytkowników w zachwyt. Na tym polega sens naszej pracy. Nie chodzi o wydobywanie od nich danych lub liczenie ich oczu, czy też opróżnianie portfeli. Pomijając nikczemne cele, nawet ograniczenie się do dostarczenia działającego oprogramowania na czas nie wystarczy. Samo to nie wprawi użytkowników w zachwyt. Użytkownicy Twojego oprogramowania nie są szczególnie motywowani kodem. Zamiast tego mają problem biznesowy, który wymaga rozwiązania w kontekście ich celów i budżetu. Wierzą, że dzięki pracy z Twoim zespołem będą mogli go osiągnąć. Oczekiwania użytkowników nie są związane z oprogramowaniem. Nie są nawet ukryte w żadnej specyfikacji, którą Ci przekażą (ponieważ ta specyfikacja będzie niekompletna, dopóki Twój zespół nie przetworzy jej w kilku iteracjach). Zatem w jaki sposób można odkryć oczekiwania użytkowników? Należy zadać sobie proste pytanie: Skąd będziesz wiedzieć, że wszyscy odnieśli sukces za miesiąc (rok lub po upływie innego czasu) po zrealizowaniu projektu? Odpowiedź może Cię zaskoczyć. Projekt poprawy rekomendacji produktu może być właściwie oceniony pod kątem retencji klienta; projekt polegający na konsolidacji dwóch baz danych można ocenić pod kątem jakości danych albo oszczędności finansowych. Ale są to oczekiwania względem wartości biznesowych. To one naprawdę się liczą, a nie projekt oprogramowania sam w sobie. Oprogramowanie jest tylko środkiem do realizacji tych celów. 4337ebf6db5c7cc89e4173803ef3875a 4 Wpraw w zachwyt użytkowników 323 Kiedy już uda Ci się odkryć niektóre z oczekiwań dotyczących wartości związanych z projektem, możesz zacząć myśleć o tym, jak możesz dostarczyć oprogramowanie, aby spełnić te oczekiwania: Zadbaj o to, aby wszyscy członkowie zespołu dokładnie rozumieli te oczekiwania. Przy podejmowaniu decyzji pomyśl o tym, która ścieżka zbliża Cię do spełnienia tych oczekiwań. Poddaj krytycznej analizie wymagania użytkowników w świetle ich oczekiwań. W wielu projektach obserwowaliśmy sformułowane „wymagania”, które w rzeczywistości były jedynie domysłami na temat tego, co można zrobić za pomocą technologii: był to jedynie amatorski plan implementacji przebrany za dokument wymagań. Nie obawiaj się sugerowania zmiany wymagań, jeżeli potrafisz wykazać, że dzięki temu projekt zbliży się do celu. Nie przestawaj myśleć o oczekiwaniach użytkowników podczas realizacji projektu. Niejednokrotnie przekonaliśmy się, że w miarę poszerzania się naszej wiedza o dziedzinie, byliśmy w stanie udzielać bardziej trafnych sugestii na temat innych rzeczy, które można zrobić, aby rozwiązać problemy biznesowe. Jesteśmy przekonani, że programiści, którzy poznają wiele różnych aspektów organizacji, często potrafią zauważyć sposoby interakcji pomiędzy różnymi obszarami działalności przedsiębiorstwa, które nie zawsze są oczywiste dla pojedynczych działów. WSKAZÓWKA NR 96 Nie ograniczaj się do dostarczania kodu. Staraj się wprawić użytkowników w zachwyt. Jeśli chcesz zachwycić swoich klientów, ukształtuj relacje z nimi w taki sposób, abyś mógł aktywnie pomagać w rozwiązywaniu ich problemów. Pomimo, że chociaż Twoje stanowisko nosi nazwę „programista” lub „inżynier oprogramowania”, to w rzeczywistości powinno ono nazywać się „specjalista ds. rozwiązywania problemów”. Wszyscy rozwiązujemy problemy — na tym polega istota pragmatycznego programisty. Rozwiązywanie problemów to podstawa naszej działalności. Pokrewne podrozdziały Temat 12., „Pociski smugowe”. Temat 13., „Prototypy i karteczki samoprzylepne”. Temat 45., „Kopalnia wymagań”. 4337ebf6db5c7cc89e4173803ef3875a 4 324 53 40 Rozdział 9. Pragmatyczne projekty Duma i uprzedzenie Zachwycałeś nas dostatecznie długo. Jane Austen, Duma i uprzedzenie Pragmatyczni programiści nie unikają odpowiedzialności. Przyjmowanie nowych wyzwań i szerzenie wiedzy o naszych dokonaniach jest dla nas raczej źródłem satysfakcji. Jeśli odpowiadamy za jakiś projekt lub fragment kodu, wykonujemy pracę, z której możemy być naprawdę dumni. WSKAZÓWKA NR 97 Podpisuj efekty swojej pracy. Rzemieślnicy zawsze byli dumni ze swojej pracy. Także my mamy powody do dumy. Zespoły projektowe składają się jednak z ludzi, co znacznie utrudnia stosowanie tych reguł. W przypadku niektórych projektów koncepcja własności kodu może utrudniać współpracę. Ludzie mogą traktować pewne obszary systemu jako własne lub niechętnie angażować się w prace nad wspólnymi, podstawowymi rozwiązaniami. Produkt opracowany w ramach takiego projektu może przypominać zbiór odrębnych, małych parceli. Z czasem zaczynamy traktować swój kod z dużo większą wyrozumiałością niż dzieło naszych współpracowników. Nie tego chcemy. Nie powinniśmy zazdrośnie strzec swojego kodu przed intruzami; podobnie, powinniśmy traktować cudzy kod z należytym respektem. Kluczem do realizacji tych założeń jest zasada „nie czyń drugiemu, co tobie niemiłe” oraz zwykły szacunek do innych programistów. Anonimowość, szczególnie w przypadku wielkich projektów, może stanowić wyjątkowo podatny grunt dla niechlujstwa, błędów, lenistwa i złego kodu. Zbyt łatwo akceptujemy rolę drobnego trybiku większej maszyny, wymyślając żałosne wymówki i stale bez końca pracując nad bezwartościowymi raportami, zamiast tworzyć dobry kod. Kod musi co prawda mieć jakiegoś właściciela, ale nie musi to być jedna osoba. Na przykład wyjątkowo udana metodyka programowania ekstremalnego (XP) autorstwa Kenta Becka10 zaleca dzielenie odpowiedzialności za kod pomiędzy wielu autorów (taki model wymaga jednak stosowania dodatkowych praktyk, jak programowanie w parach, które dodatkowo zabezpieczają projekt przed niekorzystnymi skutkami anonimowości). Chcemy, aby własność była źródłem dumy. „Napisałem ten kod i stoję murem za efektami swojej pracy”. Nasz podpis powinien być rozpoznawany jako znak 10 http://www.extremeprogramming.org 4337ebf6db5c7cc89e4173803ef3875a 4 Duma i uprzedzenie 325 jakości. Ludzie powinni widzieć nasze nazwisko pod fragmentem kodu i traktować je jako sygnał, że mają do czynienia z solidną, dobrze napisaną, przetestowaną i udokumentowaną pracą. Naprawdę profesjonalną robotą. Dziełem prawdziwego profesjonalisty. Pragmatycznego programisty. Dziękujemy. 4337ebf6db5c7cc89e4173803ef3875a 4 W dłuższej perspektywie kształtujemy nasze życie i kształtujemy siebie. Proces nigdy się nie kończy — trwa dopóki nie umrzemy. Wybory, których dokonujemy, to nasza własna odpowiedzialność. Eleanor Roosevelt Posłowie W ciągu dwudziestu lat, które doprowadziły do powstania pierwszego wydania tej książki, byliśmy świadkami komputerowej ewolucji; w części jej inspiracją była ciekawość, a w części konieczność spełnienia potrzeb biznesowych. W ciągu dwudziestu lat od tamtego czasu oprogramowanie rozwinęło się — wykroczyło poza zakres zwykłych maszyn biurowych i rzeczywiście zawładnęło światem. Ale co to tak naprawdę oznacza dla nas? W książce Mythical Man-Month: Essays on Software Engineering [Bro96] Fred Brooks napisał: „Programista jest jak poeta, tworzy tylko wtedy, gdy jest nieznacznie odsunięty od racjonalnego świata. Buduje swoje zamki w powietrzu i z powietrza — tworzy wysiłkiem własnej wyobraźni”. Zaczynamy od pustej kartki — możemy stworzyć prawie wszystko, co można sobie wyobrazić. To, co stworzymy, może zmienić świat. Począwszy od Twittera, który pomaga ludziom planować rewolucje, poprzez pracujące w samochodach procesory, które pozwalają zapobiec poślizgom, do smartfonów, dzięki którym nie musimy już pamiętać szczegółów planu dnia — nasze programy są wszędzie. Nasza wyobraźnia jest wszędzie. Programiści to bardzo uprzywilejowana grupa. Naprawdę budujemy przyszłość. To niezwykła moc. Ale wiąże się ona z wielką odpowiedzialnością. Jak często zatrzymujemy się, żeby o tym pomyśleć? Jak często rozmawiamy — zarówno między sobą, jak i w szerszym gronie — co to oznacza? 4337ebf6db5c7cc89e4173803ef3875a 4 Posłowie 327 W urządzeniach wbudowanych stosuje się komputery w liczbie o rząd wielkości większej w porównaniu z tymi stosowanymi w laptopach, komputerach desktop i centrach danych. Te wbudowane komputery często zarządzają systemami kluczowymi dla naszego życia — od elektrowni, poprzez samochody, aż do sprzętu medycznego. Nawet prosty system sterowania centralnym ogrzewaniem lub urządzenie domowe może kogoś zabić, jeśli będzie źle zaprojektowane lub zainstalowane. Gdy opracowujesz oprogramowanie dla tych urządzeń, bierzesz na siebie wielką odpowiedzialność. Wiele niewbudowanych systemów może również zarówno czynić wielkie dobro, jak i wyrządzać wielkie szkody. Media społecznościowe mogą promować pokojową rewolucję lub propagować nienawiść. Techniki Big Data ułatwiają robienie zakupów, ale przyczyniają się także do zniszczenia każdego śladu prywatności, który — jak nam się wydaje — jeszcze nam pozostał. Systemy bankowe podejmują decyzje kredytowe, które zmieniają ludziom życie. I niemal każdy system może być wykorzystany do szpiegowania użytkowników. Możemy sobie wyobrazić utopijną przyszłość i znaleźć przykłady niezamierzonych konsekwencji prowadzących do koszmaru. Różnica pomiędzy tymi dwoma wynikami może być bardziej subtelna niż myślisz. Wszystko w Twoich rękach. Kompas moralny Ceną za tę niespodziewaną władzę jest konieczność zachowania czujności. Nasze działania bezpośrednio wpływają na ludzi. Nie ma już tworzonych w garażu hobbystycznych programów na 8-bitowe procesory, wyizolowanych wsadowych procesów biznesowych na komputerach mainframe w centrach obliczeniowych lub wykonywanych w izolacji na komputerze stacjonarnym. Całe nasze oprogramowanie tka tę samą tkaninę codziennego współczesnego życia. Mamy obowiązek zadać sobie dwa pytania o każdy fragment kodu, który dostarczamy: 1. Czy zabezpieczyliśmy użytkowników? 2. Czy sam będę korzystać ze swojego programu? Po pierwsze należy zadać pytanie: „Czy zrobiłem wszystko, aby zabezpieczyć użytkowników tego kodu przed szkodami?”. Czy zapewniłem mechanizmy instalacji bieżących poprawek zabezpieczeń dla tego prostego programu monitorującego dzieci? Czy dałem użytkownikowi możliwość ręcznego sterowania automatycznym termostatem centralnego ogrzewania? Czy przechowuję tylko te dane, które są mi potrzebne, i szyfruję wszystkie dane osobowe? Nikt nie jest doskonały; każdy czasem o czymś zapomina. Ale jeśli nie możesz zgodnie z prawdą powiedzieć, że starałeś się znaleźć wszystkie konsekwencje i zadbać o ochronę przed nimi użytkowników, to poniesiesz odpowiedzialność, gdy coś pójdzie źle. 4337ebf6db5c7cc89e4173803ef3875a 4 328 Posłowie WSKAZÓWKA NR 98 Po pierwsze — nie szkodzić. Po drugie należy przeprowadzić ocenę spełnienia złotej reguły: czy byłbym zadowolony, gdybym był użytkownikiem tego oprogramowania? Czy chcę udostępniać moje dane? Czy życzę sobie, żeby to, co robię, zostało przekazane do sklepów online? Czy byłbym zadowolonym pasażerem tego autonomicznego pojazdu? Czy czułbym się z w nim komfortowo? Niektóre odkrywcze idee zaczynają wykraczać poza granice postępowania etycznego. Jeśli bierzesz udział w takim projekcie, jesteś za to tak samo odpowiedzialny, jak sponsorzy. Bez względu na liczbę stopni separacji, które potrafisz uzasadnić, jedna zasada pozostaje prawdą: WSKAZÓWKA NR 99 Nie pozwalaj na łajdactwo. Wyobraź sobie wymarzoną przyszłość Wszystko zależy od Ciebie. To Twoja wyobraźnia, Twoje nadzieje, Twoje obawy przyczynią się do powstania przemyślanych programów, które zbudują następne dwadzieścia lat i dalszą przyszłość. Budujesz przyszłość dla siebie i dla swoich potomków. Twoim zadaniem jest zadbanie o to, aby była to taka przyszłość, jakiej wszyscy moglibyśmy sobie życzyć. Rozpoznaj sytuacje, gdy robisz coś przeciwko temu ideałowi i miej odwagę powiedzieć „nie!”. Przewiduj przyszłość, jaką możemy mieć, i miej odwagę, aby ją tworzyć. Codziennie buduj zamki w powietrzu. Wszyscy mamy wspaniałe życie. WSKAZÓWKA NR 100 To jest Twoje życie. Dziel się nim. Świętuj je. Buduj. I MIEJ Z TEGO PRZYJEMNOŚĆ! 4337ebf6db5c7cc89e4173803ef3875a 4 Bibliografia [ADSS18] Antinyan Vard, Derehag Jesper, Sandberg Anna, Staron Miroslaw, Mythical Unit Test Coverage, „IEEE Software” 2018, no. 35, s. 73–79. [And10] Andrade Jackie, What does doodling do?, „Applied Cognitive Psychology” 2010, vol. 24(1), s. 100–106. [Arm07] Armstrong Joe, Programming Erlang: Software for a Concurrent World, The Pragmatic Bookshelf, Raleigh 2007. [Bec98] Becker Gavin de, The Gift of Fear: And Other Survival Signals That Protect Us from Violence, Dell Publishing, New York 1998. [BR89] Bernstein Albert J., Rozen Sydney Craft, Dinosaur Brains: Dealing with All Those Impossible People at Work, John Wiley & Sons, New York 1989. [Bro96] Brooks Jr. Frederick P., The Mythical Man-Month: Essays on Software Engineering, Addison-Wesley, Reading 1996 [wydanie polskie: Legendarny osobomiesiąc. Opowieści o inżynierii oprogramowania, Helion, Gliwice 2019]. [CN91] Cox Brad J., Novobilski Andrew J., Object-Oriented Programming: An Evolutionary Approach, Addison-Wesley, Reading 1991. [Con68] Conway Melvin E., How do Committees Invent?, „Datamation” 1968, vol. 14(5), s. 28–31. [DL13] DeMacro Tom, Lister Tim, Peopleware: Productive Projects and Teams, Addison-Wesley, Boston 2013. [Fow00] Fowler Martin, UML Distilled: A Brief Guide to the Standard Object Modeling Language, Addison-Wesley, Boston 2000. [Fow04] Fowler Martin, UML Distilled: A Brief Guide to the Standard Object Modeling Language, Addison-Wesley, Boston 2004. 4337ebf6db5c7cc89e4173803ef3875a 4 330 Bibliografia [Fow19] Martin Fowler, Refactoring: Improving the Design of Existing Code. Addison-Wesley, Boston 2019 [wydanie polskie: Refaktoryzacja. Ulepszanie struktury istniejącego kodu, Helion, Gliwice 2011]. [GHJV95] Gamma Erich, Helm Richard, Johnson Ralph, Vlissides John, Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley, Reading 1995 [wydanie polskie: Wzorce projektowe. Elementy oprogramowania obiektowego wielokrotnego użytku, Helion, Gliwice 2010]. [Hol92] Holt Michael, Math Puzzles & Games, Dorset House, New York 1992. [Hun08] Hunt Andy, Pragmatic Thinking and Learning: Refactor Your Wetware, The Pragmatic Bookshelf, Raleigh 2008. [Joi94] Joiner T.E., Contagious depression: Existence, specificity to depressed symptoms, and the role of reassurance seeking, „Journal of Personality and Social Psychology” 1994, vol. 67(2), s. 287–296. [Knu11] Knuth Donald E., The Art of Computer Programming. Volume 4A: Combinatorial Algorithms, Part 1, Addison-Wesley, Boston 2011. [Knu98a] Knuth Donald E., The Art of Computer Programming. Volume 1: Fundamental Algorithms, Addison-Wesley, Reading 1998 [wydanie polskie: Sztuka programowania. Tom I: Algorytmy podstawowe, Wydawnictwa NaukowoTechniczne, Warszawa 2002]. [Knu98b] Knuth Donald E., The Art of Computer Programming. Volume 2: Seminumerical Algorithms, Addison-Wesley, Reading 1998 [wydanie polskie: Sztuka programowania. Tom II: Algorytmy seminumeryczne, Wydawnictwa Nukaowo-Techniczne, Warszawa 2002]. [Knu98c] Knuth Donald E., The Art of Computer Programming. Volume 3: Sorting and Searching, Addison-Wesley, Reading 1998 [wydanie polskie: Sztuka programowania. Tom III: Sortowanie i wyszukiwanie, Wydawnictwa Naukowo-Techniczne, Warszawa 2002]. [KP99] Kernighan Brian W., Pike Rob, The Practice of Programming, Addison-Wesley, Reading 1999 [wydanie polskie: Lekcja programowania. Najlepsze praktyki, Helion, Gliwice 2011]. [LH89] Lieberherr Karl J., Holland Ian, Assuring good style for object-oriented programs, „IEEE Software” 1989, vol. 6, s. 38–48. [Mey97] Meyer Bertrand, Object-Oriented Software Construction, Prentice Hall, Upper Saddle River 1997 [wydanie polskie: Programowanie zorientowane obiektowo, Helion, Gliwice 2005]. [Mul18] Muller Jerry Z., The Tyranny of Metrics, Princeton University Press, Princeton 2018. 4337ebf6db5c7cc89e4173803ef3875a 4 Możliwe odpowiedzi do ćwiczeń 331 [SF13] Sedgewick Robert, Flajolet Phillipe, An Introduction to the Analysis of Algorithms, Addison-Wesley, Boston 2013. [Str35] Stroop James Ridley, Studies of Interference in Serial Verbal Reactions, „Journal of Experimental Psychology” 1935, no. 18, s. 643–662. [SW11] Sedgewick Robert, Wayne Kevin, Algorithms, Addison-Wesley, Boston 2011 [wydanie polskie: Algorytmy, Helion, Gliwice 2011]. [Tal10] Taleb Nassim Nicholas, The Black Swan: Second Edition: The Impact of the Highly Improbable. Random House, New York 2010 [wydanie polskie: Czarny łabędź. Jak nieprzewidywalne zdarzenia rządzą naszym życiem, Zysk i S-ka, Poznań 2020]. [WH82] Wilson James Q., Helling George, The police and neighborhood safety, „The Atlantic Monthly” 1982, vol. 249(3), s. 29–38. [YC79] Yourdon Edward, Constantine Larry L., Structured Design: Fundamentals of a Discipline of Computer Program and Systems Design, Prentice Hall, Englewood Cliffs 1979. [You95] Yourdon Edward, When good-enough software is best, „IEEE Software” 1995, vol. 12(3), s. 79–81. Wolałbym mieć pytania, na które nie można odpowiedzieć niż odpowiedzi, dla których nie da się postawić pytań. Richard Feynman 36 Możliwe odpowiedzi do ćwiczeń Odpowiedź do ćwiczenia 1. W naszej ocenie klasa Split2 jest bardziej ortogonalna. Klasa Split2 koncentruje się na konkretnym zadaniu (podziału wierszy) i ignoruje takie szczegóły jak źródło, z którego pochodzą te wiersze. Takie rozwiązanie nie tylko ułatwia opracowanie kodu, ale; też czyni program bardziej elastycznym. Klasa Split2 może dzielić wiersze odczytywane z pliku, generowane przez inną procedurę lub przekazywane za pośrednictwem środowiska. 4337ebf6db5c7cc89e4173803ef3875a 4 332 Bibliografia Odpowiedź do ćwiczenia 2. Zacznijmy od twierdzenia: można napisać dobry, ortogonalny kod w niemal każdym języku. Jednocześnie w każdym języku istnieją pokusy: własności, które mogą prowadzić do zwiększenia sprzężeń i zmniejszenia ortogonalności. W językach obiektowych takie konstrukcje jak dziedziczenie wielokrotne, wyjątki, przeciążanie operatorów czy przykrywanie metod klas bazowych (przez podklasy) stwarzają wystarczająco dużo okazji do tworzenia zbędnych, nie zawsze oczywistych sprzężeń. Istnieje również rodzaj sprzężenia wynikający z powiązania przez klasę kodu z danymi. Jest to zazwyczaj dobre (gdy sprzężenie jest dobre, nazywamy je spójnością — ang. cohesion). Ale jeśli klasy nie będą odpowiednio skoncentrowane, powstałe za ich pomocą interfejsy mogą być bardzo brzydkie. Języki funkcyjne zachęcają do pisania wielu niewielkich, niezależnych od siebie funkcji i łączenia ich na różne sposoby w celu rozwiązania konkretnego problemu. W teorii to brzmi dobrze. W praktyce często także jest dobre. Ale istnieje rodzaj sprzężenia, które może wystąpić również w językach funkcyjnych. Funkcje zazwyczaj przekształcają dane, co oznacza, że wyjście jednej funkcji może stać się wejściem drugiej. Jeśli nie zachowamy odpowiedniej ostrożności, to wprowadzenie zmian w formacie danych generowanych przez jedną z funkcji może doprowadzić do awarii w jakimś miejscu strumienia transformacji. W złagodzeniu tego problemu może pomóc zastosowanie języków z dobrymi systemami typowania. Odpowiedź do ćwiczenia 3. Ratunkiem jest niska technologia! Warto narysować pisakiem kilka kształtów na białej tablicy — samochód, telefon i dom. Nasze rysunki nie muszą być dziełem sztuki — proste szkice w zupełności wystarczą. Należy teraz umieścić na tablicy (w miejscach reprezentujących obszary klikania) karteczki samoprzylepne opisujące zawartość docelowych stron. W trakcie spotkania możemy stopniowo doskonalić rysunki i rozmieszczenie karteczek. Odpowiedź do ćwiczenia 4. Ponieważ chcemy, aby nasz język był rozszerzalny, stworzymy analizator składniowy sterowany przez tabelę. Każdy wpis w tej tabeli zawiera literę polecenia, flagę określającą, czy jest wymagany jakiś argument, oraz nazwę procedury wywoływanej w celu obsłużenia danego polecenia. lang/turtle.c typedef struct { char cmd; /* litera polecenia */ int hasArg; /* czy wymaga jakiegoś argumentu */ void (*func)(int, int); /* wywoływana funkcja */ } Command; static Command cmds[] = { 4337ebf6db5c7cc89e4173803ef3875a 4 Możliwe odpowiedzi do ćwiczeń { { { { { { { 'P', 'U', 'D', 'N', 'E', 'S', 'W', ARG, NO_ARG, NO_ARG, ARG, ARG, ARG, ARG, 333 doSelectPen }, doPenUp }, doPenDown }, doPenDir }, doPenDir }, doPenDir }, doPenDir } }; Główny program jest dość prosty: czytanie wiersza, poszukiwanie komendy, pobranie argumentu, jeśli jest potrzebny, a następnie wywołanie procedury obsługi. lang/turtle.c while (fgets(buff, sizeof(buff), stdin)) { Command *cmd = findCommand(*buff); if (cmd) { int arg = 0; if (cmd->hasArg && !getArg(buff+1, &arg)) { fprintf(stderr, "'%c' wymaga argumentu\n", *buff); continue; } cmd->func(*buff, arg); } } Funkcja szukająca polecenia liniowo przeszukuje naszą tabelę i zwraca albo pasujący wpis, albo wartość NULL. lang/turtle.c Command *findCommand(int cmd) { int i; for (i = 0; i < ARRAY_SIZE(cmds); i++) { if (cmds[i].cmd == cmd) return cmds + i; } fprintf(stderr, "Nieznane polecenie '%c'\n", cmd); return 0; } I wreszcie, odczytanie liczbowego argumentu wymaga prostego wywołania funkcji scanf. lang/turtle.c int getArg(const char *buff, int *result) { return sscanf(buff, "%d", result) == 1; } Odpowiedź do ćwiczenia 5. W zasadzie już rozwiązaliśmy ten problem w poprzednim ćwiczeniu, w którym napisaliśmy interpreter języka zewnętrznego, który będzie zawierać wewnętrzny interpreter. W przypadku naszego przykładowego kodu są to funkcje doXxx. 4337ebf6db5c7cc89e4173803ef3875a 4 334 Bibliografia Odpowiedź do ćwiczenia 6. Odpowiednia specyfikacja w notacji Backusa-Naura (BNF) mogłaby mieć następującą postać: <time> ::= <hour> <ampm> | <hour> : <minute> <ampm> | <hour> : <minute> <ampm> ::= am | pm <hour > ::= <digit> | <digit> <digit> <minute> ::= <digit> <digit> <digit> ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 Lepsze definicje godziny i minuty powinny uwzględniać fakt, że godzina może mieć wartość z przedziału od 00 do 23, a minuta od 00 do 59: hour ::= h-tens digit | digit minute ::= m-tens digit h-tens ::= 0 | 1 m-tens ::= 0 | 1 | 2 | 3 | 4 | 5 digit ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 Odpowiedź do ćwiczenia 7. Oto parser napisany przy użyciu biblioteki Pegjs dla języka JavaScript: lang/peg_parser/time_parser.pegjs time = h:hour offset:ampm { return h + offset } / h:hour ":" m:minute offset:ampm { return h + m + offset } / h:hour ":" m:minute { return h + m } ampm = "am" { return 0 } / "pm" { return 12*60 } hour = h:two_hour_digits { return h*60 } / h:digit { return h*60 } minute = d1:[0-5] d2:[0-9] { return parseInt(d1+d2, 10); } digit = digit:[0-9] { return parseInt(digit, 10); } two_hour_digits = d1:[01] d2:[0-9 ] { return parseInt(d1+d2, 10); } / d1:[2] d2:[0-3] { return parseInt(d1+d2, 10); } Oto przykładowe użycie tego parsera w testach: lang/peg_parser/test_time_parser.js let test = require('tape'); let time_parser = require('./time_parser.js'); // time ::= hour ampm | // hour : minute ampm | // hour : minute // // ampm ::= am | pm 4337ebf6db5c7cc89e4173803ef3875a 4 Możliwe odpowiedzi do ćwiczeń // // hour ::= digit | digit digit // // minute ::= digit digit // // digit ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 const const const const h = (val) => val*60; m = (val) => val; am = (val) => val; pm = (val) => val + h(12); let tests = { "1am": h(1), "1pm": pm(h(1)), "2:30": h(2) + m(30), "14:30": pm(h(2)) + m(30), "2:30pm": pm(h(2)) + m(30), } test('time parsing', function (t) { for (const string in tests) { let result = time_parser.parse(string) t.equal(result, tests[string], string); } t.end() }); Odpowiedź do ćwiczenia 8. Oto możliwe rozwiązanie w Ruby: lang/re_parser/time_parser.rb TIME_RE = %r{ (?<digit>[0-9]){0} (?<h_ten>[0-1]){0} (?<m_ten>[0-6]){0} (?<ampm> am | pm){0} (?<hour> (\g<h_ten> \g<digit>) | \g<digit>){0} (?<minute> \g<m_ten> \g<digit>){0} \A( ( \g<hour> \g<ampm> ) | ( \g<hour> : \g<minute> \g<ampm> ) | ( \g<hour> : \g<minute> ) )\Z }x def parse_time(string) result = TIME_RE.match(string) if result result[:hour].to_i * 60 + (result[:minute] || "0").to_i + (result[:ampm] == "pm" ? 12*60 : 0) end end 4337ebf6db5c7cc89e4173803ef3875a 335 4 336 Bibliografia (W tym kodzie wykorzystano sztuczkę polegającą na zdefiniowaniu nazwanych wzorców na początku wyrażenia regularnego, a następnie odwołaniu się do nich jako podwzorców w faktycznym dopasowaniu). Odpowiedź do ćwiczenia 9. Naszą odpowiedź należy obwarować wieloma założeniami: Urządzenie pamięci masowej zawiera informacje, które mają zostać przeniesione. Znamy prędkość, z jaką porusza się osoba przenosząca urządzenie. Znamy odległość dzielącą oba komputery. Nie uwzględniamy czasu potrzebnego do skopiowania informacji na urządzenie pamięci masowej i z niego. Koszty kopiowania danych na taśmę są zbliżone do kosztów wysyłania tych danych za pośrednictwem linii komunikacyjnej. Odpowiedź do ćwiczenia 10. Należy tutaj zastosować te same zastrzeżenia co w odpowiedzi 9. Taśma o pojemności 1 TB zawiera 8×240 bitów, zatem linia o przepustowości 1 Gb/s pozwoliłaby przesłać podobną ilość danych w czasie 9000 sekund, czyli w przybliżeniu w ciągu 2,5 godziny. Jeśli człowiek pokonuje średnio 5 km w ciągu godziny, oba komputery musiałyby być oddalone o co najmniej 12,5 kilometra, aby linia komunikacyjna zapewniała wyższą wydajność niż kurier. W przeciwnym razie człowiek niosący taśmę wygrywa. Odpowiedź do ćwiczenia 14. Sygnatury funkcji zaprezentujemy w Javie. Warunki wstępne i końcowe umieścimy w komentarzach. Po pierwsze tworzymy niezmiennik dla klasy: /** * @invariant getSpeed() > 0 * implies isFull() * * @invariant getSpeed() >= 0 && * getSpeed() < 10 */ // Nie uruchamiaj, jeśli pusty // Sprawdzenie zakresu Następnie określamy warunki wstępne i końcowe: /** * @pre Math.abs(getSpeed() - x) <= 1 // Zmiana tylko o jeden * @pre x >= 0 && x < 10 // Sprawdzenie zakresu * @post getSpeed() == x // Uwzględnienie żądanej szybkości */ public void setSpeed(final int x) 4337ebf6db5c7cc89e4173803ef3875a 4 Możliwe odpowiedzi do ćwiczeń 337 /** * @pre !isFull() // Nie wypełniaj dwa razy * @post isFull() // Sprawdź, czy operację wykonano */ void fill() /** * @pre isFull() // Nie opróżniaj dwukrotnie * @post !isFull() // Sprawdź, czy operację wykonano */ void empty() Odpowiedź do ćwiczenia 15. Ciąg składa się z 21 wyrazów. Gdybyśmy udzielili odpowiedzi 20, wpadlibyśmy w pułapkę błędu słupków ogrodzeniowych (nie wiedzielibyśmy, czy liczymy słupki, czy też przestrzenie pomiędzy słupkami). Odpowiedź do ćwiczenia 16. Wrzesień w 1752 roku miał tylko 19 dni. Taki zabieg miał na celu synchronizację kalendarzy w ramach reform gregoriańskich. Katalog mógł zostać usunięty przez inny proces, możemy nie dysponować uprawnieniami do jego odczytania, zmienna &sb może być nieprawidłowa — trudno przewidzieć wszystkie scenariusze. Celowo nie określiliśmy typów zmiennych a i b. Przeciążanie operatorów umożliwia zdefiniowanie operatora +, = lub != w sposób powodujący nieoczekiwane zachowania. Co więcej, symbole a i b mogą być aliasami tej samej zmiennej, zatem drugie przypisanie nadpisze wartość z pierwszego przypisania. W geometrii nieeuklidesowej suma miar kątów trójkąta nie jest równa 180º. Wystarczy wyobrazić sobie trójkąt rzutowany na powierzchnię sfery. Minuty przestępne mogą mieć 61 lub 62 sekundy. Przepełnienie może spowodować, że wynik wyrażenia a + 1 będzie ujemny (w zależności od języka programowania). Odpowiedź do ćwiczenia 17. W większości implementacji języków C i C++ nie istnieją mechanizmy do sprawdzania, czy wskaźnik rzeczywiście wskazuje na prawidłowy obszar pamięci. Typowym błędem jest zwolnienie bloku pamięci i odwoływanie się do tego bloku w dalszej części programu. Wskazywana pamięć mogła zostać w międzyczasie przydzielona na potrzeby innych zadań. Programiści mają nadzieję, że przypisanie takiemu wskaźnikowi wartości NULL zapobiegnie niebezpiecznym odwołaniom w przyszłości — w większości przypadków próba odwołania do wskaźnika pustego powoduje błąd czasu wykonywania. 4337ebf6db5c7cc89e4173803ef3875a 4 338 Bibliografia Odpowiedź do ćwiczenia 18. Przypisanie wartości NULL pozwala ograniczyć o jeden liczbę wskaźników do danego obiektu. Kiedy licznik tych wskaźników osiągnie zero, obiekt może zostać usunięty z pamięci przez mechanizm odzyskiwania. Przypisywanie wartości NULL może być szczególnie ważne w przypadku długo działających programów, gdzie programiści muszą wyeliminować ryzyko zwiększania potrzeb pamięciowych w czasie. Odpowiedź do ćwiczenia 19. Oto przykład prostej implementacji: event/strings_ex_1.rb class FSM def initialize(transitions, initial_state) @transitions = transitions @state = initial_state end def accept(event) @state, action = TRANSITIONS[@state][event] || TRANSITIONS[@state][:default] end end (Pobierz ten plik, aby uzyskać zaktualizowany kod, który wykorzystuje nową klasę FSM). Odpowiedź do ćwiczenia 20. …awaria trzech interfejsów sieciowych w ciągu pięciu minut Mechanizm ten można zaimplementować za pomocą maszyny stanów, ale byłoby to trudniejsze, niż może się z pozoru wydawać: jeśli zdarzenia wystąpią w minutach 1, 4, 7 i 8, to dla czwartego zdarzenia należałoby zainicjować ostrzeżenie. To oznacza, że maszyna stanów musi zapewnić możliwość resetowania samej siebie. Z tego powodu lepszą techniką wydają się strumienie zdarzeń. Należałoby zaimplementować reaktywną funkcję o nazwie buffer z parametrami size i offset. Funkcja ta pozwoliłaby zwrócić każdą grupę z trzech nadchodzących zdarzeń. Następnie, aby określić, czy należy wszcząć alarm, można by przyjrzeć się znacznikom czasu pierwszego i ostatniego zdarzenia w grupie. …jeśli po zachodzie słońca wykryjesz ruch na dole schodów, po czym wykryjesz ruch u szczytu schodów… Ten mechanizm można by prawdopodobnie zaimplementować za pomocą kombinacji wzorca pubsub i maszyny stanów. Wzorca pubsub można użyć do rozpowszechniania zdarzeń do dowolnej liczby maszyn stanów, a następnie maszyny stanów mogłyby zdecydować, co należy zrobić. …powiadom różne systemy raportowania, że obsługa zamówienia została zakończona. 4337ebf6db5c7cc89e4173803ef3875a 4 Możliwe odpowiedzi do ćwiczeń 339 Ten mechanizm prawdopodobnie najlepiej obsłużyć za pomocą wzorca pubsub. Można by użyć strumieni, ale w takim przypadku powiadamiane systemy także musiałyby bazować na strumieniach. …wysłać żądania do trzech usług backend i poczekać na odpowiedzi. To zadanie jest podobne do przykładu, w którym używaliśmy strumieni do pobierania danych użytkownika. Odpowiedź do ćwiczenia 21. 1. Dodanie do zamówienia podatku od wysyłki i podatku VAT. podstawowe zamówienie sfinalizowane zamówienie W konwencjonalnym kodzie najprawdopodobniej zdefiniowalibyśmy jedną funkcję, obliczającą koszty wysyłki, i drugą, obliczającą podatek. Tutaj jednak myślimy o transformacjach, więc przekształcimy zamówienie zawierające listę towarów na zamówienie gotowe do wysłania. 2. Aplikacja ładuje informacje o konfiguracji z pliku o podanej nazwie. nazwa pliku struktura konfiguracji 3. Logowanie użytkownika do aplikacji webowej. poświadczenia użytkownika sesja Odpowiedź do ćwiczenia 22. Transformację wysokiego poziomu: field contents as string [walidacja i konwersja] {:ok, value} | {:error, reason} można podzielić na: field contents as string [konwersja ciągu znaków na liczbę integer] [sprawdzenie value >= 18] [sprawdzenie value <= 150] {:ok, value} | {:error, reason} W rozwiązaniu zakładamy istnienie potoku obsługi błędów. Odpowiedź do ćwiczenia 23. Najpierw odpowiemy na drugą część pytania: wolimy pierwszy fragment kodu. W drugim fragmencie w każdym kroku zwracany jest obiekt, który implementuje następną wywoływaną funkcję: obiekt zwrócony przez content musi implementować find_matching_lines i tak dalej. Oznacza to, że obiekt zwrócony przez funkcję content_of jest sprzężony z naszym kodem. Wyobraźmy sobie, że wymaganie się zmieniło i musimy zignorować wiersze zaczynające się od znaku #. W przypadku zastosowania stylu transformacyjnego, spełnienie tego wymagania byłoby proste: 4337ebf6db5c7cc89e4173803ef3875a 4 340 const const const const Bibliografia content = File.read(file_name); no_comments = remove_comments(content) lines = find_matching_lines(no_comments, pattern) result = truncate_lines(lines) Moglibyśmy nawet zamienić kolejność wywołania funkcji remove_comments i find_ matching_lines, a rozwiązanie nadal by działało. Natomiast w przypadku zastosowania stylu „łańcuchowego” spełnienie nowego wymagania będzie trudniejsze. Gdzie powinna znaleźć się metoda remove_comments: w obiekcie zwróconym przez funkcję content_of, czy w obiekcie zwróconym przez funkcję find_matching_lines? I jaki inny kod przestanie działać, jeśli zmienimy ten obiekt? Z powodu tego sprzężenia styl łączenia wywołań metod jest czasami nazywany pociągiem-wrakiem. Odpowiedź do ćwiczenia 24. Przetwarzanie obrazów. W celu stworzenia prostego harmonogramu obciążenia pomiędzy równoległymi procesami, bardziej niż wystarczająca może być wspólna kolejka zadań. Być może, w przypadku gdy istnieje sprzężenie zwrotne pomiędzy fragmentami kodu — to znaczy, jeżeli wyniki jednego przetworzonego fragmentu wpływają na inne fragmenty (tak, kawałki, jak w aplikacjach przetwarzania wizji lub realizujących złożone przekształcenia osnowy obrazu 3D) — warto rozważyć system typu czarna tablica. Kalendarze grupowe. System typu czarna tablica może być w tym przypadku dobrym rozwiązaniem. Na tablicy można publikować zaplanowane spotkania i dostępność. Występują tu podmioty funkcjonujące autonomicznie — informacje zwrotne dotycząc decyzji są ważne, a uczestnicy mogą przychodzić i odchodzić. Warto rozważyć podział tego rodzaju systemu typu czarna tablica w zależności od tego, kto realizuje wyszukiwanie: niższy personel może być zainteresowany tylko lokalną siedzibą, dział kadr może być zainteresowany tylko anglojęzycznymi przedstawicielstwami na całym świecie, a prezes całością. Istnieje również pewna elastyczność dotycząca formatów danych: możemy swobodnie ignorować formaty lub języki, których nie rozumiemy. Musimy rozumieć różne formaty tylko dla tych placówek, które prowadzą ze sobą spotkania i nie musimy ujawniać wszystkim użytkownikom pełnego, tranzytywnego domknięcia wszystkich możliwych formatów. To ogranicza sprzężenia tylko do tych miejsc, w których są one konieczne, i nie wprowadza sztucznych ograniczeń. Narzędzie do monitorowania sieci. To zadanie jest bardzo podobne do programu do obsługi kredytu hipotecznego z podrozdziału „Czarna tablica w praktyce”. Zgłaszane przez użytkowników raporty dotyczące usterek sieci oraz automatycznie generowane statystyki są umieszczane na tablicy. W celu zdiagnozowania awarii sieci tablicę może analizować człowiek lub agent programowy: dwa błędy z rzędu mogą być spowodowane czymś 4337ebf6db5c7cc89e4173803ef3875a 4 Możliwe odpowiedzi do ćwiczeń 341 równie ulotnym, jak promieniowanie kosmiczne, ale wystąpienie 20 000 błędów świadczy o problemie sprzętowym. Podobnie jak podczas pracy wielu detektywów, którzy rozwiązują tajemnicę morderstwa, możemy mieć wiele podmiotów, które dokonują analizy i przedstawiają pomysły dotyczące tego, jak rozwiązać problemy z siecią. Odpowiedź do ćwiczenia 25. W przypadku list par klucz-wartość ogólnie rzecz biorąc zakłada się, że klucz jest unikatowy. Wymuszają to zazwyczaj biblioteki obsługi tablic asocjacyjnych — albo przez zachowanie samej tablicy asocjacyjnej, albo poprzez jawne generowanie komunikatów o błędach w przypadku zdublowanych kluczy. Jednak zwykła tablica zazwyczaj nie ma takich ograniczeń i bezproblemowo zaakceptuje przechowywanie zdublowanych kluczy, chyba że specjalnie obsłużymy tę sytuację w kodzie. Zatem w tym przypadku znaleziony zostanie pierwszy klucz, który pasuje do klucza DepositAccount, a wszystkie pozostałe pasujące elementy będą zignorowane. Kolejność elementów nie jest gwarantowana, więc czasami rozwiązanie będzie działać, a czasami nie. A z czego wynika różnica w działaniu kodu na maszynie programistycznej i produkcyjnej? To zwykły przypadek. Odpowiedź do ćwiczenia 26. Fakt, że czysto numeryczne pole działa w Stanach Zjednoczonych, Kanadzie i na Karaibach, to przypadek. Zgodnie ze specyfikacją ITU numery międzynarodowe rozpoczynają się od znaku +. W niektórych lokalizacjach jest również stosowany znak *, a bardziej powszechną częścią numeru mogą być wiodące zera. Nigdy nie należy przechowywać numerów telefonów w polu numerycznym. Odpowiedź do ćwiczenia 27. Wszystko zależy od tego, gdzie jesteś. W Stanach Zjednoczonych powszechną miarą objętości jest galon, czyli objętość cylindra o wysokości 6 cali i 7 calach średnicy, zaokrąglona do całkowitej wartości cali sześciennych. W Kanadzie „jedna szklanka” w przepisie może oznaczać dowolną z poniższych wartości: 1/5 imperialnej kwarty, czyli 227 ml, 1/4 amerykańskiej kwarty, czyli 236 ml, 16 metrycznych łyżek stołowych, czyli 240 ml, 1/4 litra, czyli 250 ml Wyjątkiem są przepisy dotyczące gotowania ryżu; w tym przypadku „jedna szklanka” oznacza 180 ml. Wynika to z tzw. koku — oznaczającego szacowaną objętość suchego ryżu potrzebną do nakarmienia jednej osoby przez okres 4337ebf6db5c7cc89e4173803ef3875a 4 342 Bibliografia jednego roku: wynosi to około 180 litrów. Szklanka ryżu to 1 gō, czyli 1/1000 z koku. Jest to mniej więcej taka ilość ryżu, jaką jedna osoba zjada w jednym posiłku1. Odpowiedź do ćwiczenia 28. Nie istnieje, oczywiście, jedno rozwiązanie tego ćwiczenia. Możemy jednak sformułować kilka wskazówek. Jeśli odkryjemy, że nasze wyniki nie tworzą na wykresie gładkiej krzywej, być może powinniśmy sprawdzić, czy jakieś inne działania nie zajmują mocy obliczeniowej procesora. Prawdopodobnie nie będziemy w stanie zgromadzić wiarygodnych statystyk w systemie wielu użytkowników ani nawet w systemie używanym tylko przez nas, jeśli jakieś procesy działające w tle będą cyklicznie zajmowały czas procesora. Warto też sprawdzić poziom wykorzystania pamięci — jeśli monitorowana aplikacja zacznie używać przestrzeni wymiany, jej wydajność drastycznie spadnie. Oto wykres z wynikami uruchomienia kodu na jednej z naszych maszyn: 1 Za tę ciekawostkę dziękujemy Aviemu Bryantowi (@avibryant). 4337ebf6db5c7cc89e4173803ef3875a 4 Możliwe odpowiedzi do ćwiczeń 343 Odpowiedź do ćwiczenia 29. Istnieje kilka sposobów na przeprowadzenie tego dowodu. Jednym z nich jest próba rozwiązania tego problemu w pamięci. Jeśli tablica ma tylko jeden element, nie jest potrzebne iterowanie w pętli. Każda dodatkowa iteracja podwaja rozmiar tablicy, którą możemy przeszukiwać. Dlatego ogólny wzór na rozmiar tablicy ma postać n = 2m, gdzie m oznacza liczbę iteracji. Jeśli obliczymy logarytmy o podstawie 2 dla każdej strony, otrzymamy lg n = lg2m, co z definicji logarytmu jest równoważne z lg n = m. Odpowiedź do ćwiczenia 30. Trzeba przypomnieć sobie matematykę ze szkoły średniej. Wzór konwersji logarytmu o podstawie a na logarytm o podstawie b ma postać: log b x log a x log a b Ponieważ logab jest stałą, to możemy zignorować ten wyraz wewnątrz wyniku Wielkie O. Odpowiedź do ćwiczenia 31. Jedną z właściwości, którą możemy przetestować, jest poprawność zamówienia w sytuacji, gdy w magazynie jest wystarczająca ilość towaru. Możemy wygenerować zamówienia dla przypadkowych ilości elementów i sprawdzić, czy system zwraca krotkę "OK", jeśli w magazynie jest odpowiednia ilość towaru. Odpowiedź do ćwiczenia 32. Jest to dobre zastosowanie dla testów właściwości. Testy jednostkowe mogą skupić się na indywidualnych przypadkach, w których wyniki obliczyliśmy za pomocą innych mechanizmów, natomiast testy właściwości mogą skupić się na następujących elementach: Czy lokalizacja dowolnych dwóch skrzyń pokrywa się? Czy jakakolwiek część dowolnej ze skrzyń przekracza szerokość lub długość ciężarówki? Czy gęstość upakowania (powierzchnia zajmowana przez skrzynie podzielona przez powierzchnię platformy ciężarówki) jest mniejsza lub równa 1? Jeśli gęstość upakowania należy do wymagań, to można również sprawdzić, czy przekracza ona minimalną dopuszczalną gęstość. Odpowiedź do ćwiczenia 33. 1. To zdanie brzmi jak prawdziwe wymaganie: mogą przecież istnieć ograniczenia nakładane na aplikację przez jej środowisko działania. 4337ebf6db5c7cc89e4173803ef3875a 4 344 Bibliografia 2. Samo w sobie to zdanie nie jest wymaganiem. Aby dowiedzieć się, co jest wymagane, należy sobie zadać magiczne pytanie: „Dlaczego?”. Może to być standard korporacyjny — wtedy wymaganie mogłoby brzmieć: „Wszystkie elementy interfejsu użytkownika muszą być zgodne ze standardem korporacyjnym V12.76”. Być może ten kolor szczególnie podoba się członkom zespołu projektowego. W takim przypadku trzeba uwzględnić fakt, że członkowie zespołu lubią zmieniać zdanie. Wtedy należałoby sformułować wymaganie jako: „Kolor tła wszystkich okien modalnych musi być konfigurowalny. Domyślnie będzie wyświetlany kolor szary”. Jeszcze lepsze byłoby bardziej ogólne sformułowanie: „Użytkownik końcowy musi mieć możliwość konfiguracji wszystkich elementów wizualnych aplikacji (kolorów, czcionek i języków)”. Wymaganie może też po prostu wskazywać, że użytkownik musi mieć możliwość odróżniania okien modalnych od niemodalnych. W takim przypadku trzeba podać więcej informacji. 3. To zdanie nie jest wymaganiem, to opis architektury. Każde takie zdanie wymaga głębszej analizy — dopiero na tej podstawie można stwierdzić, co użytkownik rzeczywiście ma na myśli. Czy chodzi o skalowanie? Wydajność? Koszty? Bezpieczeństwo? Decyzja będzie zależeć od odpowiedzi na te pytania. 4. Za przytoczonym sformułowaniem prawdopodobnie kryje się następujące wymaganie: „System będzie zapobiegał wprowadzaniu błędnych wartości w poszczególnych polach i będzie ostrzegał użytkownika o próbach dodawania takich wpisów”. 5. Stwierdzenie w tej formie można traktować jako jasne wymaganie, bazujące na ograniczeniach sprzętowych. Oto rozwiązanie zagadki czterech punktów łączonych trzema odcinkami. 4337ebf6db5c7cc89e4173803ef3875a 4 4337ebf6db5c7cc89e4173803ef3875a 4 4337ebf6db5c7cc89e4173803ef3875a 4