Skip to content

Commit daf2ebd

Browse files
committed
C++ std::size_t: Comparaisons sûres (C++20)
1 parent 8e5c6f6 commit daf2ebd

File tree

1 file changed

+125
-38
lines changed

1 file changed

+125
-38
lines changed

_articles/c++_std_size_t.md

Lines changed: 125 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -130,21 +130,25 @@ Mélanger des types signés et ``std::size_t`` est une source majeure de bugs:
130130
int i = -1;
131131
std::size_t n = 10;
132132

133-
if (i < n) {
134-
// On s'attend à 'true', mais ce sera 'false' !
133+
if (i < n)
134+
{
135+
// On s'attend à true, mais ce sera false
135136
}
136137
{% endhighlight %}
137138

138139
Lorsqu'un type signé et un type non signé sont utilisés dans une opération (ici i < n), C++ applique les [**usual arithmetic conversions**](https://en.cppreference.com/w/cpp/language/usual_arithmetic_conversions.html).<br>
139140
Le type signé (``int``) est converti vers le type non-signé (``std::size_t``). ``i = -1`` devient une valeur non-signée sur 64 bits, ``std::size_t{-1}``. La valeur ``-1`` devient alors la valeur maximale de ``std::size_t`` par overflow (``2⁶⁴-1``), ce qui est bien supérieur à 10.
140141

142+
> C'est d'ailleurs ce que souligne la règle [**ES.100**](#ne-mélangez-pas-signé-et-non-signé-es100) des C++ Core Guidelines: **"Don't mix signed and unsigned arithmetic"**.
143+
141144
Ca aura donc l'effet suivant:
142145
{% highlight cpp highlight_lines="4" %}
143146
int i = -1;
144147
std::size_t n = 10;
145148

146-
if (static_cast<std::size_t>(i) < n) {
147-
// On s'attend à 'true', mais ce sera 'false' !
149+
if (static_cast<std::size_t>(i) < n)
150+
{
151+
// On s'attend à true, mais ce sera false
148152
}
149153
{% endhighlight %}
150154

@@ -361,17 +365,14 @@ Si ``std::size_t`` fait 32 bits -> ``qsizetype`` est un ``qint32``.<br>
361365
Si ``std::size_t`` fait 64 bits -> ``qsizetype`` est un ``qint64``.
362366

363367
Il s'agit de l'équivalent Qt de ``std::size_t``, mais avec une différence fondamentale: **il est signé**.
364-
C'est donc un équivalent à [``std::make_signed_t<std::size_t>``](#les-alternatives-signées-en-c-stdssize-et-stdptrdiff_t), c'est à dire **équivalent à [``std::ptrdiff_t``](#stdptrdiff_t-le-type-des-distances)**.
365-
366-
Il bénéficie donc par dépendance au type ``std::size_t`` des mêmes garanties que ``std::ptrdiff_t`` (supporte la même largeur que ``std::ptrdiff_t``).
367368

368-
Mais l'arrivée de ``qsizetype`` vient également avec une **série de problèmes** dont on aimerait bien se passer.
369+
Dans la plupart des plateformes modernes, ``qsizetype`` est identique à ``std::ptrdiff_t``, mais cette équivalence n'est **pas garantie par le standard**. En revanche, ``qsizetype`` possède systématiquement la **même largeur** (nombre de bits) que ``std::size_t``, c'est donc un équivalent à [``std::make_signed_t<std::size_t>``](#les-alternatives-signées-en-c-stdssize-et-stdptrdiff_t).
369370

370-
Face à ces **équivalences**, on pourrait utiliser ``qsizetype`` et ``std::ptrdiff_t`` de manière interchangeable (en décidant d'**ignorer les coûts** liés aux conversions si les types sous-jacents ne sont pas strictement identiques, ce qui en soi **est déjà un problème**).
371+
### Un choix de conception contestable
371372

372-
Mais ``qsizetype`` introduit un grand nombre de **frictions** avec la **STL**, le **langage** et les **appels système**.
373+
Le choix de Qt d'utiliser un **type signé** pour des tailles est **souvent critiqué**. Bien que cela permette l'utilisation de valeurs sentinelles (comme ``-1``), cela autorise également des **états sémantiquement absurdes**: rien n'interdit techniquement d'écrire ``qsizetype n = -5;``, ce qui n'a **aucun sens** pour une mesure de taille physique.
373374

374-
Pour vous aider à y voir plus clair, détaillons ces points de friction pour **comprendre les coûts** que ça entraine et **prévenir les risques d'erreurs**:
375+
Ce choix de conception introduit une **dissonance sémantique** permanente dès que l'**on sort de l'écosystème de Qt**. Le développeur doit jongler entre **deux modèles mentaux opposés**: l'un où une **valeur négative** est une **erreur légitime** (**Qt**), et l'autre où une taille est par **définition une quantité absolue non-signée** (la **STL** et le langage (**``sizeof``**)). Cette ambiguïté rend chaque interaction propice aux bugs de signe.
375376

376377
### La friction entre Qt et la STL
377378

@@ -382,34 +383,99 @@ Cela force souvent le développeur à jongler entre trois types pour manipuler d
382383
- ``std::ptrdiff_t`` (signé standard)
383384
- ``qsizetype`` (signé Qt).
384385

385-
#### L'enfer des comparaisons mixtes
386+
#### Les comparaisons mixtes
386387

387388
Dès que vous comparez un index issu d'une recherche Qt avec une taille ou un index standard, le piège se referme.
388389

389-
Le risque est **réel**: une valeur négative de ``qsizetype`` (``-1`` utilisé comme **sentinelle** si non trouvé) [sera interprétée comme une valeur positive gigantesque](#le-mélange-signé--non-signé) lors de la comparaison avec un ``std::size_t``.
390+
Le risque est qu'une valeur "non trouvée" (``-1`` utilisé comme **sentinelle**) [soit interprétée comme une valeur positive gigantesque](#le-mélange-signé--non-signé) lors de la comparaison avec un ``std::size_t``.
390391

391-
{% highlight cpp linenos highlight_lines="9" %}
392-
const auto text = "Hello World!"; // de type const char[13]
393-
const auto string = QString{text};
392+
{% highlight cpp linenos highlight_lines="8" %}
393+
QString url = "/api/v1/resource/data"; // Pas de paramètres '?' ici
394+
std::size_t MaxPathLength = 128;
394395

395-
// indexOf retourne -1 si le mot-clé n'est pas trouvé
396-
auto position = string.indexOf("Word");
396+
auto queryStart = url.indexOf('?');
397397

398-
// Comparaison d'une valeur signée (qsizetype) avec une non-signée (std::size_t)
399-
// Si "Word" n'est pas trouvé (position = -1), la condition sera VRAIE car -1 > 13 en non-signé.
400-
if (position > std::size(text))
398+
// Comparaison entre un qsizetype (signé) et un std::size_t (non-signé)
399+
// Si '?' n'est pas trouvé (queryStart = -1), la condition sera VRAIE car -1 > 128 en non-signé.
400+
if (queryStart > MaxPathLength)
401401
{
402-
// ...
402+
// On rejette l'URL car on croit que le chemin est trop long !
403+
return Error::BadRequest;
403404
}
404405
{% endhighlight %}
405406

406-
> **Activez** le warning ``-Wsign-compare`` pour être avertis de ce genre de problème.
407+
> **Activez** le warning ``-Wsign-compare`` pour être avertis de ce genre de problème, car c'est l'une des sources de bugs les plus fréquentes en C++.
407408
{: .block-warning }
408409

409410
Nous avons détaillé le mécanisme à l'oeuvre [ici](#le-mélange-signé--non-signé).
410411

411412
**Le langage** ayant lui-même **choisi ``std::size_t``** pour exprimer les tailles physiques (**``sizeof``**), cela force à des **conversions incessantes**, **même dans un projet "100% Qt"** (et jusque **dans l'implémentation même du framework**).
412413

414+
#### Comparaisons sûres (C++20)
415+
416+
Pour résoudre définitivement ce problème sans conversion manuelle risquée, le C++20 a introduit une famille de fonctions dans le header ``<utility>``:
417+
- [``std::cmp_equal``](http://en.cppreference.com/w/cpp/utility/intcmp.html)
418+
- [``std::cmp_not_equal``](http://en.cppreference.com/w/cpp/utility/intcmp.html)
419+
- [``std::cmp_less``](http://en.cppreference.com/w/cpp/utility/intcmp.html)
420+
- [``std::cmp_less_equal``](http://en.cppreference.com/w/cpp/utility/intcmp.html)
421+
- [``std::cmp_greater``](http://en.cppreference.com/w/cpp/utility/intcmp.html)
422+
- [``std::cmp_greater_equal``](http://en.cppreference.com/w/cpp/utility/intcmp.html)
423+
424+
Ces fonctions appliquent une logique correcte selon le signe de chaque valeur, **empêchant les conversions implicites dangereuses**.
425+
426+
{% highlight cpp %}
427+
QString url = "/api/v1/resource/data"; // Pas de paramètres '?' ici
428+
std::size_t maxPathLength = 128;
429+
430+
// Solution moderne et sûre:
431+
if (std::cmp_greater(queryStart, maxPathLength))
432+
{
433+
// La comparaison est mathématiquement correcte: -1 > 128 est FAUX.
434+
return Error::BadRequest;
435+
}
436+
{% endhighlight %}
437+
438+
#### L'asymétrie des conversions
439+
440+
Le passage d'un type à l'autre n'est jamais neutre, car leurs capacités diffèrent.
441+
442+
**Sens 1: De Qt vers le standard (``qsizetype`` => ``std::size_t``)**
443+
444+
La conversion est techniquement sûre pour toutes les tailles car la plage positive de ``qsizetype`` tient toujours dans un ``std::size_t``. Cependant, elle **détruit la sémantique d'erreur**:
445+
{% highlight cpp %}
446+
qsizetype qtSize = -1; // En Qt, la sentinelle -1 signifie sémantiquement "non trouvé" ou "erreur"
447+
std::size_t stdSize = qtSize;
448+
449+
// stdSize vaut désormais 18 446 744 073 709 551 615.
450+
// L'erreur est devenue une taille gigantesque "valide"
451+
{% endhighlight %}
452+
453+
**Sens 2: Du standard vers Qt (``std::size_t`` => ``qsizetype``)**
454+
455+
C'est ici que le risque d'**overflow** est le plus critique. Si vous manipulez une donnée dépassant la moitié de la mémoire adressable (ex: un énorme fichier), la conversion produira une valeur négative:
456+
{% highlight cpp %}
457+
// Imaginons un buffer de 9 exaoctets sur un système très spécifique
458+
std::size_t hugeSize = 9'000'000'000'000'000'000uz;
459+
qsizetype qtSize = hugeSize;
460+
461+
// qtSize devient négatif par overflow
462+
// Qt croira que votre buffer est une erreur ou une chaîne vide
463+
{% endhighlight %}
464+
465+
#### Interopérabilité des conteneurs
466+
467+
Ces frictions obligent à une vigilance constante lors de l'interaction entre les deux mondes. Tenter de réserver de la place dans une ``QList`` en se basant sur la taille d'un ``std::vector`` (ou inversement) génère systématiquement un warning.
468+
469+
Par exemple, si nous voulons dimensionner un ``std::vector`` par rapport à la taille d'une ``QList``:
470+
{% highlight cpp %}
471+
std::vector<int> v = { ... };
472+
QList<int> list;
473+
474+
// Warning: conversion de size_t vers qsizetype
475+
// Le compilateur avertit que v.size() pourrait ne pas tenir dans list
476+
list.reserve(v.size());
477+
{% endhighlight %}
478+
413479
#### Frictions avec les appels système et le langage
414480

415481
Les appels système de Qt doivent systématiquement **convertir** leurs types signés **vers les types non-signés** attendus par le système (POSIX ou Windows).
@@ -420,9 +486,7 @@ Le jonglage entre ces mondes génère un bruit de code permanent, obligeant à *
420486

421487
- **Manipulation mémoire**: Des fonctions comme [``QByteArray::fromRawData(const char *data, qsizetype size)``](https://doc.qt.io/qt-6/qbytearray.html#fromRawData) demandent un ``qsizetype``, mais les fonctions système de copie ([``memcpy``](https://man7.org/linux/man-pages/man3/memcpy.3.html)) appelées en interne attendent un ``size_t``.
422488

423-
La documentation de Qt montre d'ailleurs souvent cette gymnastique, où un ``sizeof`` (non-signé) est passé directement à un paramètre ``qsizetype`` (signé).
424-
425-
Par exemple ici, dans l'exemple donné par la documentation Qt sur l'utilisation de [``QByteArray::fromRawData(const char *data, qsizetype size)``](https://doc.qt.io/qt-6/qbytearray.html#fromRawData):
489+
La documentation de Qt montre d'ailleurs souvent cette gymnastique, où un ``sizeof`` (non-signé) est passé directement à un paramètre ``qsizetype`` (signé), comme dans l'exemple de [``QByteArray::fromRawData(const char *data, qsizetype size)``](https://doc.qt.io/qt-6/qbytearray.html#fromRawData):
426490
{% highlight cpp highlight_lines="8" %}
427491
static const char mydata[] = {
428492
'\x00', '\x00', '\x03', '\x84', '\x78', '\x9c', '\x3b', '\x76',
@@ -439,7 +503,33 @@ Ceci change **deux fois** le domaine de signe de la valeur (non-signé -> signé
439503

440504
N'est-ce pas absurde d'imposer un type signé pour des tailles, pour finir par le convertir systématiquement ? Introduisant au passage **des risques d'erreurs inutiles** (si on passe une valeur négative en argument) ou **des coûts supplémentaires** si la fonction Qt vérifie systématiquement que la valeur passée n'est pas négative.
441505

442-
**En résumé:** Si vous utilisez Qt, le type ``qsizetype`` est un passage obligé, mais il agit comme un corps étranger dès que vous sollicitez les fonctions de la STL **ou des fonctions système**. L'utilisation de [**``std::ssize()``** (C++20)](https://en.cppreference.com/w/cpp/iterator/size.html) est souvent le meilleur moyen de "ramener" les conteneurs STL dans le monde signé de Qt pour éviter les frictions.
506+
## Les recommandations contradictoires de C++ Core Guidelines
507+
508+
Les [C++ Core Guidelines](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines) reconnaissent ce conflit historique entre la STL et les besoins de calcul.
509+
510+
### Ne mélangez pas signé et non signé (ES.100)
511+
512+
Le principe est simple: [**Don't mix signed and unsigned arithmetic**](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#res-mix). Le mélange provoque des **conversions silencieuses** et des bugs difficiles à tracer. Nous l'avons illustré avec les [frictions de Qt](#les-comparaisons-mixtes).
513+
514+
### Préférez le signé pour les index (ES.107)
515+
516+
Ces guidelines recommandent de [**préférer les types signés pour les indices de tableaux**](https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#es107-dont-use-unsigned-for-subscripts-prefer-gslindex).
517+
518+
Comme nous l'avons vu avec [les boucles décrémentales](#lunderflow-dans-les-boucles) qui peuvent provoquer un **underflow** si l'index est non-signé, cette guideline vise à prévenir ce genre d'erreur.
519+
520+
### La position ambiguë sur ``size_t``
521+
522+
Les guidelines se retrouvent ici dans une impasse: elles recommandent le signé pour les index (ES.107) tout en devant composer avec ``std::size_t`` imposé par la STL pour les tailles de conteneurs et le langage (``sizeof``).
523+
524+
En effet, les index sont **très massivement** affectés ou comparés avec des **tailles**, qui sont **non-signées**. Causant un nombre considérable d'interactions entre des valeurs signées et non-signées dans un programme. Cette guideline rentre donc complètement en **contradiction** avec la 1ère ([ES.100](#ne-mélangez-pas-signé-et-non-signé-es100)).
525+
526+
C'est exactement la **même dissonance** que celle rencontrée **avec Qt**, montrant que le débat entre signé et non-signé pour les tailles reste l'un des points **les plus clivants du C++**.
527+
528+
De nombreux développeurs (dont vous aurez deviné, je fais partie) rangent les **index** et les **tailles** dans **la même arithmétique non-signée** (``std::size_t``). Réservant les index signés **uniquement aux boucles décrémentales** (en priorisant une autre forme d'écriture pour éviter d'y avoir recours).
529+
530+
### Faut-il utiliser ``qsizetype`` ?
531+
532+
Si vous utilisez Qt, le type ``qsizetype`` est un passage obligé, mais il agit comme un corps étranger dès que vous sollicitez les fonctions de la STL **ou des fonctions système**. L'utilisation de [**``std::ssize()``** (C++20)](https://en.cppreference.com/w/cpp/iterator/size.html) est souvent le meilleur moyen de "ramener" les conteneurs STL dans le monde signé de Qt pour éviter les frictions.
443533

444534
{% highlight cpp %}
445535
QList<int> list = { ... };
@@ -449,22 +539,19 @@ std::vector<int> vector = { ... };
449539
if (std::ssize(list) < std::ssize(vector)) { ... }
450540
{% endhighlight %}
451541

452-
### Faut-il utiliser ``qsizetype``
453-
454-
- **Lorsque vous manipulez des objets Qt**, utiliser les types attendus et retournés par Qt (comme ``qsizetype``) permet d'être sûr de ne pas avoir de conversions. Mais vous serez [parfois obligés de les faire interagir avec des ``std::size_t``](#lenfer-des-comparaisons-mixtes).
455-
456-
- Si votre code n'est **pas fortement lié à Qt**, il est cohérent de confiner la propagation ``qsizetype`` aux strictes parties qui l'utilisent. Et d'utiliser les standards ``std::size_t`` et ``std::ptrdiff_t`` dans le reste de votre projet lorsque vous avez une **sémantique** de **taille**, de **quantité**, d'**index**, de **différence** ou de **distance**.
542+
**Si votre code n'est pas fortement lié à Qt**, confinez ``qsizetype`` aux strictes parties qui l'utilisent. Préférez les standards ``std::size_t`` et ``std::ptrdiff_t``.
457543

458-
Ce n'est **pas une question simple**. Utiliser ``qsizetype`` introduit un grand nombre de **frictions** avec la **STL**, le **langage** et les **appels système**. Et ne pas l'utiliser introduit des conversions (**risques et coût**) entre les types qu'on manipule ``std::size_t``/``std::ptrdiff_t`` et le type attendu par les fonctions Qt ``qsizetype``.
544+
Mais comme nous l'avons vu, ce n'est **pas une question simple**. Utiliser ``qsizetype`` introduit un grand nombre de **frictions** avec la **STL**, le **langage** et les **appels système**. Mais ne pas l'utiliser introduit des conversions incessantes entre vos types standards et les types attendus par Qt.
459545

460-
> **Aucun** des deux choix **n'est idéal et gratuit** (hormis se tourner vers **autre chose que Qt** ?<br>
461-
> A noter que ce n'est **pas le seul point de friction** entre Qt, la STL et le langage. On peut noter aussi le [*copy-on-write*](https://en.wikipedia.org/wiki/Copy-on-write) et les [iterateurs](https://wiki.qt.io/Iterators)).
546+
> **Aucun** des deux choix **n'est idéal et gratuit** (hormis se tourner vers **autre chose que Qt** ?).
547+
> A noter que ce n'est **pas le seul point de friction**. On peut noter aussi le [*copy-on-write*](https://en.wikipedia.org/wiki/Copy-on-write) et les [itérateurs](https://wiki.qt.io/Iterators) propres à Qt.
462548
{: .block-warning }
463549

464-
Une **3ème option** s'offre à nous, car **Qt fait quelques efforts** pour **se conformer au standard** et **se rendre compatible** avec la STL (mais il reste encore beaucoup de chemin):
550+
Une **3ème option** s'offre à nous, car **Qt fait quelques efforts** pour **se conformer au standard** et **se rendre compatible** avec la STL (bien qu'il reste encore du chemin):
465551

466-
> Si votre **code est [suffisamment générique](/articles/c++/programmation_generique)**, que vous utilisez les [**customization point**](/articles/c++/customization_point_design) et [**auto**](/articles/c++/auto), la **propagation** de ``qsizetype`` ou de ``std::size_t`` sera **automatique**. Et il sera à vous de bien [utiliser ``std::ssize()``](#frictions-avec-les-appels-système-et-le-langage) lorsque les deux types risquent d'entrer en collision.<br>
467-
> Ceci permettant de **prévenir les risques d'erreur** tout en **délèguant** les questions de **coûts** (liées aux conversions) **à l'appelant** de vos fonctions. Ainsi, votre code deviendrait **agnostique** des types utilisés, **laissant à l'appelant la responsabilité de ces choix**.
552+
> Si votre **code est [suffisamment générique](/articles/c++/programmation_generique)**, que vous utilisez les [**customization points**](/articles/c++/customization_point_design), [**auto**](/articles/c++/auto) et les [**comparaisons sûres**](#comparaisons-sûres-c20), la **propagation** du type correct sera **automatique** et ses manipulations seront **sûres**.
553+
>
554+
> Cette approche permet de **prévenir les risques d'erreur** tout en **déléguant** la responsabilité du choix des types à l'appelant. Votre code devient ainsi **agnostique** et plus résilient.
468555
469556
---
470557

0 commit comments

Comments
 (0)