web-dev-qa-db-fra.com

Syndicats et punition de type

Je cherche depuis un moment, mais je ne trouve pas de réponse claire.

Beaucoup de gens disent que le recours aux syndicats pour taper des mots est indéfini et une mauvaise pratique. Pourquoi est-ce? Je ne vois aucune raison pour laquelle cela ferait quoi que ce soit d'indéfini étant donné que la mémoire dans laquelle vous écrivez les informations d'origine ne changera pas de lui-même (sauf si cela sort du cadre de la pile, mais ce n'est pas un problème d'union , ce serait une mauvaise conception).

Les gens citent la règle stricte d'alias, mais cela me semble être comme dire que vous ne pouvez pas le faire parce que vous ne pouvez pas le faire.

Quel est également l'intérêt d'un syndicat si ce n'est de taper un jeu de mots? J'ai vu quelque part qu'ils sont censés être utilisés pour utiliser le même emplacement de mémoire pour différentes informations à différents moments, mais pourquoi ne pas simplement supprimer les informations avant de les réutiliser?

Pour résumer:

  1. Pourquoi est-il mauvais d'utiliser des syndicats pour les punitions de type?
  2. À quoi cela sert-il sinon?

Informations supplémentaires: J'utilise principalement C++, mais j'aimerais en savoir plus à ce sujet et C. En particulier, j'utilise des unions pour convertir entre les flottants et l'hex brut pour envoyer via le bus CAN.

59
Matthew Wilkins

Pour réitérer, la frappe de type via les unions est parfaitement correcte en C (mais pas en C++). En revanche, l'utilisation de transtypages de pointeurs à cet effet viole l'alias strict C99 et est problématique car différents types peuvent avoir des exigences d'alignement différentes et vous pouvez soulever un SIGBUS si vous le faites mal. Avec les syndicats, ce n'est jamais un problème.

Les citations pertinentes des normes C sont:

C89 section 3.3.2.3 §5:

si un membre d'un objet union est accédé après qu'une valeur a été stockée dans un autre membre de l'objet, le comportement est défini par l'implémentation

C11 section 6.5.2.3 §3:

Une expression de suffixe suivie du. L'opérateur et un identifiant désignent un membre d'une structure ou d'un objet union. La valeur est celle du membre nommé

avec la note de bas de page 95 suivante:

Si le membre utilisé pour lire le contenu d'un objet union n'est pas le même que le dernier membre utilisé pour stocker une valeur dans l'objet, la partie appropriée de la représentation d'objet de la valeur est réinterprétée en tant que représentation d'objet dans le nouveau type comme décrit au 6.2.6 (un processus parfois appelé "type punning"). Cela pourrait être une représentation piège.

Cela devrait être parfaitement clair.


James est confus parce que C11 section 6.7.2.1 §16 lit

La valeur d'au plus un des membres peut être stockée à tout moment dans un objet union.

Cela semble contradictoire, mais ce n'est pas le cas: contrairement à C++, en C, il n'y a pas de concept de membre actif et il est parfaitement correct d'accéder à la valeur stockée unique via une expression d'un type incompatible.

Voir également C11 annexe J.1 §1:

Les valeurs des octets qui correspondent aux membres de l'union autres que le dernier stocké dans [ne sont pas spécifiées].

En C99, cela lisait

La valeur d'un membre du syndicat autre que le dernier enregistré dans [n'est pas spécifiée]

C'était incorrect. Comme l'annexe n'est pas normative, elle n'a pas évalué son propre TC et a dû attendre la prochaine révision standard pour être corrigée.


Extensions GNU au standard C++ (et au C90) autorise explicitement la punition de type avec les unions . D'autres compilateurs qui ne prennent pas en charge GNU peuvent également prendre en charge le type union punning, mais cela ne fait pas partie de la norme de langage de base.

38
Christoph

Le but initial des syndicats était d'économiser de l'espace lorsque vous voulez pouvoir représenter différents types, ce que nous appelons un type de variante voir Boost.Variant comme un bon exemple de cette.

L'autre utilisation courante est type punning la validité de ceci est discutée mais pratiquement la plupart des compilateurs le supportent, nous pouvons voir que gcc documente son support :

La pratique de lire à partir d'un autre membre du syndicat que celui sur lequel on a écrit le plus récemment (appelé "punition de type") est courante. Même avec -fstrict-aliasing, la punition de type est autorisée, à condition que la mémoire soit accessible via le type d'union. Ainsi, le code ci-dessus fonctionne comme prévu.

notez qu'il dit même avec -fstrict-aliasing, la punition de type est autorisée ce qui indique qu'il y a un problème d'aliasing en jeu.

Pascal Cuoq a fait valoir que rapport de défaut 28 a clarifié cela était autorisé dans C. rapport de défaut 28 a ajouté la note de bas de page suivante pour clarification:

Si le membre utilisé pour accéder au contenu d'un objet union n'est pas le même que le dernier membre utilisé pour stocker une valeur dans l'objet, la partie appropriée de la représentation d'objet de la valeur est réinterprétée en tant que représentation d'objet dans le nouveau type comme décrit en 6.2.6 (un processus parfois appelé "type punning"). Cela pourrait être une représentation piège.

en C11, ce serait la note de bas de page 95.

Bien que dans le std-discussion sujet du groupe de messagerie Type Punning via a Union l'argument est avancé, ceci n'est pas spécifié, ce qui semble raisonnable puisque DR 283 n'a pas ajouté de nouvelle formulation normative, juste une note de bas de page:

Il s'agit, à mon avis, d'un bourbier sémantique sous-spécifié en C. Aucun consensus n'a été atteint entre les implémenteurs et le comité C quant à savoir exactement quels cas ont défini un comportement et lesquels ne [...]

En C++ on ne sait pas si c'est un comportement défini ou non .

Cette discussion couvre également au moins une raison pour laquelle il n'est pas souhaitable d'autoriser la punition de type via une union:

[...] les règles de la norme C rompent les optimisations d'analyse d'alias basées sur le type que les implémentations actuelles effectuent.

cela casse certaines optimisations. Le deuxième argument contre cela est que l'utilisation de memcpy devrait générer un code identique et ne rompt pas les optimisations et les comportements bien définis, par exemple ceci:

std::int64_t n;
std::memcpy(&n, &d, sizeof d);

au lieu de cela:

union u1
{
  std::int64_t n;
  double d ;
} ;

u1 u ;
u.d = d ;

et nous pouvons voir en utilisant godbolt cela génère du code identique et l'argument est fait si votre compilateur ne génère pas de code identique, cela devrait être considéré comme un bug:

Si cela est vrai pour votre implémentation, je vous suggère de déposer un bug dessus. Rompre de réelles optimisations (quoi que ce soit basé sur une analyse d'alias basée sur le type) afin de contourner les problèmes de performances avec un compilateur particulier me semble une mauvaise idée.

Le billet de blog Type Punning, Strict Aliasing et Optimization arrive également à une conclusion similaire.

La discussion sur la liste de diffusion des comportements indéfinis: Tapez punning pour éviter de copier couvre beaucoup le même terrain et nous pouvons voir à quel point le territoire peut être gris.

11
Shafik Yaghmour

C'est légal en C99:

De la norme: 6.5.2.3 Structure et membres du syndicat

Si le membre utilisé pour accéder au contenu d'un objet union n'est pas le même que le dernier membre utilisé pour stocker une valeur dans l'objet, la partie appropriée de la représentation d'objet de la valeur est réinterprétée en tant que représentation d'objet dans le nouveau type comme décrit en 6.2.6 (un processus parfois appelé "type punning"). Cela pourrait être une représentation piège.

6
David Ranieri

BREVE RÉPONSE:Type punning peut être sûr dans quelques circonstances. D'un autre côté, bien que cela semble être une pratique très connue, il semble que la norme ne s'intéresse pas beaucoup à l'officialiser.

Je ne parlerai que de C (pas C++).

1. TYPE PUNNING ET LES NORMES

Comme les gens l'ont déjà souligné, mais, type punning est autorisé dans la norme C99 et également C11, dans la sous-section 6.5.2.. Cependant, je vais réécrire les faits avec ma propre perception du problème:

  • La section 6.5 des documents standard C99 et C11 développe le sujet expressions.
  • La sous-section 6.5.2 est appelée expressions postfixées.
  • La sous-section 6.5.2. parle de structures et unions.
  • Le paragraphe 6.5.2.3 (3) explique opérateur point appliqué à un objet struct ou union, et quelle valeur sera obtenue .
    Juste là, le note de bas de page 95 apparaît. Cette note de bas de page dit:

Si le membre utilisé pour accéder au contenu d'un objet union n'est pas le même que le dernier membre utilisé pour stocker une valeur dans l'objet, la partie appropriée de la représentation d'objet de la valeur est réinterprétée en tant que représentation d'objet dans le nouveau type comme décrit en 6.2.6 (un processus parfois appelé "type punning"). Cela pourrait être une représentation piège.

Le fait que type punning apparaît à peine, et comme note de bas de page, cela donne un indice que ce n'est pas un problème pertinent dans la programmation C.
En fait, le but principal de l'utilisation de unions est d'économiser de l'espace (en mémoire). Puisque plusieurs membres partagent la même adresse, si l'on sait que chaque membre utilisera différentes parties du programme, jamais en même temps, alors un union peut être utilisé à la place un struct, pour économiser de la mémoire.

  • La sous-section 6.2.6 est mentionnée.
  • La sous-section 6.2.6 parle de la façon dont les objets sont représentés (en mémoire, par exemple).

2. REPRÉSENTATION DES TYPES ET DE SON PROBLÈME

Si vous prêtez attention aux différents aspects de la norme, vous pouvez être sûr de presque rien:

  • La représentation des pointeurs n'est pas clairement spécifiée.
  • Pire, les pointeurs de types différents peuvent avoir une représentation différente (en tant qu'objets en mémoire).
  • Les membres union partagent la même adresse de titre en mémoire, et c'est la même adresse que celle de l'objet union lui-même.
  • Les membres struct ont une adresse relative croissante, en commençant exactement à la même adresse mémoire que l'objet struct lui-même. Cependant, des octets de remplissage peuvent être ajoutés à la fin de chaque membre. Combien? C'est imprévisible. Les octets de remplissage sont principalement utilisés à des fins d'alignement de mémoire.
  • Les types arithmétiques (entiers, nombres réels à virgule flottante et nombres complexes) peuvent être représentables de plusieurs façons. Cela dépend de la mise en œuvre.
  • En particulier, les types entiers pourraient avoir bits de remplissage. Ce n'est pas vrai, je crois, pour les ordinateurs de bureau. Cependant, la norme a laissé la porte ouverte à cette possibilité. Les bits de remplissage sont utilisés à des fins spécifiques (parité, signaux, qui sait), et non pour conserver des valeurs mathématiques.
  • signed les types peuvent avoir 3 manières d'être représentés: complément à 1, complément à 2, juste signe-bit.
  • Les types char n'occupent qu'un octet, mais 1 octet peut avoir un nombre de bits différent de 8 (mais jamais inférieur à 8).
  • Cependant, nous pouvons être sûrs de certains détails:

    une. Les types char n'ont pas de bits de remplissage.
    b. Les types entiers unsigned sont représentés exactement comme sous forme binaire.
    c. unsigned char Occupe exactement 1 octet, sans bits de remplissage, et il n'y a pas de représentation d'interruption car tous les bits sont utilisés. De plus, il représente une valeur sans aucune ambiguïté, suivant le format binaire pour les nombres entiers.

. TYPE PUNNING vs TYPE REPRESENTATION

Toutes ces observations révèlent que, si nous essayons de faire type punning avec des membres union ayant des types différents de unsigned char, Nous pourrions avoir beaucoup d'ambiguïté. Ce n'est pas du code portable et, en particulier, nous pourrions avoir un comportement imprévisible de notre programme.
Cependant, la norme autorise ce type d'accès.

Même si nous sommes sûrs de la manière spécifique dans laquelle chaque type est représenté dans notre implémentation, nous pourrions avoir une séquence de bits ne signifiant rien du tout dans d'autres types (représentation trap). Nous ne pouvons rien faire dans ce cas.

4. LE CAS SÛR: caractère non signé

La seule manière sûre d'utiliser type punning est avec les tableaux unsigned char Ou bien unsigned char (Car nous savons que les membres des objets tableau sont strictement contigus et qu'il n'y a pas de remplissage octets lorsque leur taille est calculée avec sizeof()).

  union {
     TYPE data;
     unsigned char type_punning[sizeof(TYPE)];
  } xx;  

Comme nous savons que unsigned char Est représenté sous une forme binaire stricte, sans bits de remplissage, le type punning peut être utilisé ici pour jeter un œil à la représentation binaire du membre data.
Cet outil peut être utilisé pour analyser comment les valeurs d'un type donné sont représentées, dans une implémentation particulière.

Je ne suis pas en mesure de voir une autre application sûre et utile de type punning selon les spécifications standard.

5. UN COMMENTAIRE SUR LES CASTS ...

Si l'on veut jouer avec les types, il vaut mieux définir ses propres fonctions de transformation, ou bien utiliser simplement casts. Nous pouvons nous souvenir de cet exemple simple:

  union {
     unsigned char x;  
     double t;
  } uu;

  bool result;

  uu.x = 7;
  (uu.t == 7.0)? result = true: result = false;
  // You can bet that result == false

  uu.t = (double)(uu.x);
  (uu.t == 7.0)? result = true: result = false;
  // result == true
4
pablo1977

Il existe (ou du moins existait, en C90) deux modifications pour rendre ce comportement indéfini. La première était qu'un compilateur serait autorisé à générer du code supplémentaire qui suivrait ce qui était dans l'union et générait un signal lorsque vous accédiez au mauvais membre. Dans la pratique, je pense que personne ne l'a jamais fait (peut-être CenterLine?). L'autre était les possibilités d'optimisation qui s'ouvraient, et celles-ci sont utilisées. J'ai utilisé des compilateurs qui différeraient l'écriture jusqu'au dernier moment possible, au motif que cela pourrait ne pas être nécessaire (parce que la variable sort du cadre ou qu'il y a une écriture ultérieure d'une valeur différente). Logiquement, on pourrait s'attendre à ce que cette optimisation soit désactivée lorsque l'union était visible, mais ce n'était pas dans les premières versions de Microsoft C.

Les problèmes de punition de type sont complexes. Le comité C (à la fin des années 1980) a plus ou moins pris la position que vous devriez utiliser des transtypages (en C++, reinterpret_cast) pour cela, et non des syndicats, bien que les deux techniques étaient répandues à l'époque. Depuis lors, certains compilateurs (g ++, par exemple) ont adopté le point de vue opposé, soutenant l'utilisation des unions, mais pas l'utilisation des transtypages. Et dans la pratique, ni l'un ni l'autre ne fonctionnent s'il n'est pas immédiatement évident qu'il existe une punition de type. Cela pourrait être la motivation derrière le point de vue de g ++. Si vous accédez à un membre du syndicat, il est immédiatement évident qu'il pourrait y avoir une punition de type. Mais bien sûr, étant donné quelque chose comme:

int f(const int* pi, double* pd)
{
    int results = *pi;
    *pd = 3.14159;
    return results;
}

appelé avec:

union U { int i; double d; };
U u;
u.i = 1;
std::cout << f( &u.i, &u.d );

est parfaitement légal selon les règles strictes de la norme, mais échoue avec g ++ (et probablement de nombreux autres compilateurs); lors de la compilation de f, le compilateur suppose que pi et pd ne peuvent pas être alias et réorganise l'écriture sur *pd et la lecture de *pi. (Je crois que cela n'a jamais été l'intention de garantir cela. Mais le libellé actuel de la norme le garantit.)

ÉDITER:

Étant donné que d'autres réponses ont fait valoir que le comportement est en fait défini (largement basé sur la citation d'une note non normative, prise hors contexte):

La bonne réponse ici est celle de pablo1977: la norme ne tente pas de définir le comportement lorsque le type punning est impliqué. La raison probable en est qu'il n'y a aucun comportement portable qu'il pourrait définir. Cela n'empêche pas une implémentation spécifique de la définir; bien que je ne me souvienne pas de discussions spécifiques sur la question, je suis presque sûr que l'intention était que les implémentations définissent quelque chose (et la plupart, sinon toutes, le font).

En ce qui concerne l'utilisation d'une union pour la punition de type: lorsque le comité C développait C90 (à la fin des années 1980), il y avait une intention claire d'autoriser le débogage des implémentations qui effectuaient une vérification supplémentaire (comme l'utilisation de pointeurs gras pour la vérification des limites). D'après les discussions de l'époque, il était clair que l'intention était qu'une implémentation de débogage puisse mettre en cache les informations concernant la dernière valeur initialisée dans une union et intercepter si vous tentiez d'accéder à autre chose. Ceci est clairement indiqué au §6.7.2.1/16: "La valeur d'au plus un des membres peut être stockée à tout moment dans un objet union." Accéder à une valeur qui n'existe pas est un comportement indéfini; il peut être assimilé à l'accès à une variable non initialisée. (Il y avait des discussions à l'époque pour savoir si l'accès à un membre différent avec le même type était légal ou non. Je ne sais pas quelle était la résolution finale, cependant; après environ 1990, je suis passé au C++.)

En ce qui concerne la citation de C89, dire que le comportement est défini par l'implémentation: le trouver dans la section 3 (Termes, définitions et symboles) semble très étrange. Je vais devoir chercher dans ma copie de C90 à la maison; le fait qu'il ait été supprimé dans les versions ultérieures des normes suggère que sa présence a été considérée comme une erreur par le comité.

L'utilisation d'unions prises en charge par la norme est un moyen de simuler la dérivation. Vous pouvez définir:

struct NodeBase
{
    enum NodeType type;
};

struct InnerNode
{
    enum NodeType type;
    NodeBase* left;
    NodeBase* right;
};

struct ConstantNode
{
    enum NodeType type;
    double value;
};
//  ...

union Node
{
    struct NodeBase base;
    struct InnerNode inner;
    struct ConstantNode constant;
    //  ...
};

et accéder légalement à base.type, même si le Node a été initialisé par inner. (Le fait que §6.5.2.3/6 commence par "Une garantie spéciale est faite .. . "et continue à autoriser explicitement ceci est une indication très forte que tous les autres cas sont censés être un comportement non défini. comportement '' ou en omettant toute définition explicite de comportement "au §4/2; pour affirmer que le comportement n'est pas indéfini, vous devez montrer où il est défini dans la norme. )

Enfin, en ce qui concerne le type-punning: toutes les implémentations (ou du moins toutes celles que j'ai utilisées) le prennent en charge d'une manière ou d'une autre. Mon impression à l'époque était que l'intention était que la distribution de pointeurs soit la manière dont une implémentation la soutenait; dans le standard C++, il y a même du texte (non normatif) pour suggérer que les résultats d'un reinterpret_cast être "sans surprise" pour quelqu'un qui connaît bien l'architecture sous-jacente. Dans la pratique, cependant, la plupart des mises en œuvre soutiennent le recours à l'union pour la punition de type, à condition que l'accès se fasse par l'intermédiaire d'un membre du syndicat. La plupart des implémentations (mais pas g ++) prennent également en charge les transtypages de pointeurs, à condition que le transtypage du pointeur soit clairement visible pour le compilateur (pour une définition non spécifiée du transtypage du pointeur). Et la "standardisation" du matériel sous-jacent signifie que des choses comme:

int
getExponent( double d )
{
    return ((*(uint64_t*)(&d) >> 52) & 0x7FF) + 1023;
}

sont en fait assez portables. (Cela ne fonctionnera pas sur les mainframes, bien sûr.) Ce qui ne fonctionne pas sont des choses comme mon premier exemple, où l'aliasing est invisible pour le compilateur. (Je suis à peu près sûr qu'il s'agit d'un défaut dans la norme. Je crois me souvenir même d'avoir vu un DR à ce sujet.)

3
James Kanze