BETA
Aby się zalogować, najpiew wybierz portal.
Aby się zarejestrować, najpiew wybierz portal.
Podaj słowa kluczowe
Słowa kluczowe muszą mieć co najmniej 3 sąsiadujące znaki alfanumeryczne
Pole zawiera niedozwolone znaki

Baza wiedzy











Wstęp do języka C++/CLI

08-11-2006 14:57 | uzytkownik usuniety
Artykuł ten stanowi wstęp do języka C++/CLI jako alternatywy w programowaniu na platformie .net. Zawiera on m.in. porównanie nowszego standardu Visual C++ z Managed Extensions for C++. Opisuje także zasadę działania samej platformy oraz implementację Common Language Infrastructure w C++/CLI.

Nieco więcej o nowym języku…

C++/CLI jest skrótem. C++ jest oczywiście odniesieniem do języka programowania stworzonego przez Bjarne Stroustrupa w Bell Laboratories. Wspiera on model statyczny – nastawiony na szybkość działania oraz jak najmniejszy rozmiar plików wykonywalnych. Nie nadaje się jednak do dokonywania zaawansowanych zmian w programie w czasie uruchomienia – z wyjątkiem alokacji na stercie. Pozwala na nieograniczony dostęp do maszyny, na której jest uruchomiony, ale ma bardzo mały dostęp do typów w trakcie działania. Krótko: język C++ jest językiem nastawionym na konkretną maszynę.

CLI odnosi się do Common Language Infrastructure, czyli wielowarstwowej architektury wspierającej dynamiczny model programowania. W wielu przypadkach oznacza to totalne złamanie modelu obiektowego znanego z C++. Warstwa programowa, tworząca maszynę wirtualną uruchamia się i pośredniczy pomiędzy aplikacją i systemem. Bezpośredni dostęp do maszyny (systemu) jest znacznie utrudniony. Wspierany jest dzięki temu dostęp do typów aktywnych podczas uruchomienia. Slash (/) pokazuje powiązanie między C++ a CLI. Skoro już rozszyfrowaliśmy skrót to zajmijmy się szczegółami.

Pierwsze z pokrewieństw obu języków to statyczny model obiektowy znany z C++ i dynamiczny wprowadzony w CLI. W skrócie, oznacza to przystosowanie C++ do platformy .NET oraz innych języków które są z nią zgodne, np. C#. Tak jak C# i CLI, także nasz język podlega standardom wydanym przez European Computer Manufacturers Association (ECMA) oraz ISO.

Common Language Runtime (CLR) jest odmianą CLI stworzoną przez Microsoft na potrzeby rynku, specyficzną dla systemów z rodziny Windows. Podobnie Visual C++ 2005 jest implementacją języka C++/CLI.

Drugim pokrewieństwem jest to, że C++/CLI jest związane z modelem programowania znanym z .NET, tak samo jak wcześniej C++ było związane z programowaniem generycznym używającym szablonów mimo, że nie były one wtedy jego częścią.

Są trzy aspekty związane z CLI, które odnoszą się do wszystkich języków: odwzorowanie poziomu składni języka w Common Type System (CTS; we wszystkich językach typy nazywają się tak samo), wybór poziomu szczegółów, których może użyć programista oraz wybór funkcjonalnych dodatków, wspieranych przez CLI.


Managed Extensions – to już historia


Kiedy Microsoft wprowadził Managed Extensions do C++ w VS.net 7, programiści C++ różnie je przyjęli. Większość z nich była szczęśliwa, że nadal można używać C++, jednak niemal każdy był niezadowolony z brzydkiej składni oferowanej przez Managed C++. Właśnie z powodu negatywnych głosów, Microsoft zdecydował się porzucić rozwój składni Managed C++. Dnia 6.10.2003 ECMA doniosła o nowym języku, który łączy ISO/C++ oraz Common Language Infrastucture – skąd pochodzi jego nazwa. Wiadomo było także, że nowy kompilator (VC++ 2005), będzie obsługiwał ten standard.


Dlaczego MC++ jest "be"?


  • brzydka i pomieszana składnia – wszystkie podwójne podkreślenia przed nowymi słowami kluczowymi
  • ułomność w stosunku do innych języków platformy, np. brak konstrukcji foreach do przeglądania kolekcji .net
  • słaba integracja C++ z .net, nie można było używać udogodnień C++, takich jak szablony dla typów CLI i na odwrót, nie można było używać udogodnień platformy w stosunku do C++ jak np. garbage–collection
  • pomieszane wskaźniki – te z C++ oraz zarządzane referencje (__gc) używały tego samego znaczka "*", co było denerwujące, zwłaszcza że wskaźniki __gc miały zupełnie inną budowę oraz zastosowanie


Co daje C++/CLI?


  • elegancką i spójną składnię – coś co pasuje do C++ i pozwala porozumieć się kodowi zarządzanemu i niezarządzanemu; brak podwójnych podkreśleń
  • pełne wsparcie CLI np. własności, garbage–collection oraz mechanizm generyczny. Co więcej, język pozwala nam używać tego także wobec niezarządzanych klas
  • pełne wsparcie C++ np. szablony, deterministyczne destruktory pracujące na obu rodzajach klas (typy bezpośrednie oraz referencyjne). C++/CLI jest jedynym językiem platformy, w którym możemy zadeklarować typ z .net na zwykłym natywnym stosie lub stercie
  • ułatwia przejście z C++ do .net, choć docelowo wcale nie jest to konieczne


Hello World


[Kod C++]

using namespace System;

int main()
{
    Console::WriteLine("Hello World");
    return 0;
}

No cóż, może na pierwszy rzut oka nie różni się zbytnio od MC++, ale pozory mylą. Nie trzeba dodawać już na początku:

[Kod C++]

#using <mscorlib.dll>

ponieważ kompilator sam to dodaje zawsze gdy kompilujemy z opcją "/clr".

Uchwyty

Powodem ich wprowadzenia jest chęć odróżnienia wskaźnika niezarządzanego ("*") od zarządzanych referencji.

[Kod C++]

int main()
{
      // punktator "^" reprezentuje uchwyt
      String^ str = "Hello World";
      Console::WriteLine(str);

return 0
}


Punktator "^" reprezentuje uchwyt do zarządzanego obiektu. Nawiązując do specyfikacji CLI, jest on zarządzaną referencją. Uchwyty są w nowej składni odpowiednikiem wskaźników __gc w MC++. Nie należy mylić uchwytów ze wskaźnikami – to dwa inne światy.

Czym się różnią?

  • wskaźniki oznaczamy przez "*", a uchwyty przez "^"
  • uchwyty są zarządzanymi referencjami do obiektów, a wskaźniki tylko adresem w pamięci
  • wskaźniki nie zmieniają się podczas cyklów garbage collectora, a uchwyty, choć pokazują na to samo, to jednak wartość zapisana w tym konkretnym mijescu w pamięci (bądź też inna informacja) może zostać przeniesiona podczas kompaktowania pamięci
  • trzeba zwalniać miejsce, które zostało przydzielone i na które pokazuje wskaźnik, pod groźbą utraty "kawałka" pamięci. Dla uchwytów kasowanie jest opcjonalne
  • uchwyty są bezpieczniejsze, gdyż nie można ich zrzutować na void^
  • new zwraca wskaźnik, gcnew uchwyt


Tworzenie obiektów CLR


[Kod C++]

using namespace System;

int main()
{
    String^ str = gcnew String("Hello World");
    Object^ o1 = gcnew Object();
    Console::WriteLine(str);
    return 0;
}

Słowa kluczowego gcnew używamy do inicjalizacji obiektów CLR, zwraca nam ono uchwyt to nowo stworzonego obiektu na stercie CLR. Dobrą rzeczą w gcnew jest to, że pozwala ono łatwo rozróżniać obiekty zarządzane i niezarządzane. Operator oraz "^" pozwalają nam także na dostęp do BCL (Base Class Library).


CTS w C++/CLI


Programując w C++/CLI należy znać CTS, który zawiera trzy podstawowe typy:

  • polimorficzny typ referencyjny, który jest używany dla wszystkich klas dziedzicznych (ref)
  • nie–polimorficzny typ bezpośredni, używany do definiowania „konkretnych” typów potrzebnych do podniesienia wydajności środowiska, np. typy numeryczne (value)
  • abstrakcyjny typ – interfejs; używany do definiowania zbioru metod, znanych z góry przed ich zaimplementowaniem (interface)

Odwzorowanie CTS na zbiór typów w konkretnym języku występuje we wszystkich językach CLI, choć  oczywiście ich składnia różni się. Przykładowo, w C# piszemy:

[Kod C#]

abstract class Shape { ... }

gdy chcemy zdefiniować klasę abstrakcyjną, natomiast w C++/CLI wyglądałoby to w ten sposób:

[Kod C++]

ref class Shape abstract { ... };

Podobnie w C# piszemy:

[Kod C#]

struct Point2D { ... };

a w C++/CLI

[Kod C++]

value class Point2D { ... };

Rodzina klas wspieranych przez C++/CLI obejmuje zintegrowanie CTS ze składnią ISO C++. Możemy więc napisać:

[Kod C++]

class native {};
value class V {};
ref class R {};
interface class I {};

CTS wspiera także typ wyliczeniowy, który można zakodować na dwa sposoby:

Pierwszy – znany z C++:

[Kod C++]

enum native { fail, pass };

Drugi – wprowadzony w CLI:

[Kod C++]

enum class CLIEnum : char { fail, pass};

Podobnie CTS ma swój własny typ tablicowy, który wygląda trochę inaczej niż ten znany z C++:

[Kod C++]

int native[] = {1, 1, 2, 3, 5, 8};
array^ managed = {1, 1, 2, 3, 5, 8};

Gdybyśmy chcieli to usystematyzować, otrzymamy:

  • Typy CLR:
    • Typy referencyjne
      • ref class RefClass{...};
      • ref struct RefClass{...};
    • Typy bezpośrednie
      • value class ValClass{...};
      • value struct ValClass{...};
  • Interfejsy
    • interface class IType{...};
    • interface struct IType{...};
  • Typy wyliczeniowe
    • enum class Color{...};
    • enum struct Color{...};
  • Typy natywne
    • class Native{...};
    • struct Native{...};

Żaden z języków CLI nie jest bliżej standardów zawartych w CTS niż C++/CLI.


Problemy z integracją obu modeli


Każdy obiekt znajdujący się na zarządzanej starcie jest przedmiotem realokacji podczas odśmiecania przez Garbage Collector. Każdy wskaźnik do takiego obiektu musi być śledzony i uaktualniany przez środowisko; programista nie ma na to wpływu. Wobec tego, gdyby można było pobrać adres typu bezpośredniego, który byłby na zarządzanej stercie, musiałaby istnieć specjalna odmiana wskaźnika, obok tej którą znamy z C++.

Jakie są tego korzyści? Z jednej strony, prostota użycia i bezpieczeństwo. Bezpośrednie wprowadzenie do języka czegoś takiego sprawiłoby, że byłby bardziej skomplikowany ale dawał większe możliwości. Jednak gdyby zrezygnować ze wskaźników, które do tej pory uważane są za główne źródło błędów w programach (np. poprzez odwołania do pamięci, która została już zwolniona, a na którą ciągle pokazuje tenże wskaźnik) stałby się one bardziej niezawodne. Dzięki temu stworzono by potencjalnie bezpieczniejsze środowisko.

Z drugiej jednak strony, trzeba wziąć pod uwagę wydajność oraz elastyczność. Za każdym razem kiedy przypisujemy obiekt do typu bezpośredniego, następuje nowe szufladkowanie wartości (zob. boxing). Możliwość dostępu do takiego typu wartości  pociąga za sobą dostęp do zmian w pamięci, które mogą dać ważny wzrost wydajności. Bez śledzącego wskaźnika nie można przechodzić po kolejnych komórkach w tablicy, używając arytmetyki wskaźników. Oznacza to, że tablice CLI nie mogą wchodzić w skład iteratorów STL oraz pracować z algorytmami generycznymi.

Tryb adresowania kolekcji obejmuje wartości przechowywane na zarządzanej stercie:

[Kod C++]

int ival = 1024;
int^ boxedi = ival;

array^ ia = gcnew array{1,1,2,3,5,8};
interior_ptr begin = &ia[0];

value struct smallInt { int m_ival; ... } si;
pin_ptr ppi = &si.m_ival;

Jest to jednak temat na oddzielny artykuł, który – mam nadzieje – pojawi się niedługo.


Funkcjonalność


Trzecim aspektem jest funkcjonalność wynikająca z używania CLI. Przykładowo funkcje wirtualne w konstruktorze i destruktorze klasy bazowej. W standardzie ISO–C++ ten przypadek wymagałby zresetowania tablicy wirtualnej z każdym wywołanym konstruktorem i destruktorem. Nie jest to możliwe, ponieważ tablica wirtualna jest zarządzana przez środowisko, a nie przez konkretny język.

Mowa więc o zachowaniu równowagi między tym, co byłoby najlepsze, a tym, co jest osiągalne. Są trzy pierwszorzędne cele, na których skupiono się w C++/CLI:

  • zdobycie zasobów podczas inicjalizacji dla typów referencyjnych (Resource Acquisition Is Initialization – RAII), potrzebnych potem dla garbatge–collectora do „deterministycznej finalizacji”
  • zachowanie składni oraz zasad dotyczących konstruktorów kopiujących oraz operatorów przypisania z języka C++
  • wsparcie szablonów znanych z C++ dla typów CTS jako dodatek do mechanizmu generycznego z CLI. Dzięki temu można było wprowadzić coś w rodzaju STL w CLI

Krótki przykład „deterministycznej finalizacji”. Zanim pamięć zostaje przydzielona dla obiektu jest on analizowany przez garbage collector. Wówczas to ustawiana jest metoda finalizująca. Można by było pomyśleć, że jest to rodzaj destruktora, jednak tak naprawdę jest ona związana z czasem życia obiektu w programie i tak naprawdę nigdy nie wiadomo kiedy zostanie wywołana. Nazywamy to finalizacją, a cały proces to niedeterministyczna finalizacja garbage collectora.

Działa ona dobrze w przypadku dynamicznej alokacji pamięci. Kiedy przydzielona pamięć przestaje być nam potrzebna, garbage collector zwalnia ją, rozwiązując w ten sposób problem. Nie działa ona jednak dobrze w przypadku, gdy obiekt przechowuje jakiś krytyczny zasób, (np. połączenie z bazą danych) lub jest umieszczona na niezarządzanej stercie. W tym przypadku dobrze byłoby zwolnić zasób od razu, gdy przestanie być już potrzebny. Rozwiązanie tego w CLI to metoda Dispose, która zwalnia zasoby klas. Implementuje ona interfejs IDisposable.

Podstawowym wzorem projektowym w C++ jest zdobycie zasobów podczas inicjalizacji, co oznacza, że zasoby są zapisywane w obiektach podczas wywołania konstruktora, natomiast zwolnienie odbywa się w destruktorze. W ten sposób zarządza się czasem życiem obiektu danej klasy.

Zasady dotyczące zwalniania pamięci, które muszą spełniać typy referencyjne:

  • muszą używać destruktora do zwolnienia zasobów powiązanych z obiektem
  • muszą mieć destruktor, który wywołuje się automatycznie podczas kasowania obiektu

CLI nie ma destruktorów klas dla typów referencyjnych. Z tego powodu destruktor musi być skojarzony z czymś innym w implementacji. Kompilator dokonuje więc następujących konwersji:

  • klasa posiada rozszerzoną listę swojej klasy bazowej do dziedziczenia po interfejsie IDisposabale
  • destruktor jest konwertowany do metody Dispose

Nadal jednak trzeba zautomatyzować wywołanie destruktora. Opracowano do tego specjalną notację, w której długość życia obiektu jest związana z zakresem jego deklaracji. Kompilator przekształca na swój sposób tą notację, żeby zaalokować obiekt na zarządzanej stercie. Gdy zakres się kończy, zostaje wywołana metoda Dispose – jako destruktor zdefiniowany przez użytkownika. Odzyskanie pamięci aktualnie zaalokowanej dla obiektu pozostaje pod kontrola garbage collector’a.

Wywołanie destruktora:

[Kod C++]

ref class Wrapper
{
    Native *pn;

    public:
    // tzw. zdobycie zasobów przy inicjalizacji
    Wrapper( int val ) { pn = new Native( val ); }
    // ten kod usunie obiekt z niezarządzanej pamięci
    ~Wrapper(){ delete pn; }

    void mfunc();

    protected:
    // metoda Finalize() – bezpieczniejsza
    !Wrapper() { delete pn; }
};

void f1()
{
    // utworzenie obiektu typu referencyjnego na zarządzanej stercie
    Wrapper^ w1 = gcnew Wrapper( 1024 );
  
    // ograniczenie długości życa obiektu w2 do zakresu lokalnego
    Wrapper w2( 2048 ); // bez " ^ "

    // różnica w składni
    w1–>mfunc();
    w2.mfunc();
    // tutaj w2 kończy żywot
}

// ... w dalszej części kodu, możliwe jest że w1 też zostanie skasowane


Boxing/Unboxing


Są to specjalne zabiegi , które umożliwiają przekształcenie typu bezpośredniego na referencyjny (boxing) i odwrotnie (unboxing). Różnica między jednym a drugim polega na tym, że boxing zachodzi niejawnie. Do zabiegu odwrotnego trzeba użyć operatora reinterpret_cast oraz dereferencji ("*"):

[Kod C++]

using namespace System;

int main()
{
    int z = 44;
    Object^ o = z; // boxing – niejawny
    int y = *reinterpret_cast(o); //unboxing == reinterpret_cast + dereferencja
    Console::WriteLine("{0} {1} {2}", o, z, y);
    z = 66;
    Console::WriteLine("{0} {1} {2}", o, z, y);

return 0;
}

// Wyjście:
//
// 44 44 44
// 44 66 44

Obiekt o jest tylko kopią i nie jest referencją do obiektu z, co widać przy drugim wywołaniu Console::WriteLine.

Kiedy używamy przekształcenia do typu bezpośredniego, nowopowstały obiekt jest tego samego typu:

[Kod C++]

using namespace System;

int main()
{
    int z = 44;
    float f = 33.567f;
    Object^ o1 = z;
    Object^ o2 = f;

    Console::WriteLine( o1–>GetType() );
    Console::WriteLine( o2–>GetType() );

return 0;

// Wyjście:
//
// System.Int32
// System.Single

Z tego powodu nie można stosować przekształcenia z typu bezpośredniego do typu, który nie jest z nim zgodny:

[Kod C++]

using namespace System;

int main()
{
    int z = 44;
    float f = 33.567f;
    Object^ o1 = z;
    Object^ o2 = f;

    try
    {
        int y = *reinterpret_cast(o2); //System::InvalidCastException
        float g = *reinterpret_cast(o1); //System::InvalidCastException
    }
    catch( InvalidCastException^ except )
    {
        Console::WriteLine("Invalid Cast Exeption");
    }

return 0;
}

Dostaniemy tu do obsłużenia wyjątek.

Jeśli spojrzymy na wygenerowany kod IL, zobaczymy instrukcję box. Przykładowo:

[Kod C++]

void Box2()
{
    float y=45;
    Object^ o1 = y;
}

Kompiluje się do:

[Kod IL]

.maxstack 1
.locals (float32 V_0, object V_1)

    ldnull
    stloc.1
    ldc.r4 45.
    stloc.0
    ldloc.0
    box [mscorlib]System.Single
    stloc.1
    ret

Instrukcja box konwertuje typ bezpośredniego do instancji typu Object. Cały proces kończy się stworzeniem nowego obiektu i skopiowaniem do niego informacji z typu bezpośredniego Jest to bardzo przydatne w języku o takiej konstrukcji jak C++/CLI, gdzie mamy do czynienia z różnymi sposobami reprezentacji danych.


Parę słów na zakończenie


C++/CLI nie jest tylko rozszerzeniem C++. Stanowi raczej pełny język opracowany do programowania obiektowego oraz generycznego, przy czym nie narzuca on z góry któregoś z tych modeli. Jest to raczej tak jak jego poprzednik, język hybrydowy.

"Wszystko pięknie, ale po co się tego uczyć i używać, skoro są łatwiejsze języki takie jak C#, J# czy choćby Visual Basic?" – powiedzą niektórzy. Już odpowiadam:

  • można skompilować istniejący kod C++ do IL
  • destruktory deterministyczne
  • można używać wszystkiego co oferuje CLI w formie natywnej
  • wszystkie niewygodne podkreślenia to już historia

Podane przykłady potwierdzają, że C++/CLI jest chyba najpotężniejszym z języków platformy .NET, a może nawet i ze wszystkich. Zachęcam do jego nauki :)


Referencje


https://secure.codeproject.com/managedcpp/cppcliintro01.asp

http://msdn.microsoft.com/msdnmag/issues/05/02/PureC/

http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-372.pdf

Podobne artykuły

Komentarze 0

pkt.

Zaloguj się lub Zarejestruj się aby wykonać tę czynność.