it-swarm-fr.com

Suis-je trop «intelligent» pour être lisible par les développeurs Jr.? Trop de programmation fonctionnelle dans mon JS?

Je suis un développeur frontal principal, codant dans Babel ES6. Une partie de notre application effectue un appel API et, sur la base du modèle de données que nous récupérons de l'appel API, certains formulaires doivent être remplis.

Ces formulaires sont stockés dans une liste à double lien (si le back-end indique que certaines des données ne sont pas valides, nous pouvons rapidement ramener l'utilisateur à la page qu'il a gâchée, puis les remettre sur la cible, simplement en modifiant le liste.)

Quoi qu'il en soit, il y a un tas de fonctions utilisées pour ajouter des pages, et je me demande si je suis trop intelligent. Ce n'est qu'un aperçu de base - l'algorithme réel est beaucoup plus complexe, avec des tonnes de pages et de types de pages différents, mais cela vous donnera un exemple.

C'est ainsi, je pense, qu'un programmeur novice s'en occuperait.

export const addPages = (apiData) => {
   let pagesList = new PagesList(); 

   if(apiData.pages.foo){
     pagesList.add('foo', apiData.pages.foo){
   }

   if (apiData.pages.arrayOfBars){
      let bars = apiData.pages.arrayOfBars;
      bars.forEach((bar) => {
         pagesList.add(bar.name, bar.data);
      })
   }

   if (apiData.pages.customBazes) {
      let bazes = apiData.pages.customBazes;
      bazes.forEach((baz) => {
         pagesList.add(customBazParser(baz)); 
      })
   } 

   return pagesList;
}

Maintenant, afin d'être plus testable, j'ai pris toutes ces instructions if et je les ai séparées, fonctions autonomes, puis je les mappe.

Maintenant, testable est une chose, mais lisible et je me demande si je rends les choses moins lisibles ici.

// file: '../util/functor.js'

export const Identity = (x) => ({
  value: x,
  map: (f) => Identity(f(x)),
})

// file 'addPages.js' 

import { Identity } from '../util/functor'; 

export const parseFoo = (data) => (list) => {
   list.add('foo', data); 
}

export const parseBar = (data) => (list) => {
   data.forEach((bar) => {
     list.add(bar.name, bar.data)
   }); 
   return list; 
} 

export const parseBaz = (data) => (list) => {
   data.forEach((baz) => {
      list.add(customBazParser(baz)); 
   })
   return list;
}


export const addPages = (apiData) => {
   let pagesList = new PagesList(); 
   let { foo, arrayOfBars: bars, customBazes: bazes } = apiData.pages; 

   let pages = Identity(pagesList); 

   return pages.map(foo ? parseFoo(foo) : x => x)
               .map(bars ? parseBar(bars) : x => x)
               .map(bazes ? parseBaz(bazes) : x => x)
               .value

}

Voici ma préoccupation. Pour moi le fond est plus organisé. Le code lui-même est divisé en petits morceaux qui peuvent être testés isolément. MAIS je pense: si je devais lire cela en tant que développeur junior, peu habitué à des concepts tels que l'utilisation de foncteurs d'identité, le curry ou les déclarations ternaires, serais-je même en mesure de comprendre ce que fait cette dernière solution? Est-il parfois préférable de faire les choses de la manière "mauvaise, plus facile"?

134
Brian Boyko

Dans votre code, vous avez apporté plusieurs modifications:

  • l'affectation de la déstructuration pour accéder aux champs dans le pages est un bon changement.
  • l'extraction des fonctions parseFoo() etc. est un bon changement possible.
  • introduire un foncteur est… très déroutant.

L'une des parties les plus déroutantes ici est de savoir comment vous mélangez la programmation fonctionnelle et impérative. Avec votre foncteur vous ne transformez pas vraiment les données, vous l'utilisez pour passer un liste mutable à travers diverses fonctions. Cela ne semble pas être une abstraction très utile, nous avons déjà des variables pour cela. La chose qui aurait dû être abstraite - analyser uniquement cet élément s'il existe - est toujours là dans votre code de manière explicite, mais maintenant nous devons réfléchir au coin de la rue. Par exemple, il n'est pas évident que parseFoo(foo) renvoie une fonction. JavaScript n'a pas de système de type statique pour vous informer si c'est légal, donc ce code est vraiment sujet aux erreurs sans un meilleur nom (makeFooParser(foo)?). Je ne vois aucun avantage à cette obscurcissement.

Ce que je m'attendrais à voir à la place:

if (foo) parseFoo(pages, foo);
if (bars) parseBar(pages, bars);
if (bazes) parseBaz(pages, bazes);
return pages;

Mais ce n'est pas idéal non plus, car il n'est pas clair sur le site d'appel que les éléments seront ajoutés à la liste des pages. Si, à la place, les fonctions d'analyse sont pures et renvoient une liste (éventuellement vide) que nous pouvons explicitement ajouter aux pages, cela pourrait être mieux:

pages.addAll(parseFoo(foo));
pages.addAll(parseBar(bars));
pages.addAll(parseBaz(bazes));
return pages;

Avantage supplémentaire: la logique sur ce qu'il faut faire lorsque l'élément est vide a maintenant été déplacée dans les fonctions d'analyse individuelles. Si cela n'est pas approprié, vous pouvez toujours introduire des conditions. La mutabilité de la liste pages est maintenant regroupée dans une seule fonction, au lieu de la répartir sur plusieurs appels. Éviter les mutations non locales est une partie beaucoup plus importante de la programmation fonctionnelle que les abstractions avec des noms amusants comme Monad.

Alors oui, votre code était trop intelligent. Veuillez appliquer votre intelligence pour ne pas écrire de code intelligent, mais pour trouver des moyens intelligents d'éviter le besoin d'une intelligence flagrante. Les meilleurs designs n'ont pas look fantaisie, mais semblent évidents pour tous ceux qui les voient. Et de bonnes abstractions sont là pour simplifier la programmation, pas pour ajouter des couches supplémentaires que je dois d'abord démêler dans mon esprit (ici, comprendre que le foncteur est équivalent à une variable, et peut effectivement être élidé).

S'il vous plaît: en cas de doute, gardez votre code simple et stupide (principe KISS).

320
amon

En cas de doute, c'est probablement trop intelligent! Le deuxième exemple présente complexité accidentelle avec des expressions comme foo ? parseFoo(foo) : x => x, et dans l'ensemble le code est plus complexe ce qui signifie qu'il est plus difficile à suivre.

Le prétendu avantage, que vous pouvez tester les morceaux individuellement, pourrait être obtenu de manière plus simple en entrant simplement dans les fonctions individuelles. Et dans le deuxième exemple, vous couplez les itérations autrement séparées, vous obtenez donc moins l'isolement.

Quels que soient vos sentiments sur le style fonctionnel en général, c'est clairement un exemple où cela rend le code plus complexe.

Je trouve un peu un signal d'avertissement en ce que vous associez du code simple et direct avec des "développeurs novices". C'est une mentalité dangereuse. D'après mon expérience, c'est le contraire: les développeurs novices sont enclins à un code trop complexe et intelligent, car cela nécessite plus d'expérience pour pouvoir voir la solution la plus simple et la plus claire.

Le conseil contre le "code intelligent" n'est pas vraiment de savoir si le code utilise ou non des concepts avancés qu'un novice pourrait ne pas comprendre. Il s'agit plutôt d'écrire du code qui est plus complexe ou compliqué que nécessaire. Cela rend le code plus difficile à suivre pour tout le monde, les novices et les experts, et probablement aussi pour vous quelques mois plus tard.

224
JacquesB

Ma réponse arrive un peu en retard, mais je veux quand même intervenir. Ce n'est pas parce que vous utilisez les dernières techniques ES6 ou que vous utilisez le paradigme de programmation le plus populaire que votre code est plus correct ou que le code junior est faux. La programmation fonctionnelle (ou toute autre technique) doit être utilisée lorsqu'elle est réellement nécessaire. Si vous essayez de trouver la moindre chance d'intégrer les dernières techniques de programmation dans chaque problème, vous vous retrouverez toujours avec une solution sur-conçue.

Prenez du recul et essayez de verbaliser le problème que vous essayez de résoudre pendant une seconde. En gros, vous voulez simplement qu'une fonction addPages transforme différentes parties de apiData en un ensemble de paires clé-valeur, puis les ajoute toutes dans PagesList.

Et si c'est tout ce qu'il y a à faire, pourquoi se donner la peine d'utiliser identity function Avec ternary operator, Ou d'utiliser functor pour l'analyse des entrées? D'ailleurs, pourquoi pensez-vous même que c'est une bonne approche pour appliquer functional programming Qui provoque effets secondaires (en mutant la liste)? Pourquoi toutes ces choses, quand tout ce dont vous avez besoin est juste ceci:

const processFooPages = (foo) => foo ? [['foo', foo]] : [];
const processBarPages = (bar) => bar ? bar.map(page => [page.name, page.data]) : [];
const processBazPages = (baz) => baz ? baz.map(page => [page.id, page.content]) : [];

const addPages = (apiData) => {
  const list = new PagesList();
  const pages = [].concat(
    processFooPages(apiData.pages.foo),
    processBarPages(apiData.pages.arrayOfBars),
    processBazPages(apiData.pages.customBazes)
  );
  pages.forEach(([pageName, pageContent]) => list.addPage(pageName, pageContent));

  return list;
}

(un jsfiddle exécutable ici )

Comme vous pouvez le voir, cette approche utilise toujours functional programming, Mais avec modération. Notez également que puisque les 3 fonctions de transformation ne provoquent aucun effet secondaire, elles sont très faciles à tester. Le code dans addPages est également trivial et sans prétention que les novices ou les experts peuvent comprendre d'un simple coup d'œil.

Maintenant, comparez ce code avec ce que vous avez trouvé ci-dessus, voyez-vous la différence? Sans aucun doute, functional programming Et les syntaxes ES6 sont puissantes, mais si vous tranchez le problème dans le mauvais sens avec ces techniques, vous vous retrouverez avec un code encore plus compliqué.

Si vous ne vous précipitez pas dans le problème et appliquez les bonnes techniques aux bons endroits, vous pouvez avoir le code fonctionnel dans la nature, tout en étant très organisé et maintenable par tous les membres de l'équipe. Ces caractéristiques ne s'excluent pas mutuellement.

21
b0nyb0y

Le deuxième extrait est pas plus testable que le premier. Il serait relativement simple de configurer tous les tests nécessaires pour l'un des deux extraits.

La vraie différence entre les deux extraits est l'intelligibilité. Je peux lire le premier extrait assez rapidement et comprendre ce qui se passe. Le deuxième extrait, pas tellement. C'est beaucoup moins intuitif et beaucoup plus long.

Cela rend le premier extrait plus facile à gérer, ce qui est une qualité précieuse de code. Je trouve très peu de valeur dans le deuxième extrait.

TD; DR

  1. Pouvez-vous expliquer votre code au développeur junior en 10 minutes ou moins?
  2. Dans deux mois, comprenez-vous votre code?

Analyse détaillée

Clarté et lisibilité

Le code original est incroyablement clair et facile à comprendre pour tout niveau de programmeur. C'est dans un style familier à tout le monde .

La lisibilité est largement basée sur la familiarité, et non sur un comptage mathématique des jetons . OMI, à ce stade, vous avez trop d'ES6 dans votre réécriture. Peut-être que dans quelques années, je modifierai cette partie de ma réponse. :-) BTW, j'aime aussi la réponse @ b0nyb0y comme un compromis raisonnable et clair.

testabilité

if(apiData.pages.foo){
   pagesList.add('foo', apiData.pages.foo){
}

En supposant que PagesList.add () a des tests, ce qui devrait être le cas, il s'agit d'un code simple et il n'y a aucune raison évidente pour que cette section nécessite des tests séparés spéciaux.

if (apiData.pages.arrayOfBars){
      let bars = apiData.pages.arrayOfBars;
      bars.forEach((bar) => {
         pagesList.add(bar.name, bar.data);
      })
   }

Encore une fois, je ne vois aucun besoin immédiat de procéder à des tests séparés spéciaux de cette section. Sauf si PagesList.add () a des problèmes inhabituels avec des valeurs nulles ou en double ou d'autres entrées.

if (apiData.pages.customBazes) {
      let bazes = apiData.pages.customBazes;
      bazes.forEach((baz) => {
         pagesList.add(customBazParser(baz)); 
      })
   } 

Ce code est également très simple. En supposant que customBazParser est testé et ne renvoie pas trop de résultats "spéciaux". Encore une fois, à moins qu'il n'y ait des situations délicates avec `PagesList.add (), (ce qui pourrait exister car je ne connais pas votre domaine), je ne vois pas pourquoi cette section a besoin de tests spéciaux.

En général, le test de la fonction entière devrait fonctionner correctement.

Disclaimer: S'il existe des raisons particulières de tester les 8 possibilités des trois instructions if(), alors oui, divisez les tests. Ou, si PagesList.add() est sensible, oui, divisez les tests.

Structure: vaut-il la peine de se diviser en trois parties (comme la Gaule)

Ici, vous avez le meilleur argument. Personnellement, je ne pense pas que le code d'origine soit "trop ​​long" (je ne suis pas un fanatique de SRP). Mais, s'il y avait quelques sections if (apiData.pages.blah) de plus, alors SRP redresse sa tête laide et cela vaudrait la peine de se diviser. Surtout si DRY s'applique et que les fonctions pourraient être utilisées à d'autres endroits du code.

Ma seule suggestion

YMMV. Pour enregistrer une ligne de code et de la logique, je pourrais combiner les si et let en une seule ligne: par exemple

let bars = apiData.pages.arrayOfBars || [];
bars.forEach((bar) => {
   pagesList.add(bar.name, bar.data);
})

Cela échouera si apiData.pages.arrayOfBars est un nombre ou une chaîne, tout comme le code d'origine. Et pour moi, c'est plus clair (et un idiome galvaudé).

3
user949300