BAZA WIEDZY
KURSY
Bazy danych w PHP
Kurs AdvancedAJAX
Kurs ASP
Kurs ASP.NET
Kurs C++
Kurs CSS
Kurs HTML
Kurs HTML drugi
Kurs JavaScript
Kurs MySQL
Kurs PHP
Kurs RSS
Kurs XHTML
Obiekty DOM
MANUALE
CSS1 - W3C
DOM - w budowie
PHP 2005
PHP 2006
Wyrażenia regularne
SHOUTBOX
STAT
Online: 34 | UU: 523

Wskaźniki do funkcji

Myśląc o tym, co jest przechowywane w pamięci operacyjnej, zwykle wyobrażamy sobie różne dane programu: zmienne, tablice, struktury itp. One stanowią informacje reprezentowane w komórkach pamięci, na których aplikacja wykonuje swoje działania.

Cała pamięć operacyjna jest więc usiana danymi każdego z aktualnie pracujących programów.

Hmm. Czy aby na pewno o czymś nie zapomnieliśmy? A co z samymi programami?! Kod aplikacji jest przecież pewną porcją binarnych danych, zatem i ona musi się gdzieś podziać. Przez większość czasu egzystuje wprawdzie na dysku twardym w postaci pliku (zwykle o rozszerzeniu EXE), ale dla potrzeb wykonywania kodu jest to z pewnością zbyt wolne medium. Gdyby system operacyjny co rusz sięgał do pliku w czasie działania programu, wtedy na pewno wszelkie czynności ciągnęłyby się niczym toffi i przyprawiały zniecierpliwionego użytkownika o białą gorączkę. Co więc zrobić z tym fantem?.

Rozsądnym wyjściem jest umieszczenie w pamięci operacyjnej także kodu działającej aplikacji. Dostęp do nich jest wówczas wystarczająco szybki, aby programy mogły działać w normalnym tempie i bez przeszkód wykonywać swoje zadania. Pamięć RAM jest przecież stosunkowo wydajna, wielokrotnie bardziej niż nawet najszybsze dyski twarde.

Tak więc podczas uruchamiania programu jego kod jest umieszczany wewnątrz pamięci operacyjnej. Każdy podprogram, każda funkcja, a nawet każda instrukcja otrzymują wtedy swój unikalny adres , zupełnie jak zmienne. Maszynowy kod binarny jest bowiem także swoistego rodzaju danymi. Z tych danych korzysta system operacyjny (glównie poprzez procesor), wykonując kolejne instrukcje aplikacji. Wiedza o tym, jaka komenda ma być za chwilę uruchomiona, jest przechowywana właśnie w postaci jej adresu - czyli po prostu wskaźnika .

Nam zwykle nie jest potrzebna aż tak dokładna lokalizacja jakiegoś wycinka kodu w naszej aplikacji, szczególnie jeżeli programujemy w języku wysokiego poziomu, którym jest z pewnością C++. Trudno jednak pogardzić możliwością uzyskania adresu funkcji w programie, jeśli przy pomocy tegoż adresu (oraz kilku dodatkowych informacji, o czym za chwilę) można ową funkcję swobodnie wywoływać. C++ oferuje więc mechanizm wskaźników do funkcji , który udostępnia taki właśnie potencjał.

Wskaźnik do funkcji (ang. pointer to function ) to w C++ zmienna, która przechowuje adres , pod jakim istnieje w pamięci operacyjnej dana funkcja .

Wiem, że początkowo może być ci trudno uświadomić sobie, w jaki sposób kod programu jest reprezentowany w pamięci i jak wobec tego działają wskaźniki na funkcje. Dokładnie wyjaśnienie tego faktu wykracza daleko poza ramy tego rozdziału, kursu czy nawet programowania w C++ jako takiego (oraz, przyznam szczerze, częściowo także mojej wiedzy :D). Dotyka to już bowiem niskopoziomowych aspektów działania aplikacji.

Niemniej postaram się przystępnie wyjaśnić przynajmniej te zagadnienia, które będą nam potrzebne do sprawnego posługiwania się wskaźnikami do funkcji. Zanim to się stanie, możesz myśleć o nich jako o swoistych łączach do funkcji, podobnych w swych założeniach do skrótów, jakie w systemie Windows można tworzyć w odniesieniu do aplikacji. Tutaj natomiast mamy do czynienia z pewnego rodzaju "skrótami" do pojedynczych funkcji; przy ich pomocy możemy je bowiem wywoływać niemal w ten sam sposób, jak to czynimy bezpośrednio.

Omawianie wskaźników do funkcji zaczniemy nieco od tyłu, czyli od bytów na które one wskazują - a więc od funkcji właśnie. Przypomnimy sobie, cóż takiego charakteryzuje funkcję oraz powiemy sobie, jakie jej cechy będą szczególne istotne w kontekście wskaźników.

Potem rzecz jasna zajmiemy się używaniem wskaźników do funkcji w naszych własnych programach, poczynając od deklaracji aż po wywoływanie funkcji za ich pośrednictwem. Na koniec uświadomimy sobie także kilka zastosowań tej ciekawej konstrukcji programistycznej.

Cechy charakterystyczne funkcji

Różnego rodzaju funkcji - czy to własnych, czy też wbudowanych w język - używaliśmy dotąd tak często i w takich ilościach, że chyba nikt nie ma najmniejszych wątpliwości, czym one są, do czego służą i jaka jest ich rola w programowaniu.

Teraz więc przypomnimy sobie jedynie te własności funkcji, które będą dla nas istotne przy omawianiu wskaźników. Nie omieszkamy także poznać jeszcze jednego aspektu funkcji, o którym nie mieliśmy okazji dotychczas mówić. Wszystko to pomoże nam zrozumieć koncepcję i stosowanie wskaźników na funkcje.

Trzeba tu zaznaczyć, że w tym momencie absolutnie nie chodzi nam o to, jakie instrukcje mogą zawierać funkcje. Przeciwnie, nasza uwaga będzie skoncentrowana wyłącznie na prototypie funkcji , jej "wizytówce". Dla wskaźników na funkcje pełni on bowiem podobne posługi, jak typ danych dla wskaźników na zmienne. Sama zawartość bloku funkcji, podobnie jak wartość zmiennej, jest już zupełnie jednak inną ("wewnętrzną") sprawą.

Na początek przyjrzyjmy się składni prototypu (deklaracji) funkcji. Wydaje się, że jest ona doskonale nam znana, jednak tutaj przedstawimy jej pełną wersję:

zwracany_typ [ konwencja_wywołania ] nazwa_funkcji ( [ parametry ] );

Każdemu z jej elementów przypatrzymy się natomiast w osobnym paragrafie.

Typ wartości zwracanej przez funkcję

Wiele języków programowania rozróżnia dwa rodzaje podprogramów. I tak procedury mają za zadanie wykonanie jakichś czynności, zaś funkcje są przeznaczone do obliczania pewnych wartości. Dla obu tych rodzajów istnieją zwykle odmienne rozwiązania składniowe, na przykład inne słowa kluczowe.

W C++ jest nieco inaczej: tutaj zawsze mamy do czynienia z funkcjami, gdyż bezwględnie konieczne jest określenie typu wartości, zwracanej przez nie. Naturalnie może być nim każdy typ, który mógłby również wystepować w deklaracji zmiennej: od typów wbudowanych, poprzez wskaźniki, referencje, aż do definiowanych przez użytkownika typów wyliczeniowych, klas czy struktur (lecz nie tablic).

Specjalną rolę pełni tutaj typ void ('pustka'), który jest synonimem 'niczego'. Nie można wprawdzie stworzyć zmiennych należących do tego typu, jednak możliwe jest uczynienie go typem zwracanym przez funkcję. Taka funkcją będzie zatem "zwracać nic", czyli po prostu nic nie zwracać; można ją więc nazwać procedurą.

Instrukcja czy wyrażenie

Od kwestii, czy funkcja zwraca jakąś wartość czy nie, zależy to, jak możemy nazwać jej wywołanie: instrukcją lub też wyrażeniem . Różnica pomiędzy tymi dwoma elementami języka programowania jest dość oczywista: instrukcja to polecenie wykonania jakichś działań, zaś wyrażenie - obliczenia pewnej wartości; wartość ta jest potem reprezentowana przez owo wyrażenie.

C++ po raz kolejny raczy nas tu niespodzianką. Otóż w tym języku niemal wszystko jest wyrażeniem - nawet taka wybitnie "instrukcyjna" działalność jak choćby przypisanie. Rzadko jednak używamy jej w takim charakterze, zaś o wiele częściej jako zwykłą instrukcję i jest to wówczas całkowicie poprawne.

Wyrażenie może być w programowaniu użyte jako instrukcja, natomiast instrukcja nie może być użyta jako wyrażenie.

Dla wyrażenia występującego w roli instrukcji jest wprawdzie obliczana jego wartość, ale nie zostaje potem do niczego wykorzystana. To raczej typowa sytuacja i chociaż może brzmi niepokojąco, większość kompilatorów nigdy o niej nie ostrzega i trudno poczytywać to za ich beztroskę.

Pedantyczni programiści stosują jednak niecodzienny zabieg rzutowania na typ void dla wartości zwróconej przez funkcję użytą w charakterze instrukcji. Nie jest to rzecz jasna konieczne, ale niektórzy twierdzą, iż można w ten sposób unikać nieporozumień.

Przeciwny przypadek: kiedy staramy się umieścić wywołanie procedury (niezwracającej żadnej wartości) wewnątrz normalnego wyrażenia, jest już w oczywisty sposób nie do przyjęcia. Takie wywołanie nie reprezentuje bowiem żadnej wartości, która mogłaby być użyta w obliczeniach. Można to również interpretować jako niezgodność typów, ponieważ void jako typ pusty jest niekompatybilny z żadnym innym typem danych.

Widzimy zatem, że kwestia zwracania lub niezwracania przez funkcję wartości oraz jej rodzaju jest nierzadko bardzo ważna.

Konwencja wywołania

Trochę trudno w to uwierzyć, ale podanie (zdawałoby się) wszystkiego, co można powiedzieć o danej funkcji: jej parametrów, wartości przezeń zwracanej, nawet nazwy - nie wystarczy kompilatorowi do jej poprawnego wywołania. Będzie on aczkolwiek wiedział, co musi zrobić, ale nikt mu nie powie, jak ma to zrobić.

Cóż to znaczy?. Celem wyjaśnienia porównajmy całą sytuację do telefonowania. Gdy mianowicie chcemy zadzwonić pod konkretny numer telefonu, mamy wiele możliwych dróg uczynienia tego. Możemy zwyczajnie pójść do drugiego pokoju, podnieść słuchawkę stacjonarnego aparatu i wystukać odpowiedni numer. Możemy też siegnąc po telefon komórkowy i użyć go, wybierając na przykład właściwą pozycję z jego książki adresowej. Teoretycznie możemy też wybrać się do najbliższej budki telefonicznej i skorzystać z zainstalowanego tam aparatu. Wreszcie, możliwe jest wykorzystanie modemu umieszczonego w komputerze i odpowiedniego oprogramowania albo też dowolnej formy dostępu do globalnej sieci oraz protokołu VoIP ( Voice over Internet Protocol ).

Technicznych możliwości mamy więc mnóstwo i zazwyczaj wybieramy tę, która jest nam w aktualnej chwili najwygodniejsza. Zwykle też osoba po drugiej stronie linii nie odczuwa przy tym żadnej różnicy.

Podobnie rzecz ma się z wywoływaniem funkcji. Znając jej miejsce docelowe (adres funkcji w pamięci) oraz ewentualne dane do przekazania jej w parametrach, możliwe jest zastosowanie kilku dróg osiągnięcia celu. Nazywamy je konwencjami wywołania funkcji.

Konwencja wywołania (ang. calling convention ) to określony sposób wywoływania funkcji, precyzujący przede wszystkim kolejność przekazywania jej parametrów.

Dziwisz się zapewne, dlaczego dopiero teraz mówimy o tym aspekcie funkcji, skoro jasno widać, iż jest on nieodzowny dla ich działania. Przyczyna jest prosta. Wszystkie funkcje, jakie samodzielnie wpiszemy do kodu i dla których nie określimy konwencji wywołania, posiadają domyślny jej wariant, właściwy dla języka C++. Jeżeli zaś chodzi o funkcje biblioteczne, to ich prototypy zawarte w plikach naglówkowych zawierają informacje o używanej konwencji. Pamiętajmy, że korzysta z nich głównie sam kompilator, gdyż w C++ wywołanie funkcji wygląda składniowo zawsze tak samo , niezależnie od jej konwencji. Jeżeli jednak używamy funkcji do innych celów niż tylko prostego przywoływania (a więc stosujemy choćby wskaźniki na funkcje), wtedy wiedza o konwencjach wywołania staje się potrzebna także i dla nas.

O czym mówi konwencja wywołania?

Jak już wspomniałem, konwencja wywołania determinuje głównie przekazywanie parametrów aktualnych dla funkcji, by mogła ona używać ich w swoim kodzie. Obejmuje to miejsce w pamięci , w którym są one tymczasowo przechowywane oraz porządek , w jakim są w tym miejscu kolejno umieszczane.

Podstawowym rejonem pamięci operacyjnej, używanym jako pośrednik w wywołaniach funkcji, jest stos . Dostęp do tego obszaru odbywa się w dość osobliwy sposób, który znajdują zresztą odzwierciedlenie w jego nazwie. Stos charakteryzuje się bowiem tym, że gdy położymy na nim po kolei kilka elementów, wtedy mamy bezpośredni dostęp jedynie do tego ostatniego , położonego najpóźniej (i najwyżej). Jeżeli zaś chcemy dostać się do obiektu znajdującego się na samym dole, wówczas musimy zdjąć po kolei wszystkie pozostałe elementy, umieszczone na stosie później. Czynimy to więc w odwrotnej kolejności niż następowało ich odkładanie na stos.

Dobrą przykładem stosu może być hałda książek, piętrząca się na twoim biurku ;D

Jeśli zatem wywołujący funkcję (ang. caller ) umieści na stosie jej parametry w pewnym porządku (co zresztą czyni), to sama funkcja (ang. callee - wywoływana albo routine - podprogram) musi je pozyskać w kolejności odwrotnej, aby je właściwie zinterpretować. Obie strony korzystają przy tym z informacji o konwencji wywołania, lecz w opisach "katalogowych" poszczególnych konwencji podaje się wyłącznie porządek stosowany przez wywołującego , a więc tylko kolejność odkładania parametrów na stos. Kolejność ich podejmowania z niego jest przecież dokładnie odwrotna.

Nie myśl jednak, że kompilatory dokonują jakichś złożonych permutacji parametrów funkcji podczas ich wywoływania. Tak naprawdę istnieją jedynie dwa porządki, które mogą być kanwą dla konwencji i stosować się dla każdej funkcji bez wyjątku.

Można mianowicie podawać parametry wedle ich deklaracji w prototypie funkcji, czyli od lewej do prawej strony. Wówczas to wywołujący jest w uprzywilejowanej pozycji, gdyż używa bardziej naturalnej kolejności; sama funkcja musi użyć odwrotnej. Drugi wariant to odkładanie parametrów na stos w odwrotnej kolejności niż w deklaracji funkcji; wtedy to funkcja jest w wygodniejszej sytuacji.

Oprócz stosu do przekazywania parametrów można też używać rejestrów procesora , a dokładniej jego czterech rejestrów uniwersalnych. Im więcej parametrów zostanie tam umieszczonych, tym szybsze powinno być (przynajmniej w teorii) wywołanie funkcji.

Typowe konwencje wywołania

Gdyby każdy programista ustalał własne konwencje wywołania funkcji (co jest teoretycznie możliwe), to oczywiście natychmiast powstałby totalny rozgardiasz w tej materii. Konieczność uwzględniania upodobań innych koderów byłaby z pewnością niezwykle frustrująca.

Za sprawą języków wysokiego poziomu nie ma na szczęście aż tak wielkich problemów z konwencjami wywołania. Jedynie korzystając z kodu napisanego w innym języku trzeba je uwzględniać. W zasadzie więc zdarza się to dość często, ale w praktyce cały wysiłek włożony w zgodność z konwencjami ogranicza się co najwyżej do dodania odpowiedniego słowa kluczowego do prototypu funkcji - w miejsce, które oznaczyłem w jego składni jako konwencja_wywołania . Często nawet i to nie jest konieczne, jako że prototypy funkcji oferowanych przez przeróżne biblioteki są umieszczane w ich plikach nagłówkowych, zaś zadanie programisty-użytkownika ogranicza się jedynie do włączenia tychże nagłówków do własnego kodu.

Kompilator wykonuje zatem sporą część pracy za nas. Warto jednak przynajmniej znać te najczęściej wykorzystywane konwencje wywołania, a nie jest ich wcale aż tak dużo. Poniższa lista przedstawia je wszystkie:

  • cdecl - skrót od C declaration ('deklaracja C') . . Zgodnie z nazwą jest to domyślna konwencja wywołania w językach C i C++. W Visual C++ można ją jednak jawnie określić poprzez słowo kluczowe __cdecl . Parametry są w tej konwencji przekazywane na stos w kolejności od prawej do lewej, czyli odwrotnie niż są zapisane w deklaracji funkcji
  • stdcall - skrót od Standard Call ('standardowe wywołanie'). Jest to konwencja zbliżona do cdecl , posługuje się na przykład tym samym porządkiem odkładania parametrów na stos. To jednocześnie niepisany standard przy pisaniu kodu, który w skompilowanej formie będzie używany przez innych. Korzysta z niego więc chociażby system Windows w swych funkcjach API.
    W Visual C++ konwencji tej odpowiada słowo __stdcall
  • fastcall ('szybkie wywołanie') jest, jak nazwa wskazuje, zorientowany na szybkość działania. Dlatego też w miarę możliwości używa rejestrów procesora do przekazywania parametrów funkcji.
    Visual C++ obsługuję tą konwencję poprzez słówko __fastcall
  • pascal budzi słuszne skojarzenia z popularnym ongiś językiem programowania. Konwencja ta była w nim wtedy intensywnie wykorzystywana, lecz dzisiaj jest już przestarzała i coraz mniej kompilatorów (wszelkich języków) wspiera ją
  • thiscall to specjalna konwencja wywoływania metod obiektów w języku C++. Funkcje wywoływane z jej użyciem otrzymują dodatkowy parametr, będący wskaźnikiem na obiekt danej klasy 1 . Nie występuje on na liście parametrów w deklaracji metody, ale jest dostępny poprzez słowo kluczowe this . Oprócz tej szczególnej właściwości thiscall jest identyczna z stdcall .
    Ze względu na specyficzny cel istnienia tej konwencji, nie ma możliwości zadeklarowania zwykłej funkcji, która by jej używała. W Visual C++ nie odpowiada jej więc żadne słowo kluczowe

A zatem dotychczas (nieświadomie!) używaliśmy tylko dwóch konwencji: cdecl dla zwykłych funkcji oraz thiscall dla metod obiektów. Kiedy zaczniemy naukę programowania aplikacji dla Windows, wtedy ten wachlarz zostanie poszerzony. W każdym przypadku składnia wywołania funkcji w C++ będzie jednak identyczna.

1 Jest on umieszczany w jednym z rejestrów procesora.

Nazwa funkcji

To zadziwiające, że chyba najważniejsza dla programisty cecha funkcji, czyli jej nazwa , jest niemal zupełnie nieistotna dla działającej aplikacji!. Jak już bowiem mówiłem, "widzi" ona swoje funkcje wyłącznie poprzez ich adresy w pamięci i przy pomocy tych adresów ewentualnie wywołuje owe funkcje.

Można dywagować, czy to dowód na całkowity brak skrzyżowania między drogami człowieka i maszyny, ale fakt pozostaje faktem, zaś jego przyczyna jest prozaicznie pragmatyczna. Chodzi tu po prostu o wydajność: skoro funkcje programu są podczas jego uruchamiania umieszczane w pamięci operacyjnej (można ładnie powiedzieć: mapowane ), to dlaczego system operacyjny nie miałby używać wygenerowanych przy okazji adresów, by w razie potrzeby rzeczone funkcje wywoływać? To przecież proste i szybkie rozwiązanie, naturalne dla komputera i niewymagające żadnego wysiłku ze strony programisty. A zatem jest ono po prostu dobre :)

Rozterki kompilatora i linkera

Jedynie w czasie kompilacji kodu nazwy funkcji mają jakieś znaczenie. Kompilator musi bowiem zapewnić ich unikalność w skali całego projektu, tj. wszystkich jego modułów. Nie jest to wcale proste, jeżeli przypomnimy sobie o funkcjach przeciążanych, które z założenia mają te same nazwy. Poza tym funkcje o tej samej nazwie mogą też występować w różnych zakresach: jedna może być na przykład metodą jakiejś klasy, zaś druga zwyczajną funkcją globalną.

Kompilator rozwiązuje te problemy, stosując tak zwane dekorowanie nazw . Wykorzystuje po prostu dodatkowe informacje o funkcji (jej prototyp oraz zakres, w którym została zadeklarowana), by wygenerować jej niepowtarzalną, wewnętrzną nazwę. Zawiera ona wiele różnych dziwnych znaków w rodzaju @ , ^ , ! czy _ , dlatego właśnie jest określana jako nazwa dekorowana .

Wywołania z użyciem takich nazw są umieszczane w skompilowanych modułach. Dzięki temu linker może bez przeszkód połączyć je wszystkie w jeden plik wykonywalny całego programu.

Parametry funkcji

Ogromna większość funkcji nie może obyć się bez dodatkowych danych, przekazywanych im przy wywoływaniu. Pierwsze strukturalne języki programowania nie oferowały żadnego wspomagania w tym zakresie i skazywały na korzystanie wyłącznie ze zmiennych globalnych. Bardziej nowoczesne produkty pozwalają jednak na deklarację parametrów funkcji, co też niejednokrotnie czynimy w praktyce.

Aby wywołać funkcję z parametrami, kompilator musi znać ich liczbę oraz typ każdego z nich. Informacje te podajemy w prototypie funkcji, zaś w jej kodzie zwykle nadajemy także nazwy poszczególnym parametrom, by móc z nich później korzystać.

Parametry pełnią rolę zmiennych lokalnych w bloku funkcji - z tą jednak różnicą, że ich początkowe wartości pochodzą z zewnątrz , od kodu wywołującego funkcję. Na tym wszakże kończą się wszelkie odstępstwa, ponieważ parametrów możemy używać identycznie, jak gdyby było one zwykłymi zmiennymi odpowiednich typów. Po zakończeniu wykonywania funkcji są one niszczone, nie pozostawiając żadnego śladu po ewentualnych operacjach, które mogły być na nich dokonywane kodzie funkcji.

Wnioskujemy stąd, że:

Parametry funkcji są w C++ przekazywane przez wartości .

Reguła ta dotyczy wszystkich typów parametrów , mimo że w przypadku wskaźników oraz referencji jest ona pozornie łamania. To jednak tylko złudzenie. W rzeczywistości także i tutaj do funkcji są przekazywane wyłącznie wartości - tyle tylko, że owymi wartościami są tu adresy odpowiednich komórek w pamięci. Za ich pośrednictwem możemy więc uzyskać dostęp do rzeczonych komórek, zawierających na przykład jakieś zmienne. Gdy dodatkowo korzystamy z referencji, wtedy nie wymaga to nawet specjalnej składni. Trzeba być jednak świadomym, że zjawiska te dotyczą samej natury wskaźników czy też referencji, nie zaś parametrów funkcji! Dla nich bowiem zawsze obowiązuje przytoczona wyżej zasada przekazywania poprzez wartość.

Używanie wskaźników do funkcji

Przypomnieliśmy sobie i uzupełniliśmy wszystkie niezbędne wiadomości funkcjach, konieczne do poznania i stosowania wskaźników na nie. Teraz więc możemy już przejść do właściwej części tematu.

Typy wskaźników do funkcji

Jakkolwiek wskaźniki są przede wszystkim adresami miejsc w pamięci operacyjnej, niemal wszystkie języki programowania oraz ich kompilatory wprowadzają pewne dodatkowe informacje, związane ze wskaźnikami. Chodzi tu głównie o typ wskaźnika .

W przypadku wskaźników na zmienne był on pochodną typu zmiennej, na którą dany wskaźnik pokazywał. Podobne pojęcie istnieje także dla wskaźników do funkcji - w tym wypadku możemy więc mówić o typie funkcji , na które wskazuje określony wskaźnik.

Własności wyróżniające funkcję

Co jednak mamy rozumieć pod pojęciem "typ funkcji"? W jaki sposób funkcja może w ogóle być zakwalifikowana do jakiegoś rodzaju?.

W odpowiedzi może nam znowu pomóc analogia do zmiennych. Otóż typ zmiennej określamy w momencie jej deklaracji - jest nim w zasadzie cała ta deklaracja z wyłączeniem nazwy . Określa ona wszystkie cechy deklarowanej zmiennej, ze szczególnym uwzględnieniem rodzaju informacji, jakie będzie ona przechowywać.

Typu funkcji możemy zatem również szukać w jej deklaracji, czyli prototypie. Kiedy bowiem wyłączymy z niego nazwę funkcji, wtedy pozostałe składniki wyznaczą nam jej typ. Będą to więc kolejno:

  • typ wartości zwracanej przez funkcję

  • konwencja wywołania funkcji

  • parametry, które funkcja przyjmuje

Wraz z adresem danej funkcji stanowi to wystarczający zbiór informacji dla kompilatora, na podstawie których może on daną funkcję wywołać.

Typ wskaźnika do funkcji

Posiadając wyliczone wyżej wiadomości na temat funkcji, możemy już bez problemu zadeklarować właściwy wskaźnik na nią. Typ tego wskaźnika będzie więc oparty na typie funkcji - to samo zjawisko miało miejsce także dla zmiennych.

Typ wskaźnika na funkcję określa typ zwracanej wartości, konwencję wywołania oraz listę parametrów funkcji, na które wskaźnik może pokazywać i które mogą być za jego pośrednictwem wywoływane.

Wiedząc to, możemy przystąpić do poznania sposóbu oraz składni, poprzez które język C++ realizuje mechanizm wskaźników do funkcji.

Wskaźniki do funkcji w C++

Deklarując wskaźnik do funkcji, musimy podać jego typ, czyli te trzy cechy funkcji, o których już kilka razy mówiłem. Jednocześnie kompilator powinien wiedzieć, że ma do czynienia ze wskaźnikiem, a nie z funkcją jako taką. Oba te wymagania skutkują specjalną składnią deklaracji wskaźników na funkcje w C++.

Zacznijmy zatem od najprostszego przykładu. Oto deklaracja wskaźnika do funkcji, która nie przyjmuje żadnych parametrów i nie zwraca też żadnego rezultatu 1 :

void (*pfnWskaznik)();

Jesteśmy teraz władni użyć tego wskaźnika i wywołać za jego pośrednictwem funkcję o odpowiednim nagłówku (czyli nic niebiorącą oraz nic niezwracającą). Może to wyglądać chociażby tak:

#include <iostream>
// funkcja, którą będziemy wywoływać
void Funkcja()
{
std::cout << "Zostalam wywolana!" ;
}
void main()
{
// deklaracja wskaźnika na powyższą funkcję
void (*pfnWskaznik)();
// przypisanie funkcji do wskaźnika
pfnWskaznik = &Funkcja;
// wywołanie funkcji poprzez ten wskaźnik
(*pfnWskaznik)();
}

Ponownie, tak samo jak w przypadku wskaźników na zmienne, moglibyśmy wywołać naszą funkcję bezpośrednio. Pamiętasz jednakże o korzyściach, jakie daje wykorzystanie wskaźników - większość z nich dotyczy także wskaźników do funkcji. Ich użycie jest więc często bardzo przydatne.

Omówmy zatem po kolei wszystkie aspekty wykorzystania wskaźników do funkcji w C++.

1 Posiada też domyślną w C++ konwencję wywołania, czyli cdecl . Później zobaczymy przykłady wskaźników do funkcji, wykorzystujących inne konwencje.

Od funkcji do wskaźnika na nią

Deklaracja wskaźnika do funkcji jest w C++ dość nietypową czynnością. Nie przypomina bowiem znanej nam doskonale deklaracji w postaci:

typ_zmiennej nazwa_zmiennej ;

Zamiast tego nazwa wskaźnika jest niejako wtrącona w typ funkcji, co w pierwszej chwili może być nieco mylące. Łatwo jednak można zrozumieć taką formę deklaracji, jeżeli porównamy ją z prototypem funkcji, np.:

float Funkcja( int );

Otóż odpowiadający mu wskaźnik, który mógłby pokazywać na zadeklarowaną wyżej funkcję Funkcja() , zostanie wprowadzony do kodu w ten sposób:

float (*pfnWskaznik)( int );

Nietrudno zauważyć różnicę: zamiast nazwy funkcji, czyli Funkcja , mamy tutaj frazę (*pfnWskaznik) , gdzie pfnWskaznik jest oczywiście nazwą zadeklarowanego właśnie wskaźnika. Może on pokazywać na funkcje przyjmujące jeden parametr typu int oraz zwracające wynik w postaci liczby typu float .

Ogólnie zatem, dla każdej funkcji o tak wyglądającym prototypie:

zwracany_typ nazwa_funkcji ( [ parametry ] );

deklaracja odpowiadającego jej wskaźnika jest bardzo podobna:

zwracany_typ (* nazwa_wskaźnika )( [ parametry ] );

Ogranicza się więc do niemal mechanicznej zmiany ściśle określonego fragmentu kodu.

Deklaracja wskaźnika na funkcję o domyślnej konwencji wywołania wygląda tak, jak jej prototyp, w którym nazwa_funkcji została zastąpiona przez (* nazwa_wskaźnika ) .

Ta prosta zasada sprawdza się w 99 procentach przypadków i będziesz z niej stale korzystał we wszystkich programach wykorzystujących mechanizm wskaźników do funkcji.

Trzeba jeszcze podkreślić znaczenie nawiasów w deklaracji wskaźników do funkcji. Mają one tutaj niebagatelną rolę składniową, gdyż ich brak całkowicie zmienia sens całej deklaracji. Gdybyśmy więc opuścili je:

void *pfnWskaznik(); // a co to jest?

cała instrukcja zostałaby zinterpretowana jako:

void * pfnWskaznik(); // to prototyp funkcji, a nie wskaźnik na nią!

i zamiast wskaźnika do funkcji otrzymalibyśmy funkcję zwracającą wskaźnik. Jest to oczywiście całkowicie niezgodne z naszą intencją.

Pamiętaj zatem o poprawnym umieszczaniu nawiasów w deklaracjach wskaźników do funkcji.

Specjalna konwencja

Opisanego powyżej sposobu tworzenia deklaracji nie można niestety użyć do wskaźników do funkcji, które stosują inną konwencję wywołania niż domyślna (czyli cdecl ) i zawierają odpowiednie słowo kluczowe w swoim naglówku czy też prototypie. W Visual C++ tymi słowami są __cdecl , __stdcall oraz __fastcall .

Przykład funkcji podpadającej pod te warunki może być następujący:

float __fastcall Dodaj( float fA, float fB) { return fA + fB; }

Dodatkowe słowo między zwracanym_typem oraz nazwą_funkcji całkowicie psuje nam schemat deklaracji wskaźników. Wynik jego zastosowania zostałby bowiem odrzucony przez kompilator:

float __fastcall (*pfnWskaznik)( float , float ); // BŁĄD!

Dzieje się tak, ponieważ gdy widzi on najpierw nazwę typu ( float ), a potem specyfikator konwencji wywołania ( __fastcall ), bezdyskusyjne interpretuje całą linijkę jako deklarację funkcji. Następującą potem niespodziewaną sekwencję (*pfnWskaznik) traktuje więc jako błąd składniowy.

By go uniknąć, musimy rozciągnąć nawiasy , w których umieszczamy nazwę wskaźnika do funkcji i "wziąć pod ich skrzydła" także określenie konwencji wywołania. Dzięki temu kompilator napotka otwierający nawias zaraz po nazwie zwracanego typu ( float ) i zinterpretuje całość jako deklarację wskaźnika do funkcji. Wygląda ona tak:

float ( __fastcall *pfnWskaznik)( float , float ); // OK

Ten, zdawałoby się, szczegół może niekiedy stanąć ością w gardle w czasie kompilacji programu. Wypadałoby więc o nim pamiętać.

Składnia deklaracji wskaźnika do funkcji

Obecnie możemy już zobaczyć ogólną postać deklaracji wskaźnika do funkcji. Jeżeli uważnie przestudiowałeś poprzednie akapity, to nie będzie on dla ciebie żadną niespodzianką. Przedstawia się zaś następująco:

zwracany_typ ( [ konwencja_wywołania ] * nazwa_wskaźnika )( [ parametry ] );

Pasujący do niego prototyp funkcji wygląda natomiast w ten sposób:

zwracany_typ [ konwencja_wywołania ] nazwa_funkcji ( [ parametry ] );

Z obu tych wzorców widać, że deklaracja wskaźnika do funkcji na podstawie jej prototypu oznacza wykonanie jedynie trzech prostych kroków:

  • zamiany nazwy_funkcji na nazwę_wskaźnika

  • dodania * (gwiazdki) przed nazwą_wskaźnika

  • ujęcia w parę nawiasów ewentualną konwencję_wywołania oraz nazwę_wskaźnika

Nie jest to więc tak trudna operacja, jak się niekiedy powszechnie sądzi.

Wskaźniki do funkcji w akcji

Zadeklarowanie wskaźnika to naturalnie tylko początek jego wykorzystania w programie. Aby był on użyteczny, powinniśmy przypisać mu adres jakiejś funkcji i skorzystać z niego celem wywołania tejże funkcji. Przypatrzmy się bliżej obu tym czynnościom.

W tym celu zdefiniujmy sobie następującą funkcję:

int PobierzLiczbe()
{
int nLiczba;
std::cout << "Podaj liczbe: " ;
std::cin >> nLiczba;
return nLiczba;
}

Właściwy wskaźnik, mogący pokazywać na tę funkcję, deklarujemy w ten oto (teraz już, mam nadzieję, oczywisty) sposób:

int (*pfnWskaznik)();

Jak każdy wskaźnik, zaraz po zadeklarowaniu nie pokazuje on na nic konkretnego - w tym przypadku na żadną konkretną funkcję. Musimy dopiero przypisać mu adres naszej przygotowanej funkcji PobierzLiczbe() . Czynimy to więc w następującej zaraz linijce kodu:

pfnWskaznik = &PobierzLiczbe;

Zwróćmy uwagę, że nazwa funkcji PobierzLiczbe() występuje tutaj bez, wydawałoby się - nieodłącznych, nawiasów okrągłych. Ich pojawienie się oznaczałoby bowiem wywołanie tej funkcji, a my przecież tego nie chcemy (przynajmniej na razie). Pragniemy tylko pobrać jej adres w pamięci , by móc jednocześnie przypisać go do swojego wskaźnika. Wykorzystujemy do tego znany już operator & .

Ale. niespodzianka! Ów operator tak naprawdę nie jest konieczny . Ten sam efekt osiągniemy również i bez niego:

pfnWskaznik = PobierzLiczbe;

Po prostu już sam brak nawiasów okrągłych () , wyróżniających wywołanie funkcji, jest wystarczająca wskazówką mówiącą kompilatorowi, iż chcemy pobrać adres funkcji o danej nazwie, nie zaś - wywoływać ją. Dodatkowy operator, chociaż dozwolony, nie jest więc niezbędny - wystarczy sama nazwa funkcji .

Czy nie mamy w związku z tym uczucia deja vu ? Identyczną sytuację mieliśmy przecież przy tablicach i wskaźnikach na nie. A zatem zasada, którą tam poznaliśmy, w poprawionej formie stosuje się również do funkcji:

Nazwa funkcji jest także wskaźnikiem do niej.

Nie musimy więc korzystać z operatora & , by pobrać adres funkcji.

W tym miejscu mamy już wskaźnik pfnWskaznik pokazujący na naszą funkcję PobierzLiczbe() . Ostatnim aktem będzie wywołanie jej za pośrednictwem tegoż wskaźnika, co czynimy poniższym wierszem kodu:

std::cout << (*pfnWskaznik)();

Liczbę otrzymaną z funkcji wypisujemy na ekranie, ale najpierw wywołujemy samą funkcję, korzystając między innymi z następnego znajomego operatora - dereferencji, czyli * .

Po raz kolejny jednak nie jest to niezbędne! Wywołanie funkcji przy pomocy wskaźnika można z równym powodzeniem zapisać też w takiej formie:

std::cout << pfnWskaznik();

Jest to druga konsekwencja faktu, iż funkcja jest reprezentowana w kodzie poprzez swój wskaźnik. Taki sam fenomen obserwowaliśmy i dla tablic.

Przykład wykorzystania wskaźników do funkcji

Wskaźniki do funkcji umożliwiają wykonywanie ogólnych operacji przy użyciu funkcji, których implementacja nie musi być im znana. Ważne jest, aby miały one nagłówek zgodny z typem wskaźnika.

Prawie podręcznikowym przykładem może być tu poszukiwanie miejsc zerowych funkcji matematycznej. Procedura takiego poszukiwania jest zawsze identyczna, również same funkcje mają nieodmiennie tę samą charakterystykę (pobierają liczbę rzeczywistą i taką też liczbę zwracają w wyniku). Możemy więc zaimplementować odpowiedni algorytm (tutaj jest to algorytm bisekcji 1 ) w sposób ogólny - posługując się wskaźnikami do funkcji.

Przykładowy program wykorzystujący tę technikę może przedstawiać się następująco:

// Zeros - szukanie miejsc zerowych funkcji
// granica toleracji

const double EPSILON = 0.0001 ;
// rozpietość badanego przedziału
const double PRZEDZIAL = 100 ;
// współczynniki funkcji f(x) = k * log_a(x - p) + q
double g_fK, g_fA, g_fP, g_fQ;
//
// badana funkcja

double f( double x) { return g_fK * (log(x - g_fP) / log(g_fA)) + g_fQ; }
// algorytm szukający miejsca zerowego danej funkcji w danym przedziale
bool SzukajMiejscaZerowego( double fX1, double fX2, // przedział
double (*pfnF)( double ), // funkcja
double * pfZero) // wynik
{
// najpierw badamy końce podanego przedziału

if (fabs(pfnF(fX1)) < EPSILON)
{
*pfZero = fX1;
return true ;
}
else if (fabs(pfnF(fX2)) < EPSILON)
{
*pfZero = fX2;
return true ;
}
// dalej sprawdzamy, czy funkcja na końcach obu przedziałów
// przyjmuje wartości różnych znaków
// jeżeli nie, to nie ma miejsc zerowych

if ((pfnF(fX1)) * (pfnF(fX2)) > 0 ) return false ;
// następnie dzielimy przedział na pół i sprawdzamy, czy w ten sposób
// nie otrzymaliśmy pierwiastka

double fXp = (fX1 + fX2) / 2 ;
if (fabs(pfnF(fXp)) < EPSILON)
{
*pfZero = fXp;
return true ;
}
// jeśli otrzymany przedział jest wystarczająco mały, to rozwiązaniem
// jest jego punkt środkowy

if (fabs(fX2 - fX1) < EPSILON)
{
*pfZero = fXp;
return true ;
}
// jezeli nadal nic z tego, to wybieramy tę połówkę przedziału,
// w której zmienia się znak funkcji

if ((pfnF(fX1)) * (pfnF(fXp)) < 0 )
fX2 = fXp;
else
fX1 = fXp;
// przeszukujemy ten przedział tym samym algorytmem
return SzukajMiejscaZerowego(fX1, fX2, pfnF, pfZero);
}
//
// funkcja main()

void main()
{
// (pomijam pobranie współczynników k, a, p i q dla funkcji)
/* znalezienie i wyświetlenie miejsca zerowego */
// zmienna na owo miejsce
double fZero;
// szukamy miejsca i je wyświetlamy
std::cout << std::endl;
if (SzukajMiejscaZerowego(g_fP > -PRZEDZIAL ? g_fP : -PRZEDZIAL,
PRZEDZIAL, f, &fZero))
std::cout << "f(x) = 0 <=> x = " << fZero << std::endl;
else
std::cout << "Nie znaleziono miejsca zerowego." << std::endl;
// czekamy na dowolny klawisz
getch();
}

Aplikacja ta wyszukuje miejsca zerowe funkcji określonej wzorem:

Niniejszy program jest przykładem zastosowania wskaźników na funkcje, a nie rozwiązywania równań. Jeśli chcemy wyliczyć miejsce zerowe powyższej funkcji, to znacznie lepiej będzie po prostu przekształcić ją, wyznaczając x :


Screen 38. Program poszukujący miejsc zerowych funkcji

Oczywiście w niniejszym programie najbardziej interesująca będzie dla nas funkcja SzukajMiejscaZerowego() - głównie dlatego, że wykorzystany w niej został mechanizm wskaźników na funkcje. Ewentualnie możesz też zainteresować się samym algorytmem; jego działanie całkiem dobrze opisują obfite komentarze :)

Gdzie jest więc ów sławetny wskaźnik do funkcji?. Znaleźć go możemy w nagłówku SzukajMiejscaZerowego() :

bool SzukajMiejscaZerowego( double fX1, double fX2,
double (*pfnF)( double ),
double * pfZero)

To nie pomyłka - wskaźnik do funkcji (biorącej jeden parametr double i zwracającej także typ double ) jest tutaj argumentem innej funkcji . Nie ma ku temu żadnych przeciwwskazań, może poza dość dziwnym wyglądem nagłówka takiej funkcji. W naszym przypadku, gdzie funkcja jest swego rodzaju "danymi", na ktorych wykonujemy operacje (szukanie miejsca zerowego), takie zastosowanie wskaźnika do funkcji jest jak najbardziej uzasadnione.

Pierwsze dwa parametry funkcji poszukującej są natomiast liczbami określającymi przedział poszukiwań pierwiastka. Ostatni parametr to z kolei wskaźnik na zmienną typu double , poprzez którą zwrócony zostanie ewentualny wynik. Ewentualny, gdyż o powodzeniu lub niepowodzeniu zadania informuje "regularny" rezultat funkcji, będący typu bool .

Naszą funkcję szukającą wywołujemy w programie w następujący sposób:

double fZero;
if (SzukajMiejscaZerowego(g_fP > -PRZEDZIAL ? g_fP : -PRZEDZIAL,
PRZEDZIAL, f, &fZero))
std::cout << "f(x) = 0 <=> x = " << fZero << std::endl;
else
std::cout << "Nie znaleziono miejsca zerowego." << std::endl;

Przekazujemy jej tutaj aż dwa wskaźniki jako ostatnie parametry. Trzeci to, jak wiemy, wskaźnik na funkcję - w tej roli występuje tutaj adres funkcji f() , którą badamy w poszukiwaniu miejsc zerowych. Aby przekazać jej adres, piszemy po prostu jej nazwę bez nawiasów okrągłych - tak jak się tego nauczyliśmy niedawno.

Czwarty parametr to z kolei zwykły wskaźnik na zmienną typu double i do tej roli wystawiamy adres specjalnie przygotowanej zmiennej. Po zakończonej powodzeniem operacji poszukiwania wyświetlamy jej wartość poprzez strumień wyjścia.

Jeżeli zaś chodzi o dwa pierwsze parametry, to określają one obszar poszukiwań, wyznaczony głównie poprzez stałą PRZEDZIAL . Dolna granica musi być dodatkowo "przycięta" z dziedziną funkcji - stąd też operator warunkowy ?: i porównanie granicy przedziału ze współczynnikiem p .

Powiedzmy sobie jeszcze wyraźnie, jaka jest praktyczna korzyść z zastosowania wskaźników do funkcji w tym programie, bo może nie jest ona zbytnio widoczna. Otóż mając wpisany algorytm poszukiwań miejsca zerowego w ogólnej wersji , działający na wskaźnikach do funkcji zamiast bezpośrednio na funkcjach, możemy stosować go do tylu różnych funkcji, ile tylko sobie zażyczymy. Nie wymaga to więcej wysiłku niż jedynie zdefiniowania nowej funkcji do zbadania i przekazania wskaźnika do niej jako parametru do SzukajMiejscaZerowego() . Uzyskujemy w ten sposób większą elastyczność programu.


1 Oprócz niego popularna jest również metoda Newtona, ale wymaga ona znajomości również pierwszej pochodnej funkcji.

Zastosowania

Poprawa elastyczności nie jest jednak jedynym, ani nawet najważniejszym zastosowaniem wskaźników do funkcji. Tak naprawdę stosuje się je glównie w technice programistycznej znanej jako funkcje zwrotne (ang. callback functions ).

Dość powiedzieć, że opierają się na niej wszystkie nowoczesne systemy operacyjne, z Windows na czele. Umożliwia ona bowiem informowanie programów o zdarzeniach zachodzących w systemie (wywołanych na przykład przez użytkownika, jak kliknięcie myszką) i odpowiedniego reagowania na nie. Obecnie jest to najczęstsza forma pisania aplikacji, zwana programowaniem sterowanym zdarzeniami . Kiedy rozpoczniemy tworzenie aplikacji dla Windows, także będziemy z niej nieustannie korzystać.

***

I tak zakończyliśmy nasze spotkanie ze wskaźnikami do funkcji. Nie są one może tak często wykorzystywane i przydatne jak wskaźniki na zmienne, ale, jak mogłeś przeczytać, jeszcze wiele razy usłyszysz o nich i wykorzystasz je w przyszłości. Warto więc było dobrze poznać ich składnię (fakt, jest nieco zagmatwana) oraz sposoby użycia.

Kurs C++
| | | |
Copyright © 2006-2013 egrafik.pl | Kontakt | Reklama | Projekty domów
jocker