Programmer avec style
CS-108
Introduction
Lorsqu'on débute en programmation, on a tendance à penser qu'un programme est terminé une fois qu'il fonctionne — ou, plus souvent, qu'il semble fonctionner. Or il n'en est rien ! En plus d'être correct, il est capital qu'un programme soit aussi :
- lisible,
- concis, et
- efficace.
La lisibilité est importante, car lorsqu'un programme est facile à comprendre, ses lecteurs — dont son auteur — pourront aisément se convaincre qu'il est correct. D'autre part, un programme lisible est aussi plus simple à modifier ultérieurement, p.ex. en vue de lui ajouter de nouvelles fonctionnalités ou de corriger un problème.
La concision est importante car un programme concis est, généralement, plus simple à comprendre qu'un programme verbeux. Une fois encore, les lecteurs d'un code concis auront plus de facilité à se convaincre qu'il est correct, ou à le modifier.
Finalement, l'efficacité est généralement importante, car les utilisateurs d'un programme préféreront un programme rapide à un programme lent, toutes choses égales par ailleurs.
Bien entendu, ces trois critères sont souvent en conflit. Par exemple, la version la plus efficace d'un programme est aussi souvent la moins lisible. Il faut donc savoir quel critère favoriser. De manière générale, il est conseillé de les appliquer dans l'ordre donné ci-dessus, c-à-d favoriser en premier lieu la lisibilité, puis la concision, et finalement l'efficacité.
Cela dit, cette règle n'est pas absolue, et admet des exceptions. Par exemple, lorsqu'une partie d'un programme doit absolument être aussi efficace que possible, on peut choisir de sacrifier en partie sa lisibilité. Néanmoins, cela ne doit être fait qu'après avoir effectivement déterminé, au moyen de mesures, que la perte en lisibilité semble bien compensée par un gain en efficacité.
Les conseils ci-dessous ont pour but de vous aider à améliorer vos programmes selon les trois critères mentionnés plus haut. Ils ne sont pas exhaustifs et ne constituent pas des règles absolues. Ne les appliquez donc pas aveuglément, mais uniquement lorsqu'ils permettent effectivement d'améliorer votre programme. Faites preuve de bon sens et de bon goût !
Lisibilité
Mettez correctement votre code en page
La mise en page d'un programme influence grandement sa lisibilité, de la même manière qu'elle influence celle d'un texte écrit dans une langue naturelle.
Les principales décisions à prendre lors de la mise en page de programmes sont :
- comment indenter les lignes du programme,
- où insérer des retours à la ligne,
- où insérer des espaces et des lignes vides.
De nos jours, l'indentation est gérée automatiquement par l'environnement de programmation. Par exemple, IntelliJ offre la commande Auto-Indent Lines dans le menu Code pour corriger l'indentation de la partie du programme actuellement sélectionnée.
Les retours à la ligne sont souvent insérés par le programmeur, même si là aussi les environnements de programmation moderne peuvent fournir de l'aide. IntelliJ offre ainsi la commande Reformat Code dans le menu Code, qui met automatiquement en page la partie du programme actuellement sélectionnée. Cela dit, le découpage des lignes est plus difficile à automatiser que l'indentation, et il est donc conseillé de le faire soi-même.
L'exemple ci-dessous illustre comment des retours à la ligne judicieusement placés rendent un programme plus lisible. Cet extrait de programme :
String s = new StringBuilder() .append(t, 1, t.length()) .append(t, 0, 1) .toString();
est ainsi beaucoup plus facile à comprendre que celui-ci :
String s = new StringBuilder().append(t, 1, t.length()). append(t, 0, 1).toString();
alors que les deux sont identiques, à l'exception des retours à la ligne. La première version est plus simple à comprendre car chaque ligne contient exactement un appel de méthode, et leur effet est donc clair.
Finalement, les espaces et lignes vides permettent d'aérer le code et de mettre en évidence sa structure, et facilitent donc également sa lisibilité. Par exemple, pour évaluer un polynôme de degré trois, il est préférable d'utiliser des espaces pour aérer la formule :
double v = a * pow(x, 3) + b * pow(x, 2) + c * x + d;
plutôt que de tout écrire de manière compacte :
double v = a*pow(x,3)+b*pow(x,2)+c*x+d;
Notez que dans ce cas, on pourrait préférer encore une troisième solution intermédiaire, dans laquelle certains espaces sont supprimés afin d'exprimer la priorité des opérateurs :
double v = a*pow(x,3) + b*pow(x,2) + c*x + d;
voire utiliser la forme de Horner, qui a aussi l'avantage d'être plus efficace :
double v = ((a * x + b) * x + c) * x + d;
Nommez les constantes que vous utilisez
Les constantes numériques utilisées telles quelles dans un programme rendent souvent sa lecture difficile. Dès lors, s'il est possible de donner un nom à une constante et que son utilisation clarifie le code, faites-le.
Par exemple, supposons qu'on doive extraire d'une chaîne de caractères stockée dans une variable line
un numéro d'identification d'une personne, et que ce numéro commence toujours au caractère 5 et ait une longueur de 6 caractères. Il est beaucoup plus clair de définir et d'utiliser des constantes nommées pour effectuer cette extraction :
private static final int PERSON_ID_START = 5; private static final int PERSON_ID_LENGTH = 6; private static final int PERSON_ID_END = PERSON_ID_START + PERSON_ID_LENGTH; // … plus loin: String personId = line.substring(PERSON_ID_START, PERSON_ID_END);
que d'utiliser directement les valeurs en question :
String personId = line.substring(5, 11);
Attention toutefois à ne pas tomber dans l'excès en nommant des constantes qui n'ont aucune raison de l'être. Par exemple, pour calculer la circonférence d'un cercle en fonction de son rayon radius
, il n'y a rien de choquant à écrire :
double perimeter = 2d * PI * radius;
plutôt que de nommer la constante 2 :
// A ne pas faire !!! private static final double TWO = 2d; // … plus loin double perimeter = TWO * PI * radius;
(Cela dit, il y aurait de bonnes raisons pour remplacer la constante mathématique \(\pi\) par une nouvelle valant \(2\pi\), mais c'est une autre histoire.)
Nommez les sous-expressions d'expressions complexes
Les noms attribués aux différentes entités d'un programme servent de commentaires, et en les choisissant judicieusement il est possible de beaucoup améliorer la lisibilité.
Entre autres, il ne faut pas hésiter à introduire parfois des variables « inutiles » dans le seul but de nommer, et donc de décrire, des parties d'expressions complexes.
Par exemple, si on désire calculer la première racine d'une équation du second degré, il peut être préférable de nommer le déterminant en introduisant une variable « inutile », d'autant que la notion de déterminant est connue de tous :
double det = b * b - 4 * a * c; double r1 = (-b + sqrt(det)) / (2 * a);
plutôt que d'écrire la formule sur une seule ligne :
double r1 = (-b + sqrt(b * b - 4 * a * c)) / (2 * a);
Contraignez autant que possible vos définitions
En Java, il est possible d'utiliser des modificateurs pour contraindre de différentes manières les entités définies. Par exemple :
- les modificateurs
private
etprotected
restreignent la visibilité des entités, - le modificateur
final
appliqué à une classe rend l'héritage impossible, - le modificateur
final
appliqué à un attribut le rend immuable, - etc.
De manière générale, il est bon de contraindre autant que possible les différentes entités que l'on définit. Cela réduit les opérations qu'il est possible d'effectuer sur cette entité, ce qui facilite le raisonnement à son sujet.
Par exemple, une personne lisant le code d'une méthode privée d'une classe sait que cette méthode ne peut être utilisée que dans la classe elle-même. Dès lors, il lui est facile de trouver toutes ces utilisations, ce qui peut p.ex. être utile pour déterminer comment la méthode est utilisée en pratique.
De même, un attribut final ne peut être initialisé que dans le constructeur de la classe, et sa valeur ne peut changer par la suite. Dès lors, lorsqu'on lit le code d'une classe contenant un tel attribut, on peut immédiatement savoir quelle valeur il a, et être certain que cette valeur ne peut jamais changer. Il n'est pas nécessaire pour cela de rechercher les éventuelles affectations à cet attribut. D'autre part, si on oublie d'initialiser un tel attribut, une erreur est signalée, ce qui n'est pas le cas si l'attribut n'est pas final.
Il en va de même des classes finales, dont il est impossible d'hériter et qui sont dès lors beaucoup plus faciles à écrire correctement et à vérifier que les classes non finales.
Respectez les conventions de nommage
La plupart des langages de programmation utilisent des conventions de nommage pour faciliter la lecture des programmes. Ainsi, en Java :
- les classes, interfaces et énumérations ont un nom commençant par une majuscule,
- les attributs statiques sont nommés en majuscule, avec des caractères soulignés pour séparer les différentes parties (p.ex.
MIN_VALUE
), - etc.
Il est important de respecter ces conventions, car elles fournissent beaucoup d'informations utiles à une personne lisant le code.
Organisez votre code de manière cohérente
L'ordre dans lequel les différentes entités définies dans un fichier y apparaissent peuvent grandement influencer la lisibilité.
En Java, il est par exemple bon d'essayer de systématiquement ordonner les membres en fonction de :
- s'il s'agit d'attributs ou de méthodes,
- s'ils sont statiques ou non,
- s'ils sont publics ou non,
- leur fonctionnalité.
Par exemple, une classe Complex
représentant un nombre complexe est plus facile à lire si ses membres sont organisés ainsi :
public final class Complex { public static final Complex ZERO = new Complex(0, 0); public static Complex cartesian(double re, double im) { … } public static Complex polar(double theta, double r) { … } private final double re, im; private Complex(double re, double im) { … } public double re() { … } public double im() { … } public Complex add(Complex that) { … } public Complex sub(Complex that) { … } public Complex mul(Complex that) { … } public Complex div(Complex that) { … } @Override public int hashCode() {…} @Override public boolean equals(Object that) {…} @Override public String toString() {…} }
que s'ils sont ordonnés de manière incohérente (p.ex. hashCode
au début de la classe, suivie de add
, suivie du constructeur, etc.).
Documentez votre code
Les entités publiques (classes, interfaces, types énumérés, méthodes et attributs) devraient être commentés avec des commentaires Javadoc. Les méthodes privées peuvent aussi être accompagnées d'un court commentaire décrivant leur but.
Finalement, les parties du code qui sont difficiles à comprendre peuvent mériter d'être commentées.
Concision
Utilisez judicieusement le langage
Utilisez judicieusement les constructions du langage, et évitez d'écrire du code inutilement complexe.
Par exemple, pour retourner vrai d'une fonction si une condition donnée est fausse, et faux si elle est vraie, il suffit d'écrire :
return ! condition;
plutôt que :
if (condition) return false; else return true;
De même, l'opérateur ?:
(souvent appelé « opérateur ternaire ») permet souvent d'écrire du code plus concis qu'un if
. Par exemple, cet extrait de programme :
int i = /* … */; String sign = (i < 0) ? "négatif" : "positif ou nul";
est plus concis et compréhensible que celui-ci :
int i = /* … */; String sign; if (i < 0) sign = "négatif"; else sign = "positif ou nul";
Réutilisez le code si possible
S'il existe déjà des méthodes — soit dans la bibliothèque Java, soit ailleurs dans le programme — qui résolvent un problème, utilisez-les plutôt que de les programmer à nouveau.
Par exemple, pour stocker dans m
le maximum de deux valeurs entières x
et y
, il vaut mieux utiliser la méthode max
de la classe Math
:
import static java.lang.Math.max; int m = max(x, y);
plutôt que d'écrire du code équivalent, même si celui-ci est aussi concis que :
int m = x > y ? x : y;
Efficacité
Utilisez correctement les bibliothèques
Il est souvent possible d'utiliser du code provenant de la bibliothèque standard pour résoudre des problèmes. Encore faut-il l'utiliser correctement ! Par exemple, en Java, il faut penser à :
- construire des chaînes de caractères au moyen de bâtisseurs (c-à-d de la classe
StringBuilder
) plutôt que de l'opérateur de concaténation (+
), - choisir le bon type de collection en fonction de l'usage que l'on veut en faire, p.ex. utiliser un ensemble (de type
Set<…>
) pour représenter un ensemble, et pas une liste (de typeList<…>
), - choisir la bonne mise en œuvre de la collection choisie en fonction de l'usage qu'on en fait, p.ex. utiliser une « deque » de type
ArrayDeque<…>
plutôt qu'une liste de typeArrayList<…>
lorsque les éléments y sont systématiquement ajoutés en tête, - parcourir les collections au moyen d'itérateurs ou de boucle for-each, pas au moyen de la méthode
get
, - etc.