What an amusing coincidence. The dust has barely settled after the Apple’s mishap with extraneous goto fail;
in OpenSSL (used widely by OSX and iOS), that made it ripe for exploits, before we’ve heard another similar story. This time it was about multiple goto cleanup;
statements that comprised a vulnerability in GnuTLS, a rough equivalent of OpenSSL on various Linux distributions.
That goto
! Everyone warns it cannot be trusted, it shouldn’t be used, it’s basically the work of the devil. Yet, some people just don’t listen, and we can witness firsthand all the woes that befall them. Will it serve as a warning for the others? Can we hope for at least this much?…
The answer is no. Not only we can’t, but also shouldn’t. Despite what many would proclaim, these two fiascoes are hardly about the goto
statement. It’s very tempting, of course, to pigeonhole them into that box, but the truth is just more complex than that.
The goto
statement is only one example, drawn from a certain class of programming techniques and language features. What they all have in common is that they overstep the safety guarantees a language offers, however elaborate or scarce they are. In return, those techniques offer additional capabilities, exceeding the usual “power level” available to developers.
Pretty much every language hides something of this kind. It may be goto
in C. It could be reflection in Java. It might be metaclasses in Python, or import hooks. Even the pure and graceful Haskell has the infamous unsafePerformIO
, the use of which potentially throws all the normal guarantees of correctness out of the window.
What all those features have in common is their inevitability. You may try to ignore them, or write off as bad practices at best. But come a sufficiently complex program, an amply convoluted system, a problem hairy enough, and they suddenly become adequate, acceptable – or even necessary. The malevolent goto
, to stick with the most prominent example, becomes basically mandatory whenever C code mixes error checking with handling resources that require proper cleanup.
Therefore, the only reasonable solution is to learn to cope with it all, and just be extra careful. Translated to software engineering practices, this means taking every precaution we can reasonably afford to minimize the risk of bugs. Most of this stuff is pretty simple, really. It doesn’t necessarily involve any gymnastics with automatic testing, too, which is every developer’s “favorite” discipline. The OpenSSL’s fiasco would have been prevented by any tool that looks for unreachable code; that’s not really asking a lot.
So if we could learn anything from those high profile fail
ures, it’s that basic stuff, low hanging fruit – like code reviews and static analyzers – actually help, for little to no cost. I’m sure such conclusion is much more valuable than yet another tirade, coming from a You Should Not Code Like That department.
As the Python docs state, with
statement is used to encapsulate common patterns of try
–except
–finally
constructs. It is most often associated with managing external resources in order to ensure that they are properly freed, released, closed, disconnected from, or otherwise cleaned up. While at times it is sufficient to just write the finally
block directly, repeated occurrences ask for using this language goodness more consciously, including writing our own context managers for specialized needs.
Those managers – basically a with
-enabled, helper objects – are strikingly similar to small local objects involved in the RAII technique from C++. The acronym expands to Resource Acquisition Is Initialization, further emphasizing the resource management part of this pattern. But let us not be constrained by that notion: the usage space of with
is much wider.
Natrafiłem niedawno na kapitalną ciekawostkę, sponsorowaną przez literkę C – język C, rzecz jasna. A jeśli już o C mowa, to jednym z pierwszym skojarzeń są oczywiście wskaźniki; prawdopodobnie nie jest ono zresztą specjalnie pozytywne ;) Wśród nich mamy wskaźniki na funkcje, które znane są z kilku nieocenionych zastosowań (weźmy na przykład funkcję qsort
), ale przede wszystkim z pokrętnej i niezbyt oczywistej składni.
Zalecam jednak przypomnienie jej sobie, bo dzisiaj będzie właśnie o wskaźnikach na funkcje, tyle że pokazujących na… etykiety. Tak, te właśnie etykiety (labels), które normalnie są celem instrukcji goto
– złej, niezalecanej, i tak dalej. Okazuje się bowiem, że etykiety też mają swoje adresy, które w dodatku można pobrać i wykorzystać jako wskaźniki:
Wymagany jest do tego podwójny znak ampersandu (&
), co można traktować jako osobny operator lub rodzaj specjalnej składni… W każdym razie jest on konieczny, aby kompilator wiedział, że następująca dalej nazwa jest etykietą. Mają one bowiem swoją własną przestrzeń nazw, co oznacza, że możliwe jest występowanie np. zmiennej start
i etykiety start
w tym samym zasięgu.
Przykładowym wynikiem działania programu jest poniższa linijka:
first = 0x4004f8, second = 0x4004f8, last = 0x400519
Pierwsze dwa adresy są sobie równe i nie powinno to właściwie być zaskakujące. Jak można się bowiem łatwo domyślić, adresy etykiet to w istocie adresy instrukcji, które są nimi opatrzone. Dla osób programujących w asemblerze powinno być to dziwnie znajome :) W powyższym przykładzie zarówno first
, jak i second
mają adresy odpowiadające położeniu w pamięci kodu wywołania funkcji printf
.
Wróćmy jednak do wspomnianych wcześniej wskaźników na funkcje. Mając bowiem pobrany adres etykiety, możemy go przypisać do takiego właśnie wskaźnika. Dzięki temu możemy etykiety możemy “wywoływać”, i to nawet z innych funkcji!
Jak to jednak w ogóle działa? Ilustruje to poniższy przykład, w którym funkcja wywołuje w ten sposób sama siebie:
Po pobieżnym przyjrzeniu się widać, że jest to zwykłe liniowe przeszukiwanie tablicy, w dodatku w sposób który wygląda na rekurencyjny… Jest to jednak specjalny rodzaj tzw. rekurencji ogonowej (tail recursion). Występuje ona wówczas, gdy wywołanie rekurencyjne jest ostatnią instrukcją funkcji. Taki przypadek może być wówczas zoptymalizowany przez co sprytniejsze kompilatory poprzez wyeliminowanie konieczności ponownego odkładania argumentów na stos. Kolejny poziom rekursji wykorzystuje po prostu te same argumenty – jest to możliwe, o ile nie ma konieczności rekurencyjnych powrotów i składania kolejnych wyników.
W powyższym przykładzie rekursja ogonowa występuje jednak niezależnie od jakichkolwiek optymalizacji, gdyż jest ona zawarta w “wywołaniu” etykiety top
. Chociaż pozornie wygląda to jak wywołanie funkcji, nie powoduje utworzenia dodatkowej ramki stosu ani ponownego odłożenia na nim argumentów. Docelowa “funkcja” operuje na istniejącym stosie. W istocie więc mamy tu do czynienia z pewną wersją instrukcji goto
, czyli zwykłego skoku.
Ciekawiej zaczyna się robić wtedy, gdy spróbujemy “wywołać” etykietę z innej funkcji, co – jak wspomniałem – jest zupełnie możliwe. Możliwe jest wówczas gładkie przekazanie jej wszystkich parametrów oraz zmiennych lokalnych, co automatycznie podpada pod kategorię Rzeczy Podejrzanych i Potencjalnie Niebezpiecznych :) Niemniej jednak jest to możliwe:
#include
int foo = 1, i;
typedef int (*args_func)(int argc, char* argv[]);
args_func process(int argc, char* argv[]) {
if (foo == 1) {
return &&inside;
}
if (foo == -1) {
inside:
for (i = 1; i < argc; ++i)
printf("Arg #%d: %s\n", i, argv[i]);
}
return 0;
}
int main(int argc, char* argv[]) {
process(0, NULL)(0, NULL); // sic
}[/c]
Powyższy program przekazuje swoje argumenty do funkcji process
(która je wypisuje) mimo iż zupełnie tego nie widać w jej wywołaniu. Zresztą normalne wywołanie tej funkcji jedynie zwraca adres jej etykiety, pod który później skaczemy z main
a bez dokładania nowej ramki do stosu.
W tym kodzie jest też kilka innych smaczków (jak chociażby rola zmiennej foo
), których odkrycie pozostawiam jednak co bardziej dociekliwym czytelnikom :) Zaznaczę tylko, że cała ta (chyba) niezbyt praktyczna zabawa z pobieraniem adresów etykiet jest w gruncie rzeczy rozszerzeniem GCC i nie jestem pewien, czy będzie działać w jakimkolwiek innym kompilatorze.
Ponieważ programowaniem zajmuję się już od dość długiego czasu, mam w tym nieco doświadczenia praktycznego. Przez ten czas zdążyłem też zmienić część swoich poglądów na niektóre rzeczy z tym związane. Mówiąc trochę górnolotnie: stałem się bogatszy o wiedzę z życia wziętą :)
Jedną z takich nauczek życiowych jest to, by nie ufać zbytnio standardom, zaleceniom i innym tzw. dobrym praktykom podpowiadających (a czasem wręcz nakazujących), jak powinien wyglądać pisany przez nas kod. Dotyczy to na przykład instrukcji rzekomo złych “z natury” i innych tego rodzaju przesądów. Tak, uważam określenie ‘przesądy’ za uprawnione w wielu przypadkach.
Nie jestem ponadto odosobniony w tych poglądach – ba, wśród swoich kolegów po fachu mógłbym wskazać wielu kilku, którzy są pod tym względem nawet bardziej… radykalni :) Jak już jednak wspomniałem na początku, nie zawsze tak było. Kiedyś miałem bardziej rygorystyczne i pryncypialne podejście do kwestii tego, jak kodować należy lub nie należy. Skąd brałem dla niego uzasadnienie, jeśli nie z praktyki, której wtedy zbyt wiele jeszcze nie miałem?…
Ano właśnie – tu dochodzimy do sedna. Przekonanie to brało się głównie – jeśli nie wyłącznie – ze wszelkiego typu materiałów pomocniczych do nauki programowania: książek, kursów, artykułów, czasem dokumentacji. Choćby nie wiem jak techniczne i merytoryczne były te teksty, w każdym – co do jednego, z moim skromnym dziełem włącznie – znajdą się rady, zalecenia, wskazówki i inne “metainformacje” odnośnie tego, jak programować należy – a nie tylko o tym, jak można. Czy ktoś widział w kursie programowania opis instrukcji goto
(że pozwolę sobie użyć najbardziej typowego przykładu), który nie zawierał chociaż śladowej wzmianki o tym, iż.. no, w zasadzie to nie należy jej używać?… …Tak myślałem ;-)
Nasuwającą się od razu repliką w obronie autorów jest sugestia, że w sumie to przecież całkiem dobrze, iż uczą oni nowicjuszy tego, co sami musieli wcześniej wypracować przez lata. Nie wątpię wręcz, że taka intencja – oszczędzenia początkującym trudności – rzeczywiście autorom przyświeca. Dodają oni do pisanych przez siebie tekstów, kursów, tutoriali, itp. liczne wskazówki, te wszystkie dos and don’ts właśnie dlatego, iż wierzą w to, że w ten sposób pomogą swoim czytelnikom od razu, już na starcie kodować w sposób przejrzysty, efektywny, elastyczny, elegancki, “łatwo konserwowalny”, i tak dalej.
Chwalebne intencje – nie da się ukryć. Ale nietrudno jest wskazać przynajmniej dwa błędy takiego rozumowania. Pierwszym jest założenie, że każdą wiedzę i każdą umiejętność da się przekazać innym – czyli że w ogóle istnieje “droga na skróty”, niejako omijająca zdobywanie doświadczenia praktycznego w danej dziedzinie (w tym przypadku w programowaniu). Żeby stwierdzić, że to jednak nieprawda, wystarczy zastanowić się, czy ukończenie podstawowego kursu szachów u arcymistrza da nam od razu ELO 2500. Niestety w rzeczywistości dojście do takiego poziomu wymaga dziesiątków tysięcy rozegranych partii – i nic się na to nie poradzi.
Drugim błędem jest przyjmowanie, że przekazywane mądrości są faktycznie odpowiednie dla odbiorców. Nie, nie chcę wcale sugerować, że ktoś może mieć traumę z powodu wczesnych doświadczeń z hermetyzacją czy wzorcem fabryki ;) Raczej sugeruję, że przywiązując zbyt wielką wagę do przekazywanych zaleceń, początkujący mogą stracić wiele czasu na dopasowywanie swoich pierwszych programów do ich wysoko zawieszonej poprzeczki – najczęściej zupełnie niepotrzebnie. Gra w zgadywanie liczby, kółko i krzyżyk czy tetris nie potrzebują zwykle skomplikowanej, wewnętrznej architektury z dokładnie zaprojektowanymi relacjami między klasami i starannym oddzieleniem interfejsu od implementacji. Prosi się to bowiem o przytoczenie znanego powiedzenia na temat artylerii i pewnego gatunku insekta :)
Tak oto okazuje się więc, że zalecenia i rady tudzież – szumnie nazywane – standardy kodowania są “dziedziczone” przez początkujących programistów od autorów książek, kursów i tutoriali, którzy je tam umieszczają pomiędzy objaśnieniami kolejnych konstrukcji językowych czy elementów API. A że ponadto, jak opisałem wyżej, standardy te zbyt wcześnie poznane mogą być nie tyle nawet zbędne, co wręcz szkodliwe, pozwalam sobie nazwać je ‘chorobami dziedzicznymi’. Nic tak bowiem nie trafia do wyobraźni jak obrazowe porównanie ;>
Zakończyć chcę jednak optymistycznym akcentem. Otóż choroby te są jak najbardziej uleczalne. Terapia jest też nieskomplikowana i – jak sądzę – skuteczna. Trzeba po prostu kodować, kodować i jeszcze raz kodować :)
Ostatnio na forum Warsztatu wyrosła dyskusja wokół niepozornej instrukcji break
. Zaczęło się od zasłyszanej opinii, że korzystanie z niej jest objawem złego stylu programowania i że jest generalnie niezalecane. Argumentem na rzecz takiego twierdzenia jest to, że break
jest podobny w swoim działaniu do etykiet i “wyklętej” instrukcji goto
:
W sumie wyszła z tego całkiem długa polemika, która ujawniła kilka ciekawych faktów. Po pierwsze: przejrzystość kodu jest w całkiem dużym stopniu kwestią subiektywną i o ile jednemu może przeszkadzać brak (lub niewystarczająca ilość) komentarzy, to dla drugiego o wiele ważniejsze może być używanie lub nieużywanie określonych konstrukcji językowych. Po drugie: oprócz wspomnianego już goto
czy wywołanego we wspomnianym wątku break
a, do instrukcji potencjalnie niepożądanych swobodnie można zaliczyć bardzo wiele innych rozwiązań – zależnie od upodobań, poczucia ‘słuszności’, bieżącej fazy księżyca i pewnie mnóstwa jeszcze innych kryteriów. I tak dostało się chociażby pętlom “nieskończonym” (typu while(true)
lub for(;;)
), wspomniano coś o wyjątkach, a sam napomknąłem o instrukcji continue
lub “przedwczesnym” return
:
Teoretycznie można by wszystkie te konstrukcje usunąć w imię ideologicznej czystości programowania strukturalnego, w którym każdy blok ma dokładnie jedno wejście i wyjście. Ale tu na szczęście wkracza wniosek trzeci: języki programowania używane w praktyce nie są żadnymi doskonałymi modelami, nad którymi należy się zachwycać. To narzędzia i jako takie powinny być przede wszystkim wygodne w użytkowaniu.
Więc nie warto drzeć kotów o sens stosowania break
czy nawet goto
(która to instrukcja też ma swoje zastosowania) i wydawać ogólne sądy na temat ich ‘nieprofesjonalności’. Bo nawet jeśli ta czy inna instrukcja rzekomo jest zła, to korzystający z niej kod nadal może być dobry, czytelny i łatwy do modyfikacji… Łącznie z refaktoringiem, który tego czy innego break
a może przecież usunąć ;P