Cours complet pour apprendre à programmer en D

Ali Çehreli

D est un langage de programmation multiparadigme qui combine un large panel de concepts de programmation, du plus bas au plus haut niveau. Il met l'accent sur la sécurité des accès à la mémoire, la correction des programmes et le pragmatisme.

Le but principal de ce livre est d'enseigner le D à des lecteurs qui sont débutants en programmation. Même si une expérience dans d'autres langages de programmation peut aider, ce livre commence de zéro.

Si vous avez des remarques concernant ce cours complet pour apprendre la programmation en langage D, un espace de discussion vous est proposé sur le forum. Commentez

- Titre : Cours complet pour apprendre à programmer en D

- Auteur : Ali Çehreli

- Parution : 24 mars 2018

- Public visé :

- Licence :

Sommaire

- 1. Avant-propos d'Andrei Alexandrescu

- 2. Préface

- 2-1. Remerciements de l'auteur original (Ali Çehreli)

- 2-2. Note du traducteur (Raphaël Jakse)

- 3. Introduction

- 4. L'art de la programmation

- 4-1. Programmer peut être très difficile à apprendre et à enseigner

- 5. Hello World !

- 5-1. Installation du compilateur.

- 5-2. Fichier source

- 5-3. Compiler le programme hello world

- 5-4. Paramètres du compilateur

- 5-5. IDE

- 5-6. Contenu du programme hello world

- 5-6-1. Bases du langage

- 5-6-2. Bibliothèques et fonctions

- 5-6-3. Modules

- 5-6-4. Caractères et chaînes

- 5-6-5. Ordre des opérations

- 5-6-6. Sens des lettres minuscules et majuscules

- 5-6-7. Mots-clés

- 5-6-8. Exercices

- 6. writeln et write

- 6-1. Exercices

- 7. Compilateur

- 7-1. Code machine

- 7-2. Langage de programmation

- 7-2-1. Langages compilés

- 7-2-2. Langages interprétés

- 7-2-3. Compilateur

- 7-2-4. Erreur de compilation

- 8. Types fondamentaux

- 8-1. Attributs des types

- 8-1-1. size_t

- 8-1-2. Exercice

- 9. Affectation et ordre d'évaluation

- 9-1. Opération d'affectation

- 9-2. Ordre d'évaluation

- 9-3. 3 Exercice

- 10. Variables

- 10-1. Exercice

- 11. Flux d'entrée et de sortie standards

- 11-1. Exercice

- 12. Expressions logiques

- 12-1. Expressions logiques

- 12-1-1. Grouper les expressions

- 12-1-2. Lire un booléen en entrée

- 12-1-3. Exercices

- 13. Lire à partir de l'entrée standard

- 13-1. Sauter les caractères blancs

- 13-2. Informations additionnelles

- 13-3. Exercice

- 14. Instruction if

- 14-1. Le bloc if et sa portée

- 14-2. Le bloc else et sa portée

- 14-3. Utilisez toujours des accolades pour entourer les blocs

- 14-4. La chaîne « if, else if, else »

- 14-5. Exercices

- 15. Boucle while (Tant que)

- 15-1. L'instruction continue

- 15-2. L'instruction break

- 15-3. Boucle infinie

- 15-4. Exercices

- 16. Nombres entiers et opérations arithmétiques

- 16-1. Information supplémentaire

- 16-1-1. Types entiers

- 16-1-2. Nombre de bits d'un type

- 16-1-3. Choisir un type

- 16-1-4. Dépassement

- 16-1-5. Troncation

- 16-1-6. .min et .max

- 16-1-7. Incrémentation: ++

- 16-1-8. Décrémentation : --

- 16-1-9. Addition : +

- 16-1-10. Soustraction : -

- 16-1-11. Multiplication : *

- 16-1-12. Division : /

- 16-1-13. Modulo : %

- 16-1-14. Puissance : ^^

- 16-1-15. Opérations arithmétiques avec affectation

- 16-1-16. Négation : -

- 16-1-17. Signe plus : +

- 16-1-18. Postincrémentation : ++

- 16-1-19. Postdécrémentation : --

- 16-1-20. Priorité opératoire

- 16-1-21. Solution potentielle contre les débordements

- 16-1-22. Solution potentielle contre la troncation

- 16-1-23. Exercices

- 17. Types à virgule flottante

- 17-1. Attributs des types flottants

- 17-2. .nan

- 17-3. Écrire des valeurs flottantes

- 17-4. Observations

- 17-5. Les débordements et les soupassements

- 17-6. Précision

- 17-7. Il n'y a pas de troncation lors d'une division

- 17-8. Quel type utiliser

- 17-9. On ne peut pas représenter toutes les valeurs

- 17-10. Comparer des valeurs flottantes

- 17-11. isnan() pour tester .nan

- 17-12. Exercices

- 18. Tableaux

- 18-1. Définition

- 18-2. Éléments et conteneurs

- 18-3. Accéder aux éléments

- 18-4. Indice

- 18-5. Tableaux de taille fixe vs tableaux dynamiques

- 18-6. .length pour récupérer ou changer la taille du tableau

- 18-7. Initialiser les éléments

- 18-8. Opérations basiques sur les tableaux

- 18-9. Trier les éléments

- 18-9-1. Inverser les éléments

- 18-9-2. Exercices

- 19. Caractères

- 19-1. Histoire

- 19-1-1. Table ASCII

- 19-1-2. Pages de code IBM

- 19-1-3. Pages de code ISO/IEC 8859

- 19-1-4. Unicode

- 19-2. Codages Unicode

- 19-3. Les types de caractères du D

- 19-4. Caractères littéraux

- 19-5. Caractères de contrôle

- 19-6. Guillemet simple et antislash

- 19-7. Le module std.uni

- 19-7-1. Prise en charge limitée pour ı et i de l'alphabet turc

- 19-7-2. Tous les alphabets ont une prise en charge limitée

- 19-8. Problème de lecture des caractères

- 19-9. Prise en charge de l'Unicode par le langage D

- 19-10. Résumé

- 20. Tranches (slices) et autres fonctionnalités des tableaux

- 20-1. Tranches

- 20-2. $, à la place de tableau.length

- 20-3. .dup pour copier

- 20-4. Affectation

- 20-5. Agrandir une tranche plus longue peut finir le partage

- 20-6. capacity pour savoir si le partage va être terminé

- 20-7. Opérations sur tous les éléments

- 20-8. Tableaux multidimensionnels

- 20-9. Résumé

- 20-10. Exercice

- 21. Chaînes de caractères

- 21-1. readln et chomp, à la place de readf

- 21-2. Guillemets doubles, et non simples

- 21-3. string, wstring, et dstring sont immuables (immutable)

- 21-4. Confusion potentielle sur la taille des chaînes

- 21-5. Chaînes littérales

- 21-6. Concaténations de chaînes

- 21-7. Comparaison de chaînes

- 21-8. Majuscule et minuscule sont différentes

- 21-9. Exercices

- 22. Rediriger les flux d'entrée et de sortie standard

- 22-1. Rediriger la sortie standard vers un fichier avec >

- 22-2. Rediriger l'entrée standard depuis un fichier avec <

- 22-3. Rediriger les deux flux standard

- 22-4. Combiner les programmes avec |

- 22-5. Exercice

- 23. Fichiers

- 23-1. Concepts fondamentaux

- 23-1-1. Le producteur et le consommateur

- 23-1-2. Droits d'accès

- 23-1-3. Ouvrir un fichier

- 23-1-4. Fermer un fichier

- 23-1-5. Écrire et lire un fichier

- 23-1-6. eof() pour tester la fin du fichier

- 23-1-7. Le module std.file

- 23-2. La structure std.stdio.File

- 23-3. Écrire dans un fichier

- 23-4. Lire un fichier

- 23-5. Exercice

- 24. auto et typeof

- 24-1. auto

- 24-2. typeof

- 24-3. Exercices

- 25. Espace de nom (name space)

- 25-1. Définir les noms le plus proche possible de leur première utilisation

- 26. Boucles for

- 26-1. Sections de la boucle while

- 26-2. Les sections d'une boucle for

- 26-3. Les sections peuvent être vides

- 26-4. L'espace de noms de la variable d'itération

- 26-5. Exercices

- 27. L'opérateur ternaire « ? »:

- 27-1. Les types des expressions de sélection doivent correspondre

- 27-2. Exercice

- 28. Littéraux

- 28-1. Littéraux entiers

- 28-2. Les types des littéraux entiers

- 28-2-1. Le suffixe L

- 28-2-2. Le suffixe U

- 28-3. Les littéraux flottants

- 28-3-1. Les types des littéraux flottant

- 28-4. Les littéraux de caractères

- 28-5. Les littéraux de chaîne

- 28-5-1. Les littéraux de chaîne entourés de guillemets anglais doubles

- 28-5-2. Les littéraux de chaîne Wysiwyg

- 28-5-3. Littéraux de chaîne hexadécimaux

- 28-5-4. Littéraux de chaîne délimités

- 28-5-5. Littéraux de chaîne « jetons »

- 28-5-6. Type des littéraux de chaîne

- 28-6. Les littéraux sont calculés lors de la compilation

- 28-7. Exercices

- 29. Sortie formatée

- 29-1. Caractères de formatage

- 29-2. Largeur

- 29-3. Précision

- 29-4. Drapeaux

- 29-5. Paramètres de position

- 29-6. format

- 29-7. Exercices

- 30. Entrée formatée

- 30-1. Caractères d'indicateur de format

- 30-2. Exercice

- 31. Boucle do-while

- 31-1. Exercice

- 32. Tableaux associatifs

- 32-1. Très rapide, mais désordonné

- 32-2. Définition

- 32-3. Ajouter des éléments

- 32-4. Initialisation

- 32-5. Supprimer des éléments

- 32-6. Déterminer la présence d'un élément

- 32-7. Propriétés

- 32-8. Exemple

- 32-9. Exercices

- 33. Boucle foreach

- 33-1. La syntaxe de foreach

- 33-2. continue et break

- 33-3. foreach avec les tableaux

- 33-4. foreach avec les chaînes et std.range.stride

- 33-5. foreach avec les tableaux associatifs

- 33-6. foreach avec les intervalles de nombres

- 33-7. foreach avec les structures, les classes et les intervalles

- 33-8. Le compteur n'est automatique que pour les tableaux

- 33-9. La copie de l'élément, pas l'élément lui-même

- 33-10. L'intégrité du conteneur doit être préservée

- 33-11. foreach_reverse pour itérer dans la direction inverse

- 33-12. Exercice

- 34. switch et case

- 34-1. L'instruction goto

- 34-2. Le type de l'expression doit être un type entier, une chaîne ou un booléen

- 34-3. Les valeurs case doivent être connues lors de la compilation

- 34-4. Intervalles de valeurs

- 34-5. Valeurs distinctes

- 34-6. L'instruction final switch

- 34-7. Quand utiliser switch ?

- 34-8. Exercices

- 35. Les énumérations (enum)

- 35-1. Les effets des constantes magiques sur la qualité du code

- 35-2. La syntaxe d'enum

- 35-3. Valeurs réelles et types de base

- 35-4. Des valeurs énumérées sans type énuméré

- 35-5. Propriétés

- 35-6. Conversion depuis le type de base

- 35-7. Exercice

- 36. Fonctions

- 36-1. Paramètres

- 36-2. Appeler une fonction

- 36-3. Faire le travail

- 36-4. La valeur de retour

- 36-5. L'instruction return

- 36-6. Les fonctions void

- 36-7. Le nom de la fonction

- 36-8. Qualité de code à travers les fonctions

- 36-8-1. La duplication de code est nuisible

- 36-8-2. Lignes de codes commentées en tant que fonctions

- 36-8-3. Exercices

- 37. Immutabilité

- 37-1. Variables immuables

- 37-1-1. Constantes enum

- 37-1-2. Variables immutable

- 37-1-3. Variables const

- 37-2. Paramètres immuables

- 37-2-1. Paramètres const

- 37-2-2. Paramètres immutable

- 37-3. Un paramètre devrait-il être const ou immutable ?

- 37-4. Immuabilité de la tranche versus immuabilité des éléments

- 37-5. .dup et .idup

- 37-6. Utilisation

- 37-7. Résumé

- 38. Les paramètres de fonction

- 38-1. La plupart des paramètres sont copiés

- 38-2. Les objets de type référence ne sont pas copiés

- 38-3. Les qualificatifs de paramètre

- 38-3-1. in

- 38-3-2. out

- 38-3-3. const

- 38-3-4. immutable

- 38-3-5. ref

- 38-3-6. auto ref

- 38-3-7. inout

- 38-3-8. Lazy (paresseux)

- 38-3-9. scope

- 38-3-10. shared

- 38-4. Résumé

- 38-5. Exercice

- 39. Lvalues et Rvalues

- 39-1. Les limitations des rvalues

- 39-1-1. Les rvalues n'ont pas d'adresse

- 39-1-2. Les rvalues ne peuvent se voir affecter de nouvelles valeurs

- 39-2. Les rvalues ne peuvent pas être passées par référence à des fonctions

- 39-3. Les paramètres auto ref pour accepter à la fois des rvalues et des lvalues

- 39-4. Terminologie

- 40. Opérateurs paresseux (lazy)

- 41. L'environnement du programme

- 41-1. La valeur de retour de main()

- 41-1-1. main retourne toujours une valeur

- 41-1-2. Spécifier la valeur de retour

- 41-2. Le flux standard d'erreur stderr

- 41-3. Paramètres de main()

- 41-4. Options en ligne de commande et le module std.getopt

- 41-5. Les variables d'environnement

- 41-6. Lancer d'autres programmes

- 41-7. Résumé

- 41-8. Exercices

- 42. Exceptions

- 42-1. L'instruction throw pour lever des exceptions

- 42-1-1. Les types d'exceptions Exception et Error

- 42-1-2. Les expressions levées mettent fin à toutes les portées

- 42-1-3. Quand utiliser throw

- 42-2. L'instruction try-catch pour attraper une exception

- 42-2-1. Les blocs catch sont parcourus séquentiellement

- 42-2-2. Le bloc finally

- 42-2-3. Quand utiliser l'instruction try-catch

- 42-3. Propriétés des exceptions

- 42-3-1. Types d'erreurs

- 42-3-1-1. Erreurs de l'utilisateur

- 42-3-1-1-1. Les erreurs du programmeur

- 42-3-1-1-2. Situations inattendues

- 42-3-1-2. Résumé

- 43. Portées (scope)

- 44. assert et enforce

- 44-1. Syntaxe

- 44-2. static assert

- 44-3. Utilisez les assertions même si c'est absolument vrai

- 44-4. Pas de valeur ni d'effet de bord

- 44-5. Désactiver les assertions

- 44-6. enforce pour lever des exceptions

- 44-7. Utilisation

- 44-8. Exercices

- 45. Tests unitaires

- 45-1. Causes des bogues

- 45-2. Découvrir les bogues

- 45-3. Le test unitaire pour déceler les bogues

- 45-4. Activer les tests unitaires

- 45-5. Blocs unittest

- 45-6. Développement piloté par les tests (Test Driven Development (TDD))

- 45-6-1. Les tests unitaires avant les corrections de bogues

- 45-7. Exercice

- 46. Programmation par contrat

- 46-1. Les blocs in pour les conditions d'entrée

- 46-2. Les blocs out pour les postconditions

- 46-3. Désactiver la programmation par contrat

- 46-4. Blocs in versus enforce

- 46-5. Exercice

- 47. Durées de vie et opérations fondamentales

- 47-1. Durée de vie d'une variable

- 47-2. Durée de vie d'un paramètre

- 47-3. Opérations fondamentales

- 47-3-1. Initialisation

- 47-3-2. Finalisation

- 47-3-3. Affectation

- 48. Types valeur et types référence

- 48-1. Types valeur

- 48-1-1. Note sur l'utilisation des assertions dans ce chapitre

- 48-1-2. Identité entre valeurs

- 48-1-3. Opérateur de déréférencement (« adresse de ») &

- 48-2. Variables référence

- 48-3. Types référence

- 48-3-1. La différence dans l'opération d'affectation

- 48-3-2. Les variables de types référence peuvent ne pas référencer d'objet

- 48-4. Les tableaux à taille fixe sont des types valeur, les tranches sont des types référence

- 48-5. Expérimentation

- 48-6. Résumé

- 49. La valeur null et l'Opérateur is

- 49-1. La valeur null

- 49-2. L'opérateur is

- 49-3. L'opérateur !is

- 49-4. Affecter la valeur null

- 49-5. Résumé

- 50. Conversions de Types, cast

- 50-1. Conversion automatique des types

- 50-1-1. Promotions entières

- 50-1-2. Conversions arithmétiques

- 50-1-3. Conversions const

- 50-1-4. Conversions immutables

- 50-1-5. Conversions enum

- 50-1-6. Conversions booléennes

- 50-1-7. Conversions automatiques vers bool dans les instructions conditionnelles

- 50-1-8. Conversions de types explicites

- 50-1-8-1. La fonction to pour la plupart des conversions

- 50-1-8-2. assumeUnique() pour des conversions immutables rapides

- 50-1-8-3. L'opérateur cast

- 50-1-9. Résumé

- 51. Structures

- 51-1. Définition

- 51-1-1. struct définit un type, pas une variable

- 51-1-2. Confort de programmation

- 51-2. Accéder aux membres

- 51-3. Construction

- 51-3-1. Construire des objets immutable

- 51-3-2. Les membres restants n'ont pas besoin d'être spécifiés

- 51-3-3. Spécifier les valeurs par défaut des membres

- 51-3-4. Construction avec la syntaxe { }

- 51-4. Copie et affectation

- 51-4-1. Attention avec les membres qui sont de types référence !

- 51-4-2. Littéraux structure

- 51-4-3. Membres static

- 51-4-4. static this() pour l'initialisation et static ~this() pour la finalisation

- 51-5. Exercices

- 52. Nombre variable de paramètres

- 52-1. Les valeurs par défaut de paramètres

- 52-2. Mots-clés spéciaux comme arguments par défaut

- 52-3. Fonctions variadiques

- 52-4. Les paramètres des fonctions variadiques ont une durée de vie courte

- 52-5. Exercice

- 53. Surcharge de fonctions

- 53-1. Résolution de surcharge

- 53-2. Surcharge de fonctions pour les types définis par l'utilisateur

- 53-3. Limitations

- 53-4. Exercice

- 54. Fonctions membres

- 54-1. Définir des méthodes

- 54-1-1. toString() pour les représentations sous forme de chaînes de caractères

- 54-1-2. Exemple : la méthode increment())

- 54-2. Exercices

- 55. Les paramètres const ref et les fonctions membres const

- 55-1. Les objets immutables

- 55-2. Les paramètres ref qui ne sont pas const

- 55-3. Les paramètres const ref

- 55-4. Fonctions membres non-const

- 55-5. Fonctions membres inout

- 55-6. Mode d'emploi

- 56. Surcharge des opérateurs

- 56-1. Opérateurs surchargeables

- 56-1-1. Opérateurs unaires

- 56-1-2. Opérateurs binaires

- 56-1-3. Opérateur d'indexation et de tranchage d'éléments

- 56-1-4. Autres opérateurs

- 56-1-5. Définir plusieurs opérateurs simultanément

- 56-1-6. Types de retour des opérateurs

- 56-1-6-1. Les opérateurs qui modifient l'objet

- 56-1-6-2. Les opérateurs logiques

- 56-1-6-3. Les opérateurs d'ordre

- 56-1-6-4. 1.6.4 Les opérateurs qui créent un nouvel objet

- 56-1-6-5. opDollar

- 56-1-6-6. Les opérateurs non contraints

- 56-1-7. Les comparaisons d'égalité avec opEquals()

- 56-1-8. L'ordre avec opCmp()

- 56-1-9. Appeler les objets comme des fonctions : opCall()

- 56-1-10. Les opérateurs d'indexation

- 56-1-10-1. Un exemple d'opérateurs d'indexation

- 56-1-11. Les opérateurs de tranchage

- 56-1-12. opCast pour les conversions de types

- 56-1-13. L'opérateur joker opDispatch

- 56-1-14. Recherche d'inclusion avec opBinaryRight!« in »

- 56-1-14-1. Exemple d'opérateur in

- 56-1-15. Exercice

- 56-2. La solution

- 57. Les classes

- 57-1. Comparaison avec les structures

- 57-1-1. Les classes sont des types références

- 57-1-2. Les variables classes peuvent être nulles

- 57-1-3. Les variables classes et les objets classes

- 57-1-4. Copie

- 57-1-5. Affectation

- 57-1-6. Définition

- 57-1-7. Construction

- 57-1-8. Destruction

- 57-1-9. Accès aux membres

- 57-1-10. Surcharge d'opérateurs

- 57-1-11. Fonctions membres

- 57-1-12. Les opérateurs is et !is

- 57-2. En résumé :

- 58. Héritage

- 58-1. Attention: héritage seulement si « c'est un »

- 58-2. Héritage d'au plus une classe

- 58-3. Tableaux hiérarchiques

- 58-4. Accéder aux membres d'une superclasse

- 58-5. Construire des membres de superclasse

- 58-6. Surcharger les définitions des fonctions membres

- 58-7. Utiliser les sous-classes à la place de la superclasse

- 58-8. L'héritage est transitif

- 58-9. Fonctions et classes abstraites

- 58-10. Exemple

- 58-11. Résumé

- 58-12. Exercices

- 59. Concurrence par messages

- 59-1. Concepts

- 59-2. Démarrer des threads

- 59-3. Identifiants de threads

- 59-4. Le passage de messages

- 59-5. Exemple

- 59-6. Attendre différents types de messages

- 59-6-1. Recevoir n'importe quel type de message

- 59-7. Attendre des messages pendant un certain temps

- 59-8. Exceptions pendant l'exécution du thread fils

- 59-9. Détecter la fin de l'exécution d'un thread

- 59-9-1. L'exception OwnerTerminated

- 59-9-2. L'exception LinkTerminated

- 59-9-3. Recevoir les exceptions en tant que messages

- 59-10. Gestion de boîte aux lettres

- 59-11. Messages prioritaires

- 59-12. Threads nommés

- 59-13. Résumé

- 60. Exercices

- 60-1. Le programme Hello world - Correction

- 60-2. writeln et write - Correction

- 60-3. Types Fondamentaux - Correction

- 60-4. Affectation et ordre d'évaluation - Correction

- 60-5. Variable - Correction

- 60-6. Entrée standard et flux de sortie - Correction

- 60-7. Expressions logiques - Correction

- 60-8. Lire depuis l'entrée - Correction

- 60-9. L'instruction if - Correction

- 60-10. La boucle while - Correction

- 60-11. Nombres entiers et opérations arithmétiques - Correction

- 60-12. Types à virgule flottante - Correction

- 60-13. Tableaux - Correction

- 60-14. Tranches et autres fonctionnalités des tableaux - Correction

- 60-15. Chaînes de caractères - Correction

- 60-16. Rediriger l'entrée standard et les flux de sortie - Correction

- 60-17. Fichiers - Correction

- 60-18. auto et typeof - Correction

- 60-19. La boucle for - Correction

- 60-20. L'opérateur ternaire « ? »: - Correction

- 60-21. Littéraux - Correction

- 60-22. Sortie formatée - Correction

- 60-23. Entrée formatée - Correction

- 60-24. La boucle do-while - Correction

- 60-25. La boucle foreach - Correction

- 60-26. Tableaux associatifs - Correction

- 60-27. switch et case - Correction

- 60-28. Les énumérations (enum) - Correction

- 60-29. Les fonctions - Correction

- 60-30. Paramètre des fonctions - Correction

- 60-31. Environnement du Programme - Correction

- 60-32. assert et enforce - Correction

- 60-33. Tests Unitaires - Correction

- 60-34. Programmation par contrat - Correction

- 60-35. Structures - Correction

- 60-36. Nombre variable de paramètres - Correction

- 60-37. Surcharge de fonctions - Correction

- 61. Note de la rédaction Developpez.com

1 - Avant-propos d'Andrei Alexandrescu

Ceux qui, parmi nous, connaissent Ali peuvent remarquer que son livre sur le D est imprégné de sa personnalité : direct, patient, et sympathique sans donner dans la flatterie.

Il y a un but dans chaque phrase et, avec chacune d'elles, un pas en avant ; ni trop rapide, ni trop lent. « Notez que opApply() elle-même est implémentée avec une boucle foreach. Ainsi, la boucle foreach dans main() finit par faire un usage indirect de foreach sur le membre points ». Et ainsi de suite, avec juste le nombre de mots nécessaires. Et dans le bon ordre, aussi ; Ali fait un travail remarquable pour présenter les concepts du langage — qui, surtout pour un débutant, arrivent « en parallèle » de manière écrasante — de façon séquentielle.

Mais il y a aussi quelque chose que j'aime beaucoup avec Programmer en D : il s'agit d'un bon livre pour apprendre la programmation en général. Vous voyez, un bon livre introductif sur Haskell enseigne la programmation fonctionnelle en cours de route, de façon implicite ; un sur le C vient avec des notions de programmation système ; un sur le Python avec du scripting, et ainsi de suite. Qu'enseignerait, alors, un bon texte introductif au D ? Idéalement, la Programmation avec un P majuscule.

D encourage une attitude du style « utiliser le bon outil pour la tâche à réaliser » et permet à son utilisateur de taper dans une grande étendue de techniques de programmation, sans mettre trop de particularités en travers du chemin. La manière la plus amusante d'appréhender la programmation en D est de le faire avec un esprit ouvert, parce que pour chaque façon de concevoir qui devient non naturelle, il y a la possibilité de la retravailler pour obtenir la bonne architecture en choisissant une implémentation, une approche, un paradigme différents. Pour choisir au mieux ce qui correspond le mieux, l'ingénieur doit connaître l'étendue des possibles — et Programmer en D est un excellent moyen de munir quelqu'un de cette connaissance. L'assimiler n'aide pas seulement à écrire un bon code en D, mais écrire un bon code tout court.

Il y a de bons conseils stratégiques, aussi, qui agrémentent l'enseignement de la programmation et des concepts du langage. Enseignement intemporel sur éviter la duplication de code, choisir de bons noms, rechercher la bonne décomposition des étapes et plus — tout est là, des bidouilles rapides et grossières progressivement transformées en solutions robustes, exactement comme elles devraient l'être dans la pratique. Au lieu de tomber dans le piège du « fait rapidement », Programmer en D se concentre sur le « fait proprement », au plus grand bénéfice de son lecteur.

J'ai longtemps pensé que le D est un bon premier langage de programmation à apprendre. Il expose ses utilisateurs à une variété de concepts — systèmes, fonctionnel, orienté objet, généricité, génératif — honnêtement, simplement et sans prétention. Tout comme le livre d'Ali, qui, pour moi, représente une excellente occasion de le faire.

Andrei Alexandrescu
San Francisco, Mai 2015

2 - Préface

D est un langage de programmation multiparadigme qui combine un large panel de concepts de programmation, du plus bas au plus haut niveau. Il met l'accent sur la sécurité des accès à la mémoire, la correction des programmes et le pragmatisme.

Le but principal de ce livre est d'enseigner le D à des lecteurs qui sont débutants en programmation. Même si une expérience dans d'autres langages de programmation peut aider, ce livre commence de zéro.

Afin que ce livre soit utile, vous aurez besoin d'un environnement pour écrire, compiler et lancer vos programmes D. Cet environnement de développement doit inclure au moins un compilateur D et un éditeur de texte. Nous allons apprendre à installer un compilateur et à compiler un programme dans le chapitre suivant.

Chaque chapitre est basé sur le contenu des précédents, introduisant aussi peu de nouveaux concepts que possible. Je recommande que vous lisiez le livre de façon linéaire, sans sauter de chapitre. Même si ce livre a été écrit avec les débutants à l'esprit, il couvre presque toutes les fonctionnalités du D. Les programmeurs les plus expérimentés peuvent utiliser le libre comme une référence du langage D en utilisant l'index.

Certains chapitres incluent des exercices et leur solution pour que vous puissiez écrire des petits programmes et comparer vos méthodes aux miennes.

La programmation est une occupation satisfaisante qui implique la découverte et l'apprentissage continu de nouveaux outils, nouvelles techniques et nouveaux concepts. Je suis sûr que vous aimerez programmer en D autant que moi. Apprendre à programmer est plus facile et plus amusant quand on le partage avec les autres. Profitez du forum D.leanpour suivre les discussions et pour poser et répondre à des questions.

Ce livre est également disponible dans d'autres langues comme le turc et l'anglais

2-1 - Remerciements de l'auteur original (Ali Çehreli)

Je suis redevable envers les personnes suivantes qui ont joué un rôle important durant l'évolution de ce livre :

Mert Ataol, Zafer Çelenk, Salih Dinçer, Can Alpay Çiftçi, Faruk Erdem Öncel, Muhammet Aydın (aka Mengü Kağan), Ergin Güney, Jordi Sayol, David Herberth, Andre Tampubolon, Gour-Gadadhara Dasa, Raphaël Jakse, Andrej Mitrović, Johannes Pfau, Jerome Sniatecki, Jason Adams, Ali H. Çalışkan, Paul Jurczak, Brian Rogoff, Михаил Страшун (Mihails Strasuns), Joseph Rushton Wakeling, Tove, Hugo Florentino, Satya Pothamsetti, Luís Marques, Christoph Wendler, Daniel Nielsen, Ketmar Dark, Pavel Lukin, Jonas Fiala, Norman Hardy, Rich Morin, Douglas Foster, Paul Robinson, Sean Garratt, Stéphane Goujet, Shammah Chancellor, Steven Schveighoffer, Robbin Carlson, Bubnenkov Dmitry Ivanovich, Bastiaan Veelo, Stéphane Goujet, Olivier Pisano et Dave Yost.

Remerciements spécifiques à Luís Marques qui, avec son travail substantiel, a amélioré chaque chapitre de ce livre. Si vous trouvez tel passage de ce livre utile, c'est certainement grâce à ses modifications assidues.

Merci à Luís Marques, Steven Schveighoffer, Andrej Mitrović, Robbin Carlson, and Ergin Güney pour leurs suggestions qui ont élevé mon englais au rang d'anglais.

Je suis reconnaissant envers la communauté D entière pour avoir gardé mon enthousiasme et ma motivation à un niveau élevé. D a une communauté extraordinaire d'individus infatigables tels que bearophile et Kenji Hara.

Ebru, Damla et Derin, merci d'avoir été si patients et encourageants pendant que j'étais perdu à écrire ces chapitres.

Ali Çehreli

Mountain View, Août 2015

2-2 - Note du traducteur (Raphaël Jakse)

http://ddili.org/ders/d.en/ est sous la licence Creative Commons BY-NC-SA, j'ai choisi de conserver cette licence pour cette traduction.

Merci à Munrek de permettre la publication de cette traduction sur le Web et merci à Ali pour son œuvre de qualité et l'accueil qu'il a réservé à cette traduction.

Merci à sclytrac (forum D) pour ses corrections.

Merci à Hassan Azi pour sa traduction du chapitre sur les fonctions membres.

Merci à Olivier Pisano pour une traduction d'excellente qualité de pas mal de chapitres.

Merci à Stéphane Goujet pour ses relectures attentives et pointilleuses qui ont permis d'élever la qualité et la cohérence de la traduction.

3 - Introduction

Cet ouvrage a pour but d'enseigner le langage D aux lecteurs novices en programmation. Même si de l'expérience dans d'autres langages de programmation faciliterait les choses, ce livre commence de zéro. Si vous êtes intéressé par apprendre à programmer, j'espère que vous trouverez ce livre utile.

Pour que ce livre soit utile, vous aurez besoin d'un environnement pour écrire, compiler et lancer vos programmes D. Cet environnement de développement doit comprendre au moins :

- un éditeur de texte ;

- un compilateur D.

Vous pouvez également utiliser un Environnement de Développement Intégré (IDE) plutôt que des programmes séparés. Vous pouvez trouver des informations à propos des éditeurs et des IDE pour le langage D sur les pages Editors et IDEs de dlang.org. Vous ne pouvez pas apprendre à programmer en D sans éditeur de texte ou compilateur. Nous verrons comment installer un compilateur et comment compiler des programmes dans des chapitres ultérieurs.

La version originale de l'ouvrage explique comment installer et utiliser le compilateur dmd.

Chaque chapitre du livre introduit le moins de nouveaux concepts possible. La plupart des chapitres ont quelques exercices à la fin. Ces exercices sont corrigés, vous pouvez ainsi comparer vos solutions aux miennes. Les corrigés sont à la fin du livre.

Je vous suggère de ne pas sauter de chapitre. Si vous arrivez à un chapitre que vous trouvez particulièrement difficile, c'est peut-être que le livre n'a par erreur pas introduit tous les concepts nécessaires. Dans ce cas, merci de me contacter pour rendre ce livre plus utile (NDT  Ou de contacter le traducteur, qui transmettra à l'auteur).

Ce livre ne couvre pas la programmation d'interfaces graphiques (GUI). Même si beaucoup de programmes sont plus utilisables avec une interface graphique, les interfaces graphiques ne sont pas directement relatives aux langages de programmation. De plus, les choix de conception et de style des interfaces graphiques peuvent se heurter à ceux du langage de programmation et de sa bibliothèque standard et rendre difficile l'apprentissage du langage. Pour cette raison, ce livre ne traite que des programmes console. Une fois que vous aurez appris le D et sa bibliothèque standard, Phobos, vous serez capable d'utiliser la bibliothèque graphique que vous voudrez.

Les chapitres de ce livre sont mis en ligne une fois qu'ils sont traduits en anglais à partir du turc. Vous pouvez suivre le fil RSS pour être informé quand de nouveaux chapitres sont disponibles.

Apprendre à programmer est plus amusant quand c'est partagé avec d'autres personnes. Allez sur le groupe de discussions D.learnsur http://forum.dlang.org/ pour suivre les conversations, poser et répondre à des questions.

N'hésitez pas à contacter l'auteur ou le traducteur par courrier électronique pour tout commentaire ou toute correction à propos de cet ouvrage ou de sa traduction. Merci !

- Auteur : Ali Çehreli

- CV : http://ddili.org/AliCehreli_resume.html (en)

- Interviews sur Ceviz.netet sur İzmir'de Sanat

- Vous pouvez le trouver sur le forum Ddili et sur les groupes de discussions sur le langage D

- Vous pouvez le contacter par courrier électronique à acehreli AT yahoo.com

- Traducteur : Raphaël Jakse

- Contact : raphael.jakse AT gmail.com

4 - L'art de la programmation

La définition de l'art de la programmation est quelque peu discutable, mais son aspect conception est très important. Quelques idées sur la programmation :

- c'est la tâche qui consiste à concevoir des programmes de manière à ce que l'ordinateur se comporte comme on le veut ;

- comme elle nécessite des outils et que l'utilisation de ces outils est guidée par l'expérience des meilleurs programmeurs, c'est un métier ;

- comme ça implique la résolution de problèmes sous contraintes, c'est de l'ingénierie ;

- c'est très amusant et satisfaisant ;

- ce n'est pas un art à proprement parler, mais autant que c'est possible dans tout ce que les humains font, les programmes peuvent être des œuvres d'art ;

- ce n'est pas une science, mais les méthodes que ça utilise viennent de la science des ordinateurs qu'est l'informatique.

4-1 - Programmer peut être très difficile à apprendre et à enseigner

La programmation a été enseignée depuis les années 1950. Il n'y a toujours pas de méthode efficace et fructueuse pour le faire à ce jour.

Malheureusement, apprendre à programmer peut être une tâche difficile pour une bonne moitié de tous les étudiants. Selon un article de la recherche, ceux qui peuvent apprendre à programmer facilement sont ceux qui sont capables de créer des modèles consistants pour décrire les situations inconnues qu'ils rencontrent.

Certaines difficultés en programmation viennent de la quantité de détails techniques qu'il est nécessaire d'apprendre.

5 - Hello World !

Le premier programme montré dans la plupart des livres qui présentent un langage de programmation est le programme hello world. C'est un programme très simple et très court qui affiche « hello world » et qui se termine (NDT « hello world » signifie « bonjour le monde »). Ce programme est important parce qu'il présente quelques concepts essentiels de ce langage.

Voilà ce que ça donne en langage D :

import std.stdio;

void main()
{
writeln("Hello world!");
}

Le code source ci-dessus doit être compilé par un compilateur D pour obtenir un programme exécutable par l'ordinateur, l'ordinateur ne pouvant pas directement exécuter le code source.

5-1 - Installation du compilateur.

Lors de l'écriture de ce chapitre, nous avons le choix entre trois compilateurs :

1. dmd, le compilateur de Digital Mars ;

2. gdc, le compilateur de GCC ;

3. ldc, le compilateur adapté à l'architecture LLVM.

dmd est le compilateur qui est utilisé pendant la conception et le développement du langage D depuis des années. Tous les exemples de ce livre ont été testés avec. (NDT L'auteur affirme que pour cette raison, ce serait plus facile pour vous de commencer avec dmd et d'essayer d'autres compilateurs seulement en cas de besoin spécifique. Personnellement, j'utilise plutôt gdc, parce qu'il est libre, parce que je connais déjà GCC et parce qu'il est plus simple à installer chez moi, mais je n'ai pas revérifié si tous les codes du tutoriel fonctionnaient avec. À priori oui, d'autant que c'est avec ce livre que j'ai appris le D.)

Pour installer dmd, rendez-vous sur la page de téléchargement de Digital Mars et sélectionnez la version qui correspond à votre système. n'installez pas un compilateur fait pour la version 1 du langage D. Ce livre ne couvre que la version 2 du D. Le processus d'installation ne devrait pas être très compliqué. (NDT Les utilisateurs de Linux devraient pouvoir installer gdc comme n'importe quel autre logiciel, il se trouve dans les dépôts de n'importe quelle distribution répandue.)

5-2 - Fichier source

Le fichier que le programmeur écrit et qui est compilé par le compilateur D s'appelle le fichier source. Comme D est un langage compilé, le fichier source lui-même n'est pas un programme exécutable. Il doit être converti en un programme exécutable par le compilateur.

Comme avec n'importe quel fichier, le fichier source doit avoir un nom. Même si le nom peut être n'importe quoi, il est conseillé d'utiliser l'extension .d pour le fichier source parce que les environnements de développement, les outils, les programmeurs s'attendent tous à ce que ce soit le cas. Par exemple, test.d, game.d, invoice.d, etc. sont des noms de fichiers source appropriés.

5-3 - Compiler le programme hello world

Copiez le code du programme hello world précédent dans un fichier texte et enregistrez-le sous le nom hello.d (NDT Je vous conseille de vous créer un dossier dans lequel vous placerez tout ce dont vous aurez besoin lors de votre apprentissage du langage D).

Le compilateur va bientôt vérifier la syntaxe de ce code source (c.-à-d. si elle est valide selon les règles du langage) et produire un programme à partir de ce code en le traduisant en code machine. Pour compiler le programme, suivez ces étapes :

1. Ouvrez un terminal ;

2. Déplacez-vous vers le dossier où vous avez placé votre fichier hello.d ;

3. Entrez la commande suivante :

- pour les utilisateurs de dmd : dmd hello.d ,

- pour les utilisateurs de gdc :

- si vous êtes sous Windows : gdc hello.d -o hello.exe ,

- sinon :gdc hello.d -o hello .

Si vous n'avez fait aucune erreur, vous pouvez penser qu'il ne s'est rien passé. Au contraire, ça veut dire que tout s'est bien passé. Il devrait y avoir un exécutable nommé hello (ou hello.exe sous Windows) qui vient d'être créé par le compilateur.

Si par contre le compilateur a affiché des messages, vous avez probablement fait une erreur. Identifiez-la, corrigez-la et réessayez de compiler. Vous allez régulièrement faire beaucoup d'erreurs en programmant, le processus de correction-compilation vous deviendra habituel.

Une fois le programme créé, tapez le nom de l'exécutable pour le lancer. Vous devriez voir « Hello world » s'afficher à l'écran :

$ ./hello      # exécution du programme.
Hello world!   # le message qu'il affiche

Le dollar ($) est ici pour séparer la commande de sa sortie. Il ne faut pas l'inclure dans la commande lorsqu'on l'exécute.

Félicitations ! Votre programme fonctionne comme prévu.

5-4 - Paramètres du compilateur

(NDT Les mentions de gdc dans cette sous-partie n'apparaissent pas dans l'œuvre originale, elles ont été ajoutées par le traducteur.)

Le compilateur a beaucoup de paramètres en lignes de commande qui sont utilisés pour influencer la compilation du programme.
Pour voir une liste des paramètres, les utilisateurs de dmd taperont simplement le nom de leur compilateur :

$ dmd    # ← Entrez seulement le nom
DMD64 D Compiler v2.059
Copyright (c) 1999-2012 by Digital Mars written by Walter Bright
Documentation: http://www.dlang.org/index.html
Usage:
dmd files.d ... { -switch }

files.d      D source files
...
-unittest    compile in unit tests
...
-w           enable warnings
...

NDT Les utilisateurs de gdc sont invités à lire la page de manuel de gdc et celle de gcc, gdc acceptant la plupart des options de gcc (pour quitter le manuel, appuyez sur « q ») :

$ man gdc
$ man gcc

L'extrait de sortie ci-avant montre seulement les paramètres que je vous recommande d'utiliser à chaque fois. Même si ça ne change rien pour le programme « hello world » de ce chapitre, la ligne de commande suivante compilera le programme en activant les avertissements et les tests unitaires. Nous verrons à quoi cela correspond ainsi que d'autres paramètres du compilateur plus en détail dans les chapitres suivants :

dmd hello.d -w -unittest

NDT Les utilisateurs de gdc pourront utiliser cette ligne :

- sous Windows :

- gdc hello.d -o hello.exe -Wall -funittest

- autres systèmes :

- gdc hello.d -o hello -Wall -funittest

La liste complète des paramètres de dmd peut être trouvée dans la documentation du compilateur DMD.

Un autre paramètre de la ligne de commande que vous pouvez trouver utile est -run. Il compile le code source, produit l'exécutable et le lance en une seule commande :

$ dmd -run hello.d -w -unittest
Hello world!     ← Le programme est automatiquement exécuté

5-5 - IDE

En plus du compilateur, vous pouvez aussi considérer l'installation d'un EDI (Environnement de Développement Intégré, IDE en anglais). Les EDI sont conçus pour rendre le développement de programmes plus facile en simplifiant les étapes d'écriture, de compilation et de débogage.

Si vous installez un EDI, compiler et exécuter un programme sera aussi simple qu'appuyer sur une touche ou cliquer sur un bouton. Je vous recommande tout de même de vous familiariser avec la compilation manuelle en ligne de commande.

Si vous décidez d'installer un EDI, allez sur la page de dlang.org sur les EDI pour voir la liste des EDI disponibles.

5-6 - Contenu du programme hello world

Voici une petite liste des quelques concepts du langage D qui apparaissent dans ce petit programme.

5-6-1 - Bases du langage

Chaque langage définit sa propre syntaxe, ses types fondamentaux, mots-clés, règles, etc. Tous ces éléments font partie des fondements du langage. Les parenthèses, les points-virgules, des mots comme main ou void font tous partie des règles du langage D. Ils sont similaires aux règles du français : sujet, verbe, ponctuation, structures de la phrase, etc.

5-6-2 - Bibliothèques et fonctions

Les bases du langage ne définissent que sa structure. Elles sont utilisées pour définir des fonctions et des types de l'utilisateur, qui sont à leur tour utilisés pour créer des bibliothèques. Les bibliothèques sont des collections de parties de programmes réutilisables qui sont liées à vos programmes pour les aider à atteindre leur but.

Ci-dessus, writeln est une fonction de la bibliothèque standard du langage D. Elle est utilisée, comme son nom l'indique, pour écrire une ligne (« write line »).

5-6-3 - Modules

Les bibliothèques sont composées de parties regroupant des codes formant une certaine unité par leur rôle, les tâches qu'ils accomplissent, etc. Ces parties forment ce qu'on appelle des modules. Le seul module que ce programme utilise est std.stdio, qui gère les entrées et sorties de données.

5-6-4 - Caractères et chaînes

Les expressions du type "Hello world" sont appelées chaînes de caractères (ou simplement « chaînes »), et les éléments des chaînes sont des caractères. La seule chaîne de ce programme contient les caractères 'H', 'e', '!', entre autres.

5-6-5 - Ordre des opérations

Les programmes accomplissent leurs tâches en exécutant des opérations dans un certain ordre. Ces tâches commencent avec les opérations qui sont écrites dans la fonction nommée main. La seule opération dans ce programme écrit « Hello world! ».

5-6-6 - Sens des lettres minuscules et majuscules

Vous pouvez choisir de taper n'importe quel caractère dans des chaînes, mais vous devez taper les autres caractères exactement tels qu'ils apparaissent dans le programme. C'est parce que les majuscules et les minuscules sont significatives dans les programmes D. Par exemple, writeln et Writeln sont deux noms différents.

5-6-7 - Mots-clés

Les mots spéciaux qui font partie du langage de base sont appelés mots-clés. Il y a deux mots-clés dans ce programme : import, qui est utilisé pour inclure un module au programme, et void, qui veut dire « ne retourne rien ».

La liste complète des mots-clés de D est : abstract, alias, align, asm, assert, auto, body, bool, break, byte, case, cast, catch, cdouble, cent, cfloat, char, class, const, continue, creal, dchar, debug, default, delegate, delete, deprecated, do, double, else, enum, export, extern, false, final, finally, float, for, foreach, foreach_reverse, function, goto, idouble, if, ifloat, immutable, import, in, inout, int, interface, invariant, ireal, is, lazy, long, macro, mixin, module, new, nothrow, null, out, override, package, pragma, private, protected, public, pure, real, ref, return, scope, shared, short, static, struct, super, switch, synchronized, template, this, throw, true, try, typedef, typeid, typeof, ubyte, ucent, uint, ulong, union, unittest, ushort, version, void, volatile, wchar, while, with, FILE, MODULE, LINE, FUNCTION, PRETTY_FUNCTION, __gshared, __traits, __vector, et __parameters.

Nous les découvrirons tous dans les chapitres suivants à l'exception de asm et de __vector, qui sortent du cadre de ce livre; delete, typedef, et volatile sont dépréciés; et macro n'est pas utilisé pour le moment.

5-6-8 - Exercices

1. Modifiez le programme pour qu'il affiche autre chose.

2. Changez le programme pour qu'il affiche plus d'une ligne.

3. Essayez de compiler le programme après avoir apporté d'autres modifications ; par exemple, supprimez le point-virgule à la fin de la ligne qui contient writeln et observez les erreurs de compilation.

Solution.

6 - writeln et write

Dans le chapitre précédent, nous avons vu que writeln prend une chaîne de caractères entre parenthèses et affiche la chaîne.

Les parties de programme qui effectuent une tâche sont appelées fonctions et les informations dont elles ont besoin pour accomplir cette tâche sont appelées paramètres. L'acte de donner ces informations aux fonctions est appelé « passer des valeurs en paramètres de celles-ci ». Les paramètres sont passés aux fonctions dans des parenthèses, séparés par des virgules.

Note : le mot « paramètre » décrit l'information qui est passée à une fonction d'un point de vue conceptuel. L'information concrète qui est effectivement passée pendant l'exécution du programme est appelée « argument ». Même si c'est incorrect, ces termes sont parfois confondus dans le monde du logiciel.

writeln peut prendre plus d'un argument. Elle les affiche l'un après l'autre sur la même ligne :

import std.stdio;

void main()
{
writeln("Hello world!", "Hello fish!");
}

Parfois, toutes les informations qui doivent être affichées sur la même ligne peuvent ne pas être disponibles au même moment. Dans de tels cas, les premières parties de la ligne peuvent être affichées avec write et la dernière partie de la ligne peut être affichée avec writeln.

writeln passe à la ligne, write reste sur la même ligne :

import std.stdio;

void main()
{
// Affichons ce qu'on a déjà :
write("Hello");

// ... ici on fait des calculs, des opérations ...

write("world!");

// ... et finalement:
writeln();
}

Appeler writeln sans aucun paramètre sert simplement à passer à la ligne.

Les lignes qui commencent par // sont appelées lignes de commentaires ou juste commentaires. Un commentaire ne fait pas partie du code du programme dans le sens où il n'affecte pas le comportement du programme.

Sa seule utilité est d'expliquer ce que le code fait dans cette section particulière du programme. Le commentaire est adressé à toute personne qui sera amenée à lire le code plus tard, y compris le programmeur qui a écrit ce commentaire lui-même.

6-1 - Exercices

1. Les deux programmes de ce chapitre affichent les chaînes sans aucun espace entre elles. Modifiez ces programmes pour qu'il y ait un espace entre les arguments comme dans « Hello world! ».

2. Essayez d'appeler write avec plus d'un paramètre.

Solution.

7 - Compilateur

Nous avons vu que les deux outils les plus utilisés en programmation D sont l'éditeur de texte et le compilateur. Les programmes D sont écrits dans des éditeurs de texte.

Le concept de compilation et le rôle du compilateur doivent aussi être compris lors de l'utilisation de langages compilés tels que le D.

7-1 - Code machine

Le cerveau de l'ordinateur est son microprocesseur (ou le CPU : Central Processing Unit, ce qui veut dire « unité centrale de traitement »). Dire au CPU ce qu'il doit faire est appeler « coder » et les instructions qui sont utilisées pour le faire sont appelées « code machine ».

La plupart des architectures de CPU utilisent du code machine (des instructions) spécifique. Ces instructions sont déterminées par des contraintes matérielles pendant la conception de l'architecture. Au plus bas niveau, ces instructions sont implémentées comme des signaux électriques. Parce que la facilité de codage n'est pas la première préoccupation à ce niveau, écrire directement des programmes sous forme de code machine est une tâche très difficile.

Ces instructions sont des nombres spéciaux, qui représentent des opérations diverses prises en charge par le CPU. Par exemple, pour un CPU 8-bit fictif, le nombre 4 pourrait représenter l'opération de chargement, le nombre 5 l'opération de stocker et le nombre 6 l'opération d'incrémenter. En supposant que les 3 bits de gauche sont le numéro d'opération et que les 5 bits de droite sont la valeur utilisée dans cette opération, un simple programme en code machine pour ce CPU pourrait ressembler à ceci :

Opération

Valeur

Signification

100

11110

LOAD

11110

101

10100

STORE

10100

110

10100

INCREMENT

10100

'000

00000

PAUSE

Le code machine est si proche du matériel qu'il n'est pas adapté pour représenter des idées de plus haut niveau comme une carte de jeu ou une inscription d'étudiant.

7-2 - Langage de programmation

Les langages de programmation sont conçus comme des moyens efficaces de programmer un CPU, capables de représenter des concepts de plus haut niveau. Ils n'ont pas a s'occuper des contraintes matérielles ; leurs buts principaux sont la facilité d'utilisation et leur expressivité. Ils sont plus faciles à comprendre pour des humains et plus proches des langages naturels :

if (une_carte_a_ete_jouee()) {
afficher_la_carte();
}

Cependant, les langages de programmation suivent des règles bien plus strictes et formelles que n'importe quel langage parlé.

7-2-1 - Langages compilés

Dans certains langages de programmation, les instructions du programme doivent être compilées avant de devenir un programme que l'on pourra exécuter. De tels langages produisent des programmes rapides à l'exécution, mais le processus de développement implique deux étapes principales : écrire le programme et le compiler.

En général, les langages compilés aident à repérer les erreurs de programmation avant même que le programme commence son exécution.

D est un langage compilé.

7-2-2 - Langages interprétés

Certains langages de programmation ne nécessitent pas de compilation. De tels langages sont appelés langages interprétés. Les programmes peuvent être exécutés directement à partir du programme écrit à la main. Quelques exemples de langages interprétés : Python, Ruby, Perl. Puisqu'il n'y a pas d'étape de compilation, le développement est plus facile en utilisant ces langages. D'un autre côté, comme les instructions du programme doivent être analysées pour être interprétées à chaque fois que le programme est exécuté, les programmes écrits dans ces langages sont plus lents que leurs équivalents écrits en langages compilés.

En général, pour un langage interprété, beaucoup de types d'erreurs ne peuvent pas être découverts avant l'exécution du programme.

7-2-3 - Compilateur

Le rôle d'un compilateur est la traduction : il traduit le programme écrit dans un langage de programmation en code machine. C'est la traduction depuis le langage du programmeur vers le langage du CPU. Cette traduction est appelée compilation. Chaque compilateur comprend un langage de programmation particulier et est présenté comme un compilateur de ce langage, comme dans « un compilateur D ».

7-2-4 - Erreur de compilation

Comme le compilateur compile un programme selon les règles du langage, il arrête la compilation dès qu'il rencontre une instruction illégale. Les instructions illégales sont celles qui sont hors des spécifications du langage. Des problèmes comme des parenthèses non fermées, un point-virgule manquant, un mot-clé mal orthographié, etc. sont des causes d'erreurs de compilation.

Le compilateur peut aussi émettre un avertissement lors de la compilation s'il rencontre un bout de code douteux qui peut poser problème, mais qui n'est pas forcément une erreur. Cependant, les avertissements indiquent presque toujours une erreur ou un mauvais style, il est donc monnaie courante de considérer la plupart des avertissements (sinon tous) comme des erreurs.

8 - Types fondamentaux

Nous avons vu que le cerveau d'un ordinateur est le CPU. La plupart des tâches d'un programme sont effectuées par le CPU et le reste est dispatché aux autres parties de l'ordinateur.

La plus petite unité de données dans un ordinateur est appelée un bit, qui peut avoir la valeur 0 ou 1.

Comme un type de données qui ne peut stocker que des 0 ou des 1 n'aurait qu'une utilité très limitée, le CPU définit des types de données plus grands qui sont des combinaisons de plus d'un bit. Par exemple, un octet est composé de 8 bits. Le type le plus efficace d'un CPU fait de ce CPU un CPU N bits, comme dans CPU 32 bits, CPU 64 bits, etc.

Les types de données que le CPU définit ne sont encore pas suffisants : ils ne peuvent représenter des concepts de plus haut niveau comme le nom d'un étudiant ou une carte à jouer. D propose beaucoup de types de données utiles, mais même ces types ne sont pas suffisants pour représenter beaucoup de concepts de plus haut niveau. De tels concepts doivent être définis par le programmeur au moyen de structures et de classes, que l'on verra dans des chapitres ultérieurs.

Les types fondamentaux du D sont très similaires aux types fondamentaux de beaucoup d'autres langages ; ils sont présentés dans le tableau qui suit (les termes qui apparaissent dans ce tableau sont expliqués en dessous) :

Type fondamental du D

Définition

Valeur initiale

bool

type booléen

false

byte

8 bits signé

0

ubyte

8 bits non signé

0

short

16 bits signé

0

ushort

16 bits non signé

0

int

32 bits signé

0

uint

32 bits non signé

0

long

64 bits signé

0L

ulong

64 bits non signé

0L

float

32 bits à virgule flottante

float.nan

double

64 bits à virgule flottante

double.nan

real

soit le type à virgule flottante le plus large que le matériel prend en charge, soit double : le plus large des deux

real.nan

ifloat

float à valeur imaginaire

float.nan * 1.0i

idouble

double à valeur imaginaire

double.nan * 1.0i

ireal

real à valeur imaginaire

real.nan * 1.0i

cfloat

nombre complexe construit à partir de deux float

float.nan + float.nan * 1.0i

cdouble

nombre complexe construit à partir de deux double

double.nan + double.nan * 1.0i

creal

nombre complexe construit à partir de deux real

real.nan + real.nan * 1.0i

char

unité de stockage UTF-8

0xFF

wchar

unité de stockage UTF-16

0xFFFF

dchar

unité de stockage UTF-32 et point de code Unicode

0x0000FFFF

En plus de ces types, le mot-clé void représente « aucun type ». Les mots-clés cent et ucent sont réservés pour un usage futur pour représenter les valeurs 128-bit signées et non signées.

Sauf s'il y a une raison spécifique de ne pas le faire, vous pouvez utiliser int pour représenter les valeurs entières. Pour représenter des valeurs fractionnaires, utilisez plutôt double.

Les termes suivants apparaissent dans le tableau :

- Booléen : le type des expressions logiques, avec la valeur true pour représenter quelque chose de vrai et false pour quelque chose de faux ;

- Type signé : un type qui peut avoir des valeurs positives ou négatives. Par exemple, byte peut stocker des valeurs entre -128 et 127. Le nom de ces types vient du signe négatif ;

- Type non signé : un type qui ne peut stocker que des valeurs positives. Par exemple, ubyte peut stocker des valeurs entre 0 et 255. Le u au début du nom de ces types vient de unsigned (qui veut dire « non signé ») ;

- Virgule flottante : un type qui peut représenter un nombre à virgule comme 1,25 (NDT On parle de valeur à virgule flottante ou de valeur flottante). La précision des calculs à virgule flottante est directement reliée au nombre de bits du type : plus le nombre de bits est grand, plus le résultat est précis. Seuls les types à virgule flottante peuvent représenter des nombres à virgule. Les types entiers comme int ne peuvent représenter que des valeurs entières comme 1 et 2 ;

- Type complexe : un type qui peut représenter les nombres complexes des mathématiques ;

- Type imaginaire : un type qui représente seulement la partie imaginaire d'un nombre complexe. Le i qui apparaît dans la colonne des valeurs initiales est le nombre i tel que en mathématiques ;

- nan  : initiales de « Not A Number » (« n'est pas un nombre »), représentant une valeur flottante non valide.

8-1 - Attributs des types

Les types D ont des propriétés auxquelles on peut accéder par des attributs. On accède à ces propriétés avec un point après le nom du type. Par exemple, on accède à la taille de int avec int.sizeof. Nous verrons seulement quatre de ces attributs dans ce chapitre :

- . stringof est le nom du type ;

- . sizeof est la longueur du type en termes d'octet. (Pour déterminer le nombre de bits, cette valeur doit être multipliée par 8, le nombre de bits dans un octet.) ;

- .min (pour « minimum »). C'est la plus petite valeur que le type peut avoir ;

- .max (pour « maximum »). C'est la plus grande valeur que le type peut avoir.

Voici un programme qui affiche ces propriétés pour int :

import std.stdio;

void main()
{
writeln("Type              ~ : ", int.stringof);
writeln("Longueur en octets~ : ", int.sizeof);
writeln("Valeur minimale   ~ : ", int.min);
writeln("Valeur maximale   ~ : ", int.max);
}

Sur ma machine, ce programme donnerait le résultat suivant :

Type           : int
Length in bytes: 4
Minimum value  : -2147483648
Maximum value  : 2147483647
Initial value  : 0

8-1-1 - size_t

Vous allez également rencontrer le type size_t. size_t n'est pas un type à part entière, mais est un alias d'un type non signé existant. Son nom vient de « size type » (type taille). C'est le meilleur type pour représenter des idées comme la taille ou pour compter. size_t doit être assez large pour représenter le nombre d'octets de la mémoire qu'un programme peut potentiellement utiliser. Sa taille est donc dépendante du système : uint sur un système 32 bits, ulong sur un système 64 bits, etc.

Vous pouvez utiliser l'attribut .stringof pour savoir de quel type size_t est un alias sur votre système :

import std.stdio;

void main()
{
writeln(size_t.stringof);
}

Sur mon système, cela donne :

ulong

8-1-2 - Exercice

Afficher les propriétés d'autres types.

Vous ne pouvez pas utiliser les types réservés cent et ucent, et void n'a pas les attributs .min, .max et .init.

De plus, la propriété .min a été dépréciée pour les types à virgule flottante. (Vous pouvez voir les différentes propriétés des types fondamentaux dans la Spécification des propriétés). Si vous utilisez un type à virgule flottante dans cet exercice, le compilateur vous préviendra que .min n'est pas valide pour ce type. À la place, comme nous le verrons plus tard dans le chapitre sur les types à virgule flottante, il faut utiliser le négatif de la propriété .max , par exemple -double.max.

Solution.

9 - Affectation et ordre d'évaluation

Les deux premières difficultés auxquelles sont confrontés les étudiants quand ils apprennent à programmer concernent l'opération d'affectation et l'ordre d'évaluation.

9-1 - Opération d'affectation

Vous verrez des lignes similaires à celle qui suit dans presque tous les programmes écrits dans presque n'importe quel langage de programmation :

a = 10;

Le sens de cette ligne est « changer la valeur de a par 10 ». De façon similaire, la ligne suivante veut dire « changer la valeur de b par 20 » :

b = 20;

D'après cette information, que peut-on dire de cette ligne ?

a = b;

Malheureusement, la ligne n'est pas une égalité mathématique classique comme nous en avons tous l'habitude. L'expression ci-dessus ne veut pas dire « a est égal à b » ! En appliquant la même logique que pour les lignes précédentes, l'expression ci-dessus doit vouloir dire « changer la valeur de a par b ». « Changer la valeur de a par b » veut dire « changer la valeur de a par la valeur de b ».

Le symbole connu « = » des mathématiques a un sens complètement différent en programmation : changer la valeur de gauche par celle de droite.

9-2 - Ordre d'évaluation

Les opérations d'un programme sont appliquées étape par étape dans un ordre spécifique. Nous pouvons voir les t expressions précédentes dans un programme dans cet ordre :

a = 10;
b = 20;
a = b;

Le sens de ces trois lignes ensemble est : « changer la valeur de a par 10, ensuite changer la valeur de b par 20, ensuite changer la valeur de a par celle de b ». En conséquence, après que ces trois opérations sont effectuées, les valeurs de a et de b sont toutes les deux égales à 20.

9-3 - 3 Exercice

Observer que les trois opérations suivantes échangent les valeurs de a et de b. Si leurs valeurs de départ sont respectivement 1 et 2, après les opérations les valeurs deviennent 2 et 1.

c = a;
a = b;
b = c;

Solution.

10 - Variables

Les concepts concrets qui sont représentés dans un programme sont appelés variables. Une valeur comme la température de l'air ou un objet plus compliqué comme un moteur de voiture peuvent être des variables d'un programme.

Toute variable a un certain type et une certaine valeur. La plupart des variables ont également un nom, mais certaines variables sont anonymes.

Pour un exemple de variable, on peut penser à l'idée du nombre d'étudiants dans une école. Comme le nombre d'étudiants est un nombre entier, int est un type convenable et nombreEtudiants serait un nom suffisamment explicite.

Selon les règles de syntaxe du langage D, une variable est introduite par son type suivi de son nom. Introduire une variable dans le programme s'appelle sa définition. Une fois qu'une variable est définie, son nom se met à représenter sa valeur.

import std.stdio;

void main()
{
// La définition de la variable ; cette définition
// indique que le type de nombreEtudiants est int :
int nombreEtudiants;

// Le nom de la variable devient sa valeur :
writeln("Il y a ", nombreEtudiants, " étudiants.");
}

Voici ce qu'affiche ce programme :

Il y a 0 étudiants.

Comme on le voit dans cet affichage, la valeur de nombreEtudiants est 0. Cela correspond au tableau des types fondamentaux du chapitre précédent : la valeur initiale de int est 0.

Notez que le nom de la variable, « nombreEtudiants », n'est jamais affiché. En d'autres termes, la sortie du programme n'est pas « Il y a nombreEtudiants étudiants. ».

Les valeurs des variables sont modifiées par l'opérateur =. Cet opérateur affecte de nouvelles valeurs aux variables, il est donc nommé l'opérateur d'affectation :

import std.stdio;

void main()
{
int nombreEtudiants;
writeln("Il y a ", nombreEtudiants, " étudiants.");

// Affecter la valeur 200 à la variable nombreEtudiants :
nombreEtudiants = 200;
writeln("Il y a maintenant ", nombreEtudiants, " étudiants.");
}

Sortie :

Il y a 0 étudiants.
Il y a maintenant 200 étudiants.

Quand la valeur d'une variable est connue au moment de sa définition, la variable peut être définie et affectée en même temps. C'est une pratique importante, elle rend impossible l'utilisation d'une variable avant son affectation avec la valeur voulue :

import std.stdio;

void main()
{
// Définition et affectation simultanées :
int nombreEtudiants = 100;

writeln("Il y a ", nombreEtudiants, " étudiants.");
}

Sortie :

Il y a 100 étudiants.

10-1 - Exercice

Définir deux variables pour afficher « J'ai changé 20 euros à un taux de 2.11 % ». Vous pouvez utiliser double pour la valeur flottante.

Solution.

11 - Flux d'entrée et de sortie standards

Jusqu'ici, l'affichage de nos programmes s'effectuait dans la console. Bien qu'elle soit la destination dans la plupart des cas, en réalité les caractères sont envoyés dans les flux de sortie des programmes.

La sortie standard est basée sur les caractères : tous les objets à afficher sont d'abord convertis en leur représentation sous forme de caractères et sont ensuite envoyés vers la sortie un par un en tant que caractères. Par exemple, la valeur entière 100 que nous avons affichée dans le chapitre précédent n'est pas envoyée vers la sortie en tant que valeur 100, mais comme trois caractères : « 1 », « 0 », et « 0 ».

De façon similaire, ce que nous percevons normalement comme le clavier est en fait le flux d'entrée standard d'un programme et il est aussi basé sur les caractères. L'information vient toujours sous forme de caractères à convertir en données. Par exemple la valeur 42 vient en fait de l'entrée standard sous forme de caractères : « 4 » et « 2 ».

Ces conversions se font automatiquement.

Ce concept de caractères qui se suivent est appelé un flux de caractères. Comme l'entrée standard et la sortie standard du D correspondent à cette description, ce sont des flux de caractères.

Les noms des flux d'entrée standard et de sortie standard en D sont respectivement stdin et stdout.

Les opérations sur ces flux nécessitent normalement le nom du flux, un point, et l'opération, comme dans stream.operation(). Cependant, comme stdin et stdout sont très couramment utilisés, par commodité, les opérations standard les concernant peuvent être appelées sans qu'il y ait besoin de préciser le nom du flux et le point, comme dans operation().

writeln, que l'on a utilisé dans les chapitres précédents, est en effet la forme abrégée de stdout.writeln. De même, write est la forme abrégée de stdout.write. Du coup, le programme hello world peut aussi être écrit comme cela :

import std.stdio;

void main()
{
stdout.writeln("Hello world!");
}

11-1 - Exercice

Confirmez que write (ou writeln) fonctionne de la même manière que stdout.write (ou stdout.writeln).

Solution.

12 - Expressions logiques

Le travail qu'un programme réalise est accompli par des expressions. Toute partie d'un programme qui produit une valeur ou un effet de bord est appelée une expression. La définition d'expression est très large, car même une constante comme 42 et une chaîne comme "bonjour" sont des expressions parce qu'elles produisent respectivement les valeurs constantes 42 et "bonjour".

Ne confondez pas produire une valeur avec définir une variable. Les valeurs n'ont pas besoin d'être associées à des variables.

Les appels de fonctions comme writeln sont également des expressions parce qu'ils ont des effets de bord. Dans le cas de writeln, l'effet se produit sur le flux de sortie et se traduit par l'ajout de caractères dans ce flux. Un autre exemple tiré des programmes que nous avons écrits est l'opération d'affectation qui affecte la variable dont le nom est présent à sa gauche.

Parce qu'elles produisent des valeurs, les expressions peuvent faire partie d'autres expressions. Ceci nous permet de former des expressions plus complexes à partir d'expressions plus simples. Par exemple, en supposant qu'il y a une fonction nommée TempératureActuelle() produisant la valeur de la température actuelle de l'air, la valeur qu'elle produit peut être directement utilisée dans une expression impliquant writeln.

writeln("Il fait actuellement ", TempératureActuelle(),
" degrés.");

Cette ligne comporte quatre expressions :

- « Il fait actuellement »

- TempératureActuelle()

- « degrés. »

- l'expression writeln qui utilise les trois autres expressions.

Dans ce chapitre, nous allons voir un genre particulier d'expressions qui sont utilisées dans les instructions conditionnelles.

Avant d'aller plus loin, je voudrais faire un petit rappel sur l'opérateur d'affectation, en mettant cette fois l'accent sur les deux expressions qui apparaissent à sa gauche et à sa droite : l'opérateur d'affectation (=) affecte la valeur de l'expression de droite à l'expression de gauche (p. ex. : à une variable).

Température = 23;   // La valeur de Température devient 23

12-1 - Expressions logiques

Les expressions logiques sont les expressions qui sont utilisées en arithmétique booléenne. Les expressions logiques sont ce qui fait qu'un programme prend des décisions comme « si la réponse est oui, j'enregistrerai ce fichier ».

Les expressions logiques ne peuvent prendre qu'une valeur parmi les deux possibles : false (faux) qui indique que l'expression est fausse et true (vrai) qui indique que l'expression est vraie.

J'utiliserai des expressions writeln dans les exemples qui suivent. Si une ligne se finit par true, cela voudra dire que ce qui est affiché sur la ligne est vrai. De même, false voudra dire que ce qui est sur la ligne est faux. Par exemple, si la sortie d'un programme est la suivante :

Il y a du café : true

Cela voudra dire qu'« il y a du café ». De même,

Il y a du café : false

voudra dire qu'« il n'y a pas de café ». Notez que le fait que « il y a » apparaît sur la gauche de la ligne ne veut pas dire que du café est présent. J'utilise la construction « Il y a… : false » pour dire « il n'y a pas » ou « c'est faux ».

Les expressions logiques sont utilisées de façon intensive dans les instructions conditionnelles, les boucles, les paramètres de fonctions, etc. Il est essentiel de comprendre comment elles fonctionnent. Heureusement, les expressions logiques sont très faciles à expliquer et utiliser.

Les opérateurs logiques qui sont utilisés dans les expressions logiques sont les suivants :

- l'opérateur == répond à la question « est égal à ? ». Il compare les deux expressions à sa droite et à sa gauche et produit true si elles sont égales et false si elle ne sont pas égales. Par définition, la valeur que == produit est une expression logique.
Par exemple, supposons que nous ayons les deux variables suivantes :

int joursDansUneSemaine = 7;
int moisDansUneAnnée = 12;

- Les expressions suivantes sont deux expressions logiques qui utilisent ces valeurs :

joursDansUneSemaine == 7      // true
moisDansUneAnnée == 11        // false

- l'opérateur != répond à la question « n'est pas égal à ? ». Il compare les deux expressions contiguës et produit l'opposé de ==
.

joursDansUneSemaine != 7      // false
moisDansUneAnnée != 11        // true

- l'opérateur || veut dire « ou » et produit true si l'une ou l'autre des expressions qui sont à ses côtés est vraie.
Si la valeur de l'expression de gauche est true, il produit la valeur true sans même évaluer l'autre expression. Si l'expression du côté gauche est fausse, alors l'opérateur produit la valeur de l'expression de droite. Cet opérateur est similaire à la conjonction « ou » du français dans le sens où si l'une, l'autre ou les deux expressions sont vraies, alors il produit true.
Toutes les valeurs possibles des deux côtés de cet opérateur et leur résultat sont présentés dans cette table de vérité :

Expression de gauche

Expression de droite

Résultat

false

false

false

false

true

true

true

false (non évalué)

true

true

true (non évalué)

true

import std.stdio;

void main()
{
// false veut dire « non », true veut dire « oui »

bool ilYADuCafé = false;
bool ilYADuThé = true;

writeln("Il y a une boisson chaude : ",
ilYADuCafé || ilYADuThé);
}

Du fait qu'une des deux expressions est vraie, l'expression logique de ce programme produit true.

- L'opérateur && signifie « et », et produit true si les deux expressions sont vraies.
Si la valeur de l'expression de gauche est fausse, il produit false sans même évaluer l'expression qui est à sa droite. Si l'expression de gauche est vraie, alors il produit la valeur de l'expression de droite. Cet opérateur est similaire à la conjonction « et » du français : si la valeur de gauche et la valeur de droite sont toutes les deux true, alors il produit true.

Expression de gauche

Expression de droite

Résultat

false

false (non évalué)

false

false

true (non évalué)

false

true

false

false

true

true

true

writeln("Je vais boire du café: ",
jeVeuxBoireDuCafé && ilYADuCafé);

Le fait que les opérateurs || et && peuvent ne pas évaluer l'expression de droite est appelé « comportement raccourci ». Le seul autre opérateur qui a ce comportement est l'opérateur ternaire ? : qui sera vu dans un prochain chapitre. Tous les autres opérateurs évaluent et utilisent toujours toutes leurs expressions.

- l'opérateur ^ répond à la question « l'un ou l'autre ? ». Cet opérateur produit true si seule une expression est vraie, mais pas les deux.

Expression de gauche

Expression de droite

Résultat

false

false

false

false

true

true

true

false

true

true

true

false

- Par exemple, la logique qui représente mon choix de jouer aux échecs si un seul de mes deux amis arrive peut être codé ainsi :

writeln("Je jouerai aux échecs : ", jimEstApparu ^ bobEstApparu);

- l'opérateur < répond à la question « est plus petit que ? » (ou « vient avant dans l'ordre de tri ? »).

writeln("Nous gagnons : ", leurScore < notreScore);

- l'opérateur > répond à la question «  est plus quand que ? » (ou « vient après dans l'ordre de tri ? »).

writeln("Ils gagnent : ", leurScore > notreScore);

- l'opérateur <= répond à la question « est inférieur ou égal ? » (ou « vient avant ou au même endroit que ? »). Cet opérateur est l'opposé de l'opérateur >.

writeln("Nous n'avons pas été vaincus : ", leurScore <= notreScore);]

- l'opérateur >= répond à la question « est supérieur ou égal à ? » (ou « vient après ou au même endroit que ? »). Cet opérateur est l'opposé de l'opérateur <.

writeln("Nous n'avons pas gagné : ", leurScore >= notreScore);

- l'opérateur ! veut dire « l'opposé de ». Différent des autres opérateurs logiques, il prend une seule expression et produit true si cette expression est fausse, et false si cette expression est vraie.

writeln("Je marcherai : ", !ilYAUnVélo);

12-1-1 - Grouper les expressions

L'ordre dans lequel les expressions sont évaluées peut être indiqué en utilisant des parenthèses pour les grouper. Quand des expressions entre parenthèses apparaissent dans des expressions plus complexes, les expressions entre parenthèses sont évaluées avant qu'elles puissent être utilisées dans les expressions dans lesquelles elles apparaissent. Par exemple, l'expression « s'il y a du café ou du thé, et aussi des cookies ou des pains au lait, alors je suis content » peut être codée de cette manière :

writeln("Je suis content : ",
(ilYADuCafé || ilYADuThé) && (ilYADesCookies || ilYADesPains));

Si les sous-expressions n'étaient pas entre parenthèses, l'expression serait évaluée selon les règles de priorité opératoire du D (qui ont été héritées du C). Comme dans ces règles && a une priorité plus grande que ||, écrire l'expression sans parenthèses ne serait pas évalué comme voulu :

writeln("Je suis content : ",
ilYADuCafé || ilYADuThé && ilYADesCookies || ilYADesPains);

L'opérateur && serait évalué en premier et l'expression entière aurait un sens équivalent à l'expression suivante :

writeln("Je suis content : ",
ilYADuCafé || (ilYADuThé && ilYADesCookies) || ilYADesPains);

Cela a un sens complètement différent : « s'il y a du café, ou du thé et des cookies, ou du pain ; alors je suis content ».

12-1-2 - Lire un booléen en entrée

Toutes les valeurs booléennes ci-dessus sont automatiquement affichées comme false et true. Ce n'est pas le cas dans la direction opposée : les chaînes "false" et "true" ne sont pas automatiquement lues comme valeurs false et true. Pour cette raison, l'entrée doit être d'abord lue en tant que chaîne et convertie vers une valeur booléenne.

Comme l'un des exercices suivants vous demande d'entrer "false" et "true" depuis l'entrée standard, j'ai été obligé d'utiliser des fonctionnalités du D que je ne vous ai pas encore expliquées. Je vais devoir définir une fonction qui convertit la chaîne en entrée en valeur booléenne. Cette fonction effectuera cette tâche en appelant to, qui est défini dans le module std.conv. (Vous pourrez voir des erreurs ConvException si vous entrez autre chose que "false" ou "true".)

J'espère que tout le code qui est dans les fonctions principales des programmes suivants est clair. read_bool() est la fonction qui contient de nouvelles fonctionnalités. Bien que j'ai inséré des commentaires pour expliquer ce qu'elle fait, vous pouvez ignorer cette fonction pour le moment. Malgré tout, elle est nécessaire pour que le programme marche et se compile correctement.

12-1-3 - Exercices

- Nous avons vu ci-dessus que les opérateurs < et > sont utilisés pour déterminer si une valeur est inférieure ou supérieure à une autre valeur ; mais il n'y a pas d'opérateur qui réponde à la question « est entre ? » pour déterminer si une valeur se trouve entre deux autres valeurs.

1. Supposons qu'un programmeur ait écrit un code pour déterminer si une valeur est entre 10 et 20. Observez que le programme ne peut pas être compilé tel quel :

import std.stdio;

void main()
{
int valeur = 15;

writeln("est entre : ",
10 < valeur < 20);      // ← ERREUR de compilation
}

2. Essayez d'utiliser des parenthèses autour de l'expression entière :

writeln("Est entre : ",
(10 < valeur < 20));      // ← ERREUR de compilation

3. Observez qu'il ne peut toujours pas être compilé.

writeln("Est entre : ",
(10 < valeur) < 20);      // ← compile mais FAUX

4. Observez que le programme marche maintenant comme attendu et affiche "true". Malheureusement, la sortie est trompeuse parce que le programme a un bogue. Pour voir les effets de ce bogue, remplacez 15 avec une valeur supérieure à 20 :

int valeur = 21;

5. Observez que le programme affiche toujours "true" même si 21 n'est pas plus petit que 20.
Astuce : se souvenir que le type d'une expression logique est bool. Vérifier qu'une valeur booléenne est inférieure à 20 ne devrait pas avoir de sens.

6. Changez l'expression du programme selon cette logique et observez que ça affiche maintenant "true" comme attendu. Vérifiez également que l'expression logique marche correctement pour d'autres valeurs. Par exemple, quand la valeur est 50 ou 1, le programme devrait afficher "false" ; et quand c'est 12, le programme devrait afficher "true".

7. si la distance jusqu'à la plage est inférieure à 10 et il y a un vélo pour tout le monde ;
si nous sommes moins de 6, avons une voiture et l'un de nous a le permis de conduire.
Tel quel, le programme suivant écrit toujours "true". Écrivez une expression logique qui affichera "true" quand l'une des conditions ci-dessus est vraie. (Lors de l'essai du programme, entrez "false" ou "true" pour les questions qui commencent par « Y a-t-il ».). N'oubliez pas d'inclure la fonction read_bool() quand vous testerez le programme suivant :

import std.stdio;
import std.conv;
import std.string;

void main()
{
write("Combien sommes-nous ? ");
int nombrePersonnes;
readf(" %s", &nombrePersonnes);

write("Combien y a-t-il de vélos ? ");
int nombreVélos;
readf(" %s", &nombreVélos);

write("À quelle distance est la mer ? ");
int distance;
readf(" %s", &distance);

bool existeUneVoiture = read_bool("Y a-t-il une voiture ? ");
bool existeUnPermis =
read_bool("Y a-t-il un permis de conduire ? ");

/*
Remplacez la valeur 'true' ci-dessous par une expression logique qui
produit la valeur true quand l'une des conditions
listées dans la question est satisfaite :
*/
writeln("Nous allons à la mer : ", true);
}

/*
Cette fonction utilise des fonctionnalités qui seront
expliquées dans des chapitres suivants.
*/
bool read_bool(string message)
{
// Afficher le message
write(message, "(false ou true) ");

// Lire une ligne comme une chaîne
string input;
while (input.longueur == 0) {
input = chomp(readln());
}

// Produire une valeur 'bool' depuis cette chaîne
bool résultat = to!bool(input);

// Retourner le résultat
return résultat;

1. Entrez des valeurs variées et vérifiez que l'expression logique que vous avez écrite fonctionne correctement.

Solutions.

13 - Lire à partir de l'entrée standard

Toute donnée lue par un programme doit d'abord être stockée dans une variable. Par exemple, un programme lisant un nombre d'étudiants à partir de l'entrée standard doit enregistrer cette information dans une variable. Le type de cette variable spécifique peut être int.

Comme on l'a vu dans le chapitre précédent, nous n'avons pas besoin de taper stdout quand nous affichons sur la sortie parce que c'est sous-entendu. De plus, ce qui est à afficher est spécifié comme argument. write(nombreÉtudiants) est donc suffisant pour afficher la valeur de nombreÉtudiants. Pour résumer :

flux :

stdout

opération :

write

donnée :

la valeur de la variable nombreÉtudiants

cible :

habituellement, la console

L'inverse de write est readf ; cette opération lit à partir de l'entrée standard. Le « f » dans son nom vient de « formaté » puisque ce qu'elle lit doit toujours respecter un format précis.

Nous avons également vu dans le chapitre précédent que le flux d'entrée standard est stdin.

Dans le cas de la lecture, il manque encore une pièce au puzzle : où stocker les données. Pour résumer :

flux :

stdin

opération :

readf

données :

une information

cible :

?

Le lieu où l'on stocke les données est spécifié par l'adresse d'une variable. L'adresse d'une variable est l'endroit exact où sa valeur est stockée dans la mémoire de l'ordinateur.

En D, le caractère & qui est placé avant le nom indique l'adresse de ce que le nom représente. Par exemple, l'adresse de ce que stocke nombreÉtudiants est &nombreÉtudiants. Ici, &nombreÉtudiants peut être lu comme : « l'adresse de nombreÉtudiants ». C'est la pièce manquante qui va remplacer le point d'interrogation ci-dessus :

flux :

stdin

opération :

readf

données :

une information

cible :

l'emplacement de la variable nombreÉtudiants

Mettre un & devant un nom signifie pointer vers ce que le nom représente. Ce concept est le fondement des références et des pointeurs que nous verrons dans les chapitres suivants.

Je laisse pour plus tard une spécificité relative à l'utilisation de readf ; pour l'instant, considérons que le premier argument de readf doit être "%s" :

readf("%s", &nombreÉtudiants);

Comme expliqué plus loin, dans la plupart des cas, il doit y avoir un caractère espace avant le caractère % : «  %s ».

"%s" indique que les données devraient automatiquement être converties d'une manière qui convient pour le type de la variable. Par exemple, quand les caractères '4' et '2' sont lus et enregistrés dans une variable de type int, ils sont convertis vers la valeur entière 42.

Le programme ci-dessous demande à l'utilisateur d'entrer le nombre d'étudiants. Vous devez appuyer sur la touche « Entrée » après avoir saisi les données.

import std.stdio;

void main()
{
write("Combien y a-t-il d'étudiants ici ? ");

/*
* La définition de la variable qui va être utilisée pour
* enregistrer l'information qui est lue depuis l'entrée.
*/
int nombreÉtudiants;

// Stockage de l'entrée dans cette variable.
readf("%s", &nombreÉtudiants);

writeln("J'ai obtenu : il y a ", nombreÉtudiants, " étudiants.");
}

13-1 - Sauter les caractères blancs

Même la touche Entrée sur laquelle nous appuyons après avoir saisi les données est stockée comme un code spécial puis est placée dans le flux stdin. C'est utile aux programmes pour déterminer si l'information a été saisie en une seule ou plusieurs lignes.

Même si c'est parfois utile, de tels codes ne sont en général pas importants pour le programme et doivent être retirés de l'entrée. Sinon, ils la bloquent et empêchent de lire d'autres données.

Pour observer ce problème dans un programme, lisons également le nombre d'enseignants à partir de l'entrée :

import std.stdio;

void main()
{
write("Combien y a-t-il d'étudiants ici ? ");
int nombreÉtudiants;
readf("%s", &nombreÉtudiants);

write("Combien y a-t-il d'enseignants ici ? ");
int nombreEnseignants;
readf("%s", &nombreEnseignants);

writeln("J'ai obtenu: Il y a ", nombreÉtudiants, " étudiants",
" et ", nombreEnseignants, " enseignants.");
}

Malheureusement, le programme ne sait pas quoi faire de ce code spécial et reste bloqué alors qu'il attend le deuxième entier :

Combien y a-t-il d'étudiants ici ? 100
Combien y a-t-il d'enseignants ici ? 20
← Une exception (erreur) est générée ici

Même si l'utilisateur entre 20 comme nombre d'enseignants, le ou les codes spéciaux qui représentent la touche entrée sur laquelle on a appuyé après avoir entré 100 sont toujours dans le flux d'entrée et le bloquent. Les caractères sont apparus dans le flux d'entrée comme ceci :

Le code qui bloque l'entrée est surligné.

La solution consiste à insérer un caractère espace avant %s pour indiquer que le code d'entrée qui apparaît avant la lecture du nombre d'enseignants n'est pas important : " %s". Les espaces présents dans la chaîne de formatage sont utilisés pour lire et ignorer zéro ou plus caractères invisibles qui sinon bloqueraient l'entrée. Ces caractères sont le caractère espace, le ou les codes qui représentent la touche Entrée, le caractère tabulation, etc. et ils sont appelés les caractères blancs (whitespace).

Comme règle générale, vous pouvez utiliser " %s" pour n'importe quelle donnée lue depuis l'entrée. Le programme précédent fonctionne comme attendu avec les changements suivants :

// ...
readf(" %s", &nombreÉtudiants);
// ...
readf(" %s", &nombreEnseignants);
// ...

La sortie :

Combien y a-t-il d'étudiants ici ? 100
Combien y a-t-il d'enseignants ici ? 20
J'ai obtenu : Il y a 100 étudiants et 20 enseignants.

13-2 - Informations additionnelles

Les lignes qui commencent par // sont utiles en tant que lignes de commentaires simples. Pour écrire un commentaire sur plusieurs lignes, entourez ces lignes par /* et */.

Pour commenter du code, y compris d'autres commentaires, utilisez /+ et +/ :

/+
// un commentaire simple d'une ligne

/*
Un commentaire
sur plusieurs lignes
*/

Un bloc de commentaire qui inclus d'autres commentaires
+/

La plupart des caractères blancs présents dans le code source ne sont pas significatifs. Écrire des expressions longues sur plusieurs lignes ou ajouter des espaces en plus pour rendre le code plus lisible est une bonne pratique. Cependant, tant que la syntaxe est respectée, les programmes peuvent être écrits sans aucun caractère blanc additionnel :

import std.stdio;void main(){writeln("Difficile à lire !");}

Il peut être difficile de lire un code source qui contient si peu de caractères blancs.

13-3 - Exercice

Entrez un caractère non numérique alors que le programme attend des valeurs entières et constatez que le programme ne fonctionne pas correctement.

Solution.

14 - Instruction if

Nous avons appris que ce que fait le programme est effectué par des expressions. Toutes les expressions de tous les programmes que nous avons vus jusqu'à maintenant ont commencé avec la fonction main et étaient exécutées jusqu'à la fin de main.

Les instructions, d'un autre côté, sont des éléments qui affectent l'exécution des expressions. Les instructions ne produisent pas de valeurs et n'ont pas d'effets de bord en elles-mêmes. Elles déterminent si et dans quel ordre les expressions sont exécutées. Les instructions utilisent parfois des expressions logiques pour prendre de telles décisions.

Certains langages de programmation peuvent avoir des définitions différentes pour les expressions et les instructions, et d'autres peuvent ne pas faire de distinction du tout.

14-1 - Le bloc if et sa portée

L'instruction if détermine si une ou plusieurs expressions doivent être exécutées. Elle prend cette décision en évaluant une expression logique. Elle a le même sens que le mot français « si » dans la phrase « s'il y a du café alors je boirai du café ».

if prend une expression logique entre parenthèses. Si la valeur de cette expression logique est true, alors les expressions qui se trouvent entre les accolades qui suivent sont exécutées. À l'inverse, si l'expression logique est false, les expressions placées entre les accolades ne seront pas exécutées.

Le code situé entre les accolades est appelé bloc.

Voici la syntaxe de l'instruction if :jlliagre 2018-02-11T20:43:08jJe n'arrive pas à avoir if comme mot clef suivi d'une espace insécable.

if (une_expression_logique)
{
// ... La ou les expressions à exécuter si l'expression logique est vraie
}

Par exemple, la construction qui représente « s'il y a du café alors boire le café et laver la tasse » peut être écrite comme suit :

import std.stdio;

void main()
{
bool ilyaDuCafé = true;

if (ilyaDuCafé) {
writeln("Boire le café");
writeln("Laver la tasse");
}
}

Si la valeur de ilyaDuCafé était false, alors les expressions qui sont dans le bloc seraient ignorées et le programme n'afficherait rien.

Notez également qu'ouvrir les accolades sur la même ligne que le mot clé if est un style de codage répandu.

14-2 - Le bloc else et sa portée

Il y a parfois des opérations à exécuter quand l'expression logique de l'instruction if est fausse. Par exemple, il y a toujours une opération à exécuter dans une décision du type « s'il y a du café, je boirai du café, sinon je boirai du thé ».

Les opérations à exécuter dans le cas false sont placées dans un bloc après le mot clé else :

if (une_expression_logique)
{
// ... expression(s) à exécuter si vrai
}
else
{
// ... expression(s) à exécuter si faux
}

Par exemple, en supposant qu'il y a toujours du thé :

if(ilyaDuCafé) {
writeln("Boire du café");
} else {
writeln("Boire du thé");
}

Dans cet exemple, soit la première, soit la deuxième chaîne de caractères sera affichée selon la valeur de ilyaDuCafé.

Notez également de quelle façon les accolades sont placées autour de else ; c'est aussi un style de codage répandu.

else lui-même n'est pas une instruction, mais une clause optionnelle de l'instruction if ; elle ne peut pas être utilisée toute seule.

14-3 - Utilisez toujours des accolades pour entourer les blocs

Ce n'est pas recommandé, mais il est possible d'omettre les accolades s'il n'y a qu'une instruction dans un bloc. Comme les blocs if et else qui précèdent ne contiennent qu'une instruction, ce code peut être écrit comme suit :

if (ilyaDuCafé)
writeln("Boire du café");
else
writeln("Boire du thé");

Les programmeurs les plus expérimentés utilisent des accolades même pour une seule instruction (l'un des exercices de ce chapitre est relatif à leur omission). J'insiste sur cette règle de toujours utiliser des accolades, mais je vais maintenant vous montrer le seul cas où il est préférable de les omettre.

14-4 - La chaîne « if, else if, else »

Une des forces des instructions et des expressions est la possibilité de les utiliser de manière plus complexe. En plus des expressions, les blocs peuvent contenir d'autres instructions. Par exemple, un bloc else peut contenir une instruction if. Combiner les instructions et les expressions nous permet de réaliser des programmes qui se comportent conformément à ce pour quoi ils sont conçus.

Ce qui suit est un code plus complexe écrit à partir de la supposition qu'aller dans un bon troquet à vélo est préférable à marcher vers un mauvais troquet :

if (ilyaDuCafé) {
writeln("Boire du café à la maison");

} else {

if (ilyaUnVélo) {
writeln("Aller au bon endroit");

} else {
writeln("Marcher vers le mauvais endroit");
}
}

Le code ci-dessus représente la phrase : « s'il y a du café, boire à la maison ; sinon, s'il y a un vélo, aller au bon endroit ; sinon marcher vers le mauvais endroit ».

Complexifions un peu ce processus de décision : au lieu de devoir marcher vers le mauvais endroit, essayer d'abord le voisin :

if (ilyaDuCafé) {
writeln("Boire du café à la maison");

} else {

if (ilyaUnVélo) {
writeln("Aller au bon endroit");

} else {

if (voisinEstChezLui) {
writeln("Prendre le café chez le voisin");

} else {
writeln("Marcher vers le mauvais endroit");
}
}
}

Des décisions comme « si ce cas, sinon si cet autre cas, sinon si cet autre autre cas, etc. » sont courantes dans les programmes. Malheureusement, quand la règle de toujours utiliser des accolades est suivie aveuglément, le code fini par occuper trop d'espace horizontal et vertical : en ignorant les lignes vides, les trois instructions if et les quatre expressions writeln ci-dessus occupent un total de treize lignes.

Pour écrire de telles constructions d'une façon plus compacte, quand un bloc else contient une seule instruction if, les accolades de ce bloc else sont exceptionnellement omises.

Je laisse intentionnellement le code suivant mal écrit en tant qu'étape intermédiaire avant de le montrer plus loin sous une meilleure forme. Aucun code ne devrait être écrit comme cela. (Je le laisse également non coloré pour indiquer qu'il est inacceptable écrit de cette manière).

Ce qui suit est ce à quoi le code ressemble après avoir supprimé les accolades des deux blocs else qui ne contiennent qu'une instruction if :

if (ilyaDuCafé) {
writeln("Boire du café à la maison");

} else

if (ilyaUnVélo) {
writeln("Aller à vélo au bon endroit");

} else

if (voisinEstChezLui) {
writeln("Prendre le café chez le voisin");

} else {
writeln("Marcher jusqu'au mauvais endroit");
}

Si maintenant nous déplaçons ces instructions if sur la même ligne que le else qui les précède et que nous nettoyons le code, nous finissons par avoir quelque chose de plus lisible :

if (ilyaDuCafé) {
writeln("Boire du café à la maison");

} else if (ilyaUnVélo) {
writeln("Aller à vélo au bon endroit");

} else if (voisinEstChezLui) {
writeln("Prendre le café chez le voisin");

} else {
writeln("Marcher jusqu'au mauvais endroit");
}

Supprimer les accolades permet au code d'être plus compact et de regrouper les expressions pour une meilleure lisibilité. Les expressions logiques, l'ordre dans lequel elles sont évaluées, et les opérations qui sont exécutées quand elles sont vraies sont maintenant plus faciles à voir d'un coup d'œil.

Cette structure de programmation courante est appelée la chaîne «  if, else if, else ».

14-5 - Exercices

- Puisque l'expression logique ci-dessous est vraie, nous pourrions nous attendre à ce que le programme boive de la limonade et lave le verre.

import std.stdio;

void main()
{
bool ilyaDeLaLimonade = true;

if (ilyaDeLaLimonade) {
writeln("Boire de la limonade");
writeln("Laver le verre");

} else
writeln("Manger de la tarte");
writeln("Laver l'assiette");
}

1. Mais si vous lancez ce programme, vous verrez qu'il lave aussi l'assiette :

Boire de la limonade
Laver le verre
Laver l'assiette

2. Pourquoi ? Corrigez le programme pour ne laver l'assiette que lorsque l'expression logique est fausse.

Valeur du dé

Sortie du programme

1

Vous gagnez

2

Vous gagnez

3

Vous gagnez

4

Je gagne

5

Je gagne

6

Je gagne

N'importe quelle autre valeur

ERREUR : Valeur non valide

3. Bonus : faire en sorte que ce programme mentionne aussi la valeur quand celle-ci est invalide. Par exemple :

- Changez le jeu pour que l'utilisateur entre une valeur entre 1 et 1000. Maintenant, l'utilisateur gagne quand la valeur se trouve dans l'intervalle 1 à 500 et l'ordinateur gagne quand la valeur se trouve dans l'intervalle 501 à 1000. Est-ce que le programme précédent peut être facilement modifié pour fonctionner de cette manière ?

Solutions.

15 - Boucle while (Tant que)

La boucle while est similaire à l'instruction if et fonctionne essentiellement comme une instruction if répétée. Tout comme if, while prend aussi une expression logique et évalue le bloc quand l'expression logique est vraie. La différence est que l'instruction while évalue l'expression et réexécute le bloc tant que l'expression est vraie, pas qu'une fois. Répéter un bloc de code de cette manière est appelé « boucler ».

Voici la syntaxe de l'instruction while :

while (une_expression_logique)
{
// ... expression(s) à exécuter tant que c'est vrai
}

Par exemple, le code qui représente « manger des cookies tant qu'il y a un cookie » peut être codé comme ceci :

import std.stdio;

void main()
{
bool ilyaUnCookie = true;

while (ilyaUnCookie) {
writeln("Prendre un cookie");
writeln("Le manger");
}
}

Ce programme continuera à répéter la boucle indéfiniment parce que la valeur de ilyaUnCookie n'est jamais changée et est toujours true.

while est utile quand la valeur d'une expression change pendant l'exécution du programme. Pour le voir, écrivons un programme qui demande un nombre à l'utilisateur tant que ce nombre est positif ou nul. Souvenez-vous que la valeur initiale de int est 0 :

import std.stdio;

void main()
{
int nombre;

while (nombre >= 0) {
write("Veuillez entrer un nombre: ");
readf(" %s", &nombre);

writeln("Merci d'avoir entré ", nombre);
}

writeln("Sorti de la boucle");
}

Le programme remercie d'avoir donné un nombre et ne quitte la boucle que quand le nombre est strictement négatif.

15-1 - L'instruction continue

Cette instruction commence directement l'itération suivante de la boucle, au lieu d'exécuter le reste des expressions du bloc.

Modifions le programme ci-dessus pour le compliquer un peu : au lieu de remercier pour n'importe quel nombre, n'acceptons pas 13. Le programme suivant ne remercie pas pour 13 parce que dans ce cas l'instruction continue fait que le programme retourne au début de la boucle et évalue à nouveau l'expression logique.

import std.stdio;

void main()
{
int nombre;

while (nombre >= 0) {
write("Veuillez entrer un nombre: ");
readf(" %s", &nombre);

if (nombre == 13) {
writeln("Désolé, nous ne pouvons pas accepter celui-là...");
continue;
}

writeln("Merci d'avoir entré ", nombre);
}

writeln("Sorti de la boucle");
}

Nous pouvons définir le comportement de ce programme comme « prendre des nombres tant qu'ils sont supérieurs ou égaux à 0, mais ignorer 13 ».

15-2 - L'instruction break

Parfois, il devient évident qu'il n'est plus nécessaire de rester dans la boucle. break permet au programme de sortir de la boucle directement. Le programme suivant sort de la boucle dès qu'il trouve un nombre spécial :

import std.stdio;

void main()
{
int nombre;

while (nombre >= 0) {
write("Veuillez entrer un nombre : ");
readf(" %s", &nombre);

if (nombre == 42) {
writeln("TROUVÉ !");
break;
}

writeln("Merci d'avoir entré ", nombre);
}

writeln("Sorti de la boucle");
}

Nous pouvons résumer ce comportement comme : « Prendre des nombres tant qu'ils sont positifs ou nuls jusqu'à ce que le nombre soit 42 ».

15-3 - Boucle infinie

Parfois, l'expression logique est intentionnellement la constante true. L'instruction break est une manière habituelle de sortir de telles boucles infinies.

Le programme suivant affiche un menu dans une boucle infinie ; le seul moyen de sortir de la boucle est l'instruction break :

import std.stdio;

void main()
{
/* Boucle infinie, parce que l'expression logique est toujours vraie */
while (true) {
write("0:Quitter, 1:Turc, 2:Français - Votre choix ? ");

int choix;
readf(" %s", &choix);

if (choix == 0) {
writeln("À bientôt...");
break;   // La seule sortie de cette boucle

} else if (choix == 1) {
writeln("Merhaba!");

} else if (choix == 2) {
writeln("Bonjour !");

} else {
writeln("Je ne connais pas ce choix... :/");
}
}
}

Les exceptions peuvent également terminer une boucle infinie. Nous aborderons les exceptions dans un chapitre suivant.

15-4 - Exercices

- Le programme suivant est conçu pour rester dans la boucle tant que l'entrée est 3, mais il y a un bogue : il ne demande jamais d'entrer quoi que ce soit :

import std.stdio;

void main()
{
int nombre;

while (nombre == 3) {
write("Nombre ? ");
readf(" %s", &nombre);
}
}

1. Corrigez le bogue. Le programme devrait rester dans la boucle tant que l'entrée est 3.

2. Une fois que le programme obtient un nombre valide de la part d'Anna, il doit demander des nombres à Bill jusqu'à ce qu'il devine le nombre qu'Anna a donné.

Les nombres qu'Anna entre restent évidemment affichés à l'écran et peuvent être vus par Bill :o). Ignorons ce détail et écrivez le programme comme un exercice qui sert à mettre en pratique l'instruction while.

Solution.

16 - Nombres entiers et opérations arithmétiques

Nous avons vu que les instructions if et while permettent aux programmes de prendre des décisions en utilisant le type bool sous forme d'expressions logiques. Dans ce chapitre, nous allons voir les opérations arithmétiques sur les types entiers du D. Ces fonctionnalités nous permettront d'écrire des programmes beaucoup plus utiles.

Même si les opérations arithmétiques font partie de notre vie quotidienne et sont en fait simples, il y a des concepts très importants dont un programmeur doit être conscient pour produire des programmes corrects : taille en bits d'un type, débordements, soupassements, et troncations.

Avant d'aller plus loin, je voudrais résumer les opérations arithmétiques dans le tableau suivant comme référence :

Opérateur

Effet

Exemple

++

incrémente

++variable

--

décrémente

--variable

+

fait la somme de deux valeurs

premier + second

-

soustrait second à premier

premier - second

*

fait le produit de deux valeurs

premier * second

/

divise premier par second

premier / second

%

donne le reste de la division de premier par second

premier % second

^^

élève premier à la puissance second (multiplie premier par lui-même second fois)

premier ^^ second

La plupart de ces opérateurs ont leurs homologues qui ont un signe = collé à eux : +=, -=, *=, %=, ^^=. La différence avec ces opérateurs est qu'ils affectent le résultat à la variable située à gauche de l'opérateur :

variable += 10;

Cette expression ajoute la valeur de variable et 10 et enregistre le résultat dans variable. À la fin, la valeur de variable est augmentée de 10. C'est l'équivalent de l'expression :

variable = variable + 10;

Je souhaiterai aussi résumer ici trois concepts importants avant de les développer plus.

- Dépassement : toutes les valeurs ne peuvent pas entrer dans un type, dans certain cas il y a dépassement (ou débordement). Par exemple, une variable de type ubyte ne peut contenir que des valeurs entre 0 et 255 ; quand on lui affecte 260, la valeur déborde et la variable contient 4.

- Soupassement : de façon similaire, des valeurs ne peuvent pas être plus petites que la valeur minimum qu'un type peut contenir.

- Troncation : les types entiers ne peuvent pas avoir de valeurs contenant des parties fractionnaires. Par exemple, la valeur de l'expression entière 3/2 n'est pas 1,5 mais 1.

16-1 - Information supplémentaire

Nous rencontrons des opérations arithmétiques quotidiennement sans trop de surprises : si une baguette vaut 1 €, deux baguettes valent 2 € ; si quatre sandwichs valent 15 €, un sandwich vaut 3,75 €, etc.

Malheureusement, les choses ne sont pas aussi simples avec les opérations arithmétiques des ordinateurs. Si on ne comprend pas comment les valeurs sont stockées dans un ordinateur, on peut être surpris de voir que la dette d'une compagnie est réduite à 1,7 milliard de dollars quand elle emprunte 3 nouveaux milliards en plus de sa dette existante de 3 milliards de dollars !
Ou quand, alors qu'une boîte de glace satisfait quatre enfants, une opération arithmétique peut prétendre que deux boîtes de glace suffiraient à onze enfants !

Les programmeurs doivent comprendre comment les entiers sont stockés dans un ordinateur.

16-1-1 - Types entiers

Les types entiers sont les types qui ne peuvent garder que des valeurs entières comme -2, 0, 10, etc. Ces types ne peuvent pas avoir de parties fractionnaires comme dans le nombre 2,5. Les types entiers que nous avons vus dans le chapitre sur les types fondamentaux sont :

Type

Nombre de bits

Valeur initiale

byte

8

0

ubyte

8

0

short

16

0

ushort

16

0

Int

32

0

uint

32

0

long

64

0L

ulong

64

0L

Le u au début du type veut dire « unsigned » (non signé) et indique que ces types ne peuvent pas contenir de valeurs inférieures à zéro.

16-1-2 - Nombre de bits d'un type

Dans les systèmes informatiques actuels, la plus petite unité d'information est appelée un bit. Au niveau physique, un bit est représenté par des signaux électriques à certains endroits des circuits d'un ordinateur. Un bit peut être dans un ou deux états qui correspondent à la présence et à l'absence de signaux électriques au point qui définit un bit particulier. Ces deux états sont définis arbitrairement aux valeurs 0 et 1. De ce fait, un bit peut avoir une de ces deux valeurs.

Comme il n'existe pas trente-six choses pouvant être représentées par deux états seulement, le type bit n'est pas très utile. Il ne peut l'être que pour des concepts ayant deux états comme « pile ou face » ou une lumière qui peut être allumée ou éteinte.

Si on considère deux bits à la fois, le nombre total d'informations qui peuvent être représentées est multiplié. Comme chaque bit vaut 0 ou 1, il y a un nombre total de quatre états possibles. En supposant que le chiffre de gauche représente le premier bit et que le chiffre de droite représente le deuxième bit, ces états sont 00, 01, 10 et 11. Ajoutons encore un bit pour mieux voir cet effet : trois bits peuvent être dans huit états différents : 000, 001, 010, 011, 100, 101, 110, 111. Comme on peut le voir, chaque bit ajouté double le nombre total d'états qui peuvent être représentés.

Les valeurs auxquelles ces huit états correspondent sont définies par convention. Le tableau suivant montre ces valeurs pour les représentations signées et non signées de 3 bits :

Etat binaire

Valeur non signée

Valeur signée

000

0

0

001

1

1

010

2

2

011

3

3

100

4

-4

101

5

-3

110

6

-2

111

7

-1

On peut écrire le tableau suivant en ajoutant plus de bits :

Bits

nombre de valeurs distinctes

Type D

Valeur minimale

Valeur maximale

1

2

2

4

3

8

4

16

5

32

6

64

7

128

8

256

byte

-128

127

ubyte

0

255

…

16

65 536

short

-32768

32767

ushort

0

65535

…

32

4 294 967 296

int

-2147483648

2147483647

uint

0

4294967295

…

64

18 446 744 073 709 551 616

long

-9223372036854775808

9223372036854775807

ulong

0

18446744073709551615

…

J'ai sauté beaucoup de lignes dans le tableau, et indiqué les versions signées et non signées des type D qui ont le même nombre de bits sur la même ligne (par exemple int et uint sont tous les deux sur la ligne des 32 bits).

16-1-3 - Choisir un type

Comme un type 3 bits ne peut avoir que 8 valeurs distinctes, il ne peut représenter que des variables comme une face d'un dé ou le nombre de jours dans une semaine. (Ce n'est qu'un exemple, il n'y a pas de type 3 bits en D).

D'un autre côté, même si uint est un type très grand, il ne peut pas représenter un numéro qui identifierait chaque personne vivante puisque sa valeur maximum est inférieure à la population mondiale de sept milliards d'habitants. long et ulong sont eux plus que suffisants pour représenter beaucoup de quantités.

En règle générale, tant qu'il n'y a aucune raison spécifique de ne pas le faire, vous pouvez utiliser int pour les valeurs entières.

16-1-4 - Dépassement

Le fait que les types ne peuvent avoir qu'un nombre limité de valeurs peut causer des effets inattendus. Par exemple, même si ajouter deux uint valant 3 milliards chacun devrait donner 6 milliards, comme la somme est plus grande que la valeur maximale qu'une variable uint peut stocker (environ 4 milliards), cette somme déborde. Sans aucun avertissement, seule la différence entre 6 et 4 milliards est stockée (un peu plus précisément, 6 moins 4,3 milliards).

16-1-5 - Troncation

Comme les entiers ne peuvent pas avoir de valeurs contenant des parties fractionnaires, ils perdent la partie située après la virgule. Par exemple, en supposant qu'une boîte de glace suffit à quatre enfants, même si onze enfants avaient besoin de 2,75 boîtes, 2,75 ne peut être stocké que comme 2 dans un type entier.

Nous verrons des techniques basiques pour aider à réduire les effets de débordements, soupassements et troncations plus loin dans le chapitre.

16-1-6 - .min et .max

Nous utiliserons les propriétés min et max plus loin, celles que nous avons vues dans le chapitre sur les Types Fondamentaux . Ces propriétés donnent les valeurs minimales et maximales qu'un type entier peut avoir.

16-1-7 - Incrémentation: ++

Cet opérateur est utilisé avec une seule variable (plus généralement, avec une seule expression) et est écrit avant le nom de la variable.

Il incrémente la valeur de cette variable de 1 :

import std.stdio;

void main()
{
int nombre = 10;
++nombre;
writeln("Nouvelle valeur : ", nombre);
}

Sortie :

Nouvelle valeur : 11

L'opérateur d'incrémentation est équivalent à l'utilisation de l'opérateur ajouter-et-affecter avec la valeur 1 :

nombre += 1;      // pareil que ++nombre

Si le résultat de l'opérateur d'incrémentation est plus grand que la valeur maximum du type de la variable, le résultat déborde et devient la valeur minimale. On peut voir cet effet en incrémentant une variable qui a la valeur int.max :

import std.stdio;

void main()
{
writeln("valeur int maximum   : ", int.min);
writeln("valeur int maximum   : ", int.max);

int nombre = int.max;
writeln("avant l'incrémentation : ", nombre);
++nombre;
writeln("après l'incrémentation : ", nombre);
}

La valeur devient int.min après l'incrémentation.

Sortie :

valeur int minimum   : -2147483648
valeur int maximum   : 2147483647
avant l'incrémentation : 2147483647
après l'incrémentation : -2147483648

C'est une observation très importante parce que la valeur passe du maximum au minimum après une incrémentation sans aucun avertissement ! Ce phénomène est appelé débordement (overflow). Nous verrons des effets similaires avec d'autres opérations.

16-1-8 - Décrémentation : --

Cet opérateur est similaire à l'opérateur d'incrémentation ; la différence est que la valeur est diminuée de 1 :

--nombre;   // La valeur est diminuée de 1

L'opération de décrémentation est équivalente à l'utilisation de l'opérateur soustraire-puis-affecter avec la valeur 1 :

nombre -= 1;      // pareil que --nombre

De manière similaire à l'opérateur ++, si la valeur de la variable est la valeur minimum, elle devient la valeur maximum. Ce phénomène est appelé soupassement (underflow).

16-1-9 - Addition : +

Cet opérateur est utilisé avec deux expressions et ajoute leur valeur :

import std.stdio;

void main()
{
int premier = 12;
int second = 100;

writeln("Résultat : ", premier + second);
writeln("Avec une expression constante : ", 1000 + second);
}

Sortie :

Résultat : 112
Avec une expression constante : 1100

De même, si le résultat est plus grand que la somme des deux expressions, il déborde et devient inférieur à la somme des deux expressions :

import std.stdio;

void main()
{
// 3 milliards chacun
uint premier = 3000000000;
uint second = 3000000000;

writeln("valeur minimum de uint : ", uint.max);
writeln("               premier : ", premier);
writeln("                second : ", second);
writeln("                 somme : ", premier + second);
writeln("DÉBORDEMENT ! Le résultat n'est pas 6 milliards !");
}

Sortie :

valeur minimum de uint : 4294967295
premier : 3000000000
second : 3000000000
somme : 1705032704
DÉBORDEMENT ! Le résultat n'est pas 6 milliards !

16-1-10 - Soustraction : -

Cet opérateur est utilisé avec deux expressions et donne la différence entre la première et la seconde :

import std.stdio;

void main()
{
int nombre_1 = 10;
int nombre_2 = 20;

writeln(nombre_1 - nombre_2);
writeln(nombre_2 - nombre_1);
}

Sortie :

-10
10

Si le résultat d'une soustraction est inférieur à zéro, ce qu'on obtient en stockant ce résultat dans un type non signé est encore une fois surprenant. Réécrivons le programme en utilisant le type uint :

import std.stdio;

void main()
{
uint nombre_1 = 10;
uint nombre_2 = 20;

writeln("PROBLÈME ! uint ne peut pas stocker de valeurs négatives :");
writeln(nombre_1 - nombre_2);
writeln(nombre_2 - nombre_1);
}

Sortie :

PROBLÈME ! uint ne peut pas stocker de valeur négatives :
4294967286
10

On peut recommander d'utiliser des types signés pour représenter des choses qui pourraient être soustraites. Tant qu'il n'y a pas de raison spécifique de ne pas le faire, vous pouvez choisir int.

16-1-11 - Multiplication : *

Cet opérateur multiplie les valeurs de deux expressions :

import std.stdio;

void main()
{
uint nombre_1 = 6;
uint nombre_2 = 7;

writeln(nombre_1 * nombre_2);
}

Sortie :

42

Le résultat peut être, encore une fois, sujet au débordement.

16-1-12 - Division : /

Cet opérateur divise la première expression par la seconde. Comme les types entiers ne peuvent pas avoir de partie fractionnaire, la partie fractionnaire est abandonnée. Cet effet est appelé troncation. De ce fait, le programme suivant affiche 3 et non 3.5.

import std.stdio;

void main()
{
writeln(7 / 2);
}

Sortie :

3

Pour les calculs où les parties fractionnaires importent, les types à virgule flottante doivent être utilisés à la place des entiers. Nous verrons les types flottants dans le chapitre suivant.

16-1-13 - Modulo : %

Cet opérateur divise la première expression par la seconde et donne le reste de cette division :

import std.stdio;

void main()
{
writeln(10 % 6);
}

Sortie :

4

Une utilisation fréquente de cet opérateur est de déterminer si un nombre est pair ou impair. Comme le reste de la division d'un nombre par 2 est toujours 0 et le reste de la division d'un nombre impair par 2 est toujours 1, comparer la valeur à 0 est suffisant pour déterminer la parité d'un nombre :

if ((nombre % 2) == 0) {
writeln("nombre pair");

} else {
writeln("nombre impair");
}

16-1-14 - Puissance : ^^

Cet opérateur élève la première expression à la puissance de la seconde. Par exemple, pour élever 3 à la puissance 4 :

import std.stdio;

void main()
{
writeln(3 ^^ 4); // équivalent à 3*3*3*3
}

Sortie :

81

16-1-15 - Opérations arithmétiques avec affectation

Tous les opérateurs qui prennent deux expressions ont un équivalent d'affectation. Ces opérateurs affectent le résultat à l'expression qui est à sa gauche :

import std.stdio;

void main()
{
int nombre = 10;

nombre += 20;   // équivaut à nombre = nombre + 20 ; maintenant 30
nombre -= 5;    // équivaut à nombre = nombre - 5 ;  maintenant 25
nombre *= 2;    // équivaut à nombre = nombre * 2 ;  maintenant 50
nombre /= 3;    // équivaut à nombre = nombre / 3 ;  maintenant 16
nombre %= 7;    // équivaut à nombre = nombre % 7 ;  maintenant  2
nombre ^^= 6;   // équivaut à nombre = nombre ^^ 6 ; maintenant 64

writeln(nombre);
}

Sortie :

64

16-1-16 - Négation : -

Cet opérateur change le signe de la valeur de l'expression (négatif devient positif et inversement) :

import std.stdio;

void main()
{
int nombre_1 = 1;
int nombre_2 = -2;

writeln(-nombre_1);
writeln(-nombre_2);
}

Sortie :

-1
2

Le type du résultat de cette opération est le même que le type de l'expression. Comme les types non signés ne peuvent pas stocker de valeurs négatives, utiliser cet opérateur sur des types non signés peut mener à des résultats surprenants :

uint nombre = 1;
writeln("négation : ", -nombre);

le type de -nombre est uint également, et celui-ci ne peut pas représenter des valeurs négatives :

Sortie :

négation: 4294967295

16-1-17 - Signe plus : +

Cet opérateur n'a pas d'effet et n'existe que pour avoir une certaine symétrie avec l'opérateur de négation. Les valeurs positives restent positives et les valeurs négatives restent négatives :

import std.stdio;

void main()
{
int nombre_1 = 1;
int nombre_2 = -2;

writeln(+nombre_1);
writeln(+nombre_2);
}

Sortie :

1
-2

16-1-18 - Postincrémentation : ++

Sauf s'il y a une très bonne raison de le faire, utilisez toujours l'opérateur d'incrémentation usuel (qui est également appelé opérateur de préincrémentation).

Contrairement à l'opérateur de préincrémentation, il est placé après l'expression. Il incrémente également la valeur de l'expression de 1. La différence est que l'opérateur de postincrémentation retourne l'ancienne valeur de l'expression, et non la nouvelle. Pour voir cette différence, comparons-la avec l'opérateur de préincrémentation :

import std.stdio;

void main()
{
int incrémenté_normalement = 1;
writeln(++incrémenté_normalement);  // affiche 2
writeln(incrémenté_normalement);    // affiche 2

int post_incrémenté = 1;

// incrémenté, mais l'ancienne valeur est utilisée :
writeln(post_incrémenté++);         // affiche 1
writeln(post_incrémenté);           // affiche 2
}

Sortie :

2
2
1
2

L'instruction writeln(post_incrémenté++); ci-dessus est équivalente à ce code :

int ancienne_valeur = post_incrémenté;
++post_incrémenté;
writeln(ancienne_valeur);   // affiche 1

16-1-19 - Postdécrémentation : --

Sauf s'il y a une bonne raison, utilisez toujours l'opérateur de décrémentation usuel (aussi appelé opérateur de prédécrémentation).

Se comporte de la même manière que l'opérateur de postincrémentation, mais décrémente.

16-1-20 - Priorité opératoire

Les opérateurs que nous avons vus jusqu'alors ont toujours été utilisés tout seuls, avec seulement une ou deux expressions. Cependant, comme les expressions logiques, il est fréquent de combiner ces opérateurs pour former des expressions arithmétiques plus complexes :

int valeur     = 77;
int résultat = (((valeur + 8) * 3) / (valeur - 1)) % 5;

Comme pour les opérateurs logiques, les opérateurs arithmétiques obéissent aussi à des règles de priorité. Par exemple, l'opérateur * est prioritaire sur l'opérateur +. Pour cette raison, quand il n'y a pas de parenthèses (par exemple dans l'expression valeur + 8 * 3), l'opérateur * est évalué avant l'opérateur +. De ce fait, cette expression vaut valeur + 24, ce qui est différent de (valeur + 8) * 3.

Utiliser des parenthèses sert à la fois à assurer des résultats corrects et à communiquer le but d'un code aux programmeurs qui pourraient à l'avenir travailler sur le code.

16-1-21 - Solution potentielle contre les débordements

Si le résultat d'une opération ne peut pas être contenu dans le type du résultat, alors il n'y a rien qui puisse être fait. Parfois, alors que le résultat final pourrait être contenu dans un certain type, les résultats intermédiaires pourraient déborder et mener à des résultats incorrects.

Par exemple, supposons que nous ayons besoin de planter un pommier par 1000 m² d'une aire de 40 par 60 km. De combien de pommiers avons-nous besoin ?

Quand on résout ce problème sur papier, on voit que le résultat est , ce qui donne 2,4 millions de pommiers. Écrivons un programme qui effectue ce calcul :

import std.stdio;

void main()
{
int largeur   = 40000;
int longueur = 60000;
int airParArbre = 1000;

int arbresNecessaires = largeur * longueur / airParArbre;

writeln("Nombre d'arbres nécessaires : ", arbresNecessaires);
}

Sortie :

Nombre d'arbres nécessaires : -1894967

Non seulement ce n'est pas proche du résultat, mais on obtient même un nombre négatif ! Dans ce cas précis, le calcul intermédiaire largeur * longueur déborde et le calcul suivant / airParArbre donne un résultat incorrect.

Une façon d'éviter le débordement dans cet exemple est de changer l'ordre des opérations :

int arbresNecessaires = largeur / airParArbre * longueur ;

Le résultat est maintenant correct :

Nombre d'arbres nécessaires : 2400000

La raison pour laquelle cette méthode fonctionne est le fait que chaque étape du calcul tient dans le type int.

Notez que ce n'est pas une solution satisfaisante parce que cette fois la valeur intermédiaire est sujette à troncation, qui pourrait affecter le résultat de façon significative dans certains ordres de calcul.

Une autre solution serait d'utiliser un type flottant à la place d'un type entier : float, double ou real.

16-1-22 - Solution potentielle contre la troncation

Changer l'ordre des opérateurs peut aussi être une solution contre la troncation. Un exemple intéressant peut être observé en divisant et en multipliant une valeur avec le même nombre. On s'attendrait à ce que 10/9*9 donne 10, mais on obtient 9 :

import std.stdio;

void main()
{
writeln(10 / 9 * 9);
}

Sortie :

9

Le résultat est correct quand la troncation est évitée en changeant l'ordre des opérations :

writeln(10 * 9 / 9);

Sortie :

10

Encore une fois, ce n'est pas une solution satisfaisante : cette fois, le calcul intermédiaire pourrait être sujet au débordement. Utiliser un type flottant peut être une autre solution à la troncation dans certains calculs.

16-1-23 - Exercices

- Écrire un programme qui demande deux entiers à l'utilisateur, affiche le quotient entier d'une division du premier et du second, et affiche aussi le reste. Par exemple, quand 7 et 3 sont entrés, le programme affiche l'équation suivante :

7 = 3 * 2 + 1

- Modifier le programme pour donner une sortie plus courte quand le reste est 0. Par exemple, quand 10 et 5 sont entrés, il ne devrait pas afficher « 10 = 5 * 2 + 0 » mais ceci :

10 = 5 * 2

- Écrire une calculette simple qui supporte les quatre opérations arithmétiques basiques. L'utilisateur choisit l'opération à effectuer à partir d'un menu et le programme effectue l'opération sur les deux valeurs qui ont été entrées. Vous pouvez ignorer les débordements et les troncations dans ce programme.

- Écrire un programme qui affiche les nombres de 1 à 10, chacun sur une ligne propre, à l'exception de la valeur 7. N'utilisez pas des lignes répétées comme cela :

import std.stdio;

void main()
{
// Ne faîtes pas cela !
writeln(1);
writeln(2);
writeln(3);
writeln(4);
writeln(5);
writeln(6);
writeln(8);
writeln(9);
writeln(10);
}

- Utilisez plutôt une variable dont la valeur est incrémentée dans une boucle. Vous pourriez avoir besoin de l'opérateur != ici.

Solutions.

17 - Types à virgule flottante

Dans le chapitre précédent, nous avons vu que malgré leur facilité d'utilisation, les opérations arithmétiques sur les entiers sont sujettes à des erreurs de programmation à cause des débordements, des soupassements et des troncations. Nous avons aussi vu que les entiers ne peuvent pas représenter des valeurs avec des parties fractionnaires comme 1,25.

Les types flottants sont spécialement conçus pour prendre en charge les parties fractionnaires. La « virgule » dans leur nom vient de la virgule qui sépare partie entière et partie fractionnaire et « flottant » fait référence à la manière avec laquelle ces types sont implémentés : la virgule flotte à droite ou à gauche de façon appropriée. (Ce détail n'est pas important pour l'utilisation de ces types.)

De même que pour les entiers, nous devons voir des détails importants dans ce chapitre. Avant tout, voici une liste de quelques aspects intéressants des types flottants :

- ajouter 0,001 un millier de fois n'est pas pareil qu'ajouter 1 ;

- utiliser les opérateurs == et != avec des types flottants est une erreur dans la plupart des cas ;

- la valeur initiale d'un type flottant est .nan, et non 0. .nan ne peut pas être utilisé dans des expressions. Quand .nan est utilisé dans des opérations de comparaison, il n'est ni plus petit, ni plus grand que n'importe quelle valeur ;

- la valeur de débordement est .infinity et la valeur de soupassement est .infinity négatif.

Même si les types flottants sont plus utiles dans certains cas, ils ont des singularités que tous les programmeurs doivent connaître. Par rapport aux entiers, ils sont très bons pour éviter les troncations parce que leur but principal est de prendre en charge les valeurs fractionnaires. Comme n'importe quel autre type, basés sur un certain nombre de bits, ils sont également sujets aux dépassements et soupassements, mais comparés aux entiers, l'ensemble des valeurs qu'ils prennent en charge est vaste.

De plus, au lieu d'être silencieux dans le cas de dépassements et de soupassements, ils prennent les valeurs spéciales d'infini positif ou négatif.

Pour rappel, voici les types flottants :

Type

Nombre de bits

Valeur initiale

float

32

float.nan

double

64

double.nan

real

Au moins 64, peut-être plus (par exemple 80, selon ce que le matériel prend en charge)

real.nan

17-1 - Attributs des types flottants

Les types flottants ont plus d'attributs que les autres types :

- .stringof est le nom du type ;

- .sizeof est la longueur du type en octets (pour avoir le nombre de bits, il faut multiplier cette valeur par 8) ;

- .max est la valeur maximale qu'un type peut stocker ; l'opposé de cette valeur est la valeur minimale que le type peut avoir ;

- .min_normal est la plus petite valeur normalisée que ce type peut avoir (le type peut stocker des valeurs plus petites que .min_normal, mais la précision de ces valeurs est moins grande que la précision normale du type) ;

- .dig (pour digits (chiffres)) indique le nombre de chiffres décimaux significatifs pour la précision du type ;

- .infinity est la valeur spéciale utilisée pour indiquer un dépassement ou un soupassement.

Notez que la valeur minimale d'un type flottant n'est pas .min, mais l'opposé de .max. Par exemple, la valeur minimale de double est -double.max.

D'autres attributs des types flottants sont utilisés moins souvent. Vous pouvez les voir tous sur cette page (en anglais) : Properties for Floating Point Types.

Les propriétés des types flottants et leurs relations peuvent être vues sur un axe comme celui-ci :

Les parties avec des tirets sont à l'échelle : le nombre de valeurs qui peuvent être représentées entre min_normal et 1 est égal au nombre de valeurs qui peuvent être représentées entre 1 et max. Cela veut dire que la précision de la partie fractionnaire des valeurs qui sont entre min_normal et 1 est très grande (c'est également vrai pour le côté négatif).

17-2 - .nan

Nous avons déjà vu que .nan est la valeur par défaut des variables flottantes. .nan peut apparaître comme résultat d'une expression flottant n'ayant pas de sens. Par exemple, les expressions flottantes du programme suivant produisent toutes double.nan :

import std.stdio;

void main()
{
double zero = 0;
double infini = double.infinity;

writeln("n'importe quelle expression avec nan : ", double.nan + 1);
writeln("zero / zero                          : ", zero / zero);
writeln("zero * infini                      : ", zero * infini);
writeln("infini / infini                  : ", infini / infini);
writeln("infini - infini                  : ", infini - infini);
}

17-3 - Écrire des valeurs flottantes

Les valeurs flottantes peuvent simplement être écrites sans point décimal comme 123, ou avec un point décimal comme 12.3 (NDT Pour écrire un nombre à virgule, on n'utilise pas la virgule, mais le point).

Les valeurs flottantes peuvent aussi être écrites avec la syntaxe flottante: 1.23e+4. La partie e+ dans cette syntaxe peut être lue comme « fois 10 puissance ». On lit 1.23e+4 comme ceci « 1,23 fois 10 puissance 4 », qui est pareil que « 1,23 fois 10^4 », qui est en fait pareil que 1,23×10 000, ce qui vaut 12 300.

Si la valeur après e est négative, comme pour 5.67e-3, alors on lit « divisé par 10 puissance ». Ainsi, pour cet exemple, on lit « 5,67 divisé par 10^3 », ce qui est pareil que , ce qui vaut 0,005 67.

Les valeurs qui sont affichées par le programme suivant sont toutes dans le format flottant. Il affiche les propriétés des trois types :

import std.stdio;

void main()
{
writeln("Type                       : ", float.stringof);
writeln("Précision                  : ", float.dig);
writeln("Valeur minimale normalisée : ", float.min_normal);
writeln("Valeur maximale            : ", float.max);
writeln("Valeur minimale            : ", -float.max);
writeln();
writeln("Type                       : ", double.stringof);
writeln("Précision                  : ", double.dig);
writeln("Valeur minimale normalisée : ", double.min_normal);
writeln("Valeur maximale            : ", double.max);
writeln("Valeur minimale            : ", -double.max);
writeln();
writeln("Type                       : ", real.stringof);
writeln("Précision                  : ", real.dig);
writeln("Valeur minimale normalisée : ", real.min_normal);
writeln("Valeur maximale            : ", real.max);
writeln("Valeur minimale            : ", -real.max);
}

La sortie du programme est celle-ci dans mon environnement. Comme real dépend du matériel (NDT Et du système qui tourne dessus), vous pouvez obtenir autre chose.

Sortie :

Type                       : float
Précision                  : 6
Valeur minimale normalisée : 1.17549e-38
Valeur maximale            : 3.40282e+38
Valeur minimale            : -3.40282e+38

Type                       : double
Précision                  : 15
Valeur minimale normalisée : 2.22507e-308
Valeur maximale            : 1.79769e+308
Valeur minimale            : -1.79769e+308

Type                       : real
Précision                  : 18
Valeur minimale normalisée : 3.3621e-4932
Valeur maximale            : 1.18973e+4932
Valeur minimale            : -1.18973e+4932

17-4 - Observations

Comme on l'a vu dans le chapitre précédent ; la valeur maximum de ulong a 20 chiffres décimaux : 18 446 744 073 709 551 616. Cette valeur semble petite même comparée au plus petit des types flottants : float peut stocker des valeurs de l'ordre de 1038, par exemple 340 282 000 000 000 000 000 000 000 000 000 000 000.

La valeur maximale de real est de l'ordre de 10^4932, une valeur de plus de 4900 chiffres décimaux !

Regardons la valeur minimale que double peut représenter avec une précision de 15 chiffres décimaux : 0.000…(il y a 300 zéros additionnels ici)…0000222507385850720.

17-5 - Les débordements et les soupassements

Malgré leur capacité de représenter des valeurs très grandes, les types flottants peuvent aussi déborder ou soupasser. Les types flottants sont plus sûrs que les types entiers dans ce domaine parce que les dépassements et les soupassements ne sont pas ignorés. Les valeurs qui débordent deviennent .infinity et les valeurs qui soupassent deviennent -.infinity. Pour l'observer, augmentons la valeur de .max de 10 %. Comme la valeur est déjà le maximum, ça débordera :

import std.stdio;

void main()
{
real valeur = real.max;

writeln("Avant        : ", valeur);

// Ajouter 10 % est équivalent à multiplier par 1.1
valeur *= 1.1;
writeln("10% ajoutés  : ", valeur);

// Essayons de la diminuer en divisant par 2 :
valeur /= 2;
writeln("Divisé par 2 : ", valeur);
}

Une fois que la valeur déborde et devient real.infinity, elle y reste même après avoir été divisée par 2.

Sortie :

Avant        : 1.18973e+4932
10% ajoutés  : inf
Divisé par 2 : inf

17-6 - Précision

La précision est une idée omniprésente dans la vie de tous les jours, sans qu'on y prête toujours attention. C'est le nombre de chiffres qui sont utilisés pour écrire une valeur. Par exemple, quand on dit que le tiers de 100 est 33, la précision est de 2 parce que 33 a deux chiffres. Quand la valeur est notée plus précisément comme 33.33, la précision est alors de quatre chiffres.

Le nombre de bits qu'a chaque type flottant n'affecte pas seulement sa valeur maximale, mais aussi sa précision. Plus il y a de bits, plus la précision est grande.

17-7 - Il n'y a pas de troncation lors d'une division

Comme nous l'avons vu dans le chapitre précédent, les divisions entières ne conservent pas la partie fractionnaire du résultat :

int premier = 3;
int second = 2;
writeln(premier / second);

Sortie :

1

Les types flottants n'ont pas ce problème de troncation ; ils sont spécialement conçus pour préserver les parties fractionnaires :

double premier = 3;
double second = 2;
writeln(premier / second);

Sortie :

30/12/99

La précision de la partie fractionnaire dépend de la précision du type : real a la plus grande précision et float la plus petite.

17-8 - Quel type utiliser

Sauf s'il y a une raison spécifique de ne pas le faire, vous pouvez utiliser double pour les valeurs flottantes. float a une précision faible, mais parce qu'il est plus petit que les autres types, il peut être utile quand l'espace de stockage est limité.

D'un autre côté, comme la précision de real est plus grande que double sur le même matériel, il sera préférable pour des calculs de haute précision.

17-9 - On ne peut pas représenter toutes les valeurs

On ne peut pas représenter certaines valeurs de la vie de tous les jours. Dans le système décimal que nous utilisons quotidiennement, les chiffres avant la virgule représentent les unités, les dizaines, les centaines, etc., et les chiffres après la virgule représentent les dixièmes, les centièmes, les millièmes, etc.

Si une valeur est une combinaison exacte de ces valeurs, elle peut être représentée précisément. Par exemple, du fait que 0.23 consiste en 2 dixièmes et 3 centièmes, cette valeur est représentée précisément. D'un autre côté, la valeur de ne peut pas être représenté précisément dans le système décimal, parce quel que soit le nombre de chiffres qui sont écrits, ce n'est jamais suffisant : 0,33333…

C'est très similaire pour les types flottants. Parce que ces types sont basés sur un certain nombre de bits, ils ne peuvent pas représenter toutes les valeurs.

La différence avec le système binaire que l'ordinateur utilise est que les chiffres avant la virgule sont les unités, les deuzaines, les quatraines, etc. et les chiffres après la virgule sont les moitiés, les quarts, les huitièmes, etc. Seules les valeurs qui sont des combinaisons exactes de ces chiffres peuvent être représentées précisément.

Par exemple, on ne peut représenter directement dans le système binaire utilisé par les ordinateurs une valeur telle que 0,1 (comme dans 10 centimes). Alors que cette valeur peut être représentée précisément dans le système décimal, sa représentation binaire ne se finit jamais et répète continuellement 4 chiffres : 0.0001100110011… (en utilisant l'écriture binaire, pas décimale). C'est toujours imprécis à un certain niveau, selon la précision du type flottant utilisé.

Le programme suivant montre ce problème. La valeur d'une variable est incrémentée de 0,001 un millier de fois dans une boucle. De façon étonnante, le résultat n'est pas 1 :

import std.stdio;

void main()
{
float résultat = 0;

// On s'attendrait à ce que résultat vaille 1 après avoir bouclé 1000 fois :
while (résultat < 1) {
résultat += 0.001;
}

// Vérifions
if (résultat == 1) {
writeln("Comme attendu: 1");

} else {
writeln("DIFFERENT: ", résultat);
}
}

Sortie :

DIFFERENT: 1.00099

Parce que 0.001 ne peut pas être représenté précisément, cette imprécision affecte le résultat de multiples fois. La sortie suggère que la boucle a été répétée 1001 fois.

17-10 - Comparer des valeurs flottantes

Nous avons vu les comparaisons suivantes pour les entiers : égal à (==), n'est pas égal à (!=), plus grand que (>), inférieur ou égal à (<=) et supérieur ou égal à (>=). Les types flottants ont beaucoup plus d'opérateurs de comparaison.

Comme la valeur spéciale .nan représente des valeurs flottantes invalides, il ne peut être comparé avec aucune autre valeur. Par exemple, il n'y a pas de sens à demander lequel de .nan ou de 1 est plus grand que l'autre.

Pour cette raison, les valeurs flottantes ne sont pas toutes ordonnées. Si deux valeurs sont non ordonnées, alors au moins une des valeurs est .nan

Le tableau suivant montre tous les opérateurs de comparaison sur les flottants. Tous les opérateurs sont binaires (ce qui veut dire qu'ils prennent deux opérandes) et sont utilisés comme dans gauche == droite). Les colonnes qui contiennent false et true sont les résultats des opérations de comparaison.

La dernière colonne indique si l'opération fait encore sens lorsque l'un des opérandes est .nan. Par exemple, même si le résultat de 1.2 < real.nan est false, ce résultat n'a pas de sens parce que l'un des opérandes est real.nan. Le résultat de la comparaison inverse real.nan < 1.2 est également fausse. L'abréviation « cg » veut dire « côté gauche », désignant l'expression qui est à gauche de chaque opérateur.

Opérateur

Signification

Si cg est plus grand

si cg est plus petit

S'ils sont égaux

Si l'un des deux est .nan

Sensé avec .nan

==

est égal à

false

false

true

false

oui

!=

n'est pas égal à

true

true

false

true

oui

>

est plus grand que

true

false

false

false

non

=

est supérieur ou égal à

true

false

true

false

non

<

est plut petit que

false

true

false

false

non

<=

est inférieur ou égal à

false

true

true

false

non

!<>=

n'est ni supérieur, ni inférieur, ni égal à

false

false

false

true

oui

<>

plus grand ou plus petit que

true

true

false

false

non

<>=

est inférieur, supérieur, ou égal à

true

true

true

false

non

!<=

n'est ni inférieur, ni égal à

true

false

false

true

oui

!<

n'est pas inférieur à

true

false

true

true

oui

!>=

n'est pas supérieur ou égal à

false

true

false

true

oui

!>

n'est pas plus grand que

false

true

true

true

oui

!<>

n'est ni inférieur, ni supérieur à

false

false

true

true

oui

Notez qu'il est sensé d'utiliser .nan avec n'importe quel opérateur qui contient « n'est pas » dans sa signification et le résultat est toujours vrai. .nan n'étant pas une valeur valide, le résultat de la plupart des comparaisons n'a pas de sens.

17-11 - isnan() pour tester .nan

Parce que le résultat est toujours false pour l'égalité avec .nan selon le tableau précédent, il n'est pas possible d'utiliser l'opérateur == pour déterminer si la valeur d'une variable flottante est .nan :

if (variable == double.nan) {    // ← FAUX
// ...
}

Pour cette raison, la fonction isnan() du module std.math doit être utilisée :

import std.math;
// ...

if (isnan(variable)) {      // ← correct
// ...
}

De même, pour déterminer si une valeur n'est pas .nan, !isnan() doit être utilisé parce que l'opérateur != donnerait toujours true.

17-12 - Exercices

1. Modifiez la calculatrice du chapitre précédent pour prendre en charge les valeurs flottantes. La nouvelle calculatrice doit fonctionner plus précisément avec cette modification. Quand vous essayerez la calculatrice, vous pourrez entrer des valeurs flottantes dans des formats divers comme 1000, 1.23 et 1.23e4.

2. Écrivez un programme qui lit cinq valeurs flottantes depuis l'entrée. Le programme doit afficher le double de ces valeurs et ensuite le cinquième de ces valeurs. Cet exercice est une introduction à l'idée des tableaux du chapitre suivant. Si vous écrivez ce programme avec ce que vous avez vu jusqu'à maintenant, vous comprendrez les tableaux et vous vous les approprierez plus facilement.

Solutions.

18 - Tableaux

Nous avons défini cinq variables dans un des exercices du dernier chapitre et nous les avons utilisés dans certains calculs. Les définitions de ces variables étaient les suivantes :

double valeur_1;
double valeur_2;
double valeur_3;
double valeur_4;
double valeur_5;

Cette méthode de définir des variables individuellement n'est pas du tout efficace dans les cas où il y a besoin d'encore plus de variables. Imaginez qu'on ait besoin d'un millier de valeurs ; il est pratiquement impossible de définir 1000 variables de valeur_1 à valeur_1000.

Les tableaux sont utiles dans de tels cas : les tableaux permettent la définition de beaucoup de valeurs ensemble. Les tableaux sont aussi la structure de données la plus fréquente quand de multiples valeurs sont utilisées ensemble en tant que collection.

Ce chapitre couvre seulement quelques-unes des fonctionnalités des tableaux. Plus de fonctionnalités seront introduites dans un chapitre ultérieur.

18-1 - Définition

La définition de tableaux est vraiment similaire à la définition de variables. La seule différence est que le nombre de variables qui sont définies en même temps est indiqué entre crochets. On peut distinguer les deux définitions suivantes :

int uneSimpleVariable;
int[10] tableauDeDixVariables ;

La première ligne ci-dessus est la définition d'une simple variable, tout à fait comme les variables que nous avons définies jusqu'alors. La deuxième ligne est la définition d'un tableau consistant en dix variables.

De même, l'équivalent des cinq variables séparées de l'exercice peut être défini comme un tableau de cinq variables en utilisant la syntaxe suivante :

double[5] valeurs;

Cette définition peut être lue comme cinq valeurs double. Notez qu'un pluriel a été choisi pour le nom du tableau pour éviter de le confondre avec une variable simple.

En résumé, la définition d'un tableau se compose du type des variables, de leur nombre et du nom du tableau :

nom_du_type[nombre_de_variables] nom_du_tableau;

Le type des variables peut aussi être un type défini par le programmeur (nous verrons les types définis par le programmeur plus tard). Par exemple :

// Un tableau qui stocke l'information météorologique de
// toutes les villes. Ici, les valeurs booléennes veulent dire :
//   false : couvert
//   true  : ensoleillé
bool[nombreDeVilles] ConditionsMeteorologiques;

// Un tableau qui stocke le poids de cent boîtes
double[100] poidsDesBoites;

// Information à propos des étudiants d'une école
DonneeEtudiant[NombredDEtudiants] DonneesEtudiants;

18-2 - Éléments et conteneurs

Les structures de données qui rassemblent des éléments d'un certain type sont appelées conteneurs. Selon cette définition, les tableaux sont des conteneurs. Par exemple, un tableau qui stocke les températures de l'air des jours de juillet peut rassembler 31 variables double et forme un conteneur d'éléments de type double.

Les variables d'un conteneur sont appelées éléments. Le nombre d'éléments d'un tableau est appelé la longueur d'un tableau.

18-3 - Accéder aux éléments

Pour différencier les variables dans l'exercice du chapitre précédent, nous devions ajouter un tiret du bas et un nombre à leur nom (par exemple, valeur_1). Ce n'est ni possible, ni nécessaire quand les variables sont définies ensemble comme un seul tableau avec un seul nom. On accède aux éléments en indiquant leur numéro entre crochets :

valeurs[0]

Cette expression peut être lue comme « l'élément numéro 0 du tableau de nom « valeurs » ». En d'autres termes, au lieu de taper valeur_1, on tape valeurs[0] avec les tableaux. Il y a deux points importants qu'il vaut la peine de relever :

- les numéros commencent à 0 : même si les humains comptent à partir de 1, les indices de tableaux commencent à 0. Les valeurs que nous avons numérotées 1, 2, 3, 4 et 5 sont numérotées 0, 1, 2, 3 et 4 dans le tableau. Cette spécificité est une cause de nombreuses erreurs de programmation ;

- deux utilisations différentes des crochets : ne les confondez pas. Quand on définit un tableau, les crochets sont écrits après le type des éléments et indiquent le nombre d'éléments. Quand on accède aux éléments, les crochets sont écrits après le nom du tableau et indiquent le numéro de l'élément auquel on accède :

// Ceci est une définition. Elle définit un tableau qui consiste
// en 12 éléments. Ce tableau est utilisé pour stocker le nombre
// de jours de chaque mois.
int[12] joursDuMois;

// Ceci est un accès. On accède à l'élément qui
// correspond à décembre et on lui donne la valeur 31.
joursDuMois[11] = 31;

// Ceci est un autre accès. On accède à l'élément qui
// correspond à janvier, sa valeur est passé à writeln.
writeln("Janvier a ", joursDuMois[0], " jours.");

Rappel : les numéros des éléments de janvier et décembre sont respectivement 0 et 11, non 1 et 12.

18-4 - Indice

Le numéro d'un élément est appelé son indice.

Un indice n'a besoin d'être une valeur constante ; la valeur d'une variable peut aussi être utilisée comme un indice, ce qui rend les tableaux encore plus utiles. Par exemple, le mois est déterminé par la valeur de la variable IndiceMois ci-dessous :

writeln("Ce mois a ", joursDuMois[IndiceMois], " jours.");

Quand la valeur de IndiceMois est 2, l'expression ci-dessus affiche la valeur de joursDuMois[2], le nombre de jours du mois de mars.

Seuls les indices entre 0 et la longueur du tableau moins 1 sont valides. Par exemple, les indices valides d'un tableau à 3 éléments sont 0, 1 et 2. Accéder à un tableau avec un mauvais indice entraîne l'arrêt du programme avec une erreur.

Les tableaux sont des conteneurs dans lesquels les éléments sont placés les uns à côté des autres dans la mémoire de l'ordinateur. Par exemple, les éléments du tableau qui stocke le nombre de jours dans chaque mois peuvent être vus comme ceci :

L'élément d'indice 0 a la valeur 31 (nombre de jours en janvier) ; l'élément d'indice 1 a la valeur 28 (nombre de jours en février), etc.

18-5 - Tableaux de taille fixe vs tableaux dynamiques

Quand la taille du tableau est indiquée lorsque le programme est écrit, ce tableau est de taille fixe. Quand la longueur peut changer pendant l'exécution du programme, ce tableau est dynamique.

Les tableaux que nous avons définis au-dessus sont des tableaux à taille fixe parce que leur nombre d'éléments est indiqué lors de l'écriture du programme (5 et 12). Les longueurs de ces tableaux ne peuvent pas être changées pendant l'exécution du programme. Pour changer leur longueur, le code source doit être modifié et le programme doit être recompilé.

Définir un tableau dynamique est plus simple que définir un tableau à taille fixe ; il suffit d'omettre la taille et on obtient un tableau dynamique :

int[] tableauDynamique;

La taille d'un tel tableau peut augmenter ou diminuer pendant l'exécution du programme.

18-6 - .length pour récupérer ou changer la taille du tableau

Les tableaux ont également des attributs. Ici, nous ne verrons que .length, qui retourne le nombre d'éléments du tableau.

writeln("Le tableau a", tableau.length, " éléments.");

De plus, la taille des tableaux dynamiques peut être modifiée en affectant une valeur à cet attribut :

int[] array;         // initialement vide
tableau.length = 5;  // maintenant, il y a 5 éléments

Voyons maintenant comment on pourrait réécrire l'exercice avec les cinq valeurs en utilisant un tableau :

import std.stdio;

void main()
{
// Cette variable est utilisée en tant que compteur dans une boucle
int compteur;

// La définition d'un tableau à taille fixe de cinq
// éléments de type double
double[5] valeurs;

// récupérer les valeurs dans une boucle
while (compteur < valeurs.length) {
write("Valeur ", compteur + 1, " : ");
readf(" %s", &valeurs[compteur]);
++compteur;
}

writeln("Le double des valeurs :");
compteur = 0;
while (compteur < valeurs.length) {
writeln(valeurs[compteur] * 2);
++compteur;
}

// La boucle qui calcule le cinquième des valeurs
// serait écrite de façon similaire
}

Observations : la valeur de compteur détermine combien de fois les boucles sont répétées (itérées). Itérer la boucle tant que cette valeur est strictement inférieure à valeurs.length assure que la boucle est exécutée une fois par élément. Comme la valeur de cette variable est incrémentée à la fin de chaque itération, l'expression valeurs[compteur] fait référence à chaque élément du tableau un par un : valeurs[0], valeurs[1], etc.

Pour voir à quel point ce programme est meilleur que le précédent, imaginez que vous ayez besoin de 20 valeurs. Le programme ci-dessus nécessiterait une seule modification : remplacer 5 par 20 ; alors que le programme qui n'utilisait pas de tableau aurait eu besoin de 15 définitions de variables de plus, idem pour les lignes dans lesquelles elles sont utilisées.

18-7 - Initialiser les éléments

Comme toute variable en D, les éléments des tableaux sont automatiquement initialisés. La valeur initiale des éléments dépend du type des éléments : 0 pour int, double.nan pour double, etc.

Tous les éléments du tableau valeurs ci-dessus sont initialisés à double.nan :

double[5] valeurs;   // les éléments valent tous double.nan

Évidemment, les valeurs des éléments peuvent être modifiées plus tard dans le programme. On a déjà vu cela ci-dessus quand on a affecté une valeur à un élément de tableau :

joursDuMois[11] = 31;

Et aussi quand on récupérait une valeur depuis l'entrée :

readf(" %s", &valeurs[compteur]);

Parfois, les valeurs souhaitées des éléments sont connues au moment où le tableau est défini. Dans de tels cas, les valeurs initiales des éléments peuvent être indiquées du côté droit de l'opérateur d'affectation, entre crochets. Voyons cela dans un programme qui demande le numéro du mois à l'utilisateur et qui affiche le nombre de jours de ce mois :

import std.stdio;

void main()
{
int[12] joursDuMois =
[ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];

write("Veuillez saisir le numéro du mois : ");
int numeroMois;
readf(" %s", &numeroMois);

int indice = numeroMois - 1;
writeln("Le mois ", numeroMois, " a ",
joursDuMois[indice], " jours.");
}

Comme vous pouvez le voir, le tableau joursDuMois est défini et initialisé au même moment. Notez aussi que le numéro du mois, qui est entre 1 et 12, est converti en un indice valide entre 0 et 11. Toute valeur entrée en dehors de l'intervalle 1-12 entraînerait l'arrêt du programme avec une erreur.

Quand on initialise des tableaux, il est possible d'utiliser une seule valeur sur le côté droit. Dans ce cas, tous les éléments du tableau sont initialisés à cette valeur :

int[10] tousUn = 1;    // Tous les éléments valent 1

18-8 - Opérations basiques sur les tableaux

Les tableaux proposent des opérations pratiques qui s'appliquent à tous leurs éléments.

- Copier des tableaux à taille fixe : l'opérateur d'affectation copie tous les éléments du tableau de droite dans le tableau de gauche :

int[5] source = [ 10, 20, 30, 40, 50 ];
int[5] destination;

destination = source;

La signification de l'opération d'affectation est complètement différente pour les tableaux dynamiques. Nous verrons cela dans un chapitre ultérieur.

- Ajouter des éléments aux tableaux : l'opérateur ~= ajoute un nouvel élément ou un nouveau tableau à la fin du tableau dynamique :

int[] tableau;               // vide
tableau ~= 7;                // un élément
tableau ~= 360;              // deux éléments
tableau ~= [ 30, 40 ];       // 4 éléments

- Il n'est pas possible d'ajouter des éléments à un tableau à taille fixe :

int[10] tableau;
tableau ~= 7;                // ← ERREUR de compilation

- Combiner des tableaux : l'opérateur ~ crée un nouveau tableau en combinant deux tableaux. Son équivalent ~= combine deux tableaux et affecte le résultat au tableau de gauche :

import std.stdio;

void main()
{
int[10] premier = 1;
int[10] second = 2;
int[] resultat;

resultat = premier ~ second;
writeln(resultat.length);   // affiche 20

resultat ~= premier;
writeln(resultat.length);   // affiche 30
}

L'opérateur ~= ne peut pas être utilisé quand le tableau de gauche est de taille fixe :

int[20] resultat;
// ...
resultat ~= premier;          // ← ERREUR de compilation

Si le tableau de gauche n'a pas exactement la même taille que le tableau résultat, le programme se termine avec une erreur pendant l'affectation :

int[10] premier = 1;
int[10] second = 2;
int[21] resultat;

resultat = premier ~ second;

Sortie :

object.Error: Array lengths don't match for copy: 21 != 20

Si on traduit : « les longueurs ne correspondent pas pour la copie de tableau. »

18-9 - Trier les éléments

std.algorithm.sort trie les éléments de plages à accès directs. Dans le cas des entiers, les éléments sont triés du plus petit au plus grand. Pour utiliser sort(), il est nécessaire d'importer le module std.algorithm :

import std.stdio;
import std.algorithm;

void main()
{
int[] tableau = [ 4, 3, 1, 5, 2 ];
sort(tableau);
writeln(tableau);
}

La sortie :

[1, 2, 3, 4, 5]

18-9-1 - Inverser les éléments

std.algorithm.reverse inverse les éléments sur place (le premier élément devient le dernier, etc.) :

import std.stdio;
import std.algorithm;

void main()
{
int[] array = [ 4, 3, 1, 5, 2 ];
reverse(array);
writeln(array);
}

La sortie :

[2, 5, 1, 3, 4]

18-9-2 - Exercices

1. Écrivez un programme qui demande à l'utilisateur combien de valeurs vont être entrées et ensuite les lit toutes. Le programme doit trier ces éléments en utilisant .sort et .reverse.

2. Écrivez un programme qui lit des nombres depuis l'entrée, et affiche les nombres pairs et impairs séparément, mais dans l'ordre. La valeur spéciale -1 termine la liste des nombres et ne fait pas partie de la liste.
Par exemple, quand les nombres suivants sont entrés :

1 4 7 2 3 8 11 -1

3. Le programme affiche ceci :

1 3 7 11 2 4 8

Vous pouvez vouloir enregistrer les éléments dans des tableaux séparés. Vous pouvez déterminer si un nombre est pair ou impair en utilisant l'opérateur % (modulo).

4. Ce qui suit est un programme qui ne marche pas comme attendu. Le programme est écrit pour lire cinq nombres depuis l'entrée et placer les carrés de ces nombres dans un tableau. Le programme essaie ensuite d'afficher les carrés dans la sortie. Au lieu de ça, le programme se termine avec une erreur.
Corrigez les bogues de ce programme et faites-le marcher comme ce qui est attendu :

import std.stdio;

void main()
{
int[5] carres;

writeln("Veuillez entrer 5 nombres");

int i = 0;
while (i <= 5) {
int nombre;
write("Nombre ", i + 1, ": ");
readf(" %s", &nombre);

carres[i] = nombre * nombre;
++i;
}

writeln("=== Les carrés des nombres ===");
while (i <= carres.length)
{
write(carres[i], " ");
++i;
}

writeln();
}

Les solutions.

19 - Caractères

Les caractères sont des briques élémentaires des chaînes. Chaque symbole d'un système d'écriture est appelé caractère : les lettres des alphabets, les chiffres, les signes de ponctuation, l'espace, etc. Les briques élémentaires des caractères elles-mêmes sont appelées caractères également, ce qui entraîne certaines ambiguïtés.

Les tableaux de caractères constituent les chaînes. Nous avons vu les tableaux dans le chapitre précédent ; les chaînes de caractères seront vues dans deux chapitres.

Comme n'importe quelle autre donnée, les caractères sont aussi représentés par des valeurs entières composées de bits. Par exemple, la valeur entière d'un 'a' minuscule est 97 et la valeur entière du chiffre '1' est 49. Ces valeurs ont été choisies principalement par convention quand la table ASCII a été écrite.

Dans la plupart des langages, les caractères sont représentés par le type char, qui ne peut stocker que 256 valeurs distinctes. Si vous êtes déjà familier-ère avec le type char dans d'autres langages, vous savez déjà probablement que ce n'est pas assez pour supporter les glyphes de beaucoup de systèmes d'écriture. Avant de parler des trois types de caractères du D, faisons un peu d'histoire sur les caractères dans les systèmes d'information.

19-1 - Histoire

19-1-1 - Table ASCII

La table ASCII a été écrite à une époque où le matériel informatique était très limité comparé aux systèmes modernes. Basée sur 7 bits, la table ASCII peut représenter 128 codes différents. C'est suffisant pour représenter des caractères comme les versions minuscules et majuscules des 26 lettres de l'alphabet latin, les chiffres, les signes de ponctuation couramment utilisés, et quelques caractères de contrôle pour le terminal.

Par exemple, les codes ASCII des caractères de la chaîne "hello" sont les suivants : 104, 101, 108, 108, 111.

Chaque code ci-dessus représente une lettre de "hello". Par exemple, il y a deux codes 108 pour les deux lettres 'l'. (Note : l'ordre réel de ces caractères dépend de la plateforme et même du document duquel ces valeurs font partie. Les codes ci-dessus sont dans l'ordre dans lequel ils apparaissent dans la chaîne.)

Les codes de la table ASCII ont ensuite été écrits sur 8 bits pour donner la table ASCII Étendue. La table ASCII Étendue contient 256 valeurs distinctes.

19-1-2 - Pages de code IBM

IBM Corporation a défini un ensemble de tables, chacune d'elles affectant les codes de 128 à 256 de la table ASCII étendue à un ou plusieurs systèmes d'écriture. Ces tables de code ont permis de prendre en charge les lettres de beaucoup plus d'alphabets. Par exemple, les lettres spéciales de l'alphabet turc font partie de la page de code IBM 857.

Bien qu'elles soient plus utiles qu'ASCII, les pages de code ont des problèmes et des limitations : pour afficher le texte correctement, la page de code utilisée pour l'écrire doit être connue. En effet, le même code correspond à un autre caractère dans la plupart des autres tables. Par exemple, le code qui représente 'Ğ' dans la table 857 correspond à 'ª' dans la table 437.

Un autre problème est la limitation du nombre d'alphabets qui peuvent être utilisés au sein d'un même document. De plus, les alphabets qui ont plus de 128 caractères non ASCII ne peuvent pas être pris en charge par une table IBM.

19-1-3 - Pages de code ISO/IEC 8859

Ces pages de code sont le résultat d'efforts de standardisation internationaux. Elles sont similaires aux pages de code IBM dans leur manière d'associer des caractères aux codes. Par exemple, les lettres spécifiques à l'alphabet turc apparaissent dans la page de code 8859-9. Ces tables ont les mêmes problèmes et limitations que les tables d'IBM. Par exemple, le digramme néerlandais ij n'apparaît dans aucune de ces tables.

19-1-4 - Unicode

Unicode résout tous les problèmes et les limitations des solutions précédentes. Unicode s'étend à plus de 100 milliers de caractères et symboles de systèmes d'écriture de beaucoup de langages humains, présents et passés (des nouveaux caractères sont constamment passés en revue pour être ajoutés à la table). Chacun de ces caractères a un code unique. Les documents qui sont codés en Unicode peuvent utiliser tous les caractères des différents systèmes d'écriture en même temps sans aucune ambiguïté ni limitation.

19-2 - Codages Unicode

Unicode associe un unique code à chaque caractère. Comme il y a plus de caractères Unicode que ce que peut contenir une valeur 8 bits, certains caractères doivent être représentés par au moins deux valeurs 8-bits. Par exemple, le code Unicode de 'Ğ' (286) est plus grand que la valeur maximale d'un type 8 bits (255).

Je vais utiliser 1 et 30 comme les valeurs des deux octets qui représentent Ğ de façon arbitraire. Ils ne sont valides dans aucun codage Unicode, mais utiles pour introduire ces codages Unicode. Les valeurs correctes de ces valeurs sont hors de la portée de ce chapitre.

La manière dont les caractères sont représentés électroniquement est appelée codage. Nous avons vu ci-dessus comment la chaîne "hello" est représentée en ASCII. Nous allons maintenant voir les trois codages Unicode qui correspondent aux types de caractères D.

- UTF-32 : ce codage utilise 32 bits (4 octets) pour chaque caractère Unicode. Le codage UTF-32 de la chaîne "hello" est similaire à son codage ASCII, mais chaque caractère est représenté par 4 octets : 0, 0, 0, 104 ; 0, 0, 0, 101 ; 0, 0, 0, 108 ; 0, 0, 0, 108 ; 0, 0, 0, 111.
Autre exemple : le codage UTF-32 de « aĞ » est 0, 0, 0,97 ; 0,0, 1, 30.
Note : les valeurs réelles de ces octets sont différentes et l'ordre réel de ces octets peut être différent.
'a' et 'Ğ' sont représentés par 1 et 2 octets significatifs respectivement, et les valeurs des 5 autres octets sont toutes zéro. Ces zéros peuvent être vus comme des octets de remplissage qui font que chaque caractère Unicode occupe 4 octets.
Pour les documents écrits avec l'alphabet latin de base, ce codage utilise toujours quatre fois plus d'octets que le codage ASCII. Quand, dans un document donné, la plupart des caractères ont des équivalents ASCII, les trois octets de remplissage pour chacun de ces caractères rendent ce codage moins efficace que les autres codages.
D'un autre côté, il y a des avantages à ce que chaque caractère soit toujours représenté par le même nombre d'octets.

- UTF-16 : ce codage utilise 16 bits (2 octets) pour représenter la plupart des caractères Unicode. Comme 16 bits peuvent représenter environ 65 milliers de valeurs uniques, les autres 35 milliers caractères Unicode doivent être représentés par des octets supplémentaires.
Par exemple,"aĞ" est codé avec 4 octets en UTF-16 : 0, 97 ; 1, 30.
Note : les valeurs réelles de certains de ces octets sont différentes et l'ordre réel des octets peut être différent.
Comparé à UTF-32, ce codage prend moins d'espace pour la plupart des documents, mais parce qu'il y a des caractères qui sont représentés par plus de deux octets. UTF-16 est plus compliqué à traiter.

- UTF-8 : ce codage utilise un ou plusieurs octets pour chaque caractère. Si un caractère a un équivalent dans la table ASCII, il est représenté par un octet et par le même code que dans la table ASCII. Les autres caractères Unicode sont représentés par 2, 3 ou 4 octets. La plupart des caractères spéciaux des systèmes d'écriture européens font partie du groupe de caractères qui sont représentés par 2 octets.
Pour la plupart des documents, UTF-8 est le codage qui prend le moins de place. Un autre bénéfice d'UTF-8 est que les documents qui ont déjà été codés en ASCII correspondent directement à leur codage UTF-8 respectif. UTF-8 ne gaspille pas d'espace : chaque caractère est représenté par des octets significatifs uniquement. Par exemple, le codage UTF-8 de "aĞ" est : 97, 1, 30.
Note : les valeurs réelles de ces octets sont différentes et leur ordre peut être différent également.

19-3 - Les types de caractères du D

Il y a trois types de caractères en D. Ces caractères correspondent aux trois codages Unicode mentionnés ci-avant. D'après ce que vous vous souvenez du chapitre sur les types fondamentaux.

Type

Définition

Valeur initiale

char

Unité de stockage UTF-8

0xFF

wchar

Unité de stockage UTF-16

0xFFFF

dchar

Unité de stockage UTF-32 et point de code Unicode

0x0000FFFF

Comparé à d'autres langages de programmation, les caractères en D peuvent ne pas avoir le même nombre d'octets. Par exemple, parce que Ğ ne peut être représenté que par 2 octets au minimum dans Unicode, il ne rentre pas dans une variable de type char. En revanche, le type dchar, faisant 4 octets, peut stocker n'importe quel caractère Unicode.

Même si le D propose ces types utiles, le D ne supporte pas certaines fonctionnalités ésotériques d'Unicode. J'y reviens après.

19-4 - Caractères littéraux

Les littéraux sont des valeurs constantes qui sont écrits dans le code source du programme. En D, les caractères littéraux sont écrits entres apostrophes :

char  letter_a = 'a';
wchar letter_e_acute = 'é';

Les guillemets ne sont pas valides pour les caractères parce qu'ils sont utilisés quand on écrit des chaînes, que nous verrons dans deux chapitres. 'a' est un caractère littéral et "a" est une chaîne littérale constituée d'un caractère.

Les variables de type char ne peuvent stocker que des lettres qui sont dans la table ASCII.

Il y a beaucoup de manières d'insérer des caractères dans le code :

- le plus naturellement, en les tapant sur le clavier ;

- en les copiant-collant depuis un autre programme ou un autre texte. Par exemple, vous pouvez copier-coller depuis un site Web, ou depuis un programme conçu pour afficher des caractères (on trouve de tels programmes dans la plupart des environnements Linux sous le nom de « Table de Caractères ») ;

- en utilisant les noms raccourcis des caractères. La syntaxe, pour le faire, est : \&nom_du_caractere;. Par exemple, le nom du caractère Euro est euro et peut être écrit dans le programme comme ceci :

wchar currencySymbol = '\&euro;';

- voir la liste de tous les caractères nommés qui peuvent être écrits de cette manière ;

- en indiquant les caractères par leur numéro Unicode :

char a = 97;
wchar Ğ = 286;

- en spécifiant les codes des caractères de la table ASCII soit par \valeur_en_octal soit par \xvaleur_en_hexadécimal :

char questionMarkOctal = '\77';
char questionMarkHexadécimal = '\x3f';

Ces méthodes peuvent également être utilisées pour écrire des caractères dans les chaînes. Par exemple, les deux lignes qui suivent contiennent la même chaîne :

writeln("Résumé préparation: 10,25€");
writeln("\x52\&eacute;sum\u00e9 pr\u00e9paration: 10,25\&euro;");

19-5 - Caractères de contrôle

Certains caractères ne font qu'affecter le format du texte, ils n'ont pas de représentation visuelle propre. Par exemple, le caractère de nouvelle ligne, qui indique que la sortie devrait continuer sur une nouvelle ligne, n'a pas de représentation visuelle. De tels caractères sont appelés caractères de contrôle. Les caractères de contrôle sont écrits avec la syntaxe \caractère_de_contrôle.

Syntaxe

Nom

Effet

\n

nouvelle ligne

Déplace l'affichage sur une nouvelle ligne

\r

etour chariot

Déplace l'affichage au début de la ligne actuelle

\t

tab

Déplace l'affichage à la prochaine tabulation

Par exemple, la fonction write, qui ne commence pas de nouvelle ligne automatiquement, le ferait pour chaque caractère \n. Chaque occurrence du caractère de contrôle \n à l'intérieur du littéral suivant représente le début d'une nouvelle ligne :

write("première ligne\ndeuxième ligne\ntroisième ligne\n");

Sortie :

première ligne
deuxième ligne
troisième ligne

19-6 - Guillemet simple et antislash

Le guillemet simple lui-même ne peut pas être écrit à l'intérieur de guillemets simples parce que le compilateur prendrait le deuxième guillemet comme le caractère qui fermerait le premier : '''. Les deux premiers seraient les guillemets ouvrant et fermant, et le troisième serait tout seul, entraînant une erreur de compilation (NDT De plus, n'écrire aucun caractère entre les deux guillemets simples est illégal).

De manière similaire, on ne peut pas écrire '\' pour désigner le caractère antislash. Comme l'antislash a une signification spéciale, le compilateur prendrait \' comme un caractère spécial. Le compilateur chercherait ensuite un guillemet fermant et ne le trouverait pas.

Pour éviter ces confusions, le guillemet simple et l'antislash sont échappés par un antislash :

caractère

Syntaxe

Nom

'

\'

guillemet simple

\

\\

antislash (contre-oblique)

19-7 - Le module std.uni

Le module std.uni inclut des fonctions pour manipuler les caractères Unicode. Vous pouvez voir ces fonctions dans la documentation de ce module.

Les fonctions qui commencent par is répondent à certaines questions sur les caractères : le résultat est false ou true selon que la réponse est non ou oui (respectivement). Ces fonctions sont utiles dans des expressions logiques :

- isLower : le caractère est-il en minuscule ?

- isUpper : le caractère est-il en majuscule ?

- isAlpha : le caractère est-il alphabétique au sens d'Unicode ? (une lettre) ;

- isWhite : le caractère est-il blanc ? (espace, nouvelle ligne, tabulation…).

Les fonctions qui commencent par to renvoient de nouveaux caractères à partir de caractères existants :

- toLower : renvoie le caractère minuscule correspondant au caractère donné ;

- toUpper : renvoie le caractère majuscule correspondant au caractère donné ;

Voici un programme qui utilise toutes ces fonctions :

import std.stdio;
import std.uni;

void main()
{
writeln("Est-ce que ğ est minuscule ? ", isLower('ğ'));
writeln("Est-ce que Ş est minuscule ? ", isLower('Ş'));

writeln("Est-ce que Ş est minuscule ? ", isUpper('İ'));
writeln("Est-ce que ç est majuscule ? ", isUpper('ç'));

writeln("Est-ce que z est alphanumérique ? ", isAlpha('z'));
writeln("Est-ce que € est alphanumérique ? ", isAlpha('\&euro;'));

writeln("Est-ce que la nouvelle ligne est un caractère blanc ? ", isWhite('\n'));
writeln("Est-ce que le tiret du bas est un caractère blanc ? ", isWhite('_'));

writeln("La minuscule de Ğ : ", toLower('Ğ'));
writeln("La minuscule de İ : ", toLower('İ'));

writeln("La majuscule de ş : ", toUpper('ş'));
writeln("La majuscule de ı : ", toUpper('ı'));

Sortie :

Est-ce que ğ est minuscule ? true
Est-ce que Ş est minuscule ? false
Est-ce que İ est majuscule ? true
Est-ce que ç est majuscule ? false
Est-ce que z est alphanumérique ? true
Est-ce que € est alphanumérique ? false
Est-ce que la nouvelle ligne est un caractère blanc ? true
Est-ce que le tiret du bas est un caractère blanc ? false
La minuscule de Ğ : ğ
La minuscule de İ : i
La majuscule de ş : Ş
La majuscule de ı : I

19-7-1 - Prise en charge limitée pour ı et i de l'alphabet turc

Les versions minuscules et majuscules des lettres ı et i sont « pointées » ou non de façon cohérente dans l'alphabet turc. La majorité des alphabets sont incohérents de ce point de vue : la majuscule du i « pointé » n'est pas « pointée ».

Parce que les systèmes informatiques ont commencé avec la table ASCII, la majuscule de i est I. Pour cette raison, ces deux lettres ont besoin d'une attention particulière. Le programme suivant montre ce problème :

import std.stdio;
import std.uni;

void main()
{
writeln("La majuscule de i : ", toUpper('i'));
writeln("La minuscule de I : ", toLower('I'));
}

Sortie :

La majuscule de i : I
La minuscule de I : i

19-7-2 - Tous les alphabets ont une prise en charge limitée

Les caractères sont transformés en majuscules/minuscules selon leur code Unicode. Cette méthode est problématique pour beaucoup d'alphabets. Par exemple, les alphabets azéri et celte sont aussi affectés par le problème de la minuscule 'I' étant 'i'.

Il y a des problèmes similaires avec le tri. Les lettres de beaucoup d'alphabets, comme le ğ en turc, sont placées après le z. Même les caractères accentués comme á sont placés après le z, et ce, même pour l'alphabet latin basique.

19-8 - Problème de lecture des caractères

La souplesse et la puissance des caractères Unicode en D peuvent être la source de résultats inattendus lors de la lecture d'un flux d'entrée. Cette contradiction est due aux multiples sens du terme « caractère ». Avant de développer plus ce point, considérons un programme qui a ce problème :

import std.stdio;

void main()
{
char lettre;
write("Veuillez entrer une lettre : ");
readf(" %s", &lettre);
writeln("La lettre suivante a été lue : ", lettre);
}

Si vous essayez ce programme dans un environnement qui n'utilise pas Unicode, vous pouvez voir que même les caractères non Unicode sont lus et affichés correctement.

Cependant, si vous démarrez ce programme dans un environnement Unicode, par exemple une console sous Linux, vous pouvez voir que le caractère affiché à la sortie n'est pas le même caractère que celui qui a été entré. Pour voir ceci, entrons un caractère non ASCII dans une console qui utilise le codage UTF-8 (comme la plupart des consoles sous Linux) :

Sortie :

Veuillez entrer une lettre : ğ
La lettre suivante a été lue :     ← Pas de lettre à la sortie

La raison de ce problème est que les caractères non ASCII tels que le ğ sont représentés par deux codes, et lire un char depuis l'entrée ne lit que le premier des deux octets. Comme ce seul caractère n'est pas suffisant pour représenter le caractère Unicode entier, la console n'affiche pas le caractère incomplet.

Pour voir que les codes UTF-8 qui constituent le caractère sont lus char par char, lisons deux char et affichons-les l'un après l'autre :

import std.stdio;

void main()
{
char premierCode;
char secondCode;

write("Veuillez entrer une lettre : ");
readf(" %s", &premierCode);
readf(" %s", &secondCode);

writeln("La lettre suivante a été lue : ",
premierCode, secondCode);
}

Le programme lit deux variables char depuis l'entrée et les affiche dans le même ordre. Quand ces codes sont envoyés à la console dans le même ordre, ils correspondent à un caractère UTF-8 entier sur la console et cette fois, le caractère Unicode est affiché correctement.

Sortie :

Veuillez entrer une lettre : ğ
La lettre suivante a été lue : ğ ← Le caractère Unicode qui
consiste en deux codes char

Ces résultats sont dépendants aussi du fait que les entrées-sorties standard des programmes sont des flux de char.

Nous verrons plus tard dans le chapitre sur les chaînes qu'il est plus facile de lire des caractères comme des chaînes, plutôt que s'occuper individuellement des codes UTF-8.

19-9 - Prise en charge de l'Unicode par le langage D

Unicode est un standard vaste et compliqué. D prend en charge un sous-ensemble utile de celui-ci.

Dans un document codé en Unicode, on distingue plusieurs idées.

- Unité de stockage (code unit) : les valeurs qui constituent les codages UTF sont appelées unités de stockage. Selon le codage et les caractères eux-mêmes, les caractères Unicode sont faits d'une ou plusieurs unités de stockage. Par exemple, dans le codage UTF-8, la lettre 'a' est faite d'une seule unité de stockage et la lettre 'ğ' est faite de deux unités de stockage.
Les types char, wchar, et dchar du D correspondent respectivement à une unité de stockage UTF-8, UTF-16, et UTF-32.

- Point de code (code point) : chaque lettre, chiffre, symbole, etc. qu'Unicode définit est appelé un point de code. Par exemple, les codes Unicode de 'a' est de 'ğ' sont deux points de code distincts.
Selon le codage, les points de code sont représentés par une ou plusieurs unités de stockage. Comme mentionné ci-avant, dans le codage UTF-8 'a' est représenté par une unité de stockage et 'ğ' est représenté par deux unités de stockage. En revanche, 'a' et 'ğ' sont représentés par une seule unité de stockage dans les codages UTF-16 et UTF-32.
Le type D qui prend en charge les points de code est dchar. char et wchar ne peuvent être utilisés que pour les unités de stockage.

- Caractère : n'importe quel symbole que le standard Unicode définit et que nous appelons « caractère » dans la vie de tous les jours est un caractère.
La définition Unicode de caractère est flexible et ceci complique les choses. Certains caractères peuvent être formés d'un ou plusieurs points de code. Par exemple, la lettre ğ peut être désignée de deux manières :

- comme simple point de code 'ğ' ;

- comme les deux points de code 'g' et '̆ '. (combining breve).

D ne prend pas nativement en charge le concept de point de code combiné. En D, le point de code ğ est différent des deux points de code consécutifs 'g' et '̆ '.

19-10 - Résumé

- Unicode supporte tous les caractères de tous les systèmes d'écriture.

- Char est pour le codage UTF-8 ; même s'il ne convient pas pour représenter les caractères en général, il prend en charge la table ASCII.

- wchar est pour l'encodage UTF-16 ; même s'il ne convient pas pour représenter les caractères en général, il peut prendre en charge une multitude d'alphabets.

- dchar est pour le codage UTF-32 ; il peut également être utilisé pour les points de code parce qu'il est 32 bits.

20 - Tranches (slices) et autres fonctionnalités des tableaux

Nous avons vu dans le chapitre sur les tableaux comment les éléments sont groupés comme une collection dans un tableau. Ce chapitre a été volontairement bref, laissant à ce chapitre-ci la plupart des fonctionnalités des tableaux.

Avant d'aller plus loin, voici quelques définitions brèves de certains termes qui se trouvent être proches dans leur signification :

- Tableau : l'idée générale d'un groupe d'éléments qui sont situés les uns à côté des autres et qui sont accédés par des indices ;

- Tableau de taille fixe (tableau statique) : tableau avec un nombre fixe d'éléments. Ce type de tableau stocke lui-même ses éléments ;

- Tableau dynamique : tableau qui peut perdre ou gagner des éléments. Ce type de tableau donne accès à des éléments qui sont stockés par l'environnement d'exécution du D ;

- Tranches (slices) : autre nom des tableaux dynamiques.

Quand j'écrirai slice, je désignerai précisément une tranche. Quand j'écrirai tableau, je désignerai soit une tranche, soit un tableau à taille fixe, sans distinction.

20-1 - Tranches

Les tranches sont la même chose que les tableaux dynamiques. Elles sont appelées tableaux dynamiques parce qu'elles sont utilisées comme des tableaux, et sont appelées tranches parce qu'elles donnent un accès à des portions d'autres tableaux. Elles permettent d'utiliser ces portions comme si elles étaient des tableaux à part entière.

Les tranches sont définies avec un intervalle correspondant aux indices du tableau duquel on veut « extraire » la tranche :

indice_de_debut .. un_apres_l_indice_de_fin

Dans la syntaxe permettant d'écrire un intervalle, l'indice de début fait partie de l'intervalle, mais l'indice de fin est hors de l'intervalle :

/* ... */ = JoursDesMois[0 .. 3];   // 0, 1, et 2 sont inclus ; mais pas 3

Les intervalles de nombres sont différents des intervalles de Phobos. Les intervalles de Phobos sont en relation avec les interfaces de classes et de structures. Nous verrons cela dans des chapitres ultérieurs.

Par exemple, on peut extraire des tranches du tableau JoursDesMois pour utiliser ses parties à travers quatre tableaux plus petits.

int[12] JoursDesMois =
[ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];

int[] premierTrimestre   = JoursDesMois[0 .. 3];
int[] deuxiemeTrimestre  = JoursDesMois[3 .. 6];
int[] troisiemeTrimestre = JoursDesMois[6 .. 9];
int[] quatriemeTrimestre = JoursDesMois[9 .. 12];

Les quatre variables dans le code ci-dessus sont des tranches ; elles donnent accès aux quatre parties d'un tableau déjà existant. Un point important qui vaut la peine d'être noté est que ces tranches ne stockent pas leurs propres éléments. Elles donnent simplement accès aux éléments du vrai tableau. Modifier un élément d'une tranche modifie l'élément du tableau. Pour voir cela, modifions les premiers éléments de chaque tranche et affichons le tableau :

premierTrimestre[0]   = 1;
deuxiemeTrimestre[0]  = 2;
troisiemeTrimestre[0] = 3;
quatriemeTrimestre[0] = 4;

writeln(JoursDesMois);

Sortie :

[1, 28, 31, 2, 31, 30, 3, 31, 30, 4, 30, 31]

Chaque tranche modifie son premier élément, et l'élément correspondant dans le tableau est affecté.

Nous avons vu plus tôt que les indices valides des tableaux vont de 0 à la taille du tableau moins un. Par exemple, les indices valides d'un tableau à trois éléments sont 0, 1 et 2. De manière similaire, l'indice de fin dans la syntaxe de la tranche est l'indice de l'élément qui est juste après le dernier élément auquel la tranche donne accès. Par exemple, une tranche de tous les éléments d'un tableau à trois éléments serait tableau[0..3]

Évidemment, l'indice de début ne peut pas être plus grand que l'indice de fin :

int[3] tableau = [ 0, 1, 2 ];
int[] tranche = tableau[2 .. 1];   // ← ERREUR lors de l'exécution

Il est correct d'avoir l'indice de début et l'indice de fin ayant la même valeur. Dans ce cas, la tranche est vide. En supposant qu'indice est valide :

int[] tranche = unTableau[indice .. indice];
writeln("La taille de la tranche : ", tranche.length);

Sortie :La taille de la tranche : 0

20-2 - $, à la place de tableau.length

Quand on accède à un élément, $ est un raccourci pour désigner la taille du tableau :

writeln(array[array.length - 1]);   // le dernier élément
writeln(array[$ - 1]);              // la même chose

20-3 - .dup pour copier

La propriété .dup (comme « dupliquer ») crée un nouveau tableau depuis les copies des éléments d'un tableau existant :

double[] array = [ 1.25, 3.75 ];
double[] theCopy = array.dup;

Par exemple, définissons un tableau qui contient le nombre de jours pour chaque mois d'une année bissextile. Une méthode est de faire la copie d'un tableau d'une année non bissextile et d'incrémenter l'élément qui correspond à février.

import std.stdio;

void main()
{
int[12] JoursDesMois =
[ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];

int[] anneeBissextile = JoursDesMois.dup;

++anneeBissextile[1];   // incrémente le nombre de jours de février

writeln("Année non bissextile : ", JoursDesMois);
writeln("Année bissextile     : ", anneeBissextile);
}

Sortie :

Année non bissextile : [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
Année bissextile      : [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]

20-4 - Affectation

Jusqu'à maintenant, nous avons vu que l'opérateur d'affectation modifie les valeurs des variables. C'est la même chose avec les tableaux de taille fixe :

int[3] a = [ 1, 1, 1 ];
int[3] b = [ 2, 2, 2 ];

a = b;      // les éléments de 'a' deviennent 2
writeln(a);

Sortie : [2, 2, 2]

L'opération d'affectation a un sens complètement différent pour les tranches : après affectation, la tranche donne accès à des nouveaux éléments.

int[] impairs = [ 1, 3, 5, 7, 9, 11 ];
int[] pairs = [ 2, 4, 6, 8, 10 ];

int[] tranche;   // ne donne accès à aucun élément pour le moment

tranche = impairs[2 .. $ - 2];
writeln(tranche);

tranche = pairs[1 .. $ - 1];
writeln(tranche);

Ci-dessus, la tranche ne donne accès à aucun élément lors de sa définition. Ensuite, elle est utilisée pour donner accès à certains éléments de impairs et ensuite à certains éléments de pairs :

Sortie :

[5, 7]
[4, 6, 8]

20-5 - Agrandir une tranche plus longue peut finir le partage

Comme la longueur d'un tableau à taille fixe ne peut pas être changée, l'idée de fin de partage ne concerne que les tranches.

Il est possible d'accéder aux mêmes éléments par plus d'une tranche. Par exemple, on accède aux deux premiers des huit éléments ci-dessous à travers trois tranches :

import std.stdio;

void main()
{
int[] tranche = [ 1, 3, 5, 7, 9, 11, 13, 15 ];
int[] moitie = tranche[0 .. $ / 2];
int[] quart = tranche[0 .. $ / 4];

quart[1] = 0;   // modification via une tranche

writeln(quart);
writeln(moitie);
writeln(tranche);
}

L'effet de la modification du second élément de quart se remarque sur toutes les tranches :

[1, 0]
[1, 0, 5, 7]
[1, 0, 5, 7, 9, 11, 13, 15]

Vu sous cet angle, les tranches donnent un accès partagé aux éléments. Ce partage pose la question de ce qu'il arrive quand un nouvel élément est ajouté à une des tranches. Comme les tranches donnent accès au même élément, il pourrait ne plus y avoir de place pour ajouter des éléments à une tranche sans rentrer en conflit avec les autres.

D répond à cette question en finissant le partage s'il n'y a pas de place pour le nouvel élément : la tranche qui n'a pas de place pour grandir abandonne le partage. Quand cela arrive, tous les éléments existants de cette tranche sont copiés à un nouvel endroit automatiquement et la tranche commence à donner accès à ces nouveaux éléments.

Pour voir ceci en action, ajoutons un élément à quart avant de modifier son second élément :

quart ~= 42;  // Cette tranche abandonne le partage parce qu'il
// n'y a pas de place pour le nouvel élément.

quart[1] = 0; // Pour cette raison, la modification
// n'affecte pas les autres tranches

La sortie du programme montre que la modification apportée à la tranche quart n'affecte pas les autres :

[1, 0, 42]
[1, 3, 5, 7]
[1, 3, 5, 7, 9, 11, 13, 15]

Augmenter la taille de la tranche de façon explicite lui fait également abandonner le partage :

++quart.length;       // Abandonne le partage

ou

quart.length += 5;    // Abandonne le partage

En revanche, raccourcir une tranche n'affecte pas le partage. Raccourcir la tranche veut simplement dire que celle-ci donne maintenant accès à moins d'éléments :

int[] a = [ 1, 11, 111 ];
int[] d = a;

d = d[1 .. $];   // raccourcir par la gauche
d[0] = 42;       // modifier l'élément depuis la tranche

writeln(a);      // afficher l'autre tranche

Comme on peut le voir sur la sortie, la modification depuis d est vue depuis a ; le partage est toujours là.

[1, 42, 111]

Réduire la longueur de différentes manières ne termine pas le partage non plus :

d = d[0 .. $ - 1];         // raccourcir par la droite
--d.length;                // idem
d.length = d.length - 1;   // idem

Le partage des éléments est toujours là.

20-6 - capacity pour savoir si le partage va être terminé

Il y a des cas où les tranches continuent à partager des éléments même après qu'un élément leur a été ajouté. Ceci arrive quand l'élément est ajouté à la tranche la plus longue et qu'il y a de la place après son dernier élément :

import std.stdio;

void main()
{
int[] tranche = [ 1, 3, 5, 7, 9, 11, 13, 15 ];
int[] half = tranche[0 .. $ / 2];
int[] quarter = tranche[0 .. $ / 4];

tranche ~= 42;      // ajout à la tranche la plus longue...
tranche[1] = 0;     // ... et modification d'un élément.

writeln(quarter);
writeln(half);
writeln(tranche);
}

Comme on peut le remarquer sur la sortie, même si l'élément ajouté augmente la taille de la tranche, le partage n'a pas été terminé et la modification est reflétée sur toutes les tranches.

[1, 0]
[1, 0, 5, 7]
[1, 0, 5, 7, 9, 11, 13, 15, 42]

La propriété capacity des tranches détermine si le partage sera fini si un élément est ajouté à une certaine tranche. (capacity est en fait une fonction, mais cette distinction n'a pas d'importance dans cette discussion.)

capacity a deux sens :

- quand sa valeur est 0, cela veut dire que ce n'est pas la tranche originale la plus grande. Dans ce cas, ajouter un nouvel élément déplacerait de toute façon les éléments de la tranche et le partage prendrait fin ;

- quand sa valeur n'est pas nulle, cela veut dire que c'est la tranche originale la plus large. Dans ce cas, capacity dénote le nombre total d'éléments que la tranche peut contenir sans avoir besoin d'être copiée. Le nombre de nouveaux éléments qui peuvent être ajoutés à cette tranche avant qu'elle ait besoin d'être copiée peut être calculé en soustrayant la taille de la tranche à sa capacité. Il n'y a pas d'espace pour un nouvel élément si la taille de la tranche vaut sa capacité.

En conséquence, un programme qui a besoin de déterminer si le partage sera fini devrait utiliser un schéma dans ce style :

if (tranche.capacity == 0) {
/* Ses éléments seraient déplacés si un élément de plus
* était ajouté à la tranche */

// ...

} else {
/* Cette tranche peut avoir de la place pour de nouveaux éléments avant
* de nécessiter un déplacement. Calculons combien d'éléments on peut
* ajouter avant déplacement de la tranche : */
auto combienDeNouveauxElements = tranche.capacity - tranche.length;

// ...
}

20-7 - Opérations sur tous les éléments

Cette fonctionnalité concerne et les tranches, et les tableaux à taille fixe.

Les caractères [] écrits après le nom d'un tableau veulent dire : « tous ses éléments ». Cette fonctionnalité simplifie le programme quand certaines opérations ont besoin d'être appliquées à tous les éléments d'un tableau.

Le compilateur D que j'ai (NDT L'auteur) utilisé lors de l'écriture de ce chapitre (dmd 2.056) ne prend pas en charge cette caractéristique complètement. Pour cette raison, j'ai utilisé uniquement des tableaux à taille fixe dans quelques-uns des exemples qui suivent.

import std.stdio;

void main()
{
double[3] a = [ 10, 20, 30 ];
double[3] b = [  2,  3,  4 ];

double[3] resultat = a[] + b[];

writeln(resultat);
}

Sortie :

[12, 23, 34]

L'opération addition dans ce programme est appliquée aux éléments correspondant aux deux tableaux dans cet ordre : d'abord, les premiers éléments sont ajoutés, ensuite les seconds éléments sont ajoutés, etc. Une précondition naturelle est que les tailles des deux tableaux doivent être égales.

L'opérateur peut être l'un des opérateurs arithmétiques +, -, /, % et ^^ ; l'un des opérateurs binaires ^, & et | ou l'un des opérateurs unaires - et ~ qui sont écrits devant un tableau. Nous verrons certains de ces opérateurs dans des chapitres ultérieurs.

Les versions d'affectation de ces opérateurs peuvent aussi être utilisées : =, +=, -=, *=, /=, %=, ^^=, ^=, &= et |=.

Cette fonctionnalité n'est pas uniquement valable entre deux tableaux ; en plus d'un tableau, une expression qui est compatible avec les éléments peut aussi être utilisée. Par exemple, l'opération suivante divise tous les éléments d'un tableau par 4 :

double[3] a = [ 10, 20, 30 ];
a[] /= 4;

writeln(a);

Sortie : [2.5, 5, 7.5]

Pour affecter tous les éléments à une valeur spécifique :

a[] = 42;
writeln(a);

Sortie : [42, 42, 42]

Cette fonctionnalité nécessite une attention particulière quand elle est utilisée avec les tranches. Même s'il n'y a pas de différence apparente dans les valeurs des éléments, les deux expressions suivantes ont un sens très différent :

tranche2 = tranche1;      // ← tranche2 donne maintenant accès
//   aux mêmes éléments que tranche1

tranche3[] = tranche1;    // ← les valeurs des éléments
//   de tranche3 changent

L'affectation à tranche2 lui fait partager les mêmes éléments que tranche1. En revanche, comme tranche3[] fait référence à tous les éléments de tranche3, les valeurs de ses éléments deviennent les mêmes que celles des éléments de tranche1. L'effet de la présence ou de l'absence des crochets ne peut pas être ignoré.

On peut voir un exemple de cette différence dans le programme suivant :

import std.stdio;

void main()
{
double[] tranche1 = [ 1, 1, 1 ];
double[] tranche2 = [ 2, 2, 2 ];
double[] tranche3 = [ 3, 3, 3 ];

tranche2 = tranche1;      // ← tranche2 commence à partager
//   les mêmes éléments que tranche1

tranche3[] = tranche1;    // ← Les valeurs des éléments de
//   tranche3 changent

writeln("tranche1 avant : ", tranche1);
writeln("tranche2 avant : ", tranche2);
writeln("tranche3 avant : ", tranche3);

tranche2[0] = 42;      // ← La valeur d'un élément qu'elle
//   partage avec tranche1 change

tranche3[0] = 43;      // ← la valeur d'un élément auquel
//   tranche3 seule procure un accès change

writeln("tranche1 après : ", tranche1);
writeln("tranche2 après : ", tranche2);
writeln("tranche3 après : ", tranche3);
}

La modification depuis tranche2 affecte tranche1 également :

tranche1 avant : [1, 1, 1]
tranche2 avant : [1, 1, 1]
tranche3 avant : [1, 1, 1]
tranche1 après : [42, 1, 1]
tranche2 après : [42, 1, 1]
tranche3 après : [43, 1, 1]

Le danger ici est que le bogue éventuel peut ne pas être remarqué jusqu'à ce que la valeur d'un élément partagé soit changée.

20-8 - Tableaux multidimensionnels

Jusqu'à maintenant nous avons utilisé les tableaux avec des types fondamentaux comme int ou double uniquement. Le type d'élément peut en fait être n'importe quel autre type, notamment d'autres tableaux. Cela permet au programmeur de définir des conteneurs complexes comme des tableaux de tableaux. Les tableaux de tableaux sont appelés tableaux multidimensionnels.

Tous les éléments de tous les tableaux que nous avons définis jusqu'à présent ont été écrits dans le code source de gauche à droite. Pour aider à comprendre l'idée de tableau bidimensionnel, définissons cette fois-ci un tableau de haut en bas :

int[] array = [
10,
20,
30,
40
];

Comme vous devez vous en souvenir, la plupart des espaces dans le code source sont là pour rendre le code lisible et ne changent pas sa signification. Le tableau ci-dessus aurait pu être défini sur une seule ligne et aurait eu, le cas échéant, la même signification.

Remplaçons maintenant chaque élément de ce tableau par un autre tableau :

/* ... */ array = [
[ 10, 11, 12 ],
[ 20, 21, 22 ],
[ 30, 31, 32 ],
[ 40, 41, 42 ]
];

On a remplacé des éléments de type entier avec des éléments de type int[]. Pour rendre le code conforme à la syntaxe permettant de définir les tableaux, on doit maintenant indiquer le type des éléments comme int[] à la place de int :

int[][] array = [
[ 10, 11, 12 ],
[ 20, 21, 22 ],
[ 30, 31, 32 ],
[ 40, 41, 42 ]
];

De tels tableaux sont appelés tableaux bidimensionnels parce qu'ils peuvent être vus comme ayant des lignes et des colonnes.

Les tableaux bidimensionnels sont utilisés comme n'importe quel autre tableau du moment que l'on se souvient que chaque élément est lui-même un tableau et qu'il est utilisé avec les opérations sur les tableaux :

array ~= [ 50, 51 ]; // ajoute un nouvel élément (une tranche)
array[0] ~= 13;      // ajoute un élément au premier élément

Le nouvel état du tableau :

[[10, 11, 12, 13], [20, 21, 22], [30, 31, 32], [40, 41, 42], [50, 51]]

Les éléments du tableau et le tableau lui-même peuvent être de taille fixe. Ce qui suit est un tableau tridimensionnel où toutes les dimensions sont de taille fixe :

int[2][3][4] tableau;   // 2 colonnes, 3 lignes, 4 pages

La définition ci-dessus peut être vue comme 4 pages de trois lignes de deux colonnes. Par exemple, un tel tableau peut être utilisé pour représenter un bâtiment à 4 étages dans un jeu d'aventure, chaque étage consistant en 2×3=6 pièces.

Par exemple, le nombre d'objets dans la première pièce du deuxième étage peut être incrémenté de cette manière :

// L'indice du deuxième étage est 1 et on accède à
// la première pièce de cet étage par [0][0]
++nombresObjets[1][0][0];

En plus de la syntaxe ci-dessus, l'expression new peut aussi être utilisée pour créer une tranche de tranches. L'exemple suivant utilise seulement deux dimensions :

import std.stdio;

void main()
{
int[][] s = new int[][](2, 3);
writeln(s);
}

L'expression new ci-dessus crée deux tranches contenant 3 éléments chacune et retourne une tranche qui donne accès à ces tranches et éléments.

Sortie :

[[0, 0, 0], [0, 0, 0]]

20-9 - Résumé

- Les tableaux de taille fixe stockent leurs éléments ; les tranches donnent accès à des éléments qui ne leur appartiennent pas.

- À l'intérieur des crochets, $ est l'équivalent de nom _tableau.length.

- .dup crée un nouveau tableau qui est composé des copies des éléments d'un tableau existant.

- Avec les tableaux à taille fixe, l'affectation change les valeurs des éléments ; avec les tranches, elle fait pointer la tranche vers d'autres éléments.

- Les tranches qui grandissent peuvent arrêter de partager les éléments et commencer à donner accès à des éléments nouvellement copiés. .capacity détermine si ce sera le cas.

- array[] fait référence à tous les éléments ; l'opération qui est appliquée à array[] est appliquée à chaque élément individuellement.

- Les tableaux de tableaux sont appelés tableaux multidimensionnels.

20-10 - Exercice

Itérez sur les éléments d'un tableau de doubles et divisez par deux ceux qui sont plus grands que 10. Par exemple, étant donné le tableau suivant :

double[] array = [ 1, 20, 2, 30, 7, 11 ];

On doit obtenir :

[1, 10, 2, 15, 7, 5.5]

Même s'il y a beaucoup de solutions à ce problème, essayez de n'utiliser que les fonctionnalités des tranches. Vous pouvez commencer avec une tranche qui donne accès à tous les éléments. Ensuite, vous pouvez réduire la tranche depuis le début et ne travailler que sur le premier élément.

L'expression suivante réduit la tranche depuis le début :

tranche = tranche[1 .. $];

La solution.

21 - Chaînes de caractères

Nous avons utilisé des chaînes de caractères dans beaucoup de programmes que nous avons vus jusqu'à maintenant. Les chaînes sont une combinaison de deux fonctionnalités que nous avons couvertes dans les trois derniers chapitres : les caractères et les tableaux. Dans la définition la plus simple, les chaînes ne sont rien d'autre que des tableaux de caractères. Par exemple,

char[]

est un type de chaîne.

Cette simple définition peut être trompeuse. Comme nous l'avons vu dans le chapitre sur les caractères, D a trois types de caractères distincts, dont certains peuvent avoir des résultats surprenants dans certaines opérations sur les chaînes.

21-1 - readln et chomp, à la place de readf

Il y a des surprises même quand on lit des chaînes depuis la console.

Étant des tableaux de caractères, les chaînes peuvent contenir des caractères de contrôle comme '\n'. Lorsqu'on lit des chaînes de caractères depuis l'entrée, le caractère de contrôle qui correspond à la touche Entrée sur laquelle on appuie à la fin d'une saisie dans la console fait partie de la chaîne également. De plus, à cause de l'impossibilité de dire à readf() combien de caractères il faut lire, readf() continue à lire jusqu'à la fin de l'entrée. De ce fait, read() ne marche pas comme attendu quand on lit des chaînes :

import std.stdio;

void main()
{
char[] nom;

write("Quel est votre nom ? ");
readf(" %s", &nom);

writeln("Salut ", nom, "!");
}

La touche entrée sur laquelle l'utilisateur appuie après le nom ne termine pas l'entrée. readf() continue à attendre plus de caractères à ajouter à la chaîne :

Quel est votre nom ? Mert
← L'entrée n'est pas terminée alors même que la touche Entrée a été enfoncée.
← (Supposons que Entrée est enfoncée une nouvelle fois ici)

Une manière de terminer le flux d'entrée standard est d'appuyer sur Ctrl+D sous les systèmes de type Unix et Ctrl+Z sous les systèmes Windows. Si l'utilisateur finit l'entrée de cette manière, on voit que les caractères de nouvelle ligne ont été lus également :

Salut Mert
← nouvelle ligne après le nom
!   ← (encore un avant le point d'exclamation)

Le point d'exclamation apparaît après ces caractères au lieu d'être affiché juste après le nom.

readln() convient mieux à la lecture de chaînes. Abréviation de read line (lire ligne), readln() lit jusqu'à la fin de la ligne. Il est utilisé différemment parce que la chaîne de formatage " %s" et l'opérateur & ne sont pas nécessaires :

import std.stdio;

void main()
{
char[] nom;

write("Quel est votre nom ? ");
readln(nom);

writeln("Salut ", nom, " !");
}

readln() enregistre le caractère de nouvelle ligne également ; ceci, pour que le programme ait un moyen de déterminer si l'entrée contient une ligne complète ou si la fin de l'entrée a été atteinte :

Quel est votre nom ? Mert
Salut Mert
! ← Caractère de nouvelle ligne avant le point d'exclamation

De tels caractères de contrôle, qui sont situés à la fin des chaînes, peuvent être supprimés par std.string.chomp.

import std.stdio;
import std.string;

void main()
{
char[] nom;

write("Quel est votre nom ? ");
readln(nom);
nom = chomp(nom);

writeln("Salut ", nom, " !");
}

L'expression chomp() ci-devant retourne une nouvelle chaîne qui ne contient pas les caractères de contrôle de la fin de la chaîne. Réaffecter la valeur de retour à nom donne le résultat attendu :

Quel est votre nom ? Mert
Salut Mert !                   # ← pas de nouvelle ligne

readln() peut être utilisé sans paramètre. Dans ce cas, readln() retourne la ligne qui vient d'être lue. Chaîner le résultat de readln() et chomp() permet une syntaxe plus lisible et plus courte :

string nom = chomp(readln());

Après avoir introduit le type string, j'utiliserai cette syntaxe.

21-2 - Guillemets doubles, et non simples

Nous avons vu que les guillemets simples sont utilisés pour définir des caractères littéraux. Les chaînes littérales sont définies avec des guillemets doubles. 'a' est un caractère ; "a" est une chaîne qui ne contient qu'un caractère.

21-3 - string, wstring, et dstring sont immuables (immutable)

Il y a trois types de chaînes qui correspondent aux trois types de caractères : char[], wchar[] et dchar[].

Il y a trois alias des versions immuables de ces types : string, wstring et dstring. Les caractères de ces variables qui sont définies avec ces alias ne peuvent pas être modifiés. Par exemple, les caractères d'un wchar[] peuvent être modifiés, mais les caractères d'une wstring ne peuvent pas l'être (nous verrons l'immuabilité en D ultérieurement).

Par exemple, le code suivant, qui essaie de capitaliser la première lettre d'une string ne compile pas :

string nePeutEtreMutée = "salut";
nePeutEtreMutée[0] = 'S'; // ERREUR DE COMPILATION

On pourrait penser à définir la variable en tant que char[] au lieu de l'alias string, mais ceci ne compilerait pas non plus :

char[] a_tranche = "hello";   // ERREUR DE COMPILATION

Cette fois, l'erreur de compilation est due à la combinaison de deux facteurs :

1. Le type des chaînes littérales comme "hello" est string, et non char[], elles sont donc immuables.

2. Le char[] sur le côté gauche est une tranche qui donnerait accès, si le code était compilé, à tous les caractères du côté droit.

Comme char[] est mutable et que string ne l'est pas, il y a conflit. Le compilateur ne permet pas d'accéder aux caractères d'un tableau immuable par une tranche « mutable ».

La solution ici est de faire une copie de la chaîne immuable avec la propriété .dup :

import std.stdio;

void main()
{
char[] s = "salut".dup;
s[0] = 'S';
writeln(s);
}

Le programme peut maintenant être compilé et afficher la chaîne modifiée :

Salut

De façon similaire, char[] ne peut pas être utilisé là où une string est nécessaire. Dans de tels cas, la propriété .idup peut être utilisée pour produire une variable string immuable à partir d'une variable mutable char. Par exemple, si s est une variable du type char[], la ligne suivante ne peut pas être compilée :

string resultat = s ~ '.'; // ERREUR DE COMPILATION

Quand le type de s est char[], le type de l'expression à droite de l'affectation ci-dessus est char[] également. .idup est utilisé pour obtenir des chaînes immuables à partir de chaînes existantes :

string resultat = (s ~ '.').idup;   // ← maintenant, compile

21-4 - Confusion potentielle sur la taille des chaînes

Nous avons vu que certains caractères Unicode sont représentés par plus d'un octet. Par exemple, la lettre 'é' est représentée par deux octets. On remarque cela avec la propriété .length des chaînes :

writeln("résumé".length);

Même si « résumé » contient 6 lettres, la taille de la chaîne est le nombre de caractères qu'elle contient : 8

Le type des éléments de chaînes littérales comme "hello" est char et char représente une unité de stockage UTF-8. Cela peut engendrer un problème quand on essaie de remplacer une lettre à deux unités de stockage par une lettre avec une seule unité de stockage :

char[] s = "résumé".dup;
writeln("Avant : ", s);
s[1] = 'e';
s[5] = 'e';
writeln("Après : ", s);

Les deux caractères 'e' ne remplacent pas les deux lettres 'é' ; ils remplacent des unités de stockage uniques, ce qui conduit à un codage UTF-8 incorrect :

Sortie :

Avant : résumé
Après : re�sueé   ← INCORRECT

Quand on s'occupe des lettres, des symboles ou autres caractères Unicode directement comme dans le code ci-dessus, le type à utiliser est dchar :

dchar[] s = "résumé"d.dup;
writeln("Before: ", s);
s[1] = 'e';
s[5] = 'e';
writeln("After : ", s);

Sortie :

Avant : résumé
Après : resume

Notez les deux différences dans le nouveau code :

1. Le type de chaîne est dchar[].

2. Il y a un d à la fin du littéral "résumé"d, indiquant que c'est un tableau de dchars.

21-5 - Chaînes littérales

Le caractère optionnel qui est indiqué après les chaînes littérales détermine le type d'élément de la chaîne :

import std.stdio;

void main()
{
string s = "résumé"c;   // équivalent à "résumé"
wstring w = "résumé"w;
dstring d = "résumé"d;

writeln(s.length);
writeln(w.length);
writeln(d.length);
}

Sortie :

8
6
6

Parce que toutes les lettres de "résumé" peuvent être représentées par un seul wchar ou dchar, les deux dernières longueurs sont égales au nombre de lettres.

21-6 - Concaténations de chaînes

Comme les chaînes sont en fait des tableaux, toutes les opérations sur les tableaux peuvent être appliquées sur les chaînes également. ~ concatène deux chaînes et ~= ajoute à une nouvelle chaîne :

import std.stdio;
import std.string;

void main()
{
write("Quel est votre nom ? ");
string nom = chomp(readln());

// Concaténer :
string salutation = "Salut " ~ nom;

// Ajouter :
salutation ~= " ! Bienvenue...";

writeln(salutation);
}

Sortie :

Quel est votre nom ? Can
Salut Can ! Bienvenue...

21-7 - Comparaison de chaînes

Unicode ne définit pas comment les caractères sont ordonnés autrement que par leurs codes Unicode. Pour cette raison, vous pouvez obtenir des résultats qui ne correspondent pas à vos attentes. Quand l'ordre alphabétique est important, vous pouvez utiliser une bibliothèque du type trileri qui prend en charge l'idée d'alphabet.

Jusqu'à maintenant, nous n'avons utilisé les opérateurs de comparaison <, >=, etc. qu'avec les entiers et les flottants. Les mêmes opérateurs peuvent être utilisés avec les chaînes également, mais avec une signification différente : les chaînes sont ordonnées de façon lexicographique. Cet ordre considère le code Unicode de chaque caractère comme étant la place de ce caractère dans un alphabet fictif géant :

import std.stdio;
import std.string;

void main()
{
write("      Entrez une chaîne : ");
string s1 = chomp(readln());

write("Entrez une autre chaîne : ");
string s2 = chomp(readln());

if (s1 == s2) {
writeln("Ce sont les mêmes !");

} else {
string avant;
string apres;

if (s1 < s2) {
avant = s1;
apres = s2;

} else {
avant = s2;
apres = s1;
}

writeln("'", avant, "' vient avant '", apres, "'.");
}
}

Du fait qu'Unicode reprend la table ASCII pour les lettres de base de l'alphabet latin, les chaînes qui ne contiennent que des caractères ASCII sont ordonnées correctement.

21-8 - Majuscule et minuscule sont différentes

Du fait que chaque lettre a un code unique, chaque lettre est différente des autres. Par exemple, 'A' et 'a' sont des lettres différentes.

De plus, du fait de leur code ASCII, les lettres majuscules sont ordonnées avant les lettres minuscules. Par exemple, 'B' est avant 'a'. La fonction icmp() du module std.string peut être utilisée quand les chaînes doivent être comparées sans tenir compte des majuscules et des minuscules. Vous pouvez voir les fonctions de ce module dans sa documentation.

Du fait que les chaînes sont des tableaux (et donc des intervalles), les fonctions des modules std.array, std.algorithm, et std.range sont très utiles avec les chaînes également.

21-9 - Exercices

- Parcourez la documentation des modules std.string, std.array, std.algorithm et std.range.

- Écrivez un programme qui utilise l'opérateur ~ : l'utilisateur entre le prénom et le nom en minuscules et le programme donne le nom complet qui contient la bonne capitalisation des noms. Par exemple, quand les chaînes sont « ebru » et « domates », le programme devrait afficher « Ebru Domates ».

- Lisez une ligne depuis l'entrée et affichez la partie entre le premier et le dernier 'e' de la ligne. Par exemple, quand la ligne est « cette ligne a 5 mots », le programme devrait afficher « cette ligne ».

sizediff_t premier_e = indexOf(ligne, 'e');

- Il est possible de définir des variables de façon plus concise avec le mot-clé auto, que nous verrons dans un chapitre ultérieur :

auto premier_e = indexOf(ligne, 'e');

Les solutions.

22 - Rediriger les flux d'entrée et de sortie standard

Tous les programmes que nous avons vus jusqu'à maintenant interagissaient à travers stdin et stdout, respectivement le flux d'entrée standard et le flux de sortie standard. Par défaut, les fonctions d'entrée et de sortie comme readf et writeln opèrent sur ces flux.

Lors de l'utilisation de ces flux, nous avons supposé que l'entrée standard venait du clavier et que la sortie standard allait vers l'écran.

Nous commencerons à écrire des programmes qui géreront des fichiers dans le chapitre suivant. Nous verrons que comme les flux d'entrée et de sortie, les fichiers sont des flux de caractères également ; ils sont donc utilisés pratiquement de la même manière que stdin et stdout.

Avant de voir comment on accède aux fichiers depuis les programmes, je voudrais vous montrer comment les entrées et sorties standard des programmes peuvent être redirigées vers des fichiers ou vers d'autres programmes. Les programmes peuvent enregistrer dans des fichiers au lieu d'afficher à l'écran, et lire depuis des fichiers à la place du clavier. Même si ces fonctionnalités ne concernent pas directement les langages de programmation, ce sont des outils utiles qui sont disponibles dans toutes les consoles modernes.

22-1 - Rediriger la sortie standard vers un fichier avec >

Quand on lance un programme depuis la console, écrire un caractère > et un nom de fichier à la fin de la ligne de commande redirige la sortie standard de ce programme vers le fichier spécifié. Tout ce que le programme envoie vers la sortie standard sera écrit dans ce fichier plutôt que sur l'écran.

Testons ceci avec un programme qui lit un nombre flottant depuis son entrée, le multiplie par 2 et l'affiche dans sa sortie standard :

import std.stdio;

void main()
{
double nombre;
readf(" %s", &nombre);

writeln(nombre * 2);
}

Si le nom du programme est par_deux, sa sortie sera écrite vers un fichier nommé par_deux_resultat.txt si on exécute la ligne suivante :

./par_deux > par_deux_resultat.txt

Par exemple, si on entre 1.2 dans la console, le résultat 2.4 apparaîtra dans par_deux_resultat.txt. Note : même si le programme n'affiche pas une ligne du style « Veuillez entrer un nombre », il s'attend quand même à ce qu'un nombre soit entré.

22-2 - Rediriger l'entrée standard depuis un fichier avec <

De manière similaire, l'entrée standard peut être redirigée depuis un fichier par <. Dans ce cas, le programme lit depuis le fichier spécifié à la place du clavier.

Pour tester cela, utilisons un programme qui calcule le dixième d'un nombre :

import std.stdio;

void main()
{
double nombre;
readf(" %s", &nombre);

writeln(nombre / 10);
}

En supposant que le fichier par_deux_resultat.txt existe toujours et contient 2.4, et que le nom du nouveau programme est un_dixieme, on peut rediriger l'entrée du nouveau programme de cette manière :

./un_dixieme < par_deux_resultat.txt

Cette fois, le programme lira depuis par_deux_resultat.txt et affichera le résultat 0.24 à la console.

22-3 - Rediriger les deux flux standard

> et < peuvent être utilisés en même temps :

./un_dixieme < par_deux_resultat.txt > un_dixieme_resultat.txt

Cette fois, l'entrée standard serait lue depuis par_deux_resultat.txt et la sortie standard serait écrite vers un_dixieme_resultat.txt.

22-4 - Combiner les programmes avec |

Notez que par_deux_resultat.txt est un intermédiaire entre les deux programmes ; par_deux écrit dedans et un_dixieme lit depuis ce fichier.

Le caractère | « achemine » (pipe) la sortie standard du programme qui est sur sa gauche vers l'entrée standard du programme qui est sur la droite sans nécessiter un fichier intermédiaire. Par exemple, quand les deux programmes ci-dessus sont « combinés » ensemble comme dans la ligne qui suit, ils calculent ensemble un cinquième du nombre en entrée :

./par_deux | ./un_dixieme

par_deux est lancé et lit un nombre depuis l'entrée. Le résultat de par_deux apparaît ensuite dans l'entrée standard de un_dixieme, qui à son tour calcule et affiche un dixième de ce résultat.

22-5 - Exercice

Combinez plus de deux programmes :

./un | ./deux | ./trois

La solution.

23 - Fichiers

Nous avons vu dans le chapitre précédent que les flux d'entrée et de sortie standard peuvent être redirigés vers et depuis des fichiers ou d'autres programmes avec les opérateurs >, < et | dans la console. Malgré leur puissance, ces outils ne sont pas adaptés dans toutes les situations parce que dans beaucoup de cas, les programmes ne peuvent pas effectuer leurs tâches en lisant leur entrée et en écrivant sur leur sortie.

Par exemple, un programme qui gère des enregistrements d'étudiants pourrait utiliser sa sortie standard pour afficher le menu du programme. Mais un tel programme aurait besoin d'écrire les enregistrements dans un fichier plutôt que dans stdout.

Dans ce chapitre, nous allons voir comment lire et écrire des fichiers du système de fichiers.

23-1 - Concepts fondamentaux

Les fichiers sont représentés par la structure File du module std.stdio. Comme je n'ai pas encore présenté les structures, il faudra accepter leur syntaxe telle quelle.

Avant de rentrer dans le code, il faut voir les idées fondamentales à propos des fichiers.

23-1-1 - Le producteur et le consommateur

Les fichiers qui sont créés sur une plateforme peuvent ne pas être utilisables tels quels sur d'autres plateformes. Simplement ouvrir un fichier et écrire des données dedans peut ne pas être suffisant pour que ces données soient disponibles au consommateur. Le producteur et le consommateur des données doivent d'abord se mettre d'accord sur le format des données qui sont dans le fichier. Par exemple, si le producteur a écrit l'identifiant et le nom des enregistrements des étudiants dans un certain ordre, le consommateur doit lire les données dans le même ordre.

De plus, les exemples de code ci-après n'écrivent pas de BOM au début du fichier. Ceci peut rendre vos fichiers incompatibles avec les systèmes qui nécessitent une BOM (BOM veut dire Byte Ordrer Mark, indicateur d'ordre des octets).

23-1-2 - Droits d'accès

Les systèmes de fichiers donnent accès aux fichiers, aux programmes avec certains droits. Les droits d'accès sont aussi importants pour l'intégrité des données que pour les performances.

Quand il s'agit de lire, autoriser plusieurs programmes à lire depuis le même fichier améliore les performances parce qu'aucun programme n'a à attendre que les autres finissent leur lecture. En revanche, quand il s'agit d'écrire, un seul programme devrait être autorisé à écrire dans un fichier donné, sinon les programmes pourraient remplacer les données des autres programmes.

23-1-3 - Ouvrir un fichier

Les flux d'entrée et de sortie standard stdin et stdout sont déjà ouverts quand les programmes démarrent. Ils sont prêts à être utilisés.

En revanche, les fichiers doivent d'abord être ouverts en indiquant leur nom et les droits d'accès nécessaires.

23-1-4 - Fermer un fichier

Tout fichier qui a été ouvert par un programme doit être fermé quand le programme a fini de l'utiliser. Dans la plupart des cas, les fichiers n'ont pas besoin d'être fermés explicitement, ils sont fermés automatiquement quand les objets File sont détruits automatiquement :

if (uneCondition) {

// On suppose qu'un objet File a été créé et utilisé ici.
// ...

} // ← Le fichier sera fermé automatiquement ici en sortie
//   de la structure if. Pas besoin de le fermer explicitement.

Dans certains cas, il peut être nécessaire qu'un objet File soit rouvert pour accéder à un fichier différent ou au même fichier avec des droits d'accès différents. Dans de tels cas, le fichier doit être fermé et ensuite rouvert :

fichier.close();
fichier.open("enregistrements_etudiants", "r");

23-1-5 - Écrire et lire un fichier

Comme les fichiers sont des flux de caractères, les fonctions d'entrée et de sortie writeln, readf, etc. sont utilisées exactement de la même manière avec eux. La seule différence est que le nom de la variable File suivi d'un point doit être ajouté :

writeln("bonjour");         // écrit sur la sortie standard
stdout.writeln("bonjour");  // même chose que la ligne précédente
fichier.writeln("bonjour"); // écrit dans un fichier spécifique

23-1-6 - eof() pour tester la fin du fichier

La méthode eof() détermine si la fin du fichier a été atteinte pendant la lecture du fichier. Quand c'est le cas, elle retourne true.

Par exemple, la boucle suivante sera exécutée jusqu'à la fin du fichier :

while (!fichier.eof()) {
/* ... */
}

23-1-7 - Le module std.file

Le module std.file contient des fonctions et des types qui sont utiles pour travailler avec le contenu des dossiers. Par exemple, la fonction exists peut être utilisée pour savoir si un fichier ou un dossier existe sur le système de fichiers :

import std.file;

// ...

if (exists(nomFichier)) {
// Il y a un fichier ou un dossier qui porte ce nom

} else {
// Aucun fichier ou dossier portant ce nom
}

23-2 - La structure std.stdio.File

File utilise les mêmes caractères de mode que ceux qui sont utilisés par la fonction fopen du langage C.

Mode

Définition

r

Accès en lecture. Le fichier est ouvert et lu depuis le début.

r+

Accès en lecture et en écriture. Le fichier est ouvert pour être lu et écrit depuis le début.

w

Accès en écriture. Si le fichier n'existe pas, un fichier vide est créé. S'il existe déjà, son contenu est écrasé.

w+

Accès en lecture et en écriture. Si le fichier n'existe pas, un fichier vide est créé. S'il existe déjà, son contenu est écrasé.

a

Accès en ajout. Si le fichier n'existe pas, un fichier vide est créé. Si le fichier existe déjà, son contenu est conservé. Les données seront écrites à la fin du fichier.

a+

Accès en lecture et en ajout. Si le fichier n'existe pas, un fichier vide est créé. Si le fichier existe déjà, son contenu est conservé et le fichier est ouvert pour être lu au début et écrit à la fin.

Un caractère 'b' peut être ajouté à la chaîne de mode, comme dans "rb". Ceci peut avoir un effet sur les plateformes qui supportent le mode binaire, mais il est ignoré sur tous les systèmes POSIX.

La structure File est comprise dans le module std.stdio.

23-3 - Écrire dans un fichier

Le fichier doit avoir été ouvert dans un des modes d'écriture :

void main()
{
File fichier = File("enregistrements_etudiants", "w");

fichier.writeln("Nom    : ", "Zafer");
fichier.writeln("Numéro : ", 123);
fichier.writeln("Classe : ", "1A");
}

Comme vous devez vous rappeler depuis le chapitre sur les chaines le type des littéraux tels que « enregistrements_etudiants » est string, qui consiste en une suite de caractères immuables. Pour cette raison, il n'est pas possible de construire des objets File à partir d'un nom mutable (par exemple char[]).

Le programme ci-dessus crée ou remplace le contenu d'un fichier nommé enregistrements_etudiants dans le dossier dans lequel il a été lancé (le dossier de travail du programme).

Les noms de fichiers peuvent contenir n'importe quel caractère autorisé par le système de fichiers. Pour être portable, je n'utiliserai que des caractères ASCII.

23-4 - Lire un fichier

Le fichier doit avoir été ouvert dans un des modes de lecture :

import std.stdio;
import std.string;

void main()
{
File fichier = File("enregistrements_etudiants", "r");

while (!fichier.eof()) {
string ligne = chomp(fichier.readln());
writeln("ligne lue -> |", ligne);
}
}

Le programme ci-dessus lit toutes les lignes du fichier nommé enregistrements_etudiants et affiche ces lignes dans la sortie standard.

23-5 - Exercice

Écrire un programme qui demande un nom de fichier à l'utilisateur, ouvre ce fichier, et écrit toutes les lignes non vides de ce fichier dans un autre fichier. Le nom du nouveau fichier peut être basé sur le nom du fichier original. Par exemple, si le nom du fichier original est foo.txt, le nouveau fichier peut être appelé foo.txt.out.

La solution.

24 - auto et typeof

24-1 - auto

Quand on a défini les variables de type File dans le chapitre précédent, on a répété le nom du type des deux côtés de l'opérateur = :

File file = File("enregistrements_etudiants", "w");

Cela semble redondant. C'est également lourd et sujet à erreurs, surtout quand le nom du type est plus long.

NomDeTypeTresLong var = NomDeTypeTresLong(/* ... */);

Heureusement, le nom du type sur le côté gauche de l'affectation n'est pas nécessaire parce que le compilateur peut inférer le type de la variable depuis l'expression à droite de l'affectation. Pour que le compilateur infère le type, le mot-clé auto peut être utilisé :

auto var = NomDeTypeTresLong(/* ... */);

auto peut être utilisé avec n'importe quel type même si le type n'est pas écrit explicitement du côté droit :

auto duree = 42;
auto distance = 1.2;
auto accueil = "Hello";
auto vehicule = JoliVelo("bleu");

Même si auto est l'abréviation de automatique, il ne vient pas d'inférence automatique des types. Il vient de classe automatique de stockage, qui est une notion à propos de la durée de vie des variables. auto est utilisé quand aucun autre indicateur n'est approprié. Par exemple, la définition suivante n'utilise pas auto :

immutable i = 42;

Dans ce code, le compilateur infère le type de i en immutable int (nous verrons immutable dans un chapitre ultérieur).

24-2 - typeof

typeof donne le type d'une expression (ce qui comprend les variables simples, les objets, les littéraux…).

Ce qui suit est un exemple de comment typeof peut être utilisé pour spécifier un type sans l'écrire explicitement :

int valeur = 100;       // déjà défini comme 'int'

typeof(valeur) valeur2; // signifie "type de valeur"
typeof(100) valeur3;    // signifie "type du littéral 100"

Les deux dernières définitions sont équivalentes à ce qui suit :

int valeur2;
int valeur3;

Il est évident que typeof n'est pas nécessaire dans des situations comme celles-ci, quand les types sont déjà connus. Ce mot-clé est surtout utile dans les définitions de modèles (templates), que nous verrons dans des chapitres ultérieurs.

24-3 - Exercices

Comme nous l'avons vu précédemment, le type des littéraux tels que 100 est int (et non short, long, ou n'importe quel autre type). Écrire un programme pour déterminer le type des littéraux à virgule flottante comme 1.2.

typeof() et .stringof sont utiles pour ce programme.

La solution.

25 - Espace de nom (name space)

Tout nom est accessible depuis l'endroit où il a été défini jusqu'à l'endroit où l'on quitte sa portée, de même que dans toutes les portées que la sienne inclut. De ce point de vue, toute portée définit un espace de noms.

Les noms ne sont plus disponibles une fois que l'on a quitté leur portée :

void main()
{
int dehors;

if (uneCondition) {   // ← les accolades commencent une nouvelle portée
int dedans = 1;
dehors = 2;        // ← ça marche ; 'dehors' est défini à cet endroit

} // ← 'dedans' n'est plus disponible au-delà de cet endroit

dedans = 3;   // ← ERREUR de compilation
//   'dedans' n'est pas disponible dans la portée extérieure.
}

Parce que dedans est défini dans la portée de la condition if, elle est disponible seulement dans cette portée. En revanche, outer est disponible et dans la portée globale et dans la portée intérieure.

Définir le même nom dans une portée intérieure n'est pas autorisé :

size_t taille = nombresImpairs.length;

if (uneCondition) {
size_t taille = nombresPremiers.length; // ← ERREUR de compilation
}

25-1 - Définir les noms le plus proche possible de leur première utilisation

Comme nous l'avons fait dans tous les programmes jusqu'à maintenant, les variables doivent être définies avant leur première utilisation :

writeln(nombre);   // ← ERREUR de compilation
//   nombre n'est pas encore connu
int nombre = 42;

Pour que ce code soit acceptable par le compilateur, nombre doit être défini avant d'être utilisé avec writeln. Même s'il n'y a pas de restriction sur combien de lignes avant il devrait être défini, une bonne pratique de programmation est de définir les variables le plus près possible de là où elles sont utilisées pour la première fois.

Voyons ceci dans un programme qui affiche la moyenne des nombres qu'il demande à l'utilisateur. Les programmeurs qui ont l'habitude d'utiliser d'autres langages de programmation peuvent être accoutumés à définir les variables au début des portées :

int nombre;                               // ← ici
int[] nombres;                            // ← ici
double valeurMoyenne;                     // ← ici

write("Combien y a-t-il de nombres ? ");

readf(" %s", &nombre);

if (nombre >= 1) {
nombres.length = nombre;

// ... Supposons que le calcul se fasse ici ...

} else {
writeln("ERREUR : Vous devez rentrer au moins un nombre !");
}

Comparez le code ci-avant avec celui qui suit et qui définit les variables plus tard, quand elles commencent à jouer un rôle dans le programme :

write("Combien y a-t-il de nombres ? ");

int nombre;                              // ← ici
readf(" %s", &nombre);

if (nombre >= 1) {
int[] nombres;                        // ← ici
nombres.length = nombre;

double valeurMoyenne;                 // ← ici

// ... supposons qu'il y a des calculs ici ...

} else {
writeln("ERREUR : vous devez entrer au moins un nombre !");
}

Même si définir toutes les variables au début peut sembler mieux au niveau de la structure, il y a plusieurs avantages à les définir le plus tard possible.

- Vitesse : chaque définition de variable a un coût en vitesse dans le programme. Comme toutes les variables sont initialisées à 0, définir les variables au début entraînerait potentiellement une initialisation coûteuse et inutile si ces variables n'étaient pas utilisées plus tard.

- Risque d'erreurs : chaque ligne qui est entre la définition et l'utilisation d'une variable entraîne un risque plus grand de faire des erreurs de programmation. Pour prendre un exemple, considérez une variable portant un nom aussi commun que « taille ». Il est possible d'utiliser cette variable par inadvertance pour une autre taille avant de l'utiliser réellement pour ce pour quoi elle a été définie. Au moment où la ligne de son utilisation prévue est atteinte, la variable peut avoir une valeur non attendue.

- Lisibilité : quand le nombre de lignes dans une portée est devenu grand, il est probable que la définition d'une variable peut être trop loin dans le code source, forçant le programmeur à faire défiler le code pour trouver cette définition.

- Maintenance du code : le code source est modifié et amélioré constamment : de nouvelles fonctionnalités sont ajoutées, d'anciennes fonctionnalités sont supprimées, les bogues sont corrigés, etc. Ces modifications rendent parfois nécessaire le déplacement d'un groupe de lignes dans une nouvelle fonction.
Quand cela arrive, avoir toutes les variables définies près des lignes qui les utilisent rend plus facile le déplacement de celles-ci comme un groupe cohérent.
Par exemple, dans le code précédent, toutes les lignes dans le bloc if peuvent être déplacées dans une nouvelle fonction. Au contraire, quand les variables sont toujours définies au début, si les lignes doivent être déplacées, les variables qui sont utilisées dans ces lignes doivent être identifiées une à une.

26 - Boucles for

La boucle for a le même but que la boucle while. for permet d'écrire les définitions et expressions relatives à l'itération de la boucle sur la même ligne.

Même si for est utilisée beaucoup moins souvent que foreach en pratique, il est important de comprendre la boucle for en premier. Nous verrons foreach dans un chapitre ultérieur.

26-1 - Sections de la boucle while

La boucle while évalue la condition et continue d'exécuter la boucle tant que la condition est vraie. Par exemple, une boucle pour afficher les nombres entre 1 et 10 peut vérifier la condition « inférieur à 11 » :

while (nombre < 11)

Itérer la boucle peut être fait en incrémentant un compteur à la fin de la boucle :

++nombre;

Pour être compilable en D, nombre doit avoir été défini avant sa première utilisation :

int nombre = 1;

Finalement, il y a ce qui est effectivement fait dans le corps de la boucle :

writeln(nombre);

Ces quatre sections peuvent être définies comme dans les commentaires ci-après :

int nombre = 1;         // ← préparation

while (nombre < 11) {   // ← vérification de la condition
writeln(nombre);     // ← travail de la boucle
++nombre;            // ← itération
}

Les sections d'une boucle while sont exécutées dans l'ordre suivant pendant l'itération de la boucle while :

préparation

vérification de la condition
travail de la boucle
itération

vérification de la condition
travail de la boucle
itération

...

Une instruction break ou une exception levée peut également terminer la boucle.

26-2 - Les sections d'une boucle for

for regroupe trois de ces sections sur une seule ligne. Elles sont écrites à l'intérieur des parenthèses de la boucle for, séparées par des points-virgules. Le corps de la boucle ne contient que ce que la boucle fait :

for (/* préparation */; /* condition */; /* itération */) {
/* travail de la boucle */
}

Le même code est plus cohérent quand il est écrit avec une boucle for :

for (int nombre = 1; nombre < 11; ++nombre) {
writeln(nombre);
}

L'avantage de la boucle for est plus évident quand le code de la boucle a un grand nombre d'instructions. L'expression qui incrémente la variable d'itération est visible sur la ligne for au lieu d'être mélangée avec les autres instructions de la boucle.

Les sections de la boucle for sont exécutées dans le même ordre que la boucle while.

Les instructions break et continue fonctionnent exactement de la même façon dans la boucle for.

La seule différence entre les boucles while et for est l'espace de noms de la variable d'itération. Ceci est expliqué ci-dessous.

Bien que ce soit généralement le cas, la variable d'itération n'a pas à être un entier, et n'est pas forcément modifiée par une opération d'incrémentation. Par exemple, la boucle suivante est utilisée pour afficher les moitiés des valeurs flottantes précédentes :

for (double valeur = 1; valeur > 0.001; valeur /= 2) {
writeln(valeur);
}

L'information énoncée au début de cette partie est techniquement incorrecte, mais couvre la majorité des cas d'utilisation de la boucle for, surtout pour les programmeurs qui ont de l'expérience en C ou en C++. En réalité, la boucle for du D n'a pas trois sections séparées par des points-virgules. Elle a deux sections, dont la première contient la préparation et la condition de la boucle. Sans aller dans les détails de cette syntaxe, voici comment définir deux variables de types différents dans la section de préparation :

for ({ int i = 0; double d = 0.5; } i < 10; ++i) {
writeln("i: ", i, ", d: ", d);
d /= 2;
}

Notez que la section de préparation est la partie entre les accolades qui sont entre les parenthèses et qu'il n'y a pas de point-virgule entre la section de préparation et la section de condition.

26-3 - Les sections peuvent être vides

Les trois sections de la boucle for peuvent être laissées vides.

- Parfois une variable d'itération n'est pas nécessaire, par exemple parce qu'une variable déjà définie est utilisée.

- Parfois la sortie de la boucle peut se faire au moyen d'une instruction break au lieu d'être basée sur la condition de boucle.

- Parfois les expressions d'itération dépendent de certaines conditions qui seront vérifiées dans le corps de la boucle.

Quand toutes les sections sont vides, la boucle for correspond à une boucle infinie :

for ( ; ; ) {
// ...
}

Une telle boucle peut être utilisée pour ne jamais finir ou finir avec une instruction break.

26-4 - L'espace de noms de la variable d'itération

La seule différence entre la boucle for et la boucle while est l'espace de noms de la variable définie pendant la préparation de la boucle : la variable n'est accessible qu'à l'intérieur de la boucle, et non à l'extérieur :

for (int i = 0; i < 5; ++i) {
// ...
}

writeln(i);   // ← Erreur de compilation
//   i n'est pas accessible ici

En revanche, quand la variable est définie dans l'espace de noms qui contient la boucle while, le nom est accessible même après la boucle :

int i = 0;

while (i < 5) {
// ...
++i;
}

writeln(i);   // ← Ça fonctionne ; i est accessible ici

Nous avons vu le conseil de définir les noms le plus près possible de leur première utilisation dans le chapitre précédent. De façon similaire, plus l'espace de noms d'une variable est petit, mieux c'est. De ce fait, quand la variable d'itération n'est pas utilisée hors de la boucle, for est mieux que while.

26-5 - Exercices

1. Afficher le tableau 9×9 suivant en utilisant deux boucles for, l'une à l'intérieur de l'autre :

2. Utiliser une ou plusieurs boucles for pour afficher le caractère * de façon à produire des formes géométriques :

Les solutions.

27 - L'opérateur ternaire « ? »:

L'opérateur ternaire ? : a un fonctionnement très similaire de celui de l'instruction if-else :

if (/* condition */) {
/* ... expression(s) à exécuter si vrai */

} else {
/* ... expression(s) à exécuter si faux */
}

L'instruction if exécute soit le bloc correspondant au cas où la condition est vraie, soit le bloc correspondant au cas où la condition est fausse. Comme vous devez vous en rappeler, étant une instruction, elle n'a pas de valeur ; if affecte principalement l'exécution des blocs de code.

En revanche, l'opérateur ? : est une expression. En plus de fonctionner d'une manière similaire à l'instruction if-else, il produit une valeur. Ce qui suit est un équivalent du code ci-dessus :

/* condition */ ? /* expression si vrai */ : /* expression si faux */

L'opérateur ? : utilise trois expressions, d'où son nom d'opérateur ternaire.

La valeur qui est produite par cet opérateur est soit la valeur de l'expression si vrai, soit celle de l'expression si faux. Du fait que c'est une expression, il peut être utilisé partout où les expressions peuvent être utilisées.

Les exemples suivants comparent l'opérateur ? : et l'instruction if-else. L'opérateur ternaire est plus concis dans les cas similaires à ces exemples.

- Initialisation. Pour initialiser à 366 s'il s'agit d'une année bissextile, à 365 sinon :

int jours = estAnneeBissextile ? 366 : 365;

- Avec if, une façon de faire est de définir la variable sans valeur initiale explicite et d'affecter la valeur voulue :

int jours;

if (estAnneeBissextile) {
jours = 366;

} else {
jours = 365;
}

- Une variante avec if est d'initialiser la variable avec la valeur des années non bissextiles et de l'incrémenter s'il s'agit d'une année bissextile :

int jours = 365;

if (estAnneeBissextile) {
++jours;
}

- Affichage. Afficher un message différemment selon une condition :

writeln("Le verre est à moitié ",
estOptimiste ? "plein." : "vide.");

- Avec if, la première et la dernière parties peuvent être affichées séparément :

write("Le verre est à moitié ");

if (estOptimiste) {
writeln("plein.");

} else {
writeln("vide.");
}

- Ou alors, le message entier peut être affiché séparément :

if (estOptimiste) {
writeln("Le verre est à moitié plein.");

} else {
writeln("Le verre est à moitié vide.");
}

- Calcul. Augmenter le score d'un gagnant au backgammon de 2 points ou 1 point selon que le jeu s'est fini sur un gammon ou pas :

score += estGammon ? 2 : 1;

- Transposition directe avec if :

if (estGammon) {
score += 2;

} else {
score += 1;
}

- Ou alors, on peut d'abord incrémenter de 1 et incrémenter une seconde fois s'il y a eu gammon :

++score;

if (estGammon) {
++score;
}

Comme on peut le voir dans les exemples précédents, le code est plus concis et plus clair avec l'opérateur ternaire dans certaines situations.

27-1 - Les types des expressions de sélection doivent correspondre

La valeur de l'opérateur ? : est soit la valeur de l'expression si vrai, soit la valeur de l'expression si faux. Les types de ces expressions n'ont pas besoin d'être les mêmes, mais ils doivent correspondre. Par exemple, ils peuvent être tous deux de type entier comme int et long, mais ne peuvent pas être de types qui ne se correspondent pas, comme int et string.

Dans les exemples précédents, les valeurs sélectionnées selon la valeur de estAnneeBissextile étaient 366 et 365. Les deux valeurs sont du type int et se correspondent.

Pour voir une erreur de compilation causée par des valeurs des expressions ne correspondant pas, considérons la composition d'un message qui rapporte le nombre d'objets à expédier. Affichons « Une douzaine » quand la valeur est 12 : « Une douzaine d'objets seront expédiés ». Dans les autres cas, on affiche le nombre exact : « 3 objets seront expédiés ».

On pourrait penser que la partie variable du message peut être sélectionnée avec l'opérateur ?:

writeln((nombre == 12) ? "Une douzaine d'" : nombre, // ← ERREUR de compilation
" objets seront expédiés.");

Les expressions ne correspondent pas parce que le type de "Une douzaine d'" est string et le type de nombre est int.

Une solution est de convertir nombre en string. La fonction to!string du module std.conv donne une valeur de type string depuis le paramètre donné :

import std.conv;
// ...
writeln((nombre == 12) ? "Une douzaine d'" : to!string(nombre),
" objets seront expédiés.");

Maintenant que les expressions de sélection de l'opérateur ? sont toutes deux du type string, le code compile et affiche le message attendu.

27-2 - Exercice

Le programme doit lire une valeur nette de type int. Quand cette valeur est positive, on l'interprète comme un gain et quand elle est négative, on l'interprète comme une perte.

Le programme doit afficher un message qui contient « gagnés » ou « perdus », selon que la quantité est positive ou négative. Par exemple, « 100 € gagnés » ou « 70 € perdus ». Même s'il se peut que ce soit plus approprié, n'utilisez pas l'instruction if dans cet exercice.

La solution.

28 - Littéraux

Les programmes effectuent leur tâche en manipulant les valeurs de variables et d'objets. Ils produisent de nouvelles valeurs et de nouveaux objets en les utilisant avec des fonctions et des opérateurs.

Certaines valeurs n'ont pas besoin d'être produites pendant l'exécution du programme ; elles sont écrites directement dans le code source. Par exemple, la valeur flottante 0.75 et la chaîne "Prix total :" ci-dessous ne sont pas calculées :

prixSoldé = prixDeBase * 0.75;
prixTotal += nombre * prixSoldé;
writeln("Prix total : ", prixTotal);

De telles valeurs écrites littéralement dans le code source sont appelées littéraux. Nous avons utilisé beaucoup de littéraux dans les programmes que nous avons écrits jusqu'à maintenant. Nous allons couvrir tous les types de littéraux et leur syntaxe.

28-1 - Littéraux entiers

Les littéraux de type entier peuvent être écrits de quatre façons différentes : le système décimal que nous utilisons dans la vie de tous les jours ; les systèmes hexadécimal et binaire, qui sont plus adaptés dans certains calculs ; et le système octal, qui peut être nécessaire dans des cas très rares.

Pour rendre le code plus lisible ou pour n'importe quelle autre raison, il est possible d'insérer des caractères _ n'importe où parmi les caractères des littéraux entiers. Par exemple, pour séparer les nombres à intervalles de trois chiffres comme dans 1_234_567. Ces caractères sont optionnels ; ils sont ignorés par le compilateur.

- Dans le système décimal : les littéraux sont indiqués par les chiffres décimaux exactement de la même manière que nous le faisons dans la vie de tous les jours, comme dans 12. Dans ce système, le premier chiffre ne peut pas être 0 parce que ce chiffre est réservé pour indiquer le système octal dans la plupart des autres langages. En D, les littéraux entiers ne peuvent pas commencer avec le chiffre 0, afin d'éviter des bogues causés par cette différence subtile. Ceci ne s'applique par à 0 lui-même : 0 est zéro.

- Dans le système hexadécimal : les littéraux commencent par 0x ou 0X et incluent les chiffres du système hexadécimal : « 0123456789abcdef » et « ABCDEF » comme dans 0x12ab00fe.

- Dans le système octal : les littéraux sont indiqués par le modèle octal du module conv et incluent les chiffres du système octal : « 01234567 », comme dans octal!576.

- Dans le système binaire : les littéraux commencent par 0b ou 0B et incluent les chiffres du système binaire : 0 et 1, comme dans 0b01100011.

28-2 - Les types des littéraux entiers

Exactement comme n'importe quelle autre valeur, tout littéral est typé. Les types des littéraux ne sont pas indiqués explicitement comme int, double, etc. Le compilateur infère le type depuis la valeur et la syntaxe du littéral lui-même.

Même si la plupart du temps les types des littéraux ne sont pas importants, parfois les types peuvent ne pas correspondre aux expressions dans lesquelles ils sont utilisés. Dans de tels cas, le type doit être explicité.

Par défaut, les littéraux entiers sont inférés comme étant de type int. Quand la valeur est trop grande pour être représentée par un int, le compilateur utilise le procédé suivant pour décider de quel type le littéral est :

- si la valeur du littéral ne rentre pas dans int et qu'il est spécifié dans le système décimal alors son type est long ;

- si la valeur du littéral ne rentre pas dans int et qu'il est spécifié dans n'importe quel autre système alors le compilateur essaie uint, long et ulong dans cet ordre pour déterminer quel type pourra contenir la valeur.

Pour voir ce procédé en action, essayons le programme suivant qui se sert de typeof et stringof :

import std.stdio;

void main()
{
writeln("\n--- Ces nombres sont écrits en décimal ---");

// rentre dans int, le type est donc int
writeln(        2_147_483_647, "\t\t",
typeof(2_147_483_647).stringof);

// ne rentre pas dans int et est décimal, le type est donc long
writeln(        2_147_483_648, "\t\t",
typeof(2_147_483_648).stringof);

writeln("\n--- Ces nombres ne sont PAS écrits en décimal ---");

// rentre dans int, le type est donc int
writeln(        0x7FFF_FFFF, "\t\t",
typeof(0x7FFF_FFFF).stringof);

// ne rentre pas dans un int et n'est pas décimal, le type est donc uint
writeln(        0x8000_0000, "\t\t",
typeof(0x8000_0000).stringof);

// ne rentre pas dans un uint et n'est pas décimal, le type est donc long
writeln(        0x1_0000_0000, "\t\t",
typeof(0x1_0000_0000).stringof);

// ne rentre pas dans un long et n'est pas décimal, le type est donc ulong
writeln(        0x8000_0000_0000_0000, "\t\t",
typeof(0x8000_0000_0000_0000).stringof);
}

La sortie :

--- Ces nombres sont écrits en décimal ---
2147483647     int
2147483648     long

--- Ces nombres ne sont PAS écrits en décimal ---
2147483647     int
2147483648     uint
4294967296     long
9223372036854775808     ulong

28-2-1 - Le suffixe L

Indépendamment de la grandeur de la valeur, si elle finit par un L comme dans 10L, le type est long.

28-2-2 - Le suffixe U

Indépendamment de la grandeur de la valeur, si elle finit par un U comme dans 10U, le type est unsigned. La minuscule 'u' peut également être utilisée.

Les indicateurs L et U peuvent être utilisés ensemble dans n'importe quel ordre. Par exemple, 7UL et 8LU sont toutes les deux ulong.

28-3 - Les littéraux flottants

Les littéraux flottants peuvent être spécifiés soit dans le système décimal (exemple : 1.1234), soit dans le système hexadécimal (0x9a.bc). (NDT Le séparateur décimal est un point.)

Dans le système décimal : un exposant peut être ajouté après le caractère e ou E, signifiant « fois 10 puissance ». Par exemple, 3.4e5 signifie « 3,4 fois 10 puissance 5 ». Un caractère + peut aussi être indiqué avant la valeur de l'exposant, mais cela n'a aucun effet. Par exemple, 5.6e2 et 5.6e+2 sont la même chose.

Le caractère - tapé avant la valeur de l'exposant change son sens, qui devient « divisé par 10 puissance ». Par exemple, 7.8e-3 signifie « 7.8 divisé par 10 puissance 3 ».

Dans le système hexadécimal : la valeur commence par 0x ou 0X et les chiffres de part et d'autre du point sont ceux du système hexadécimal. Comme dans ce système, e et E sont des chiffres valides, l'exposant est indiqué avec p ou P.

Une autre différence est que l'exposant ne veut pas dire « 10 puissance » mais « 2 puissance ». Par exemple, la partie P4 dans 0xabc.defP4 veut dire « 2 puissance 4 ».

Les littéraux en virgule flottante ont presque toujours un point, mais il peut être omis si un exposant est indiqué. Par exemple, 2e3 est un littéral flottant dont la valeur est 2000.

La valeur avant le point peut être omise si elle est nulle. Par exemple, .25 est un littéral qui a la valeur d'un quart.

Les tirets du bas optionnels (_) peuvent aussi être utilisés avec les littéraux flottant : 1_000.5.

28-3-1 - Les types des littéraux flottant

Sauf si explicitement indiqué, le type d'un littéral flottant est double. Les indicateurs f et F signifient float, et l'indicateur L signifie real. Par exemple, 1.2 est double, 3.4f est float et 5.6L est real.

28-4 - Les littéraux de caractères

Les littéraux de caractères sont indiqués avec des apostrophes (guillemets anglais simples) comme dans 'a', '\n', '\x21', etc.

Il y a différentes manières d'écrire un littéral de caractère.

Directement. Le caractère peut être écrit directement avec le clavier ou copié depuis un texte : « a », « ş », etc.

Avec une lettre spéciale. Le caractère est indiqué par un antislash suivi d'une lettre spéciale ; par exemple, le caractère antislash lui-même peut être désigné de cette manière : '\\'. Voici la liste des lettres spéciales :

Syntaxe

Définition

\'

guillemet simple

\"

guillemet double

\?

point d'interrogation

\\

antislash

\a

alerte (son de cloche dans certains terminaux)

\b

caractère de suppression

\f

nouvelle page

\n

nouvelle ligne

\r

retour chariot

\t

tabulation

\v

tabulation verticale

Par son code ASCII étendu. Les codes peuvent être indiqués soit dans le système hexadécimal, soit dans le système octal. Quand le système hexadécimal est utilisé, le littéral doit commencer par \x et doit utiliser deux chiffres pour le code ; quand le système octal est utilisé, le littéral doit commencer par \ et comporter de un à trois chiffres. Par exemple, les littéraux 'x21' et ' \41' désignent tous les deux le point d'exclamation.

Par son code Unicode. Quand le littéral est indiqué avec un u suivi par 4 chiffres hexadécimaux, son type est wchar. Quand il est indiqué avec U suivi de 8 chiffres hexadécimaux, son type est dchar. Par exemple, '\u011e' et '\U0000011e' désignent tous les deux le caractère Ğ et ont respectivement les types wchar et dchar.

Par son nom d'entité (comme en HTML). Les caractères qui ont un nom peuvent être désignés par ce nom en utilisant la syntaxe '\&nom;' (voir le tableau des noms de caractères). Par exemple, '\&euro;' désigne le caractère €, '\&hearts;' le caractère ♥ et '\&copy;' le caractère ©.

28-5 - Les littéraux de chaîne

Les littéraux de chaîne sont une combinaison de littéraux de caractères et peuvent être désignés de diverses manières.

28-5-1 - Les littéraux de chaîne entourés de guillemets anglais doubles

La manière la plus commune d'écrire un littéral de chaîne est d'entrer les caractères entre guillemets anglais doubles, comme dans "salut". Les caractères individuels d'un littéral de chaîne suivent les règles des littéraux de caractères. Par exemple, le littéral "A4 ka\u011fıt: 3\&frac12;TL" est le même que "A4 kağıt: 3½TL".

28-5-2 - Les littéraux de chaîne Wysiwyg

Quand les littéraux de chaîne sont écrits dans des guillemets obliques (accents graves, apostrophes inversées, backticks, backquotes), les caractères de la chaîne n'obéissent pas aux règles spéciales de la syntaxe d'un littéral de caractère. Par exemple, le littéral `c:\nurten` peut être un nom de répertoire sur le système d'exploitation Windows. S'il était écrit avec des guillemets doubles, le '\n' désignerait le caractère de nouvelle ligne :

writeln(`c:\nurten`);
writeln("c:\nurten");

Sortie :

c:\nurten  ← wysiwyg (what you see is what you get, ce que vous voyez est ce que vous obtenez)
c:         ← Le littéral caractère est pris comme une nouvelle ligne
urten

Les littéraux de chaîne Wysiwyg (NDT What You See Is What You Get : « ce que vous voyez est ce que vous obtenez ») peuvent également être écrits entre guillemets doubles en les préfixant avec le caractère r : r"c:\nurten" est aussi un littéral de chaîne wysiwyg.

28-5-3 - Littéraux de chaîne hexadécimaux

Dans des situations où tous les caractères d'une chaîne doivent être indiqués dans le système hexadécimal, au lieu d'entrer \x avant chacun d'eux, un caractère x peut être ajouté avant le guillemet double ouvrant. Dans ce cas, chaque caractère du littéral de chaîne est pris comme étant écrit en hexadécimal. De plus, le littéral peut contenir des espaces et ceux-ci seront ignorés par le compilateur. Par exemple, "\x44\x64\x69\x6c\x69" et x"44 64 69 6c 69" désignent la même chaîne.

28-5-4 - Littéraux de chaîne délimités

Le littéral de chaîne peut contenir des délimiteurs qui sont entrés à l'intérieur des guillemets anglais doubles. Ces délimiteurs ne sont pas considérés comme faisant partie de la valeur du littéral. Les littéraux de chaîne délimités commencent par un q avant le guillemet double ouvrant. Par exemple, la valeur de q".hello." est "hello", les points ne font pas partie de la valeur. S'il finit par une nouvelle ligne, le délimiteur peut avoir plus d'un caractère :

writeln(q"MON_DELIMITEUR
première ligne
seconde ligne
MON_DELIMITEUR");

MON_DELIMITEUR ne fait pas partie de la valeur :

première ligne
seconde ligne

28-5-5 - Littéraux de chaîne « jetons »

Les littéraux de chaîne qui commencent par q et qui utilisent { et } comme délimiteurs peuvent contenir du code D correct :

auto str = q{int nombre = 42; ++nombre;};
writeln(str);

La sortie :

int nombre = 42; ++nombre;

Cette fonctionnalité est particulièrement utile pour aider les éditeurs de textes à colorer le code D dans les chaînes de caractères.

28-5-6 - Type des littéraux de chaîne

Par défaut, le type d'un littéral de chaîne est immutable(char)[]. On peut ajouter un caractère c, w ou d pour spécifier explicitement le type de la chaîne comme étant immutable(char)[], immutable(wchar)[] ou immutable(dchar)[] respectivement. Par exemple, les caractères de la chaîne « hello »d sont de type immutable(dchar).

Nous avons vu dans le chapitre sur les chaines que ces trois types de chaînes ont respectivement pour alias string, wstring et dstring.

28-6 - Les littéraux sont calculés lors de la compilation

Il est possible d'utiliser des expressions littérales. Par exemple, au lieu d'écrire le nombre total de secondes du mois de janvier comme 2678400 ou 2_678_400, il est possible de l'écrire par un calcul plus explicite comme 60 * 60 * 24 * 31. Les opérations de multiplication n'influent pas sur la vitesse d'exécution du programme, car le programme obtenu après compilation est exactement le même que si 2678400 avait été écrit à la place de l'opération.

La même chose s'applique aux littéraux de chaîne. Par exemple, l'opération de concaténation dans "bonjour" ~ " le " ~ "monde" est exécutée lors de la compilation, pas pendant l'exécution. Le programme est compilé comme si le code ne contenait que le littéral de chaîne "bonjour le monde".

28-7 - Exercices

1. La ligne suivante donne une erreur de compilation :

int quantite = 10_000_000_000;    // ← ERREUR de compilation

2. Modifiez le programme de sorte que la ligne puisse être compilée et que quantite vaille dix milliards.

3. Écrire un programme qui augmente la valeur d'une variable et qui l'affiche dans une boucle infinie. La valeur doit toujours être affichée sur la même ligne :

Nombre : 25774       ← toujours sur la même ligne

4. Un caractère spécial autre que '\n' peut être utilisé ici.

Les solutions.

29 - Sortie formatée

Ce chapitre traite des fonctionnalités du module std.format de Phobos, et non des fonctionnalités du langage D lui-même. Les indicateurs de formatage d'entrée et de sortie du D sont similaires à ceux du C. Avant d'aller plus loin, je voudrais résumer ici les indicateurs de format et les drapeaux :

Drapeaux

(peuvent être combinés)

-

aligner à gauche

+

afficher le signe

#

afficher d'une autre manière

0

compléter avec des zéros

espace

compléter avec des espaces

Indicateurs de format

s

par défaut

b

binaire

d

décimal

o

octal

x,X

hexadécimal

e,E

écriture scientifique

f,F

virgule flottante

g,G

comme e ou f

a,A

virgule flottante hexadécimal

Nous avons utilisé des fonctions comme writeln avec de multiples paramètres si nécessaire. Les paramètres sont alors convertis vers leur représentation en chaîne de caractères et envoyés vers la sortie.

Parfois, ce n'est pas suffisant. La sortie peut devoir suivre un certain format. Jetons un œil sur le code suivant, utilisé pour afficher les éléments d'une facture :

éléments ~= 1.23;
éléments ~= 45.6;

for (int i = 0; i != éléments.length; ++i) {
writeln("Élément ", i + 1, " : ", éléments[i]);
}

La sortie :

Élément 1 : 1.23
Élément 2 : 45.6

L'information est correcte. Cependant, il est parfois souhaitable d'avoir un format de sortie différent. Par exemple, on pourrait vouloir qu'il y ait toujours deux chiffres après la virgule et que les points soient alignés, comme dans la sortie suivante :

Élément 1 :      1.23
Élément 2 :     45.60

Dans de tels cas, la sortie formatée se révèle utile. Les fonctions de sortie que nous avons vues jusqu'à maintenant ont des équivalents contenant la lettre f, pour « formaté », dans leur nom : writef() et writefln(). Le premier paramètre de ces fonctions est une chaîne de formatage qui décrit comment les autres paramètres doivent être affichés.

Par exemple, la chaîne de formatage qui ferait afficher la sortie souhaitée ci-dessus à writefln() est la suivante :

writefln("Élément %d : %9.02f", i + 1, éléments[i]);

La chaîne de formatage contient des caractères normaux qui sont affichés tels quels, ainsi que des indicateurs de format spéciaux qui correspondent à chaque paramètre à afficher. Les indicateurs de format commencent par le caractère % et finissent par un caractère de formatage. La chaîne de formatage ci-dessus contient deux indicateurs de format : %d et %9.02f.

Chaque indicateur est associé aux paramètres qui suivent dans l'ordre. Par exemple, %d est associé avec i+1 et %9.02f est associé avec éléments[i]. Chaque indicateur donne le format du paramètre auquel il correspond (les indicateurs de format peuvent également avoir des paramètres numériques ; nous verrons cela plus loin dans le chapitre).

Tous les autres caractères de la chaîne de format qui ne font pas partie d'un indicateur de format sont affichés tels quels. Ils correspondent aux caractères surlignés dans la chaîne suivante : « Élément %d:%9.02f ».

Les indicateurs de format comportent six parties, la plupart étant optionnelles. La partie nommée position sera expliquée un peu plus tard. Les cinq autres parties sont les suivantes (note : les espaces entre ces parties sont insérés ici pour des raisons de lisibilité, ils ne font pas partie des indicateurs) :

% drapeaux largeur précision caractère_de_formatage

Le caractère % au début et le caractère de formatage à la fin sont requis, les autres sont optionnels.

Du fait que % a une signification spécifique dans les chaînes de formatage, quand un % doit être affiché, il doit être écrit comme ceci : %%.

29-1 - Caractères de formatage

- b : le paramètre entier est affiché dans le système binaire.

- o : le paramètre entier est affiché dans le système octal.

- x et X : le paramètre entier est affiché dans le système hexadécimal ; avec des minuscules pour x et des majuscules pour X.

- d : le paramètre entier est affiché dans le système décimal ; un signe négatif est également affiché s'il s'agit d'un type signé et que la valeur est strictement négative.

int valeur = 12;

writefln("Binaire     : %b", valeur);
writefln("Octal       : %o", valeur);
writefln("Hexadécimal : %x", valeur);
writefln("Décimal     : %d", valeur);

- Sortie :

Binaire     : 1100
Octal       : 14
Hexadécimal : c
Décimal     : 12

- e : le paramètre flottant est affiché selon les règles suivantes :

- un seul chiffre avant le point ;

- un point si la précision n'est pas nulle ;

- des chiffres après le point, en nombre déterminé par la précision (6 par défaut) ;

- le caractère e (signifiant « 10 puissance ») ;

- le signe + ou -, selon si l'exposant est plus grand ou plus petit que zéro ;

- l'exposant, constitué d'au moins deux chiffres.

- E : pareil que e, sauf que le caractère E est affiché à la place de e.

- f et F : le paramètre flottant est affiché dans le système décimal ; il y a au moins un chiffre avant le point et la précision par défaut est 6.

- g : pareil que f si l'exposant est entre -5 et la précision ; sinon pareil que e. La précision n'indique pas le nombre de chiffres après le point, mais les chiffres significatifs de la valeur complète. S'il n'y a pas de chiffres significatifs après le point, le point n'est pas affiché. Les zéros les plus à droite après le point ne sont pas affichés.

- G : pareil que pour g mais avec E ou F.

- a : le paramètre en virgule flottante est affiché dans le format flottant hexadécimal :

- les caractères 0x ;

- un seul chiffre hexadécimal ;

- le point si la précision n'est pas nulle ;

- les chiffres après le point, dont le nombre est déterminé par la précision ; si la précision est indiquée, il y a autant de chiffres que nécessaire ;

- le caractère p (signifiant « 2 puissance ») ;

- le caractère + ou - selon si l'exposant est plus grand ou plus petit que zéro ;

- l'exposant consistant en au moins un chiffre (l'exposant de la valeur 0 est 0).

- A : pareil que a, mais avec 0X et P.

double valeur = 123.456789;

writefln("avec e : %e", valeur);
writefln("avec f : %f", valeur);
writefln("avec g : %g", valeur);
writefln("avec a : %a", valeur);

- Sortie :

avec e : 1.234568e+02
avec f : 123.456789
avec g : 123.457
avec a : 0x1.edd3c07ee0b0bp+6

- s : la valeur est affichée de la même manière que dans une sortie non formatée, selon le type de paramètre :

- les valeurs booléennes comme true ou false ;

- les valeurs entières comme avec %d ;

- les valeurs en virgule flottante comme avec %g ;

- les chaînes codées en UTF-8 ; la précision détermine le nombre maximal d'octets à utiliser (souvenez-vous que dans le codage UTF-8, le nombre d'octets n'est pas le même que le nombre de caractères ; par exemple la chaîne "ağ" a deux caractères consistant en un total de trois octets) ;

- les structures et les classes comme le retour de la fonction toString() de leur type ; la précision détermine le nombre maximal d'octets à utiliser ;

- les tableaux avec leurs éléments côte à côte.

bool b = true;
int i = 365;
double d = 9.87;
string s = "formatée";
auto o = File("fichier_test", "r");
int[] a = [ 2, 4, 6, 8 ];

writefln("booléen : %s", b);
writefln("entier  : %s", i);
writefln("double  : %s", d);
writefln("chaîne  : %s", s);
writefln("objet   : %s", o);
writefln("tableau : %s", a);

- Sortie :

booléen : true
entier  : 365
double  : 9.87
chaîne  : formatée
objet   : File(55738FA0)
tableau : [2, 4, 6, 8]

29-2 - Largeur

Cette partie détermine la largeur du champ dans lequel le paramètre est affiché. Si la largeur est indiquée avec le caractère *, alors c'est le paramètre suivant qui indique cette largeur. Si la largeur est une valeur négative, le drapeau - est implicite.

int valeur = 100;

writefln("Dans un champ de 10 caractères : %10s", valeur);
writefln("Dans un champ de 5 caractères  : %5s", valeur);

Sortie :

Dans un champ de 10 caractères :        100
Dans un champ de 5 caractères  :   100

29-3 - Précision

La précision est indiquée après le point dans l'indicateur de format. Pour les types en virgule flottante, elle détermine la précision de la représentation des valeurs. Si la précision est indiquée par le caractère *, la précision est obtenue à partir du paramètre suivant (ce paramètre doit être un int). Les précisions négatives sont ignorées.

double valeur = 1234.56789;

writefln("%.8g", valeur);
writefln("%.3g", valeur);
writefln("%.8f", valeur);
writefln("%.3f", valeur);

Sortie :

1234.5679
1.23e+03
1234.56789000
1234.568

auto nombre = 0.123456789;
writefln("Nombre : %.*g", 4, nombre);

Sortie :

Nombre : 0.1235

29-4 - Drapeaux

On peut indiquer plus d'un drapeau à la fois.

- - : la valeur est alignée à gauche dans son champ ; ce drapeau annule le drapeau 0.

int valeur = 123;

writefln("Normalement aligné à droite : |%10d|", valeur);
writefln("Aligné à gauche             : |%-10d|", valeur);

- Sortie :

Pas d'effet sur les valeurs négatives : -50
Valeur positive avec le drapeau +     : +50
Valeur positive sans le drapeau +     : 50

- + : si la valeur est positive, elle est préfixée par le caractère + ; ce drapeau annule le drapeau espace.

writefln("Pas d'effet sur les valeurs négatives : %+d", -50);
writefln("Valeur positive avec le drapeau +     : %+d", 50);
writefln("Valeur positive sans le drapeau +     : %d", 50);

- Sortie :

Pas d'effet sur les valeurs négatives : -50
Valeur positive avec le drapeau +     : +50
Valeur positive sans le drapeau +     : 50

- # : affiche la valeur sous une autre forme, selon caractère_de_formatage.

- o : le premier caractère de la valeur octale est toujours 0 ;

- x et X : si la valeur n'est pas zéro, elle est préfixée avec 0x ou 0X ;

- virgules flottantes : un point est affiché même s'il n'y a pas de chiffres significatifs après le point ;

- g et G : même les zéros non significatifs après le point sont affichés.

writefln("L'octal commence par un 0        : %#o", 1000);
writefln("L'hexadécimal commence par 0x    : %#x", 1000);
writefln("Un point même si non nécessaire  : %#g", 1f);
writefln("Les zéros à droite sont affichés : %#g", 1.2);

- Sortie :

L`octal commence par un 0        : 01750
L'hexadécimal commence par 0x    : 0x3e8
Un point même si non nécessaire  : 1.00000
Les zéros à droite sont affichés : 1.20000

- 0 : le champ est complété avec des zéros (sauf si la valeur est nan ou infinity) ; si la précision est également indiquée, ce drapeau est ignoré.

writefln("Dans un champ de 8 caractères : %08d", 42);

- Sortie :

Dans un champ de 8 caractères : 00000042

- caractère espace : si la valeur est positive, un caractère espace est préfixé pour aligner les valeurs négatives et positives.

writefln("Pas d'effet sur les valeurs négatives : % d", -34);
writefln("Valeur positive avec un espace        : % d", 56);
writefln("Valeur positive sans espace           : %d", 56);

1. Sortie :

Pas d`effet sur les valeurs négatives : -34
Valeur positive avec un espace        :  56
Valeur positive sans espace           : 56

29-5 - Paramètres de position

Nous avons vu ci-dessus que les paramètres sont associés un à un avec les indicateurs dans la chaîne de formatage. Il est possible d'utiliser des numéros de position dans les indicateurs de format. Ceci permet d'associer des indicateurs avec des paramètres spécifiques. Les paramètres sont numérotés de façon croissante, en commençant par un 1. Les numéros de paramètres sont indiqués juste après le caractère %, suivi d'un $ :

%   position$   drapeaux   largeur   précision   caractère_de_formatage

Un avantage des paramètres de position est d'utiliser le même paramètre à plusieurs endroits dans la même chaîne de formatage :

writefln("%1$d %1$x %1$o %1$b", 42);

La chaîne de formatage utilise le paramètre numéro 1 dans quatre indicateurs différents pour l'afficher en formats décimal, hexadécimal, octal et binaire :

42 2a 52 101010

Une autre application de ces paramètres de position est de prendre en charge plusieurs langues. Lorsqu'ils sont référés par des numéros de position, les paramètres peuvent être déplacés n'importe où avec une chaîne de formatage spécifique à une langue. Par exemple, le nombre d'étudiants d'une classe peut être affiché de cette manière :

writefln("Il y a %s étudiants dans la classe %s.", nombre, classe);

Sortie :

Il y a 20 étudiants dans la classe 1A.

Supposons que le programme doive aussi prendre en charge le turc. La chaîne de formatage doit alors être choisie en fonction de la langue choisie. La méthode suivante utilise l'opérateur ternaire :

auto format = (langue == "fr"
? "Il y a %s étudiants dans la classe %s."
: "%s sınıfında %s öğrenci var.");

writefln(format, nombre, classe);

Malheureusement, quand les paramètres sont associés un à un, la classe et le nombre d'étudiants sont inversés dans le message turc ; la classe s'affiche là où le nombre devrait être et inversement :

20 sınıfında 1A öğrenci var.   ← Faux : signifie "classe 20", et "1A étudiants" !

Pour éviter cela, les paramètres peuvent être indiqués par leur position pour associer chaque indicateur avec le bon paramètre :

auto format = (langue == "fr"
? "Il y a %s étudiants dans la classe %s."
: "%2$s sınıfında %1$s öğrenci var.");

writefln(format, nombre, classe);

Maintenant, les paramètres apparaissent dans le bon ordre, indépendamment de la langue choisie :

Il y a 20 étudiants dans la pièce 1A.

1A sınıfında 20 öğrenci var.

29-6 - format

La sortie formatée est également utilisable avec la fonction format() du module std.string. format() fonctionne de la même manière que writef() mais retourne le résultat dans une chaîne au lieu de l'afficher :

import std.stdio;
import std.string;

void main()
{
write("Quel est ton nom ? ");
auto nom = chomp(readln());

auto resultat = format("Bonjour %s!", nom);
}

Le programme pourra utiliser ce résultat plus tard.

29-7 - Exercices

1. Écrire un programme qui lit une valeur et qui l'affiche dans le système hexadécimal.

2. Écrire un programme qui lit une valeur flottante et qui l'affiche comme un pourcentage avec deux chiffres après le point. Par exemple, si la valeur est 1.2345, il devrait afficher 1.23 %.

Les solutions.

30 - Entrée formatée

Il est possible d'indiquer le format des données attendues à l'entrée. Le format indique aussi bien les données à lire que les caractères qui doivent être ignorés.

Les indicateurs de formatage en entrée du D sont similaires à ceux du langage C.

L'indicateur de formatage " %s", que nous avons déjà utilisé dans les chapitres précédents, lit une donnée selon le type de la variable. Par exemple, comme le type de la variable suivante est double, les caractères à l'entrée seront lus comme une valeur flottante :

double nombre;

readf(" %s", &nombre);

La chaîne de formatage peut contenir trois types d'information :

- le caractère espace : indique zéro, un ou plusieurs caractères blancs dans l'entrée et indique que tous ces caractères devraient être lus et ignorés ;

- indicateur de format : comme pour les indicateurs de format de sortie, les indicateurs de format d'entrée commencent par le caractère % et déterminent le format de la donnée à lire ;

- n'importe quel autre caractère : indique que ces caractères sont attendus dans l'entrée tels quels, et qu'ils doivent être lus et ignorés.

La chaîne de formatage permet de sélectionner des informations spécifiques dans l'entrée et d'ignorer les autres.

Penchons-nous sur un exemple qui utilise les trois types d'information dans la chaîne de formatage. Supposons que l'on s'attende à voir apparaître le numéro d'étudiant et la classe dans l'entrée dans le format suivant :

numero:123 classe:90

Supposons également que les étiquettes numero: et classe: doivent être ignorées. La chaîne de formatage suivante sélectionnera les valeurs du numéro et de la classe et ignorera les autres caractères :

Les caractères surlignés doivent apparaître tels quels dans l'entrée ; readf() les lit et les ignore.

Le caractère espace qui apparaît dans la chaîne de formatage fera que tous les caractères blancs qui apparaîtront exactement à cet endroit seront lus et ignorés.

Comme le caractère % a un sens spécial dans les chaînes de formatage, quand ce caractère lui-même doit être lu et ignoré, il doit être écrit deux fois : %%.

Dans le chapitre sur les chaines pour lire une ligne de données depuis l'entrée, nous avons utilisé chomp(readln()). On peut également faire ceci en plaçant un caractère \n à la fin de la chaîne de formatage :

import std.stdio;

void main()
{
write("Prénom : ");
string prenom;
readf(" %s\n", &prenom);  // ← \n à la fin

write("Nom : ");
string nom;
readf(" %s\n", &nom);      // ← \n à la fin

write("Âge : ");
int age;
readf(" %s", &age);

writefln("%s %s (%s)", prenom, nom, age);
}

Les caractères \n à la fin des chaînes de formatage quand on lit le nom et le prénom font que les caractères de nouvelle ligne sont lus depuis l'entrée et ignorés. Cependant, il peut toujours être nécessaire de supprimer les caractères blancs se trouvant éventuellement en fin de ligne avec chomp().

30-1 - Caractères d'indicateur de format

- d : lit un entier dans le système décimal.

- o : lit un entier dans le système octal.

- x : lit un entier dans le système hexadécimal.

- f : lit un nombre en virgule flottante.

- s : lit selon le type de la variable.

- c : lit un caractère. Cet indicateur permet aussi de lire des caractères blancs (ceux-ci ne sont pas ignorés).

Par exemple, si l'entrée contient « 23 23 23 », les valeurs seront lues différemment selon l'indicateur de format utilisé :

int nombre_d;
int nombre_o;
int nombre_x;

readf(" %d %o %x", &nombre_d, &nombre_o, &nombre_x);

writeln("Lu avec %d : ", nombre_d);
writeln("Lu avec %o : ", nombre_o);
writeln("Lu avec %x : ", nombre_x);

Même si l'entrée contient trois ensembles identiques de caractères « 23 », les valeurs des variables sont différentes :

Lu avec %d : 23
Lu avec %o : 19
Lu avec %x : 35

Très brièvement, 23 vaut 2 × 8 + 3 = 19 dans le système octal et 2 × 16 + 3 = 35 dans le système hexadécimal.

30-2 - Exercice

Supposons que l'entrée contient la date dans le format année.mois.jour. Écrivez un programme qui affiche le numéro du mois. Par exemple, si l'entrée est 2009.09.30, la sortie doit être 9.

La solution.

31 - Boucle do-while

Dans le chapitre sur la boucle forapitre sur la boucle forpitre sur la boucle for, nous avons vu les étapes d'exécution d'une boucle while :

préparation

vérification de la condition
ce qu'il y a à faire
itération

vérification de la condition
ce qu'il y a à faire
itération

...

La boucle do-while est très similaire à la boucle while. La différence est que la vérification de la condition est opérée à la fin de chaque itération de la boucle do-while, et donc que le corps de la boucle est exécuté au moins une fois :

préparation

ce qu'il y a à faire
itération
vérification de la condition    ← à la fin de l'itération

ce qu'il y a à faire
itération
vérification de la condition    ← à la fin de l'itération

...

Par exemple, do-while peut-être plus naturel dans le programme suivant où l'utilisateur devine un nombre, puisque l'utilisateur doit faire au minimum une tentative pour deviner le nombre :

import std.stdio;
import std.random;

void main()
{
int nombre = uniform(1, 101);

writeln("Je pense à un nombre entre 1 et 100.");

int tentative;

do {
write("Essayez de deviner ce nombre. ");

readf(" %s", &tentative);

if (nombre < tentative) {
write("Mon nombre est plus petit que cela. ");

} else if (nombre > tentative) {
write("Mon nombre est plus grand que cela. ");
}

} while (tentative != nombre);

writeln("Correct !");
}

La fonction uniform() qui est utilisée dans le programme fait partie du module std.random. Elle retourne un nombre dans l'intervalle spécifié. Le deuxième nombre donné en argument est hors de cet intervalle. Cela veut dire que dans ce programme, uniform() ne retournera jamais 101.

31-1 - Exercice

Écrivez un programme qui joue au même jeu, mais qui devine le nombre que l'utilisateur donne. Si le programme est bien écrit, il devrait deviner le nombre de l'utilisateur en 7 essais au plus.

La solution.

32 - Tableaux associatifs

Les tableaux associatifs sont une fonctionnalité que l'on trouve dans la plupart des langages modernes de haut niveau. Ils constituent une structure de données très rapide qui fonctionne comme une mini base de données et sont couramment utilisés dans beaucoup de programmes.

Nous avons vu les tableaux dans le chapitre sur les tableaux comme des conteneurs qui stockent leurs éléments côte à côte, éléments auxquels on peut accéder par des indices. Un tableau qui stocke le nom des jours de la semaine peut être défini comme suit :

string[] nomsDesJours =
[ "lundi", "mardi", "mercredi", "jeudi",
"vendredi", "samedi", "dimanche" ];

On peut accéder au nom d'un jour donné par son indice dans ce tableau :

writeln(nomsDesJours[1]);   // affiche "mardi"

Le fait que l'on peut accéder aux éléments par les valeurs d'indice peut être décrit comme une association d'indices avec les éléments. En d'autres termes, les tableaux associent les indices aux éléments. Les tableaux ne peuvent utiliser que des entiers comme indices.

Les tableaux associatifs permettent d'indexer les éléments par des entiers, mais également par n'importe quel autre type. Ils associent les valeurs d'un type à des valeurs d'un autre type. Les valeurs qui servent d'indices dans un tableau associatif sont appelées clés (keys), plutôt qu'indices. Les tableaux associatifs donnent accès à chacun de leurs éléments par leur clé.

32-1 - Très rapide, mais désordonné

Les tableaux associatifs sont une implémentation de table de hashage. Les tables de hashage font partie des collections les plus rapides pour stocker et accéder à des éléments. Sauf cas pathologique, le temps de stockage d'accès à un élément est indépendant du nombre d'éléments qu'il y a dans le tableau associatif.

La contrepartie de la grande efficacité des tableaux associatifs est leur stockage désordonné. À la différence des tableaux, les éléments des tables de hashage ne sont pas côte à côte. Ils ne sont triés d'aucune manière particulière.

32-2 - Définition

La syntaxe des tableaux associatifs est similaire à la syntaxe des tableaux. La différence est que c'est le type des clés qui est spécifié entre les crochets, et non la taille du tableau :

type_des_éléments[type_des_clés] nom_du_tableau_associatif;

Par exemple, un tableau associatif qui associe le nom des jours de type string à un numéro de type int peut être défini de cette manière :

int[string] numerosDesJours;

La variable numerosDesJours est un tableau associatif qui peut être utilisé comme un tableau qui fournit une association du nom de jour à son numéro. En d'autres termes, il peut être utilisé comme l'inverse du tableau nomsDesJours du début de ce chapitre. On utilisera le tableau associatif numerosDesJours dans les exemples qui suivent.

Les clés des tableaux associatifs peuvent être de n'importe quel type, structures et classes définies par l'utilisateur comprises. Nous verrons les types définis par l'utilisateur dans des chapitres ultérieurs.

La taille des tableaux associatifs ne peut pas être spécifiée lors de la définition. Ceux-ci grandissent automatiquement quand des éléments sont ajoutés.

32-3 - Ajouter des éléments

L'opérateur d'affectation est suffisant pour construire l'association entre une clé et une valeur :

// associe l'élément 0 avec la clé "lundi"
numerosDesJours["lundi"] = 0;

// associe l'élément 1 avec la clé "mardi"
numerosDesJours["mardi"] = 1;

La table grandit automatiquement avec chaque association. Par exemple, numerosDesJours contient deux éléments après les opérations précédentes. On peut le montrer en affichant le tableau entier :

writeln(numerosDesJours);

La sortie indique que les éléments de valeur 0 et 1 correspondent respectivement aux clés de valeur "lundi" et "mardi" :

["lundi":0, "mardi":1]

Il ne peut y avoir qu'un élément par clé. Pour cette raison, quand on affecte une valeur à une clé déjà existante, le tableau ne grandit pas. L'élément associé à la clé est simplement remplacé :

numerosDesJours["mardi"] = 222;   // modification d'un élément existant
writeln(numerosDesJours);

La sortie :

["lundi":0, "mardi":222]

32-4 - Initialisation

Parfois, certaines associations entre les clés et les valeurs sont déjà connues au moment de la définition du tableau associatif. Les tableaux associatifs sont initialisés de façon similaire aux tableaux, avec un deux-points séparant chaque clé de son élément :

// clé : élément
int[string] numerosDesJours =
[ "lundi"    : 0, "mardi"    : 1, "mercredi" : 2,
"jeudi"    : 3, "vendredi" : 4, "samedi"   : 5,
"dimanche" : 6 ];

writeln(numerosDesJours["mardi"]);    // affiche 1

32-5 - Supprimer des éléments

L'élément qui correspond à une clé est supprimé par .remove() :

numerosDesJours.remove("mardi");
writeln(numerosDesJours["mardi"]);    // ← ERREUR lors de l'exécution

La première ligne supprime l'élément de la clé "mardi". Comme cet élément n'est plus dans le conteneur, la seconde ligne entraîne une exception et l'arrêt du programme si l'exception n'est pas attrapée. Nous verrons les exceptions dans un chapitre ultérieur.

Il est possible de supprimer tous les éléments en une fois, comme nous le verrons dans le premier exercice de fin de chapitre.

32-6 - Déterminer la présence d'un élément

L'opérateur in détermine si l'élément pour une clé donnée existe dans le tableau associatif :

int[string] codesDeCouleurs = [ /* ... */ ];

if ("violet" in codesDeCouleurs) {
// il y a un élément pour la clé "violet"

} else {
// aucun élément pour la clé "violet"
}

Parfois, utiliser une valeur par défaut pour les clés qui n'existent pas dans le tableau associatif a un sens. Par exemple, la valeur spéciale -1 peut être utilisée comme le code des couleurs qui ne sont pas dans codesDeCouleurs. .get() est utile dans de telles situations : elle retourne la valeur de l'élément si l'élément pour la clé donnée existe, la valeur par défaut sinon. La valeur par défaut est indiquée en second paramètre de .get() :

int[string] codesDeCouleurs = [ "bleu" : 10, "vert" : 20 ];
writeln(codesDeCouleurs.get("violet", -1));

Comme le tableau ne contient pas d'élément pour la clé « violet », .get() returne -1 :

-1

32-7 - Propriétés

- .length retourne le nombre d'éléments dans le tableau.

- .keys retourne les copies des clés du tableau associatif dans un tableau dynamique.

- .byKey donne un accès aux clés du tableau associatif sans les copier ; nous verrons comment .byKey est utilisé dans les boucles foreach dans le chapitre suivant.

- .values retourne les copies de toutes les valeurs du tableau associatif dans un tableau dynamique.

- .byValue donne un accès aux éléments du tableau associatif sans les copier.

- .rehash peut rendre le tableau plus efficace dans certains cas comme après avoir inséré un grand nombre d'éléments et avant d'effectivement utiliser le tableau associatif.

- .sizeof est la taille de la référence du tableau (cela n'a rien à voir avec le nombre d'éléments dans le tableau et a la même valeur pour tous les tableaux associatifs).

- .get retourne l'élément s'il existe, la valeur par défaut sinon.

- .remove supprime l'élément indiqué du tableau.

32-8 - Exemple

Voici un programme qui affiche le nom turc des couleurs qui sont indiquées en français :

import std.stdio;
import std.string;

void main()
{
string[string] couleurs = [ "noir" : "siyah",
"blanc" : "beyaz",
"rouge" : "kırmızı",
"vert"  : "yeşil",
"bleu"  : "mavi"
];

writefln("Je connais les noms turcs de ces %s couleurs : %s",
couleurs.length, couleurs.keys);

write("Demandez-m'en une : ");
string enFrancais = chomp(readln());

if (enFrancais in couleurs) {
writefln("\"%s\" est \"%s\" en turc.",
enFrancais, couleurs[enFrancais]);

} else {
writeln("Je ne connais pas cette couleur.");
}
}

32-9 - Exercices

1. Comment tous les éléments d'un tableau associatif peuvent-ils être supprimés ? Il y a au moins trois méthodes :

1. Supprimer les éléments un par un ;

2. Affecter un tableau associatif vide ;

3. De façon similaire à la méthode précédente, affecter la propriété .init du tableau.

nombre = int.init;    // 0 pour int

Note : la propriété .init de n'importe quelle variable est la valeur initiale de ce type :

2. Comme pour les tableaux, il ne peut y avoir qu'une valeur par clé. Ceci peut être vu comme une limitation pour certaines applications.
Supposons qu'un tableau associatif soit utilisé pour stocker des notes d'étudiants. Par exemple, supposons que les notes 18, 17, 19, etc. soient à stocker pour l'étudiant « pierre ».
Les tableaux associatifs permettent facilement d'accéder aux notes par le nom de l'étudiant comme dans notes["pierre"]. Cependant, les notes ne peuvent pas être ajoutées de la manière suivante parce que chaque note remplacerait la précédente :

int[string] notes;
notes["pierre"] = 18;
notes["pierre"] = 17;   // ← Remplace la note précédente !

3. Comment pouvez-vous résoudre ce problème ? Définir un tableau associatif qui peut stocker plusieurs notes par étudiant.

Les solutions.

33 - Boucle foreach

Une des structures de contrôle les plus communes du D est la boucle foreach. Elle est utilisée pour appliquer la même opération à tous les éléments d'un conteneur (ou d'un intervalle).

Les opérations qui sont appliquées aux éléments d'un conteneur sont très répandues en programmation. Nous avons vu dans le chapitre sur la boucle for que l'on peut accéder aux éléments d'un tableau dans une boucle for par une valeur d'indice qui est incrémentée à chaque itération :

for (int i = 0; i != tableau.length; ++i) {
writeln(tableau[i]);
}

Voici les étapes d'une itération sur tous les éléments :

- définir une variable compteur, souvent nommée i ;

- itérer la boucle jusqu'à la valeur de la propriété .length du tableau ;

- incrémenter i ;

- accéder à l'élément.

foreach a essentiellement le même comportement, mais simplifie le code en gérant ces étapes automatiquement :

foreach (element; tableau) {
writeln(element);
}

Une partie du pouvoir de foreach vient du fait qu'elle peut être utilisée de la même manière indépendamment du type du conteneur. Comme nous l'avons vu dans le chapitre précédent, une manière d'itérer sur les valeurs d'un tableau associatif dans une boucle for est d'utiliser la propriété .values du tableau :

auto valeurs = aa.values;
for (int i = 0; i != valeurs.length; ++i) {
writeln(valeurs[i]);
}

foreach ne nécessite rien de particulier pour les tableaux associatifs ; elle est utilisée de la même façon qu'avec les tableaux :

foreach (valeur; aa) {
writeln(valeur);
}

33-1 - La syntaxe de foreach

foreach consiste en trois sections :

foreach (noms ; conteneur_ou_intervalle) {
// opérations
}

- conteneur_ou_intervalle indique où sont les éléments ;

- // opérations indique les opérations à appliquer à chaque élément ;

- noms indique le nom de l'élément et potentiellement d'autres variables dépendant du type du conteneur ou de l'intervalle. Même si le choix de noms appartient au programmeur, le nombre et les types de ces noms dépendent du type du conteneur.

33-2 - continue et break

Ces mots-clés ont le même sens que celui qu'ils ont avec la boucle for : continue mène à l'itération suivante au lieu de finir celle qui est en cours et break sort de la boucle.

33-3 - foreach avec les tableaux

Quand il y a un seul nom dans la section noms, c'est la valeur de l'élément à chaque itération :

foreach (element; tableau) {
writeln(element);
}

Quand deux noms sont indiqués dans la section noms, il y a respectivement un compteur automatique et la valeur de l'élément :

foreach (i, element; tableau) {
writeln(i, ": ", element);
}

Le compteur est incrémenté automatiquement par foreach. Son nom est choisi par le programmeur.

33-4 - foreach avec les chaînes et std.range.stride

Comme les chaînes sont des tableaux de caractères, foreach fonctionne de la même manière qu'avec les tableaux : un nom unique est le caractère, deux noms sont le compteur et le caractère :

foreach (c; "salut") {
writeln(c);
}

foreach (i, c; "salut") {
writeln(i, ": ", c);
}

Cependant, étant des unités de stockage UTF, char et wchar itèrent sur les unités de stockage, pas sur les points de code Unicode :

foreach (i, code; "abcçd") {
writeln(i, ": ", code);
}

On accède aux deux unités de stockage UTF-8 qui forment ç par des éléments séparés :

0: a
1: b
2: c
3:
4: �
5: d

Une manière d'itérer sur les caractères Unicode des chaînes dans une boucle foreach est la fonction stride du module std.range. stride présente la chaîne comme un conteneur constitué de caractères Unicode. Il prend la taille de son pas en second paramètre :

import std.range;

// ...

foreach (c; stride("abcçd", 1)) {
writeln(c);
}

Quel que soit le type de caractère de la chaîne, stride présente toujours ses éléments comme des caractères Unicode :

a
b
c
ç
d

J'expliquerai plus loin pourquoi cette boucle ne peut pas inclure de compteur automatique.

33-5 - foreach avec les tableaux associatifs

Un seul nom indique la valeur, deux noms indiquent la clé et la valeur :

foreach (valeur; ta) {
writeln(valeur);
}

foreach (clé, valeur; ta) {
writeln(clé, ": ", valeur);
}

Les tableaux associatifs peuvent également donner leurs clés et leurs valeurs comme des intervalles. Nous verrons les intervalles dans un chapitre ultérieur. .byKey() et .byValue() retournent des objets intervalle efficaces qui sont aussi utiles dans d'autres contextes. .byValue() n'a pas grand intérêt dans les boucles foreach par rapport aux itérations classiques que l'on a déjà décrites. En revanche, .byKey() est la seule manière efficace d'itérer seulement sur les clés d'un tableau associatif :

foreach (clé; ta.byKey()) {
writeln(clé);
}

33-6 - foreach avec les intervalles de nombres

Nous avons vu les intervalles de nombres dans le chapitre « Tranches (slices) et autres fonctionnalités des tableaux ». Il est possible d'indiquer un intervalle de nombres dans la section conteneur_ou_intervalle :

foreach (nombre; 10..15) {
writeln(nombre);
}

Rappel : 10 est inclus dans l'intervalle, mais pas 15.

33-7 - foreach avec les structures, les classes et les intervalles

foreach peut également être utilisé avec des objets d'un type de l'utilisateur qui définit sa propre itération dans les boucles foreach. Comme le type lui-même définit sa propre façon d'itérer, il n'est pas possible de dire grand-chose ici. Les programmeurs doivent se référer à la documentation de ce type particulier.

Les structures et les classes apportent une prise en charge de l'itération foreach soit avec leur méthode opApply(), soit par un ensemble de méthodes d'intervalle. Nous verrons ces fonctionnalités dans des chapitres ultérieurs.

33-8 - Le compteur n'est automatique que pour les tableaux

Le compteur automatique est fourni seulement quand on itère sur les tableaux. Quand un compteur est nécessaire lors d'une itération sur d'autres types de conteneurs, le compteur peut être défini et incrémenté de façon explicite :

int i;
foreach (element; conteneur) {
// ...
++i;
}

Une telle variable est aussi nécessaire quand on compte une condition spécifique. Par exemple, le code suivant compte seulement les valeurs qui sont divisibles par 10 :

import std.stdio;

void main()
{
auto nombres = [ 1, 0, 15, 10, 3, 5, 20, 30 ];

int compteur;
foreach (nombre; nombres) {
if ((nombre % 10) == 0) {
++compteur;
write(compteur);

} else {
write(' ');
}

writeln(" : ", nombre);
}
}

La sortie :

: 1
1 : 0
: 15
2 : 10
: 3
: 5
3 : 20
4 : 30

33-9 - La copie de l'élément, pas l'élément lui-même

La boucle foreach fournit normalement une copie de l'élément, pas l'élément qui est stocké dans le conteneur. Ceci peut être la cause de bogues.

Pour voir un exemple de ceci, jetons un œil sur le programme suivant qui essaie de doubler les valeurs des éléments d'un tableau :

import std.stdio;

void main()
{
double[] nombres = [ 1.2, 3.4, 5.6 ];

writefln("Avant : %s", nombres);

foreach (nombre; nombres) {
nombre *= 2;
}

writefln("Après : %s", nombres);
}

La sortie du programme montre que l'affectation faite à chaque élément à l'intérieur du corps de foreach n'a aucun effet sur les éléments du conteneur :

Avant : [1.2, 3.4, 5.6]
Après : [1.2, 3.4, 5.6]

Ceci s'explique par le fait que nombre n'est pas un élément du tableau, mais une copie d'élément. Quand on a besoin de modifier les éléments eux-mêmes, le nom doit être défini comme une référence à l'élément par le mot-clé ref :

foreach (ref nombre; nombres) {
nombre *= 2;
}

La nouvelle sortie montre que maintenant, les affectations modifient les éléments du tableau :

Avant : [1.2, 3.4, 5.6]
Après : [2.4, 6.8, 11.2]

Le mot-clé ref fait de nombre un alias de l'élément à chaque itération. De ce fait, les modifications apportées à nombre sont apportées à cet élément du conteneur.

33-10 - L'intégrité du conteneur doit être préservée

Même s'il est correct de modifier les éléments du conteneur à travers des variables ref, la structure du conteneur ne doit pas changer. Par exemple, les éléments ne doivent pas être supprimés ou ajoutés au conteneur pendant une boucle foreach.

De telles modifications peuvent perturber le fonctionnement interne de l'itération de la boucle et mettre le programme dans un état incohérent.

33-11 - foreach_reverse pour itérer dans la direction inverse

foreach_reverse fonctionne de la même manière que foreach, mais itère dans la direction inverse :

auto conteneur = [ 1, 2, 3 ];

foreach_reverse (element; conteneur) {
writefln("%s ", element);
}

La sortie :

3
2
1

L'utilisation de foreach_reverse n'est pas répandue parce que la fonction d'intervalle retro() fait la même chose. Nous verrons cette fonction dans un chapitre suivant.

33-12 - Exercice

Nous savons que les tableaux associatifs proposent une relation de type clé-valeur. Cette relation est unidirectionnelle : on accède aux valeurs par les clés, mais ce n'est pas vrai dans l'autre sens.

Supposons que l'on ait ce tableau associatif :

string[int] noms = [ 1:"un", 7:"sept", 20:"vingt" ];

Utilisez ce tableau associatif et une boucle foreach pour remplir un tableau associatif nommé valeurs. Ce nouveau tableau associatif doit donner les valeurs qui correspondent aux noms. Par exemple, la ligne suivante devrait afficher 20 :

writeln(valeurs["vingt"]);

La solution.

34 - switch et case

switch est une structure qui permet de comparer la valeur d'une expression avec de multiples valeurs spéciales. Elle est similaire à une chaîne if, else if, else, mais fonctionne un peu différemment. case est utilisé pour spécifier les valeurs à comparer avec l'expression du switch. case fait partie de la structure switch et n'est pas une structure en lui-même.

switch prend une expression entre parenthèses, compare la valeur de cette expression aux valeurs des case et exécute les opérations du case qui est égal à la valeur de l'expression. Sa syntaxe consiste en un bloc switch qui contient une ou plusieurs sections case et une section default :

switch (expression) {

case valeur_1:
// opérations à exécuter si l'expression est égale à valeur_1
// ...
break;

case valeur_2:
// opérations à exécuter si l'expression est égale à valeur_2
// ...
break;

// ... autres case ...

default:
// opérations à exécuter si l'expression ne correspond à aucun case
// ...
break;
}

Même si elle est utilisée dans des vérifications conditionnelles, l'expression que le switch prend n'est pas utilisée comme une expression logique. Elle n'est pas évaluée comme « si cette condition est vraie » comme dans la structure if. La valeur de l'expression du switch est utilisée dans des comparaisons d'égalité avec les valeurs des case. C'est similaire à une chaîne if, else if, else qui n'a que des opérations d'égalité :

auto valeur = expression;

if (valeur == valeur_1) {
// opérations pour valeur_1
// ...

} else if (valeur == valeur_2) {
// opérations pour valeur_2
// ...
}

// ... autres 'else if' ...

} else {
// opérations pour les autres valeurs
// ...
}

Cependant, la chaîne if, else if, else ci-dessus n'est pas l'équivalent de la structure switch. Les raisons à cela seront expliquées dans les sections suivantes.

Si une valeur case correspond à la valeur de l'expression du switch, alors les opérations qui sont sous ce case sont exécutées. Si aucune valeur ne correspond, alors les opérations qui sont en dessous de default sont exécutées. Les opérations sont exécutées jusqu'à ce qu'une instruction break ou goto est rencontrée.

34-1 - L'instruction goto

Certains usages de goto sont en général déconseillés dans la plupart des langages de programmation. Cependant, goto est utile dans les structures switch dans quelques rares cas. L'instruction goto sera vue plus en détail dans un chapitre ultérieur.

case n'introduit pas un nouveau bloc comme le fait la structure if. Une fois que les opérations dans un bloc if ou else sont finies, l'évaluation totale de la structure if est également terminée. Ce n'est pas le cas avec les sections case ; une fois qu'un case correspondant est trouvé, l'exécution du programme saute vers ce case et exécute les opérations sous ce case. Quand ceci est nécessaire dans certains cas rares, goto fait sauter l'exécution du programme vers le case suivant :

switch (valeur) {
case 5:
writeln("cinq");
goto case;   // continue sur le prochain case

case 4:
writeln("quatre");
break;

default:
writeln("inconnu");
break;
}

Si la valeur est 5, l'exécution continue sous la ligne case 5: et le programme affiche « cinq ». Ensuite, l'instruction goto case fait continuer l'exécution au case suivant, et « quatre » est également affiché :

cinq
quatre

goto peut apparaître de trois manières dans les sections case :

- goto case fait continuer l'exécution au case suivant ;

- goto default fait continuer l'exécution à la section default ;

- goto case expression fait continuer l'exécution au case qui correspond à cette expression.

Le programme suivant montre ces trois utilisations en utilisant une boucle foreach :

import std.stdio;

void main()
{
foreach (valeur; [ 1, 2, 3, 10, 20 ]) {
writefln("--- valeur : %s ---", valeur);

switch (valeur) {
case 1:
writeln("case 1");
goto case;

case 2:
writeln("case 2");
goto case 10;

case 3:
writeln("case 3");
goto default;

case 10:
writeln("case 10");
break;

default:
writeln("default");
break;
}
}
}

La sortie :

--- valeur : 1 ---
case 1
case 2
case 10
--- valeur : 2 ---
case 2
case 10
--- valeur : 3 ---
case 3
default
--- valeur : 10 ---
case 10
--- valeur : 20 ---
default

34-2 - Le type de l'expression doit être un type entier, une chaîne ou un booléen

Tous les types peuvent être utilisés dans des comparaisons d'égalité dans les structures if. Par contre, le type des expressions du switch est limité aux entiers, aux chaînes et à bool.

string op = /* ... */;
// ...
switch (op) {
case "ajouter":
resultat = premier + second;
break;

case "soustraire":
resultat = premier - second;
break;

case "multiplier":
resultat = premier * second;
break;

case "diviser":
resultat = premier / second;
break;

default:
throw new Exception(format("Opération inconnue : %s", op));
}

Le code ci-dessus lève une exception quand l'opération n'est pas reconnue par le programme. Nous verrons les exceptions dans un chapitre ultérieur.

Même s'il est possible d'utiliser des expressions booléennes, comme bool n'a que deux valeurs, utiliser une structure if ou l'opérateur ternaire (?:) avec ce type est peut-être plus adapté.

34-3 - Les valeurs case doivent être connues lors de la compilation

Avec les structures if, les valeurs qui sont des deux côtés de l'opérateur d'égalité peuvent être calculées lors de l'exécution. Par exemple, l'instruction if suivante peut utiliser deux valeurs, une qui est calculée par le programme et une qui est entrée par l'utilisateur :

if (tentative == nombre) {
writeln("Correct !");
}

Par contre, même si la valeur de l'expression du switch est normalement calculée lors de l'exécution, les valeurs des case doivent être connues lors de la compilation.

34-4 - Intervalles de valeurs

Les intervalles de valeurs peuvent être indiqués par des .. entre les case :

switch (valeurDuDé) {
case 1:
writeln("Vous avez gagné");
break;

case 2: .. case 5:
writeln("Match nul");
break;

case 6:
writeln("J'ai gagné");
break;

default:
/* Le programme ne devrait jamais arriver ici parce que les case
* précédents couvrent l'intervalle complet des valeurs possibles d'un dé
* (voir l'instruction "final switch" un peu plus loin) */
break;
}

Le code qui précède détermine que le jeu se termine par match nul quand la valeur du dé est 2, 3, 4 ou 5.

34-5 - Valeurs distinctes

Supposons que le match nul est obtenu seulement pour les valeurs 2 et 4, plutôt que les valeurs qui sont dans l'intervalle [2,5]. Les valeurs distinctes d'un case sont séparées par des virgules :

case 2, 4:
writeln("Match nul");
break;

34-6 - L'instruction final switch

Cette structure fonctionne de la même manière qu'un classique, avec les différences suivantes :

- il ne peut pas y avoir de section default. Notez que cette section n'a aucun sens dans les cas où les sections case couvrent l'intervalle complet de valeurs, comme dans le cas des six valeurs du dé ;

- les intervalles de valeurs ne peuvent pas être utilisés avec les case (on peut cependant utiliser les valeurs distinctes) ;

- si l'expression est un type enum, toutes les valeurs du type doivent être couvertes par les case (nous verrons les types enum dans le chapitre suivant).

int valeurDuDé = 1;

final switch (valeurDuDé) {
case 1:
writeln("Vous avez gagné");
break;

case 2, 3, 4, 5:
writeln("Match nul");
break;

case 6:
writeln("J'ai gagné");
break;
}

34-7 - Quand utiliser switch ?

switch est adapté quand on compare la valeur d'une expression avec un ensemble de valeurs connues lors de la compilation.

Quand il n'y a que deux valeurs à comparer, une instruction if peut avoir plus de sens. Par exemple, pour vérifier si on obtient pile ou face :

if (resultatPileOuFace == pile) {
// ...

} else {
// ...
}

En règle générale, switch est plus adapté quand il y a plus de deux valeurs à comparer.

34-8 - Exercices

1. Écrivez un programme de calculatrice qui prend en charge les opérations arithmétiques. Le programme lit une première ligne avec l'opération comme une chaîne et ensuite une seconde ligne avec deux valeurs de type double depuis l'entrée. La calculatrice doit afficher le résultat de l'opération. Par exemple, quand l'opération et les valeurs sont « ajouter » et « 5 7 », le programme devrait afficher 12.
L'entrée peut être lue comme dans le code suivant :

string op;
double premier;
double second;

// ...

op = chomp(readln());
readf(" %s %s", &premier, &second);

2. Améliorez la calculatrice pour prendre en charge les opérateurs du type + en plus des mots du type « ajouter ».

3. Levez une exception pour les opérations inconnues.

Les solutions.

35 - Les énumérations (enum)

enum est la fonctionnalité qui permet de définir des valeurs constantes nommées.

35-1 - Les effets des constantes magiques sur la qualité du code

Le code suivant apparaît dans les solutions des exercices du chapitre sur les entiers et les opérations arithmétiques :

if (operation == 1) {
resultat = premier + second;

} else if (operation == 2) {
resultat = premier - second;

} else if (operation == 3) {
resultat = premier * second;

} else if (operation == 4) {
resultat = premier / second;
}

Les littéraux entiers 1, 2, 3 et 4 dans ce bout de code sont appelés constantes magiques. Il n'est pas facile de déterminer à quoi correspond chacun de ces littéraux dans le programme. Le code de chaque bloc doit être examiné pour voir que 1 désigne l'addition, 2 la soustraction, etc. Cette tâche est relativement aisée dans cet exemple parce que chaque bloc ne contient qu'une ligne. Il serait considérablement plus difficile de déchiffrer le sens des constantes magiques dans la plupart des autres programmes.

Les constantes magiques doivent être évitées parce qu'elles limitent les deux qualités les plus importantes des programmes : lisibilité et maintenabilité.

enum permet de donner des noms à de telles constantes et donc de rendre le code plus lisible et maintenable. Chaque condition est immédiatement compréhensible quand les valeurs énumérées suivantes sont utilisées :

if (operation == Operation.ajouter) {
resultat = premier + second;

} else if (operation == Operation.soustraire) {
resultat = premier - second;

} else if (operation == Operation.multiplier) {
resultat = premier * second;

} else if (operation == Operation.diviser) {
resultat = premier / second;
}

Le type énuméré Operation ci-dessus qui rend inutile le recours aux constantes magiques 1, 2, 3 et 4 peut être défini comme ceci :

enum Operation { ajouter = 1, soustraire, multiplier, diviser }

35-2 - La syntaxe d'enum

La forme la plus simple de la définition d'une énumération est la suivante :

enum NomDuType { NomDeValeur_1, NomDeValeur_2, /* etc. */ }

Il est parfois nécessaire de spécifier également le vrai type (le type de base) des valeurs :

enum NomDuType : type_de_base { NomDeValeur_1, NomDeValeur_2, /* etc. */ }

Nous verrons comment ceci peut être utilisé dans la section suivante.

NomDuType définit la signification commune des valeurs. Toutes les valeurs d'un type énuméré sont listées entre accolades. Voici quelques exemples :

enum PileOuFace { pile, face }
enum Couleur { pique, cœur, carreau, trèfle }
enum Voyageur { ordinaire, enfant, etudiant, senior }

Chaque ensemble de valeur fait alors partie d'un type distinct. Par exemple, pile et face deviennent des valeurs du type PileOuFace. Le nouveau type peut être utilisé comme n'importe quel type lors de la définition de variables :

PileOuFace resultat;         // initialisé à la valeur par défaut
auto tq = PileOuFace.pile;   // type inféré

Comme nous l'avons vu dans les codes précédents, les valeurs des types énumérés sont toujours spécifiées au moyen du nom de leur type :

if (resultat == PileOuFace.pile) {
// ...
}

35-3 - Valeurs réelles et types de base

Les valeurs des types énumérés sont normalement implémentées, en interne, par des valeurs int. Autrement dit, même si elles apparaissent comme des valeurs nommées comme pile et face dans le code, elles sont en réalité des valeurs int. (Note : il est possible de choisir un autre type que int lorsque c'est nécessaire.)

Sauf dans le cas où c'est explicitement indiqué par le programmeur, les valeurs int commencent par 0 et sont incrémentées de 1 pour chaque valeur énumérée. Par exemple, les deux valeurs de PileOuFace ont les valeurs 0 et 1 :

writeln("pile vaut 0 : ", (PileOuFace.pile == 0));
writeln("face vaut 1 : ", (PileOuFace.face == 1));

La sortie :

pile vaut 0 : true
face vaut 1 : true

Il est possible de réinitialiser manuellement les valeurs à n'importe quel endroit. Cela a été le cas quand on a donné la valeur 1 à Operation.ajouter. L'exemple suivant réinitialise les valeurs deux fois :

enum Test { a, b, c, ç = 100, d, e, f = 222, g, ğ }
writefln("%d %d %d", Test.b, Test.ç, Test.ğ);

La sortie :

0

Si int n'est pas adapté en tant que type de base des valeurs énumérées, le type de base peut être indiqué de façon explicite après le nom de l'énumération :

enum ConstanteNaturelle : double { pi = 3.14, e = 2.72 }
enum UnitéDeTempérature : string { C = "Celsius", F = "Fahrenheit" }

35-4 - Des valeurs énumérées sans type énuméré

Nous avons vu qu'il était important d'éviter les constantes magiques et qu'il valait mieux se servir des énumérations.

Cependant, parfois, il peut ne pas être naturel d'utiliser les noms des types énumérés pour simplement utiliser des constantes nommées. Supposons que l'on ait besoin de représenter le nombre de secondes par jour. Il ne devrait pas être nécessaire de définir également un type énuméré pour cette constante. Tout ce dont on a besoin est une valeur constante à laquelle on peut se référer par son nom. Dans de tels cas, le type de l'énumération et les accolades ne sont pas écrits :

enum secondesParJour = 60 * 60 * 24;

Le type de la valeur peut être spécifié explicitement, ce qui est nécessaire si le type ne peut pas être inféré depuis la valeur :

enum int secondesParJour = 60 * 60 * 24;

Comme il n'y a pas de type énuméré auquel se référer, de telles constantes nommées peuvent être simplement utilisées dans le code par leur nom :

nombreTotalDeSecondes = nombreDeJours * secondesParJour;

enum peut également être utilisé pour définir des constantes nommées d'autres types. Par exemple, le type de la constante suivante est string :

enum nomDeFichier = "list.txt";

35-5 - Propriétés

Les propriétés .min et .max sont les valeurs minimale et maximale du type énuméré. Quand les valeurs du type énuméré sont consécutives, elles peuvent être itérées dans une boucle for entre ces limites :

enum Couleur { pique, cœur, carreau, trèfle }

for (auto couleur = Couleur.min; couleur <= Couleur.max; ++couleur) {
writefln("%s : %d", couleur, couleur);
}

Les indicateurs de format "%s" et "%d" produisent des sorties différentes :

pique : 0
cœur : 1
carreau : 2
trèfle : 3

Notez qu'une boucle foreach sur cet intervalle ne considérerait pas la valeur .max :

foreach (couleur; Couleur.min .. Couleur.max) {
writefln("%s : %d", couleur, couleur);
}

La sortie :

pique : 0
cœur : 1
carreau : 2
← il manque le trèfle

35-6 - Conversion depuis le type de base

Comme nous l'avons vu dans la sortie formatée ci-dessus, une valeur énumérée peut être automatiquement convertie vers son type de base (par ex. vers int). La conversion inverse n'est pas automatique :

Couleur couleur = 1;   // ← ERREUR de compilation

La raison à cela est d'éviter de se retrouver avec des valeurs énumérées invalides :

couleur = 100;         // ← cela serait une valeur énumérée invalide

Les valeurs que l'on sait pouvoir correspondre à des valeurs énumérées valides d'un type énuméré particulier peuvent quand même être converties vers ce type par un cast explicite :

couleur = cast(Couleur)1;   // devient cœur

Il est à la charge du programmeur de s'assurer de la validité des valeurs quand un cast explicite est utilisé. Nous verrons les conversions de type et les casts dans des chapitres ultérieurs.

35-7 - Exercice

Modifiez le programme de calculatrice des exercices du chapitre sur les entiers et les opérations arithmétiques en faisant choisir à l'utilisateur l'opération arithmétique dans un menu.

Ce programme doit différer du précédent par au moins ces points :

- utilisez des valeurs énumérées, pas des constantes magiques ;

- utilisez double à la place d'int ;

- utilisez une instruction switch à la place de la chaîne « if, else if, else ».

La solution.

36 - Fonctions

Alors que les types fondamentaux sont les briques élémentaires des données d'un programme, les fonctions sont les briques élémentaires du comportement de ce programme.

Les fonctions sont également très liées au savoir-faire du programmeur. Les fonctions qui sont écrites par des programmeurs expérimentés sont succinctes, simples et claires. Cela est vrai dans les deux sens : le simple fait d'essayer d'identifier et d'écrire des briques élémentaires plus petites rend un programmeur meilleur.

Nous avons vu des expressions et instructions basiques dans les chapitres précédents. Même s'il y en aura encore beaucoup dans les chapitres à venir, celles que nous avons vues jusqu'à maintenant sont des fonctionnalités couramment utilisées du D. Cependant, à elles seules, elles ne suffisent pas à écrire de grands programmes. Les programmes que nous avons écrits jusqu'à maintenant sont très petits, chacun montrant une seule fonctionnalité simple du langage. Essayer d'écrire un programme un tant soit peu complexe sans fonction serait très difficile et propice aux bogues.

Les fonctions regroupent des instructions et des expressions pour former des unités d'exécution de programme. On donne un nom à ce regroupement d'instructions et d'expressions qui décrit la tâche que celles-ci accomplissent. Elles peuvent être appelées (ou exécutées) par ce nom.

L'idée de donner des noms à un groupe d'étapes est courante dans la vie de tous les jours. Par exemple, l'action de cuisiner une omelette peut être décrite plus en détail par les étapes suivantes :

- prendre une poêle ;

- prendre du beurre ;

- prendre un œuf ;

- allumer la cuisinière ;

- mettre la poêle sur le feu ;

- mettre le beurre dans la poêle quand celle-ci est chaude ;

- mettre l'œuf dans le beurre quand celui-ci est fondu ;

- enlever la poêle du feu quand l'œuf est cuit ;

- éteindre la cuisinière.

Comme une telle précision est évidemment excessive, les étapes qui sont liées entre elles peuvent être regroupées sous un nom unique :

- faire les préparations (prendre la poêle, le beurre et l'œuf) ;

- allumer la cuisinière ;

- cuire l'œuf (mettre la poêle sur le feu, etc.) ;

- éteindre la cuisinière.

En allant plus loin, il peut y avoir un seul nom pour toutes les étapes :

- faire une omelette avec un œuf (toutes les étapes).

Les fonctions sont basées sur la même idée : les étapes qui peuvent être regroupées sous un même nom sont mises ensemble pour former une fonction. Par exemple, commençons avec les lignes de code suivantes qui affichent un menu :

writeln(" 0 Quitter");
writeln(" 1 Ajouter");
writeln(" 2 Soustraire");
writeln(" 3 Multiplier");
writeln(" 4 Diviser");

Comme rassembler ces lignes sous le nom d'afficherMenu aurait un sens, on peut les mettre ensemble pour former une fonction avec la syntaxe suivante :

void afficherMenu()
{
writeln(" 0 Quitter");
writeln(" 1 Ajouter");
writeln(" 2 Soustraire");
writeln(" 3 Multiplier");
writeln(" 4 Diviser");
}

Le contenu de cette fonction peut maintenant être exécuté depuis main() simplement par son nom :

void main()
{
afficherMenu();

/* ... */
}

On peut noter, en observant les similarités entre les définitions de afficherMenu() et main() que main() est également une fonction. L'exécution des programmes D commence par la fonction nommée main() et exécute les autres fonctions depuis là.

36-1 - Paramètres

Une des forces des fonctions vient du fait que l'on peut ajuster leur comportement à travers des paramètres.

Continuons avec l'exemple de l'omelette en le modifiant pour faire une omelette de cinq œufs au lieu d'un. Les étapes sont exactement les mêmes, la seule différence étant le nombre d'œufs à utiliser. On peut changer la description précédente en conséquence :

- faire les préparations (prendre la poêle, le beurre et cinq œufs) ;

- allumer la cuisinière ;

- cuire les œufs (mettre la poêle sur le feu, etc.) ;

- éteindre la cuisinière.

De même, l'étape la plus générale deviendrait :

- préparer une omelette avec cinq œufs (toutes les étapes).

Cette fois, il y a une information de plus qui concerne certaines étapes : « prendre cinq œufs », « cuisiner les œufs » et « préparer une omelette avec cinq œufs ».

Le comportement des fonctions peut être ajusté de la même manière que dans l'exemple de l'omelette. Les données que les fonctions utilisent pour ajuster leur comportement sont appelées paramètres. Les paramètres sont indiqués dans la liste des paramètres de la fonction, séparés par des virgules. La liste des paramètres est écrite entre parenthèses après le nom de la fonction.

La fonction afficherMenu() ci-dessus était définie par une liste de paramètres vide parce que cette fonction affichait toujours le même menu. Supposons que, parfois, on veuille que le menu soit affiché différemment dans différents contextes. Par exemple, il pourrait être préférable d'afficher la première entrée comme « Revenir » plutôt que « Quitter », selon l'endroit du programme dans lequel on se trouve.

Dans une telle situation, la première entrée du menu peut être paramétrée en étant définie dans la liste des paramètres. La fonction utilise alors la valeur de ce paramètre au lieu du littéral "Quitter" :

void afficherMenu(string premiereEntree)
{
writeln(" 0 ", premiereEntree);
writeln(" 1 Ajouter");
writeln(" 2 Soustraire");
writeln(" 3 Multiplier");
writeln(" 4 Diviser");
}

Notez que comme l'information que premiereEntree contient est un bout de texte, son type a été indiqué comme string dans la liste des paramètres. Cette fonction peut maintenant être appelée avec différentes valeurs de paramètre pour afficher des menus qui ont une première entrée différente. Il suffit pour cela d'utiliser des valeurs de chaînes appropriées selon l'endroit où la fonction est appelée :

// À un endroit du programme :
afficherMenu("Quitter");
// ...
// À un autre endroit du programme :
afficherMenu("Revenir");

Quand vous écrivez vos propres fonctions, vous pouvez rencontrer des erreurs de compilation avec des paramètres de type string. La fonction afficherMenu() ci-dessus a un problème de ce genre. Telle qu'elle est écrite, elle ne peut pas être appelée avec des paramètres de type char[]. Par exemple, le code suivant donnerait une erreur de compilation :

char[] uneEntrée;
uneEntrée ~= "Prendre la racine carrée";
afficherMenu(uneEntrée);  // ← ERREUR de compilation

En revanche, si afficherMenu() avait été définie pour prendre un paramètre char[], alors elle n'aurait pas pu être appelée avec des chaînes du type "Quitter". Ceci est lié à l'idée d'immuabilité et du mot-clé immutable, que nous verrons tous deux dans le chapitre suivant.

Continuons avec la fonction du menu et supposons qu'il n'est pas approprié de toujours commencer la numérotation des entrées par zéro. Dans ce cas, le numéro de début peut également être passé à la fonction en deuxième paramètre. Les paramètres de la fonction doivent être séparés par des virgules :

void afficherMenu(string premiereEntree, int premierNumero)
{
writeln(' ', premierNumero, ' ', premiereEntree);
writeln(' ', premierNumero + 1, " Ajouter");
writeln(' ', premierNumero + 2, " Soustraire");
writeln(' ', premierNumero + 3, " Multiplier");
writeln(' ', premierNumero + 4, " Diviser");
}

Il est maintenant possible de dire à la fonction à partir de quel numéro commencer :

afficherMenu("Revenir", 1);

36-2 - Appeler une fonction

Démarrer une fonction pour qu'elle effectue sa tâche est « appeler une fonction ». La syntaxe d'appel d'une fonction est la suivante :

nom_de_la_fonction(valeurs_des_parametres)

Les valeurs réelles des paramètres qui sont passées aux fonctions sont appelées arguments de la fonction. Même si les termes paramètre et argument sont parfois interchangés dans la littérature, ils désignent des concepts différents.

Les arguments sont mis en correspondance avec les paramètres un à un dans l'ordre de définition des paramètres. Par exemple, le dernier appel à afficherMenu() ci-dessus utilise les arguments "Revenir" et 1, qui correspondent aux paramètres premiereEntree et premierNombre, respectivement.

Le type de chaque argument doit correspondre au type du paramètre correspondant.

36-3 - Faire le travail

Dans les chapitres précédents, nous avons défini les expressions comme des entités qui effectuent une tâche. Les appels de fonctions sont aussi des expressions : elles effectuent une certaine tâche. Effectuer une tâche peut signifier une combinaison de deux choses.

- Avoir des effets de bord : les effets de bord sont n'importe quel changement d'état du programme ou de son environnement. Seules certaines opérations ont des effets de bord. Un exemple est la modification de stdout quand afficherMenu() affiche le menu. Un autre exemple d'effet de bord serait une fonction qui ajouterait un objet Etudiant à un conteneur étudiant : cela entraînerait l'agrandissement du conteneur.
En résumé, les opérations qui modifient l'état du programme ont des effets de bord.

- Produire une valeur : certaines opérations ne font que produire une valeur. Par exemple, une fonction qui ajoute des nombres produirait le résultat de cette opération. Un autre exemple serait une fonction qui créerait un objet Etudiant à partir du nom et de l'adresse d'un étudiant produirait un objet Etudiant.

- Avoir des effets de bord et produire une valeur : certaines opérations font les deux. Par exemple, une fonction qui lit deux valeurs depuis stdin et calcule leur somme a des effets de bord parce qu'elle modifie l'état de stdin et produit également la somme des deux valeurs.

- Aucune opération : même si toute fonction fait partie d'une des trois catégories ci-dessus, il arrive que certaines fonctions ne fassent rien, dans certaines conditions lors de la compilation ou de l'exécution.

36-4 - La valeur de retour

Le résultat qu'une fonction produit est appelé sa valeur de retour. Ce terme vient de l'observation qu'une fois que l'exécution du programme passe dans une fonction, elle finit par revenir là où elle a été appelée. Les fonctions sont appelées et elles retournent des valeurs.

Comme n'importe quelle autre valeur, les valeurs de retour ont un type. Le type de la valeur de retour est indiqué juste avant le nom de la fonction à l'endroit où la fonction est définie. Par exemple, une fonction qui ajoute deux valeurs int et qui retourne leur somme en tant qu'int serait définie de cette manière :

int ajouter(int premier, int second)
{
// ...  le travail de la fonction ...
}

La valeur que la fonction retourne vient en place de l'appel de la fonction lui-même. Par exemple, en supposant que l'appel de fonction ajouter(5, 7) produise la valeur 12, les deux lignes suivantes sont équivalentes :

writeln("Résultat: ", ajouter(5, 7));
writeln("Résultat: ", 12);

À la première ligne, la fonction ajouter() est appelée avec les arguments 5 et 7 avant que writeln() soit appelée. La valeur 12 que la fonction retourne est à son tour passée à writeln() en deuxième argument.

Ceci permet de passer les valeurs de retour des fonctions aux autres fonctions pour former des expressions complexes :

writeln("Résultat : ", ajouter(5, diviser(100, nombreEtudiants())));

Dans cette ligne, la valeur de retour de nombreEtudiants() est passée à diviser() en second argument, la valeur de retour de diviser() est passée à ajouter() en second argument, et finalement la valeur de retour de ajouter() est passée à writeln() en second argument.

36-5 - L'instruction return

La valeur de retour d'une fonction est indiquée par le mot-clé return :

int ajouter(int premier, int second)
{
int resultat = premier + second;
return resultat;
}

Une fonction produit sa valeur de retour en utilisant des instructions, des expressions et éventuellement en appelant d'autres fonctions. La fonction retourne ensuite cette valeur avec le mot-clé return, point auquel l'exécution de la fonction se finit.

Il est possible d'avoir plus d'une instruction de retour dans une fonction. La valeur de la première instruction de retour qui est exécutée détermine la valeur de retour de la fonction pour un appel particulier :

int calculComplexe(int unParametre, int unAutreParametre)
{
if (unParametre == unAutreParametre) {
return 0;
}

return unParametre * unAutreParametre;
}

La fonction ci-dessus retourne 0 quand les deux paramètres sont égaux et le produit de leurs valeurs quand ils sont différents.

36-6 - Les fonctions void

Le type de retour des fonctions qui ne produisent pas de valeur est conventionnellement void. Nous l'avons vu de nombreuses fois avec la fonction main(), de même qu'avec la fonction afficherMenu définie plus haut. Comme elles ne retournent pas de valeur aux fonctions qui les appellent, leur valeur de retour a été définie comme void. (Note : main() peut également être définie comme retournant int. Nous le verrons dans un chapitre ultérieur.)

36-7 - Le nom de la fonction

Le nom d'une fonction doit être choisi de manière à indiquer clairement son rôle. Par exemple, les noms ajouter et afficherMenu étaient appropriés parce que leur rôle était respectivement d'ajouter deux valeurs et d'afficher un menu.

Une règle courante pour les noms de fonctions est de contenir un verbe comme ajouter ou afficher. Selon cette règle, des noms comme addition() et menu() seraient moins bons.

Cependant, il est acceptable de simplement nommer les fonctions avec un substantif si ces fonctions n'ont pas d'effet secondaire. Par exemple, une fonction qui retourne la température actuelle peut être nommée temperatureActuelle() au lieu de obtenirTemperatureActuelle().

Trouver des noms clairs, concis et cohérents relève un peu de l'art de la programmation.

36-8 - Qualité de code à travers les fonctions

Les fonctions peuvent améliorer la qualité du code. Des fonctions plus petites avec des responsabilités plus légères permettent d'écrire des programmes qui sont plus faciles à maintenir.

36-8-1 - La duplication de code est nuisible

Une pratique hautement néfaste à la qualité d'un programme est la duplication de code. La duplication de code est ce qui se produit quand il y a plus d'un bout de code dans le programme qui réalise la même tâche.

Même si cela arrive parfois en copiant des lignes de code à un endroit différent, cela peut également arriver en écrivant des lignes de codes similaires sans le vouloir.

Un des problèmes avec la duplication de code est qu'un éventuel bogue serait présent dans chaque copie, nécessitant d'effectuer sa correction à plusieurs endroits. Inversement, quand le code apparaît une seule fois dans le programme, le corriger à un seul endroit permet de se débarrasser du bogue une fois pour toutes.

Comme je l'ai mentionné précédemment, les fonctions sont très liées au savoir-faire du programmeur. Les programmeurs expérimentés sont toujours sur leurs gardes à propos de la duplication de code. Ils essayent toujours d'identifier les similarités dans le code et déplacent les bouts de codes communs dans des fonctions à part (ou dans des structures, classes, modèles… communs, comme nous le verrons dans des chapitres ultérieurs).

Commençons par un programme qui contient du code dupliqué. Voyons comment cette duplication peut être éradiquée en déplaçant du code dans des fonctions (c.-à-d. en refactorisant le code). Le programme suivant lit des nombres depuis l'entrée et les affiche d'abord dans l'ordre dans lequel ils sont arrivés et ensuite dans l'ordre croissant :

import std.stdio;

void main()
{
int[] nombres;

int compte;
write("Combien de nombres allez-vous saisir ? ");
readf(" %s", &compte);

// Lecture des nombres
foreach (i; 0 .. compte) {
int nombre;
write("Nombres ", i, "? ");
readf(" %s", &nombre);

nombres ~= nombre;
}

// affichage des nombres
writeln("Avant de trier :");
foreach (i, nombre; nombres) {
writefln("%3d:%5d", i, nombre);
}

nombres.sort;

// affichage des nombres
writeln("Après avoir trié :");
foreach (i, nombre; nombres) {
writefln("%3d:%5d", i, nombre);
}
}

Certaines lignes dupliquées sont évidentes dans ce code. Les deux dernières boucles qui sont utilisées pour afficher les nombres sont exactement les mêmes. Définir une fonction qui peut être judicieusement nommée afficher() éviterait cette duplication. La fonction pourrait prendre une tranche en paramètre et l'afficher :

void afficher(int[] tranche)
{
foreach (i, element; tranche) {
writefln("%3s:%5s", i, element);
}
}

Notez que le nom du paramètre est défini de façon plus générique comme tranche plutôt que nombres. La raison est que la fonction n'a pas spécialement à savoir ce que les éléments de la tranche représentent. Cela n'est connu qu'à l'endroit où la fonction est appelée. Les éléments peuvent être des numéros d'étudiants, des bouts d'un mot de passe, etc. Comme cela peut ne pas être connu par la fonction afficher(), des noms généraux comme tranche et element sont utilisés dans son implémentation.

La nouvelle fonction peut être appelée depuis les deux endroits où la tranche doit être affichée :

import std.stdio;

void afficher(int[] tranche)
{
foreach (i, element; tranche) {
writefln("%3s:%5s", i, element);
}
}

void main()
{
int[] nombres;

int compte;
write("Combien de nombres allez-vous saisir ? ");
readf(" %s", &compte);

// Lecture des nombres
foreach (i; 0 .. compte) {
int nombre;
write("Nombre ", i, "? ");
readf(" %s", &nombre);

nombres ~= nombre;
}

// Affichage des nombres
writeln("Avant le tri :");
afficher(nombres);

nombres.sort;

// Affichage des nombres
writeln("Après le tri :");
afficher(nombres);
}

On peut encore faire mieux. Notez qu'il y a toujours une ligne de titre affichée juste avant l'affichage des éléments de la tranche. Même si le titre est différent, la tâche est la même. Si afficher le titre peut être vu comme faisant partie de l'affichage de la tranche, le titre peut également être passé en paramètre. Voici les nouvelles modifications :

void afficher(string titre, int[] tranche)
{
writeln(titre, " :");

foreach (i, element; tranche) {
writefln("%3s:%5s", i, element);
}
}

// ...

// Afficher les nombres
afficher("Avant le tri", nombres);

// ...

// Afficher les nombres
afficher("Après le tri", nombres);

Cette étape a l'avantage de rendre évidents les commentaires qui apparaissent juste avant les deux appels d'afficher(). Comme le nom de la fonction communique déjà clairement ce qu'elle fait, ces commentaires ne sont plus nécessaires :

afficher("Avant le tri", nombres);
nombres.sort;
afficher("Apres le tri", nombres);

Même si c'est subtil, il y a encore de la duplication de code dans ce programme : les valeurs de compte et nombre sont lues exactement de la même manière. Les seules différences sont le message qui est affiché à l'utilisateur et le nom de la variable :

int compte;
write("Combien de nombres allez-vous saisir ? ");
readf(" %s", &compte);

// ...

int nombre;
write("Nombre ", i, "? ");
readf(" %s", &nombre);

Le code serait encore mieux s'il utilisait une nouvelle fonction nommée de façon appropriée lire_entier(). La nouvelle fonction peut prendre le message en paramètre, afficher ce message, lire un entier depuis l'entrée et le retourner :

int lire_entier(string message)
{
int resultat;
write(message, " ? ");
readf(" %s", &resultat);
return resultat;
}

compte peut maintenant être initialisé directement avec la valeur de retour d'un appel à cette nouvelle fonction :

int compte = lire_entier("Combien de nombres allez-vous saisir");

nombre ne peut pas être initialisé aussi simplement parce qu'il se trouve que le compteur de boucle i fait partie du message à afficher quand on lit le nombre.

import std.string;
// ...
int nombre = lire_entier(format("Nombre %s", i));

De plus, comme nombre n'est utilisé qu'à un endroit dans la boucle foreach, sa définition peut être complètement supprimée et la valeur de retour de lire_entier() peut directement être utilisée à sa place :

foreach (i; 0 .. compte) {
nombres ~= lire_entier(format("Nombre %s", i));
}

Apportons une dernière modification à ce programme en déplaçant les lignes qui lisent les nombres dans une fonction à part. Ceci éliminera la nécessité du commentaire « Lecture des nombres » parce que le nom de la nouvelle fonction portera déjà cette information.

La nouvelle fonction lireLesNombres() n'a besoin d'aucun paramètre pour effectuer sa tâche. Elle lit quelques nombres et les retourne dans une tranche. Voici la version finale du programme :

import std.stdio;
import std.string;

void afficher(string titre, int[] tranche)
{
writeln(titre, " :");

foreach (i, element; tranche) {
writefln("%3s:%5s", i, element);
}
}

int lire_entier(string message)
{
int resultat;
write(message, " ? ");
readf(" %s", &resultat);
return resultat;
}

int[] lireLesNombres()
{
int[] resultat;

int compte = lire_entier("Combien de nombres allez-vous saisir");

foreach (i; 0 .. compte) {
resultat ~= lire_entier(format("Nombre %s", i));
}

return resultat;
}

void main()
{
int[] nombres = lireLesNombres();
afficher("Avant le tri", nombres);
nombres.sort;
afficher("Après le tri", nombres);
}

Comparez cette version du programme à la première. Les étapes principales du programme sont très claires dans la fonction main() du nouveau programme alors qu'il fallait examiner avec attention la fonction main() de la première version du programme pour en comprendre le but.

Même si ici le nombre total de lignes non triviales des deux versions du programme reste similaire, les fonctions rendent généralement les programmes plus courts. Ceci n'est pas apparent dans ce programme simple. Par exemple, avant que la fonction lire_entier() n'ait été définie, lire un entier depuis l'entrée demandait trois lignes de code. Après la définition de cette fonction, le même but est atteint avec une seule ligne de code. De plus, la définition de lire _entier() a permis de se débarrasser complètement de la définition de la variable nombre.

36-8-2 - Lignes de codes commentées en tant que fonctions

Parfois, le besoin d'écrire un commentaire pour décrire le rôle d'un groupe de lignes de code est un indice sur le fait que ces lignes pourraient être mieux dans une nouvelle fonction. Si le nom de la fonction est assez explicite, il n'y aurait même pas besoin de commentaire.

Les trois groupes de lignes commentés dans la première version du programme ont été utilisés pour définir de nouvelles fonctions qui effectuent la même tâche.

Un autre gros avantage à supprimer des lignes de commentaires est que les commentaires tendent à devenir obsolètes par rapport au code qui reçoit des modifications. La mise à jour des commentaires lors de la modification du code est parfois oubliée, ce qui rend ces commentaires inutiles, voire trompeurs. Pour cette raison, écrire des programmes qui n'ont pas besoin d'être beaucoup commentés est bénéfique.

36-8-3 - Exercices

1. Modifiez la fonction afficherMenu() pour qu'elle prenne l'ensemble des éléments du menu dans un paramètre. Par exemple, les éléments du menu peuvent être passés à la fonction comme dans le code suivant :

string[] items =
[ "Noir", "Rouge", "Vert", "Bleu", "Blanc" ];
afficherMenu(items, 1);

2. Le programme devra produire la sortie suivante :

1 Noir
2 Rouge
3 Vert
4 Bleu
5 Blanc

3. Le programme suivant utilise un tableau bidimensionnel pour représenter une toile. Partez de ce programme et améliorez-le en lui ajoutant des fonctionnalités nouvelles :

import std.stdio;

enum nbLignesTotal = 20;
enum nbColonnesTotal = 60;

/*
* L´'alias' dans la ligne suivante fait de 'Ligne' un alias de
* dchar[nbColonnesTotal]. À partir de cet endroit, chaque
* 'Ligne' qui est utilisée dans le reste du programme voudra
* dire dchar[nbColonnesTotal] .
*
* Notez également que 'Ligne' est un tableau à taille fixe.
*/
alias dchar[nbColonnesTotal] Ligne;

/*
* 'Toile' est un alias de 'tableau dynamique de Lignes'.
*/
alias Ligne[] Toile;

/*
* Affiche la toile ligne par ligne.
*/
void afficher(Toile toile)
{
foreach (ligne; toile) {
writeln(ligne);
}
}

/*
* Place un point à l'endroit indiqué sur la toile.
* En un sens, "peint" la toile.
*/
void placerPoint(Toile toile, int ligne, int colonne)
{
toile[ligne][colonne] = '#';
}

/*
* Dessine une ligne verticale de la taille indiquée et à
* l'endroit indiqué.
*/
void dessinerLigneVerticale(Toile toile,
int ligne,
int colonne,
int taille)
{
foreach (ligneApeindre; ligne .. ligne + taille) {
placerPoint(toile, ligneApeindre, colonne);
}
}

void main()
{
Ligne ligneVide = '.';

/* An empty toile */
Toile toile;

/* Construction de la toile en ajoutant des lignes vides */
foreach (i; 0 .. nbLignesTotal) {
toile ~= ligneVide;
}

/* Utilisation de la toile */
placerPoint(toile, 7, 30);
dessinerLigneVerticale(toile, 5, 10, 4);

afficher(toile);
}

Les solutions.

37 - Immutabilité

Nous avons vu que les variables représentent des idées dans les programmes. Les interactions de ces idées sont matérialisées par des expressions qui changent les valeurs de ces variables :

// Régler la note
prixTotal = calculerTotal(prixDesElements);
argentDansLePortefeuille -= prixTotal;
argentDuMarchand += prixTotal;

Quand on modifie une variable, on dit qu'on la mute. La mutabilité est essentielle pour la plupart des tâches. Cependant, dans certains cas, la mutabilité n'est pas adaptée :

- Certaines idées sont immuables par définition. Par exemple, il y a toujours sept jours par semaine, pi (π) est une constante, un programme peut ne prendre en charge qu'un nombre limité de langages (par ex. seulement français et turc), etc.

- Si toute variable était modifiable comme nous l'avons vu jusqu'à maintenant, alors tout morceau de code qui l'utiliserait pourrait potentiellement la modifier. Même quand il n'y a pas de raison de modifier une variable lors d'une opération quelconque, il n'y a également aucune garantie que ce n'est pas le cas. Les programmes sont difficiles à lire et à modifier quand il n'y a pas de garantie d'immuabilité.
Par exemple, il peut être clair que l'appel de fonction retirer(bureau, employé) retire un employé d'un bureau. Si toutes les variables étaient mutables, on ne saurait déterminer avec certitude laquelle des deux variables serait modifiée par l'appel de fonction. On peut s'attendre à ce que le nombre d'employés de bureau actifs soit décrémenté, mais est-ce que l'appel de fonction modifie également employé d'une certaine manière ?

L'idée d'immuabilité aide à comprendre des bouts de programmes en garantissant que certaines opérations ne modifient pas certaines variables. Elle permet aussi de réduire le risque de faire certaines erreurs de programmation.

L'immuabilité en D est représentée par les mots-clés const et immutable. Même si les deux mots sont proches dans leur signification, leurs rôles sont différents et parfois incompatibles.

37-1 - Variables immuables

Les expressions « variables immuables » ou « variables constantes » sont contradictoires quand le mot « variable » est pris au sens littéral pour désigner quelque chose qui change. Le mot « variable » désigne n'importe quel élément d'un programme qui peut être mutable ou immuable.

Il y a trois manières de définir des variables qui ne peuvent jamais être mutées :

37-1-1 - Constantes enum

Nous avons vu plus tôt dans le chapitre sur les énumérations qu'enum définit des valeurs constantes nommées :

enum nomFichier = "liste.txt";

Tant que leurs valeurs peuvent être déterminées lors de la compilation, les variables enum peuvent aussi être initialisées par des valeurs de retour de fonctions :

int nbLignesTotal()
{
return 42;
}

int nbColonnesTotal()
{
return 7;
}

string nom()
{
return "liste";
}

void main()
{
enum nomFichier = nom() ~ ".txt";
enum nbCarrésTotal = nbLignesTotal() * nbColonnesTotal();
}

Comme on peut s'y attendre, les valeurs des constantes enum ne peuvent pas être modifiées :

++nbCarrésTotal;   // ← ERREUR de compilation

Même si c'est une manière très efficace de représenter des valeurs immuables, enum ne peut être utilisé que pour des valeurs connues lors de la compilation.

Une constante enum est une constante manifeste, ce qui signifie que le programme est compilé comme si la constante avait été remplacée par sa valeur partout dans le code. Par exemple, considérons la définition énumérée suivante et les deux expressions qui l'utilisent :

enum i = 42;
writeln(i);
foo(i);

Ce code serait le même en remplaçant i par sa valeur 42 :

writeln(42);
foo(42);

Même si ce remplacement fait sens pour des types simples comme int et ne fait aucune différence dans le programme, les constantes enum apportent un coût caché quand elles sont utilisées pour des tableaux ou des tableaux associatifs :

enum a = [ 42, 100 ];
writeln(a);
foo(a);

En remplaçant a par sa valeur, le code équivalent que le compilateur compilerait est le suivant :

writeln([ 42, 100 ]);   // Un tableau est créé lors de l'exécution
foo([ 42, 100 ]);       // Un autre tableau est créé lors de l'exécution

Le coût caché ici est qu'il y aurait deux tableaux créés pour les deux expressions ci-dessus. Pour cette raison, il vaut mieux définir les tableaux et les tableaux associatifs en tant que variables immuables s'ils sont utilisés plusieurs fois dans le programme.

37-1-2 - Variables immutable

Comme enum, ce mot-clé indique que la valeur d'une variable ne changera jamais. Sa différence par rapport à enum est que les valeurs des variables immutable peuvent être calculées lors de l'exécution du programme.

Le programme suivant compare les utilisations de enum et de immutable. Le programme attend que l'utilisateur devine un nombre qui a été choisi au hasard. Comme le nombre aléatoire ne peut pas être déterminé lors de la compilation, il ne peut pas être défini comme un enum. Cependant, comme la valeur choisie ne doit jamais être changée après avoir été choisie, il est correct de marquer cette variable comme immutable.

Le programme utilise la fonction lire_entier() définie dans le chapitre précédent :

import std.stdio;
import std.random;

int lire_entier(string message)
{
int resultat;
write(message, " ? ");
readf(" %s", &resultat);
return resultat;
}

void main()
{
enum min = 1;
enum max = 10;

immutable nombre = uniform(min, max + 1);

writefln("Je pense à un nombre entre %s et %s.",
min, max);

auto estCorrect = false;
while (!estCorrect) {
immutable tentative = lire_entier("Que proposez-vous");
estCorrect = (tentative == nombre);
}

writeln("Correct !");
}

Observations :

min et max font partie intégrante du comportement du programme et leurs valeurs sont connues lors de la compilation. Pour cette raison, elles sont définies comme des constantes enum. nombre est défini comme immutable parce qu'il ne serait pas approprié de modifier cette variable lors de l'exécution du programme. Idem pour chaque tentative de l'utilisateur : une fois qu'elle est lue, la tentative ne devrait pas être modifiée.

Notez que les types de ces variables ne sont pas explicitement spécifiés. Comme avec auto, les types des variables enum et immutable peuvent être inférés depuis l'expression à droite de l'opérateur d'affectation.

Même s'il n'est pas nécessaire d'écrire le type complet comme dans l'exemple immutable(int), immutable prend normalement le type entre parenthèses. La sortie du programme suivant montre que le nom du type des trois variables est en fait le même :

import std.stdio;

void main()
{
immutable      typeInféré    = 0;
immutable int  typeExplicite = 1;
immutable(int) typeComplet   = 2;

writeln(typeof(typeInféré).stringof);
writeln(typeof(typeExplicite).stringof);
writeln(typeof(typeComplet).stringof);
}

Le nom du type inclut immutable :

immutable(int)
immutable(int)
immutable(int)

Le type qui est spécifié entre parenthèses a une signification. Nous verrons cela plus tard quand nous aborderons l'immuabilité d'une tranche entière comparativement à celle de ses éléments.

37-1-3 - Variables const

Ce mot-clé a le même effet qu'immutable sur les variables. Les variables const ne peuvent pas être modifiées :

const moitié = total / 2;
moitié = 10;    // ← ERREUR de compilation

Je vous suggère d'utiliser immutable plutôt que const pour les variables. La raison à cela est que les variables immutable peuvent être passées aux fonctions en paramètre immutable. Nous allons voir cela tout de suite.

37-2 - Paramètres immuables

Il est possible pour les fonctions de promettre qu'elles ne vont pas modifier certains paramètres qu'elles prennent. Le compilateur garantit que cette promesse est tenue. Avant de voir comment ça fonctionne, voyons que les fonctions peuvent en effet modifier les éléments des tranches qui sont passées en arguments à ces fonctions.

D'après ce que nous avons vu dans le chapitre sur les tranches et autres fonctionnalités des tableaux, les tranches ne stockent pas leurs éléments mais y donnent accès. Il peut y avoir plus d'une tranche à un moment donné qui donne accès aux mêmes éléments.

Même si les exemples de cette section ne se concentrent que sur les tranches, cela s'applique aussi aux tableaux associatifs et aux classes parce que ce sont également des types référence.

Une tranche qui est passée en argument à une fonction n'est pas la tranche avec laquelle la fonction est appelée. L'argument est une copie de la tranche :

import std.stdio;

void main()
{
int[] tranche = [ 10, 20, 30, 40 ];  // 1
moitié(tranche);
writeln(tranche);
}

void moitié(int[] nombres)              // 2
{
foreach (ref nombre; nombres) {
nombre /= 2;
}
}

Quand l'exécution du programme entre dans la fonction moitié(), il y a deux tranches qui donnent accès aux mêmes éléments :

- la tranche nommée tranche qui est définie dans main(), qui est passée en argument à moitié() ;

- la tranche nommée nombres que moitié() reçoit en argument, qui donne accès aux mêmes éléments que tranche.

Aussi, à cause du mot-clé ref dans la boucle foreach, les valeurs des éléments d'origine (et seulement eux) sont divisées par deux :

[5, 10, 15, 20]

Il est utile pour les fonctions d'avoir la possibilité de modifier les éléments des tranches qui sont passées en arguments. Certaines fonctions n'existent que pour cela, comme dans cet exemple.

Le compilateur n'autorise pas à passer des variables immuables comme arguments à de telles fonctions, parce qu'il est impossible de modifier une variable immuable :

immutable int[] tranche = [ 10, 20, 30, 40 ];
moitié(tranche);   // ← ERREUR de compilation

L'erreur de compilation indique qu'une variable de type immutable(int[]) ne peut pas être utilisée comme un argument de type int[].

Error: function essai.moitié (int[] nombres) is not callable

37-2-1 - Paramètres const

Il est important et naturel que le passage de variables immutable à des fonctions comme moitié(), qui modifient leurs arguments, soit interdit. Cependant, ne pas pouvoir les passer à des fonctions qui ne modifient pas leurs arguments serait une limitation :

import std.stdio;

void main()
{
immutable int[] tranche = [ 10, 20, 30, 40 ];
afficher(tranche);   // ← ERREUR de compilation
}

void afficher(int[] tranche)
{
writefln("%s éléments: ", tranche.length);

foreach (i, element; tranche) {
writefln("%s: %s", i, element);
}
}

Interdire à tranche d'être imprimée uniquement parce qu'elle est immuable n'a pas de sens. La bonne manière de gérer cette situation est d'utiliser les paramètres const.

Le mot-clé const indique qu'une variable n'est pas modifiée à travers cette référence particulière (par ex. une tranche) de la variable. Indiquer un paramètre comme const garantit que les éléments de la tranche ne seront pas modifiés à l'intérieur de la fonction. Une fois que afficher() donne cette garantie, le programme peut être compilé :

afficher(tranche);   // maintenant, compile
// ...
void afficher(const int[] tranche)

Cette garantie permet de passer aussi bien des variables mutables que des variables immuables en arguments :

immutable int[] tranche = [ 10, 20, 30, 40 ];
afficher(tranche);          // compile

int[] trancheMutable = [ 7, 8 ];
afficher(trancheMutable);   // compile

Un paramètre qui n'est pas modifié dans une fonction, mais qui n'est pas indiqué comme const limite l'utilisabilité de cette fonction. De plus, les paramètres const donnent des informations utiles au programmeur. Savoir qu'une variable ne sera pas modifiée lorsqu'elle est passée à une fonction rend le code plus facile à comprendre. Cela évite également de faire des erreurs puisque le compilateur interdit les modifications sur les paramètres const :

void afficher(const int[] tranche)
{
tranche[0] = 42;   // ← ERREUR de compilation
// ...
}

Le programmeur prend alors conscience de l'erreur dans la fonction ou repense la conception et supprime éventuellement l'indicateur const.

Le fait que les paramètres const acceptent et les variables mutables et les variables immuables a une conséquence intéressante. Ceci est expliqué plus loin dans la section « Un paramètre devrait-il être const ou immutable ? »

37-2-2 - Paramètres immutable

Comme nous l'avons vu à la section précédente, les variables immuables ou non peuvent toutes être passées aux fonctions en paramètre const. Dans un sens, les paramètres const sont accueillants.

En revanche, les paramètres immutable apportent un prérequis important : seules les variables immutable peuvent être passées aux fonctions en paramètres immutable :

void func(immutable int[] tranche)
{
/* ... */
}

void main()
{
immutable int[] immTranche = [ 1, 2 ];
int[]    tranche = [ 8, 9 ];

func(immTranche);        // compile
func(tranche);         // ← ERREUR de compilation
}

Pour cette raison, l'indicateur immutable devrait être utilisé uniquement quand ce prérequis est nécessaire. Nous avons en fait déjà utilisé l'indicateur immutable indirectement à travers certains types de chaînes de caractères. Ceci sera couvert plus loin.

Nous avons vu que les paramètres qui sont indiqués comme const ou immutable promettent de ne pas modifier la variable qui est passée en argument. Ceci n'est pertinent que pour les types référence, le problème ne se pose pas pour les types valeur.

Les types référence et les types valeur seront couverts dans des chapitres ultérieurs. Parmi les types que nous avons vus jusqu'à maintenant, seuls les tranches et les tableaux associatifs sont des types référence ; les autres sont des types valeur.

37-3 - Un paramètre devrait-il être const ou immutable ?

Les deux sections précédentes peuvent donner l'impression que comme ils sont plus flexibles, les paramètres const devraient être préférés aux paramètres immutable. Ceci n'est pas toujours vrai.

const efface l'information de l'immuabilité de la variable d'origine. Cette information est cachée même au compilateur.

Une conséquence de ceci est que les paramètres const ne peuvent pas être passés en arguments à des fonctions qui prennent des paramètres immutable. Par exemple, la fonction foo() qui suit ne peut pas passer son paramètre const à bar() :

void main()
{
/* La variable d'origine est immuable */
immutable int[] tranche = [ 10, 20, 30, 40 ];
foo(tranche);
}

/* Une fonction qui prend un paramètre const, dans le
* but d'être plus utile. */
void foo(const int[] tranche)
{
bar(tranche);   // ← ERREUR de compilation
}

/* Une fonction qui prend un paramètre immuable pour une
* raison valable. */
void bar(immutable int[] tranche)
{
/* ... */
}

bar() nécessite un paramètre immutable. Cependant, on ne sait pas si la variable originale que le paramètre const de foo() référence est immuable ou non.

Il est clair dans le code qui précède que la variable d'origine dans main() est immuable. Cependant, le compilateur compile les fonctions individuellement sans regarder à tous les endroits depuis lesquels la fonction est appelée. Pour le compilateur, le paramètre tranche() peut se référer à une variable mutable ou à une variable immuable.

Une solution serait d'appeler bar() avec une copie immuable du paramètre :

void foo(const int[] tranche)
{
bar(tranche.idup);
}

Même s'il s'agit d'une solution raisonnable, elle a le coût d'une copie, qui serait un gâchis dans le cas où la variable d'origine était déjà immutable.

Après cette analyse, il devrait être clair qu'utiliser systématiquement des paramètres const ne semble pas la meilleure approche dans toutes les situations. Après tout, si le paramètre foo() a été défini comme immutable, il ne devrait pas être nécessaire de le copier avant d'appeler bar() :

void foo(immutable int[] tranche) // Cette fois-ci, immutable
{
bar(tranche);   // Copier n'est plus nécessaire
}

Même si le code compile, définir le paramètre en tant qu'immutable a un coût similaire : cette fois, une copie immuable de la variable originale est nécessaire lors de l'appel à foo() si cette variable n'était pas immutable à l'origine :

foo(trancheMutable.idup);

Les modèles peuvent aider. (Nous verrons les modèles dans des chapitres ultérieurs). Même si je ne m'attends pas à ce que vous compreniez totalement la fonction suivante à cet endroit du livre, je vous la présente comme une solution à ce problème. La fonction modèle foo() suivante peut être appelée aussi bien avec des arguments mutables qu'immuables. Le paramètre ne sera copié que si la variable d'origine était mutable ; aucune copie ne sera faite si elle était immuable :

import std.conv;
// ...

/* Parce qu'elle est un modèle, foo() peut être appelée avec
* des variables mutable et immuable. */
void foo(T)(T[] tranche)
{
/* 'to()' ne fait pas de copie si la variable d'origine est
* déjà immuable. */
bar(to!(immutable T[])(tranche));
}

37-4 - Immuabilité de la tranche versus immuabilité des éléments

Nous avons vu plus haut le type d'une tranche immuable affiché comme immutable(int). Comme les parenthèses après immutable l'indiquent, c'est la tranche entière qui est immuable. Une telle tranche ne peut être modifiée d'aucune manière : les éléments ne peuvent pas être ajoutés ou supprimés, leurs valeurs ne peuvent pas être modifiées et la tranche ne peut pas se mettre à donner accès à un autre ensemble d'éléments :

immutable int[] immTranche = [ 1, 2 ];
immTranche ~= 3;                // ← ERREUR de compilation
immTranche[0] = 3;              // ← ERREUR de compilation
immTranche.length = 1;          // ← ERREUR de compilation

immutable int[] immAutreTranche = [ 10, 11 ];
immTranche = immAutreTranche;   // ← ERREUR de compilation

Pousser cette immuabilité à un tel extrême peut ne pas être adapté dans tous les cas. Dans la plupart des cas, ce qui est important est seulement l'immuabilité des éléments. Comme une tranche est seulement un outil d'accès aux éléments, effectuer des changements sur la tranche elle-même ne devrait pas poser de problème tant que les éléments ne sont pas modifiés.

Pour indiquer que seuls les éléments sont immuables, le type des éléments (seul) est écrit entre parenthèses. Quand le code est modifié de cette manière, seuls les éléments sont immuables, pas la tranche elle-même :

immutable(int)[] immTranche = [ 1, 2 ];
immTranche ~= 3;                // peut ajouter des éléments
immTranche[0] = 3;              // ← ERREUR de compilation
immTranche.length = 1;          // peut perdre des éléments

immutable int[] immAutreTranche = [ 10, 11 ];
immTranche = immAutreTranche;   // peut donner accès à d'autres éléments

Même si les deux syntaxes sont très similaires, elles ont des significations différentes. En résumé :

immutable int[]  a = [1]; /* Ni les éléments, ni la
* tranche ne peuvent être modifiés */

immutable(int[]) b = [1]; /* identique à la précédente */

immutable(int)[] c = [1]; /* Les éléments ne peuvent pas être
* modifiés mais la tranche peut l'être */

Cette distinction se retrouve dans certains programmes que nous avons rencontrés. Comme vous devez vous en souvenir, les trois alias de chaînes de caractères impliquent l'immuabilité :

- string est un alias de immutable(char)[] ;

- wstring est un alias de immutable(wchar)[] ;

- dstring est un alias de immutable(dchar)[].

De même, les littéraux de chaînes sont également immuables :

- Le type du littéral "hello"c est string ;

- Le type du littéral "hello"w est wstring ;

- Le type du littéral "hello"d est dstring.

Selon ces définitions, les chaînes D sont normalement des tableaux de caractères immuables.

37-5 - .dup et .idup

Il peut y avoir des problèmes d'immuabilité quand les chaînes sont passées aux fonctions en paramètres. Les propriétés .dup et .idup font des copies des tableaux avec l'immuabilité désirée :

- .dup fait une copie mutable du tableau (son nom vient de "dupliquer") ;

- .idup fait une copie immuable du tableau.

Par exemple, une fonction qui insiste sur l'immuabilité d'un paramètre peut devoir être appelée avec une copie immuable d'une chaîne mutable :

void foo(string s)
{
// ...
}

void main()
{
char[] salutation;
foo(salutation);            // ← ERREUR de compilation
foo(salutation.idup);       // ← ceci compile
}

37-6 - Utilisation

- En règle générale, préférez les variables immuables aux variables mutables.

- Définissez les valeurs constantes comme enum si elles peuvent être calculées lors de la compilation. Par exemple, la valeur constante représentant le nombre de secondes par minute peut être une enum :

enum int secondesParMinute = 60;

- Il n'y a pas besoin de spécifier le type de façon explicite s'il peut être inféré depuis le côté droit de l'affectation :

enum secondesParMinute = 60;

- Considérez le coût de copie des tableaux (associatifs) enum. Définissez-les comme des variables immutable si les tableaux sont larges et qu'ils sont utilisés plus d'une fois dans le programme.

- Indiquez les variables comme immutable si leur valeur ne change pas, mais qu'elle ne peut pas être connue lors de la compilation. De même, le type peut être inféré :

immutable tentative = lire_entier("Que proposez-vous");

- Si une fonction ne modifie pas un paramètre, indiquez ce paramètre comme const. Ceci permettra de passer en argument aussi bien des variables mutables qu'immuables :

import std.stdio;

void inverser(dchar[] s)
{
foreach (i; 0 .. s.length / 2) {
immutable temp = s[i];
s[i] = s[$ - 1 - i];
s[$ - 1 - i] = temp;
}
}

void main()
{
dchar[] salutation = "salut"d.dup;
inverser(salutation);
writeln(salutation);
}

- La sortie :

tulas

37-7 - Résumé

- Les variables enum représentent des idées immuables connues lors de la compilation.

- Les variables immutable représentent des idées immuables qui doivent être calculées lors de l'exécution.

- Les paramètres const sont ceux que les fonctions ne modifient pas. Les variables mutables et immuables peuvent toutes être passées comme arguments de paramètres const.

- Un paramètre ne doit être déclaré immutable que si cela répond à un impératif spécifique à l'intérieur de la fonction. Seules les variables immutable peuvent être passées comme arguments de paramètres immutable.

- immutable(int[]) : indique que ni la tranche ni ses éléments ne peuvent être modifiés.

- immutable(int)[] : indique que seuls les éléments ne peuvent être modifiés.

- L'immuabilité est un outil très puissant en programmation. Il est utile de savoir que les variables et les paramètres ne sont pas modifiés dans des contextes spécifiques ou lors d'opérations spécifiques.

38 - Les paramètres de fonction

Ce chapitre couvre les différentes manières de définir des paramètres de fonction.

Certaines idées de ce chapitre sont déjà apparues dans les chapitres précédents. Par exemple, le mot-clé ref que nous avons vu dans le chapitre sur les boucles foreach permettait d'accéder directement aux éléments eux-mêmes dans les boucles foreach au lieu d'accéder à des copies de ces éléments.

De plus, nous avons couvert les mots-clés const et immutable dans le chapitre précédent.

Nous avons écrit des fonctions qui produisaient des résultats en utilisant leurs paramètres. Par exemple, la fonction suivante utilise ses paramètres dans un calcul :

double moyennePondérée(double noteDuQuiz, double noteFinale)
{
return noteDuQuiz * 0.4 + noteFinale * 0.6;
}

Cette fonction calcule la note moyenne en prenant 40 % de la note du quiz et 60 % de la note finale. Voici comment elle peut être utilisée :

int noteDuQuiz = 76;
int noteFinale = 80;

writefln("Moyenne pondérée : %2.0f",
moyennePondérée(noteDuQuiz, noteFinale));

38-1 - La plupart des paramètres sont copiés

Dans le code qui précède, les deux variables sont passées en arguments à moyennePondérée() et la fonction utilise ses paramètres. Ceci peut donner la mauvaise impression que la fonction utilise directement les variables qui sont passées en argument. En réalité, la fonction utilise des copies de ces variables.

Cette distinction est importante parce que modifier un paramètre change uniquement la copie. On peut observer cela dans la fonction suivante qui essaie de modifier ses paramètres (c'est-à-dire avoir un effet de bord). Supposons que la fonction suivante soit écrite pour réduire l'énergie d'un personnage de jeu :

void reduireEnergie(double energie)
{
energie /= 4;
}

Voici un programme qui teste reduireEnergie() :

import std.stdio;

void reduireEnergie(double energie)
{
energie /= 4;
}

void main()
{
double energie = 100;

reduireEnergie(energie);
writeln("Nouvelle energie : ", energie);
}

La sortie :

Nouvelle energie : 100     ← non changée !

Même si reduireEnergie() divise la valeur de son paramètre par quatre, la variable energie dans main() ne change pas. La raison à cela est que la variable energie dans main() et le paramètre energie de reduireEnergie() sont distincts ; le paramètre est une copie de la variable de main().

Pour observer cela plus précisément, plaçons quelques writeln() :

import std.stdio;

void reduireEnergie(double energie)
{
writeln("En entrant dans la fonction  : ", energie);
energie /= 4;
writeln("En sortant de la fonction    : ", energie);
}

void main()
{
double energie = 100;

writeln("En appelant la fonction      : ", energie);
reduireEnergie(energie);
writeln("Après l'appel de la fonction : ", energie);
}

La sortie :

En appelant la fonction      : 100
En entrant dans la fonction  : 100
En sortant de la fonction    : 25   ← le paramètre change,
Après l'appel de la fonction : 100  ← la variable reste la même

38-2 - Les objets de type référence ne sont pas copiés

Les éléments des tranches et des tableaux associatifs, ainsi que les objets de classes, ne sont pas copiés quand ils sont passés en paramètres. De telles variables sont passées aux fonctions par références. En effet, le paramètre devient une référence à l'objet ; les modifications apportées à travers la référence affectent l'objet.

Étant des tranches, les chaînes sont également passées par référence :

import std.stdio;

void premiereLettreEnPoint(dchar[] chaine)
{
chaine[0] = '.';
}

void main()
{
dchar[] chaine = "abc"d.dup;
premiereLettreEnPoint(chaine);
writeln(chaine);
}

La modification apportée au premier élément du paramètre affecte l'élément dans main() :

.bc

38-3 - Les qualificatifs de paramètre

Les paramètres sont passés aux fonctions selon les règles générales suivantes :

- les types valeur sont copiés ;

- les types référence sont passés par références.

Ce sont les règles par défaut qui sont appliquées quand les définitions de paramètres n'ont pas de qualificatifs. Les qualificatifs suivants changent la manière dont les paramètres sont passés et quelles opérations sur eux sont permises.

38-3-1 - in

Nous avons vu que les fonctions sont un service qui produit des valeurs et peut avoir des effets de bords. Le mot-clé in indique qu'un paramètre va être utilisé seulement comme une donnée d'entrée. De tels paramètres ne peuvent pas être modifiés par la fonction. in implique const :

import std.stdio;

double poidsTotal(in double totalActuel,
in double poids,
in double quantitéAjoutée)
{
return totalActuel + (poids * quantitéAjoutée);
}

void main()
{
writeln(poidsTotal(1.23, 4.56, 7.89));
}

Les paramètres in ne peuvent pas être modifiés :

void foo(in int valeur)
{
valeur = 1;    // ← ERREUR de compilation
}

38-3-2 - out

Nous avons vu que les fonctions retournent les valeurs qu'elles produisent comme leur valeur de retour. Parfois, n'avoir qu'une seule valeur de retour est limitatif et certaines fonctions peuvent avoir besoin de produire plus d'une valeur (note : il est en fait possible de retourner plus d'un résultat en définissant le type de retour comme un n-uplet ou une structure. Nous verrons ces fonctionnalités dans des chapitres ultérieurs).

Le mot-clé out permet aux fonctions de retourner des résultats à travers leurs paramètres. Quand les paramètres out sont modifiés dans une fonction, ces modifications affectent la variable qui a été passée à la fonction.

Examinons la fonction qui divise deux nombres et produit le quotient et le reste. La valeur de retour peut être utilisée pour le quotient et le reste peut être retourné à travers un paramètre out :

import std.stdio;

int diviser(in int dividende, in int diviseur, out int reste)
{
reste = dividende % diviseur;
return dividende / diviseur;
}

void main()
{
int reste;
int resultat = diviser(7, 3, reste);

writeln("résultat : ", resultat, ", reste : ", reste);
}

Modifier le paramètre reste de la fonction modifie la variable reste dans main() (leurs noms n'ont pas besoin d'être les mêmes) :

résultat: 2, reste : 1

Indépendamment de leurs valeurs au moment de l'appel, la valeur init du type des paramètres out leur est automatiquement affectée :

import std.stdio;

void foo(out int parametre)
{
writeln("Après être entré dans la fonction : ", parametre);
}

void main()
{
int variable = 100;

writeln("Avant l'appel de la fonction     : ", variable);
foo(variable);
writeln("Après le retour de la fonction   : ", variable);
}

Même s'il n'y a pas d'affectation explicite au paramètre dans la fonction, la valeur du paramètre devient automatiquement la valeur initiale de int, affectant la variable dans main() :

Avant l'appel de la fonction      : 100
Après être entré dans la fonction : 0  ← la valeur de int.init
Après le retour de la fonction    : 0

Cela montre que les paramètres out ne peuvent pas passer de valeur aux fonctions ; ils sont strictement là pour transmettre des valeurs hors de la fonction.

Nous verrons dans des chapitres ultérieurs que retourner des n-uplets ou structures peut être mieux qu'utiliser des paramètres out.

38-3-3 - const

Comme nous l'avons vu dans le chapitre précédent, const garantit que le paramètre ne sera pas modifié dans la fonction. Il est utile aux programmeurs de savoir que certaines variables ne seront pas modifiées par la fonction. const rend également les fonctions plus utiles en autorisant des variables const, immutable et non immutable à être passées en paramètres :

import std.stdio;

dchar derniereLettre(const dchar[] str)
{
return str[$ - 1];
}

void main()
{
writeln(derniereLettre("constante"));
}

38-3-4 - immutable

Comme nous l'avons vu dans le chapitre précédent, immutable force certains arguments à être des éléments immutable. À cause d'un tel prérequis, la fonction suivante ne peut être appelée qu'avec des chaînes immuables (par ex. avec des littéraux de chaînes) :

import std.stdio;

dchar[] mix(immutable dchar[] premier,
immutable dchar[] second)
{
dchar[] resultat;
int i;

for (i = 0; (i < premier.length) && (i < second.length); ++i) {
resultat ~= premier[i];
resultat ~= second[i];
}

resultat ~= premier[i..$];
resultat ~= second[i..$];

return resultat;
}

void main()
{
writeln(mix("BONJOUR", "le monde"));
}

Comme il demande un prérequis sur le paramètre, le qualificatif immutable ne devrait être utilisé que quand il est vraiment nécessaire. Étant plus accueillant, const est plus utile.

38-3-5 - ref

Ce mot-clé permet de passer un paramètre par référence même dans les cas où il serait normalement passé par valeur.

Pour que la fonction reduireEnergie() vue plus tôt modifie la variable qui lui est passée en argument, son paramètre doit être marqué avec ref :

import std.stdio;

void reduireEnergie(ref double energie)
{
energie /= 4;
}

void main()
{
double energie = 100;

reduireEnergie(energie);
writeln("Nouvelle energie : ", energie);
}

Cette fois, les modifications qui sont appliquées aux paramètres affectent la variable qui est passée à la fonction dans main() :

Nouvelle energie: 25

Comme on peut le remarquer, les paramètres ref peuvent être utilisés aussi bien comme entrée que comme sortie. Les paramètres ref peuvent aussi être vus comme des alias des variables passées en argument. Le paramètre de la fonction energie ci-dessus est un alias de la variable energie dans main().

Tout comme les paramètres out, les paramètres ref permettent aux fonctions d'avoir des effets de bords. En fait, reduireEnergie() ne retourne pas de valeur ; elle ne fait qu'avoir un effet de bord à travers son seul paramètre.

Le style de programmation dit fonctionnel favorise les valeurs de retour sur les effets de bords, à tel point que certains langages de programmation fonctionnels ne permettent pas du tout les effets de bords. Les fonctions qui produisent des résultats uniquement par leur valeur de retour sont en effet plus faciles à comprendre, à écrire correctement et à maintenir.

La même fonction peut être écrite en style fonctionnel en retournant le résultat, au lieu de créer un effet de bord.

import std.stdio;

double energieReduite(double energie)
{
return energie / 4;
}

void main()
{
double energie = 100;

energie = energieReduite(energie);
writeln("Nouvelle energie: ", energie);
}

Notez également le changement de nom de la fonction. Il ne s'agit plus d'un verbe, mais d'un groupe nominal.

38-3-6 - auto ref

Ce qualificateur ne peut être utilisé qu'avec les modèles (templates). Comme nous le verrons dans le chapitre suivant, un paramètre auto ref prend les valeurs de gauche par référence et les valeurs de droite par copie.

38-3-7 - inout

Malgré son nom composé de in et de out, ce mot-clé ne veut pas dire entrée et sortie ; nous avons déjà vu que le mot-clé qui fait cela est ref.

inout transmet la mutabilité du paramètre au type de retour. Si le paramètre est mutable, const ou immutable, alors la valeur de retour est respectivement mutable, const ou immutable.

Pour voir l'utilité d'inout, examinons une fonction qui retourne une tranche qui a un élément de moins au début et à la fin que la tranche d'origine :

import std.stdio;

int[] rognée(int[] tranche)
{
if (tranche.length) {
--tranche.length;                // rogner depuis la fin

if (tranche.length) {
tranche = tranche[1 .. $];   // rogner depuis le début
}
}

return tranche;
}

void main()
{
int[] nombres = [ 5, 6, 7, 8, 9 ];
writeln(rognée(nombres));
}

La sortie :

[6, 7, 8]

Selon le chapitre précédent, pour que la fonction soit plus utile, son paramètre devrait être const(int)[] parce que le paramètre n'est pas modifié dans la fonction (notez qu'il n'est pas dangereux de modifier le paramètre tranche lui-même parce c'est une copie de l'argument originel).

Cependant, définir la fonction de la façon suivante entraînerait une erreur de compilation :

int[] rognée(const(int)[] tranche)
{
// ...
return tranche;    // ← ERREUR de compilation
}

L'erreur de compilation indique que la tranche de const(int) ne peut pas être retournée comme une tranche de mutable int :

Sortie :

Error: cannot implicitly convert expression (tranche) of type
const(int)[] to int[]

On pourrait croire que spécifier le type de retour comme const(int)[] peut être la solution :

const(int)[] rognée(const(int)[] tranche)
{
// ...
return tranche;    // compile maintenant
}

Bien que le code puisse maintenant être compilé, il apporte une limitation : même si la fonction est appelée avec une tranche d'éléments mutable, la tranche retournée contient des éléments const. Pour voir à quel point ceci est limitant, regardons le code suivant, qui essaie de modifier les éléments d'une tranche autres que ceux qui sont au début ou à la fin :

int[] nombres = [ 5, 6, 7, 8, 9 ];
int[] milieu = rognée(nombres);    // ← ERREUR de compilation
milieu[] *= 10;

Comme on peut s'y attendre, la tranche retournée de type const(int)[] ne peut pas être assignée à une tranche de type int[].

Error: cannot implicitly convert expression (rognée(nombres))
of type const(int)[] to int[]

Cependant, comme la tranche de départ est constituée d'éléments mutable, cette limitation peut être vue comme artificielle et malheureuse. inout résout ce problème de mutabilité entre les paramètres et les valeurs de retour. Il est indiqué aussi bien sur le type du paramètre que sur le type de retour et transmet la mutabilité du premier au second :

inout(int)[] rognée(inout(int)[] tranche)
{
// ...
return tranche;
}

Avec ce changement, la même fonction peut maintenant être appelée avec des tranches mutable, const et immutable :

{
int[] nombres = [ 5, 6, 7, 8, 9 ];
// Le type de retour est une tranche d'éléments mutables
int[] milieu = rognée(nombres);
milieu[] *= 10;
writeln(milieu);
}

{
immutable int[] nombres = [ 10, 11, 12 ];
// Le type de retour est une tranche d'éléments immuables
immutable int[] milieu = rognée(nombres);
writeln(milieu);
}

{
const int[] nombres = [ 13, 14, 15, 16 ];
// Le type de retour est une tranche d'éléments const
const int[] milieu = rognée(nombres);
writeln(milieu);
}

38-3-8 - Lazy (paresseux)

Il est naturel de s'attendre à ce que les arguments soient évalués avant d'entrer dans les fonctions qui utilisent ces arguments. Par exemple, la fonction ajouter() ci-dessous est appelée avec les valeurs de retours de deux autres fonctions :

resultat = ajouter(uneQuantité(), uneAutreQuantité());

Pour qu'ajouter() soit appelée, uneQuantité() et uneAutreQuantité() doivent être appelées avant. Autrement, les valeurs dont ajouter() a besoin ne seraient pas disponibles.

Évaluer les arguments avant d'appeler une fonction est non paresseux (eager).

Cependant, certains paramètres peuvent ne pas être utilisés du tout par une fonction selon certaines conditions. Dans de tels cas, les évaluations non paresseuses des arguments sont inutiles.

Regardons un programme qui utilise un de ses paramètres seulement quand il est nécessaire. La fonction suivante essaie de prendre le nombre requis d'œufs dans le réfrigérateur. Quand il y a un nombre suffisant d'œufs dans le réfrigérateur, elle n'a pas besoin de savoir combien d'œufs les voisins ont :

void faireOmelette(in int œufsRequis,
in int œufsDansLeRefrigérateur,
in int œufsDesVoisins)
{
writefln("Besoin de faire une omelette de %s œufs", œufsRequis);

if (œufsRequis <= œufsDansLeRefrigérateur) {
writeln("Prendre tous les œufs dans le réfrigérateur");

} else if (œufsRequis <= (œufsDansLeRefrigérateur + œufsDesVoisins)) {
writefln("Prendre %s œufs du réfrigérateur"
"     et %s œufs chez les voisins",
œufsDansLeRefrigérateur, œufsRequis - œufsDansLeRefrigérateur);

} else {
writefln("Impossible de faire une omelette de % œufs", œufsRequis);
}
}

De plus, supposons qu'il y ait une fonction qui calcule et retourne le nombre total d'œufs du voisinage. Pour des raisons pédagogiques, la fonction affiche aussi quelques informations :

int nombreŒufs(in int[string] œufsDisponibles)
{
int resultat;

foreach (voisin, nombre; œufsDisponibles) {
writeln(voisin, " : ", nombre, " œufs");
resultat += nombre;
}

writefln("Un total de %s œufs disponibles chez les voisins",
resultat);

return resultat;
}

La fonction itère sur les éléments d'un tableau associatif et somme le nombre d'œufs.

La fonction faireOmelette() peut être appelée avec la valeur de retour de nombreŒufs() comme dans le programme suivant :

import std.stdio;

void main()
{
int[string] chezLesVoisins = [ "Jane":5, "Jim":3, "Bill":7 ];

faireOmelette(2, 5, nombreŒufs(chezLesVoisins));
}

Comme on peut le constater dans la sortie du programme, la fonction nombreŒufs() est d'abord exécutée et faireOmelette() est ensuite appelée :

Jane : 5 œufs     ⎫
Bill : 7 œufs     ⎬ Comptage des œufs chez les voisins
Jim : 3 œufs      ⎭
Un total de 15 œufs disponibles chez les voisins
Besoin de faire une omelette de 2 œufs
Prendre tous les œufs depuis le réfrigérateur

Bien qu'il soit possible de faire l'omelette de deux œufs avec les œufs du réfrigérateur uniquement, les œufs chez les voisins ont été comptés de façon non paresseuse.

Le mot-clé lazy (« paresseux ») indique qu'une expression qui a été passée à une fonction comme paramètre sera évaluée seulement si et quand elle est nécessaire :

void faireOmelette(in int œufsRequis,
in int œufsDansLeRefrigérateur,
lazy int œufsDesVoisins)
{
// …Le corps de la fonction est le même qu'avant ...
}

Comme vu dans la nouvelle sortie, quand le nombre d'œufs dans le réfrigérateur satisfait le nombre d'œufs requis, le comptage des œufs chez les voisins ne se fait plus :

Besoin de faire une omelette de 2 œufs
Prendre tous les œufs dans le réfrigérateur

Ce comptage sera toujours fait si nécessaire. Par exemple, considérons le cas où le nombre d'œufs requis est plus grand que le nombre d'œufs dans le réfrigérateur :

faireOmelette(9, 5, nombreŒufs(chezLesVoisins));

Cette fois, le nombre total d'œufs chez les voisins est vraiment nécessaire :

Besoin de faire une omelette de 9 œufs.
Jane : 5 œufs
Bill : 7 œufs
Jim : 3 œufs
Un total de 15 œufs disponibles chez les voisins
Prendre 5 œufs dans le réfrigérateur et 4 œufs chez les voisins

Les valeurs des paramètres lazy sont évalués à chaque fois qu'ils sont utilisés dans la fonction.

Par exemple, parce que le paramètre lazy de la fonction suivante est utilisé trois fois dans la fonction, l'expression qui donne sa valeur est évaluée trois fois :

import std.stdio;

int ValeurDeLArgument()
{
writeln("Calcul...");
return 1;
}

void FonctionAvecParametreLazy(lazy int valeur)
{
int resultat = valeur + valeur + valeur;
writeln(resultat);
}

void main()
{
FonctionAvecParametreLazy(ValeurDeLArgument());
}

La sortie :

Calcul...
Calcul...
Calcul...
3

38-3-9 - scope

Ce mot-clé indique qu'un paramètre ne sera pas utilisé au-delà de la portée de la fonction :

int[] trancheGlobale;

int[] foo(scope int[] parametre)
{
trancheGlobale = parametre;    // ← ERREUR de compilation
return parametre;              // ← ERREUR de compilation
}

void main()
{
int[] tranche = [ 10, 20 ];
int[] resultat = foo(tranche);
}

La fonction casse la promesse de scope à deux endroits : elle assigne le paramètre à une variable globale et le retourne. Ces deux actions rendraient possible l'accès aux paramètres après que la fonction s'est terminée.

(Note : dmd 2.066.1, le compilateur qui a été utilisé pour compiler les exemples de ce chapitre, ne prend pas en charge le mot-clé scope. )

38-3-10 - shared

Ce mot-clé nécessite que le paramètre soit partageable entre les fils d'exécutions :

void foo(shared int[] i)
{
// ...
}

void main()
{
int[] nombres = [ 10, 20 ];
foo(nombres);    // ← ERREUR de compilation
}

Le programme ci-devant ne peut pas être compilé parce que l'argument n'est pas partagé. Le programme peut être compilé avec les changements suivants :

shared int[] nombres = [ 10, 20 ];
foo(nombres);    // maintenant, compile

Nous utiliserons le mot-clé shared dans le chapitre sur la concurrence des partages de données.

38-4 - Résumé

- Le paramètre est ce que la fonction prend depuis le code qui l'appelle pour réaliser une tâche.

- L'argument est une expression (par exemple une variable) qui est passée à une fonction en paramètre.

- Les arguments de type valeur sont copiés lors du passage, les arguments de type référence sont passés par référence (nous reverrons ce sujet dans des chapitres ultérieurs).

- in indique que le paramètre est seulement pour une entrée de données.

- out indique que le paramètre est seulement pour une sortie de données.

- ref indique que le paramètre est pour une entrée et une sortie de données.

- auto ref est seulement pour les modèles. Cela spécifie qu'un argument de type « valeur de gauche » argument est passé par référence et qu'un argument de type « valeur de droite » est passé par copie.

- const garantit que le paramètre n'est pas modifié à l'intérieur de la fonction.

- immutable nécessite que l'argument soit immuable.

- inout apparaît aussi bien pour le paramètre que pour le type de retour et transfère la mutabilité du paramètre au type de retour.

- lazy évalue le paramètre quand (et à chaque fois que) sa valeur est utilisée.

- scope garantit qu'aucune référence au paramètre ne sera « fuitée » par la fonction.

- shared nécessite que le paramètre soit partagé.

38-5 - Exercice

Le programme suivant essaie d'échanger les valeurs de deux arguments :

import std.stdio;

void echanger(int premier, int second)
{
int temp = premier;
premier = second;
second = temp;
}

void main()
{
int a = 1;
int b = 2;

echanger(a, b);

writeln(a, ' ', b);
}

Le programme n'a aucun effet sur a ni sur b :

1 2          ← non échangés

Corrigez la fonction pour que les valeurs de a et b soient échangées.

La solution.

39 - Lvalues et Rvalues

NDT Dans cette traduction, on a choisi de ne pas traduire les termes « rvalue » et « lvalue », mais il se peut qu'à certain endroit, « valeur à droite (du égal) » et « valeur à gauche (du égal) » soient utilisées occasionnellement. Consultez la dernière partie du chapitre pour plus d'explication sur ces noms.

La valeur de chaque expression est, soit une lvalue soit une rvalue. Une façon simple de différencier les deux est de voir les lvalues comme de véritables variables (y compris les éléments de tableau et de tableaux associatifs) et les rvalues comme des résultats temporaires d'expressions (y compris les littéraux).

Comme démonstration, la première expression writeln ci-après n'utilise que des lvalues et l'autre que des rvalues :

import std.stdio;

void main()
{
int i;
immutable(int) imm;
auto tab = [ 1 ];
auto ta = [ 10: "dix" ];

/* tous les arguments suivants sont des lvalues. */

writeln(i,                       // variable mutable
imm,                     // variable immutable
tab,                     // tableau
tab[0],                  // élément de tableau
ta[10]);                 // élément de tableau associatif
// etc.

enum message = "Bonjour";

/* tous les arguments suivants sont des rvalues. */

writeln(42,                      // un littéral
message,                 // une constante manifeste
i + 1,                   // une valeur temporaire
calculer(i));            // valeur de retour d'une fonction
// etc.
}

int calculer(int i)
{
return i * 2;
}

39-1 - Les limitations des rvalues

Comparées aux lvalues, les rvalues présentent les trois limitations suivantes.

39-1-1 - Les rvalues n'ont pas d'adresse

Une lvalue peut se trouver à un emplacement dans la mémoire, une rvalue non.

Par exemple, il est impossible de prendre l'adresse de l'expression rvalue a + b dans le programme suivant :

import std.stdio;

void main()
{
int a;
int b;

readf(" %s ", &a);       // compile
readf(" %s ", &(a + b)); // Erreur: ne compile pas
}

Erreur : a + b n'est pas une lvalue

39-1-2 - Les rvalues ne peuvent se voir affecter de nouvelles valeurs

Il est possible d'affecter une nouvelle valeur à une lvalue si elle est mutable. Ce n'est pas possible pour les rvalues :

a = 1; // compile
(a + b) = 2; // Erreur: ne compile pas

Sortie :

Erreur : a + b n'est pas une lvalue

39-2 - Les rvalues ne peuvent pas être passées par référence à des fonctions

Une lvalue peut être passée à une fonction qui prend un argument par référence. Ce n'est pas possible avec une rvalue :

void incrementerDeDix(ref int valeur)
{
value += 10;
}

// ...

incrementerDeDix(a);     // compile
incrementerDeDix(a + b); // Erreur: ne compile pas

Sortie :

Erreur : la fonction incrementerDeDix(ref int valeur) n'est pas appelable avec les types d'arguments (int)

La raison principale de cette limitation est que les fonctions prenant un paramètre ref pourraient stocker la référence pour une utilisation ultérieure, à un moment où la rvalue ne serait plus disponible.

À la différence de langages comme C++, en D une rvalue ne peut pas être passée à une fonction même si cette fonction ne modifie pas l'argument (par exemple en prenant une référence à const) :

void afficher(ref const(int) valeur)
{
writeln(valeur);
}

// ...

afficher(a);        // compile
afficher(a + b);    // Erreur: ne compile pas

Sortie :

Erreur : la fonction afficher(ref const(int) valeur) n'est pas appelable en utilisant les types d'arguments (int)

39-3 - Les paramètres auto ref pour accepter à la fois des rvalues et des lvalues

Comme mentionné dans le chapitre précédent, les paramètres auto ref des fonctions templates peuvent prendre à la fois des lvalues et des rvalues.

Quand l'argument est une lvalue, auto ref signifie par référence. À l'inverse, puisque les rvalues ne peuvent pas être passées par référence, quand l'argument est une rvalue, auto ref signifie par copie. Pour que le compilateur puisse générer les différents codes pour ces deux cas de figure, la fonction doit être un template.

Nous verrons les templates dans un chapitre ultérieur. Pour l'instant, veuillez accepter que les parenthèses vides surlignées ci-après signifient que la définition de fonction suivante est une fonction template.

Comme mentionné dans le commentaire du code ci-avant, la mutation du paramètre peut ne pas être observable par l'appelant. Pour cette raison, auto ref est essentiellement utilisé dans des situations où le paramètre n'est pas modifié ; la plupart du temps comme auto ref const.

39-4 - Terminologie

Les noms lvalue et rvalue ne représentent pas précisément les caractéristiques de ces deux types de valeurs. Les initiales l et r viennent de l'anglais left et right, faisant référence au côté gauche et au côté droit d'une expression de l'opérateur d'affectation :

- en partant du principe qu'elle est mutable, une lvalue peut être l'expression du côté gauche d'une opération d'affectation ;

- une rvalue ne peut pas être le côté gauche d'une opération d'affectation.

Les termes « valeur gauche » et « valeur droite » sont trompeurs, car en général, lvalues et rvalues peuvent à la fois se trouver de chaque côté d'une opération d'affectation :

// rvalue 'a + b' sur la gauche, lvalue 'a' sur la droite :
tableau[a + b] = a;

40 - Opérateurs paresseux (lazy)

L'évaluation paresseuse consiste à retarder l'exécution d'expressions jusqu'au moment où l'on a besoin du résultat de ces expressions. Les évaluations paresseuses font partie des fonctionnalités fondamentales de certains langages de programmation.

Naturellement, retarder l'exécution d'expressions jusqu'à ce que leur résultat soit vraiment nécessaire peut rendre les programmes plus rapides.

Le comportement raccourci des opérateurs suivants est conceptuellement similaire aux évaluations paresseuses :

- l'opérateur || (ou) : la seconde expression est évaluée seulement si la première est fausse (false) :

if (uneExpression() || peutNePasÊtreÉvaluée()) {
// ...
}

- Si le résultat de uneExpression() est true, le résultat de l'expression || est aussi vrai et la seconde expression n'est pas évaluée ;

- l'opérateur && (et) : la seconde expression est évaluée seulement si la première est vraie (true) :

if (uneExpression() && peutNePasÊtreÉvaluée()) {
// ...
}

- Si le résultat de uneExpression est faux (false), le résultat de l'expression && est également faux et la seconde expression n'est pas évaluée ;

- l'opérateur ternaire ? : soit la première, soit la deuxième expression est évaluée, si la condition est vraie ou fausse, respectivement :

int i = condition() ? soitCeci() : soitCela();

La paresse de ces opérateurs n'est pas seulement une question de performance. Parfois, évaluer une des expressions peut être une erreur.

Par exemple, ci-après, vérifier la condition « si la première lettre est A » serait une erreur si la chaîne est vide :

dstring s;
// ...
if (s[0] == 'A') {
// ...
}

Pour pouvoir accéder à l'élément d'indice zéro de s, il faut d'abord s'assurer que la chaîne contient un tel élément. Pour cette raison, la vérification de condition suivante déplace cette expression logique potentiellement erronée du côté droit de l'opérateur && afin de s'assurer qu'elle ne sera évaluée que lorsqu'effectuer cette évaluation sera sans risque :

dstring s;
// ...
if ((s.length >= 1) && (s[0] == 'A')) {
// ...
}

Les évaluations paresseuses peuvent également être effectuées par les pointeurs de fonctions, les déléguées (delegates) et les tranches. Nous verrons cela dans un chapitre ultérieur.

41 - L'environnement du programme

Nous avons vu que main() est une fonction. L'exécution du programme commence par main() et continue avec d'autres fonctions depuis là. La définition de main() que nous avons utilisée jusqu'à maintenant est :

void main()

Selon cette définition, main() ne prend pas de paramètre et ne retourne pas de valeur. En réalité, il est impossible de ne pas retourner de valeur, parce que les environnements qui démarrent les programmes s'attendent à avoir des valeurs de retour de leur part. Même s'il est possible de spécifier le type de retour de main() comme void, elle retourne quand même une valeur.

41-1 - La valeur de retour de main()

Les programmes sont toujours démarrés par une entité dans un environnement particulier. L'entité qui démarre le programme peut être l'interpréteur de commandes (shell) où l'utilisateur tape le nom du programme et appuie sur la touche Entrée, ou ça peut être un environnement de développement où le programmeur clique sur le bouton Exécuter, etc.

Le programme communique son code de retour à son environnement par la valeur de retour de main().

Une valeur de retour de 0 veut dire que tout s'est bien passé et n'importe quelle autre valeur indique un certain type d'échec. Bien que le choix de la valeur de retour appartienne au programmeur, la valeur 0 veut toujours dire réussite, par convention.

Seules les valeurs dans l'intervalle [0,127] sont portables ; tous les environnements ne prennent pas en charge d'autres valeurs.

Les valeurs autres que 0 peuvent avoir différents sens en fonction de chaque programme. Par exemple, le programme Unix ls, qui est utilisé pour lister le contenu des répertoires, retourne 1 pour les erreurs mineures et 2 pour les erreurs sérieuses. Dans beaucoup d'environnements, la valeur de retour du programme qui a été exécuté le plus récemment dans la console peut être vu à travers la variable d'environnement $?. Par exemple, quand on demande à ls de lister un fichier qui n'existe pas, sa valeur de retour non nulle peut être vue avec $? comme ci-dessous.

Dans la session en ligne de commande suivante, les lignes qui commencent par # indiquent les lignes que l'utilisateur tape. Si vous voulez essayer les mêmes commandes, vous devez saisir le contenu de ces lignes sans le caractère #. En outre, les commandes tapées plus loin démarrent un programme nommé essai ; remplacez ce nom avec le nom de votre programme test.

De plus, même si les exemples suivants affichent des interactions dans une console Linux, elles seraient similaires, mais pas exactement les mêmes dans des consoles d'autres systèmes d'exploitation.

# ls un_fichier_qui_nexiste_pas
ls: impossible `'accéder à un_fichier_qui_n'existe_pas: Aucun fichier ou dossier de ce type
# echo $?
2          ← La valeur de retour de ls

41-1-1 - main retourne toujours une valeur

Certains programmes que nous avons écrits jusqu'à maintenant ont levé des exceptions quand ils ne pouvaient pas continuer leur tâche. D'après ce que nous avons vu jusqu'à maintenant, quand une exception est levée, le programme finit avec un message d'erreur object.Exception.

Quand cela arrive, même si main() a été définie comme retournant void, une valeur de 1 est automatiquement retournée à l'environnement du programme.

Voyons ceci en action avec ce programme qui se termine avec une exception :

void main()
{
throw new Exception("Il y a eu une erreur.");
}

Même si le type de retour est spécifié comme void, la valeur de retour est 1 :

# ./essai
object.Exception: Il y a eu une erreur.
# echo $?
1

De façon similaire, les fonctions void main() qui terminent correctement retournent aussi 0 automatiquement. Voyons ceci avec le programme suivant qui termine correctement :

import std.stdio;

void main()
{
writeln("Fait !");
}

Le programme retourne 0 :

# ./essai
Fait !
# echo $?
0

41-1-2 - Spécifier la valeur de retour

Retourner une valeur de retour spécifique depuis main() est la même chose que retourner une valeur depuis n'importe quelle fonction. Le type de retour doit être positionné à int et la valeur doit être retournée par l'instruction return :

import std.stdio;

int main()
{
int nombre;
write("Veuillez saisir un nombre entre 3 et 6 : ");
readf(" %s", &nombre);

if ((nombre < 3) || (nombre > 6)) {
stderr.writefln("ERREUR : %s n'est pas valide !", nombre);
return 111;
}

writefln("Merci d'avoir saisi %s.", nombre);

return 0;
}

Quand le nombre saisi est dans l'intervalle de validité, la valeur de retour du programme est 0 :

# ./essai
Veuillez saisir un nombre entre 3 et 6 : 5
Merci d'avoir saisi 5.
# echo $?
0

Quand le nombre est hors de l'intervalle de validité, la valeur de retour du programme est 111 :

# ./essai
Veuillez saisir un nombre entre 3 et 6 : 10
ERREUR : 10 n'est pas valide !
# echo $?
111

La valeur de 111 dans le programme précédent est arbitraire ; normalement 1 est utilisé pour le code d'échec.

41-2 - Le flux standard d'erreur stderr

Le programme ci-avant utilise le flux stderr. Il est utilisé pour écrire des messages d'erreur. Ce flux est un des trois flux standards :

- stdin : flux d'entrée standard ;

- stdout : flux de sortie standard ;

- stderr : flux d'erreur standard.

Quand un programme est lancé dans une console, normalement les messages qui sont écrits dans stdout et stderr apparaissent tous dans la console. Quand c'est nécessaire, il est possible de rediriger ces sorties individuellement. Ceci est hors du programme de ce chapitre et les détails peuvent varier pour chaque shell.

41-3 - Paramètres de main()

Il est courant que les programmes prennent des paramètres depuis l'environnement qui les a démarrés. Par exemple, nous venons de passer un nom de fichier en option en ligne de commande à ls. Il y a deux options en ligne de commande dans la ligne suivante :

# ls -l essai
-rwxr-xr-x 1 acehreli users 460668 Nov  6 20:38 essai

L'ensemble des paramètres en ligne de commande et leurs significations sont entièrement définis par le programme. Chaque programme documente son usage, dont la signification de chaque paramètre.

Les arguments qui sont utilisés lors du démarrage d'un programme D sont passés à la fonction main() de ce programme sous la forme d'une tranche de strings. Il suffit de définir main() comme prenant un paramètre de type string[] pour avoir accès aux arguments du programme. Le nom de ce paramètre est souvent appelé args. Le programme suivant affiche tous les arguments qui ont été passés au programme :

import std.stdio;

void main(string[] args)
{
foreach (i, arg; args) {
writefln("Argument %-3s: %s", i, arg);
}
}

Lançons ce programme avec des arguments arbitraires :

# ./essai des arguments en ligne de commande 42 --une-option
Argument 0  : ./essai
Argument 1  : des
Argument 2  : arguments
Argument 3  : en
Argument 4  : ligne
Argument 5  : de
Argument 6  : commande
Argument 7  : 42
Argument 8  : --une-option

Le premier argument est toujours le nom du programme, tel que l'utilisateur l'a écrit. Les autres arguments apparaissent dans l'ordre dans lequel ils ont été saisis.

La manière d'utiliser les arguments est complètement du ressort du programme. Le programme suivant affiche ses deux arguments dans l'ordre inverse :

import std.stdio;

int main(string[] args)
{
if (args.length != 3) {
stderr.writefln("ERREUR ! Utilisation correcte :\n"
"  %s mot1 mot2", args[0]);
return 1;
}

writeln(args[2], ' ', args[1]);

return 0;
}

Le programme affiche aussi son utilisation correcte s'il n'y a pas exactement deux mots :

# ./essai
ERREUR ! Utilisation correcte :
./essai mot1 mot2
# ./essai mot salut
salut mot

41-4 - Options en ligne de commande et le module std.getopt

C'est tout ce qu'il y a à savoir sur les paramètres et la valeur de retour de main(). Cependant, analyser les arguments est une tâche répétitive. Le module std.getopt est conçu pour aider à analyser les options en lignes de commande du programme.

Certains paramètres comme « mot » et « salut » dans l'exemple précédent sont purement des données utilisées par le programme. D'autres types de paramètres sont appelés options en ligne de commande, et sont là pour changer le comportement du programme. Un exemple d'une option en ligne de commande qu'on a déjà rencontré est -l, qui a été passé à ls.

Les options en ligne de commande rendent les programmes plus utiles en supprimant le besoin qu'un utilisateur humain interagisse avec le programme pour obtenir de lui un comportement particulier. Avec les options en ligne de commande, les programmes peuvent être lancés à partir de scripts et leurs comportements peuvent être spécifiés à travers les options en ligne de commande.

Même si la syntaxe et le sens des arguments en ligne de commande de chaque programme sont spécifiques à ce programme, leur format est plus ou moins standard. Par exemple, en POSIX, les options en ligne de commande commencent par « --» suivi par le nom de l'option, et les valeurs viennent après des caractères « = » :

# ./essai --an-option=17

Le module std.getopt simplifie l'analyse de telles options. Il offre plus de possibilités que celles qui vont être couvertes dans cette section.

Concevons un programme qui affiche des nombres aléatoires. Prenons le minimum, le maximum et le nombre total de ces nombres en arguments du programme. On s'attendra à la syntaxe suivante pour obtenir ces valeurs depuis la ligne de commande :

# ./essai --nombre=7 --minimum=10 --maximum=1

La fonction getopt() analyse et affecte ces valeurs aux variables. Comme quand on utilise readf(), on doit spécifier les adresses des variables au moyen de l'opérateur & :

import std.stdio;
import std.getopt;
import std.random;

void main(string[] args)
{
int nombre;
int minimum;
int maximum;

getopt(args,
"nombre", &nombre,
"minimum", &minimum,
"maximum", &maximum);

foreach (i; 0 .. nombre) {
write(uniform(minimum, maximum + 1), ' ');
}

writeln();
}

Sortie :

# ./essai --nombre=7 --minimum=10 --maximum=15
11 11 13 11 14 15 10

La plupart des options en ligne de commande de la plupart des programmes ont aussi des syntaxes plus courtes. Par exemple, -c peut avoir le même sens que --nombre. Une syntaxe alternative pour chaque option peut être spécifiée après un caractère « | ». Il peut y avoir plus d'un raccourci par option :

getopt(args,
"nombre|c", &nombre,
"minimum|n", &minimum,
"maximum|x", &maximum);

Il est commun d'utiliser un seul tiret pour la version courte et le caractère « = » est généralement omis.

# ./essai -c7 -n10 -x15
11 13 10 15 14 15 14

getopt() convertit les arguments depuis le type string vers le type de chaque variable. Par exemple, comme la variable nombre ci-dessus est un int, getopt() convertit la valeur spécifiée avec l'argument --nombre en int. Lorsque cela est nécessaire, de telles conversions peuvent aussi être faites explicitement avec to.

Jusqu'à maintenant, nous avons utilisé std.conv.to seulement vers string. to peut en fait convertir depuis n'importe quel type vers n'importe quel type pourvu que cette conversion soit possible. Par exemple, le programme suivant utilise to pour convertir ses arguments vers size_t :

import std.stdio;
import std.conv;

void main(string[] args)
{
// Le nombre par défaut est 10
size_t nombre = 10;

if (args.length > 1) {
// Il y a un argument
nombre = to!size_t(args[1]);
}

foreach (i; 0 .. nombre) {
write(i * 2, ' ');
}

writeln();
}

Le programme produit 10 nombres quand il n'y a pas d'argument spécifié :

# ./essai
0 2 4 6 8 10 12 14 16 18
# ./essai 3
0 2 4

41-5 - Les variables d'environnement

L'environnement dans lequel le programme est démarré fournit des variables que le programme peut utiliser. On peut accéder aux variables d'environnement avec std.process.getenv. Le programme suivant affiche la variable d'environnement PATH.

import std.stdio;
import std.process;

void main()
{
writeln(getenv("PATH"));
}

La sortie :

# ./essai
/usr/local/bin:/usr/bin:/home/acehreli/dmd/linux/bin

std.process.environment donne accès aux variables d'environnement à travers la syntaxe des tableaux associatifs :

import std.process;
// ...
writeln(environment["PATH"]);

Cependant, environment n'est en réalité pas un tableau associatif. Lorsque c'est nécessaire, environment peut être converti vers un tableau associatif avec toAA() :

string[string] envVars = environment.toAA();

41-6 - Lancer d'autres programmes

Les programmes peuvent lancer d'autres programmes et devenir l'environnement de ces programmes. La fonction executeShell de module std.process permet cela.

executeShell exécute son paramètre comme s'il avait été saisi dans le terminal. Elle retourne aussi bien le code de retour que la sortie de cette commande dans un n-uplet. Les n-uplets (tuples) sont des structures qui ressemblent aux tableaux ; nous les verrons plus tard dans le chapitre sur les n-upplets.

import std.stdio;
import std.process;

void main()
{
const resultat = executeShell("ls -l deneme");
const codeRetour = resultat[0];
const sortie = resultat[1];

writefln("ls a retourné %s.", codeRetour);
writefln("Sa sortie:\n%s", sortie);
}

Le résultat :

# ./deneme
ls a retourné 0.
Sa sortie:
-rwxrwxr-x. 1 acehreli acehreli 1359178 Apr 21 15:01 deneme

41-7 - Résumé

- Même quand elle est définie avec un type de retour void, main() retourne automatiquement 0 en cas de réussite et 1 en cas d'échec.

- stderr est approprié pour afficher des messages d'erreur.

- main() peut prendre des paramètres string[].

- std.getopt facilite l'analyse des options en ligne de commande.

- std.process facilite l'accès aux variables d'environnement et le lancement d'autres programmes.

41-8 - Exercices

1. Écrivez une calculatrice qui prend un opérateur et deux opérandes en arguments de la ligne de commande. Le programme doit prendre en charge l'utilisation suivante :

# ./essai 3.4 x 7.8
26.52

Parce que le caractère « * » a une signification spéciale sur la plupart des shells, j'ai utilisé « x ». Vous pouvez toujours utiliser « * » tant qu'il est échappé ainsi : « \* » (NDT Ou ainsi : « "*" »).

2. Écrivez un programme qui demande à l'utilisateur quel programme lancer, démarre ce programme et retourne sa valeur de retour.

Les solutions.

42 - Exceptions

Les situations non attendues font partie des programmes : erreurs de l'utilisateur, erreurs de programmation, changements dans l'environnement de programmation, etc. Les programmes doivent être écrits d'une manière qui évite la production de résultats incorrects quand on se trouve face à de telles conditions exceptionnelles.

Certaines de ces conditions peuvent être suffisamment graves pour stopper l'exécution du programme. Par exemple, une information nécessaire peut manquer ou être invalide, ou un périphérique peut ne pas fonctionner correctement. Le mécanisme de gestion des exceptions du D aide à stopper l'exécution du programme lorsque c'est nécessaire et à récupérer d'une situation inattendue quand c'est possible.

Comme exemple de situation inattendue, on peut penser au cas où l'on passe un opérateur inconnu à une fonction qui ne connaît que les quatre opérateurs arithmétiques, comme nous l'avons vu dans les exercices d'un chapitre précédent :

switch (opérateur) {

case "+":
writeln(premier + second);
break;

case "-":
writeln(premier - second);
break;

case "x":
writeln(premier * second);
break;

case "/":
writeln(premier / second);
break;

default:
throw new Exception(format("Opérateur invalide : %s", opérateur));
}

L'instruction switch ci-dessus ne sait que faire avec les opérateurs qui ne sont pas listés dans les instructions case, elle lève donc une exception.

Il y a plein d'exemples d'expressions levées dans Phobos. Par exemple, to!int, qui peut être utilisé pour convertir une représentation textuelle d'un entier à une valeur int lève une exception quand cette représentation n'est pas valide :

import std.conv;

void main()
{
const int value = to!int("salut");
}

Le programme termine avec une exception levée par to!int :

std.conv.ConvException@/usr/include/d/4.8/std/conv.d(1826): Unexpected 's' when converting from type string to type int

std.conv.ConvException au début du message est le type de l'objet exception levé. D'après ce nom, on peut dire que le type est ConvException, qui est défini dans le module std.conv.

42-1 - L'instruction throw pour lever des exceptions

Nous avons vu l'instruction throw aussi bien dans les exemples ci-dessus que dans des chapitres précédents.

throw lève un objet exception et cela termine l'opération courante du programme. Les expressions et les instructions qui sont écrites après l'instruction throw ne sont pas exécutées. Ce comportement correspond à la nature des exceptions : elles doivent être levées quand le programme ne peut pas continuer sa tâche courante.

À l'inverse, si le programme pouvait continuer, alors la situation ne justifierait pas l'utilisation d'une exception. Dans de tels cas, la fonction trouverait une solution et continuerait.

42-1-1 - Les types d'exceptions Exception et Error

Seuls les types qui sont hérités de la classe Throwable peuvent être levés. Throwable n'est quasiment jamais directement utilisée dans les programmes. En pratique, les types qui sont levés sont des types qui héritent d'Exception ou d'Error, qui sont eux-mêmes des types qui héritent de Throwable. Par exemple, toutes les exceptions que Phobos lève sont héritées de Exception ou Error.

Error représente des situations irrécupérables qu'il n'est pas recommandé d'attraper. Pour cette raison, la plupart des exceptions qu'un programme lève sont d'un type hérité de Exception. L'héritage est un concept relatif aux classes. Nous verrons les classes dans un chapitre ultérieur.

Les objets de type Exception sont construits à partir d'une valeur string qui représente un message d'erreur. Vous pouvez éventuellement utiliser la fonction format du module std.string pour créer ce message :

import std.stdio;
import std.random;
import std.string;

int[] ValeursDeDesAleatoires(int nombre)
{
if (nombre < 0) {
throw new Exception(
format("Nombre de dés invalide : %s", nombre));
}

int[] valeurs;

foreach (i; 0 .. nombre) {
valeurs ~= uniform(1, 7);
}

return valeurs;
}

void main()
{
writeln(ValeursDeDesAleatoires(-5));
}

Sortie :

object.Exception...: Nombre de dés invalide : -5

Dans la plupart des cas, au lieu de créer un objet Exception explicitement avec new et lever l'exception avec throw, la fonction enforce() est appelée. Par exemple, l'équivalent de la vérification d'erreur précédente est l'appel suivant à enforce() :

enforce(nombre  >= 0, format("Nombre de dés invalide : %s", nombre));

Nous verrons les différences entre enforce() et assert() dans un chapitre ultérieur.

42-1-2 - Les expressions levées mettent fin à toutes les portées

Nous avons vu que l'exécution du programme commence par la fonction main et entre dans les autres fonctions à partir de là. Cette exécution par étapes, entrant progressivement en profondeur dans les fonctions et en ressortant, peut être vue comme les branches d'un arbre.

Par exemple, main() peut appeler une fonction nommée faireOmelette(), qui peut à son tour appeler une autre fonction nommée toutPreparer(), qui peut à son tour appeler une autre fonction nommée preparerOeufs(), etc. En supposant que les flèches indiquent des appels, l'arborescence d'un tel programme peut être représentée comme dans cet arbre d'appels de fonctions :

Le programme suivant montre son arborescence en utilisant différents niveaux d'indentation dans sa sortie. Le programme n'a d'autre utilité que de produire une sortie qui correspond à ce que nous voulons illustrer :

import std.stdio;

void indenter(in int niveau)
{
foreach (i; 0 .. niveau * 2) {
write(' ');
}
}

void entrant(in char[] nomFonction, in int niveau)
{
indenter(niveau);
writeln("▶ Première ligne de ", nomFonction);
}

void sortant(in char[] nomFonction, in int niveau)
{
indenter(niveau);
writeln("◁ Dernière ligne de ", nomFonctionx);
}

void main()
{
entrant("main", 0);
faireOmelette();
mangerOmelette();
sortant("main", 0);
}

void faireOmelette()
{
entrant("faireOmelette", 1);
toutPreparer();
cuisinerOeufs();
toutNettoyer();
sortant("faireOmelette", 1);
}

void mangerOmelette()
{
entrant("mangerOmelette", 1);
sortant("mangerOmelette", 1);
}

void toutPreparer()
{
entrant("toutPreparer", 2);
preparerOeufs();
preparerBeurre();
preparerPoelle();
sortant("toutPreparer", 2);
}

void cuisinerOeufs()
{
entrant("cuisinerOeufs", 2);
sortant("cuisinerOeufs", 2);
}

void toutNettoyer()
{
entrant("toutNettoyer", 2);
sortant("toutNettoyer", 2);
}

void preparerOeufs()
{
entrant("preparerOeufs", 3);
sortant("preparerOeufs", 3);
}

void preparerBeurre()
{
entrant("preparerBeurre", 3);
sortant("preparerBeurre", 3);
}

void preparerPoelle()
{
entrant("preparerPoelle", 3);
sortant("preparerPoelle", 3);
}

Le programme produit la sortie suivante :

▶ Première ligne de main
▶ Première ligne de faireOmelette
▶ Première ligne de toutPreparer
▶ Première ligne de preparerOeufs
◁ Dernière ligne de preparerOeufs
▶ Première ligne de preparerBeurre
◁ Dernière ligne de preparerBeurre
▶ Première ligne de preparerPoelle
◁ Dernière ligne de preparerPoelle
◁ Dernière ligne de toutPreparer
▶ Première ligne de cuisinerOeufs
◁ Dernière ligne de cuisinerOeufs
▶ Première ligne de toutNettoyer
◁ Dernière ligne de toutNettoyer
◁ Dernière ligne de faireOmelette
▶ Première ligne de mangerOmelette
◁ Dernière ligne de mangerOmelette
◁ Dernière ligne de main

Les fonctions entrant et sortant sont utilisées pour indiquer les première et dernière lignes des fonctions avec l'aide des caractères ▶ et ◁. Le programme commence par la première ligne de main(), entre dans les autres fonctions et finit par la dernière ligne de main().

Modifions la fonction preparerOeufs() pour prendre le nombre d'œufs en paramètre. Comme certaines valeurs de ce paramètre seraient des erreurs, faisons en sorte que cette fonction lève une exception quand le nombre d'œufs est inférieur à 1 :

void preparerOeufs(int nombre)
{
entrant("preparerOeufs", 3);

if (nombre < 1) {
throw new Exception(
format("Impossible de prendre %s oeufs du réfrigérateur", nombre));
}

sortant("preparerOeufs", 3);
}

Afin de pouvoir compiler le programme, nous devons modifier d'autres lignes du programme pour les rendre compatibles avec cette modification. Le nombre d'œufs à prendre du réfrigérateur peut être passé de fonction en fonction, en commençant par main(). Les parties du programme qui doivent changer sont les suivantes. La valeur invalide de -8 est voulue, elle est là pour montrer ce qui change dans la sortie du programme par rapport à la sortie précédente, quand une exception est levée :

// ...

void main()
{
entrant("main", 0);
faireOmelette(-8);
mangerOmelette();
sortant("main", 0);
}

void faireOmelette(int nombreOeufs)
{
entrant("faireOmelette", 1);
toutPreparer(nombreOeufs);
cuisinerOeufs();
toutNettoyer();
sortant("faireOmelette", 1);
}

// ...

void toutPreparer(int nombreOeufs)
{
entrant("toutPreparer", 2);
preparerOeufs(nombreOeufs);
preparerBeurre();
preparerPoelle();
sortant("toutPreparer", 2);
}

// ...

Maintenant, quand on démarre le programme, on voit qu'il manque les lignes qui étaient affichées après le point où l'exception est levée :

▶ Première ligne de main
▶ Première ligne de faireOmelette
▶ Première ligne de toutPreparer
▶ Première ligne de preparerOeufs
object.Exception: Impossible de prendre -8 oeufs du réfrigérateur

Quand l'exception est levée, l'exécution du programme sort des fonctions preparerOeufs(), toutPreparer(), faireOmelette() et main() dans cet ordre, du niveau le plus profond au niveau le moins profond. Aucune étape additionnelle n'est exécutée quand le programme sort de ces fonctions.

Un arrêt si brutal est justifié par le fait qu'on devrait considérer qu'un échec dans une fonction de plus bas niveau implique que les fonctions de plus hauts niveaux, nécessitant un succès de celle-ci, ont également échoué.

L'objet exception qui est levé depuis un niveau de fonction plus bas est transféré aux fonctions de niveaux supérieurs, niveau par niveau, pour finalement entraîner la sortie du programme de la fonction main(). Le chemin que l'exception prend peut être vu comme le chemin rouge dans l'arbre suivant :

Le but du mécanisme des exceptions est précisément d'avoir ce comportement : sortir de tous les appels de fonctions directement. Parfois, attraper l'exception levée pour trouver une manière de continuer l'exécution du programme fait sens. Nous allons bientôt introduire le mot-clé catch.

42-1-3 - Quand utiliser throw

Utilisez throw dans les situations où il n'est pas possible de continuer. Par exemple, une fonction qui lit un nombre d'étudiants depuis un fichier peut lever une exception si l'information n'est pas disponible ou si elle est incorrecte.

D'un autre côté, si le problème est causé par une action quelconque de l'utilisateur telle que la saisie d'une valeur invalide, il peut être plus judicieux de valider cette donnée au lieu de lever une exception. Afficher un message d'erreur et demander à l'utilisateur de saisir une nouvelle fois la donnée est plus approprié dans ce genre de cas.

42-2 - L'instruction try-catch pour attraper une exception

Comme nous l'avons vu précédemment, une exception levée entraîne la sortie de toutes les fonctions et, finalement, l'arrêt du programme entier.

L'objet exception peut être attrapé avec une instruction try-catch à n'importe quel endroit du chemin qu'elle emprunte pour sortir des fonctions. L'instruction try-catch modélise la phrase « essaie de faire quelque chose et attrape les exceptions qui peuvent être levées ». Voici la syntaxe de try-catch :

try {
// Le bloc de code qui est exécuté, où une
// exception peut être levée

} catch (un_type_d_exception)) {
// Expressions à exécuter si une exception de ce
// type est attrapée

} catch (un_autre_type_d_exception)) {
// Expressions à exécuter si une exception de cet
// autre type est attrapée

// ... d'autres blocs catch peuvent être placés ici ...

} finally {
// Expressions à exécuter indépendamment du fait
// qu'une exception soit levée ou pas
}

Commençons par le programme suivant qui n'utilise pas d'instruction try-catch. Le programme lit une valeur d'un dé depuis un fichier et l'affiche sur la sortie standard :

import std.stdio;

int lireDeDepuisFichier()
{
auto fichier = File("le_fichier_qui_contient_la_valeur", "r");

int de;
fichier.readf(" %s", &de);

return de;
}

void main()
{
const int de = lireDeDepuisFichier();

writeln("Valeur du dé : ", de);
}

Notez que la fonction lireDeDepuisFichier() est écrite de manière à ignorer les conditions d'erreurs, s'attendant à ce que le fichier et la valeur qu'il contient soient là. En d'autres termes, la fonction fait ce qu'elle doit faire sans s'occuper des conditions d'erreurs. C'est un des avantages des exceptions : beaucoup de fonctions peuvent être écrites en se concentrant sur leur tâche, plutôt qu'en se concentrant sur les conditions d'erreurs.

Lançons le programme alors que le fichier le_fichier_qui_contient_la_valeur manque :

std.exception.ErrnoException@std/stdio.d(286): Cannot open
file `le_fichier_qui_contient_la_valeur` in mode `r` (No such
file or directory)

Une exception du type ErrnoException est levée et le programme se termine sans afficher « Valeur du dé : ».

Ajoutons une fonction intermédiaire au programme qui appelle lireDeDepuisFichier() depuis un bloc try et main() appellera cette nouvelle fonction :

import std.stdio;

int lireDeDepuisFichier()
{
auto fichier = File("le_fichier_qui_contient_la_valeur", "r");

int de;
fichier.readf(" %s", &de);

return de;
}

int essayerDeLireDepuisLeFichier()
{
int de;

try {
de = lireDeDepuisFichier();

} catch (std.exception.ErrnoException exc) {
writeln("Impossible de lire le fichier ; on suppose 1");
de = 1;
}

return de;
}

void main()
{
const int de = essayerDeLireDepuisLeFichier();

writeln("Valeur du dé : ", de);
}

Quand on lance le programme alors que le_fichier_qui_contient_la_valeur n'existe pas, le programme ne se termine cette fois pas par une exception :

Impossible de lire depuis le fichier ; on suppose 1
Valeur du dé : 1

Le nouveau programme essaie d'exécuter lireDeDepuisFichier() dans un bloc try. Si ce bloc s'exécute correctement, la fonction se termine normalement avec l'instruction return de;. Si l'exécution du bloc try finit avec l'exception std.exception.ErrnoException, alors le programme entre dans le bloc catch.

Voici un récapitulatif de ce qu'il se passe lorsque le programme est démarré alors que le fichier n'existe pas :

- comme dans le programme précédent, une exception std.exception.ErrnoException est levée (par File(), pas par notre code) ;

- cette exception est attrapée par catch ;

- la valeur de 1 est supposée pendant l'exécution normale du bloc catch ;

- et le programme continue normalement.

catch est là pour attraper les exceptions levées afin de tenter de continuer l'exécution du programme.

En guise d'exemple supplémentaire, revenons au programme des omelettes et ajoutons une instruction try-catch à sa fonction main() :

void main()
{
entrant("main", 0);

try {
faireOmelette(-8);
mangerOmelette();

} catch (Exception exc) {
write("Impossible de manger l'omelette : ");
writeln('"', exc.msg, '"');
writeln("Je mangerai chez le voisin...");
}

sortant("main", 0);
}

Ce bloc try contient deux lignes de codes. Toute exception levée depuis une de ces deux lignes sera attrapée par le bloc catch.

Sortie :

▶ main, première ligne
▶ faireOmelette, première ligne
▶ toutPreparer, première ligne
▶ preparerOeufs, première ligne
Impossible de manger l'omelette : "Impossible de prendre -8 oeufs du réfrigérateur"
Je mangerai chez le voisin...
◁ main, dernière ligne

Comme nous pouvons le voir dans la sortie, le programme ne se termine plus à cause de l'exception. Il se remet de son état d'erreur et continue à s'exécuter normalement jusqu'à la fin de la fonction main().

42-2-1 - Les blocs catch sont parcourus séquentiellement

Le type Exception, que nous avons utilisé jusqu'à maintenant dans les exemples, est un type générique d'exception. Ce type indique simplement qu'une erreur s'est produite dans le programme. Il contient aussi un message qui peut expliquer l'erreur plus en détail, mais il ne contient pas d'information sur le type de l'erreur.

ConvException et ErrnoException, que nous avons rencontrés plus tôt dans le chapitre, sont des types d'exceptions plus spécifiques : le premier concerne une erreur de conversion et le second une erreur système. Comme beaucoup d'autres types d'exceptions dans Phobos et comme leurs noms respectifs le suggèrent, ConvException et ErrnoException héritent tous deux de la classe Exception.

Exception et sa sœur Error sont eux-mêmes hérités de Throwable, le type d'exception le plus général.

Même si c'est possible, il n'est pas recommandé d'attraper des objets de type Error ou de types qui en héritent. Comme ils sont plus généraux que les types Error, il n'est pas non plus recommandé d'intercepter des objets de type Throwable. Les seuls objets qui devraient normalement être attrapés sont ceux qui font partie de la hiérarchie Exception.

On verra la représentation hiérarchique plus tard dans le chapitre sur l'héritage. L'arbre précédent indique que Throwable est la plus générale et qu'Exception et Error sont plus spécifiques.

Il est possible d'attraper les objets d'un type particulier. Par exemple, il est possible d'attraper de manière spécifique un objet ErrnoException pour gérer une erreur système.

Les exceptions sont attrapées seulement si elles correspondent au type qui est spécifié dans un bloc catch. Par exemple, un bloc catch qui essaie d'attraper une exception SpecialExceptionType n'attrapera pas un ErrnoException.

Le type de l'objet exception qui est levée pendant l'exécution d'un bloc try est comparé aux types qui sont spécifiés par les blocs catch, dans l'ordre dans lequel les blocs catch sont écrits. Si le type de l'objet correspond au type d'un bloc catch, alors l'exception est considérée comme attrapée par ce bloc catch et le code qui est à l'intérieur de ce bloc est exécuté. Une fois qu'une correspondance a été trouvée, les blocs catch restant sont ignorés.

Du fait que les blocs catch sont testés dans l'ordre, les blocs catch doivent être ordonnées de l'exception du type le plus spécifique au plus général. De même, si nécessaire, le type Exception doit être indiqué au dernier bloc catch.

Par exemple, une instruction try-catch qui essaie d'attraper plusieurs types spécifiques d'exceptions à propos d'une liste d'étudiants peut ordonner les blocs catch du plus spécifique au plus général comme dans le code qui suit :

try {
// des opérations sur les enregistrements d'étudiants
// qui peuvent lever une exception

} catch (ChiffreIdEtudiantException exc) {

// Une exception dédiée aux erreurs sur
// les chiffres des identifiants des étudiants

} catch (IdEtudiantException exc) {

// Une exception plus générale sur les identifiants des étudiants
// mais pas nécessairement sur leurs chiffres

} catch (EnregistrementEtudiantException exc) {

// Une exception plus générale sur les enregistrements des étudiants

} catch (Exception exc) {

// L'exception la plus générale qui peut ne pas être en
// rapport avec les enregistrements des étudiants

}

42-2-2 - Le bloc finally

finally est un bloc optionnel de l'instruction try-catch. Il contient des expressions qui doivent être exécutées, qu'une exception ait été levée ou pas.

Pour voir comment finally fonctionne, examinons un programme qui lève une exception 50 % du temps :

import std.stdio;
import std.random;

void leverExceptionLaMoitieDuTemps()
{
if (uniform(0, 2) == 1) {
throw new Exception("le message d'erreur");
}
}

void foo()
{
writeln("La première ligne de foo()");

try {
writeln("La première ligne du bloc try");
leverExceptionLaMoitieDuTemps();
writeln("La dernière ligne du bloc try");

// ... il peut y avoir un ou des blocs catch ici ...

} finally {
writeln("Le corps du bloc finally");
}

writeln("La dernière ligne de foo()");
}

void main()
{
foo();
}

La sortie du programme est la suivante quand la fonction ne lève pas une exception :

La première ligne de foo()
La première ligne du bloc try
La dernière ligne du bloc try
Le corps du bloc finally
La dernière ligne de foo()

La sortie du programme est la suivante quand la fonction lève une exception :

La première ligne de foo()
La première ligne du bloc try
Le corps du bloc finally
object.Exception@essai.d: le message d'erreur

Comme on peut le constater, quand une exception est levée, bien que « La dernière ligne du bloc try » et « La dernière ligne de foo() » ne soient pas affichées, le contenu du bloc finally est quant à lui toujours exécuté.

42-2-3 - Quand utiliser l'instruction try-catch

L'instruction try-catch est utile pour attraper des exceptions pour faire quelque chose de spécial avec.

Pour cette raison, l'instruction try-catch ne devrait être utilisée que quand il y a quelque chose de spécial à faire. Sinon, n'attrapez pas d'exceptions et laissez-les aux fonctions des niveaux supérieurs qui pourraient vouloir les attraper.

42-3 - Propriétés des exceptions

L'information qui est automatiquement affichée dans la sortie quand le programme se termine à cause d'une exception est aussi accessible par les propriétés des objets Exception. Ces propriétés sont fournies par l'interface Throwable :

- .file : le fichier source à partir duquel l'exception a été levée ;

- .line : le numéro de la ligne à partir de laquelle l'exception a été levée ;

- .msg : le message d'erreur ;

- .info : l'état de la pile du programme quand l'exception a été levée ;

- .next : l'exception collatérale suivante.

Nous avons vu que les blocs finally sont exécutés quand on sort des blocs de code, y compris à cause des exceptions (comme nous le verrons dans des chapitres ultérieurs, cela est aussi vrai pour les instructions scope et les destructeurs).

Naturellement, de tels blocs de code peuvent aussi lever des exceptions. Les exceptions qui sont levées quand on quitte des blocs de code à cause d'une exception sont appelées exceptions collatérales. L'exception principale et les exceptions collatérales sont des éléments d'une structure de type liste chaînée, dans laquelle toutes les exceptions sont accessibles à travers la propriété .next de l'exception précédente. La valeur de la propriété .next de la dernière exception est null (nous verrons null dans un chapitre ultérieur).

Il y a trois exceptions qui sont levées dans l'exemple suivant : l'exception principale qui est levée dans foo() et les deux exceptions collatérales qui sont levées dans les blocs finally de foo() et bar(). Le programme accède aux exceptions collatérales à travers les propriétés .next.

Certaines concepts qui sont utilisés dans ce programme seront expliqués dans des chapitres ultérieurs. Par exemple, la condition de continuation de la boucle for , exc, signifie que exc n'est pas null.

import std.stdio;

void foo()
{
try {
throw new Exception("Exception levée dans foo");

} finally {
throw new Exception(
"Exception levée dans le bloc finally de foo");
}
}

void bar()
{
try {
foo();

} finally {
throw new Exception(
"Exception levée dans le block finally de bar");
}
}

void main()
{
try {
bar();

} catch (Exception exceptionLevee) {

for (Throwable exc = exceptionLevee;
exc;    // ← ce qui veut dire: dans que exc n'est pas 'null'
exc = exc.next) {

writefln("message d'erreur : %s", exc.msg);
writefln("fichier source   : %s", exc.file);
writefln("ligne source     : %s", exc.line);
writeln();
}
}
}

La sortie :

message d`erreur : Exception levée in foo
fichier source   : deneme.d
ligne source     : 6

message d`erreur : Exception levée dans le bloc finally de foo
fichier source   : deneme.d
ligne source     : 9

message d'erreur : Exception levée dans le bloc finally de bar
fichier source   : deneme.d
ligne source     : 20

42-3-1 - Types d'erreurs

Nous avons vu comment le mécanisme des exceptions peut être utile. Il permet aux opérations de bas niveau comme aux opérations de haut niveau d'être interrompues directement, au lieu de laisser le programme continuer avec des données incorrectes ou manquantes ou agir de n'importe quelle autre mauvaise manière.

Ceci ne veut pas dire que chaque état erroné justifie une levée d'exception. On peut parfois faire mieux selon les types d'erreurs.

42-3-1-1 - Erreurs de l'utilisateur

Certaines erreurs sont causées par l'utilisateur. Comme nous l'avons vu précédemment, l'utilisateur peut avoir saisi une chaîne comme « bonjour » alors que le programme attendait un nombre. Il peut être plus approprié d'afficher un message d'erreur et demander à l'utilisateur de saisir la donnée une nouvelle fois.

Cela dit, il peut être correct d'accepter et d'utiliser l'information directement sans la valider à l'avance, tant que le code qui utilise la donnée lève l'exception quand même. L'important est de pouvoir prévenir l'utilisateur que la donnée n'est pas appropriée.

Par exemple, considérons un programme qui demande un nom de fichier à l'utilisateur. Il y a au moins deux manières de gérer des noms de fichiers potentiellement invalides :

- valider l'information avant l'utilisation : on peut déterminer si le fichier avec le nom donné existe en appelant la fonction exists() du module std.file :

if (exists(nomFichier)) {
// oui, le fichier existe

} else {
// non, le fichier n'existe pas
}

- Cela donne la possibilité de n'ouvrir le fichier que s'il existe. Malheureusement, il est encore possible que le fichier ne puisse pas être ouvert même si exists() retourne true, si par exemple un autre processus du système supprime ou renomme le fichier avant que notre programme l'ouvre, pour cette raison, la méthode suivante peut être plus utile ;

- utiliser les données avant de les valider : on peut supposer les données valides et les utiliser directement, parce que File lèvera de toute façon une exception si le fichier ne peut pas être ouvert.

import std.stdio;
import std.string;

void utiliserLeFichier(string nomFichier)
{
auto fichier = File(nomFichier, "r");
// ...
}

string lireChaîne(in char[] invite)
{
write(invite, ": ");
return chomp(readln());
}

void main()
{
bool fichierUtilisé = false;

while (!fichierUtilisé) {
try {
utiliserLeFichier(
lireChaîne("Entrez un nom de fichier"));

/*
* Si nous arrivons ici, c'est que la fonction
* utiliserLeFichier() s'est déroulée avec succès,
* ce qui indique que le nom de fichier était
* valide.
*
* Nous pouvons maintenant positionner le drapeau
* de boucle afin de quitter la boucle while.
*/
fichierUtilisé = true;
writeln("Le fichier a bien été utilisé");

} catch (std.exception.ErrnoException exc) {
stderr.writeln("Ce fichier n'a pas pu être ouvert");
}
}
}

42-3-1-1-1 - Les erreurs du programmeur

Certaines erreurs sont causées par des erreurs du programmeur. Par exemple, le programmeur peut penser qu'une fonction qui vient d'être écrite sera toujours appelée avec une valeur supérieure ou égale à zéro, et cela peut être vrai d'après la conception du programme. Appeler la fonction avec une valeur inférieure ou égale à zéro serait une erreur dans la conception du programme ou dans l'implémentation de cette conception. Ces deux cas peuvent être considérés comme des erreurs de programmation.

Il est plus judicieux d'utiliser assert au lieu du mécanisme des exceptions pour des erreurs qui sont causées par des erreurs du programmeur.

Note : nous verrons assert dans un chapitre ultérieur.

void gérerSelectionMenu(int selection)
{
assert(selection >= 0);
// ...
}

void main()
{
gérerSelectionMenu(-1);
}

Le programme se termine par une erreur d'assertion :

core.exception.AssertError@essai.d(3): Assertion failure

assert vérifie l'état du programme et affiche le nom du fichier et le numéro de la ligne de la vérification si elle échoue. Le message précédent indique que l'assertion à la ligne 3 de essai.d a échoué.

42-3-1-1-2 - Situations inattendues

Pour les situations inattendues qui sont hors des deux cas généraux que l'on vient de voir, il est quand même justifié de lever des exceptions. Si le programme ne peut pas continuer son exécution, il n'y a rien d'autre à faire que lever une exception.

Il appartient aux fonctions de plus hauts niveaux qui appellent cette fonction de décider que faire avec les exceptions levées. Elles peuvent attraper les exceptions que nous levons pour rectifier le tir.

42-3-1-2 - Résumé

- Lorsque vous êtes confronté à une erreur de l'utilisateur, avertissez l'utilisateur directement ou assurez-vous qu'une exception soit levée ; l'exception peut être levée par une autre fonction lors de l'utilisation d'une donnée incorrecte, ou directement par vous.

- Utilisez assert pour vérifier la logique ou l'implémentation du programme (nous verrons assert dans un chapitre ultérieur).

- Utilisez enforce pour valider la bonne utilisation de vos fonctions par les programmeurs.

- En cas de doute, levez une exception.

- Ordonnez les blocs catch du plus spécifique au plus général.

- Attrapez les exceptions si et seulement si vous pouvez faire quelque chose d'utile avec ces exceptions. Sinon, n'encapsulez pas de code avec une instruction try-catch. Laissez plutôt la gestion de ces exceptions aux couches de code de plus haut niveau qui peuvent en faire quelque chose.

- Placez les expressions qui doivent toujours être exécutées lors de la sortie d'une portée dans des blocs finally.

43 - Portées (scope)

Comme nous l'avons vu, les expressions qui doivent toujours être exécutées sont écrites dans le bloc finally, et les expressions qui doivent être exécutées quand il y a des conditions d'erreurs sont écrites dans des blocs catch.

On peut faire les observations suivantes sur l'utilisation de ces blocs :

- catch et finally ne peuvent pas être utilisés sans un bloc try ;

- certaines variables dont ces blocs ont besoin peuvent ne pas être accessibles à l'intérieur de ces blocs :

void foo(ref int r)
{
try {
int nombreÀAjouter = 42;

r += nombreÀAjouter;
peutLever();

} catch (Exception exc) {
r -= nombreÀAjouter;       // ERREUR de compilation
}
}

- Cette fonction modifie le paramètre référence puis annule cette modification quand une exception est levée. Malheureusement, nombreÀAjouter n'est accessible que dans le bloc try, là où il est défini.
Note : ceci renvoie à la notion d'espace de noms ; comme pour la notion de durée de vie des objets, cela sera expliqué dans un chapitre ultérieur ;

- écrire toutes les expressions potentiellement sans rapport dans un seul bloc finally en bas sépare ces expressions du code auquel elles sont liées.

Les instructions scope ont la même fonctionnalité que les blocs catch et finally, mais sont meilleures sur beaucoup d'aspects. Les trois instructions scope décrivent des expressions qui devraient être exécutées lorsqu'on quitte des portées :

- scope(exit) : l'expression est toujours exécutée lorsqu'on quitte la portée, que ce soit normalement ou à cause d'une exception ;

- scope(success) : l'expression n'est exécutée que si l'on quitte la portée normalement ;

- scope(failure) : l'expression n'est exécutée que si l'on quitte la portée à cause d'une exception.

Même si ces instructions sont proches du mécanisme des exceptions, elles peuvent être utilisées sans bloc try-catch.

Comme exemple, réécrivons la fonction précédente avec une instruction scope(failure) :

void foo(ref int r)
{
int nombreÀAjouter = 42;

r += nombreÀAjouter;
scope(failure) r -= nombreÀAjouter;

peutLever();
}

L'instruction scope(failure) assure que l'expression r -= nombreÀAjouter sera exécutée si on sort de la portée de la fonction à cause d'une exception. Un avantage de scope(failure) est le fait que l'expression qui annule l'autre expression est écrite à côté de celle-ci.

Les instructions scope peuvent aussi être utilisées par blocs :

scope(exit) {
// ... expressions ...
}

Voici une autre fonction qui teste ces trois instructions :

void test()
{
scope(exit) writeln("en quittant 1");

scope(success) {
writeln("si succès 1");
writeln("si succès 2");
}

scope(failure) writeln("si levée 1");
scope(exit) writeln("en quittant 2");
scope(failure) writeln("si levée 2");

leveLaMoitieDuTemps();
}

Si aucune exception n'est levée, la sortie de la fonction n'inclut que les expressions scope(exit) et scope(success) :

en quittant 2
si succès 1
si succès 2
en quittant 1

Si une exception est levée, la sortie inclut les expressions scope(exit) et scope(failure) :

si levée 2
en quittant 2
si levée 1
en quittant 1
object.Exception@...: le message d'erreur

Comme on peut le constater, les blocs des instructions scope sont exécutés dans l'ordre inverse de celui dans lequel ils sont écrits dans le programme. C'est parce que du code situé plus bas peut dépendre de variables qui le précèdent. Exécuter les instructions scope dans l'ordre inverse permet d'annuler les effets de bords d'expressions précédentes dans un ordre cohérent.

44 - assert et enforce

Dans les deux chapitres précédents, nous avons vu comment les exceptions et les instructions scope sont utilisées pour améliorer la justesse des programmes. assert est aussi un outil puissant qui poursuit le même but, en s'assurant que certaines suppositions sur lesquelles le programme est basé sont valides.

Il peut parfois être difficile de choisir entre lever une exception et utiliser assert. On utilisera assert dans tous les exemples suivants sans grande justification. On verra les différences plus tard dans le chapitre.

Même si ce n'est pas toujours évident, les programmes sont pleins de suppositions. Par exemple, la fonction suivante est écrite en faisant l'hypothèse que les deux paramètres d'âge sont supérieurs ou égaux à zéro :

double ageMoyen(double premier, double second)
{
return (second + second) / 2;
}

Même s'il peut être incorrect pour le programme de rencontrer un âge qui est négatif, la fonction produirait quand même une moyenne, qui peut être utilisée dans le programme sans que personne ne le remarque, ce qui fait que le programme continue avec des données incorrectes.

Un autre exemple est la fonction suivante qui suppose qu'elle sera toujours appelée avec une des deux commandes « chanter » ou « danser » :

void executerCommande(string commande)
{
if (commande == "chanter") {
robotChanter();

} else {
robotDanser();
}
}

À cause de cette supposition, la fonction robotDanser sera appelée pour toute commande autre que « chanter », correcte ou incorrecte.

Quand de telles suppositions n'existent que dans la tête du programmeur, le programme peut finir par fonctionner de façon incorrecte. assert vérifie les suppositions et termine le programme immédiatement quand elles sont incorrectes.

44-1 - Syntaxe

assert peut être utilisé de deux manières :

assert(expression_logique);
assert(expression_logique, message);

L'expression logique représente une supposition sur le programme. assert évalue cette expression pour valider cette supposition. Si la valeur de l'expression logique est vraie, alors la supposition est considérée comme valide. Sinon, la supposition est invalide et une exception AssertError est levée.

Comme son nom le suggère, cette exception hérite d'Error, et comme nous l'avons vu dans le chapitre sur les Exceptions, les exceptions qui héritent de Error ne doivent pas être attrapées. Il est important que le programme se termine au lieu de continuer avec des hypothèses invalides.

Les deux suppositions implicites de la fonction ageMoyen() que l'on vient de voir peuvent être écrites avec deux appels à assert :

double ageMoyen(double premier, double second)
{
assert(premier >= 0);
assert(second >= 0);

return (premier + second) / 2;
}

void main()
{
auto resultat = ageMoyen(-1, 10);
}

Ces vérifications avec assert portent le sens de « on suppose que les deux âges sont supérieurs ou égaux à zéro. ». On peut aussi le voir comme « cette fonction ne peut marcher correctement que si les deux âges sont supérieurs ou égaux à zéro ».

assert vérifie ces suppositions et termine le programme avec une AssertError quand elles ne sont pas valides :

core.exception.AssertError@essai(3): Assertion failure

La partie après l'arobase dans le message indique le fichier source et le numéro de la ligne de l'assertion qui a échoué. Selon la sortie ci-dessus, l'assertion qui a échoué est à la ligne 3 du fichier essai.d.

L'autre syntaxe de assert permet d'afficher un message personnalisé quand l'assertion échoue :

assert(premier >= 0, "L'âge ne peut pas être négatif.");

La sortie :

core.exception.AssertError@essai.d(3): L'âge ne peut pas être négatif.

Parfois, on pense qu'il est impossible pour un programme de rentrer dans un bloc de code donné. Dans de tels cas, il est courant d'utiliser le littéral false comme expression logique pour faire échouer une assertion. Par exemple, pour indiquer qu'on ne s'attend pas à ce que la fonction executerCommande() soit appelée avec autre chose que « chanter » ou « danser », et pour se protéger d'une telle possibilité, assert(false) peut être inséré dans la branche impossible :

void executerCommande(string commande)
{
if (commande == "chanter") {
robotChanter();

} else if (commande == "dancer") {
robotDanser();

} else {
assert(false);
}
}

On garantit que la fonction ne fonctionne qu'avec les deux commandes qu'elle connaît. Note : une autre possibilité aurait été d'utiliser un final switch.

44-2 - static assert

Comme les assertions sont là pour vérifier l'exécution correcte du programme, elles sont testées quand le programme est en fonctionnement. D'autres vérifications s'appliquent à la structure du programme et peuvent être vérifiées lors de sa compilation elle-même.

static assert est l'équivalent d'assert, mais appliqué lors de la compilation. L'avantage est qu'un programme qui aurait autrement eu un comportement incorrect à l'exécution ne peut ainsi même pas compiler. Un prérequis naturel est qu'il doit être possible d'évaluer l'expression logique lors de la compilation.

Par exemple, en supposant que le titre d'un menu soit affiché sur un périphérique de sortie qui a une largeur limitée, le static assert suivant s'assure qu'il ne sera jamais plus large que cette limite :

enum dstring titreMenu = "Menu Commande";
static assert(titreMenu.length <= 16);

Notez que la chaîne est définie comme enum pour que sa taille puisse être évaluée lors de la compilation.

Supposons qu'un programmeur change ce titre pour le rendre plus descriptif :

enum dstring titreMenu = "Menu des Commandes Directionnelles";
static assert(titreMenu.length <= 16);

L'assertion statique empêche la compilation du programme :

Error: static assert  (34u <= 16u) is false

Ceci rappelle au programmeur la limitation du périphérique de sortie.

static assert est encore plus utile lorsqu'on l'utilise avec les modèles (templates). Nous verrons les modèles dans des chapitres ultérieurs.

44-3 - Utilisez les assertions même si c'est absolument vrai

J'insiste sur le « absolument vrai » parce que, de toute façon, on ne s'attend jamais à ce que les suppositions sur le programme soient fausses. Une grande partie des erreurs de programmation viennent de suppositions qu'on pensait absolument vraies.

Pour cette raison, utilisez des assertions même si cela ne semble pas nécessaire. Regardons la fonction suivante qui retourne le nombre de jours des mois d'une année donnée :

int[] joursMois(in int annee)
{
int[] jours = [
31, joursFevrier(annee),
31, 30, 31, 30, 31, 31, 30, 31, 30, 31
];

assert((sum(jours) == 365) ||
(sum(jours) == 366));

return jours;
}

Cette assertion peut paraître inutile parce que la fonction retourne naturellement soit 365, soit 366. Cependant, ces vérifications protègent également contre les erreurs potentielles dans la fonction joursFevrier. Par exemple, le programme se terminerait si joursFevrier retournait 30.

Une autre vérification paraissant inutile peut vérifier que la taille de la tranche est toujours 12 :

assert(jours.length == 12);

De cette manière, si on supprime ou on ajoute des éléments dans la tranche involontairement, cela sera détecté. De telles vérifications sont des outils importants vis-à-vis de la correction des programmes.

assert est aussi l'outil de base qui est utilisé dans les tests unitaires et la programmation par contrat, que nous verrons dans des chapitres ultérieurs.

44-4 - Pas de valeur ni d'effet de bord

Nous avons vu que les expressions produisent des valeurs ou ont des effets de bord. Les assertions n'ont pas de valeur et ne devraient pas avoir d'effet de bord.

Le langage D requiert que l'évaluation de l'expression logique ne doive pas avoir d'effet de bord. assert doit rester un observateur passif de l'état du programme.

44-5 - Désactiver les assertions

Comme les assertions concernent la correction des programmes, elles peuvent sembler inutiles dès lors que le programme a été testé suffisamment. De plus, comme les assertions ne produisent pas de valeur et qu'elles n'ont pas d'effet de bord, les supprimer du programme ne devrait pas changer le comportement du programme.

L'option -release de dmd permet d'ignorer les assertions, comme si elles n'avaient jamais été écrites dans le programme :

dmd essai.d -release

NDT Pour gdc, vous pouvez utiliser l'option -frelease ou -fno-assert.

Cela devrait permettre aux programmes de s'exécuter plus vite en évitant d'évaluer des expressions logiques potentiellement lentes dans les assertions.

En revanche, les assertions qui ont le littéral false ou 0 comme expression logique ne sont pas désactivées même quand le programme est compilé avec ces options. En effet, assert(false) est là pour s'assurer qu'un bloc de code n'est jamais atteint, et cela doit être le cas même pour une version en mode release.

44-6 - enforce pour lever des exceptions

Toutes les situations inattendues ne signifient pas une erreur de programmation. Les programmes peuvent aussi rencontrer des entrées ou des états de l'environnement inattendus. Par exemple, une donnée qui est entrée par l'utilisateur ne devrait pas être vérifiée en utilisant une assertion, parce qu'une donnée invalide n'a rien à voir avec la correction du programme lui-même. Dans de tels cas, lever une exception comme nous l'avons fait dans les programmes précédents est plus approprié.

std.exception.enforce est une manière commode de lever des exceptions. Par exemple, supposons qu'une exception doive être levée quand une condition donnée n'est pas remplie :

if (nombre < 3) {
throw new Exception("Doit être supérieur ou égal à 3.");
}

enforce fait une vérification et lève une exception si elle n'est pas vérifiée. Le code suivant est l'équivalent du code précédent :

import std.exception;
// ...
enforce(nombre >= 3, "Doit être supérieur ou égal à 3.");

Notez que l'expression logique est inversée par rapport à l'instruction if, puisqu'elle traduit ce qui est forcé.

44-7 - Utilisation

assert est là pour détecter des erreurs du programmeur. Les conditions que vérifie assert dans la fonction joursMois, et celles sur la variable titreMenu, vues plus tôt dans ce chapitre, concernant les erreurs du programmeur.

Parfois, il est difficile de choisir entre utiliser assert ou lever une exception. La décision devrait être basée sur le fait que la situation inattendue vienne d'un problème dans le code du programme, ou non.

Si ce n'est pas le cas, le programme doit lever une exception quand il n'est pas possible d'accomplir une tâche. enforce est expressif et commode pour lever des exceptions.

Un autre point à considérer est la possibilité de remédier à la situation d'une quelconque manière. Si le programme peut faire quelque chose, même simplement afficher un message d'erreur sur le problème avec des données entrées, il est approprié de lever une exception. De cette manière, les appelants du code qui a levé l'exception peuvent l'attraper et gérer l'erreur.

44-8 - Exercices

1. Le programme suivant inclut plusieurs assertions. Compilez et exécutez le programme pour découvrir les bogues qui sont révélés par ces assertions.
Le programme demande un moment de départ et une durée à l'utilisateur et calcule le moment de fin en ajoutant la durée au moment de départ :

10 heures et 8 minutes après 06:09 donne 16:17.

2. Notez que ce problème pourrait être écrit d'une manière beaucoup plus propre en définissant des structures. Nous reprendrons ce programme dans des chapitres ultérieurs.

import std.stdio;
import std.string;
import std.exception;

/* Récupère le moment en tant qu'heures et minutes après avoir affiché
* un message. */
void lireMoment(in string message, out int heures, out int minutes)
{
write(message, "? (HH:MM) ");

readf(" %s:%s", &heures, &minutes);

enforce((heures >= 0) && (heures <= 23) &&
(minutes >= 0) && (minutes <= 59),
"moment invalide !");
}

/* Retourne le moment en tant que chaîne de caractères. */
string momentVersChaine(in int heures, in int minutes)
{
assert((heures >= 0) && (heures <= 23));
assert((minutes >= 0) && (minutes <= 59));

return format("%02s:%02s", heures, minutes);
}

/* Ajoute la durée au moment de départ et retourne le résultat dans
* la troisième paire de paramètres */
void ajouterDuree(in int heuresDepart, in int minutesDepart,
in int dureeHeures, in int dureeMinutes,
out int heuresResultat, out int minutesResultat)
{
heuresResultat = heuresDepart + dureeHeures;
minutesResultat = minutesDepart + dureeMinutes;

if (minutesResultat > 59) {
++heuresResultat;
}
}

void main()
{
int heuresDepart;
int minutesDepart;
lireMoment("Moment de départ", minutesDepart, heuresDepart);

int dureeHeures;
int dureeMinutes;
lireMoment("Durée", dureeHeures, dureeMinutes);

int heuresFin;
int minutesFin;
ajouterDuree(heuresDepart, minutesDepart,
dureeHeures, dureeMinutes,
heuresFin, minutesFin);

writefln("%s heures et %s minutes après %s donne %s.",
dureeHeures, dureeMinutes,
momentVersChaine(heuresDepart, minutesDepart),
momentVersChaine(heuresFin, minutesFin));
}

3. Lancez le programme et entrez 06:09 comme moment de départ et 1:2 comme durée. Observez que le programme termine normalement.
Note : il se peut que vous remarquiez un problème avec la sortie. Ignorez ce problème, vous le découvrirez avec l'aide des assertions très bientôt.

4. Cette fois, entrez 06:09 et 15:2. Observez que le programme se termine par une AssertError. Allez à la ligne du programme qui est indiquée dans le message de l'assertion et voyez qu'une des assertions a échoué. Découvrir la cause de cet échec particulier peut prendre du temps.

5. Entrez 06:09 et 20:0 ; observez que la même assertion échoue encore et corrigez aussi ce bogue.

6. Modifiez le programme pour afficher les temps au format 12 heures avec des indicateurs « am » (avant midi) et « pm » (après midi).

Les solutions.

45 - Tests unitaires

Les gens devraient, pour la plupart, en être conscients : toute machine contenant du code d'un programme contient des bogues logiciels. Les bogues logiciels menacent les systèmes informatiques du plus simple au plus complexe. Déboguer et corriger les bogues logiciels font partie des activités quotidiennes les moins agréables d'un programmeur.

45-1 - Causes des bogues

Il y a plein de raisons pour lesquelles les bogues logiciels existent. Ce qui suit en est une liste incomplète depuis de la conception d'un programme jusqu'à la programmation elle-même :

- les prérequis et les spécifications du programme peuvent ne pas être clairs. Ce que le programme devrait vraiment faire peut ne pas être connu au moment de la conception ;

- le programmeur peut mal comprendre certains des prérequis du programme ;

- le langage de programmation peut ne pas être suffisamment expressif. En considérant qu'il y a des confusions même entre deux humains parlant la même langue maternelle, la syntaxe et les règles non naturelles d'un langage de programmation peuvent être source d'erreurs ;

- certaines suppositions du programmeur peuvent être incorrectes. Par exemple, le programmeur peut supposer que 3.14 est assez précis pour représenter  ;

- le programmeur peut avoir une connaissance incorrecte sur un sujet ou même pas du tout. Par exemple, le programmeur peut ne pas savoir qu'utiliser une variable en point flottant dans une expression logique particulière n'est pas fiable ;

- le programme peut se trouver dans une situation imprévue. Par exemple, un des fichiers d'un répertoire peut être supprimé ou renommé pendant que le programme utilise les fichiers de ce répertoire dans une boucle foreach ;

- le programmeur peut faire des erreurs idiotes. Par exemple, le nom d'une variable peut être mal écrit et accidentellement correspondre au nom d'une autre variable ;

- etc.

Malheureusement, il n'y a toujours pas de méthode de développement qui assure qu'un programme fonctionnera toujours correctement. Il s'agit toujours d'un sujet d'actualité en génie logiciel dans lequel des solutions prometteuses émergent tous les dix ans.

45-2 - Découvrir les bogues

Les bogues logiciels sont découverts à différents moments dans la vie du programme avec différents types d'outils ou de personnes. Ce qui suit est une liste partielle de moments pouvant correspondre à la découverte d'un bogue, du plus tôt au plus tard :

- lors de l'écriture du programme :

- par le programmeur,

- par un autre programmeur pendant une session de programmation en binôme,

- par le compilateur, qui va afficher des messages lors de la compilation,

- par les tests unitaires, lors de la construction du programme ;

- lors de la relecture du code :

- par les outils qui analysent le code lors de la compilation,

- par d'autres programmeurs lors d'un audit du code ;

- lors de l'exécution du programme :

- par des outils, tels que Valgrind, qui analysent l'exécution du programme,

- pendant les tests de qualité, soit par l'échec d'une assertion ou par le comportement observé du programme,

- par les utilisateurs des versions bêta avant la sortie du programme,

- par les utilisateurs finals après la sortie du programme.

Détecter le bogue le plus tôt possible réduit les pertes d'argent, de temps et dans certains cas, de vies humaines. De plus, identifier les causes du bogue qui a été découvert par les utilisateurs finals est plus difficile qu'identifier les causes des bogues qui sont découverts plus tôt, pendant le développement.

45-3 - Le test unitaire pour déceler les bogues

Comme les programmes sont écrits par les programmeurs et que D est un langage compilé, les programmeurs et le compilateur seront toujours là pour découvrir les bogues. Par ces deux aspects et pour ces raisons, les tests unitaires sont le meilleur moyen d'intercepter les bogues. .

Le test unitaire constitue une partie indispensable de la programmation d'aujourd'hui. Il s'agit de la méthode la plus efficace pour limiter les erreurs de programmation. Selon certaines techniques de développement, un code auquel ne correspond aucun test unitaire est du code bogué.

Malheureusement, l'opposé n'est pas vrai : les tests unitaires ne garantissent pas que le code est libre de tout bogue. Même s'ils sont très efficaces, ils ne peuvent que réduire le risque de bogues.

Le test unitaire permet aussi la refactorisation du code (c.-à-d. l'améliorer) avec facilité et confiance. Autrement, il est courant de casser une fonctionnalité existante d'un programme lors d'un ajout de nouvelles fonctionnalités. Des bogues de ce type sont appelés régressions. Sans test unitaire, les régressions ne sont parfois découvertes que lors des tests de qualité des nouvelles versions ou pire, par les utilisateurs finals.

Le risque de régression décourage les programmeurs de refactoriser le code, ce qui les empêche parfois d'apporter la plus simple des améliorations comme corriger le nom d'une variable. Ceci entraîne le pourrissement du code, une situation dans laquelle le code devient de moins en moins maintenable. Par exemple, même si c'est préférable que quelques lignes de code soient logées dans une nouvelle fonction qui serait appelée depuis plusieurs endroits, la peur de créer des régressions fait que les programmeurs vont plutôt copier-coller les lignes, ce qui entraîne le problème de la duplication de code.

Les phrases du type « Si ce n'est pas cassé, ne le corrigez pas » viennent de la peur des régressions. Même si elles semblent transmettre la sagesse, de telles lignes de conduite entraînent, lentement mais sûrement, le pourrissement du code et celui-ci devient un bazar sans nom.

La programmation moderne rejette une telle « sagesse ». Au contraire, pour l'empêcher de devenir une source de bogues, le code est supposé être « refactorisé sans merci ». L'outil le plus important de cette approche moderne est le test unitaire.

Le test unitaire implique le test des plus petites unités de code indépendamment. Quand chaque unité de code est testée de façon indépendante, il est moins probable que des bogues apparaissent dans des codes de plus haut niveau qui utilisent ces unités. Quand les parties fonctionnent correctement, il est plus probable que l'ensemble fonctionnera également.

Dans d'autres langages, les tests unitaires sont proposés par des bibliothèques (par ex. JUnit, CppUnit, Unittest++, etc.). En D, le test unitaire est une fonctionnalité au cœur du langage. Qu'il soit préférable que la fonctionnalité fasse partie du cœur du langage est discutable. D ne proposant pas certaines fonctionnalités souvent trouvées dans les bibliothèques de test unitaire, il peut valoir la peine de considérer l'utilisation des bibliothèques.

Le test unitaire en D est aussi simple que d'insérer des assertions dans des blocs unittest.

45-4 - Activer les tests unitaires

Les tests unitaires ne font pas partie de l'exécution du programme. Ils ne devraient être activés que pendant le développement lorsqu'ils sont explicitement demandés.

L'option du compilateur dmd qui active les tests unitaires est -unittest. (NDT L'option équivalente pour gdc est -funittest.)

En supposant que le programme est écrit dans un seul fichier source nommé deneme.d, ses tests unitaires peuvent être activés par la commande suivante :

dmd deneme.d -w -unittest

Quand un programme qui est compilé avec les tests unitaires est démarré, ses blocs de tests unitaires sont d'abord exécutés. L'exécution du programme ne continue sur main() que si tous les tests unitaires passent.

45-5 - Blocs unittest

Les lignes de code qui impliquent les tests unitaires sont écrites dans les blocs unittest. Ces blocs n'ont aucune autre signification pour le programme que de contenir les tests unitaires :

unittest
{
/* ... Les tests et leur code support... */
}

Même si les blocs unittest peuvent apparaître n'importe où, il est pratique de les définir juste après le code qu'ils testent.

Par exemple, testons une fonction qui retourne la forme ordinale du nombre spécifié, comme dans « 1er », « 2e », etc. Un bloc unittest de cette fonction pourrait simplement contenir des instructions assert qui comparent les valeurs de retour de la fonction aux valeurs attendues. La fonction suivante est testée avec les trois formes de résultats de cette fonction :

string ordinal(size_t nombre)
{
// ...
}

unittest
{
assert(ordinal(1) == "1er");
assert(ordinal(2) == "2e");
assert(ordinal(3) == "3e");
}

Les trois tests ci-dessus vérifient que la fonction fonctionne correctement au moins pour les valeurs 1,2 et 3 en faisant trois appels séparés à la fonction et en comparant les valeurs retournées aux valeurs attendues.

Même si les tests unitaires sont basés sur des assertions, les blocs unittest peuvent contenir n'importe quel code D. Ceci permet des préparations avant de commencer les tests ou tout autre code support dont les tests pourraient avoir besoin. Par exemple, le bloc suivant définit d'abord une variable pour réduire la duplication de code.

dstring toFront(dstring str, in dchar letter)
{
// ...
}

unittest
{
immutable str = "hello"d;

assert(toFront(str, 'h') == "hello");
assert(toFront(str, 'o') == "ohell");
assert(toFront(str, 'l') == "llheo");
}

Les trois assertions ci-avant vérifient que toFront() est en accord avec sa spécification.

Comme ces exemples le montrent, les tests unitaires sont aussi utiles comme exemples d'utilisation des fonctions. Habituellement, il est facile de se faire une idée sur ce que fait une fonction simplement en regardant ses tests unitaires.

45-6 - Développement piloté par les tests (Test Driven Development (TDD))

Le développement piloté par les tests est une méthode de développement logiciel qui prescrit l'écriture de tests unitaires avant d'implémenter une fonctionnalité. En TDD, on se concentre sur le test unitaire. Coder est une activité secondaire qui fait passer les tests.

En accord avec le TDD, la fonction ordinal ci-avant peut d'abord être implémentée incorrectement de façon intentionnelle :

import std.string;

string ordinal(size_t nombre)
{
return "";    // ← intentionnellement faux
}

unittest
{
assert(ordinal(1) == "1er");
assert(ordinal(2) == "2e");
assert(ordinal(3) == "3e");
}

void main()
{}

Même si la fonction est évidemment fausse, l'étape suivante serait de lancer les tests unitaires décelant effectivement les problèmes de la fonction :

$ dmd deneme.d -w -O -unittest
$ ./deneme
core.exception.AssertError@deneme.d(10): unittest failure

La fonction ne devrait être implémentée qu'après avoir vu l'échec, et seulement pour faire passer les tests. Voici une implémentation qui passe les tests :

import std.string;

string ordinal(size_t nombre)
{
string suffixe;

switch (nombre) {
case  1: suffixe = "er"; break;
case  2: suffixe = "e"; break;
default: suffixe = "e"; break;
}

return format("%s%s", nombre, suffixe);
}

unittest
{
assert(ordinal(1) == "1er");
assert(ordinal(2) == "2e");
assert(ordinal(3) == "3e");
}

void main()
{}

Comme les implémentations ci-avant passent les tests unitaires, il y a des raisons de penser que la fonction ordinal est correcte. Avec l'assurance que les tests apportent, l'implémentation de la fonction peut être changée avec confiance.

45-6-1 - Les tests unitaires avant les corrections de bogues

Les tests unitaires ne sont pas la panacée ; il y aura toujours des bogues. Si un bogue est découvert pendant l'exécution du programme, cela peut être vu comme une indication que les tests unitaires sont incomplets. Pour cette raison, il est mieux de d'abord écrire un test unitaire qui reproduit le bogue et seulement alors de corriger le bogue et de passer le nouveau test.

Considérons la fonction suivante qui retourne l'orthographe de la forme ordinale d'un nombre donné en dstring, en anglais :

import std.exception;
import std.string;

dstring orthographeOrdinal(dstring nombre)
{
enforce(nombre.length, "Le nombre ne peut pas être vide");

dstring[dstring] exceptions = [
"one": "first", "two" : "second", "three" : "third",
"five" : "fifth", "eight": "eighth", "nine" : "ninth",
"twelve" : "twelfth"
];

dstring resultat;

if (nombre in exceptions) {
resultat = exceptions[nombre];

} else {
resultat = nombre ~ "th";
}

return resultat;
}

unittest
{
assert(orthographeOrdinal("one") == "first");
assert(orthographeOrdinal("two") == "second");
assert(orthographeOrdinal("three") == "third");
assert(orthographeOrdinal("ten") == "tenth");
}

void main()
{}

La fonction fait attention aux exceptions orthographiques et inclut même un test unitaire pour cela. Cependant, la fonction a un bogue qui reste à découvrir :

import std.stdio;

void main()
{
writefln("He came the %s in the race.", // "il arriva %s de la course"
orthographeOrdinal("twenty")); // "vingt" - en anglais, "vingtième" se dit "twentieth".
}

La faute d'orthographe dans la sortie du programme est due à un bogue dans orthographeOrdinal, que les tests unitaires ne décèlent malheureusement pas :

He came the twentyth in the race.

Même s'il est facile de voir que la fonction ne produit pas la syntaxe correcte pour les nombres qui finissent par un « y », Le TTD prescrit l'écriture d'un test unitaire qui reproduit le bogue avant de le corriger :

unittest
{
// ...
assert(orthographeOrdinal("twenty") == "twentieth");
}

Avec cet ajout aux tests, le bogue de la fonction est maintenant décelé pendant le développement :

core.exception.AssertError@deneme.d(33): unittest failure

Et seulement alors, la fonction devrait être corrigée :

dstring orthographeOrdinal(dstring nombre)
{
// ...
if (nombre in exceptions) {
resultat = exceptions[nombre];

} else {
if (nombre[$-1] == 'y') {
resultat = nombre[0..$-1] ~ "ieth";

} else {
resultat = nombre ~ "th";
}
}

return resultat;
}

45-7 - Exercice

Implémentez toFront() en utilisant le TDD. Commencez par l'implémentation intentionnellement incomplète qui suit. Observez que les tests unitaires échouent et donnez une implémentation qui passe les tests.

dstring toFront(dstring str, in dchar lettre)
{
dstring resultat;
return resultat;
}

unittest
{
immutable str = "hello"d;

assert(toFront(str, 'h') == "hello");
assert(toFront(str, 'o') == "ohell");
assert(toFront(str, 'l') == "llheo");
}

void main()
{}

Solution.

46 - Programmation par contrat

La programmation par contrat est une approche en conception logicielle qui traite les parties d'un logiciel comme des entités individuelles qui se rendent mutuellement des services. Cette approche considère que le programme peut fonctionner en accord avec sa spécification tant que le fournisseur et le consommateur du service obéissent tous deux à un contrat.

Les fonctionnalités du D en matière de programmation par contrat considèrent les fonctions comme les unités de services du logiciel. Comme les tests unitaires, la programmation par contrat est basée sur les assertions.

La programmation par contrat en D est implémentée avec trois types de blocs de code :

- les blocs in des fonctions ;

- les blocs out des fonctions ;

- les blocs invariant des structures et des classes.

Nous verrons les blocs invariant et l'héritage par contrat dans un chapitre ultérieur, après avoir couvert les structures et les classes.

46-1 - Les blocs in pour les conditions d'entrée

L'exécution correcte des fonctions dépend habituellement de la correction des valeurs de leurs paramètres. Par exemple, une fonction racine carrée peut avoir besoin que son paramètre ne soit pas négatif. Une fonction qui traite des dates peut avoir besoin que le numéro de mois soit compris entre 1 et 12.

Nous avons déjà vu de telles vérifications dans le chapitre sur assert et enforce. Les conditions sur les valeurs des paramètres peuvent être forcées par des assertions à l'intérieur des définitions de fonctions :

string momentVersChaine(in int heure, in int minute)
{
assert((heure >= 0) && (heure <= 23));
assert((minute >= 0) && (minute <= 59));

return format("%02s:%02s", heure, minute);
}

En programmation par contrat, les mêmes vérifications sont écrites dans les blocs in des fonctions. Quand un bloc in ou out est utilisé, le contenu réel de la fonction doit être placé dans un bloc body :

import std.stdio;
import std.string;

string momentVersChaine(in int heure, in int minute)
in
{
assert((heure >= 0) && (heure <= 23));
assert((minute >= 0) && (minute <= 59));
}
body
{
return format("%02s:%02s", heure, minute);
}

void main()
{
writeln(momentVersChaine(12, 34));
}

Un avantage d'un bloc in est que toutes les préconditions sont rassemblées et séparées du corps de la fonction elle-même. De cette manière, le corps de la fonction est libre de toute assertion concernant les préconditions. Au besoin, il est toujours possible et même conseillé d'avoir d'autres assertions dans le corps de la fonction qui ne correspondent pas à des préconditions et qui protégeraient des erreurs de programmation dans le corps de la fonction.

Le code qui est à l'intérieur du bloc in est exécuté automatiquement à chaque fois que la fonction est appelée. L'exécution de la fonction elle-même ne commence que si toutes les assertions du bloc in passent. Cela prévient l'exécution de la fonction avec des préconditions non respectées et, par conséquent, évite de produire des résultats incorrects.

Toute assertion qui échoue dans le bloc in indique que le contrat a été violé par l'appelant.

46-2 - Les blocs out pour les postconditions

L'autre côté du contrat implique les garanties que la fonction donne. Un exemple de fonction avec une postcondition serait une fonction qui retourne le nombre de jours en février ; la valeur retournée sera toujours 28 ou 29.

Les postconditions sont vérifiées dans les blocs out des fonctions.

La valeur qu'une fonction retourne avec l'instruction return n'a pas besoin d'être définie comme une variable à l'intérieur la fonction, il n'y a habituellement pas de nom faisant référence à cette valeur de retour. Ceci peut être problématique parce que les assertions dans le bloc out ne peuvent pas utiliser la valeur de retour par son nom.

D propose une solution à ce problème en donnant un moyen de nommer la valeur de retour juste après le mot-clé out. Ce nom représente précisément la valeur que la fonction est en train de retourner :

int joursEnFevrier(in int annee)
out (resultat)
{
assert((resultat == 28) || (resultat == 29));
}
body
{
return estAnneeBissextile(annee) ? 29 : 28;
}

Même si resultat est un nom raisonnable pour une valeur de retour, d'autres noms parfaitement valides peuvent aussi être utilisés.

Certaines fonctions n'ont pas de valeur de retour ou la valeur de retour n'a pas besoin d'être vérifiée. Dans ce cas, le bloc out n'oblige pas à spécifier un nom :

out
{
// ...
}

De manière similaire aux blocs in, les blocs out sont exécutés automatiquement après l'exécution du corps de la fonction.

Une assertion qui échoue dans un bloc out indique que le contrat a été violé par la fonction.

Évidemment, les blocs in et out sont optionnels. En incluant les blocs unittest, qui sont également optionnels, les fonctions D peuvent être constituées d'un nombre de blocs pouvant aller jusqu'à quatre :

- in : optionnel ;

- out : optionnel ;

- body : obligatoire, mais le mot-clé body peut être omis s'il n'y a pas de bloc in ni de bloc out ;

- unittest : optionnel et ne fait techniquement pas partie de la définition de la fonction, mais est couramment défini juste après la fonction.

Voici un exemple qui utilise tous ces blocs :

import std.stdio;

/*
* Distribue la somme entre deux variables.
*
* Distribue d'abord à la première variable, mais ne lui donne
* jamais plus de 7. Le reste de la somme est distribué
* à la seconde variable.
*/
void distribuer(in int somme, out int premiere, out int seconde)
in
{
assert(somme >= 0);
}
out
{
assert(somme == (premiere + seconde));
}
body
{
premiere = (somme >= 7) ? 7 : somme;
seconde = somme - premiere;
}

unittest
{
int premiere;
int seconde;

// Les deux doivent valoir 0 si la somme vaut 0
distribuer(0, premiere, seconde);
assert(premiere == 0);
assert(seconde == 0);

// Si la somme est plus petite que 7, tout doit être donné
// à premiere
distribuer(3, premiere, seconde);
assert(premiere == 3);
assert(seconde == 0);

// Test d'une condition limite
distribuer(7, premiere, seconde);
assert(premiere == 7);
assert(seconde == 0);

// Si la somme est plus grande que 7, la première doit recevoir 7
// et le reste doit être donné à la seconde
distribuer(8, premiere, seconde);
assert(premiere == 7);
assert(seconde == 1);

// Une grande valeur quelconque
distribuer(1_000_007, premiere, seconde);
assert(premiere == 7);
assert(seconde == 1_000_000);
}

void main()
{
int premiere;
int seconde;

distribuer(123, premiere, seconde);
writeln("premiere : ", premiere, ", seconde : ", seconde);
}

Le programme peut être compilé et exécuté dans la console avec la commande suivante :

$ dmd essai.d -w -unittest // ou : gdc -Wall -funittest essai.d -o essai
$ ./essai
premiere : 7, seconde : 116

Même si le travail de la fonction lui-même consiste en seulement deux lignes, il y a un total de 19 lignes non triviales qui prennent en charge sa fonctionnalité. On peut discuter du fait qu'autant de code en plus est superflu pour une si petite fonction. Cependant, les bogues ne sont jamais intentionnels. Le programmeur écrit toujours du code qui devrait fonctionner correctement, et qui finit souvent par contenir divers types de bogues.

Quand les attentes sont explicitement spécifiées dans les tests unitaires et dans les contrats, les fonctions qui sont initialement correctes ont plus de chances de rester correctes. Je vous recommande de tirer parti de toutes les fonctionnalités qui améliorent la correction des programmes. Les tests unitaires comme les contrats sont des outils efficaces pour cela. Ils aident à réduire le temps passé à déboguer (au profit du temps passé à écrire le code).

46-3 - Désactiver la programmation par contrat

Contrairement au test unitaire, la programmation par contrat est activée par défaut. L'option -release de dmd et les options -fno-in -fno-out -fno-invariants ou -frelease de gdc désactivent la programmation par contrat :

dmd essai.d -w -release // ou gdc essai.d -Wall -frelease essai

Quand la programmation par contrat est désactivée, le contenu des blocs in, out et invariant est ignoré.

46-4 - Blocs in versus enforce

Nous avons vu dans le chapitre sur assert et enforce qu'il est parfois difficile de choisir entre assert ou enforce pour faire des vérifications. De manière similaire, il est parfois difficile de choisir entre les assertions dans des blocs in et des vérifications avec enforce à l'intérieur du corps des fonctions.

La possibilité de désactiver la programmation par contrat est une indication que celle-ci est là pour protéger contre les erreurs de programmation. Pour cette raison, la décision devrait ici être basée sur les mêmes règles que ce que nous avons vu dans le chapitre sur assert et enforce :

- si une vérification est là pour prévenir une erreur de programmation, alors elle devrait être dans le bloc in. Par exemple, si la fonction est appelée seulement depuis d'autres endroits du programme pour construire une fonctionnalité, les valeurs des paramètres sont entièrement sous la responsabilité du programmeur. Pour cette raison, les préconditions d'une telle fonction devraient être vérifiées dans son bloc in ;

- si la fonction ne peut pas jouer son rôle pour n'importe quelle autre raison, des valeurs de paramètres incorrectes incluses, alors elle doit lever une exception et enforce est un moyen pratique de le faire.
Pour voir un exemple de cela, définissons une fonction qui retourne une tranche du milieu d'une autre tranche. Supposons que cette fonction est là pour être utilisée par les utilisateurs du module, et non une fonction interne utilisée uniquement par le module lui-même. Comme les utilisateurs de ce module peuvent appeler cette fonction avec divers paramètres potentiellement incorrects, il peut être approprié de vérifier les valeurs des paramètres à chaque fois que la fonction est appelée. Il serait insuffisant de ne les vérifier que pendant le développement, après lequel les contrats peuvent être désactivés.
Pour cette raison, la fonction suivante vérifie ses paramètres en appelant enforce dans le corps de la fonction au lieu d'une assertion dans le bloc in :

import std.exception;

inout(int)[] milieu(inout(int)[] trancheOriginale, size_t largeur)
out (resultat)
{
assert(resultat.length == largeur);
}
body
{
enforce(trancheOriginale.length >= largeur);

immutable debut = (trancheOriginale.length - largeur) / 2;
immutable fin = debut + largeur;

return trancheOriginale[debut .. fin];
}

unittest
{
auto slice = [1, 2, 3, 4, 5];

assert(milieu(slice, 3) == [2, 3, 4]);
assert(milieu(slice, 2) == [2, 3]);
assert(milieu(slice, 5) == slice);
}

void main()
{}

- Le problème se pose moins pour les blocs out. Comme la valeur de retour de toute fonction relève de la responsabilité du programmeur, les postconditions doivent toujours être vérifiées dans le bloc out. La fonction que l'on vient de voir suit cette règle ;

- un autre critère à considérer lors du choix entre les blocs in et enforce est de se demander si la situation est rattrapable. Si elle est rattrapable par les couches de code de plus haut niveau, alors il peut être plus approprié de lever une exception (ce qu'enforce rend pratique).

46-5 - Exercice

Écrivez un programme qui augmente le nombre total de points de deux équipes de foot selon le résultat d'un match.

Les deux premiers paramètres de cette fonction sont les buts que les deux équipes ont marqués. Les deux autres paramètres sont les points de chaque équipe avant le match. Cette fonction doit ajuster les points des équipes selon les buts qu'elles ont marqués. Pour rappel, l'équipe gagnante prend trois points et l'équipe perdante ne prend pas de point. Dans le cas d'un match nul, les deux équipes prennent un point chacune.

De plus, la fonction devrait indiquer l'équipe gagnante : 1 si la première équipe a gagné, 2 si la seconde équipe a gagné, 0 si le match a fini par un match nul.

Partez du programme suivant et complétez les quatre blocs de la fonction de façon appropriée. Ne supprimez pas les assertions dans la fonction main ; elles montrent comment cette fonction doit se comporter.

int ajouterPoints(in int buts1,
in int buts2,
ref int points1,
ref int points2)
in
{
// ...
}
out (resultat)
{
// ...
}
body
{
int gagnante;

// ...

return gagnante;
}

unittest
{
// ...
}

void main()
{
int points1 = 10;
int points2 = 7;
int gagnante;

gagnante = ajouterPoints(3, 1, points1, points2);
assert(points1 == 13);
assert(points2 == 7);
assert(gagnante == 1);

gagnante = ajouterPoints(2, 2, points1, points2);
assert(points1 == 14);
assert(points2 == 8);
assert(gagnante == 0);
}

Note : il peut être plus judicieux de retourner une valeur énumérée depuis cette fonction :

enum ResultatJeu
{
premiereGagne, secondeGagne, nul
}

ResultatJeu ajouterPoints(in int buts1,
in int buts2,
ref int points1,
ref int points2)
// ...

J'ai choisi de retourner un int pour cet exercice, de cette manière, la valeur de retour peut être comparée aux valeurs 0, 1 et 2.

La solution.

47 - Durées de vie et opérations fondamentales

Nous verrons bientôt les structures, la fonctionnalité de base qui permet au programmeur de définir des types spécifiques à une application. Les structures sont faites pour combiner des types fondamentaux et d'autres structures pour définir des types de plus haut niveau qui se comportent selon des besoins spécifiques des programmes. Après les structures, nous verrons les classes, qui sont la base de la programmation orientée objet en D.

Avant de parler de structures et de classes, nous allons d'abord aborder des notions importantes. Ces notions aideront à comprendre les structures, les classes et certaines de leurs différences.

Nous avons appelé toute donnée qui représente une idée dans un programme une variable. À certains endroits, nous avons nommé des variables de type structure ou classe des objets. On va continuer à les appeler des variables dans ce chapitre.

Même si ce chapitre n'aborde que les types fondamentaux, les tranches et les tableaux associatifs, ces notions s'appliquent également aux types définis par l'utilisateur.

47-1 - Durée de vie d'une variable

Le temps écoulé entre la définition d'une variable et sa finalisation est sa durée de vie. Même si c'est le cas pour beaucoup de types, devenir indisponible et être finalisé ne se passent pas nécessairement en même temps.

Si vous vous souvenez du chapitre sur les espaces de noms, vous vous rappelez de la manière dont les variables deviennent indisponibles. Dans les cas simples, quitter la portée dans laquelle une variable a été définie rend cette variable indisponible.

Considérons l'exemple suivant comme un rappel :

void testVitesse()
{
int vitesse;               // Variable unique ...

foreach (i; 0 .. 10) {
vitesse = 100 + i;     // ... qui prend 10 valeurs différentes.
// ...
}
} // ← 'vitesse' est indisponible après ce point.

La vie de la variable vitesse dans ce code prend fin lorsque l'on sort de la fonction testVitesse. Il y a une seule variable dans le code suivant et elle prend 10 valeurs différentes, de 100 à 109.

En ce qui concerne la durée de vie des variables, le code suivant est très différent comparé au précédent :

void testVitesse()
{
foreach (i; 0 .. 10) {
int vitesse = 100 + i; // Dix variables différentes.
// ...
} // ← La vie de chacune des variables prend fin ici.
}

Il y a 10 variables différentes dans ce code, chacune prenant une valeur unique. À chaque itération de la boucle, une nouvelle variable commence sa vie, qui prend fin à la fin de chaque itération.

47-2 - Durée de vie d'un paramètre

La durée de vie d'un paramètre dépend de ses qualificateurs :

- ref : le paramètre est seulement un alias de la variable qui est indiquée lors de l'appel de la fonction. Les paramètres refs n'affectent pas la durée de vie des variables ;

- in : pour les types valeur, la vie d'un paramètre commence en entrant dans la fonction et prend fin lorsque l'on en sort. Pour les types référence, la vie du paramètre est la même qu'avec ref ;

- out : pareil que pour ref, le paramètre n'est qu'un alias de la variable qui est indiquée lors de l'appel de la fonction. La seule différence est que la variable est mise à sa valeur .init automatiquement en entrant dans la fonction ;

- lazy : la vie du paramètre commence quand le paramètre est utilisé et prend aussitôt fin.

L'exemple suivant utilise ces quatre types de paramètres et explique leur durée de vie dans les commentaires :

void main()
{
int main_in;      /* La valeur de main_in est copiée lors
* de l'appel. */

int main_ref;     /* main_ref est passé à la fonction tel quel,
* sans copie. */

int main_out;     /* main_out est passé à la fonction tel quel.
* Sa valeur prend celle de int.init
* en entrant dans la fonction. */

foo(main_in, main_ref, main_out, unCalcul());
}

void foo(
in int p_in,      /* La vie de p_in débute lors
* de l'entrée dans la fonction et
* finit en quittant la fonction. */

ref int p_ref,    /* p_ref est un alias de main_ref. */

out int p_out,    /* p_out est un alias de main_out. Sa
* valeur prend celle de int.init lors
* de l'entrée dans la fonction. */

lazy int p_lazy)  /* La vie de p_lazy commence lors de
* son utilisation et prend fin lorsque
* son utilisation prend fin. Sa valeur
* est recalculée en appelant unCalcul()
* à chaque fois que p_lazy est utilisé
* dans la fonction. */
{
// ...
}

int unCalcul()
{
int resultat;
// ...
return resultat;
}

47-3 - Opérations fondamentales

Indépendamment de son type, il y a trois opérations fondamentales au cours de la vie d'une variable :

- initialisation : le début de sa vie ;

- finalisation : la fin de sa vie ;

- affectation : changer sa valeur.

Pour être considérée comme un objet, elle doit d'abord être initialisée. Il peut y avoir des opérations finales pour certains types. La valeur d'une variable peut changer au cours de sa vie.

47-3-1 - Initialisation

Toute variable doit être initialisée avant d'être utilisée. L'initialisation implique deux étapes :

- réservation de l'espace pour la variable : cet espace est l'endroit dans lequel la valeur de la variable est stockée en mémoire ;

- construction : fixer la première valeur de la variable dans cet espace (ou les premières valeurs des membres des structures et des classes).

Toute variable vit à un endroit en mémoire qui lui est réservé. Une partie du code que le compilateur génère sert à réserver de l'espace pour chaque variable.

Considérons la variable suivante :

int vitesse = 123;

Le nombre d'octets que la variable vitesse prend dans la mémoire du programme est la taille d'un int. Si on voit la mémoire comme un ruban allant de gauche à droite, on peut imaginer la variable vivant quelque part dans ce ruban :

L'endroit dans lequel la variable est placée dans la mémoire a une adresse. En un sens, la variable vit à cette adresse. Quand la valeur d'une variable est changée, la nouvelle valeur est stockée à la même place :

++vitesse;

La nouvelle valeur se retrouve au même endroit, là où l'ancienne valeur était stockée :

La construction est nécessaire pour préparer l'utilisation des variables. Comme une variable ne peut pas être utilisée de façon fiable avant d'avoir été construite, ceci est fait par le compilateur automatiquement.

Les variables peuvent être construites de trois manières :

- par leur valeur par défaut : quand le programmeur ne spécifie pas une valeur explicite ;

- en copiant : quand la variable est construite comme étant la copie d'une variable du même type ;

- par une valeur spécifique : quand le programmeur spécifie une valeur explicitement.

Quand une valeur n'est pas spécifiée, la valeur de la variable est la valeur par défaut de son type, c.-à-d. sa valeur .init.

int vitesse;

La valeur de vitesse est int . init, qui se trouve être zéro. Naturellement, une variable qui est construite par sa valeur par défaut peut prendre d'autres valeurs durant sa vie (sauf si elle est immutable).

File fichier;

Avec la définition précédente, la variable fichier est un objet File qui n'a pas encore été associée à un fichier du système de fichiers. Elle reste inutilisable jusqu'à ce qu'elle soit modifiée pour être associée à un fichier.

Les variables sont parfois construites comme des copies d'une autre variable :

int vitesse = autreVitesse;

vitesse est ici construite par la valeur de autreVitesse.

Comme nous le verrons dans des chapitres ultérieurs, cette opération a une signification différente pour les variables de type classe :

auto variableClasse = autreVariableClasse;

Même si variableClasse commence sa vie comme une copie de variableAutreClasse, il y a une différence fondamentale avec les classes : bien que vitesse et autreVitesse soient des valeurs distinctes, variableClasse et variableAutreClasse donnent toutes deux accès à la même valeur. C'est la différence fondamentale entre les types valeurs et les types références. Nous verrons cela dans le chapitre suivant.

Enfin, les variables peuvent être construites par la valeur d'une expression d'un type compatible :

int vitesse = unCalcul();

vitesse est ici construite avec la valeur de retour de la fonction unCalcul.

47-3-2 - Finalisation

La finalisation est l'ensemble des opérations finales qui sont exécutées pour une variable ainsi que la libération de la mémoire qu'elle occupait :

- destruction : les opérations finales qui doivent être exécutées pour la variable ;

- libération de la mémoire : on reprend la case mémoire que la variable occupait pendant sa vie.

Pour les types fondamentaux, il n'y a pas d'opération finale à exécuter. Par exemple, la valeur d'une variable de type int n'est pas remise à zéro. Pour de telles variables, il n'y a que la libération de la mémoire, qui pourra être utilisée pour d'autres variables plus tard.

En revanche, certains types de variables nécessitent des opérations spéciales pendant la finalisation. Par exemple, un objet File aura besoin d'écrire sur le disque les caractères qui sont toujours dans son tampon de sortie et de notifier au système de fichiers le fait qu'il n'utilise plus le fichier. Ces opérations sont la destruction d'un objet File.

Les opérations finales des tableaux sont d'un niveau un peu plus haut : avant de finaliser le tableau, ses éléments doivent d'abord être détruits. Si les éléments sont d'un type fondamental simple comme int, il n'y a alors pas d'opération finale spéciale à effectuer. Si les éléments sont d'un type structure ou classe qui a besoin d'être finalisé, ces opérations sont alors exécutées pour chaque élément.

Les tableaux associatifs sont similaires aux tableaux. De plus, leurs clés peuvent également avoir besoin d'être finalisées si elles sont d'un type qui nécessite une destruction.

Le ramasse-miettes : D est un langage géré par un ramasse-miettes. Dans de tels langages, finaliser un objet ne nécessite pas d'être fait explicitement par le programmeur. Quand la vie d'une variable se termine, sa finalisation est automatiquement gérée par le ramasse-miettes. Nous verrons le ramasse-miettes et la gestion spéciale de la mémoire dans un chapitre ultérieur.

Les variables peuvent être finalisées de deux manières :

- quand leur vie prend fin : la finalisation se passe lors de la fin de vie de la variable ;

- un moment dans le futur : la finalisation se passe à un moment indéterminé du futur et elle est faite par le ramasse-miettes.

La manière de laquelle une variable est finalisée dépend surtout de son type. Certains types de variables comme les tableaux, les tableaux associatifs et les classes sont normalement détruits par le ramasse-miettes à un certain point dans le futur.

47-3-3 - Affectation

L'autre opération fondamentale qu'une variable subit dans sa vie est l'affectation.

Pour les types simples fondamentaux, l'affectation est simplement le changement de sa valeur. Comme nous l'avons vu ci-dessus dans la représentation de la mémoire, une variable de type int prendrait la valeur 124 au lieu de 123. Cependant, plus généralement, l'affectation consiste en deux étapes, qui ne sont pas forcément exécutées dans l'ordre suivant :

- destruction de l'ancienne valeur ;

- construction de la nouvelle valeur.

Ces deux étapes ne sont pas importantes pour les types fondamentaux puisqu'ils n'ont pas besoin de destruction. Pour les types qui nécessitent une destruction, il est important de se souvenir que l'affectation est une combinaison de ces deux étapes.

48 - Types valeur et types référence

Ce chapitre introduit les notions de types valeur et types référence. Ces notions sont particulièrement importantes pour comprendre les différences entre les structures et les classes.

Ce chapitre décrit également plus en détail l'opérateur &.

Le chapitre se clôt avec un tableau qui contient les résultats des deux types de comparaisons suivants pour chaque type de variable :

- comparaison par valeur ;

- comparaison par adresse.

48-1 - Types valeur

Les types valeur sont faciles à décrire : les variables de types valeur contiennent des valeurs. Par exemple, tous les types entiers et les flottants sont des types valeur. Même si ce n'est pas immédiatement évident, les tableaux à taille fixe sont aussi des types valeur.

Par exemple, une variable de type int a une valeur entière :

int vitesse = 123;

Le nombre d'octets que la variable vitesse occupe est la taille d'un int. Si on représente la mémoire comme un ruban allant de gauche à droite, on peut imaginer la variable résidant dans une partie de ce ruban :

Quand des variables de types valeur sont copiées, elles reçoivent leurs propres valeurs :

int nouvelleVitesse = vitesse;

La nouvelle variable a une place et une valeur propres :

Naturellement, les modifications qui sont apportées à ces variables sont indépendantes :

vitesse = 200;

La valeur de l'autre variable ne change pas :

48-1-1 - Note sur l'utilisation des assertions dans ce chapitre

Les exemples qui suivent contiennent des assertions pour indiquer que leurs conditions sont vraies. En d'autres termes, ce ne sont pas des vérifications au sens propre du temps, plutôt un moyen d'indiquer au lecteur que « ceci est vrai ».

Par exemple, l'assertion assert(vitesse == nouvelleVitesse) dans la section suivante signifie que vitesse est égale à nouvelleVitesse.

48-1-2 - Identité entre valeurs

- Égalité entre valeurs : l'opérateur == qui apparaît dans beaucoup d'exemples à travers le livre compare les variables par leurs valeurs. Quand deux variables sont dites égales dans ce sens, leurs valeurs sont égales.

- Identité entre valeurs : dans le sens où elles ont chacune des valeurs indépendantes, vitesse et nouvelleVitesse ne sont pas identiques. Même si leurs valeurs sont égales, ce sont des variables différentes.

int vitesse = 123;
int nouvelleVitesse = vitesse;
assert(vitesse == nouvelleVitesse);
vitesse = 200;
assert(vitesse != nouvelleVitesse);

48-1-3 - Opérateur de déréférencement (« adresse de ») &

Jusqu'à maintenant, nous avons utilisé l'opérateur & avec readf(). L'opérateur & indique à readf() où ranger la donnée d'entrée.

Les adresses des variables peuvent être utilisées pour d'autres choses. Le code suivant affiche simplement les adresses des deux variables :

int vitesse = 123;
int nouvelleVitesse = vitesse;

writeln("vitesse         : ", vitesse,         " adresse : ", &vitesse);
writeln("nouvelleVitesse : ", nouvelleVitesse, " adresse : ", &nouvelleVitesse);

vitesse et nouvelleVitesse ont la même valeur, mais leurs adresses sont différentes :

vitesse         : 123 adresse : 7FFF4B39C738
nouvelleVitesse : 123 adresse : 7FFF4B39C73C

Il est normal que les adresses aient des valeurs différentes à chaque fois que le programme est lancé. Les variables résident aux endroits où la mémoire est disponible au moment où le programme est exécuté.

Les adresses sont normalement affichées au format hexadécimal.

De plus, le fait que la différence entre les deux adresses vaille 4 indique que ces deux entiers sont placés l'un à côté de l'autre en mémoire (la valeur de C en hexadécimal est 12, et 12−8=4).

48-2 - Variables référence

Avant d'arriver aux types référence, commençons par définir les variables par référence.

Terminologie : jusqu'à maintenant, nous avons utilisé l'expression « donner accès à » dans divers contextes à travers le livre. Par exemple, les tranches et les tableaux associatifs ne stockent pas eux-mêmes d'éléments, mais donnent accès à des éléments qui sont stockés par l'environnement d'exécution (runtime) du D. Une autre expression qui veut dire la même chose est « être une référence de », comme dans « les tranches sont des références de zéro, un ou plusieurs éléments », qui est même raccourci en « cette tranche référence deux éléments ». Finalement, accéder à une valeur à travers une référence est le déréférencement.

Les variables référence agissent comme des alias d'autres variables. Même si elles ressemblent à et sont utilisées comme des variables, elles n'ont pas de valeur propre.

Nous avons déjà utilisé des variables référence dans deux contextes :

- ref dans les boucles foreach : quand le mot-clé ref est utilisé dans les boucles foreach, la variable de boucle est l'élément correspondant à l'itération lui-même. Sans le mot-clé ref, la variable de boucle est une copie de cet élément.
Ceci peut être démontré avec l'opérateur &. Si leurs adresses sont les mêmes, deux variables référencent la même valeur (ou le même élément) :

int[] tranche = [ 0, 1, 2, 3, 4 ];

foreach (i, ref element; tranche) {
assert(&element == &tranche[i]);
}

- Même si ce sont des variables différentes, le fait que les adresses de element et tranche[i] soient les mêmes montre qu'elles sont identiques en valeur. En d'autres termes, element et tranche[i] sont des références de la même valeur. Modifier l'une ou l'autre affecte la valeur. Voici une représentation de la mémoire lors de l'itération pour laquelle i vaut 3 :

- Paramètres de fonction ref et out : les paramètres ref et out sont des alias de la variable avec laquelle la fonction a été appelée.
L'exemple suivant montre ceci en passant la même variable à une fonction, avec deux paramètres ref et out distincts. Encore une fois, l'opérateur & permet de voir que les deux paramètres pointent vers la même valeur :

import std.stdio;

void main()
{
int variableOriginale;
writeln("adresse de variableOriginale : ", &variableOriginale);
foo(variableOriginale, variableOriginale);
}

void foo(ref int parametreRef, out int parametreOut)
{
writeln("adresse de parametreRef      : ", &parametreRef);
writeln("adresse de parametreOut      : ", &parametreOut);
assert(&parametreRef == &parametreOut);
}

- Même si elles sont définies comme des paramètres différents, les variables parametreRef et parametreOut sont des alias de variableOriginale :

adresse de variableOriginale : 7FFF24172958
adresse de parametreRef      : 7FFF24172958
adresse de parametreOut      : 7FFF24172958

48-3 - Types référence

Les variables des types référence ont des identités propres, mais n'ont pas de valeurs propres. Elles donnent accès à des variables existantes.

Nous avons vu cette idée avec les tranches. Les tranches ne stockent pas leurs éléments, elles donnent accès à des éléments existants :

void main()
{
// Même si elle est nommée 'tableau' ici, cette variable est
// aussi une tranche. Elle donne accès à tous les éléments
// initiaux :
int[] tableau = [ 0, 1, 2, 3, 4 ];

// Une tranche qui donne accès aux éléments, sans le premier ni
// le dernier :
int[] tranche = tableau[1 .. $ - 1];

// À cet endroit, tranche[0] et tableau[1] donnent accès à la même
// valeur :
assert(&tranche[0] == &tableau[1]);

// Changer tranche[0] modifie également tableau[1] :
tranche[0] = 42;
assert(tableau[1] == 42);
}

Contrairement aux variables par référence, les types référence ne sont pas simplement des alias. Pour voir cette distinction, définissons une autre tranche comme copie de la tranche existante :

int[] tranche2 = tranche;

Ces deux tranches ont chacune leur propre adresse. Autrement dit, elles ont leur propre identité :

assert(&tranche != &tranche2);

La liste suivante est le résumé des différences entre les variables par référence et les types référence :

- les variables par référence n'ont pas d'identité, elles sont des alias de variables existantes ;

- les variables de types référence ont une identité, mais n'ont pas de valeur propre ; elles donnent accès à des valeurs existantes.

La manière dont tranche et tranche2 vivent en mémoire peut être illustrée comme suit :

Une des différences entre le C++ et le D est que les classes sont des types référence en D. Même si nous verrons les classes dans des chapitres ultérieurs, ce qui suit est un petit exemple qui démontre ce fait :

class MaClasse
{
int membre;
}

Les objets de type classe sont construits avec le mot-clé new :

auto variable = new MaClasse;

variable est une référence d'un objet MaClasse anonyme qui a été construit avec new :

Tout comme avec les tranches, quand variable est copiée, la copie devient une autre référence du même objet. La copie a sa propre adresse :

auto variable = new MaClasse;
auto variable2 = variable;
assert(variable == variable2);
assert(&variable != &variable2);

Elles sont égales dans le sens où elles référencent le même objet, mais elles sont des variables distinctes :

Cela peut aussi être vu en modifiant le membre de l'objet :

auto variable = new MaClasse;
variable.membre = 1;

auto variable2 = variable;    // Elles partagent le même objet
variable2.membre = 2;

assert(variable.membre == 2); // L'objet que les deux variables référencent a changé.

Un autre type référence est le tableau associatif. Comme pour les tranches et les classes, quand une variable de type tableau associatif est copiée dans une autre variable, les deux donnent accès au même ensemble d'éléments :

string[int] parNom =
[
1   : "un",
10  : "dix",
100 : "cent",
];

// Les deux tableaux associatifs vont partager le même ensemble d'éléments :
string[int] parNom2 = parNom;

// La nouvelle association ajoutée via le second tableau...
parNom2[4] = "quatre";

// ...se retrouve dans le premier.
assert(parNom[4] == "quatre");

48-3-1 - La différence dans l'opération d'affectation

L'opération d'affectation est différente pour les types valeur et les types référence ; avec les types valeur et les variables référence, l'affectation change la vraie valeur :

void main()
{
int nombre = 8;

diviserParDeux(nombre); // La valeur change
assert(nombre == 4);
}

void diviserParDeux(ref int dividende)
{
dividende /= 2;
}

D'un autre côté, avec les types référence, l'opération d'affectation change l'accès : la valeur pointée par la variable affectée ne change pas, mais la variable affectée pointe vers une autre valeur. Par exemple, l'affectation de la variable tranche3 dans le code suivant ne change la valeur d'aucun élément ; elle change les éléments que tranche3 référence :

int[] tranche1 = [ 10, 11, 12, 13, 14 ];
int[] tranche2 = [ 20, 21, 22 ];

int[] tranche3 = tranche1[1 .. 3]; // Accès aux éléments de tranche1
// avec les indices 1 et 2

tranche3[0] = 777;
assert(tranche1 == [ 10, 777, 12, 13, 14 ]);

// Cette affectation ne modifie pas les éléments que tranche3 référence,
// elle fait référencer d'autres éléments par tranche3.
tranche3 = tranche2[$ - 1 .. $]; // Accès au dernier élément.

tranche3[0] = 888;
assert(tranche2 == [ 20, 21, 888 ]);

Montrons le même effet avec, cette fois, deux objets de type MaClasse :

auto variable1 = new MaClasse;
variable1.membre = 1;

auto variable2 = new MaClasse;
variable2.membre = 2;

auto uneCopie = variable1;
uneCopie.membre = 3;

uneCopie = variable2;
uneCopie.membre = 4;

assert(variable1.membre == 3);
assert(variable2.membre == 4);

La variable uneCopie référence d'abord le même objet que variable1, puis le même objet que variable2. Par conséquent, le .membre qui est modifié à travers uneCopie est d'abord celui de variable1 puis celui de variable2.

48-3-2 - Les variables de types référence peuvent ne pas référencer d'objet

Une variable référence est toujours l'alias d'une autre variable, elle ne peut pas commencer sa vie sans variable. En revanche, les variables de types référence peuvent commencer leur vie sans référencer aucun objet.

Par exemple, une variable MaClasse peut être définie sans avoir créé d'objet avec new :

MaClasse variable;

De telles variables ont la valeur spéciale null. Nous verrons null et le mot-clé is dans un chapitre ultérieur.

48-4 - Les tableaux à taille fixe sont des types valeur, les tranches sont des types référence

Les tableaux et les tranches du D divergent lorsqu'on considère la différence entre type valeur et type référence.

Nous l'avons déjà vu, les tranches sont des types référence. Par contre, les tableaux à taille fixe sont des types valeur. Ils stockent eux-mêmes leurs éléments et se comportent comme des valeurs individuelles :

int[3] tableau1 = [ 10, 20, 30 ];

auto tableau2 = tableau1; // Les éléments de tableau2 sont différents
// des éléments de tableau1
tableau2[0] = 11;

// Le premier tableau n'est pas affecté :
assert(tableau1[0] == 10);

tableau1 est un tableau à taille fixe parce que sa taille est indiquée lors de sa définition. Comme auto infère le type de tableau2, tableau2 est également un tableau à taille fixe. Les valeurs des éléments de tableau2 sont copiées depuis les valeurs des éléments de tableau1. Chaque tableau a ses propres éléments. Modifier un élément d'un tableau n'affecte pas l'autre tableau.

48-5 - Expérimentation

Le programme suivant applique l'opérateur == à des types différents. Il applique l'opérateur à deux variables d'un certain type et aux adresses de ces variables. Le programme produit la sortie suivante :

Sortie :

Cette table a été générée par le programme suivant :

import std.stdio;
import std.conv;
import std.tableau;

int variableModule = 9;

class MaClasse
{
int membre;
}

void afficherEntete()
{
immutable dchar[] entete =
"                            Type de variable"
"                      a == b  &a == &b";

writeln();
writeln(entete);
writeln(replicate("=", entete.length));
}

void afficherInfo(const dchar[] etiquette,
bool egaliteValeur,
bool egaliteAdresse)
{
writefln("%55s%9s%9s",
etiquette,
to!string(egaliteValeur),
to!string(egaliteAdresse));
}

void main()
{
afficherEntete();

int nombre1 = 12;
int nombre2 = 12;
afficherInfo("variables avec des valeurs égales (type valeur)",
nombre1 == nombre2,
&nombre1 == &nombre2);

int nombre3 = 3;
afficherInfo("variables avec des valeurs differentes (type valeur)",
nombre1 == nombre3,
&nombre1 == &nombre3);

int[] tranche = [ 4 ];
foreach (i, ref element; tranche) {
afficherInfo("foreach avec variable 'ref'",
element == tranche[i],
&element == &tranche[i]);
}

foreach (i, element; tranche) {
afficherInfo("foreach sans variable 'ref'",
element == tranche[i],
&element == &tranche[i]);
}

parametreOut(variableModule);
parametreRef(variableModule);
parametreIn(variableModule);

int[] longueTranche = [ 5, 6, 7 ];
int[] tranche1 = longueTranche;
int[] tranche2 = tranche1;
afficherInfo("tranches donnant accès aux mêmes éléments",
tranche1 == tranche2,
&tranche1 == &tranche2);

int[] tranche3 = tranche1[0 .. $ - 1];
afficherInfo("tranches donnant accès à des éléments différents",
tranche1 == tranche3,
&tranche1 == &tranche3);

auto variable1 = new MaClasse;
auto variable2 = variable1;
afficherInfo(
"variables MaClasse vers le même objet (type référence)",
variable1 == variable1,
&variable1 == &variable2);

auto variable3 = new MaClasse;
afficherInfo(
"variables MaClasse vers des objets différents (type référence)",
variable1 == variable3,
&variable1 == &variable3);
}

void parametreOut(out int parametre)
{
afficherInfo("fonction avec paramètre 'out'",
parametre == variableModule,
&parametre == &variableModule);
}

void parametreRef(ref int parametre)
{
afficherInfo("fonction avec paramètre 'ref'",
parametre == variableModule,
&parametre == &variableModule);
}

void parametreIn(in int parametre)
{
afficherInfo("fonction avec paramètre 'in'",
parametre == variableModule,
&parametre == &variableModule);
}

Notes

- Le programme utilise une variable module pour comparer différents types de paramètres de fonctions. Les variables module sont définies au niveau des modules, hors de toute fonction. Elles sont accessibles globalement à tout le code du module.

- La fonction replicate du module std.array prend un tableau (la chaîne "=" dans le code précédent) et le répète le nombre de fois donné.

48-6 - Résumé

Les variables de types valeur ont leurs propres valeurs et adresses.

- Les références n'ont ni propre valeur ni adresse. Elles sont des alias de variables existantes.

- Les variables de types référence ont leurs propres adresses, mais les valeurs qu'elles référencent ne leur appartiennent pas.

- Avec les types référence, l'affectation ne change pas la valeur, mais quelle valeur est pointée.

- Les variables de types référence peuvent être nulles (null).

49 - La valeur null et l'Opérateur is

Dans le chapitre précédent, nous avons vu qu'une variable d'un type référence n'a pas besoin de référencer un objet :

MaClasse referenceUnObjet = new MaClasse;
MaClasse variable;   // ne référence pas un objet

Étant d'un type par référence, variable a, dans le code précédent, une identité, mais ne référence pas encore d'objet. Un tel objet peut être représenté comme ceci dans sa case mémoire :

Une référence qui ne référence aucune valeur est null. Nous développerons ce point.

Une telle variable est dans un état quasiment inutile. Comme elle ne référence pas d'objet, elle ne peut pas être utilisée dans un contexte où un objet MaClasse est requis :

import std.stdio;

class MaClasse
{
int membre;
}

void utiliser(MaClasse variable)
{
writeln(variable.membre);  // ← BOGUE
}

void main()
{
MaClasse variable;
utiliser(variable);
}

Il n'y a pas d'objet référencé par le paramètre que la fonction utiliser reçoit. Essayer d'accéder à un membre d'un objet non existant fait planter le programme :

$ ./essai
$ Erreur de segmentation

« Erreur de segmentation » est une indication que le programme a été tué par le système d'exploitation parce qu'il a essayé d'accéder à une adresse mémoire illégale.

49-1 - La valeur null

La valeur spéciale null peut être affichée comme n'importe quelle autre valeur.

writeln(null); // affiche 'null' dans la sortie

Comme dans beaucoup d'autres langages, la représentation entière de null est 0.

Une variable null peut être utilisée dans deux contextes :

1. En lui affectant un objet

variable = new MaClasse;  // référence maintenant un objet

2. Cette affectation fait pointer variable vers l'objet nouvellement créé. À partir de là, variable peut être utilisée pour n'importe quelle opération valide du type MaClasse ;

3. Pour déterminer si elle est null.
Cependant, comme l'opérateur == a besoin de deux objets pour faire la comparaison, l'expression qui suit ne peut pas être compilée :

if (variable == null)     // ERREUR de compilation

4. Pour cette raison, pour savoir si une variable est null, on utilise l'opérateur is.

49-2 - L'opérateur is

Cet opérateur répond à la question « a la valeur nulle ? » (NDT is veut dire « est » en anglais) :

if (variable is null) {
// Ne référence aucun objet
}

L'opérateur is peut aussi être utilisé avec d'autres types de variable. Dans l'utilisation suivante, il compare les valeurs de deux entiers :

if (vitesse is nouvelleVitesse) {
// Leurs valeurs sont égales

} else {
// Leurs valeurs sont différentes
}

Lorsqu'on l'utilise avec les tranches, il détermine si deux tranches référencent le même ensemble d'éléments :

if (tranche is tranche2) {
// Elles donnent accès aux mêmes éléments
}

49-3 - L'opérateur !is

!is est l'opposé de is. Il donne true quand les valeurs sont différentes :

if (vitesse !is nouvelleVitesse) {
// Leurs valeurs sont différentes
}

49-4 - Affecter la valeur null

Affecter la valeur null à une variable de type référence fait que la variable arrête de référencer son objet actuel.

S'il se trouve que cette affectation supprime la dernière référence à cet objet, alors l'objet devient un candidat à la finalisation par le ramasse-miettes. En effet, pour un objet, n'être référencé par aucune variable veut dire qu'il n'est pas utilisé du tout dans le programme.

Regardons l'exemple d'un chapitre précédent où deux variables référençaient le même objet :

auto variable = new MaClasse;
auto variable2 = variable;

Ce qui suit est une représentation de l'état de la mémoire après l'exécution de ce code :

Affecter la valeur null à l'une des deux variables casse sa relation avec l'objet :

variable = null;

À partir de ce point, il n'y a plus que variable2 qui référence l'objet :

Affecter null à la dernière référence rendrait l'objet inatteignable :

variable2 = null;

Un tel objet inatteignable sera finalisé par le ramasse-miettes à un certain moment dans le futur. Du point de vue du programme, l'objet n'existe pas :

Nous avons discuté des moyens de vider un tableau associatif dans les exercices du chapitre sur les tableaux associatifs. Nous connaissons maintenant une quatrième méthode : affecter null à un tableau associatif casse la relation entre cette variable et les éléments :

string[int] noms;
// ...
noms = null;     // Ne donne accès à aucun élément

De manière similaire aux exemples avec MaClasse, si noms était la dernière référence aux éléments du tableau associatif, ces éléments seront finalisés par le ramasse-miettes.

Les tranches peuvent également être affectées à null :

int[] tranche = autreTranche[ 10 .. 20 ];
// ...
tranche = null;     // Ne donne accès à aucun élément

49-5 - Résumé

- null est la valeur indiquant qu'une variable ne donne accès à aucun élément.

- Les références qui ont la valeur null ne peuvent être utilisées que dans deux opérations : leur affecter une valeur et déterminer si elles sont null ou pas.

- Comme l'opérateur == peut nécessiter l'accès à un objet, déterminer si une variable est null doit être fait avec l'opérateur is.

- !is est l'opposé de is.

- Affecter null à une variable fait que cette variable ne pointe plus vers aucun objet.

- Les objets qui ne sont référencés par aucune variable sont finalisés par le ramasse-miettes.

50 - Conversions de Types, cast

Les variables doivent être compatibles avec les expressions dans lesquelles elles apparaissent. Comme vous l'avez peut-être déjà remarqué dans les programmes que nous avons vus jusqu'à maintenant, D est un langage statiquement typé, ce qui veut dire que la compatibilité des types est vérifiée lors de la compilation.

Toutes les expressions que nous avons écrites jusqu'à maintenant avaient des types compatibles parce que dans le cas contraire le code aurait été rejeté par le compilateur. Ce qui suit est un exemple de code qui a des types incompatibles :

char[] tranche;
writeln(tranche + 5);    // ← ERREUR de compilation

Le compilateur rejette le code à cause de l'incompatibilité des types char[] et int pour l'opération d'addition :

Error: incompatible types for ((tranche) + (5)): 'char[]' and 'int'

Des types différents ne sont pas nécessairement incompatibles ; des types différents peuvent dans certains cas être utilisés dans les expressions en toute sécurité. Par exemple, une variable int peut être utilisée à la place d'une valeur double sans problème :

double somme = 1.25;
int increment = 3;
somme += increment;

Même si somme et increment sont de types différents, ce code est correct parce qu'incrémenter une variable double avec une valeur int est légal.

50-1 - Conversion automatique des types

Les conversions automatiques de types sont aussi appelées conversions implicites de types.

Même si double et int sont des types compatibles dans l'expression précédente, l'opération d'addition doit toujours être évaluée comme un type spécifique au niveau du microprocesseur. Comme nous l'avons vu dans le chapitre sur les virgules flottantes, le type 64 bits double est plus large que le type 32 bits int. De plus, toute valeur qui peut être représentée dans une variable int peut être représentée dans une variable double.

Quand le compilateur rencontre une expression qui implique des types incompatibles, il convertit d'abord les parties de l'expression vers un type commun et évalue ensuite l'expression entière. Les conversions automatiques qui sont effectuées par le compilateur se font dans une direction qui évite toute perte de données. Par exemple, double peut contenir toute valeur que int peut contenir mais l'inverse n'est pas vrai. Dans l'exemple précédent, l'opération += peut fonctionner parce que toute valeur int peut être convertie vers double en toute sécurité.

La valeur résultant d'une conversion automatique est toujours une variable anonyme et elle est souvent temporaire. La valeur originale ne change pas. Par exemple, dans notre cas la conversion automatique lors de l'opération += ne change pas le type de increment ; celui-ci reste un int. Par contre, une valeur temporaire de type double est construite à partir de la valeur d'increment. La conversion qui se déroule en arrière-plan est équivalente au code suivant :

{
double une_valeur_double_anonyme = increment;
somme += une_valeur_double_anonyme;
}

Le compilateur convertit la valeur int vers une valeur temporaire double et utilise cette valeur dans l'opération. Dans cet exemple, la variable temporaire vit uniquement pendant l'opération +=.

Les conversions automatiques ne sont pas limitées aux opérations arithmétiques. Il y a d'autres cas où les types sont convertis vers d'autres types automatiquement. Tant que les conversions de types sont correctes, le compilateur en profite pour utiliser les valeurs dans les expressions. Par exemple, une valeur byte peut être passée en argument pour un paramètre int :

void fonc(int nombre)
{
// ...
}

void main()
{
byte petiteValeur = 7;
fonc(petiteValeur);    // conversion automatique
}

Dans ce code, une valeur int temporaire est d'abord construite et ensuite la fonction est appelée avec cette valeur.

50-1-1 - Promotions entières

Les valeurs des types qui sont dans la colonne de gauche du tableau suivant ne font jamais partie d'expressions arithmétiques en tant que telles. Chaque type est d'abord promu vers le type de la colonne à droite du tableau.

De

Vers

bool

int

byte

int

ubyte

int

short

int

ushort

int

char

int

wchar

Int

dchar

uint

Les promotions entières sont également appliquées aux valeurs enum.

Les raisons d'être des promotions entières sont aussi historiques (où les règles viennent du C) que techniques, le type arithmétique naturel du processeur étant int. Par exemple, même si les deux variables suivantes sont ubyte, l'opération d'addition n'est effectuée qu'après que les deux valeurs ont été promues en int :

ubyte a = 1;
ubyte b = 2;
writeln(typeof(a + b).stringof);  // l'addition ne se fait pas en ubyte

La sortie :

int

Notez que les types respectifs des variables a et b ne changent pas ; seules leurs valeurs sont temporairement promues en int pour la durée de l'opération d'addition.

50-1-2 - Conversions arithmétiques

Il y a d'autres règles de conversions qui sont appliquées pour les opérations arithmétiques. En général, les conversions arithmétiques automatiques sont appliquées dans la direction sûre : du type le plus étroit au type le plus large. Même si cette règle est facile à retenir et est correcte dans la plupart des cas, les règles de conversions automatiques sont très compliquées et, dans les cas de conversion signé vers non signé, sont de potentielles causes de bogues.

Les règles de conversions arithmétiques sont les suivantes :

1. Si l'une des valeurs est real, alors l'autre valeur est convertie en real ;

2. Sinon, si l'une des valeurs est double, alors l'autre valeur est convertie en double ;

3. Sinon, si l'une des valeurs est float, alors l'autre valeur est convertie en float ;

4. Sinon, les promotions entières sont appliquées selon le tableau de la section précédente, et les règles suivantes sont ensuite appliquées :

1. Si les deux types sont les mêmes, alors aucune autre étape n'est nécessaire,

2. Si le type signé est plus large que le type non signé, alors la valeur non signée est convertie vers le type signé,

3. Sinon le type signé est converti vers le type non signé.

Malheureusement, cette dernière règle peut être à l'origine de bogues subtils :

int    a = 0;
int    b = 1;
size_t c = 0;
writeln(a - b + c);  // Résultat surprenant !

Étonnement, le résultat n'est pas -1 mais size_t.max :

0

Même si l'on s'attendrait à ce que 0 - 1 + 0 donne -1, selon les règles ci-avant, le type de l'expression entière est size_t et non int ; et comme size_t ne peut pas représenter des valeurs négatives, le résultat soupasse et devient size_t.max.

50-1-3 - Conversions const

Comme nous l'avons vu précédemment dans le chapitre sur les paramètres de fonctions. les types référence peuvent être automatiquement convertis vers le const du même type. Les conversions vers const sont sûres parce que la largeur du type ne change pas et const est une promesse de ne pas modifier la variable :

dchar[] entre_accolades(const dchar[] texte)
{
return "{" ~ texte ~ "}";
}

void main()
{
dchar[] bienvenue;
bienvenue ~= "bonjour le monde";
entre_accolades(bienvenue);
}

La variable mutable bienvenue est automatiquement convertie vers un const dchar[] lorsqu'elle est passée à la fonction entre_accolades.

Comme nous l'avons aussi vu précédemment, la conversion inverse n'est pas automatique. Une référence const n'est pas automatiquement convertie vers une référence mutable :

dchar[] entre_accolades(const dchar[] texte)
{
dchar[] argument = texte;  // ERREUR de compilation
// ...
}

Notez que cette partie concerne uniquement les références ; comme les variables de types valeur sont copiées, il n'est de toute façon pas possible d'assigner l'original à travers la copie :

const int nombreDeCoins = 4;
int laCopie = nombreDeCoins;      // compile (type valeur)

Cette conversion de const vers mutable est légale parce que la copie n'est pas une référence vers l'original.

50-1-4 - Conversions immutables

immutable indiquant qu'une variable ne peut jamais changer, aucune conversion depuis ou vers immutable n'est automatique :

string a = "salut";    // caractères immuables
char[] b = a;          // Erreur de compilation
string c = b;          // Erreur de compilation

Comme pour la section précédente (conversions de const), cette partie ne concerne que les types référence. Comme les variables de types valeur sont de toute façon copiées, les conversions depuis et vers immutable sont valides :

immutable a = 10;
int b = a;           // compile (type valeur)

50-1-5 - Conversions enum

Comme nous l'avons vu dans le chapitre sur les énumérations enum définit des constantes nommées :

enum Couleur { pique, coeur, carreau, trefle }

Souvenez-vous que comme aucune valeur n'est spécifiée explicitement, les valeurs des membres de l'énumération commencent par zéro et sont automatiquement incrémentées de 1 en 1. Ainsi, la valeur de Couleur.trefle est 3.

Les valeurs enum sont automatiquement converties vers des types entiers. Par exemple, la valeur de Couleur.coeur est comprise comme 1 dans le calcul suivant et le résultat est 11 :

int resultat = 10 + Couleur.coeur;
assert(resultat == 11);

La conversion inverse n'est pas automatique : les valeurs entières ne sont pas automatiquement converties vers les valeurs enum correspondantes. Par exemple, on pourrait s'attendre que la variable couleur ci-dessous prenne pour valeur Couleur.carreau, mais le code ne peut pas être compilé :

Couleur couleur = 2;    // ERREUR de compilation

Comme nous le verrons juste après, les conversions depuis les entiers vers les valeurs enum sont néanmoins possibles, mais elles doivent être explicites.

50-1-6 - Conversions booléennes

false et true sont automatiquement convertis vers 0 et 1, respectivement :

int a = false;
assert(a == 0);

int b = true;
assert(b == 1);

La conversion inverse est également automatique, mais seulement pour deux valeurs spéciales. Les littéraux 0 et 1 sont convertis automatiquement vers false et true, respectivement :

bool a = 0;
assert(!a);     // false

bool b = 1;
assert(b);      // true

Les autres valeurs littérales ne peuvent pas être converties vers bool automatiquement :

bool b = 2;    // ERREUR de compilation

50-1-7 - Conversions automatiques vers bool dans les instructions conditionnelles

Certaines instructions utilisent des expressions logiques : if, while, etc. Pour les expressions logiques de telles instructions, en plus de bool, la plupart des autres types peuvent également être utilisés. La valeur zéro est automatiquement convertie vers false et les valeurs non nulles sont automatiquement converties vers true.

int i;
// ...

if (i) {    // ← valeur int utilisée comme une expression logique
// ... 'i' ne vaut pas zéro

} else {
// ... 'i' vaut zero
}

De manière similaire, les références nulles (null) sont automatiquement converties vers false et les références non nulles sont automatiquement converties vers true. Cela facilite la vérification qu'une référence n'est pas nulle avant de s'en servir :

int[] a;
// ...

if (a) {    // ← conversion booléenne automatique
// ... non null; 'a' peut être utilisé...

} else {
// ... null; 'a' ne peut pas être utilisé...
}

50-1-8 - Conversions de types explicites

Comme nous venons de le voir, il y a des cas où les conversions automatiques ne sont pas possibles :

- les conversions depuis des types plus larges vers des types plus restreints ;

- les conversions depuis const vers mutable ;

- les conversions immutables ;

- les conversions depuis les entiers vers les valeurs enum.

S'il sait qu'une telle conversion est sûre, le programmeur peut explicitement demander une conversion de types avec l'une des trois méthodes suivantes :

- en appelant la fonction std.conv.to ;

- en appelant la fonction std.exception.assumeUnique ;

- en utilisant l'opérateur cast.

50-1-8-1 - La fonction to pour la plupart des conversions

La fonction to, que nous avons déjà utilisée surtout pour convertir des valeurs vers string, peut en fait être utilisée pour beaucoup d'autres types. Sa syntaxe complète est la suivante :

to!(TypeDestination)(valeur)

Puisque c'est un modèle, on peut utiliser la fonction to avec la notation raccourcie des paramètres de modèles : quand le type de destination ne consiste qu'en un seul token (en général, qu'un seul mot), elle peut être appelée sans la première paire de parenthèses :

to!TypeDestination(valeur)

Le programme suivant essaie de convertir une valeur double vers short et une chaîne vers int :

void main()
{
double d = -1.75;

short s = d;     // ERREUR de compilation
int i = "42";    // ERREUR de compilation
}

Comme toutes les valeurs double ne peuvent pas être représentées par short et comme toute chaîne ne peut pas être représentée comme un entier, ces conversions ne sont pas automatiques. Quand le programmeur sait que les conversions sont en fait sûres ou que les conséquences d'une mauvaise conversion sont acceptables, les types peuvent être convertis par to() :

import std.conv;

void main()
{
double d = -1.75;

short s = to!short(d);
assert(s == -1);

int i = to!int("42");
assert(i == 42);
}

Notez que puisque short ne peut pas représenter les valeurs fractionnaires, la valeur convertie est -1.

La fonction to est sûre : elle lève une exception quand une conversion n'est pas possible.

50-1-8-2 - assumeUnique() pour des conversions immutables rapides

La fonction to peut également effectuer des conversions immutables :

int[] tranche = [ 10, 20, 30 ];
auto trancheImmuable = to!(immutable int[])(tranche);

Pour garantir que les éléments de trancheImmuable ne changeront jamais, elle ne peut pas partager ses éléments avec tranche. Pour cette raison, la fonction to crée une tranche nouvelle avec des éléments immutables. Sinon, les modifications des éléments de tranche entraîneraient également la modification des éléments de trancheImmuable. Ce comportement est le même avec la propriété .idup des tableaux.

Nous pouvons voir que les éléments de trancheImmuable sont en fait des copies des éléments de tranche en regardant l'adresse de leurs premiers éléments :

assert(&(tranche[0]) != &(trancheImmuable[0]));

Parfois, cette copie n'est pas nécessaire et peut ralentir le programme de façon négligeable dans certains cas. Par exemple, considérons la fonction suivante qui prend une tranche immutable :

void calculer(immutable int[] coordonnees)
{
// ...
}

void main()
{
int[] nombres;
nombres ~= 10;
// ... autres diverses modifications ...
nombres[0] = 42;

calculer(nombres);    // ERREUR de compilation
}

Ce programme ne peut pas être compilé parce que l'appelant ne passe pas un argument immutable à la fonction calculer. Comme nous l'avons vu auparavant, une tranche immutable peut être créée par la fonction to :

import std.conv;
// ...
auto nombresImmuables = to!(immutable int[])(nombres);
calculer(nombresImmuables);    // ← maintenant, compile

Cependant, si on n'a besoin de nombres que pour produire cet argument et qu'elle ne sera jamais utilisée après l'appel de la fonction, copier ses éléments vers nombresImmuables n'est pas nécessaire. La fonction assumeUnique rend les éléments d'une tranche immuables sans les copier :

import std.exception;
// ...
auto nombresImmuables = assumeUnique(nombres);
calculer(nombresImmuables);
assert(nombres is null);    // La tranche originale devient nulle

assumeUnique retourne une nouvelle tranche qui fournit un accès immuable aux éléments existants. Elle rend aussi la tranche originale null pour empêcher les éléments d'être accidentellement modifiés.

50-1-8-3 - L'opérateur cast

Les fonctions to() et assumeUnique() utilisent toutes deux l'opérateur de conversion cast, qui est également disponible pour le programmeur.

L'opérateur cast prend le type de destination entre parenthèses :

cast(TypeDestination)valeur

cast est utile pour les conversions que la fonction to() ne peut pas effectuer de façon sûre. to() ne réalise pas les conversions suivantes :

Couleur couleur = to!Couleur(2);    // ERREUR de compilation
bool b = to!bool(2);       //ERREUR de compilation

Parfois, seul le programmeur peut savoir si une valeur entière correspond à une valeur enum valide ou s'il fait sens de traiter une valeur entière comme un booléen. L'opérateur cast peut être utilisé quand on sait que la conversion est correcte selon la logique du programme :

Couleur couleur = cast(Couleur)2;
assert(couleur == Couleur.carreau);

bool b = cast(bool)2;
assert(b);

Bien que cela arrive rarement, certaines interfaces de bibliothèques C demandent de stocker une valeur de pointeur dans un type non-pointeur. S'il est garanti que la conversion préserve la bonne valeur, cast peut aussi effectuer la conversion entre des types pointeur et non-pointeur :

size_t valeurPointeurSauvée = cast(size_t) p;
// ...
int * p2 = cast(int*)valeurPointeurSauvée;

50-1-9 - Résumé

- Les conversions de types automatiques sont principalement celles qui se font dans le sens sûr : du type le plus restreint au type le plus large et de mutable vers const.

- Cependant, les conversions vers les types non signés peuvent avoir des effets surprenants parce que les types non signés ne peuvent pas avoir de valeur négative.

- Les types enum peuvent automatiquement être convertis vers des valeurs entières, mais la conversion inverse n'est pas automatique.

- false et true sont automatiquement convertis vers 0 et 1, respectivement. De manière similaire, les valeurs nulles (dans le sens « zéro ») sont automatiquement converties vers false et les valeurs non nulles sont automatiquement converties vers true.

- Les références nulles sont automatiquement converties vers false et les références non nulles sont automatiquement converties vers true.

- La fonction to couvre la plupart des conversions explicites.

- La fonction assumeUnique convertit vers immutable sans copier.

- L'opérateur cast est l'outil de conversion le plus puissant.

51 - Structures

Comme nous l'avons vu plusieurs fois dans les précédents chapitres de ce livre, les types fondamentaux ne sont pas suffisants pour représenter des concepts de plus haut niveau. Par exemple, même si une valeur de type int peut être utilisée pour représenter une heure de la journée, deux variables int seraient ensemble plus appropriées pour représenter un point dans le temps : une pour l'heure, une pour la minute.

Les structures sont la fonctionnalité qui permet de définir de nouveaux types en combinant d'autres types déjà existants. Le nouveau type est défini par le mot-clé struct. Le gros de ce chapitre est également directement applicable aux classes ; en particulier, l'idée de combiner les types existant pour définir un nouveau type est exactement la même pour celles-ci.

Pour comprendre l'utilité des structures, considérons la fonction ajouterDuree que nous avons définie plus tôt dans le chapitre sur les assertions et enforce. La définition suivante est tirée des solutions de l'exercice de ce chapitre :

void ajouterDuree(in int heuresDepart, in int minutesDepart,
in int dureeHeures, in int dureeMinutes,
out int heuresResultat, out int minutesResultat)
{
heuresResultat = heuresDepart + dureeHeures;
minutesResultat = minutesDepart + dureeMinutes;
heuresResultat += minutesResultat / 60;

minutesResultat %= 60;
heuresResultat %= 24;
}

Je vais ignorer les blocs in, out et unittest dans ce chapitre pour garder les codes exemples courts.

Même s'il est clair que cette fonction prend six paramètres, quand on regarde les trois paires de paramètres de plus près, elle ne prend que trois informations : le moment de départ, la durée et le résultat.

51-1 - Définition

Le mot-clé struct définit un nouveau type en combinant des variables qui ont un certain rapport :

struct MomentDeLaJournee
{
int heure;
int minute;
}

Ce code définit un nouveau type nommé MomentDeLaJournee, qui consiste en deux variables nommées heure et minute. Cette définition permet d'utiliser le nouveau type MomentDeLaJournee dans le programme comme n'importe quel autre type. Le code suivant démontre comment son utilisation est similaire à celle d'un int :

int nombre;               // une variable
nombre = autreNombre;     // prenant la valeur de autreNombre

MomentDeLaJournee moment; // un objet
moment = autreMoment;     // prenant la valeur de autreMoment

La syntaxe de la définition struct est la suivante :

struct NomType
{
// ... fonctions et variables membres
}

Nous verrons les fonctions membres dans des chapitres ultérieurs.

Les variables qu'une structure combine sont appelées ses membres. Selon cette définition, MomentDeLaJournee a deux membres : heure et minute.

51-1-1 - struct définit un type, pas une variable

Il y a une distinction importante ici : surtout après les chapitres sur les espaces de noms et sur les Durées de Vie et Opérations Fondamentales, les accolades des définitions de structures peuvent donner la fausse impression que les membres des structures commencent et finissent leurs vies à l'intérieur de ce bloc. Ce n'est pas le cas.

Les définitions de membres ne sont pas des définitions de variables :

struct MomentDeLaJournee
{
int heure;     // ← pas une définition de variable !
int minute;    // ← pas une définition de variable !
}

La définition d'une structure détermine les types et les noms des membres que les objets de cette structure auront :

struct MomentDeLaJournee
{
int heure;     // ← fera partie d'une variable
int minute;    // ← fera partie d'une variable
}

Ces variables membres seront construites comme parties des objets MomentDeLaJournee qui seront utilisés dans le programme :

MomentDeLaJournee sieste; // Cet objet contient ses propres
// variables membres heure et minute.

MomentDeLaJournee reveil; // Cet objet contient également ses propres
// variables membres heure et minute.
// Les variables membres de cet objet
// n'ont pas de rapport avec les variables
// membres de l'objet précédent.

51-1-2 - Confort de programmation

La possibilité de combiner les idées d'heure et de minute ensemble dans un nouveau type est très pratique. Par exemple, la fonction précédente peut être réécrite de façon plus parlante en prenant trois paramètres de type MomentDeLaJournee au lieu des six paramètres existants :

void ajouterDuree(in MomentDeLaJournee depart,
in MomentDeLaJournee duree,
out MomentDeLaJournee resultat)
{
// ...
}

Il n'est pas normal d'ajouter deux variables qui représentent deux points du temps. Par exemple, il n'y a pas de sens à ajouter le moment du déjeuner (12h00) au moment du petit-déjeuner (7h30). Il serait plus sensé de définir un autre type, convenablement appelé Duree, et d'ajouter les objets de ce type aux objets MomentDeLaJournee. Malgré cette erreur de conception, on continuera d'utiliser seulement MomentDeLaJournee dans ce chapitre et on introduira Duree dans un chapitre ultérieur.

Comme nous l'avons vu, les fonctions retournent au plus une valeur. Ceci était précisément la raison pour laquelle la fonction ajouterDuree prenait deux paramètres résultats (out) : elle ne pouvait pas retourner à la fois l'heure et la minute.

Les structures enlèvent cette limitation : comme de multiples valeurs peuvent être combinées en un seul type struct, les fonctions peuvent retourner un objet d'une telle structure, en retournant effectivement plusieurs valeurs à la fois. La fonction ajouterDuree peut maintenant retourner son résultat :

MomentDeLaJournee ajouterDuree(in MomentDeLaJournee depart,
in MomentDeLaJournee duree)
{
// ...
}

Par conséquent, ajouterDuree devient une fonction qui produit une valeur, au lieu d'être une fonction qui a des effets de bord. Comme vu dans le chapitre sur les fonctions, il est préférable de produire des valeurs plutôt qu'avoir des effets de bord.

Les structures peuvent être des membres d'autres structures. Par exemple, la structure suivante a deux membres de type MomentDeLaJournee :

struct Reunion
{
string    sujet;
size_t    nombreDeParticipants;
MomentDeLaJournee debut;
MomentDeLaJournee fin;
}

La structure Reunion peut à son tour servir de membre d'une autre structure. En supposant qu'on ait aussi une structure Repas :

struct planningDeLaJournee
{
Reunion reunionDeProjet;
Repas   dejeuner;
Reunion reunionBudget;
}

51-2 - Accéder aux membres

Les membres de structures peuvent être utilisés comme n'importe quelle autre variable. La seule différence est que le nom de la structure et un point doivent être indiqués avant le nom du membre :

depart.heure = 10;

La ligne ci-dessus affecte la valeur 10 au membre heure de l'objet depart.

Réécrivons la fonction ajouterDuree avec ce que nous avons vu jusqu'ici :

MomentDeLaJournee ajouterDuree(in MomentDeLaJournee depart,
in MomentDeLaJournee duree)
{
MomentDeLaJournee resultat;

resultat.minute = depart.minute + duree.minute;
resultat.heure = depart.heure + duree.heure;
resultat.heure += resultat.minute / 60;

resultat.minute %= 60;
resultat.heure %= 24;

return resultat;
}

On remarque que les noms des variables sont maintenant beaucoup plus courts dans cette version de la fonction : depart, duree et resultat. De plus, au lieu d'utiliser des noms complexes comme heuresDepart, il est possible d'accéder aux membres des structures à travers leurs structures respectives comme dans depart.heure.

Voici un code qui utilise la nouvelle fonction ajouterDuree. Étant donné le moment de début et la durée, le code suivant calcule l'horaire de fin d'un cours dans une école :

void main()
{
MomentDeLaJournee debutCours;
debutCours.heure = 8;
debutCours.minute = 30;

MomentDeLaJournee dureeCours;
dureeCours.heure = 1;
dureeCours.minute = 15;

immutable finCours = ajouterDuree(debutCours, dureeCours);

writefln("Fin du cours : %sh%s", finCours.heure, finCours.minute);
}

La sortie :

Fin du cours : 9h45

La fonction main a été écrite en utilisant uniquement ce que nous avions vu jusqu'à maintenant. Nous rendrons bientôt ce code encore plus clair et succinct.

51-3 - Construction

Les trois premières lignes de main servent à construire l'objet debutCours et les trois lignes suivantes servent à construire l'objet dureeCours. Dans chacun de ces blocs de trois lignes, un objet est d'abord défini, et ensuite ses membres heure et minute sont affectés.

Pour qu'une variable soit utilisée de manière sûre, cette variable doit d'abord être construite dans un état cohérent. La construction étant une action très commune, il existe une syntaxe spéciale pour construire les structures :

MomentDeLaJournee debutCours = MomentDeLaJournee(8, 30);
MomentDeLaJournee dureeCours = MomentDeLaJournee(1, 15);

Les valeurs sont automatiquement affectées aux membres dans l'ordre dans lequel ils sont définis : comme heure est défini en premier dans la structure, la valeur 8 est affectée à debutCours.heure et 30 est affecté à debutCours.minute.

Comme nous le verrons dans un chapitre ultérieur , la syntaxe de construction peut aussi être utilisée pour d'autres types :

auto u = ubyte(42);    // u est un ubyte
auto i = int(u);       // i est un int

51-3-1 - Construire des objets immutable

Pouvoir construire l'objet en spécifiant les valeurs de ses membres au même moment permet de définir des objets immutable :

immutable debutCours = MomentDeLaJournee(8, 30);
immutable dureeCours = MomentDeLaJournee(1, 15);

Autrement, il ne serait pas possible de marquer un objet comme immutable et ensuite de modifier ses membres :

immutable MomentDeLaJournee debutCours;
debutCours.heure = 8;     // ERREUR de compilation
debutCours.minute = 30;   // ERREUR de compilation

51-3-2 - Les membres restants n'ont pas besoin d'être spécifiés

Il peut y avoir moins de valeurs spécifiées qu'il n'y a de membres. Dans ce cas, les membres restants sont initialisés par les valeurs .init de leur type respectif.

Le programme suivant construit des objets Test avec un paramètre de moins à chaque construction. Les assertions indiquent que les membres non spécifiés sont automatiquement initialisés par leur valeur .init (l'utilisation de la fonction isNaN est expliquée après le programme) :

import std.math;

struct Test
{
char   c;
int    i;
double d;
}

void main()
{
// Les valeurs initiales de tous les membres sont spécifiées
auto t1 = Test('a', 1, 2.3);
assert(t1.c == 'a');
assert(t1.i == 1);
assert(t1.d == 2.3);

// Il manque la dernière
auto t2 = Test('a', 1);
assert(t2.c == 'a');
assert(t2.i == 1);
assert(isNaN(t2.d));

// Il manque les deux dernières
auto t3 = Test('a');
assert(t3.c == 'a');
assert(t3.i == int.init));
assert(isNaN(t3.d));

// Aucune valeur initiale n'est spécifiée
auto t4 = Test();
assert(t4.c == char.init);
assert(t4.i == int.init);
assert(isNaN(t4.d));

// Identique à la précédente
Test t5;
assert(t5.c == char.init);
assert(t5.i == int.init);
assert(isNaN(t5.d));
}

Comme nous l'avons vu dans le chapitre sur les virgules flottantes, la valeur initiale de double est double.nan. Comme la valeur .nan n'est pas ordonnée, l'utiliser dans des comparaisons d'égalité n'a pas de sens. C'est pourquoi appeler std.math.isNaN est la manière correcte de déterminer si une valeur est égale à .nan ou non.

51-3-3 - Spécifier les valeurs par défaut des membres

Il est important que les variables membres soient automatiquement initialisées avec des valeurs initiales connues. Ceci évite au programme de continuer avec des valeurs indéterminées. Cependant, la valeur .init de leurs types respectifs peut ne pas convenir pour tous les types. Par exemple, char.init n'est même pas une valeur valide.

Les valeurs initiales des membres d'une structure peuvent être spécifiées lors de la définition de la structure. Ceci peut servir, par exemple, à initialiser les membres à virgule flottante à 0.0, plutôt qu'à .nan qui est souvent inexploitable.

Les valeurs par défaut sont spécifiées avec la syntaxe de l'affectation lors de la définition des membres :

struct Test
{
char   c = 'A';
int    i = 11;
double d = 0.25;
}

Veuillez noter que cette syntaxe n'est pas vraiment une affectation. Le code se contente de déterminer les valeurs par défaut qui seront utilisées quand les objets de cette structure seront construits plus tard dans le programme.

Par exemple, l'objet Test suivant est construit sans valeur spécifique :

Test t;  // aucune valeur n'est spécifiée pour les membres de t
writefln("%s,%s,%s", t.c, t.i, t.d);

Tous les membres sont initialisés avec leur valeur par défaut :

A,11,0.25

51-3-4 - Construction avec la syntaxe { }

Les structures peuvent aussi être construites avec la syntaxe suivante :

MomentDeLaJournee debutCours = { 8, 30 };

Comme avec la syntaxe précédente, les valeurs indiquées sont affectées aux membres dans l'ordre dans lequel ceux-ci sont définis. Les membres restants seront initialisés à leurs valeurs par défaut.

Cette syntaxe est héritée du langage C :

auto debutCours = MomentDeLaJournee(8, 30);  // ← recommandé
MomentDeLaJournee finCours = { 9, 30 };      // ← façon C

Cette syntaxe autorise les initialisateurs désignés. Les initialisateurs désignés sont utilisés pour spécifier le membre associé à une valeur d'initialisation. Il est même possible d'initialiser les membres dans un ordre différent de celui dans lequel ils sont définis dans la structure :

MomentDeLaJournee m = { minute: 42, heure: 7 };

51-4 - Copie et affectation

Les structures sont des types valeur. Comme décrit dans le chapitre sur les types valeur et types référence, ceci veut dire que chaque objet struct a sa propre valeur. Les objets reçoivent leurs propres valeurs quand ils sont construits et leurs valeurs changent quand on leur affecte de nouvelles valeurs.

auto votreHeureDuDejeuner = MomentDeLaJournee(12, 0);
auto monHeureDuDejeuner = votreHeureDuDejeuner;

// Seul monHeureDuDejeuner passe à 12h05 :
monHeureDuDejeuner.minute += 5;

// ... votreHeureDuDejeuner reste inchangé :
assert(yourLunchTime.minute == 0);

Pendant une copie, tous les membres de l'objet source sont automatiquement copiés vers les membres correspondants dans l'objet de destination. De manière similaire, l'affectation implique l'affectation de chaque membre de la source au membre de destination correspondant.

Les membres de structures qui sont d'un type référence demandent une attention particulière.

51-4-1 - Attention avec les membres qui sont de types référence !

Comme vous le savez, le fait de copier ou d'affecter les variables de types référence ne modifie aucune valeur, cela ne fait que changer l'objet effectivement référencé. Ainsi, copier ou affecter une structure qui a des membres de type référence va créer dans l'objet de destination des références supplémentaires aux objets référencés dans l'objet source. Ceci implique que des membres de deux structures distinctes peuvent donner accès à la même valeur.

Pour voir un exemple de ceci, regardons une structure dont l'un des membres est de type référence. Cette structure est utilisée pour stocker le numéro et les notes d'un étudiant :

struct Etudiant
{
int numero;
int[] notes;
}

Le code suivant construit un second objet Etudiant en copiant un objet existant :

// On construit le premier objet :
auto etudiant1 = Etudiant(1, [ 70, 90, 85 ]);

// On construit le deuxième objet en copiant le premier
// et en changeant le numéro :
auto etudiant2 = etudiant1;
etudiant2.numero = 2;

// AVERTISSEMENT: Les notes sont maintenant partagées par les deux objets!

// Changer les notes du premier étudiant...
etudiant1.notes[0] += 5;

// ... affecte les notes du second étudiant:
writeln(etudiant2.notes[0]);

Quand etudiant2 est construit, ses membres prennent les valeurs des membres de etudiant1. Comme int est un type valeur, le second objet obtient sa propre valeur numero.

Les deux objets Etudiant ont aussi un membre notes chacun. Cependant, comme les tranches sont des types référence, les éléments que les tranches partagent sont les mêmes. Ainsi, une modification faite depuis une des tranches sera reflétée sur l'autre tranche.

La sortie du code indique que la note du second étudiant a été augmentée également :

75

Pour cette raison, une meilleure approche serait de construire le deuxième objet en copiant les notes du premier :

// Le deuxième Etudiant est construit en copiant les notes du premier :
auto etudiant2 = Etudiant(2, etudiant1.notes.dup);

// Changer les notes du premier étudiant...
etudiant1.notes[0] += 5;

// ... n`affecte pas les notes du deuxième :
writeln(etudiant2.notes[0]);

Comme les notes ont été copiées avec .dup, les notes du deuxième étudiant ne sont cette fois pas affectées :

0

Il existe néanmoins une possibilité de faire en sorte que les membres référence soient également copiés. Nous verrons comment plus tard, quand on abordera les membres fonction.

51-4-2 - Littéraux structure

De la même manière que l'on peut utiliser des valeurs littérales entières (comme 10) dans les expressions sans avoir besoin de définir de variable, des littéraux de structures peuvent également être utilisés :

Les littéraux de structures sont construits avec la syntaxe de construction d'objets.

MomentDeLaJournee(8, 30) // ← littéral de structure

Réécrivons la dernière fonction main() que nous avons définie, avec ce qu'on a appris depuis sa dernière version. Les variables sont construites avec la syntaxe de construction et sont cette fois immuables :

void main()
{
immutable debutCours = MomentDeLaJournee(8, 30);
immutable dureeCours = MomentDeLaJournee(1, 15);

immutable finCours = ajouterDuree(debutCours,
dureeCours);

writefln("Fin du cours : %s:%s",
finCours.heure, finCours.minute);
}

Notez qu'il n'est pas nécessaire de définir debutCours et dureeCours comme des variables nommées dans ce cas. Elles sont en fait des variables temporaires dans ce programme simple, qui sont utilisées seulement pour calculer la variable finCours. Elles pourraient être passées à ajouterDuree() comme des valeurs littérales :

void main()
{
immutable finCours = ajouterDuree(MomentDeLaJournee(8, 30),
MomentDeLaJournee(1, 15));

writefln("Fin du cours : %s:%s",
finCours.heure, finCours.minute);
}

51-4-3 - Membres static

Même si les objets ont surtout besoin de copies individuelles de leurs membres, il peut être utile pour un type de structure particulier de partager certaines variables. Ce peut être nécessaire quand, par exemple, une information générale sur ce type de structure a besoin d'être maintenue.

Par exemple, imaginons un type qui affecte un identificateur différent pour chaque objet de ce type :

struct Point
{
// L'identificateur de chaque objet
size_t id;

int ligne;
int colonne;
}

Afin de pouvoir affecter un identificateur différent à chaque objet, il faut une variable distincte pour garder la prochaine valeur que l'on peut utiliser, variable qui sera incrémentée à chaque fois qu'un nouvel objet sera créé. Supposons que idSuivant est définie autre part et accessible à la fonction suivante :

Point creerPoint(int ligne, int colonne)
{
size_t id = idSuivant;
++idSuivant;

return Point(id, ligne, colonne);
}

Une décision doit être prise quant à l'endroit où l'on définit la variable commune idSuivant. Les membres statiques sont utiles dans de tels cas. Une information de cette sorte est définie comme un membre statique (static) de la structure. Contrairement aux membres classiques, il y a une seule variable pour chaque membre statique dans tout le fil d'exécution. Cette variable unique est partagée par tous les objets de cette structure :

import std.stdio;

struct Point
{
// L'identificateur de chaque objet
size_t id;

int ligne;
int colonne;

// L'identificateur de l'objet suivant
static size_t idSuivant;
}

Point creerPoint(int ligne, int colonne)
{
size_t id = Point.idSuivant;
++Point.idSuivant;

return Point(id, ligne, colonne);
}

void main()
{
auto haut = creerPoint(7, 0);
auto milieu = creerPoint(8, 0);
auto bas =  creerPoint(9, 0);

writeln(haut.id);
writeln(milieu.id);
writeln(bas.id);
}

Comme idSuivant est incrémentée lors de la construction de chaque objet, les objets ont chacun un identificateur unique :

0
1
2

Comme les membres statiques appartiennent au type lui-même, il n'y a pas besoin d'objet pour y accéder. Comme nous l'avons vu ci-dessus, on peut accéder à de tels objets par le nom du type, mais on peut aussi bien le faire à travers le nom de tout objet de ce type :

++Point.idSuivant;
++bas.idSuivant;    // même effet que la ligne précédente

51-4-4 - static this() pour l'initialisation et static ~this() pour la finalisation

Au lieu d'affecter explicitement une valeur initiale à idSuivant ci-dessus, nous avons utilisé sa valeur initiale par défaut, zéro. Nous aurions pu utiliser n'importe quelle autre valeur :

static size_t idSuivant = 1000;

Cependant, une telle initialisation est possible seulement quand la valeur initiale est connue lors de la compilation. De plus, certains codes spéciaux peuvent nécessiter d'être exécutés avant qu'une structure soit utilisée dans un fil d'exécution. De tels codes peuvent être écrits dans des portées static this().

Par exemple, le code suivant lit la valeur initiale dans un fichier s'il existe :

import std.file;

struct Point {
// ...

enum fichierIdSuivant = "Point_fichier_id_suivant"

static this() {
if (exists(fichierIdSuivant)) {
auto fichier = File(fichierIdSuivant, "r");
fichier.readf(" %s", &idSuivant);
}
}
}

Le contenu des blocs static this() est exécuté une fois par fil d'exécution avant que le type de struct soit utilisé dans ce fil d'exécution. Le code qui devrait être exécuté seulement une fois durant toute l'exécution du programme (par exemple, pour initialiser des variables shared ou immutable) doit être défini dans des blocs shared static this() et shared static this(), qui seront vus dans le chapitre sur les données partagées et la concurrence.

De manière similaire, static this() est utilisé pour les opérations finales d'un fil d'exécution et shared static this() est utilisé pour les opérations finales du programme entier.

L'exemple suivant complète le bloc static this() en écrivant la valeur de idSuivant dans le même fichier, ce qui permet de rendre les identifiants des objets persistants entre les exécutions du programme :

struct Point {
// ...

static ~this() {
auto fichier = File(fichierIdSuivant, "w");
file.writeln(idSuivant);
}
}

Le programme initialise maintenant idSuivant à partir de l'identifiant de l'exécution précédente. Par exemple, ce qui suit est la sortie de la deuxième exécution du programme :

3
4
5

51-5 - Exercices

- Concevez une structure Carte pour représenter une carte à jouer.

auto carte = Carte('♣', '2');

struct Carte
{
// ... définir la structure ...
}

void afficherCarte(in Carte carte)
{
// ... écrire le corps de la fonction ...
}

void main()
{
auto carte = Carte(/* ... */);
afficherCarte(carte);
}

- Par exemple, la fonction peut afficher le deux de trèfle de cette manière :

♣2

- L'implémentation de cette fonction peut dépendre des choix de types des membres.

Carte[] nouveauJeu()
out (resultat)
{
assert(resultat.length == 52);
}
body
{
// ... écrire le corps de la fonction ...
}

- Il doit être possible d'appeler nouveauJeu() comme dans le code suivant :

void main()
{
Carte[] jeu = nouveauJeu();

foreach (carte; jeu) {
afficherCarte(carte);
write(' ');
}

writeln();
}

- La sortie devrait ressembler à ce qui suit, avec 52 cartes distinctes :

♠2 ♠3 ♠4 ♠5 ♠6 ♠7 ♠8 ♠9 ♠0 ♠V ♠D ♠R ♠A ♡2 ♡3 ♡4
♡5 ♡6 ♡7 ♡8 ♡9 ♡0 ♡V ♡D ♡R ♡A ♢2 ♢3 ♢4 ♢5 ♢6 ♢7
♢8 ♢9 ♢0 ♢V ♢D ♢R ♢A ♣2 ♣3 ♣4 ♣5 ♣6 ♣7 ♣8 ♣9 ♣0
♣V ♣D ♣R ♣A

- Écrivez une fonction qui mélange le jeu. Une manière de le faire est de prendre deux cartes au hasard avec std.random.uniform, les échanger et répéter ce processus un nombre de fois suffisant. La fonction devrait prendre le nombre de répétitions en paramètre :

void melanger(Carte[] jeu, in int repetitions)
{
// ... écrire le corps de la fonction ...
}

- Voici comment elle doit pouvoir être utilisée :

void main()
{
Carte[] jeu = nouveauJeu();
melanger(jeu, 1);

foreach (carte; jeu) {
afficherCarte(carte);
write(' ');
}

writeln();
}

- La fonction doit échanger les cartes repetitions fois. Par exemple, un appel avec 1 doit donner une sortie similaire à ceci :

♠2 ♠3 ♠4 ♠5 ♠6 ♠7 ♠8 ♠9 ♠0 ♠V ♠D ♠R ♠A ♡2 ♡3 ♡4
♡5 ♡6 ♡7 ♡8 ♣4 ♡0 ♡V ♡D ♡R ♡A ♢2 ♢3 ♢4 ♢5 ♢6 ♢7
♢8 ♢9 ♢0 ♢V ♢D ♢R ♢A ♣2 ♣3 ♡9 ♣5 ♣6 ♣7 ♣8 ♣9 ♣0
♣V ♣D ♣R ♣A

- Une valeur plus grande de repetitions devrait résulter en un jeu plus mélangé :

melanger(jeu, 100);

- La sortie :

♠4 ♣7 ♢9 ♢6 ♡2 ♠6 ♣6 ♢A ♣5 ♢8 ♢3 ♡D ♢V ♣R ♣8 ♣4
♡V ♣D ♠D ♠9 ♢0 ♡A ♠A ♡9 ♠7 ♡3 ♢R ♢2 ♡0 ♠V ♢7 ♡7
♠8 ♡4 ♣V ♢4 ♣0 ♡6 ♢5 ♡5 ♡R ♠3 ♢D ♠2 ♠5 ♣2 ♡8 ♣A
♠R ♣9 ♠0 ♣3

- Note : une meilleure façon de mélanger le jeu est expliquée dans les solutions.

Les solutions.

52 - Nombre variable de paramètres

Ce chapitre évoque deux fonctionnalités de D qui apportent de la flexibilité aux paramètres lors des appels de fonctions :

- les valeurs par défaut de paramètres ;

- les fonctions variadiques.

52-1 - Les valeurs par défaut de paramètres

Il peut être commode de spécifier des valeurs par défaut aux paramètres d'une fonction. Cela est similaire aux valeurs initiales par défaut des membres d'une structure.

Certains paramètres de fonctions sont la plupart du temps utilisés avec les mêmes valeurs. Par exemple, imaginons une fonction qui affiche les éléments d'un tableau associatif de type

string[string].

. Considérons que cette fonction prenne également les caractères séparateurs en paramètres :

void afficheTA(in char[] titre,
in string[string] ta,
in char[] separateurCle,
in char[] separateurElement)
{
writeln("-- ", titre, " --");

auto cles = ta.keys.sort;

foreach (i, cle; cles) {
// Pas de séparateur avant le premier élément
if (i != 0) {
write(separateurElement);
}

write(cle, separateurCle, ta[cle]);
}

writeln();

}

Cette fonction est appelée ci-dessous avec « : » comme séparateur de clé et « , » comme séparateur d'éléments :

string[string] dictionnaire = [ "bleu":"blue", "rouge":"red", "gris":"gray" ];
afficheTA("Dictionnaire des couleurs", dictionnaire, ":", ", ");

Ce qui produit le résultat suivant :

-- Dictionnaire des couleurs --
bleu:blue, gris:gray, rouge:red

Si les séparateurs sont presque toujours les deux même, ils peuvent être définis avec des valeurs par défaut :

void afficheTA(in char[] titre,
in string[string] ta,
in char[] separateurCle = ":",
in char[] separateurElement = ", ")
{
// ...
}

Les paramètres par défaut n'ont pas besoin d'être spécifiés lors de l'appel d'une fonction :

afficheTA("Dictionnaire des couleurs", dictionnaire); /* ← pas de séparateur spécifié. Les deux paramètres prendront leur valeur par défaut */

Au besoin, les valeurs des paramètres peuvent toujours être spécifiées, pas nécessairement dans leur intégralité :

afficheTA("Dictionnaire des couleurs", dictionnaire, "=");

Ce qui produit le résultat suivant :

-- Dictionnaire des couleurs --
bleu=blue, gris=gray, rouge=red

L'appel ci-dessous spécifie les deux paramètres :

afficheTA("Dictionnaire des couleurs", dictionnaire, "=", "\n");

Le résultat :

-- Dictionnaire des couleurs --
bleu=blue
gris=gray
rouge=red

Les valeurs par défaut ne peuvent être définies que pour les paramètres en fin de liste.

52-2 - Mots-clés spéciaux comme arguments par défaut

Les mots-clés suivants fonctionnent comme des littéraux ayant des valeurs dépendant de l'endroit où ils apparaissent dans le code :

- MODULE : nom du module ;

- FILE : nom du fichier source ;

- LINE : numéro de la ligne ;

- FUNCTION : nom de la fonction ;

- PRETTY_FUNCTION : signature complète de la fonction.

Bien qu'ils puissent être utiles n'importe où dans le code, ils fonctionnement différemment quand ils sont utilisés en tant qu'arguments par défaut. Quand ils sont utilisés dans du code classique, leurs valeurs font référence à l'endroit où ils apparaissent dans le code :

import std.stdio;

void fonct(int parametre) {
writefln("Dans la fonction%s du fichier %s, ligne %s.",
__FUNCTION__, __FILE__, __LINE__);    // ← ligne 6
}

void main() {
fonct(42);
}

La ligne 6 rapportée est dans la fonction.

Sortie :

Dans la fonction test.fonct du fichier test.d, ligne 6.

Cependant, il est parfois plus intéressant de déterminer la ligne d'où la fonction a été appelée plutôt que l'endroit où elle a été définie. Quand ces mots-clés spéciaux sont donnés en paramètres par défaut, leur valeur fait référence à l'endroit où la fonction est appelée :

import std.stdio;

void fonct(int parametre,
string nomFonction = __FUNCTION__,
string fichier = __FILE__,
size_t line = __LINE__) {
writefln("Appelé depuis la fonction %s du fichier %s, ligne %s.",
nomFonction, fichier, ligne);
}

void main() {
fonct(42);    // ← ligne 14
}

Cette fois, les mots-clés spéciaux font référence à main(), l'appelant de la fonction.

Sortie :

Appelé depuis la fonction test.main du fichier test.d, ligne 14.

En plus des mots-clés précédents, il y a aussi les mots-clés suivants, qui prennent leur valeur dépendant du compilateur et du moment de la compilation :

- DATE : date de la compilation ;

- TIME : moment de la compilation (heures, minutes, secondes) ;

- TIMESTAMP : date et moment de la compilation ;

- VENDOR : auteurs du compilateur (par ex. Digital Mars D) ;

- VERSION : version du compilateur, en tant qu'entier (par ex. la valeur 2069 pour la version 2.069)

52-3 - Fonctions variadiques

Malgré les apparences, les valeurs par défaut des paramètres ne changent pas le nombre de paramètres reçus par une fonction. Par exemple, même si certains paramètres peuvent se voir assigner leur valeur par défaut, afficheTA() prend toujours quatre paramètres et les utilise selon son implémentation.

Les fonctions variadiques quant à elles peuvent s'appeler avec un nombre d'arguments non défini. Nous nous sommes déjà servis de cette fonctionnalité avec des fonctions comme writeln(). writeln() peut être appelée avec un nombre quelconque de paramètres :

writeln("hello", 7, "world", 9.8); /* et autant d'autres arguments que nécessaire */

Il y a quatre manières de définir des fonctions variadiques en D :

- celle qui ne fonctionne que pour les fonctions marquées comme extern (C). Cette fonctionnalité définit une variable cachée _argptr qui est utilisée pour accéder aux paramètres. Ce livre ne la traite pas parce qu'elle n'est pas sûre ;

- la manière des fonctions D normales, qui utilise également la variable cachée _argptr ainsi que la variable _arguments, cette dernière étant de type TypeInfo[], ce livre ne la couvre pas non plus, car elle requiert la notion de pointeurs que nous n'avons pas encore vue et qu'elle peut être utilisée de manière non sûre ;

- une fonctionnalité sûre, qui nécessite que tous les paramètres soient du même type. C'est de cela que nous allons parler dans cette section ;

- un nombre non spécifié de paramètres templates. Cette fonctionnalité sera expliquée plus tard dans le chapitre sur les modèles (templates).

Les paramètres des fonctions variadiques sont passés sous la forme d'une tranche. Les fonctions variadiques sont définies avec un seul paramètre d'un certain type de tranche immédiatement suivi par les caractères … :

double somme(in double[] nombres ...)
{
double resultat = 0.0;

foreach (nombre; nombres) {
resultat += nombre;
}

return resultat;
}

Cette définition donne une fonction somme variadique, c'est-à-dire qu'elle peut recevoir un nombre quelconque d'arguments du moment qu'ils sont du type double ou implicitement convertibles en double :

writeln(somme(1.1, 2.2, 3.3));

Le seul paramètre tranche et les caractères … représentent tous les arguments. Par exemple, la tranche contiendrait 5 éléments si la fonction était appelée avec 5 doubles.

En fait, les arguments peuvent aussi être passés sous la forme d'une seule tranche :

writeln(somme([1.1, 2.2, 3.3])); // identique au code précédent

Les fonctions variadiques peuvent aussi avoir des paramètres obligatoires, qui doivent être définis en premier dans la liste des paramètres. Par exemple, la fonction suivante affiche un nombre quelconque de paramètres entre parenthèses. Bien que la fonction soit ouverte quant au nombre d'éléments, elle requiert que les parenthèses soient toujours spécifiées :

char[] parentheser(
in char[] ouverture,  // ← Les deux premiers paramètres doivent toujours être
in char[] fermeture,  //   spécifiés quand la fonction est appelée.
in char[][] mots ...) { // ← Pas obligatoire

char[] resultat;

foreach (mot; mots) {
resultat ~= ouverture;
resultat ~= mot;
resultat ~= fermeture;
}

return resultat;
}

Les deux premiers paramètres sont obligatoires :

parentheser("{"); // ← ERREUR de compilation

À partir du moment où les deux premiers paramètres sont spécifiés, les autres sont optionnels :

writeln(parentheser("{", "}","pomme", "poire", "banane"));

En sortie :

{pomme}{poire}{banane}

52-4 - Les paramètres des fonctions variadiques ont une durée de vie courte

La tranche qui est automatiquement générée pour un paramètre variadique pointe vers un tableau temporaire qui a une durée de vie courte. Ce fait n'importe pas si la fonction n'utilise les arguments que pendant son exécution. Cependant, si la fonction gardait une tranche de ces éléments pour usage ultérieur, ce serait un bogue :

int[] nombresPourUsageUlterieur;

void foo(int[] nombres...) {
nombresPourUsageUlterieur = nombres;    // ← BOGUE
}

struct S {
string[] nomsPourUsageUlterieur;

void foo(string[] noms...) {
nomsPourUsageUlterieur = noms;    // ← BOGUE
}
}

void bar() {
foo(1, 10, 100);  /* Le tableau temporaire [ 1, 10, 100 ] n'est
* plus valide après cet endroit. */

auto s = S();
s.foo("bonjour", "le", "monde");  /* Le tableau temporaire
* [ "bonjour", "le", "monde" ] n'est
* plus valide après cet endroit. */

// ...
}

void main() {
bar();
}

La fonction foo() aussi bien que la fonction membre S.foo() sont boguées parce qu'elles stockent des tranches vers des tableaux temporaires générés automatiquement qui vivent sur la pile. Ces tableaux ne sont valides que pendant l'exécution des fonctions variadiques.

Pour cette raison, si une fonction a besoin de stocker une tranche vers les éléments d'un paramètre variadique, elle doit d'abord faire une copie de ces éléments :

void foo(int[] nombres...) {
nombresPourUsageUlterieur = nombres.dup;    // ← correct
}

// ...

void foo(string[] noms...) {
nomsPourUsageUlterieur = noms.dup;    // ← correct
}

Cependant, comme les fonctions variadiques peuvent aussi être appelées avec des tranches de tableaux classiques, copier les éléments ne serait pas nécessaire dans ces cas.

Une solution qui serait à la fois correcte et efficace est de définir deux fonctions qui ont le même nom, une prenant un paramètre variadique et l'autre prenant une tranche classique. Si l'appelant passe un nombre variable d'arguments, la version variadique de la fonction est appelée, et si l'appelant passe une tranche classique, la fonction qui prend une tranche est appelée :

int[] nombresPourUsageUlterieur;

void foo(int[] nombres...) {
/* Since this is the variadic version of foo(), we must
* first take a copy of the elements before storing a
* slice to them. */
nombresPourUsageUlterieur = nombres.dup;
}

void foo(int[] nombres) {
/* Since this is the non-variadic version of foo(), we can
* store the slice as is. */
nombresPourUsageUlterieur = nombres;
}

struct S {
string[] nomsPourUsageUlterieur;

void foo(string[] noms...) {
/* Comme il s'agit de la version variadique de S.foo(), nous
* devons d'abord faire une copie des éléments avant
* de stocker une tranche vers eux. */
nomsPourUsageUlterieur = noms.dup;
}

void foo(string[] noms) {
/* Comme il s'agit de la version non-variadique de S.foo(),
* nous pouvons stocker la tranche telle quelle. */
nomsPourUsageUlterieur = noms;
}
}

void bar() {
// Cet appel est propagé vers la fonction variadique.
foo(1, 10, 100);

// Cet appel est propagé vers la fonction qui prend une tranche.
foo([ 2, 20, 200 ]);

auto s = S();

// Cet appel est propagé vers la fonction variadique.
s.foo("salut", "le", "monde");

// Cet appel est propagé vers la fonction qui prend une tranche.
s.foo([ "salut", "la", "lune" ]);

// ...
}

void main() {
bar();
}

52-5 - Exercice

Considérez que l'énumération suivante est définie :

enum Operation { addition, soustraction, multiplication, division }

Considérez également qu'il existe une structure qui représente le résultat d'un calcul et ses deux opérandes :

struct Calcul {
Operation op;
double premier;
double second;
}

Par exemple, l'objet Calcul(Operation.division, 7.7, 8.8) représenterait la division de 7.7 par 8.8.

Concevez une fonction qui reçoive un nombre non spécifié de ces objets struct, calcule le résultat de chacun et retourne ceux-ci comme une tranche de type double[].

Par exemple, il doit être possible d'appeler cette fonction comme ceci :

void main() {
writeln(calculer(Calcul(Operation.addition, 1.1, 2.2),
Calcul(Operation.soustraction, 3.3, 4.4),
Calcul(Operation.multiplication, 5.5, 6.6),
Calcul(Operation.division, 7.7, 8.8)));
}

Le résultat de ce code doit être le suivant :

[3.3, -1.1, 36.3, 0.875]

La solution.

53 - Surcharge de fonctions

On appelle surcharger une fonction le fait de définir plusieurs fonctions ayant toutes le même nom. Pour pouvoir les différencier, leurs paramètres doivent être différents.

Le code suivant montre plusieurs surcharges de la fonction info(), chacune prenant un type de paramètre différent :

import std.stdio;

void info(in double nombre)
{
writeln("Virgule flottante~ : ", nombre);
}

void info(in int nombre)
{
writeln("Entier~ : ", nombre);
}

void info(in char[] chaine)
{
writeln("Chaîne de caractères~ : ", chaine);
}

void main()
{
info(1.2);
info(3);
info("bonjour");
}

Bien que toutes les fonctions soient nommées info(), le compilateur choisit celle qui correspond à l'argument qui est utilisé lors de l'appel. Ici, la fonction info() prenant un double est appelée parce que la constante littérale 1.2 est de type double.

Le choix de la fonction à appeler est fait à la compilation, ce qui n'est pas toujours facile ou évident. Par exemple, puisque le type int peut être implicitement converti en type double et en type real, le compilateur ne peut choisir quelle fonction employer dans le programme suivant :

real septFois(in real valeur)
{
return 7 * valeur;
}

double septFois(in double valeur)
{
return 7 * valeur;
}

void main()
{
int valeur = 5;
auto resultat = septFois(valeur);  // ← ERREUR de compilation
}

Il n'est généralement pas nécessaire d'écrire des fonctions séparées quand les corps des fonctions sont identiques. Nous verrons plus tard dans le chapitre des templates comment écrire une définition de fonction unique qui peut être utilisée avec des types différents.

Néanmoins, s'il y a une autre surcharge de fonction prenant un paramètre long, alors l'ambiguïté est résolue, car long correspond mieux à int que double ou real :

long septFois(in long valeur)
{
return 7 * valeur;
}

// ...

auto resultat =  septFois(valeur);  // compile maintenant

53-1 - Résolution de surcharge

Le compilateur choisit la surcharge qui correspond le mieux aux arguments. C'est ce que l'on appelle la résolution de surcharge.

Bien que la résolution soit simple et intuitive la plupart du temps, ce n'est pas toujours le cas. Voici les règles de résolution de surcharge. Elles sont présentées de manière simplifiée dans ce livre.

Il y a quatre niveaux de correspondance, du pire au meilleur :

- pas de correspondance ;

- correspondance via conversion automatique de type ;

- correspondance via qualification const ;

- correspondance exacte.

Le compilateur considère toutes les surcharges d'une fonction pendant la résolution de surcharge. Si la fonction a plusieurs paramètres, le niveau de correspondance global d'une fonction est le plus mauvais des niveaux de correspondance de tous les paramètres.

Après que tous les niveaux de correspondance ont été déterminés, la surcharge avec la meilleure correspondance est choisie. S'il y a plusieurs surcharges qui ont la même correspondance, alors des règles plus complexes sont appliquées. Je ne rentrerai pas dans les détails de ces règles dans ce livre. Si votre programme en est au point où il dépend de règles complexes de résolution de surcharge de fonctions, c'est peut-être un signe qu'il est temps d'en revoir la conception. Une autre option est de profiter d'autres fonctionnalités de D comme des templates. Une approche encore plus simple serait de nommer chaque fonction différemment pour chaque type comme septFois_real() et septFois_double().

53-2 - Surcharge de fonctions pour les types définis par l'utilisateur

La surcharge de fonctions s'utilise également avec les structures et les classes. Qui plus est, les ambiguïtés liées à la surcharge de fonctions sont bien moins fréquentes avec les types définis par l'utilisateur. Surchargeons la fonction info() précédente avec quelques-uns des types que nous avons définis dans le chapitre sur les structures :

struct MomentDeLaJournee
{
int heure;
int minute;
}

void info(in MomentDeLaJournee moment)
{
writef("%02s:%02s", moment.heure, moment.minute);
}

La surcharge permet aux objets MomentDeLaJournee d'être utilisés avec info(). Finalement, les types définis par l'utilisateur peuvent être affichés de la même manière que les types fondamentaux :

auto momentPetitDejeuner =  MomentDeLaJournee(7, 0);
info(momentPetitDejeuner);

Les objets MomentDeLaJournee correspondent à cette nouvelle surcharge de info() :

00:00:00

Le code suivant est une surcharge de info() pour le type Reunion :

struct Reunion
{
string sujet;
size_t nombreDeParticipants;
MomentDeLaJournee debut;
MomentDeLaJournee fin;
}

void info(in Reunion reunion)
{
info(reunion.debut);
write('-');
info(reunion.fin);

writef(" réunion \"%s\" avec %s personnes", reunion.sujet,
reunion.nombreDeParticipants);
}

Veuillez noter que cette surcharge utilise la surcharge déjà définie pour MomentDeLaJournee. Les objets Reunion peuvent maintenant être affichés comme le sont les types fondamentaux :

auto reunionCyclisme = Reunion("Cyclisme", 3, MomentDeLaJournee(9, 0),
MomentDeLaJournee(9, 10));

Résultat :

09:00-09:10 réunion "Cyclisme" avec 3 personnes

53-3 - Limitations

Bien que nos surcharges de la fonction info() soient très pratiques, cette méthode présente quelques limitations :

- info() écrit systématiquement sur la sortie standard. Elle serait nettement plus utile si elle pouvait écrire dans n'importe quel fichier. Une solution pour cela serait de passer également le flux de sortie en paramètre. Par exemple, pour le type MomentDeLaJournee :

void info(File fichier, in MomentDeLaJournee temps)
{
fichier.writef("%02s:%02s", temps.heure, temps.minute);
}

- Cela permettrait d'écrire des objets MomentDeLaJournee dans n'importe quel fichier, y compris la sortie standard :

info(stdout, momentPetitDejeuner);

auto fichier = File("un_fichier", "w");
info(fichier, momentPetitDejeuner);

- Note : les objets spéciaux stdin, stdout et stderr sont de type File.

- Plus important : info() ne permet pas de produire une représentation textuelle de variables. Par exemple, elle ne permet pas de passer des types utilisateur à writeln() :

writeln(momentPetitDejeuner); // Écrit avec le format générique, inutile.

- Le code qui précède affiche l'objet dans un format générique qui contient son type et les valeurs de ses membres, d'une manière qui n'est pas pertinente pour le programme :

MomentDeLaJournee(7, 0)

- Il serait beaucoup plus utile d'avoir une fonction qui convertisse des objets MomentDeLaJournee en string dans leur format spécial "12:34". Nous verrons comment définir des représentations textuelles d'objets structures dans le prochain chapitre.

53-4 - Exercice

Surchargez la fonction info() pour les structures suivantes :

struct Repas
{
MomentDeLaJournee moment;
string            adresse;
}

struct planningDeLaJournee
{
Reunion reunionMatin;
Repas   repas;
Reunion reunionApresMidi;
}

Puisque Repas a seulement un moment de début, ajoutez une heure et demie pour déterminer sa fin. Vous pouvez utiliser la fonction ajouterDuree que nous avons définie précédemment dans le chapitre sur les structures :

MomentDeLaJournee ajouterDuree(in MomentDeLaJournee debut,
in MomentDeLaJournee duree)
{
MomentDeLaJournee resultat;

resultat.minute = debut.minute + duree.minute;
resultat.heure = debut.heure + duree.heure;
resultat.heure += resultat.minute / 60;

resultat.minute %= 60;
resultat.heure %= 24;

return resultat;
}

Une fois que les moments de fin des objets repas seront calculés au moyen de ajouterDuree(), les objets planningDeLaJournee devront être affichés ainsi :

10:30-11:45 Réunion "Cyclisme" avec 4 personnes
12:30-14:00 Repas, adresse : Istamboul
15:30-17:30 Réunion "Budget" avec 8 personnes

La solution.

54 - Fonctions membres

Bien que ce chapitre se concentre principalement sur les structures, une grande partie des informations détaillées ci-après peuvent être également appliquées aux classes également.

Au cours de ce chapitre, nous couvrirons les méthodes des structures, notamment la méthode spéciale toString() qui est utilisée afin de représenter les objets sous forme d'une chaîne de caractères, ou string.

Lorsqu'une structure ou une classe est définie, on définit aussi souvent un certain nombre de fonctions avec elle. Nous avions déjà vu des exemples dans les chapitres précédents : ajouterDuree() et la surcharge de info() ont été écrites spécialement pour être utilisées avec le type TempsDuJour. En quelque sorte, ces deux fonctions forment l'interface de TempsDuJour.

ajouterDuree() et info() avaient pris pour premier paramètre un objet TempsDuJour sur lequel elles allaient opérer. En plus de cela, comme c'est le cas pour toutes les fonctions jusque-là étudiées, nos deux fonctions avaient été définies au niveau du module (module level), en dehors de toute autre portée.

Le fait qu'un ensemble de fonctions définissent l'interface d'une structure est un concept très répandu. Pour cette raison, les fonctions ayant une relation proche d'un certain type peuvent être définies au sein de celui-ci.

54-1 - Définir des méthodes

Les fonctions définies à l'intérieur des accolades d'une struct sont dites des méthodes :

struct UneStructure
{
void methode(/* les paramètres de la fonction */)
{
// ... la définition de la fonction ...
}

// ... les autres membres de la structure ...
}

Les méthodes peuvent être accédées de la même façon que les champs, en les séparant du nom de l'objet par un point :

[i objet.methode(arguments));

Nous avions déjà utilisé les méthodes quand on avait pris soin de spécifier stdin et stdout lors des opérations d'entrée et de sortie :

stdin.readf(" %s", &nombre);
stdout.writeln(nombre);

Dans ce code, nous avons effectué des appels aux méthodes readf() et writeln() qui opèrent respectivement sur les objets stdin et stdout.

Définissions maintenant info() en tant que méthode. Auparavant, nous l'avions définie ainsi :

void info(in TempsDuJour temps)
{
writef("%02s:%02s", temps.heure, temps.minute);
}

Afin de transformer info() en une méthode, il faudra non seulement déplacer sa définition à l'intérieur de la structure, mais aussi lui faire subir deux modifications :

struct TempsDuJour
{
int heure;
int minute;

void info()    // (1)
{
writef("%02s:%02s", heure, minute);    // (2)
}
}

1. La méthode ne prend plus l'objet comme paramètre.

2. Suite à ceci, elle accède aux champs directement par heure et minute.

La raison pour cela est que les méthodes sont toujours appelées sur un objet existant. Ce dernier est implicitement disponible pour la méthode :

auto temps = TempsDuJour(10, 30);
temps.info();

La méthode info() est appelée sur l'objet temps ci-avant. Les variables heure et minute qui sont indiquées à l'intérieur de la définition de la fonction correspondent aux champs de l'objet temps, plus précisément, temps.heure et temps.minute.

Ici, l'appel à la méthode est presque l'équivalent de l'appel à une fonction normale :

temps.info();    // méthode
info(temps);     // fonction habituelle (la définition précédente)

Dès qu'une méthode est appelée sur un objet, les champs de celui-ci sont implicitement accessibles pour la fonction :

auto matin = TempsDuJour(10, 0);
auto apresmidi = TempsDuJour(22, 0);

matin.info();
write('-');
apresmidi.info();
writeln();

Appelées sur matin, les variables heure et minute utilisées à l'intérieur de la méthode ne sont autres que matin.heure et matin.minute. De même, en appelant apresmidi.info(), elles font référence à apresmidi.heure et apresmidi.minute :

10:00-22:00

54-1-1 - toString() pour les représentations sous forme de chaînes de caractères

Dans le chapitre précédent, nous venons de parler des limitations de la fonction info(). Mais malgré tout ce que nous venons de voir, la fonction garde au moins un autre inconvénient : bien qu'elle affiche le temps dans un format lisible, le programmeur devra toujours prendre soin d'expliciter le caractère '-' ainsi que le caractère de fin de ligne.

Il serait plus pratique si les objets TempsDuJour pouvaient être utilisés comme des types fondamentaux dans le code ci-après :

writefln("%s-%s", matin, apresmidi);

En plus d'avoir réduit quatre lignes de code en seulement une, cela aurait aussi pour effet de pouvoir écrire l'objet à n'importe quel flux :

auto fichier = File("temps_information", "w");
fichier.writefln("%s-%s", matin, apresmidi);

La méthode toString() des types définis par l'utilisateur est un peu spéciale : elle est automatiquement appelée pour produire la représentation string des objets. toString() doit impérativement retourner la représentation string de l'objet en question.

Avant de plonger dans les détails, étudions d'abord la définition de la fonction toString() :

import std.stdio;

struct TempsDuJour
{
int heure;
int minute;

string toString()
{
return "a_faire";
}
}

void main()
{
auto matin = TempsDuJour(10, 0);
auto apresmidi = TempsDuJour(22, 0);

writefln("%s-%s", matin, apresmidi);
}

Pour le moment, toString() ne produit pas grand-chose, mais l'exécution du code ci-avant montre qu'elle a effectivement été appelée à deux reprises par writefln() :

a_faire-a_faire

Remarquez aussi que info() est désormais redondante vu que toString() offre la même fonctionnalité.

La façon la plus simple d'implémenter toString() serait d'appeler la fonction format(), celle-ci est disponible dans le module std.string. Le fonctionnement de format() est similaire à celui des fonctions d'affichage formaté comme writef(). La seule différence réside dans le fait qu'au lieu d'afficher directement les variables, format() retourne le résultat sous la forme d'une string.

toString() peut donc directement retourner le résultat de format() :

import std.string;
// ...
struct TempsDuJour
{
// ...
string toString()
{
return format("%02s:%02s", heure, minute);
}
}

Remarquez que toString() retourne uniquement la représentation textuelle de l'objet this. writefln() se charge d'afficher le reste : elle appelle la méthode toString() pour chacun des deux objets tout en prenant soin d'afficher le caractère '-' entre les deux appels, puis termine la ligne :

10:00-22:00

La définition de toString() qui a été expliquée ci-avant ne prend aucun paramètre, elle produit simplement une string et la retourne. Cependant, il existe une autre façon de définir toString(), celle-ci prend en paramètre un delegate. Nous étudierons cette définition plus tard dans le chapitre sur les pointeurs de fonctions, les delegates et les lambdas.

54-1-2 - Exemple : la méthode increment())

Définissons une méthode qui ajoute une certaine durée aux objets TempsDuJour.

Avant d'aller plus loin, corrigeons d'abord un défaut de conception qui était là tout le temps. Dans le chapitre sur des structures, nous avions vu que l'addition de deux objets TempsDuJour dans ajouterDuree n'avait pas de sens :

TempsDuJour ajouterDuree(in TempsDuJour start,
in TempsDuJour duree)    // absurde
{
// ...
}

Il serait plus naturel d'ajouter une durée à un point dans le temps. En ajoutant par exemple la durée d'un voyage à son temps de départ, on obtiendrait le temps d'arrivée.

D'un autre côté, soustraire deux points dans le temps est une opération naturelle, et dans ce cas le résultat serait une durée.

Le programme ci-après définit une structure Duree d'une précision allant à la minute, ainsi qu'une fonction ajouterDuree() qui l'utilise :

struct Duree
{
int minute;
}

TempsDuJour ajouterDuree(in TempsDuJour debut,
in Duree duree)
{
// Commençons par faire une copie de start
TempsDuJour resultat = debut;

// Ajoutons-y la durée
resultat.minute += duree.minute;

// Puis prenons soin de régler les débordements
resultat.heure += resultat.minute / 60;
resultat.minute %= 60;
resultat.heure %= 24;

return resultat;
}

unittest
{
// Un test banal
assert(ajouterDuree(TempsDuJour(10, 30), Duree(10))
== TempsDuJour(10, 40));

// Un temps à minuit
assert(ajouterDuree(TempsDuJour(23, 9), Duree(51))
== TempsDuJour(0, 0));

// Un temps dans le jour suivant
assert(ajouterDuree(TempsDuJour(17, 45), Duree(8 * 60))
== TempsDuJour(1, 45));
}

Maintenant, redéfinissons cette fonction sous la forme d'une méthode. Jusqu'à présent, ajouterDuree() retournait un nouvel objet à chaque appel. Définissons donc une méthode incrementer() qui modifiera directement l'objet this :

struct Duree
{
int minute;
}

struct TempsDuJour
{
int heure;
int minute;

string toString()
{
return format("%02s:%02s", heure, minute);
}

void $(HILITE incrementer)(in Duree duree)
{
minute += duree.minute;

heure += minute / 60;
minute %= 60;
heure %= 24;
}

unittest
{
auto temps = TempsDuJour(10, 30);

// Un test banal
temps$(HILITE .incrementer)(Duree(10));
assert(temps == TempsDuJour(10, 40));

// 15 heures plus tard devra être dans le jour suivant
temps$(HILITE .incrementer)(Duree(15 * 60));
assert(temps == TempsDuJour(1, 40));

// 22 heures et 20 minutes devra être à minuit
temps$(HILITE .incrementer)(Duree(22 * 60 + 20));
assert(temps == TempsDuJour(0, 0));
}
}

incrementer() incrémente la valeur de l'objet dépendamment de la durée qu'on lui passe. Plus tard, nous étudierons comment la surcharge d'opérateur (operator overloading) va nous permettre d'ajouter une durée à l'aide de l'opérateur += :

temps += Duree(10);    // sera expliqué dans un chapitre ultérieur

Notez également que les blocs de tests unitaires unittest peuvent être écrits à l'intérieur des définitions des struct aussi, c'est surtout le cas lorsque l'on souhaite tester ses méthodes. Mais il reste tout de même possible de déplacer de tels blocs unittest en dehors du corps de la structure :

struct TempsDuJour
{
// ... définition de la structure ...
}

unittest
{
// ... tests de la structure ...
}

54-2 - Exercices

1. Ajoutez une méthode decrementer() à TempsDuJour qui en retrancherait la durée de temps spécifiée. Tout comme pour incrementer(), elle devrait repasser au jour précédent s'il n'y a pas assez de temps dans le jour actuel. Par exemple, soustraire 10 minutes de 00:05 devra donner pour résultat 23:55.
En d'autres termes, implémentez decrementer() de façon à ne pas échouer les tests unitaires suivants :

struct TempsDuJour
{
// ...

void decrementer(in Duree duree)
{
// ... veuillez implémenter cette fonction ...
}

unittest
{
auto temps = TempsDuJour(10, 30);

// Un test banal
temps.decrementer(Duree(12));
assert(temps == TempsDuJour(10, 18));

// 3 jours and 11 heures plus tôt
temps.decrementer(Duree(3 * 24 * 60 + 11 * 60));
assert(temps == TempsDuJour(23, 18));

// 23 heures and 18 minutes plus tôt devra être à minuit
temps.decrementer(Duree(23 * 60 + 18));
assert(temps == TempsDuJour(0, 0));

// 1 minute plus tôt
temps.decrementer(Duree(1));
assert(temps == TempsDuJour(23, 59));
}
}

2. Convertissez également les surcharges de infos() en méthodes toString() des structures Reunion, Repas et MomentDeLaJournee.

Vous remarquerez qu'en plus de rendre leur structure respective plus pratique, les implémentations des méthodes toString() ne prendront chacune qu'une ligne de code.

55 - Les paramètres const ref et les fonctions membres const

Ce chapitre explique comment les paramètres et les fonctions membres peuvent être marqués comme const afin d'être utilisés avec des variables immutables. Comme nous avons déjà parlé des paramètres const dans les chapitres précédents, certaines informations de ce chapitre seront pour vous des révisions de fonctionnalités que vous connaissez déjà.

Bien que les exemples de ce chapitre n'utilisent que des structures, les fonctions membres const s'appliquent également aux classes.

55-1 - Les objets immutables

Nous avons déjà vu qu'il n'est pas possible de modifier des variables immutables :

immutable momentLecture = MomentDeLaJournee(15, 0);

momentLecture ne peut pas être modifiée~ :
momentLecture = MomentDeLaJournee(16, 0); // Erreur de compilation
momentLecture.minute = 10;

Le compilateur n'autorise en aucune façon la modification d'objets immutables.

55-2 - Les paramètres ref qui ne sont pas const

Nous avons déjà vu ce concept dans le chapitre sur les paramètres de fonction. Les paramètres marqués comme ref peuvent être modifiés par la fonction. C'est pour cette raison que même si la fonction ne modifie réellement le paramètre, le compilateur ne permet pas de passer des objets immuables par ces paramètres.

/* bien qu'elle ne soit pas modifiée, duree n'est pas marquée
comme 'const' */
int totalSecondes(ref Duree duree)
{
return 60 * duree.minute;
}
// ...
immutable tempsEchauffement = Duree(3);
totalSecondes(tempsEchauffement); // Erreur de compilation

Le compilateur ne permet pas de passer la variable immutable tempsEchauffement à la fonction totalSecondes parce que la fonction ne garantit pas que le paramètre ne sera pas modifié.

55-3 - Les paramètres const ref

const ref signifie que le paramètre ne sera pas modifié par la fonction :

int totalSecondes(const ref Duree duree)
{
return 60 * duree.minutes;
}
// ...
immutable tempsEchauffement = Duree(3);
totalSecondes(tempsEchauffement); // compile désormais

Ces fonctions peuvent recevoir des objets immutables comme paramètres parce que l'immutabilité de l'objet est vérifiée par le compilateur~ :
int totalSecondes(const ref Duree duree)
{
duree.minute = 7; // Erreur de compilation
}

Une alternative à const ref est in ref. Comme nous le verrons dans un chapitre ultérieur, in signifie que le paramètre est seulement utilisé en lecture par la fonction, désactivant toute possibilité de modification.

int totalSecondes(in ref Duree duree)
{
//...
}

55-4 - Fonctions membres non-const

Comme nous l'avons vu avec la fonction MomentDeLaJournee.incrementer(), les objets peuvent également être modifiés par leurs fonctions membres. incrementer() modifie les membres de l'objet sur lesquels elle est appelée :

struct MomentDeLaJournee
{
//...
void incrementer(in Duree duree)
{
minute += duree.minute;
heure += minute / 60;
minute %= 60;
heure %= 24;
}
//...
}

auto debut = MomentDeLaJournee(5, 30);
debut.incrementer(Duree(30)); // debut est modifié

Fonctions membres const
Certaines fonctions membres ne modifient pas les objets sur lesquels elles sont appelées. Par exemple, la fonction toString()~ :
struct MomentDeLaJournee
{
//...
string toString()
{
return format("%02s:%02s", heure, minute);
}
//...
}

Puisque le propre de toString() est de représenter l'objet dans un format textuel, il ne devrait pas modifier l'objet.

Le fait qu'une fonction membre ne modifie pas l'objet est déclaré par le mot-clé const après la liste des paramètres :

struct MomentDeLaJournee
{
// ...
string toString() const
{
return format("%02s:%02s", heure, minute);
}
}

Ce const garantit que l'objet lui-même ne sera pas modifié par la fonction membre. En conséquence, la fonction membre toString() peut être appelée, même sur un objet immutable. Autrement, toString() ne serait pas appelée automatiquement :

struct MomentDeLaJournee
{
// ...
// Conception de qualité inférieure,
// la fonction n'est pas marquée const
string toString()
{
return format("%02s:%02s", heure, minute);
}
}

// ...
immutable debut = MomentDeLaJournee(5, 30);
writeln(debut); // MomentDeLaJournee.toString() n'est pas appelé !

Le résultat du code précédent n'est pas la chaîne 05:30 comme on pourrait s'y attendre, ce qui indique qu'une fonction générique est appelée à la place de MomentDeLaJournee.toString() :

immutable(MomenDeLaJournee)(5, 30)

Qui plus est, un appel explicite à toString() génère une erreur à la compilation :

auto s = debut.toString(); // ERREUR

Selon ce principe, toutes les fonctions membres toString() que nous avons définies au chapitre précédent présentent le même défaut de conception et devraient toutes être marquées const.

Le mot-clé const peut aussi être spécifié avant la définition de la fonction :

// identique au code ci-avant
const string toString()
{
return format("%02s:%02s", heure, minute);
}

Puisque cette version pourrait donner la fausse impression que le const se réfère au type retourné, je vous recommande de spécifier const après la liste de paramètres.

55-5 - Fonctions membres inout

Comme nous l'avons vu dans le chapitre sur les paramètres de fonction, inout transfère la mutabilité d'un paramètre au type retourné par la fonction.

De la même manière, une fonction membre inout transfère la mutabilité de l'objet courant au type retourné par la fonction.

import std.stdio;

struct Conteneur
{
int[] elements;

inout(int)[] premierePartie(size_t n) inout
{
return elements[0..n];
}
}

void main()
{
{
// un conteneur immutable
auto conteneur = immutable(Conteneur)([1, 2, 3]);
auto tranche = conteneur.premierePartie(2);
writeln(typeof(tranche).stringof);
}
{
// un conteneur const
auto conteneur = const(Conteneur)([1, 2, 3]);
auto tranche = conteneur.premierePartie(2);
writeln(typeof(tranche).stringof);
}
{
// un conteneur mutable
auto conteneur = Conteneur([1, 2, 3]);
auto tranche = conteneur.premierePartie(2);
writeln(typeof(tranche).stringof);
}
}

Les trois tranches qui sont retournées par les trois objets ayant des mutabilités différentes sont cohérentes avec les objets qui les ont retournées :

immutable(int)[]
const(int)[]
int[]

Parce qu'elle doit pouvoir être appelée sur des objets const comme sur des objets immutables, une fonction membre inout est compilée comme si elle était const.

55-6 - Mode d'emploi

Pour donner la garantie qu'un paramètre n'est pas modifié par la fonction, marquez ce paramètre in, const ou const ref.

Marquez les fonctions membres qui ne modifient pas l'objet courant comme const.

struct MomentDeLaJournee
{
// ...
string toString() const
{
return format("%02s:%02s");
}
}

Cela rendra la structure (ou la classe) plus utile en supprimant une limitation inutile. Les exemples dans la suite de ce livre respecteront ce principe.

56 - Surcharge des opérateurs

Les sujets abordés dans ce chapitre s'appliquent également pour la plupart aux classes. La principale différence est que le comportement de l'opération d'affectation opAssign() ne peut pas être surchargé pour les classes.
La surcharge des opérateurs met en œuvre de nombreux concepts dont certains seront abordés plus loin dans ce livre (les modèles, auto ref, etc.). Pour cette raison, vous pourriez trouver ce chapitre plus difficile à suivre que les précédents.
La surcharge des opérateurs permet de définir comment des types définis par l'utilisateur se comportent lorsqu'ils sont utilisés avec des opérateurs. Dans ce contexte, surcharger signifie fournir une définition d'un opérateur pour un typespécifique.

Nous avons vu comment définir des structures et leurs fonctions membres dans les chapitres précédents. Par exemple, nous avons défini la fonction incrementer() afin d'ajouter des objets Duree à des objets MomentDeLaJournee. Voici les deux structures des chapitres précédents, avec seulement les parties qui sont importantes pour ce chapitre :

struct Duree
{
int minute;
}

struct MomentDeLaJournee
{
int heure;
int minute;

void incrementer(in Duree duree)
{
minute += duree.minute;
heure += minute / 60;
minute %= 60;
heure %= 24;
}
}

void main()
{
auto momentDuRepas = MomentDeLaJournee(12, 0);
momentDuRepas.incrementer(Duree(10));
}

L'avantage des fonctions membres est de pouvoir définir les opérations d'un type avec les variables membres de ce type.
Malgré leur avantage, les fonctions membres peuvent être vues comme limitées comparées aux opérations sur les types fondamentaux. Après tout, les types fondamentaux peuvent être utilisés avec des opérateurs :

int masse = 50;
masse += 10; // avec un opérateur

Selon ce que l'on a déjà vu jusqu'à présent, de telles opérations ne peuvent être rendues possibles que par des fonctions membres pour les types utilisateur :

auto momentDuRepas = MomentDeLaJournee(12, 0);
momentDuRepas.incrementer(Duree(10));        // avec une fonction membre

La surcharge des opérateurs permet d'utiliser également les opérateurs avec les structures et les classes. Par exemple, en partant du principe que l'opérateur += est défini pour MomentDeLaJournee, l'opération ci-avant peut-être écrite de la même manière que pour un type fondamental :

momentDuRepas += Duree(10); // avec un opérateur (même pour une structure)

Avant de rentrer dans les détails de la surcharge des opérateurs, voyons tout d'abord comment la ligne ci-avant serait rendue possible pour MomentDeLaJournee. Ce qu'il nous faut, c'est renommer la fonction incrementer et lui donner le nom spécial opOpAssign(string op) et spécifier que cette définition vaut pour le caractère +. Comme cela sera expliqué ci-après, cette définition correspond à l'opérateur +=.

La définition de cette fonction membre ne ressemble pas à celles que nous avons vues jusqu'à présent. C'est parce que opOpAssign est une fonction modèle. Puisque nous verrons les modèles bien plus tard dans ce livre, je vous demande pour le moment de bien vouloir accepter telle quelle la syntaxe de surcharge des opérateurs :

struct MomentDeLaJournee
{
// ...
ref MomentDeLaJournee opOpAssign(string op)(in Duree duree) //(1)
if (op == "+")                                          //(2)
{
minute += duree.minute;
heure += minute / 60;
minute %= 60;
heure %= 24;

return this;
}
}

La définition du modèle consiste en deux parties :

1. opOpAssign(string op) : cette partie doit être écrite telle quelle et devrait être acceptée comme étant le nom de la fonction. Nous verrons ci-après qu'il existe d'autres fonctions membres en plus de opOpAssign :

2. if (op == "+") : opOpAssign sert pour plus d'une surcharge d'opérateur. « + » spécifie qu'il s'agit de la surcharge d'opérateur qui correspond au caractère +. Cette syntaxe est une contrainte de modèle, qui sera abordée dans des chapitres ultérieurs.

Veuillez par ailleurs noter que type de retour est différent de celui de la fonction membre incrementer : ce n'est plus void. Nous discuterons des types de retours des opérateurs ci-après.

En coulisse, le compilateur remplace les utilisations de l'opérateur += par des appels à la fonction membre opOpAssign!"+" :

momentDuRepas += Duree(10);

// la ligne suivante est l'équivalent de la précédente
momentDuRepas.opOpAssign!"+"(Duree(10));

La partie !« + » qui est après opOpAssign spécifie que cet appel vaut pour la définition de l'opérateur avec le caractère +. Nous verrons également cette syntaxe dans le chapitre sur les modèles.
Notez que l'opérateur qui correspond à += est défini par « + » et pas par « += ». Le mot Assign (affectation en anglais) dans opOpAssign() implique déjà que c'est un opérateur d'affectation.
Pouvoir définir le comportement des opérateurs implique une responsabilité : le programmeur doit respecter les attentes de son public. Comme exemple extrême, l'opérateur précédent pourrait avoir été défini pour décrémenter du temps au lieu d'en incrémenter. Néanmoins, les gens qui liraient le code s'attendraient naturellement à ce que l'opérateur += incrémente la valeur qui lui est passée.
Dans une certaine mesure, le type de retour d'un opérateur peut aussi être choisi librement. Cependant, les attentes générales doivent également être respectées pour les types de retours.
Gardez à l'esprit que les opérateurs qui ne se comportent pas naturellement sèment la confusion et sont cause de bogues.

56-1 - Opérateurs surchargeables

Il y a différents types d'opérateurs qui peuvent être surchargés.

56-1-1 - Opérateurs unaires

Un opérateur qui ne prend qu'un seul opérande est appelé un opérateur unaire :

++masse;

++ est un opérateur unaire parce qu'il n'agit que sur une seule variable.

Les opérateurs unaires sont définis par des fonctions membres appelées opUnary. OpUnary ne prend aucun paramètre parce qu'elle n'utilise que le seul objet sur lequel l'opérateur est exécuté.
Les opérateurs unaires qui peuvent être surchargés et leur chaîne de caractères correspondante sont les suivants :

Opérateur

Description

Chaîne de caractères

-objet

Négatif (opposé numérique)

« - »

+objet

Même valeur (ou une copie)

« + »

~objet

Négation bit à bit

""

*objet

Accès à la valeur pointée

« * »

++objet

incrément

« ++ »

--objet

décrément

« -- »

Par exemple, l'opérateur ++ pour Duree peut-être défini comme ceci :

struct Duree
{
int minute;

ref Duree opUnary(string op)()
if (op == "++")
{
++minute;
return this;
}
}

Notez que le type de retour de l'opérateur est également marqué comme ref. Ceci sera expliqué plus tard. Les objets Duree peuvent maintenant être incrémentés avec ++ :

auto duree = Duree(20);
++duree;

Les opérateurs de postincrément et de postdécrément ne peuvent pas être surchargés. Les cas d'utilisation d'objet++ et objet-- sont gérés automatiquement, pris en charge par le compilateur en sauvegardant la valeur précédente de l'objet. Par exemple, le compilateur applique l'équivalent du code suivant pour le postincrément :

/* La valeur précédente est copiée par le compilateur
automatiquement */
Duree __valeurPrecedente__ = duree;

/* L'opérateur ++ est appelé */
++duree;

/* Puis __valeurPrecedente__ est retournée comme valeur de retour
* de l'opération de postincrément */

Contrairement à certains autres langages, la copie à l'intérieur du postincrément n'a aucun coût en D si la valeur de retour du postincrément n'est pas utilisée. C'est parce que le compilateur remplace ces expressions de postincrément par leur homologue préincrément :

/* La valeur de l'expression n'est pas utilisée ci-après, le
seul effet de l'expression est d'incrémenter 'i'. */
i++;

Parce ce que la précédente valeur de i n'est pas utilisée dans le code ci-avant, le compilateur remplace l'expression par la suivante :

/* L'expression qui est réellement utilisée par le compilateur: */
++i;

Par ailleurs, si une surcharge de opBinary supporte l'utilisation de duree += 1, alors la surcharge de opUnary n'est pas nécessaire pour ++duree et duree++. Dans ce cas, le compilateur utilise duree += 1 en coulisse. De la même manière, la surcharge de duree -= 1 couvre les utilisations de --duree et de duree--.

56-1-2 - Opérateurs binaires

Un opérateur qui prend deux opérandes et appelé opérateur binaire :

masseTotale = masseBoite + masseChocolat;

La ligne ci-dessus contient deux opérateurs binaires différents : l'opérateur +, qui additionne les valeurs des opérandes qui sont sur ses deux côtés, et l'opérateur = qui affecte la valeur de son opérande de droite à son opérande de gauche.
La colonne de droite ci-après décrit la catégorie de chaque opérateur. Ceux marqués comme « = » affectent une valeur à l'objet à leur gauche.

Opérateur

Description

Nom de fonction

Nom de fonction côté droit

Catégorie

+

Addition

opBinary

opBinaryRight

arithmétique

-

Soustraction

opBinary

opBinaryRight

arithmétique

*

Multiplication

opBinary

opBinaryRight

arithmétique

/

Division

opBinary

opBinaryRight

arithmétique

%

Reste

opBinary

opBinaryRight

arithmétique

^^

Puissance

opBinary

opBinaryRight

arithmétique

&

Et bit à bit

opBinary

opBinaryRight

bit à bit

|

Ou bit à bit

opBinary

opBinaryRight

bit à bit

^

Ou exclusif bit à bit

opBinary

opBinaryRight

bit à bit

<<

Décalage à gauche

opBinary

opBinaryRight

bit à bit

>

Décalage à droite

opBinary

opBinaryRight

bit à bit

>>

Décalage à droite non signé

opBinary

opBinaryRight

bit à bit

~

Concaténation

opBinary

opBinaryRight

in

Est contenu dans

opBinary

opBinaryRight

==

Est égal à

opEquals

Égalité

!=

Est différent de

opEquals

Égalité

<

Est avant

opCmp

Ordre

<=

N'est pas après

opCmp

Ordre

Est après

opCmp

Ordre

=

N'est pas avant

opCmp

Ordre

=

Affectation

opAssign

=

+=

Additionne et affecte

opOpAssign

=

-=

Soustrait et affecte

opOpAssign

=

*=

Multiplie et affecte

opOpAssign

=

/=

Divise et affecte

opOpAssign

=

%=

Affecte le reste de

opOpAssign

=

^^=

Élève à la puissance et affecte

opOpAssign

=

&=

Affecte le résultat de &

opOpAssign

=

|=

Affecte le résultat de |

opOpAssign

=

^=

Affecte le résultat de ^

opOpAssign

=

<<=

Affecte le résultat de <<

opOpAssign

=

>=

Affecte le résultat de >>

opOpAssign

=

>>=

Affecte le résultat de >>>

opOpAssign

=

~=

Affecte le résultat de ~

opOpAssign

=

opBinaryRight est utilisé lorsque l'objet peut apparaître du côté droit de l'opérateur. Supposons qu'un opérateur binaire que nous appellerons op apparaisse dans le programme :

x op y

Pour déterminer quelle fonction membre appeler, le compilateur considère les deux options suivantes :

// la définition pour x à gauche :
x.opBinary!"op"(y);

// la définition pour y à droite :
y.opBinaryRight!"op"(x);

Le compilateur choisit l'option qui correspond le mieux.
Dans la plupart des cas, il n'est pas nécessaire de définir opBinaryRight, sauf pour l'opérateur in : il est souvent plus pertinent de définir l'opérateur in au moyen de opBinaryRight.
Le nom de paramètre rhs qui apparaît dans les définitions ci-après est une abréviation de l'anglais right-hand side. Il indique que l'opérande apparaît du côté droit de l'opérateur :

x op y

Pour l'expression ci-dessus, le paramètre rhs représenterait la variable y.

56-1-3 - Opérateur d'indexation et de tranchage d'éléments

Les opérateurs suivants permettent d'utiliser un type comme une collection d'éléments :

Description

Nom de fonction

Exemple d'utilisation

Accès à un élément

opIndex

collection[i]

Affectation d'un élément

opIndexAssign

collection[i] = 7

Opération unaire sur un élément

opIndexUnary

++collection[i]

Opération avec affectation à un élément

opIndexOpAssign

collection[i] *= 2

Nombre d'éléments

opDollar

collection[$-1]

Tranche de tous les éléments

opSlice

collection[]

Tranche de certains éléments

opSlice(size_t, size_t)

collection[i..j]

Nous aborderons ces opérateurs plus loin.

Les opérateurs suivants viennent d'une ancienne version de D. Leur utilisation est découragée :

Description

Nom de fonction

Exemple d'utilisation

Opération unaire sur tous les éléments

opSliceUnary (découragée)

++collection[]

Opération unaire sur certains éléments

opSliceUnary (découragée)

++collection[i..j]

Affectation de tous les éléments

opSliceAssign (découragée)

collection[] = 42

Affectation de certains éléments

opSliceAssign (découragée)

collection[i..j] = 7

Opération avec affectation de tous les éléments

opSliceOpAssign (découragée)

collection[] *= 2

Opération avec affectation de certains éléments

opSliceOpAssign (découragée)

collection[i..j] *= 2

56-1-4 - Autres opérateurs

Les opérateurs suivants peuvent également être surchargés :

Description

Nom de fonction

Exemple d'utilisation

Appel de fonction

opCall

objet(42)

Conversion de type

opCast

to!int(objet

Transfert de fonction inexistante

opDispatch

objet.inexistante()

Ces opérateurs seront expliqués plus loin dans leur propre section.

56-1-5 - Définir plusieurs opérateurs simultanément

Pour garder des exemples de codes courts, nous avons jusqu'à présent utilisé les opérateurs ++, +, et +=. Lorsqu'un opérateur est surchargé pour un type, il est concevable que de nombreux autres doivent l'être également. Par exemple, les opérateurs -, -- et -= sont aussi définis pour la Duree suivante :

struct Duree
{
int minute;

ref Duree opUnary(string op)()
if (op == "++")
{
++minute;
return this;
}

ref Duree opUnary(string op)()
if (op == "--")
{
--minute;
return this;
}

ref Duree opOpAssign(string op)(in int montant)
if (op == "+")
{
minute += montant;
return this;
}

ref Duree opOpAssign(string op)(in int montant)
if (op == "-")
{
minute -= montant;
return this;
}
}

unittest
{
auto duree = Duree(10);

++duree;
assert (duree.minute == 11);

--duree;
assert (duree.minute == 10);

duree += 5;
assert (duree.minute == 15);

duree -= 3;
assert (duree.minute == 12);
}

void main()
{}

Les surcharges d'opérateurs ci-dessus dupliquent beaucoup de code. Les seules différences entre les fonctions similaires sont surlignées. De telles duplications de code peuvent être réduites et quelquefois évitées au moyen des mixins de chaînes. Nous verrons également le mot-clé mixin dans un chapitre ultérieur. Je voudrais juste vous montrer brièvement comment ce mot-clé facilite la surcharge d'opérateurs.
mixin insère la chaîne spécifiée comme code source à l'endroit même où la déclaration mixin apparaît dans le code. La structure suivante est équivalente à celle plus avant :

struct Duree
{
int minute;

ref Duree opUnary(string op)()
if ((op == "++") || (op == "--"))
{
mixin (op ~ "minute;");
return this;
}

ref Duree opOpAssign(string op)(in int montant)
if ((op == "+" || op == "-"))
{
mixin ("minute " ~ op ~ "= montant;");
return this;
}
}

Si les objets Duree doivent également pouvoir être multipliés ou divisés par un montant, la seule chose nécessaire est d'ajouter deux conditions supplémentaires à la contrainte de modèle :

struct Duree
{
// ...

ref Duree opOpAssign(string op)(in int montant)
if ((op == "+") || (op == "-") ||
(op == "*") || (op == "/"))
{
mixin ("minute " ~ op ~ "= montant;");
return this;
}
}

unittest
{
auto duree = Duree(12);

duree *= 4;
assert (duree.minute == 48);

duree /= 2;
assert (duree.minute == 24);
}

En fait, les contraintes de modèles sont optionnelles :

ref Duree opOpAssign(string op)(in int montant)
// ← pas de contrainte
{
mixin ("minute " ~ op ~ "= montant;");
return this;
}

56-1-6 - Types de retour des opérateurs

Lorsqu'un opérateur est surchargé, il est conseillé de garder le même type de retour que le même opérateur sur les types fondamentaux. Cela aide à la compréhension du code et réduit la confusion.
Aucun opérateur ne renvoie void lorsqu'il est appliqué sur des types fondamentaux. Ceci devrait vous paraître évident pour certains opérateurs. Par exemple, le résultat de l'addition de deux valeurs int a + b est int.

int a = 1;
int b = 2;
int c = a + b; // c est initialisé avec la valeur de retour de
// l'opérateur +

Les valeurs de retour de certains opérateurs sont moins évidentes. Par exemple, même les opérateurs comme ++i ont une valeur :

int i = 1;
writeln(++i); // affiche 2

L'opérateur ++ ne se contente pas d'incrémenter i, il renvoie également la nouvelle valeur de celui-ci. Plus exactement, ce qui est retour par ++ n'est pas seulement la nouvelle valeur de i mais plutôt la variable i elle-même. Nous pouvons le voir en affichant l'adresse du résultat de cette expression :

int i = 1;
writeln("L'adresse de i               : ", &i);
writeln("L'adresse du résultat de ++i : ", &(++i));

La sortie contient des adresses identiques :

L`adresse de i               : 7FFF39BFEE78
L`adresse du résultat de ++i : 7FFF39BFEE78

Je vous recommande de suivre les consignes suivantes lorsque vous surchargez des opérateurs pour vos propres types.

56-1-6-1 - Les opérateurs qui modifient l'objet

À l'exception de opAssign, il est recommandé que les opérateurs qui modifient l'objet retournent ce même objet. Cette consigne a été observée ci-avant par MomentDeLaJournee.opOpAssign!"+" et Duree.opUnary!"++".
Pour retourner l'objet courant il faut suivre ces deux étapes :

1. Le type de retour doit être le type de la structure, précédé du mot-clé ref qui signifie référence ;

2. La fonction doit se terminer par return this, qui signifie retourner cet objet.

Les opérateurs qui modifient l'objet sont opUnary!"++", o pUnary!"--" et toutes les surcharges de opOpAssign.

56-1-6-2 - Les opérateurs logiques

opEquals, qui représente == et != doit retourner un bool. Bien que l'opérateur in retourne normalement l'objet contenu, il peut tout aussi bien retourner un bool.

56-1-6-3 - Les opérateurs d'ordre

opCmp, qui représente <, <=, > et >= doit retourner un int.

56-1-6-4 - 1.6.4 Les opérateurs qui créent un nouvel objet

Certains opérateurs doivent créer et retourner un nouvel objet :

- les opérateurs unaires -, +,  et l'opérateur binaire ;

- les opérateurs arithmétiques +, -, *, /, % et ^^ ;

- les opérateurs bit à bit &, |, ^, <<, >>, et >>> ;

- comme vu dans le chapitre précédent, opAssign retourne une copie de l'objet au moyen de return this.

À des fins d'optimisation, il peut être préférable que opAssign renvoie une const ref pour les grosses structures. Je n'utiliserai pas cette optimisation dans ce livre.

Comme exemple d'un opérateur qui crée un nouvel objet, définissons la surcharge de l'opérateur opBinary!"+" pour Duree. Cet opérateur doit additionner deux objets Duree et en retourner un nouveau.

struct Duree {
int minute;

Duree opBinary(string op)(in Duree, rhs) const
if (op == "+") {
return Duree(minute + rhs.minute); // nouvel objet
}
}

Cette définition nous permet d'additionner deux objets Duree au moyen de l'opérateur + :

auto dureeVoyage = Duree(10);
auto dureeRetour = Duree(11);
Duree dureeTotale;
// ...
dureeTotale = dureeVoyage + dureeRetour;

Le compilateur remplace cette expression par l'appel de fonction membre suivant sur l'objet dureeVoyage :

// Équivalent à l'expression ci-avant
dureeTotale = dureeVoyage.opBinary!"+"(dureeRetour);

56-1-6-5 - opDollar

Comme il retourne le nombre d'éléments d'un conteneur, le type le plus adapté pour opDollar est size_t. Néanmoins le type de retour peut aussi être différent (par exemple, int).

56-1-6-6 - Les opérateurs non contraints

Pour certains opérateurs, le type de retour dépend entièrement de la conception du type défini par l'utilisateur : le * unaire, opCall, opCast, opDispatch, opSlice et toutes les variétés de opIndex.

56-1-7 - Les comparaisons d'égalité avec opEquals()

Cette fonction membre définit les comportements des opérateurs == et !=. Le type de retour de opEquals est bool.
Pour les structures, le paramètre de opEquals peut être défini comme in. Cependant, pour des questions de vitesse opEquals peut être défini comme un modèle qui prend une auto ref const (notez également les parenthèses vides ci-après) :

bool opEquals()(auto ref const MomentDeLaJournee rhs) const {
// ...
}

Comme nous l'avons vu dans le chapitre sur les lvalues et les rvalues, auto ref permet de passer les lvalues par référence et les rvalues par copie. Néanmoins, puisque les rvalues ne sont pas copiées, mais déplacées, la signature ci-avant est efficace pour les lvalues comme pour les rvalues.
Pour éviter toute confusion, opEquals et opCmp doivent travailler de façon cohérente. Pour deux objets avec lesquels opEquals retourne true, opCmp doit retourner zéro.
Une fois qu'opEquals a été défini pour l'égalité, le compilateur utilise son opposé pour l'inégalité :

x == y;
// l'équivalent à l'expression précédente :
x.opEquals(y);

x != y;
// l'équivalent à l'expression précédente :
!(x.opEquals(y));

Normalement, il n'est pas nécessaire de définir opEquals() pour les structures. Le compilateur la génère automatiquement. La fonction opEquals() générée automatiquement compare tous les membres individuellement.
Parfois, ce comportement automatique de l'égalité de deux objets doit être redéfini. Par exemple, certains membres peuvent ne pas être significatifs pour cette comparaison, ou alors l'égalité peut dépendre d'une logique plus complexe.
Juste pour exemple, définissons opEquals() de façon à ne pas tenir compte des minutes :

struct MomentDeLaJournee
{
int heure;
int minute;

bool opEquals(in MomentDeLaJournee rhs) const
{
return heure == rhs.heure;
}
}

// ....

assert (MomentDeLaJournee(20, 10) == MomentDeLaJournee(20, 59));

Puisque la comparaison d'égalité ne considère plus que le membre heure, 20h10 et 20h59 reviennent à la même chose. (Ceci vous est juste montré à titre d'exemple, il est clair que ce genre de comparaison d'égalité génère de la confusion.)

56-1-8 - L'ordre avec opCmp()

Les opérateurs d'ordre déterminent l'ordre de tri des objets. Tous les opérateurs d'ordre <, <=, > et >= sont couverts par la fonction opCmp().
Pour les structures, le paramètre de opCmp peut être défini comme in. Néanmoins, comme pour opEquals, il est plus efficace de définir opCmp comme un modèle qui prend un paramètre auto ref const :

int opCmp()(auto ref const MomentDeLaJournee rhs) const {
// ...
}

Pour éviter toute confusion, opEquals et opCmp doivent travailler de façon cohérente. Pour deux objets avec lesquels opEquals retourne true, opCmp doit retourner zéro.
Imaginons qu'un de ces quatre opérateurs soit utilisé dans le code suivant :

if (x op y) { // ← op est soit <, <=, > ou >=

Le compilateur transforme cette expression en l'expression logique suivante et utilise le résultat dans la nouvelle expression logique :

if (x.opCmp(y) op 0 {

Prenons l'opérateur <=, par exemple :

if (x <= y) {

Le compilateur génère le code suivant en coulisse :

if (x.opCmp(y) <= 0) {

Pour qu'une fonction opCmp définie par l'utilisateur fonctionne correctement, cette fonction doit renvoyer un résultat selon les règles suivantes :

- une valeur négative si l'objet de gauche est considéré comme précédent l'objet de droite ;

- une valeur positive si l'objet de gauche est considéré comme suivant l'objet de droite ;

- zéro si les deux objets ont le même ordre de tri.

Pour pouvoir prendre en charge ces valeurs, le type de retour de opCmp() doit être un int, pas un bool.
Voici une façon d'ordonner les objets MomentDeLaJournee en comparant tout d'abord les membres heure, puis les membres minute (seulement si les valeurs des membres heure sont égales) :

int opCmp(in MomentDeLaJournee rhs) const {
/* Note: la soustraction est un bogue ici si les valeurs peuvent
* déborder. (voir l'avertissement suivant dans le texte */

return (heure == rhs.heure
? minute - rhs.minute
: heure - rhs.heure);
}

Cette définition retourne la différence entre les valeurs de minute quand les membres heure sont identiques, et la différence entre les membres heure autrement. La valeur retournée sera négative lorsque l'objet de gauche arrive avant dans l'ordre chronologique, positive lorsque l'objet de droite arrive avant, et zéro si les deux objets représentent le même moment de la journée.
Avertissement : utiliser des soustractions pour implémenter opCmp est un bogue si les valeurs valides d'un membre peuvent causer des débordements. Par exemple, les deux objets ci-après seront triés incorrectement puisque l'objet avec la valeur -2 est calculé comme étant plus grand que celui avec la valeur int.max.

struct S {
int i;

int opCmp(in S rhs) const {
return i - rhs.i;         // ← bogue
}
}

void main() {
assert (S(-2) > S(int.max)); // ← mauvais ordre de tri
}

D'un autre côté, la soustraction est parfaitement acceptable pour les objets MomentDeLaJournee car aucune valeur valide des membres de cette structure ne peut causer de débordement lors de la soustraction.
Pour comparer des tranches (ainsi que tous les types de chaînes et les intervalles), vous pouvez utiliser std.algorithm.cmp. cmp() compare les tranches de manière lexicographique et produit une valeur négative, nulle ou positive en fonction de leur ordre. Le résultat peut être directement utilisé comme valeur de retour de opCmp :

import std.algorithm;

struct S {
string nom;

int opCmp(in S rhs) const
{
return cmp(nom, rhs.nom);
}
}

Une fois qu'opCmp est défini, un type peut être utilisé avec des algorithmes de tri comme std.algorithm.sort. Lorsque sort() travaille sur les éléments, l'opérateur opCmp() est appelé en arrière-plan pour déterminer leur ordre. Le programme suivant construit dix objets avec des valeurs aléatoires et les trie avec sort() :

import std.random;
import std.stdio;
import std.string;
import std.algorithm;

struct MomentDeLaJournee
{
int heure;
int minute;
int opCmp(in MomentDeLaJournee rhs) const {
return (heure == rhs.heure
? minute - rhs.minute
: heure - rhs.heure);
}

string toString() const {
return format("%02s:%02s", heure, minute);
}
}

void main() {
MomentDeLaJournee[] moments;

foreach (i; 0 .. 10) {
moments ~= MomentDeLaJournee(uniform(0, 24), uniform(0, 60));
}

sort(moments);

writeln(moments);
}

Comme attendu, les éléments sont triés du plus tôt au plus tard :

[03:40, 04:10, 09:06, 10:03, 10:09, 11:04, 13:42, 16:40, 18:03, 21:08]

56-1-9 - Appeler les objets comme des fonctions : opCall()

Les parenthèses autour de la liste de paramètres lors de l'appel de fonction sont aussi un opérateur. Nous avons déjà vu comment static opCall() rend possible l'utilisation d'un nom de type comme fonction. static opCall() permet de créer des objets de types définis par l'utilisateur avec des valeurs par défaut au moment de l'exécution.
Un opCall() non statique permet d'utiliser les objets de type définis par l'utilisateur comme des fonctions :

Foo foo;
foo();

L'objet foo ci-dessus est appelé comme une fonction.
Par exemple, considérons une struct qui représente une équation linéaire. Cette struct sera utilisée pour calculer les valeurs de y de l'équation linéaire suivante pour les valeurs x spécifiques :

y = ax + b

La méthode opCall() suivante calcule et retourne simplement la valeur de y selon cette équation :

struct EquationLineaire {
double a;
double b;

double opCall(double x) const {
return a * x + b;
}
}

Avec cette définition, chaque objet de EquationLineaire représente une équation linéaire pour des valeurs spécifiques a et b. Un tel objet peut être utilisé comme une fonction qui calcule des valeurs de y :

EquationLineaire equation = { 1.2, 3.4 };
// l'objet est utilisé comme une fonction
double y = equation(5.6);

Note : définir opCall pour une structure désactive le constructeur par défaut généré par le compilateur. C'est pourquoi la syntaxe { } est utilisée ci-dessus au lieu de la version recommandée en EquationLineaire(1.3, 3.4) . Lorsque cette dernière syntaxe est désirée, une méthode static opCall() qui prend DEUX paramètres double doit également être définie.

La variable equation ci-avant représente l'équation y = 1,2x + 3,4. L'utilisation de cet objet comme une fonction exécute la fonction membre opCall().
Cette fonctionnalité peut-être utile pour définir et stocker les valeurs de a et b une fois dans un objet et utiliser cet objet plusieurs fois par la suite. Le code suivant utilise un tel objet dans une boucle :

EquationLineaire equation = { 0.01, 0.4 };

for (double x = 0.0; x <= 1.0; x += 0.125) {
writefln("%f: %f", x, equation(x));
}

Cet objet représente l'équation y = 0,01x + 0,4. Il est utilisé pour calculer les résultat des valeurs de x dans l'intervalle 0 à 1.

56-1-10 - Les opérateurs d'indexation

opIndex, opIndexAssign, opIndexUnary, opIndexOpAssign et opDollar rendent l'utilisation des opérateurs d'indexation sur les types définis par l'utilisateur similaires à des tableaux comme dans des objets.
Contrairement aux tableaux, ces opérateurs prennent aussi en charge l'indexation sur plusieurs dimensions. Les multiples valeurs d'index sont spécifiées comme une liste séparée par des virgules à l'intérieur des crochets (ex. : objetindex1). Dans les exemples suivants, nous utiliserons ces opérateurs avec une seule dimension et nous aborderons leurs usages multidimensionnels dans le chapitre sur plus de modèles.
La variable deque dans les exemples suivants est un objet de struct FileADoubleSens, que nous définirons plus tard ; et e est une variable de type int.
opIndex sert à l'accès aux éléments. L'index passé entre les crochets devient le paramètre de la fonction opérateur :

e = deque[3];               // l'élement à l'index 3
e = deque.opIndex(3);       // équivalent à la ligne ci-dessus

opIndexAssign sert à affecter une valeur à un élément. Le premier paramètre est la valeur qui est affectée et le second est l'index de l'élément :

deque[5] = 55;              // Affecte 55 à l'élément à l'index 5
deque.opIndexAssign(55, 5); // équivalent à la ligne ci-dessus

opIndexUnary est similaire à opUnary. La différence est que l'opération est appliquée à l'élément à l'index spécifié :

++deque[4];                 // Incrémente l'élément à l'index 4
deque.opIndexUnary!"++"(4); // équivalent à la ligne ci-dessus

opIndexOpAssign est similaire à opOpAssign. La différence est que l'opération est appliquée à un élément :

deque[6] += 66;                   // ajoute 66 à l'élément à l'index 6
deque.opIndexOpAssign!"+"(66, 6); // équivalent à la ligne ci-dessus

opDollar définit le caractère $ qui est utilisé durant les indexations et les tranchages. Il sert à retourner le nombre d'éléments dans le conteneur :

e = deque[$ - 1];                // le dernier élément
e = deque[deque.opDollar() - 1]; // équivalent à la ligne ci-dessus

56-1-10-1 - Un exemple d'opérateurs d'indexation

Une file à double sens (Double Ended Queue, aussi appelée Deque) est une structure de données qui est similaire aux tableaux, mais qui fournit également une insertion en début de séquence efficace (au contraire, insérer un élément au début d'un tableau nécessite de déplacer les éléments existants dans un tableau nouvellement créé).
Une manière d'implémenter une file à double sens est d'utiliser deux tableaux en coulisse, mais d'utiliser le premier en sens inverse. L'élément qui est inséré en tête de séquence est en fait ajouté à la fin du tableau de tête. Finalement cette opération est aussi efficace qu'ajouter un élément en fin de séquence.
La struct suivante implémente une file à double sens qui surcharge les opérateurs que nous avons vus dans cette section :

import std.stdio;
import std.string;
import std.conv;

struct FileADoubleSens // Aussi appelée Deque
{
private:

/* Les éléments sont représentés comme le chaînage des deux tranches
* membres. Néanmoins, la tête est indexée en sens inverse de
* manière à ce que le premier élément de la collection entière soit
* tete[$-1], le second tete[$-2], etc.:
*
* tete[$-1], tete[$-2], tete[$-3],...tete[0], queue[0], ... queue[$-1]
*/
int[] tete;
int[] queue;

/* Détermine la tranche réelle où l'élément spécifié reside
* et le retourne en référence */
ref inout(int) elementA(size_t index) inout {
return (index < tete.length
? tete[$-1 - index]
: queue[index - tete.length]);
}

public:

string toString() const {
string resultat;

foreach_reverse (element; tete) {
resultat ~= format("%s ", to!string(element));
}

foreach (element; queue) {
resultat ~= format("%s ", to!string(element));
}

return resultat;
}

/* Note: Comme nous le verrons dans le chapitre suivant,
le code suivant est une implémentation plus simple et plus
efficace de toString(): */
version (none) {
void toString(void delegate(const(char)[]) sink) const {
import std.format;
import std.range;

formattedWrite(sink, "%(%s %)", chain(head.retro, tail));
}
}

/* ajoute un élément en tête de collection. */
void insererEnTete(int valeur) {
tete ~= valeur;
}

/* ajoute un élément en bout de collection. */
ref FileADoubleSens opOpAssign(string op)(int valeur)
if (op == "~") {
queue ~= valeur;
return this;
}

/* Retourne l'élément spécifié
*
* Exemple: deque[index]
*/
inout(int) opIndex(size_t index) inout {
return elementA(index);
}

/* applique une opération unaire à l'élément spécifié.
*
* exemple: ++deque[index]
*/
int opIndexUnary(string op)(size_t index) {
mixin ("return " ~ op ~ " elementA(index);");
}

/* affecte une valeur à l'élément spécifié.
*
* exemple: deque[index] = valeur
*/
int opIndexAssign(int valeur, size_t index) {
return elementA(index) = valeur;
}

/* utilise l'élément spécifié et une valeur dans une opération
* et affecte ce résultat à cet élément.
*
* exemple: deque[index] += valeur
*/
int opIndexOpAssign(string op)(int valeur, size_t index) {
mixin ("return elementA(index) " ~ op ~ "= valeur;");
}

/* définit le caractère $, qui est la taille de la collection.
*
* exemple: deque[$ - 1]
*/
size_t opDollar() const {
return tete.length + queue.length;
}
}

void main() {
auto deque= FileADoubleSens();

foreach (i; 0 .. 10) {
if (i % 2) {
deque.insererEnTete(i);
} else {
deque ~= i;
}
}

writefln("Élément à l'index 3: %s",
deque[3]);   // Accès à l'élément
++deque[4];           // Incrément d'un élément
deque[5] = 55;        // Affectation d'un élément
deque[6] += 66;       // Addition à un élément

(deque ~= 100) ~= 200);

writeln(deque);
}

Selon les lignes directrices ci-avant, le type de retour de opOpAssign est ref pour que l'opérateur = puisse être chaîné pour la même collection :

(deque ~= 100) ~= 200;

Finalement, 100 est 200 sont ajoutés à la même collection :

Élément à l'index 3: 3
9 7 5 3 2 55 68 4 6 8 100 200

56-1-11 - Les opérateurs de tranchage

opSlice permettent de trancher les types définis par l'utilisateur au moyen de l'opérateur.
En plus de cet opérateur, il y a également opSliceUnary, opSliceAssign et opSliceOpAssign, mais leur utilisation n'est pas recommandée.
D prend en charge les tranches multidimensionnelles. Nous verrons un exemple multidimensionnel plus tard dans le chapitre additionnel sur les modèles. Bien que les méthodes décrites dans ce chapitre puissent également être employées avec une seule dimension, elles ne correspondent pas aux opérateurs d'indexation qui ont été définis plus haut et mettent en œuvre les modèles, qui n'ont pas encore été abordés. C'est pourquoi nous verrons une utilisation non templatée de opSlice dans ce chapitre ; qui marche avec une seule dimension (utiliser opSlice de cette manière n'est pas non plus recommandé).
opSlice prend deux formes différentes :

- les crochets peuvent être vides comme dans deque pour signifier tous les éléments ;

- les crochets peuvent contenir un intervalle numérique comme dans deque.fin pour signifier les éléments dans un intervalle spécifié.

Les opérateurs de tranchage sont relativement plus complexes que les autres opérateurs parce qu'ils mettent en œuvre deux concepts distincts : les conteneurs et les intervalles. Nous verrons ces concepts plus en détail dans les chapitres suivants.
Dans le cas du tranchage unidimensionnel qui n'utilise pas les modèles, opSlice retourne un objet qui représente un intervalle d'éléments d'un conteneur. Cet objet a la responsabilité de définir les opérations qui sont appliquées sur les éléments de cet intervalle. Par exemple, en coulisse, l'expression suivante est exécutée en appelant opSlice pour obtenir un objet intervalle puis en appliquant opOpAssign!"*" sur cet objet :

deque[] *= 10;    // multiplie tous les éléments par 10

// équivalent au code ci-dessus
{
auto intervalle = deque.opSlice();
range.opOpAssign!"*"(10);
}

En conséquence, les opérateurs opSlice de FileADoubleSens retournent un objet spécial Intervalle sur lequel on peut appliquer ces opérations :

import std.exception;

struct FileADoubleSens {
// ...

/* Retourne un intervalle qui représente tous les éléments.
* (La structure 'Intervalle' est définie plus loin).
*
* ex : deque[]
*/
inout(Intervalle) opSlice() inout {
return inout(Intervalle)(tete[], queue[]);
}

/* Retourne un intervalle qui représente certains des éléments
*
* ex : deque[debut..fin]
*/
inout(Intervalle) opSlice(size_t debut, size_t fin) inout {
enforce(fin <= opDollar());
enforce(debut <= fin);

/* Détermine quelles parties de 'tete' et 'queue'
correspondent à l'intervalle spécifié: */
if (debut < tete.length) {
if (fin < tete.length) {
/* l'intervale entier est dans 'tete'. */
return inout(Intervalle)(tete[$ - fin .. $ - debut],
[]);
} else {
/* Une partie de l'intervalle est dans 'tete' et
le reste est dans 'queue'. */
return inout(Intervalle)(tete[0 .. $ - debut],
queue[0 .. fin - tete.length]);
}
} else {
/* L'intervalle est complètement dans 'queue'. */
return inout(Intervalle)(
[],
queue[debut-tete.length .. fin - tete.length]);
}
}

/* Représente un intervalle d'éléments de la collection.
Cette structure a la responsabilité de définir les opérateurs
opUnary, opAssign et opOpAssign. */
struct Intervalle {
int[] intervalleTete;
int[] intervalleQueue;

/* Applique les opérations unaires aux éléments de l'intervalle. */
Intervalle opUnary(string op)() {
mixin(op ~ "intervalleTete[];");
mixin(op ~ "intervalleQueue[];");
return this;
}

/* Affecte une valeur spécifiée à chaque élément de l'intervalle */
Intervalle opAssign(int valeur) {
intervalleTete[] = valeur;
intervalleQueue[] = valeur;
return this;
}

/* Utilise chaque élément et une valeur dans une opération binaire
et réaffecte le résultat à cet élément. */
Intervalle opOpAssign(string op)(int valeur)
{
mixin("intervalleTete[] " ~ op ~ "= valeur;");
mixin("intervalleQueue[] " ~ op ~ "= valeur;");
return this;
}
}
}

void main() {
auto deque = FileADoubleSens();

foreach (i; 0 .. 10) {
if (i % 2) {
deque.insererEnTete(i);
} else {
deque ~= i;
}
}

writeln(deque);
deque[] *= 10;
deque[3 .. 7] = -1;
writeln(deque);
}

Donne comme résultat :

9 7 5 3 1 0 2 4 6 8
90 70 50 -1 -1 -1 -1 40 60 80

56-1-12 - opCast pour les conversions de types

opCast définit les conversions de types explicites. Il peut être surchargé séparément pour chaque type cible. Comme nous l'avons vu dans les chapitres antérieurs, les conversions de types explicites sont effectuées par la fonction to et l'opérateur cast.
opCast est aussi un modèle, mais a un format différent : le type cible est spécifié au moyen de la syntaxe (T : type_cible) :

type_cible opCast(T: type_cible)() {
// ...
}

Cette syntaxe deviendra claire lorsque nous aurons abordé le chapitre sur les modèles.
Modifions la définition de Duree pour qu'elle ait désormais deux membres : heures et minutes. L'opérateur qui convertit les objets de ce type en double peut être défini ainsi :

import std.stdio;
import std.conv;

struct Duree {
int heure;
int minute;

double opCast(T: double)() const {
return heure + (to!double(minute) / 60);
}
}

void main() {
auto duree = Duree(2, 30);
double d = to!double(duree);
// (pourrait aussi être 'cast(double)duree'

writeln(d);
}

Le compilateur remplace la conversion de type par l'appel suivant :

double d = duree.opCast!double();

La conversion vers double ci-dessus produit 2,5 pour deux heures et trente minutes :

30/12/99

56-1-13 - L'opérateur joker opDispatch

opDispatch est appelé à chaque fois qu'on essaie d'accéder à un membre absent d'un objet. Toutes les tentatives d'accès à des membres absents sont redirigées vers cette fonction.
Le nom du membre absent devient la valeur de paramètre modèle de opDispatch.
Le code suivant montre une définition simple :

import std.stdio;

struct Foo {
void opDispatch(string nom, T)(T parametre) {
writefln("Foo.opDispatch - nom: %s, valeur: %s",
nom, parametre);
}
}

void main() {
Foo foo;
foo.uneFonctionInexistante(42);
foo.uneAutreFonctionInexistante(100);
}

Les appels à des membres inexistants ne produisent pas d'erreur de compilation. À la place, tous ces appels sont envoyés à opDispatch. Le premier paramètre du modèle est le nom du membre. Les valeurs des paramètres utilisés lors de l'appel apparaissent aussi comme paramètres de opDispatch :

Foo.opDispatch - nom: uneFonctionInexistante, valeur: 42
Foo.opDispatch - nom: uneAutreFonctionInexistante, valeur: 100

Le paramètre nom peut être utilisé dans la fonction afin de décider comment l'appel à la fonction inexistante doit être géré :

switch (nom) {
// ...
}

56-1-14 - Recherche d'inclusion avec opBinaryRight!« in »

Cet opérateur permet de définir le comportement de l'opérateur in pour les types définis par l'utilisateur. Il est couramment utilisé avec les tableaux associatifs afin de déterminer si une valeur existe dans le tableau pour une clé spécifiée.
À la différence des autres opérateurs, cet opérateur est normalement surchargé pour le cas où l'objet apparaît du côté droit :

if (moment in pauseDejeuner) {

Le compilateur utilise opBinaryRight en coulisse :

// l'équivalent de code ci-dessus
if (pauseDejeuner.opBinaryRight!"in"(moment)) {

Il existe aussi l'opérateur !in qui détermine si une clé spécifiée n'existe pas dans le tableau :

if (a !in b) {

!in ne peut pas être surchargé parce que le compilateur utilise à la place le négatif du résultat de l'opérateur in :

if (!(a in b)) { // équivalent au code ci-dessus

56-1-14-1 - Exemple d'opérateur in

Le programme suivant définit un type LapsDeTemps en plus de Duree et MomentDeLaJournee. L'opérateur in qui est défini pour LapsDeTemps détermine si un moment est compris dans ce laps de temps.
Afin de garder le code concis, le programme suivant ne définit que les fonctions membres nécessaires.
Notez comment MomentDeLaJournee est utilisé harmonieusement dans la boucle for. Cette boucle montre l'utilité de la surcharge des opérateurs.

import std.stdio;
import std.string;

struct Duree {
int minute;
}

struct MomentDeLaJournee {
int heure;
int minute;

ref MomentDeLaJournee opOpAssign(string op)(in Duree duree)
if (op == "+") {
minute += duree.minute;

heure += minute / 60;
minute %= 60;
heure %=24;

return this;
}

int opCmp(in MomentDeLaJournee rhs) const {
return (heure == rhs.heure
? minute - rhs.minute
: heure - rhs.heure);
}

string toString() const {
return format("%02s:%02s", heure, minute);
}
}

struct LapsDeTemps {
MomentDeLaJournee debut;
MomentDeLaJournee fin;  // non inclusif

bool opBinaryRight(string op)(MomentDeLaJournee moment) const
if (op == "in") {
return (moment >= debut) && (moment < fin);
}
}

void main() {
auto pauseDejeuner = LapsDeTemps(MomentDeLaJournee(12, 00),
MomentDeLaJournee(13, 00));

for (auto moment = MomentDeLaJournee(11, 30);
moment < MomentDeLaJournee(13, 30);
moment += Duree(15)) {

if (moment in pauseDejeuner) {
writeln(moment, " est pendant la pause déjeuner");
} else {
writeln(moment, " est en dehors de la pause déjeuner");
}
}
}

En sortie :

11:30 est en dehors de la pause déjeuner
11:45 est en dehors de la pause déjeuner
12:00 est pendant la pause déjeuner
12:15 est pendant la pause déjeuner
12:30 est pendant la pause déjeuner
13:00 est en dehors de la pause déjeuner
13:15 est en dehors de la pause déjeuner

56-1-15 - Exercice

Définissez un type fraction qui stocke son numérateur et son dénominateur comme des membres de type long. Un tel type peut être utile parce qu'il ne perd pas de valeur comme float, double et real à cause de leur précision. Par exemple, multiplier une valeur ``double` de 1,0 / 3 par 3 ne donne pas 1,0 alors que multiplier un objet Fraction qui représente la fraction 1/3 donnerait exactement 1 :

struct Fraction {
long num;
long den;

/* Comme commodité, le constructeur utilise la valeur par
défaut 1 pour le dénominateur. */
this(long num, long den = 1) {
enforce(den != 0, "Le dénominateur ne peut être zéro");

this.num = num;
this.den = den;

/* S'assurer que le dénominateur est toujours positif
simplifiera les définitions de quelques opérateurs */
if (this.den < 0) {
this.num = -this.num;
this.den = -this.den;
}
}

/* ... À vous de définir les surcharges d'opérateurs... */
}

Définissez les opérateurs demandés afin de rendre ce type commode et aussi proche à utiliser que les types fondamentaux que possible. Assurez-vous que la définition de ce type passe tous les tests unitaires suivants. Ces tests unitaires vérifient le comportement suivant :

- une exception doit être lancée lorsque l'on construit un objet avec un dénominateur à zéro (c'est déjà pris en compte par l'expression enforce ci-avant) ;

- produire l'opposé de la valeur. Par exemple, l'opposé de 1/3 devrait être -1/3 et l'opposé de -2/5 devrait être 2/5.

- Incrémenter et décrémenter la valeur avec ++ et .

- Supporter les quatre opérations arithmétiques : pouvoir modifier la valeur d'un objet au moyen de +=, -=, *= et /= ; et le résultat de l'utilisation de deux objets avec les opérateurs +, -, * et /. Comme avec le constructeur, la division par zéro devrait être empêchée. Pour rappel, voici les formules des opérations arithmétiques qui mettent en œuvre deux fractions a/b et c/d :

- addition : a/b + c/d = (a×d + c×b) / (b×d)

- soustraction : a/b − c/d = (a×d − c×b) / (b×d)

- multiplication : a/b × c/d = (a×c) / (b×d)

- division : (a/b) / (c/d) = (a×d) / (b×c)

- La valeur concrète (et nécessairement imprécise) de l'objet peut être convertie en double.

- L'ordre de tri et le opération de comparaison doivent se baser sur les valeurs concrètes des fractions et pas sur les valeurs des numérateurs et dénominateurs. Par exemple, les fractions 1/3 et 20/60 doivent être considérées comme égales.

unittest {
/* Doit lancer une exception quand le dénominateur est zéro */
assertThrown(Fraction(42, 0));

/* Commençons avec 1/3 */
auto a = Fraction(1, 3);

/* -1/3 */
assert (-a == Fraction(-1, 3));

/* 1/3 + 1 == 4/3 */
++a;
assert (a == Fraction(4, 3));

/* 4/3 - 1 == 1/3 */

a;
assert (a == Fraction(1, 3));

/* 1/3 + 2/3 == 3/3 */
a += Fraction(2, 3);
assert (a == Fraction(1));

/* 3/3 - 2/3 == 1/3 */
a -= Fraction(2, 3);
assert (a == Fraction(1, 3));

/* 1/3 * 8 == 8/3 */
a *= 8;
assert (a == Fraction(8, 3));

/* 8/3 / 16/9 == 3/2 */
a /= Fraction(16, 9);
assert (a == Fraction(3, 2));

/* Doit produire l'équivalent en type double

Notez que si double ne pas pas représenter toutes les valeurs,

précisément, 1,5 est une exception. C'est pourquoi ce test est

effectué à ce stade. */

assert (to!double(a) == 1.5);

/* 1,5 + 2, 5 == 4 */
assert (a + Fraction(5, 2) == Fraction(4, 1));

/* 1,5 − 0,75 == 0,75 */
assert (a - Fraction(3, 4) == Fraction(3, 4));

/* 1,5 × 10 == 15 */
assert (a * 10 == Fraction(15, 1));

/* 1,5 / 4 == 3 / 8 */
assert (a / Fraction(4) == Fraction(3, 8));

/* Doit lancer une exception lors d'une division par zéro */
assertThrown(Fraction(42, 1) / Fraction(0));

/* Celui avec le petit numérateur est avant */
assert (Fraction(3, 5) < Fraction(4, 5));

/* Celui avec le plus grand dénominateur est avant */
assert (Fraction(3, 9) < Fraction(3, 8));
assert (Fraction(1, 1_000) > Fraction(1, 10_000));

/* Celui avec la plus petite valeur est avant */
assert (Fraction(10, 100) < Fraction(1, 2));

/* Les négatifs sont avant */
assert (Fraction(-1, 2) < Fraction(0));
assert (Fraction(1, -2) < Fraction(0));

/* Les valeurs égales doivent être à la fois <= et >= */
assert (Fraction(-1, -2) <= Fraction(1, 2));
assert (Fraction(1, 2) <= Fraction(-1, -2));
assert (Fraction(3, 7) <= Fraction(9, 21));
assert (Fraction(3, 7) >= Fraction(9, 21));

/* ceux qui ont une valeur égale doivent être égaux */
assert (Fraction(1, 3) == Fraction(20, 60));

/* Ceux qui ont une valeur égale avec le signe doivent être égaux */
assert (Fraction(-1, 2) == Fraction(1, -2));
assert (Fraction(1, 2) == Fraction(-1, -2));

56-2 - La solution

L'implémentation qui suit passe tous les tests unitaires avec succès. Les choix de conception ont été inclus dans le code en commentaires.
Certaines des fonctions de cette structure peuvent être implémentées de manière plus performante. Par ailleurs, il serait bénéfique de normaliser le numérateur et le dénominateur. Par exemple, au lieu de garder les valeurs 20 et 60, celles-ci pourraient être divisées par leur plus grand commun diviseur et être remplacées respectivement par 1 et 3. Autrement, le numérateur et le dénominateur augmenteraient à la plupart des opérations.

import std.exception;
import std.conv;

struct Fraction {

long num; // numérateur
long den; // dénominateur
/* Par commodité, le constructeur utilise une valeur de 1 pour le
dénominateur. */
this(long num, long den = 1) {
enforce(den != 0, "Le dénominateur ne peut pas être 0");

this.num = num;
this.den = den;

/* S'assurer que le dénominateur est toujours positif
simplifiera les définitions de certains opérateurs. */
if (this.den < 0) {
this.num = -this.num;
this.den = -this.den;
}
}

/* - unaire: retourne l'opposé de cette fraction */
Fraction opUnary(string op)() const
if (op == "-") {
/* On construit juste un objet anonyme et on le retourne */
return Fraction(-num, den);
}

/* ++: incrémente la valeur de la fraction par un. */
ref Fraction opUnary(string op)()
if (op == "++") {
/* Nous aurions pu utiliser 'this += Fraction(1)' ici. */
num += den;
return this;
}

ref Fraction opUnary(string op)()
if (op == "--") {
/* Nous aurions pu utiliser 'this -= Fraction(1)' ici. */
num -= den;
return this;
}

/* +=: Ajoute la Fraction côté droit à celle-ci */
ref Fraction opOpAssign(string op)(in Fraction rhs)
if (op == "+") {
/* Formule de l'addition:a/b + c/d = (a*d + c*b) / (b*d) */
num = (num * rhs.den) + (rhs.num * den);
den *= rhs.den;
return this;
}

/* -=: Soustrait la Fraction côté droit à celle-ci */
ref Fraction opOpAssign(string op)(in Fraction rhs)
if (op == "-") {
/* Nous utilisons les opérateurs += et le - unaire
que nous avons déjà définis. Autrement, nous
aurions pu utiliser la formule de la soustraction,
de la même manière que dans l'opérateur += ci-avant.

Formule de la soustraction: a/b - c/d = (a*d - c*b)/(b*d)
*/
this += -rhs;
return this;
}

/* *=: Multiplie la Fraction côté droit à celle-ci */
ref Fraction opOpAssign(string op)(in Fraction rhs)
if (op == "*") {
/* Formule de la multiplication: a/b * c/d = (a*c)/(b*d) */
num *= rhs.num;
den *= den.num;
return this;
}

/* /=: Divise cette Fraction par celle côté droit */
ref Fraction opOpAssign(string op)(in Fraction rhs)
if (op == "/") {
/* Formule de la division:(a/b) / (c/d) = (a*d)/(b*c) */
num *= rhs.den;
den *= rhs.num;
return this;
}

/* + binaire: Produit le résultat de l'addition de cette
Fraction avec celle côté droit */
Fraction opBinary(string op)(in Fraction rhs) const
if (op == "+") {
/* On prend une copie de cette Fraction et on ajoute
la fraction côté droit à cette copie. */
Fraction resultat = this;
resultat += rhs;
return resultat;
}

/* - binaire: Produit le résultat de la soustraction de la
Fraction côté droit à cette Fraction. */
Fraction opBinary(string op)(in Fraction rhs) const
if (op == "-") {
/* On utilise l'opérateur -= déjà défini. */
Fraction resultat = this;
resultat -= rhs;
return resultat;
}

/* * binaire: Produit le résultat de la multiplication de
cette Fraction à celle côté droit */
Fraction opBinary(string op)(in Fraction rhs) const
if (op == "*") {
/* On utilise l'opérateur *= déjà défini. */
Fraction resultat = this;
resultat *= rhs;
return resultat;
}

/* / binaire: Produit le résultat de la division de cette
Fraction par celle côté droit. */
Fraction opBinary(string op)(in Fraction rhs) const
if (op == "/") {
/* On utilise l'opérateur /= déjà défini. */
Fraction resultat = this;
resultat /= this;
return resultat;
}

/* Retourne la valeur de la fraction comme double */
double opCast(T : double)() const {
/* Une simple division. Cependant, comme la division de deux
long causerait la perte de la valeur après la virgule,
nous n'aurions pas pu écrire 'num / den' ici. */
return to!double(num) / den;
}

/* Opérateur d'ordre de tri. Retourne une valeur négative si cette
Fraction vient avant, une valeur positive si cette Fraction
vient après, et zéro si les deux Fractions sont égales. */
int opCmp(const ref Fraction rhs) const {
immutable result = this - rhs;
/* comme num est un long, il ne peut être converti en
int implicitement. La conversion doit être faite de
façon explicite avec 'to' (ou cast). */
return to!int(result.num);
}

/* Opérateur de comparaison d'égalité: retourne true si les deux
Fractions sont égales.

La comparaison d'égalité doit être définie pour ce type,
autrement le compilateur en générerait une qui comparerait
les valeurs des membres un par un, sans se soucier des valeurs
réellement représentées.

Par exemple, bien que les deux Fraction(1, 2) et Fraction(2, 4)
valent 0,5, le opEquals généré par le compilateur les jugerait
inégales car leurs membres ont des valeurs différentes.*/
bool opEquals(const ref Fraction rhs) const {
/* Vérifier si la valeur retournée par opCmp vaut zéro est
suffisant ici */
return opCmp(rhs) == 0;
}
}

57 - Les classes

Comme les structures, class est une fonctionnalité pour définir de nouveaux types. Cependant, les classes fournissent à D le paradigme de la programmation orientée objet (POO). Les principaux aspects de la POO sont les suivants :

- l'encapsulation : le contrôle d'accès aux membres (l'encapsulation est également disponible pour les structs, mais n'a pas été encore mentionnée jusqu'ici) ;

- l'héritage : l'acquisition de membres d'un autre type ;

- le polymorphisme : la possibilité d'utiliser un type plus spécialisé à la place d'un type plus général.

L'encapsulation est obtenue par des attributs de protection, que nous verrons dans un chapitre ultérieur. L'héritage permet d'acquérir des implémentations d'autres types. Le polymorphisme permet de rendre abstraites des parties de programme l' une de l'autre et est obtenu au moyen des interfaces de classes.

Ce chapitre présente sommairement les classes, en soulignant le fait que ce sont des types références. Les classes seront expliquées plus en détail dans des chapitres ultérieurs.

57-1 - Comparaison avec les structures

De manière générale, les classes sont très similaires aux structures. La plupart des fonctionnalités que nous avons vues dans les chapitres précédents s'appliquent également aux classes :

- structures :

- fonctions membres :

- paramètres const ref et fonctions membres const :

- constructeur et autres fonctions spéciales :

- surcharge d'opérateurs.

Il y a néanmoins des différences importantes entre les classes et les structures.

57-1-1 - Les classes sont des types références

La principale différence avec les structures est que ces dernières sont des types valeurs et que les classes sont des types références. Les autres différences résumées ci-après sont essentiellement dues à ce fait.

57-1-2 - Les variables classes peuvent être nulles

Comme indiqué brièvement dans le chapitre sur la valeur null et l'opérateur is, les variables classes peuvent être null. En d'autres termes, les variables classes peuvent ne pas fournir d'accès à aucun objet. Les variables classes n'ont pas de valeur elles-mêmes ; les véritables objets classes doivent être construits via le mot-clé new. Comme vous vous en souvenez peut-être, comparer des références à null au moyen des opérateurs == ou != est une erreur. La comparaison doit plutôt être faite au moyen des opérateurs is ou !is, de cette manière :

MaClasse referenceUnObjet = new MaClasse;
assert(referenceUnObjet !is null);

MaClasse variable; // ne référence pas un objet
assert(variable is null);

La raison en est la suivante : l'opérateur == peut avoir besoin de consulter les valeurs des membres des objets et tenter d'accéder aux membres d'une variable null causerait une erreur d'accès mémoire. Pour cette raison, les variables classes doivent toujours être comparées au moyen des opérateurs is et !is.

57-1-3 - Les variables classes et les objets classes

Les variables classes et les objets classes sont des concepts séparés.
Les objets classes sont construits via le mot-clé new ; ils n'ont pas de nom. Le véritable concept représenté par un type classe dans un programme est fourni par un objet classe. Par exemple, en supposant une classe Etudiant qui représente des étudiants avec leur nom et leurs notes, on pourrait enregistrer ces informations dans les membres des objets Etudiant. En partie parce qu'ils sont anonymes, il n'est pas possible d'accéder à des objets classes directement.
Une variable classe d'un autre côté est une fonctionnalité du langage qui permet d'accéder à des objets classes. Bien qu'il semble syntaxiquement que des opérations soient effectuées sur une variable classe, ces opérations sont en fait transmises à l'objet classe.
Considérons le code suivant que nous avions vu précédemment dans le chapitre sur les types valeurs et les types références :

auto variable1 = new MaClasse;
auto variable2 = variable1;

Le mot-clé new construit un objet classe anonyme. Les variables variable1 et variable2 ci-dessus fournissent juste un accès à cet objet anonyme :

57-1-4 - Copie

La copie affecte seulement les variables, pas l'objet.
Parce que les classes sont des types références, définir une nouvelle variable classe comme copie d'une autre fait que les deux variables fournissent un accès au même objet. L'objet réel n'est pas copié.
Puisqu'aucun objet n'est copié, la fonction postblit this(this) n'est pas disponible pour les classes.

auto variable2 = variable1;

Dans le code ce-dessus, variable2 est initialisée au moyen de variable1. Les deux variables fournissent un accès au même objet.
Lorsque l'objet réel doit être copié, la classe doit fournir une fonction membre à cet effet. Afin d'être compatible avec les tableaux, on peut nommer cette fonction dup(). Cette fonction doit créer et retourner un nouvel objet classe. Voyons cela sur une classe qui a plusieurs types de membres :

class Foo {
S      o; // Suppose que S est un type structure
char[] s;
int    i;

// ...

this(S o, const char[] s, int i) {
this.o = o;
this.s = s.dup;
this.i = i;
}

Foo dup() const {
return new Foo(o, s, i);
}
}

La fonction membre dup() fabrique un nouvel objet via le constructeur de Foo et retourne ce nouvel objet. Notez que le constructeur copie le membre s explicitement via la propriété .dup des tableaux. Étant des types valeurs, o et i sont copiés automatiquement.
Le code suivant utilise dup() pour créer un nouvel objet :

auto var1 = new Foo(S(1.5), "bonjour", 42);
auto var2 = var1.dup();

En résultat, les objets qui sont associés à var1 et var2 sont distincts.
Pareillement, une copie immutable d'un objet peut être fournie par une fonction membre appropriée appelée idup() :

class Foo {
// ...
immutable(Foo) idup() const {
return new immutable(Foo)(o, s, i);
}
}

// ...

immutable(Foo) imm = var1.idup();

57-1-5 - Affectation

Tout comme la copie, l'affectation modifie seulement les variables.
Affecter à une variable classe dissocie cette variable de son objet courant et l'associe à un nouvel objet.
Si aucune autre variable classe ne fournit d'accès à l'objet qui vient d'être dissocié de la variable, alors cet objet sera détruit plus tard par le ramasse-miettes.

auto variable1 = new MaClasse();
auto variable2 = new MaClasse();
variable1 = variable2;

L'affectation ci-dessus oblige variable1 à laisser son objet et à fournir un accès à l'objet de variable2. Puisqu'il n'y a aucune autre variable pour l'objet original de variable1, cet objet sera détruit par le garbage collector.
Le comportement de l'affectation ne peut pas être changé pour les classes. En d'autres termes, opAssign ne peut pas être surchargé pour elles.

57-1-6 - Définition

Les classes sont définies au moyen du mot-clé class en lieu et place du mot-clé struct :

class PieceEchec {
// ...
}

57-1-7 - Construction

Comme pour les structures, le nom du constructeur est this. Contrairement aux structures, les objets classes ne peuvent être construits avec la syntaxe { }.

class PieceEchec {
dchar forme;

this(dchar forme) {
this.forme = forme;
}
}

Contrairement aux structures, il n'y a pas de construction d'objet automatique lorsque les paramètres du constructeur sont affectés aux membres séquentiellement :

class PieceEchec {
dchar forme;
size_t valeur;
}

void main() {
auto roi = new PieceEchec('♔', 100); // ← ERREUR de compilation
}

Sortie :

Erreur: pas de constructeur pour PieceEchec

Pour que cette syntaxe fonctionne, un constructeur doit être défini explicitement par le programmeur.

57-1-8 - Destruction

Comme pour les structures, le nom du destructeur est this :

~this() {
// ...
}

Néanmoins, à la différence des structures, les destructeurs des classes ne sont pas exécutés au moment où la durée de vie d'un objet classe se termine. Comme nous l'avons vu plus avant, le destructeur est appelé plus tard pendant un cycle du ramasse-miettes. (Par cette distinction, les destructeurs de classe auraient dû être appelés plus précisément finaliseurs).
Comme nous le verrons plus tard dans le chapitre sur la gestion de la mémoire, les destructeurs de classe doivent respecter les règles suivantes :

- un destructeur de classe ne peut pas accéder à une membre qui est géré par le ramasse-miettes. La raison en est que les ramasse-miettes ne sont pas tenus de garantir l'ordre dans lequel l'objet et ses membres sont finalisés lorsque le ramasse-miettes s'exécute ;

- un destructeur de classe ne doit pas allouer de mémoire qui soit gérée par le ramasse-miettes. En effet, les ramasse-miettes ne sont pas tenus de garantir qu'ils puissent allouer de nouveaux objets durant un cycle de ramasse-miettes.

Enfreindre ces règles est un comportement indéfini. Il est facile de voir un exemple de ce type de problème simplement en essayant d'allouer un objet dans un destructeur de classe :

class C {
~this() {
auto c = new C(); // ← FAUX : allocation explicite dans un
// destructeur de classe
}
}

void main() {
auto c = new C();
}

Le programme est interrompu au moyen d'une exception :

core.exception.InvalidMemoryOperationError@(0)

Il est également faux de provoquer une allocation mémoire du ramasse-miettes indirectement dans un destructeur. Par exemple, la mémoire utilisée pour les éléments d'un tableau dynamique est aussi allouée au moyen du ramasse-miettes. Utiliser un tableau d'une manière qui requiert un nouveau bloc de mémoire pour les éléments est aussi un comportement indéfini :

~this() {
auto tabl = [ 1 ]; // ← FAUX : Allocation indirecte dans un
// destructeur de classe
}

57-1-9 - Accès aux membres

Comme avec les structures, on accède aux membres au moyen de l'opérateur point :

auto roi = new PieceEchec('♔');
writeln(roi.forme);

Même si la syntaxe peut faire croire que l'on accède à un membre de la variable, c'est en fait un membre de l'objet. Les variables classes n'ont pas de membres, ce sont les objets membres qui en ont. La variable roi n'a pas de membre forme, c'est l'objet anonyme qui en a.

Il n'est normalement pas correct d'accéder directement aux membres comme dans le code ci-avant. Quand cette même syntaxe est désirée, les propriétés devraient être préférées. Elles seront abordées dans un chapitre ultérieur.

57-1-10 - Surcharge d'opérateurs

À part le fait qu'opAssign ne puisse pas être surchargé pour les classes, la surcharge d'opérateur est identique aux structures. Pour les classes, la signification de opAssign est toujours associée à une variable classe avec un objet classe.

57-1-11 - Fonctions membres

Bien que les fonctions membres soient définies et utilisées de la même manière que les structures, il existe une différence importante : les fonctions membres des classes peuvent être spécialisées par défaut. Nous verrons ce concept plus tard dans le chapitre sur l'héritage.
Puisque les fonctions membres spécialisables ont un impact sur les performances à l'exécution, sans entrer plus dans les détails, je vous recommande de définir toutes les fonctions membres des classes qui n'ont pas besoin d'être spécialisables avec le mot-clé final. Vous pouvez appliquer cette consigne aveuglément sauf en cas d'erreurs de compilation :

class C {
final int fonct() { // ← Recommandé
// ...
}
}

Une autre différence par rapport aux structures est que certaines fonctions membres sont automatiquement héritées de la classe Object. Nous verrons dans le chapitre suivant comment la définition de toString peut être changée par le mot-clé override.

57-1-12 - Les opérateurs is et !is

Ces opérateurs s'appliquent aux variables classes.
L'opérateur is indique si deux variables classes fournissent un accès au même objet classe. Il retourne true s'il s'agit du même objet et false autrement. L'opérateur !is est l'opposé de is.

auto monRoi = new PieceEchec('♔');
auto tonRoi = new PieceEchec('♔');
assert(monRoi !is tonRoi);

Puisque les objets des variables monRoi et tonRoi sont différents, l'opérateur !is retourne true. Même si les deux objets sont construits avec le même caractère '♔' il sont néanmoins distincts.
Lorsque les variables fournissent un accès au même objet, is retourne true :

auto monRoi2 = monRoi;
assert(monRoi2 is monRoi);

Les deux variables ci-avant fournissent un accès au même objet.

57-2 - En résumé :

- Les classes et les structures partagent des fonctionnalités communes, mais ont de grandes différences.

- Les classes sont des types référence. Le mot-clé new construit un objet classe anonyme et retourne une variable classe.

- Les variables classes qui ne sont associées à aucun objet valent null. Vérifier la nullité d'une variable doit se faire au moyen de is ou !is, pas au moyen de == ou !=.

- Le fait de copier associe une variable supplémentaire à un objet. Pour copier un objet, son type doit posséder une fonction spéciale préférablement nommée dup().

- L'affectation associe une variable à un objet. Ce comportement ne peut pas être modifié.

58 - Héritage

L'héritage définit un type plus spécialisé depuis un type plus général servant de type de base.

Le type spécialisé acquiert les membres du type de base et par conséquent peut lui être substitué. L'héritage est disponible pour les classes, pas pour les structures. La classe héritant d'une autre classe est appelée une sous-classe, et la classe héritée s'appelle une superclasse, aussi appelée classe de base. Il y a deux types d'héritage en langage D. Nous couvrirons dans ce chapitre l'héritage d'implémentation et laisserons l'héritage d'interface à un chapitre ultérieur. Lors de la définition d'une sous-classe, la superclasse est spécifiée après un deux-points :

class SousClasse : SuperClasse {
// ...
}

Pour voir un autre exemple, supposons qu'il y a déjà une classe représentant une horloge :

class Horloge {
int heure;
int minute;
int seconde;

void ajustage(int heure, int minute, int seconde = 0) {
this.heure = heure;
this.minute = minute;
this.seconde = seconde;
}
}

Apparemment, les membres de cette classe n'ont pas besoin de valeurs spéciales pendant leur construction. Il n'y a donc pas de constructeur. Les membres sont positionnés par la fonction membre ajustage() :

auto deskClock = new Horloge;
deskClock.adjust(20, 30);
writefln(
"%02s:%02s:%02s",
deskClock.heure, deskClock.minute, deskClock.seconde);

Il serait plus utile de produire la chaîne d'heure par la fonction toString(). Elle sera ajoutée plus tard lors de l'explication sur la surcharge.

La sortie :

00:00:00

Avec si peu de fonctionnalité, Horloge pourrait aussi bien être une structure, et selon les besoins du programme, cela pourrait être suffisant. Cependant, le fait que ce soit une classe permettra d'hériter d'Horloge. Pour voir un exemple d'héritage, considérons une classe alarme_horloge, qui inclut toutes les fonctionnalités d'Horloge, mais fournit aussi un moyen de régler une alarme. Commençons par définir ce type de classe sans s'occuper d'Horloge. Si nous faisions cela, nous devrions avoir à inclure les mêmes trois membres et la fonction ajustage(). alarme_horloge devrait aussi avoir d'autres membres pour ses fonctionnalités additionnelles.

class alarme_horloge {
int heure;
int minute;
int seconde;
int heure_alarme;
int minute_alarme;

void adjustage(int heure, int minute, int seconde = 0) {
this.heure = heure;
this.minute = minute;
this.seconde = seconde;
}

void ajustage_alarme(int heure, int minute) {
heure_alarme = heure;
minute_alarme = minute;
}
}

L'héritage est utile dans ces cas. Faire hériter alarme_horloge depuis Horloge simplifie la nouvelle classe et réduit la duplication de code :

class alarme_horloge : Horloge {
int heure_alarme;
int minute_alarme;

void ajustage_alarme(int heure, int minute) {
heure_alarme = heure;
minute_alarme = minute;
}
}

Cette nouvelle définition est équivalente à la première.

Comme alarme_horloge hérite des membres de Horloge, la classe peut être utilisée de la même façon qu'Horloge :

auto horloge_de_chevet = new alarme_horloge;
horloge_de_chevet.ajustage(20, 30);
horloge_de_chevet.ajustage_alarm(7, 0);

Les membres qui ont été hérités de la superclasse peuvent être accédés comme s‘ils étaient membres de la sous-classe :

writefln("%02s:%02s:%02s ♫%02s:%02s",
horloge_de_chevet.heure,
horloge_de_chevet.minute,
horloge_de_chevet.seconde,
horloge_de_chevet.heure_alarme,
horloge_de_chevet.minute_alarme);

La sortie :

20:30:00 ♫07:00

Une fonction AlarmClock.toString serait plus utile dans ce cas. Elle sera définie plus tard.

L'héritage utilisé dans cet exemple est l'héritage implémentation.

Si nous imaginons la mémoire comme un ruban allant de haut en bas, le placement des membres de horloge_alarme en mémoire peut être représenté comme ci-dessous ;

L'illustration ci-dessus sert juste à donner une idée de comment les membres d'une superclasse et d'une sous-classe peuvent être combinés ensemble. La couche actuelle des membres dépend des détails d'implémentation du compilateur utilisé. Par exemple, la partie marquée autres données inclut typiquement le pointeur vers la table des fonctions virtuelles (vtbl) du type particulier de cette classe. Les détails de la couche objet sont en dehors du périmètre de ce livre.

58-1 - Attention: héritage seulement si « c'est un »

Nous avons vu que l'héritage d'implémentation consiste à acquérir des membres. Considérez ce type d'héritage seulement si le sous-type peut être considéré comme une sorte de supertype, comme dans l'expression « l'alarme est une horloge ». «C'est un » n'est pas la seule relation entre les types, une relation plus commune est «a un» . Par exemple, considérons que nous voulons ajouter le concept de batterie à la classe Horloge. Il ne serait pas approprié d'ajouter Batterie à Horloge par héritage, car la déclaration «une horloge est une batterie» n'est pas vraie.

class Horloge : Batterie {    // ← MAUVAIS DESIGN
// ...
}

Une horloge n'est pas une batterie, elle a une batterie. Quand il y a une telle relation de conteneur, le type contenu doit être défini comme un membre du type qui le contient.

class Horloge {
Batterie batterie;       // ← design correct
// ...
}

58-2 - Héritage d'au plus une classe

Les classes peuvent seulement hériter d'une classe de base (qui peut elle-même potentiellement hériter d'une classe de base). En d'autres termes, l'héritage multiple n'est pas supporté en langage D. Supposons par exemple qu'il y ait aussi une classe émettrice_son, et bien qu' «une alarme horloge est un objet émetteur de son» soit également vrai, il n'est pas possible d'hériter de Horloge et de Emetteur_son :

class Emetteur_son {
// ...
}

class Horloge_alarme : Horloge, Emetteur_son {    // ← erreur de compilation
// ...
}

Par contre, il n'y a pas de limite au nombre d'interfaces dont une classe peut hériter. Nous allons voir le mot clé Iinterface dans un prochain chapitre.

Additionnellement, il n'y a pas de limite à la profondeur de hiérarchie d'héritage :

class InstrumentDeMusique {
// ...
}

class InstrumentACorde : InstrumentDeMusique {
// ...
}

class Violon : InstrumentACorde {
// ...
}

La hiérarchie d'héritage ci-dessus définit une ration entre l'élément le plus général : l'instrument de musique, en passant par l'instrument à cordes, puis le violon.

58-3 - Tableaux hiérarchiques

Les types liés par la relation « est un » forment une hiérarchie de classe.

Selon les conventions OOP, les hiérarchies de classe sont représentées par les superclasses en haut et la sous-classe en bas. Les relations d'héritage sont indiquées par les flèches pointant des sous-classes aux superclasses.

Par exemple, le code suivant peut être une hiérarchie d'instruments de musique :

58-4 - Accéder aux membres d'une superclasse

Le mot clé super permet de faire référence aux membres hérités de la superclasse.

class AlarmeHorloge : Horloge {
// ...

void foo() {
super.minute = 10; // Le membre 'minute' hérité
minute = 10;       // Même chose s'il n'y a pas d'ambiguïté
}
}

Le mot clé super n'est pas tout le temps nécessaire ; minute seul a la même signification dans le code ci-dessus. Le mot clé super est requis quand la superclasse et la sous-classe ont des membres avec le même nom. Nous verrons cela ci-dessous quand nous aurons besoin d'écrire super.reset() et super.toString(). Si de multiples classes d'un arbre d'héritage définissent un symbole avec le même nom, on peut utiliser le nom spécifique de la classe pour supprimer l'ambiguïté entre les symboles :

class Device {
string fabricant;
}

class Horloge : Device {
string fabricant;
}

class AlarmeHorloge : Horloge {
// ...

void foo() {
Device. fabricant = "Sunny Horology, Inc.";
Horloge. fabricant = "Better Watches, Ltd.";
}
}

58-5 - Construire des membres de superclasse

L'autre usage du mot clé super est d'appeler le constructeur de la superclasse. C'est similaire à appeler les constructeurs surchargés de la classe courante : this lors de l'appel aux constructeurs de la classe courante et super pour les constructeurs de la superclasse.

Il n'est pas nécessaire d'appeler le constructeur de la superclasse explicitement, si le constructeur de la sous-classe fait un appel explicite à une surcharge de super, ce constructeur est donc exécuté par cet appel. Sinon, et si la superclasse a un constructeur par défaut, il est exécuté automatiquement avant d'entrer dans le corps de la sous-classe.

Nous n'avons pas encore défini de constructeur pour les classes Horloge et AlarmeHorloge. Pour cette raison, les membres de ces deux classes sont initialisés par les valeurs .init de leur type respectif, qui est 0 pour les int.

Supposons que Horloge ait le constructeur suivant :

class Horloge {
this(int heure, int minute, int seconde) {
this.heure = heure;
this.minute = minute;
this.seconde = seconde;
}

// ...
}

Ce constructeur doit être utilisé lors de la construction des objets Horloge :

auto horloge = new Horloge(17, 15, 0);

Naturellement, les programmeurs qui utilisent le type Horloge directement devraient utiliser cette syntaxe. Cependant, lors de la construction d'un objet AlarmeHorloge, ils ne peuvent pas construire cette partie d'Horloge séparément. En outre, les utilisateurs d'AlarmeHorloge n'ont même pas besoin de savoir qu'ils héritent d‘Horloge.

Un utilisateur d'AlarmeHorloge devrait simplement construire un objet AlarmeHorloge et l'utiliser dans le programme sans avoir à prêter attention à son héritage d‘Horloge :

auto bedSideClock = new AlarmeHorloge(/* ... */);
// ... utilisation comme un objet AlarmeHorloge ...

Pour cette raison, la construction de la superclasse est de la responsabilité de la sous-classe. La sous-classe appelle le constructeur de la superclasse avec la syntaxe super() :

class AlarmeHorloge : Horloge {
this(int heure, int minute, int seconde,  // pour les membres d'Horloge
int heureAlarme, int minuteAlarme) {  //pour les membres d' AlarmeHorloge
super(heure, minute, seconde);
this. heureAlarme = heureAlarme;
this. minuteAlarme = minuteAlarme;
}

// ...
}

Le constructeur d'AlarmeHorloge prend comme arguments ses propres membres et les membres de la superclasse, puis les utilise pour construire sa partie superclasse.

58-6 - Surcharger les définitions des fonctions membres

Un des bénéfices de l'héritage est la capacité à redéfinir des fonctions membres de la superclasse dans la sous-classe. Ceci est appelé la surcharge : la définition existante de la superclasse est écrasée par la sous-classe avec le mot clé override .

Les fonctions surchargeables sont appelées fonctions virtuelles. Les fonctions virtuelles sont implémentées par le compilateur à travers les tables de pointeurs de fonctions virtuelles (vtbl) et les pointeurs vtbl. Les détails de ce mécanisme sont hors périmètre de ce livre. Cependant, il doit être connu par chaque programmeur système que les appels aux fonctions virtuelles sont plus coûteux que les appels aux fonctions régulières. En D, chaque fonction membre de classe non privée est virtuelle par défaut. Pour cette raison, quand une fonction de superclasse n'a aucune raison d'être surchargée, elle devrait être définie comme finale, elle sera ainsi non virtuelle. Nous verrons le mot clé final plus tard dans le chapitre interfaces.

Supposons que Horloge a une fonction membre utilisée pour remettre tous ses membres à zéro :

class Horloge {

void reset() {
heure = 0;
minute = 0;
seconde = 0;
}

// ...
}

Cette fonction est héritée d' AlarmeHorloge et peut être appelée sur un objet AlarmeHorloge :

auto bedSideClock = new AlarmeHorloge(20, 30, 0, 7, 0);
// ...
bedSideClock.reset();

Cependant, en ignorant nécessairement les membres d' AlarmeHorloge et Horloge, reset peut seulement remettre à zéro ses propres membres. Pour cette raison, pour remettre aussi bien à zéro des membres de sous-classes, reset() doit être surchargée :

class AlarmeHorloge : Horloge {
override void reset() {
super.reset();
alarmeHeure = 0;
alarmeMinute = 0;
}

// ...
}

La sous-classe remet seulement à zéro ses propres membres et délègue le reste de la tâche à Horloge par l'appel à super.reset(). Notez qu'écrire seulement reset() ne devrait pas fonctionner, car cela appellerait la fonction reset() d'AlarmeHorloge, Appeler reset() en interne pourrait causer une récursion infinie.

La raison pour laquelle j'ai retardé la définition de toString() jusqu'à maintenant est que pour les classes, cette fonction doit être définie par le mot clé override. Comme nous le verrons dans le prochain chapitre, chaque classe hérite automatiquement d'une superclasse Object qui définit déjà la fonction membre toString().

Pour cette raison, la fonction membre toString() pour les classes doit être définie en utilisant le mot clé override :

import std.string;

class Horloge {
override string toString() const {
return format("%02s:%02s:%02s", heure, minute, seconde);
}

// ...
}

class AlarmeHorloge: Horloge {
override string toString() const {
return format("%s ♫%02s:%02s", super.toString(),
alarmeHeure, alarmeMinute);
}

// ...
}

Notez que AlarmeHorloge délègue encore certaines tâches à Horloge par l'appel à super.toString().

Ces deux surcharges de toString() permettent la conversion des objets AlarmeHorloge en chaînes :

void main() {
auto HorlogedeBureau = new AlarmeHorloge(10, 15, 0, 6, 45);
writeln(HorlogedeBureau);
}

La sortie :

10:15:00 ♫06:45

58-7 - Utiliser les sous-classes à la place de la superclasse

Comme la superclasse est plus générale, et la sous-classe plus spécialisée, les objets de sous-classes peuvent être utilisés quand un type d'objet de la superclasse est requis. Cela s'appelle le polymorphisme. Les concepts de types généraux et spécialisés peuvent être vus dans les déclarations comme «ce type est de ce type » : «l'alarme d'horloge est une horloge», «l'étudiant est une personne», «le chat est un animal», etc. En conséquence, une alarme d'horloge peut être utilisée là où une horloge est nécessaire, un étudiant peut être utilisé là où une personne est nécessaire, et un chat peut être utilisé là où un animal est nécessaire. Quand un objet sous-classe est utilisé comme un objet superclasse, il ne perd pas son propre

type spécialisé. Cela est similaire aux exemples de la vraie vie : utiliser une alarme d'horloge simplement comme une horloge ne change pas le fait que c'est une alarme d'horloge. Elle se comporterait toujours comme un réveil. Supposons qu'une fonction prenne un objet Horloge en paramètre, lequel est remis à zéro à un certain endroit durant son exécution :

void use(Horloge horloge) {
// ...
horloge.reset();
// ...
}

Le polymorphisme rend possible l'envoi d'un AlarmeHOrloge à une telle fonction :

auto HorlogedeBureau = new AlarmeHorloge(10, 15, 0, 6, 45);
writeln("Avant : ", HorlogedeBureau);
use(HorlogedeBureau);
writeln("Après : ", HorlogedeBureau);

C'est en accord avec la relation « une alarme d'horloge est une horloge » comme résultat, les membres de l'objet HorlogedeBureau sont remis à zéro :

Avant : 10:15:00 ♫06:45
Après : 00:00:00 ♫00:00

L'observation importante ici est que non seulement les membres de Horloge , mais aussi les membres de AlarmeHorloge ont été réinitialisés.

Bien que use() appelle reset() sur l'objet Horloge, comme l'objet actuel est un objet AlarmeHorloge, la fonction qui est appelée est AlarmeHorloge.reset. Selon sa définition plus haut, AlarmeHorologe.reset remet à zéro les membres des deux classes Horloge et AlarmeHorloge.

En d'autres termes, bien que use() utilise l'objet comme une Horloge, l'objet réel peut être un type hérité qui se comporte de manière particulière.

Ajoutons une autre classe à la hiérarchie de Horloge. La fonction reset() de ce type positionne ses membres à des valeurs aléatoires :

import std.random;

class horlogeCassee : Horloge {
this() {
super(0, 0, 0);
}

override void reset() {
heure = uniform(0, 24);
minute = uniform(0, 60);
seconde = uniform(0, 60);
}
}

Quand un objet de HorlogeCassee est envoyé à use(), alors la fonction spéciale reset() de HorlogeCassee devrait être appelée. Encore une fois, bien qu'il soit passé comme un objet Horloge, l'actuel objet est toujours un objet HorlogeCassee :

auto shelfClock = new HorlogeCassee;
use(shelfClock);
writeln(shelfClock);

La sortie des valeurs de temps aléatoires en résultat de remise à zéro de HorlogeCassee :

00:00:00

58-8 - L'héritage est transitif

Le polymorphisme n'est pas juste limité à deux classes. Les sous-classes de sous-classes peuvent aussi être utilisées à la place de toute superclasse dans la hiérarchie.

Considérons la hiérarchie InstrumendeMusique :

class InstrumentdeMusique {
// ...
}

class InstrumentACorde : InstrumentdeMusique {
// ...
}

class Violon : InstrumentACorde {
// ...
}

Les héritages ci-dessus construisent les relations suivantes : « Un instrument à cordes est un instrument de musique », et « un violon est un instrument à cordes ». Par contre, il est aussi vrai de dire : « le violon est un instrument de musique ». En conséquence, un objet Violon peut être utilisé à la place d'un objet IntrumentdeMusique.

Supposons que tout le code supporté ci-dessous ait été aussi défini :

void playInTune(InstrumentdeMusique instrument,
MorceaudeMusique mrceau) {
instrument.tune();
instrument.play(morceau);
}

// ...

auto monViolon = new Violon;
playInTune(myViolin, improvisation);

Bien que playInTune() attende un InstrumentdeMusique, il va être appelé avec un objet Violon à cause de la relation « un violon est un instrument de musique ».

L'héritage peut être aussi profond que nécessaire.

58-9 - Fonctions et classes abstraites

Parfois, il y a des fonctions membres qu'il est naturel de faire apparaître dans une interface de classe, même si cette classe ne peut pas fournir sa définition. Quand il n'y a pas de définition concrète d'une fonction membre, cette fonction est une fonction membre abstraite. Une classe qui a au moins une fonction abstraite est une classe abstraite.

Par exemple, la superclasse PieceEchec dans une hiérarchie devrait avoir un membre estValide() qui détermine si un mouvement donné est valide pour ce type de pièce. Comme la validité d'un mouvement dépend du type actuel de la pièce d'échec, la classe générale PieceEchec ne peut prendre la décision elle-même. Les mouvements valides peuvent seulement être connus par la sous-classe comme le pion, le roi, etc.

Le mot clé Abstract spécifie que la classe héritée doit implémenter une telle méthode elle-même :

class PieceEchec {
abstract bool isValid(in Square from, in Square to);
}

Il n'est pas possible de construire des objets de classes abstraites :

auto piece = new PieceEchec;    // ← erreur compilation

La sous-classe devrait avoir à surcharger et implémenter toutes les fonctions abstraites afin que la classe soit abstraite et donc constructible :

class Pion : PieceEchec {
override bool isValid(in Square from, in Square to) {
// ... implémentation de isValid pour pion ...
return decision;
}
}

Il est maintenant possible de construire des objets Pion :

auto piece = new Pion;             // compile

Notez qu'une fonction abstraite pourrait avoir sa propre implémentation, mais requerra toujours une sous-classe pour fournir sa propre implémentation d'une telle fonction. Par exemple, sa propre implémentation de PiecedEchec devrait fournir quelques contrôles utiles :

class PiecedEchec {
abstract bool isValid(in Square from, in Square to) {
// Nous avons besoin que la position « de »
// soit différente de la position « à »
return from != to;
}
}

class Pion : PiecedEchec {
override bool isValid(in Square from, in Square to) {
// Vérifions d'abord  si le mouvement est valide
// n'importe quelle pièce
if (!super.isValid(from, to)) {
return false;
}

// ... puis contrôlons qu'il est valide pour le  Pion ...

return decision;
}
}

La classe PiecedEchec est toujours une classe abstraite même si isvalid() était déjà implémentée. Mais la classe Pion est non abstraite et peut être instanciée.

58-10 - Exemple

Considérons une hiérarchie de classe qui représente un véhicule ferroviaire :

Les fonctions que WagonPassager déclarera comme abstraites sont indiquées par des points d'interrogation.

Comme mon but est seulement de présenter une hiérarchie de classe et signaler certaines décisions de design, je ne vais pas implémenter ces classes entièrement. Elles se contenteront d'afficher des messages.

La classe la plus générale de la hiérarchie ci-dessus est VehiculeFerroviere. Dans ce programme, il saura seulement comment bouger lui-même :

class VehiculeFerroviere {
void avance(in size_t kilomètres) {
writefln("Le véhicule avance de  %s kilomètres",
kilometres);
}
}

Une classe qui hérite de VehiculeFerroviere est locomotive, qui n'a pas encore de membre particulier :

class Locomotive : VehicleFerroviere {
}

Nous allons ajouter une fonction membre spéciale klaxonner plus tard durant un des exercices.

WagonPassager est un VehiculeFerroviere. Cependant, si la hiérarchie supporte différents types de wagons passager, alors certains comportements comme le chargement et le déchargement doivent être faits selon leurs types exacts. Pour cette raison, VehiculeFerroviere peut seulement déclarer ces deux fonctions comme abstraites :

class WagonPassager : VehiculeFerroviere {
abstract void chargement();
abstract void dechargement();
}

Charger et décharger un wagon de passagers est aussi simple que d'ouvrir les portières d'une voiture, tandis que le chargement et déchargement de fret peut nécessiter des porteurs et des treuils. Les sous-classes suivantes fournissent des définitions pour les fonctions abstraites de WagonPassager :

class WagonPassager : VehiculeFerroviere {
override void chargement() {
writeln("Les passagers montent à bord");
}

override void dechargement() {
writeln("Les passagers descendent");
}
}

class VoituredeFret chargement WagonPassager {
override void chargement() {
writeln("les ca sont chargés");
}

override void dechargement() {
writeln("Les caisses sont déchargées");
}
}

Être une classe abstraite n'empêche pas l'usage de WagonPassager dans le programme. Les objets de WagonPassager ne peuvent pas être construits, mais WagonPassager peut être utilisé comme une interface. Comme la sous-classe définit les deux relations « un wagon passagers est un véhicule ferroviaire » et « le wagon de fret est un véhicule ferroviaire », les objets de WagonPassager et de WagondeFret peuvent être utilisés à la place de VehiculeFerroviere, cela sera vu dans la classe Train ci-dessous.

La classe représentant un train peut consister en une locomotive et un tableau de véhicules ferroviaires :

class Train : VehiculeFerroviere {
Locomotive locomotive;
VehiculeFerroviere[] wagons;

// ...
}

Je voudrais répéter un point important: bien que Locomotive et Wagon héritent tous les deux de VehiculeFerroviere, il ne serait pas correct que Train hérite de l'un ou l'autre. L'héritage implique la relation «est un» et un train n'est ni une locomotive ni des wagons de passagers. Un train consiste en leur ensemble.

Si nous avons besoin que chaque train doive avoir une locomotive, le constructeur de Train doit s'assurer qu'il prenne un objet Locomotive valide. De même, si les wagons sont optionnels, ils peuvent être ajoutés par une fonction membre :

import std.exception;
// ...

class Train : VehiculeFerroviere {
// ...

this(Locomotive locomotive) {
enforce(locomotive !is null,
"Locomotive ne peut être  null");
this.locomotive = locomotive;
}

void ajoutWagon(Wagon[] voiture...) {
this.voiture ~= voiture;
}

// ...
}

Remarquez que ajoutVoiture() peut valider ainsi les objets Wagon, j'ignore cette validation ici.

Nous pouvons imaginer que les départs et arrivées de trains puissent aussi être supportés :

class Train : VehiculeFerroviere {
// ...

void stationDepartstring station) {
foreach (wagon; wagons) {
wagon.chargement();
}

writefln("Départ depuis la station %s", station);
}

void stationArriveestring station) {
writefln("Arrivée à la station %s", station);

foreach (wagon; wagons) {
wagon.dechargement();
}
}
}

/* La fonction main suivante fait usage de la hiérarchie VehiculeFerroviere :*/

import std.stdio;

// ...

void main() {
auto locomotive = new Locomotive;
auto train = new Train(locomotive);

train.ajoutWagon(new wagonPassager, new wgondeFret);

train.departStation("Ankara");
train.avance(500);
train.arriveStation("Haydarpaşa");
}

La classe Train est utilisée par les fonctions qui sont fournies par deux interfaces séparées :

1. Quand la fonction avance() est appelée, l'objet Train va être utilisé comme un objet VehiculeFerroviere, car la fonction est déclarée par VehiculeFerroviere ;

2. Quand les fonctions departStation() et arriveStation() sont appelées, train va être utilisé comme un objet Train, car ces fonctions sont déclarées par Train.

Les flèches indiquent que les fonctions chargement() et dechargement() fonctionnent selon le type actuel de Wagon :

Les passagers montent à bord

←

Les caisses sont chargées

←

Départ de la station Ankara

Le véhicule avance de 500 kilomètres

Arrivée à la station Haydarpaşa

Les passagers descendent

←

Les caisses sont déchargées

←

58-11 - Résumé

- L'héritage est utilisé pour la relation « est un » ;

- chaque classe peut hériter d'une classe ;

- super a deux usages : appeler le constructeur de la superclasse et accéder aux membres de celle-ci ;

- override sert à redéfinir des fonctions membres d'une superclasse, spécialement pour une sous-classe ;

- abtract requiert qu'une fonction membre doit être surchargée.

58-12 - Exercices

1. Modifions VehiculeFerroviere. En plus de retourner la distance d'avancement, faisons-la également faire des sons. Pour garder la sortie courte, affichons les sons par paliers de 100 kilomètres :

class VehiculeFerroviere {
void avance(in size_t kilometres) {
writefln("Le véhicule avance de %s kilomètres",
kilometres);

foreach (i; 0 .. kilometres / 100) {
writefln("  %s", faireSon());
}
}

// ...
}

2. Cependant, faireSon() ne peut pas être défini par VehiculeFerroviere, car les véhicules ont des sons différents :

Laissez Train.faireSon pour le prochain exercice.

Parce qu'il doit être surchargé, faireSon() doit être déclaré comme abstraite par la superclasse :

class vehiculeFerroviere {
// ...

abstract string faireSon();
}
// Implémentez faireSon() pour la sous-classe et essayez
// le code avec le main() suivant :

void main() {
auto wagonn1 = new wagonPassager;
wagonn1.avance(100);

auto wagonn2 = new wagonMarchandise;
wagonn2.avance(200);

auto locomotive = new Locomotive;
locomotive.avance(300);
}

Faites en sorte que le programme produise la sortie suivante :

Le véhicule avance de 100 kilomètres
clac clac
Le véhicule avance de 200 kilomètres
clac clac
clac clac
Le véhicule avance de 300 kilomètres
chop chop
chop chop
chop chop

Notez qu'il n'est pas requis que les sons wagonPassager et wagonMarchandise soient différents. Ils peuvent partager la même implémentation depuis wagon.

Pensez à comment faireSon()peut être implémenté pour Train. Une idée est que train.faireSon retourne une chaîne des sons des membres de Train.

59 - Concurrence par messages

La concurrence est à la fois similaire et différente du sujet que nous avons abordé dans le dernier chapitre, le parallélisme. Comme ces deux concepts impliquent tous les deux d'exécuter un programme sur des threads, et que le parallélisme est basé sur la concurrence, ils sont souvent confondus.

Voici les différences entre le parallélisme et la concurrence :

- l'objectif principal du parallélisme est de profiter des multiples cœurs d'un processeur pour améliorer les performances d'un programme. La concurrence d'un autre côté, est un concept qui peut être utile même dans un environnement monocœur. La concurrence, c'est faire s'exécuter un programme sur plus d'un thread à la fois ;

- en parallélisme, les tâches sont indépendantes les unes des autres, ce serait en fait un bogue si une tâche dépendait d'autres tâches qui s'exécutent au même moment. Dans le cas de la concurrence, il est tout à fait normal qu'un thread dépende des résultats d'autres threads ;

- même si les deux modèles utilisent les threads du système, les threads du parallélisme sont encapsulés dans le concept de tâche. La concurrence utilise les threads explicitement ;

- le parallélisme est facile à utiliser, et tant que les tâches sont indépendantes, il est facile de créer des programmes qui fonctionnent correctement. La concurrence est facile à mettre en place uniquement quand elle est basée sur les messages. Il est très difficile d'écrire des programmes concurrents s'ils sont basés sur le modèle traditionnel de la concurrence, qui implique le partage de données verrouillées.

Le langage D permet d'utiliser les deux modèles de concurrence : les messages et le partage de données. Nous allons parler des messages dans ce chapitre et du partage de données dans le prochain chapitre.

59-1 - Concepts

- Thread  : les systèmes d'exploitation considèrent les programmes comme des unités qu'ils appellent threads. L'exécution d'un programme D commence par la fonction main dans le thread qui a été assigné au programme par le système d'exploitation. Toutes les opérations du programme sont normalement exécutées dans ce thread. Le programme est libre de démarrer d'autres threads pour pouvoir exécuter plusieurs tâches simultanément. En fait, les tâches ont été vues dans le chapitre précédent, et sont basées sur des threads démarrés automatiquement par std.parallelism.
Le système d'exploitation peut mettre en pause l'exécution des threads sans qu'on puisse prédire quand ni pour combien de temps. Même les opérations aussi simples qu'incrémenter une variable peuvent être interrompues pendant leur exécution :

i++;

- L'opération ci-avant implique trois étapes : lire la valeur de la variable, l'incrémenter, et assigner la nouvelle valeur à la variable. Le thread peut être mis en pause entre ces étapes et continuer sans qu'on sache quand.

- Message : les données qui sont transmises entre des threads sont appelées des messages. Les messages peuvent être composés d'une ou de plusieurs variables, de n'importe quel type.

- Identifiant de thread : chaque thread a un identifiant, qui est utilisé pour spécifier le destinataire d'un message.

- Père : n'importe quel thread qui démarre un autre thread est le père de ce thread.

- Fils : n'importe quel thread qui est démarré par un propriétaire est appelé un fils.

59-2 - Démarrer des threads

spawn() prend un pointeur vers une fonction en paramètre et démarrer un nouveau thread dans lequel va s'exécuter cette fonction. Toutes les opérations qui sont exécutées au sein de cette fonction, y compris des appels à d'autres fonctions, seront exécutées dans ce nouveau thread. La différence principale entre un thread démarré avec spawn() et un thread démarré avec task() est que spawn() rend possible le passage de messages entre les threads.

Dès qu'un nouveau thread est lancé, le père et le fils s'exécutent séparément comme s'ils étaient des programmes indépendants :

import std.stdio;
import std.concurrency;
import core.thread;

void fils() {
foreach (i; 0 .. 5) {
Thread.sleep(500.msecs);
writeln(i, " (fils)");
}
}

void main() {
spawn(&fils);

foreach (i; 0 .. 5) {
Thread.sleep(300.msecs);
writeln(i, " (main)");
}

writeln("main est terminé.");
}

Les exemples dans ce chapitre font des appels à la fonction Thread.sleep pour ralentir les threads et montrer qu'ils s'exécutent simultanément. La sortie du programme montre que les deux threads, celui dans lequel s'exécute main() et l'autre qui a été démarré par spawn(), s'exécutent indépendamment en même temps :

0 (main)
0 (fils)
1 (main)
2 (main)
1 (fils)
3 (main)
2 (fils)
4 (main)
main est terminé
3 (fils)
4 (fils)

Le programme attend que tous les threads aient fini de s'exécuter. On peut le voir dans la sortie ci-avant, en observant que fils() continue son exécution, même après que main se soit terminé après avoir affiché « main est terminé ».

Les paramètres que la fonction démarrée dans un thread prend sont passés à spawn() comme arguments. Les deux threads fils dans le programme suivant affichent quatre nombres chacun. Ils prennent le nombre de départ en paramètre :

import std.stdio;
import std.concurrency;
import core.thread;

void fils(int nbInitial) {
foreach (i; 0 .. 4) {
Thread.sleep(500.msecs);
writeln(nbInitial + i);
}
}

void main() {
foreach (i; 1 .. 3) {
spawn(&fils, i * 10);
}
}

La sortie d'un de ces threads est précédée d'une flèche () :

10
> 20
11
> 21
12
> 22
13
> 23

La sortie du programme peut être différente entre deux exécutions en fonction de comment le système d'exploitation a mis en pause et remis en route les threads.

Chaque système d'exploitation a une limite de threads qui peuvent s'exécuter simultanément. Cette limite peut être réglée par l'utilisateur, pour tout le système ou selon un autre critère. Les performances globales d'un système peuvent être altérées s'il y a plus de threads qui sont occupés que de cœurs dans le processeur. Quand un thread est occupé à réaliser des opérations, on dit qu'il est attaché au processeur. De nombreux threads passent beaucoup de temps sans réaliser d'opérations, à attendre qu'un événement se produise comme une entrée de l'utilisateur, l'arrivée de données sur une connexion réseau, la fin d'un appel à Thread.sleep, etc. Ces threads sont dits attachés à l'E/S (E/S : Entrée/Sortie). Quand ce genre de thread s'exécute, un programme peut se permettre de démarrer plus de threads que de cœurs sans dégrader ses performances. Comme dans chaque décision qui concerne les performances d'un programme, il faut réaliser des mesures pour être certains des ressources que consomme effectivement chaque thread.

59-3 - Identifiants de threads

thisTid() retourne l'identifiant du thread courant. On l'utilise communément sans parenthèse :

import std.stdio;
import std.concurrency;

void printTid(string tag) {
writefln("%s: %s", tag, thisTid);
}

void fils() {
printTid("Fils");
}

void main() {
spawn(&fils);
printTid("Père");
}

Le type du retour de thisTid() et Tid, ce qui n'a pas beaucoup de sens dans ce programme, car Tid ne redéfinit pas sa fonction toString():

Père : Tid(std.concurrency.MessageBox)
Fils : Tid(std.concurrency.MessageBox)

La valeur de retour de spawn(), que nous avions ignoré jusqu'à maintenant, est l'identifiant du thread fils :

Tid monFils = spawn(&fils);

Inversement, l'id du thread père peut être obtenu grâce à la fonction ownerTid().

En résumé, le père est identifié par ownerTid() et le fils par la valeur retournée par spawn().

59-4 - Le passage de messages

send() envoi des messages et receiveOnly() attend un message d'un type particulier. (Il y a aussi prioritySend(), receive() et receiveTimeout(), que l'on expliquera plus tard dans ce chapitre.)

Le père dans le programme ci-après envoie à son fils un message de type int et attend en retour un message de type double. Les threads continuent de s'envoyer des messages jusqu'à ce que le père reçoive un int négatif. Voici le thread père :

void main() {
Tid fils = spawn(&fonctionFille);

foreach (valeur; 1 .. 5) {
fils.send(value);
double resultat = receiveOnly!double();
writefln("envoyé: %s, reçu: %s", valeur, resultat);
}

/* Envoie d'une valeur négative au fils pour que le programme
* s'arrête */
fils.send(-1);
}

main() stocke la valeur retournée par spawn() dans la variable fils et utilise cette valeur quand elle envoie des messages au fils.

De l'autre côté, le fils reçoit un int, utilise cette valeur dans un calcul, et envoie le résultat de type double à son père :

void fonctionFille(){
int valeur = 0;

while(valeur >= 0){
valeur = receiveOnly!int();
double resultat = to!double(valeur) / 5;
ownerTid.send(resultat);
}
}

Le thread principal affiche les messages qu'il envoie, et ceux qu'il reçoit :

envoyé : 1, reçu : 0.2
envoyé : 2, reçu : 0.4
envoyé : 3, reçu : 0.6
envoyé : 4, reçu : 0.8

Il est possible d'envoyer plus qu'une valeur dans un même message. Le message suivant est fait de trois parties :

ownerTid.send(thisTid, 42, 1.5);

Les valeurs passées ensemble dans un message forment un tuple pour celui qui reçoit le message. Dans ces cas, les paramètres du template de receiveOnly() doivent correspondre aux membres du tuple :

auto message = receiveOnly!(Tid, int, double)();

auto sender   = message[0];
auto integer  = message[1];
auto floating = message[2];

Si les types ne correspondent pas, une exception MessageMismatch est levée :

import std.concurrency;

void fonctionFils() {
ownerTid.send("bonjour");    // ← Sending string
}

void main() {
spawn(&fonctionFils);

auto message = receiveOnly!double();    // ← Expecting double
}

La sortie :

std.concurrency.MessageMismatch@std/concurrency.d(235):
Unexpected message type: expected 'double', got 'immutable(char)[]'

Les exceptions que le fils peut lever ne sont pas attrapées par le père. Une solution possible est de faire envoyer l'exception en tant que message par le fils. Nous verrons cela plus tard dans le chapitre.

59-5 - Exemple

Utilisons ce que nous avons vu jusqu'à présent dans un programme de simulation.

Le programme va simuler des robots indépendants qui bougent au hasard dans un espace en deux dimensions. Le mouvement de chaque robot est géré par un thread qui prend trois informations quand il est démarré :

- le numéro (id) du robot : cette information est renvoyée au père pour identifier le robot qui envoie un message ;

- l'origine : ce sont les coordonnées de départ du robot ;

- la durée entre chaque étape : cette information est utilisée pour déterminer quand le robot bougera.

Ces informations peuvent être stockées dans cette structure Job :

struct Job {
size_t robotId;
Position origine;
Duration reposDuree;
}

La fonction qui fait bouger les robots dans un thread envoie l'id du robot et ses mouvements au thread père continuellement :

void bougeurRobot(Job job){
Position depuis = job.origin;

while(true){
Thread.sleep(job.reposDuree);

Position to = voisinAuHasard(depuis);
Movement mouvement = Movement(depuis, vers);
depuis = vers;
ownerTid.send(MovementMessage(job.robotId, mouvement));
}
}

Le père ne fait qu'attendre ces messages dans une boucle infinie. Il identifie les robots par leurs id qui sont spécifiés dans les messages. Le père ne fait qu'afficher les différents mouvements :

while(true){
auto message = receiveOnly!MovementMessage();

writefln("%s %s", robots[message.robotId], message.mouvement);
}

Tous les messages dans ce programme vont du fils au père. Le passage de messages implique des schémas de communication bien plus complexes dans de vrais programmes.

Voici le programme complet :

import std.stdio;
import std.random;
import std.string;
import std.concurrency;
import core.thread;

struct Position {
int ligne;
int colonne;

string toString() {
return format("%s,%s", ligne, colonne);
}
}

struct Movement {
Position depuis;
Position vers;

string toString() {
return ((depuis == vers)
? format("%s (idle)", depuis)
: format("%s -> %s", depuis, vers));
}
}

class Robot {
string image;
Duration reposDuree;

this(string image, Duration reposDuree) {
this.image = image;
this.reposDuree = reposDuree;
}

override string toString() {
return format("%s(%s)", image, reposDuree);
}
}

/* Retourne une position au hasard */
Position positionHasard() {
return Position(uniform!"[]"(-10, 10),
uniform!"[]"(-10, 10));
}

/* Retourne une coordonnée à une distance d'au maximum un de la coordonnée courante */
int pasAuHasard(int courant) {
return courant + uniform!"[]"(-1, 1);
}

/* Retourne un voisin de la position passée en paramètre.
* Cela peut être un des voisins dans les huit directions,
* ou la position elle-même. */
Position voisinAuHasard(Position position) {
return Position(pasAuHasard(position.line),
pasAuHasard(position.column));
}

struct Job {
size_t robotId;
Position origine;
Duration reposDuree;
}

struct MovementMessage {
size_t robotId;
Movement mouvement;
}

void bougeurRobot(Job job) {
Position from = job.origine;

while (true) {
Thread.sleep(job.reposDuree);

Position to = voisinAuHasard(from);
Movement mouvement = Movement(from, to);
from = to;

ownerTid.send(MovementMessage(job.robotId, mouvement));
}
}

void main() {
/* Des robots avec des durées de repos différentes. */
Robot[] robots = [ new Robot("A",  600.msecs),
new Robot("B", 2000.msecs),
new Robot("C", 5000.msecs) ];

/* Démarre un bougeurRobot pour chaque robot. */
foreach (robotId, robot; robots) {
spawn(&bougeurRobot, Job(robotId,
positionHasard(),
robot.reposDuree));
}

/* Affiche les infos de chaque mouvement des robots. */
while (true) {
auto message = receiveOnly!MovementMessage();

/* Affiche le mouvement dans ce robot. */
writefln("%s %s",
robots[message.robotId], message.movement);
}
}

Le programme affiche chaque mouvement jusqu'à ce qu'il soit coupé :

A(600 ms) 6,2 -> 7,3
A(600 ms) 7,3 -> 8,3
A(600 ms) 8,3 -> 7,3
B(2 secs) -7,-4 -> -6,-3
A(600 ms) 7,3 -> 6,2
A(600 ms) 6,2 -> 7,1
A(600 ms) 7,1 (idle)
B(2 secs) -6,-3 (idle)
A(600 ms) 7,1 -> 7,2
A(600 ms) 7,2 -> 7,3
C(5 secs) -4,-4 -> -3,-5
A(600 ms) 7,3 -> 6,4
...

Ce programme montre comment la concurrence par passage de messages peut être utile : les mouvements des robots sont calculés indépendamment par des threads séparés qui ne savent rien les uns des autres. C'est le père qui traite le processus d'affichage en série en recevant les messages un par un.

59-6 - Attendre différents types de messages

receiveOnly() peut attendre un type de message. receive() par contre peut attendre plus d'un type de message. Cette fonction peut trier les messages et les distribuer à des fonctions déléguées. Quand un message arrive, son type est comparé à celui qu'accepte chaque fonction déléguée. La déléguée qui correspond au type prend alors le message en charge.

Par exemple, l'appel à la fonction receive() suivant spécifie deux fonctions qui prennent en charge les messages de type string et int respectivement :

void fonctionFils(){
bool termine = false;

while(!termine){
void intHandler(int message){
writeln("prise en charge d'un int : ", message);

if(message == -1){
writeln("sortie");
termine = true;
}
}

void stringHandler(string message){
writeln("prise en charge d'un string : ", message);
}

receive(&intHandler, &stringHandler);
}
}

Les messages de type int correspondront à intHandler() et les messages de type string correspondront à stringHandler(). Le thread fils ci-avant peut être testé dans le programme suivant :

import std.stdio;
import std.concurrency;

//...

void main(){
auto fils = spawn(&fonctionFils);

fils.send(10);
fils.send(42);
fils.send("hello");
fils.send(-1);
}

La sortie du programme montre que les messages sont bien répartis aux fonctions correspondantes à la réception :

prise en charge d'un int : 10
prise en charge d'un int : 42
prise en charge d'un string : hello
prise en charge d'un int : -1
sortie

Les lambdas-fonctions et les objets qui implémentent la fonction membre opCall() peuvent aussi être passés en paramètre à receive() en tant que fonctions qui prennent en charge des messages. Le programme suivant gère les messages en les passant à des lambda-fonctions. Il définit également un type Exit utilisé dans la communication avec le thread pour préciser si oui ou non il faut terminer le programme. Utiliser un type explicité pour cela est plus clair que d'envoyer une valeur arbitraire comme -1, tel que nous l'avons fait dans l'exemple précédent.

Il y a trois fonctions anonymes ci-après qui sont passées à receive() comme fonctions qui prennent en charge des messages :

import std.stdio;
import std.concurrency;

struct Exit(){
}

void fonctionFils(){
bool termine = false;

while(!termine){
receive(
(int message) {
writeln("message int : ", message);
},
(string message) {
writeln("message string : ", message);
},
(Exit message) {
writeln("exiting");
termine = true;
}
);
}
}

void main(){
auto fils = spawn(&fonctionFils);

fils.send(10);
fils.send(42);
fils.send("coucou");
fils.send(Exit());
}

59-6-1 - Recevoir n'importe quel type de message

std.variant.Variant est un type qui peut encapsuler n'importe quel type de données. Les messages qui ne correspondent à aucune fonction spécifiée avant dans la liste des arguments de receive() sont toujours passées à la fonction qui prend un Variant en paramètre :

import std.stdio;
import std.concurrency;

void fonctionFils(){
receive(
(int message){
/* ... */
},
(string message){
/* ... */
},
(Variant message){
writeln("Message inattendu : ", message);
}
);
}

struct SpecialMessage{
// ...
}

void main(){
auto fils = spawn(&fonctionFils);
fils.send(SpecialMessage);
}

La sortie :

Message inattendu : SpecialMessage()

Les détails concernant Variant sortent du cadre de ce chapitre.

59-7 - Attendre des messages pendant un certain temps

Il se peut qu'on veuille attendre un message uniquement dans un certain délai de temps. Il se peut par exemple que l'expéditeur ait fini son exécution. receiveTimeout() empêche le thread recevant d'attendre indéfiniment. La fonction renvoie true si un message a été reçu et false dans le cas contraire.

import std.stdio;
import std.concurrency;
import core.thread;

void fonctionFils() {
Thread.sleep(3.seconds);
ownerTid.send("bonjour");
}

void main() {
spawn(&fonctionFils);

writeln("En attente d'un message");
bool recu = false;
while (!recu) {
recu = receiveTimeout(600.msecs,
(string message) {
writeln("recu: ", message);
});

if (!recu) {
writeln("... Pas de message pour l'instant");

/* ... D'autres opérations peuvent être effectuées ici ...*/
}
}
}

Le père ci-avant attend pour un message pendant 600 millisecondes. Il peut continuer à exécuter d'autres opérations si aucun message ne s'exécute pendant ce temps.

En attente d'un message
... Pas de message pour l'instant
... Pas de message pour l'instant
... Pas de message pour l'instant
recu: bonjour

59-8 - Exceptions pendant l'exécution du thread fils

Comme nous l'avons vu dans le chapitre précédent, quand on utilise std.parallelism, une exception levée dans une tâche peut être automatiquement attrapée par le thread père. Cela permet de traiter ces exceptions :

try{
theTask.yieldForce();
} catch (Exception e) {
writefln("Erreur détectée dans la tâche : '%s'", exec.msg);
}

std.concurrency ne permet pas de base de traiter les exceptions de cette façon. Cependant, les exceptions peuvent être attrapées et envoyées explicitement par le thread fils. Comme nous le verrons ci-après, il est aussi possible de recevoir des exceptions OwnerTerminated et LinkTerminated en tant que message.

La fonction calculer() ci-après reçoit des messages de type string, les convertit en double, leur ajoute 0.5 et renvoie le résultat par message :

void calculer(){
while(true){
auto message = receiveOnly!string();
ownerTid.send(to!double(message) + 0.5);
}
}

La fonction to!double() peut lever une exception si la chaîne de caractères n'est pas convertible en valeur double. Comme cette exception entraînerait la fin de l'exécution de la fonction fille, le père dans le programme suivant ne pourrait recevoir qu'un seul message :

import std.stdio;
import std.concurrency;
import std.conv;

// ...

void main() {
Tid calculator = spawn(&calculer);

calculator.send("1.2");
calculator.send("hello");  // ← Entrée incorrecte
calculator.send("3.4");

foreach (i; 0 .. 3) {
auto message = receiveOnly!double();
writefln("résultat %s: %s", i, message);
}
}

Le père reçoit la réponse 1.7 pour « 1.2 », mais parce que la fonction fille s'est terminée, le père sera bloqué à attendre un message qui n'arrivera jamais.

result 0: 1.7
← waiting for a message that will never arrive

Ce que peut faire la fonction fille, c'est capter l'exception et l'envoyer dans un message d'erreur spécial. La programme suivant envoie la raison de l'échec dans une structure EchecCalcul dans un message. De plus, ce programme utilise un type Exit pour signaler quand la fonction fille doit se terminer :

import std.stdio;
import std.concurrency;
import std.conv;

struct EchecCalcul {
string reason;
}

struct Exit {
}

void calculate() {
bool termine = false;

while (!termine) {
receive(
(string message) {
try {
ownerTid.send(to!double(message) + 0.5);

} catch (Exception exc) {
ownerTid.send(EchecCalcul(exc.msg));
}
},

(Exit message) {
termine = true;
});
}
}

void main() {
Tid calculator = spawn(&calculate);

calculator.send("1.2");
calculator.send("hello");  // ← entrée incorrecte
calculator.send("3.4");
calculator.send(Exit());

foreach (i; 0 .. 3) {
writef("resultat %s: ", i);

receive(
(double message) {
writeln(message);
},

(EchecCalcul message) {
writefln("ERREUR! '%s'", message.reason);
});
}
}

Cette fois-ci, la raison de l'échec est affichée par le père :

resultat 0: 1.7
resultat 1: ERREUR! 'no digits seen'
resultat 2: 3.9

Un autre moyen de faire serait d'envoyer l'exception en elle-même au thread père. La père peut alors utiliser l'exception ou la lever à nouveau :

// ... Dans le fils ...
try{
//...

} catch (share(Exception) exc){
ownerTid.send(exc);
}},

// ... Dans le père ...
receive(
//...
(shared(Exception) exc){
throw exc;
}
);

59-9 - Détecter la fin de l'exécution d'un thread

Les threads peuvent détecter la fin de l'exécution d'un receveur de messages.

59-9-1 - L'exception OwnerTerminated

Cette exception est levée à la réception d'un message du père, si le père a terminé son exécution. Le thread père suivant se contente d'envoyer deux messages à son fils et se termine. Cela lance une OwnerTerminated dans le thread fils :

import std.stdio;
import std.concurrency;

void main() {
spawn(&fonctionIntermediaire);
}

void fonctionIntermediaire() {
auto fils = spawn(&fonctionFils);
fils.send(1);
fils.send(2);
}  // ← Se termine après avoir envoyé deux messages

void fonctionFils() {
while (true) {
auto m = receiveOnly!int(); // ← Une exception est levée
//  si le père est terminé.
writeln("Message: ", m);
}
}

La sortie :

Message: 1
Message: 2
std.concurrency.OwnerTerminated@std/concurrency.d(248):
Owner terminated

Le fils peut attraper l'exception et finir son exécution proprement :

void fonctionFille(){
bool termine = false;

while(!termine){
try{
auto m = receiveOnly!int();
writeln("Message: ", m);
} catch(OwnerTerminated exc){
writeln("La fonction mère est terminée.");
termine = true;
}
}
}

La sortie :

Message: 1
Message: 2
La fonction mère est terminée.

Nous allons voir dans la prochaine partie que cette exception peut aussi être reçue en tant que message.

59-9-2 - L'exception LinkTerminated

spawnLinked() est utilisée de la même façon que spawn(). Quand un thread fils lancé par spawnLinked() se termine, le thread père est informé par le lancement d'une exception LinkTerminated :

import std.stdio;
import std.concurrency;

void main() {
auto fils = spawnLinked(&fonctionFille);

while (true) {
auto m = receiveOnly!int(); // ← Une exception est
//   levée si le fils
//   est terminé.
writeln("Message: ", m);
}
}

void fonctionFille() {
ownerTid.send(10);
ownerTid.send(20);
}  // ← Se termine après avoir envoyé deux valeurs.

Le fils ci-avant se termine après avoir envoyé deux messages. Comme il a été démarré par spawnLinked(), le père est notifié de la fin de l'exécution du fils par une exception LinkTerminated.

Message: 10
Message: 20
std.concurrency.LinkTerminated@std/concurrency.d(263):
Link terminated

Le père peut attraper cette exception et faire quelque chose pour terminer son exécution proprement :

bool termine = false;

while (!termine) {
try {
auto m = receiveOnly!int();
writeln("Message: ", m);

} catch (LinkTerminated exc) {
writeln("Le fils s'est terminé");
termine = true;
}
}

59-9-3 - Recevoir les exceptions en tant que messages

Les exceptions OwnerTerminated et LinkTerminated peuvent également être reçues comme des messages. Le code suivant le montre pour une exception OwnerTerminated :

bool termine = false;

while (!termine) {
receive(
(int message) {
writeln("Message: ", message);
},

(OwnerTerminated exc) {
writeln("Le fils s'est terminé, on quitte le programme.");
termine = true;
}
);
}

59-10 - Gestion de boîte aux lettres

Chaque thread a une boîte aux lettres dédiée qui contient tous les messages envoyés à ce thread. Le nombre de messages dans une boîte aux lettres peut croître ou décroître en fonction du temps que met le thread à recevoir les réponses et à y répondre. Une boîte aux lettres qui grossit continuellement sature le système et résulte probablement d'un défaut dans la conception du programme. Cela peut également signifier que le thread ne traitera jamais les messages les plus récents.

setMaxMailboxSite() est une fonction utilisée pour limiter le nombre de messages qu'une boîte aux lettres peut contenir. Ses trois paramètres précisent la boîte aux lettres, le nombre maximum de messages qu'elle peut contenir, et ce qui doit être fait quand la boîte aux lettres est pleine, dans cet ordre. Il y a quatre options pour le dernier paramètre :

- OnCrowding.block: l'expéditeur est mis en pause jusqu'à ce qu'il y ait de la place dans la boîte aux lettres ;

- OnCrowding.ignore: le message est ignoré ;

- OnCrowding.throwException: une exception MailboxFull est levée à l'expéditeur du message ;

- une fonction de type function(Tid): la fonction spécifiée est appelée.

Avant d'examiner un exemple de setMaxMailboxSize(), parlons de ce qui peut amener une boîte aux lettres à grossir continuellement. Dans le programme suivant, le fils envoie des messages les uns à la suite des autres, mais le père passe un peu de temps à recevoir chaque message :

/* ATTENTION: votre système peut avoir
* un comportement imprédictible quand
* ce programme est exécuté.*/
import std.concurrency;
import core.thread;

void fonctionFille() {
while (true) {
ownerTid.send(42);    // ← Produit continuellement des messages
}
}

void main() {
spawn(&fonctionFille);

while (true) {
receive(
(int message) {
//Prend du temps à chaque message
Thread.sleep(1.seconds);
});
}
}

Parce que le destinataire est plus lent que l'expéditeur, la mémoire que le programme ci-avant utilise va grandir sans s'arrêter. Pour se prémunir de ce phénomène, le père peut limiter la taille de sa boîte aux lettres avant de démarrer la fonction fille :

void main() {
setMaxMailboxSize(thisTid, 1000, OnCrowding.block);

spawn(&fonctionFille);
// ...
}

L'appel à setMaxMailboxSize() définit la taille de la boîte aux lettres du thread principal à 1000. OnCrowding.block fait attendre l'expéditeur jusqu'à ce qu'il y ait de la place dans la boîte aux lettres.

L'exemple suivant utilise onCrowding.throwException, qui lance une exception MailboxFull à l'expéditeur quand la boîte aux lettres est pleine :

import std.concurrency;
import core.thread;

void fonctionFille() {
while (true) {
try {
ownerTid.send(42);

} catch (MailboxFull exc) {
/* Échec de l'envoi, on réessaye dans une seconde. */
Thread.sleep(1.msecs);
}
}
}

void main() {
setMaxMailboxSize(thisTid, 1000, OnCrowding.throwException);

spawn(&fonctionFille);

while (true) {
receive(
(int message) {
Thread.sleep(1.seconds);
});
}
}

59-11 - Messages prioritaires

Certains messages peuvent être prioritaires par rapport aux messages ordinaires grâce à la fonction prioritySend(). Ces messages sont traités avant les autres dans la boîte aux lettres :

prioritySend(pereTid, ImportantMessage(100));

Si le destinataire n'a pas de fonction qui gère le type du message prioritaire, une exception PriorityMessageException est levée :

std.concurrency.PriorityMessageException@std/concurrency.d(280):
Priority message

59-12 - Threads nommés

Dans les programmes simples que nous avons vus avant, il était facile d'utiliser les id des pères et des fils. Transmettre les id de threads en threads peut être extrêmement compliqué dans des programmes qui utilisent plus que quelques threads. Pour rendre tout cela moins complexe, il est possible d'assigner des noms aux threads qui sont accessibles depuis n'importe quel thread.

Les trois fonctions suivantes sont une interface pour accéder à un tableau associatif auquel chaque thread a accès :

- register() : associe un thread à un nom ;

- locate() : retourne le thread associé à un nom. S'il n'y a pas de thread associé au nom, alors Tid.init est retourné ;

- unregister() : dissocie l'association entre un thread et un nom.

La programme suivant démarre deux threads qui communiquent entre eux par leur nom. Ces threads s'envoient des messages l'un à l'autre jusqu'à ce qu'ils reçoivent un message Exit :

import std.stdio;
import std.concurrency;
import core.thread;

struct Exit{}{
}

void main(){
// Un thread dont le partenaire s'appelle "second"
auto premier = spawn(&player, "second");
register("premier", premier);
scope(exit) unregister("premier");

// Un thread dont le partenaire s'appelle "premier"
auto second = spawn(&player, "first");
register("second", second);
scope(exit) unregister("second");

Thread.sleep(2.seconds);

prioritySend(first, Exit());
prioritySend(second, Exit());

// Pour que les appels à unregister() soient effectués, main()
// doit attendre que les fils aient terminé leur exécution.
thread_joinAll();
}

void player(string nomDuPartenaire){
Tid partenaire;

while(partenaire == Tid.init){
Thread.sleep(1.msecs);
partnaire = locate(nomDuPartenaire);
}

bool termine = false;

while(!termine){
partner.send("hello " ~ nomDuPartenaire);
receive(
(string message){
writeln("Message: ", message);
Thread.sleep(500.msecs);
},
(Exit message){
writefln("%s, Je me termine", nomDuPartenaire);
termine = true;
}
);
}
}

L'appel à thread_joinAll() que l'on peut voir à la fin du main fait attendre le père jusqu'à ce que les fils aient terminé.

La sortie :

Message: hello second
Message: hello first
Message: hello second
Message: hello first
Message: hello first
Message: hello second
Message: hello first
Message: hello second
second, Je me termine.
first, Je me termine.

59-13 - Résumé

- Quand les threads ne doivent pas communiquer entre eux, préférez le parallélisme, qui a été vu dans le chapitre précédent. N'utilisez la concurrence que quand les threads doivent interagir.

- La concurrence par partage de mémoire étant difficile à implémenter correctement, préférez la concurrence par messages, qui a été couverte dans ce chapitre.

- spawn() et spawnLinked() démarrent des threads.

- thisTid est l'id du thread courant.

- ownerTid est l'id du thread père du thread courant.

- send() et prioritySend() envoient des messages.

- receiveOnly(), receive() et receiveTimeout() attendent des messages.

- Variant correspond à n'importe quel type de message.

- setMaxMailboxSize() limite la taille de la boîte aux lettres.

- register(), unregister() et locate() permettent d'identifier les threads par des noms.

- Des exceptions peuvent être levées pendant le passage de messages : MessageMismatch, OwnerTerminated, LinkTerminated, MailboxFull et PriorityMessageException.

- Le père peut attraper automatiquement les exceptions levées par un fils.

60 - Exercices

60-1 - Le programme Hello world - Correction

import std.stdio;

void main()
{
writeln("Autre chose... :p");
}

import std.stdio;

void main()
{
writeln("Une ligne...");
writeln("Une autre ligne...");
}

1. Le programme suivant ne peut pas être compilé parce qu'il manque le point-virgule à la fin de la ligne du writeln :

import std.stdio;

void main()
{
writeln("Hello world!")
}

60-2 - writeln et write - Correction

1. Une méthode est d'utiliser un autre paramètre entre les deux :

writeln("Hello world!", " ", "Hello fish!");

2. write peut elle aussi prendre plusieurs paramètres :

write("one", " two", " three");

60-3 - Types Fondamentaux - Correction

On peut utiliser d'autres types à la place de int :

import std.stdio;

void main()
{
writeln("Type             : ", short.stringof);
writeln("Taille en octets : ", short.sizeof);
writeln("Valeur minimale  : ", short.min);
writeln("Valeur maximale  : ", short.max);

writeln();

writeln("Type             : ", ulong.stringof);
writeln("Taille en octets : ", ulong.sizeof);
writeln("Valeur minimale  : ", ulong.min);
writeln("Valeur maximale  : ", ulong.max);
}

60-4 - Affectation et ordre d'évaluation - Correction

Les valeurs de a, b et c sont affichées à droite de chaque opération. La valeur qui change est à chaque opération surlignée

initialement

→ a 1, b 2, c sans importance

c = a

→ a 1, b 2,

a = b

→ , b 2, c 1

b = c

→ a 2, , c 1

À la fin, les valeurs de a et de b ont été échangées.

60-5 - Variable - Correction

import std.stdio;

void main()
{
int montant = 20;
double taux = 2.11;

writeln("J'ai échangé ", montant,
" euros au taux ", taux);
}

60-6 - Entrée standard et flux de sortie - Correction

import std.stdio;

void main()
{
writeln(1, ",", 2);
stdout.writeln(3, ",", 4);
}

60-7 - Expressions logiques - Correction

1. Parce que le compilateur reconnaît 10 < valeur comme une expression, il s'attend à avoir une virgule après elle pour l'accepter comme un argument valable de writeln. Ajouter des parenthèses autour de l'expression ne marche pas non plus parce que, cette fois, une parenthèse fermante serait attendue après la même expression.

2. Grouper l'expression de cette manière : (10 < value) < 20 supprime l'erreur de compilation, parce que dans ce cas, 10 < value est évaluée et son résultat est utilisé avec < 20.
Nous savons que la valeur d'une expression logique comme 10 < value est soit fausse, soit vraie. Dans des expressions entières, false et true sont convertis en 0 et 1 respectivement (nous verrons les conversions de types automatiques dans un chapitre suivant). De ce fait, l'expression complète est l'équivalent de 0 < 20 ou 1 < 20 , qui sont vraies toutes les deux.

3. L'expression « plus grand que la valeur basse et plus petit que la valeur haute » peut être écrite de cette façon :

writeln("Est entre : ", (valeur > 10) && (valeur < 20));

4. Il y a un vélo pour tout le monde peut être codée comme nombrePersonnes <= nombreVelos ou nombreVelos >= nombrePersonnes. Le reste de l'expression logique peut directement être traduit en code depuis l'énoncé :

writeln("Nous allons à la plage : ",
((distance < 10) && (nombreVelos >= nombrePersonnes))
||
((nombrePersonnes <= 5) && existeUneVoiture && existeUnPermis)
);

5. Notez le placement de l'opérateur || pour faciliter la lecture en séparant les deux conditions principales.

60-8 - Lire depuis l'entrée - Correction

Quand les caractères ne peuvent pas être convertis au type voulu, stdin devient inutilisable. Par exemple, en entrant « abc » quand un entier est attendu rendrait stdin inutilisable.

60-9 - L'instruction if - Correction

1. L'instruction writeln("Laver l'assiette") est indentée comme si elle était dans le bloc else. Cependant, du fait que le bloc de ce else n'est pas écrit avec des accolades, seule l'instruction writeln("Manger la tarte") est considérée comme étant dans la portée de ce else.
Comme les caractères blancs ne sont pas pris en compte dans le langage D, l'instruction « assiette » est en fait une instruction indépendante située au niveau de main et exécutée de façon inconditionnelle. Cela embrouille le lecteur à cause de l'indentation plus grande que normale. Si l'instruction « assiette » doit vraiment être dans le bloc else, il doit y avoir des accolades autour de ce bloc :

import std.stdio;

void main()
{
bool ilyaDeLaLimonade = true;

if (ilyaDeLaLimonade) {
writeln("Boire de la limonade");
writeln("Laver le verre");

} else {
writeln("Manger la tarte");
writeln("Laver l'assiette");
}
}

2. On peut trouver plusieurs solutions pour les conditions de ce jeu. Je vais montrer deux exemples. Dans la première, on fait une traduction directe depuis l'énoncé :

import std.stdio;

void main()
{
write("Quelle est la valeur du dé ? ");
int dé;
readf(" %s", &dé);

if (dé == 1) {
writeln("Vous avez gagné");

} else if (dé == 2) {
writeln("Vous avez gagné");

} else if (dé == 3) {
writeln("Vous avez gagné");

} else if (dé == 4) {
writeln("J'ai gagné");

} else if (dé == 5) {
writeln("J'ai gagné");

} else if (dé == 6) {
writeln("J'ai gagné");

} else {
writeln("ERREUR : ", dé, " est invalide.");
}
}

3. Malheureusement, ce programme comporte beaucoup de répétitions. On peut arriver au même résultat d'une autre manière. En voici une :

import std.stdio;

void main()
{
write("Quelle est la valeur du dé ? ");
int dé;
readf(" %s", &dé);

if ((dé == 1) || (dé == 2) || (dé == 3)) {
writeln("Vous avez gagné");

} else if ((dé == 4) || (dé == 5) || (dé == 6)) {
writeln("J'ai gagné");

} else {
writeln("ERREUR : ", dé, " est invalide");
}
}

4. Les précédentes manières de faire ne peuvent pas être utilisées dans ce cas. Il n'est pas pratique de taper 1000 valeurs différentes dans un programme et s'attendre à ce qu'elles soient toutes correctes ou lisibles. Pour cette raison, il est mieux de déterminer si la valeur du dé est à l'intérieur d'un intervalle ou non :

if ((dé >= 1) && (dé <= 500))

60-10 - La boucle while - Correction

1. À cause de la valeur initiale de nombre qui est 0, l'expression logique de la boucle while est fausse depuis le tout début, et cela empêche de rentrer dans le corps de la boucle. Une solution est d'utiliser une valeur initiale qui permettra à la condition du while d'être vraie au tout début :

int number = 3;

2. Toutes les variables dans le programme suivant sont initialisées par défaut à 0. Cela permet d'entrer dans les deux boucles au moins une fois :

import std.stdio;

void main()
{
int nombreSecret;

while ((nombreSecret < 1) || (nombreSecret > 10)) {
write("Veuillez entrer un nombre entre 1 et 10 : ");
readf(" %s", &nombreSecret);
}

int deviner;

while (deviner != nombreSecret) {
write("Deviner le nombre secret : ");
readf(" %s", &deviner);
}

writeln("C'est le bon nombre !");
}

60-11 - Nombres entiers et opérations arithmétiques - Correction

1. On peut utiliser l'opérateur / pour la division et l'opérateur % pour le reste :

import std.stdio;

void main()
{
int premier;
write("Veuillez entrer le premier nombre : ");
readf(" %s", &premier);

int second;
write("Veuillez entrer le second nombre : ");
readf(" %s", &second);

int quotient = premier / second;
int reste = premier % second;

writeln(premier, " = ",
second, " * ", quotient, " + ", reste);
}

2. On peut déterminer si le reste est 0 ou non avec une instruction if :

import std.stdio;

void main()
{
int premier;
write("Veuillez entrer le premier nombre : ");
readf(" %s", &premier);

int second;
write("Veuillez entrer le second nombre : ");
readf(" %s", &second);

int quotient = premier / second;
int reste = premier % second;

// Nous ne pouvons pas appeler writeln tout de suite avant de déterminer si
// le reste est 0 ou non. On doit terminer la ligne plus loin
// avec writeln.
write(premier, " = ", second, " * ", quotient);

// Le reste doit être affiché seulement si non nul.
if (reste != 0) {
write(" + ", reste);
}

// On peut maintenant terminer la ligne.
writeln();
}

3. Code :

import std.stdio;

void main()
{
while (true) {
write("0: Quitter, 1: Ajouter, 2: Soustraire, 3: Multiplier,",
" 4: Diviser - Veuillez entrer l'opération : ");

int operation;
readf(" %s", &operation);

// Vérifions qu'une opération valide a été entrée
if ((operation < 0) || (operation > 4)) {
writeln("Je ne connais pas cette opération");
continue;
}

if (operation == 0){
writeln("Au revoir !");
break;
}

// Si nous sommes ici, nous savons que nous avons affaire à l'une
// des 4 opérations. Maintenant, il est temps de demander
// deux entiers à l'utilisateur:

int premier;
int second;

write(" Premier nombre : ");
readf(" %s", &premier);

write("Second nombre : ");
readf(" %s", &second);

int resultat;

if (operation == 1) {
resultat = premier + second;

} else if (operation == 2) {
resultat = premier - second;

} else if (operation == 3) {
resultat = premier * second;

} else if (operation == 4) {
resultat = premier / second;

}  else {
writeln(
"erreur ! ",
"Cette condition ne devrait jamais avoir eu lieu.");
break;
}

writeln("       Résultat : ", resultat);
}
}

4. Code :

import std.stdio;

void main()
{
int valeur = 1;

while (valeur <= 10) {
if (valeur != 7) {
writeln(valeur);
}

++valeur;
}
}

60-12 - Types à virgule flottante - Correction

1. Il suffit de remplacer les trois int par trois double.

double first;
double second;

// ...

double result;

2. Le programme suivant laisse entrevoir comment il deviendrait compliqué si plus de cinq variables étaient nécessaires :

import std.stdio;

void main()
{
double valeur_1;
double valeur_2;
double valeur_3;
double valeur_4;
double valeur_5;

write("Value 1: ");
readf(" %s", &valeur_1);
write("Value 2: ");
readf(" %s", &valeur_2);
write("Value 3: ");
readf(" %s", &valeur_3);
write("Value 4: ");
readf(" %s", &valeur_4);
write("Value 5: ");
readf(" %s", &valeur_5);

writeln("Le double des valeurs :");
writeln(valeur_1 * 2);
writeln(valeur_2 * 2);
writeln(valeur_3 * 2);
writeln(valeur_4 * 2);
writeln(valeur_5 * 2);

writeln("Le cinquième des valeurs :");
writeln(valeur_1 / 5);
writeln(valeur_2 / 5);
writeln(valeur_3 / 5);
writeln(valeur_4 / 5);
writeln(valeur_5 / 5);
}

60-13 - Tableaux - Correction

1. Code :

import std.stdio;

void main()
{
write("Combien de valeurs seront entrées ? ");
int nombre;
readf(" %s", &nombre);

double[] valeurs;
valeurs.length = nombre;

// Le compteur est souvent appelé 'i'
int i;
while (i < nombre) {
write("Valeur ", i, " : ");
readf(" %s", &valeurs[i]);
++i;
}

writeln("Dans l'ordre:");
valeurs.sort;

i = 0;
while (i < nombre) {
write(valeurs[i], " ");
++i;
}
writeln();

writeln("Dans l'ordre inverse:");
valeurs.reverse;

i = 0;
while (i < nombre) {
write(valeurs[i], " ");
++i;
}
writeln();
}

2. Les explications sont fournies dans les commentaires :

import std.stdio;

void main()
{
// On utilise des tableaux dynamiques parce qu'on ne connaît pas
// le nombre de valeurs qui vont être lues depuis l'entrée
int[] pairs;
int[] impairs;

writeln("Merci d'entrer des entiers (-1 pour finir) :");

while (true) {

// Lecture de la valeur
int valeur;
readf(" %s", &valeur);

// La valeur spéciale de -1 casse la boucle
if (valeur == -1) {
break;
}

// Ajout au tableau correspondant, selon
// si la valeur est paire ou impaire. C'est un nombre
// pair s'il n'y a pas de reste quand on le divise par 2.
if ((valeur % 2) == 0) {
pairs ~= valeur;

} else {
impairs ~= valeur;
}
}

// Les tableaux impairs et pairs sont triés séparément
impairs.sort;
pairs.sort;

// Les deux tableaux sont ensuite concaténés dans un nouveau tableau
int[] resultat;
resultat = impairs ~ pairs;

writeln("D'abord les impairs, ensuite les pairs, triés :");

// Afficher les tableaux dans une boucle
int i;
while (i < resultat.length) {
write(resultat[i], " ");
++i;
}

writeln();
}

3. Il y a trois erreurs (bogues) dans ce programme. Les deux premières concernent à la boucle while : les deux conditions de boucle utilisent l'opérateur <= au lieu de l'opérateur <. Du coup, le programme utilise des indices invalides et essaie d'accéder à des éléments qui ne sont pas dans le tableau.
Comme il est plus bénéfique pour vous de déboguer la troisième erreur vous-même, il serait souhaitable que vous lanciez d'abord le programme après avoir corrigé les deux premiers bogues avant de lire le paragraphe suivant. Vous remarquerez que le programme n'affichera pas les résultats. Pourquoi ? …
… La valeur de i est 5 quand la première boucle while se termine et cette valeur entraîne que l'expression logique de la deuxième boucle est fausse, ce qui en fait empêche l'exécution de la deuxième boucle. La solution est de remettre i à 0 avant la seconde boucle while, par exemple avec l'instruction i = 0.

60-14 - Tranches et autres fonctionnalités des tableaux - Correction

Itérer sur les éléments d'une tranche depuis le début de la tranche est une idée intéressante. Cette méthode est aussi la base des intervalles de Phobos que nous verrons dans un chapitre ultérieur.

import std.stdio;

void main()
{
double[] tableau = [ 1, 20, 2, 30, 7, 11 ];

double[] tranche = tableau;     // On démarre avec une tranche
// qui donne accès à l'ensemble
// des éléments du tableau

while (tranche.length) {        // Tant qu'il y a au moins
// un élément dans cette tranche

if (tranche[0] > 10) {      // On travaille toujours sur le premier
tranche[0] /= 2;        // élément de la tranche dans ces expressions
}

tranche = tranche[1 .. $];  // On raccourcit la tranche par
// son début
}

writeln(tableau);               // Les éléments du tableau
// ont été mis à jour
}

60-15 - Chaînes de caractères - Correction

1. Même si certaines fonctions des modules de Phobos sont faciles à utiliser avec les chaînes, les documentations des bibliothèques sont souvent laconiques comparées aux tutoriels. Vous pouvez en particulier trouver les intervalles (ranges) de Phobos confus, à ce moment du cours. Nous verrons les intervalles de Phobos dans un chapitre ultérieur.

2. Beaucoup d'autres fonctions peuvent aussi être chaînées :

import std.stdio;
import std.string;

void main()
{
write("Prénom : ");
string prenom = capitalize(chomp(readln()));

write("Nom : ");
string nom = capitalize(chomp(readln()));

string nomComplet = prenom ~ " " ~ nom;
writeln(nomComplet);
}

3. Ce programme utilise deux indices pour créer une tranche :

import std.stdio;
import std.string;

void main()
{
write("Veuillez entrer une ligne : ");
string line = chomp(readln());

sizediff_t premier_e = indexOf(line, 'e');

if (premier_e == -1) {
writeln("Il n'y a pas de lettre e dans cette ligne.");

} else {
sizediff_t dernier_e = lastIndexOf(line, 'e');
writeln(line[premier_e .. dernier_e + 1]);
}
}

60-16 - Rediriger l'entrée standard et les flux de sortie - Correction

Rediriger l'entrée standard et la sortie des programmes est souvent utilisé, spécialement dans les consoles des systèmes de type Unix. Certains programmes sont conçus pour fonctionner correctement quand ils sont combinés avec d'autres programmes.

Par exemple, un fichier nommé essai.d peut être cherché dans une arborescence en combinant find avec grep :

find | grep essai.d

find affiche le nom de tous les fichiers sur sa sortie. grep reçoit cette sortie dans son entrée et affiche les lignes qui contiennent essai.d sur sa propre sortie.

60-17 - Fichiers - Correction

import std.stdio;
import std.string;

void main()
{
write("Veuillez saisir le nom du fichier à lire : ");
string nomFichierEntree = chomp(readln());
File fichierEntree = File(nomFichierEntree, "r");

string nomFichierSortie = nomFichierEntree ~ ".out";
File fichierSortie = File(nomFichierSortie, "w");

while (!fichierEntree.eof()) {
string ligne = chomp(fichierEntree.readln());

if (ligne.length != 0) {
fichierSortie.writeln(ligne);
}
}

writeln(nomFichierSortie, " a été créé.");
}

60-18 - auto et typeof - Correction

On peut utiliser typeof() pour déterminer le type du littéral et .stringof pour avoir le nom de ce type en tant que string :

import std.stdio;

void main()
{
writeln(typeof(1.2).stringof);
}

La sortie :

double

60-19 - La boucle for - Correction

1. Code :

import std.stdio;

void main()
{
for (int ligne = 0; ligne != 9; ++ligne) {
for (int colonne = 0; colonne != 9; ++colonne) {
write(ligne, ',', colonne, ' ');
}

writeln();
}
}

2. Triangle :

import std.stdio;

void main()
{
for (int ligne = 0; ligne != 9; ++ligne) {
int longueur = ligne + 1;

for (int i = 0; i != longueur; ++i) {
write('*');
}

writeln();
}
}

3. Parallélogramme :

import std.stdio;

void main()
{
for (int ligne = 0; ligne != 9; ++ligne) {
for (int i = 0; i != ligne; ++i) {
write(' ');
}

writeln("********");
}
}

4. Pouvez-vous produire la forme du diamant ?

60-20 - L'opérateur ternaire « ? »: - Correction

Même s'il pourrait être plus sensé d'utiliser une instruction if-else dans cet exercice, le programme suivant utilise deux opérateurs ?: :

import std.stdio;

void main()
{
write("Veuillez entrer la valeur nette : ");
int valeur;
readf(" %s", &valeur);

writeln(valeur < 0 ? -valeur : valeur,
"€ ",
valeur < 0 ? "perdus" : "gagnés");
}

60-21 - Littéraux - Correction

1. Le problème ici est que la valeur à droite de l'affectation est trop grande pour être contenue dans un int. Selon les règles sur les littéraux entiers, son type est long. Pour cette raison, il ne peut pas être contenu dans la variable à gauche du signe égal. Il y a au moins deux solutions.
Une première solution est de laisser le compilateur déduire le type de la variable avec le mot-clé auto :

auto quantite = 10_000_000_000;

2. Le type de quantite est déduit comme long.
Une autre solution est de spécifier explicitement le type de la variable en tant que long :

long quantite = 10_000_000_000;

3. Nous pouvons utiliser le caractère spécial '\r' qui ramène le curseur d'affichage au début de la ligne.

import std.stdio;

void main()
{
for (int compteur = 0; ; ++compteur) {
write("\rNombre: ", compteur);
}
}

4. La sortie de ce programme peut être erratique à cause de ses interactions avec le tampon de sortie. Le programme suivant force l'affichage du contenu du tampon de sortie avec la fonction flush et attend une milliseconde après chaque write :

import std.stdio;
import core.thread;

void main()
{
for (int compteur = 0; ; ++compteur) {
write("\rNombre : ", compteur);
stdout.flush();
Thread.sleep(Thread.sleep(10.msecs));
}
}

5. Forcer l'affichage du tampon de sortie n'est normalement pas nécessaire, car c'est fait automatiquement avant d'aller sur une nouvelle ligne (par exemple par writeln, ou avant de lire depuis stdin).

60-22 - Sortie formatée - Correction

1. Nous avons déjà vu que c'est trivial avec les indicateurs de format :

import std.stdio;

void main()
{
writeln("(Entrez 0 pour quitter le programme.)");

while (true) {
write("Veuillez entrer un nombre : ");
long nombre;
readf(" %s", &nombre);

if (nombre == 0) {
break;
}

writefln("%1$d <=> %1$#x", nombre);
}
}

2. En se souvenant que le caractère % doit apparaître deux fois dans la chaîne de formatage pour être affiché une fois :

import std.stdio;

void main()
{
write("Veuillez entrer la valeur en pourcentage : ");
double pourcentage;
readf(" %s", &pourcentage);

writefln("%.2f%%", pourcentage);
}

60-23 - Entrée formatée - Correction

Il suffit d'utiliser une chaîne de formatage où les parties de date sont remplacées par %s :

import std.stdio;

void main()
{
int annee;
int mois;
int jour;

readf("%s.%s.%s", &annee , &mois , &jour);

writeln("Mois : ", mois);
}

60-24 - La boucle do-while - Correction

Ce programme n'est pas directement lié à la boucle do-while puisque tout problème qui peut être résolu avec une boucle do-while peut également être résolu par les autres types de boucle.

Le programme peut deviner le nombre auquel l'utilisateur pense en réduisant l'intervalle candidat des deux côtés selon les réponses de l'utilisateur. Par exemple, si sa première tentative est 50 et que l'utilisateur répond que le nombre secret est plus grand, le programme sait alors que le nombre doit être dans l'intervalle [51, 100]. Si le programme tente alors un autre nombre dans le milieu de cet intervalle, cette fois on saura que le nombre est soit dans l'intervalle [51, 75], soit dans l'intervalle [76, 100].

Quand la taille de l'intervalle est 1, le programme est sûr que le nombre qui est dedans est le nombre que l'utilisateur a choisi.

60-25 - La boucle foreach - Correction

Pour obtenir un tableau associatif qui marche dans le sens opposé de noms, les types des clés et des valeurs doivent être échangés. Le nouveau tableau associatif doit être de type int[string].

En itérant sur les clés et les valeurs du tableau associatif original et en utilisant les clés comme les valeurs et les valeurs comme les clés, on peuple le tableau valeurs :

import std.stdio;

void main()
{
string[int] noms = [ 1:"un", 7:"sept", 20:"vingt" ];

int[string] valeurs;

foreach (cle, valeur; noms) {
valeurs[valeur] = cle;
}

writeln(valeurs["vingt"]);
}

60-26 - Tableaux associatifs - Correction

1. La propriété .keys retourne une tranche (c.-à-d. un tableau dynamique) qui inclut toutes les clés du tableau associatif. Itérer sur cette tranche et supprimer les éléments de chaque clé en appelant .remove résultera en un tableau associatif vide :

import std.stdio;

void main()
{
string[int] noms =
[
1   : "un",
10  : "dix",
100 : "cent",
];

writeln("Taille initiale : ", noms.length);

int[] clés = noms.keys;

for (int i = 0; i != clés.length; ++i) {
writefln("suppression de l'élément %s", clés[i]);
noms.remove(clés[i]);
}

writeln("Taille finale : ", noms.length);
}

2. Cette solution peut être lente, surtout pour des tableaux larges. Les méthodes suivantes vident le tableau en une seule étape.

- Une autre solution est d'assigner un tableau vide :

string[int] AAvide;
noms = AAvide;

- Comme la valeur initiale d'un tableau est de toute manière un tableau vide, la méthode suivante aura le même effet :

noms = noms.init;

- Le but est de stocker de multiples notes par étudiant. Comme plusieurs notes peuvent être stockées dans un tableau dynamique, un tableau associatif qui associe des chaînes à des int[] fonctionne. Les notes peuvent être ajoutées aux tableaux dynamiques qui sont stockés dans le tableau associatif :

import std.stdio;

void main()
{
/* Le type de clé de ce tableau associatif est string et
* le type de valeur est int[], c.-à-d. un tableau d'int.
* le tableau associatif est défini avec un espace en plus
* pour aider à distinguer le type de valeur : */
int[] [string] notes;

/* Le tableau d'int qui correspond à "pierre" est
* utilisé pour ajouter la nouvelle note à ce tableau : */
notes["pierre"] ~= 18;
notes["pierre"] ~= 17;

/* afficher les notes de "pierre": */
writeln(notes["pierre"]);
}

- Les notes peuvent aussi être assignées d'un coup via un tableau littéral :

import std.stdio;

void main()
{
int[][string] notes;

notes["pierre"] = [ 18, 17, 19 ];

writeln(notes["pierre"]);
}

60-27 - switch et case - Correction

import std.stdio;
import std.string;

void main()
{
string op;
double premier;
double second;

write("Veuillez entrer l'opération :");
op = chomp(readln());

write("Veuillez entrer deux valeurs séparées par un espace :");
readf(" %s %s", &premier, &second);

double resultat;

final switch (op) {
case "ajouter":
resultat = premier + second;
break;

case "soustraire":
resultat = premier - second;
break;

case "multiplier":
resultat = premier * second;
break;

case "diviser":
resultat = premier / second;
break;
}

writeln(resultat);
}

1. En utilisant les valeurs distinctes :

final switch (op) {
case "ajouter", "+":
resultat = premier + second;
break;

case "soustraire", "-":
resultat = premier - second;
break;

case "multiplier", "*":
resultat = premier * second;
break;

case "diviser", "/":
resultat = premier / second;
break;
}

2. Comme la section default est nécessaire pour lever l'exception, on ne peut plus utiliser le final switch. Voici les parties modifiées du programme :

// ...

switch (op) {

// ...

default:
throw new Exception("Opération invalide : " ~ op);
}

// ...

60-28 - Les énumérations (enum) - Correction

import std.stdio;
import std.conv;

enum Operation { sortir, ajouter, soustraire, multiplier, diviser }

void main()
{
// Afficher les opérations prises en charge
write("Opérations - ");
for (Operation operation;
operation <= Operation.max;
++operation) {

writef("%d:%s ", operation, operation);
}
writeln();

// Boucle infinie jusqu'à ce que l'utilisateur veuille sortir
while (true) {
write("Opération ? ");

// L'entrée doit être lue dans le type réel (int) de l'énumération
int codeOperation;
readf(" %s", &codeOperation);

/*
À partir d'ici, on utilise les valeurs de l'énumération
plutôt que les constantes magiques. Le code de l'opération
qui a été lu doit donc être converti vers sa valeur énumérée
correspondante.

(Les conversions de types seront traitées plus en détail dans un
chapitre ultérieur.)
*/
Operation operation = cast(Operation)codeOperation;

if ((operation < Operation.min) ||
(operation > Operation.max)) {
writeln("ERREUR : Opération invalide");
continue;
}

if (operation == Operation.sortir) {
writeln("Au revoir !");
break;
}

double premier;
double second;
double resultat;

write("Premier opérande ? ");
readf(" %s", &premier);

write("Second opérande  ? ");
readf(" %s", &second);

switch (operation) {

case Operation.ajouter:
resultat = premier + second;
break;

case Operation.soustraire:
resultat = premier - second;
break;

case Operation.multiplier:
resultat = premier * second;
break;

case Operation.diviser:
resultat = premier / second;
break;

default:
throw new Exception(
"ERREUR: Cette ligne n'aurait jamais dû être atteinte.");
}

writeln("        resultat : ", resultat);
}
}

60-29 - Les fonctions - Correction

import std.stdio;

void afficherMenu(string[] elements, int premierNombre)
{
foreach (i, element; elements) {
writeln(' ', i + premierNombre, ' ', element);
}
}

void main()
{
string[] elements =
[ "Noir", "Rouge", "Vert", "Bleu", "Blanc" ];
afficherMenu(elements, 1);
}

1. Voici quelques idées :

void placerPoint(Toile toile, int ligne, int colonne, dchar point)
{
toile[ligne][colonne] = point;
}

60-30 - Paramètre des fonctions - Correction

Parce que les paramètres de cette fonction sont du genre de ceux qui sont copiés depuis les arguments (types valeur), ce sont ces copies qui sont échangées à l'intérieur de la fonction.
Pour faire en sorte que la fonction échange les arguments, les deux paramètres doivent être passés par référence :

void echanger(ref int premier, ref int second)
{
immutable int temp = premier;
premier = second;
second = temp;
}

Avec ce changement, les variables dans main() sont échangées :

2 1

Même si ce n'est pas lié au problème original, notez également que temp est spécifié comme immutable, car il n'a pas à être modifié dans la fonction après son initialisation.

60-31 - Environnement du Programme - Correction

import std.stdio;
import std.conv;

int main(string[] args)
{
if (args.length != 4) {
stderr.writeln(
"ERREUR ! Usage : \n    ", args[0],
" un_nombre operateur un_autre_nombre");
return 1;
}

immutable premier = to!double(args[1]);
string op = args[2];
immutable second = to!double(args[3]);

switch (op) {

case "+":
writeln(premier + second);
break;

case "-":
writeln(premier - second);
break;

case "x":
writeln(premier * second);
break;

case "/":
writeln(premier / second);
break;

default:
throw new Exception("Opérateur invalide : " ~ op);
}

return 0;
}

import std.stdio;
import std.process;

void main()
{
write("Veuillez saisir la ligne de commande à exécuter : ");
string ligneDeCommande = readln();

writeln("La sortie : ", executeShell(ligneDeCommande));
}

60-32 - assert et enforce - Correction

1. Vous remarquerez que le programme termine normalement quand on saisit 06:09 et 1:2. Cependant, vous pourrez remarquer que le temps de départ ne correspond pas à ce qui a été saisi par l'utilisateur :

1 heure et 2 minutes après 09:06 donne 10:08.

2. Comme vous pouvez le voir, même si le moment qui a été saisi est 06:09, la sortie contient 09:06. Cette erreur sera attrapée à l'aide d'une assertion dans le problème suivant.

3. L'erreur d'assertion après la saisie de 06:09 et 15:2 nous amène à la ligne suivante :

string momentVersChaine(in int heure, in int minute)
{
assert((heure >= 0) && (heure <= 23));
// ...
}

4. Pour que cette assertion échoue, cette fonction doit être appelée avec une heure incorrecte.
Les deux seuls appels à momentVersChaine() dans le programme ne semblent pas avoir de problème :

writefln("%s heures et %s minutes après %s donne  %s.",
DuréeHeures, duréeMinutes,
momentVersChaine(heuresDepart, minutesDepart),
momentVersChaine(endHour, endMinute));

5. Une enquête un peu plus poussée devrait révéler la vraie cause du bogue : les variables heure et minute sont échangées lors de la lecture du moment de départ :

lireMoment("Moment de départ", minutesDepart, heuresDepart);

6. Cet erreur de programmation provoque l'interprétation de l'heure comme 09:06 et l'incrémente par la durée 15:2 causant une valeur horaire invalide.
Une correction évidente est de passer les variables heure et minute dans le bon ordre :

lireMoment("Start time", heuresDepart, minutesDepart);

7. La sortie :

Moment de départ ? (HH:MM) 06:09
Durée ? (HH:MM) 15:2
15 heures et 2 minutes après 06:09 donne 21:11.

8. Il s'agit de la même assertion :

assert((heure >= 0) && (heure <= 23));

9. La raison est que ajouterMoment() peut produire des heures qui sont plus grandes que 23. Ajouter un modulo à la fin garantirait une des sorties de la fonction :

void ajouterDurée(in int heuresDepart, in int minutesDepart,
in int DuréeHeures, in int duréeMinutes,
out int heuresResultat, out int minutesResultat)
{
heuresResultat = heuresDepart + DuréeHeures;
minutesResultat = minutesDepart + duréeMinutes;

if (minutesResultat > 59) {
++heuresResultat;
}

heuresResultat %= 24;
}

10. Notez que la fonction a d'autres problèmes. Par exemple, minutesResultat peut être supérieure à 59. La fonction suivante calcule la valeur des minutes correctement et s'assure que la sortie de la fonction vérifie les spécifications :

void ajouterDurée(in int heuresDepart, in int minutesDepart,
in int DuréeHeures, in int duréeMinutes,
out int heuresResultat, out int minutesResultat)
{
heuresResultat = heuresDepart + DuréeHeures;
minutesResultat = minutesDepart + duréeMinutes;

heuresResultat += minutesResultat / 60;
heuresResultat %= 24;
minutesResultat %= 60;

assert((heuresResultat >= 0) && (heuresResultat <= 23));
assert((minutesResultat >= 0) && (minutesResultat <= 59));
}

11. Bonne chance.

60-33 - Tests Unitaires - Correction

La première chose à faire est de compiler et exécuter le programme pour s'assurer que les tests fonctionnent et échouent :

$ dmd deneme.d -ofdeneme -w -unittest // ou: gdc -Wall -funittest deneme.d -o deneme
$ ./deneme
core.exception.AssertError@deneme(11): unittest failure

Le numéro de la ligne 11 indique que le premier test a échoué.

Pour des raisons pédagogiques, écrivons une implémentation incorrecte qui passe le premier test par accident. La fonction suivante retourne simplement une copie de l'entrée :

dstring toFront(dstring str, in dchar lettre)
{
dstring resultat;

foreach (c; str) {
resultat ~= c;
}

return resultat;
}

unittest
{
immutable str = "hello"d;

assert(toFront(str, 'h') == "hello");
assert(toFront(str, 'o') == "ohell");
assert(toFront(str, 'l') == "llheo");
}

void main()
{}

Le premier test passe, mais le deuxième échoue :

$ ./deneme
core.exception.AssertError@deneme.d(17): unittest failure

Voici une implémentation correcte qui passe tous les tests :

dstring toFront(dstring str, in dchar lettre)
{
dchar[] debut;
dchar[] fin;

foreach (c; str) {
if (c == lettre) {
debut ~= c;

} else {
fin ~= c;
}
}

return (debut ~ fin).idup;
}

unittest
{
immutable str = "hello"d;

assert(toFront(str, 'h') == "hello");
assert(toFront(str, 'o') == "ohell");
assert(toFront(str, 'l') == "llheo");
}

void main()
{}

Les tests passent :

$ ./deneme
$

Cette fonction peut maintenant être modifiée de différentes manières avec l'assurance que ses tests devront passer. Les deux implémentations suivantes sont très différentes de la première, mais sont toutes deux correctes vis-à-vis des tests.

Une implémentation qui utilise std.algorithm.partition :

import std.algorithm;

dstring toFront(dstring str, in dchar lettre)
{
dchar[] result = str.dup;
partition!(c => c == lettre, SwapStrategy.stable)(result);

return result.idup;
}

unittest
{
immutable str = "hello"d;

assert(toFront(str, 'h') == "hello");
assert(toFront(str, 'o') == "ohell");
assert(toFront(str, 'l') == "llheo");
}

void main()
{}

Note : la syntaxe => qui apparaît dans le programme ci-avant crée une fonction lambda. Nous verrons les fonctions lambda dans des chapitres ultérieurs.

L'implémentation suivante compte combien de fois la lettre spéciale apparaît dans la chaîne. Cette information est ensuite envoyée à une autre fonction nommée repete pour produire la première partie du résultat. Notez que repete a elle-même un jeu de tests unitaires :

dstring repete(size_t compte, dchar lettre)
{
dstring resultat;

foreach (i; 0..compte) {
resultat ~= lettre;
}

return resultat;
}

unittest
{
assert(repete(3, 'z') == "zzz");
assert(repete(10, 'é') == "éééééééééé");
}

dstring toFront(dstring str, in dchar lettre)
{
size_t nombreLettreSpeciale;
dstring fin;

foreach (c; str) {
if (c == lettre) {
++nombreLettreSpeciale;

} else {
fin ~= c;
}
}

return repete(nombreLettreSpeciale, lettre) ~ fin;
}

unittest
{
immutable str = "hello"d;

assert(toFront(str, 'h') == "hello");
assert(toFront(str, 'o') == "ohell");
assert(toFront(str, 'l') == "llheo");
}

void main()
{}

60-34 - Programmation par contrat - Correction

Le bloc unittest peut être implémenté simplement en copiant les vérifications qui ont déjà été écrites dans la fonction main. Le seul ajout ci-dessous est le test pour le cas dans lequel la seconde équipe gagne :

int ajouterPoints(in int buts1,
in int buts2,
ref int points1,
ref int points2)
in
{
assert(buts1 >= 0);
assert(buts2 >= 0);
assert(points1 >= 0);
assert(points2 >= 0);
}
out (resultat)
{
assert((resultat >= 0) && (resultat <= 2));
}
body
{
int gagnante;

if (buts1 > buts2) {
points1 += 3;
gagnante = 1;

} else if (buts1 < buts2) {
points2 += 3;
gagnante = 2;

} else {
++points1;
++points2;
gagnante = 0;
}

return gagnante;
}

unittest
{
int points1 = 10;
int points2 = 7;
int gagnante;

// La première équipe gagne
gagnante = ajouterPoints(3, 1, points1, points2);
assert(points1 == 13);
assert(points2 == 7);
assert(gagnante == 1);

// Nul
gagnante = ajouterPoints(2, 2, points1, points2);
assert(points1 == 14);
assert(points2 == 8);
assert(gagnante == 0);

// La seconde équipe gagne
gagnante = ajouterPoints(0, 1, points1, points2);
assert(points1 == 14);
assert(points2 == 11);
assert(gagnante == 2);
}

void main()
{
// ...
}

60-35 - Structures - Correction

1. Un des choix de conception les plus aisés est d'utiliser deux membres dchar :

struct Carte
{
dchar couleur;
dchar valeur;
}

2. Il s'agit simplement d'afficher les deux membres côte à côte :

void afficherCarte(in Carte carte)
{
write(carte.couleur, carte.valeur);
}

3. En supposant qu'il y ait déjà une fonction appelée nouvelleCouleur(), nouveauJeu() peut être implémentée en appelant cette fonction pour chaque couleur :

Carte[] nouveauJeu()
out (resultat)
{
assert(resultat.length == 52);
}
body
{
Carte[] jeu;

jeu ~= nouvelleCouleur('♠');
jeu ~= nouvelleCouleur('♡');
jeu ~= nouvelleCouleur('♢');
jeu ~= nouvelleCouleur('♣');

return jeu;
}

4. Le reste du travail peut être accompli par la fonction nouvelleCouleur() suivante, qui construit la couleur en combinant le caractère de la couleur avec chaque valeur d'une chaîne :

Carte[] nouvelleCouleur(in dchar couleur)
in
{
assert((couleur == '♠') ||
(couleur == '♡') ||
(couleur == '♢') ||
(couleur == '♣'));
}
out (resultat)
{
assert(resultat.length == 13);
}
body
{
Carte[] cartesCouleur;

foreach (valeur; "234567890VDRA") {
cartesCouleur ~= Carte(couleur, valeur);
}

return cartesCouleur;
}

5. On peut noter que ces fonctions mettent en œuvre la programmation par contrat afin de réduire les risques d'erreurs de programmation.

6. Si l'on échange deux éléments pris au hasard, cela suffit à ce que le jeu soit de plus en plus mélangé à chaque itération. Bien qu'il soit possible de tomber par hasard deux fois sur le même élément, échanger un élément avec lui-même n'a pas d'effet néfaste hormis le fait de ne pas obtenir un jeu plus mélangé après l'itération en question qu'avant son exécution.

void melanger(Carte[] jeu, in int repetitions)
{
/* Note : un meilleur algorithme consisterait à parcourir
*        le jeu du début à la fin, en échangeant l'élément
*        courant avec un tiré aléatoirement parmi les
*        éléments situés entre l'élément courant et la fin.
*
* Il serait encore préférable d'appeler randomShuffle, du
* module std.algorithm.module, qui implémente déjà cet
* algorithme. Reportez-vous au commentaire dans main()
* pour voir comment randomShuffle() s'utilise.
*/

foreach (i; 0 .. repetitions) {
// Choisit deux éléments au hasard
immutable premier = uniform(0, jeu.length);
immutable second = uniform(0, jeu.length);

swap(jeu[premier], jeu[second]);
}
}

7. La fonction ci-dessus appelle std.algorithm.swap qui échange tout simplement les valeurs de ses deux paramètres ref. En pratique, c'est l'équivalent de la fonction suivante :

void mySwap(ref Carte gauche,
ref Carte droite)
{
immutable temporaire = gauche;
gauche = droite;
droite = temporaire;
}

8. Voici le programme au complet :

import std.stdio;
import std.random;
import std.algorithm;

struct Carte
{
dchar couleur;
dchar valeur;
}

void afficherCarte(in Carte carte)
{
write(carte.couleur, carte.valeur);
}

Carte[] nouvelleCouleur(in dchar couleur)
in
{
assert((couleur == '♠') ||
(couleur == '♡') ||
(couleur == '♢') ||
(couleur == '♣'));
}
out (resultat)
{
assert(resultat.length == 13);
}
body
{
Carte[] cartesCouleur;

foreach (valeur; "234567890JQKA") {
cartesCouleur ~= Carte(couleur, valeur);
}

return cartesCouleur;
}

Carte[] nouveauJeu()
out (resultat)
{
assert(resultat.length == 52);
}
body
{
Carte[] jeu;

jeu ~= nouvelleCouleur('♠');
jeu ~= nouvelleCouleur('♡');
jeu ~= nouvelleCouleur('♢');
jeu ~= nouvelleCouleur('♣');

return jeu;
}

void melanger(Carte[] jeu, in int repetitions)
{
/* Note : un meilleur algorithme consisterait à parcourir
*        le jeu du début à la fin, en échangeant l'élément
*        courant avec un tiré aléatoirement parmi les
*        éléments situés entre l'élément courant et la fin.
*
* Il serait encore préférable d'appeler randomShuffle, du
* module std.algorithm.module, qui implémente déjà cet
* algorithme. Reportez-vous au commentaire dans main()
* pour voir comment randomShuffle() s'utilise.
*/

foreach (i; 0 .. repetitions) {
// Choisit deux éléments au hasard
immutable premier = uniform(0, jeu.length);
immutable second = uniform(0, jeu.length);

swap(jeu[premier], jeu[second]);
}
}

void main()
{
Carte[] jeu = nouveauJeu();

melanger(jeu, 100);
/* Note : au lieu d'appeler melanger(), il serait préférable
*        d'appeler randomShuffle comme ceci :
*
* randomShuffle(jeu);
*/

foreach (carte; jeu) {
afficherCarte(carte);
write(' ');
}

writeln();
}

60-36 - Nombre variable de paramètres - Correction

Pour que la fonction calculer puisse accepter un nombre variable de paramètres, elle doit inclure une tranche de Calcul suivie de … :

double[] calculer(in Calcul[] calculs ...)
{
double[] resultats;

foreach (calcul; calculs) {
final switch (calcul.op) {
case Operation.addition:
resultats ~= calcul.premier + calcul.second;
break;

case Operation.soustraction:
resultats ~= calcul.premier - calcul.second;
break;

case Operation.multiplication:
resultats ~= calcul.premier * calcul.second;
break;

case Operation.division:
resultats ~= calcul.premier /  calcul.second;
break;
}
}

return resultats;
}

Voici le programme complet :

import std.stdio;

enum Operation { addition, soustraction, multiplication, division }

struct Calcul {
Operation op;
double premier;
double second;
}

double[] calculer(in Calcul[] calculs ...)
{
double[] resultats;

foreach (calcul; calculs) {
final switch (calcul.op) {
case Operation.addition:
resultats ~= calcul.premier + calcul.second;
break;

case Operation.soustraction:
resultats ~= calcul.premier - calcul.second;
break;

case Operation.multiplication:
resultats ~= calcul.premier * calcul.second;
break;

case Operation.division:
resultats ~= calcul.premier /  calcul.second;
break;
}
}

return resultats;
}

void main() {
writeln(calculer(Calcul(Operation.addition, 1.1, 2.2),
Calcul(Operation.soustraction, 3.3, 4.4),
Calcul(Operation.multiplication, 5.5, 6.6),
Calcul(Operation.division, 7.7, 8.8)));
}

60-37 - Surcharge de fonctions - Correction

Les deux surcharges suivantes utilisent les surcharges d'info() existantes :

void info(in Repas repas)
{
info(repas.moment);
write('-');
info(ajouterDuree(repas.moment, MomentDeLaJournee(1, 30)));
write(" Repas, adresse : ", repas.adresse);
}

void info(planningDeLaJournee planning)
{
info(planning.reunionMatin);
writeln();
info(planning.repas);
writeln();
info(planning.reunionApresMidi);
}

Voici le programme complet, qui utilise tous ces types :

import std.stdio;

struct MomentDeLaJournee
{
int heure;
int minute;
}

void info(in MomentDeLaJournee moment)
{
writef("%02s:%02s", moment.heure, moment.minute);
}

MomentDeLaJournee ajouterDuree(in MomentDeLaJournee debut,
in MomentDeLaJournee duree)
{
MomentDeLaJournee resultat;

resultat.minute = debut.minute + duree.minute;
resultat.heure = debut.heure + duree.heure;
resultat.heure += resultat.minute / 60;

resultat.minute %= 60;
resultat.heure %= 24;

return resultat;
}

struct Reunion
{
string sujet;
size_t nombreDeParticipants;
MomentDeLaJournee debut;
MomentDeLaJournee fin;
}

void info(in Reunion reunion)
{
info(reunion.debut);
write('-');
info(reunion.fin);

writef(" Réunion \"%s\" avec %s personnes", reunion.sujet,
reunion.nombreDeParticipants);
}

struct Repas
{
MomentDeLaJournee moment;
string            adresse;
}

void info(in Repas repas)
{
info(repas.moment);
write('-');
info(ajouterDuree(repas.moment, MomentDeLaJournee(1, 30)));

write(" Repas, adresse : ", repas.adresse);
}

struct planningDeLaJournee
{
Reunion reunionMatin;
Repas   repas;
Reunion reunionApresMidi;
}

void info(planningDeLaJournee planning)
{
info(planning.reunionMatin);
writeln();
info(planning.repas);
writeln();
info(planning.reunionApresMidi);
}

void main()
{
immutable reunionCyclisme = Reunion("Cyclisme", 4,
MomentDeLaJournee(10, 30),
MomentDeLaJournee(11, 45));

immutable dejeuner = Repas(MomentDeLaJournee(12, 30), "Istamboul");

immutable reunionBudget = Reunion("Budget", 8,
MomentDeLaJournee(15, 30),
MomentDeLaJournee(17, 30));

immutable planningAujourdhui = planningDeLaJournee(reunionCyclisme,
dejeuner,
reunionBudget);

info(planningAujourdhui);
writeln();
}

La fonction main() peut aussi n'être écrite qu'avec des objets littéraux :

void main()
{
info(planningDeLaJournee(Reunion("Cyclisme", 4,
MomentDeLaJournee(10, 30),
MomentDeLaJournee(11, 45)),

Repas(MomentDeLaJournee(12, 30), "Istamboul"),

Reunion("Budget", 8,
MomentDeLaJournee(15, 30),
MomentDeLaJournee(17, 30))));

writeln();
}

61 - Note de la rédaction Developpez.com

Developpez.com remercie Ali Çehreli pour l'autorisation de publication de ce cours.

Nos remerciements également à Christophe Louvet pour la mise au gabarit, à Jérôme Marsaguet pour son assistance dans les différentes mises en forme, et à Claude Leloup, Jlliagre, Cédric Duprez, et F-Leb pour la relecture orthographique.