Alexander Stepanov powiedział:
Concepts are all about semantics.
Koncept ma mieć jakieś znaczenie. Jak mówimy o liczbie całkowitej, to wiemy o co chodzi, nie musimy precyzyjnie opisywać jak, na przykład, jest ona reprezentowana w pamięci komputera, bo jest to już raczej szczegół implementacyjny. Koncept jest pewną ugruntowaną ideą.
Możemy powiedzieć, że typ danych int
już jest konceptem: jest to typ
całkowity ze znakiem. A już jego implementacja zależy od systemu: typ
może być 24 bitowy lub 32 bitowy, little-endian lub big-endian.
Koncepty przeszły długą drogę, żeby w końcu być w C++20. Od strony technicznej programowania w C++, koncepty pozwalają na wygodną i pełną definicję wymagań stawianych argumentom szablonu. Dzięki konceptom wzbogacono i uproszczono programowanie uogólnione w C++, a komunikaty o błędach generowane przez kompilator są jasne.
Koncept jako idea programowania funkcjonuje od dekad. Koncept to zbiór minimalnych warunków dotyczących typu. Ponieważ mówimy o minimalnych warunkach, to koncepty są minimalistyczne: stawiane są tylko te wymagania, które są niezbędne. W ten sposób programowanie uogólnione ma swoje korzenie w algebrze abstrakcyjnej zapoczątkowanej przez Emmy Noether, gdzie struktury algebraiczne (np. monoid) definiuje się przez minimalne warunki. W informatyce, podstawowe własności typów (a w tym i struktur danych) możemy opisać własnościami struktur algebraicznych.
Możemy nawet powiedzieć, że std::set<T>
i std::vector<T>
są
konceptami. Możemy powiedzieć nawet, że sortowanie jest pewnym
konceptem, pewną ideą oderwaną od typu sortowanych danych. W C++
jednak koncepty dotyczą wyłącznie danych, a nie algorytmów. Na
przykład, w C++ nie ma sposobu upewnienia się, że algorytm sortuje
stabilnie. Jeżli chcemy sortować stabilnie, to powinniśmy wybrać
std::stable_sort
, w przeciwnym razie std::sort
.
Koncept jako idea był zastosowany od początku lat dziewięćdziesiątych przez Alexandra Stepanova w standardowej bibliotece szablonów (STL). Już wtedy, w dokumentacji STLa na stronie Silicon Graphics (SGI) mówiono o iteratorze jako koncepcie, o koncepcie jako abstrakcyjnej idei. Wtedy nie bardzo wiedziałem o co chodzi, a dokumentacja tych konceptów nie trafiała do mnie, bo koncepty nie były wprost wpisane w przykładowe programy, więc mi się to po prostu nie kompilowało. Koncepty jako idea były, ale nie były zdefiniowane w postaci biblioteki C++, bo język nie miał takiej funkcjonalności. Teraz jest i to bogata.
Koncept to nazwane ograniczenie. Ponieważ ograniczenie jest predykatem czasu kompilacji, to koncept też nim jest, ale nazwanym. Ponieważ ograniczenie jest sparametryzowane (ma parametry, więc przyjmuje argumenty), to koncept też jest sparametryzowany, czyli jest szablonem. Tak więc w C++ szablony są nie tylko struktur danych, funkcji, czy typów, ale też konceptów.
W bibliotece standardowej C++, główną biblioteką konceptów jest
concepts
, ale koncepty są zdefiniowane też w innych bibliotekach.
Na przykład w bibliotece iterator
zdefiniowano koncept
incrementable
.
Koncept definiujemy tak:
template <parameter list>
concept nazwa = constraint;
Konceptu nie można ograniczyć, czyli po liście parametrów nie możemy
napisać requires
i podać ograniczenie na parametry.
Oto prosty przykład:
#include <iostream>
#include <string>
using namespace std;
template <typename T>
concept incr = requires (T t) {++t;};
template <typename T> requires incr<T>
void
inc(T &t)
{
++t;
}
int
main()
{
int x = 1;
inc(x);
string s;
// inc(s);
}
Raz zdefiniowany koncept możemy użyć w różnych miejscach. Gdybyśmy nie chcieli używać konceptów, a jedynie ograniczeń, to musielibyśmy te ograniczenia (często rozbudowane) kopiować w każde miejsce użycia. Wiemy z autopsji, że kopiowanie i wklejanie to najlepszy sposób na wprowadzenie błędów.
Koncepty wprowadzają porządek. Biblioteka standardowa definiuje standardowe koncepty. Jak użyjemy standardowego konceptu, to każdy będzie wiedział, o co nam chodziło (także my sami, jak już zapomnimy).
Korzystając z konceptów możemy skrócić definicję szablonów. Zamiast
deklarować typowy parametr T
z użyciem typename
i potem definiować
ograniczenie na tym parametrze, na przykład, requires incr<T>
, to
możemy zadeklarować parametr szablonu już nie z użyciem typename
,
ale jako spełniający koncept incr
, czyli jako incr T
. Oto
przykład:
#include <iostream>
#include <string>
using namespace std;
template <typename T>
concept incr = requires (T t) {++t;};
template <incr T>
void
inc(T &t)
{
++t;
}
int
main()
{
int x = 1;
inc(x);
string s;
// inc(s);
}
C++ pozwala nam jeszcze bardziej skrócić definicję szablonu. Już nie
musimy pisać, że chodzi o szablon, którego parametr spełnia jakiś
koncept. Teraz możemy zdefiniować szablon funkcji używając nazwy
konceptu jako typu parametru funkcji, po której podajemy specyfikator
typu auto
. Jako pierwszy argument konceptu będzie przekazany
wywnioskowany typ, ten który jest podstawiany w miejsce auto
. Oto
przykład:
#include <iostream>
#include <string>
using namespace std;
template <typename T>
concept incr = requires (T t) {++t;};
void
inc(incr auto &t)
{
++t;
}
int
main()
{
int x = 1;
inc(x);
string s;
// inc(s);
}
Przy deklaracji typu parametru funkcji, nazwę konceptu możemy nawet
pominiąć, pozostawiając typ auto
– tak możemy zdefiniować w sposób
skrócony szablon funkcji. W przykładzie niżej definiujemy dwa
przeciążenia szablonu funkcji print
: jedno bez ograniczeń, a drugie
dla typu spełniającego koncept integral
. Wygląda prosto, ale wiemy,
że skomplikowane.
#include <concepts>
#include <iostream>
using namespace std;
void
print(const auto &x)
{
cout << "Print: " << x << endl;
}
void
print(const integral auto &x)
{
cout << "Print for integrals: " << x << endl;
}
int
main()
{
print(1);
print(.1);
print("Hello World!");
}
Jeżeli użyjemy skróconej definicji szablonu, to tracimy nazwę
parametru szablonu, który musimy przekazać funkcji std::forward
w
przypadku doskonałego przekazywania argumentów. W tej sytuacji cel
możemy osiągnąć przekazując funkcji std::forward
typ parametru
funkcji (zadeklarowanego jako referencja przekazująca), który możemy
otrzymać z użyciem specyfikatora decltype
. Oto przykład:
#include <iostream>
#include <utility>
using namespace std;
template <typename T>
concept incr = requires (T t) {++t;};
void
g(int &)
{
cout << "l-value\n";
}
void
g(int &&)
{
cout << "r-value\n";
}
void
f(incr auto &&x)
{
g(std::forward<decltype(x)>(x));
}
int
main()
{
int x = 1;
f(x);
f(1);
}
Ograniczenia i koncepty mogą dotyczyć także typu callable. W ten sposób możemy określić dla jakich callable (jakie warunki ma spełniać typ callable) może być użyty szablon funkcji.
W przykładzie niżej używamy szablonu wariadycznego do zdefiniowania
konceptu Callable
. Pierszym argumentem szablonu jest typ callable,
a kolejne argumenty definiują typy argumentów, które chcemy przekazać
do wywołania callable. Jeżeli callable będzie można tak wywołać, to
koncept Callable
będzie spełniony.
template <typename F, typename... P>
concept Callable = requires (F f, P... p)
{
f(p...);
};
void f(int, bool);
void g();
void g(int);
int main()
{
// Here we pass a function type 'void()', and make sure we can call
// it with no argument.
static_assert(Callable<void()>);
// Can we call f with an int and a bool?
static_assert(Callable<decltype(f), int, bool>);
// We cannot just say 'decltype(g)', because the compiler wouldn't
// know which overload we mean, and thefore we need to static_cast.
static_assert(Callable<decltype(static_cast<void(*)()>(g))>);
static_assert(Callable<decltype(static_cast<void(*)(int)>(g)), int>);
// The declaration below overshadows the 'void g()' overload, so now
// (that there's just one g in scope) we can say 'decltype(g)'.
void g(int);
static_assert(Callable<decltype(g), int>);
}
W przykładzie niżej definiujemy także typ EmptyCallable
, który nic
nie robi i który jest domyślnym argumentem szablonu funkcji f
.
Zdefiniowaliśmy także domyślny argument {}
(bezargumentowa
inicjalizacja) wywołania funkcji f
. Wnioskowany argument (dla
parametru T
) szablonu funkcji f
jest pierwszym argumentem konceptu
Callable
, int
staje się drugim, a string
trzecim.
EmptyCallable
nie wprowadza żadnego narzutu.
Oto przykład:
#include <functional>
#include <iostream>
#include <string>
using namespace std;
template <typename F, typename ... Args>
concept Callable = requires(F f, Args ... args)
{
f(args...);
};
template <typename ... Args>
using EmptyCallable = decltype([](Args ...){});
template <Callable<int, string> T = EmptyCallable<int, string>>
void f(T t = {})
{
t(1, "Hello!");
}
void
foo(int i, string s)
{
cout << "foo: " << i << ", " << s << endl;
}
struct A
{
void
operator()(int i, string s)
{
cout << "A: " << i << ", " << s << endl;
}
};
int main()
{
// The function is called with a default-constructed EmptyCallable.
f();
// Error: the constraint of the concept not satisfied: the closure
// (i.e., its call operator) accepts no argument, while it should
// accept an int and a string.
// f([]{});
f(foo);
f(function(foo));
f([](int i, string s)
{cout << "lambda: " << i << ", " << s << endl;});
f(function([](int i, string s)
{cout << "lambda: " << i << ", " << s << endl;}));
f(A());
f(function(A()));
}
Ale biblioteka standardowa ma już koncept podobny do naszego
Callable
, którym jest invocable
. Teraz nasz przykład jest lepszy:
#include <concepts>
#include <iostream>
#include <string>
using namespace std;
template <typename ... Args>
using EmptyCallable = decltype([](Args ...){});
template <typename T = EmptyCallable<int, string>>
void f1(T t = {}) requires invocable<T, int, string>
{
t(2, "Hello!");
}
template <invocable<int, string> T = EmptyCallable<int, string>>
void f2(T t = {})
{
t(1, "Hello!");
}
void f3(invocable<int, string> auto t = EmptyCallable<int, string>{})
{
t(3, "Hello!");
}
void
foo(int i, string s)
{
cout << "foo: " << i << ", " << s << endl;
}
int main()
{
f1();
f1(foo);
f2();
f2(foo);
// The following should compile, I reckon, because the variable t
// below (which is just like the parameter of function f3) compiles
// fine. Yet, it doesn't with GCC 12.1.0.
// f3();
invocable<int, string> auto t = EmptyCallable<int, string>{};
f3(foo);
}
Koncept to nazwane ograniczenie.
Koncept z idei stał się funkcjonalnością C++20.
Podstawowe koncepty zostały zdefiniowane w bibliotece standardowej.
Kiedy warto używać konceptów a nie ograniczeń?
Na czym polega skrócony zapis szablonu funkcji?
Jak doskonale przekazać argument wywołania funkcji, kiedy jej szablon jest zadeklarowany w skrócony sposób?