Faut-il privilégier la performance ou la lisibilité ?

Johnathan MEUNIER
Just-Tech-IT
Published in
10 min readSep 12, 2023

Tu devrais plutôt écrire ton algo comme ça, ce sera plus performant même si c’est moins lisible !

Ou encore

Et si tu écrivais ta boucle comme ça ? Ce serait beaucoup plus lisible pour les juniors !

On a déjà eu plusieurs fois le débat en Code Review sur la lisibilité du code versus sa performance.

Le débat en lui même a peu de sens. Les deux concepts n’ont rien d’exclusif, ils peuvent se compléter et non s’opposer.

Cependant, il pourrait se comparer, il faut reconnaître que privilégier un des axes est souvent au détriment de l’autre. Nous allons voir, dans cet article, différents axes de réflexion autour de ces deux concepts qui reviennent souvent.

Le lièvre et la Tortue — Jean de la Fontaine

Lisibilité vs Performance

Ces deux concepts sont très difficiles à comparer : les performances sont factuelles et mesurables, la lisibilité de code est subjective et abstraite.

Lisibilité

Commençons par un petit point historique. Je n’aime pas spécialement ressasser le passé, mais se rappeler d’où on vient est toujours bénéfique. Avec un langage contemporain, qu’importe comment on écrit notre algo, il y a de grande chance qu’il soit plus lisible qu’un programme écrit en assembleur.

Le code que vous écrivez est beaucoup plus souvent lu et relu qu’il ne fut écrit. Il sera relu aussi bien par vous lors de son écriture, par vos collègues lors de la Pull Request, que plus tard par n’importe qui durant sa durée de vie : d’autres collègues, vous, ou même ses utilisateurs.

On ne va pas commencer ici à donner des bonnes pratiques pour améliorer la lisibilité de votre code, ce n’est pas le but. Vous trouverez sur Internet de nombreux articles ou même livre en parlant, en vrac vous pouvez orienter vos recherches autour du Clean Code, WIP.

La lisibilité consiste à faire en sorte que toute personne qui relit du code le comprend rapidement et avec peu de contexte, aussi bien technique que fonctionnel. Enormément de variables sont à prendre en compte :

  • expérience du relecteur sur la techno afin de comprendre les possibilités d’amélioration technique
  • expérience du relecteur sur le projet en lui-même afin de comprendre le contexte, pourquoi ça été fait comme ça et pas autrement
  • mode et tendance du moment, notre métier évolue tellement vite que d’un mois à l’autre une pratique peut devenir obsolète en faveur d’une autre
  • formation
  • historique (technos et langages précédents par exemple)

Performance

Concernant les performances, beaucoup prennent l’argument de la loi de Moore pour justifier un manque d’implication sur l’optimisation de leur code. Ce n’est parce que l’on a accès à plus de puissance que l’on doit l’exploiter sans faire attention, faisons preuve d’un peu de sobriété.

Les performances d’une application vont être impactées par l’environnement où le code va être exécuté, mais à environnements égaux, il est très facile de vérifier si un morceau de code est plus performant qu’un autre. Il suffit d’exécuter ces deux morceaux dans des conditions similaires.

Par environnement, il faut entendre soit la machine de l’utilisateur si on fait du front, soit le serveur si on fait du back (donc une ressource “partagée” par tous les utilisateurs). Dans le cas de la machine de l’utilisateur, il est beaucoup plus compliqué de déterminer à l’avance la puissance à laquelle on aura accès pour tester notre code, nous ne savons pas si l’utilisateur exécutera notre code sur un ancien téléphone ou un ordinateur très puissant.

Dans des conditions égales, les différences de performance seront plus ou moins perceptibles selon un dernier critère : la complexité de la donnée et surtout sa quantité.

L’écart et le gain de performance seront donc beaucoup plus importants selon la quantité de données à gérer. Prenons l’exemple d’une liste de données à afficher avec des filtres. Les débats seront souvent les mêmes : faut-il filtrer côté client ou côté serveur ou encore faut-il utiliser une librairie pour gérer les filtres ou plutôt tout réécrire à la main. De nombreuses questions, tout autant de réponses. Et si la première question était plutôt : “quelle quantité de données je vais avoir à gérer ?”. Si la quantité est faible, toutes les réponses à ces questions dépendront surtout de l’appétence de l’équipe de dev et de sa bande passante et non de soucis de performance.

Quelques exemples

Les différents exemples seront écrits en Javascript et exécutés plusieurs fois, dans mon navigateur (Edge sur MacOS, sur un i9), dans des conditions équivalentes à chaque fois, à travers ce morceau de code :

var possibilities = [
{
"label" : "libellé du test",
"fn": (data) => {}
},
]

var length = 10000000

var data = Array.from({length}, () => Math.floor(Math.random() * 100) + 1);

possibilities.forEach(({label, fn}) => {
console.time(label);
fn(data);
console.timeEnd(label);
})

En sortie nous aurons donc quelque chose comme :

test 1 : xx.xxms
test 2 : xx.xxms

Exécuté de nombreuses fois, cela permet d’avoir une idée macroscopique de l’algo le plus rapide pour chaque cas.

Nous pourrons jouer avec la valeur de length pour faire varier la quantité de données du test.

Des var sont utilisées afin d’exécuter facilement ce code plusieurs fois à la suite dans les devtools d’un navigateur ou une console en node.js .

Somme des éléments d’un tableau

On va chercher la somme de tous les éléments de ce tableau de Number, qu’on va stocker dans une variable sum.

Tout d’abord avec une boucle for et de la mutabilité :

let sum = 0;
for(let i = 0; i < data.length; i++){
sum += data[i]
}

Puis avec un forEach et de la mutabilité :

let sum = 0;
data.forEach(value => sum += value);

Un reduce sans mutabilité :

const sum = data.reduce((acc, curr) => acc += curr, 0)

Et pour terminer une méthode un peu hacky qui ressemble plus à du code golf, pas mal utilisé en concours d’algo, en fusionnant le tableau en string avec comme séparateur un “+” et en évaluant cette string :

const sum = eval(data.join('+'))

On va chercher à répondre à deux questions, quel est le plus lisible ? quel est le plus rapide ?

Le plus lisible

Facile à répondre pour chacun, car ça dépend comme on l’a dit plus tôt d’un avis totalement personnel. Je préfère le reduce pour deux raisons : l’un des plus court à écrire et surtout pas de mutabilité en dehors de la boucle. Mais c’est un avis complètement subjectif qui n’est pas dénué de défaut, je reconnais qu’il faut se souvenir de l’ordre de la valeur courante et de l’accumulateur (et qu’il faut l’initialiser par exemple). Peut-être que dans quelques mois ou années je trouverai plus lisible une boucle for.

Le plus performant

Voici déjà le code global pour l’expérimentation, si vous souhaitez le reproduire dans votre environnement :

var possibilities = [
{
label: "for",
fn: (data) => {
let sum = 0;
for (let i = 0; i < data.length; i++) {
sum += data[i];
}
},
},
{
"label": "forEach",
fn : (data) => {
let sum = 0;
data.forEach(value => sum += value);
}
},
{
label: "reduce",
fn: (data) => {
const sum = data.reduce((acc, curr) => (acc += curr), 0);
},
},
{
"label": "eval",
fn : (data) => {
const sum = eval(data.join('+'))
}
}
];

var length = 100000000;

var data = Array.from({ length }, () => Math.floor(Math.random() * 100) + 1);

possibilities.forEach(({ label, fn }) => {
console.time(label);
fn(data);
console.timeEnd(label);
});

Ici la réponse est beaucoup plus simple à apporter et va sûrement en étonner certains. En effet j’aime bien poser cette question en entretien de recrutement pour pleins de raisons, notamment parce qu’elle peut apporter pleins de réponses possibles et surtout pleins de justifications différentes. Et la réponse qui vient le plus souvent c’est le reduce car il serait le plus performant. Et pourtant ce n’est pas le cas, le plus performant sera la bonne vieille boucle for !

Voici le résultat du benchmark exécuté de nombreuses fois, les résultats sont similaires à chaque fois :

Pour 100 données :

for: 0.054931640625 ms
forEach: 0.037841796875 ms
reduce: 0.031005859375 ms
eval: 0.087890625 ms

Pour 100 000 données :

for: 0.979248046875 ms
forEach: 1.8798828125 ms
reduce: 1.671875 ms
eval: 15.1298828125 ms

Pour 100 000 000 données :

for: 149.739990234375 ms
forEach: 1614.530029296875 ms
reduce: 1089.27294921875 ms
eval: 14202.463134765625 ms

Pour une quantité très faible de donnée, le reduce est le plus performant avec un écart extrêmement faible avec les autres méthodes, l’écart est négligeable.

Pour une quantité moyenne de donnée, l’écart est toujours minime mais la boucle for commence déjà à prendre la tête et peut déjà avoir un certain impact.

Dès que les données sont plus réalistes la boucle for creuse de plus en plus l’écart !

Nous aurions donc tendance à conseiller une boucle for dans la plupart des cas pour un soucis de perf et d’adaptabilité.

Clone d’un tableau

Toujours en partant de notre petit script de test, on va chercher cette fois à voir comment cloner un tableau, en évitant les problèmes de références.

J’ai pris quelques exemples parlant avec des écarts significatifs, vous pouvez retrouver une liste encore plus importante des différentes façons de faire sur JSBEN.CH Benchmarking for JavaScript — copy / clone Array

Concat

const obj2 = [].concat(testArray);

Push

const obj2 = [];
for (var i = 0, l = testArray.length; i < l; i++) {
obj2.push(testArray[i]);
}

JSON Stringify > JSON Parse

const obj2 = JSON.parse(JSON.stringify(testArray));

map

const obj2 = testArray.map(function(i) {
return i;
});

structuredClone()

const obj2 = structuredClone(data)

Le plus lisible

Mon avis est encore plus subjectif, j’ai une préférence pour le JSON Parse du tableau stringifié car on est certain de détruire les clés en clonant depuis un tout nouveau tableau, et c’est le plus court et rapide à écrire. Par contre c’est très déroutant pour ceux qui ne connaissent pas cette façon de faire.

J’apprécie également beaucoup le structuredClone mais attention à la compatiblité navigateur !

Le concat est très élégant également.

Le plus performant

Voici déjà le code global pour l’expérimentation, si vous souhaitez le reproduire dans votre environnement :

var possibilities = [
{
label: "concat",
fn: (data) => {
const obj2 = [].concat(data);
},
},
{
label: "push",
fn: (data) => {
const obj2 = [];
for (var i = 0, l = data.length; i < l; i++) {
obj2.push(data[i]);
}
},
},
{
label: "JSON Stringify > JSON Parse",
fn: (data) => {
const obj2 = JSON.parse(JSON.stringify(data));
},
},
{
label: "map",
fn: (data) => {
const obj2 = data.map(function (i) {
return i;
});
},
},
{
label: "structuredClone",
fn: (data) => {
const obj2 = structuredClone(data)
}
}
];

var length = 100000000;

var data = Array.from({ length }, () => Math.floor(Math.random() * 100) + 1);

possibilities.forEach(({ label, fn }) => {
console.time(label);
fn(data);
console.timeEnd(label);
});

De façon candide, avant de tester je pensais que stringifier puis parser serait assez performant. Transformer un élément en chaine de caractères puis en faire un tableau ne me semblait pas si coûteux; belle erreur ! En effet c’est de loin la technique la plus gourmande et ce assez rapidement !

Je pensais également que le structuredClone serait performant car assez récent et très facile à lire, cette function est sortie exprès pour palier au manque d’uniformité pour cloner un tableau justement, mais les perfs sont malheureusement catastrophiques, pire que le JSON parse …

Pour 100 données :

concat: 0.02294921875 ms
push: 0.050048828125 ms
JSON Stringify > JSON Parse: 0.031005859375 ms
map: 0.032958984375 ms
structuredClone: 0.0400390625 ms

Pour 100 000 données :

concat: 0.263916015625 ms
push: 2.526123046875 ms
JSON Stringify > JSON Parse: 4.929931640625 ms
map: 2.147216796875 ms
structuredClone: 3.954345703125 ms

Pour 100 000 000 données :

concat: 318.744140625 ms
push: 1582.159912109375 ms
JSON Stringify > JSON Parse: 18424.497802734375 ms
map: 1342.93701171875 ms
structuredClone: 22709.979248046875 ms

Comme on le voit, les écarts sont immédiats et beaucoup plus tranchés ! Le concat sera toujours le plus rapide.

Pour une faible quantité de donnée, on a un écart de ratio de 2 environ, mais pour une grosse quantité de donnée, le ratio passe à plus de 100 et ça ne fera qu’empirer !

Dans ce sens, il faut toujours privilégier le concat. De plus, le nouveau tableau renvoyé par concat n’aura pas les références des premiers.

Le structuredClone nous donne un exemple parfait de dissonance entre lisibilité et performance. Cette méthode récente essaie de palier à un problème de lisibilité des 1001 façons de faire pour cloner un tableau mais en proposant des performances catastrophiques.

A travers ces deux exemples très simple, on se rend tout de suite compte que privilégier un code performant est rapide et utile, à l’inverse d’un code plus lisible qui est dépendant de trop de variables subjectives et apporte peu de valeur ajoutée.

Photo by Marc-Olivier Jodoin on Unsplash

Et si on parlait écologie (et donc d’argent) ?

Nous avons dit précédemment que l’écart de perf minime sur des quantités de données faibles à moyennes pouvaient être négligé. Et c’est totalement vrai d’un point de vue UX.

Maintenant essayons de dézoomer un peu. Votre morceau de code pourrait être exécuté des milliers de fois, peut-être même des millions de fois par jour, je vous le souhaite ! L’écart que l’on considérait minime avant pourrait avoir un impact énorme. 1ms économisée sur un algo exécuté 1 000 000 fois par jour correspond à environ 15mns d’utilisation de CPU. Et ça pour un seul morceau de code de votre app, qu’il soit exécuté sur le client ou le serveur !

En consommation énergétique et temps d’utilisation CPU, le coût peut rapidement exploser. Et quand on parle de coût, on ne parle pas seulement d’argent mais d’impact énergétique, d’impact environnemental.

Et si on pensait notre code plus vert ? Et si on se projetait en se disant que potentiellement un tout petit écart de perf, un tout petit geste pourrait avoir des répercussions énorme ?

Que privilégier ?

Dans tous les cas votre code doit déjà respecter les standards et les bonnes pratiques de développement en vigueur. Si c’est bien le cas, dans la plupart des situations, privilégier une façon d’écrire votre code ou une autre car un collègue dans une PR a dit que c’était plus lisible est généralement une perte de temps et d’énergie pour peu de résultat, comme nous l’avons vu, ce point de vue est subjectif et sujet à trop d’éléments extérieur.

Privilégier la lisibilité reste encore une fois un avis purement subjectif et à contextualiser. La préférence entre deux morceaux de code concernant sa lisibilité dépend beaucoup de l’humain, du relecteur, de ses goûts, de son histoire, etc. L’importance de cette caractéristique pourra beaucoup dépendre du scope de la fonctionnalité et non plus du développeur. Plus un code devra s’intégrer et interagir avec un système complexe et large, plus sa lisibilité sera importante.

Par contre, privilégier les performances de votre code aura un impact beaucoup plus important. Directement sur vos utilisateurs qui peuvent exécuter votre code sur plein d’environnements différents mais surtout à une échelle globale si votre application est beaucoup utilisée !

--

--