Podrozdziały


23.1 Moduły programu

Program w języku C++ może być fizycznie zapisany w wielu plikach. Jak wiemy z rozdziału o dyrektywach preprocesora , w pliku można umieścić polecenie włączenia innego pliku z kodem źródłowym. To, co zobaczy kompilator po przetworzeniu przez preprocesor, to tekst całości: dla kompilatora zatem będzie to jeden moduł, choć fizycznie zapisany jest w dwóch lub więcej plikach. Taki moduł zwany jest jednostką translacji. Z kolei cały program może składać się z wielu jednostek translacji, z których każda może być kompilowana osobno. Przy większych programach jest to bardzo istotne; zmiana wprowadzona w jednej jednostce powoduje konieczność ponownej kompilacji tej jednostki, ale nie zawsze całego programu.

Jednostki translacji mogą, i powinny, stanowić jednocześnie podstawę podziału programu na jednostki logiczne. Tak jak pewne powtarzalne pojedyncze zadania staramy się zapisać w postaci oddzielnych funkcji, tak zespół funkcji i klas dotyczących pewnego wycinka ogólnego zadania realizowanego przez cały program można zebrać w jednej jednostce translacji. Upraszcza to pisanie, analizowanie i pielęgnację kodu, szczególnie gdy przybiera on znaczne rozmiary i jest pisany czy modyfikowany przez wielu programistów.

Każda jednostka translacji kompilowana jest niezależnie, być może w innym czasie i na innym komputerze. Ponieważ w C++ sprawdzane są typy zmiennych i poprawność wywołań funkcji, wynika z tego, że każda funkcja, która jest w danej jednostce translacji używana (wywoływana), musi być w tej jednostce zadeklarowana. Natomiast definicja funkcji powinna być tylko jedna, umieszczona w jednej tylko jednostce translacji (nie dotyczy to funkcji rozwijanych, których definicja musi być widoczna w każdej jednostce translacji w której są używane). Oczywiście wszystkie deklaracje i definicja funkcji muszą być zgodne.

Po połączeniu przez linker (program łączący), wszystkie funkcje z różnych jednostek translacji „widzą” się nawzajem bez dodatkowych zabiegów. Zatem nazwy funkcji globalnych należą do „uwspólnionego” zakresu złożonego z zakresów globalnych wszystkich modułów (mówimy, że są eksportowane). Wielu programistów umieszcza jednak słowo kluczowe extern przed deklaracją funkcji w jednostce translacji, w której nie ma definicji tej funkcji. Jest to pamiątka po czystym C, w C++ dopuszczalna, ale zbędna.

Wyjątkowo, funkcje globalne zdefiniowane ze specyfikatorem static nie są eksportowane (włączane do „uwspólnionego” zakresu globalnego); są widoczne tylko dla funkcji z tego samego modułu.

Inaczej rzecz się ma ze zmiennymi zadeklarowanymi w zasięgu globalnym. Tu uwspólnienia nie ma: zmienna globalna x z jednej jednostki translacji jest widoczna tylko wewnątrz tej jednostki; inny moduł może bezkonfliktowo zdefiniować zmienną globalną o tej samej nazwie i będą to dwie oddzielne zmienne, każda widoczna tylko w swoim module. Jeśli taką zmienną chcemy eksportować, to należy ją zdefiniować w jednym tylko module, a w pozostałych jednostkach translacji, w których będziemy z niej korzystać, zadeklarować ją jako zmienną zewnętrzną za pomocą specyfikatora extern (patrz rozdział o zmiennych zewnętrznych ). Jeśli natomiast, na odwrót, chcemy zdefiniować zmienną globalną i zagwarantować, że nie będzie ona dostępna w innych modułach, nawet jeśli, przypadkowo, będzie w nich zadeklarowana zmienna zewnętrzna (extern) o tej samej nazwie, to definiujemy ją z modyfikatorem static (patrz rozdział o zmiennych statycznych ).


23.1.1 Pliki nagłówkowe i implementacyjne

Pisząc większy program, musimy godzić ze sobą dwa wymagania. Z jednej strony, program powinien być łatwy do zrozumienia, rozwijania i modyfikowania dla autorów programu. Z drugiej strony, pamiętać trzeba o wygodzie użytkownika — zapewnić trzeba przejrzysty interfejs pozwalający na efektywne korzystanie z programu i ewentualne jego rozwijanie bez konieczności wnikania w gąszcz szczegółów implementacyjnych. Tym celom służy podział programu na pliki o różnym charakterze.

Wiele jednostek kompilacyjnych może korzystać z tych samych funkcji, klas, szablonów, przestrzeni nazw, wyliczeń... Ich deklaracje muszą więc być dokładnie takie same. Moglibyśmy je oczywiście powtarzać we wszystkich modułach. Byłoby to jednak proszeniem się o kłopoty. Jakąkolwiek poprawkę czy zmianę trzeba by wtedy wprowadzać do każdego pliku, gdzie deklaracje te występują. Zamiast tego można zebrać je do jednego pliku i w tych modułach, gdzie są potrzebne i powinny być znane, włączać je za pomocą dyrektywy #include (rozdział o preprocesorze ). W plikach takich nie umieszczamy definicji funkcji czy metod klas, tylko ich deklaracje (z wyjątkiem funkcji rozwijanych, które powinny być tam umieszczone wraz z definicją). Pliki te stanowią właśnie interfejs, z którego odczytać można nazwy, typ, przeznaczenie i „instrukcje obsługi” deklarowanych obiektów. Dobrym zwyczajem jest wprowadzanie do takich plików precyzyjnych komentarzy. Pliki takie nazywamy plikami nagłówkowymi i tradycyjnie mają one rozszerzenie .h. Są zwykle niewielkie, bo nie zawierają definicji.

Definicje zadeklarowanych w pliku nagłówkowym obiektów zbieramy z kolei w innym pliku, pliku implementacyjnym. Tu nie ma ogólnie przyjętej konwencji co do jego rozszerzenia, ale często stosuje się rozszerzenie .cxx lub .C, lub po prostu .cpp. Do tego pliku również włączamy za pomocą dyrektywy #include plik nagłówkowy z deklaracjami definiowanych funkcji czy metod. W ten sposób mamy gwarancję, że definicje będą spójne z deklaracjami (a więc z interfejsem), bo ewentualne niezgodności będą wtedy wychwycone i zgłoszone przez kompilator.

Przy bardziej skomplikowanej strukturze programu istnieje niebezpieczeństwo, że wskutek zagnieżdżenia dyrektyw #include ten sam plik nagłówkowy zostanie do tej samej jednostki translacji włączony więcej niż raz. Czasem nie ma w tym niczego złego, czasem może stwarzać problemy. Aby się przed tym uchronić, można stosować „sztuczkę” z użyciem dyrektywy #ifndef opisaną w rozdziale o dyrektywach preprocesora .

Mając już pliki nagłówkowy i implementacyjny, w których zebraliśmy zarówno interfejs, jak i implementację pewnej funkcjonalności (na przykład stosu czy drzewa poszukiwań binarnych), możemy ich użyć w wielu różnych aplikacjach, które tej funkcjonalności wymagają. Wystarczy wtedy

Rozpatrzmy prosty przykład. Przy wielu okazjach przydaje się możliwość sortowania tablicy czy drukowania zawartości tablicy. Piszemy zatem moduł złożony z dwóch plików. W pliku nagłówkowym sortint.h deklarujemy funkcje do tego służące. Zamieszczamy komentarze mówiące, jak te funkcje stosować i do czego służą

P176: sortint.h     Plik nagłówkowy

      1.  #ifndef _SORTINT_H
      2.  #define _SORTINT_H
      3.  
      4.  // komentarze...
      5.  
      6.  void sort(int[],int);
      7.  void pisztab(const int[],int);
      8.  #endif

W osobnym pliku, sortintImpl.cpp, implementujemy te dwie funkcje.

P177: sortintImpl.cpp     Plik implementacyjny

      1.  #include <iostream>
      2.  #include "sortint.h"   // wlaczamy naglowek
      3.                         // z deklaracjami
      4.  using namespace std;
      5.  
      6.  // implementacja funkcji sort
      7.  void sort(int a[], int size) {
      8.      int i, indmin = 0;
      9.      for (i = 1; i < size; ++i)
     10.          if (a[i] < a[indmin]) indmin = i;
     11.      if (indmin != 0) {
     12.          int p = a[0];
     13.          a[0] = a[indmin];
     14.          a[indmin] = p;
     15.      }
     16.  
     17.      for (i = 2; i < size; ++i) {
     18.          int j = i, v = a[i];
     19.          while (v < a[j-1]) {
     20.              a[j] = a[j-1];
     21.              j--;
     22.          }
     23.          if (i != j ) a[j] = v;
     24.      }
     25.  }
     26.  
     27.  // implementacja funkcji pisztab
     28.  void pisztab(const int t[], int size) {
     29.      cout << "[ ";
     30.      for (int i = 0; i < size; ++i)
     31.          cout << t[i] << " ";
     32.      cout << "]" << endl;
     33.  }

W tym pliku koniecznie włączamy za pomocą #include plik nagłówkowy. Nie jest on tu co prawda potrzebny, bo definicja jest jednocześnie deklaracją, ale chodzi o to, aby kompilator mógł sprawdzić zgodność definicji z interfejsem, jaki jest znany użytkownikowi, który ma dostęp tylko do pliku nagłówkowego. Plik implementacyjny możemy teraz skompilować z opcją '-c', aby utworzyć plik wynikowy, czyli kod binarny, ale jeszcze nie połączony (zlinkownany) do pliku wykonywalnego. Takie pliki mają zwykle rozszerzenie .o.
    cpp> ls                                             (1)
    sortintImpl.cpp  sortint.h                          (2)
    cpp> g++ -pedantic-errors -Wall -c sortintImpl.cpp  (3)
    cpp> ls                                             (4)
    sortintImpl.cpp  sortint.h  sortintImpl.o           (5)
W linii 1 powyższej sesji wypisujemy listę plików rozpoczynających się od 'sortint'. W linii 3 kompilujemy plik implementacyjny sortintImpl.cpp z opcją '-c'. Jak widać w linii 5, pojawił się rzeczywiście plik sortintImpl.o. Oczywiście, aby kompilacja się udała, musiał być dostępny plik nagłówkowy sortint.h który jest przez preprocesor dołączany do pliku sortintImpl.cpp dając w sumie jedną jednostkę translacyjną.

Przypuśćmy teraz, że piszemy aplikację sortintApp.cpp w której nasze funkcje chcemy wykorzystać. Aby móc je zastosować, włączamy w aplikacji, za pomocą dyrektywy #include, tylko plik nagłówkowy. Zawiera on deklaracje funkcji sortpisztab, a to wystarczy kompilatorowi aby sprawdzić poprawność ich wywołań.


P178: sortintApp.cpp     Aplikacja

      1.  #include "sortint.h"  // tylko plik naglowkowy!
      2.  #include <iostream>
      3.  using namespace std;
      4.  
      5.  int main() {
      6.      int tab[] = {9,7,2,6,4,5,6,2,7,9,2,9,5,2},
      7.          size  = sizeof(tab)/sizeof(tab[0]);
      8.  
      9.      cout << "Tablica  oryginalna: ";
     10.      pisztab(tab, size);
     11.  
     12.      sort(tab, size);
     13.  
     14.      cout << "Tablica posortowana: ";
     15.      pisztab(tab, size);
     16.  }

Kompilujemy teraz, być może po roku od utworzenia pliku sortintImpl.o, kod źródłowy naszej aplikacji sortintApp.cpp. Podczas kompilacji dołączamy dla linkera plik sortintImpl.o — otrzymujemy plik wykonywalny sortintApp, który po uruchomieniu drukuje wyniki programu. Zauważmy, że nie potrzebowaliśmy tu pliku źródłowego sortintImpl.cpp.
    cpp> ls
    sortintApp.cpp  sortint.h  sortintImpl.o
    cpp> g++ -o sortintApp sortintApp.cpp sortintImpl.o
    cpp> ./sortintApp
    Tablica  oryginalna: [ 9 7 2 6 4 5 6 2 7 9 2 9 5 2 ]
    Tablica posortowana: [ 2 2 2 2 4 5 5 6 6 7 7 9 9 9 ]
Załóżmy, że po upływie następnego roku zorientowaliśmy się, że użyty tu algorytm sortowania przez wstawianie jest nieefektywny i należy zastąpić go algorytmem sortowania przez kopcowanie. Co musimy zrobić? Zmienić implementację tej funkcji w pliku sortintImpl.cpp, skompilować go i plik wynikowy dostarczyć użytkownikowi aplikacji. Użytkownik musi jeszcze raz zlinkować program (jeśli zachował plik wynikowy ' .o' swojej aplikacji, to nie musi jej nawet powtórnie kompilować). Samo łączenie (linkowanie) jest proste i szybkie. Interfejs opisany plikiem nagłówkowym nie zmienił się, a zatem nie trzeba zmieniać ani jednej linijki w aplikacji użytkownika. Skompilowany kod implementacyjny zamieszcza się często w bibliotekach, na przykład tzw. bibliotekach dzielonych, zwykle w plikach o rozszerzeniu .so (shared object) pod Linuksem i  .dll (dynamic-link library) w systemie Windows. Wtedy wystarczy „podmienić” pliki biblioteczne, a wszystkie korzystające z nich programy powinny działać bez żadnych dodatkowych zabiegów (tyle że lepiej, bo przecież poprawiliśmy implementacje biblioteki).

Na tej zasadzie zorganizowane jest środowisko C/C++. Programista dołącza w pisanej przez siebie aplikacji pliki nagłówkowe zawierające deklaracje potrzebnych mu narzędzi. Pliki te umieszczone są zwykle w katalogu include instalacji C/C++ na danej platformie. Są to najczęściej zwykłe pliki tekstowe, które można i warto przeglądać. Implementacja zadeklarowanych w plikach nagłówkowych obiektów (funkcji, klas...) jest zawarta, w binarnej, skompilowanej postaci, w plikach bibliotecznych — zwykle w katalogu lib.

T.R. Werner, 25 lutego 2017; 22:31