Przejdź do treści

Blog “Zakasane rękawy”

Podcast

Audycje » Programowanie na śniadanie » Odcinek #3 » Skrypt

PNŚ #3. Programowanie defensywne

Otrzymaj powiadomienie o nowych odcinkach

To jest podcast "Programowanie na śniadanie". Słuchasz odcinka numer 3, w którym mowa będzie o programowaniu defensywnym.

intro

Wstęp ogólny

Cześć, z tej strony Paweł, witam Cię w trzecim odcinku podcastu "Programowanie na śniadanie". Jeśli subskrybujesz ten podcast mimo jego młodego stażu, to chcę Ci z tego miejsca podziękować za zaufanie i mam nadzieję, że dzisiejszy odcinek uznasz za ciekawy. Jeśli to Twój pierwszy raz, to oczywiście mam nadzieję, że zostaniesz na dłużej :)

Dzisiaj chciałbym poruszyć temat programowania defensywnego. To pojęcie kojarzyło mi się zwykle z zestawem kilku dobrych praktyk pozwalających robić mniej błędów przy pisaniu kodu i zaciekawiło mnie niedawno gdy spostrzegłem, że niektórzy rozumieją ten termin na całkiem inny sposób, a więc jako rodzaj antywzorca i zbioru złych nawyków które trzeba jak najszybciej wyplenić.

Trochę zdezorientowany ale i zaciekawiony poszedłem więc za tropem i udałem się do zaufanego źródła wiedzy jakim jest dla mnie biblioteka Pluralsight. Znalazłem tam dwa kursy w temacie, mianowicie Defensive Coding in C# oraz Advanced Defensive Programming Techniques. Pierwszy z nich wymieniał garść praktyk pozwalających pisać lepszy kod. Drugi z kursów, już innego autora, polecił pierwszy kurs jako zestaw klasycznych technik programowania defensywnego, natomiast przez kolejne 6 godzin w zasadzie zrównywał te wszystkie praktyki z ziemią, mottem kursu czyniąc frazę że "kiedy musisz się bronić, to już przegrałeś".

Tym dezorientującym wstępem chciałbym więc zaprosić Cię do krótkiego przeglądu tematu, w którym postaram się spojrzeć w miarę obiektywnym okiem na te różne zdania i ocenić czy mamy tu faktycznie jakiś konstruktywny spór, czy po prostu samo pojęcie jest tak niejasne że każdy rozumie je na swój sposób, ale w gruncie rzeczy wszyscy zgadzają się ze sobą.

Czym jest programowanie defensywne?

Skąd wzięła się nazwa?

Zacznijmy więc może od tego skąd w ogóle nazwa defensive programming. Mamy więc defense czyli po angielsku "obrona", etymologia sięga łaciny gdzie słowo znaczy to samo. Po polsku mówi się "programowanie defensywne" i wydaje się że nie podjęto do tej pory innych i bardziej kreatywnych sposobów tłumaczenia tego zwrotu.

Wikipedia polska ani angielska nie tłumaczy nigdzie pochodzenia frazy, ale np. w książce Kod Doskonały (Code Complete) Steve'a McConnela jeden z rozdziałów jest poświęcony właśnie technikom programowania defensywnego i tam nazwa jest wywodzona od pojęcia "defensive driving", co brzmi sensownie więc tego się będę trzymał.

Defensive driving, czyli jazda defensywna, to sposób myślenia podczas prowadzenia naszego ulubionego pojazdu, w którym staramy się przewidywać wszelkie niebezpieczeństwa które mogą wystąpić i być na nie przygotowani.

W polskim kodeksie drogowym mamy zapisaną zasadę ograniczonego zaufania, która mówi że inni kierowcy i uczestnicy ruchu będą zwykle stosować się do przepisów, ale nie mamy takiej gwarancji i musimy zachować ostrożność. Możemy wyobrazić sobie inne strategie na drodze, np. strategię bezwzględnego zaufania w to że wszyscy zawsze stosują przepisy drogowe. Inną skrajnością jest założenie, że nikt nie przestrzega przepisów ruchu drogowego, w związku z czym musimy jeździć skrajnie ostrożnie i nie możemy ani trochę ufać znakom pierwszeństwa na drodze albo kierunkowskazom włączanym przez innych.

Na drodze obie te skrajności, czyli kompletne zaufanie do innych uczestników ruchu, oraz w kontraście do niego zupełny brak zaufania, wydają się niepraktyczne. Kiedy jednak spróbujemy szukać analogii w świecie programowania, może się okazać że takie skrajne opcje zaczną wyglądać kusząco.

Mogę więc teraz Tobie zadać pytanie: czy Twoim zdaniem jako programisty piszącego aplikację, powinniśmy ufać w dobre intencje użytkowników i niezawodność środowiska w 100%, czy może ufać, ale w ograniczonym zakresie, czy może nie ufać ani trochę i nawet założyć złe intencje użytkowników? Jeśli chcesz, to spróbuj zająć w tej sprawie jakieś stanowisko, a ja wrócę jeszcze do tego tematu pod koniec odcinka ;)

Definicja programowania defensywnego

Mając w głowie wcześniejszą analogię do jazdy defensywnej, pewnie każdy z nas mógłby już wyjść z własną definicją programowania defensywnego i pomysłem na reguły, które pozwolą zmniejszyć ryzyko wypadków na produkcji spowodowanych naszym kodem.

Ja zacytuje dwie definicje, jedną trochę szerszą z Wikipedii i drugą autorstwa doświadczonego programisty Zorana Horvata, która to samo ujmuje w jednym zdaniu.

Wikipedia definiuje programowanie defensywne jako "podejście przy projektowaniu oprogramowania, które zapewni jego działanie w nieprzewidzianych sytuacjach (...)". Podejście to, jak kontynuuje Wikipedia, "jest szczególnie użyteczne kiedy od programu oczekiwana jest wysoka niezawodność albo wysoki poziom bezpieczeństwa (...)". Celem technik programowania defensywnego jest "zmniejszenie liczby błędów i problemów", "sprawienie żeby kod był czytelny i łatwiejszy w audycie" oraz "zachowywał się przewidywalnie nawet w przypadku nietypowych danych wejściowych oraz akcji użytkownika".

Druga - krótsza - definicja jest zaczerpnięta z kursu Advanced Defensive Programming Techniques w bibliotece Pluralsight, i mówi, że w programowaniu defensywnym chodzi o "utrzymanie stabilności programu w warunkach niepewności i błędów w danych".

Tak zdefiniowane cele wydają mi się łatwe do zaakceptowania przez chyba wszystkich. Nie spotkałem nigdy firmy, która chciałaby, żeby jej oprogramowanie było zawodne i niebezpieczne i zaskakiwało użytkownika nieprzewidzianymi wyjątkami.

Oczywiście potrafię wyobrazić sobie odstępstwa od reguły, jeśli czasem piszemy drobne narzędzia na swój użytek, to bardziej nas interesuje zakodowanie w 15 minut happy path które da nam wynik i wygeneruje na szybko jakąś CSV-kę, zamiast ostrożnego projektowania i dbania o obsługę przypadków brzegowych. Ale w większych projektach to sama ekonomia powinna nas skłaniać do tego żeby programować ostrożniej i ponosić mniejsze koszty utrzymania w przyszłości.

To, co wydaje się różnić sporo osób, to pomysł na to JAK pisać ten odporny kod i próba kodyfikacji tego do zestawu kilku reguł. Nie jestem tu w stanie w ramach odcinka zrelacjonować wszystkich dyskusji które w ostatnich dniach przeczytałem na ten temat, bo same kursy o których wspomniałem to 10h solidnej wiedzy.

Zamiast tego postaram się znaleźć wspólny mianownik tej wiedzy i zwrócić uwagę na kilka obszarów które wydają się szczególnie często powodować błędy. Z pragmatycznego punktu widzenia, te obszary wydają się warte szczególnej uwagi kiedy projektujemy nowe klasy albo kiedy robimy code review i mamy szanse coś poprawić w kodzie, który za chwile wejdzie gdzieś na ważną gałąź.

Techniki programowania defensywnego

Możemy w takim razie zacząć przegląd technik programowania defensywnego.

Ochrona przed nieprawidłowymi danymi wejściowymi

Tematem, który jest chyba zawsze omawiany w kontekście programowania defensywnego jest ochrona przed nieprawidłowymi danymi wejściowymi.

Weźmy pod uwagę przykład, w którym chcemy napisać metodę, która na podstawie numeru PESEL zwraca datę urodzenia osoby. Operacja wydaje się w miarę sensowna i wykonalna, PESEL w swoim kodowaniu przewiduje że można w nim zapisać lata urodzenia od 1800 do 2299.

Rok urodzenia jest zakodowany w pierwszych 6 cyfrach numeru PESEL, co każdy z nas kto ma nadany PESEL już pewnie zauważył, natomiast cały numer ma zawsze 11 cyfr. Co w takim razie powinna zrobić metoda, która dostanie np. 12-cyfrowy PESEL zamiast 11-cyfrowego?

Autorzy kursów, książek i blogów zgadzają się tu na pewno w jednym: metoda nie powinna nawet próbować przetwarzać takiego numeru, bo jest nieprawidłowy. Najgorszym wyjściem w tej sytuacji jest więc zastosowanie zasady "garbage in, garbage out" i próba pozyskania daty z pierwszych 6 cyfr, mimo że numer na pewno nie jest prawidłowym PESELem.

Co w takim razie powinniśmy zrobić, kiedy na wejściu pojawią się nieprawidłowe dane? Tutaj można rozpocząć długą dyskusję, bo dużo zależy od języka programowania i kontekstu, ale upraszczając: jeśli wywołujący naszą metodę kod naruszył kontrakt przekazując błędne dane wejściowe, to możemy rzucić wyjątek, albo zwrócić błąd w jakiejś innej formie. Więc zamiast "garbage in, garbage out" lepiej przyjąć zasadę "garbage in, error out" albo "no garbage allowed in".

Asercje

Drugim tematem często wiązanym z programowaniem defensywnym jest użycie asercji, czyli kodu który upewnia się że coś jest prawdą, a jeśli okaże się że nie jest, przerywa działanie programu błędem. Asercje mają nieco inny charakter niż wyjątki. Sprawdzają raczej poprawność samego kodu który napisaliśmy, a nie danych wejściowych od użytkownika. Typowo też nie wchodzą do kodu produkcyjnego, a jedynie mają pomagać nam programistom w fazie developmentu i być może jeszcze testów.

Asercje to taka startupowa reguła fail fast, fail cheap, tylko że zastosowana w kodzie programu. Użyteczność bierze się z obserwacji, że jeśli popełnimy błąd pisząc program, to w najlepszym interesie naszym i projektu jest znalezienie go możliwie szybko. Jeśli zaobserwujemy jakąś anomalię w naszym nowym kodzie już podczas developmentu, to wtedy łatwo poprawiamy ją od ręki i oszczędzamy czas testerów i unikamy błędu na produkcji.

Przykładowo, gdybym ja miał przepisać do programu jakiś długi wzór matematyczny z książki, który ma zawsze dawać wynik z zakresu 0-1, to tuż po nim napisał bym asercję, żeby w przypadku gdy wynik wyjdzie poza ten zakres mieć tę sytuację złapaną w debuggerze i gotową do analizy. Ostatnio na blogu 'Low Level Design' Sebastiana Solnicy widziałem właśnie artykuł o asercjach w .NET, gdzie m.in. pokazał konkretne użycia tych konstrukcji w kodzie .NET Core, np. w popularnej klasie StringBuilder. Można oczywiście dyskutować sobie filozoficznie, czy asercji może być za dużo, czy też nigdy ich nie jest za wiele, ale najważniejsze to wiedzieć kiedy warto je zastosować.

Ogólna reguła na podstawie wielu dobrych źródeł byłaby tu więc taka, że jeśli jeszcze nie używamy asercji w kodzie, to pewnie czas zacząć.

Obsługa błędów

Trzecim obszarem, który powinien znaleźć się pod lupą kiedy chcemy stworzyć odporną aplikację, jest wybór sposobu w jaki obsługujemy błędy. W przypadku problemu z wykonaniem typowej ścieżki kodu, mamy zawsze wybór czy np. rzucić wyjątek, czy zwrócić kod błędu, a możemy zwrócić jakiś bardziej złożony obiekt.

Możliwości jest kilka i autorzy potrafią się w tej kwestii mocno różnić opiniami. Myślę że nie ma jednego słusznego sposobu, sporo zależy od kontekstu w jakim działamy, więc wymienię tylko kilka ogólniejszych wniosków ze swojej lektury.

Rzucanie wyjątków jest przez wielu autorów krytykowane i jest kilka dobrych argumentów, które za tym stoją. Najbardziej przemawia do mnie to, że przynajmniej w C# sygnatura metody nie mówi nic o wyjątkach jakie ta może rzucić. Ktoś, kto pracuje z interfejsem i nie interesuje go implementacja pod spodem, chce po prostu żeby wywołana metoda zwróciła to co obiecuje, czyli wynik takiego typu jaki deklaruje, nie robiąc nic nieprzewidzianego w międzyczasie. Programista może nie mieć pojęcia, że metoda czasami rzuca wyjątek określonego typu i że musi go obsłużyć, czasem przekonuje się o tym dopiero kiedy wyjątek poleci po raz pierwszy na produkcji. Można więc patrzyć na rzucanie wyjątków trochę jak na rodzaj cieknącej abstrakcji i raczej unikać ich rzucania w kodzie biznesowym.

Alternatywą dla rzucania wyjątków może być zwracanie wartości w postaci jakiejś struktury opakowującej wynik LUB informację o błędzie. Można to zrobić używając typu takiego jak Either zapożyczonego z języków funkcyjnych, albo konstruując jakąś własną strukturę na taki wynik. Są też alternatywy postrzegane przez prawie wszystkich negatywnie, takie jak zwracanie wartości domyślnej albo nulla, zwracanie kodu błędu albo flagi boolowskiej mówiącej o tym czy operacja się udała czy nie. Inną reakcją na błąd może być natychmiastowe wyłączenie programu, co może mieć sens np. jeśli program steruje choćby naświetlaniem pacjentów przy radioterapii.

Tak jak mówiłem, obsługa błędów to trochę temat rzeka i trudno o jednozdaniową konkluzję. W code review na pewno warto zatrzymać próbę owinięcia całego kodu w blok try-catch po to żeby zalogować i zignorować błąd, co do tego chyba wszyscy autorzy się zgadzają i my wszyscy się zgadzamy. Miejsca gdzie w przypadku błędu zwracany jest null to drugi przykład kodu, na który nie powinniśmy zgadzać. No i w końcu, powinniśmy unikać rzucania wyjątków bez dobrego uzasadnienia, bo nie mamy żadnej gwarancji od języka takiego jak np. C#, że kod który jest wyżej w stosie wywołań złapie taki wyjątek. Wtedy zamiast asekuracyjnego podejścia które chcemy mieć w programowaniu defensywnym, tworzymy raczej problem który może objawić się w przyszłości.

Inne techniki defensywne

Na koniec wymienię już tylko dwie inne techniki, które same w sobie są obszernymi tematami.

Pierwszą jest tworzenie testów automatycznych dla kodu, które bywa niekiedy określane techniką programowania defensywnego i faktycznie wydaje się pasować do definicji, bo pozwala zmniejszyć ilość błędów i zwiększa przewidywalność kodu, dzięki temu że testujemy zwykle różne nietypowe dane wejściowe.

Druga techniką jest utrzymywanie czystego kodu, co również pozwala zmniejszyć ryzyko błędów. Temat rzeka, można by napisać książkę, no i oczywiście niejedna na ten temat powstała z Clean Code Uncle Boba na czele.

Jeśli coś z tych punktów Cię zdziwiło albo zaintrygowało, to polecam w szczególności wspomniany kurs zaawansowanego programowania defensywnego w C# na Pluralsight, do którego link znajdziesz w notatkach. Tam oprócz szerokiego omówienia tych technik programowania defensywnego znajdziesz też sporo różnych technik zapożyczonych z języków funkcyjnych, w szczególności pomysły na to jak pisać kod w którym nie ma if-ów, nie ma enumów, nie ma rzucania wyjątków, co jest naprawdę ciekawym sposobem myślenia i nieźle wpisuje się w nasz dzisiejszy temat.

Na początku odcinka zadałem Ci pytanie o to, czy Twoim zdaniem jako programiści powinniśmy raczej ufać użytkownikom, czy jednak bronić naszego oprogramowania przed złośliwymi albo bezsensownymi danymi wejściowymi i akcjami.

Na drodze zasada ograniczonego zaufania, czyli bycie gdzieś po środku, wydaje się rozsądna. W świecie programowania, myślę że znacznie częściej zahaczamy o skrajności, bo możemy na szybko pisać happy path, a możemy w paranoicznym stylu próbować budować żelazne security i bardzo defensywny kod.

Nie sądzę, że istnieje jedna słuszna odpowiedź, Joel Spolsky na swoim blogu Joel on Software w artykule Five Worlds, czyli "Pięć światów" pokazuje fajną perspektywę na to, że jako programiści mamy tak naprawdę bardzo różne cele i że to co dla jednych z nas jest dobrą praktyką, nie musi być uzyteczne dla innych. Te 5 światów z tytułu to światy w których tworzy się oprogramowanie albo na szeroki rynek kliencki, albo oprogramowanie wewnętrzne dla korporacji, oprogramowanie typu embedded czyli związane ze sprzętem, gry, albo oprogramowanie tworzone do jakiegoś jednorazowego celu, które następnego dnia wyrzucamy.

Z tej perspektywy widać, że są sytuacje, gdzie nie musi nam w ogóle zależeć na defensywnym podejściu. Są też sytuacje w których wolimy dojechać do celu szybciej niż bezpieczniej. Ale w ogólnym przypadku, szczególnie w długoterminowych projektach, myślę że defensywne podejście pomaga nam robić lepszy software.

Zakończenie i podziękowania

To koniec na dziś. Dzięki Ci za Twoją obecność. Dziękuję również tym z Was, którzy po pierwszych odcinkach napisali kilka zdań żeby dodać trochę motywacji. Szczególne podziękowania dla Alka, który doradził mi w kwestiach audio i tego jak poprawić jakość dźwięku, jeszcze nie wiem jak to ostatecznie wyjdzie, bo dopiero za chwilę będę montował, ale dzisiaj nagrywam już innym programem i też trochę popracuję nad lepszą zrozumiałością i głośnością nagrania.

Podcast możesz zasubskrybować w swojej ulubionej aplikacji do podcastów, na moim blogu zakasanerekawy.taurit.pl znajdziesz też linki do podcastu w iTunes, do kanału RSS, oraz skrypty odcinków w formie tekstowej. Zachęcam do śledzenia podcastu Programowanie na śniadanie na Facebooku, albo do zapisu na listę mailingową, gdzie komu wygodniej. Dzięki raz jeszcze i do usłyszenia!