Przejdź do treści

Podstawy korzystania z bibliotek

Wprowadzenie

Biblioteki programistyczne to pliki, które dostarczają gotowe funkcje, typy danych (struktury lub klasy), a czasem zestawy danych, które mogą zostać użyte przez programistę we własnym kodzie źródłowym. Aby korzystać z wybranej biblioteki trzeba ją pobrać lub zainstalować na docelowym komputerze. Z perspektywy kodu źródłowego, korzystanie z biblioteki sprowadza się do używania jej interfejsu programistycznego (API). Z perspektywy programu wykonywalnego, trzeba zadbać o odpowiednią kompilację programu, a w przypadku bibliotek linkowanych dynamicznie o ich dostępność na etapie uruchomienia programu.

Inne nazwy na określenie biblioteki

Dla niektórych języków programowania wymiennie z pojęciem "biblioteka" może być stosowana inna nazwa. Przykładem może być język Python, gdzie mówi się o pakietach (które się pobiera i instaluje menedżerem pakietów pip).

wizualizacja programu korzystającego z bibliotek

wizualizacja programu korzystającego z bibliotek [źródło]

API

Każda biblioteka posiada tzw. API (application programming interface), czyli interfejs programistyczny. Interfejs ten określa sposób odwoływania się przez programistę do poszczególnych elementów biblioteki oraz opisuje które wywołanie jaki pociąga za sobą efekt. Dzięki niemu programista nie musi wiedzieć jak dokładnie zbudowana jest biblioteka wewnątrz, otrzymuje bowiem informacje o jej "zewnętrznym interfejsie" czyli tym jak z niej korzystać.

Analogia do świata rzeczywistego – API na przykładzie kalkulatora

Wyobraźmy sobie kalkulator. Nie wiemy jak dokładnie jest wewnątrz zbudowany, jakie układy scalone posiada, ani jak są połączone. Kalkulator posiada jednak wyświetlacz oraz klawisze (fizyczne, podpisane przyciski), które są jego interfejsem. To właśnie za ich pomocą używamy kalkulatora. API kalkulatora sprowadzałoby się do wyspecyfikowania jakie działanie pociąga za sobą który przycisk. Efekt działania możemy obserwować na wyświetlaczu.

W praktycznym ujęciu, API biblioteki to specyfikacja elementów które programista może używać w swoim kodzie, czyli lista deklaracji:

  • funkcji (nazwa, typy argumentów, typ zwracanej wartości),
  • struktur (nazwa, typy i nazwy pól składowych),
  • klas (nazwa, typy i nazwy pól, deklaracja konstruktorów i metod).

Specyfikacja funkcji dodatkowo zawiera opis działania, np. które argumenty są modyfikowane i jaka wartość jest zwracana, oraz ewentualne dodatkowe wymagania. Przykładem tych drugich może być określenie jakie warunki muszą spełniać podane argumenty, albo informacja że przed użyciem danej funkcji najpierw inna funkcja musi być wywołana. Bardzo często specyfikacja danej biblioteki wprowadza dodatkowe pojęcia i przy ich użyciu opisuje zmieniające się stany obiektów i efekty działania funkcji.

Przykład specyfikacji funkcji

Funkcja std::strcpy() to funkcja z biblioteki standardowej języka C++, należąca do przestrzeni nazw std.

Opis działania:

Funkcja kopiuje zawartość ciągu znakowego pod wskazaną lokalizację. Jest zdefiniowana w plikach nagłówkowych <string.h> oraz <cstring>.

Składnia:

char* strcpy(char* dest, const char* src);

Parametry funkcji:

  • dest: wskaźnik do docelowej tablicy, gdzie zawartość zostanie skopiowana,
  • src: wskaźnik do tablicy zawierającej źródłowy ciąg znaków zakończony zerem (null-terminated).

Wartość zwracana:

  • funkcja zwraca wskaźnik na docelowy ciąg znaków, czyli wartość dest.

Funkcje i struktury danych dostarczane przez bibliotekę są zorganizowane zgodnie z hierarchią jednostek kodu źródłowego dla danego języka programowania. Mamy tu na myśli takie jednostki logiczne grupujące funkcje i klasy jak np. pliki nagłówkowe (header files) oraz przestrzenie nazw (namespace) w C++, moduły w Fortran, pakiety w Java, czy pakiety oraz moduły w Python. W efekcie pojedyncza biblioteka zwykle dostarcza wiele jednostek logicznych możliwych do wykorzystania w kodzie źródłowym, każda udostępniająca pewien fragment API biblioteki.

Przykład wielu modułów – Biblioteka NumPy (Python)

Biblioteka NumPy posiada moduł numpy.ma (masked array) pozwalający oznaczać zawartość tabeli jako niepoprawną. Aby użyć funkcji zeros() z tego modułu, w kodzie źródłowym można użyć odwołania numpy.ma.zeros().

Przykładowe inne moduły udostępniane przez NumPy to numpy.random, implementujący generatory liczb pseudolosowych, czy numpy.linalg dostarczający funkcji algebry liniowej.

Wyszczególnienie interfejsu programistycznego pozwala na różne zabiegi na poziomie implementacji danego API. Przykładowo możliwe jest to, że ten sam interfejs posiada kilka implementacji, albo że właściwa implementacja jest w innym języku. Bardzo często można również natknąć się na biblioteki pośredniczące (tzw. wrappery), które nie tyle implementują daną bibliotekę, co udostępniają inne API. Więcej w sekcji implementacje API.

Interfejsu programistycznego (API) nie należy mylić z pojęciem ABI (application binary interface), które oznacza interfejs binarny.

Używanie

Kod biblioteki zwykle jest podzielony na mniejsze jednostki logiczne, odpowiadające różnym funkcjonalnościom. Aby umożliwić korzystanie z API danej biblioteki konieczne jest użycie odpowiedniej instrukcji w kodzie źródłowym. Instrukcje te różnią się między językami programowania, przykładowo są to: #include w C/C++, use (dawniej include) w Fortran, czy import w Python. Ich efektem jest wskazanie jednostki logicznej kodu biblioteki, którą chcemy używać, co pozwala w dalszej części programu odwoływać się do wszystkich funkcji i struktur udostępnianych przez tę jednostkę.

Użycie biblioteki dla różnych języków

Załóżmy, że posiadamy bibliotekę o nazwie library dla różnych języków programowania. Do dyspozycji mamy odpowiednio: pliki nagłówkowe dla języka C/C++ (library.h), moduł dla języka Fortran (library.mod), oraz moduł dla języka Python (library.py). Bibliotekę "włączamy" do użytku odpowiednio przez:

  • #include "library.h" lub #include <library.h> dla C/C++,
  • use library dla Fortran,
  • import library dla Python.

Pojedyncza biblioteka, np. w języku C, może udostępniać wiele plików nagłówkowych.

przykładowe odwołanie do biblioteki standardowej C

przykładowe odwołanie do biblioteki standardowej C [źródło]

W przypadku języków kompilowanych (np. C, C++, Fortran), na etapie kompilacji trzeba również wskazać odpowiednie pliki biblioteki, zawierające zarówno deklaracje funkcji jak i ich właściwą implementację. Więcej w pliki biblioteki oraz kompilacja programu.

W przypadku stosowania bibliotek linkowanych dynamicznie (patrz typy bibliotek) lub języków interpretowanych (np. Python), czasem trzeba zadbać o dostępność biblioteki na etapie uruchomienia programu. Zwykle sprowadza się to zdefiniowania ścieżek dostępu w formie odpowiednich zmiennych systemowych, np. PYTHONPATH dla języka Python, lub LD_LIBRARY_PATH dla bibliotek dynamicznych (patrz widoczność).

Biblioteka standardowa

Biblioteka standardowa języka zawiera wszystkie typy danych oraz podstawowe funkcje dla danego języka programowania. Jest ona dostępna automatycznie: dostarczana razem z każdym kompilatorem danego języka i nie trzeba jej w żaden dodatkowy sposób instalować. Można więc powiedzieć, że jest to zestaw standardowych funkcjonalności dostępnych dla danego języka programowania już w momencie jego instalacji.

Korzystanie z biblioteki standardowej gwarantuje przenośność kodu. Program korzystający wyłącznie z niej będzie można skompilować na każdej platformie dla której jest dostępny kompilator danego języka, a otrzymany program powinien działać w taki sam sposób.

Biblioteka standardowa jako element standardu języka

Języki programowania są sformalizowane poprzez stosowne dokumenty określające ich składnię i działanie – zazwyczaj odpowiednie dokumenty ISO. Specyfikacja biblioteki standardowej najczęściej jest elementem takiego dokumentu. Oznacza to, że każdy kompilator – aby być zgodnym ze standardem danego języka – musi implementować bibliotekę standardową zgodnie ze specyfikacją.

Biblioteki standardowe zwykle są bardzo rozbudowanymi bibliotekami, przy czym zakres dostępnych funkcji różni się znacznie w zależności od języka programowania. Przykłady:

Biblioteka standardowa dla Fortran

Język Fortran nie ma oficjalnej biblioteki standardowej (określonej przez standard języka, dostępnej wraz z kompilatorem). Jednakże dostępna jest zewnętrzna biblioteka (wymaga instalacji), którą można tak traktować. Więcej informacji: Programming in Modern Fortran – Standard Library.

Na poziomie kodu źródłowego używanie biblioteki standardowej nie różni się niczym od używania innych bibliotek. Aby skorzystać z jej odpowiedniego fragmentu również trzeba w kodzie włączyć go do użytku (#include, use, import). Na etapie kompilacji i uruchomienia zwykle nie trzeba specyfikować żadnej dodatkowej ścieżki, gdyż zazwyczaj biblioteka standardowa znajduje się w miejscu domyślnie dostępnym dla kompilatora (patrz kompilacja > wskazywanie lokalizacji).

Biblioteka standardowa a funkcje wbudowane

Należy odróżnić bibliotekę standardową od funkcji wbudowanych (built-in, intrinsic) danego języka. Te drugie dostępne są zawsze, nie jest potrzebna żadna instrukcja włączająca do programu daną jednostkę kodu jak w przypadku bibliotek. W języku C/C++ nie ma funkcji wbudowanych. Niektóre kompilatory mogą dostarczać własne funkcje wbudowane, ale nie są to standardowe funkcje (zdefiniowane przez standard danego języka).

Przykładem może być funkcja abs() w Python. Dla odróżnienia, analogiczna funkcja abs() w C++ jest dostępna jako funkcja biblioteczna (wymaga odwołania się do cstdlib lub cmath).

Więcej informacji:

Dla odróżnienia od biblioteki standardowej można mówić o "bibliotekach zewnętrznych", rozumianych jako te, które trzeba samemu pobrać i zainstalować żeby móc z nich korzystać. Możemy również mówić o "bibliotekach systemowych", czyli bibliotekach które są automatycznie dostępne dla danego systemu operacyjnego, służące do interakcji z nim. Używając różnych funkcjonalności, warto wiedzieć czy korzystamy z biblioteki standardowej czy systemowej. Z perspektywy programistycznej, to co odróżnia bibliotekę systemową od standardowej, to brak przenośności kodu – kod korzystający z biblioteki systemowej, nie będzie mógł być skompilowany na innym systemie operacyjnym.

Zalety i wady

Używanie bibliotek programistycznych niesie wiele korzyści. Jednak jak każde rozwiązanie, pociąga za sobą również pewne konsekwencje, które czasem mogą okazać się negatywne.

Zalety

Największą zaletą bibliotek jest przyspieszenie pisania kodu – można wykorzystać już gotowe funkcje czy typy danych bez konieczności pisania własnych. W ten sposób można zaoszczędzić czas i skupić się na zaprogramowaniu rozwiązań specyficznych dla własnego problemu obliczeniowego.

Porada

Zanim przystąpi się do pisania własnego kodu, szczególnie bardziej złożonych funkcjonalności, warto sprawdzić czy nie istnieje już gotowe rozwiązanie.

Przykłady funkcjonalności dostarczanych przez biblioteki

Przykładowym elementem biblioteki standardowej języka C++ jest biblioteka ctime (time.h dla języka C). Oferuje ona typ danych czasowych, gdzie nie musimy przejmować się formatem, latami przestępnymi itp. – ktoś już raz to zaprogramował i możemy korzystać z gotowego rozwiązania.

Przykładami bibliotek zewnętrznych są biblioteki GSL oraz GMP. Zapewniają one dodatkowe funkcjonalności związane z arytmetyką obliczeniową: konwersja, dzielenie, eksponenta, wektory, macierze, statystyka, minimalizacja i wiele innych. GMP wspiera obliczenia na liczbach dowolnego rozmiaru (tzw. bignum) i dowolnej precyzji.

Kolejnymi zaletami są wydajność oraz poprawność. Popularne i dłużej rozwijane biblioteki zwykle są zoptymalizowane pod kątem typowych przypadków użycia. Zazwyczaj są też już wielokrotnie przetestowane przez różnych użytkowników. Interfejsy takich bibliotek zwykle są dostosowane do wielu różnych przypadków użycia. Dla niektórych bibliotek dostępne są ich alternatywne implementacje, np. zoptymalizowane pod kątem architektury konkretnego sprzętu (procesora, karty GPU). Najczęściej dostawcami takich implementacji są producenci danych układów.

Ważnym czynnikiem przy wyborze biblioteki jest to czy jest ona aktywnie rozwijana lub przynajmniej utrzymywana. W przypadku aktywnych projektów zwykle można liczyć na wsparcie od twórców lub społeczności skupionej wokół danego projektu.

Jakość bibliotek programistycznych

Nie należy podchodzić bezkrytycznie do bibliotek programistycznych. Wspomniane wyżej zalety zależą od jakości danej biblioteki. Niektóre biblioteki mogą się okazać niezbyt wydajne lub posiadać dużo trudnych do wychwycenia błędów.

Jeśli dana biblioteka nie spełnia oczekiwań, warto sprawdzić alternatywne biblioteki o podobnej funkcjonalności. W przypadku bibliotek open source można rozważyć swoje własne zaangażowanie w ich rozwój i ulepszenie.

Porada

Decydując się na skorzystanie z biblioteki, warto sprawdzić jej historię rozwoju – czy jest obecnie rozwijana, oraz czy autorzy oferują wsparcie. Warto wybierać rozwiązania tworzone przez zaufane w społeczności firmy i organizacje aby w dłuższym terminie uniknąć porzucenia rozwoju danej biblioteki (tzw. abandonware).

Wady

Korzystanie z zewnętrznych bibliotek może również mieć pewne wady. Dużo zależy od jakości danej biblioteki. Wśród najbardziej znaczących warto wymienić:

  • narzucony interfejs do którego trzeba się stosować,
  • czasem zbyt duża złożoność biblioteki względem tego, co potrzebujemy,
  • interfejsy niektórych bibliotek potrafią być dość zagmatwane i nawet prostą operację trzeba wykonywać w złożony sposób,
  • dokumentacja nie zawsze jest przejrzysta i dokładnie napisana,
  • komplikacja budowy i drzewa zależności (dana biblioteka może zależeć od innych bibliotek),
  • mogą występować problemy dotyczące kompatybilności np. w przypadku aktualizacji biblioteki do nowszej wersji,
  • brak możliwości zajrzenia w kod źródłowy w przypadku bibliotek dostarczanych w skompilowanej formie (dotyczy to zwłaszcza bibliotek dystrybuowanych przez producentów sprzętu),
  • brak przenośności, niektóre biblioteki działają tylko pod konkretnym systemem operacyjnym albo konkretną architekturą.

Instalacja

Aby można było korzystać z danej biblioteki programistycznej, konieczne jest posiadanie jej na swoim komputerze. Niektóre biblioteki mogą być automatycznie dostępne w ramach danej dystrybucji systemu operacyjnego, albo mogą zostać zainstalowane przy okazji instalacji większych pakietów oprogramowania czy środowisk programistycznych. Jeśli jednak nie posiadamy danej biblioteki konieczne jest jej pobranie oraz zainstalowanie. Przez instalację rozumiemy przygotowanie biblioteki do używania jej przez programy użytkownika na etapie kompilacji i uruchomienia.

Biblioteki można pozyskać na kilka sposobów:

  • w sposób automatyczny, za pośrednictwem menedżerów pakietów (wbudowanych w system lub dostępnych dla danego języka programowania),
  • w sposób ręczny, poprzez ręczne pobranie najczęściej ze strony lub repozytorium dostawcy danej biblioteki.

Ze względu na formę w jakiej pozyskujemy bibliotekę, możemy wyróżnić:

  • wersję binarną, czyli biblioteka, która jest już skompilowana i gotowa do użycia,
  • kod źródłowy, biblioteka wymaga samodzielnej kompilacji.

Poniżej wymienione są najważniejsze uwagi dotyczące różnych sposobów pozyskania biblioteki.

Menedżer pakietów

Systemy operacyjne z rodziny UNIX/Linux posiadają własne repozytoria (np. Arch Linux, Fedora czy Ubuntu), w których znajdują się różne pakiety oprogramowania. Niektóre biblioteki programistyczne mogą być w nich dostępne. Możemy wtedy zainstalować je przy użyciu wbudowanego w system menedżera pakietów (dnf, yum, apt, itp.). Zwykle pakiety zawierające wersje binarne bibliotek mają w nazwie prefiks lib, natomiast nagłówki bibliotek (potrzebne do kompilacji programów korzystających z bibliotek) znajdują się w osobnych pakietach o sufiksie -dev lub -devel. W niektórych systemach (np. Arch Linux) pliki binarne nie są oddzielane od plików nagłówkowych i znajdują się w tym samym pakiecie.

Oprócz systemowych menedżerów, dla niektórych języków programowania dostępne są inne mechanizmy instalacji gotowych bibliotek. Przykładem może być pip dla języka Python. Istnieją również bardziej zaawansowane systemy zarządzania zależnościami, takie jak gradle czy maven, które potrafią automatycznie pobierać i budować wskazane biblioteki wraz z ich zależnościami.

Wersja binarna

Pobierając taki plik należy zwrócić uwagę na to, aby był przeznaczony na nasz komputer, zarówno pod względem zgodności systemu operacyjnego, jak i zgodności sprzętowej (hardware). Kluczowy jest tutaj wybór odpowiedniej architektury, zgodnej z procesorem z jakiego korzystamy (zwykle jest to architektura x86-64, inne możliwości to np. ARM lub starsza architektura 32-bitowa 2). Ważna może być również informacja jakim kompilatorem została zbudowana dana biblioteka. Ma to znaczenie gdyż kompilacja innym kompilatorem niż ten z którego będziemy korzystać może prowadzić do późniejszych problemów z kompatybilnością binarną (patrz ABI).

Czasami mamy do wyboru kilka wersji biblioteki uwzględniających optymalizacje procesora, np. posiadanie zestawu instrukcji AVX2 lub AVX512. W takim przypadku najlepszą wydajność powinna dostarczać możliwie najwyższa optymalizacja dostępna dla naszego procesora (np. wersja z AVX512 powinna być szybsza niż z AVX2).

Własna kompilacja

Samodzielna kompilacja dotyczy sytuacji gdy pobraliśmy i mamy do dyspozycji kod źródłowy. Daje ona najwięcej możliwości w kontekście konfiguracji budowanej biblioteki (np. dokładne opcje kompilacji, w tym flagi debugowania i optymalizacje). Jednakże z tego powodu własna kompilacji może być skomplikowana.

Kompilując kod należy odwołać się do dokumentacji danej biblioteki, która zwykle podaje w jaki sposób ma ona zostać skompilowana oraz z użyciem jakich narzędzi (Automake, CMake, itp.). Jeden z typowych scenariuszy to wywołanie w katalogu ze źródłami sekwencji ./configure, make, make install. Po zbudowaniu biblioteki ważne jest, aby uzyskane pliki były dostępne na etapie kompilacji programów korzystających z tej biblioteki (patrz kompilacja programu).

Biblioteki w centrach HPC

Zwykle w centrach HPC zalecane jest korzystanie z gotowych, przygotowanych przez administratorów pakietów bibliotek. Centra posiadają dokumentację objaśniającą w jaki sposób z nich korzystać. Jest to sposób najprostszy i najbardziej wydajny, gdyż gotowe biblioteki są zazwyczaj odpowiednio przygotowane: są zoptymalizowane pod dany klaster obliczeniowy oraz zapewniają zgodność z innym dostępnym na klastrze oprogramowaniem.

Centra HPC zwykle udostępniają oprogramowanie poprzez system modułów oprogramowania (Modules czy Lmod). Umożliwia on przygotowanie i użycie w prosty sposób różnych wydań (wersji) danej biblioteki z uwzględnieniem ich kompilacji różnymi kompilatorami. Pozwala to na samodzielne testy wydajności różnych rozwiązań. Ładowanie bibliotek (modułów) odbywa się wtedy przy użyciu polecenia module.

Przykład ładowania modułu

Na klastrze Ares dostępnym w ACK Cyfronet AGH odpowiedni wariant biblioteki OpenBLAS można wybrać np. poleceniami:

  • module load OpenBLAS/0.3.24-GCC-11.2.0 – biblioteka OpenBLAS w wersji 0.3.24 zbudowana z użyciem kompilatora GCC w wersji 11.2.0,
  • module load OpenBLAS/0.3.20-intel-compilers-2021.4.0 – biblioteka OpenBLAS w wersji 0.3.20 zbudowana w oparciu o kompilatory Intel Compilers w wersji 2021.4.0.

Szczegóły

Pliki biblioteki

Wiele bibliotek jest zaimplementowanych w języku C lub C++. W przypadku tych języków pliki zbudowanej biblioteki są zwykle rozbite na dwie grupy:

  • pliki nagłówkowe ─ zawierające deklaracje zmiennych, funkcji i struktur danych; zwykle nie zawierają one kodu funkcji czy klas, a jedynie definiują API danej biblioteki;
  • pliki wykonywalne ─ zawierające właściwą implementację funkcji, czyli skompilowany kod funkcji; jest to postać binarna danej biblioteki.

Z perspektywy użytkowej:

  • pierwsze są wystarczające do kompilacji programu użytkownika do postaci obiektowej (opcja -c); to właśnie te pliki są dołączane słowem kluczowym #include;
  • drugie są niezbędne do kompilacji bądź linkowania programu do postaci wykonywalnej.

Zazwyczaj jednej bibliotece odpowiada jeden plik wykonywalny o nazwie lib... i rozszerzeniu .a lub .so. Czasem do nazwy biblioteki jest dodany numer jej wersji, a na używaną wersję wskazuje link symboliczny (o nazwie biblioteki, bez numeru wersji). Zgodnie ze standardem hierarchii plików: pliki nagłówkowe (zwykle mają rozszerzenie .h) można odnaleźć w katalogach include/, natomiast pliki wykonywalne w lib/ (lub lib32/ albo lib64/). Przykładowo pliki programistyczne biblioteki FlexiBLAS w systemie operacyjnym Fedora znajdują się w katalogach: /usr/include oraz /usr/lib64.

Typy bibliotek

Biblioteki dzielimy na dwa rodzaje pod względem sposobu ich budowania oraz działania: biblioteki statyczne oraz biblioteki dynamiczne. Zasadnicza różnica dotyczy sposobu dołączenia (linkowania) biblioteki do kodu kompilowanego programu.

różnica pomiędzy linkowaniem statycznym i dynamicznym

różnica pomiędzy linkowaniem statycznym i dynamicznym [źródło]

Biblioteki statyczne

Biblioteki statyczne to archiwa plików obiektowych. Do ich utworzenia wykorzystuje się np. program ar. Zwykle mają rozszerzenie .a (od archive). Taka biblioteka jest łączona z programem na etapie kompilacji (linkowania). W efekcie staje się jego częścią, tj. kod biblioteki zostaje włączony do końcowego pliku wykonywalnego programu użytkownika. Dzięki temu na etapie wykonania pliki biblioteki nie są potrzebne.

schemat tworzenia biblioteki statycznej – archiwum plików obiektowych

schemat tworzenia biblioteki statycznej – archiwum plików obiektowych [źródło]

Można powiedzieć, że program skompilowany z biblioteką w sposób statyczny staje się niezależny od jej instalacji na danym komputerze. Późniejsza zmiana w kodzie biblioteki i jej przekompilowanie czy nawet usunięcie, nie wpływają na działanie programu (przynajmniej dopóki nie zostanie on ponownie skompilowany). Program skompilowany w sposób statyczny można w łatwy sposób przenieść na inny komputer gdyż nie ma on żadnych zależności do bibliotek zewnętrznych.

Minusem linkowania statycznego jest znacznie większy rozmiar wyjściowego pliku binarnego programu, gdyż zawiera on w sobie wszystkie dołączone biblioteki statyczne. Taka kompilacja powoduje też, że aby skorzystać z nowszej wersji biblioteki wymagana jest rekompilacja programu.

schemat linkowania z biblioteką statyczną

schemat linkowania z biblioteką statyczną [źródło]

Biblioteki dynamiczne

Biblioteki dynamiczne, inaczej biblioteki współdzielone (shared libraries), to drugi rodzaj bibliotek. Zwykle mają rozszerzenie .so od shared object (w systemach z rodziny Windows stosowany jest skrót DLL od dynamic-link library). Kod biblioteki w tym przypadku jest łączony z aplikacją dopiero na etapie wykonania programu. Na etapie kompilacji (linkowania) w pliku wykonywalnym zostaje tylko zapisana informacja jakich bibliotek potrzebuje dany program.

porównanie korzystania z tej samej biblioteki przez kilka programów

porównanie korzystania z tej samej biblioteki przez kilka programów [źródło]

Gdy program zostanie uruchomiony, odwołania programu do funkcji z bibliotek dynamicznych są automatycznie obsługiwane przez tzw. linker dynamiczny (oznaczany w instrukcji man jako ld.so/ld-linux.so3, nie mylić z ld, który jest klasycznym linkerem stosowanym na etapie kompilacji). Jest on odpowiedzialny za zlokalizowanie biblioteki i odpowiednie powiązanie z nią programu użytkownika. Ten proces czasem jest też nazywany późnym linkowaniem (late linking), dla odróżnienia od statycznego sposobu linkowania z biblioteką.

schemat linkowania programu z biblioteką dynamiczną oraz jego obrazu w pamięci po uruchomieniu

schemat linkowania programu z biblioteką dynamiczną oraz jego obrazu w pamięci po uruchomieniu [źródło]

Zaletą bibliotek dynamicznych jest zmniejszenie pliku binarnego skompilowanego programu, gdyż w przeciwieństwie do biblioteki statycznej, kod biblioteki dynamicznej nie jest dołączany do programu. Kolejną zaletą jest potencjalne zmniejszenie ilości pamięci potrzebnej do uruchomienia aplikacji – system operacyjny ładuje kod biblioteki dynamicznej do pamięci tylko raz, nawet jeśli jest on współużytkowany przez różne programy (procesy). To właśnie od tej cechy takie biblioteki nazywane się bibliotekami współdzielonymi.

Position independent code (PIC)

Kod wykonywalny bibliotek współdzielonych musi mieć specjalną formę określaną jako position independent code (PIC). Nazwa PIC nawiązuje do tego, że taki kod może zostać zmapowany w różnych procesach pod różne adresy pamięci wirtualnej – niezależnie od swojej lokalizacji w pamięci, kod działa poprawnie. Aby uzyskać taki kod na etapie kompilacji używa się opcji -fPIC lub -fpic (więcej informacji: tworzenie bibliotek współdzielonych).

Biblioteki ładowane dynamicznie stwarzają możliwość łatwej podmiany implementacji biblioteki, bez konieczności rekompilacji kodu. Więcej w sekcji podmiana . Co ciekawe, mechanizm bibliotek dynamicznych może być wykorzystany również do samodzielnego wgrywania dowolnych bibliotek do pamięci programu (nie tylko tych zadeklarowanych przy kompilacji). Służy do tego zestaw funkcjonalności zdefiniowanych przez plik nagłówkowy dlfcn.h, z których podstawową rolę odgrywa funkcja dlopen.

Kompilacja programu

Poniższe przykłady oparte są o kompilację programu w języku C przy użyciu kompilatora gcc. Dla większości kompilatorów C oraz innych języków (np. g++ dla C++ czy gfortran dla Fortran) takie podstawowe opcje jak -L, -l, czy -I są obsługiwane w sposób analogiczny do gcc. Szczegóły zachowania konkretnego kompilatora, jego opcje, wspierane zmienne środowiskowe itp. warto sprawdzić w jego dokumentacji.

Kompilacja kodu źródłowego programu do postaci wykonywalnej to dość złożony proces, w którym można wyodrębnić kilka faz. Co więcej, mimo tego że do kompilacji używamy pojedynczej komendy (np. kompilatora gcc), poszczególne zadania są realizowane przez różne narzędzia (np. asembler as, linker ld). Z użytkowego punktu widzenia najważniejszy podział to:

  1. kompilacja do postaci obiektowej,
  2. linkowanie.

proces kompilacji z podziałem na fazy

proces kompilacji z podziałem na fazy [źródło]

Przykład kompilacji dwufazowej

Rozważmy program, którego kod źródłowy został umieszczony w dwóch plikach: main.c oraz util.c. Kompilujemy go do pliku wykonywalnego prog.

  • standardowa kompilacja

    gcc -o prog  main.c utils.c
    
  • kompilacja z rozbiciem na dwie fazy

    # kompilacja do postaci obiektowej
    gcc -c -o main.o  main.c 
    gcc -c -o utils.o  utils.c 
    # linkowanie
    gcc -o prog  main.o utils.o 
    

Pliki obiektowe są bliskie plikom wykonywalnym gdyż zawierają już kod w postaci instrukcji maszynowych dla procesora. Jest to jednak tylko kod z podanych przy kompilacji plików źródłowych – może on zawierać odwołania do zewnętrznych symboli, tj. funkcji i zmiennych, które umieszczone są w innych plikach.

Do uzyskania końcowego pliku wykonywalnego potrzebne jest powiązanie ze sobą wszystkich potrzebnych plików obiektowych. Ten proces to właśnie linkowanie (określane także jako konsolidacja). W celu jego wykonania, kompilator gcc wywołuje na końcu linker ld4. Świadomość tego mechanizmu ma to o tyle znaczenie, że argumenty przekazywane przy wywołaniu gcc są również przekazywane do linkera. W efekcie wyjaśnienia szczegółów działania niektórych opcji (np. -l) możemy szukać zarówno w instrukcji gcc (man gcc) jak i instrukcji linkera (man ld).

Przekazywanie argumentów do linkera

Sekcja "OPTIONS" w instrukcji gcc zawiera spis argumentów z podziałem na rodzaje; m.in. jest tam sekcja opcji linkera ("Linker Options"). Co ważne, jeśli chcemy przekazać konkretny argument do linkera – taki, który nie jest bezpośrednio obsługiwany przez gcc – możemy skorzystać ze składni -Wl,option.

Przykładowo, poniższa komenda przekaże opcję -Bstatic do linkera.

gcc program.o -Wl,-Bstatic -llib
Obsługa bibliotek

Podczas kompilacji programu korzystającego z bibliotek musimy zadbać o to aby kompilator miał dostęp zarówno do plików nagłówkowych jak i plików wykonywalnych (patrz pliki biblioteki). W tym celu należy wskazać:

  • ścieżki do folderów zawierających pliki nagłówkowe (opcja -I),
  • ścieżki do folderów zawierających pliki wykonywalne (opcja -L),
  • nazwy bibliotek z których korzysta program (opcja -l).

Nazwa biblioteki podawana w opcji -l to skrócona nazwa pliku z kodem biblioteki: bez prefiksu lib oraz rozszerzenia (.a lub .so). Przykładowo -lblas będzie odpowiadało bibliotece libblas.so lub libblas.a. To którą wersję biblioteki wybierze linker (statyczną czy dynamiczną) zależy od opcji kompilacji. Domyślnie w pierwszej kolejności używane są pliki bibliotek dynamicznych ale np. opcja -static wymusza korzystanie z bibliotek statycznych.

Przykład kompilacji programu korzystającego z biblioteki

Rozważmy bibliotekę libmylib zainstalowaną w katalogu /home/user/mylibs:

  • podkatalog include/ zawiera plik nagłówkowy mylib.h,
  • podkatalog lib64/ zawiera
    • plik biblioteki statycznej libmylib.a
    • tę samą bibliotekę w wersji dynamicznej libmylib.so.

Do kompilacji programu program.c korzystającego z biblioteki poprzez dyrektywę #include "mylib.h", użyjemy komendy:

gcc -o program program.c \
    -I/home/user/mylibs/include -L/home/user/mylibs/lib64 -lmylib
Kompilator dzięki podaniu opcji -I znajdzie plik mylib.h (z dyrektywy #include) w katalogu /home/user/mylibs/include. Dzięki opcji -L będzie szukał biblioteki "mylib" (z opcji -l) w katalogu /home/user/mylibs/lib64 i odnajdzie tam plik libmylib.so, gdyż pierwszeństwo mają biblioteki dynamiczne.

Gdyby do kompilacji użyto opcji -static, wybrana zostałaby biblioteka libmylib.a.

Jeśli przeprowadzamy proces kompilacji w dwóch krokach – osobno kompilacja do postaci obiektowej (opcja -c), osobno linkowanie – wtedy lokalizacje plików nagłówkowych (-I) są potrzebne tylko w pierwszym kroku (dokładniej na etapie preprocessingu), natomiast pliki bibliotek (-L, -l) tylko w drugim (na etapie linkowania).

Porada - umiejscowienie opcji kompilacji dot. bibliotek

Zaleca się wskazywanie nazw bibliotek -l na samym końcu komendy kompilacji, po podaniu wszystkich plików źródłowych.

Umiejscowienie opcji -l ma znaczenie, gdyż linker przetwarza podane pliki i biblioteki zgodnie z kolejnością opcji. Jeśli jakiś plik zależy od innej biblioteki, musi on zostać podany wcześniej. Dotyczy to również sytuacji gdy jedna biblioteka zależy od drugiej. Więcej wyjaśnień w man gcc oraz man ld (patrz opis opcji -l).

Kolejność bibliotek

W sytuacji gdy linkujemy program z dwoma bibliotekami, z których każda implementuje tę samą funkcję, nasz program zostanie zlinkowany z implementacją funkcji z pierwszej z nich.

Przykładowo, poniższe komendy mogą spowodować zlinkowanie programu z różnymi implementacjami funkcji BLAS/LAPACK.

gcc ... -o program program.c -lmkl_rt -lc -lopenblas
gcc ... -o program program.c -lopenblas -lmkl_rt -lc
Wskazywanie lokalizacji bibliotek

Podczas kompilacji można podać więcej niż jedną lokalizację poszukiwania bibliotek poprzez kilkukrotne użycie opcji -I lub -L. Podanie ścieżek do plików nagłówkowych (-I) lub plików wykonywalnych (-L) nie zawsze jest konieczne. Niektóre ścieżki w systemie operacyjnym są traktowane jako domyślne lokalizacje, np. /usr/include dla plików nagłówkowych oraz /usr/lib dla plików wykonywalnych. Zawsze jednak musimy podać nazwę biblioteki (-l) z której chcemy skorzystać (nie dotyczy to biblioteki standardowej, którą automatycznie dodaje sam sterownik kompilacji).

Kolejność przeszukiwania lokalizacji

Jeśli podano kilka ścieżek poprzez opcje -I lub -L, wtedy kolejność wyszukiwania jest zgodna z kolejnością podawania lokalizacji (od lewej do prawej). Jeśli biblioteka o danej nazwie znajduje się w kilku lokalizacjach, program zostanie połączony z tą która znajduje się we wcześniejszej lokalizacji. Ścieżki podawane jako opcje kompilacji są sprawdzane przed domyślnymi lokalizacjami.

Biblioteki jako moduły w środowiskach HPC

W centrach HPC gotowe biblioteki zwykle są udostępnione jako moduły (patrz Instalacja > Biblioteki w centrach HPC). W takich środowiskach załadowanie modułu powoduje odpowiednie ustawienie zmiennych środowiskowych dla kompilatora, co sprawia że na etapie kompilacji podawanie opcji -I oraz -L nie jest potrzebne. Dzięki temu, użytkownik nie musi zastanawiać się w jakiej lokalizacji znajduje się dana biblioteka.

Co ciekawe, zamiast podawania nazwy biblioteki i ścieżki do niej jako opcje -l oraz -L, można podać pełną ścieżkę do pliku biblioteki. Ten sposób może być pomocny np. wtedy gdy do kompilacji chcemy wskazać bibliotekę statyczną zamiast biblioteki dynamicznej.

Bezpośrednie wskazanie pliku biblioteki

Mając plik biblioteki libmylib.a w katalogu /home/user/mylibs/lib64, program korzystający z niej można skompilować nie używając opcji -L oraz -l.

gcc -o program program.c /home/user/mylibs/lib64/libmylib.a
Podanie nazwy pliku biblioteki przez -l:

Linker posiada jeszcze jedną dodatkową własność: bibliotekę libmylib.a zamiast przez -lmylib można wskazać przez -l:libmylib.a. Wariant z : powoduje że plik o podanej nazwie (w tym przypadku libmylib.a) będzie wyszukiwany w lokalizacjach bibliotek podanych przez -L. Pozwala to wskazać konkretny plik bez specyfikowania pełnej ścieżki. To zachowanie jest opisane w instrukcji linkera (man ld).

Zmienne środowiskowe

Obok opcji kompilatora -I i -L, możliwe jest również podawanie ścieżek poprzez zmienne środowiskowe. Służą do tego zmienne sterujące kompilacją oraz procesem linkowania. Należy ustawić je przed wywołaniem kompilatora.

  • CPATH, ścieżki do plików nagłówkowych (oddzielone znakiem :),
  • LIBRARY_PATH, ścieżki do plików wykonywalnych bibliotek (oddzielone znakiem :).

Ścieżki podane w ten sposób są przeszukiwane po tych podanych jako bezpośrednie opcje kompilacji. Dokładny opis zmiennych znajduje się w instrukcji kompilatora (man gcc).

Wykorzystanie zmiennych środowiskowych do kompilacji

Rozważmy polecenie kompilacji z wcześniejszego przykładu.

gcc -o program program.c \
    -I/home/user/mylibs/include -L/home/user/mylibs/lib64 -lmylib

Z użyciem zmiennych środowiskowych możemy zmodyfikować je następująco:

  1. określenie położenia plików nagłówkowych poprzez CPATH

    export CPATH="/home/user/mylibs/include:$CPATH" 
    gcc -o program program.c  -L/home/user/mylibs/lib64 -lmylib
    
  2. określenie położenia plików wykonywalnych poprzez LIBRARY_PATH

    export LIBRARY_PATH="/home/user/mylibs/lib64:$LIBRARY_PATH"
    gcc -o program program.c  -I/home/user/mylibs/include  -lmylib
    
  3. użycie obydwu zmiennych: CPATH oraz LIBRARY_PATH

    export CPATH="/home/user/mylibs/include:$CPATH"
    export LIBRARY_PATH="/home/user/mylibs/lib64:$LIBRARY_PATH"
    gcc -o program program.c  -lmylib
    

W powyższych komendach, aby nie nadpisać poprzedniej zawartości CPATH, używamy na końcu deklaracji :$CPATH. W ten sposób podana ścieżka zostaje dodana na początek. ⚠️ W sytuacji gdy ta zmienna jest pusta, powoduje to dodanie na końcu zbędnego znaku :. Może to mieć znaczenie, gdyż pusty element powoduje wyszukiwanie w bieżącym katalogu (odpowiednik opcji -I.) – patrz opis CPATH w man gcc.

W przypadku korzystania z systemu budowania (np. GNU Make, CMake) dostępne mogą być również inne zmienne, charakterystyczne dla tego systemu.

Zmienne programu GNU Make

Dla GNU Make, odpowiednie zmienne środowiskowe nie zastępują opcji -I i -L, tylko pozwalają na ich ustawienie przed wywołaniem procesu kompilacji.

  • CPPFLAGS – zmienne przekazywane do preprocesora, tutaj można podać ścieżki do plików nagłówkowych (-I),
  • LDFLAGS – zmienne przekazywane do linkera, tutaj można podać ścieżki do plików wykonywalnych bibliotek (-L),
  • LDLIBS – zmienne przekazywane do linkera, tutaj można podać nazwy bibliotek (-l).

Przykład:

export CPPFLAGS="-I/home/user/mylibs/include $CPPFLAGS"
export LDFLAGS="-L/home/user/mylibs/lib64 $LDFLAGS"
make

Więcej informacji:

Widoczność

Po zbudowaniu kodu z użyciem biblioteki dynamicznej musi ona być dostępna w momencie uruchomienia programu. Co więcej, system musi wiedzieć, gdzie szukać takiej biblioteki, inaczej aplikacja nie będzie w stanie z niej skorzystać. W systemach z gałęzi Linux dostępne jest polecenie ldd (list dynamic dependencies), która pozwala dowiedzieć się z jakimi bibliotekami dynamicznymi został zbudowany dany kod oraz pod jakimi ścieżkami system je odnalazł. Wystarczy użyć polecenia ldd ./program aby uzyskać te informacje. Jeszcze więcej informacji może dostarczyć program lddtree lub program libtree.

Przykład użycia ldd
$ ldd ~/.local/bin/python
        linux-vdso.so.1 (0x00007ffce5157000)
        libffi.so.8 => /lib64/libffi.so.8 (0x000014d00195c000)
        libpython3.9.so.1.0 => /lib64/libpython3.9.so.1.0 (0x000014d0015fc000)
        libcrypt.so.2 => /lib64/libcrypt.so.2 (0x000014d0015c2000)
        libm.so.6 => /lib64/libm.so.6 (0x000014d0014e7000)
        libssl.so.1.1 => not found
        libcrypto.so.1.1 => not found
        libc.so.6 => /lib64/libc.so.6 (0x000014d0012dc000)
        /lib64/ld-linux-x86-64.so.2 (0x000014d001972000)

Jeśli system nie znajdzie danej biblioteki dynamicznej, ldd wyświetli obok nazwy tej biblioteki informację => not found. Jeśli posiadamy tę bibliotekę w niestandardowej lokalizacji, musimy powiadomić o tym system operacyjny. Do tego celu służy zmienna LD_LIBRARY_PATH. Jest to zmienna linkera dynamicznego, jej opis znajduje się w man ld.so. Zawiera ona dodatkowe ścieżki w których system operacyjny ma szukać bibliotek dynamicznych (analogicznie do szukania na etapie kompilacji bibliotek według zmiennej LIBRARY_PATH). Po ustawieniu LD_LIBRARY_PATH dzięki ldd można sprawdzić czy na pewno odwołujemy się do tej biblioteki, do której chcemy.

Przykład wykorzystania LD_LIBRARY_PATH

Rozważmy program kompilowany z biblioteką BLAS (libblas.so):

gcc -o program program.c \
    -I/home/user/mylibs/include -L/home/user/mylibs/lib64 -lblas 

Aby biblioteka libblas.so była widoczna w momencie uruchomienia programu należy wykonać polecenie dodające odpowiedni katalog do ścieżki przeszukiwania.

export LD_LIBRARY_PATH="/home/user/mylibs/lib64:$LD_LIBRARY_PATH"
./program

Alternatywnie można zmienić LD_LIBRARY_PATH tylko na czas samego uruchomienia programu.

LD_LIBRARY_PATH="/home/user/mylibs/lib64:$LD_LIBRARY_PATH"  ./program

Inną metodą do podawania lokalizacji biblioteki dynamicznej jest mechanizm rpath. Jest to mechanizm umożliwiający umieszczenie bezpośrednio w pliku wykonywalnym listy katalogów z lokalizacją bibliotek dynamicznych niezbędnych do jego uruchomienia. Te lokalizacje są uwzględniane przez wspomniany wcześniej linker dynamiczny w celu zlokalizowania bibliotek wymaganych przez konkretny program.

Aby skorzystać z mechanizmu rpath, należy do opcji kompilatora dodać flagę -Wl,-rpath=/path/to/library, gdzie /path/to/library to katalog zawierający bibliotekę dynamiczną. Alternatywnie, na etapie kompilacji można skorzystać ze zmiennej LD_RUN_PATH. Więcej szczegółów znajduje się w instrukcji zwykłego linkera, patrz opcja -rpath w man ld.

Wady i zalety mechanizmu rpath

Korzystanie z rpath może być korzystne z kilku powodów:

  • zmienna LD_LIBRARY_PATH nie musi już być ustawiana przy każdym uruchamianiu programu,
  • w systemach HPC może nie być potrzeby ładowania dodatkowych modułów bibliotek zależnych,
  • w czasie uruchamiania aplikacji zmienna LD_LIBRARY_PATH nie musi być już iterowana dla wyszukania każdej biblioteki zależnej.

Jak każde rozwiązanie rpath posiada też swoje wady: w momencie gdy biblioteka dynamiczna z której program korzysta zostanie przeniesiona do innej lokalizacji, rpath przestanie spełniać swoją rolę. Wtedy konieczne staje się standardowe ustawianie zmiennej LD_LIBRARY_PATH.

Szczegółowy opis wpływu różnych opcji na kolejność wyszukiwania bibliotek dynamicznych można znaleźć w man ld w sekcji dotyczącej opcji -rpath-link.

Podmiana

Biblioteki dynamiczne pozwalają na przydatną funkcjonalność, jaką jest podmiana implementacji biblioteki na etapie uruchamiania programu. Można to wykorzystać na kilka sposobów:

  • podmiana danej biblioteki na jej nowszą wersję,
  • podmiana biblioteki na jej alternatywną implementację,
  • testowanie kilku wersji tej samej biblioteki, skompilowanej z różnymi opcjami kompilacji.

Zaletą takiej operacji jest to, że nie ma potrzeby rekompilacji programu. Dzięki temu taką podmianę można zastosować bardzo szybko i można jej użyć także wobec programów w wersji binarnej, których kodu źródłowego nie posiadamy.

Zgodność ABI

Bibliotekę dynamiczną można podmieniać tylko na taką bibliotekę, która ma zgodny interfejs binarny (ABI).

Do podmiany wykorzystujemy zmienną LD_LIBRARY_PATH (patrz widoczność). Wystarczy żeby dodać do niej ścieżkę do folderu, gdzie znajduje się plik wykonywalny alternatywnej implementacji czy innej wersji biblioteki. Nowa biblioteka musi mieć taką samą nazwę jak plik oryginalnej biblioteki.

Przykład podmiany wersji biblioteki

Załóżmy, że nasza aplikacja została zbudowana kompilatorem gfortran w wersji 4.8.5 i jedną z zależności, do której linkuje nasz kod jest biblioteka libgfortran.so.3. Dla nowszych wersji kompilatora zmieniła się wersja tej biblioteki – np. dla gfortran w wersji 8.5 jest to libgfortran.so.5. Zakładając, że nowa wersja biblioteki posiada kompatybilność wsteczną (tj. każda funkcja dostępna w libgfortran.so.3 jest też dostepna w libgfortran.so.5) można wykonać następującą sztuczkę pozwalającą na wykorzystanie nowszej wersji przez ten konkretny program.

  • tworzymy w dowolnym katalogu link symboliczny do biblioteki libgfortran.so.5,
  • nadajemy mu nazwę libgfortran.so.3,
  • dodajemy ten katalog do zmiennej LD_LIBRARY_PATH.

Opisane wcześniej polecenie ldd pozwala sprawdzić czy nasz program faktycznie skorzysta z biblioteki ze wskazanej lokalizacji.

Wstępne ładowanie

Biblioteki dynamiczne na etapie uruchomienia programu są wyszukiwane zgodnie z kolejnością katalogów w zmiennej LD_LIBRARY_PATH. Istnieje jednak możliwość wymuszenia ładowania biblioteki przed jakąkolwiek inną biblioteką dynamiczną. Służy do tego zmienna LD_PRELOAD. Jest to opcjonalna zmienna linkera dynamicznego zawierająca jedną lub więcej ścieżek do bibliotek współdzielonych. Biblioteki te zostaną załadowane przed wszystkimi bibliotekami, łącznie z biblioteką standardową C (libc.so). Opis zmiennej mozna znaleźć w man ld.so.

Dzięki mechanizmowi wstępnego ładowania bibliotek dynamicznych możliwe jest np. przeładowanie jakiejś funkcji systemowej w celu uzyskania dodatkowej funkcjonalności. Mechanizm ten jest bardzo często używany przez narzędzia do instrumentacji i śledzenia kodu.

Implementacje API

Jak zostało przedstawione we wprowadzeniu (API), każda biblioteka posiada swój "zewnętrzny" interfejs programistyczny. Możemy więc na każdą bibliotekę patrzeć na dwa sposoby:

  • biblioteka definiuje pewne API, oraz
  • biblioteka implementuje wybrane API.

Rozróżnianie interfejsu programistycznego od właściwej implementacji jest bardzo użyteczne i pozwala określać relacje jakie zachodzą między poszczególnymi bibliotekami.

Wiele interfejsów

Pojedyncza biblioteka może posiadać rozbudowany interfejs programistyczny, który logicznie jest podzielony na kilka części. Możemy wtedy mówić, że biblioteka udostępnia kilka API. Przykładem może być biblioteka BLIS, która wyróżnia Typed API oraz Object API (patrz dokumentacja BLIS).

Wiele implementacji

Wyszczególnienie API pozwala na zaimplementowanie tej samej funkcjonalności ale w inny sposób. W efekcie możliwe jest, że dana biblioteka implementuje API innej biblioteki. W takim przypadku możemy mówić o alternatywnej implementacji biblioteki czy wielu implementacjach tego samego API.

Biblioteka BLAS – wiele implementacji

Przykładem może być biblioteka Netlib BLAS dostarczająca podstawowe operacje algebry liniowej, powszechnie używane w kodach obliczeniowych (przykładowo mnożenie macierzy). Biblioteka ta z czasem doczekała się innych implementacji, np. OpenBLAS. Chociaż biblioteki te udostępniają ten sam interfejs, to każda realizuje obliczenia w inny sposób:

  • Netlib BLAS wykonuje obliczenia w sposób sekwencyjny (jeden CPU),
  • OpenBLAS potrafi przyspieszyć obliczenia i wykorzystać wiele rdzeni.

Więcej o różnych implementacjach BLAS w BLAS/LAPACK.

Zgodność API między dwoma bibliotekami sprawia, że w łatwy sposób można podmienić implementację z jednej na drugą. W idealnym przypadku programista nie musi niczego zmieniać w swoim kodzie źródłowym, wystarczy jedynie że na etapie kompilacji wskaże inną implementację danej biblioteki. Czasem mogą być potrzebne drobne zmiany, np. include innego pliku nagłówkowego. Zdarza się, że alternatywna implementacja dostarcza funkcje specyficzne dla danej implementacji, lub nie implementuje całości API źródłowej biblioteki. W takim przypadku korzystanie z takich funkcji może prowadzić do braku przenośności programu między tymi bibliotekami, tj. kompilacja z alternatywną implementacją nie będzie możliwa bez zmian w kodzie.

Bardzo często głównym motywem powstania alternatywnych implementacji jest optymalizacja wydajności danej biblioteki. Przykładem może być libjpeg oraz libjpeg-turbo. Czasem są to optymalizacje natury ogólnej (np. wprowadzenie wielowątkowości albo wektoryzacji), a czasem są to optymalizacje pod konkretną architekturę czy model procesora. Dla popularnych bibliotek obliczeniowych istnieją właśnie tak zoptymalizowane implementacje producentów sprzętu (np. Intel MKL czy AMD AOCL dla BLAS, patrz BLAS/LAPACK > Implementacje).

Wrappery

Ogólnie pod pojęciem wrapper mamy na myśli takie konstrukcje w kodzie jak np. funkcja lub typ danych, które opakowują funkcjonalność innej funkcji czy typu. Polega to na tym, że funkcja-wrapper pod spodem wywołuje oryginalną funkcję, czasem wykonując jakieś dodatkowe czynności czy konwersje.

Na poziomie biblioteki wrappery mogą posłużyć do kilku rzeczy. Bardzo często jest tak, że dwie biblioteki z tej samej dziedziny obliczeniowej posiadają dwa zupełnie różne interfejsy programistyczne. W efekcie przeniesienie się z jednej biblioteki na drugą na poziomie kodu źródłowego oznacza bardzo dużo zmian, gdyż trzeba przetłumaczyć wywołania funkcji z jednej biblioteki na drugą. Aby ułatwić to zadanie, dana biblioteka oprócz swojego własnego API może udostępniać również interfejs innej biblioteki. Oczywiście pod spodem, API drugiej biblioteki jest zaimplementowane właśnie poprzez wrappery do oryginalnego (natywnego) interfejsu danej biblioteki. Przykładem takiej zależności może być biblioteka BLIS, która ma inną konstrukcję i interfejs niż BLAS, jednak z BLIS można również korzystać przy użyciu interfejsu BLAS.

Możemy również mieć do czynienia z biblioteką, która w całości jest wrapperem na jedną lub kilka innych bibliotek. Taką bibliotekę-wrapper będziemy również określać jako biblioteka pośrednicząca. Można wyróżnić kilka typów takich bibliotek.

  • wrapper dostarczający inny interfejs do wybranej biblioteki; może on np. upraszczać oryginalny interfejs i udostępniać funkcje, które pod spodem wołają po kilka funkcji z wybranej biblioteki;
  • biblioteka pośrednicząca do kilku innych bibliotek; czasem niezgodność API dwóch bibliotek może zostać rozwiązana przez stworzenie zewnętrznej biblioteki, która ma możliwość wyboru (na etapie kompilacji lub uruchomienia) do jakiej biblioteki ma się odwołać;
  • biblioteka pośrednicząca przechwytująca wywołania do oryginalnej biblioteki i wykonująca dodatkowe akcje; takie podejście może być zastosowane np. w celu wzbogacenia źródłowej biblioteki o logowanie odwołań do niej czy pomiar czasu działania funkcji z biblioteki.
Inne języki

Popularne biblioteki zwykle nie ograniczają się tylko do jednego języka programowania i udostępniają swoje API dla kilku języków. Oczywiście API te będą się różnić i może być tak, że w którymś języku nie wszystkie funkcjonalności będą dostępne.

Zwykle biblioteka jest zaimplementowana w jednym języku (najczęściej jest to język C) natomiast jej funkcjonalność dla pozostałych języków jest dostarczona w postaci wrapperów. Wrappery te pod spodem korzystają z mechanizmów wywoływania funkcji zaimplementowanych w innym języku z poziomu języka źródłowego danego wrappera. Takie wrappery są czasem określane jako binding (od language binding). Biblioteka może posiadać oficjalne wrappery do innych języków, dostarczane lub wspierane przez twórców danej biblioteki, jak i zewnętrzne (nieoficjalne), utrzymywane niezależnie od źródłowej biblioteki.

W przypadku gdy biblioteka nie dostarcza API dla innego języka, czasem jest możliwe by samemu skorzystać z możliwości łączenia różnych kodów. W takim przypadku trzeba niejako samemu przygotować odpowiedni binding. Może to wymagać zastosowania dodatkowych technologii, chociaż dla niektórych języków programowania sposób powiązania z kodem w drugim języku nie wiąże się niemal z żadnymi dodatkowymi poleceniami. Przykładem mocno zgodnych języków sa C i Fortran. Funkcje z skompilowanych bibliotek napisanych w jednym z tych języków łatwo wywoływać bezpośrednio z poziomu drugiego języka.

API jako standard

W niektórych przypadkach to nie biblioteka definiuje API, tylko API zostaje zdefiniowane jako standard. Przykładem może być standard MPI, który określa interfejs biblioteki MPI dla języków C oraz Fortran. W takiej sytuacji standard z założenia może posiadać wiele implementacji (np. MPICH oraz Open MPI w przypadku MPI) i mówimy o zgodności biblioteki z daną wersją standardu. Zdefiniowanie standardu ma gwarantować przenośność kodu źródłowego między różnymi implementacjami. Przykładem bibliotek zadanych przez standard są biblioteki standardowe; każdy kompilator danego języka może dostarczać własną implementację takiej biblioteki.

Niektóre standardy oprócz zdefiniowania API udostępniają jego podstawową implementację. Jest to tzw. implementacja referencyjna. Często nie jest ona zoptymalizowana pod kątem wydajności, a jej głównym celem jest dookreślenie funkcjonalności oraz pokazanie przykładowej implementacji o czytelnym kodzie źródłowym. Implementacja referencyjna może służyć na potrzeby rozwoju kodu, a na etapie produkcyjnym można ją zastąpić inną implementacją.

ABI

Każdy program czy biblioteka po skompilowaniu posiada swoje ABI (application binary interface) czyli interfejs binarny. Jest to odpowiednik interfejsu programistycznego API ale zdefiniowany na poziomie kodu wykonywalnego. Określa on niskopoziomowy interfejs poszczególnych funkcji i typów danych, czyli sposób w jaki na poziomie instrukcji maszynowych należy się odwoływać do danej funkcji (np. w jakich rejestrach umieścić kolejne argumenty funkcji) lub jak interpretować kolejne bity danej struktury w pamięci.

Kompatybilność binarna bibliotek zachodzi wtedy gdy posiadają one takie samo ABI. Oznacza to, że z perspektywy niskopoziomowej oferują one funkcje o takich samych nazwach, które wywołuje się w ten sam sposób (odpowiednie argumenty są tego samego typu i przekazuje się je przez te same rejestry), a struktury danych są tak samo reprezentowane w pamięci. Zazwyczaj interesuje nas kompatybilność binarna w zakresie funkcji wyszczególnionych w API i pomijamy interfejs funkcji wewnętrznych danych bibliotek.

Z perspektywy użytkowej kompatybilność binarna pozwala na możliwość podmiany biblioteki bez konieczności rekompilacji programu. Dokładniej mówiąc: program skompilowany z jedną biblioteką (poprzez linkowanie dynamiczne) może z powodzeniem zostać uruchomiony z podłożoną w jej miejsce kompatybilną z nim biblioteką (podmiana).

Źródła niekompatybilności

Za ABI jest odpowiedzialny kompilator, który zmienia kod źródłowy w postać wykonywalną. Ta sama biblioteka skompilowana różnymi kompilatorami może nie mieć tego samego ABI. Z tego względu łączenie ze sobą różnie skompilowanych komponentów aplikacji (jak osobne pliki obiektowe czy biblioteki) może spowodować niekompatybilność binarną. Co więcej, aplikacja może się zbudować i uruchomić, a trudno uchwytne błędy mogą pojawić się dopiero w trakcie wykonania. Problem ten może dotyczyć również stosowania różnych wersji tego samego kompilatora.

Niekompatybilność kompilatora Fortran GNU oraz Intel

Przykładem różnic na poziomie ABI są kompilatory gfortran (GNU) oraz ifort (Intel). Różnica między ABI programów Fortran kompilowanych nimi polega na sposobie zwracania wartości z funkcji. Styl GNU zwraca ją jako wartość na stosie. Styl Intela (F2C, GNU F77) zwraca ją tak jakby była ona dodatkowym parametrem przed właściwymi argumentami funkcji.

W przypadku kilku bibliotek implementujących ten sam interfejs API mogłoby się wydawać, że po skompilowaniu wszystkie implementacje powinny być kompatybilne na poziomie ABI. W teorii skoro na poziomie programistycznym mają ten sam interfejs (a więc te same nazwy funkcji i argumenty, te same typy danych i ich składowe) to kompilator powinien je przekształcić do takiej samej postaci binarnej. Często może tak być, jednak nie ma takiej gwarancji i trzeba rozróżnić interfejs programistyczny od interfejsu binarnego.

Biblioteki o zgodnym API a zgodność ABI

Biblioteki o tym samym interfejsie programistycznym nie zawsze mają zgodne ze sobą interfejsy binarne.

Zgodność interfejsu programistycznego biblioteki ze standardem lub API innej biblioteki sprawia, że z powodzeniem można skompilować ten sam kod z różnymi implementacjami tego standardu czy API. Jednakże czasami na poziomie binarnym mogą występować nieznaczne różnice. Potencjalnym źródłem rozbieżności może być np. sposób zaimplementowania struktur danych. W różnych implementacjach elementy wewnątrz danej struktury mogą być ułożone inaczej, albo mogą zawierać dodatkowe pola (jeśli standard pozostawia im w tym zakresie wolną rękę). Może być też tak, że dany standard definiuje jedynie nazwy typów danych, nie specyfikując w jaki sposób mają być zaimplementowane i w efekcie ten sam typ w różnych implementacjach będzie miał różny rozmiar.

Linki


  1. Dawna szata graficzna serwisu cplusplus.com dostępna jest pod adresem legacy.cplusplus.com

  2. 32-bitowa architektura x86 jest różnie określana, np. jako "i386" lub "i686". Bardzo często mówi się po prostu o wersji 32-bitowej ("32-bit") w domyśle mając na myśli architekturę x86. ⚠️ W obszarze nazewnictwa występują niejednoznaczności – zestawia się ze sobą "x86" i "x86-64", w pierwszym przypadku mając na myśli wersję 32-bitową; ściśle mówiąc, nie jest to poprawne gdyż "x86" można traktować jako ogólną rodzinę architektur, do której m.in. należy "x86-64". Czasem można spotkać określenie "x86-32", chociaż w praktyce nie jest ono oficjalnie używane. 

  3. Przykładowa ścieżka do linkera dynamicznego w systemie Ubuntu 22.04 to /lib64/ld-linux-x86-64.so.2

  4. Linker z pakietu GCC nazywany jest "GNU linker". Istnieją również inne linkery np. mold lub gold. Zazwyczaj komenda do ich wywołania jest inna niż ld, która to zwykle może być utożsamiana z linkerem GNU. W przypadku korzystania z innych linkerów należy w ich dokumentacji sprawdzić wspierane opcje oraz zmienne środowiskowe. 


Ostatnia aktualizacja: 29 lipca 2024