Écrire du code efficace sans sacrifier la lisibilité
L’équilibrage de l’efficacité du code et de la lisibilité est une compétence essentielle pour les développeurs de logiciels. Bien que les performances sont cruciales, elles ne doivent pas être au détriment de la clarté du code et de la facilité de maintenance.
Clarté tout d’abord, optimiser si nécessaire
Un principe de référence dans l’ingénierie logicielle consiste d’abord à faire fonctionner le code correctement et clairement, puis à optimiser si et si nécessaire. Ne commencez pas à optimiser avant de savoir ce qui en a vraiment besoin.
Lorsque vous tentez d’optimiser des sections de code spécifiques sans preuve que le code est lent, vous risquez les problèmes suivants :
Rendre le code plus difficile à comprendre et à gérer : Les optimisations complexes (en particulier les micro-optimisations) peuvent introduire une logique convolue, des astuces obscures ou du code pour des cas particuliers. Les futurs mainteneurs peuvent avoir du mal à le comprendre, ou pire, à introduire des bogues lors de sa modification.
Perdre du temps : Vous pouvez passer des heures à ajuster quelque chose qui a un effet négligeable sur les performances globales, tout en négligeant un problème plus important ailleurs.
Réduction de la fiabilité ou de la flexibilité : Parfois, des ajustements extrêmes des performances suppriment les couches d’abstraction ou les vérifications d’erreur. Par exemple, l’utilisation de l’arithmétique du pointeur pour la vitesse en C# (code non sécurisé) peut gagner un peu de performances, mais à un risque élevé pour la sécurité et une perte de portabilité. Ce type de changement est difficile à justifier dans les applications métier.
Commencez par une solution claire. N’oubliez pas que les développeurs lisent le code plus souvent qu’ils ne l’écrivent. Mettez en évidence des noms lisibles, une structure et une simplicité. Mis à part les choix algorithmiques de haut niveau, de nombreuses micro-optimisations (comme la mise en cache de calculs triviaux ou l’épargne de quelques cycles d’UC) ne valent pas la perte de clarté. Les compilateurs et le matériel modernes sont efficaces pour exécuter du code simple efficacement.
Quand les optimisations sont-elles logiques ?
Après avoir écrit la version initiale de votre code, identifiez les sections critiques qui nécessitent une optimisation (la « critique 3%»). Voici des signes et des scénarios où l’optimisation (même si elle complique le code un peu) est justifiée :
Points d’accès confirmés : Le profilage montre une méthode ou une boucle particulière consomme un pourcentage significatif de temps d’exécution. Exemple : vous effectuez un profilage et constatez qu'une fonction prend 60% du temps d'exécution du programme. Si une optimisation peut réduire le temps de cette fonction en moitié, elle génère une victoire globale significative.
Inefficacité algorithmique évidente : Parfois, vous savez qu’une approche plus simple est considérablement moins efficace en termes big-O. Par exemple, l’utilisation d’une double boucle imbriquée pour comparer les éléments de deux grandes listes est O(n*m). Si vous utilisez plutôt une table de hachage pour une liste, vous pouvez potentiellement passer à O(n+m). Si
netmpeut être grand, la différence est énorme. Dans ce cas, un développeur expérimenté peut implémenter l’approche la plus efficace dès le départ , ce n’est pas « prématuré » si le besoin est évident. De façon cruciale, de nombreuses améliorations algorithmiques ne rendent pas le code moins lisible si elles sont bien effectuées (à l’aide de noms de méthodes descriptives, de commentaires, etc.).Opérations répétées : Si un morceau de code s’exécute occasionnellement, de petites inefficacités sont correctes. Mais s'il fonctionne des milliers de fois par seconde (par exemple, à l'intérieur d'une boucle serrée ou d'un appel de service à haute fréquence), vous le scrutez davantage. Par exemple, la construction d’un nouvel objet peut être adéquate, mais faire cela dans une boucle 100 000 fois par seconde, lorsque vous pouvez réutiliser un objet, peut nécessiter une modification.
Domaine critique pour les performances : Dans certains domaines (comme le développement de jeux, les systèmes en temps réel ou les systèmes incorporés), les exigences de performances sont strictes. Ici, les développeurs pensent souvent à l’efficacité dès le départ, car une approche naïve peut ne pas répondre aux exigences. Même si elles s’appuient sur des modèles connus et des bonnes pratiques plutôt que sur des ajustements imprévisibles de bas niveau.
L’objectif est d’optimiser quand les données le prennent en charge ou le contexte de domaine le demandent, et même de le faire de manière gérable.
Lisibilité et optimisation
Il existe souvent un compromis entre l’écriture de code facile à lire et de code hautement optimisé. Toutefois, de nombreuses optimisations peuvent être réalisées sans sacrifier la clarté. Examinons quelques exemples qui illustrent l’équilibre entre la lisibilité et l’efficacité.
Utiliser les structures de données appropriées
Supposons que vous disposez d’une collection et que vous devez vérifier à plusieurs reprises si la collection contient une certaine valeur. Vous avez plusieurs options :
Lisible mais moins efficace : Effectuez une
List<T>itération à chaque fois pour trouver la valeur. Cette approche présente une complexité O(n) pour chaque vérification, et le code reste clair et simple (soit par une boucle de base, soit grâce àList.Contains, qui effectue une itération interne).Efficace et lisible : Utilisez un
HashSet<T>ou unDictionary<TKey, TValue>pour les recherches, en donnant une durée moyenne O(1) par vérification. Il y a un peu plus de code (vous remplissez le HashSet et utilisez sa méthodeContains), mais cela reste limpide. En fait, l’utilisation d’un HashSet peut être encore plus expressif : il indique au lecteur « nous avons besoin de recherches rapides ». C’est un cas où la solution la plus efficace est également propre.Sur-optimisé et moins lisible : Une alternative artificielle pourrait impliquer une manipulation de bits à bas niveau ou un algorithme de hachage personnalisé adapté à ce jeu de données spécifique. Cette approche confondrait probablement les mainteneurs de code et ne fournirait que des gains de performances minimes sur la norme
HashSet(si une amélioration du tout). Vous devez éviter cette stratégie, sauf si le profilage montre que la structure de données intégrée crée un goulot d’étranglement des performances et qu’une implémentation personnalisée est vraiment essentielle (ce qui se produit rarement).
Extension de boucle ou inlining manuel
Parfois, les développeurs essaient de « optimiser » les boucles en les déroulant ou en inlinant manuellement du code pour économiser sur la surcharge de boucle. Considérez une boucle qui traite un tableau :
Lisible: Écrivez une boucle qui traite un tableau de 100 éléments. Le code est concis et clair. Le compilateur peut l’optimiser correctement, et tout processeur moderne peut gérer facilement 100 itérations.
Sur-optimisé : « Dérouler » la boucle en écrivant 100 instructions répétées pour éviter la surcharge de la charge de boucle. Cette approche peut économiser quelques cycles de l'unité centrale pour le contrôle de boucle, mais votre code compte désormais 100 lignes d'instructions répétées, ce qui ne vaut clairement pas le coup. Cauchemar de maintenance si vous avez jamais besoin de passer à 101 éléments !
Quand c'est important : Pour les boucles internes sensibles aux performances (trouvées dans l’informatique haute performance ou les bibliothèques algorithmiques), les développeurs ont parfois recours à un déroulement partiel des boucles pour optimiser le code. Toutefois, les compilateurs gèrent généralement ce scénario automatiquement ou via d’autres mécanismes d’optimisation, plutôt que d’exiger une implémentation manuelle dans le code d’application. En tant que développeur d’applications, reposez sur les fonctionnalités d’optimisation du compilateur et conservez du code simple et clair.
Concaténation de chaînes en C#
La concaténation de chaînes est un scénario courant où les performances et la lisibilité peuvent entrer en conflit. Lorsque la concaténation de chaîne est effectuée à plusieurs reprises, le choix de la méthode peut avoir un impact significatif sur les performances.
Approche naive : Utilisation
string += stringdans une boucle. Exemple : création d’une longue requête SQL ou csv en ajoutant des lignes dans une boucle. Cette technique est facile à lire, mais dans .NET chaque+=chaîne crée une nouvelle chaîne (car les chaînes sont immuables). Si vous ajoutez 1 000 fois, vous créez un grand nombre d’objets de chaîne intermédiaire : ce code est inefficace à la fois dans le temps et la mémoire.Meilleure approche : Utilisez un
StringBuilderpour plusieurs concaténations. Cette classe est conçue pour ce scénario ; elle génère la chaîne dans une mémoire tampon et produit une chaîne finale à la fin. Le code est légèrement plus verbeux (vous devez appelerAppendau lieu de+=), mais il est toujours facile à comprendre. Il signale clairement « nous créons une chaîne efficacement ». En fait, les guides de bonnes pratiques pour .NET recommandentStringBuilderpour la concaténation à l’intérieur des boucles. L’utilisationStringBuilderde concaténations de chaînes répétées est à la fois plus lisible (pour les développeurs expérimentés) et plus performante.
Cet exemple montre que parfois une petite modification (à l’aide d’une AUTRE API) génère des gains de performances importants avec un effet minimal sur la lisibilité. L’approche initiale peut fonctionner pour les petites chaînes de caractères, mais si vous rencontrez de grandes entrées, les performances diffèrent beaucoup.
Résultats du cache
La mise en cache est une technique d’optimisation courante qui peut améliorer les performances en stockant les résultats des appels de fonction coûteux et en les réutilisant lorsque les mêmes entrées se produisent à nouveau.
Sans mise en cache : Imaginez une fonction
GetExchangeRate(currency)qui extrait le taux de change actuel via un appel HTTP. Si vous l’appelez à plusieurs reprises pour la même devise et qu’il n’est pas mis en cache, vous effectuez des opérations redondantes (et des entrées/sorties réseau). C’est simple, mais pas efficace.Avec la mise en cache : Vous ajoutez un dictionnaire pour stocker les résultats après l’extraction, de sorte que les appels suivants retournent immédiatement à partir de la mémoire. Cette technique ajoute une certaine complexité (vous devez gérer le cache, éventuellement l’invalidation si les taux changent), mais pour les données fréquemment demandées, elle peut considérablement améliorer les performances en évitant les appels inutiles.
La décision de mettre en cache dépend souvent des modèles d’utilisation. Le code devient légèrement plus complexe (vous devez gérer la logique du cache) et vous devez vous assurer qu’il reste correct (données obsolètes, sécurité des threads si vous y accédez à partir de plusieurs threads, etc.). La mise en cache est un scénario classique d'échange de complexité contre des performances. La mise en cache offre des améliorations substantielles des performances lorsque les données sont consultées à plusieurs reprises.
Meilleures pratiques pour équilibrer l’efficacité et la lisibilité
Voici quelques bonnes pratiques pour vous aider à équilibrer l’efficacité et la lisibilité dans votre code :
Préférer la clarté algorithmique : Lorsque vous choisissez comment implémenter quelque chose, réfléchissez d’abord à la complexité algorithmique (est-il linéaire, quadratique, etc. ?). Choisissez une conception qui offre une bonne complexité sans contorser votre code. Souvent, la solution la plus élégante sur le plan algorithmique est également du code propre.
Utilisez l’outil approprié pour le travail : Les langages et bibliothèques de haut niveau fournissent des fonctionnalités optimisées que vous devez utiliser. Par exemple, LINQ (Language Integrated Query) en C# peut exprimer clairement certaines opérations de données, et elle est raisonnablement optimisée en interne. De même, les bibliothèques de traitement parallèle (
Parallel.ForEachPLINQ) permettent l’exécution simultanée tout en conservant une structure de code relativement simple. Ne réinventez pas la roue, sauf si vous devez.Commentez les optimisations non évidentes : Si vous faites quelque chose d’une manière non intuitive pour des raisons de performances, ajoutez un commentaire expliquant pourquoi. Exemple : « Utilisation d’un pool d’objets manuel ici pour réduire la pression du GC, car cette méthode est appelée dans une boucle serrée et nous ne pouvons pas nous permettre d’allocations fréquentes ». L’ajout d’un commentaire aide les futurs lecteurs (et vous-même en six mois) à se rappeler pourquoi le code est de cette façon.
Amélioration incrémentielle : Vous pouvez souvent commencer par une conception simple, puis améliorer incrémentiellement les parties qui en ont besoin. Comparez toujours la version optimisée de votre code avec l’original pour vous assurer que le comportement du code n’est pas modifié. Le contrôle de version peut vous aider à restaurer les modifications si nécessaire.
Ne pas compromettre la sécurité pour la rapidité : Par exemple, ignorer la validation des entrées ou la gestion des erreurs peut permettre au code de s'exécuter un peu plus rapidement, mais cela ne vaut presque jamais la peine d'un tel compromis. La robustesse est plus importante. Visez les optimisations qui ne sapent pas la justesse ou la sécurité du code.
Éviter l’optimisation prématurée
Se précipiter pour optimiser le code avant de savoir où les goulots d’étranglement réels sont un piège courant.
Pratiquement :
- Écrivez votre code correctement avec une structure sonore.
- Identifiez si une partie est un goulot d’étranglement.
- Optimisez cette partie, de façon à assurer la maintenance et vérifiez l’amélioration.
Cette approche vous permet de passer du temps sur ce qui importe et de maintenir votre codebase à la fois efficace et sain.
Résumé
L'écriture de code efficace ne doit pas se faire au détriment de la lisibilité. En hiérarchisant d’abord la clarté et en optimisant en fonction des preuves, vous pouvez obtenir un équilibre qui sert à la fois les performances et la maintenance. Utilisez des structures de données appropriées, implémentez des bibliothèques intégrées et appliquez des optimisations judicieusement. Documentez toujours les choix nonobieux et évitez l’optimisation prématurée. Cette approche équilibrée conduit à un code robuste, efficace et compréhensible qui est le test du temps.