1. Pourquoi utiliser les concepts standard
Quand on commence à écrire des templates, on a parfois envie d'y croire : « Une fois écrit template <typename T> — et ça marche pour tout ». La réalité vous ramène vite sur terre : à l'intérieur du template vous faites x / 2 ou x.size(), et ensuite quelqu'un appelle votre fonction avec une std::string ou une std::vector, et le compilateur vous sort une erreur sur 200 lignes (et, bien sûr, en anglais, parce que le compilateur est aussi un développeur).
Les concepts standard ne servent pas à « complexifier la vie », mais à rendre le code plus honnête : si une fonction est destinée uniquement aux entiers — que cela soit visible directement dans la signature. Si l'algorithme n'a de sens que pour les réels — idem. C'est une question de lisibilité, d'erreurs compréhensibles et d'un contrat d'API correct. Historiquement, les idées des « standard library concepts » ont été formalisées par des propositions et des décisions du comité, et dans les documents WG21 vous trouverez régulièrement des mentions de “Standard library concepts” comme un bloc de travail distinct.
Comment les constraints « filtrent » les types
Quand vous voyez template <std::integral T>, il est important de se rappeler : ce n'est pas une vérification à l'exécution ni un if. Le compilateur n'exécute pas le programme pour vérifier le type. Il se contente de décider : « cette fonction existe-t-elle pour le type T donné ou non ».
Voilà un modèle mental utile :
flowchart TD
A[Il y a un appel à une fonction-template] --> B[Le compilateur choisit T]
B --> C{Vérification des constraints}
C -->|satisfaites| D[Instantiation du corps du template]
C -->|non satisfaites| E[La fonction ne convient pas / erreur de compilation]
L'idée est que std::integral, std::floating_point et std::ranges::range sont des « portes » par lesquelles un type passe ou non.
2. std::integral : seulement les entiers
Quand on parle de « nombre entier » en C++, on pense souvent à int. Mais le langage offre toute une famille : short, long, long long, toutes les variantes unsigned, plus char (oui) et même bool (parfois surprenant). Dans les templates, on veut souvent restreindre une fonction à ces types : par exemple pour des index, compteurs, opérations bit à bit, arithmétique avec %, division entière, etc.
std::integral est un concept standard du header <concepts>, qui signifie : « T est un type entier ». En pratique pédagogique, considérez-le comme une catégorie de types, pas comme une vérification « est-ce int ». Ainsi std::integral<int> et std::integral<long long> seront vrais, ce qui est exactement ce dont on a besoin.
Petit mémo :
| Concept | De quoi il s'agit | Représentants typiques |
|---|---|---|
|
« nombres entiers » | |
|
« nombres réels » | |
Exemple minimal : fonction pour entiers seulement
Commençons par l'exemple le plus simple et « honnête ». Supposons que nous ayons une fonction qui compte des « pas », et par nature doit fonctionner uniquement avec des entiers.
#include <concepts>
#include <iostream>
void print_twice(std::integral auto x) {
std::cout << (x * 2) << '\n'; // par exemple : 14
}
int main() {
print_twice(7);
// print_twice(3.14); // ne se compile pas : 3.14 n'est pas integral
}
Remarquez la forme std::integral auto x. C'est un « placeholder contraint » : ça se lit presque comme une fonction classique, mais le contrat est intégré dans le type du paramètre.
Exemple dans DataBox : lire exactement N valeurs
Pour ancrer l'idée dans une application, imaginons qu'au fil du cours nous créons l'utilitaire console DataBox — un petit analyseur de données. Il lit des nombres et affiche des statistiques. Avant les concepts on écrivait souvent une version int et une version double séparément ; maintenant on veut généraliser proprement.
On commence par une fonction qui lit exactement N valeurs. N est le nombre d'éléments, et c'est un candidat classique pour std::integral.
#include <concepts>
#include <iostream>
#include <vector>
std::vector<int> read_n_values(std::integral auto n) {
std::vector<int> v;
for (int i = 0; i < n; ++i) {
int x = 0;
std::cin >> x;
v.push_back(x);
}
return v;
}
Oui, à l'intérieur nous utilisons encore int i, et oui, il y a des subtilités (par exemple si n est un long long). Mais l'idée importante est : le paramètre de quantité est un entier, et cela se voit dans la signature.
Subtilité : bool est aussi integral
À un moment donné vous pouvez être surpris : « Pourquoi ma fonction accepte true ? » C'est parce que bool est considéré comme un type entier. Ce n'est pas un bug : c'est l'état réel du langage C++ et le concept le reflète.
Dans le code pratique cela impose une discipline simple : si vous voulez accepter « un entier mais pas bool », un seul std::integral ne suffit — il faudra ajouter des contraintes supplémentaires (mais c'est un sujet sur la combinaison des constraints et des exigences plus précises ; aujourd'hui nous restons sur les briques standard).
3. std::floating_point : pour les nombres réels
Les réels sont ceux qui ont une partie fractionnaire et ses joies associées : imprécisions, NaN, infinis et « pourquoi 0.1 + 0.2 n'est pas égal à 0.3 ». Vous avez déjà travaillé avec double et discuté de la précision, donc il est logique de parfois dire au compilateur : « ici seulement les types réels ».
std::floating_point est un concept standard du header <concepts>, qui signifie : « le type est float, double ou long double (avec les qualifiers cv) ». C'est très utile pour calculer une moyenne, la variance, la normalisation, des pourcentages, des racines et des puissances — bref tout ce pour quoi la division entière ruinerait le sens.
Exemple : moyenne réservée aux réels
La moyenne est une démonstration idéale : sur des entiers (1 + 2) / 2 donne 1, pas 1.5. Parfois c'est voulu, parfois c'est une erreur subtile.
#include <concepts>
#include <iostream>
std::floating_point auto mean2(std::floating_point auto a,
std::floating_point auto b) {
return (a + b) / 2; // 2 est aussi flottant dans le contexte de l'expression
}
int main() {
std::cout << mean2(1.0, 2.0) << '\n'; // 1.5
// std::cout << mean2(1, 2) << '\n'; // ne se compile pas
}
Ici nous avons fait quelque chose d'intéressant : les paramètres et le type de retour sont exprimés via auto contraint. Cela se lit presque comme du pseudo-code, et c'est précisément la force de cette approche.
Exemple dans DataBox : moyenne des données
Réfléchissons comme les auteurs de DataBox. Nous voulons pouvoir calculer la moyenne d'un std::vector<double>.
#include <concepts>
#include <iostream>
#include <vector>
double mean(const std::vector<double>& v) {
double sum = 0.0;
for (double x : v) {
sum += x;
}
return v.empty() ? 0.0 : (sum / v.size());
}
Pour l'instant ce n'est pas un template. Mais l'idée est la suivante : la « moyenne » demande généralement un type réel. Si on veut ensuite généraliser sur un type T, std::floating_point sera la contrainte naturelle.
4. Quand integral et floating_point impliquent des sens différents
Les contraintes deviennent particulièrement claires quand on compare deux implémentations côte à côte. C'est comme deux portes : « entrée pour entiers » et « entrée pour réels ». Le compilateur est votre videur (pas très sympathique, mais juste).
Imaginons que DataBox sache faire une « normalisation sur 100 » (par exemple pour convertir en pourcentage). Sur des entiers c'est étrange : vous perdrez rapidement la partie fractionnaire. Sur des réels c'est normal.
#include <concepts>
#include <iostream>
std::floating_point auto to_percent(std::floating_point auto x) {
return x * 100.0;
}
int main() {
std::cout << to_percent(0.123) << '\n'; // 12.3
// std::cout << to_percent(5) << '\n'; // ne se compile pas
}
C'est à ce moment que les constraints ne « interdisent » pas, mais protègent le sens. Vous dites littéralement : « Si tu as passé un int, ami, stoppons et réfléchissons ».
5. std::ranges::range : tout ce que l'on peut parcourir
Avant ranges vous avez souvent parcouru un std::vector avec une boucle for, puis appris le range-for : for (auto& x : v). Au ressenti cela signifie : « si on peut itérer sur un objet avec range-for, alors c'est un container (ou quelque chose de similaire) ». En C++ on généralise cela par le mot range : un objet dont on peut obtenir le début et la fin (en pratique begin et end).
std::ranges::range est un concept standard du header <ranges>. Il signifie : « pour un objet on peut obtenir des itérateurs début/fin (via les mécanismes des ranges) ». Donc ce n'est pas nécessairement un std::vector ; ça peut être un std::array, une std::string, une std::list, une view, ou même votre propre type si vous avez défini begin/end.
Dans les documents WG21 et les discussions LWG on trouve souvent des « range concepts » et des exigences pour ranges::begin/ranges::end — c'est une partie fondamentale du STL moderne.
Fonction simple : compter les éléments dans n'importe quel range
Oui, les containers ont souvent une .size(). Mais pour un range en général .size() peut ne pas exister. Commençons donc par une approche universelle : itérer et compter.
#include <cstddef>
#include <ranges>
std::size_t count_elements(const std::ranges::range auto& r) {
std::size_t n = 0;
for (const auto& x : r) {
(void)x;
++n;
}
return n;
}
Deux idées importantes ici. D'une part, std::ranges::range auto est un contrat direct : « donne-moi quelque chose qu'on peut parcourir ». D'autre part, nous ne demandons rien de superflu : ni size(), ni accès aléatoire, ni indexation.
Exemple dans DataBox : aperçu pour n'importe quel range
Faisons un pas de plus : écrivons une fonction qui affiche les premiers éléments de n'importe quel range. C'est pratique pour le débogage de DataBox : on a lu des données, on veut rapidement vérifier qu'elles « semblent correctes ».
#include <iostream>
#include <ranges>
void print_preview(const std::ranges::range auto& r, int limit) {
int shown = 0;
for (const auto& x : r) {
if (shown >= limit) break;
std::cout << x << ' ';
++shown;
}
std::cout << '\n';
}
Remarquez : limit est resté un int. Ici nous ne cherchons pas à tout perfectionner — l'important est de voir l'utilité de std::ranges::range dans du code réel.
6. Mini-version de DataBox avec constraints
Construisons maintenant un petit bloc d'application cohérent que vous pourrez étendre. Il lira un N, puis N nombres, affichera un aperçu et la somme. Nous choisirons volontairement le type de données comme paramètre de template : int ou double. Et nous limiterons ce type avec les concepts standard.
Fonction de lecture : uniquement des nombres
Nous voulons autoriser uniquement des « types numériques », et dans le cadre actuel du cours cela signifie « entiers ou réels ». Écrivons la contrainte directement dans la déclaration.
#include <concepts>
#include <iostream>
#include <vector>
template <class T>
requires (std::integral<T> || std::floating_point<T>)
std::vector<T> read_n(std::integral auto n) {
std::vector<T> v;
for (int i = 0; i < n; ++i) {
T x{};
std::cin >> x;
v.push_back(x);
}
return v;
}
Remarquez l'équilibre soigné. Nous avons limité T en tant que « nombre », et n comme « entier ». Ce sont deux sens différents, et il est utile de les exprimer par des contraintes séparées plutôt que par une seule grosse expression confuse.
Somme pour un range : accepter n'importe quel range de nombres
Nous voulons maintenant sommer « n'importe quoi », à condition que ce soit un range et que ses éléments soient sommables. Aujourd'hui nous restons sur les concepts standard, donc nous n'allons pas définir un Addable — nous limiterons simplement les éléments aux « nombres » (integral ou floating_point). C'est raisonnable pour DataBox.
#include <concepts>
#include <ranges>
template <class R>
requires std::ranges::range<R>
auto sum_range(const R& r) {
using T = std::ranges::range_value_t<R>;
static_assert(std::integral<T> || std::floating_point<T>,
"sum_range: range elements must be integral or floating_point");
T sum{};
for (const auto& x : r) {
sum += x;
}
return sum;
}
Oui, nous avons légèrement « emprunté » std::ranges::range_value_t<R>. Mais conceptuellement c'est simplement le « type des éléments du range ». Si vous le comprenez comme « T est le type de ce qui est à l'intérieur », c'est suffisant.
main : assembler le scénario
Construisons un court main exécutable.
#include <iostream>
#include <vector>
int main() {
int n = 0;
std::cin >> n;
auto data = read_n<double>(n);
print_preview(data, 5); // par exemple : 1.1 2.2 3.3 4.4 5.5
std::cout << sum_range(data) << '\n'; // par exemple : 16.5
}
Ceci ressemble déjà à un petit utilitaire : « lire → afficher → calculer ». Et le plus agréable : si quelqu'un tente read_n<std::string>(n), le compilateur empêchera cela avant que vous n'ayez le temps de dire « je voulais juste essayer ».
7. Erreurs typiques
Erreur n°1 : confondre std::integral avec « seulement int ».
Cette façon de penser vient souvent des premiers exercices où tout était en int. En C++ réel il existe beaucoup de types entiers, et std::integral accepte honnêtement tous ceux-ci. Si votre API doit vraiment accepter seulement int, alors écrivez int, pas un concept. Un concept représente une catégorie de types.
Erreur n°2 : utiliser std::floating_point « au cas où », puis être surpris que int ne passe pas.
Classique : vous écrivez une formule qui « semble mathématique », donc vous choisissez double. Puis on réalise que le paramètre est un index, un compteur, une taille, ou un numéro de page — et doit être entier. Les concepts vous obligent à nommer honnêtement le sens d'un paramètre.
Erreur n°3 : penser que std::ranges::range implique « l'objet a .size() ».
Un range concerne la possibilité d'obtenir un début et une fin et de l'itérer. .size() peut exister, ou pas. Si vous écrivez du code générique, ne confondez pas ces notions. Si la taille est nécessaire, c'est une autre contrainte (exprimée via des range-concepts supplémentaires ou des exigences additionnelles).
Erreur n°4 : oublier d'inclure les headers nécessaires et accuser les concepts d'être « capricieux ».
Pour std::integral et std::floating_point il faut <concepts>. Pour std::ranges::range il faut <ranges>. Si vous oubliez un include, le compilateur vous dira qu'il ne connaît pas ces noms — et il aura raison. Parfois cela semble être une « casse-tête », mais en réalité c'est juste la discipline des includes.
Erreur n°5 : contraindre l'argument mais ne pas vérifier le type des éléments du range.
Par exemple, vous écrivez sum_range(const std::ranges::range auto& r) et à l'intérieur faites sum += x. Le range existe, mais ses éléments peuvent être n'importe quoi — même std::string. Si vous savez à l'avance que vous voulez sommer seulement des nombres, ajoutez la vérification sur le type des éléments (comme nous l'avons fait via static_assert). Ce n'est pas de la paranoïa, c'est transformer des exigences en contrat.
GO TO FULL VERSION