Minęło już sporo czasu odkąd ostatni raz zajmowałem się tematem konceptów w języku C++. Dziś mamy już przygotowany standard C++20, w którym w końcu pojawią się tak długo wyczekiwane koncepty. Warto więc przyjrzeć się im ponownie i zobaczyć w jakiej formie trafią do języka. Zapraszam więc do lektury!

Na początku przypomnijmy czym są koncepty. Koncept to po prostu zbiór cech, które musi posiadać klasa. Klasa spełnia koncept, jeśli posiada każdą cechę wymaganą przez koncept. Dla przykładu: jeśli zdefiniujemy koncept Dodawalna, który wymaga, aby można było dodać ze sobą dwie instancje klasy, to klasy posiadające operator dodawania będą spełniać koncept Dodawalna.

Dlaczego koncepty mogą być przydatne? Głównie z dwóch powodów. Po pierwsze, tworząc biblioteki szablonowe niejednokrotnie zakładamy pewne obostrzenia na typie, który będzie przekazany do szablonu. Np. przekazując iteratory do funkcji std::sort() zakładamy, że te iteratory pozwalają na swobodny dostęp do elementów (są RandomAccessIterator). Przed konceptami nie mieliśmy możliwości wyrażenie tego oczekiwania w sposób czytelny dla programisty i możliwy do sprawdzanego przez kompilator (konstrukcja std::enable_if nie była raczej wystarczająco czytelna, a komentarze do kodu czy opisy w dokumentacji nie były sprawdzane przez kompilator).

Drugim powodem jest czytelność komunikatu błędu kompilacji. Przed konceptami, gdy podaliśmy np. do wspomnianej funkcji std::sort() iteratory do listy (czyli nie będące RandomAccessIterator) to komunikat błędu ciągnął się przez wiele linii i wskazywał miejsce głęboko w bibliotece. Ta nieczytelność błędu zostaje rozwiązana przez koncept. Jeśli zdefiniowalibyśmy funkcję std::sort() jako przyjmującą dwa iteratory, które spełniają koncept RandomAccessIterator to przy podaniu iteratorów do listy od razu otrzymamy komunikat, że przekazane argumenty nie spełniają wymaganego konceptu.

Przejdźmy jednak do samej składni konceptów przyjętej w standardzie C++20. Przypomina ona bardzo Texas Proposal oraz podejście usage-pattern, które pojawiały się w trakcie dyskusji nad tym jak powinny wyglądać koncepty (zainteresowanych historią rozwoju konceptów odsyłam do swojej pracy licencjackiej C++ Concepts – complete overview). Dla zobrazowania składni posłużmy się następującym przykładem. Potrzebujemy stworzyć szablonową funkcję, która będzie wyliczała potęgę. Wykładnik będzie liczbą całkowitą, ale podstawa będzie typu szablonowego. Ów szablonowy typ powinien posiadać możliwość mnożenia swoich instancji. Oto jak moglibyśmy zaimplementować wymaganą funkcję z użyciem konceptów:

#include <concepts>

template <typename T>
concept Multipliable = requires(T a, T b)
{
    {a * b} -> std::convertible_to<T>;
};

template <Multipliable T>
T power(T base, unsigned long exponent)
{
    T result = base;
    while(--exponent)
    {
        result = result * base;
    }
    return result;
}

W powyższym przykładzie zdefiniowaliśmy za pomocą słowa kluczowego concept koncept Multipliable , który wymaga (requires), aby mając dwie instancje typu, który ma spełniać definiowany koncept ((T a, T b)):

  1. wyrażenie a * b było poprawne oraz
  2. typ zwracany powyższego wyrażenia był konwertowalny na typ T ({} -> std::convertible_to).

Następnie korzystamy ze zdefiniowanego konceptu przy definicji funkcji szablonowej power() zamieniając słowo kluczowe typename na nazwę konceptu. Tym samym dajemy informację, że typ szablonowy musi spełniać koncept Multipliable.

Poniżej przykład poprawnego i niepoprawnego użycia szablonowej funkcji:

#include <iostream>

template <unsigned MODULO>
struct IntegerModulo
{
    long value;
    
    friend IntegerModulo operator*(IntegerModulo const& lhs, IntegerModulo const& rhs)
    {
           return {(lhs.value * rhs.value) % MODULO};
    }
    
    friend std::ostream& operator<<(std::ostream& os, IntegerModulo const& integer)
    {
        return os << integer.value;
    }
};

int main()
{
    std::cout << power(2, 10) << std::endl;
    std::cout << power(IntegerModulo<1000>{2}, 10) << std::endl;
    std::cout << power("a", 2) << std::endl;
}

Przy próbie kompilacji dostajemy następujący błąd:

prog.cc: In function 'int main()':
prog.cc:42:30: error: use of function 'T power(T, long unsigned int) [with T = const char*]' with unsatisfied constraints
   42 |     std::cout << power("a", 2) << std::endl;
      |                              ^
prog.cc:10:3: note: declared here
   10 | T power(T base, unsigned long exponent)
      |   ^~~~~
prog.cc:10:3: note: constraints not satisfied
prog.cc: In instantiation of 'T power(T, long unsigned int) [with T = const char*]':
prog.cc:42:30:   required from here
prog.cc:4:9:   required for the satisfaction of 'Multipliable<const char*>'
prog.cc:4:24:   in requirements with 'const char* a', 'const char* b'
prog.cc:6:8: note: the required expression '(a * b)' is invalid
    6 |     {a * b} -> std::convertible_to<T>;
      |      ~~^~~

Jak dość łatwo zauważyć kompilator informuje nas, że podajemy do funkcji power() instancję klasy, która nie spełnia konceptu. Poniżej podaje nawet konkretne wymaganie, które nie zostało spełnione.

Jeśli zakomentujemy linię 42 to program się skompiluje, a po uruchomieniu otrzymamy następujący wynik:

1024
24

Widzimy więc, że koncepty działają i spełniają swoje zadanie. Cały mechanizm ma jednak dużo więcej możliwości. Więcej o konceptach przeczytacie w kolejnych postach, które pojawią się wkrótce. Zapraszam więc do śledzenia!


0 komentarzy

Dodaj komentarz

Avatar placeholder

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *