Jak bezpiecznie przekazywać obiekty, zwłaszcza obiekty STL, do iz biblioteki DLL?

106

Jak przekazać obiekty klas, zwłaszcza obiekty STL, do iz biblioteki DLL C ++?

Moja aplikacja musi współdziałać z wtyczkami innych firm w postaci plików DLL i nie mogę kontrolować, z jakim kompilatorem te wtyczki są zbudowane. Zdaję sobie sprawę, że nie ma gwarantowanego ABI dla obiektów STL i obawiam się spowodowania niestabilności w mojej aplikacji.

cf stoi z Moniką
źródło
4
Jeśli mówisz o bibliotece standardowej C ++, to prawdopodobnie powinieneś tak to nazwać. STL może oznaczać różne rzeczy w zależności od kontekstu. (Zobacz także stackoverflow.com/questions/5205491/… )
Micha Wiedenmann

Odpowiedzi:

157

Krótka odpowiedź na to pytanie brzmi : nie rób tego . Ponieważ nie ma standardowego C ++ ABI (binarny interfejs aplikacji, standard wywoływania konwencji, pakowania / wyrównywania danych, rozmiaru typu itp.), Będziesz musiał przejść przez wiele kółek, aby spróbować i narzucić standardowy sposób radzenia sobie z klasami obiekty w programie. Nie ma nawet gwarancji, że zadziała po przejściu przez wszystkie te obręcze, ani też nie ma gwarancji, że rozwiązanie, które działa w jednym wydaniu kompilatora, będzie działać w następnym.

Po prostu utwórz prosty interfejs w C extern "C", ponieważ C ABI jest dobrze zdefiniowany i stabilny.


Jeśli naprawdę chcesz przekazać obiekty C ++ przez granicę biblioteki DLL, jest to technicznie możliwe. Oto kilka czynników, które musisz wziąć pod uwagę:

Pakowanie / wyrównanie danych

W ramach danej klasy poszczególne składowe danych są zwykle specjalnie umieszczane w pamięci, aby ich adresy odpowiadały wielokrotności rozmiaru typu. Na przykład intmoże być wyrównany do 4-bajtowej granicy.

Jeśli biblioteka DLL została skompilowana za pomocą innego kompilatora niż plik EXE, wersja biblioteki DLL danej klasy może mieć inne opakowanie niż wersja EXE, więc gdy plik EXE przekazuje obiekt klasy do biblioteki DLL, biblioteka DLL może nie mieć prawidłowego dostępu do danego członka danych w tej klasie. Biblioteka DLL będzie próbowała czytać z adresu określonego przez własną definicję klasy, a nie z definicji EXE, a ponieważ żądany element członkowski danych nie jest tam faktycznie przechowywany, powstałyby wartości śmieci.

Możesz obejść ten problem za pomocą #pragma packdyrektywy preprocesora, która zmusi kompilator do zastosowania określonego pakietu. Kompilator będzie nadal stosować domyślne pakowanie, jeśli wybierzesz wartość pakietu większą niż ta , którą wybrałby kompilator , więc jeśli wybierzesz dużą wartość pakowania, klasa może nadal mieć różne pakowanie między kompilatorami. Rozwiązaniem jest użycie #pragma pack(1), które zmusi kompilator do wyrównywania elementów składowych danych na granicy jednobajtowej (zasadniczo nie będzie stosowane pakowanie). To nie jest dobry pomysł, ponieważ może powodować problemy z wydajnością, a nawet awarie w niektórych systemach. Jednak to będzie zapewnienie spójności w sposób członkowie danych klasie są wyrównane w pamięci.

Zmiana kolejności członków

Jeśli Twoja klasa nie ma układu standardowego , kompilator może zmienić rozmieszczenie elementów członkowskich danych w pamięci . Nie ma standardu, jak to się robi, więc każda zmiana kolejności danych może spowodować niezgodności między kompilatorami. Dlatego przekazywanie danych w obie strony do biblioteki DLL będzie wymagało klas o standardowym układzie.

Konwencja telefoniczna

Istnieje wiele konwencji wywoływania, które może mieć dana funkcja. Te konwencje wywoływania określają sposób przekazywania danych do funkcji: czy parametry są przechowywane w rejestrach czy na stosie? W jakiej kolejności argumenty są umieszczane na stosie? Kto czyści wszystkie argumenty pozostawione na stosie po zakończeniu funkcji?

Ważne jest, aby zachować standardową konwencję wywoływania; jeśli zadeklarujesz funkcję jako _cdecl, domyślną dla C ++ i spróbujesz wywołać ją używając _stdcall złych rzeczy . _cdecljest jednak domyślną konwencją wywoływania funkcji C ++, więc jest to jedna rzecz, która nie zepsuje się, chyba że celowo ją złamiesz, podając _stdcallw jednym miejscu a _cdeclw innym.

Rozmiar typu danych

Zgodnie z tą dokumentacją w systemie Windows większość podstawowych typów danych ma takie same rozmiary, niezależnie od tego, czy aplikacja jest 32-bitowa, czy 64-bitowa. Jednakże, ponieważ rozmiar danego typu danych jest wymuszany przez kompilator, a nie przez żaden standard (wszystkie standardowe gwarancje są takie 1 == sizeof(char) <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)), dobrym pomysłem jest używanie typów danych o stałym rozmiarze, aby zapewnić zgodność rozmiaru typu danych tam, gdzie to możliwe.

Problemy ze stertą

Jeśli biblioteka DLL łączy się z inną wersją środowiska wykonawczego C niż EXE, oba moduły będą używać różnych stert . Jest to szczególnie prawdopodobny problem, biorąc pod uwagę, że moduły są kompilowane za pomocą różnych kompilatorów.

Aby to złagodzić, cała pamięć będzie musiała zostać przydzielona do współużytkowanej sterty i cofnięta z tej samej sterty. Na szczęście system Windows udostępnia interfejsy API, które pomagają w tym: GetProcessHeap pozwoli ci uzyskać dostęp do sterty EXE hosta, a HeapAlloc / HeapFree pozwoli ci przydzielić i zwolnić pamięć w tej stercie. Ważne jest, aby nie używać normalnego malloc/, freeponieważ nie ma gwarancji, że będą działać zgodnie z oczekiwaniami.

Problemy z STL

Biblioteka standardowa C ++ ma własny zestaw problemów ABI. Nie ma gwarancji, że dany typ STL jest umieszczony w pamięci w ten sam sposób, ani nie ma gwarancji, że dana klasa STL ma ten sam rozmiar w różnych implementacjach (w szczególności kompilacje debugowania mogą umieszczać dodatkowe informacje debugowania w danego typu STL). Dlatego każdy kontener STL będzie musiał zostać rozpakowany na podstawowe typy, zanim zostanie przekazany przez granicę biblioteki DLL i przepakowany po drugiej stronie.

Imię zniekształcone

Twoja biblioteka DLL prawdopodobnie wyeksportuje funkcje, które Twój EXE będzie chciał wywołać. Jednak kompilatory C ++ nie mają standardowego sposobu manipulowania nazwami funkcji . Oznacza to, że nazwana funkcja GetCCDLLmoże zostać zniekształcona _Z8GetCCDLLvw GCC i ?GetCCDLL@@YAPAUCCDLL_v1@@XZMSVC.

Już nie będziesz w stanie zagwarantować statycznego łączenia z biblioteką DLL, ponieważ biblioteka DLL utworzona za pomocą GCC nie utworzy pliku .lib, a statyczne połączenie biblioteki DLL w MSVC wymaga takiego. Dynamiczne łączenie wydaje się znacznie czystszą opcją, ale GetProcAddresszniekształcanie nazw przeszkadza : jeśli spróbujesz użyć niewłaściwej zniekształconej nazwy, połączenie zakończy się niepowodzeniem i nie będziesz mógł użyć swojej biblioteki DLL. Wymaga to trochę włamań, aby obejść ten problem i jest dość głównym powodem, dla którego przekazywanie klas C ++ przez granicę DLL jest złym pomysłem.

Będziesz musiał zbudować bibliotekę DLL, a następnie zbadać utworzony plik .def (jeśli taki został utworzony; będzie się to różnić w zależności od opcji projektu) lub użyć narzędzia takiego jak Dependency Walker, aby znaleźć zniekształconą nazwę. Następnie musisz napisać własny plik .def, definiując niezarządzany alias do zniekształconej funkcji. Jako przykład użyjmy GetCCDLLfunkcji, o której wspomniałem nieco dalej. W moim systemie następujące pliki .def działają odpowiednio dla GCC i MSVC:

GCC:

EXPORTS
    GetCCDLL=_Z8GetCCDLLv @1

MSVC:

EXPORTS
    GetCCDLL=?GetCCDLL@@YAPAUCCDLL_v1@@XZ @1

Odbuduj bibliotekę DLL, a następnie ponownie sprawdź funkcje, które eksportuje. Wśród nich powinna znajdować się niezarządzona nazwa funkcji. Zauważ, że nie możesz używać przeciążonych funkcji w ten sposób : nazwa niezarządzonej funkcji jest aliasem dla jednego określonego przeciążenia funkcji, zdefiniowanego przez zniekształconą nazwę. Zauważ również, że będziesz musiał utworzyć nowy plik .def dla swojej biblioteki DLL za każdym razem, gdy zmieniasz deklaracje funkcji, ponieważ zniekształcone nazwy ulegną zmianie. Co najważniejsze, pomijając zniekształcanie nazwy, zastępujesz wszelkie zabezpieczenia, które konsolidator próbuje ci zaoferować w związku z problemami z niekompatybilnością.

Cały proces jest prostszy, jeśli utworzysz interfejs dla swojej biblioteki DLL, ponieważ będziesz mieć tylko jedną funkcję do zdefiniowania aliasu, zamiast tworzyć alias dla każdej funkcji w bibliotece DLL. Jednak nadal obowiązują te same zastrzeżenia.

Przekazywanie obiektów klas do funkcji

Jest to prawdopodobnie najbardziej subtelny i najniebezpieczniejszy z problemów, które nękają przekazywanie danych między kompilatorami. Nawet jeśli zajmujesz się wszystkim innym, nie ma standardu przekazywania argumentów do funkcji . Może to powodować subtelne awarie bez wyraźnego powodu i bez łatwego sposobu ich debugowania . Będziesz musiał przekazać wszystkie argumenty za pośrednictwem wskaźników, w tym buforów dla wszelkich zwracanych wartości. Jest to niezgrabne i niewygodne, a jednocześnie jest kolejnym hackowym obejściem, które może działać lub nie.


Łącząc wszystkie te obejścia i opierając się na pewnej twórczej pracy z szablonami i operatorami , możemy próbować bezpiecznie przekazywać obiekty przez granicę biblioteki DLL. Zauważ, że obsługa C ++ 11 jest obowiązkowa, podobnie jak obsługa #pragma packi jego wariantów; MSVC 2013 oferuje tę obsługę, podobnie jak najnowsze wersje GCC i clang.

//POD_base.h: defines a template base class that wraps and unwraps data types for safe passing across compiler boundaries

//define malloc/free replacements to make use of Windows heap APIs
namespace pod_helpers
{
  void* pod_malloc(size_t size)
  {
    HANDLE heapHandle = GetProcessHeap();
    HANDLE storageHandle = nullptr;

    if (heapHandle == nullptr)
    {
      return nullptr;
    }

    storageHandle = HeapAlloc(heapHandle, 0, size);

    return storageHandle;
  }

  void pod_free(void* ptr)
  {
    HANDLE heapHandle = GetProcessHeap();
    if (heapHandle == nullptr)
    {
      return;
    }

    if (ptr == nullptr)
    {
      return;
    }

    HeapFree(heapHandle, 0, ptr);
  }
}

//define a template base class. We'll specialize this class for each datatype we want to pass across compiler boundaries.
#pragma pack(push, 1)
// All members are protected, because the class *must* be specialized
// for each type
template<typename T>
class pod
{
protected:
  pod();
  pod(const T& value);
  pod(const pod& copy);
  ~pod();

  pod<T>& operator=(pod<T> value);
  operator T() const;

  T get() const;
  void swap(pod<T>& first, pod<T>& second);
};
#pragma pack(pop)

//POD_basic_types.h: holds pod specializations for basic datatypes.
#pragma pack(push, 1)
template<>
class pod<unsigned int>
{
  //these are a couple of convenience typedefs that make the class easier to specialize and understand, since the behind-the-scenes logic is almost entirely the same except for the underlying datatypes in each specialization.
  typedef int original_type;
  typedef std::int32_t safe_type;

public:
  pod() : data(nullptr) {}

  pod(const original_type& value)
  {
    set_from(value);
  }

  pod(const pod<original_type>& copyVal)
  {
    original_type copyData = copyVal.get();
    set_from(copyData);
  }

  ~pod()
  {
    release();
  }

  pod<original_type>& operator=(pod<original_type> value)
  {
    swap(*this, value);

    return *this;
  }

  operator original_type() const
  {
    return get();
  }

protected:
  safe_type* data;

  original_type get() const
  {
    original_type result;

    result = static_cast<original_type>(*data);

    return result;
  }

  void set_from(const original_type& value)
  {
    data = reinterpret_cast<safe_type*>(pod_helpers::pod_malloc(sizeof(safe_type))); //note the pod_malloc call here - we want our memory buffer to go in the process heap, not the possibly-isolated DLL heap.

    if (data == nullptr)
    {
      return;
    }

    new(data) safe_type (value);
  }

  void release()
  {
    if (data)
    {
      pod_helpers::pod_free(data); //pod_free to go with the pod_malloc.
      data = nullptr;
    }
  }

  void swap(pod<original_type>& first, pod<original_type>& second)
  {
    using std::swap;

    swap(first.data, second.data);
  }
};
#pragma pack(pop)

podKlasa specjalizuje się dla każdego podstawowego typu danych, dzięki czemu intzostanie automatycznie zawinięte int32_t, uintzostaną zawinięte uint32_titp Wszystko to odbywa się za kulisami, dzięki przeciążony =i ()operatorów. Pominąłem pozostałe specjalizacje typu podstawowego, ponieważ są one prawie całkowicie takie same, z wyjątkiem podstawowych typów danych ( boolspecjalizacja ma trochę dodatkowej logiki, ponieważ jest konwertowana na a, int8_ta następnie int8_tjest porównywana z 0, aby przekonwertować z powrotem na bool, ale to jest dość trywialne).

Możemy również owijać typy STL w ten sposób, chociaż wymaga to trochę dodatkowej pracy:

#pragma pack(push, 1)
template<typename charT>
class pod<std::basic_string<charT>> //double template ftw. We're specializing pod for std::basic_string, but we're making this specialization able to be specialized for different types; this way we can support all the basic_string types without needing to create four specializations of pod.
{
  //more comfort typedefs
  typedef std::basic_string<charT> original_type;
  typedef charT safe_type;

public:
  pod() : data(nullptr) {}

  pod(const original_type& value)
  {
    set_from(value);
  }

  pod(const charT* charValue)
  {
    original_type temp(charValue);
    set_from(temp);
  }

  pod(const pod<original_type>& copyVal)
  {
    original_type copyData = copyVal.get();
    set_from(copyData);
  }

  ~pod()
  {
    release();
  }

  pod<original_type>& operator=(pod<original_type> value)
  {
    swap(*this, value);

    return *this;
  }

  operator original_type() const
  {
    return get();
  }

protected:
  //this is almost the same as a basic type specialization, but we have to keep track of the number of elements being stored within the basic_string as well as the elements themselves.
  safe_type* data;
  typename original_type::size_type dataSize;

  original_type get() const
  {
    original_type result;
    result.reserve(dataSize);

    std::copy(data, data + dataSize, std::back_inserter(result));

    return result;
  }

  void set_from(const original_type& value)
  {
    dataSize = value.size();

    data = reinterpret_cast<safe_type*>(pod_helpers::pod_malloc(sizeof(safe_type) * dataSize));

    if (data == nullptr)
    {
      return;
    }

    //figure out where the data to copy starts and stops, then loop through the basic_string and copy each element to our buffer.
    safe_type* dataIterPtr = data;
    safe_type* dataEndPtr = data + dataSize;
    typename original_type::const_iterator iter = value.begin();

    for (; dataIterPtr != dataEndPtr;)
    {
      new(dataIterPtr++) safe_type(*iter++);
    }
  }

  void release()
  {
    if (data)
    {
      pod_helpers::pod_free(data);
      data = nullptr;
      dataSize = 0;
    }
  }

  void swap(pod<original_type>& first, pod<original_type>& second)
  {
    using std::swap;

    swap(first.data, second.data);
    swap(first.dataSize, second.dataSize);
  }
};
#pragma pack(pop)

Teraz możemy utworzyć bibliotekę DLL, która korzysta z tych typów podów. Najpierw potrzebujemy interfejsu, więc będziemy mieć tylko jedną metodę, aby dowiedzieć się, jak to zrobić.

//CCDLL.h: defines a DLL interface for a pod-based DLL
struct CCDLL_v1
{
  virtual void ShowMessage(const pod<std::wstring>* message) = 0;
};

CCDLL_v1* GetCCDLL();

To po prostu tworzy podstawowy interfejs, z którego mogą korzystać zarówno biblioteka DLL, jak i wszyscy wywołujący. Zauważ, że przekazujemy wskaźnik do a pod, a nie do podsiebie. Teraz musimy to zaimplementować po stronie DLL:

struct CCDLL_v1_implementation: CCDLL_v1
{
  virtual void ShowMessage(const pod<std::wstring>* message) override;
};

CCDLL_v1* GetCCDLL()
{
  static CCDLL_v1_implementation* CCDLL = nullptr;

  if (!CCDLL)
  {
    CCDLL = new CCDLL_v1_implementation;
  }

  return CCDLL;
}

A teraz zaimplementujmy ShowMessagefunkcję:

#include "CCDLL_implementation.h"
void CCDLL_v1_implementation::ShowMessage(const pod<std::wstring>* message)
{
  std::wstring workingMessage = *message;

  MessageBox(NULL, workingMessage.c_str(), TEXT("This is a cross-compiler message"), MB_OK);
}

Nic nadzwyczajnego: to po prostu kopiuje przekazany podplik do normalnego wstringi wyświetla go w skrzynce wiadomości. W końcu to tylko POC , a nie pełna biblioteka narzędzi.

Teraz możemy zbudować bibliotekę DLL. Nie zapomnij o specjalnych plikach .def do obejścia zniekształcania nazw konsolidatora. (Uwaga: struktura CCDLL, którą faktycznie zbudowałem i uruchomiłem, miała więcej funkcji niż ta, którą tutaj przedstawiam. Pliki .def mogą nie działać zgodnie z oczekiwaniami).

Teraz plik EXE wywoła DLL:

//main.cpp
#include "../CCDLL/CCDLL.h"

typedef CCDLL_v1*(__cdecl* fnGetCCDLL)();
static fnGetCCDLL Ptr_GetCCDLL = NULL;

int main()
{
  HMODULE ccdll = LoadLibrary(TEXT("D:\\Programming\\C++\\CCDLL\\Debug_VS\\CCDLL.dll")); //I built the DLL with Visual Studio and the EXE with GCC. Your paths may vary.

  Ptr_GetCCDLL = (fnGetCCDLL)GetProcAddress(ccdll, (LPCSTR)"GetCCDLL");
  CCDLL_v1* CCDLL_lib;

  CCDLL_lib = Ptr_GetCCDLL(); //This calls the DLL's GetCCDLL method, which is an alias to the mangled function. By dynamically loading the DLL like this, we're completely bypassing the name mangling, exactly as expected.

  pod<std::wstring> message = TEXT("Hello world!");

  CCDLL_lib->ShowMessage(&message);

  FreeLibrary(ccdll); //unload the library when we're done with it

  return 0;
}

A oto wyniki. Nasza biblioteka DLL działa. Z powodzeniem dotarliśmy do poprzednich problemów z STL ABI, poprzednich problemów z C ++ ABI, poprzednich problemów z manglingiem, a nasza biblioteka MSVC DLL działa z GCC EXE.

Obraz, który pokazuje wynik później.


Podsumowując, jeśli absolutnie musisz przekazywać obiekty C ++ przez granice bibliotek DLL, tak to się robi. Jednak nic z tego nie gwarantuje, że będzie działać z twoją lub kimkolwiek innym. Wszystko to może się zepsuć w dowolnym momencie i prawdopodobnie zepsuje się dzień przed zaplanowaną wersją główną oprogramowania. Ta ścieżka jest pełna hacków, zagrożeń i ogólnego idiotyzmu, za które prawdopodobnie powinienem zostać zastrzelony. Jeśli wybierzesz tę trasę, wykonaj test z najwyższą ostrożnością. I naprawdę ... po prostu w ogóle tego nie rób.

cf stoi z Moniką
źródło
1
Hmm, nieźle! Wyciągnąłeś dość dobrą kolekcję argumentów przeciwko używaniu standardowych typów C ++ do interakcji z biblioteką DLL systemu Windows i odpowiednio oznakowano. Te szczególne ograniczenia ABI nie będą miały zastosowania do innych łańcuchów narzędzi niż MSVC. Należy nawet o tym wspomnieć ...
πάντα ῥεῖ
12
@DavidHeffernan Right. Jest to jednak wynik moich kilkutygodniowych badań, więc pomyślałem, że warto byłoby udokumentować to, czego się nauczyłem, aby inni nie musieli przeprowadzać tych samych badań i tych samych prób zhakowania działającego rozwiązania. Tym bardziej, że wydaje się to być tu dość powszechne pytanie.
por. Z Moniką
@ πάνταῥεῖ Te szczególne ograniczenia ABI nie będą miały zastosowania do innych łańcuchów narzędzi niż MSVC. Należy o tym nawet wspomnieć ... Nie jestem pewien, czy dobrze to rozumiem. Czy wskazujesz, że problemy z ABI dotyczą wyłącznie MSVC i, powiedzmy, biblioteka DLL zbudowana za pomocą clang będzie z powodzeniem działać z EXE zbudowanym za pomocą GCC? Jestem trochę zdezorientowany, ponieważ wydaje się to sprzeczne z wszystkimi moimi badaniami ...
por. Z Moniką
@computerfreaker Nie, mówię, że PE i ELF używają różnych formatów ABI ...
πάντα ῥεῖ
3
@computerfreaker Większość głównych kompilatorów C ++ (GCC, Clang, ICC, EDG itp.) jest zgodna z Itanium C ++ ABI. MSVC nie. Więc tak, te problemy z ABI są w dużej mierze specyficzne dla MSVC, choć nie wyłącznie - nawet kompilatory C na platformach Unix (a nawet różne wersje tego samego kompilatora!) Cierpią na mniej niż doskonałą interoperacyjność. Zwykle są jednak na tyle blisko, że nie zdziwiłbym się, gdyby okazało się, że można z powodzeniem połączyć bibliotekę DLL zbudowaną w Clang z plikiem wykonywalnym zbudowanym w GCC.
Stuart Olsen
17

@computerfreaker napisał świetne wyjaśnienie, dlaczego brak ABI uniemożliwia przekazywanie obiektów C ++ przez granice DLL w ogólnym przypadku, nawet jeśli definicje typów są pod kontrolą użytkownika i dokładnie ta sama sekwencja tokenów jest używana w obu programach. (Istnieją dwa przypadki, które działają: klasy o standardowym układzie i czyste interfejsy)

W przypadku typów obiektów zdefiniowanych w standardzie C ++ (w tym zaadaptowanych z biblioteki szablonów standardowych) sytuacja jest o wiele gorsza. Tokeny definiujące te typy NIE są takie same w wielu kompilatorach, ponieważ C ++ Standard nie zapewnia pełnej definicji typu, a jedynie minimalne wymagania. Ponadto wyszukiwanie nazw identyfikatorów, które pojawiają się w tych definicjach typów, nie rozwiązuje tego samego. Nawet w systemach, w których występuje C ++ ABI, próba udostępnienia takich typów ponad granicami modułów skutkuje ogromnym niezdefiniowanym zachowaniem z powodu naruszenia jednej definicji.

Jest to coś, z czym programiści Linuksa nie byli przyzwyczajeni, ponieważ libstdc ++ g ++ było de facto standardem i praktycznie wszystkie programy z niego korzystały, spełniając w ten sposób ODR. Biblioteka clang's libc ++ złamała to założenie, a następnie C ++ 11 przyszedł wraz z obowiązkowymi zmianami w prawie wszystkich typach bibliotek standardowych.

Po prostu nie udostępniaj standardowych typów bibliotek między modułami. To niezdefiniowane zachowanie.

Ben Voigt
źródło
16

Niektóre z odpowiedzi sprawiają, że zaliczanie zajęć w C ++ brzmi naprawdę przerażająco, ale chciałbym podzielić się innym punktem widzenia. Czysta metoda wirtualnego C ++ wspomniana w niektórych innych odpowiedziach okazuje się być czystsza, niż mogłoby się wydawać. Zbudowałem cały system wtyczek wokół tej koncepcji i działa on bardzo dobrze od lat. Mam klasę „PluginManager”, która dynamicznie ładuje biblioteki DLL z określonego katalogu przy użyciu funkcji LoadLib () i GetProcAddress () (oraz odpowiedników systemu Linux, więc plik wykonywalny, aby był międzyplatformowy).

Wierz lub nie, ta metoda jest wybaczająca, nawet jeśli robisz jakieś zwariowane rzeczy, takie jak dodanie nowej funkcji na końcu czystego wirtualnego interfejsu i próba załadowania bibliotek dll skompilowanych z interfejsem bez tej nowej funkcji - ładują się dobrze. Oczywiście ... będziesz musiał sprawdzić numer wersji, aby upewnić się, że plik wykonywalny wywołuje nową funkcję tylko dla nowszych bibliotek dll, które implementują tę funkcję. Ale dobra wiadomość jest taka: to działa! Więc w pewnym sensie masz prostą metodę ewolucji interfejsu w czasie.

Kolejna fajna rzecz dotycząca czystych wirtualnych interfejsów - możesz dziedziczyć dowolną liczbę interfejsów i nigdy nie napotkasz problemu z diamentami!

Powiedziałbym, że największą wadą tego podejścia jest to, że musisz bardzo uważać na typy, które przekazujesz jako parametry. Żadnych klas ani obiektów STL bez uprzedniego owinięcia ich czystymi wirtualnymi interfejsami. Żadnych struktur (bez przechodzenia przez voodoo pakietu pragmy). Tylko typy pierwotne i wskaźniki do innych interfejsów. Nie możesz też przeciążać funkcji, co jest niedogodnością, ale nie przeszkadza w wyświetlaniu.

Dobrą wiadomością jest to, że za pomocą kilku wierszy kodu można tworzyć klasy ogólne i interfejsy wielokrotnego użytku, aby zawijać ciągi znaków STL, wektory i inne klasy kontenerów. Alternatywnie możesz dodać funkcje do swojego interfejsu, takie jak GetCount () i GetVal (n), aby umożliwić ludziom przechodzenie przez listy.

Ludzie, którzy tworzą dla nas wtyczki, uważają to za całkiem łatwe. Nie muszą być ekspertami w zakresie granic ABI ani czymkolwiek - po prostu dziedziczą interfejsy, którymi są zainteresowani, kodują funkcje, które obsługują, i zwracają fałsz w przypadku tych, których nie.

O ile wiem, technologia, która sprawia, że ​​wszystko to działa, nie jest oparta na żadnym standardzie. Z tego, co wiem, Microsoft postanowił zrobić swoje wirtualne tabele w ten sposób, aby mogli tworzyć COM, a inni twórcy kompilatorów zdecydowali się pójść w ich ślady. Obejmuje to GCC, Intel, Borland i większość innych głównych kompilatorów C ++. Jeśli planujesz użyć mało znanego wbudowanego kompilatora, to podejście prawdopodobnie nie zadziała. Teoretycznie każda firma kompilująca mogłaby zmienić swoje wirtualne stoły w dowolnym momencie i zepsuć rzeczy, ale biorąc pod uwagę ogromną ilość kodu napisanego przez lata, który zależy od tej technologii, byłbym bardzo zaskoczony, gdyby któryś z głównych graczy zdecydował się przełamać rangę.

Więc morał tej historii jest taki ... Z wyjątkiem kilku ekstremalnych okoliczności, potrzebujesz jednej osoby odpowiedzialnej za interfejsy, która może upewnić się, że granica ABI pozostaje czysta dzięki prymitywnym typom i uniknąć przeciążenia. Jeśli nie masz nic przeciwko temu warunkowi, nie bałbym się udostępniać interfejsów klas w bibliotekach DLL / SO między kompilatorami. Bezpośrednie udostępnianie klas == kłopoty, ale udostępnianie czystych wirtualnych interfejsów nie jest takie złe.

Ph0t0n
źródło
To dobra uwaga ... Powinienem był powiedzieć: „Nie bój się udostępniać interfejsów klasom”. Zmienię odpowiedź.
Ph0t0n
2
Hej, to świetna odpowiedź, dzięki! Moim zdaniem jeszcze lepiej byłoby kilka linków do dalszej lektury, która pokazuje kilka przykładów rzeczy, o których wspominasz (lub nawet kod) - na przykład do zawijania klas STL itp. W przeciwnym razie czytam ta odpowiedź, ale jestem trochę zagubiony w tym, jak te rzeczy będą wyglądać i jak ich szukać.
Ela782
8

Nie możesz bezpiecznie przekazywać obiektów STL przez granice DLL, chyba że wszystkie moduły (.EXE i .DLL) są zbudowane z tą samą wersją kompilatora C ++ i tymi samymi ustawieniami i smakami CRT, co jest bardzo ograniczające i oczywiście nie jest twoim przypadkiem.

Jeśli chcesz udostępnić interfejs zorientowany obiektowo z biblioteki DLL, powinieneś udostępnić czyste interfejsy C ++ (co jest podobne do tego, co robi COM). Rozważ przeczytanie tego interesującego artykułu o CodeProject:

HowTo: Eksportuj klasy C ++ z biblioteki DLL

Możesz również rozważyć udostępnienie czystego interfejsu C na granicy biblioteki DLL, a następnie utworzenie opakowania C ++ w witrynie wywołującej.
Jest to podobne do tego, co dzieje się w Win32: kod implementacji Win32 jest prawie C ++, ale wiele interfejsów API Win32 udostępnia czysty interfejs C (istnieją również interfejsy API, które udostępniają interfejsy COM). Następnie ATL / WTL i MFC opakowują te czyste interfejsy C klasami i obiektami C ++.

Panie C64
źródło