Omezení pro argumenty šablony (template) v C++ napodobující “where“ omezení pro generické argumenty v C#
Tento článek je hlavně reakce na stížnost, kterou měl kolega-vývojář z firmy, kde vývojáři použivají C++ i C#. Stížnost byla zaměřena na to, že na rozdíl od C# není možné v C++ zkontrolovat v době kompilace, zda argument předaný do šablony implementuje vyžadované rozhraní, nebo je potomkem námi vyžadované třídy.
Zdůrazním hned v úvodu, že v článku mluvím o “klasickém” C++ a ne o jeho .Net dialektu C++/CLI, který podporuje šablony i generiku.
Příkladem v C# může být následující třída Collection, která vyžaduje, aby za generický parametr T byla dosazena třída podporující rozhraní IBusinessObject. Podrobněji jsem podobnou kolekci už v éře př. lq. (čteme před Linqem) popisoval třeba zde.
public class Collection<T> : where T : IBusinessObject
{
}
Šablony v C++ jsou i přes povrchní syntaktickou podobnost zcela odlišnou jazykovou konstrukcí při srovnání s generikou v C#, a protože jsou “uzavřené šablonové typy” generovány již v době kompilace, kompilátor sám zajistí, že typ předaný do šablony podporuje všechny námi vyžadované metody, vlastnosti a operátory. Přesto existují situace, kdy chceme garantovat, že do naší “šablonové” kolekce jsou vkládány jen objekty podporující stejné rozhraní, nebo vyžadujeme, aby vkládané objekty byly povinně potomky nějaké třídy. Nestačí nám tedy, když argument šablony podporuje metodu Commit, která má stejnou signaturu jako metoda Commit z třídy Transaction, ale chceme mít již v době kompilace ověřeno, že předaná instance je potomkem zřídy Transaction. Důvod? Třeba to, že metoda Commit z třídy Transaction má v sobě vysokoúrovňový scénář, na který spoléháte (vzor Template method, jehož název nemá nic společného s šablonami ani generikou ).
Pro novou verzi jazyka C++0x bylo plánováno, že různá omezení parametru šablony bude možné vyjádřit pomocí tzv. “Concepts”. Bohužel Concepts ani v C++0x nakonec nebudou a my si musíme vystačit s výrazovými prostředky současné verze jazyka C++. S jednou výjimkou - v článku použiju i jednu elegantní konstrukci z C++0x (implementována ve Visual Studio 2010), statickou verifikaci pomocí klíčového slova static_assert, ale ukážu, jak snadno můžeme static_assert nahradit.
Mějme běžnou šablonu třídy Collection. V našem případě jde jen o dostatečný draft třídy Collection s konstruktorem, destruktorem a prázdnou implementací metod Add a Remove.
template
<typename T>
class Collection
{
public:
~Collection(void)
{
}
Collection(void)
{
}
void Add(T& t)
{
}
void Remove(T& t)
{
}
};
Za generický parametr T lze nyní substituovat “cokoli”, ale my chceme omezit typ T pouze na instance podporující rozhraní IBusinessObject.
class IBusinessObject
{
public:
IBusinessObject(void);
virtual void CommitChanges() = 0;
virtual void RollbackChanges() = 0;
virtual ~IBusinessObject(void);
};
V C++ samozřejmě za rozhraní považujeme třídu, jejíž všechny metody jsou abstraktní, a proto převod dvou omezení ze C# ( potomek třídy, implementor rozhraní (realizace) ) se nám v C++ redukuje na nalezení ekvivalentu omezení “parametr šablony musí být potomkem námi vyžadované třídy”.
K vyjádření takového omezení si zavedeme pomocnou šablonovou třídu IsDerivedTest:
#pragma once
template
<typename Base, typename Derived>
class TestIsDerived
{
private:
struct Is_Derived_Helper
{
int dummy;
};
struct Not_Derived_Helper
{
int dummy;
int dummy2;
};
private:
TestIsDerived(void);
~TestIsDerived(void);
static Is_Derived_Helper Test(Base* base);
static Not_Derived_Helper Test(...);
public:
enum TestResult
{
IsDerivedResult = ((sizeof(Test(static_cast<Derived*>(0))) == (sizeof(Is_Derived_Helper))))
};
};
Co třída TestIsDerived umí?
Dva parametry šablony TestIsDerived mají výmluvné názvy – Base (bázová třída) a Derived (třída, u níž chceme zkontrolovat, zda je z Base odvozena)
Tuto třídu nechceme nikdy instanciovat, proto jsou její konstruktor a destruktor privátní. U privátních struktur Is_Derived_Helper a Not_Derived_Helper je důležitá jen jedna věc – musí mít odlišnou velikost (sizeof(Is_Derived_Helper) != sizeof(Not_Derived_Helper ), a proto první obsahuje jednu "dummy" proměnnou typu int a druhá struktura dvě “dummy” proměnné typu int.
Při kontrole zda parametr šablony Derived je odvozen z parametru šablony Base postupujeme takto:
- Máme dvě privátní statické metody nazvané Test. Tyto funkce jsou jen deklarovány, jejich definici nepotřebujeme, protože nebudou nikdy zavolány. Důležité je jen to, že jedna metoda Test vrací Is_Derived_Helper a druhá metoda Test vrací Not_Derived_Helper - připomňme si, že jde o struktury mající různou velikost.
- Metoda Test, která vrací strukturu Is_Derived_Helper, přijímá jako argument pointer na parametr Base, a může tedy přijmout i pointer na Derived, pokud Derived je potomkem Base. Druhá metoda Test (Test(…)) bude vyvolána vždy, když se nepodaří Derived* převést na Base*.
- Do hodnoty IsDerivedResult v enumeraci TestResult uložíme výsledek výrazu ((sizeof(Test(static_cast<Derived*>(0))) == (sizeof(Is_Derived_Helper)))) - “voláme” metodu Test tak, že přes operátor static_cast přetypujeme konstantu 0 na pointer na Derived . Ač může vypadat přetypování 0 na Derived* jako “zvěrstvo” za účelem rychlého poslání programu do
řiti říše binárního nebytí, slovo voláme je v předchozí větě záměrně v uvozovkách, protože jak jsem již psal, k žádnému volání metody Test nikdy nedojde. V době kompilace ale kompilátor zjistí, jakou variantu metody Test by zavolal pro Derived* a my z velikosti “potenciální a nikdy skutečně nevrácené” návratové hodnoty zvolené metody Test jsme schopni poznat, jestli je Derived potomkem Base. Pokud je Derived potomkem Base, bude zvolena varianta Test(Base* base), která vrací Is_Derived_Helper, a podmínka ((sizeof(Test(static_cast<Derived*>(0))) == (sizeof(Is_Derived_Helper) bude pravdivá - uložíme do IsDerivedResult hodnotu 1. Pokud Derived není potomkem Base, bude zvolena varianta metody Test(…), která vrací Not_Derived_Helper, a podmínka ((sizeof(Test(static_cast<Derived*>(0))) == (sizeof(Is_Derived_Helper) bude nepravdivá (uložíme do IsDerivedResult hodnotu 0). Znovu zdůrazňuji, že k vyhodnocení podmínky dojde již v době kompilace.
Scéna je připravena, pozveme hlavní aktéry:
Mějme dvě třídy. Třída Invoice je potomkem třídy IBusinessObject a třída NotBusinessObject (nomen omen :) ) překvapivě není.:)
class NotBusinessObject
{
public:
NotBusinessObject(void);
virtual ~NotBusinessObject(void);
};
#pragma once
#include "IBusinessObject.h"
class Invoice : public IBusinessObject
{
public:
virtual void CommitChanges();
virtual void RollbackChanges();
Invoice(void);
~Invoice(void);
};
#include "StdAfx.h"
#include <iostream>
#include "Invoice.h"
using namespace std;
Invoice::Invoice(void)
{
}
Invoice::~Invoice(void)
{
}
void Invoice::CommitChanges()
{
cout << "Commit";
}
void Invoice::RollbackChanges()
{
cout << "Rollback";
}
Naše třída Collection má přijímat jen instance potomků třídy IBusinessObject.
#pragma once
#include "TestIsDerived.h"
template
<typename T>
class Collection
{
public:
~Collection(void)
{
static_assert(TestIsDerived<IBusinessObject, T>::IsDerivedResult, "Invalid base class, IBusinessObject required");
}
Collection(void)
{
}
void Add(T& t)
{
}
void Remove(T& t)
{
}
};
Do destruktoru jsme vložili statickou kontrolu, kdy námi vytvořené pomocné třídě TestIsDerived substituujeme za parametr šablony Base třídu IBusinessObject a za parametr Derived přímo parametr šablony třídy Collection, který musí být vždy potomkem IBusinessObject. Do destruktoru jsme kód vložili proto, aby bylo garantováno, že při instanciaci šablony Collection ke statické kontrole vždy v době kompilace dojde – kód bychom mohli vložit i do konstruktoru, ale budeme-li mít konstruktorů více, museli bychom kontrolu duplikovat v každém konstruktoru. Statická kontrola v destruktoru spoléhá na to, že vytvořený konkrétní objekt Collection<T> je v aplikaci zlikvidován - buď jde o likvidaci automatické (lokální) proměnné, nebo vy sami použijete operátor delete atd. Jestliže by destruktor objektu v aplikaci nikdy nebyl volán, kompilátor nebude destruktor v šablonové třídě při rozvíjení šablony instanciovat, stejně jako nikdy neinstanciuje další nepoužité metody v šabloně.
A použití:
Tento kód bez problémů projde. Invoice je potomkem IBusinessObject.
// TemplateConstraint_Console.cpp : Defines the entry point for the console application.
//
#include "stdafx.h"
#include "Collection.h"
#include "Invoice.h"
#include "NotBusinessObject.h"
int _tmain(int argc, _TCHAR* argv[])
{
Collection<Invoice> col1;
}
Tento kód nahlásí chybu. NotBusinessObject není potomkem třídy IBusinessObject.
// TemplateConstraint_Console.cpp : Defines the entry point for the console application.
//
#include "stdafx.h"
#include "Collection.h"
#include "Invoice.h"
#include "NotBusinessObject.h"
int _tmain(int argc, _TCHAR* argv[])
{
Collection<NotBusinessObject> col1;
}
Ve Visual Studiu 2010 dostanu v době kompilace tuto chybu:
Error 1 error C2338: Invalid base class, IBusinessObject required c:\users\stein\documents\visual studio 2010\projects\templateconstraint_console\templateconstraint_console\collection.h 16 1 TemplateConstraint_Console
Tím je náš úkol splněn, ale jak jsem sliboval, ukážu nyní, i jak se můžeme obejít bez klíčového slova static_assert z C++0x.
Třída TestIsDerived zůstane beze změny, ale napíšeme další dvě šablony, ve kterých budeme reagovat na hodnotu IsDerivedResult z třídy TestIsDerived, což původně dělal přímo static_assert.
#pragma once
template
<int T>
struct Invalid_Base_Class
{
public:
Invalid_Base_Class(void)
{
}
~Invalid_Base_Class(void)
{
}
};
template<>
struct Invalid_Base_Class<0>
{
private:
Invalid_Base_Class(void);
~Invalid_Base_Class(void);
};
Šablona Invalid_Base_Class má jeden šablonový parametr typu int. Struktura není svým rozhaním zajímavá, podstatný je pro nás jen název struktury, abychom v době kompilace viděli chybovou hlášku “Špatná bázová třída”. První šablona Invalid_Base_Class je běžnou strukturou, která bude použita pro instanciaci všech hodnot typu int krome hodnoty 0. Pro hodnotu 0, o níž víme, že je uložena v hodnotě enumerace TestIsDerived::IsDerivedResult, jestliže DERIVED NENÍ potomkem BASE, je určena explicitní specializace Invalid_Base_Class<0>, která má ale privátní konstruktor, takže pokus o její vytvoření vždy již při kompilaci selže.
V hrubé podobě, tedy bez vlastního makra assert_is_derived apod., které skryje práci s našimi strukturami Invalid_base_Class a které by zde jen zamlžovalo průzračnost řešení, můžeme kontrolu na nutnost šablonového parametru T třídy Collection<T> dědit z třídy IBusinessObject přepsat takto:
#pragma once
#include "Invalid_Base_Class.h"
template
<typename T>
class Collection
{
public:
~Collection(void)
{
Invalid_Base_Class<TestIsDerived<IBusinessObject, T>::IsDerivedResult> invalid_base;
invalid_base;
}
Collection(void)
{
}
void Add(T& t)
{
}
void Remove(T& t)
{
}
};
V destruktoru se pokusíme instanciovat strukturu Invalid_Base_Class, které jako šablonový argument předáme hodnotu v IsDerivedResult.
Collection<Invoice> opět bez problémů projde, ale při pokusu vytvořit Collection<NotBusinessObject> dostanu při kompilaci tuto chybovou zprávu:
Error 1 error C2248: 'Invalid_Base_Class<0>::Invalid_Base_Class' : cannot access private member declared in class 'Invalid_Base_Class<0>' c:\users\stein\documents\visual studio 2010\projects\templateconstraint_console\templateconstraint_console\collection.h 14 1 TemplateConstraint_Console
Řešení by se dalo přisladit dalším syntaktickým cukrem, ale princip by měl být z článku zřejmý.
Triky s šablonami jsou úžasné a převod některých rysů generiky ze C# není zase tak problematický. Jen ta nonšalantní elegance, která je C# vlastní, v přihroublém, ale o to výkonnějším, C++ trochu chybí. :) Kdyby někoho triky se šablonami zaujaly, doporučuji ke studiu Alexendrescovu knihu Modern C++ Design: Generic Programming and Design Patterns Applied.
Monday, 24 May 2010 15:50:36 (Central Europe Standard Time, UTC+01:00)
C# | Nativní kód