Histoire : Avant C++20, les développeurs s'appuyaient sur reinterpret_cast, unions, ou std::memcpy pour réinterpréter les représentations des objets. Ces méthodes invoquaient soit un comportement indéfini à travers des violations du strict aliasing ou des règles d'activation des membres, soit manquaient de sécurité de type et de support constexpr. Le comité a introduit std::bit_cast pour fournir un mécanisme bien défini pour accéder à la représentation d'objet d'un type en tant qu'un autre.
Problème : std::bit_cast doit garantir que le motif de bits de l'objet source est préservé exactement dans l'objet de destination sans invoquer de comportement indéfini. Cela nécessite que le type source puisse être copié en toute sécurité octet par octet (triviellement copiables) et qu'aucune information ne soit perdue ou fabriquée lors du transfert (taille identique). Sans ces contraintes, l'opération pourrait trancher des objets, contourner les sémantiques de copie privées ou créer des motifs de bits invalides pour le type de destination.
Solution : La norme impose que les deux types soient trivially copyable (permettant une copie octet par octet) et aient des tailles identiques. L'implémentation effectue une copie bit par bit équivalente à std::memcpy mais avec sécurité de type et support d'évaluation constexpr. Cela évite les problèmes de strict aliasing liés au casting de pointeurs et les restrictions d'activation des membres des unions, fournissant un primitive portable et optimisable pour le punning de type.
struct Packet { uint32_t id; float value; }; static_assert(std::is_trivially_copyable_v<Packet>); Packet p{42, 3.14f}; auto bytes = std::bit_cast<std::array<std::byte, sizeof(Packet)>>(p); Packet restored = std::bit_cast<Packet>(bytes);
Dans un moteur de jeu multijoueur, le système de physique génère des structures Transform contenant des données de position et de rotation en float. La couche réseau doit transmettre ces données sous forme d'octets bruts sans coût de copie. L'implémentation initiale utilisait reinterpret_cast<const std::byte*>(&transform) pour obtenir une séquence d'octets, mais cela violait les règles du strict aliasing et provoquait des plantages sous une optimisation agressive du compilateur (-fstrict-aliasing).
Extraction manuelle de champs : Sérialiser chaque float individuellement en utilisant des décalages de bits dans un tampon d'octets. Cette approche garantit un comportement défini et gère explicitement la conversion d'endianness. Cependant, elle nécessite des centaines de lignes de code de circulation pour des structures complexes, est lourde à maintenir lorsque des champs changent, et engendre un coût mesurable en CPU à partir des opérations de boucle sur de grands tableaux.
Punning de type par union : Définir union TransformPayload { Transform t; std::byte bytes[sizeof(Transform)]; } et accéder au membre bytes après avoir écrit dans le membre transform. Bien que pris en charge en tant qu'extension de compilateur dans GCC et Clang, cela viole la règle d'activation des membres standards de C++ (un seul membre d'une union peut être actif à la fois). Cela entraîne un comportement indéfini qui se manifeste sous forme de valeurs d'octets incorrectes lorsque l'optimisation au moment de l'édition des liens (LTO) est activée.
std::memcpy : Copier le transform dans un tableau d'octets en utilisant std::memcpy(dst, &transform, sizeof(Transform)). Cela est bien défini pour les types trivially copyable et s'optimise en une seule instruction CPU. Cependant, cela nécessite un stockage pré-alloué, manque de support constexpr dans les contextes antérieurs à C++20 pour l'opération inverse, et obscurcit l'intention du code par rapport à une opération de cast.
std::bit_cast : Convertir la structure directement en utilisant auto packet = std::bit_cast<std::array<std::byte, sizeof(Transform)>>(transform);. Cela fournit une conversion de type sûre et capable de constexpr avec une intention explicite, permettant la vérification des structures de paquets à la compilation. Cela nécessite un support C++20 et impose que Transform soit trivially copyable, ce que le système de physique a déjà garanti, et la syntaxe exprime clairement la réinterprétation bit par bit sans ambiguïté de cast de pointeurs.
L'équipe a sélectionné std::bit_cast après avoir migré le système de build vers C++20. Cela a éliminé le comportement indéfini tout en maintenant la syntaxe propre du punning par union, et la capacité constexpr a permis de valider la construction des paquets réseau à la compilation lors des tests automatisés.
Le module de mise en réseau a passé les contrôles UBSan et ASan sans règles de suppression. Les benchmarks de performance ont montré un débit identique à memcpy (0,3 ns par conversion sur x86_64), tandis que les outils d'analyse statique n'ont plus signalé de violations d'aliasing. Le code désérialise avec succès 100 000 transformations par seconde en production.
Pourquoi std::bit_cast nécessite-t-il que les types source et destination aient des tailles identiques, et que se passe-t-il si les octets de remplissage diffèrent entre les types ?
L'exigence de taille identique garantit une correspondance bijective entre les motifs de bits ; aucun bit n'est tronqué ou inventé. Si les tailles diffèrent, le cast est mal formé. Les octets de remplissage sont préservés exactement tels qu'ils existent dans l'objet source. Cependant, si le type de destination a des exigences de remplissage différentes, lire ces octets de remplissage à travers le type de destination plus tard est toujours valide (ils font partie de la représentation de valeur de l'objet de destination), mais les valeurs ne sont pas spécifiées. Cela signifie que std::bit_cast peut copier le remplissage, mais vous ne pouvez pas interpréter les bits de remplissage portablement comme ayant des valeurs spécifiques.
En quoi std::bit_cast diffère-t-il de reinterpret_cast en termes de durée de vie d'objet et de durée de stockage ?
reinterpret_cast crée un alias vers le même emplacement de stockage, violant potentiellement la règle du strict aliasing si les types sont non connexes, et ne crée pas un nouvel objet. std::bit_cast crée conceptuellement un nouvel objet du type de destination avec une durée de stockage automatique (ou un stockage constexpr s'il est utilisé dans une expression constante), copiant le motif de bits de la source. Cela ne crée pas un alias ; la source et la destination sont des objets distincts. Cette distinction permet d'utiliser std::bit_cast dans des contextes constexpr où reinterpret_cast est interdit, car il ne nécessite pas de passer par des pointeurs qui échapperaient à l'évaluation constante.
Peut-on utiliser std::bit_cast pour convertir un pointeur en un entier de la même taille, et pourquoi cela pourrait-il produire des résultats définis par l'implémentation bien que ceci soit bien formé ?
Oui, si sizeof(T*) == sizeof(U), std::bit_cast peut convertir entre eux car les pointeurs sont trivially copyable. Cependant, le résultat est défini par l'implémentation car la norme ne prescrit pas une représentation spécifique pour les valeurs de pointeur (par exemple, adressage segmenté, pointeurs tagués). Bien que les bits soient préservés exactement, interpréter ces bits comme un entier ou revenir à un pointeur donne des valeurs définies par l'implémentation. Cela diffère de reinterpret_cast qui garantit une conversion aller-retour pour les pointeurs vers les entiers et vice versa (si le type entier est suffisamment grand), mais std::bit_cast traite le pointeur comme un bagage de bits, perdant l'information de provenance que le compilateur utilise pour l'analyse d'alias.