7.4 Implémentation des langages de programmation
Objectifs d'apprentissage
À la fin de cette section, vous serez capable de :
- Discuter de la manière de construire et d'exécuter des programmes écrits dans divers langages de haut niveau (HLL)
- Décrire le fonctionnement d'une implémentation de gestion de l'exécution (runtime) d'un HLL
- Énumérer et expliquer diverses méthodes d'optimisation des HLL applicables aux programmes
Pour implémenter les programmes que vous créez, vous devez utiliser un processus (process) pour générer du code machine à partir du code source (source code). Comme discuté précédemment, les principales méthodes d'implémentation des langages de programmation sont la compilation, l'interprétation pure et l'implémentation hybride (hybrid implementation). Ce sont des processus complexes qui s'apprennent mieux par étapes. Il existe des différences entre un compilateur (compiler) et un interpréteur, comme le montre le Tableau 7.9.
| Compilateur | Interpréteur |
|---|---|
| Analyse et traduit l'intégralité du code source en une seule fois | Analyse et traduit le code source ligne par ligne |
| Prend un temps relativement long pour analyser et traduire | Prend un temps relativement court pour analyser et traduire, mais a un temps d'exécution global plus long |
| Génère toujours un code objet (object code) intermédiaire et nécessitera une édition de liens supplémentaire, donc plus de mémoire | Ne génère pas de code intermédiaire, donc très efficace en termes de mémoire |
| Génère tout message d'erreur seulement après avoir analysé le programme complet | Traduit et exécute le programme jusqu'à ce qu'une erreur soit rencontrée, puis il s'arrête |
Prétraitement
Le processus unique qui prend le code source créé par le programmeur et le transforme en un autre langage est appelé compilation (compilation). En bref, il s'agit de la conversion d'un langage à un autre et inclut une déconstruction de l'entrée, et son produit peut être le langage machine. La compilation moderne commence généralement par le prétraitement (preprocessing), une étape où le code source est manipulé pour le préparer à la phase de compilation. Certaines des actions que le préprocesseur peut effectuer sont les suivantes :
- Suppression de tout commentaire (comment) et des espaces blancs
- Exécution d'une directive de préprocesseur (preprocessor directive), qui décrit les ressources dont le programme a besoin pour compiler, généralement des fichiers à insérer dans le code source. Le préprocesseur C/C++ en est un excellent exemple.
- La directive #include de C/C++ gère une variété de fichiers tels que les fichiers d'en-tête ou les fichiers standard contenant les définitions d'objets et de fonctions de l'API qui seront utilisés dans le programme. Elle peut également prendre en charge les fichiers ou modules (module) définis par l'utilisateur.
- La directive #define de C/C++ nomme un morceau de code que le préprocesseur remplacera chaque fois qu'il verra ce nom.
- La directive #ifdef de C/C++ indique au compilateur d'inclure un morceau de code spécifié dans la compilation en fonction d'une certaine condition.
- D'autres tâches incluent la pré-identification de constructions de haut niveau telles que les boucles et les fonctions (function).
Compilation
Les phases de compilation sont regroupées en trois étapes qui sont généralement exécutées en trois passes à travers le code source prétraité :
- frontal (front end) qui est responsable de l'analyse du code source
- partie médiane (middle end) qui est responsable de l'optimisation du code
- dorsal (back end) (ou générateur de code) qui est responsable de la production du code cible
Structure du frontal du compilateur
Le frontal est responsable de l'analyse du code source pour la syntaxe, la sémantique et d'autres tâches. La première partie de ce processus est l'analyse lexicale (lexical analysis), qui convertit les chaînes de caractères (string) du code source en jetons (tokens). Ce sont des séquences de symboles atomiques reconnaissables par le compilateur, ce qui facilite grandement leur reconnaissance par les étapes suivantes.
Le processus se poursuit par l'analyse syntaxique (syntax analysis), généralement appelée analyse syntaxique (parsing) ou lecture du code pour s'assurer qu'il est conforme aux règles de syntaxe du langage. Cela est fait pour tester la conformité logique en décomposant le code en parties distinctes.
L'étape suivante est l'analyse sémantique (semantic analysis), qui est la découverte du sens dans le code. Elle prend en charge la vérification de type dans les langages fortement typés (strongly typed). Elle effectue également la liaison, qui associe les références de variable (variable) et de fonction à leurs déclarations et définitions. Le compilateur exécute une analyse sémantique statique qui ne concerne que le code source sans tester les entrées.
L'analyse sémantique ne peut pas déterminer tout le sens du code. De nombreux langages utilisent également la liaison dynamique (liaison tardive (late binding)) qui diffère la liaison de certains éléments (element) tels que les objets et les méthodes jusqu'à l'exécution. C'est particulièrement vrai pour les langages orientés objet (POO) qui supportent l'héritage (inheritance) et le polymorphisme (polymorphism). Certains processus, comme lorsqu'un tableau (array) dépasse ses limites, ne seront connus qu'au moment de l'exécution et font partie de la sémantique dynamique du programme.
L'analyse sémantique se conclut par la création et la gestion de la table des symboles, une structure de données utilisée pour connecter chaque symbole avec les informations nécessaires telles que le type de données, la portée (scope) et l'emplacement mémoire.
Structure de la partie médiane du compilateur
Le rôle principal de l'étape médiane de la compilation est l'optimisation (optimization), produisant ce qui sera nécessaire pour générer le code le plus rapide et le plus efficace possible. Les attributs qui sont généralement optimisés sont le temps d'exécution et l'espace (utilisation de la mémoire). La Figure 7.24 décrit ce processus.
Forme intermédiaire
Le résultat créé après l'analyse sémantique lorsque le programme passe toutes les vérifications est appelé forme intermédiaire (IF) (intermediate form (IF)). La nature de l'IF est souvent choisie pour son indépendance de la machine et sa facilité d'optimisation. Les IF ressemblent généralement au code machine d'une machine idéalisée imaginaire. Un exemple est le code géré (managed code) qui est destiné à s'exécuter dans un environnement d'exécution particulier. C'est le cas de C# et d'autres langages Microsoft.net. Ils sont conçus pour s'exécuter sous son CLR (CLR), qui gère l'exécution des langages .NET de Microsoft. Dans ce cas, le but est de permettre au code des différents langages de bien fonctionner ensemble au moment de l'exécution.
La technologie au quotidien
Les HLL et les mégadonnées
Il existe un besoin croissant de traiter des quantités massives de mégadonnées pour obtenir des informations en temps réel sur la meilleure façon dont une entreprise peut servir ses clients. C'est ce qu'on appelle l'analyse de données, l'analyse computationnelle systématique de données ou de statistiques. Netflix utilise cette technique pour planifier sa publicité ciblée. En gros, Netflix surveille les types de films que ses clients regardent habituellement et collecte des données connexes qu'il utilise pour proposer de nouveaux films et les annoncer à ses clients. Cela a conduit à une utilisation accrue de cadriciels de traitement concurrent/parallèle qui permettent la programmation concurrente (concurrent programming). Cette popularité a fait que des langages comme Python ont attiré beaucoup d'attention pour l'analyse des mégadonnées. Faites des recherches sur Python et donnez un avis bien documenté sur les fonctionnalités de Python qui soutiennent cela.
Structure du dorsal du compilateur
Le processus dorsal est connu sous le nom de génération de code (code generation), la transformation du code vers le langage cible ou objet. Il utilise la forme intermédiaire du code en combinaison avec la table des symboles pour réaliser cette tâche. Comme indiqué précédemment, le code final produit n'est pas nécessairement du langage machine. Dans les implémentations hybrides (hybrid implementation), comme en Java et d'autres, le code sera toujours un type de forme intermédiaire et il est laissé à un autre processus le soin de le transformer en code machine, généralement au moment de l'exécution.
La génération de code de compilation pure produit généralement cette phase (et toutes les phases de compilation) tout en s'appuyant également sur la table des symboles, qui est la structure responsable du suivi des identifiants (identifier) au sein d'un programme et du suivi du travail du compilateur. Une fois la compilation terminée, le débogueur peut conserver la table des symboles.
La phase dorsale du compilateur peut également effectuer une génération de code indépendante de la machine. Le but est de permettre à un morceau de code source de générer des instructions qui s'exécuteront sur différentes plateformes sans modification, telles que Windows et Android. Java en est un excellent exemple, comme le montre la Figure 7.25.
Cela peut ensuite être exécuté dans n'importe quel environnement pour lequel une JVM a été construite.
Compilation des langages interprétés
Dans certains langages, des compilateurs sont présents, mais ils ne sont pas purs et n'effectuent qu'une compilation sélective de morceaux, ou un prétraitement, du reste du code source. Parfois, le compilateur génère du code qui crée des attentes autour de décisions qui ne seront pas finalisées avant l'exécution. Si ces hypothèses sont valides, le code s'exécute très rapidement. Sinon, une vérification dynamique revient à l'interpréteur.
Compilation dynamique et à la volée
Parfois, un programme retarde la compilation jusqu'à la dernière minute. En Java, un code binaire (bytecode) est un ensemble d'instructions pour une machine virtuelle. Le processus (process) de compilation à la volée (JIT) (just-in-time (JIT) compilation) est lorsque le code intermédiaire (intermediate code), qui est la phase de traduction du langage produisant un niveau de code entre le code source (source code) et le code objet (object code), subit sa compilation finale (ou généralement son interprétation) juste au début de l'exécution. Le code binaire est le format standard de distribution des programmes Java vers n'importe quelle plateforme d'exécution telle que Windows, macOS et Linux. Le code binaire est interprété par une machine virtuelle Java (JVM) (Java virtual machine (JVM)) qui a été implémentée par le producteur spécifiquement pour sa plateforme. Comme il existe une JVM pour macOS mais pas pour Mac iOS, Java est incompatible avec les iPhones.
Assemblage
La plupart des compilateurs génèrent du langage d'assemblage qui doit ensuite être traité par un assembleur pour créer un fichier objet. Le processus de conversion d'un langage d'assemblage de bas niveau du compilateur (compiler) en langage machine que l'ordinateur peut exécuter sous forme binaire est appelé assemblage (assembly). Le langage d'assemblage est un langage de très bas niveau qui a une forte correspondance avec le langage machine mais reste lisible par l'homme, comme visible dans la Figure 4.7. L'assemblage post-compilation présente des avantages distincts, tels que l'accélération du débogage afin que le langage soit plus facile à lire tout en permettant le partage entre les compilateurs. Il isole également le compilateur des changements nécessaires dans le format des fichiers de langage machine ; par exemple, lorsque les puces informatiques changent, seul l'assembleur doit être modifié.
Le matériel d'un ordinateur n'implémente pas le jeu d'instructions de niveau assemblage ; il est exécuté par l'interpréteur. L'interpréteur est écrit en instructions de bas niveau (microcode ou micrologiciel). Ces éléments sont logés dans une mémoire en lecture seule et exécutés par le matériel.
Édition de liens
Les implémentations de langage destinées à la construction de grands programmes supportent la compilation séparée (separate compilation) où des morceaux du programme peuvent être compilés et construits indépendamment. Une fois la compilation terminée, ces morceaux appelés unités de compilation sont réunis par un éditeur de liens.
Une fonction (function) importante de l'éditeur de liens est de joindre les modules de bibliothèques de sous-programmes précompilées, telles que celles de l'API du langage ou des fonctions définies par l'utilisateur précompilées, au programme final afin qu'elles puissent être compilées séparément. Si le programme utilise une bibliothèque, il devra toujours être lié. De plus, un éditeur de liens statique (static linker) produit un fichier objet exécutable en accomplissant ses tâches avant l'exécution du programme, tandis qu'un éditeur de liens dynamique (dynamic linker) permet au programme d'être transféré en mémoire pour exécution après son démarrage.
Gestion des programmes à l'exécution
Le système d'exécution (runtime system) fait référence à l'ensemble des bibliothèques dont dépend l'implémentation du langage pour un fonctionnement correct. Le compilateur génère des métadonnées (metadata) spécifiques au programme, des données sur les données, que le runtime doit inspecter pour faire son travail. Il est recommandé que le compilateur et le système d'exécution soient développés ensemble pour éviter certains des problèmes suivants :
- Ramasse-miettes : la capacité de détruire dynamiquement les objets du programme lorsqu'ils ne sont plus nécessaires et de récupérer leur mémoire
- Nombre variable d'arguments (argument) : pour les langages qui autorisent des nombres et des types différents pour les arguments de fonction rencontrés uniquement à l'exécution ; les langages supportent la surcharge de fonctions, tels que Java, C++ et C#
- Gestion des exceptions : récupération implémentée lorsqu'un programme rencontre une erreur d'exécution ou une exception (exception)
- Gestion des événements : la capacité de répondre aux occurrences d'exécution telles qu'un clic de bouton ou un autre événement imprévisible
- Implémentation de coroutines et de fils d'exécution : langages où l'implémentation peut gérer la concurrence (concurrency) et le parallélisme (parallelism)
Machines virtuelles
Une machine virtuelle (VM) fournit un environnement d'exécution de programme complet qui est la reproduction d'une architecture informatique. De nombreux langages modernes utilisent des machines virtuelles comme environnement d'exécution. En informatique générale, les VM peuvent faire référence à une VM système fournie par des fournisseurs tels que VMware, qui fournissent toutes les installations matérielles nécessaires pour exécuter un système d'exploitation standard afin que les programmes conçus pour un système d'exploitation puissent s'exécuter sur un autre.
Cependant, avec les HLL, nous créons un autre type distinct de VM appelé VM de processus qui fournit l'environnement nécessaire à un seul processus de niveau utilisateur. Le premier et le meilleur exemple est peut-être la machine virtuelle Java (JVM) car son objectif était de répondre élégamment et efficacement à un objectif de conception principal du langage, qui était l'indépendance du matériel et du système d'exploitation.
La JVM est un gestionnaire d'exécution et un interpréteur complet. Comme nous l'avons appris, le compilateur Java génère en sortie une forme intermédiaire de code connue sous le nom de code binaire qui est placée dans des fichiers objets avec une extension .classe (class). Ce code binaire est identique pour chaque plateforme et système d'exploitation sur lesquels Java peut être exécuté. La JVM est conçue pour effectuer la dernière étape d'interprétation du code binaire en langage machine et en jeu d'instructions du système d'exploitation de l'architecture cible. C'est aussi le gestionnaire d'exécution du processus. Une invention plus récente est le compilateur Java JIT, qui peut exiger plus de vitesse qu'un interpréteur JVM ne peut en produire. La Figure 7.26 illustre ces principes.
Une JVM gère l'exécution et fournit des fonctionnalités de gestion du stockage qui englobent des caractéristiques, notamment les structures de classes (métadonnées), les tas, les jeux de registres, les piles de fils d'exécution et le stockage de code.
Le CLR (CLR) de Microsoft est un animal similaire mais orienté vers la plateforme .NET de Microsoft et tous ses langages composants. CLR et JVM partagent des fonctionnalités telles que le multithreading et les VM basées sur pile, et tous deux disposent d'un ramasse-miettes. De plus, tous deux utilisent l'indépendance de la plateforme, sont auto-descriptifs et contiennent une notation de code binaire.
Pleins feux sur l'industrie
Utiliser des HLL éprouvés
Alors que la gestion des programmes à l'exécution à l'aide de machines virtuelles offre de grands gains de flexibilité, de nombreuses industries qui dépendent de logiciels d'entreprise de grande envergure pour prendre en charge des applications métier critiques et exigeantes s'appuient généralement sur des environnements d'exécution plus traditionnels et des HLL plus anciens et éprouvés. C'est particulièrement vrai dans l'industrie financière aujourd'hui. Un bon exemple est l'utilisation de Python comme langage principal pour le programme Athena de J.P. Morgan et le programme Quartz de Bank of America. Pourquoi pensez-vous que c'est le cas ?
Débogage symbolique
Bien que les débogueurs soient présents dans la plupart des machines virtuelles et des environnements de développement de programmes intégrés (IDE) et fournis par les interpréteurs de langages de programmation, ils existent également en tant qu'outils autonomes. Les débogueurs symboliques comprennent la syntaxe de haut niveau. En bref, un débogueur trouve les erreurs dans les programmes. Il peut également définir des paramètres pour arrêter une exécution si elle atteint un certain point dans le code source ou si une constante est lue.
Optimisation et amélioration du code
L'objectif de l'optimisation du code est de retravailler des parties de votre code pour en améliorer l'efficacité. Certains des problèmes d'optimisation sont les calculs redondants, ainsi que l'utilisation inefficace des registres matériels, des unités fonctionnelles multiples (entrée, sortie), de la mémoire et du cache.
Nous pouvons interpréter les résultats de l'optimisation comme signifiant une exécution rapide et/ou une diminution des besoins en mémoire. L'optimisation est mieux réalisée à plusieurs niveaux, dont l'un est au niveau du bloc de base (basic block). Un bloc de base est une séquence d'instructions de longueur minimale qui s'exécutera toujours dans son intégralité si elle s'exécute, équivalente à un bloc de code (code block). L'amélioration du code au niveau des blocs de base est connue sous le nom d'optimisation locale (local optimization), consistant à éliminer les opérations redondantes (chargements inutiles, calculs de sous-expressions (expression) communes), à fournir un ordonnancement efficace des instructions et à allouer les registres.
À des niveaux supérieurs, les compilateurs examinent et analysent toutes les sous-routines pour d'autres améliorations. Les améliorations de code à plusieurs couches qui incluent l'amélioration des performances des boucles, l'allocation des registres et l'ordonnancement des instructions sont appelées optimisation globale (global optimization).
Réfléchissez-y
Comprendre l'optimisation
L'industrie médicale considère l'optimisation du code comme un impératif. La plupart des opérations médicales utilisent des portails patients pour la communication entre les patients et la pratique médicale. Cela nécessite à la fois de la vitesse et de la portabilité (portability) de la plateforme, comme Windows et Android. Pour cette raison, Java est le langage de choix pour ces applications. Pourquoi est-il important pour les programmeurs de comprendre les techniques d'optimisation du code ? Devraient-ils seulement se fier au compilateur d'optimisation fourni pour le HLL qu'ils utilisent ? Pourquoi ou pourquoi pas ? Deviendrez-vous un meilleur programmeur en comprenant le fonctionnement du code de bas niveau afin d'avoir une vue d'ensemble de l'optimisation ?
Comme pour l'optimisation du code, l'amélioration du code vise également à augmenter la vitesse d'exécution. L'amélioration du code se concentre sur l'élimination de la redondance des blocs de base, l'amélioration des boucles et l'ordonnancement des instructions. Le plus grand domaine d'amélioration se concentre sur l'examen et l'amélioration du comportement des boucles. Le réordonnancement des boucles peut être difficile mais gratifiant car toutes les dépendances de données doivent être respectées (dépendances portées par la boucle).
La conclusion que l'on peut tirer de l'optimisation et de l'amélioration du code est que le « besoin de vitesse » est primordial dans les environnements logiciels et Internet mondiaux d'aujourd'hui.
Concepts en pratique
Optimiser votre code
Saviez-vous que les compilateurs peuvent souvent améliorer les performances de votre code ? Ce processus est appelé optimisation. Les optimiseurs peuvent faire en sorte que votre code s'exécute plus rapidement en utilisant des techniques telles que la suppression de calculs inutiles, l'amélioration de l'utilisation de la mémoire et la réorganisation du code pour une meilleure efficacité.
En comprenant les techniques d'optimisation, vous pouvez écrire un code plus fluide et utiliser moins de ressources. À titre d'information, les compilateurs de langages de programmation courants fournis par Microsoft et Oracle sont conçus pour optimiser l'exécution des programmes. Le cadriciel .Net de Microsoft utilise des machines virtuelles qui gèrent le CLR de divers langages de programmation. Le code binaire intermédiaire généré par les compilateurs qui ciblent ces machines virtuelles est optimisé en utilisant certaines des techniques décrites dans ce chapitre.