Nighthawk.dk

C++: Compile-time checks med templates

Skrevet d. 21. March 2023 af Claus Nielsen.

Templates i C++ er ikke kun brugbare hvis man skriver generiske biblioteker eller container-klasser.

For eksempel kan de kan også bruges til at afvikle visse checks af typer eller værdier under kompilering, som vi ser på her.

Lad os forestille os, at vi laver et RPG-spil, hvor vi har denne datastruktur til at beskrive spillets karakterer:

enum class character_class
{
	rogue,
	warrior,
	sorcerer,
};

enum class property
{
	damage,
	health,
	character_class,
	level,
	is_npc,
};

struct character
{
	private:
		double damage;
		double health;
		character_class type;
		unsigned int level;
		bool is_npc;

	public:
		// ... getters/setters tilføjes her ...
};

Nu mangler vi bare at tilføje get/set-metoder til at tilgå data i character strukturen. Dette kan gøres på flere måder, så lad os prøve at lave et uniformt interface vha. templates:

struct character
{
	private:
		double damage;
		double health;
		character_class type;
		unsigned int level;
		bool is_npc;

	public:
		template <property p, typename T> constexpr void set(T value)
		{
			switch(p)
			{
				case property::damage:
					damage = static_cast<double>(value);
					break;
				case property::health:
					health = static_cast<double>(value);
					break;
				case property::character_class:
					type = static_cast<character_class>(value);
					break;
				case property::level:
					level = static_cast<unsigned int>(value);
					break;
				case property::is_npc:
					is_npc = static_cast<bool>(value);
					break;
			}
		}

		template <property p, typename T> constexpr T get()
		{
			switch(p)
			{
				case property::damage:
					return static_cast<T>(damage);
				case property::health:
					return static_cast<T>(health);
				case property::character_class:
					return static_cast<T>(type);
				case property::level:
					return static_cast<T>(level);
				case property::is_npc:
					return static_cast<T>(is_npc);
			}
		}
};

Det er nu muligt f.eks. at lave en karakter til spilleren:

character player;
player.set<property::damage>(10.0);
player.set<property::health>(100.0);
player.set<property::character_class>(character_class::rogue);
player.set<property::level>(4);
player.set<property::is_npc>(false);

På samme måde er det muligt at læse data vha. get-metoden. Om dette interface er optimalt kan altid diskuteres, og er vel i sidste ende også et spørgsmål om smag - der er dog et problem med den nuværende kode!

Der er i øjeblikket intet der forhindrer os i at gøre følgende:

player.set<property::damage>(false);
player.set<property::health>(100);
player.set<property::character_class>(-1);
player.set<property::level>(3.14);
player.set<property::is_npc>(character_class::warrior);

Vores nuværende kode laver ingen type-check, så vi kan i øjeblikket smide hvad som helst in i set-metoden, også helt forkerte typer!

Men her kan templates komme os til undsætning med en super smart løsning - se følgende kode:

template <property p, typename T> constexpr void assert_type();
template <> constexpr void assert_type<property::damage, double>() {}
template <> constexpr void assert_type<property::health, double>() {}
template <> constexpr void assert_type<property::character_class, character_class>() {}
template <> constexpr void assert_type<property::level, unsigned int>() {}
template <> constexpr void assert_type<property::level, int>() {}
template <> constexpr void assert_type<property::is_npc, bool>() {}

Den første linie angiver bare en funktions-prototype, assert_type, som kræver to template-parametre (en property, se enum defineret tidligere, samt en type). Vi definerer aldrig en funktion, som passer helt på denne prototype - i stedet definerer vi template specializations i alle de efterfølgende linier.

En specialization af assert_type-prototypen betyder bare, at vi implementerer assert_type for en specifik property og typename. Vi kan lave alle de specialiseringer vi vil - og vi behøver ikke lave specialiseringer for alle de mulige værdier af property og typename (hvilket ville være en nærmest umulig opgave).

Lad os nu se hvad der sker, hvis vi opdaterer get/set-metoder med et kald til assert_type:

template <property p, typename T> constexpr void set(T value)
{
	assert_type<p,T>();
	... anden kode ...
}

template <property p, typename T> constexpr T get()
{
	assert_type<p,T>();
	... anden kode ...
}

Lad os prøve at se, hvad der sker i følgende tilfælde:

character player;
player.set<property::damage>(10.0);	// OK, type og property passer sammen
player.set<property::health>(100.0);	// OK, type og property passer sammen
player.set<property::character_class>(character_class::rogue);	// OK, type og property passer sammen
player.set<property::level>(4);	// OK, type og property passer sammen
player.set<property::is_npc>(false);	// OK, type og property passer sammen
player.set<property::damage>(false);	// Fejl (compile-time)!
player.set<property::health>(100);	// Fejl (compile-time)!
player.set<property::character_class>(-1);	// Fejl (compile-time)!
player.set<property::level>(3.14);	// Fejl (compile-time)!
player.set<property::is_npc>(character_class::warrior);	// Fejl (compile-time)!

Hvis man nu bruger en ugyldig værdi (dvs. forkert type), har vi ikke nogen tilsvarende implementation/specialisering af assert_type-prototypen, og det vil derfor resultere i en kompileringsfejl.

Og det bliver endnu bedre: Bemærk at assert_type er defineret som constexpr, og dermed kan afvikles compile-time - der er derfor tale om et type-check helt uden run-time cost! Det eneste overhead ved dette ligger compile-time, og påvirker ikke performance.

Bemærk også at der er lavet to specialiseringer til property::level, idet vi gerne bare vil kunne bruge en int til at angive level, selvom den underliggende type i character er unsigned int. På samme måde kan det f.eks. tillades at angive property::health med en int eller property::damage med float.

Man skal altså explicit tillade hver type til hver property med en template-specialisering. Dette kan i nogle tilfælde være en ulempe, men til gengæld slipper vi for implicit type-konvertering.

Udover type-checks som her, kunne man også sagtens lave checks på forskellige værdier, som man kender compile-time:

template <int i> constexpr void check_integer();
template <> constexpr void check_integer<2>() {}
template <> constexpr void check_integer<4>() {}

// Funktion der kan evalueres compile-time
constexpr int areal(int sideA, int sideB) { return sideA * sideB; }

... Brug af check_integer:
check_integer<1>();	// Fejl (compile-time), ikke defineret for 1
check_integer<2>();	// OK
check_integer<3>();	// Fejl (compile-time), ikke defineret for 3
check_integer<1+3>();	// OK
check_integer<areal(2,2)>();	// OK
check_integer<areal(2,3)>();	// Fejl (compile-time), ikke defineret for 6

Der er altså en del muligheder for at udføre compile-time checks vha. templates.

Relaterede ressourcer
C++ Templates: The Complete Guide

D. Vandevoorde, N. Josuttis, D. Gregor,
Addison-Wesley Professional, 2nd ed.

C++ Primer

S. Lippman, J. Lajoie, B. Moo,
Addison-Wesley Professional, 5th ed.

×