Le biais de familiarité vous retient: il est temps d'adopter les fonctions fléchées

«Anchor» - Actor212 - (CC BY-NC-ND 2.0)

J'enseigne JavaScript pour gagner ma vie. Récemment, j'ai parcouru mon programme pour enseigner les fonctions fléchées au curry plus tôt - au cours des premières leçons. Je l'ai déplacé plus tôt dans le programme parce que c'est une compétence extrêmement précieuse, et les élèves apprennent le curry avec des flèches beaucoup plus rapidement que je ne le pensais.

S'ils peuvent le comprendre et en profiter plus tôt, pourquoi ne pas l'enseigner plus tôt?

Remarque: Mes cours ne sont pas conçus pour les personnes qui n'ont jamais touché une ligne de code auparavant. La plupart des étudiants se joignent après avoir passé au moins quelques mois à coder - seuls, dans un bootcamp ou professionnellement. Cependant, j'ai vu de nombreux développeurs juniors avec peu ou pas d'expérience reprendre rapidement ces sujets.

J'ai vu un groupe d'élèves se familiariser avec les fonctions fléchées au curry au cours d'une seule leçon d'une heure. (Si vous êtes membre de «Apprendre JavaScript avec Eric Elliott», vous pouvez regarder la leçon de Curry et Composition ES6 de 55 minutes en ce moment).

Voyant à quelle vitesse les étudiants le ramassent et commencent à exercer leurs nouveaux pouvoirs de curry, je suis toujours un peu surpris lorsque je poste des fonctions de flèche au curry sur Twitter, et le Twitterverse répond avec indignation à l'idée d'infliger ce code "illisible" à les gens qui auront besoin de le maintenir.

Tout d'abord, permettez-moi de vous donner un exemple de ce dont nous parlons. La première fois que j'ai remarqué le contrecoup, c'était la réponse de Twitter à cette fonction:

const secret = msg => () => msg;

J'ai été choqué lorsque des gens sur Twitter m'ont accusé d'avoir tenté de semer la confusion. J'ai écrit cette fonction pour montrer à quel point il est facile d'exprimer des fonctions au curry dans ES6. C'est l'application pratique la plus simple et l'expression d'une fermeture à laquelle je peux penser en JavaScript. (Connexes: «Qu'est-ce qu'une fermeture?»).

C'est l'équivalent de l'expression de fonction suivante:

const secret = fonction (msg) {
  fonction de retour () {
    return msg;
  };
};

secret () est une fonction qui prend un msg et retourne une nouvelle fonction qui retourne le msg. Il profite des fermetures pour fixer la valeur de msg à la valeur que vous passez dans secret ().

Voici comment vous l'utilisez:

const mySecret = secret ('hi');
mon secret(); // 'salut'

Il s'avère que la «double flèche» est ce qui a dérouté les gens. Je suis convaincu que c'est un fait:

Avec la familiarité, les fonctions fléchées en ligne sont le moyen le plus lisible pour exprimer les fonctions curry en JavaScript.

Beaucoup de gens m'ont soutenu que la forme plus longue est plus facile à lire que la forme plus courte. Ils ont en partie raison, mais ils ont surtout tort. C'est plus verbeux et plus explicite, mais pas plus facile à lire - du moins, pas pour quelqu'un qui connaît les fonctions fléchées.

Les objections que j'ai vues sur Twitter ne plaisaient tout simplement pas avec l'expérience d'apprentissage fluide que mes élèves appréciaient. D'après mon expérience, les élèves utilisent des fonctions fléchées au curry comme les poissons prennent l'eau. Quelques jours après leur apprentissage, ils ne font qu'un avec les flèches. Ils les élinguent sans effort pour relever toutes sortes de défis de codage.

Je ne vois aucun signe que les fonctions fléchées sont «difficiles» à apprendre, à lire ou à comprendre - une fois qu'ils ont fait l'investissement initial de les apprendre au cours de quelques leçons et séances d'étude d'une heure.

Ils lisent facilement les fonctions fléchées au curry qu'ils n'ont jamais vues auparavant et m'expliquent ce qui se passe. Ils écrivent naturellement le leur lorsque je leur présente un défi.

En d'autres termes, dès qu'ils se familiarisent avec les fonctions des flèches au curry, ils n'ont aucun problème avec eux. Ils les lisent aussi facilement que vous lisez cette phrase - et leur compréhension se reflète dans un code beaucoup plus simple avec moins de bogues.

Pourquoi certaines personnes pensent que les expressions de fonction héritées semblent «plus faciles» à lire

Le biais de familiarité est un biais cognitif humain mesurable qui nous amène à prendre des décisions autodestructrices malgré la conscience d'une meilleure option. Nous continuons à utiliser les mêmes anciens modèles malgré le fait de connaître de meilleurs modèles par confort et habitude.

Vous pouvez en apprendre beaucoup plus sur le biais de familiarité (et sur bien d'autres façons de nous tromper) grâce à l'excellent livre «The Undoing Project: A Friendship that Changed Our Minds». Ce livre doit être lu par tous les développeurs de logiciels, car il vous encourage à réfléchir de manière plus critique et à tester vos hypothèses afin d'éviter de tomber dans une variété de pièges cognitifs - et l'histoire de la découverte de ces pièges cognitifs est également très bonne. .

Les expressions de fonction héritées sont probablement à l'origine de bogues dans votre code

Aujourd'hui, je réécrivais une fonction de flèche au curry d'ES6 à ES5 afin de pouvoir la publier en tant que module open source que les gens pouvaient utiliser dans les anciens navigateurs sans transpiler. La version ES5 m'a choqué.

La version ES6 était simple, courte et élégante - seulement 4 lignes.

Je pensais que c'était la fonction qui prouverait à Twitter que les fonctions fléchées sont supérieures et que les gens devraient abandonner leurs fonctions héritées comme la mauvaise habitude qu'ils sont.

J'ai donc tweeté:

Voici le texte des fonctions, au cas où l'image ne fonctionnerait pas pour vous:

// au curry avec des flèches
const composeMixins = (... mixins) => (
  instance = {},
  mix = (... fns) => x => fns.reduce ((acc, fn) => fn (acc), x)
) => mix (... mixins) (instance);
// vs style ES5
var composeMixins = function () {
  var mixins = [] .slice.call (arguments);
  fonction de retour (instance, mix) {
    if (! instance) instance = {};
    si (! mix) {
      mix = fonction () {
        var fns = [] .slice.call (arguments);
        fonction de retour (x) {
          return fns.reduce (fonction (acc, fn) {
            return fn (acc);
          }, X);
        };
      };
    }
    return mix.apply (null, mixins) (instance);
  };
};

La fonction en question est un simple wrapper autour de pipe (), un utilitaire de programmation fonctionnelle standard couramment utilisé pour composer des fonctions. Une fonction pipe () existe dans lodash comme lodash / flow, dans Ramda comme R.pipe (), et a même son propre opérateur dans plusieurs langages de programmation fonctionnels.

Il devrait être familier à tous ceux qui connaissent la programmation fonctionnelle. De même que sa dépendance principale: réduire.

Dans ce cas, il est utilisé pour composer des mixins fonctionnels, mais c'est un détail non pertinent (et un autre article de blog). Voici les détails importants:

La fonction prend n'importe quel nombre de mixins fonctionnels et retourne une fonction qui les applique l'un après l'autre dans un pipeline - comme une chaîne de montage. Chaque mixin fonctionnel prend l'instance en entrée et y ajoute des éléments avant de la passer à la fonction suivante du pipeline.

Si vous omettez l'instance, un nouvel objet est créé pour vous.

Parfois, nous pouvons vouloir composer les mixins différemment. Par exemple, vous souhaiterez peut-être passer compose () au lieu de pipe () pour inverser l'ordre de priorité.

Si vous n'avez pas besoin de personnaliser le comportement, laissez simplement la valeur par défaut et obtenez un comportement pipe () standard.

Juste les faits

Mis à part les opinions sur la lisibilité, voici les faits objectifs concernant cet exemple:

  • J'ai plusieurs années d'expérience avec les expressions de fonction ES5 et ES6, les flèches ou autre. Le biais de familiarité n'est pas une variable dans ces données.
  • J'ai écrit la version ES6 en quelques secondes. Il ne contenait aucun bogue (à ma connaissance - il passe tous ses tests unitaires).
  • Il m'a fallu plusieurs minutes pour écrire la version ES5. Au moins un ordre de grandeur plus de temps. Minutes vs secondes. J'ai perdu deux fois ma place dans les indentations de fonction. J'ai écrit 3 bugs, que j'ai tous dû déboguer et corriger. Deux d'entre eux j'ai dû recourir à console.log () pour comprendre ce qui se passait.
  • La version ES6 est composée de 4 lignes de code.
  • La version ES5 comprend 21 lignes (17 contiennent en fait du code).
  • Malgré sa verbosité fastidieuse, la version ES5 perd en fait une partie de la fidélité des informations disponible dans la version ES6. C'est beaucoup plus long, mais communique moins, lisez la suite pour plus de détails.
  • La version ES6 contient 2 spreads pour les paramètres de fonction. La version ES5 omet les spreads et utilise à la place l'objet arguments implicites, ce qui nuit à la lisibilité de la signature de fonction (rétrogradation de fidélité 1).
  • La version ES6 définit la valeur par défaut pour le mixage dans la signature de fonction afin que vous puissiez clairement voir qu'il s'agit d'une valeur pour un paramètre. La version ES5 masque ce détail et le cache au fond du corps de la fonction. (déclassement de fidélité 2).
  • La version ES6 n'a que 2 niveaux d'indentation, ce qui permet de clarifier la structure de la lecture. La version ES5 en a 6, et les niveaux d'imbrication obscurcissent plutôt que d'aider à la lisibilité de la structure de la fonction (rétrogradation de fidélité 3).

Dans la version ES5, pipe () occupe la majeure partie du corps de la fonction - à tel point qu'il est un peu fou de le définir en ligne. Il doit vraiment être divisé en une fonction distincte pour rendre la version ES5 lisible:

var pipe = fonction () {
  var fns = [] .slice.call (arguments);
  fonction de retour (x) {
    return fns.reduce (fonction (acc, fn) {
      return fn (acc);
    }, X);
  };
};
var composeMixins = function () {
  var mixins = [] .slice.call (arguments);
  fonction de retour (instance, mix) {
    if (! instance) instance = {};
    if (! mix) mix = pipe;
    return mix.apply (null, mixins) (instance);
  };
};

Cela me semble clairement plus lisible et compréhensible.

Voyons ce qui se passe lorsque nous appliquons la même «optimisation» de lisibilité à la version ES6:

const pipe = (... fns) => x => fns.reduce ((acc, fn) => fn (acc), x);
const composeMixins = (... mixins) => (
  instance = {},
  mix = pipe
) => mix (... mixins) (instance);

Comme l'optimisation ES5, cette version est plus détaillée (elle ajoute une nouvelle variable qui n'était pas là auparavant). Contrairement à la version ES5, cette version n'est pas beaucoup plus lisible après avoir résumé la définition de tuyau. Après tout, un nom de variable lui était déjà clairement attribué dans la signature de la fonction: mix.

La définition de mix était déjà contenue sur sa propre ligne, ce qui rend peu probable que les lecteurs ne sachent où elle se termine et le reste de la fonction continue.

Maintenant, nous avons 2 variables représentant la même chose au lieu de 1. Avons-nous beaucoup gagné? Pas évidemment, non.

Alors, pourquoi la version ES5 est-elle évidemment meilleure avec la même fonction abstraite?

Parce que la version ES5 est évidemment plus complexe. La source de cette complexité est au cœur de cette affaire. J'affirme que la source de la complexité se résume au bruit de syntaxe, et que le bruit de syntaxe obscurcit le sens de la fonction, n'aide pas.

Passons à la vitesse supérieure et éliminons d'autres variables. Utilisons ES6 pour les deux exemples et comparons uniquement les fonctions fléchées aux expressions de fonction héritées:

var composeMixins = fonction (... mixins) {
  fonction de retour (
    instance = {},
    mix = fonction (... fns) {
      fonction de retour (x) {
        return fns.reduce (fonction (acc, fn) {
          return fn (acc);
        }, X);
      };
    }
  ) {
    return mix (... mixins) (instance);
  };
};

Cela me semble beaucoup plus lisible. Tout ce que nous avons changé, c'est que nous profitons du repos et de la syntaxe des paramètres par défaut. Bien sûr, vous devrez vous familiariser avec le repos et la syntaxe par défaut pour que cette version soit plus lisible, mais même si vous ne l'êtes pas, je pense qu'il est évident que cette version est encore moins encombrée.

Cela a beaucoup aidé, mais il est toujours clair pour moi que cette version est encore assez encombrée pour que le fait de résumer pipe () dans sa propre fonction aiderait évidemment:

const pipe = fonction (... fns) {
  fonction de retour (x) {
    return fns.reduce (fonction (acc, fn) {
      return fn (acc);
    }, X);
  };
};
// Expressions de fonction héritées
const composeMixins = fonction (... mixins) {
  fonction de retour (
    instance = {},
    mix = pipe
  ) {
    return mix (... mixins) (instance);
  };
};

C’est mieux, non? Maintenant que l'assignation de mixage n'occupe qu'une seule ligne, la structure de la fonction est beaucoup plus claire - mais il y a encore trop de bruit de syntaxe à mon goût. Dans composeMixins (), je ne vois pas d'un coup d'œil où se termine une fonction et où commence une autre.

Plutôt que d'appeler des corps de fonction, ce mot-clé de fonction semble se fondre visuellement avec les identifiants qui l'entourent. Il y a des fonctions qui se cachent dans ma fonction! Où se termine la signature du paramètre et le corps de la fonction? Je peux le découvrir si je regarde de près, mais ce n'est pas visuellement évident pour moi.

Et si nous pouvions nous débarrasser du mot-clé function et appeler les valeurs de retour en les pointant visuellement avec une grosse grosse flèche => au lieu d'écrire un mot-clé de retour qui se confond avec les identifiants environnants?

Il s'avère que nous pouvons, et voici à quoi cela ressemble:

const composeMixins = (... mixins) => (
  instance = {},
  mix = pipe
) => mix (... mixins) (instance);

Maintenant, il devrait être clair ce qui se passe. composeMixins () est une fonction qui prend n'importe quel nombre de mixins et renvoie une fonction qui prend deux paramètres facultatifs, instance et mix. Il retourne le résultat de l'instance de piping via les mixins composés.

Encore une chose… si nous appliquons la même optimisation à pipe (), il se transforme comme par magie en un seul revêtement:

const pipe = (... fns) => x => fns.reduce ((acc, fn) => fn (acc), x);

Avec cette définition sur une seule ligne, l'avantage de l'abstraction dans sa propre fonction est moins clair. Rappelez-vous, cette fonction existe comme un utilitaire dans Lodash, Ramda et un tas d'autres bibliothèques, mais vaut-il vraiment la peine d'importer une autre bibliothèque?

Cela vaut-il même la peine de le retirer dans sa propre ligne? Probablement. Ce sont vraiment deux fonctions différentes, et les séparer rend cela plus clair.

D'un autre côté, l'avoir en ligne clarifie les attentes de type et d'utilisation lorsque vous regardez la signature du paramètre. Voici ce qui se passe lorsque nous le créons en ligne:

const composeMixins = (... mixins) => (
  instance = {},
  mix = (... fns) => x => fns.reduce ((acc, fn) => fn (acc), x)
) => mix (... mixins) (instance);

Maintenant, nous revenons à la fonction d'origine. En cours de route, nous n'avons ignoré aucun sens. En fait, en déclarant nos paramètres et valeurs par défaut en ligne, nous avons ajouté des informations sur la façon dont la fonction est utilisée et à quoi pourraient ressembler les valeurs des paramètres.

Tout ce code supplémentaire dans la version ES5 n'était que du bruit. Bruit de syntaxe. Cela n'a servi à rien d'autre que d'acclimater les gens qui ne connaissent pas les fonctions fléchées au curry.

Une fois que vous vous serez suffisamment familiarisé avec les fonctions fléchées au curry, il devrait être clair que la version originale est plus lisible car il y a beaucoup moins de syntaxe pour s'y perdre.

Il est également moins sujet aux erreurs, car il y a beaucoup moins de surface pour cacher les bogues.

Je soupçonne qu'il y a beaucoup de bogues cachés dans les fonctions héritées qui seraient trouvés et éliminés si vous deviez passer aux fonctions fléchées.

Je pense également que votre équipe deviendrait beaucoup plus productive si vous appreniez à adopter et à favoriser davantage la syntaxe concise disponible dans ES6.

S'il est vrai que parfois les choses sont plus faciles à comprendre si elles sont explicitées, il est également vrai qu'en règle générale, moins de code est préférable.

Si moins de code peut accomplir la même chose et communiquer plus, sans sacrifier le sens, c'est objectivement mieux.

La clé pour connaître la différence est le sens. Si plus de code ne parvient pas à ajouter plus de sens, ce code ne devrait pas exister. Ce concept est tellement basique, c'est une ligne directrice de style bien connue pour le langage naturel.

La même directive de style s'applique au code source. Embrassez-le et votre code sera meilleur.

À la fin de la journée, une lumière dans l'obscurité. En réponse à un autre tweet disant que la version ES6 est moins lisible:

Il est temps de se familiariser avec ES6, le curry et la composition des fonctions.

Prochaines étapes

Les membres de «Learn JavaScript with Eric Elliott» peuvent regarder la leçon de Curry et Composition ES6 de 55 minutes dès maintenant.

Si vous n'êtes pas membre, vous passez à côté!

Eric Elliott est l’auteur de «Programming JavaScript Applications» (O’Reilly) et «Learn JavaScript with Eric Elliott». Il a contribué à des expériences logicielles pour Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC et les meilleurs artistes d'enregistrement, notamment Usher, Frank Ocean, Metallica et bien d'autres.

Il passe la plupart de son temps dans la baie de San Francisco avec la plus belle femme du monde.