Skip to content

2 2 6 3 Histoire2 5

Langages impératifs

Cette première famille, de loin la plus répandue, est à l'origine une « humanisation », une formalisation plus lisible par un humain du code machine binaire (machine code) ou de sa version textuelle (i.e. sous forme de véritables caractères) en langage d'assemblage ou assembleur (assembly language).

Un programme dans un langage impératif (imperative langage) décrit donc une suite d'instructions qui modifient l'état de la machine.

Un tel langage respecte le modèle d'architecture de von Neumann, c'est-à-dire que la machine qui doit l'exécuter est constituée d'un processeur et d'une mémoire contenant le code lui-même, d'une part, et les données qu'il traite d'autre part.

Un programme impératif qui s'exécute n'est qu'une suite de modifications de l'état de la mémoire, soit en modifiant une donnée, une variable (c'est une assignation), soit en indiquant explicitement son instruction suivante (c'est un saut, un « go to » conditionné ou non par l'état d'une variable). Le nouvel état d'une variable est lié à une évaluation, une opération (arithmétique, logique ou booléenne) effectuée sur une ou plusieurs variables.

Le jeu d'instructions des langages impératifs, quoique formel et a priori universel, reste proche de celui d'un langage de bas niveau, mais les adresses et les registres disparaissent au profit de variables — y compris de pointeurs parfois — et d'expressions, donc de valeurs temporaires invisibles et anonymes. Le modèle d'exécution devient celui de l'architecture de Harvard : la séparation stricte du code et des données en mémoire. Cette propriété est même devenue un critère essentiel de distinction entre les langages impératifs et les langages déclaratifs.

Ils peuvent ainsi être très aisément compilés, très rarement interprétés. La genèse de cette famille de langage a été étroitement liée à celle de la conception de leurs compilateurs, ces derniers précédant et influençant même la définition réelle du langage.

Langages procéduraux

Il n'existe pratiquement plus de langages impératifs bruts, parce que le besoin d'une plus grande garantie dans l'écriture de programmes corrects — ou du moins s'en approchant — a incité l'évolution de la syntaxe et de la sémantique vers plus de contraintes et plus de structures.

Parmi les ajouts, on trouve : 1. l'introduction d'instructions composées (boucles et choix) pour remplacer les sauts (jumps) et les étiquettes (labels) ; 2. la définition de types statiques, non seulement pour les valeurs, mais également pour les variables, que ces types soient déclarés ou inférés ; le type d'une variable reste fixé pour toute sa durée de vie, ce qui induit un mécanisme souvent complexe de conversions implicites ; 3. les division et paramétrisation du code en fonctions ou procédures, sous différentes formes, avec paramètres formels et permettant également la réutilisation d'un même code (code reuse) de nombreuses fois, par un mécanisme de CALL-RETURN.

Certains auteurs distinguent les langages en fonction de ces trois propriétés : la propriété 1 correspond aux langages de programmation structurée ou algorithmiques, ceux qui satisfont la deuxième sont les langages typés et la troisième est la catégorie des langages procéduraux au sens strict. Il est exact, par exemple, que dès ses origines réelles (l'existence du premier compilateur), FORTRAN avait des sous-routines et fonctions, donc bien avant l'apparition d'instructions composées (autres que le «DO» basique). A contrario, le COBOL classique (avant sa version de 2002) n'a pas de fonctions ou procédures avec paramètres, mais des structures de choix et des boucles évoluées. Mais généralement, ces trois propriétés coopèrent à la structure du langage et nous adopterons donc la définition suivante.

Un langage procédural (procedural language) est un langage de programmation impératif qui satisfait les trois propriétés ci-dessus : instructions composées de programmation structurée, système de typage statique et découpe du code en fonctions avec paramètres formels.

Cette famille est la plus grande parmi les langages fortement utilisés. Son modèle originel est ALGOL, puis ses nombreux descendants, parmi lesquels C (apparu en 1971 seulement) est sans doute le plus célèbre représentant, ne serait-ce que pour la foison d'autres langages qu'il a inspirés, directement ou indirectement. Leur syntaxe est donc largement commune. ALGOL est partiellement issu de FORTRAN ; il en emprunte par exemple la syntaxe algébrique des expressions, y compris les appels de fonctions ou les tableaux («abs(x)+v[i]»), ainsi que les déclarations de variables (« integer i, j »). Il introduit toutefois de très nombreuses nouveautés essentielles qui ont été reprises, et parfois tristement abandonnées plus tard, par les générations suivantes. Citons, par exemple : - Un séparateur d'instruction « ; ». Il remplace la marque de fin de ligne utilisée par FORTRAN, puisque son écriture est complètement insensible aux blancs et aux lignes. Ce concept est devenu dans toute la famille C un terminateur d'instruction. Un terminateur (le « ; » en C, le « . » en COBOL, etc.) est une marque de fin d'instruction obligatoire, souvent redondante, mais utile au compilateur pour se resynchroniser en cas d'erreur de syntaxe, tandis qu'un séparateur ne doit s'écrire qu'entre deux instructions au sein d'une séquence. - Une notation propre pour l'assignation «:=». Elle évite l'ambiguité avec la notation mathématique traditionnelle d'égalité, le « = » étant utilisé naturellement pour tester cette égalité. On retrouve ce choix astucieux et pédagogique en Pascal, en Smalltalk ou en Go (pourtant récent, 2009), mais aussi en CPL et BCPL, les ancêtres du C. On se demande pourquoi les concepteurs du langage B sont revenus à la syntaxe malheureuse de FORTRAN en influençant directement celle de C et de ses successeurs, ce qui impose en plus d'adopter une notation différente pour la comparaison (« .EQ. » en FORTRAN, « == » en C, etc.). - Des blocs d'instructions formalisant le concept algorithmique de séquence d'opérations. Ainsi la séquence, les choix et les boucles sont trois structures de programmation qui peuvent s'imbriquer pour décrire un algorithme. - La localité des déclarations. Une variable est déclarée au début d'un bloc (ou d'une instruction) et a une portée limitée à ce bloc. - Des fonctions récursives (voir ci-dessous) avec, a priori, une pile d'exécution. On retrouve plus ou moins ces différents concepts dans les langages procéduraux modernes, mais leur syntaxe diffère très souvent sur la représentation syntaxique des séquences d'instructions.

Plusieurs critères peuvent être utilisés pour subdiviser cet immense ensemble de langages. Nous allons en donner cinq exemples, sur base de la forme syntaxique ou sur la richesse d'expressivité.

1° Avec ou sans blocs

La séquence d'instruction est, comme nous le savons, une des structures algorithmiques essentielles. Certains langages procéduraux en font une instruction composée en soi avec sa syntaxe propre, d'autres en font un usage implicite et plus limité : il n'existe de bloc qu'au sein d'une autre instruction composée ou comme le corps d'une fonction.

La définition d'un bloc comme instruction autonome se fait généralement par marques explicites de début et fin de bloc, avec plusieurs variantes toutes équivalentes. - Par mots-clés « begin...end ». C'est ALGOL qui a introduit historiquement ce couple de mots-clés ; on le retrouve en SIMULA, en Ada, en Pascal et tous ses descendants (Modula-2, Modula-3, Delphi, etc.), mais pas de manière générale en ALGOL 68, comme on le verra ci-dessous (même s'il maintient cette notation dans certaines circonstances, avec la notation «(...)» complètement synonyme à « begin...end », d'ailleurs). - Par parenthésage, généralement par accolades «{...}». C'est une simple variante du choix précédent, certes plus synthétique. C'est une création du langage B devenue presque une « marque de fabrique » du C et de ses descendants (C++, D, Java, JavaScript, C#, F#...), parfois lointains (Perl, Raku, Go...), mais il a existé d'autres formes tombées en désuétude (« \((...)\) » en BCPL ou « [...] » en Smalltalk, par exemple). - Par indentation par «INDENT...DEDENT» de Python ou Occam.

Pour les langages sans bloc autonome, une séquence est implicitement délimitée en son début, par l'instruction qui le contrôle (choix, boucle, fonction) et à sa fin, par une marque de fin d'instruction particulière. C'est donc la syntaxe de chaque instruction composée qui contient une indication de terminaison propre, ce qu'on appelle une syntaxe d'instructions gardées (guarded command syntax). Ici encore, plusieurs formes sont possibles. - Par mot-clé général, «end» le plus souvent. Les instructions composées se terminent toutes ainsi : « if...end » ou « if...else...end », « while...end », « function...end », etc. On trouve cette syntaxe en Ruby, Lua, etc. - Par mot-clé spécifique, selon l'instruction. Par exemple, « IF...ELSE...END IF » ou « DO WHILE... END DO » en FORTRAN 77, « IF...ENF-IF », « PERFORM...END-PERFORM » « READ...END-READ », etc. en COBOL-85, « If...End If », « While...End While », « For...Next » ou « Do...Loop » en Visual Basic, etc. Le plus bel exemple de tous est celui d'ALGOL68 : chaque instruction se termine par l'image miroir de son mot-clé caractéristique. On écrit ainsi «if...then...fi» (avec clauses «elif...then» et «else» facultatives), «case...esac», «do...od» (précédé de clauses « for » et « while » facultatives), etc.

2° Avec ou sans récursivité

S'il paraît évident pour un programmeur d'aujourd'hui qu'une fonction peut être appelée récursivement, il n'en a pas toujours été ainsi et on peut toujours complètement dérécursifier un algorithme, ce qui le rend souvent beaucoup plus efficace. .

Pour cette raison, certains langages font le choix délibéré de ne pas autoriser les appels récursifs, directs ou indirects, avec parfois une dérogation si cet usage est explicitement déclaré (avec un mot-clé « recursive », par exemple) pour chaque fonction individuellement.

ALGOL, PL/I (moyennant déclaration), C, etc. permettent des appels récursifs. Voilà sans doute pourquoi ce modèle s'est largement généralisé. La version FORTRAN 90 a également autorisé les procédures et fonctions récursives moyennant déclaration explicite.

Pascal, PL/I, Ada, et quelques autres seulement permettent également l'imbrication de fonctions. Mais ces exemples sont plus rares, même si l'implantation d'algorithmes est plus aisée lorsqu'on dispose de cette possibilité.

3° Avec ou sans types programmés

A priori, un langage même statiquement typé peut ne travailler qu'avec un ensemble restreint de types élémentaires et quelques constructions simples supplémentaires, comme celle de vecteurs (variables indicées) et pointeurs. Un tel contexte permet d'implanter tout algorithme et plusieurs générations de langages n'ont connu que ce modèle.

Mais il est plus simple et surtout plus lisible et plus robuste de pouvoir enrichir son ensemble de types disponibles par de véritables constructions de nouveaux types par combinaison d'autres types préalablement définis, ce qu'on appelle des types programmés. Ces combinaisons sont l'imbrication itérée de constructions : vecteur (bloc homogène indicé de taille fixe), structure (ou « enregistrement », bloc hétérogène de taille fixe) et pointeur (ou référence, adresse d'un objet d'un type donné).

ALGOL68 a été le premier à introduire une syntaxe complète de définition de véritables types programmés. Toutes les constructions du langage et leur sémantique — définies formellement à l'aide d'une grammaire de van Wijngaarden — sont fondées sur un concept général de « type » (appelé « MODE » dans ce contexte, car le mot-clé pour une définition de type est « mode ») qui peut être une combinaison quelconque d'autres types. Et surtout, il permet la définition d'opérateurs (en surcharge) agissant sur ces types nouveaux, en plus évidemment de fonctions (surchargeables) ayant des paramètres formels ou des valeurs de ces types.

L'évolution naturelle des langages impératifs à types programmés est celle de langages orientés objet.

4° Avec ou sans pointeurs

Qu'on le veuille ou non, tout langage impératif doit utiliser des références. En effet, un tel langage est fondé sur l'existence d'assignations et celles-ci font nécessairement intervenir une R-value qui doit être évaluée — c'est donc la valeur d'une donnée — et une L-value qui est l'adresse de la destination — c'est donc une référence.

Mais ces références peuvent n'être que constantes et implicitement définies par la déclaration (ou le premier usage) d'une variable à laquelle elle est ainsi à jamais liée. Tel est le cas d'une partie des langages procéduraux que nous qualifierons de « sans pointeurs ».

Certains permettent toutefois de modifier le lien d'une référence. On peut alors définir des variables dont la valeur est une référence, c'est-à-dire, puisqu'une variable non constante est une L-value, des « références vers une référence ». Dans ce cas, ces variables peuvent contenir une référence nulle (ce nom varie selon le langage) ou se voir assigner la valeur d'expressions particulières — notamment des constructions dynamiques de nouvelles variables — qui doivent s'évaluer comme une référence. Dans ce cas, on peut aussi transmettre des références comme paramètre effectif de fonction. Mais l'usage de ces références reste semblable : servir de L-value ou être automatiquement déréférencée dans une expression à évaluer. Java, JavaScript et Python sont des exemples de tels langages.

Cela ne permet de pas réellement utiliser dans son code le fait qu'il s'agit de données non constantes représentant une adresse et de leur appliquer des opérations de nature à modifier cette adresse (autrement qu'en l'écrasant par une autre adresse constante). Les langages qui le permettent sont ceux que nous qualifions de « avec pointeurs ». ALGOL68, Pascal, C et de nombreux successeurs sont dans ce cas.

Un pointeur n'est pas qu'une simple adresse : pour s'inscrire dans la structure d'un vrai langage procédural (voir définition ci-dessus), un pointeur doit être typé ; c'est donc un type composé qui ne peut référencer qu'un seul type de donnée. Cette contrainte permet notamment un contrôle statique du type après déréférencement et l'ajout de conversions implicites si nécessaire. C'est bien le cas de la plupart des langages « avec pointeurs ».

Ces langages sont très nombreux : FORTRAN (depuis la version FORTRAN 90), PL/I, ALGOL68, Pascal, Modula-2, Modula-3, Ada, BASIC, C, C++, D, Eiffel, Go, etc. et, de manière plus limitée, c'est-à-dire sans arithmétique de pointeurs possible, C#, Visual Basic, Perl, Raku... Les langages vraiment sans pointeurs, c'est-à-dire sans opérateur de prise d'adresse par exemple, comme Java, JavaScript ou Python font presque figure d'exceptions dans cet univers.

Ce que l'on peut regretter, c'est que le modèle le plus répandu de pointeur reste celui de langages conçus entre 1968 et 1971, ce qu'on appelle des « pointeurs bruts » (raw pointers) : une simple adresse, sans possibilité de vérifier à l'exécution si elle est correcte, c'est-à-dire qu'elle correspond effectivement à une donnée vivante. Il existe pourtant une solution simple : lui adjoindre un identifiant unique de la donnée référencée, un hachage (hash) de celle-ci. Ceci éliminerait le risque de pointeurs vers des zombies (dangling pointers) ou non initialisés (wild pointers), mais avec un surcoût en espace et en temps (le runtime doit systématiquement faire la vérification).

Les risques d'erreurs à l'exécution liés aux pointeurs sont sans doute ce qui a conduit quelques langages, pourtant issus directement de C, à abandonner les pointeurs, malgré la perte d'expressivité et d'efficacité du code produit.

Langages orientés-objet

Nous avons déjà présenté certains aspects en Python.

Les langages orientés-objet sont les plus utilisés aujourd'hui. Ils permettent d'utiliser le paradigme de programmation par objets, qui change la façon de concevoir les programmes informatiques via l'assemblage de briques logicielles appelées objets.

L'histoire des langages orientés-objet commence en 1967. En
implantant les Record Class de Hoare, le langage Simula 67 pose les
constructions qui seront celles des langages orientés-objet à classes
classe, polymorphisme, héritage, etc.

Mais c'est réellement par et avec Smalltalk 71 puis Smalltalk 80, résultats des travaux d'Alan Kay, inspiré en grande partie par Simula 67 et Lisp, que les principes de la programmation par objets sont pleinement définis : objet, encapsulation, messages, typage et polymorphisme via la sous-classification ;

les autres principes, comme l'héritage, sont dérivés de ceux-ci.

À partir des années 80, commence l'effervescence des langages à objets :

  • C++ (en 1983),
  • Objective-C (en 1984),
  • Eiffel (en 1986),
  • Common Lisp Object System (en 1988),
  • etc.

Les années quatre-vingt dix voient l'âge d'or de l'extension de la programmation par objets dans les différents secteurs du développement logiciel.

Essayons de résumer les principes des langages orientés-objet. Concrètement, un objet est une structure de données qui répond à un ensemble de messages.

Cette structure de données définit son état tandis que l'ensemble des messages qu'il comprend décrit son comportement :

  • les données, ou champs, qui décrivent sa structure interne sont appelés ses attributs ;
  • l'ensemble des messages forme ce que l'on appelle l'interface de l'objet ;

c'est seulement au travers de celle-ci que les objets interagissent entre eux.

Une méthode donne la réponse au message reçu.

Le principe d'encapsulation consiste à cacher certains attributs ou méthodes. Ainsi, le programme peut modifier la structure interne des objets ou leurs méthodes associées sans avoir d'impact sur les utilisateurs de l'objet.

Dans la programmation par objets, chaque objet est typé. Le polymorphisme permet d'utiliser des objets de types différents là où est attendu un objet d'un certain type. Une façon de réaliser le polymorphisme est le sous-typage appelé aussi héritage de type : on raffine un type-père en un autre type par des restrictions sur les valeurs possibles des attributs. Notons qu'il existe d'autres types de polymorphisme.

On distingue dans les langages objets deux mécanismes de sous-typage :

  • l'héritage simple (comme c'est le cas avec Smalltalk, Java, C#), et
  • l'héritage multiple (comme c'est le cas avec C++, Python, Common Lisp, Eiffel).

Notons également le principe de redéfinition des messages (ou overriding en anglais) qui permet à un objet de raffiner une méthode des objets d'un type parent.

Enfin, parmi les critères de classement des langages orientés-objet, on distingue les langages orientés-objet purs ou non.

Dans un langage orienté-objet pur, toute la syntaxe et la sémantique sont construites autour de la notion d'objet, sans exception :

  • tous les types, même les primitifs, sont des classes,
  • toutes les opérations, même prédéfinies, sont des méthodes de classes,
  • toutes les classes sont des objets, instances de métaclasses,
  • et le code du programme lui-même est un objet ;

il y a donc réflexivité complète.

Des exemples sont Smalltalk, évidemment, mais aussi Ruby, Raku, Self, ou encore Eiffel.