Projektowanie zstępujące, program make, prywatne funkcje składowe, instrukcja if, pętla do...while.
Nadszedł czas, by przedstawione w poprzednich paragrafach techniki wykorzystać przy projektowaniu i tworzeniu implementacji niewielkiego, acz użytecznego programu -- prostego kalkulatora. Mimo bardzo małego rozmiaru tego projektu, potraktujemy go bardzo poważnie, przechodząc wszystkie etapy procesu tworzenia rzeczywistego programu. Najpierw przedstawimy specyfikację funkcjonalną, następnie opracujemy koncepcję jego struktury ("projekt architektoniczny"), po czym przystąpimy do tworzenia właściwej implementacji programu.
Interfejs naszego kalkulatora będzie bardzo prosty. Użytkownik będzie mógł wprowadzać, za pośrednictwem klawiatury, liczby i symbole działań matematycznych ("operatory"). Przyjmiemy upraszczające założenie, że kalkulator będzie wykonywał działania wyłącznie na liczbach całkowitych. Kolejne liczby, wprowadzane przez użytkownika, będą przechowywane na stosie. Jeżeli użytkownik zamiast liczby poda operator, ze stosu zostaną zdjęte dwie liczby, które zostaną potraktowane jako argumenty danego działania matematycznego; jego wynik zostanie odłożony z powrotem na szczyt stosu. Jeżeli w momencie wprowadzenia operatora na stosie znajdować się będzie tylko jedna liczba, przyjmiemy, że reprezentuje ona zarówno lewy jak i prawy argument tego operatora. Po wprowadzeniu z klawiatury dowolnej liczby lub operatora kalkulator będzie wyświetlać aktualną zawartość swojego stosu.
Ponieważ tak zdefiniowany kalkulator dostosowany jest do przetwarzania wyrażeń zapisanych w tzw. Odwrotnej Notacji Polskiej (ONP, notacja przyrostkowa, Reverse Polish Notation, RPN, Postfix Notation), nie musimy zajmować się przetwarzaniem nawiasów, które w tym zapisie są po prostu zbędne. Dla uproszczenia programu zaimplementujemy tylko cztery podstawowe działania arytmetyczne.
Prosta sesja z naszym kalkulatorem może wyglądać następująco (dane wprowadzane przez użytkownika wyświetlane są po znaku zachęty '>'):
> 3 3 > 2 3 2 > + 5 > + 10 |
Jakie obiekty najwyższego poziomu powinniśmy zdefiniować w naszym programie? Oczywiście przychodzi nam na myśl calculator. Będzie on przechowywał wprowadzane do niego liczby oraz wykonywał na nich operacje arytmetyczne. Ponieważ chcemy mieć możliwość wyświetlania zawartości stosu kalkulatora, musimy mieć dostęp do iteratora stosu (nazwiemy go sequencer). Ponieważ chcemy zupełnie oddzielić od kalkulatora operacje wejścia i wyjścia, powinniśmy także zdefiniować obiekt input, który będzie służył do wczytywania i wstępnego przetwarzania danych, wprowadzanych przez użytkownika. Dzięki temu w innych częściach programu bez trudu będziemy mogli dowiedzieć się, jaki rodzaj (liczba czy operator) oraz jaką wartość reprezentują wprowadzone przez użytkownika znaki. Do wyprowadzania danych na zewnątrz programu posłużymy się standardowym obiektem std::cout.
Minimalny czas życia tych trzech obiektów musi spełniać następujące warunki:
Obiekt klasy Input otrzymuje ze standardowego urządzenia wejściowego ciąg znaków. Obiekt ten odróżnia liczby od operatorów -- dlatego zwraca różne "żetony" (ang. tokens) zależnie od tego, co pojawia się na wejściu. Jeżeli jest tam liczba, obiekt klasy Input dokona konwersji reprezentującego ją ciągu znaków na wartość tej liczby, która następnie zostanie zapamiętana w składowej typu int.
Obiekt klasy Input przekazywać będziemy do obiektu, reprezentującego kalkulator, który za jego pośrednictwem odczyta wstępnie przetworzone dane, po czym wykona odpowiednie polecenie. Jeżeli na wejściu pojawi się liczba, kalkulator odłoży ją na stos; jeżeli zaś jest tam operator, kalkulator wykona odpowiednie działanie arytmetyczne. Wyniki działania kalkulatora można śledzić za pośrednictwem iteratora stosu. Aby ułatwić współpracę tych dwóch obiektów, kalkulator może po prostu udostępnić iteratorowi (poprzez odpowiednią metodę) stałą referencję do swojego stosu.
Zwróćmy uwagę na to, że na najwyższym poziomie mamy tylko trzy rodzaje oddziałujących ze sobą obiektów; do tego dochodzi jeszcze jeden typ (stos), który możemy jednak traktować jak czarną skrzynkę (nie będziemy wywoływać jego metod, po prostu przekażemy uchwyt do niego z jednego komponentu do drugiego). Właściwość ta nie jest wyłącznie efektem ubocznym prostoty naszego projektu -- zawsze powinniśmy dążyć do tego, aby w programie mieć co najwyżej kilka obiektów najwyższego poziomu.
Po ustaleniu obiektów najwyższego poziomu możemy przystąpić do projektowania kolejnego poziomu. W naszym przypadku możemy wyróżnić jeden obiekt podrzędny względem obiektu calculator. Wiemy przecież, że kalkulator posiada stos. Dlatego posłużymy się stosem jako obiektem osadzonym wewnątrz kalkulatora. Wykorzystamy stos, którym posługiwaliśmy się w poprzednim rozdziale.
Projektowi zstępującemu powinna towarzyszyć zstępująca kolejność implementacji. Posługując się naszą specyfikacją architektoniczną rozpoczynamy od napisania kodu funkcji main.
void main ()
{
Calculator TheCalculator;
bool status;
do
{
// Zachęcamy użytkownika, by wprowadził dane
cout << "> ";
Input input;
status = TheCalculator.Execute (input);
if (status)
{
for (StackSeq seq (TheCalculator.GetStack ());
!seq.AtEnd ();
seq.Advance () )
{
cout << " " << seq.GetNum () << endl;
}
}
} while (status);
}
|
W powyższym kodzie wprowadziliśmy dwie nowe konstrukcje języka, pętlę do/while oraz instrukcję if. Ciało pętli do/while wykonywane jest tak długo, jak długo spełniony jest warunek sterujący pętli, zapisywany w nawiasach okrągłych po słowie kluczowym while. Zwróćmy uwagę na to, że w przeciwieństwie do pętli for, treść pętli do/while musi być wykonane co najmniej raz. Jak Czytelnik już się zapewne domyślił, ciało pętli tworzy oddzielny zakres lokalny (nawet wtedy, gdy składa się z jednej instrukcji, której nie umieszczono w klamrach).
Instrukcje zawarte w treści (tzw. ciele) instrukcji if wykonywane są tylko wtedy, gdy spełniony jest warunek logiczny zapisany w nawiasach okrągłych po słowie kluczowym if. Zamiast wyrażenia logicznego można tu też umieścić wyrażenie arytmetyczne; treść instrukcji if będzie wykonana wtedy i tylko wtedy, gdy wartość tego wyrażenia będzie różna od zera. Ciało instrukcji if tworzy własny zakres lokalny nawet wówczas, gdy składa się z pojedynczej instrukcji i pominięto klamry.
Zwróćmy także uwagę na to, że w funkcji main zdefiniowaliśmy zmienną status, jednak nie nadaliśmy jej wartości początkowej. W języku C++ staramy się unikać takich sytuacji. W moim programie pozwoliłem sobie opuścić inicjator tej zmiennej, gdyż i tak prawdziwa inicjacja nastąpi wewnątrz pętli do/while (a wiemy już, że zawarte w niej instrukcje zostaną wykonane co najmniej raz). Zmiennej status nie mogłem zdefiniować wewnątrz zakresu pętli, gdyż jej wartość jest testowana w wyrażeniu warunkowym pętli (po słowie kluczowym while), które należy do zakresu zewnętrznego względem pętli. Wartość tego wyrażenia jest testowana po każdorazowym wykonaniu ciała pętli.
Zgodnie z paradygmatem projektowania zstępującego przystępujemy do napisania kadłubków wszystkich klas. Kadłubkiem nazywamy uproszczoną definicję klasy lub funkcji, która posiada wymagany interfejs, jednak tak naprawdę nie robi niczego konkretnego. Zadaniem kadłubków jest doprowadzenie do sytuacji, w której od samego początku mamy ustaloną strukturę programu, który można skompilować. Dzięki kadłubkom od samego początku możemy program testować i odpluskwiać.
Jak wiemy, kalkulator musi mieć możliwość przekazywania swojego stosu do iteratora - w tej chwili nie musimy jednak wiedzieć niczego o samym stosie (poza tym, że musi to być obiekt klasy IStack). Dlatego jako kadłubek wystarczy trywialna implementacja klasy IStack:
class IStack { };
|
Jako kadłubki zaimplementuję też wszystkie metody iteratora. Żeby móc zasymulować skończoną pojemność stosu, do kadłubka jego iteratora dodałem tymczasową zmienną _done. Z kolei metoda GetNum zawsze zwraca arbitralnie wybraną liczbę 13.
class StackSeq
{
public:
StackSeq (IStack const & stack) : _stack (stack), _done (false)
{
cout << "Utworzono iterator stosu\n";
}
bool AtEnd () const { return _done; }
void Advance () { _done = true; }
int GetNum () const { return 13; }
private:
IStack const & _stack;
bool _done;
};
|
W obecnej, najmniej szczegółowej fazie konstrukcji programu, klasa Input będzie posiadać jedynie (publiczny) konstruktor:
class Input
{
public:
Input ()
{
cout << "Utworzono obiekt klasy Input\n";
}
};
|
Kadłubek klasy Calculator również będzie posiadać tymczasową składową, _done. Jej celem jest zagwarantowanie możliwości przerwania pętli do/while w funkcji main po wykonaniu dokładnie jednej iteracji.
class Calculator
{
public:
Calculator () : _done (false)
{
cout << "Utworzono obiekt klasy Calculator\n";
}
bool Execute (Input& input)
{
cout << "Calculator::Execute\n";
return !_done;
}
IStack const & GetStack () const
{
_done = true;
return _stack;
}
private:
IStack _stack;
bool _done;
};
|
Metoda GetStack zwraca stałą (const) referencję do obiektu klasy IStack. Innymi słowy, definiuje ona alternatywną nazwę prywatnej składowej kalkulatora (_stack). Nazwa ta zapewnia wyłącznie możliwość odczytu stanu składowej _stack. Tę alternatywną nazwę udostępnia się na zewnątrz, do punktu wywołania metody GetStack. Dzięki tej metodzie inne obiekty mogą uzyskać dostęp do składowej _stack, jednak tylko za pośrednictwem jej metod stałych (const) lub, jeżeli obiekt należy do klasy zaprzyjaźnionej z klasą IStack, poprzez bezpośrednie odczytywanie wartości składowych obiektu _stack, np. zmiennej _top lub elementów tablicy _arr. Jest to dokładnie to, czego potrzebuje iterator! Zwróćmy także uwagę na to, że kompilator interpretuje instrukcję return _stack w ten sposób, że faktycznie z metody GetStack zwracana jest referencję do składowej _stack. Dzieje się tak dlatego, że metodę GetStack zadeklarowano jako funkcję zwracającą referencję. Gdyby zadeklarowano ją inaczej,
IStack const GetStack () const; |
kompilator uznałby, że metoda ta powinna zwracać niemodyfikowalną kopię stosu. Jednak kopiowanie stosu jest zazwyczaj bardziej kosztowną operacją, niż przekazanie referencji. Do zagadnienia tego powrócimy podczas omawiania klas o semantyce wartości (ang. value classes).
Po stworzeniu kadłubkowych implementacji wszystkich klas możemy przystąpić do skompilowania i uruchomienia testowej wersji programu. Wynik jego działania świadczy o tym, że wszystkie elementy programu funkcjonują zgodnie z planem.
Utworzono obiekt klasy Calculator > Utworzono obiekt klasy Input Calculator::Execute Utworzono iterator stosu 13 > Utworzono obiekt klasy Input Calculator::Execute |
Nadszedł czas, by kolejno napisać rzeczywiste implementacje poszczególnych klas. Proces ten rozpoczniemy od klasy Calculator. Pisząc jej implementację zorientujemy się przy okazji, realizacji jakich usług oczekujemy od obiektów klasy Input. Implementację kalkulatora rozpoczynamy od metody Execute. Przede wszystkim powinna ona pobierać z obiektu klasy Input tzw. żeton (ang. token). W naszym programie zdefiniujemy następujące żetony: liczba, osobne żetony dla każdego działania arytmetycznego oraz żeton sygnalizujący błąd. Każdy rodzaj żetonów będziemy przetwarzac w inny sposób. W przypadku liczby (ang. number) będziemy odczytywać jej wartość ze standardowego strumienia wejściowego i umieszczać ją na stosie. W przypadku operatora będziemy pobierać ze stosu dwie liczby (lub jedną, jeżeli na stosie jest tylko jedna liczba), przekażemy je do metody Calculate, a wynik jej działania umieścimy na stosie.
bool Calculator::Execute (Input const & input)
{
int token = input.Token ();
bool status = false; // zakładamy pojawienie się błędu
if (token == tokError)
{
cout << "Nieznany zeton\n";
}
else if (token == tokNumber)
{
if (_stack.IsFull ())
{
cout << "Stos jest pelny!\n";
}
else
{
_stack.Push (input.Number ());
status = true; // sukces
}
}
else
{
assert (token == '+' || token == '-'
|| token == '*' || token == '/');
if (_stack.IsEmpty ())
{
cout << "Stack is empty\n";
}
else
{
int num2 = _stack.Pop ();
int num1;
// Przypadek specjalny - na stosie jest tylko jedna liczba:
// traktujemy ja jednocześnie jako lewy i prawy argument operatora.
if (_stack.IsEmpty ())
num1 = num2;
else
num1 = _stack.Pop ();
_stack.Push (Calculate (num1, num2, token));
status = true;
}
}
return status;
}
|
W powyższym kodzie wykorzystaliśmy rozszerzoną wersję instrukcji warunkowej if -- instrukcję if/else. Treść instrukcji, znajdującej się za słowem kluczowym else, wykonywana jest tylko wtedy, gdy nie był spełniony warunek wykonania odpowiedniej instrukcji if (w języku polskim if można czytać "jeśli", a else -- "w przeciwnym wypadku", przyp. tłum.).
Instrukcje if/else można ze sobą łączyć w większą całość:
if (A) ... // jeśli A else if (B) ... // jeśli nie-A i tak-B else if (C) ... // jeśli nie-A i nie-B i tak-C else ... // jeśli nie-A i nie-B i nie-C |
Metoda Calculate jest nową metodą klasy Calculator. Ponieważ będziemy się nią posługiwać wyłącznie w implementacji kalkulatora, jej definicję umieściliśmy w sekcji prywatnej. Metoda ta ma trzy argumenty (liczba, liczba, żeton) i zwraca jako swoją wartość wynik działania arytmetycznego. Powodem zapisania kodu wyznaczającego wynik działania w osobnej metodzie było zbytnie rozrośnięcie się kodu metody Execute. Metoda Calculate ma ściśle określone przeznaczenie i jest w dużym stopniu niezależna od reszty programu. Zaimplementowałem ją przy pomocy jednej, złożonej instrukcji if/else.
int Calculator::Calculate (int num1, int num2, int token) const
{
int result;
if (token == '+')
result = num1 + num2;
else if (token == '-')
result = num1 - num2;
else if (token == '*')
result = num1 * num2;
else if (token == '/')
{
if (num2 == 0)
{
cout << "Dzielenie przez zero\n";
result = 0;
}
else
result = num1 / num2;
}
return result;
}
|
Proszę zwrócić uwagę na sposób posługiwania się w programie literałami znakowymi, np.
'+', '-', '*', '/' |
Literały znakowe otaczamy pojedynczymi cudzysłowami (jak już wiemy, podwójnymi znakami cudzysłowu otaczamy literały napisowe). W naszym programie zamiast przypisać żetonom operatorowym pewne specjalne wartości, po prostu skorzystaliśmy z wartości odpowiednich literałów znakowych (wartości kodów ASCII).
Przyjrzyjmy się teraz zmodyfikowanej definicji klasy Calculator.
class Calculator
{
public:
bool Execute ( Input& input );
// Metoda GetStack udostępnia stos
IStack const & GetStack () const { return _stack; }
private:
int Calculate (int n1, int n2, int token) const;
IStack _stack;
};
|
Po napisaniu pełnej implementacji klasy Calculator możemy teraz do naszego programu dołączyć napisane w poprzednich rozdziałach implementacje klas IStack i StackSeq, rozszerzyć tymczasową implementację klasy Input (chodzi tu o rzetelną implementację jej metod Token i Number), całość skompilować i przetestować.
Obecnie przystępujemy do napisania rzeczywistej implementacji klasy Input. Jednocześnie nadszedł czas, by nasz projekt podzielić na kilka plików. Zdecydowałem, że program będzie się składał z trzech plików nagłówkowych -- calc.h, stack.h, input.h -- oraz trzech plików implementacyjnych -- calc.cpp, stack.cpp i input.cpp. Ogólnie rzecz biorąc, żeby z kilku plików źródłowych można było utworzyć program wykonywalny, należy skorzystać ze specjalnego programu, zwanego konsolidatorem (ang. linker). Na szczęście użytkownicy zintegrowanych środowisk programistycznych mogą uprościć sobie ten proces, tworząc po prostu tzw. projekt i dodając do niego wszystkie pliki, wchodzących w skład programu. Po wykonaniu tej czynności program wykonywalny można utworzyć (ang. build) jednym kliknięciem myszki. Szczegółowe omówienie pracy z projektami znajdzie Czytelnik w instrukcji obsługi swojego środowiska programistycznego.
Doświadczenie uczy, że im więcej w projekcie mamy plików nagłówkowych, tym większa szansa, że ten sam plik włączymy gdzieś dwukrotnie. Jak to możliwe? Przypuśćmy, że plik input.h włączamy do programu wewnątrz pliku calc.h. Następnie oba te pliki nagłówkowe włączamy w pliku calc.cpp. I mamy kłopot. Kompilator zobaczy bowiem dwie definicje klasy Input (co prawda są identyczne, ale dla kompilatora nie ma to żadnego znaczenia). Przed takim konfliktem można się zabezpieczyć, stosując w plikach nagłówkowych dyrektywy kompilacji warunkowej.W tym celu całą treść pliku input.h umieszczamy wewnątrz bloku #if...#endif.
#if !defined input_h ... #endif |
Kompilator przetworzy instrukcje, zawarte pomiędzy tymi dyrektywami preprocesora tylko wtedy, gdy nie zdefiniowano jeszcze symbolu input_h. Dowcip polega na tym, że przy pierwszej próbie przetworzenia tego pliku symbol input_h nie jest zdefiniowany. Dlatego gdy kompilator po raz pierwszy dotrze do dyrektywy #include "input.h", posłusznie przetworzy całą zawartość pliku input.h. Jednakże pierwszą czynnością, którą wykonujemy w bloku if...endif jest zdefiniowanie symbolu input_h
#define input_h
|
Dlatego, gdy kompilator kolejny raz dotrze do dyrektywy #include "input.h", stwierdzi, że symbol input_h już zdefiniowano, więc w pliku "input.h" pominie wszystkie instrukcje zawarte pomiędzy dyrektywami #if !defined input_h i #endif
#if !defined input_h // zabezpieczenie przed wielokrotnym włączeniem #define input_h const int maxBuf = 100; // Możliwe żetony: tokNumber, tokError, +, -, *, /. const int tokNumber = 1; const int tokError = 2; // Klasa Input pobiera dane z stdin i zamienia je na żeton (token). class Input { public: Input (); int Token () const { return _token; } int Number () const; private: int _token; char _buf [maxBuf]; }; #endif // input_h |
Zwróćmy uwagę na modyfikator const w deklaracjach metod Token i Number. Mimo iż ogólnie rzecz biorąc ogranicza on dostęp do tych metod, jednak nie dotyczy to kalkulatora, który posługuje się obiektem klasy Input za pośrednictwem stałej (const) referencji. Natomiast składowa _buf służy jako bufor przechowujący wprowadzony przez użytkownika łańcuch znaków.
W pliku input.cpp włączamy dwa standardowe pliki nagłówkowe.
#include <cctype> #include <cstdlib> |
Plik cctype zawiera definicje (bardzo efektywnych) makr, służących do rozpoznawania rodzaju znaków. Na przykład makro isdigit zwraca true, jeżeli znak odpowiada jednej z cyfr dziesiętnych ('0',...,'9'), w przeciwnym przypadku jego wartością jest false. Drugi plik, cstdlib, zawiera deklarację funkcji atoi, która służy do konwersji łańcucha znaków ASCII na odpowiadającą jej liczbę całkowitą. Więcej informacji o tych i podobnych im funkcjach znajduje się w opisie biblioteki standardowej. Znajdziemy go w systemie pomocy online, instrukcji obsługi kompilatora lub w innych książkach.
Input::Input ()
{
cin >> _buf;
// zazwyczaj żeton można zidentyfikować już na po przeczytaniu pierwszego znaku
int c = _buf [0];
if (isdigit (c))
_token = tokNumber;
else if (c == '+' || c == '*' || c == '/')
_token = c;
else if (c == '-') // uwzględniamy liczby ujemne
{
if (isdigit (_buf [1])) // sprawdzamy następny znak
_token = tokNumber;
else
_token = c;
}
else
_token = tokError;
}
|
Konstruktor klasy Input odczytuje ze standardowego strumienia wejściowego wiersz tekstu i umieszcza go w buforze znaków. To jeszcze jedna niesamowita sztuczka, którą potrafi wykonać obiekt std::cin (oczywiście na bieżącym etapie konstrukcji programu nawet nie myślimy o tym, co mogłoby się zdarzyć, gdyby w buforze zabrakło miejsca na cały napis; wciąż bowiem tworzymy kod na poziomie programisty niedzielnego).
Rodzaj żetonu określamy na podstawie pierwszego znaku w buforze. Proszę zwrócić uwagę na szczególny sposób przetwarzania znaku "minus". Może on bowiem oznaczać zarówno minus unarny (np w wyrażeniu -7), jak i binarny (np. w wyrażeniu 3 - 4). Żeby rozstrzygnąć, z którym operatorem mamy akurat do czynienia, sprawdzamy wartość następnego znaku w buforze. Jeśli chodzi o rozstrzygnięcie, czy dany znak reprezentuje cyfrę dziesiętną ('0', '1', '2',... lub '9'), użycie makra isdigit jest lepszym pomysłem na stwierdzenie niż kod typu
if ( c >= '0' && c <= '9' ) |
Makro isdigit posługuje się algorytmem tablicowym i działa nieco szybciej, niz powyższy kod. W pliku nagłówkowym cctype znajdują sie także definicje innych użytecznych makr, np. isspace (czy znak jest tzw. białym znakiem), islower (czy znak jest małą literą) i isupper (czy znak jest dużą literą).
Jeżeli mamy do czynienia z żetonem liczbowym (tokNumber), kalkulator chciałby poznać wartość tej liczby. Informację tę może on otrzymać z obiektu klasy Input poprzez jego metodę Number, która zamienia przechowywany w buforze napis na liczbę typu int. W tym celu metoda Number posługuje się standardową funkcją atoi, zadeklarowaną w pliku cstdlib.
int Input::Number () const
{
assert (_token == tokNumber);
return atoi (_buf); // zamiana napisu na liczbę całkowitą (int)
}
|
Część Czytelników być może nie chce lub nie może posługiwać się zintegrowanym środowiskiem programistycznym. W sytuacji, gdy projekt rozrasta się na tyle, że chcemy lub musimy podzielić go na kilka plików, prawdopodobnie nadszedł czas, by zacząć posługiwać się programem narzędziowym make. Poniżej przedstawiam bardzo uproszczony plik makefile, w którym zdefiniowano zależności pomiędzy różnymi plikami, wchodzącymi w skład projektu. W pliku tym określono także, w jaki sposób należy utworzyć kod wykonywalny:
calc.exe : calc.obj stack.obj input.obj cl calc.obj stack.obj input.obj calc.obj : calc.cpp calc.h input.h stack.h cl -c calc.cpp stack.obj : stack.cpp stack.h cl -c stack.cpp input.obj : input.cpp input.h cl -c input.cpp |
Jeżeli powyższe instrukcje umieścimy w pliku o nazwie calc.mak, to proces kompilacji programu inicjujemy komendą make calc.mak (lub nmake calc.mak). Spowoduje ona przeprowadzenie wszystkich niezbędnych etapów kompilacji i konsolidację uzyskanych w ten sposób kodów pośrednich (*.obj) do pliku wykonywalnego calc.exe. Szczegółowe omówienie programu make (lub nmake) znajdzie Czytelnik w systemie pomocy online lub instrukcji obsługi kompilatora. Program make jest podstawową metodą kompilacji dużych projektów w środowisku systemu operacyjnego Unix (przyp. tłum.).