XXI. Dzielenie kodu na kilka plików źródłowych
25.1. Poznajemy dyrektywę #include na nowo
Wraz z rozwojem każdego programu kodu przybywa, a poruszanie się po nim staje się coraz bardziej uciążliwe ze względu na jego długość. Z pewnością starasz się grupować tematycznie większość funkcji w programie, jednak i to niewiele daje, gdy przychodzi pracować z kodem, który ma co najmniej 1000 wierszy. Z pomocą przychodzi tu dyrektywa #include, którą już miałeś okazję niejednokrotnie poznać, stosując ją nawet do najprostszego programu.
Język C++ to zbiór logicznych zasad umożliwiających programowanie. Same zasady umożliwiają zarządzać danymi, jednak nie są one wystarczające do tego, aby wykorzystywać w łatwy sposób możliwości sprzętowe komputera. Ponieważ język C++ umożliwia łatwe organizowanie danych, to oczywistym też jest, że równie potrzebnym elementem jest interfejs, umożliwiający prezentację danych oraz interfejs reagujący na wszystkie urządzenia, jakie posiadamy w komputerze (np. mysz, klawiaturę, kartę graficzną, kartę sieciową itd.).
Zadaniem dyrektywy #include jest umożliwienie łatwego wykorzystywania zasobów sprzętowych komputera, poprzez dołączanie plików nagłówkowych bibliotek, które są odpowiedzialne za komunikację z różnymi urządzeniami. Innymi słowy za każdym razem, gdy korzystałeś z dyrektywy #include, dołączałeś do swojego programu interfejs umożliwiający łatwy dostęp do wybranych zasobów komputera. Za pomocą tego polecenia będziesz mógł w łatwy sposób pisać serwery TCP/UDP, używać OpenGL'a, czy też innych modułów umożliwiających łatwy dostęp do sprzętowych zasobów komputera.
Niniejsza dyrektywa pomimo, iż jest tak prosta w użyciu jest potężnym narzędziem w ręku programisty. Oprócz dołączania istniejących bibliotek, pozwala Ci ona dołączać własne biblioteki, które wkrótce sam zaczniesz pisać. Dzięki tej własności będziesz mógł zorganizować swój kod źródłowy lepiej, a wszelkie modyfikacje kodu staną się szybsze i łatwiejsze.
25.2. Ustawianie ścieżki do pliku nagłówkowego
Do tej pory gdy chciałeś dołączyć bibliotekę do programu, stosowałeś tylko i wyłącznie zapis #include <ścieżka do pliku>. Użycie ostrych nawiasów <...> informuje kompilator, aby przeszukiwał domyślne ścieżki, w których znajdują się pliki nagłówkowe. Ścieżki te są ustawione w środowisku Code::Blocks, które wskazują na katalogi w których znajdują się wszystkie standardowe biblioteki. Jeśli chcesz sprawdzić jakie ścieżki są domyślnie dołączane podczas kompilacji programów wejdź w następujące ustawienia:
- wybierz: Narzędzia/Opcje kompilatora
- kliknij zakładkę: Katalogi
- w ramce która się pokazała, kliknij Pliki nagłówkowe C++
Lista którą widzisz, to zbiór katalogów, które są przeszukiwane w celu odnalezienia odpowiedniego pliku nagłówkowego. Jeśli kompilator nie odnajdzie żądanego pliku nagłówkowego, kompilator zwróci błąd informując Cię, że pliku o podanej nazwie nie znaleziono.
Drugą metodą na dołączanie plików nagłówkowych jest wykorzystanie zapisu #include "ścieżka do pliku". Zapis z użyciem podwójnych apostrofów informuje kompilator, że plik nagłówkowy ma być poszukiwany tylko i wyłącznie względem aktualnego katalogu, w którym znajduje się nasz projekt.
25.3. Rozszerzenia plików i ich znaczenie
Na przestrzeni lat język C, a od jakiegoś czasu również i C++ wypracowały sobie nazwy rozszerzeń dla plików, które automatycznie sugerują co się w nich znajduje i w jakim standardzie były pisane.
25.3.1. Pliki *.h *.hpp
Pliki *.h i *.hpp, są nazywane plikami nagłówkowymi (ang. header files). Pierwszy z nich, tj. *.h oznacza, że był on pisany zgodnie ze standardami języka C. Rozszerzenie *.hpp natomiast mówi nam, że program pisany był zgodnie ze standardami języka C++. Jeśli masz kompilator C++ to nie musisz obawiać się o problemy z wykorzystywaniem bibliotek pisanych zarówno w C jak i C++, ponieważ standard C++ powstał w oparciu o C. Problemy możesz mieć natomiast w sytuacji odwrotnej, ponieważ mogą być zastosowane polecenia których język C po prostu nie zna.
Każdy plik nagłówkowy powinien zawierać tylko i wyłącznie interfejs. Przez słowo interfejs rozumiemy:
- deklarację ewentualnych zmiennych globalnych
Dodatkowo dołączamy do niego niezbędne pliki nagłówkowe, jakie będą wykorzystywane przez daną bibliotekę. W pliku nagłówkowym nie umieszczamy natomiast bloków funkcji. Można powiedzieć po prostu, że w pliku nagłówkowym umieszczamy wszystko oprócz bloków funkcji.
25.3.2. Pliki *.c *.cpp
Pliki *.c i *.cpp nazywamy plikami źródłowymi. Jak nietrudno domyślić się, *.c oznacza standard użytego języka C, natomiast *.cpp standard użytego języka C++. W plikach z takim rozszerzeniem umieszczamy tylko i wyłącznie definicje funkcji, czyli nazwę funkcji razem z jej ciałem (czyli blokiem funkcji).
25.3.3. Inne rozszerzenia plików
Język C++ nie narzuca nazw dla rozszerzeń plików. Zalecane jest jednak stosowanie się do wymienionych standardów, ponieważ są one jasne i zrozumiałe przez wszystkich programistów C i C++. Dodatkowo edytory często rozpoznają typ pliku po rozszerzeniach i w zależności od nich kolorują Tobie składnię.
25.4. Budowa pliku nagłówkowego *.h *.hpp
Istnieje co najmniej kilka wersji budowy plików nagłówkowych dla języka C i C++. Niektóre z nich nie działają jednak pod wszystkimi kompilatorami, dlatego też skupię się tylko i wyłącznie na jednej, która jest akceptowana przez wszystkie kompilatory.
#ifndef nazwaPliku_hpp
#define nazwaPliku_hpp
/*
tutaj piszesz cały interfejs
*/
#endif
Opis użytych instrukcji preprocesora:
#ifndef zmienna_preprocesora | Instrukcja preprocesora, która sprawdza czy zmienna preprocesora o podanej nazwie istnieje. Jeśli zmienna preprocesora nie istnieje to wszystkie instrukcje, które się znajdują poniżej #ifndef, zostaną wykonane. Jeśli natomiast zmienna będzie istniała, kompilator pominie wszystkie instrukcje jakie znajdują się pomiędzy słowami preprocesora #ifndef, a #endif. |
#endif | instrukcja oznacza miejsce końca bloku warunkowego preprocesora. |
#define nazwa_zmiennej | Polecenie służy do tworzenia zmiennych preprocesora. |
Zaprezentowany przykład czytasz następująco:
#ifndef dowolna_nazwa | jeśli nie istnieje dowolna_nazwa, wykonuj blok |
#define dowolna_nazwa | utwórz zmienną o nazwie dowolna_nazwa |
#endif | koniec bloku warunkowego |
Wykorzystując instrukcje preprocesora zabezpieczasz bibliotekę przed wielokrotnym dołączaniem tego samego kodu do własnego programu. Jeśli go nie użyjesz, a dołączysz tą samą bibliotekę co najmniej dwukrotnie w programie (nawet w różnych plikach), otrzymasz błąd kompilacji nawet jeśli wszystko będzie poprawnie napisane. Nazwy zmiennych, które definiujesz za pomocą preprocesora muszą być unikatowe podczas kompilacji projektu dla każdego używanego pliku, tak więc w każdym pliku musi się znajdować inna nazwa zmiennej preprocesora. Najpopularniejszą metodą zapewnienia sobie unikatowych nazw zmiennych, jest używanie nazwy pliku dla zmiennej preprocesora. Nie jest to jednak obowiązek i masz tu praktycznie pełną dowolność. Nazwy zmiennych preprocesora mają takie same kryteria dla nazewnictwa jak zmienne języka C++.
25.5. Budowa pliku źródłowego *.c *.cpp
Plik źródłowy ma bardzo prostą budowę. Jedyne co musisz zrobić to dołączyć plik nagłówkowy pliku, który opisuje interfejs jaki znajduje się w tym pliku.
#include "nazwaPliku.hpp"
/*
tutaj piszesz definicje funkcji
*/
25.6. Błędy kompilacji
Jeśli będziesz próbował skompilować plik *.cpp i nie będzie w nim żadnych błędów, otrzymasz następujący błąd kompilacji:
[Linker error] undefined reference to `WinMain@16'
ld returned 1 exit status
Komunikat ten informuje Cię, że w programie nie ma 'ciała' programu, czyli głównej funkcji programu. Ponieważ pliki, które utworzyłeś nie mają być programem, tylko źródłem dołączanym do programu, który będzie zawierał funkcję main(), wskazane jest aby ten plik nie zawierał funkcji o którą 'doczepił się' kompilator.
Dołączając natomiast plik nagłówkowy (*.hpp) do programu głównego za pomocą dyrektywy #include, uzyskasz w pełni sprawny kod, który się kompiluje (pod warunkiem, że nie ma w nim innych błędów).
25.7. Utworzenie pliku źródłowego
By stworzyć plik nagłówkowy w Code::Blocks wystarczy utworzyć nowy pusty plik(New → Empty File), możemy też skorzystać ze skrótu klawiszowego SHIFT+CTRL+N. Ukaże nam się komunikat, czy dodać ten plik do aktywnego projektu, oczywiście potwierdzamy.
Następnie nadajemy mu odpowiednią nazwę dla pliku nagłówkowego nazwaPliku.hpp i analogicznie dla pliku cpp nazwaPliku.cpp. Ukaże nam się jeszcze informacja o tym czy pliki mają być obsługiwane podczas Debuger czy Release
W tym wypadku można zaznaczyć obie opcje i potwierdzić OK. W ten sposób do projektu dodaliśmy dwa puste pliki. Jeśli wszystko wykonałeś to powinieneś otrzymać taki układ dla plików w projekcie.
25.7.1. Przykład - Pliki źródłowe
//Plik: main.cpp
#include <iostream>
#include <conio.h>
#include "nazwaPliku.hpp"
using namespace std;
int main()
{
cout<<"Wynik dodawania to: "<<dodajLiczby(10,15)<<endl;
getch();
return(0);
}
//Plik: nazwaPliku.hpp
#ifndef nazwaPliku_hpp
#define nazwaPliku_hpp
int dodajLiczby(int a,int b);
#endif
//Plik: nazwaPliku.cpp
#include "nazwaPliku.hpp"
int dodajLiczby(int a,int b)
{
return(a+b);
}
25.7.2. Przykład 2 - 18.1 Funkcje z strukturami
//Plik: main.cpp
#include "nazwaPliku.hpp"
//funkcja główna -------------
int main()
{
using std::cout;
using std::cin;
//tworzenie obiektów struktur
BokiTrojkata wprowadzDane;
WynikiTrojkata wyswietl;
cout << "Wprowadz dana bokow a, b i c: ";
//sprytny sposób wprowadzania danych
while (cin >> wprowadzDane.bokA >> wprowadzDane.bokB >> wprowadzDane.bokC)
{
wyswietl = TwierdzeniePitagorasa(wprowadzDane);
WyswietlWyniki(wyswietl);
cout << "\nPodaj ponownie boki lub 'k' by wyjsc.\n";
}
return 0;
}
//Plik: nazwaPliku.hpp
#ifndef nazwaPliku_hpp
#define nazwaPliku_hpp
#include <iostream>
//definicja struktur ---------
struct BokiTrojkata
{
double bokA;
double bokB;
double bokC;
bool czy_prawda;
};
struct WynikiTrojkata
{
double Wynik1;
double Wynik2;
double Wynik3;
bool czy_prawda;
};
//definicje funkcji-----------
WynikiTrojkata TwierdzeniePitagorasa(BokiTrojkata pobiezboki);
void WyswietlWyniki(const WynikiTrojkata wynik);
#endif
//Plik: nazwaPliku.cpp
#include "nazwaPliku.hpp"
#include <cmath>//nowa biblioteka
//funkcja obliczająca kwadrat poszczególnych boków A, B, C
WynikiTrojkata TwierdzeniePitagorasa(BokiTrojkata pobierzboki)
{
// pow służy do potęgowania liczb
using std::pow;
//tworzy strukturę by móc ją zwrócić przez return
WynikiTrojkata odpowiedz;
if ( (pow(pobierzboki.bokA, 2) + pow(pobierzboki.bokB, 2)) == pow(pobierzboki.bokC, 2))
{
odpowiedz.Wynik1 = (pow(pobierzboki.bokA, 2));
odpowiedz.Wynik2 = (pow(pobierzboki.bokB, 2));
odpowiedz.Wynik3 = (pow(pobierzboki.bokC, 2));
odpowiedz.czy_prawda = true;
}else{
odpowiedz.czy_prawda = false;
}
return odpowiedz;//zwraca strukturę
}
void WyswietlWyniki(const WynikiTrojkata wynik)
{
using std::cout;
if (wynik.czy_prawda)
{
cout << "\nTo sa boki trojkata, a dodatkowo trojkata prostokatnego!\n"
<< "Udalo sie to ustalic dzieki twierdzeniu pitagorasa.\n"
<< "Bok a * a = " << wynik.Wynik1
<< "\tBok b * b = " << wynik.Wynik2
<< "\tBok c * c = " << wynik.Wynik3
<< "\nTwierdzenie pitagorasa to: (a*a) + (b*b) = (c*c)\n"
<< "\n";
}else
cout << "\nPodane boki nie tworza trojkata prostokatnego(lub zadnego innego)\n\n";
}
25.7.3. Przykład 3 - 17.4 Budowa aplikacji opartej na funkcjach
//Plik: main.cpp
#include "nazwaPliku.hpp"
//funkcja główna -------------
int main()
{
using std::string;
const int LINIE = 6;
string LotyKlient[LINIE][BILETY];
int max_wierszy = 0;
// tabela która będzie przechowywać informacje dla których miast zostały zakupione bilety
int tab_wiersze[6] = {-1, -1, -1, -1, -1, -1};
Menu();
//pętla zakończy się gdy Funkcja OperacjeKasjera zwróci wartość 27 (jest wartością klawisza ESC)
while ((max_wierszy = OperacjeKasjera()) != 27){
//sprawdza jaka opcja z menu została wybrana
if (max_wierszy != 27 && max_wierszy != -1 && max_wierszy != 6){
//jeżeli od 0 - 5 to wprowadzamy dana dla wybranego lotu(miasta)
tab_wiersze[max_wierszy] = BazaLotow(LotyKlient, max_wierszy);
}else if (max_wierszy == 6) {
//jeżeli 6 to wyświetlamy dana dla wszystkich lotów
Wyswietl_Dane(LotyKlient, LINIE, tab_wiersze, LINIE);
}
//powrót do menu
Menu();
}
return 0;
}
//Plik: nazwaPliku.hpp
#ifndef nazwaPliku_hpp
#define nazwaPliku_hpp
#include <iostream>
#include <conio.h>
#include <string>
//stała
const int BILETY = 10;
//definicje funkcji-----------
void Kursor(int, int);
void Menu();
void Wyswietl_Dane(const std::string [][BILETY], int, const int[], int);
int BazaLotow(std::string tabela1[][BILETY], int indeks1);
int OperacjeKasjera();
#endif
//Plik: nazwaPliku.cpp
#include "nazwaPliku.hpp"
#include "ddtconsole.h"
//FUNKCJE APLIKACJI --------------------------------------------------------------
//funkcja wyświetlająca wprowadzone dane
void Wyswietl_Dane(std::string const tabela1[][BILETY], int indeks1, const int tabela2[], int indeks2)
{
using namespace ddt::console;
using std::cout;
std::string Miasta[6] = {"Barcelona", "Londyn", "Paryz",
"New York", "Moskwa", "Tokyo"};
clrscr();
gotoxy(5, 5);
textcolor(10);
cout << "Oto Dane dla linii lotniczych\n";
textcolor(11);
for (int i = 0; i < indeks2; i++) {
//sprawdza czy w tabeli są dane jeżeli większe od -1 to dane są wprowadzone
if (tabela2[i] != -1) {
cout << "-----------------------------\n";
textcolor(10);
cout << Miasta[i] << "\n";
textcolor(11);
for (int j = 0; j <= tabela2[i]; j++) {
cout << tabela1[i][j] << "\n";
}
cout << "-----------------------------\n";
}
}
// pod Linuxem system(
//wyświetla komunikat systemowy i robi pauzę
system("pause");
}
//funkcja odpowiadająca za kursor (strzałkę) dokładnie jej pozycja na ekranie
void Kursor(int pion, int poziom)
{
using namespace ddt::console;
using std::cout;
using std::endl;
gotoxy(pion, poziom);
textcolor(7);
cout << "->" << endl;
}
// funkcja wprowadzająca dane do tabeli
int BazaLotow(std::string tabela1[][BILETY], int indeks1)
{
using namespace ddt::console;
using std::cin;
using std::cout;
std::string bufor;
int bilety;
int straznik = BILETY;
int licz = 0;
//glowna pętla wprowadzania
for (licz; licz < straznik; licz++){
clrscr();
gotoxy(20,8);
textcolor(15);
cout << "Literki 'q' i 'Q' - koncza wprowadzenie!\n\n";
cout << "Imie i nazwisko klienta: ";
//pobiera dana dla danego miasta--------------------
getline(cin, tabela1[indeks1][licz]);
if (tabela1[indeks1][licz] == "q" || tabela1[indeks1][licz] == "Q") {
tabela1[indeks1][licz] = "\0";
return licz - 1;
}
gotoxy(20,12);
cout << "Liczba ujemna przerywa wprowadzanie\n";
cout << "Ile biletow: ";
(cin >> bilety).get();
// sprawdza czy została podana liczba
if (!cin) //kontrola danych
{ // czyści strumień
cin.clear();
gotoxy(20,19);
cout << "Nie podano liczby!!! Restart!";
//robi pauzę na czas podanyc w milisekundach
Sleep(4000);
return licz - 1;
}
// jeżeli zostałą podana liczba ujemna przerywa
else if (bilety <= 0){
if (bilety == 0) {
// ustawiono że dla zera bilet automatycznie jest równy 1
bilety = 1;
}else{
// zeruje tabele bo wprowadzono imienie i nazwisko
tabela1[indeks1][licz] = "\0";
return licz - 1;
}
//sprawdza ile jest dostępnych biletów
}if (straznik >= bilety){
straznik -= bilety;
bufor = ", ilosc biletow: ";
tabela1[indeks1][licz] += bufor;
bufor = 'a'; //wprowadzam znak by usunąć z bufora poprzedni łancuch
// zamienia liczbe int na łancuch string
std::sprintf((char*)bufor.c_str(), "%d", bilety);
//łopatologiczne rozwiązanie problemu wpisania do tabeli sring liczby 10
if (bilety == 10) {
tabela1[indeks1][licz] += bufor;
tabela1[indeks1][licz] += '0';
}else
tabela1[indeks1][licz] += bufor;
}else{
cout << "\nPrzekroczono liczbe biletow!!!\n"
<< "Zostalo ich tylko - " << straznik;
//robi pauzę na czas podanyc w milisekundach
Sleep(5000);
licz -= 1;
}
}
return licz - 1;
}
//funkcja do nawigacji i obsługi menu ----
int OperacjeKasjera(){
using namespace ddt::console;
using std::cout;
using std::cin;
using std::endl;
int znak;
int pion = 17;
int poziom = 12;
// nawigacja kursorem-----------
do
{
Kursor(pion, poziom);
znak = getch();
/* cout << znak
<< " -- "
<< (int)znak;*/
// użycie strzałek to tak naprawde dwa znaki dlatego należy usunąć pierwsz znak
if (znak == 224 || znak == 0)
znak = getch();
switch(znak)
{ // strzalka w dół
case 80:
{
gotoxy(pion, poziom);
cout << " " << endl;
if (poziom == 14)
poziom = 11;
else
poziom++;
}break;
case 72:
{ //strzałka w górę
gotoxy(pion, poziom);
cout << " " << endl;
if (poziom == 11)
poziom = 14;
else
poziom--;
}break;
case 77:
{ //strzałka w prawo
gotoxy(pion, poziom);
cout << " " << endl;
if (pion == 17){
pion = 37;
}else
pion -= 20;
}break;
case 75:
{ //strzałka w lewo
gotoxy(pion, poziom);
cout << " " << endl;
if (pion == 37)
pion = 17;
else
pion += 20;
}break;
//jeśli ENTER to
case 13:
{ // w zaleźności od pozycji strzałki taka opcja będzie wybrana
if (poziom == 11 && pion == 17)
return 0;
else if (poziom == 12 && pion == 17)
return 1;
else if (poziom == 13 && pion == 17)
return 2;
else if (poziom == 14 && pion == 17)
return 3;
else if (poziom == 11 && pion == 37)
return 4;
else if (poziom == 12 && pion == 37)
return 5;
else if (poziom == 13 && pion == 37)
return 6;
else if (poziom == 14 && pion == 37)
return 27;
}break;
/* default:
{
//clrscr();
cout << "eror" << endl;
} break;*/
}
//Gdy ESC to koniec
}while(znak != 27);
return znak;
}
// menu aplikacji-------------
void Menu(){
using namespace ddt::console;
using std::cout;
using std::endl;
//czyści ekran
clrscr();
//ustawia pozycję
gotoxy(30,8);
//zmienia kolor czcionki
textcolor(10);
cout << "Linie AirDDT" << endl;
gotoxy(20,11);
textcolor(11);
cout << "1 - Barcelona" << endl;
gotoxy(20,12);
textcolor(12);
cout << "2 - Londyn" << endl;
gotoxy(20,13);
textcolor(13);
cout << "3 - Paryz" << endl;
gotoxy(20,14);
textcolor(14);
cout << "4 - New York" << endl;
gotoxy(40,11);
textcolor(15);
cout << "5 - Moskwa" << endl;
gotoxy(40,12);
textcolor(13);
cout << "6 - Tokyo" << endl;
gotoxy(40,13);
textcolor(11);
cout << "7 - Wszystkie linie" << endl;
gotoxy(40,14);
textcolor(10);
cout << "8 - Przerwa" << endl;
gotoxy(30,20);
textcolor(11);
cout << "Esc - Zakoncz program" << endl;
gotoxy(30,21);
textcolor(11);
cout << "Enter - Zatwierdzanie" << endl;
}
25.8 Pliki nagłówkowe a źródłowe
Tworzenie projektów kilku plikowych jak widać nie jest trudne. W plikach nagłówkowych nazwaPliku.hpp powinnyśmy zawierać następujące elementy języka C++:
- stałe symboliczne const(lub #define)
- deklaracje funkcji (również inline), struktur, klas i szablonów
- gdy main.cpp i nazwaPliku.cpp, korzystają z tych samych bibliotek też warto je umieścić w jednym pliku, a nie osobno dodawać jak w pokazanych wyżej przykładach
- pliku ten nie powinien posiadać definicji funkcji i deklaracji zmiennych
Więc jak pewnie się domyślasz w pliku nazwaPliku.cpp znajdują się funkcje zadeklarowane w pliku nagłówkowym. W ten sposób tworzy się spójność pomiędzy tymi plikami a plikiem głównym main.cpp.
Dzięki takiemu postępowaniu możemy użyć funkcji i mechanizmów zawartych w tych plikach w innych aplikacjach, bez konieczności kopiowania kodu wystarczy podpiąć odpowiednie pliki. Więc warto poświęcić trochę czasu tej tematyce, ponieważ dobrze napisane mechanizmy w plikach nagłówkowych mogą Wam się przydać w przyszłości, bez konieczności poświęcania kolejnego czasu na ich budowę.
25.9 Ćwiczenia
Znajdź wybrane zadanie z kursów i podziel je na kilka plików. Sam zdecyduj jakie to mają być zadania, uwzględnij przy tym by nie dzielić programów, które tego nie wymagają.