blog.michalt.pl

Koncepty w C++ 20

21.07.2019 18:59 - Tech

Dziś wracam do Was z dwudziestym standardem języka C++. W tym artykule chciałbym poruszyć tematykę konceptów. Z radością informuję, że działają one na kompilatorze GCC w wersji 8.3 i wyższych. Co do wcześniejszych wersji, nie mam pewności. Ja pracuję ze snapshotem GCC 10 z 14 lipca i GCC w wersji 8.3.0. Niestety do przykładów w kodzie C++, delikatnie rzecz ujmując, zabrakło mi inwencji, ale myślę, że dobrze ilustrują one ideę konceptów. Jeśli pierwszy raz zaglądasz na mojego bloga, zachęcam Cię do zapoznania się z wcześniejszymi wpisami poświęconymi C++20. „Kilka słów o modułach w C++20, ale niestety tylko teoretycznie” i „Króciutko i teoretycznie o kontraktach w C++ 20”


Gdy pierwszy raz zetknąłem się z szablonami i programowaniem generycznym, byłem zaskoczony, że coś takiego jak koncepty nie istnieje. Dostrzegałem wiele problematycznych wariantów funkcji generycznych, które można by ująć w pewne grupy i nadać im generyczne implementacje zależnie od cech przyjmowanych typów. Od tamtego czasu minęło kilka lat i wiele się zmieniło… Koncept (często nazywany również koncepcją) jest narzędziem nakładającym na szablon ograniczenia co do cech przyjmowanych typów. Co ciekawe, istnieje możliwość ich przeciążania, ale o tym na końcu.


Co się stanie, jeśli funkcja, metoda lub klasa otrzymają w argumencie typ niespełniający wymagań konceptu? Wówczas kod nie powinien się skompilować i powinniśmy otrzymać komunikat wyglądający mniej więcej tak: „error: cannot call function ‘T someFunction(Type) [with Type = someType] (…) note:   constraints not satisfied”.


Czas na kod. Nasz przykład teoretycznie można by ująć w funkcji niekorzystającej z programowania generycznego:

char getFirstCharFromNumber(int n) {
  return std::to_string(std::fabs(n))[0];
}

Czy aby na pewno jest on bezpieczny?

Zacznijmy najpierw od celu, a jest nim funkcja, która będzie zwracała pierwszy znak liczby w postaci wartości typu char. Czyli dla 4096 zwrócona zostanie wartość typu char ‘4’, dla -60: ‘6’ i dla 3.14 char o wartości ’3’. Ma ona jednak pewne ograniczenia. Po pierwsze, muszą to być liczby typu nie większego niż 4 bajty (tu pojawia się pułapka, o której wspominam dalej), po drugie argument musi być liczbą.

Co się stanie, gdy wywołamy naszą funkcję z argumentem typu long long? Dopóki nie osiągnie ona odpowiednio dużych wartości, wszystko będzie ok. Natomiast, jeśli nastąpi wywołanie z wartością 7123456789876543210 okaże się, że zwróconym znakiem nie będzie ‘7’, tylko ‘1’. Z pozoru to mała katastrofa, bo niby jest to tylko druga cyfra od lewej i „nastąpiło drobne przycięcie”. Czy aby na pewno? Zrzutujmy ją zatem na int. Wynik 1836027626 (int 4 bajty). Czemu tak? Ponieważ po przekroczeniu długości typu dochodzi do tzw. przekręcenia typu. Na przykład minimalna wartość dla int32_t to -2147483648, a maksymalna to 2147483647. Jeśli do 2147483647 dodamy 1, otrzymamy -2147483648. Łatwo tu o błędy, no nie? A to jeszcze nie koniec pułapek! Mamy tu do czynienia z typem o nie do końca określonym rozmiarze (zależnie od kompilatora i architektury, int może liczyć 2 lub 4 bajty), co może powodować zwracanie różnych wyników. Można użyć typu o określonym rozmiarze i dużej wielkości (np. int64_t), ale przy wielu równoległych operacjach może to spowodować duży narzut pamięci i nie będzie to oznaczać końca kłopotów. Problemem mogą się okazać przypadki użycia typów bez znaków, które mają ten sam rozmiar i inne zakresy wartości!

Wyobraźmy sobie następujący scenariusz:

 int a = UINT_MAX;
 unassigned int b = INT_MIN;

Zmienna ‘a’ otrzyma wartość -1, mimo próby przypisania 4294967295, a zmienna b 2147483648, mimo próby przypisania -2147483648. W przypadku naszej funkcji niebezpieczne okazuje się wywołanie getFirstCharFromShortNumber(UINT32_MAX); gdzie UINT32_MAX wynosi 4294967295.


Spróbujmy z konceptami!

Najpierw warto się zastanowić nad tym, co mamy do dyspozycji, w celu sprawdzenia naszych typów wejściowych. Tu w pierwszej kolejności z pomocą przychodzi nam biblioteka type_traits, wchodząca w skład STL – jej zawdzięczamy strukturę sprawdzającą, czy typ jest numeryczny, tj. is_arithmetic. Rzecz polega na tym, że przy okazji, chcielibyśmy sprawdzać rozmiar naszego typu i ograniczyć go do 4 bajtów.

W tym celu przygotowujemy sobie strukturę dokonującą potrzebnego nam sprawdzenia:

template<typename NUMT>
struct is_small {
 static const bool value = (sizeof(NUMT) <=4);
};

Następnym krokiem będzie utworzenie konceptu SmallNumber, który za pośrednictwem naszej struktury i is_aritmetic pochodzącej z type_traits zweryfikuje, czy nasz typ jest liczbą o typie mniejszym lub równym 4 bajty:

template<typename NUM>
concept bool SmallNumber(){
  return is_small<NUM>::value && std::is_arithmetic<NUM>::value;
}  

Następnie przygotowujemy udoskonalony, oparty na konceptach wariant naszej funkcji:

template <SmallNumber Number> 
char getFirstCharFromNumber(Number element) {
  return std::to_string(std::fabs(element))[0];
}

...i voilà… :)


Jak już wspomniałem, koncepty można przeciążać. Wyobraźmy sobie, że chcemy z jakiegoś powodu mieć dwie oddzielne funkcje: jedną dla małych liczb, drugą dla wielkich. Mając już gotowy zestaw kodu, można dopisać do niego kolejną strukturę, tym razem weryfikującą, czy nasz typ jest większy niż 4 bajty:

template<typename NUMT>
struct is_huge {
 static const bool value = (sizeof(NUMT) >4);
};
 

Następnie dopisujemy koncept:

template<typename NUM>
concept bool HugeNumber(){
  return is_huge<NUM>::value && std::is_arithmetic<NUM>::value;
}  

...a także funkcję, która z niego korzysta:

template <HugeNumber Number> 
char getFirstCharFromNumber(Number element) {
  return std::to_string(std::fabs(element))[0];
}

Całość:

#include <iostream> 
#include <type_traits>
#include <cmath>
#include <climits>
#include <cfloat>


template<typename NUMT>
struct is_small {
 static const bool value = (sizeof(NUMT) <=4);
};


template<typename NUMT>
struct is_huge {
 static const bool value = (sizeof(NUMT) >4);
};

template<typename NUM>
concept bool SmallNumber(){
  return is_small<NUM>::value && std::is_arithmetic<NUM>::value;
}  

template<typename NUM>
concept bool HugeNumber(){
  return is_huge<NUM>::value && std::is_arithmetic<NUM>::value;
}  

template <SmallNumber Number> 
char getFirstCharFromNumber(Number element) {
  return std::to_string(std::fabs(element))[0];
}

template <HugeNumber Number> 
char getFirstCharFromNumber(Number element) {
  return std::to_string(std::fabs(element))[0];
}



int main() {
  int8_t a = 12;
  long long b = 7912345670672302237;
  std::cout << getFirstCharFromNumber(a) << std::endl << getFirstCharFromNumber(b) << std::endl;
}

Koncepty można wykorzystywać wszędzie tam, gdzie pojawiają się szablony. Moim zdaniem jest to świetne rozwiązanie, fajnie rozszerzające ich możliwości i pozwalające na uniknięcie wielu błędów. Z konceptów możemy korzystać w kompilatorze GCC, dodając flagę -fconcepts.

Miłego kodzenia i do przeczytania!