Memoire, types, variables

Un programme C peut accéder à la mémoire de l’ordinateur pour conserver le résultat de calculs antérieurs. On parle de mémoire ou mémoire volatile (en anglais memory) lorsque celle ci est accessible rapidement mais se disparait à l’arrêt du programme, et de stockage ou mémoire de masse (en anglais storage) pour la mémoire à accès lent mais persistant à l’arrêt du programme (disque dur, cloud…).

Voici un programme qui accède à la mémoire temporaire de l’ordinateur :

#include <stdio.h>

int main()
{
    int toto = 10;

    printf("%d\n", 5 + toto);
    // affiche 15
}

L’accès à la mémoire se fait par l’intermédiaire de variables et de fonctions : une fonction occupe de la place en mémoire et une variable occupe de la place en mémoire. Variables et fonctions sont considérés comme des objets en C. Un objet en C est une entité qui occupe de la place en mémoire.

Voici un programme qui affiche l’adresse des deux objets que nous avons crée

#include <stdio.h>

int main()
{
    int toto = 10;

    printf("%zu\n", &toto);
    printf("%zu\n", &main);
    // affiche 15
}

Exemple d’affichage

6165771116
4301127484

La machine virtuelle du C nous fournit une adresse représentée par un nombre entier positif.

Ce programme déclare une variable nomée « toto » de type « int » et l’initialise à la valeur 10. Ensuite il additione 5 à la valeur en mémoire référencée par le nom toto. L’opérateur = en C affecte une valeur à une variable. L’opérateur d’égalité en C s’écrit ==.

Noms

Le nom de la variable sert à la référencer après l’avoir déclarée. Un nom de variable doit être unique dans sa portée, et ne pas commencer par un chiffre. Une variable ne peut pas avoir le même nom qu’un mot clé du langage (if, switch, etc.). La casse est significative : toto et Toto sont deux variables différentes. Une variable hormis des chiffres et des lettres peut contenir des underscore _ : on peut les utiliser pour améliorer la lisibilité du nom de la variable, mais il faut éviter de faire commencer une variable par un _ car un tel procédé est souvent utilisé par la librairie standart. Traditionellement, les noms de variables en C sont en minuscule et les constantes symboliques en toutes majuscules.

Pour une variable locale, les 31 premiers caractères sont significatifs, pour les variables globales exportées par une librarie, on recommande qu’elle ne dépasse pas 6 caractères de sorte qu’un maximum de langages puissent linker notre librairie.

Types

Le C possède quatre types de base : char qui contient un octet et qui sert à contenir un caractère ASCII, int qui sert à contenir un nombre entier servant à faire des calculs avec des nombres entiers, float qui sert à contenir des nombres à virgule flottante en simple précision et double qui contient des nombres à virgule flottante en double précision.

Il est possible de regrouper ces variables dans des tableaux, des structures et des unions. Un tableau de caractères est considéré comme une chaine de caractères et possède une notation particulière.

Il est possible de déclarer des pointeurs qui pointent sur ces variables, ainsi que sur des fonctions retournant et prenant en argument de telles variables.

Le type d’une variable spécifie sa taille en mémoire, c’est à dire l’ensemble des valeurs qu’il peut avoir, et les opérations qui sont permises dessus.

#include <stdio.h>

main() {
    char variable; // variable de type char non initialiasée
    char variable1 = 'A'; // variable de type char initialiasée
    int variable2 = 12; // variable de int initialisée
    float variable3 = 12.34f; // variable de type nombre à virgule
    double variable4 = 12.34; // variable de type nombre à virgule en double précision

    int 1var; // illegal
    int _ma_var; // déconseillé
    ma_var_implicite = 12; // par défaut le type d'une variable est 'int'

    putchar(variable1); // affiche un caractère à l'écran
}

putchar() affiche à l’écran un caractère isolé. Contrairement à printf() qui prend en argument des chaînes de caractère entourées de guillemets double « " », les caractères littéraux isolés sont entourés de guillemets simples « ’ »

Types entiers signés

Le type par défaut d’une variable en C est int pour integer soit un nombre entier. Une variable déclarée sans type est par défaut de type int. Ce mécanisme à l’origine facilitant le portage de programmes écrits en B est aujourd’hui désuet.


// fonction retournant un int de manière implicite, notation désuete
ma_fonction() {  
    int ma_variable;
    mon_autre_variable; // aussi un int, désuet

}

On peut spécifier si on veut que la variable soit signée ou non, c’est à dire qu’elle accepte ou non des valeurs négatives.

ma_fonction() {
    signed int var1 = -12;
    unsigned int var2 = 12;  // ne pourra jamais être plus petit que zero (0)
    int var3 = -12; // par défaut un int est de type signé
    unsigned var4 = 12; // int est optionel si on spécifie unsigned
    signed var4 = 12; // de même pour signed
}

Par défaut le type int est signé : ainsi le mot-clé signed est inutile, sauf si on travaille avec le type char car il n’est pas spécifié qu’il doive être signé ou non. En pratique il est généralement non signé pour permettre plus intuitivement de contenir des caractères étendus de l’ASCII. Si on veut travailler avec des entiers de un octet, il faut donc spécifier explicitement signed char et unsigned char selon qu’on veuille travailler sur une plage de -128/127 ou 0/255.

En dehors de ces rares cas, il est rare de rencontrer le mot clé signed dans un code source, auquel cas il peut être un indicateur de problème.

Le C traditionnellement accepte 3 représentations différentes des entiers signés : une représentation en bit de signe et magnitude, une représentation en complément à un, et une représentation en complément à 2.

Dans une représentation en bit de signe, on utilise le premier bit de poids fort pour indiquer le signe du nombr : 1 aura la représentation binaire 0000 0001, et -1 aura la représentation binaire 1000 0001. Dans cette représentation il y a deux facons de noter zero : 0000 0000 et 1000 0000.

La représentation en complément inverse tous les bits pour représenter la valeur opposée : c’est à dire que 12 sera noté 0000 1100, et -12 sera noté 1111 0011. Dans cette notation il y a aussi deux facons de représenter 0 : 0000 0000 et 1111 1111 On parle parfois de zero positif et négatif : +0 et -0.

Finalement la notation en complément à deux soustrait de un la valeur qu’on souhaite inverser puis inverse tous les bits : pour avoir -10 dans cette notation on part de 10 soit 0000 1010, on soustrait un soit 0000 1001, puis on inverse soit 1111 0110. Dans cette notation -1 se note 1111 1111, et il n’y a qu’une seule facon de représenter le chiffre 0.

La notation en complément à deux permet de faire des opérations arithmétiques sans nécessiter de conversion, par exemple pour calculer 10 - 1, on additionne 10 à la valeur -1, soit 0000 1010 + 1111 1111, soit 0000 1001.

Si on a la valeur 127 codée 0111 1111 et qu’on lui ajoute 1, on obtient 1000 0000, qui dans une machine en signe et magnitude vaut -0, une machine en complément à 1 vaut -127 et une machine en complément à 2 vaut -128. Le C ne garantit pas la valeur d’un tel entier signé en cas de dépassement de capacité et considère qu’un tel comportement comme une condition d’erreur.

La notation en complément à deux s’est imposée comme unique standart, mais historiquement le C considérait les trois procédés comme possible, et ne garantissait pas la représentation binaire d’un nombre négatif, et à fortiori le comportement en cas de débordement d’entier.

En complément à un et en signe et magintude, un signed char peut être dans la plage -127/127, en complément à deux un signed char peut être dans la place -128/127, en cas de dépassement le C ne garantit pas la valeur qu’aura un signed int. Pour profiter de l’arithmétique modulaire il faut donc spécifiquement déclarer la variable comme unsigned.

Dans les prochaines versions du C, le codage en complément à deux sera l’unique acceptée et le comportement en cas de dépassement ne sera plus indéfini.

Types entiers extensibles

Un entier de un octet s’appelle char : il sert à contenir un caractère affichable dans le jeu de caractères local. Depuis le C99 ce jeu de caractères est l’utf8.

Un entier de 16 bits s’appelle int. Lorsque les machines 32 bits sont apparues, les mots clés short et long ont permis de spécifier explicitement un entier de 16 ou 32 bits : dans ce cas il faut spécifier short int et long int comme type. Si on utilise une valeur de int nue, selon qu’on soit sur une machine 16 ou 32 bits, celui-ci vaudra 16 ou 32 bits - la taille avec laquelle le processeur préfère effectuer des calculs avec des nombres entiers.

Lorsque les machines 64 bits sont apparues il est devenu possible de spécifier un type long long int pour obtenir explicitement des entiers de 64 bits, mais si lors du passage des ordinateurs 16 à 32 bits, le type int a suivi le mouvement, lors du passage de 32 à 64 bits, le type int est généralement resté bloqué à 32 bits.

Sur linux (et plus généralement POSIX) on utilise short int pour un entier 16 bits, int pour un entier 32 bits et long int pour un entier 64 bits (💩).

Le type int a une taille variable et long int a une signifiation différente selon qu’on soit dans le monde Linux ou non, pour pallier cela on a des types entiers à taille explicite de la forme uint_32, int_16 et ainsi de suite (💩).

Voici les bornes des différents types des entiers et leur équivalent en écriture explicte.

type signed unsigned
char -128 / 127 (int_8) 0/255 (uint_8)
short -32768 / 32767 0/65536
long -2 147 483 648 / 2 147 483 647 0/4 294 967 296
long long -9 223 372 036 854 775 808 / 9 223 372 036 854 775 807 0/18 446 744 073 709 551 616

Performance

On peut également spécifier la taille maximale souhaitée pour l’entier. Avant les années 70 et l’ASCII, les ordinateurs avaient des mots de grande taille, typiquement 12, 16, 18 bits… où on stockait des nombres mais aussi des lettres. L’ASCII ayant popularisé un système a 7 bits, et l’EBCDIC étant sur 8 bits, pour stocker du texte les ordinateurs à 8 bits sont devenus la norme, et pour calculer avec des nombres de grande taille les processeurs sont devenus « extensibles », avec des registres étendus, où AX, BX, CX, DX aggrandissent les reguistres A, B, C et D et pemettent des calculs sur 16 bits là où les registres initiaux jadis allants jusque 24 bits sont confinés à 8 bits. Avec les ordinateurs 32 bits, les registres EAX, EBX, etc. doublent cette capacité. Le C permet de travailler avec des sous parties de registres pour coller aux programmes assembleur qui se prêtaient à des optimisations d’usage des registres. Le besoin d’optimiser l’usage des registres est extrêmement marginal.

Il est en règle générale dangeureux et inutile de travailler avec des entiers plus petits que la taille maximale permise par les registres. Le gain de stocker ses calculs dans des short ou des char plutot que des int est marginal au risque de débordement de tampon. Faire travailler un processeur en « sous régime » a également pour conséquence de le faire travailler en performances dégradées.

Utiliser le modificateur « unsigned » pour gagner quelques octets d’espace est également contre-productif, car il est peu probable qu’on ait besoin de stocker des valeurs spécifiquement plus grandes que 2 milliard et plus petites que 4 milliard. Le gain est ici marginal au danger de soustraire une valeur qui produirait un nombre négatif, ou de faire une comparaison avec un type incompatible qui enclencherait un mécanisme complèxe et hasardeux de promotion d’entier .

Les types int décorés sont une source récurrente de problèmes difficile à déceler : ainsi trouver un « short », un « signed char » ou un « unsigned » dans un code source et généralement un indicateur de problème et un symptome de code non robuste. On peut également considérer que d’avoir besoin de stocker des valeurs plus grandes que 2 milliards soit marginal et que « long long » soit problématique. Le simple type int devrait être le seul que vous utilisiiez.

Même si vous êtes certain que votre variable ne contiendra que des valeurs de petite taille ou que des valeurs positives, n’utilisez pas de types unsigned int, short int ou char, car vous dégraderiez les performances du programme et ouvrez la portée à une volée de problèmes et de bugs potentiels.

Type texte

Le C fournit un type char contenu sur un octet et qui contient un caractère ASCII qui a vocation a être affiché comme texte. Un caractère ASCII nécessite 7 bits, le 8ème peut être utilisé comme bit de parité , ou bien comme mécanisme d’extension de l’ASCII. Selon la machine le type char peut être signé ou non. On considère, même dans la norme, que le type char seul soit un type à part entière - non entier ayant pour unique vocation de contenir des valeurs de code ASCII. Si on veut faire des calculs mathématiques sur des entiers limités 8 bits on doit utiliser signed char et unsigned char qui sont des types de nombres entiers et dont des types texte.

Nombres à virgule fixe

Pour palliers aux débordements d’entiers, une extension au C propose des nombres à virgule fixe saturés 🌍⤴ , qui ne sont pas pour l’instant dans la norme.

Portée

Dans le C ANSI, une variable ne peut être déclarée que en début de fonction. Depuis le C99, cette restriction est enlevée et on peut déclarer une variable nímporte où au sein d’une fonction. Depuis le C99 une variable possède donc une visibilité qui n’est plus globale à la fonction mais limitée au bloc où elle se trouve.

#include<stdio.h>

main() {
    char ma_var = 'A';

    putchar(ma_var); // A

    { // déclare un bloc
        char ma_var = 'B'; // redéclare la variable au sein du bloc
        putchar(ma_var); // B

        char ma_deuxieme_var = 'C';
    }

    // a la sortie du bloc, la variable ma_var de valeur B disparait

    putchar(ma_var); // A

    putchar(ma_deuxieme_var); // en outre ceci est illégal, 
    // ma_deuxieme_var n'existe que au sein du bloc
}

Le goto n’ignore pas les déclarations de variables qui le précèdent

#include<stdio.h>

main() {
    char ma_var = 'A';

    goto ici;
    { // déclare un bloc
        char ma_var = 'B'; // redéclare la variable au sein du bloc
        
        ici:
        putchar(ma_var); // indéfini, le goto a sauté l'instruction
        // d'assignation, mais l'espace sur la pile existe
    }
}

Additional resources

  • Christer Ericson: Game Development Memory Optimization 🌍⤴
  • Performance http://mgarcia.org/Blog/2019-05-01-Game-engine-programmers-attracting-huge-wages 🌍⤴