Arbeiten mit .NET: Materialien/ C++ CLI templates zu .NET-Komponenten machen

C++/CLI ist eine von Microsoft entwickelte Variante der Programmiersprache C++, die den Zugriff auf die virtuelle Laufzeitumgebung der .NET-Plattform mit Hilfe von speziell darauf zugeschnittenen Spracherweiterungen ermöglicht. Umgekehrt ist es mit entsprechendem Aufwand auch möglich, von einer .NET-Sprache wie z.B. C# aus auch auf Sprachfeatures zuzugreifen, die sonst nur in der C++-Welt vorhanden sind. Dieser Artikel behandelt die Frage, wie über C# generics auf C++ Templates zugegriffen werden kann und damit die Vorteile von C++ auch in C# nutzbar sind.


C# generics vs. C++ Templates

Bearbeiten

Im Gegensatz zur wohl durchdachten, besonders sorgfätig entwickelten Unterstützung für generische Programmiertechniken in C++, ist eine solche in C# nur rudimentär vorhanden. Selbst besonders wichtige Techniken wie Spezialisierung und partielle Spezialisierung werden nicht unterstützt. Dadurch sind C# generics für anspruchsvollere Programmieraufgaben praktisch wertlos. Trotzdem ist es dank C++/CLI möglich, C++ Templates für C# zugänglich zu machen.

C# kennt als Sprachelement neben generischen Klassen auch generische Interface-Definitionen:

public interface IMatrix<T>  
{
    int Size1 { get; }
    int Size2 { get; }

    T this[int IndexI, int IndexJ] { get; set; }
}

Die selbe Interface-Definition kann auch in C++/CLI angegeben werden:

// compile with: /clr
generic<typename T>
public interface class IMatrix
{
    property int Size1{ int get(); }
    property int Size2{ int get(); }

    property T default[int, int]
    {
       T get(int, int); 
       void set(int, int, T);
    }
};


Da in C++/CLI Template-Klassen von .NET Interfaces erben können, ist es also im Prinzip möglich, die Parameter des generischen Interface auf die Template-Parameter zu mappen. Die besondere Schwierigkeit hierbei ist die Tatsache, dass C# Generics erst zur Laufzeit instanziiert werden, C++ Templates aber schon zur Compile-Zeit.

Dadurch ist es nicht möglich, den Typ-Parameter einer Generic-Klasse als Argument für ein C++ Template zu verwenden. Der folgende C++/CLI-Code ist also nicht gültig:

// compile with: /clr
template <typename T> class AClassicalTemplate {};

generic <typename T> 
public ref class Wrong
{
    AClassicalTemplate<T> * Ouch;
};

Umgekehrt ist es natürlich möglich, C++ Template-Parameter als Parameter für C# Generics zu verwenden:

generic <typename T> 
public ref class AVerySimpleGeneric
{
    // ...
};

template <typename T> 
ref class Right {
    AVerySimpleGeneric<T> ^ Yeah;
};

Ebenfalls möglich: eine C++/CLI Template-Klasse erbt von einem C# Generic Interface.

// compile with: /clr
template <typename T>  
ref class Matrix : public IMatrix<T> 
{
    // ....
};

Implementierung einer C++/CLI Template-Klasse mit C# Generic Interface

Bearbeiten

Damit ist es also möglich, eine Template-Klasse in C++/CLI so zu implementieren, dass sie das C# Generic Interface unterstützt. Hier eine (verbesserungswürdige) Version einer Matrix.

#include <vector>

// compile with: /clr
template <typename T>  
ref class Matrix : public IMatrix<T> 
{
    std::vector<std::vector<T> > * m_Data;
    int i_, j_;
public:
    Matrix(int i, int j)
        : i_(i)
        , j_(j)
        , m_Data(new std::vector<std::vector<T> >(i))
    {
        std::vector<T> v(j);
        for (int ii = 0; ii < i; ++ii)
        {
            (*m_Data)[ii] = v;
        }
    }

    property T default[int, int] 
    {
        virtual T get(int i, int j)
        {
            return (*m_Data)[i][j];
        }

        virtual void set(int i, int j, T d)
        {
            (*m_Data)[i][j] = d;
        }
    }

    property int Size1
    {
        virtual int get() { return i_; }
    }

    property int Size2
    {
        virtual int get() { return j_; }
    }
};

Diese Klasse lässt sich nun leider nur mit nativen C++-Typen parametrisieren. Will man in C++/CLI .NET-Datentypen verwenden, so geht dies nur über eine Kapselung in die Klasse gcroot. Das soeben verwendete Template kann man für in gcroot gekapselte Klassen spezialisieren:

template <typename T>  
ref class Matrix<gcroot<T> > : public IMatrix<T> 
{
    std::vector<std::vector<gcroot<T> > > * m_Data;
    int i_, j_;
public:
    Matrix(int i, int j)
        : i_(i)
        , j_(j)
        , m_Data(new std::vector<std::vector<gcroot<T> > >(i))
    {
        std::vector<gcroot<T> > v(j);
        for (int ii = 0; ii < i; ++ii)
        {
            (*m_Data)[ii] = v;
        }
    }

    property T default[int, int] 
    {
        virtual T get(int i, int j)
       {
            return (*m_Data)[i][j];
        }

        virtual void set(int i, int j, T d)
        {
            (*m_Data)[i][j] = d;
        }
    }

    property int Size1
    {
        virtual int get() { return i_; }
    }

    property int Size2
    {
        virtual int get() { return j_; }
    }
};

Das ist natürlich alles andere als elegant, weil man jede Menge Code dupliziert. Die Einführung einer kleinen traits-Klasse erleichtert die Benutzung sowohl für native C++-Typen als auch für .NET Typen, die gekapselt werden müssen:

template <typename T> struct gc_type_dispatcher
{
    typedef T type;
};

template <typename T> struct gc_type_dispatcher<gcroot<T> >
{
    typedef T type;
};

template <typename T>  
ref class Matrix 
    : public IMatrix<typename gc_type_dispatcher<T>::type> 
{
    std::vector<std::vector<T> > * m_Data;
    int i_, j_;
public:
    Matrix(int i, int j)
        : i_(i)
        , j_(j)
        , m_Data(new std::vector<std::vector<T> >(i))
    {
        std::vector<T> v(j);
        for (int ii = 0; ii < i; ++ii)
        {
            (*m_Data)[ii] = v;
        }
    }

    typedef typename gc_type_dispatcher<T>::type contained_t;

    property contained_t default[int, int] 
    {
        virtual contained_t get(int i, int j)
        {
            return (*m_Data)[i][j];
        }

        virtual void set(int i, int j, contained_t d)
        {
            (*m_Data)[i][j] = d;
        }
    }

    property int Size1
    {
        virtual int get()
        {
            return i_;
        }
    }

    property int Size2
    {
        virtual int get()
        {
            return j_;
        }
    }
};


.NET Factory für C++/CLI templates

Bearbeiten

Da C++/CLI Template Klassen außerhalb von C++/CLI nicht sichtbar sind, können wir die Klasse Matrix von 'C# aus nicht direkt benutzen. Hier hilft das sog. Factory Pattern weiter: Wir schreiben eine Klasse mit Methoden, die Objekte von Typ der Template Klasse Matrix<...> auf dem managed heap erzeugen und einen Managed Pointer auf das Interface zurückgeben.

public ref class MatrixFactory
{
public:
    IMatrix<double> ^ CreateMatrixDouble(int i, int j)
    {
        return gcnew Matrix<double>(i, j);             
    }
    IMatrix<int> ^ CreateMatrixInt(int i, int j)
    {
        return gcnew Matrix<int>(i, j);             
    }
    IMatrix<System::String ^> ^ CreateMatrixString(int i, int j)
    {
        return gcnew Matrix<gcroot<System::String^> >(i, j);             
    }
};

Nun kann man von C# aus Objekte vom Typ Matrix<...> instanziieren:

MatrixFactory p = new MatrixFactory();
IMatrix<Double> m1 = p.CreateMatrixDouble(3, 4);
IMatrix<int> m2 = p.CreateMatrixInt(4, 5);
IMatrix<String> m3 = p.CreateMatrixString(3, 4);

// ...
m1[0, 1] = 42.0;
m2[1, 2] = 42;
m3[2, 3] = "Yeah!";

Automatisches Mapping von Parametern zwischen Generics und Templates

Bearbeiten

Der bisher angewendete Ansatz ist insofern noch nicht perfekt, als das Mapping zwischen den Parametern der Generic Interfaces und den Factory-Methoden von Hand geschieht. Bei einer großen Anzahl von Factory-Methoden führt das zu einem sog. fetten Interface, das sich auch noch bei neu zu unterstützenden Datentypen immer wieder ändert.

Aus Benutzersicht und unter Design-Aspekten wäre ein Aufruf in dieser Form hier viel besser:

MatrixFactory p = new MatrixFactory();
IMatrix<Double> m1 = p.CreateMatrix<Double>(3, 4);
IMatrix<int> m2 = p.CreateMatrix<int>(4, 5);
IMatrix<String> m3 = p.CreateMatrix<String>(3, 4);

Da C# Generics im Gegensatz zu C++ Templates erst zur Laufzeit instanziiert werden, ist ein automatisches Mapping zwischen den Parametern des Generic Interface und den Template-Parametern erst zur Laufzeit möglich. Interessanterweise unterstützt C# Reflection, weshalb die Typinformation sehr detailliert zur Laufzeit zur Verfügung steht. Ein erster Versuch für eine "generische" Factory könnte deshalb so aussehen:

public ref class MatrixFactory
{
private:
    IMatrix<double> ^ CreateMatrixDouble(int i, int j)
    {
        return gcnew Matrix<double>(i, j);             
    }
    IMatrix<int> ^ CreateMatrixInt(int i, int j)
    {
        return gcnew Matrix<int>(i, j);             
    }
    IMatrix<System::String ^> ^ CreateMatrixString(int i, int j)
    {
        return gcnew Matrix<gcroot<System::String^> >(i, j);             
    }

public:
    generic <typename T> IMatrix<T> ^ CreateMatrix(int i, int j)
    {    
        if (T::typeid == int::typeid)
        {
            return (IMatrix<T> ^)(MatrixCreator<IMatrix<int> >().create(i, j));
        }
        else if (T::typeid == double::typeid)
        {
            return (IMatrix<T> ^)(MatrixCreator<IMatrix<double> >().create(i, j));
        }
        else if (T::typeid == System::String::typeid)
        {
            return (IMatrix<T> ^)(MatrixCreator<IMatrix<System::String ^> >().create(i, j));
        }
        else
        {
            throw gcnew Exception("No CreateMatrix available for " + T::typeid);
        }
    }
};

Dieser Code hat bereits einige Auffälligkeiten: Interessant ist vor allen Dingen, dass wirklich jeder Datentyp in C++/CLI eine statische Member-Variable ::typeid besitzt. So lässt sich zu Laufzeit der Typ des Parameters auf die vorhandenen Factory-Funktionen mappen. Der Compiler übertreibt dabei allerdings ein wenig bei der Typsicherheit und unterscheidet zwischen IMatrix<T> und IMatrix<int>. Darum muss der Rückgabewert noch einmal auf seinen eigenen Typ gecastet werden.

Der vorgeschlagene Code hat allerdings einen großen Nachteil: Wenn die Anzahl der zu mappenden Factory-Funktionen groß wird, hat man eine große Anzahl an Typvergleichen im if-then-else-Teil der Generischen Factory-Funktion. Das ist u.U. nicht tragbar. Besser wäre es, man könnte die vorhandenen Factory-Funktionen in einer Lookup-Table speichern und abhängig vom Typ abrufen.

Um die Factory-Funtionen in einer Lookup-Table (z.B. std::map) speichern zu können, müssen alle Factory-Funtionen vom gleichen Typ sein, was sie zur Zeit nicht sind. Leider unterstützt C++/CLI keine covarianten Rückgabetypen, so dass man auch nicht alle Rückgabetypen von einer Basisklasse ableiten könnte und über diesen Trick eine einheitliche Signatur hinbekäme. C# (in .NET 3.0) unterstützt covariante Rückgabetypen für Delegates, C++/CLI ist hier leider nicht so weit ausgebaut. Eine Rückgabe als void * scheidet ebenfalls aus, weil man .NET Datentypen nicht auf void * casten kann und umgekehrt.

Glücklicherweise haben alle Typen in .NET System::Object als Basisklasse. So lassen sich alle Factory-Funktionen auf einen Delegate-Typ mappen:

public delegate System::Object ^ MatrixCreationFn(int i, int j);
MatrixCreationFn ^ op = gcnew MatrixCreationFn(&MatrixCreator<IMatrix<int> >::create);
// usw ...

Wenn wir für die Lookup-Table eine std::map verwenden wollen, so brauchen wir folgende Features, die Microsoft für die Unterstützung der Interoperabilität zur Verfügung stellt:

  1. Den Wrapper gcroot, um die Delegates im nativen Container speichern zu können
  2. Eine automatische Übersetzung des Typs in einen String, der als Lookup-Key verwendet werden kann. Hierzu gibt es X::typeid->ToString().
  3. Weil für gcroot ein operator< keinen Sinn macht und deshalb nicht definiert ist, eine automtische Übersetzung des System::String ^ in einen std::string. Dies wird über Marshalling mittels marshal_as erreicht.

Ausgerüstet mit diesen Mitteln gelingt die Implementierung incl. Table-Lookup auf folgende Weise:

#include <map>
#include <msclr\marshal_cppstd.h> 

template <typename T> 
ref class MatrixCreator;

template <> 
ref class MatrixCreator<IMatrix<double> >
{
public:
    static System::Object ^ create(int i, int j) 
    {
        return gcnew TestMatrix<double>(i, j);
    }
};

template <> 
ref class MatrixCreator<IMatrix<int> >
{
public:
    static System::Object ^ create(int i, int j) 
    {
        return gcnew TestMatrix<int>(i, j);
    }
};

template <> 
ref class MatrixCreator<IMatrix<System::String ^> >
{
public:
    static System::Object ^ create(int i, int j)
    {
        return gcnew TestMatrix<gcroot<System::String ^> >(i, j);
    }
};

public delegate System::Object ^ MatrixCreationFn(int i, int j);

public ref class MatrixFactory
{
private:
    typedef std::map<std::string, gcroot<MatrixCreationFn ^> > factory_storage_t;

    factory_storage_t * m_pFactoryFunctions;

    void InitializeFactoryFunctions()
    {
        m_pFactoryFunctions->insert(std::make_pair
            (marshal_as<std::string>(int::typeid->ToString()),
             gcroot<MatrixCreationFn ^>
             (gcnew MatrixCreationFn(&MatrixCreator<IMatrix<int> >::create))));

        m_pFactoryFunctions->insert(std::make_pair
            (marshal_as<std::string>(double::typeid->ToString()),
             gcroot<MatrixCreationFn ^>
             (gcnew MatrixCreationFn(&MatrixCreator<IMatrix<double> >::create))));

        m_pFactoryFunctions->insert(std::make_pair
            (marshal_as<std::string>(System::String::typeid->ToString()),  
             gcroot<MatrixCreationFn ^>
             (gcnew MatrixCreationFn(&MatrixCreator<IMatrix<System::String ^> >::create))));
    }

public:
    MatrixFactory()
        : m_pFactoryFunctions(new factory_storage_t())    
    {
        InitializeFactoryFunctions();
    }

public:
    generic <typename T> IMatrix<T> ^ CreateMatrix(int i, int j)
    {    
        std::string type_id_as_string = marshal_as<std::string>(T::typeid->ToString());

        factory_storage_t::const_iterator lb = m_pFactoryFunctions->lower_bound(type_id_as_string);
        

        if(lb != m_pFactoryFunctions->end() 
           && 
           !(m_pFactoryFunctions->key_comp()(type_id_as_string, lb->first))) 
        {
            MatrixCreationFn ^ fn = lb->second;
            return (IMatrix<T> ^)(fn(i, j));
        }
        else
        {
            throw gcnew Exception("No CreateMatrix available for " + T::typeid);
        }
    }
};