Sprytny kod w C – nie rób tego

ucgosu.pl 4 lat temu

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, iż 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, iż 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ę, iż 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, iż 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, iż 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ć, iż ten kod jest bardzo sprytny. I wielu uważa, iż 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ć, iż 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, iż 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żeli używamy ich w takim ifie:

if ((a == 5) && (b++ == 3))

Czy o ile 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ę, iż 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, iż 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żeli znak to nie EOF, wartość c zawsze wyniesie TRUE, czyli 1. Miłego debugowania

Poza tym czasem sobie nie zdajemy sprawy, iż 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, iż 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, iż często przy debugowaniu dostawiam nawiasy choćby jak wiem, iż 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ę, iż 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 interesujące o ile 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, iż 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ść, iż 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żeli 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ę, iż 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ą.

Idź do oryginalnego materiału