Wskaźniki funkcyjne

wskaźniki do funkcji

Programując w języku C nie sposób obejść się bez użycia wskaźników. W kursach, książkach dla początkujących to zagadnienie jest niemal zawsze opisywane, bo jest naprawdę ważne. Na początku nauki programowania wskaźniki same w sobie sprawiają problem w zrozumieniu, przez co takie narzędzie jak wskaźniki funkcyjne (ang. function pointer) jest bardzo często pomijane. Ma to swoje uzasadnienie – autorzy nie chcą tego jeszcze bardziej komplikować. Niestety potem początkujący programista nawet nie wie o ich istnieniu. A szkoda, bo dają ogromne możliwości.

Czym są wskaźniki funkcyjne?

Funkcje podobnie jak zmienne umieszczane są w pamięci – mają więc swój adres. Jak łatwo się domyślić wskaźniki funkcyjne są wskaźnikami, które wskazują właśnie na ten obszar pamięci, w którym znajduje się funkcja. Wskaźnik na funkcję różni się jednak trochę od wskaźnika na zmienną. Po pierwsze nie obowiązują tutaj zasady arytmetyki wskaźnikowej – każda funkcja może zajmować przecież różną ilość pamięci, której z góry nie znamy. Różni się także deklaracja takiego wskaźnika, która wygląda następująco:

Przykład deklaracji:

Jak widać deklaracja jest bardzo podobna do deklaracji zwykłej funkcji. Najistotniejszy jest tutaj nawias obejmujący wskaźnik do funkcji. Co gdyby jego zabrakło?

W takim wypadku foo jest funkcją przyjmującą argument typu int i zwracająca wskaźnik do zmiennej typu char, a nie o to przecież nam tutaj chodzi.

Przeanalizujmy teraz prosty przykład, żeby zobaczyć jak z takich wskaźników korzystać:

Jak widać przypisanie adresu funkcji do wskaźnika wygląda tak samo jak przypisanie adresu zmiennej do zwykłych wskaźników. Poprawny jest także zapis bez ampersandu:

Przypisując do wskaźnika nazwę funkcji bez nawiasów informujemy kompilator, że chodzi nam o adres funkcji.

Wywołanie funkcji zapisanej do wskaźnika wygląda jak wywołanie zwykłej funkcji. Kiedy dodajemy nawiasy kompilator wie, że chodzi nam o wywołanie funkcji. Jest jeszcze inna, równoznaczna metoda wywołania tej funkcji:

Taki zapis może wydawać się trochę bardziej złożony, ale ma swoje plusy – gdy po jakimś czasie wracamy do kodu, to od razu widzimy, że foo jest wskaźnikiem do funkcji, a nie zwykłą funkcją.

Co istotne, typ funkcji, który określany jest poprzez ilość i typy przyjmowanych argumentów oraz typ wartości zwracanej powinien być zgodny z typem wskaźnika.

Wskaźnik funkcyjny może być też argumentem zwykłej funkcji. Rozważmy przykład, w którym tworzymy funkcję inicjalizującą tablicę. Jednak chcemy, aby w zależności od potrzeby umożliwiała ona wypełnienie tablicy wartościami zerowymi lub losowymi. Możemy to zrealizować w następujący sposób:

Inny przykład:

Czasami deklaracje wskaźników funkcyjnych mogą być bardzo złożone i wyglądać na mocno skomplikowane. Jako ciekawostkę dodam, że jeśli chcemy, to w takich sytuacjach możemy stworzyć nowy typ funkcyjny:

Dzięki czemu potem deklaracja takiego wskaźnika może wyglądać następująco:

Korzystając ze stworzonego typu, nagłówek funkcji oblicz() z poprzedniego przykładu mógłby wyglądać następująco:

Na co należy uważać?

Wskaźniki funkcyjne dają bardzo duże możliwości, ale niosą ze sobą pewne zagrożenia. Aby się przed nimi uchronić warto zastosować następujące punkty:

  • przy definicji wskaźnika funkcyjnego należy przypisać mu wartość NULL lub jakąś wartość domyślną,
  • przed wywołaniem funkcji przy pomocy wskaźnika należy sprawdzić czy adres, na który wskazuje jest różny od NULL

Gdzie wykorzystać?

Przedstawione tutaj przykłady były trywialne – największe plusy stosowania wskaźników funkcyjnych dostrzeżesz dopiero przy trochę bardziej złożonych projektach. Podobnie jak to było przy podstawowych wskaźnikach. Dzięki wskaźnikom funkcyjnym mamy m.in. możliwość prostej i szybkiej zmiany działania programu w locie, bez stosowania tysiąca instrukcji warunkowych if…else, albo instrukcji wyboru switch…case, dzięki czemu maleje ilość kodu, a i procesor nie marnuje czasu na przechodzenie przez kolejne warunki. Wystarczy, że w którymś miejscu programu wywołamy funkcję przy użyciu wskaźnika, a potem podmieniamy przechowywany adres funkcji na inny, aby zmienić działanie programu. W artykułach „Menu na LCD #3: implementacja” i „Menu na LCD #4: implementacja (rozszerzenie)” na przykładzie pokazałem jak bardzo pomocne i wygodne potrafią być 🙂

Ponadto istnieje możliwość przekazania funkcji jako argumentu, czemu zawdzięczamy bardzo istotny mechanizm – wywołania zwrotne (ang. callback).

Wywołania zwrotne (ang. callback)

Wywołania zwrotne są mechanizmem, w którym funkcja jest przekazywane do innej części kodu (zazwyczaj niskopoziomowej) jako argument – np. do jakiejś funkcji bibliotecznej. Przekazana funkcja zostanie wywołana gdy funkcja biblioteczna uzna to za słuszne – wystąpi jakieś zdarzenie (ang. event), np. został wciśnięty przycisk, nastąpił obrót enkodera, odebrano 100 bajtów danych, itd… przykłady można by było mnożyć i mnożyć. Graficznie prezentuje się to następująco:

źródło: wikipedia.com

Cały mechanizm funkcji zwrotnych zazwyczaj składa się z trzech fragmentów:

  • definicji funkcji zwrotnej,
  • rejestracji funkcji zwrotnej
  • wywołania funkcji w obsłudze zdarzenia

Najlepiej będzie przedstawić to na jakimś przykładzie. Załóżmy, że pewien programista tworzy moduł oprogramowania lub bibliotekę niskopoziomową, np. do obsługi enkodera. Z jego punktu widzenia, istotne jest, aby możliwe było wykrycie obrotu, jego kierunku oraz zliczanie impulsów w celu określenia jak duży nastąpił obrót. Nie interesuje go jaka funkcjonalność (np. zwiększanie głośności, jasności) będzie realizowana, ale wie, że jakaś funkcja powinna być wywołana w przypadku wykrycia obrotu enkodera. Wystarczy więc, że dokona wywołania funkcji przy pomocy wskaźnika funkcyjnego. Do określania, jaka funkcja ma zostać wywołana implementuje się dodatkową, oddzielną funkcję, która umożliwia tzw. rejestrację funkcji zwrotnej. Funkcja rejestrująca, jako argument przyjmuje adres funkcji zwrotnej, czyli tej, która będzie miała być wywoływana (np. realizująca zwiększenie głośności) i przypisuje jej adres do wskaźnika wykorzystywanego w obsłudze zdarzenia.

Implementacja funkcjonalności jest traktowana jako oddzielny moduł, który może zostać zaimplementowany przez kogoś innego. Wystarczy więc napisać odpowiednią funkcję, a następnie wywołać funkcję rejestrującą. W taki sposób możemy dopasować działanie biblioteki do naszych potrzeb bez konieczności jej modyfikowania.

Podsumowanie

Mam nadzieję, że dowiedziałeś się czegoś ciekawego i znajdziesz wiele zastosowań dla przedstawionych tu informacji. Jak widzisz, stosowanie wskaźników funkcyjnych może być bardzo pomocne, a do tego kod staje się czysty i przenośny. Zachęcam do dokładniejszego przyjrzenia się temu tematowi 😉

Zostaw komentarz!

Podobał Ci się wpis? Masz jakieś pytania lub uwagi? Zostaw mi proszę komentarz, a jeśli uważasz artykuł za wartościowy – podaj go dalej 🙂 Fajnie by było gdyby trafił do większej ilości osób. Pozdro i do następnego! 😉