Skip to content

Texte : systèmes de typage et types élémentaires. Texte

Notion de type

Abordons brièvement la notion de type.

Un type (de donnée) est : 1. un ensemble de valeurs que peut prendre la donnée ; 2. un ensemble fini (et exhaustivement énuméré) d'opérations qui peuvent lui être appliquées.

La traduction de ce concept dans un langage de programmation nécessite de compléter cette définition par :

  1. un codage des valeurs sous forme binaire, occupant donc un certain nombre de bits.

Chaque langage offre un certain nombre de types élémentaires prédéfinis (« standard types » en Python, « fundamental types » en C/C++), à partir desquels l'on peut définir de nouveaux types composés ou dérivés.

Une donnée informatique ne peut occuper qu'un nombre fini de bits — elle est toujours limitée ne serait-ce que par la taille physique des ressources de l'ordinateur hôte — et le nombre de valeurs possibles pour un type est toujours fini, quelle que soit sa représentation ou sa définition formelle. Qu'on le veuille ou non, « l'informatique est la science du fini », contrairement aux mathématiques (et certains sujets d'informatique théorique) qui se complaisent dans les infinis de tous genres.

Système de typage

Les règles déterminant la manière dont les types sont attribués aux entités (variables, constantes, objets, fonctions...) et expressions d'un langage constituent un système de typage.

Typage statique ou dynamique Cette distinction repose sur le moment où la détermination du type et la vérification de la faisabilité de l'opération, moyennant d'éventuelles conversions supplémentaires, sont effectuées : dès la compilation — vérification statique (static type-checking) — ou lors de l'exécution — vérification dynamique ou « par le runtime » (dynamic type-checking).

Dans les langages à système de type statique, un programme doit contenir une information permettant d'annoncer qu'un nom sera celui d'une entité ayant un type précis et déterminé. Pour cela, des « instructions déclaratives » sont utilisées.

Le système de typage de Python utilise un mécanisme réflexif : les types sont des objets auxquels le programme accède durant l'exécution.

Typage fort ou faible Le sens de cette distinction n'est pas universellement fixé. La majorité des auteurs qualifient de « typage fort » un système suffisamment strict et riche pour procurer une vérification sûre de la cohérence du programme écrit et l'insertion non ambiguë de conversions efficaces, ce qu'on appelle « type-safety ». Un système de typage fort devrait également garantir l'intégrité des données lors de l'exécution du programme, donc assurer un accès discrétionnaire au contenu de la mémoire (et au codage binaire des valeurs), ce qu'on appelle « memory-safety ».

Des langages comme Java, Pascal, Ada, LISP... et même COBOL sont fortement typés. Par contre, FORTRAN et C ne le sont pas. Python pourrait, par certains aspects, être qualifié de fortement typé, mais la possibilité d'agir de manière imprévue sur le type d'un objet (une simple assignation à un nouvel attribut inexistant dans la définition de la classe, par exemple) rend impossible la détermination a priori du (bon) sens des opérations de la suite du programme. Celles-ci conduiront, en cas d'erreur, à la levée d'une exception qui, si elle n'a pas été explicitement prévue par le programmeur, conduit à l'arrêt brutal du programme. Python est plutôt « typé comme un canard » (duck typing).

Nous vous référons à l'apprentissage de base de Python pour l'étude des types natifs, qui sont soit des types élémentaires : int, float, complex, bool, soit des types composés : tuple, list, dict, set, .... Notons qu'en python, le type caractère n'existe pas : en Python 'a' est de type chaîne de caractères (str).

D'une manière générale, les types composés dans les langages de programmation se répartissent en 5 familles principales :

  1. les pointeurs et références : le type donne un accès indirect à un type de base ;
  2. les énumérations : le type est un sous-type d'entiers — son type de base — constitué d'une collection de noms de constantes ;
  3. les tableaux (array) : le type correspond à une collection de valeurs toutes du même type de base — c'est un type homogène — contiguës en mémoire et identifiées par un ou plusieurs indices ;
  4. les classes et leurs nombreuses variantes : le type correspond à une collection de valeurs diverses — c'est un type hétérogène — appelées «membres» et identifiées par un identificateur. On distingue :
  5. les enregistrements (record) : un « simple amalgame » de valeurs de types hétérogènes contiguës en mémoire ;
  6. les unions : une même zone de mémoire interprétée comme plusieurs types différents ;
  7. les champs de bits (bit-fields) : les valeurs sont des configurations de quelques bits qui sont rassemblés de façon compacte ;
  8. les n-uplets (tuples) : les composantes sont identifiées par un indice, comme dans un tableau ;
  9. les classes proprement dites, c'est-à-dire « avec méthodes » : le type associe non seulement des données — appelées attributs —, mais également des fonctions — appelées méthodes.
  10. les fonctions : le type est caractérisé par une valeur produite à partir d'une suite de paramètres.

Parmi les types composés nous ne discutons dans ce qui suit que des pointeurs et références.

Références et pointeurs

Un pointeur et une référence fournisssent tous deux un accès indirect à un objet. La différence se situe dans le fait de pouvoir (et devoir) manipuler explicitement ces « adresses ».

Un pointeur est un type d'objet dont la valeur fournit l'accès indirect à un autre objet. Un pointeur est « typé » s'il ne peut pointer que vers des objets d'un seul type fixé, son type de base. L'accès à l'objet pointé s'appelle un déréférencement (dereferencing), opération inverse de la prise d'adresse (ou « calcul » d'adresse) ; ces deux opérations sont toujours explicites.

Un pointeur typé est un type composé, car il est lié au type des objets auquel il donne accès, ce qu'on nomme son type de base.

Ni Java ni Python ne comprennent ce concept de pointeur ; ils utilisent exclusivement de références. L'argument justifiant ce choix est de décharger le programmeur de la responsabilité de la gestion dynamique de données et du contrôle nécessaire associé. Par contre, la plupart des langages algorithmiques tels ALGOL68, Pascal, C, C++, etc. et les versions modernes de FORTRAN, COBOL, BASIC, etc. définissent de tels types, qu'ils implémentent un ramasse-miettes (garbage collector) ou qu'ils laissent le soin au programmeur de désallouer explicitement les objets en fin de vie.

Le ramasse-miettes (garbage collector) est un algorithme sophistiqué de détection des zones mémoire devenues inaccessibles qui peuvent a priori être réutilisées à d'autres fins. Ce processus est appelé automatiquement, à intervalles réguliers ou en cas de manque de place apparent, et de façon transparente pour le programmeur. Toutefois, il est souvent possible de lancer le ramasse-miettes explicitement : c'est une des fonctions de la bibliothèque. Java et Python fonctionnent à l'aide d'un ramasse-miettes.

Une référence est un type de valeur — ce n'est donc pas un type d'objet ni un objet en soi — qui fournit l'accès indirect à une entité (objet, fonction...). Une référence est liée (bound) à une entité particulière, sauf lorsqu'elle est nulle (ce qui n'est pas possible dans tous les langages). Le déréférencement (dereferencing) donnant accès à l'objet lié est implicite.

En général, vu ce déréférencement automatique et inévitable, on utilise une référence dans les expressions et instructions « comme si » l'on utilisait directement l'objet lié ; il n'est donc pas possible d'accéder à la valeur propre de la référence.

En Python, toutes les variables sont des références ; ce sont les objets auxquels elles sont liées qui ont un type particulier. En Java aussi, à l'exception des types primitifs et des énumérations. Par contre, en C, il n'existe aucune référence (toutes les adresses sont des pointeurs), mais en C++, il s'agit d'un type composé distinct. Plus précisément, en Python, l'association, le lien ou la liaison (binding) d'un nom avec une valeur se fait principalement à l'aide de l'instruction d'assignation («=»). Ce lien est brisé à l'aide de l'instruction « del ».

Déclarations et portée

Les « instructions déclaratives » sont apparues dès le début des langages de programmation. Leurs rôles sont multiples et encore peu définis. Elles constituent, pour ces premiers langages, non des instructions stricto sensu, donc destinées à être traduites en code exécutable, mais plutôt des indications au compilateur modifiant la façon dont il traduira d'autres instructions. Parmi ces indications, la plus fréquente (et importante) est celle indiquant le type d'une variable, donc le codage binaire utilisé et le type d'opération et de conversion à lui appliquer.

Comme déjà dit, dans le langage Python, il n'y a pas lieu de déclarer une variable puisqu'un nom acquiert ce statut lorsqu'on lui assigne une valeur. Cette valeur est en fait utilisée pour la construction d'un objet qui est simplement lié au nom. Toute variable est donc une simple référence vers un objet et elle peut être liée successivement à des objets de types différents en cours d'exécution du programme.

Recherche du nom

La recherche du nom (name lookup) est la méthode — donc l'algorithme suivi par le compilateur ou l'interpréteur — qui permet d'associer un nom apparaissant dans une instruction ou tout morceau de code source à sa déclaration, ce qui en identifie les propriétés, type et autres attributs, et le sens précis de cette instruction.

Le mécanisme de recherche du nom en Python est particulièrement complexe et finalement peu intuitif. En effet, les variables n'étant pas déclarées et le mécanisme d'exécution étant conçu comme un système interprété, la recherche s'effectue non selon la logique du texte du programme source, comme dans l'écrasante majorité des langages, même interprétables, mais bien dans l'ordre et le contexte où est exécutée l'instruction qui utilise le nom, ce qui suppose de prendre en compte toutes les instructions déjà exécutées, y compris celles qui suivent dans le texte source.

Comme l'écrit le concepteur même de ce langage, Guido van Rossum : [...] This rule is subtle. Python lacks declarations and allows name binding operations to occur anywhere within a code block. The local variables of a code block can be determined by scanning the entire text of the block for name.

Modèle d'exécution : les blocs

Le modèle d'exécution d'un programme Python et de recherche des noms est donc fondé sur celui de bloc. Un bloc est un morceau de code qui est interprété comme un tout. C'est l'équivalent logique d'une unité de traduction, même si ce terme est ici inapproprié ; on pourrait utiliser, par analogie, « d'unité d'exécution » ou « d'unité d'interprétation ».

Un bloc en Python est : - soit un module : un morceau de code, un fichier source, chargé directement par appel à l'interpréteur, depuis le système ou à l'aide de l'instruction «import» ou appel aux fonctions «eval» ou «exec» ; - soit un corps de fonction : c'est le sous-morceau de code introduit, « contrôlé » par l'instruction « def » qui n'est exécuté que lors d'un appel à la fonction ; - soit une définition de classe : c'est le morceau de code introduit par le mot-clé « class » dont l'exécution a pour effet de créer un objet particulier de type classe accueillant les attributs d'instance de classe, selon un mécanisme décrit plus loin.

Ces blocs peuvent être imbriqués : une fonction ou une classe dans un module, dans une fonction, dans une classe, etc. La hiérarchie de blocs dans laquelle apparaît l'instruction créant un nouveau bloc est ce qu'on appelle l'environnement du bloc.

Chaque bloc en Python dispose de son propre espace de nommage sous la forme d'un dictionnaire contenant les références aux objets locaux. Lors de l'exécution d'un bloc, l'interpréteur lui alloue un bloc d'activation — un objet de type prédéfini «frame» — sur une pile d'exécution (gérée comme une donnée propre par l'interpréteur). On y trouve, entre autres, un attribut (de type référence) lié à un objet de type dictionnaire : le dictionnaire local. Le dictionnaire du module principal d'un programme est créé dès le début de l'interprétation de celui-ci et s'appelle « main » ; celui d'une classe ou d'une fonction est son attribut « dict ».

Vie des objets en Python

Le modèle d'exécution a donc pour conséquence que toutes les variables, y compris les paramètres, sont de simples entrées dans un dictionnaire, elles-mêmes des références vers des objets et tous les objets sont placés dans une zone de mémoire de type « tas » (heap). Le mode de stockage est donc dynamique. Les objets sont créés lors de l'exécution de la première liaison : - « = » : assignation, liaison à un objet contenant la valeur du membre de droite ; - Variable de contrôle dans l'en-tête d'un « for » ; - « as » dans une instruction « with » ou « except » ; - Appel de fonction : attribution de la valeur de chaque paramètre effectif ; - « import » : import du ou des noms définis dans le module ; - « def » et « class » : définition de classe ou fonction.

Ils sont détruits par un mécanisme de ramasse-miettes fondé sur un décompte de références vers l'objet dont la fréquence d'appel et le fonctionnement sont indéfinis (ils dépendent de l'implémentation de l'interpréteur).

Il est à noter qu'un objet cesse rapidement d'être référencé en Python, donc que la mémoire contient de très nombreux « zombies », des objets inutiles. En effet, comme nous l'avons vu, la plupart des types prédéfinis en Python sont immuables. Les seules exceptions sont les séquences variables (mutable sequences), c'est-à-dire «list» et «bytearray», les dictionnaires «dict», les ensembles «set» et quelques « types internes ». Par conséquent, dès qu'on souhaite modifier une variable référençant un objet immuable, cette référence est déliée, puis liée à un nouvel objet, ce qui fait que l'ancienne valeur devient un objet non référencé, donc situé ailleurs en mémoire et devenu inutile. Voyons cela sur un exemple.

x = 1
for i in range(1, 100):
    x = i * x
print(x)

Ce programme — qui calcule la factorielle 100! — remplit en fait la mémoire de 100 objets inutiles : les valeurs successives de i !, puisqu'à chaque itération un nouvel objet est créé, puis lié à x, laissant l'ancienne valeur simplement non référencée. En C/C++, toutes les valeurs successives auraient été contenues dans une seule et même variable.

Déclarations de portée

A priori, une instruction ayant pour effet de lier un objet à une variable fait référence à une variable du dictionnaire local, une référence qu'elle modifie ou crée dans ce dictionnaire. Ce comportement peut être modifié par deux instructions particulières. Elles n'ont pas d'effet sur la recherche des noms, mais toutes deux inhibent la création d'entrées dans le dictionnaire local.

La déclaration « global »

L'instruction « global » indique que les noms fournis dans la suite sont à rechercher ou à insérer dans le dictionnaire global, c'est-à-dire celui du module dans lequel le bloc est écrit, et non dans le dictionnaire local. Pour une fonction, y compris une méthode de classe, ce dictionnaire global est accessible via son attribut « globals ».

Cette déclaration est une instruction interprétée à la place où elle est rencontrée dans l'ordre d'exécution du code, mais son effet s'applique à l'ensemble du bloc où elle apparaît. Par conséquent, une variable locale (ou un paramètre formel) de même nom ne peut avoir été créée précédemment dans ce bloc.

Vu le sens de l'assignation, il est impossible de modifier une variable globale si elle n'a pas été déclarée comme telle, car l'occurrence d'un nom comme membre de gauche l'insère dans le dictionnaire local, s'il n'y figure pas déjà. Les variables créées au niveau d'un module sont à la fois locales à ce bloc et globales pour le code de ce module.

La déclaration « nonlocal »

L'instruction précédente permet d'utiliser des variables du dictionnaire global, mais si l'on veut simplement éviter la création de variables locales pour réutiliser celles existant dans l'environnement, il faut les déclarer par une instruction « nonlocal ». Cette instruction ne peut lister que des noms qui existent déjà dans l'environnement (et non dans le dictionnaire local, bien sûr).

Comme c'est le cas pour « global », cette instruction est interprétée dans l'ordre d'exécution du code et aucune des variables ne peut avoir été créée localement dans son bloc.

Nous allons maintenant illustrer cela.

1  x = 7
2  z = -1
3  def f():
4      x = 42         # crée une variable x locale à f, masque le x global
5      def g():
6          nonlocal x
7          x+=1       # modifie la variable x de f
8      g()            # ce x vaut maintenant 43
9      global y       # crée une variable y globale
10     y = x * z      # usage de la variable z globale
11 print(x, z)        # 7 -1 ; y n'existe pas encore
12 f()
13 print(x, y, z)     # 7 -43 -1 ; x global vaut toujours 7

Recherche du nom

Comme nous venons de le voir à la ligne 10, une variable « z » non locale à un bloc peut être utilisée sans être déclarée par une instruction particulière «nonlocal» ou «global». C'est ce qu'on appelle une variable libre.

Une variable libre (free variable) est une variable (un nom) qui est utilisée dans un bloc sans y être définie.

La recherche du nom de Python se fait dans le bloc local, puis on « remonte » dans l'environnement. Mais il y a une différence notoire avec l'algorithme de C ou de C++ : cette recherche a lieu à l'exécution, donc dynamiquement, et pas statiquement selon le texte du programme. Ceci peut induire des comportements, certes conformes à la définition du langage, mais particulièrement étranges ou inattendus.

x = 7
def f():
    global y   # crée une variable y globale
    y = 2 * x  # usage de la variable x globale
print(x)       # 7 ; y n'existe pas encore
f()
print(x, y)    # 7 14 ; OK
x = "abc"      # y calculé selon la nouvelle valeur de x
f()
print(x, y)    # abc abcabc