1. Skąd bierze się problem: „szablon zjada wszystko”
Kiedy dopiero opanowujesz szablony, chcesz napisać funkcję uniwersalną i już nigdy więcej nie myśleć o typach. To normalny etap: wszyscy przez niego przeszliśmy. Ale szablony mają charakterystyczny „żart”: dopóki nie wywołasz szablonu z problematycznym typem, wszystko wygląda idealnie — kompilacja przechodzi, a błędy chowają się jak kot pod kanapą.
Spójrzmy na mini-przykład. Wygląda niewinnie, ale problem jest już ukryty: wymaganie dotyczące typu (operator+) znajduje się nie w interfejsie, tylko „ukryte” w ciele.
#include <iostream>
#include <vector>
template <typename T>
T add(T a, T b) {
return a + b; // Wymagane, aby T miał operator+
}
int main() {
std::cout << add(2, 3) << '\n'; // 5
std::vector<int> x{1, 2};
std::vector<int> y{3, 4};
// std::cout << add(x, y) << '\n'; // Błąd kompilacji (i nie będzie krótki)
}
Problem nie polega na tym, że kompilator jest „zły”. On uczciwie próbuje wygenerować wersję add<std::vector<int>>, dochodzi do a + b i mówi: „dla std::vector<int> nie ma takiego dodawania”. Ale komunikat o błędzie często bywa głośny, ponieważ w rzeczywistym kodzie wewnątrz szablonu mogą być algorytmy, iteratory, mnóstwo ogólnego kodu i przyczyna tonie w szczegółach.
I tutaj pojawia się pomysł: czy można powiedzieć kompilatorowi z wyprzedzeniem: „add działa tylko dla typów, które da się dodawać”?
2. Constraint, concept i dwa różne requires
Aby się nie gubić, ustalmy słowa. Słowo „constraint” (ograniczenie) oznacza regułę na etapie kompilacji: jakie typy są dopuszczone. To nie jest if ani sprawdzenie podczas wykonywania. To podobne do bramki w metrze: albo masz żeton, albo nie przejdziesz — ale bramka nie „myśli”, po prostu nie wpuszcza.
concept — to nazwa ograniczenia. Czyli bierzesz sprawdzenie „typ obsługuje wymagane operacje” i nadajesz mu zrozumiałą nazwę. Wtedy sygnatura funkcji staje się czytelna: od razu w nagłówku widać, czego się oczekuje od T.
A requires w C++ występuje w dwóch „rolach”, i to klasyczne źródło nieporozumień dla początkujących.
Pierwszy przypadek — requires-expression. To wyrażenie opisujące wymagania dotyczące operacji lub wyrażeń, i wygląda mniej więcej tak: requires(...) { ... }. Wewnątrz piszesz „spróbuj skompilować takie wyrażenia”.
Drugi przypadek — requires-clause (część deklaracji). To kiedy już sformułowałeś warunek i teraz „przyczepiasz” go do funkcji: ... requires MyConcept<T>.
Krótko mówiąc, requires-expression opisuje wymagania, a requires-clause nakłada wymagania na deklarację.
Mała ciekawostka ze świata standardu: nawet w komitecie C++ zdarzają się czysto „gramatyczne” potknięcia w dokumentach i później są poprawiane redakcyjnie. Na przykład w jednym z roboczych szkiców osobno zwracano uwagę, że w gramatyce definicji concept zapomniano o obowiązkowym średniku i potem to poprawiono. To dobre pocieszenie: jeśli zapomniałeś ;, jesteś w klubie „tu się mylili nawet ci, którzy piszą standard”.
3. Minimalny concept: sprawdzamy, że wyrażenie w ogóle istnieje
Teraz zbudujemy najmniejszy „cegiełkę”: własny concept, napisany przez requires-expression. To dokładnie ten moment, gdy kod staje się nieco bardziej „matematyczny”, ale w rzeczywistości sens jest bardzo praktyczny: „jeżeli można napisać x.size(), to typ pasuje”.
Zwróć uwagę: requires nie wywołuje size() podczas wykonywania. Sprawdza jedynie, że takie wyrażenie jest poprawne na etapie kompilacji. Czyli to nie dotyczy danych, a formy kodu.
#include <string>
#include <vector>
template <class T>
concept HasSize = requires(const T& x) {
x.size(); // Wyrażenie musi być poprawne
};
int main() {
static_assert(HasSize<std::string>);
static_assert(HasSize<std::vector<int>>);
// static_assert(HasSize<int>); // Nie skompiluje się: int nie ma size()
}
Tutaj ważne jest to, że piszemy requires(const T& x), a nie requires(T x). Mówiąc obrazowo: „daj mi referencję do obiektu, ja tylko spróbuję napisać x.size()”.
To prawie zawsze opcja najłagodniejsza i najmniej wymagająca, ponieważ referencja nie wymaga kopiowania ani przenoszenia i nie zmusza typu do bycia „łatwym do utworzenia”.
4. Minimalna składnia: jak zastosować concept do funkcji
Gdy concept jest zdefiniowany, następny krok to „przyczepić” go do funkcji. W C++ jest kilka sposobów, które są podobne znaczeniowo, ale różnią się czytelnością. Ważne nie jest zapamiętywanie ich jak tablicy zaklęć, lecz zrozumienie: to trzy różne zapisy tego samego kontraktu.
Ograniczony parametr szablonu: template <HasSize T>
Ten styl bywa najczytelniejszy: dosłownie mówisz „T musi być HasSize”.
#include <cstddef>
#include <string>
template <class T>
concept HasSize = requires(const T& x) { x.size(); };
template <HasSize T>
std::size_t len(const T& x) {
return x.size();
}
int main() {
std::string s = "hello";
(void)len(s);
// (void)len(10); // Nie pasuje: int nie jest HasSize
}
requires-clause: … requires HasSize<T>
Ten styl jest przydatny, gdy masz złożoną sygnaturę albo chcesz najpierw zadeklarować typy jak zwykle, a potem „dopiisać” kontrakt.
#include <cstddef>
#include <string>
template <class T>
concept HasSize = requires(const T& x) { x.size(); };
template <class T>
std::size_t len2(const T& x) requires HasSize<T> {
return x.size();
}
int main() {
std::string s = "abc";
(void)len2(s);
}
Ograniczony placeholder: HasSize auto&
To wygląda nietypowo, ale do krótkich funkcji bywa wygodne. Mówimy: „parametr to coś, co spełnia HasSize”.
#include <cstddef>
#include <string>
template <class T>
concept HasSize = requires(const T& x) { x.size(); };
std::size_t len3(const HasSize auto& x) {
return x.size();
}
int main() {
std::string s = "world";
(void)len3(s);
}
Aby nie traktować tego jak chaos, warto raz zobaczyć mini-tabelkę (a potem zapomnieć, dopóki nie będzie potrzeby):
| Zapisy | Jak to czytać | Kiedy wygodne |
|---|---|---|
|
„T musi być HasSize” | najczęściej, gdy funkcja szablonowa jest prosta |
|
„funkcja dostępna, jeśli…” | gdy warunków jest kilka lub lubisz zapis „jak w matematyce” |
|
„parametr ograniczony przez concept” | gdy funkcja jest krótka, niemal jak inline-utility |
Przykład: narzędzie print_size dla aplikacji konsolowej
Żeby to nie zostało „w próżni”, wyobraźmy sobie proste konsolowe narzędzie (nazwijmy je MiniStats): przechowuje liczby w std::vector<double>, wypisuje je, liczy sumę i średnią. W pewnym momencie chcieliśmy napisać „uniwersalne” funkcje, żeby ponownie użyć ich i dla stringów, i dla wektorów, i dla dowolnych kontenerów.
Zaczniemy od praktycznego zadania: chcemy wypisywać długość obiektu, jeśli ją ma. Dla std::string i std::vector — tak, dla int — nie.
#include <iostream>
#include <string>
#include <vector>
template <class T>
concept HasSize = requires(const T& x) { x.size(); };
template <HasSize T>
void print_size(const T& x) {
std::cout << "size = " << x.size() << '\n';
}
int main() {
std::string s = "abc";
print_size(s); // size = 3
std::vector<int> v{10, 20};
print_size(v); // size = 2
// int n = 5;
// print_size(n); // Nie skompiluje się: brak size()
}
Tu dzieje się ważna pedagogiczna magia: błąd pojawi się nie „gdzieś w środku”, ale bezpośrednio przy wywołaniu print_size(n). Czyli kontrakt wypływa na interfejs, a nie tonie w ciele.
5. Addable: „da się dodać”
Kolejny popularny scenariusz — „typ można dodać”. Przydaje się to, gdy chcesz pisać ogólne sumowania, konkatenacje itd. Znowu: ważne jest wymaganie dokładnie tego, czego faktycznie używasz.
Zrobimy Addable, który sprawdza, że wyrażenie a + b jest poprawne.
#include <string>
template <class T>
concept Addable = requires(const T& a, const T& b) {
a + b; // Ma się kompilować
};
template <Addable T>
T add(T a, T b) {
return a + b;
}
int main() {
(void)add(1, 2);
(void)add(std::string{"ab"}, std::string{"cd"});
}
Zwróć uwagę, że w requires użyliśmy const T&, a nie „wartości”. To nie jest tylko estetyka. To ochrona przed przypadkowym wymaganiem „typ musi być kopiowalny”. Za chwilę zobaczymy dlaczego.
6. Pułapka requires(T x): zbędne wymagania wobec typu
W requires(...) deklarujesz „fikcyjne” parametry, z którymi sprawdzasz wyrażenia. I bardzo łatwo napisać to w sposób niezamierzony.
Jeśli napiszesz requires(T x), często zmuszasz kompilator do sprawdzenia, czy da się utworzyć obiekt T „jako wartość”. A to może oznaczać wymagania dotyczące kopiowania lub przenoszenia. Dla wielu typów-właścicieli zasobów kopiowanie jest zabronione, i wtedy twój concept niespodziewanie zaczyna odrzucać typy, które logicznie mogłyby przejść.
Porównajmy dwie wersje „czy można dodać 1” — złą i dobrą.
#include <memory>
template <class T>
concept BadConcept = requires(T x) {
x; // Już tutaj mogą pojawić się zbędne wymagania dotyczące tworzenia/kopiowania
};
template <class T>
concept GoodConcept = requires(const T& x) {
x; // Dla referencji wymagania są dużo łagodniejsze
};
int main() {
using Ptr = std::unique_ptr<int>;
// static_assert(BadConcept<Ptr>); // może być fałszywy z powodu zbędnych wymagań
static_assert(GoodConcept<Ptr>); // znacznie bardziej prawdopodobne, że będzie prawdziwy
}
Tak, przykład jest specjalnie „na granicy”: w praktyce błąd objawia się inaczej — piszesz concept dla operacji, a on nagle wymaga kopiowalności i zaczyna narzekać na std::unique_ptr, std::mutex i inne „niekopiowalne, ale normalne” typy.
Morał jest prosty: przy sprawdzaniu operacji najczęściej zaczynaj od requires(const T& x) lub requires(T& x).
W dokumentach roboczych standardu termin requires-expression nawet osobno występuje jako część gramatyki języka (czyli to nie „słodki cukierek składniowy”, ale pełnoprawna konstrukcja języka). A jako że to część gramatyki, to drobiazgi typu „jakie parametry zadeklarowałeś” rzeczywiście zmieniają sens sprawdzenia.
7. concept — to kontrakt interfejsu, a nie „sprawdzenie wartości”
Łatwo (szczególnie po if i while) myśleć, że requires to coś jak warunek. Ale to nie „warunek na wartości”, tylko „warunek na poprawność kodu”. Czyli HasSize nie sprawdza, że size() zwraca „dobrą liczbę” ani że kontener „nie jest pusty”. Sprawdza tylko: czy można napisać x.size().
To wprowadza dobrą praktykę projektowania: concept powinien opisywać dokładnie ten zestaw operacji, który potrzebuje twoja funkcja. Nie więcej. Jeśli wymagasz za dużo, ograniczysz ponowne użycie. Jeśli wymagiesz za mało, dostaniesz błąd w ciele (czyli wrócisz do starego problemu).
8. Typowe błędy
Błąd nr 1: mylić requires-expression i requires-clause.
To wygląda jak to samo słowo, ale używane jest w dwóch sensach. W concept zwykle piszesz requires(...) { ... }, i to opis wymagań. A w deklaracji funkcji możesz napisać ... requires HasSize<T>, i to jest już „przyczepienie” wymogu do funkcji. Jeśli pomylisz, zaczniesz pisać dziwne konstrukcje i dziwić się komunikatom kompilatora.
Błąd nr 2: pisać requires(T x) i przypadkowo wymagać kopiowalności/tworzalności typu.
Na początku wydaje się, że „po prostu deklaruję zmienną”. Ale dla conceptów to nie jest zwykła zmienna: to część sprawdzenia. Z tego powodu twój concept nagle zaczyna odrzucać całkiem sensowne typy jak std::unique_ptr. Prawie zawsze lepiej zaczynać od requires(const T& x) i sprawdzać dokładnie te wyrażenia, które są ci potrzebne.
Błąd nr 3: robić jeden ogromny concept „na wszystkie przypadki”.
Gdy po raz pierwszy poczujesz moc constraints, pojawia się pokusa stworzyć UniversalType = requires(...) { ... 20 wymagań ... };. Potem używasz go w trzech miejscach i rozumiesz, że jest zbyt ścisły, i połowa typów „nie przechodzi”, chociaż funkcja sensownie by działała. Lek jest prosty: jedna odpowiedzialność — jeden mały concept, jak mały klocek konstrukcyjny.
Błąd nr 4: oczekiwać, że concept da ci „sprawdzenie wartości”.
concept nie sprawdzi, że kontener nie jest pusty ani że liczba jest dodatnia. Sprawdza tylko obecność operacji i poprawność wyrażeń. Dla wartości pozostają zwykłe if i sprawdzenia w czasie wykonywania — to już inna historia.
Błąd nr 5: zapomnieć średnika po definicji concept.
Definicja concept — to deklaracja na poziomie języka i kończy się ;. To ten przypadek, kiedy nawet w roboczym dokumencie standardu kiedyś osobno zaznaczano, że „w gramatyce zapomniano ; i poprawiono”. Więc jeśli kompilator mówi coś w stylu expected ';', wiedz: nie jesteś sam.
GO TO FULL VERSION