Kolejny rozdział poświęcony językowi C 📖. Przyjrzymy się dość nietypowemu zapisowi, jeśli chodzi o kod źródłowy, a w każdym razie dużo rzadziej spotykanym w dobie dzisiejszego programowania 😳. Chodzi o funkcje. W języku C (i w C++ też), to nie powinno wyglądać tak, że wstawiamy sobie funkcję gdzie chcemy i mamy z głowy 😄. Języki te są bardzo wyczulone na położenie zarówno deklaracji, jak i definicji funkcji. Robi to jakąś różnicę? Jak cholera ⚠️! Bo w języku C, za deklarację odpowiada prototyp funkcji, a za definicję, prototyp + treść 🤯. Zapraszam Ciebie do środka artykułu po wyjaśnienia 🙂.

OTO DLACZEGO PROTOTYP FUNKCJI W JĘZYKU C, JEST ISTOTNIEJSZY NIŻ CI SIĘ WYDAJE!

Wyjaśnijmy sobie najpierw co to jest i po co się stosuje, a potem tradycyjnie kodzik źródłowy...a nawet wiele 😄!

DEFINICJA I ZASTOSOWANIE

Prototyp to najprościej pisząc, sam nagłówek funkcji pozbawiony treści bloku ℹ️. Jego zadaniem jest poinformowanie kompilatora, że w chwili wywołania pożądanej funkcji, jej pełna definicja gdzieś znajduje się w kodzie i może jej się "spodziewać" 👀.

Prototyp musi być zastosowany w sytuacji, gdy definicja funkcji znajduje się PO funkcji "main" ⚠️! W przeciwnym razie, czeka Cię błąd kompilacji ❌! A to dlatego, że kompilator w języku C "skleja" wszystkie pliki w program, "idąc" wierszami od góry do dołu ℹ️! Nie mając żadnej informacji na temat funkcji przed "wejściem" do funkcji "main" (w postaci prototypu), w momencie natrafienia na jej wywołanie, uzna że jest to byt niezdefiniowany i odmówi dalszego sprawdzania kodu ✋.

Jeżeli zdefiniujesz całą funkcję u góry pliku źródłowego, przed funkcją "main", to możesz pominąć dzielenie na prototyp funkcji i pełną treść (bo wtedy kompilator od razu "natrafi" na nią 🔍) ✅. Natomiast dalej jest zalecane trzymanie się tej reguły, żeby prototyp funkcji w języku C był stosowany - w dalszej części dowiesz się dlaczego 🫵!

ZALETY STOSOWANIA PROTOTYPÓW

Teraz ważna rzecz: co dostajemy w zamian dzielenia na prototyp i definicję funkcji? Dwie korzyści 👇:

  1. możliwość weryfikacji poprawności typów parametrów ➡️ w momencie wykrycia niespójnej listy parametrów pomiędzy prototypem, a definicją, kompilator generuje błąd,
  2. możliwość dekompozycji kodu ➡️ stosowanie prototypów umożliwia podział kodu na plików źródłowe i nagłówkowe, co ułatwia zarządzanie całością.

Krótko teraz rozwinę te zalety 📜.

W pkt. 1, chodzi o wymuszenie dodatkowego sprawdzenia poprawności wprowadzonych parametrów aktualnych (wartości wprowadzanych w momencie wywoływania funkcji). Jeżeli zdarzy Ci się wprowadzić listę parametrów inną niż oczekiwana, jest szansa, że w chwili kompilacji dostaniesz błąd na ten temat ⛔, dzięki czemu możesz uchronić się przed zostawieniem "bubla" w kodzie 🐛. Piszę o szansie, bo to zależy od kompilatora i standardu języka z jakiego korzystasz (np. C99) ℹ️.

W pkt. 2, chodzi o wydzielanie kodu na malutkie części, co sprzyja późniejszej rozbudowie projektu. Jest to niezmiernie istotna sprawa w kontekście pisania czytelnego i zrozumiałego kodu! W kodach źródłowych w języku C, możesz trafić na tzw. "pliki nagłówkowe" (ang. header files), charakteryzujące się rozszerzeniem ".h" ℹ️. Wyjaśnię o co chodzi pod sam koniec artykułu. Na razie wiedz, że prototyp funkcji w języku C zwykle umieszczany jest w przeznaczonych do tego plikach nagłówkowych, aby pomóc kompilatorowi "skleić" cały program w jeden plik wykonywalny - tam powinien się znajdować, bo taka jest praktyka!

PRZYKŁAD KODU ŹRÓDŁOWEGO

Czas na prezentację przykładowego kodu źródłowego z wykorzystaniem funkcji oraz jej prototypu. Pokażę Ci 3 różne scenariusze, razem z opisaniem potencjalnych konsekwencji z tym związanych 👍. Zaczynamy ▶️!

SCENARIUSZ #1: PROTOTYP I DEFINICJA MAJĄ ZGODNĄ LISTĘ PARAMETRÓW

Scenariusz optymistyczny: wszystko jest ze sobą zgodne, a kod napisany tak, jak powinien 👇:

#include <stdio.h>

int max(int, int);

int main(void)
{
	int a = 6;
	int b = 5;

	printf("Liczba: %d\n", max(a, b));

	return 0;
}

int max(int a, int b)
{
	return a > b ? a : b;
}

Mamy własną funkcję "max" zwracającą większą z dwóch podanych liczb. Natomiast pod dyrektywą "#include" znajduje się jeszcze prototyp funkcji "max"! To jest ta linijka:

int max(int, int);

Jak uprzedziłem, składa się jedynie z nagłówka, czyli typu zwracanej wartości ("int"), nazwy, a także ile przyjmuje parametrów oraz jakiego typu. Zwróć uwagę, że nie muszę podawać nawet nazw parametrów ℹ️! Nic złego się nie stanie, to jest całkowicie w porządku ✅! Natomiast oczywiście możesz wstawić nazwy:

int max(int a, int b);

i to też będzie poprawny zapis 👍.

SCENARIUSZ #2: PROTOTYP NIE JEST ZGODNY Z DEFINICJĄ

Drugi scenariusz: prototyp jest niezgodny z definicją funkcji, czyli czym grozi zignorowanie dodania prawidłowego nagłówka. Popatrz na kod źródłowy prezentujący "wybrakowany" prototyp funkcji w języku C (obowiązujący przed standardem ANSI C w 1989 roku) 👇:

#include <stdio.h>

int max();

int main(void)
{
	int a = 6;
	int b = 5;

	printf("Liczba: %d\n", max(a, b));

	return 0;
}

int max(int a, int b)
{
	return a > b ? a : b;
}

Pierwsza ważna rzecz jaka się stanie przy próbie kompilacji to to...że się powiedzie 💥! Kompilator nie uprzedzi, że mamy niezgodność nagłówków między sobą 😱!!! Domyślnie "zakłada", że będzie to typ "int" i posiada 2 parametry. To oczywiście jest nieprawdą, tylko pamiętajmy, że to język C - on polega w całości na Tobie, programisto 🫵!

Druga kwestia - język pozwala na użycie funkcji z niezgodną liczbą parametrów! Wystarczy, że prototyp funkcji w języku C nie dostarcza dostatecznej ilości informacji, a już tragedia wisi w powietrzu 💣. W przypadku wywołania zgodnego z nagłówkiem definicji:

max(a, b)

nic złego się nie stanie i funkcja prawidłowo zwróci wynik ✔️. Gorzej, że możesz tę samą funkcję wywołać tak:

max(a)

a nawet tak:

max()

Ciekawi Cię co komputer "podstawia" w miejsce oczekiwanych parametrów 🙂? Byle jaką "śmieciową" wartość, która aktualnie znajduje się w pamięci. To jest niezdefiniowane zachowanie, czyli Bóg jeden wie co się wydarzy ⚡! Unikaj tego za wszelką cenę ⚠️!!!

Ciekawostką jest to, iż jeżeli w prototypie podasz jeden parametr (co dalej będzie niezgodne z definicją) 👇:

int max(int);

to już będzie zdecydowanie większa odporność na błędy - kompilator zwróci błąd przy próbie wywołania funkcji niezgodnej z nagłówkiem definicji ⭐. Jednak nadal apeluję, aby przestrzegać spójności 😉!

SCENARIUSZ #3: PROTOTYP JEST CAŁKOWICIE POMINIĘTY

To też może być ciekawe 😊! Co, gdy pominiemy tworzenie prototypu 🤔? Wtedy oczywiście, język ma do dyspozycji samą definicję, także jesteśmy bezpieczni co do zgodności listy parametrów 👍. A jaki efekt końcowy? A to już zależy gdzie się znajduje definicja- czy nad, czy pod funkcją "main" 😲! Gdy jest umieszczona nad 👇:

#include <stdio.h>

int max(int a, int b)
{
	return a > b ? a : b;
}

int main(void)
{
	int a = 6;
	int b = 5;

	printf("Liczba: %d\n", max(a, b));

	return 0;
}

to kompilacja się powiedzie, a program zwróci poprawny wynik, czyli "miodzio" 🍯! Gdy jest pod:

#include <stdio.h>

int main(void)
{
	int a = 6;
	int b = 5;

	printf("Liczba: %d\n", max(a, b));

	return 0;
}

int max(int a, int b)
{
	return a > b ? a : b;
}

dojdzie do błędu kompilacji, ponieważ idąc kolejnością według wierszy kompilator jeszcze "nie widzi" żadnego śladu na temat naszej funkcji "max" 💡. Właśnie dlatego stosujemy prototypy 🔍!

To jest dla tych, którzy nie są przyzwyczajeni do podziału na prototyp funkcji i pełną definicję. Mimo tego, że program zadziała poprawnie (o ile definicję wstawimy u samej góry), to nie jest dobra praktyka przyjęta w języku C. I już przechodzę do wyjaśnienia ostatniego zagadnienia 😉!

MIEJSCE DLA PROTOTYPÓW TO PLIK NAGŁÓWKOWY!

Wracamy do tematu wspomnianego wcześniej 📜. Powyższe kody źródłowe nie prezentują pełni reguł jakie powinny być przestrzegane w językach C/C++. Aby można było stwierdzić w 100%, że prototyp funkcji w języku C (bądź w C++) został prawidłowo określony, musisz go przenieść do osobnego pliku - pliku nagłówkowego 😲!

Plik nagłówkowy to miejsce do określania prototypów funkcji. O samych plikach nagłówkowych można napisać całą litanię (będzie dla nich odrębny artykuł), a tutaj jedynie opiszę wszystko, co ma związek z prototypami.

Język C weryfikuje istnienie konkretnej funkcji "schodząc" z góry na dół. Definiując dowolną własną funkcję poniżej funkcji "main", kompilator zgłosi błąd z powodu nieodnalezienia żadnych "poszlak" wywoływanej funkcji. Można omijać prototypy i umieszczać definicje u samej góry, tylko wtedy pozbawiasz się możliwości "podzielenia" kodu źródłowego całego projektu na osobne pliki.

"Dekompozycja", to jedna ze świętych reguł jakimi się kieruję podczas pisania kodu 🚀. Chodzi o programowanie "kawałeczkami", tak aby uczynić funkcje jak najmniejszymi budulcami, jakimi mogą być. To samo tyczy się plików. Gdy plik robi się zbyt obszerny w liczbie wierszy, dzielisz go na wiele mniejszych plików 😊!

To sprzyja też innej ważnej regule - "reużywalności", bo jedna ta sama funkcja może być użyta w wielu innych miejscach w kodzie, dzięki czemu unikasz duplikowania kodu, co jest niepotrzebne, nieprofesjonalne i generuje bałagan 👎!

To nie wszystko ⚠️. Każdy plik nagłówkowy potrzebuje również dodatkowego pliku jako treści wszystkich prototypów funkcji jakie się znalazły, czyli pliku źródłowego (ang. source file) 😱! Tak, tego samego co plik "main", w którym znajduje się funkcja uruchomieniowa! Także, krótko formułując, każdy dobrze poukładany program napisany w języku C, dzieli się na 2 rodzaje plików 👇:

  • plik nagłówkowy = miejsce dla prototypów funkcji,
  • plik źródłowy = miejsce dla definicji funkcji.

Komunikacja między plikami odbywa się poprzez znaną Ci już dyrektywę, z której korzystasz od samego początku poznawszy funkcję "printf" - "#include" 😎! Teraz masz wytłumaczoną całą "drogę" dołączania pliku i uzyskiwania w ten sposób dostępu do uprzednio zdefiniowanych struktur 🌟!

PRZYKŁAD KODU ŹRÓDŁOWEGO Z PLIKIEM NAGŁÓWKOWYM

Wystarczy teorii. Popatrz na kod źródłowy - taki sam jak nasz pierwszy scenariusz ukazany wyżej, tylko z wydzieleniem prototypu i definicji do poszczególnych plików (każdy z plików zostaje przeze mnie opisany) 👇:

PLIK NAGŁÓWKOWY "MATH.H"
#ifndef MATH_I
#define MATH_I

int max(int, int);

#endif

Zawsze zaczynasz od pliku nagłówkowego. Tak jak pisałem, w nim mają się znaleźć tylko same prototypy - żadnych definicji, bo to też potem rodzi problemy 🚫. Umieszczamy w nim tę samą postać, jaką Ci pokazałem dużo wcześniej, lecz musimy też wstawić dodatkowe dyrektywy zabezpieczające przed wielokrotnym dołączeniem pliku 🔒. To jest postać zwana jako "include guard", której szczegóły przedstawię w osobnym artykule.

Radzę Ci stosować ten zapis w każdym pliku nagłówkowym nadając inną nazwę stałej zdefiniowanej z użyciem "#define" - tu chodzi o sprawdzenie czy stała o podanej nazwie już istnieje i zapewnienie, że program "wejdzie" do tego kodu tylko jeden raz (bo wtedy ta stała już będzie istnieć przez cały czas ✅).

PLIK ŹRÓDŁOWY "MATH.C"
int max(int a, int b)
{
	return a > b ? a : b;
}

Drugi krok, to utworzenie pliku tym razem źródłowego odpowiadającego tamtemu pliku nagłówkowemu ℹ️. Tu dorzucamy definicję funkcji "max" i to wszystko 😁. Gdybyśmy w tym miejscu potrzebowali skorzystać z innych funkcji, wystarczy wstawić dyrektywę "#include" u samej góry.

PLIK ŹRÓDŁOWY "MAIN.C"
#include "math.h"
#include <stdio.h>

int main(void)
{
	int a = 6;
	int b = 5;

	printf("Liczba: %d\n", max(a, b));

	return 0;
}

No i została nam na deser funkcja uruchomieniowa z drobną zmianą u góry 😉.

Popatrz na dodatkową dyrektywę "#include". Import własnych plików nagłówkowych odbywa się tak samo jak zwykle, natomiast jest drobna różnica w zapisie ⚠️. Otaczasz nazwy cudzysłowami zamiast nawiasami kątowymi, które oznacza szukanie pliku w katalogu zawierającym plik, w którym zapisałeś(-aś) tę dyrektywę (ścieżka względna, inaczej pisząc ✒️).

Chcąc to sobie skompilować, pamiętaj że musisz uwzględnić nie tylko plik źródłowy "main", lecz także nowo utworzony, "math" ⚠️!!! Podając przykład na kompilatorze GCC, należy wtedy napisać tak 👇:

gcc -o main.exe main.c math.c

Wiem, dużo roboty i dużo rozdzielania, natomiast to jest właściwe podejście do dzielenia kodu na małe części, bo w ten sposób nie łączymy ze sobą dwóch odpowiedzialności 🏆! "main" odpowiada za uruchamianie, "math" przechowuje definicję funkcji "max" i wszystko pasuje 🧩!

Prototyp funkcji w języku C

Prototyp funkcji w języku C to informacja dla kompilatora, że gdzieś w dalszej części przetwarzanego kodu, znajduje się pełna definicja niezbędna do uznania, czy wywołanie tejże funkcji jest poprawne.


To by było na tyle.

PODOBNE ARTYKUŁY