W dobie wielu konkurujących ze sobą systemów operacyjnych programista często staje przed wyborem platformy docelowej. Często okazuje się, że wybór ten nie jest prosty, ponieważ różni użytkownicy mają różne oczekiwania w stosunku do platformy. Podobnie było w przypadku oprogramowania telekomunikacyjnego, którego rozwijaniem zajmuję się dotychczas. Ze względu na łatwość i dobrą znajomość środowiska pierwotnie wybrany został system operacyjny Windows. Niestety, okazało się, że dostawca sprzętu niektóre potrzebne funkcje udostępnia jedynie pod systemem Linux RHEL. Najlepszym rozwiązaniem podczas pisania oprogramowania jest takie tworzenie kodu, aby był on dostępny pod maksymalną liczbą platform. Łatwo powiedzieć.

Dwa podejścia do implementowania multiplatformowości

Generalnie można wyróżnić dwa różne podejścia do zapewniania multiplatformowości. Pierwsze zakłada, że znamy wszystkie systemy operacyjne i sami tworzymy kod, który w zależności od platformy woła potrzebną funkcję. Drugie podejście zakłada, że korzystamy z istniejących bibliotek, które przykrywają specyficzne dla systemu operacyjnego wywołania i udostępniają jednolity i prosty interfejs dla aplikacji.

Własna implementacja

Własna implementacja opiera się zazwyczaj na wykorzystaniu dyrektyw preprocesora do zawołania funkcji specyficznej dla danego systemu operacyjnego. Przykładem może być funkcja wczytująca biblioteki dynamiczne. Są to pliki z rozszerzeniem .dll na platformie Windows oraz.sona platformach opartych o POSIX. Aby zaimplementować niezależne od systemu API umożliwiające wczytanie pliku, należy wprowadzić mniej więcej taki kod:

//Zdefiniuj typ uchwytu specyficzny dla platformy systemowej.

//wersja na Windows
#ifdef WIN32
#define SHLIB_HANDLE HMODULE
#define INVALID_SHLIB_HANDLE NULL
#endif //WIN32

//wersja na Unix oraz Linux
#ifdef __unix
#define SHLIB_HANDLE (void *)
#define INVALID_SHLIB_HANDLE NULL
#endif //__unix

inline SHLIB_HANDLE load_dynamic_library(const char * fileName)
{
#ifdef WIN32
return LoadLibrary(fileName);
#endif //WIN32
#ifdef __unix
return dlopen(fileName, 0);
#endif //__unix
}

Powyższy kod został uproszczony, aby prezentować tylko najważniejsze elementy. Dyrektywy preprocesora zostają wykorzystane, aby zdefiniować uniwersalne typy danych, takie jak uchwyt do wczytanej biblioteki oraz niepoprawna wartość uchwytu. Następnie definiowana jest funkcja load_dynamic_library(), która w zależności od systemu operacyjnego woła odpowiednią funkcję, która realizuje tę funkcjonalność na danym systemie operacyjnym. Aplikacja korzystająca tylko z typu SHLIB_HANDLE, funkcji load_dynamic_library() oraz wartości INVALID_SHLIB_HANDLE będzie przenośna na dwie obsługiwane platformy. Oczywiście w praktyce liczba platform, które chcemy obsługiwać, może być bardzo duża. Dla każdej trzeba pisać dodatkowy kod, aby wołać dla niej potrzebną funkcję. Proces ręcznego pisania warstwy izolującej aplikację od API systemu operacyjnego jest więc długotrwały oraz stosunkowo trudny, ponieważ trzeba znać każdy z obsługiwanych systemów operacyjnych, a wprowadzany kod należy testować na każdej z platform niezależnie.

Korzystanie z bibliotek

Drugim rozwiązaniem jest korzystanie z przenośnych bibliotek. Przykładem jest tutaj biblioteka standardowa C++. Biblioteka ta m.in. obsługuje operacje na plikach, co jest zależne od systemu operacyjnego. Biblioteka standardowa jest właściwie tylko określeniem interfejsu, przez jaki użytkownik obsługuje tę bibliotekę oraz definicją reakcji biblioteki na wołanie określonych funkcji. Wewnętrzna implementacja jest zależna od platformy. Dzięki jednolitemu interfejsowi istnieje możliwość korzystania z tej biblioteki praktycznie na wszystkich dostępnych systemach operacyjnych w dokładnie taki sam sposób. W przypadku innych bibliotek najczęściej to nie twórcy systemu, ale programiści biblioteki tworzą od razu implementację multiplatformową, wykorzystując opisaną wcześniej metodą uzależniania kodu od platformy.

Niestety, biblioteka standardowa z funkcjonalności specyficznych dla systemu operacyjnego definiuje jedynie sposób korzystania z plików. Jeśli chcemy zrobić coś więcej, musimy poszukać innej biblioteki. Istnieje wiele bibliotek przykrywających system operacyjny własną warstwą, najpopularniejszymi i najlepszymi są niewątpliwie boost (http://boost.org) oraz Adaptive Communication Environment (http://www.cs.wustl.edu/~schmidt/ACE.html).

Porównanie obydwu sposobów

Każdy z wymienionych sposobów ma swoje zalety. Głównymi zaletami własnej implementacji jest bezpośrednia kontrola nad kodem oraz możliwość optymalizacji implementacji do swoich potrzeb. Jednak podejście oparte na wykorzystaniu gotowych komponentów wydaje się lepsze ze względu przede wszystkim na prostotę takiego rozwiązania.

Do zalet wykorzystania bibliotek należą m.in.:

  • Brak konieczności znajomości wielu systemów operacyjnych, programista musi jedynie znać jedną bibliotekę.
  • Korzystanie z bibliotek znacznie skraca czas, ponieważ nie trzeba pisać własnego kodu przykrywającego warstwę systemu operacyjnego.
  • Biblioteki zazwyczaj są bardzo dobrze zaprojektowane, najczęściej pracowały nad nimi całe sztaby programistów oraz projektantów oprogramowania. Samemu trudno jest uzyskać podobny poziom.
  • Biblioteki są bardzo dobrze testowane, co znacznie zmniejsza prawdopodobieństwo błędu. W kodzie tworzonym przez siebie należy spodziewać się błędów oraz konieczności ich późniejszego usuwania.
  • Dzięki pracy specjalistów biblioteki zazwyczaj są bardzo mocno zoptymalizowane.
  • Biblioteki posiadają własną dokumentację, w przypadku pisania własnych rozwiązań konieczne jest pisanie własnej dokumentacji.
  • Jeżeli biblioteka jest popularna, to zamiast wdrażać nowego programistę w rozwiązania firmy, można poszukać takiego, który już zna daną bibliotekę.
  • Biblioteki zazwyczaj obsługują znacznie większą liczbę systemów operacyjnych, niż będzie to kiedykolwiek potrzebne podczas tworzenia aplikacji. Usuwa to groźbę rozszerzania własnej warstwy o nowy system operacyjny.

Biblioteki

Ta część ma za zadanie opisać dwie istniejące biblioteki: boost oraz ACE. Głównym celem jest przedstawienie głównych cech biblioteki, bez wdawania się w konkretną implementację. Do każdej z bibliotek istnieje dokumentacja, która szczegółowo opisuje, jak należy korzystać z poszczególnych elementów.

Boost

Boost jest biblioteką pomyślaną jako kontynuacja biblioteki standardowej C++. Dlatego też wiele klas z tej biblioteki w jakiś sposób współpracuje z biblioteką standardową. Przykładem może być współpraca klasy reprezentującej datę ze strumieniami – przekierowanie do strumienia da w efekcie dobrze sformatowaną datę. Jako biblioteka boost jest najpoważniejszym standardem, chociaż jedną z jego wad jest niewielka niestabilność związana z ciągłym rozwojem – zdarza się, że niektóre nazwy się zmieniają, powodując brak kompatybilności z kodem z poprzednich wersji.

Boost udostępnia bardzo dużo klas, które nie są zależne od platformy, a tylko upraszczają pracę programiście. Jednak dostępnych jest także bardzo dużo klas związanych bezpośrednio z platformą systemową. Są to m.in.:

  • Asynchroniczne operacje wejścia-wyjścia włączając w to operacje na plikach i gniazdach, obsługę limitów czasowych (ang. timeout) i strumienie oparte na gniazdach
  • Pobieranie/ustawianie aktualnej daty i godziny
  • Obsługa systemu plików, m.in. przenośny sposób reprezentacji ścieżek, sprawdzanie struktury katalogów oraz operacje kopiowania/usuwania plików i podobne
  • Współpraca między procesami, m.in. dzielona pamięć, pliki mapowane do pamięci, międzyprocesowe sekcje krytyczne, zmienne warunkowe (ang. conditional variables), kontenery i allokatory
  • Wsparcie ze strony systemu operacyjnego w diagnozowaniu problemów
  • Obsługa wątków, w tym tworzenie i usuwanie wątków, sekcje krytyczne, zmienne warunkowe oraz pamięć specyficzna dla wątku

Cechą boost jest absolutna przenośność i ukrywanie wszystkich elementów specyficznych dla systemu operacyjnego. Jeśli więc mamy w którym miejscu aplikacji uchwyt do pliku czy gniazda specyficzny dla naszego systemu operacyjnego, to nie ma możliwości, aby w jakiś sposób zmusić boost do współpracy. Inną cechą charakterystyczną jest obsługa tylko tych rozwiązań, które są dostępne na wszystkich platformach systemowych. Boost nigdy nie wprowadza emulacji.

Adaptive Communication Environment

ACE jest biblioteką pomyślaną przede wszystkim jako biblioteka służąca do uproszczenia programowania sieciowego. Jednak rozrosła się do rozmiarów pełnej biblioteki ukrywającej działanie systemu operacyjnego pod warstwą własnych klas. Cechą charakterystyczną jest tworzenie dwóch warstw przy tworzeniu międzyplatformowych warstw. Pierwszą warstwą jest ujednolicenie funkcji systemowych z języka C. Typowym funkcjom z API systemów operacyjnych odpowiada więc funkcja składnią przypominająca język C, a zachowująca się podobnie do funkcji z danego systemu operacyjnego. Przykładem może być funkcja ACE_OS::dlopen(), która robi to samo, co przedstawiony wcześniej przykład, czyli zapewnia międzyplatformowy sposób ładowania bibliotek.

ACE działa na tak wielu platformach, że na części z nich nie istnieje implementacja biblioteki standardowej C++. To sprawia, że ACE udostępnia swoje własne komponenty i jest zupełnie niezależna od innych bibliotek. Chociaż biblioteka ta udostępnia niektóre komponenty, które nie mają związku z funkcjami systemowymi (np. obsługa standardu XML), to jednak absolutna większość klas powiązana jest z przykrywaniem wywołań systemowych. Biblioteka ta obsługuje m.in.:

  • Pełne przykrycie warstwy systemu operacyjnego własnymi funkcjami przypominającymi składnią język C(warstwa adaptacyjna)
  • Pełna obsługa wątków, w tym sekcje krytyczne, bariery, pamięć specyficzna dla wątku, obsługa operacji atomicznych itd.
  • Obsługa asynchronicznych operacji wejścia-wyjścia (szkielety Reactor oraz Proactor) oraz konwersja kolejności bitów ze specyficznej dla platformy do postaci sieciowej
  • Pobieranie i ustawianie godziny i daty, w tym pobieranie czasu o wysokiej precyzji
  • Obsługa operacji dyskowych
  • Zsynchronizowane kolejki wiadomości i wsparcie dla tego typu komunikacji między wątkami
  • Obsługa istniejących oraz tworzenie dynamicznych bibliotek
  • Tworzenie i zarządzanie procesami, pamięć współdzielona między procesami itp.
  • Obsługa jakości serwisu (ang. Quality of Service) dla połączeń internetowych

ACE – w przeciwieństwie do boost – jest biblioteką bardzo niskopoziomową i daje możliwość dostępu do parametrów pochodzących z różnych systemów operacyjnych. Jeśli więc na platformie Windows otworzymy jakieś gniazdo i uzyskamy jego identyfikator HANDLE, to nic nie stoi na przeszkodzie, żebyśmy go później wykorzystywali podczas korzystania z funkcji ACE. Inną cechą charakterystyczną jest emulowanie funkcji niedostępnych na danej platformie. Przykładem może być emulacja pipe-ów na Windows, co fizycznie jest realizowane przez gniazda.

Dostosowanie ACE do czasem bardzo dziwnych i nietypowych środowisk zaowocowało konfigurowalnością tak zaawansowaną, że czasem trudno jest zapanować nad całą biblioteką. ACE radzi sobie m.in. z błędami w implementacji niektórych kompilatorów czy też brakiem podstawowych bibliotek na niektórych platformach. Empirycznie stwierdziłem, że czasem konieczne jest zejście do poziomu kodu i sprawdzenie, jakie makra należy zdefiniować w aplikacji, aby prawidłowo sterować budowaniem na danej platformie. Poza tym ACE jest stosunkowo słabo udokumentowane.