Quand le paradigme «Do One Thing» devient-il dangereux?


21

Pour des raisons d'argument, voici un exemple de fonction qui imprime le contenu d'un fichier donné ligne par ligne.

Version 1:

void printFile(const string & filePath) {
  fstream file(filePath, ios::in);
  string line;
  while (std::getline(file, line)) {
    cout << line << endl;
  }
}

Je sais qu'il est recommandé que les fonctions fassent une chose à un niveau d'abstraction. Pour moi, bien que le code ci-dessus fasse à peu près une chose et soit assez atomique.

Certains livres (comme le Clean Code de Robert C. Martin) semblent suggérer de diviser le code ci-dessus en fonctions distinctes.

Version 2:

void printFile(const string & filePath) {
  fstream file(filePath, ios::in);
  printLines(file);
}

void printLines(fstream & file) {
  string line;
  while (std::getline(file, line)) {
    printLine(line);
  }
}

void printLine(const string & line) {
  cout << line << endl;
}

Je comprends ce qu'ils veulent réaliser (ouvrir un fichier / lire des lignes / imprimer une ligne), mais n'est-ce pas un peu exagéré?

La version originale est simple et dans un certain sens fait déjà une chose - imprime un fichier.

La deuxième version entraînera un grand nombre de fonctions très petites qui peuvent être beaucoup moins lisibles que la première version.

Ne serait-il pas préférable, dans ce cas, d'avoir le code en un seul endroit?

À quel moment le paradigme "Do One Thing" devient-il nuisible?


13
Ce type de pratique de codage est toujours basé sur le cas par cas. Il n'y a jamais une seule approche.
iammilind

1
@Alex - La réponse acceptée n'a littéralement rien à voir avec la question. Je trouve ça vraiment étrange.
ChaosPandion

2
Je note que votre version refactorisée est à l'envers, ce qui contribue à un manque de lisibilité. La lecture sur le fichier, vous pouvez vous attendre à voir printFile, printLineset enfin printLine.
Anthony Pegram

1
@Kev, encore une fois, je ne peux qu'être en désaccord, en particulier avec cette catégorisation. Ce n'est pas du pédantisme, c'est le point! C'est l'OP qui dit spécifiquement que la deuxième version n'est peut-être pas aussi lisible. C'est l'OP qui cite spécifiquement Clean Code comme source d'inspiration pour la deuxième version. Mon commentaire est essentiellement que Clean Code ne lui écrire de code de cette façon. L'ordre est en fait important pour la lisibilité, vous lisez le fichier comme vous lisez un article de journal, obtenant de plus en plus de détails jusqu'à ce que vous deveniez désintéressé.
Anthony Pegram

1
Comme vous ne vous attendriez pas à lire un poème à l'envers, vous ne vous attendriez pas non plus à voir le niveau de détail le plus bas comme la première chose à l'intérieur d'une classe particulière. Pour votre point, ce code prend peu de temps pour trier rapidement, mais je suppose que ce code n'est pas le seul code qu'il va écrire. À mon point de vue, s'il veut citer Clean Code, le moins qu'il puisse faire est de le suivre. Si le code est en panne, il sera certainement moins lisible qu'autrement.
Anthony Pegram

Réponses:


15

Bien sûr, cela pose simplement la question "Qu'est-ce qu'une chose?" Lire une ligne est-il une chose et écrire une ligne en est une autre? Ou la copie d'une ligne d'un flux à l'autre doit-elle être considérée comme une chose? Ou copier un fichier?

Il n'y a pas de réponse dure et objective à cela. C'est à vous. Tu peux décider. Vous devez décider. Le principal objectif du paradigme «faire une chose» est probablement de produire un code aussi facile à comprendre que possible, afin que vous puissiez l'utiliser comme ligne directrice. Malheureusement, ce n'est pas objectivement mesurable non plus, alors vous devez vous fier à votre intuition et au "WTF?" compter dans l'examen du code .

IMO, une fonction composée d'une seule ligne de code en vaut rarement la peine. Votre printLine()n'a aucun avantage sur l'utilisation directe de std::cout << line << '\n'1 . Si je voisprintLine() , je dois soit supposer qu'il fait ce que dit son nom, soit le rechercher et vérifier. Si je vois std::cout << line << '\n', je sais immédiatement ce qu'il fait, car c'est la manière canonique de sortir le contenu d'une chaîne sous forme de ligne std::cout.

Cependant, un autre objectif important du paradigme est de permettre la réutilisation du code, et c'est une mesure beaucoup plus objective. Par exemple, dans votre 2ème versionprintLines() pourrait facilement être écrite de sorte que c'est un algorithme universellement utile qui copie des lignes d'un flux à un autre:

void copyLines(std::istream& is, std::ostream& os)
{
  std::string line;
  while( std::getline(is, line) );
    os << line << '\n';
  }
}

Un tel algorithme pourrait également être réutilisé dans d'autres contextes.

Vous pouvez ensuite mettre tout ce qui est spécifique à ce cas d'utilisation dans une fonction qui appelle cet algorithme générique:

void printFile(const std::string& filePath) {
  std::ifstream file(filePath.c_str());
  printLines(file, std::cout);
}

1 Notez que j'ai utilisé '\n'plutôt que std::endl. '\n'devrait être le choix par défaut pour la sortie d'une nouvelle ligne , std::endlest le cas étrange .


2
+1 - Je suis généralement d'accord, mais je pense qu'il y a plus que le "sentiment d'intestin". Le problème, c'est quand les gens jugent «une chose» en comptant les détails de la mise en œuvre. Pour moi, la fonction devrait implémenter (et son nom décrire) une seule abstraction claire. Vous ne devez jamais nommer une fonction "do_x_and_y". L'implémentation peut et doit faire plusieurs choses (plus simples) - et chacune de ces choses plus simples peut être décomposée en plusieurs choses encore plus simples et ainsi de suite. C'est juste une décomposition fonctionnelle avec une règle supplémentaire - que les fonctions (et leurs noms) doivent chacune décrire un seul concept / tâche / autre clair.
Steve314

@ Steve314: Je n'ai pas répertorié les détails d'implémentation comme des possibilités. La copie de lignes d'un flux à un autre est clairement une abstraction d' une chose . Ou est-ce? Et c'est facile à éviter do_x_and_y()en nommant la fonction à la do_everything()place. Oui, c'est un exemple stupide, mais cela montre que cette règle ne parvient même pas à empêcher les exemples les plus extrêmes de mauvaise conception. L'OMI est une décision qui fait tout autant penser que celle dictée par les conventions. Sinon, si c'était objectif, vous pourriez trouver une métrique pour cela - ce que vous ne pouvez pas.
sbi

1
Je n'avais pas l'intention de contredire - seulement de suggérer un ajout. Je suppose que ce que j'ai oublié de dire, c'est que, de la question, la décomposition en printLineetc est valide - chacun d'eux est une seule abstraction - mais cela ne signifie pas nécessaire. printFilec'est déjà "une chose". Bien que vous puissiez décomposer cela en trois abstractions de niveau inférieur distinctes, vous n'avez pas à les décomposer à tous les niveaux d'abstraction possibles . Chaque fonction doit faire «une chose», mais toutes les «une chose» possibles ne doivent pas nécessairement être une fonction. Déplacer trop de complexité dans le graphique d'appel peut lui-même être un problème.
Steve314

7

Avoir une fonction ne faire qu'une "chose" est un moyen d'atteindre deux fins souhaitables, pas un commandement de Dieu:

  1. Si votre fonction ne fait qu'une "chose", cela vous aidera à éviter la duplication de code et le gonflement de l'API, car vous serez en mesure de composer des fonctions pour faire des choses plus complexes au lieu d'avoir une explosion combinatoire de fonctions de niveau supérieur et moins composables .

  2. Le fait d'avoir des fonctions ne fait qu'une "chose" peut rendre le code plus lisible. Cela dépend si vous gagnez plus de clarté et de facilité de raisonnement en découplant les choses que vous ne perdez face à la verbosité, à l'indirection et aux frais généraux conceptuels des constructions qui vous permettent de découpler les choses.

Par conséquent, "une chose" est inévitablement subjective et dépend du niveau d'abstraction pertinent pour votre programme. SiprintLines est considérée comme une opération unique et fondamentale et la seule façon d'imprimer des lignes dont vous vous souciez ou envisagez de prendre soin, alors pour vos besoinsprintLines ne fait qu'une chose. À moins que vous ne trouviez la deuxième version plus lisible (je ne le sais pas), la première version est très bien.

Si vous commencez à avoir besoin de plus de contrôle sur les niveaux d'abstraction inférieurs et que vous vous retrouvez avec une duplication subtile et une explosion combinatoire (c'est-à-dire un printLinespour les noms de fichiers et un complètement séparé printLinespour les fstreamobjets, un printLinespour la console et un printLinespour les fichiers), alors printLinesfaites plus d'une chose au niveau d'abstraction qui vous tient à cœur.


J'ajouterais un troisième et c'est que les petites fonctions sont plus faciles à tester. Comme il y a probablement moins d'entrées requises si la fonction ne fait qu'une seule chose, il est plus facile de la tester indépendamment.
PersonalNexus

@PersonalNexus: Je suis quelque peu d'accord sur la question des tests, mais à mon humble avis, il est idiot de tester les détails de mise en œuvre. Pour moi, un test unitaire devrait tester «une chose» telle que définie dans ma réponse. Tout ce qui est plus fin rend vos tests fragiles (car la modification des détails de l'implémentation nécessitera que vos tests changent) et votre code énormément verbeux, indirect, etc. (parce que vous ajouterez une indirection juste pour prendre en charge les tests).
dsimcha

6

À cette échelle, cela n'a pas d'importance. L'implémentation à fonction unique est parfaitement évidente et compréhensible. Cependant, en ajoutant un peu plus de complexité, il est très intéressant de séparer l'itération de l'action. Par exemple, supposons que vous deviez imprimer des lignes à partir d'un ensemble de fichiers spécifié par un modèle comme "* .txt". Ensuite, je séparerais l'itération de l'action:

printLines(FileSet files) {
   files.each({ 
       file -> file.eachLine({ 
           line -> printLine(line); 
       })
   })
}

Maintenant, l'itération du fichier peut être testée séparément.

J'ai divisé les fonctions pour simplifier les tests ou pour améliorer la lisibilité. Si l'action effectuée sur chaque ligne de données était suffisamment complexe pour justifier un commentaire, je la diviserais certainement en une fonction distincte.


4
Je pense que vous avez réussi. Si nous avons besoin d'un commentaire pour expliquer une ligne, il est toujours temps d'extraire une méthode.
Roger CS Wernersson

5

Extrayez les méthodes lorsque vous ressentez le besoin d'un commentaire pour expliquer les choses.

Écrivez des méthodes qui ne font que ce que leur nom dit d'une manière évidente, ou racontez une histoire en appelant des méthodes intelligemment nommées.


3

Même dans votre cas simple, il vous manque des détails que le principe de responsabilité unique vous aiderait à mieux gérer. Par exemple, que se passe-t-il en cas de problème lors de l'ouverture du fichier. L'ajout de la gestion des exceptions pour renforcer les cas de bord d'accès aux fichiers ajouterait 7 à 10 lignes de code à votre fonction.

Après avoir ouvert le fichier, vous n'êtes toujours pas en sécurité. Il pourrait vous être arraché (surtout s'il s'agit d'un fichier sur un réseau), vous pourriez manquer de mémoire, encore une fois, un certain nombre de cas extrêmes peuvent survenir contre lesquels vous voulez vous durcir et gonfler votre fonction monolithique.

La doublure imprimée semble assez inoffensive. Mais à mesure que de nouvelles fonctionnalités sont ajoutées à l'imprimante de fichiers (analyse et formatage du texte, rendu sur différents types d'écrans, etc.), elle se développera et vous vous en remercierez plus tard.

L'objectif de SRP est de vous permettre de penser à une seule tâche à la fois. C'est comme casser un gros bloc de texte en plusieurs paragraphes afin que le lecteur puisse comprendre le point que vous essayez de traverser. Il faut un peu plus de temps pour écrire du code qui respecte ces principes. Mais ce faisant, nous facilitons la lecture de ce code. Pensez à la façon dont votre futur sera heureux quand il devra retrouver un bogue dans le code et le trouver proprement partitionné.


2
J'ai voté contre cette réponse parce que j'aime la logique même si je ne suis pas d'accord avec elle! Fournir une structure basée sur une réflexion complexe sur ce qui pourrait arriver à l'avenir est contre-productif. Factorisez le code quand vous en avez besoin. N'abstenez pas les choses avant d'en avoir besoin. Le code moderne est en proie à des gens qui essaient de suivre servilement les règles au lieu d'écrire simplement du code qui fonctionne et de l'adapter à contrecœur . Les bons programmeurs sont paresseux .
Yttrill

Merci pour le commentaire. Remarque: je ne préconise pas l'abstraculation prématurée, je divise simplement les opérations logiques afin qu'il soit plus facile de le faire plus tard.
Michael Brown

2

Personnellement, je préfère cette dernière approche, car elle vous évite de travailler dans le futur et force la mentalité "comment le faire de manière générique". Malgré cela, dans votre cas, la version 1 est meilleure que la version 2 - simplement parce que les problèmes résolus par la version 2 sont trop triviaux et spécifiques au flux. Je pense que cela devrait être fait de la manière suivante (y compris la correction de bogue proposée par Nawaz):

Fonctions utilitaires génériques:

void printLine(ostream& output, const string & line) { 
    output << line << endl; 
} 

void printLines(istream& input, ostream& output) { 
    string line; 
    while (getline(input, line)) {
        printLine(output, line); 
    } 
} 

Fonction spécifique au domaine:

void printFile(const string & filePath, ostream& output = std::cout) { 
    fstream file(filePath, ios::in); 
    printLines(file, output); 
} 

Maintenant printLineset printLinepeut fonctionner non seulement avec fstream, mais avec n'importe quel flux.


2
Je ne suis pas d'accord. Cette printLine()fonction n'a aucune valeur. Voir ma réponse .
sbi

1
Eh bien, si nous conservons printLine (), nous pouvons ajouter un décorateur qui ajoute des numéros de ligne ou une coloration syntaxique. Cela dit, je ne retirerai pas ces méthodes avant d'avoir trouvé une raison de le faire.
Roger CS Wernersson

2

Chaque paradigme , (pas seulement nécessairement celui que vous avez cité) à suivre, requiert une certaine discipline, et donc - la réduction de la "liberté de parole" - entraîne un surcoût initial (au moins juste parce que vous devez l'apprendre!). En ce sens, chaque paradigme peut devenir nuisible lorsque le coût de ces frais généraux n'est pas surcompensé par l'avantage que le paradigme est conçu pour conserver avec lui-même.

La vraie réponse à la question requiert donc une bonne capacité à "prévoir" l'avenir, comme:

  • Je dois maintenant faire AetB
  • Quelle est la probabilité, dans un avenir proche, je devrai faire aussi A-et B+(c'est-à-dire quelque chose qui ressemble à A et B, mais juste un peu différent)?
  • Quelle est la probabilité dans un avenir plus lointain que A + devienne A*ou A*-?

Si cette probabilité est relativement élevée, ce sera une bonne chance si -en pensant à A et B- je pense aussi à leurs variantes possibles, donc d'isoler les parties communes pour pouvoir les réutiliser.

Si cette probabilité est très faible (quelle que soit la variante autour, elle An'est rien de plus que Alui-même), étudiez comment décomposer A plus loin entraînera très probablement une perte de temps.

À titre d'exemple, laissez-moi vous raconter cette histoire vraie:

Au cours de ma vie passée en tant que professeur, j'ai découvert que -Sur la plupart des projets- étudiant pratiquement tous d'entre eux fournissent leur propre fonction pour calculer la longueur d'une chaîne C .

Après quelques recherches, j'ai découvert que, étant un problème fréquent, tous les étudiants ont eu l'idée d'utiliser une fonction pour cela. Après leur avoir dit qu'il existe une fonction de bibliothèque pour cela (strlen ), beaucoup d'entre eux ont répondu que puisque le problème était si simple et trivial, il était plus efficace pour eux d'écrire leur propre fonction (2 lignes de code) que de chercher le manuel de la bibliothèque C (c'était en 1984, j'ai oublié le WEB et google!) dans un ordre alphabétique strict pour voir s'il y avait une fonction prête pour cela.

C'est un exemple où aussi le paradigme "ne pas réinventer la roue" peut devenir nuisible, sans un catalogue de roue efficace!


2

Votre exemple est parfait pour être utilisé dans un outil à jeter qui a été nécessaire hier pour effectuer une tâche spécifique. Ou comme un outil d'administration directement contrôlé par un administrateur. Rendez-le maintenant robuste pour convenir à vos clients.

Ajoutez une gestion correcte des erreurs / exceptions avec des messages significatifs. Peut-être avez-vous besoin d'une vérification des paramètres, y compris des décisions qui doivent être prises, par exemple comment gérer les fichiers non existants. Ajoutez une fonctionnalité de journalisation, peut-être avec différents niveaux comme les informations et le débogage. Ajoutez des commentaires pour que vos collègues de l'équipe sachent ce qui s'y passe. Ajoutez toutes les parties qui sont généralement omises par souci de concision et laissées comme exercice au lecteur lors de la présentation d'exemples de code. N'oubliez pas vos tests unitaires.

Votre petite fonction agréable et assez linéaire se termine soudainement dans un gâchis complexe qui demande à être divisé en fonctions distinctes.


2

OMI, il devient nuisible quand il va si loin qu'une fonction ne fait pratiquement rien du tout, mais délègue du travail à une autre fonction, car c'est un signe qu'il ne s'agit plus d'une abstraction de quoi que ce soit et que l'état d'esprit qui conduit à de telles fonctions est toujours en danger de faire des choses pires ...

De la poste d'origine

void printLine(const string & line) {
  cout << line << endl;
}

Si vous êtes assez pédant, vous remarquerez peut-être que printLine fait toujours deux choses: écrire la ligne dans cout et ajouter un caractère de "fin". Certaines personnes pourraient vouloir gérer cela en créant de nouvelles fonctions:

void printLine(const string & line) {
  reallyPrintLine(line);
  addEndLine();
}

void reallyPrintLine(const string & line) {
  cout << line;
}

void addEndLine() {
  cout << endl;
}

Oh non, maintenant nous avons encore aggravé le problème! Maintenant, il est même évident que printLine fait DEUX choses !!! 1! Il ne fait pas beaucoup de bêtise de créer les "solutions" les plus absurdes que l'on puisse imaginer juste pour se débarrasser de ce problème inévitable que l'impression d'une ligne consiste à imprimer la ligne elle-même et à ajouter un caractère de fin de ligne.

void printLine(const string & line) {
  for (int i=0; i<2; i++)
    reallyPrintLine(line, i);
}

void reallyPrintLine(const string & line, int action) {
  cout << (action==0?line:endl);
}

1

Réponse courte ... cela dépend.

Pensez à ceci: que se passe-t-il si, à l'avenir, vous ne voudrez pas imprimer uniquement sur la sortie standard, mais sur un fichier.

Je sais ce qu'est YAGNI, mais je dis juste qu'il pourrait y avoir des cas où certaines implémentations sont connues pour être nécessaires, mais reportées. Alors peut-être que l'architecte ou n'importe qui sait que cette fonction doit également pouvoir imprimer dans un fichier, mais ne veut pas faire l'implémentation pour le moment. Il crée donc cette fonction supplémentaire, donc à l'avenir, vous n'aurez qu'à modifier la sortie à un seul endroit. Logique?

Cependant, si vous êtes certain de n'avoir besoin que d'une sortie dans la console, cela n'a pas vraiment de sens. L'écriture d'un «wrapper» cout <<semble inutile.


1
Mais à proprement parler, la fonction printLine n'est-elle pas un niveau d'abstraction différent de l'itération sur les lignes?

@Petr Je suppose que oui, c'est pourquoi ils vous suggèrent de séparer la fonctionnalité. Je pense que le concept est correct, mais vous devez l'appliquer au cas par cas.

1

La raison pour laquelle il existe des livres consacrant des chapitres aux vertus de «faire une chose» est qu'il existe encore des développeurs qui écrivent des fonctions de 4 pages et imbriquent des conditions à 6 niveaux. Si votre code est simple et clair, vous l'avez bien fait.


0

Comme d'autres affiches l'ont fait remarquer, faire une chose est une question d'échelle.

Je dirais également que l'idée One Thing est d'empêcher les gens de coder par effet secondaire. Ceci est illustré par le couplage séquentiel où les méthodes doivent être appelées dans un ordre particulier pour obtenir le «bon» résultat.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.