Kiedyś bardzo popularne było pisanie sprytnego kodu. Żeby jak najwięcej zmieściło się w jednej linijce. Żeby oszczędzić sobie nadmiarowego pisania, bo w końcu wiem, że coś się wydarzy pod spodem. Osoba czytająca ten kod mogła jedynie stwierdzić – ale dobry jest ten, kto to napisał, ja nic nie rozumiem. Język C doskonale się do czegoś takiego nadawał. Możemy (nad)używać niejawnego rzutowania, wyrażeń z efektami ubocznymi, magicznych operacji bitowych, wskaźnikowych i innych.
Społeczności skupione wokół innych języków zrozumiały swój błąd i poszły w zupełnie przeciwnym kierunku. Zauważono, że nie opłaca się zyskiwać jednej linijki po to, żeby potem nie dało się zrozumieć co autor miał na myśli. Jednak z C jest inaczej. Pisanie sprytnego kodu weszło nam w krew i przykłady tego typu przeniknęły do materiałów edukacyjnych, do przykładów z internetu, na uczelnie i są przekazywane nowym pokoleniom programistów. W tym artykule przyjrzymy się jak objawia się nasz spryt i jak nieuchronnie prowadzi do strzelenia sobie w stopę.
Efekty uboczne w warunku wyjścia z pętli – przykład 1
Flagowe przykłady sprytnego kodu, który idzie na skróty, są wykorzystywane już na początku nauki C. Jak choćby taki kod obrabiający tekst:
char c; while ((c = getchar()) != EOF) { parse_characher(c); }
Warunek wyjścia w pętli sprawdza, czy doszliśmy do końca pliku. Ale dlaczego by nie wykorzystać tego warunku również do przypisania znaku do zmiennej. Co w tym złego? Otóż zwykle nie spodziewamy się przypisania w warunku wyjścia z pętli. Raczej szukalibyśmy go w ciele pętli albo w deklaracji zmiennej. Dlatego kod:
char c = getchar(); while (c != EOF) { parse_characher(c); c = getchar(); }
Jest po prostu lepszy. Jest bardziej intuicyjny i nie sprawia żadnego wysiłku ani podczas pisania, ani podczas czytania. Poza tym nie przyzwyczajamy się, że ktoś może celowo umieścić przypisanie (pojedyncze =) w warunku logicznym. Często może nam się zdarzyć pomylenie przypisania = z porównaniem ==.
W sumie nowa wersja też nie jest idealna – chociażby dlatego, że po to aby w warunku while zrobić sprawdzenie EOF musieliśmy dwa razy umieścić w kodzie c = getchar(). Może więc lepiej by było napisać tak:
char c; while (1) { c = getchar(); if (EOF == c) { break; } parse_characher(c); }
Teraz na pewno lepiej oddaliśmy kolejność operacji, ale za to nie dało się ładnie sformułować warunku wyjścia z pętli i mamy pętlę nieskończoną z breakiem.
Ten przykład jest dosyć prosty i ktoś może zapytać o co tyle hałasu. Tym bardziej, że ten konkretny przykład jest chyba wszystkim znany. Ale to pokazuje sposób myślenia, którego uczymy. I stąd już tylko jeden krok do czegoś takiego:
for(i = 0; i < lim-1 && (c = getchar()) != EOF && c != '\n'; ++i)
Efekty uboczne w warunku wyjścia z pętli – przykład 2
Kolejny przykład, również często pokazywany początkującym, to prosta implementacja kopiowania stringów:
char * strcpy(char *t, const char *s) { char *p = t; while(*t++=*s++); return p; }
Trzeba przyznać, że ten kod jest bardzo sprytny. I wielu uważa, że pokazuje piękno i prostotę języka C. Co dokładnie tutaj się dzieje? Najpierw zapisujemy do zmiennej lokalnej wskaźnik na docelowy string – na koniec zwrócimy go z funkcji. Potem wykorzystujemy jednolinijkowy while (nie przeoczcie średnika kończącego pętlę). Przepisywanie zawartości jednego stringa do drugiego oczywiście robię w warunku – bo mogę. String musi kończyć się bajtem zerowym, co spowoduje wyjście z pętli. Nie muszę jawnie robić porównania, bo przypisana wartość będzie analizowana przez while.
I taki kod pokazujemy początkującym. Jaki będzie efekt? Najpierw pewnie nie ogarną co tu się dzieje. Ale potem jak już się oswoją to zaczną kreatywnie rozwijać tą ideę. W ten sposób z kolei nikt nie ogarnie ich przyszłego kodu.
Jak można to zrobić po ludzku?
char * strcpy(char *t, const char *s) { char *p = t; char last_char; do { *t = *s; last_char = *s; t++; s++; } while (last_char != '\0'); return p; }
Nie ma while ze średnikiem jako ciałem pętli. Wszystkie linijki zawierają pojedynczą operację – dzięki temu można debugować. I chyba najważniejsza poprawka – teraz jasno widzimy jaki jest warunek wyjścia z pętli – sprawdzamy, czy doszliśmy do końca stringa.
I przy okazji skoro to przykład edukacyjny to możemy pokazać pewien niuans. String kończy się na bajcie zerowym i musimy go również skopiować. Używamy do tego tymczasowej zmiennej. Owszem – poprzednia wersja kodu jest dużo krótsza i obsługuje dobrze znak końca, ale ktoś może w ogóle nie zauważyć, że ten przypadek wymaga rozpatrzenia.
Inkrementacja i dekrementacja
Możliwość zwiększania i zmniejszania zmiennej o jeden od razu w momencie wykorzystania jest w ogóle jedną z ulubionych rzeczy pokazywanych w przykładach dla początkujących. Uczymy się na przykład, żeby inkrementować argumenty funkcji, czy właśnie składowe wyrażeń logicznych. To ciekawe, że w ramach edukacji uczymy się wszystkich złych wzorców z przykładów.
Inkrementacja i dekrementacja mogą prowadzić do różnych dziwnych błędów. Na przykład co, jeśli używamy ich w takim ifie:
if ((a == 5) && (b++ == 3))
Czy jeżeli a jest różne od 5 to b zmieni swoją wartość, czy nie? To jest klasyczny przykład, dlaczego nie powinniśmy stosować efektów ubocznych w warunkach. Ale może być jeszcze gorzej:
#define MAX(a, b) (((a) > (b)) ? (a) : (b)) int a = 5; int b = 4; int c = MAX(++a, b);
Tutaj mamy efekt uboczny w makrze. Jego zawartość jest wklejana do kodu i okazuje się, że inkrementacja może zajść dwa razy.
Dlatego standardy kodu – takie jak MISRA C – zakazują efektów ubocznych w funkcjach, makrach i warunkach.
Kolejność operatorów
Operatory opisujące operacje dozwolone na zmiennych mają swoją kolejność działań. Pełną listę znajdziesz tutaj. Część z nich jest naturalna – na przykład mnożenie wykona się przed dodawaniem, a dodawanie przed przypisaniem do zmiennej. Takie same zasady występują w matematyce:
a = 5 * 3 + 8; b = 8 + 3 * 5;
Jednak tych operatorów jest bardzo dużo i nie wszystkie są tak intuicyjne. Weźmy wcześniejszy przykład z getchar:
while (c = getchar() != EOF)
Jedyna różnica jest taka, że usunąłem nawiasy wokół przypisania c = getchar(). Jednak to zmienia wszystko. Teraz najpierw kompilator sprawdzi, czy odczytany znak to EOF, a do c zapisze wynik tej operacji logicznej. Czyli jeśli znak to nie EOF, wartość c zawsze wyniesie TRUE, czyli 1. Miłego debugowania 😀
Poza tym czasem sobie nie zdajemy sprawy, że coś w ogóle jest operatorem. Na przykład w przykładzie z strcpy:
while(*t++=*s++);
Inkrementacja (++) odnosi się do wskaźnika, czy wartości po dereferencji (*)? Z przykładu wiemy, że inkrementuje się wskaźnik, ale ten błąd też już mi się zdarzyło kiedyś popełnić.
Kolejny przykład to operatory logiczne i bitowe:
if (value & mask != 0)
Operator != ma wyższy priorytet, dlatego maska będzie najpierw porównana z zerem, a dopiero potem zandowana z wartością. To jest typowy sposób tworzenia warunków, które zawsze są spełnione, czy nieskończonych pętli.
Jeżeli chcemy uniknąć problemów – wystarczy dodać nawiasy:
if ((value & mask) != 0)
W każdym razie – programuję w C już od paru ładnych lat i nie znam dokładnie kolejności operatorów. Po prostu staram się pisać kod tak, żeby na niej nie bazować.
Z drugiej strony tyle razy już widziałem błędy spowodowane kolejnością operatorów, że często przy debugowaniu dostawiam nawiasy nawet jak wiem, że kolejność jest dobra. Po prostu nie ufam priorytetom operatorów – taki nawyk (a może paranoja?). W każdym razie tak działa mózg, nie tylko mój, i nie ma co z tym walczyć. Jak te dodatkowe nawiasy mają uchronić od błędu to warto je dodać.
Podwójna negacja
Kiedyś w jednym projekcie natknąłem się na tego typu kod:
if (!!val1 == !!val2)
Moja pierwsza reakcja – po co do cholery ktokolwiek miałby używać dwóch wykrzykników? Czy to jakiś operator, którego nie znałem wcześniej? Okazuje się, że nie. To podwójna negacja logiczna. No super, ale to bez sensu. Podwójna negacja przecież zostawia wartość początkową! No właśnie nie do końca. W ten sposób dowolną wartość liczbową możemy przekonwertować na wartość logiczną TRUE albo FALSE. Ma to swoje zastosowanie, ponieważ domyślnie TRUE ma wartość 1, więc taki kod nie zadziała:
uint32_t mask = 0x00008000; if (mask == TRUE) { handle_event(); }
Wartość mask jest różna od TRUE, czyli if nie będzie spełniony. Co ciekawe jeżeli byśmy nie robili porównania z TRUE i bazowali na niejawnej konwersji – if zadziałałby poprawnie. I tutaj wkracza !! cały na biało:
uint32_t mask = 0x00008000; if (!!mask == TRUE) { handle_event(); }
Dzięki temu dowolna maska bitowa może być zmieniona w wartość logiczną. Jak odkryłem o co w tym chodzi, oczywiście chciałem stosować podwójny wykrzyknik gdzie tylko się da. Jednocześnie dając WTF moment każdemu, kto to musiał potem czytać.
Dopiero po dłuższym czasie dotarło do mnie, że to jednak bez sensu. Dużo prościej osiągnąć wartość liczbową stosując standardowe porównanie:
if (mask != 0)
I przy okazji mam pewność, że każdy to zrozumie. Do tego nie muszę używać operatora boolowego na zmiennej typu int. Czyli nie ma niejawnej konwersji.
Podsumowanie
C pozwala napisać naprawdę sprytny, pomysłowy i kompletnie niezrozumiały kod. Nic nie stoi na przeszkodzie, żebyśmy pisali tak:
//multiply it by four and make sure it is positive return i > 0 ? i << 2 : ~(i << 2) + 1;
Albo robili bardziej subtelne hacki jak na przykład Duff’s Device. Ale zróbmy przysługę sobie i innym – powstrzymajmy się do tego.
Posłuchajmy porad mądrych ludzi takich jak Martin Fowler:
“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”
czy Brian Kernighan:
Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.
A jeśli mamy pilną potrzebę pokazać światu swoje umiejętności pisania sprytnego kodu, to wyładujmy się na konkurach typu code golf albo obfuscated code contest. Nie będziemy wtedy musieli stosować takich tricków na produkcji.
Ludzie się zniechęcają do wgłębiania w skomplikowany kod. Wydaje im się, że zajmie to dużo czasu. Często słusznie! Tracimy energię tam gdzie nie musimy. Nawet, jak się przyzwyczaimy do poruszania po takim kodzie, dalej jesteśmy mniej efektywni. Nie ma żadnego uzasadnienia, żeby to robić sobie i innym.
Jeżeli chcesz dowiedzieć się więcej o tym, jak pisać dobry kod w C – zapisz się na newsletter przez formularz poniżej. Przygotowuję właśnie szkolenie online „C dla zaawansowanych” i na liście mailowej informacje na ten temat na pewno Cię nie ominą.
10 kwietnia 2020 at 10:58
O ile zgadzam się z „ogólną wymową” tego tekstu, moim zdaniem przykłady zostały dobrane raczej niezbyt fortunnie.
Chociażby ten z
while ((c = getchar()) != EOF)
Tak się złożyło, że właśnie wczoraj oglądałem sobie „Elements of Style” Briana Kernighana, i on użył dokładnie tego przykładu w celu zademonstrowania, dlaczego przy nauce języka istotne jest zapoznanie się z idiomami tego języka:
https://youtu.be/8SUkrR7ZfTA?t=2106
Wspomniany sposób przetwarzania strumieni jest właśnie idiomem — czymś, co nie wynika z „elementów języka”, i co należy studiować w całości, i co poznaje się poprzez „opatrzenie się” z kodem.
Języki programowania mają swoje tradycje, i raczej bym ich zbyt lekkomyślnie nie odrzucał.
Podobnie przykład ze `strcpy`. Przykład o tyle moim zdaniem nierelewantny, że po pierwsze, raczej należałoby zachęcać z korzystania z istniejących implementacji funkcji bibliotecznych, a po drugie użycie `strcpy` i tak powinno być odradzane na rzecz `strncpy`, która pomaga unikać problemów z nadpisywaniem bufora.
Jeżeli idzie o „priorytety operatorów”, to oczywiście tutaj trzeba uważać, ale sądzę, że najbardziej jaskrawy przykład źle dobranych precedensów to operatory bitowe `&` i `|`, których zachowanie jest mylące.
W każdym razie zarówno w ich przypadku, jak i w przypadku operatora przypisania w pętli, kompilator jest nas w stanie ostrzec przed błędnym użyciem (no i nawiasy wokół operatora przypisania są oczywiście częścią idiomu)
Jeżeli idzie o język C, to on akurat ma bardzo dobrze udokumentowaną tradycję. Nawet na polskim rynku można zdobyć takie książki, jak „Język ANSI C”, „Lekcja programowania” czy „UNIX. Sztuka programowania”.
10 kwietnia 2020 at 13:02
Temat jest trochę subiektywny i na pewno dużo programistów C nie będzie się ze mną zgadzać.
Przykłady specjalnie dobrałem takie, żeby pokazać jak od początku uczymy się pisać sprytnie, więc potem nie ma się dziwić, że zbieramy tego owoce.
Ten przykład z getchar już tak przeniknął do codziennego użycia, że faktycznie pewnie każdy go zna. Ale moim zdaniem jak na samym początku komuś pokazujemy taki kod to zachęcamy go do twórczego rozwijania tej idei. A powiedzenie komuś „ok z getcharem możesz sobie tak robić, ale w innych przypadkach już nie” po prostu się nie obroni. I potem za każdym razem otwierając kod musimy rozwiązywać sudoku 😀
Przykład strcpy też nieraz widziałem w materiałach edukacyjnych. Celem nie jest oczywiście zastępowanie funkcji bibliotecznych, tylko pokazanie składni języka w akcji. I ten przykład jest właśnie często pokazywany jako reprezentatywny dla sposobu myślenia programisty C – wiem, co robię, więc mogę pójść na skróty. I zdaniem wielu pokazuje wszystkie zalety tego języka.
Co do priorytetów operatorów też mogłem dopisać więcej przykładów. Faktycznie == i operatory bitowe & | ^ to jeden z typowych błędów. I już tyle tego typu błędów widziałem, że po prostu wolę nadmiarowe nawiasy.
10 kwietnia 2020 at 14:00
Ok, sprawdziłem książkę K&R w polskiej wersji. Przykład z getchar jest w rozdziale 1.5. Rozdział 1 (który w ogóle nosi nazwę „Elementarz”) zaczyna się na stronie 23, a na 38 już jest przykład z getchar – czyli praktycznie na samym początku nauki.
Mamy tam wytłumaczone właśnie, że to idiom i po przyswojeniu kod jest bardziej zwięzły i czytelny. Mamy nawet adnotację, że nie należy przesadzać, bo możemy stworzyć coś zupełnie nieczytelnego.
No ale trochę dalej (strona 53) mamy już coś takiego:
for(i = 0; i < lim-1 && (c = getchar()) != EOF && c != '\n'; ++i) Gdzie między innymi c zadeklarowane w jednym pod warunku jest od razu sprawdzane w drugim. I to wszystko dalej w rozdziale "Elementarz". Po takim wprowadzeniu po prostu nie ma opcji, żeby ktoś pisał prosto i czytelnie.