Z pogranicza – używamy DLL pisanej w C++ – część 1.

Z pogranicza – używamy DLL pisanej w C++ – część 1.

Wstęp

Czasem bywa tak, że trzeba użyć DLL pisanej w innym języku. Sprawa jest dość prosta, jeśli to są języki „natywne”, jak np. C, C++, czy Pascal. Jednak podczas używania natywnej biblioteki w .NET czeka na nas kilka niespodzianek.

Ze względu na ogrom informacji i możliwości, podzielę ten artykuł na kilka części. W tej części ogólny zarys działania mechanizmu, przekazywanie typów prostych i stringów.

W artykule przedstawię głównie praktykę. Teorii jest naprawdę sporo, a znając praktyczne metody radzenia sobie z tym problemem, teoria niewiele daje. Jeśli jednak naprawdę chcecie wiedzieć dokładnie dlaczego tak, a nie inaczej i teoria zawarta w tym artykule jest dla Was niewystarczająca, dajcie znać w komentarzu 🙂

Za każdym razem, gdy piszę o funkcji C++, mam na myśli funkcję pisaną w dowolnym języku niskiego poziomu (niezarządzanego) typu C, C++, Pascal.

Zaznaczam, że cykl jest dość zaawansowany. Wymaga trochę wiedzy na temat pamięci i wskaźników. Co nieco pisałem w tym artykule. Ale jeśli czegoś nie rozumiesz, to koniecznie daj znać w komentarzu.

Czym się różni funkcja od metody

Jeśli nigdy nie miałeś do czynienia z programowaniem w innych językach niż C#, możesz nie znać różnicy między funkcją a metodą. Można powiedzieć, że metoda to funkcja będąca składnikiem klasy. W innych językach nie wszystko musi być klasą, wobec czego funkcja może być umieszczona poza jakąkolwiek klasą.

Rozróżnienie w tym artykule jest istotne, ponieważ, aby można było używać DLL napisanej w C++, w kodzie muszą zostać wyeksportowane poszczególne funkcje (z klasami też w prawdzie się da, ale to jest już zupełnie inny mechanizm na zupełnie inny cykl).

Jak to działa z grubsza?

Mamy dwa światy – kody zarządzane (managed) i niezarządzane (unmanaged). Zarządzane to te pisane przykładowo w C#. Niezarządzane to natywne – może być to C++, Pascal itp.

Jak się pewnie domyślasz te dwa różne światy dzieli pewna przepaść. Powstał więc mechanizm marshalingu jako pomost między nimi.

Co to ten Marshaling?

Wyobraź sobie Marshaling jako proces przekształcania danych między różnymi formatami. Jeśli przykładowo wysyłasz żądanie HTTP z jakimś obiektem, to ten obiekt jest przekształcany na format JSON, a po drugiej stronie przekształcany z JSON na jakąś inną klasę (być może nawet w innym języku). Można powiedzieć, że to też pewna forma Marshalingu. Chociaż w przypadku programowania międzyplatformowego oczywiście mówimy o czymś bardziej skomplikowanym.

Ale wszystko sprowadza się właśnie do tego. Mechanizm marshalingu po prostu bierze kilka bajtów z pamięci i konwertuje je (przekształca) na odpowiedni typ danych.

Co to Platform Invoke (P/Invoke)?

To jest mechanizm (w C# wyrażany za pomocą atrybutów), który pozwala na używanie funkcji ze świata niezarządzanego w środowisku .NET. Częścią tego mechanizmu jest Marshaling.

Co się dzieje, gdy wywołujemy funkcję z C++?

Mechanizm P/Invoke wkracza do akcji i:

  1. Szuka biblioteki DLL, która zawiera wymaganą funkcję
  2. Ładuje bibliotekę do pamięci (tylko przy pierwszym wywołaniu)
  3. Znajduje adres w pamięci, w którym zaczyna się wywoływana funkcja
  4. Kładzie (push) argumenty na stosie, robiąc marshaling (marshaling tylko przy pierwszym wywołaniu)
  5. Uruchamia funkcję znajdującą się pod znalezionym adresem.
  6. Jeśli funkcja coś zwraca, wtedy P/Invoke znów dokonuje marshalingu (jeśli trzeba) i zwraca te dane do kodu zarządzanego (w uproszczeniu).

Lokalizowanie funkcji

Mangling

Każda funkcja w DLL ma swój numer lub nazwę. Niestety to wcale nie musi być nazwa jakiej się spodziewamy. Istnieje mechanizm manglingu, szczególnie istotny w C++. Polega to na tym, że do funkcji dodaje się prefix i/lub sufix, którego zadaniem jest utworzenie jednoznaczej nazwy funkcji. Załóżmy, że mamy dwie funkcje:

void foo();
void foo(int a);

Teraz mangling bierze pod uwagę parametry i typy zwracane poszczególnych funkcji i ostatecznie mogą one zostać wyeksportowane pod takimi nazwami (zupełnie przykładowo):

foo@_v1
foo@_vi2

Od czego zależy mangling? Chociażby od tego, czy funkcje eksportujesz jako funkcje C, czy C++. W przypadku eksportu funkcji jako C, mangling nie jest wykorzystywany. Dzięki czemu funkcje w DLL mają dokładnie takie nazwy, jakich się spodziewamy.

Kolejność parametrów i odpowiedzialność

Jak się domyślasz, kolejność parametrów jest cholernie ważna. Ale też może być różna. Co więcej, czasem za niszczenie parametrów na stosie odpowiedzialny jest caller, a czasem biblioteka, którą wywołujemy. To wszystko zależy od konwencji wywołania funkcji.

Mamy w programowaniu kilka konwencji:

  • stdcall – funkcja w bibliotece jest odpowiedzialna za zniszczenie parametrów na stosie. Parametry są przesyłane w odwrotnej kolejności (od prawej do lewej). Głównie świat Windows.
  • cdecl – kod wywołujący (caller) jest odpowiedzialny za zniszczenie parametrów na stosie. Parametry są przesyłane od lewej do prawej. Głównie świat Unix

Są jeszcze inne konwencje wywołań, ale nie będę o nich pisał, bo nie są istotne dla tego artykułu. Jeśli Cię to interesuje, poczytaj o: fastcall, thiscall, vectorcall, syscall.

Zatem, żeby możliwe było używanie funkcji z C++, konwencje wywołań muszą być jednakowe zarówno w bibliotece jak i w kodzie wywołującym. Jeśli będą inne – wszystko wybuchnie i posypią się meteoryty.

Zestaw znaków (Charset)

Funkcje WinApi (i niektórych frameworków) można podzielić m.in. na użycie zestawu znaków. Jedne używają czystego ANSI, a drugie Unicode (WideString). To jest istotne, ponieważ te, używające ANSI są zakończone literką A. Te drugie – W. Np.:

int MessageBoxA(const char * pMessage); //używa ANSI
int MessageBoxW(const wchar_t * pMessage); //używa Unicode

Jeśli zatem używasz funkcji, która przyjmuje w parametrze jakieś stringi, wtedy istotne jest, abyś określił zestaw znaków w mechanizmie P/Invoke. To pomaga na wyszukanie odpowiedniej wersji funkcji, a poza tym na odpowiedni marshaling stringów. Chociaż to jest trochę głębszy temat, do którego wrócimy później. Tak, mechanizm P/Invoke może szukać funkcji o nazwie FooA, zamiast Foo, jeśli określisz Charset na ANSI (analogicznie może szukać FooW, jeśli określisz Charset na Unicode).

Ok, czas pobrudzić sobie ręce…

Jedziemy z koksem

Przygotowałem prostą solucję, którą możesz pobrać z GitHuba. To są dwa projekty – jeden C++, drugi C#. Projekt C++ to prosta biblioteka DLL, którą będziemy używać w C#. Nie ma żadnego konkretnego sensu. Napisałem ją tylko na potrzeby tego artykułu. Na dzień pisania artykułu (kwiecień 2023) biblioteka nie jest w pełni ukończona. Będę dodawał jej funkcjonalność przy kolejnych częściach artykułu. Niemniej jednak, projekty się budują i działają w obrębie tego artykułu.

Budowanie solucji

Żeby móc zbudować tę solucję, musisz mieć zainstalowane narzędzia do budowania C++. Biblioteka używa toolseta v143 (VisualStudio 2022). Musisz mieć w Visual Studio Installer zaznaczone „Programowanie aplikacji klasycznych w C++”:

Uruchamianie i debugowanie

Zaznacz w Visual Studio konfigurację Debug x64, a jako projekt startowy – CsClient:

Zwróć też uwagę na plik launchSettings.json w projekcie CsClient. Powinien wyglądać tak:

{
  "profiles": {
    "CsClient": {
      "commandName": "Project",
      "nativeDebugging": true
    }
  }
}

Dzięki temu możliwe będzie debugowanie kodu C++ z aplikacji pisanej w C#.

Zacznijmy od czegoś prostego…

Podstawy

Przede wszystkim pamiętaj, że dobrze jest stworzyć sobie klasę w C#, która będzie odwoływać się do funkcji eksportowanych z biblioteki. Tutaj rolę tej klasy przejmuje LibraryClient.

Weź też po uwagę, że w rzeczywistym świecie niektóre biblioteki mogą nie nadawać się do importu. Nie eksportują żadnych funkcji albo eksportują je z natywnymi typami danych (np. std::wstring). Lub też eksportują całe klasy (jeśli to jest mechanizm COM, to można z tym sobie poradzić, ale to temat na zupełnie inny artykuł).

Pierwsza klasa

Na początek spójrzmy na kod w C++ – plik library.h. To jest plik nagłówkowy, w którym są umieszczane deklaracje funkcji. Zobacz, że są w bloku extern "C", dzięki czemu nie ma manglingu. Każda z nich jest eksportowana w konwencji stdcall. Teraz spróbujmy użyć pierwszej:

DLL_EXPORT int __stdcall add(int a, int b);

Ta funkcja dodaje do siebie dwa inty i zwraca ich sumę.

Stwórzmy teraz klasę wrappera w C#:

internal class LibraryClient
{
    [DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall)]
    private static extern int add(int a, int b);

    public int Add(int a, int b)
    {
        return add(a, b);
    }
}

Zobacz teraz, co się dzieje. W linijce 3 i 4 używamy mechanizmu P/Invoke. Mówimy mu: „Hej, używam metody add z biblioteki CppDll.dll z konwencją StdCall„. Mechanizm P/Invoke wie jakiej funkcji szukać, ponieważ taką nazwę przekazałeś w linijce 4. Oczywiście jest też parametr atrybutu DllImportEntryPoint, w którym możesz przekazać inną nazwę funkcji.

Tutaj nie ma żadnego problemu. Nie ma też marshalingu. Dlatego, że typ int jest tzw. typem kopiowalnym (blittable type). O tym za chwilę.

Co się dzieje:

  • P/Invoke ładuje bibliotekę CppDll.dll do pamięci
  • P/Invoke szuka adresu funkcji o nazwie add
  • P/Invoke kopiuje na jej stos parametry b, a
  • P/Invoke uruchamia funkcję
  • Po stronie C++ parametry są zdejmowane ze stosu, robione na nich obliczenia, następnie na stos jest zwracany wynik. C++ zwalnia pamięć używaną przez parametry po swojej stronie.
  • P/Invoke zdejmuje ze stosu wynik funkcji i zwraca go w odpowiedniej postaci: int.

Proste? Proste 🙂

Typy kopiowalne i niekopiowalne

Część typów danych ma wspólną reprezentację zarówno po stronie zarządzanej jak i niezarządzanej. Przykładowo typ int zarówno po stronie C++ jak i C# ma 4 bajty, a te 4 bajty są ułożone w tej samej kolejności. W związku z tym bez problemu można sobie je „kopiować”. Bo znaczą dokładnie to samo. Te typy są kopiowalne. Nie wymagają żadnej konwersji pomiędzy dwoma światami. Należą do nich:

  • System.Byte
  • System.SByte
  • System.Int16
  • System.Int32
  • System.UInt32
  • System.Int64
  • System.UInt64
  • System.IntPtr
  • System.UIntPtr
  • System.Single
  • System.Double
  • Jednowymiarowe statyczne tablice typów kopiowalnych
  • Typy wartościowe składające się z typów kopiowalnych

Teraz pytanie o typy niekopiowalne. One wymagają marshalingu. Czyli jakiejś formy konwersji.

Ważne jest, że referencje do obiektów nie są kopiowalne. Możesz mieć kopiowalną strukturę, ale jeśli masz tablicę referencji do obiektów tej struktury, to ten obiekt nie jest już kopiowalny.

Przesyłanie stringa

Trochę gorzej ze stringami. To jest typ niekopiowalny. Co więcej jest traktowany w specjalny sposób. Zanim w nie wejdziemy, muszę odpowiedzieć na pytanie – czym tak naprawdę jest string?

Czym jest string?

String to tablica znaków zakończona znakiem ASCII #0 (null) lub dwoma takimi znakami w przypadku unicode. W przypadku stringu ANSI na jeden znak przypada jeden bajt. Jednak w przypadku unicode jeden znak jest już kodowany dwoma bajtami (jeśli znak jest typowym znakiem z zakresu ASCII, wtedy drugi bajt ma wartość 0x00). A w niektórych przypadkach – nawet czterema (języki głównie azjatyckie). Spójrz na fragment pamięci z kodu w C++:

To jest najbardziej prawdziwy string. Znaki 0x00 na końcu są potrzebne do tego, żeby komputer wiedział, gdzie ten string się kończy. On nie ma informacji o długości ani o niczym innym. To jest po prostu tablica pojedynczych znaków.

A teraz spójrz na string w C#:

A co tu się dzieje? Co to za awantury?

No właśnie, string w .NET to nie jest prawdziwy string. To jest jakaś klasa, która posiada więcej informacji (chociażby długość stringa). Co więcej, wcale nie musi się kończyć dwoma nullami. Zatem widzisz, że to jest jakaś większa klasa. Coś jak std::wstring w C++ – to jest klasa. Dlatego też, jeśli w parametrze eksportowanej funkcji C++ jest std::string lub std::wstring, taka funkcja nie nadaje się do użytku w .NET (ogólnie poza kodem pisanym w C++ w odpowiedniej wersji biblioteki).

Przesyłanie stringa do C++

Na szczęście przesyłanie stringa do C++ jest banalnie proste.

Od strony C++ wystarczy go przechwycić jako wskaźnik na tablicę znaków:

DLL_EXPORT size_t getStrLen(const wchar_t* pStr);

A od strony C# po prostu wysyłamy go jako zwykły string:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
private static extern int getStrLen(string str);

Co ważne, zwróć uwagę, że przekazujemy tutaj CharSet. Jeśli tego nie zrobisz, domyślnie będzie użyty ANSI.

Zwróć uwagę, że do C++ jest w takim wypadku przesyłany dokładnie ten string (dokładnie to miejsce w pamięci na zarządzanej stercie, gdzie zaczyna się tekst).

I teraz bardzo ważne jest, żeby kod C++ używał tego stringa tylko do odczytu. Jeśli w jakiś sposób spróbuje go zmienić, najpewniej skończy się to uszkodzeniem sterty.

Jednak, jeśli po stronie C++ odbieramy ANSI string (char *), wtedy string z C# jest kopiowany do dodatkowego bufora (i konwertowany) i to to miejsce jest przekazywane do C++. Gdy wracamy z funkcji, zawartość tego bufora jest ponownie kopiowana do stringa w C#. Więc jeśli masz dużo takich wywołań, możesz mieć problem z wydajnością. Wtedy spróbuj wysyłać tablicę bajtów zamiast stringa i sprawdź, co się stanie.

Jeśli jednak masz taką opcję, to posłuż się BSTR – o którym piszę niżej.

Pobieranie string z C++

Tutaj jest trochę trudniej. Dla takiego stringa trzeba zaalokować pamięć na stercie i odpowiednio go przekonwertować. Jest kilka sposobów na pobranie stringa z C++. Wszystkie mają swoje plusy i minusy.

Pobieranie jako rezultat

Pobierz i zwolnij

Po stronie C++ musisz skopiować string do miejsca, w którym będzie rezultat.

DLL_EXPORT wchar_t* getInfo()
{
	return _wcsdup(L"Jestem wywołany z C++!"); //C++ tworzy kopię tego stringa i zwraca ją
}

W związku z tym, że C++ utworzył nowy obiekt na stercie, musi też go zwolnić w odpowiednim momencie. Dlatego też biblioteka powinna dać funkcję, która zwolni zaalokowaną pamięć, np.:

DLL_EXPORT void freeInfo(wchar_t* pData)
{
	delete pData;
}

Teraz po stronie C# musisz pobrać ten string jako wskaźnik (w końcu wchar_t * jest wskaźnikiem), potem użyć marshalingu, żeby skonwertować go na string .NETowy, na koniec musisz zwolnić pobrany wskaźnik:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
private static extern IntPtr getInfo();

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall)]
private static extern void freeInfo(IntPtr pData);

//

public string GetInfo()
{
    var ptr = getInfo();
    var result = Marshal.PtrToStringUni(ptr);
    freeInfo(ptr);
    return result;
}

Marshal.PtrToStringUni traktuje dane, na które wskazuje ptr jak string w Unicode. Czyli szuka jego końca (2 znaki null), kopiuje go na stertę zarządzaną i zwraca w postaci .NETowego stringa. Pobrany wskaźnik cały czas wskazuje na miejsce na stercie niezarządzanej, więc trzeba go zwolnić. Dlatego też wywołujemy metodę freeInfo z C++.

Pamiętaj, że za zwolnienie pamięci jest odpowiedzialna biblioteka, która tę pamięć zaalokowała. W tym przypadku C# używa innej biblioteki do zarządzania pamięcią niż C++. Dlatego też to po stronie C++ pamięć musi zostać zwolniona.

Pobieranie bez zwalniania

Jest jednak mechanizm, który zadziała nieco inaczej. Prościej dla programisty C#, jednak od strony C++ wymaga nieco więcej zachodu. To wykorzystuje technologię COM. Nie wchodząc w szczegóły – C# używa tej samej biblioteki do zwolnienia pamięci, co C++ do jej alokacji. Dlatego też w tym przypadku C# może być odpowiedzialny za zwolnienie tej pamięci i tak też się dzieje (zupełnie automatycznie). Od strony C++ wygląda to tak:

DLL_EXPORT wchar_t* __stdcall getInfoWithCom()
{
	std::wstring str = L"Cześć, jestem z COM";
	int allocSize = str.size() * 2 + 2;
	STRSAFE_LPWSTR result = (STRSAFE_LPWSTR)CoTaskMemAlloc(allocSize);
	StringCchCopy(result, allocSize, str.c_str());

	return (wchar_t*)result;
}

Najpierw liczymy, ile pamięci musimy zaalokować. Pamiętaj, że jeden znak stringa unicodowego zajmuje dwa bajty. Dlatego też ilość znaków w string str mnożymy przez dwa. Następnie trzeba dodać jeszcze dwa znaki na zakończenie stringa (to będą te dwa nulle na końcu).

Pamiętaj, że w niektórych przypadkach jeden znak może potrzebować aż 4 bajtów. Jeśli wiesz, że Twoja aplikacja będzie działała na rynku azjatyckim i nie znasz długości stringa, dla jakiego chcesz zaalokować pamięć, to lepiej mnóż przez 4.

Następnie dzieje się magia. Używamy funkcji CoTaskMemAlloc do zaalokowania odpowiedniej ilości pamięci. C# pod spodem użyje CoTaskMemFree do zwolnienia tej pamięci. To jest ta sama biblioteka.

Na koniec kopiujemy string str do zaalokowanej pamięci i zwracamy go.

A po stronie C#? Możemy to odebrać jako zwykły string:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
private static extern string getInfoWithCom();

//

public string GetInfoWithCom()
{
    return getInfoWithCom();
}

Plusy – to widać po stronie C#. Mniej kodu i programiści nie muszą zajmować się pamięcią. Minusy – to widać po stronie C++. Kod musi być odpowiednio napisany, żeby dało się tak zrobić.

Pobieranie z użyciem BSTR

BSTR to specjalna forma stringa, która składa się z trzech części:

  • długość stringu
  • tekst
  • znaki kończące (null)

Zwróć uwagę, że sam BSTR wskazuje na początek tekstu, a nie na 4 wcześniejsze bajty, w których jest zaszyta długość stringu. BSTR jest używany w mechanizmie COM i jest naturalny dla P/Invoke. Jego użycie jest bardzo podobne do tego, co było wyżej, jednak jest prostsze (od strony C++) i zwraca inny typ:

DLL_EXPORT BSTR __stdcall getInfoWithBstr()
{
	return SysAllocString(L"A tak działa BSTR");
}

A w C#:

[DllImport("CppDll.dll", CallingConvention=CallingConvention.StdCall, CharSet = CharSet.Unicode)]
[return:MarshalAs(UnmanagedType.BStr)]
private static extern string getInfoWithBstr();

//

public string GetInfoWithBstr()
{
    return getInfoWithBstr();
}

Tutaj jest istotne, żeby oznaczyć typ zwracany odpowiednim Marshalerem. W tym przypadku BStr. Dzięki temu to C# może zatroszczyć się o zwolnienie pamięci zarezerwowanej przez C++. Ta rezerwacja i zwalnianie też dzieje się przez mechanizm COM.

Pobieranie w parametrze

String można pobierać również przez parametr. W internetach (m.in. Stack Overflow) często widać taki kod jak poniżej. Jest on prosty, ale ma swoje problemy:

Użycie StringBuilder

Po stronie C++ mamy taki kod:

DLL_EXPORT void getInfo2(wchar_t* pData, int strLen)
{
	ZeroMemory(pData, strLen * 2);
	std::wstring result = L"Można i tak...";
	result = result.substr(0, strLen);
	std::copy(result.begin(), result.end(), pData);
}

W parametrze pData przekazujemy gotowe miejsce w pamięci do przyjęcia stringu. Jak duże? O tym mówi parametr strLen. Zarówno miejsce, jak i strLen jest przekazywane przez kod C#. Tylko trzeba pamiętać o dwóch rzeczach, o których powiem jeszcze przy okazji kodu C#.

Musisz przekazać strLen wystarczająco duży, aby pomieścił string, który będzie zwracany. No i parametr strLen musi też wziąć pod uwagę dwa dodatkowe bajty na zakończenie stringa (2 znaki null). W innym przypadku dostaniesz jakieś śmieci. A teraz, co się dzieje w kodzie?

W pierwszej kolejności zerujemy tę pamięć. Chodzi o to, żeby mieć pewność, że te dwa ostatnie znaki (zakończenie stringa) będą nullami. Tak, można by zerować tylko te dwa ostatnie bajty, ale tak jest mi prościej i łatwiej tłumaczyć 🙂

Potem kopiujemy do jakiejś zmiennej stringa – w tym przypadku, jeśli podamy ilość znaków większą niż string faktycznie zawiera, string będzie skopiowany do końca.

Na koniec kopiujemy go w przekazane w parametrze miejsce.

A jak to wygląda od strony C#?

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, EntryPoint = "getInfo2", CharSet = CharSet.Unicode)]
private static extern void getInfo(StringBuilder data, int length);

//

public string GetInfoWithStrBuilder()
{
    var sb = new StringBuilder(255);
    getInfo(sb, sb.Capacity);

    return sb.ToString();
}

No i tutaj sprawa jest prosta. Najpierw tworzymy StringBuildera odpowiednie dużego, żeby mógł przetrzymać zwracany string. Pamiętamy też o dwóch znakach kończących stringa. Następnie wywołujemy funkcję z tymi parametrami.

Jak dużo pamięci?

Teraz pojawia się pytanie – skąd mam wiedzieć, ile pamięci mam zarezerwować na ten string? Skąd mam znać jego długość?

No to jest odwieczne pytanie programistów C++. Dlatego też powstało kilka mechanizmów, które rozwiązują ten problem. Oczywiście trzeba je samemu zaprogramować.

Pierwszy sposób jest taki, że funkcja C++ getInfo zwraca wartość bool, która mówi, czy string się skończył, czy nie. Wtedy tę funkcję wywołujemy w pętli, przekazując od którego znaku ma być czytany string. Często ten mechanizm zwraca stałą ilość znaków (lub mniej, jeśli już nie ma).

Drugi sposób to taki, gdzie wywołujemy funkcję getInfo z parametrem pierwszym (miejsce w pamięci) ustawionym na null – C++ widzi, że to miejsce nie jest zaalokowane (bo jest nullem) i zwraca długość stringa, jaki wyprodukuje. Wtedy w C# ustawiamy odpowiednio duży bufor. To może wyglądać tak:

DLL_EXPORT int __stdcall getInfo3(wchar_t* pData, int strLen)
{
	std::wstring str = L"A to jest string o nieznanej długości";

	if (pData == nullptr)
		return str.size();

	ZeroMemory(pData, strLen * 2);
	str = str.substr(0, strLen);
	std::copy(str.begin(), str.end(), pData);
	return 0;
}

A po stronie C#:

public string GetInfoWithLen()
{
    int len = getInfoWithLen(null, 0);
    var sb = new StringBuilder(len + 2);
    getInfoWithLen(sb, sb.Capacity);

    return sb.ToString();
}

Tak naprawdę wystarczyłoby dodanie 1 znaku w zaznaczonej wyżej linii zamiast dwóch. Jeden znak w unicode to dwa bajty i o te dwa bajty nam chodzi. Dodałem specjalnie 2, żeby wbić do głowy te dwa bajty kończące string unicodowy.

Problemy

Minusem rozwiązania ze string builderem jest ilość alokacji na stercie. To jest stosunkowo wolna operacja, więc jeśli masz duże stringi i robisz to często, to raczej zauważysz problemy z wydajnością:

  • tworzenie StringBuildera alokuje pamięć na stercie zarządzanej
  • wywołanie funkcji:
    • alokuje bufor na stercie niezarządzanej
    • kopiuje zawartość StringBuildera do sterty niezarządzanej
    • tworzy zarządzaną tablicę i tam kopiuje zawartość natywnego stringa
  • StringBuffer.ToString() tworzy kolejną zarządzaną tablicę.

Także podczas tej operacji mamy 4 alokacje, których można by uniknąć. Dlatego tak mocno powielany sposób ze StringBuilderem uważam za zły. Są inne – lepsze.

Użycie tablicy

Zamiast StringBuildera, możesz użyć tablicy znaków – pamiętając, że string to nic innego niż tablica znaków. Nie ma tutaj za wiele do omówienie, po prostu pokażę kod:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, EntryPoint = "getInfo3", CharSet = CharSet.Unicode)]
private static extern int getInfoWithArray([Out] char[] data, int length);

//

public string GetInfoWithArray()
{
    int len = getInfoWithArray(null, 0);
    char[] buffer = ArrayPool<char>.Shared.Rent(len + 1);
    getInfoWithArray(buffer, buffer.Length);

    return new string(buffer);
}

Tutaj nie ma już tylu alokacji co przy StringBuilderze, ten kod jest zdecydowanie lepszej jakości.

Użycie BSTR

Już pisałem wcześniej o użyciu BSTR. Mając wiedzę z całego artykułu, możesz na spokojnie wywnioskować taki kod:

DLL_EXPORT void __stdcall getInfo4(BSTR& data)
{
	data = SysAllocString(L"Dzień dobry, jak się masz?");
}

A po stronie C#:

[DllImport("CppDll.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode, EntryPoint = "getInfo4")]
private static extern void getInfo([MarshalAs(UnmanagedType.BStr)] ref string data);

//

public string GetInfoFromBstr()
{
    string result = "";
    getInfo(ref result);

    return result;
}

To jest najprostszy i najlepszy sposób pobierania stringa z parametru. Oczywiście nie zawsze biblioteka natywna będzie do tego przygotowana, ale jeśli możesz iść w tym kierunku, to idź. W taki sam sposób możesz przekazać stringa.


Ufff, to na tyle jeśli chodzi o pierwszą część artykułu o połączeniu C++ z C#. W drugiej części zajmiemy się tablicami.

Jeśli znalazłeś w artykule błąd lub czegoś nie zrozumiałeś, koniecznie daj znać w komentarzu. Dzięki za przeczytanie, koniecznie dodaj go do ulubionych, bo kiedyś może Ci się przydać. I do następnego! 🙂

Podziel się artykułem na:
Idziemy w świat! Czyli aplikacje wielojęzykowe

Idziemy w świat! Czyli aplikacje wielojęzykowe

Wstęp

Aplikacje wielojęzykowe są mimo wszystko dość łatwe w pisaniu, jednak trzeba pamiętać o kilku rzeczach, które koniecznie musimy stosować w tego typu programach. Inaczej bardzo łatwo o bum. Dlatego też uważam, że są łatwe, jednak upierdliwe 🙂

Zagadnienie jest dość rozrośnięte, więc podzieliłem artykuł na kilka części.

W tym cyklu pokażę Ci, czym jest globalizacja, czym się różni od lokalizacji i jak to wszystko ogarnąć w różnych typach aplikacji (Xamarin, WPF, WebApp). Także bierz kawkę lub herbatę i zaczynamy.

Czym jest globalizacja?

Różne kraje mają różne formaty dat, liczb itd. Przykładowo w USA domyślnym formatem daty jest miesiąc/dzień/rok (MM/dd/yyyy). Jest to unikalne, że miesiąc występuje na pierwszym miejscu. W Polsce domyślny format to dzień-miesiąc-rok (dd-MM-yyyy), chociaż czasem stosuje się też odwrotny: rok-miesiąc-dzień (yyyy-mm-dd). Różne kraje też inaczej obsługują liczby. Np. w Polsce (i wielu innych krajach) separatorem dziesiętnym jest przecinek. Ale już np. w Australii, Irlandii, UK, czy USA (i wielu innych) separatorem dziesiętnym jest kropka.

Jakie to ma znaczenie?

Jeśli piszesz aplikację tylko dla swojego kraju, to małe. Twój program jednak może zostać uruchomiony przez dziwnych, złośliwych typów, którzy zrobili sobie inne ustawienia w systemie. Wtedy jest problem. Jeśli z aplikacji mogą korzystać osoby z innych państw, to musisz zatroszczyć się o globalizację.

Tzn. jeśli np. zapiszesz datę w bazie danych w formacie dd-mm-yyyy, a program później zostanie uruchomiony w innym kraju (albo przez złośliwego użytkownika, który zdefiniował sobie inny format), no to już będzie problem. W najgorszym przypadku program zadziała – pokazując złą datę. W najlepszym – po prostu wybuchnie, urywając użytkownikowi rączki i nóżki. Dlatego też powinieneś zatroszczyć się o to, żeby program pokazywał dobre dane przy różnych ustawieniach.

Tworzenie aplikacji wielojęzycznej

Dzisiaj dużo aplikacji jest tworzone od razu z myślą o innych krajach. I Ty także powinieneś tak je tworzyć (chyba, że robisz wewnętrzne narzędzia dla siebie/swojego zespołu, który mieści się tylko w Polsce). Tworzenie aplikacji wielojęzycznej składa się z dwóch procesów: globalizacji i lokalizacji. I ten cały proces nazywa się internacjonalizacją… Ufff.

Jak już wspomniałem, globalizacja polega na takim zaprojektowaniu aplikacji, żeby działała ona z różnymi kulturami (różne ustawienia dat, liczb itd). Lokalizacja natomiast polega na takim zaprojektowaniu aplikacji, żeby teksty dało się tłumaczyć. Czyli nie wpisujesz ich na sztywno, tylko pobierasz je skądś… No właśnie. To kolejny element lokalizacji. Musisz pomyśleć skąd masz pobierać teksty i w jaki sposób ustawiać aktualny język.

Globalizacja

Stringi

  1. Zawsze używaj stringów UNICODE. Na szczęście .NET domyślnie używa UNICODE (UTF-16) dla wszystkich stringów. Jednak można używać też innych stron kodowych. W specyficznych przypadkach ma to sens. Ale nie kombinuj z tym bezmyślnie. Staraj się używać wszędzie UNICODE.
  2. Traktuj string jako całość, a nie jako zbiór znaków. Jest to istotne jeśli chodzi o sortowanie lub porównywanie. W różnych krajach, różne znaki mogą być sortowane inaczej. Co więcej w niektórych sytuacjach jeden znak może być utworzony z kilku obiektów typu char. Dlatego też używaj przeciążenia String.IndexOf(String), zamiast String.IndexOf(char). Ponadto, to przeciążenie ze stringiem bierze pod uwagę kulturę.
  3. Nie porównuj stringów w standardowy sposób: str1 == str2 ani str1.Equals(str2). Te metody nie biorą pod uwagę kultury.
  4. Zawsze porównuj stringi używając metody String.Compare. ZAWSZE. Ta metoda ma dodatkowy parametr StringComparison, który może mieć takie wartości:
    • CurrentCulture – porównaj stringi używając aktualnej kultury i zasad sortowania
    • CurrentCultureIgnoreCase – tak jak wyżej, tylko nie bierz pod uwagę wielkości znaków
    • InvariantCulture – porównaj stringi używając niezmiennej kultury (invariant culture). Ta kultura jest związana z językiem angielskim.
    • InvariantCultureIgnoreCase – jak wyżej, ale nie bierz pod uwagę wielkości znaków
    • Ordinal – porównaj stringi binarnie
    • OrdinalIgnoreCase – jak wyżej, ale nie bierz pod uwagę wielkości znaków.

Najczęściej będziesz używał CurrentCulture(IgnoreCase) lub InvariantCulture(IgnoreCase) chyba, że ważne dla Ciebie są wielkości znaków. InvariantCulture używasz raczej wtedy, kiedy masz pewność, że dane mieszczą się w standardowym zestawie znaków (alfabet łaciński).

UWAGA! Porównanie stringów z CurrentCulture jest wolne. Jeśli widzisz u siebie problem z wydajnością w tym miejscu, zastanów się, czy możesz to zrobić inaczej

Generalnie podczas działań na stringach zawsze powinieneś szukać metod, które przyjmują w parametrze kulturę.

Daty

Często tutaj jest problem. Daty powinny być wyświetlane zgodnie z aktualną kulturą. Format daty w danej kulturze możesz wyciągnąć z CultureInfo.CurrentCulture.DateTimeFormat


Zróbmy małą symulację. Załóżmy, że ktoś z USA wpisał do bazy danych datę, a potem ktoś z Polski to odczytuje:

string dateStr = "02/01/2020"; //Polak odczytał z bazy danych
DateTimeOffset dt = DateTimeOffset.Parse(dateStr);

Amerykanin wprowadził datę po swojemu – 1 luty 2020. W efekcie Polak odczyta 2 stycznia 2020 -> w tych kulturach miesiąc i dzień zamieniają się miejscami. No i mamy najgorszy możliwy przypadek. Program się nie wywalił, ale zadziałał pokazując złą datę. Dlaczego tak się stało? Bo data została zapisana z użyciem kultury en-US, a odczytana z użyciem pl-PL. Jest to częsty błąd, ale łatwy do uniknięcia. Jak to powinno być zrobione poprawnie?

Poprawnie to Amerykanin powinien zapisać datę z InvariantCulture, a Polak ją tak odczytać. Spójrz na ten kod:

//Zapis w USA
DateTimeOffset dt = new DateTimeOffset(2020, 2, 1, 0, 0, 0, 0, TimeSpan.Zero);
string valueToDatabase = dt.ToString(CultureInfo.InvariantCulture);
//i zapis do bazy valueToDatabase

//Odczyt w Polsce
string valueFromDatabase = GetDateFromDbAsString();
DateTimeOffset plDt = DateTimeOffset.Parse(valueFromDatabase, CultureInfo.InvariantCulture);
string s = plDt.ToString();

Console.WriteLine(s);

Przeanalizujmy to:

  1. Najpierw Amerykanin utworzył datę.
  2. Zmienił ją na string, używając niezmiennej kultury („02/01/2020”)
  3. Zapisał do bazy danych
  4. Polak odczytał z bazy danych („02/01/2020”)
  5. Sparsował tego stringa na datę, używając niezmiennej kultury – tu otrzymujemy już poprawną datę, a nie jak w poprzednim przypadku
  6. Polak zamienił datę na stringa, używając aktualnej kultury, w efekcie zobaczył: „01.02.2020”

Zauważ, że DateTime.ToString() i DateTimeOffset.ToString() zmieniają datę na stringa, używając aktualnej kultury. I teraz jeśli Amerykanin wprowadzi w swoim formacie datę 5/20/2020 (20 maj), a Polak spróbuje to odczytać bez użycia InvariantCulture, to program się wywali, ponieważ DateTime.ToString() będzie próbowało odczytać tę datę jako piąty dzień dwudziestego miesiąca.

Jest jeszcze jeden problem. W grę wchodzi baza danych. Więc jeśli tam są robione jakieś operacje na datach, trzeba to też wziąć pod uwagę. Rozwiązania są dwa:

  • zawsze używamy niezmiennej kultury
  • dogadujemy się co do własnego formatu zapisu daty, np: „dd-MM-YYYY HH::ss”

Sam sobie odpowiedz, które rozwiązanie jest lepsze 🙂

Generalnie daty można zapisywać poprawnie na 3 sposoby:

  • binarnie, zamiast stringa (co może być uciążliwe)
  • używając Unix Timestamp – czyli ilość sekund, które upłynęło od 1 stycznia 1970 (co też może być uciążliwe)
  • jako string – używając niezmiennej kultury (lub ustalonego formatu, który NIGDY się nie zmieni).

Każdy z tych sposobów ma swoje plusy i minusy. Np. jeśli w bazie danych zapiszesz daty binarnie lub jako integer (Unix Timestamp), bazodanowe operacje będą szybsze. Ale dla kogoś, kto przegląda rekordy lub próbuje coś debugować, jest to mordęga. Zawsze jednak można sobie stworzyć widok, który doda kolumnę z datą przekonwertowaną na ludzki format.


UWAGA! Jeśli w bazie danych trzymasz czas jako Unix Timestamp w polu 32 bitowym, miej świadomość, że w 2038 roku będzie problem, bo wtedy zakres dat zostanie przekroczony!

To jednak nie wszystko, jeśli chodzi o datę. W grę wchodzą jeszcze strefy czasowe.

Strefy czasowe

W C# mamy dwie klasy, które ułatwiają posługiwanie się strefami czasowymi. TimeZone i TimeZoneInfo. Jednak jeśli chodzi o TimeZone, to klasa została uznana za przestarzałą już w .NetCore, zatem skupimy się tylko na TimeZoneInfo.

W skrócie, TimeZoneInfo pozwala na konwertowanie czasu pomiędzy dowolnymi strefami czasowymi. Ma kilka metod statycznych, które służą do utworzenia obiektu. Nie posługujemy się tutaj konstruktorem.

Informacje o strefie czasowej można w bardzo prosty sposób zapisać, np:

TimeZoneInfo tz = TimeZoneInfo.Local; //pobranie aktualnej strefy czasowej
string serialized = tz.ToSerializedString(); //serializacja do string


TimeZoneInfo restoredTimeZone = TimeZoneInfo.FromSerializedString(serialized);

Najpierw pobieramy aktualną strefę czasową, potem zapisujemy ją w specjalnym stringu za pomocą ToSerializedString(). Takiego stringa teraz możemy zapisać w bazie danych, a potem utworzyć strefę czasową z niego, używając metody FromSerializedString().

Jako twórca aplikacji, nie powinieneś zakładać, że wszystkie czasy są wyrażone w aktualnej strefie czasowej. Np. jeśli ktoś z USA doda post na forum o godzinie 13:00 swojego czasu, Ty mógłbyś w Polsce zobaczyć, że post został dodany o godzinie 13:00 Twojego, polskiego czasu, co oczywiście nie jest prawdą. To jest prosty przykład, ale są systemy, które mocno polegają na czasie i w takich przypadkach mogą nie działać lub być podatne na jakieś ciekawe ataki.

Na szczęście istnieje coś takiego jak UTC (Coordinated Universal Time) – uniwersalny czas koordynowany (Zulu), czyli po prostu niezmienny czas w strefie z południkiem 0. Wszystkie inne strefy czasowe są wyrażone w godzinach dodatnich lub ujemnych od czasu UTC. Zatem, jeśli do wyrażenia dat posługujesz się typem DateTime, zdecydowanie powinieneś dodać do tego TimeZoneInfo.

Teraz spójrz na taki ZŁY kod:

//zapis w USA
DateTime dt = new DateTime(2021, 6, 1, 15, 0, 0);
string usaDt = dt.ToString(CultureInfo.InvariantCulture);
   
//odczyt w Polsce
DateTime dtInPoland = DateTime.Parse(usaDt, CultureInfo.InvariantCulture);

Ostatecznie wychodzi, że ktoś w USA dodał jakiś zasób o godzinie 15:00 czasu Polskiego. To bzdura. Tylko z tego powodu, że pominęliśmy strefy czasowe. Jak taki kod naprawić? Na przykład przed zapisem zmień go na UTC:

//zapis w USA - symulacja wprowadzenia daty w USA
DateTime dt = new DateTime(2021, 6, 1, 23, 0, 0);
TimeZoneInfo pacificZone = TimeZoneInfo.FindSystemTimeZoneById("Pacific Standard Time");

//konwersja daty na UTC
dt = TimeZoneInfo.ConvertTime(dt, pacificZone);
DateTime utcTime = TimeZoneInfo.ConvertTimeToUtc(dt, pacificZone);
   
//zapis do bazy
string usaDt = utcTime.ToString(CultureInfo.InvariantCulture);
   
//odczyt w Polsce
DateTime utc = DateTime.Parse(usaDt, CultureInfo.InvariantCulture);
TimeZoneInfo polishTimeZone = TimeZoneInfo.Local;
DateTime polishTime = TimeZoneInfo.ConvertTimeFromUtc(utc, polishTimeZone);
string plDt = polishTime.ToString();

Niech on Cię nie przeraża. Pierwsze cztery linijki to tak naprawdę SYMULACJA zapisu daty w USA. Musiałem zrobić tę symulację, bo na swoim komputerze mam ustawiony polski czas.

A teraz przeanalizujmy go:

  • Ktoś w USA zapisuje dane 1 czerwca o godzinie 15 (czasu USA)
  • Następnie ten czas jest zmieniany na czas UTC – czas uniwersalny
  • Czas UTC jest zapisywany w bazie w formacie InvariantCulture (o tym mówiliśmy w wyżej)
  • Ktoś z Polski odczytuje datę z bazy. Pamiętaj, że data jest zapisana w czasie uniwersalnym.
  • Pobieramy teraz lokalną strefę czasową ustawioną na komputerze (dla ludzi z Polski powinna to być aktualna polska strefa czasowa)
  • Za pomocą TimeZoneInfo.ConvertTimeFromUtc konwertujemy czas UTC na nasz polski czas
  • Na koniec możemy go wyświetlić.

Okazuje się, że wpis został dokonany 1 czerwca o godzinie 23:00 czasu polskiego. I teraz wszystko śmiga. Oczywiście w swoim programie pewnie wystarczy, że zamiast FindSystemTimeZoneById użyjesz po prostu TimeZoneInfo.Local, ponieważ to zwróci Ci lokalną strefę czasową klienta. Jeśli klient będzie w USA, zwróci to odpowiednią strefę z USA. Jeśli będzie z Polski, zwróci to aktualną strefę w Polsce.

UWAGA! NIGDY nie używaj TimeZoneInfo.Local na serwerze, ponieważ zwróci to strefę czasową SERWERA, a nie klienta!

Oczywiście można też zrobić coś innego. Możesz w bazie danych trzymać datę lokalną (zamiast UTC), ale to wymaga, żeby w innym polu były zawarte informacje o strefie czasowej. To z kolei wymaga odpowiedniego mechanizmu odczytu takich danych. I prędzej, czy później ktoś tu coś może spieprzyć.

DateTimeOffset

Staraj się unikać typu DateTime na rzecz DateTimeOffset. Ten drugi jest w pewnym sensie kolejną wersją DateTime. Dodatkowo zawiera informacje o przesunięciu czasowym. Ale nie zawiera pełnych informacji o strefie czasowej. Więc przykładowo porównywanie dat z użyciem DateTime ma sens tylko wtedy, kiedy obie daty pochodzą z tej samej strefy czasowej. DateTimeOffset nie ma już tego problemu. Poza tym, DateTime ma jeszcze kilka takich „kruczków”, m.in., jeśli dodajesz lub odejmujesz jakiś czas do DateTime, najpierw musisz przekonwertować to na UTC:

DateTime dt = DateTime.Parse("Oct 26, 2003 12:00:00 AM");
dt = d.ToUniversalTime().AddHours(3).ToLocalTime();

jeśli tego nie zrobisz, to w specyficznych przypadkach (dni zmiany strefy czasowej w kraju) otrzymasz złe dane.

Zatem używaj DateTimeOffset*

*Niestety niektóre kontrolki third party nie umieją w DateTimeOffset. Trzymają się sztywno DateTime, jak tonący brzytwy. Postaraj się wtedy o odpowiednie konwersje, chyba że faktycznie nie są potrzebne.

Liczby

Tutaj też istnieje różnica pomiędzy wyświetlaniem numerów, a ich składowaniem. Przede wszystkim musisz zapamiętać, że metoda ToString konwertuje liczby do stringa zgodnie z aktualną kulturą. Więc liczbę „jeden i pół” amerykanin zobaczy tak: „1.5”, a Polak tak: „1,5” (zakładając standardowe ustawienia).

I niby fajnie, ale… NIGDY nie przechowuj liczb w takiej postaci (stringa). Grozi to wybuchem i urwaniem rączek. Jeśli robisz czyste zapytania do bazy danych (mam nadzieję, że tego nie robisz), to pamiętaj też, że taką liczbę powinieneś przekonwertować do InvariantCulture. Spójrz na prostą tabelę z dwiema kolumnami:

Tabela(ID: BIGINT, number: float)

I teraz jeśli użytkownik wpisze daną w postaci „1,5”, to jak będzie wyglądać Twoje czyste zapytanie do bazy?
INSERT INTO Tabela(number) VALUES(1,5);

SQL pomyśli, że chcesz wprowadzić dane dla dwóch kolumn (jedna o wartości 1, druga o wartości 5). Dlatego też konwersja do InvariantCulture lub zdecydowanie lepiej – używanie parametrów SQL (ale to historia na inny artykuł).

Ten sam błąd możesz uzyskać, zapisując dane w postaci tekstowej np. w XML. Jeśli zapiszesz w taki sposób:

<Data value="1,5" />

ten przykładowy Amerykanin też tak to odczyta. I później nastąpi konwersja ze stringa na double. I BUM! W USA separatorem dziesiętnym jest KROPKA, a nie przecinek. Dlatego też w takich sytuacjach powinieneś konwertować dane do InvariantCulture.

Przy okazji, skoro tu jesteśmy – jeśli zapisujesz jakieś sumy pieniędzy (czy to w bazie, czy w XML, czy jeszcze gdzieś indziej), nie zapisuj tego jako double. Zawsze zapisuj to jako liczbę całkowitą, czyli np. ilość groszy/centów, a nie złotówek/dolarów. Zamiast zapisać 1,23 (złoty dwadzieścia trzy), po prostu pozbądź się tej części ułamkowej, mnożąc przez 100. Zostanie zapisana liczba 123. Dane finansowe są niezwykle wrażliwe i każde nieodpowiednie (nieprzewidziane) zaokrąglenie może duuużo kosztować. Dlatego przejmij w tej kwestii całkowicie kontrolę i podawaj ilość groszy/centów itd.

CurrentCulture vs CurrentUICulture

Żeby dopełnić artykułu, muszę o tym wspomnieć. Microsoft wyprodukował nam dwie podobne właściwości: CurrentCulture i CurrentUICulture. Powinnśsmy dobrze poznać różnice między nimi, żeby nie było przykrych niespodzianek i niepotrzebnych nerwów.

  • CurrentCulture – używaj do formatowania dat, liczb itd.
  • CurrentUICulture – używaj do pobierania zasobów.

Czyli: Culture – wszelkie formatowanie; UICulture – zasoby.

To tyle w kwestii globalizacji. W kolejnym artykule z tej serii zajmiemy się lokalizacją, czyli skąd i jak wyświetlać stringi w różnych technologiach.

Jeśli masz jakieś problemy lub znalazłeś błąd w artykule, podziel się tym w komentarzu.

Podziel się artykułem na: