Modularyzacja i interfejsy w C++
Każdy realny program składa się z pewnej liczby osobnych części. Nawet w prostym programie „Witaj, świecie” wykorzystywane są przynajmniej dwie części: kod użytkownika żądający wydrukowania napisu Witaj, świecie oraz system wejścia i wyjścia wykonujący to zadanie.
Zastanówmy się nad przykładem kalkulatora. W programie tym można wyróżnić pięć części:
1. Parser wykonujący analizę składniową: expr(), term() oraz prim().
2. Lekser składający tokeny ze znaków: Kind, Token, Token_stream oraz ts.
3. Tablica symboli zawierająca pary (łańcuch, wartość): table.
4. Sterownik: main() i calculate().
5. Mechanizm obsługi błędów: error() i number_of_errors.
Można to przedstawić na schemacie:
Strzałki należy czytać jako „używa”. Dla uproszczenia nie zaznaczyłem, że każda z części wykorzystuje mechanizm obsługi błędów. Początkowo kalkulator ten składał się z trzech części, a sterownik i obsługa błędów zostały dodane później.
Moduł nie musi wiedzieć wszystkiego o innym module, który wykorzystuje. W istocie im więcej szczegółów implementacyjnych jest ukrytych przed użytkownikami, tym lepiej. Dlatego też w modułach oddziela się część implementacyjną od interfejsu. Na przykład parser do działania potrzebuje tylko interfejsu leksera, nie tego modułu w całości. Lekser natomiast implementuje usługi, które udostępnia poprzez swój interfejs. Można to przedstawić w postaci schematu:
Przerywana linia oznacza „implementuje”. Moim zdaniem tak powinna wyglądać struktura tego programu i zadaniem programisty jest dążyć do jej wiernego odwzorowania. Dzięki temu kod będzie prosty, wydajny, zrozumiały, łatwy do modyfikacji itd., bo będzie bezpośrednio odzwierciedlał zamierzenia projektowe.
Omawiany kalkulator to niewielki program, więc w „rzeczywistości” nie zaprzątałbym sobie głowy używaniem przestrzeni nazw ani rozdzielaniem kompilacji w takim stopniu, jak to robię tutaj. Opis struktury tego programu ma na celu jedynie przedstawienie
technik wykorzystywanych przy budowie większych programów bez grzebania w wielkich ilościach kodu źródłowego. W wielu prawdziwych programach każdy „moduł” reprezentowany przez osobną przestrzeń nazw zawiera setki funkcji, klas, szablonów itd.
Obsługa błędów przenika strukturę programu. Dzieląc program na moduły i składając program z modułów, należy starać się zminimalizować zależności między tymi modułami wywoływane przez obsługę błędów. W języku C++ procedury wykrywania i zgłaszania błędów można oddzielić od procedur obsługi błędów za pomocą wyjątków.
Modularność może się wyrażać na wiele innych sposobów niż opisane w tym i następnym rozdziale. Można na przykład używać współbieżnie wykonywanych i porozumiewających się ze sobą zadań lub procesów reprezentujących ważne aspekty modularności. Także wykorzystanie osobnych przestrzeni adresowych i przekazywanie między nimi informacji to ważne zagadnienia, które nie są w tej książce opisane. Moim zdaniem te aspekty modularności są w dużym stopniu niezależne i ortogonalne. Co ciekawe, podział systemu na moduły jest zawsze łatwy. Trudności sprawia tylko zapewnienie bezpiecznej, wygodnej i sprawnej komunikacji między tymi modułami.
Przestrzenie nazw i moduły
Przestrzeń nazw to mechanizm umożliwiający definiowanie logicznych grup elementów, tzn. jeśli jakieś deklaracje można powiązać wg jakiegoś kryterium, to można ten fakt wyrazić, umieszczając je w jednej przestrzeni nazw. A zatem przestrzeni nazw możemy użyć do wyrażenia logicznej struktury naszego kalkulatora. Przykładowo deklaracje z parsera można zaliczyć
do przestrzeni nazw Parser:
double expr(bool);
double prim(bool get) { /*...*/ }
double term(bool get) { /*...*/ }
double expr(bool get) { /*...*/ }
}
Funkcja expr() musi być zadeklarowana pierwsza, a później zdefiniowana, aby przerwać pętlę zależności. Część odpowiedzialną za pobieranie danych do programu również można wyodrębnić w postaci przestrzeni nazw:
enum class Kind : char { /*...*/ };
class Token { /*...*/ };
class Token_stream { /*...*/ };
Token_stream ts;
}
Najprostsza jest chyba tablica symboli:
namespace Table {
map table;
}
Sterownika nie można bezpośrednio umieścić w przestrzeni nazw, bo zasady języka wymagają, aby funkcja main() była globalna:
void calculate() { /*...*/ }
}
int main() { /*...*/ }
Mechanizm obsługi błędów również jest banalnie prosty:
int no_of_errors;
double error(const string& s) { /*...*/ }
}
Taki podział na przestrzenie nazw pozwala łatwo się zorientować, co lekser i parser udostępniają do użytku. Gdybym dodał kod źródłowy funkcji, struktura stałaby już niewidoczna. Gdyby w przestrzeni nazw o realnych rozmiarach dodano definicje do deklaracji funkcji, to w celu dowiedzenia się, jakie usługi są realizowane, tzn. jaki jest interfejs, trzeba by było przekopywać
się przez setki wierszy kodu.
Alternatywnym rozwiązaniem w stosunku do określania osobnych interfejsów jest używanie narzędzia wyodrębniającego interfejs z modułu zawierającego szczegóły implementacyjne. Uważam to za złe rozwiązanie. Określanie interfejsu to jeden z filarów projektowania programu, a poza tym moduł może udostępniać różne interfejsy różnym użytkownikom i interfejs często projektuje się na długo przed implementacją reszty. Poniżej znajduje się kod parsera z interfejsem oddzielonym od implementacji:
double prim(bool);
double term(bool);
double expr(bool);
}
double Parser::prim(bool get) { /*...*/ }
double Parser::term(bool get) { /*...*/ }
double Parser::expr(bool get) { /*...*/ }
Należy zauważyć, że w efekcie oddzielenia implementacji od interfejsu każda funkcja ma dokładnie jedną deklarację i jedną definicję. Dla użytkowników dostępny będzie tylko interfejs zawierający deklaracje. Implementacja — w tym przypadku treść właściwa funkcji — zostanie umieszczona gdzie indziej, gdzie użytkownik nie musi zaglądać.
Najlepiej żeby każdy składnik użyty do budowy programu należał do jakiejś rozpoznawalnej logicznej jednostki (modułu). Dlatego też każda deklaracja w większym programie powinna być przypisana do jakiejś przestrzeni nazw o nazwie odzwierciedlającej jej logiczną rolę w tym programie. Wyjątkiem jest funkcja main(), która musi być globalna, aby kompilator mógł ją rozpoznać.
Implementacje
Jak będzie wyglądał kod po zmianie jego struktury na modułową? To zależy od tego, jak postanowimy go używać w innych przestrzeniach nazw. Nazwy w obrębie jednej przestrzeni nazw można używać w taki sam sposób, jak gdyby tych przestrzeni nie było. Jednak dostęp do nazw między różnymi przestrzeniami jest możliwy tylko dzięki pomocy kwalifikatorów lub deklaracji i dyrektyw using. Dobrym przykładem do zobrazowania technik wykorzystania przestrzeni nazw jest funkcja Parser::prim(), która korzysta z wszystkich pozostałych przestrzeni nazw z wyjątkiem Driver. Jeśli zastosujemy bezpośrednią kwalifikację, to otrzymamy taki kod:
{
if (get) Lexer::ts.get();
switch (Lexer::ts.current().kind) {
case Lexer::Kind::number: // stała zmiennoprzecinkowa
{ double v = Lexer::ts.current().number_value;
Lexer::ts.get();
return v;
}
case Lexer::Kind::name:
{ double& v = Table::table[Lexer::ts.current().string_value];
if (Lexer::ts.get().kind == Lexer::Kind::assign) v = expr(true); // znaleziono =: przypisanie
return v;
}
case Lexer::Kind::minus: // jednoargumentowy minus
return −prim(true);
case Lexer::Kind::lp:
{ double e = expr(true);
if (Lexer::ts.current().kind != Lexer::Kind::rp) return Error::error("Oczekiwano ')'");
Lexer::ts.get(); // zjada ')'
return e;
}
default:
return Error::error("Oczekiwano wyrażenia podstawowego");
}
}
Naliczyłem 14 wystąpień kwalifikatora Lexer:: i (mimo że teoria mówi co innego) nie wydaje mi się, żeby program stał się choć odrobinę bardziej czytelny dzięki modularności. Nie używałem kwalifikatora Parser::, bo jest to zbędne w przestrzeni nazw Parser.
Jeśli użyjemy deklaracji using, to otrzymamy taki kod:
using Lexer::Kind; // eliminuje sześć wystąpień kwalifikatora Lexer::
using Error::error; // eliminuje dwa wystąpienia kwalifikatora Error ::
using Table::table; // eliminuje jedno wystąpienie kwalifikatora Table::
double prim(bool get) // obsługuje wyrażenia podstawowe
{
if (get) ts.get();
switch (ts.current().kind) {
case Kind::number: // stała zmiennoprzecinkowa
{ double v = ts.current().number_value;
ts.get();
return v;
}
case Kind::name:
{ double& v = table[ts.current().string_value];
if (ts.get().kind == Kind::assign) v = expr(true); // znaleziono =: przypisanie
return v;
}
case Kind::minus: // jednoargumentowy minus
return −prim(true);
case Kind::lp:
{ double e = expr(true);
if (ts.current().kind != Kind::rp) return error("Oczekiwano ')'");
ts.get(); // zjada ')'
return e;
}
default:
return error("Oczekiwano wyrażenia podstawowego");
}
}
Podejrzewam, że deklaracje using pozwalające pozbyć się kwalifikatora Lexer:: są warte zachodu, ale znaczenie pozostałych jest marginalne.Gdybyśmy użyli dyrektyw using, otrzymalibyśmy taki kod:
using namespace Error; // eliminuje dwa wystąpienia kwalifikatora Error ::
using namespace Table; // eliminuje jedno wystąpienie kwalifikatora Table::
double prim(bool get) // obsługuje wyrażenia podstawowe
{
// itd.
}
Deklaracje using dla przestrzeni nazw Error i Table dają niewiele i można nawet się spierać, czy nie przeszkadzają tylko w odkryciu pochodzenia wcześniej dobrze oznaczonych nazw. Z tego wynika, że decyzję, czy użyć kwalifikatora, czy deklaracji lub dyrektywy using, należy podejmować w każdym przypadku osobno. Oto kilka podstawowych zasad:
1. Jeśli jakiś kwalifikator pojawia się w kodzie wiele razy dla kilku nazw, pozbądź się go przy użyciu dyrektywy using.
2. Jeśli jakiś kwalifikator powtarza się wielokrotnie dla jednej nazwy, pozbądź się go przy użyciu deklaracji using.
3. Jeśli nazwa z kwalifikatorem pojawia się sporadycznie, niech pozostanie z kwalifikatorem, aby było wiadomo, skąd pochodzi.
4. Nie używaj kwalifikatora dla nazw znajdujących się w tej samej przestrzeni nazw co użytkownik.
Interfejsy i implementacje
Jest raczej jasne, że definicja przestrzeni nazw, którą utworzyliśmy dla parsera, nie jest idealnym interfejsem do zaprezentowania użytkownikom. Zawiera ona tylko zestaw deklaracji potrzebnych do wygodnego napisania poszczególnych funkcji parsera. Jednak interfejs przeznaczony dla użytkowników powinien być znacznie prostszy:
double expr(bool);
}
Pierwotna wersja przestrzeni nazw Parser pełniła dwie role:
2. zewnętrznego interfejsu udostępnianego przez parser użytkownikom.
W związku z tym w kodzie sterownika, funkcji main(), powinien być widoczny tylko interfejs użytkownika. Funkcje implementacji parsera powinny „widzieć” ten interfejs, który wybraliśmy jako najlepszy do określenia ich wspólnego środowiska, tzn.:
double prim(bool);
double term(bool);
double expr(bool);
using namespace Lexer; // dostęp do wszystkich narzędzi dostępnych w lekserze
using Error::error;
using Table::table;
}
Można to też przedstawić graficznie:
Strzałki należy czytać „korzysta z interfejsu udostępnianego przez”.
Można by było interfejsowi użytkownika nadać inną nazwę niż interfejsowi implementacyjnemu, ale (jako że przestrzenie nazw są otwarte) nie musimy tego robić. Brak takiego rozdziału nie będzie powodował problemów, bo i tak osobne nazwy zostaną naturalnie dostarczone przez fizyczny układ programu w plikach. Gdybyśmy zdecydowali się na osobną przestrzeń nazw dla implementacji, dla użytkowników nie miałoby to znaczenia:
double expr(bool);
}
namespace Parser_impl { // interfejs implementacyjny
using namespace Parser;
double prim(bool);
double term(bool);
double expr(bool);
using namespace Lexer; // dostęp do wszystkich narzędzi dostępnych w lekserze
using Error::error;
using Table::table;
}
Graficzna reprezentacja:
W większych programach preferuję rozwiązanie z dodatkowym interfejsem _impl. Interfejsy implementacyjne są bardziej obszerne od interfejsów użytkownika. Gdyby był to interfejs dla normalnego rozmiaru modułu w prawdziwym systemie, to zmieniałby się częściej niż interfejs widoczny dla użytkowników. A użytkownik modułu (w tym przypadku Driver wykorzystującego przestrzeń nazw Parser) nie powinien być narażony na takie zmiany.
Język C++. Kompendium wiedzy. Wydanie IV, Autor: Bjarne Stroustrup, Wydawnictwo: Helion