Do C++20 szablony nie pozwalały na wprowadzenie ograniczeń dotyczących argumentów szablonu. Jeżeli argument jest niedozwolony, to dopiero na etapie konkretyzacji szablonu otrzymamy błąd, że ciało funkcji nie kompiluje się, ze wskazaniem w której linii jest błąd.
W przypadku prostego kodu łatwo zorientujemy się o co chodzi, jak na przykład tu:
#include <string>
using namespace std;
template <typename T>
void
inc(T &t)
{
++t;
}
template <unsigned I>
int
divide(int t)
{
return t / I;
}
int
main()
{
int x = 1;
inc(x);
std::string y("Hello World!");
// Fails at compile time because std::string is not incrementable.
// We shouldn't be allowed to use std::string.
// inc(y);
divide<2>(2);
// Fails at run time because of the division by zero.
// We shouldn't be allowed to use 0.
divide<0>(2);
}
Ale to jest problem w przypadku programu korzystającego z bibliotek, do których kodu nie chcemy nawet zaglądać. Oczywiście możemy zajrzeć i może się domyślimy w czym problem (często po długim czasie i ze sporym wysiłkiem), ale kto chce to robić. Chcemy lepszej diagnostyki błędów.
Lepsza diagnostyka polega na sprawdzeniu warunków stawianych argumentom szablonu, co możemy zrobić na dwa sposoby:
stary sposób (C++11): static_assert
,
nowy sposób (C++20): ograniczenia.
static_assert
Statyczna asercja static_assert
sprawdza w czasie kompilacji, czy
argument asercji jest prawdą.
Przykład:
#include <string>
#include <type_traits>
using namespace std;
template <typename T>
void
inc(T &t)
{
static_assert(std::is_integral_v<T>, "Gimme an integral type.");
++t;
}
template <unsigned I>
int
divide(int t)
{
static_assert(I != 0, "Cannot be 0.");
return t / I;
}
int
main()
{
auto x = 1;
inc(x);
auto y = .1;
// Error: double is not an integral.
// inc(y);
divide<2>(2);
// Error: the argument can not be 0.
// divide<0>(2);
}
Do sprawdzenia niektórych warunków możemy użyć standardowej biblioteki cech typów (ang. type traits). Cecha typu jest szablonem (struktury lub zmiennej), który dostarcza nam informacji (typów, stałych wartości) w czasie kompilacji na temat dowolnego typu, który jest argumentem szablonu.
W przykładzie wyżej użyliśmy cechy typu std::is_integral_v<T>
, który
jest prawdą, jeżeli typ T
jest całkowity. Ta cecha typu jest
szablonem stałej zmiennej (chociaż brzmi to dziwnie), która istnieje
tylko w czasie kompilacji (nie w czasie uruchomienia).
Błąd niespełnionej statycznej asercji jest jakąś lepszą diagnostyką, ale to ciągle błąd kompilacji ciała funkcji, który kończy kompilację. Lepiej jest mieć możliwość definicji warunków jako część definicji interfejsu, poza ciałem funkcji.
Ograniczenie (ang. constraint) podajemy jako część definicji interfejsu, czyli w części deklaracyjnej szablonu, a nie w ciele szablonu. Cześć deklaracyjna szablonu to wszystko oprócz ciała szablonu. Ograniczenia to funkcjonalność C++20.
Przykład:
#include <string>
#include <type_traits>
using namespace std;
template <typename T> requires std::is_integral_v<T>
void
inc(T &t)
{
++t;
}
template <unsigned I> requires (I != 0)
int
divide(int t)
{
return t / I;
}
int
main()
{
auto x = 1;
inc(x);
auto y = .1;
// Error: double is not an integral.
// inc(y);
divide<2>(2);
// Error: the argument cannot be 0.
// divide<0>(2);
}
Ograniczenie to predykat czasu kompilacji, który definiuje warunki dotyczące argumentów szablonu. Predykat czasu kompilacji to wyrażenie typu logicznego, którego wartość jest znana w czasie kompilacji. Ograniczenie jest warunkiem, który musi być spełniony (czyli wartością ma być prawda) przez argumenty szablonu, żeby można było użyć szablonu.
Ograniczenie podajemy po słowie kluczowym requires
po liście
parametrów szablonu funkcji czy struktury danych. Słowo kluczowe
requires
wraz z następującym ograniczeniem nazywamy klauzulą
ograniczenia (ang. a requires-clause).
Predykat może być alternatywą czy koniunkcją, których operandami mogą być kolejne predykaty. Jeżeli predykat jest wyrażeniem, które wymaga opracowania (na przykład wykonania porównania), to należy wyrażenie ująć w nawiasy.
Na przykład:
#include <iostream>
#include <type_traits>
using namespace std;
template <unsigned I, typename T>
requires (I != 0) && is_arithmetic_v<T>
T
divide(T t)
{
return t / I;
}
int
main()
{
cout << divide<2>(4) << endl;
// cout << divide<0>(4) << endl;
// cout << divide<2>("Hi!") << endl;
}
Są warunki do sprawdzenia, których nie sprawdzimy używając cech typów, a przynajmniej nie tych standardowych. Na przykład, jak sprawdzić, czy typ danych, który jest argumentem szablonu, ma zdefiniowany operator inkrementacji? Najlepiej sprawdzić, czy fragment kodu z inkrementacją kompilowałby się. I do tego właśnie służy wyrażenie ograniczenia.
Wyrażenie ograniczenia (ang. a requires-expression) jest predykatem
czasu kompilacji. Zaczyna się od słowa kluczowego requires
, po
którym następuje opcjonalna lista parametrów (taka jak w funkcji), po
czym następuje ciało zawierające definicje warunków zakończone
średnikami. Wyrażenie jest prawdziwe, jeżeli spełnione są wszystkie
warunki. Taka jest więc składnia:
requires (parameter-list) {expression body}
Przykład:
#include <string>
using namespace std;
template <typename T> requires requires (T t) {++t;}
void
inc(T &t)
{
++t;
}
struct A
{
A &
operator++()
{
return *this;
}
};
int
main()
{
int x = 1;
inc(x);
A a;
inc(a);
std::string y("Hello World!");
// This would not compile, because std::string is not incrementable.
// inc(y);
}
W przykładzie wyżej requires requires
to nie błąd. Pierwsze
requires
wprowadza klauzulę ograniczenia, a drugie requires
wprowadza wyrażenie ograniczenia. Wyrażenie ograniczenia jest
predykatem klauzuli ograniczenia. Słowo kluczowe requires
ma po
prostu dwa różne, chociaż pokrewne, zastosowania.
Lista parametrów w wyrażeniu ograniczenia jest opcjonalna i możemy ją pominąć, na przykład wtedy, kiedy w ciele wyrażenia ograniczenia użyjemy parametrów funkcji. Żeby jednak użyć parametrów funkcji, to klauzulę ograniczenia musimy podać po liście parametrów funkcji, a nie po liście argumentów szablonu, jak wcześniej. Oto przykład:
#include <string>
using namespace std;
template <typename T>
void
inc(T &t) requires requires {++t;}
{
++t;
}
int
main()
{
int x = 1;
inc(x);
}
Możemy także sprawdzić dostępność typu składowego klasy, na przykład typu iteratora:
#include <array>
#include <string>
#include <utility>
using namespace std;
template <typename C> requires requires {typename C::iterator;}
void
iterate(C &t)
{
}
int
main()
{
array a{1, 2};
iterate(a);
std::string s("Hello World!");
iterate(s);
pair p{1, .1};
// Error: pair doesn't have the iterator type.
// iterate(p);
}
Możemy sprawdzić typ wyrażenia jak w drugim wyrażeniu ograniczenia w
poniższym przykładzie. Sprawdzamy typ wyrażenia c.begin()
, które
umieszczamy w {}
. Potem następuje ->
, a potem warunek. Warunkiem
jest szablonowy predykat, za którego pierwszy argument jest
podstawiany typ testowanego wyrażenia. Czyli w przykładzie niżej
sprawdzamy same_as<typ testowanego wyrażenia, typename C::iterator>
,
gdzie std::same_as
jest dwuargumentowy konceptem (koncept to
szablon predykatu).
Próbowałem użyć cechy typu is_same_v
jako warunku, zamiast konceptu
same_as
, ale nie kompiluje się. Nie wiem dlaczego.
#include <array>
#include <string>
#include <utility>
using namespace std;
template <typename C> requires requires {typename C::iterator;}
void
iterate(C &t) requires requires
{
{t.empty()} -> same_as<bool>;
{t.begin()} -> same_as<typename C::iterator>;
}
{
}
int
main()
{
array a{1, 2};
iterate(a);
std::string s("Hello World!");
iterate(s);
pair p{1, .1};
// Error: pair doesn't have the iterator type.
// iterate(p);
}
Jeżeli funkcja ma przyjąć jako argument wywołania obiekt typu, który
dziedziczy po klasie bazowej A
, to możemy ten obiekt przyjąć przez
referencję na obiekt klasy A
, tak jak robi to funkcja foo1
w
przykładzie niżej. W ten sposób funkcja działa zawsze na obiekcie
typu A
, czyli tracimy typ argumentu.
Możemy też napisać szablon funkcji (foo2
w przykładzie niżej), która
przyjmie obiekt dowolnego typu i będzie używała składowych klasy A
,
bez deklarowania tego wymogu, co może się skończyć błędem kompilacji.
Kolejną możliwością jest napisanie szablonu funkcji (foo3
w
przykładzie niżej) i zdefiniowanie ograniczenia: przyjmujemy dowolny
typ T
wyprowadzony z A
. W ten sposób funkcja działa na obiekcie
typu T
, czyli nie tracimy typu argumentu.
#include <concepts>
#include <iostream>
#include <type_traits>
using namespace std;
struct A
{
virtual void hello() const
{
cout << __PRETTY_FUNCTION__ << endl;
}
};
struct B: A
{
void hello() const override
{
cout << __PRETTY_FUNCTION__ << endl;
}
};
struct C
{
};
void
goo(const A &)
{
cout << "A\n";
}
void
goo(const B &)
{
cout << "B\n";
}
// For objects of any class derived from A.
void
foo1(const A &a)
{
cout << __PRETTY_FUNCTION__ << endl;
// This call has to be virtual.
a.hello();
goo(a);
}
// For objects of any class that has the hello function.
template <typename T>
void
foo2(const T &t)
{
cout << __PRETTY_FUNCTION__ << endl;
// This call does not have to be virtual.
t.hello();
goo(t);
}
// For objects of any class derived from A.
template <typename T> requires std::derived_from<T, A>
void
foo3(const T &t)
{
cout << __PRETTY_FUNCTION__ << endl;
// This call does not have to be virtual.
t.hello();
goo(t);
}
int
main()
{
A a;
B b;
C c;
foo1(a);
foo1(b);
// Error with a clear message from the interface.
// foo1(c);
foo2(a);
foo2(b);
// Error with an ugly message from the implementation.
// foo2(c);
foo3(a);
foo3(b);
// Error with a clear message from the interface.
// foo3(c);
}
Szablony funkcji można przeciążać pod względem ograniczeń, ponieważ są one częścią interfejsu. Szablony bez spełnionych warunków są pomijane, bez zgłaszania błędu.
Dla danego wywołania funkcji, co najmniej dla jednego przeciążenia powinny zostać poprawnie wywnioskowane argumenty spełniające ograniczenia. Jeżeli takich przeciążeń nie ma, zgłaszany jest błąd.
Jeżeli takich przeciążeń jest więcej, to wybierane jest to bardziej wyspecjalizowane, którego warunki nie są spełniane przez pozostałe przeciążenia. Jeżeli kompilator nie jest w stanie wybrać jednego przeciążenia, zgłaszany jest błąd niejednoznaczności (ang. ambiguity error).
#include <concepts>
#include <iostream>
using namespace std;
template <unsigned I, typename T> requires integral<T>
void
divide(T x)
{
cout << "divide: integral" << endl;
}
template <unsigned I, typename T> requires (I == 0) && integral<T>
void
divide(T x)
{
cout << "divide: don't divide integrals by 0" << endl;
}
int
main()
{
int x = 1;
divide<0>(x);
divide<1>(x);
}
Tutaj jest kolejny przykład, który nie kompiluje się w całości: jest problem z wyborem przeciążenia dla zakomentowanego wywołania funkcji. Problem może być rozwiązany wyłącznie z użyciem konceptów.
#include <iostream>
using namespace std;
enum class sex:bool {male, female};
template <sex S, unsigned I> requires (I >= 18)
void
play()
{
cout << "Toys for adults.\n";
}
template <sex S, unsigned I> requires (I < 18)
void
play()
{
cout << "Toys for kids.\n";
}
template <sex S, unsigned I> requires (S == sex::male) && (I < 18)
void
play()
{
cout << "Toys for boys.\n";
}
int
main()
{
// play<sex::male, 10>();
play<sex::female, 10>();
play<sex::male, 20>();
play<sex::female, 20>();
}
Przeciążeń szablonów pod względem spełniania warunków nie zaimplementujemy ze statyczną asercją, bo ona zawsze kończy kompilację błędem dla niespełnionego warunku.
Ograniczenia to mechanizm czasu kompilacji. Nie wprowadzają żadnego narzutu w czasie uruchomienia.
Ograniczenia pozwalają na definicję warunków szablonu.
Ograniczenia pozwalają na przeciążanie szablonów.
Dlaczego ograniczenia są lepsze od statycznych asercji?
Dlaczego “requires requires” to nie błąd?
W jaki sposób możemy dostarczyć różne implementacje szablonu funkcji w zależności od wymagań dotyczących argumentów szablonu?