cpp

Wprowadzenie

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 w C++20

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).

Skrócone zapisy

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!");
}

Doskonałe przekazywanie argumentów

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);
}

Przykład

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);
}

Podsumowanie

Quiz