En tant que développeur JS, c'est ce qui m'empêche de dormir la nuit
Publié: 2022-03-11JavaScript est un excentrique d'un langage. Bien qu'inspiré de Smalltalk, il utilise une syntaxe de type C. Il combine des aspects des paradigmes de programmation procédurale, fonctionnelle et orientée objet (POO). Il a de nombreuses approches, souvent redondantes, pour résoudre presque tous les problèmes de programmation imaginables et n'a pas d'opinion très arrêtée sur celles qui sont préférées. Il est typé faiblement et dynamiquement, avec une approche labyrinthique de la coercition de type qui fait trébucher même les développeurs expérimentés.
JavaScript a aussi ses verrues, ses pièges et ses fonctionnalités douteuses. Les nouveaux programmeurs ont du mal avec certains de ses concepts les plus difficiles - pensez à l'asynchronicité, aux fermetures et au levage. Les programmeurs ayant de l'expérience dans d'autres langages supposent raisonnablement que des choses avec des noms et des apparences similaires fonctionneront de la même manière en JavaScript et sont souvent erronées. Les tableaux ne sont pas vraiment des tableaux ; quel est le problème avec this
, qu'est-ce qu'un prototype et que fait réellement new
?
Le problème avec les classes ES6
Le pire contrevenant est de loin le nouveau venu dans la dernière version de JavaScript, ECMAScript 6 (ES6) : classes . Certaines des discussions autour des cours sont franchement alarmantes et révèlent une incompréhension profondément enracinée du fonctionnement réel de la langue :
"JavaScript est enfin un vrai langage orienté objet maintenant qu'il a des classes !"
Ou:
"Les classes nous libèrent de la réflexion sur le modèle d'héritage brisé de JavaScript."
Ou même:
"Les classes sont une approche plus sûre et plus simple pour créer des types en JavaScript."
Ces déclarations ne me dérangent pas car elles impliquent qu'il y a quelque chose qui ne va pas avec l'héritage prototypique ; laissons de côté ces arguments. Ces affirmations me dérangent parce qu'aucune d'entre elles n'est vraie, et elles démontrent les conséquences de l'approche « tout pour tout le monde » de JavaScript en matière de conception de langage : cela paralyse la compréhension d'un programmeur du langage plus souvent qu'elle ne le permet. Avant d'aller plus loin, illustrons.
JavaScript Pop Quiz #1 : Quelle est la différence essentielle entre ces blocs de code ?
function PrototypicalGreeting(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } PrototypicalGreeting.prototype.greet = function() { return `${this.greeting}, ${this.name}!` } const greetProto = new PrototypicalGreeting("Hey", "folks") console.log(greetProto.greet())
class ClassicalGreeting { constructor(greeting = "Hello", name = "World") { this.greeting = greeting this.name = name } greet() { return `${this.greeting}, ${this.name}!` } } const classyGreeting = new ClassicalGreeting("Hey", "folks") console.log(classyGreeting.greet())
La réponse ici est qu'il n'y en a pas . Ceux-ci font effectivement la même chose, c'est seulement une question de savoir si la syntaxe de classe ES6 a été utilisée.
Certes, le deuxième exemple est plus expressif. Pour cette seule raison, vous pourriez dire que class
est un bel ajout à la langue. Malheureusement, le problème est un peu plus subtil.
JavaScript Pop Quiz #2 : Que fait le code suivant ?
function Proto() { this.name = 'Proto' return this; } Proto.prototype.getName = function() { return this.name } class MyClass extends Proto { constructor() { super() this.name = 'MyClass' } } const instance = new MyClass() console.log(instance.getName()) Proto.prototype.getName = function() { return 'Overridden in Proto' } console.log(instance.getName()) MyClass.prototype.getName = function() { return 'Overridden in MyClass' } console.log(instance.getName()) instance.getName = function() { return 'Overridden in instance' } console.log(instance.getName())
La bonne réponse est qu'il imprime sur la console :
> MyClass > Overridden in Proto > Overridden in MyClass > Overridden in instance
Si vous avez mal répondu, vous ne comprenez pas ce qu'est réellement class
. Ce n'est pas ta faute. Tout comme Array
, class
n'est pas une fonctionnalité de langage, c'est un obscurantisme syntaxique . Il essaie de cacher le modèle d'héritage prototypique et les idiomes maladroits qui l'accompagnent, et cela implique que JavaScript fait quelque chose qu'il ne fait pas.
On vous a peut-être dit que class
a été introduite dans JavaScript pour rendre les développeurs OOP classiques issus de langages comme Java plus à l'aise avec le modèle d'héritage de classe ES6. Si vous êtes l'un de ces développeurs, cet exemple vous a probablement horrifié. Cela devrait. Cela montre que le mot-clé class
de JavaScript ne comporte aucune des garanties qu'une classe est censée fournir. Il illustre également l'une des principales différences dans le modèle d'héritage des prototypes : les prototypes sont des instances d'objet , et non des types .
Prototypes vs classes
La différence la plus importante entre l'héritage basé sur les classes et sur les prototypes est qu'une classe définit un type qui peut être instancié au moment de l'exécution, alors qu'un prototype est lui-même une instance d'objet.
Un enfant d'une classe ES6 est une autre définition de type qui étend le parent avec de nouvelles propriétés et méthodes, qui à leur tour peuvent être instanciées lors de l'exécution. Un enfant d'un prototype est une autre instance d'objet qui délègue au parent toutes les propriétés qui ne sont pas implémentées sur l'enfant.
Remarque : vous vous demandez peut-être pourquoi j'ai mentionné les méthodes de classe, mais pas les méthodes de prototype. C'est parce que JavaScript n'a pas de concept de méthodes. Les fonctions sont de première classe en JavaScript, et elles peuvent avoir des propriétés ou être des propriétés d'autres objets.
Un constructeur de classe crée une instance de la classe. Un constructeur en JavaScript est simplement une vieille fonction qui renvoie un objet. La seule particularité d'un constructeur JavaScript est que, lorsqu'il est invoqué avec le mot-clé new
, il affecte son prototype comme prototype de l'objet renvoyé. Si cela vous semble un peu déroutant, vous n'êtes pas seul - c'est le cas, et c'est en grande partie pourquoi les prototypes sont mal compris.
Pour mettre un point très fin là-dessus, un enfant d'un prototype n'est pas une copie de son prototype, ni un objet ayant la même forme que son prototype. L'enfant a une référence vivante au prototype, et toute propriété de prototype qui n'existe pas sur l'enfant est une référence unidirectionnelle à une propriété du même nom sur le prototype.
Considérer ce qui suit:
let parent = { foo: 'foo' } let child = { } Object.setPrototypeOf(child, parent) console.log(child.foo) // 'foo' child.foo = 'bar' console.log(child.foo) // 'bar' console.log(parent.foo) // 'foo' delete child.foo console.log(child.foo) // 'foo' parent.foo = 'baz' console.log(child.foo) // 'baz'
Dans l'exemple précédent, alors que child.foo
était undefined
, il faisait référence à parent.foo
. Dès que nous avons défini foo
sur child
, child.foo
avait la valeur 'bar'
, mais parent.foo
conservé sa valeur d'origine. Une fois que nous delete child.foo
, il fait à nouveau référence à parent.foo
, ce qui signifie que lorsque nous modifions la valeur du parent, child.foo
fait référence à la nouvelle valeur.
Regardons ce qui vient de se passer (pour une illustration plus claire, nous allons prétendre qu'il s'agit de Strings
et non de chaînes littérales, la différence n'a pas d'importance ici):
La façon dont cela fonctionne sous le capot, et en particulier les particularités de new
et this
, sont un sujet pour un autre jour, mais Mozilla a un article complet sur la chaîne d'héritage du prototype de JavaScript si vous souhaitez en savoir plus.
La clé à retenir est que les prototypes ne définissent pas un type
; ce sont eux-mêmes des instances
et ils sont modifiables à l'exécution, avec tout ce que cela implique et implique.
Encore avec moi? Revenons à la dissection des classes JavaScript.
JavaScript Pop Quiz #3 : Comment implémentez-vous la confidentialité dans les cours ?
Nos propriétés de prototype et de classe ci-dessus ne sont pas tant "encapsulées" que "suspendues de manière précaire par la fenêtre". On devrait régler ça, mais comment ?
Aucun exemple de code ici. La réponse est que vous ne pouvez pas.
JavaScript n'a aucun concept de confidentialité, mais il a des fermetures :
function SecretiveProto() { const secret = "The Class is a lie!" this.spillTheBeans = function() { console.log(secret) } } const blabbermouth = new SecretiveProto() try { console.log(blabbermouth.secret) } catch(e) { // TypeError: SecretiveClass.secret is not defined } blabbermouth.spillTheBeans() // "The Class is a lie!"
Comprenez-vous ce qui vient de se passer ? Sinon, vous ne comprenez pas les fermetures. Ce n'est pas grave, vraiment - ils ne sont pas aussi intimidants qu'on le prétend, ils sont super utiles et vous devriez prendre le temps de les découvrir.
JavaScript Pop Quiz #4 : Quel est l'équivalent de ce qui précède en utilisant le mot-clé class
?
Désolé, c'est une autre question piège. Vous pouvez faire essentiellement la même chose, mais cela ressemble à ceci:
class SecretiveClass { constructor() { const secret = "I am a lie!" this.spillTheBeans = function() { console.log(secret) } } looseLips() { console.log(secret) } } const liar = new SecretiveClass() try { console.log(liar.secret) } catch(e) { console.log(e) // TypeError: SecretiveClass.secret is not defined } liar.spillTheBeans() // "I am a lie!"
Faites-moi savoir si cela semble plus facile ou plus clair que dans SecretiveProto
. À mon avis, c'est un peu pire - cela casse l'utilisation idiomatique des déclarations de class
en JavaScript et cela ne fonctionne pas comme on pourrait s'y attendre venant, disons, de Java. Cela sera précisé par ce qui suit :

JavaScript Pop Quiz #5 : Que fait SecretiveClass::looseLips()
?
Découvrons-le:
try { liar.looseLips() } catch(e) { // ReferenceError: secret is not defined }
Eh bien… c'était gênant.
JavaScript Pop Quiz #6 : Que préfèrent les développeurs JavaScript expérimentés : les prototypes ou les classes ?
Vous l'avez deviné, c'est une autre question piège - les développeurs JavaScript expérimentés ont tendance à éviter les deux quand ils le peuvent. Voici une belle façon de faire ce qui précède avec JavaScript idiomatique :
function secretFactory() { const secret = "Favor composition over inheritance, `new` is considered harmful, and the end is near!" const spillTheBeans = () => console.log(secret) return { spillTheBeans } } const leaker = secretFactory() leaker.spillTheBeans()
Il ne s'agit pas seulement d'éviter la laideur inhérente à l'héritage ou d'imposer l'encapsulation. Pensez à ce que vous pourriez faire d'autre avec secretFactory
et leaker
que vous ne pourriez pas facilement faire avec un prototype ou une classe.
D'une part, vous pouvez le déstructurer car vous n'avez pas à vous soucier du contexte de this
:
const { spillTheBeans } = secretFactory() spillTheBeans() // Favor composition over inheritance, (...)
C'est plutôt sympa. En plus d'éviter les new
et this
pitreries, cela nous permet d'utiliser nos objets de manière interchangeable avec les modules CommonJS et ES6. Cela rend également la composition un peu plus facile :
function spyFactory(infiltrationTarget) { return { exfiltrate: infiltrationTarget.spillTheBeans } } const blackHat = spyFactory(leaker) blackHat.exfiltrate() // Favor composition over inheritance, (...) console.log(blackHat.infiltrationTarget) // undefined (looks like we got away with it)
Les clients de blackHat
n'ont pas à se soucier de l'origine de l' exfiltrate
, et spyFactory
n'a pas à s'embarrasser de Function::bind
jonglant avec le contexte ou de propriétés profondément imbriquées. Remarquez, nous n'avons pas à this
en soucier beaucoup dans le code procédural synchrone simple, mais cela cause toutes sortes de problèmes dans le code asynchrone qu'il vaut mieux éviter.
Avec un peu de réflexion, spyFactory
pourrait être développé en un outil d'espionnage hautement sophistiqué qui pourrait gérer toutes sortes de cibles d'infiltration - ou en d'autres termes, une façade.
Bien sûr, vous pouvez également le faire avec une classe, ou plutôt un assortiment de classes, qui héritent toutes d'une abstract class
ou d'une interface
… sauf que JavaScript n'a aucun concept d'abstraits ou d'interfaces.
Revenons à l'exemple de greeter pour voir comment nous l'implémenterions avec une fabrique :
function greeterFactory(greeting = "Hello", name = "World") { return { greet: () => `${greeting}, ${name}!` } } console.log(greeterFactory("Hey", "folks").greet()) // Hey, folks!
Vous avez peut-être remarqué que ces usines deviennent de plus en plus concises au fur et à mesure que nous avançons, mais ne vous inquiétez pas, elles font la même chose. Les roues d'entraînement se détachent, les amis !
C'est déjà moins passe-partout que le prototype ou la version de classe du même code. Deuxièmement, il parvient à encapsuler plus efficacement ses propriétés. En outre, il a une mémoire et une empreinte de performances inférieures dans certains cas (cela peut ne pas sembler à première vue, mais le compilateur JIT travaille discrètement dans les coulisses pour réduire la duplication et déduire les types).
C'est donc plus sûr, c'est souvent plus rapide et c'est plus facile d'écrire du code comme celui-ci. Pourquoi avons-nous encore besoin de cours ? Oh, bien sûr, la réutilisabilité. Que se passe-t-il si nous voulons des variantes de greeter mécontents et enthousiastes ? Eh bien, si nous utilisons la classe ClassicalGreeting
, nous sautons probablement directement dans l'idée d'une hiérarchie de classes. Nous savons que nous devrons paramétrer la ponctuation, nous allons donc faire une petite refactorisation et ajouter quelques enfants :
// Greeting class class ClassicalGreeting { constructor(greeting = "Hello", name = "World", punctuation = "!") { this.greeting = greeting this.name = name this.punctuation = punctuation } greet() { return `${this.greeting}, ${this.name}${this.punctuation}` } } // An unhappy greeting class UnhappyGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, " :(") } } const classyUnhappyGreeting = new UnhappyGreeting("Hello", "everyone") console.log(classyUnhappyGreeting.greet()) // Hello, everyone :( // An enthusiastic greeting class EnthusiasticGreeting extends ClassicalGreeting { constructor(greeting, name) { super(greeting, name, "!!") } greet() { return super.greet().toUpperCase() } } const greetingWithEnthusiasm = new EnthusiasticGreeting() console.log(greetingWithEnthusiasm.greet()) // HELLO, WORLD!!
C'est une bonne approche, jusqu'à ce que quelqu'un arrive et demande une fonctionnalité qui ne rentre pas parfaitement dans la hiérarchie et que tout cela n'ait plus de sens. Mettez une épingle dans cette pensée pendant que nous essayons d'écrire la même fonctionnalité avec les usines :
const greeterFactory = (greeting = "Hello", name = "World", punctuation = "!") => ({ greet: () => `${greeting}, ${name}${punctuation}` }) // Makes a greeter unhappy const unhappy = (greeter) => (greeting, name) => greeter(greeting, name, ":(") console.log(unhappy(greeterFactory)("Hello", "everyone").greet()) // Hello, everyone :( // Makes a greeter enthusiastic const enthusiastic = (greeter) => (greeting, name) => ({ greet: () => greeter(greeting, name, "!!").greet().toUpperCase() }) console.log(enthusiastic(greeterFactory)().greet()) // HELLO, WORLD!!
Il n'est pas évident que ce code soit meilleur, même s'il est un peu plus court. En fait, vous pourriez dire qu'il est plus difficile à lire, et c'est peut-être une approche obtuse. Ne pourrions-nous pas simplement avoir un unhappyGreeterFactory
et un enthusiasticGreeterFactory
?
Ensuite, votre client arrive et dit : "J'ai besoin d'un nouveau messager qui est mécontent et qui veut que toute la salle le sache !"
console.log(enthusiastic(unhappy(greeterFactory))().greet()) // HELLO, WORLD :(
Si nous avions besoin d'utiliser plus d'une fois ce messager enthousiaste et malheureux, nous pourrions nous faciliter la tâche :
const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory)) console.log(aggressiveGreeterFactory("You're late", "Jim").greet())
Il existe des approches de ce style de composition qui fonctionnent avec des prototypes ou des classes. Par exemple, vous pouvez repenser UnhappyGreeting
et EnthusiasticGreeting
en tant que décorateurs. Cela prendrait encore plus de passe-partout que l'approche de style fonctionnel utilisée ci-dessus, mais c'est le prix à payer pour la sécurité et l'encapsulation de classes réelles .
Le fait est qu'en JavaScript, vous n'obtenez pas cette sécurité automatique. Les frameworks JavaScript qui mettent l'accent sur l'utilisation class
font beaucoup de "magie" pour dissimuler ce genre de problèmes et obligent les classes à se comporter d'elles-mêmes. Jetez un œil au code source ElementMixin
de Polymer de temps en temps, je vous défie. Ce sont des niveaux d'archi-sorciers d'arcanes JavaScript, et je veux dire cela sans ironie ni sarcasme.
Bien sûr, nous pouvons résoudre certains des problèmes évoqués ci-dessus avec Object.freeze
ou Object.defineProperties
avec un effet plus ou moins important. Mais pourquoi imiter la forme sans la fonction, tout en ignorant les outils que JavaScript nous fournit nativement et que nous ne trouverions peut-être pas dans des langages comme Java ? Utiliseriez-vous un marteau étiqueté « tournevis » pour enfoncer une vis, alors que votre boîte à outils avait un véritable tournevis juste à côté ?
Trouver les bonnes pièces
Les développeurs JavaScript mettent souvent l'accent sur les bons côtés du langage, à la fois familièrement et en référence au livre du même nom. Nous essayons d'éviter les pièges tendus par ses choix de conception de langage plus discutables et de nous en tenir aux parties qui nous permettent d'écrire un code propre, lisible, minimisant les erreurs et réutilisable.
Il existe des arguments raisonnables sur les parties de JavaScript éligibles, mais j'espère vous avoir convaincu que la class
n'en fait pas partie. A défaut, j'espère que vous comprenez que l'héritage en JavaScript peut être un gâchis déroutant et que cette class
ne le résout pas et ne vous évite pas d'avoir à comprendre les prototypes. Crédit supplémentaire si vous avez compris les indices selon lesquels les modèles de conception orientés objet fonctionnent correctement sans classes ni héritage ES6.
Je ne te dis pas d'éviter complètement class
. Parfois, vous avez besoin d'héritage, et class
fournit une syntaxe plus propre pour ce faire. En particulier, class X extends Y
est beaucoup plus agréable que l'ancienne approche de prototype. À côté de cela, de nombreux frameworks frontaux populaires encouragent son utilisation et vous devriez probablement éviter d'écrire du code non standard étrange par principe seul. Je n'aime pas où cela mène.
Dans mes cauchemars, toute une génération de bibliothèques JavaScript est écrite à l'aide class
, dans l'espoir qu'elle se comportera de la même manière que d'autres langages populaires. De toutes nouvelles classes de bogues (jeu de mots) sont découvertes. Les anciens sont ressuscités et auraient facilement pu être laissés dans le Cimetière du JavaScript malformé si nous n'étions pas tombés par inadvertance dans le piège class
. Les développeurs JavaScript expérimentés sont en proie à ces monstres, car ce qui est populaire n'est pas toujours ce qui est bon.
Finalement, nous abandonnons tous par frustration et commençons à réinventer les roues dans Rust, Go, Haskell, ou qui sait quoi d'autre, puis à compiler vers Wasm pour le Web, et de nouveaux frameworks et bibliothèques Web prolifèrent à l'infini multilingue.
Ça m'empêche vraiment de dormir la nuit.