Dnešní článek bude pro Vbnet trochu netypický – zatímco většina článků tu se zabývá čistě managed světem (nebo algoritmy) a slovíčka jako pointer či unsafe blok jsou tu vzácná, tento článek bude pointerů plný a něco jako ne-unsafe kód ani v C++/CLI nenajdete. Tento článek je určen pro programátory, kteří znají (aspoň trochu) jak managed (C#, VB.NET) tak i unmanaged (C++ aspoň syntakticky a nativní programování obecně) programování a rádi by tyto dva “světy” nějakým programmer-friendly způsobem spojili.
A co to vlastně to C++/CLI (C++/Common Language Infrastructure) je? Je to Microsoftí rozšíření (jinak čistě unmanaged) jazyka C++, které do něj přidává jazykové konstrukce a další věci nutné pro programování pro .Net framework, přičemž z něj nic neubírá (pokud nezvolíte pure MSIL, více dále). Takže už obsahuje knihovny třech jazyků – C, C++ a .NET, což samozřejmě na přehlednosti nepřidává, nicméně dává programátorovi do ruky velmi mocnou zbraň – kombinovat a přímo používat managed a unmanaged kód v jednom projektu. Což jiný .NET jazyk od MS neumí (mimo MS jazyky to tuším uměl/umí ObjectPascal v novějších Delphi). Zkompilováním C++/CLI projektu vzniká tzv. mixed assembly, která obsahuje jak část v MSILu (včetně např. managed zapouzdření některých C++ tříd) tak část přímo ve strojovém (nativním) kódu. Managed třídy pak můžete používat (při dodržení určitých pravidel) i z jiných jazyků platformy .NET. Na závěr popisu ještě trošku historie - C++/CLI je obsažen až ve Visual Studiu 2005, 2003ka a nižší obsahovali tzv. Managed Extensions for C++, kterým chyběli některé featury a hlavně jejich syntaxe byla ještě chaotičtější než C++/CLI. MC++ je nyní deprecated a i když ho VS pořád podporuje, už se nedoporučuje v něm cokoli programovat.
Takže už víme, co to je, teď je třeba uvést, k čemu je to dobré. neboť, jak praví M. Valášek, neexistují špatné technologie, jsou jen technologie dobře použité a špatně použité.
- Použití čistě unmanaged knihoven či funkcí je důvod, který mě “nutí” používat C++/CLI nejčastěji. .Netu chybí podpora pro pohodlnou práci s HW na trochu nižší úrovní, případně se sice k HW dodávají knihovny pro komunikaci, nicméně pouze Cčko. Samozřejmě tento problém se týká všech unmanaged-only knihovnen, ke kterým neexistuje vyhovující managed alternativa, nicméně se stále se zvětšujícím rozšířením .NETu se tyto knihovny moc nevyskytují (nebo člověk má příliš exotické požadavky či úkoly;) ).
- Zrychlení výpočetního jádra se také občas hodí. I v .netu sice lze psát rychlé aplikace, ale často je to na úkor přehlednosti či “best-practices” managed platformy, která na to navíc není stavěná a člověk pak nese overhead GC a dalších managed featur, i když je zrovna při těch časově či paměťově náročných výpočtech vůbec nepotřebuje. Navíc C++ kompilátor generuje optimalizovanější kód než .netí JIT, což se projeví hlavně při nějakém složitějším kódu. Občas sice vidím ukázky toho, že .NET je stejně rychlý jako nativní kód psaný např. v C ale vždy na nějakém triviálním kódu, což je přinejmenším zavádějící.
- A nebo naopak – mám nativní aplikaci v C++ a potřebuju k ní napsat nějaké pěkné GUI a nechce se mi zlobit se s nějakou více či méně dokonalou knihovnou na GUI v C++.
- Připadně kdykoli jindy, když je potřeba nějak používat .NET z nativního kódu či naopak.
Samozřejmě v C++/CLI se dají psát i čistě managed aplikace, ale osobně to z důvodu vyšší komplikovanosti jazyka nedoporučuju, je lepší použít nějaký normální .NET jazyk.
Nicméně C++/CLi má kromě subjektivně složitější syntaxi i další nedostatky – zásadnim problémem .NET/native interoperability je nízká výkonnost. To lze pozorovat už i v P/invocích v “čistých” .NET jazycích – každý přechod mezi nativním a managed kódem s sebou nese jistou režii a v případě neopatrného používání může tento přechod nastávat často, což má pak vliv na výkonnost. Toto lze do jisté míry eliminovat striktím oddělováním managed a unmanaged části kódu a direktivami #pragma managed a #pragma unmanaged, nicméně je třeba přemýšlet. V neposlední řadě také C++/CLI způsobuje dost značnou obfuskaci výsledného kódu (což by někdo mohl vidět i jako výhodu …), jeden příklad uvedu později.
Kooperaci či interoperabilitu managed a unmanaged lze samozřejmě zajistit i jinými prostředky. Nejjednodušším příkladem jsou P/Invoky, kde ale nemůžu sdílet celé objekty, musím spravovat navíc ještě extern deklarace v managed jazyku a hlavně tím nezavolám managed kód z nativního programu. Lepší variantou jsou pak COM+ objekty, které jsou ale složitější, musí se registrovat a je s nimi obecně více práce. Na druhou stranu jsou obecnější.
Náš první C++/CLI projekt
Náš první projekt vytvoříme buď přímo přes klikátko New projekt v záložce Visual C++\CLR nebo ho uděláme z klasického C++ projektu nastavením Common Language Runtime support (co znamená Pure MSIL nebo safe MSIL je popsáno celkem pěkně na http://msdn.microsoft.com/cs-cz/library/85344whh%28en-us%29.aspx)
Pro tento článek budeme zatím uvažovat konzolovou aplikaci vytvořenou přes klikátko (CLR Console application).
Základní syntaxe
Nově vytvořený projekt obsahuje jen main funkci, import System namespacu a výpis “Hello worldu” na konzoli. C++kaře na první pohled upoutají nejspíše ony divné operátory ^ vypadající jak z Pascalu, mě jako C#áře zarazily hlavně :: a to, že tam není klasická statická třída Program, ale jen funkce main.
Nejdřív by asi bylo dobré vysvětlit základní strukturu projektu. I přes .NETí příchuť si projekt zachovává vzezření klasického Visual C++ projektu – máme klasické dělení na hlavičkové a .cpp soubory, máme stdafx.h, kam se píší standardní a knihovní hlavičky, které si kompilátor umí předkompilovat a nemusel je kompilovat při každém #include “stdafx.h”, čímž se kompilace výrazně urychlí. Preprocesor se chová stejně jako v C++, jen navíc přidává #using pro referencování jiných assembly. Ty je možné referencovat dvojím způsobem, buď v nastavení projektu v Common Properties nebo pomocí #usingů, které se používají #using <NázevAssembly> a píší se přímo do cpp kódu.
Základní typy
Základní typy jako int, double či char jsou přímo mapovány na jejich .NET ekvivalenty (koneckonců int či double vypadá v paměti stejně jak v .netu tak v C++). To se týká jak generických typů (tj. můžu psát System::Collections::Generic::List<int> stejně jako std::vector<int>), tak i statických metod a vlastností, které tyto typy v .NETu mají (tj můžu psát int::Parse()). Přehled mapování je v následující tabulce:
Visual C++ typ | .NET Framework typ |
bool | System.Boolean |
signed char | System.SByte |
unsigned char | System.Byte |
wchar_t | System.Char |
double, long double | System.Double |
float | System.Single |
int, signed int, long, signed long | System.Int32 |
unsigned int, unsigned long | System.UInt32 |
__int64, signed __int64 | System.Int64 |
unsigned __int64 | System.UInt64 |
short, signed short | System.Int16 |
unsigned short | System.UInt16 |
void | System.Void |
S řetězci je to již trošku složitější – v paměti sice vypadají podobně, ale rozhodně ne stejně. Navíc System::String je referenční typ a tak sedí v managed haldě. Tudíž nemohu jen tak přiřadit přímo System::String do char * či opačně. Ale mám několik možností, jak toho dosáhnout nepřímo:
Podmínky a cykly
If podmínky, Switch větvení, For, While a do .. while cykly jsou uplně stejné jako C++, novinkou je for each cyklus, což je ekvivalent C# foreach či Vb.Net ForEach cyklů. Umí procházet IEnumerable kolekce, unmanaged pole a dokonce i STL kontejnery. Má tvar for each(typ iterační_proměnná in kolekce). Oproti C# či VbNet foreachi ale umí jednu užitečnou věc – nemusíme iterovat hodnotou ale můžeme i referencí, takže můžeme přímo ve foreachi měnit prvky kolekce nejen jejich obsah. V příkladu to používám pro vytvoření jagged pole (pole polí). Reference se zapisuje pomocí operátoru % a ještě o ní bude reč dále.
int main(array<System::String ^> ^args)
{
std::vector<int> vect; //stvoříme std::vector
vect.push_back(4); vect.push_back(6); // naplníme ho
for each(int i in vect) // a vypíšeme
Console::WriteLine(i); // ....
// vyvoření jagged array pomocí foreache
array<array<int> ^> ^jagged = gcnew array<array<int> ^>(5); //vytvoření v vnějšího pole
for each (array<int> ^% arr in jagged) // enumerace vytvoření vnitřního pole
arr = gcnew array<int>(6);
}
Pokud by vás zajímalo, jak je to interně řešené, tak vězte, že nikterak pěkně. Stačí si vzít Reflector a podívat se. Následující kód je v skoro-C# i když to tak na první pohled nevypadá. Konstrukci try … fault běžně neuvidíte, slouží pro obsluhu a práci se Structured Exception Handling, což je mechanismus Windows jak řešit chyby jako je špatný přístup do paměti. Je také pěkně vidět, jak mají Microsofti definice STL tříd i v .NETu.
internal static unsafe int main(string[] args)
{
int[][] $S4 = null;
int[][] jagged = null;
vector<int,std::allocator<int> > vect;
std.vector<int,std::allocator<int> >.{ctor}(&vect);
try
{
_Vector_const_iterator<int,std::allocator<int> > $S2;
_Vector_iterator<int,std::allocator<int> > local2;
int modopt(IsConst) num2 = 4;
std.vector<int,std::allocator<int> >.push_back(&vect, &num2);
int modopt(IsConst) num = 6;
std.vector<int,std::allocator<int> >.push_back(&vect, &num);
vector<int,std::allocator<int> >* modopt(IsImplicitlyDereferenced) $S1 = &vect;
_Vector_iterator<int,std::allocator<int> >* localPtr2 = std.vector<int,std::allocator<int> >.end($S1, &local2);
try
{
std._Vector_const_iterator<int,std::allocator<int> >.{ctor}(&$S2, (_Vector_const_iterator<int,std::allocator<int> > modopt(IsConst)* modopt(IsImplicitlyDereferenced)) localPtr2);
}
fault
{
___CxxCallUnwindDtor(std._Vector_iterator<int,std::allocator<int> >.{dtor}, (void*) &local2);
}
try
{
_Vector_const_iterator<int,std::allocator<int> > $S3;
_Vector_iterator<int,std::allocator<int> > local;
std._Vector_iterator<int,std::allocator<int> >.{dtor}(&local2);
_Vector_iterator<int,std::allocator<int> >* localPtr = std.vector<int,std::allocator<int> >.begin($S1, &local);
try
{
std._Vector_const_iterator<int,std::allocator<int> >.{ctor}(&$S3, (_Vector_const_iterator<int,std::allocator<int> > modopt(IsConst)* modopt(IsImplicitlyDereferenced)) localPtr);
}
fault
{
___CxxCallUnwindDtor(std._Vector_iterator<int,std::allocator<int> >.{dtor}, (void*) &local);
}
try
{
std._Vector_iterator<int,std::allocator<int> >.{dtor}(&local);
goto Label_0089;
Label_0081:
std._Vector_const_iterator<int,std::allocator<int> >.++(&$S3);
Label_0089:
if (std._Vector_const_iterator<int,std::allocator<int> >.!=((_Vector_const_iterator<int,std::allocator<int> > modopt(IsConst)* modopt(IsConst) modopt(IsConst)) &$S3, (_Vector_const_iterator<int,std::allocator<int> > modopt(IsConst)* modopt(IsImplicitlyDereferenced)) &$S2))
{
int i = *(std._Vector_const_iterator<int,std::allocator<int> >.*((_Vector_const_iterator<int,std::allocator<int> > modopt(IsConst)* modopt(IsConst) modopt(IsConst)) &$S3));
Console.WriteLine(i);
goto Label_0081;
}
}
fault
{
___CxxCallUnwindDtor(std._Vector_const_iterator<int,std::allocator<int> >.{dtor}, (void*) &$S3);
}
std._Vector_const_iterator<int,std::allocator<int> >.{dtor}(&$S3);
}
fault
{
___CxxCallUnwindDtor(std._Vector_const_iterator<int,std::allocator<int> >.{dtor}, (void*) &$S2);
}
std._Vector_const_iterator<int,std::allocator<int> >.{dtor}(&$S2);
jagged = new int[5][];
$S4 = jagged;
int $I = 0;
goto Label_00E8;
Label_00E4:
$I++;
Label_00E8:
if ($I < $S4.Length)
{
ref int[] arr = &($S4[$I]);
arr = new int[6];
goto Label_00E4;
}
}
fault
{
___CxxCallUnwindDtor(std.vector<int,std::allocator<int> >.{dtor}, (void*) &vect);
}
std.vector<int,std::allocator<int> >.{dtor}(&vect);
return 0;
}
Namespaces
.Net namespacy se používají úplně stejně jako klasickém C++, tj. import namespace, abychom je nemuseli vždy vypisovat (using konstrukce v C#) probíhá pomocí using namespace <Namespace>, umí to samozřejmě i vnořené namespacy, using namespace System::Drawing, operátor :: (scope resolution operátor, pro přístup ke statickým prvkům tříd k a explicitnímu vypisování namespacu k nějakému typu/funkci, PHPkářům jistě známý jako T_PAAMAYIM_NEKUDOTAYIM) je jistě všem C++ programátorům důvěrně známý. Pokud chceme, aby náš kód nebyl v defaultním namespacu, musíme uzavřít kód do konstrukce namespace Neco { … } (stejně jako v C++ a C#). Otravná nevýhoda tohoto konstruktu v C++ je, že neumí vnořený namespace napsat najednou, pokud chci tedy psát kód v Cermi::Aplikace namespace musím udělat:
namespace Cermi
{
namespace Aplikace
{
// muj kod
}
}
Pole
Managed a unmanaged pole jsou (stejně jako retězce) 2 rozdílné věci a proto máme v C++/CLI obojí. Unmanaged pole se deklarují a používají stejně jako v C++
int upole[20]; //pole na stacku
int *upole2 = new int[20]; // pole na haldě
Console::WriteLine(upole[5]); // vypíše náhodnou hodnotu, pole není inicializované
int *ptr = upole + 4; //ukazatel na 5. prvek
ptr++; //ptr je ted ukazatel na 6. prvek
Managed pole se vytváří pomocí klíčového slova array, které vypadá jako template třída array s 2 template parametry – první je typ pole, druhý je číslo značící počet dimenzí. Typ pole může být handle na refereční typ (type ^) či hodnotový managed typ nebo pointer (type *). Nesmí to být žádný nejednoduchý nativní typ. Obecná syntaxe je následující:
[qualifiers] array<[qualifiers]type1[, dimension]>^var = gcnew array<type2[, dimension]>(val[,val...])
, kde qualifiers jsou kvalifikátory jako const či static, type1 je formální typ pole, dimension je dimenze pole, type2 skutečný typ pole a val1…valn jsou rozměry jednotlivých dimenzí pole. Type1 a type2 jsou většinou stejné, vždy ale platí, že musí existovat konverze z Type2 do Type1. Pole podporují tzv. kovarianci, což znamená že pokud mám pole typu A a pole typu B a existuje konverze z A do B, tak můžu přiřadit pole A do pole B,
array<String ^> ^mpole = gcnew array<String ^>(4); // pole 4 řetězců
mpole[0] = "Ahoj"; // přístup k prvku pole
Console::WriteLine(mpole->Length); //vypiseme delku pole
array<int *,3> ^mpole2 = gcnew array<int *, 3>(2,3,4); //vytvoření 3dimenzinálního pole
mpole2[0,0,0]=NULL; // přístup k prvku pole
Můžu také vytvářet zubatá neboli jagged arrays, což je vlastně pole polí a tak se taky i zapisuje:
array<array<int> ^> ^jagged = gcnew array<array<int> ^>(5); //vytvoření v vnějšího pole
for each (array<int> ^% arr in jagged) //vytvoření vnitřního pole
arr = gcnew array<int>(6);
Třídy a struktury
Jak všichni víme, tak C++ podporuje jeden typ třídy (je jedno jestli je zapsaný jako class nebo struct), který mohu vytvořit buď na stacku nebo na haldě (co je zásobník a halda popsal Tomáš ve svém článku o základech platformy .NET), zatímco .NET rozlišuje referenční typy (class) a hodnotové typy (struct). V první řadě je třeba si uvědomit, že máme jeden stack společný pro managed a unmanaged objekty, ale 2 různé haldy – jednu pro managed a druhou pro unmanaged objekty. Také nesmíme mixovat nativní a managed typy – tj. nemůžu jako member proměnnou v managed typu mít nějakou nativní či naopak (i když tam to jde obejít pomocí gcroot, což popíšu v dalším díle).
Handles
Protože nativní ukazatel na managed objekt nespolupracuje s GC (a ani ho přímo nemůžu vytvořit), tak se zavedly tzv. handles (českým ekvivalentem jsi nejsem jist), což jsou jiné handles než ty, které se používají jako “identifikátory” objektů Windows (jakýkoli objekt OS používaný z userspacu má své handle, pomocí něhož se s ním manipuluje). Vzhledem k tomu, že Win32 handly zde nebudu používat, tak pojem handle bude znamenat vždy handle na managed objekt. Když máme ujasněnou terminologii, tak vysvětlím, co handle znamená. Je to něco, co ukazuje na celý objekt na managed haldě, tady v podstatě klasická reference z C# či VB.NETu:
StringBuilder sb = new StringBuilder();
Slovo “celý” jsem zvýraznil proto, že existují ještě tzv. tracking references, které mohou ukazovat i na nějakou členskou proměnnou managed objektu. Důležité je to, že ukazuje na objekt a ne na kus paměti. Protože GC může, pokud uzná za vhodné, objekty přesouvat po paměti, tak ukazatel na paměť, kde se nachází objekt, je nám v podstatě k ničemu (pokud objekt “nepřipíchneme”, viz další díl), neboť nám nikdo nezaručí, že objekt se na dané adrese bude nacházet i v příští sekundě. Handles ale s GC “spolupracují” a tak se o toto nemusí starat a ukazují na objekt, ať už se nachází na jakékoli adrese.
Handles se deklarují pomocí operátoru ^ (stříška), který se píše před název proměnné. Pokud chceme přistupovat k prvkům objektu, na který máme handle, tak použijeme operátor –> stejně jako když přistupujeme k nativnímu objektu, na který mám ukazatel (nativní ukazatel, v tomto článku budu pojmem ukazatel vždy rozumět “hloupý” nativní ukazatel na nějakou adresu v paměti). Handle můžeme i dereferencovat pomocí operátoru * (hvězdička).
Unmanaged třídy
Unmanaged třídy se chovají a deklarují uplně stejně jako v C++, zde se nic nemění.
Hodnotový managed typ
Hodnotový managed typ se deklaruje pomocí klíčového slůvka value před slovem class nebo struct. Stejně jako v C++ platí, že mezi struct a class je pouze jediný rozdíl a to v tom, že členové bez určení viditelnosti jsou u class private, kdežto u struct public. Vytváří se na stacku, nicméně můžeme pomocí gcref vytvořit i jeho instanci na managed haldě (to ani v C# ani VbNetu nejde, pokud se nepletu).
value class Struktura
{
public:
int A,B;
};
int main(array<System::String ^> ^args)
{
int x = 0x78563412;
Struktura s;
s.B = 5;
return 0;
}
Z výpisu z paměti (červeně je x, zeleně s.A, modře s.B) je také vidět, že member proměnné v managed typech se inicializují na defaultní hodnotu:
Referenční managed typ
Rerefenční managed typy se deklarují pomocí slova ref před slovem class nebo struct. Instance se vytváří pomocí operátoru gcnew, což je new pro managed typy, a vrací handle na nově vytvořenou instanci. Ve starém Managed C++ se gcnew nepoužívalo, bylo všude pouze new, takže nebylo přímo z kódu jasné, jestli se dělá managed nebo unmanaged instance. Referenční typy se vždy vytváří na managed haldě. Je možné při deklaraci instancí použít i syntaxi pro vytváření struktur na stacku – instance se ale stejně interně vytvoří na managed haldě, ale používá se trochu jinak.
ref class Trida
{
public:
int A,B; //deklarace promennych
void VypisA() { std::cout << A << std::endl; } // deklarace funkce
void VypisB(); //druha moznost deklarace fce
};
void zmenA(Trida ^x)
{
x->A = 7;
}
void Trida::VypisB()
{
std::cout << B << std::endl;
}
int main(array<System::String ^> ^args)
{
Trida ^t = gcnew Trida();
t->VypisA(); //vypise 0
t->A = 5;
t->VypisA(); //vypise 5
zmenA(t);
t->VypisA(); //vypise 7
}
Konstruktory a destruktory
Konstruktor se definuje jako C++ – jako metoda bez návratového typu se stejným názvem, jako má třída. Statický konstruktor máme také a definuje se jako statický konstruktor (nevím jak lépe to popsat, snad příkladem)
ref class Trida
{
public:
static int X;
static Trida()
{
X=7;
}
};
Destruktor, který se definuje také stejně jako v C++ (tj. ~NázevTřídy), ale u managed objektů není destruktorem v pravém slova smyslu, neboť managed objekty žádný nemají. Je to totiž do samé, jako kdybyste implementovali metodu Dispose z IDisposable rozhraní. Podle MSDN dokumentace se nedoporučuje implementovat IDisposable přímo, ale unmanaged resources uvolňovat právě pomocí destruktoru. Destruktory se volají deterministicky (narozdil od finalizeru), pokud nastane nějaká z následujících situací:
- Objekt vytvořený pomocí “stack semantics” (tj. tak jak se vytvářejí hodnotové typy na stacku, bez gcnew) se zničí, když aplikace vyskočí z bloku, kde byl definován, ať už normálně nebo během stack unwinding při “probublávání” výjimky
- Při vyhození výjimky v konstruktoru
- Když se destruuje objekt, jehož je member proměnnou (nadeklarovanou přímo, ne jako handle nebo pointer)
- Voláním operátoru delete na handle daného objektu
- Voláním Dispose
- Explicitním voláním desktoru
C++/CLI samozřejmě podporuje i finalizery – deklarují se jako metoda bez návratového typu se jménem
!NázevTřídy. Jsou to stejné finalizery jako je známe z jiných .NET jazyků, tj. provádí se při ničení objektu Garbage Collectorem (pokud to není vypnuté, proto se na finalizery nevyplatí spoléhat).
Pokračování příště …
V dalším díle (a pravděpodobně posledním) dokončíme povídání o třídách a popíšeme eventy, pointery, reference a další důležité prvky C++/CLI.