Como desenvolvedor JS, é isso que me mantém acordado à noite
Publicados: 2022-03-11JavaScript é uma linguagem excêntrica. Embora inspirado em Smalltalk, ele usa uma sintaxe semelhante a C. Ele combina aspectos dos paradigmas de programação procedural, funcional e orientada a objetos (OOP). Ele tem várias abordagens, muitas vezes redundantes, para resolver quase qualquer problema de programação concebível e não é fortemente opinativo sobre quais são os preferidos. É digitado de forma fraca e dinâmica, com uma abordagem labiríntica para a coerção de tipos que atrapalha até mesmo desenvolvedores experientes.
JavaScript também tem suas verrugas, armadilhas e recursos questionáveis. Novos programadores lutam com alguns de seus conceitos mais difíceis – pense em assincronicidade, fechamentos e içamento. Programadores com experiência em outras linguagens assumem razoavelmente que coisas com nomes e aparências semelhantes funcionarão da mesma maneira em JavaScript e geralmente estão erradas. Matrizes não são realmente matrizes; qual é o problema this
, o que é um protótipo e o que o new
realmente faz?
O problema com as classes ES6
O pior infrator, de longe, é novo na versão mais recente do JavaScript, ECMAScript 6 (ES6): classes . Algumas das conversas em torno das aulas são francamente alarmantes e revelam um mal-entendido profundamente enraizado sobre como a linguagem realmente funciona:
“JavaScript é finalmente uma linguagem orientada a objetos real agora que tem classes!”
Ou:
“As aulas nos libertam de pensar no modelo de herança quebrado do JavaScript.”
Ou ainda:
“As classes são uma abordagem mais segura e fácil para criar tipos em JavaScript.”
Essas declarações não me incomodam porque implicam que há algo errado com a herança prototípica; vamos deixar de lado esses argumentos. Essas declarações me incomodam porque nenhuma delas é verdadeira, e elas demonstram as consequências da abordagem “tudo para todos” do JavaScript para o design de linguagem: ela prejudica a compreensão de um programador da linguagem com mais frequência do que permite. Antes de prosseguir, vamos ilustrar.
JavaScript Pop Quiz #1: Qual é a diferença essencial entre esses blocos de código?
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())
A resposta aqui é que não há um . Eles fazem efetivamente a mesma coisa, é apenas uma questão de saber se a sintaxe da classe ES6 foi usada.
É verdade que o segundo exemplo é mais expressivo. Por esse motivo, você pode argumentar que a class
é uma boa adição à linguagem. Infelizmente, o problema é um pouco mais sutil.
JavaScript Pop Quiz #2: O que o código a seguir faz?
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())
A resposta correta é que imprime no console:
> MyClass > Overridden in Proto > Overridden in MyClass > Overridden in instance
Se você respondeu incorretamente, você não entende o que realmente é class
. Isso não é sua culpa. Assim como Array
, class
não é um recurso de linguagem, é obscurantismo sintático . Ele tenta esconder o modelo de herança prototípico e os idiomas desajeitados que o acompanham, e isso implica que o JavaScript está fazendo algo que não está.
Você pode ter sido informado de que a class
foi introduzida no JavaScript para tornar os desenvolvedores de OOP clássicos vindos de linguagens como Java mais confortáveis com o modelo de herança de classe ES6. Se você é um desses desenvolvedores, esse exemplo provavelmente o deixou horrorizado. Deveria. Isso mostra que a palavra-chave class
do JavaScript não vem com nenhuma das garantias que uma classe deve fornecer. Ele também demonstra uma das principais diferenças no modelo de herança de protótipo: Protótipos são instâncias de objeto , não tipos .
Protótipos vs. Classes
A diferença mais importante entre herança baseada em classe e baseada em protótipo é que uma classe define um tipo que pode ser instanciado em tempo de execução, enquanto um protótipo é em si uma instância de objeto.
Um filho de uma classe ES6 é outra definição de tipo que estende o pai com novas propriedades e métodos, que por sua vez podem ser instanciados em tempo de execução. Um filho de um protótipo é outra instância de objeto que delega ao pai quaisquer propriedades que não sejam implementadas no filho.
Nota lateral: Você pode estar se perguntando por que mencionei métodos de classe, mas não métodos de protótipo. Isso porque JavaScript não tem um conceito de métodos. As funções são de primeira classe em JavaScript e podem ter propriedades ou ser propriedades de outros objetos.
Um construtor de classe cria uma instância da classe. Um construtor em JavaScript é apenas uma função simples e antiga que retorna um objeto. A única coisa especial sobre um construtor JavaScript é que, quando invocado com a palavra-chave new
, ele atribui seu protótipo como o protótipo do objeto retornado. Se isso soa um pouco confuso para você, você não está sozinho - está, e é uma grande parte do motivo pelo qual os protótipos são mal compreendidos.
Para colocar um ponto muito bom nisso, um filho de um protótipo não é uma cópia de seu protótipo, nem é um objeto com a mesma forma que seu protótipo. O filho tem uma referência viva ao protótipo e qualquer propriedade de protótipo que não existe no filho é uma referência unidirecional a uma propriedade de mesmo nome no protótipo.
Considere o seguinte:
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'
No exemplo anterior, enquanto child.foo
era undefined
, ele fazia referência a parent.foo
. Assim que definimos foo
em child
, child.foo
tinha o valor 'bar'
, mas parent.foo
manteve seu valor original. Uma vez que delete child.foo
, ele se refere novamente a parent.foo
, o que significa que quando alteramos o valor do pai, child.foo
se refere ao novo valor.
Vejamos o que acabou de acontecer (para fins de ilustração mais clara, vamos fingir que são Strings
e não literais de string, a diferença não importa aqui):
A maneira como isso funciona nos bastidores, e especialmente as peculiaridades de new
e this
, são um tópico para outro dia, mas a Mozilla tem um artigo completo sobre a cadeia de herança de protótipos do JavaScript se você quiser ler mais.
A principal conclusão é que os protótipos não definem um type
; eles próprios são instances
e são mutáveis em tempo de execução, com tudo o que isso implica e implica.
Ainda comigo? Vamos voltar a dissecar classes JavaScript.
JavaScript Pop Quiz #3: Como você implementa a privacidade nas aulas?
Nosso protótipo e propriedades de classe acima não são tanto “encapsulados” quanto “pendurados precariamente pela janela”. Devemos corrigir isso, mas como?
Nenhum exemplo de código aqui. A resposta é que você não pode.
JavaScript não tem nenhum conceito de privacidade, mas tem encerramentos:
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!"
Você entende o que acabou de acontecer? Se não, você não entende encerramentos. Tudo bem, na verdade - eles não são tão intimidantes quanto parecem, são super úteis e você deve dedicar algum tempo para aprender sobre eles.
JavaScript Pop Quiz # 4: Qual é o equivalente ao acima usando a palavra-chave class
?
Desculpe, esta é outra pergunta capciosa. Você pode fazer basicamente a mesma coisa, mas fica assim:
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!"
Deixe-me saber se isso parece mais fácil ou mais claro do que em SecretiveProto
. Na minha opinião pessoal, é um pouco pior - quebra o uso idiomático de declarações de class
em JavaScript e não funciona muito como você esperaria, digamos, Java. Isso ficará claro com o seguinte:

JavaScript Pop Quiz #5: O que SecretiveClass::looseLips()
faz?
Vamos descobrir:
try { liar.looseLips() } catch(e) { // ReferenceError: secret is not defined }
Bem... isso foi estranho.
JavaScript Pop Quiz #6: O que os desenvolvedores experientes de JavaScript preferem — protótipos ou classes?
Você adivinhou, essa é outra pergunta capciosa - desenvolvedores experientes de JavaScript tendem a evitar ambos quando podem. Aqui está uma boa maneira de fazer o acima com JavaScript idiomático:
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()
Não se trata apenas de evitar a feiúra inerente da herança ou impor o encapsulamento. Pense no que mais você poderia fazer com secretFactory
e o leaker
que você não poderia fazer facilmente com um protótipo ou uma classe.
Por um lado, você pode desestruturar porque não precisa se preocupar com o contexto this
:
const { spillTheBeans } = secretFactory() spillTheBeans() // Favor composition over inheritance, (...)
Isso é muito bom. Além de evitar new
e tolices, this
nos permite usar nossos objetos de forma intercambiável com os módulos CommonJS e ES6. Também torna a composição um pouco mais fácil:
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)
Os clientes do blackHat
não precisam se preocupar com a origem do exfiltrate
, e o spyFactory
não precisa mexer com Function::bind
contexto malabarismo ou propriedades profundamente aninhadas. Lembre-se, não precisamos nos preocupar muito com this
em código procedural síncrono simples, mas isso causa todos os tipos de problemas em código assíncrono que é melhor evitar.
Com um pouco de reflexão, spyFactory
poderia ser desenvolvido em uma ferramenta de espionagem altamente sofisticada que poderia lidar com todos os tipos de alvos de infiltração – ou em outras palavras, uma fachada.
Claro que você poderia fazer isso com uma classe também, ou melhor, uma variedade de classes, todas herdadas de uma abstract class
ou interface
... exceto que JavaScript não tem nenhum conceito de abstratos ou interfaces.
Vamos voltar ao exemplo de saudação para ver como o implementaríamos com uma fábrica:
function greeterFactory(greeting = "Hello", name = "World") { return { greet: () => `${greeting}, ${name}!` } } console.log(greeterFactory("Hey", "folks").greet()) // Hey, folks!
Você deve ter notado que essas fábricas estão ficando mais concisas à medida que avançamos, mas não se preocupe - elas fazem a mesma coisa. As rodinhas estão saindo, pessoal!
Isso já é menos clichê do que o protótipo ou a versão de classe do mesmo código. Em segundo lugar, atinge o encapsulamento de suas propriedades de forma mais eficaz. Além disso, ele tem menos memória e desempenho em alguns casos (pode não parecer à primeira vista, mas o compilador JIT está trabalhando silenciosamente nos bastidores para reduzir a duplicação e inferir tipos).
Portanto, é mais seguro, geralmente é mais rápido e é mais fácil escrever código como este. Por que precisamos de aulas novamente? Ah, claro, reutilização. O que acontece se quisermos variantes de saudação infelizes e entusiasmadas? Bem, se estivermos usando a classe ClassicalGreeting
, provavelmente pularemos diretamente para sonhar com uma hierarquia de classes. Sabemos que precisaremos parametrizar a pontuação, então faremos uma pequena refatoração e adicionaremos alguns filhos:
// 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!!
É uma boa abordagem, até que alguém apareça e peça um recurso que não se encaixe perfeitamente na hierarquia e a coisa toda pare de fazer sentido. Coloque um alfinete nesse pensamento enquanto tentamos escrever a mesma funcionalidade com as fábricas:
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!!
Não é óbvio que este código seja melhor, embora seja um pouco mais curto. Na verdade, você pode argumentar que é mais difícil de ler, e talvez essa seja uma abordagem obtusa. Não poderíamos ter apenas uma unhappyGreeterFactory
e uma enthusiasticGreeterFactory
?
Então seu cliente chega e diz: “Preciso de um novo recepcionista que está insatisfeito e quer que toda a sala saiba disso!”
console.log(enthusiastic(unhappy(greeterFactory))().greet()) // HELLO, WORLD :(
Se precisássemos usar essa saudação entusiasticamente infeliz mais de uma vez, poderíamos facilitar para nós mesmos:
const aggressiveGreeterFactory = enthusiastic(unhappy(greeterFactory)) console.log(aggressiveGreeterFactory("You're late", "Jim").greet())
Existem abordagens para esse estilo de composição que funcionam com protótipos ou classes. Por exemplo, você pode repensar UnhappyGreeting
e EnthusiasticGreeting
como decoradores. Ainda seria necessário mais clichê do que a abordagem de estilo funcional usada acima, mas esse é o preço que você paga pela segurança e encapsulamento de classes reais .
O problema é que, em JavaScript, você não está obtendo essa segurança automática. Estruturas JavaScript que enfatizam o uso class
fazem muita “mágica” para ocultar esses tipos de problemas e forçar as classes a se comportarem. Dê uma olhada no código-fonte ElementMixin
do Polymer algum dia, eu te desafio. São níveis de arquimago do arcano JavaScript, e quero dizer isso sem ironia ou sarcasmo.
Claro, podemos corrigir alguns dos problemas discutidos acima com Object.freeze
ou Object.defineProperties
para maior ou menor efeito. Mas por que imitar a forma sem a função, ignorando as ferramentas que o JavaScript nos fornece nativamente que talvez não encontremos em linguagens como Java? Você usaria um martelo rotulado como “chave de fenda” para apertar um parafuso, quando sua caixa de ferramentas tivesse uma chave de fenda real ao lado dela?
Encontrando as partes boas
Os desenvolvedores de JavaScript geralmente enfatizam as partes boas da linguagem, tanto coloquialmente quanto em referência ao livro de mesmo nome. Tentamos evitar as armadilhas criadas por suas escolhas de design de linguagem mais questionáveis e nos ater às partes que nos permitem escrever código limpo, legível, minimizador de erros e reutilizável.
Existem argumentos razoáveis sobre quais partes do JavaScript se qualificam, mas espero tê-lo convencido de que class
não é uma delas. Falhando nisso, espero que você entenda que a herança em JavaScript pode ser uma bagunça confusa e essa class
não a corrige nem poupa você de entender protótipos. Crédito extra se você pegou as dicas de que padrões de design orientados a objetos funcionam bem sem classes ou herança ES6.
Eu não estou dizendo para você evitar totalmente class
. Às vezes você precisa de herança, e class
fornece uma sintaxe mais limpa para fazer isso. Em particular, class X extends Y
é muito melhor do que a antiga abordagem de protótipo. Além disso, muitos frameworks front-end populares incentivam seu uso e você provavelmente deve evitar escrever códigos estranhos fora do padrão apenas por princípio. Eu só não gosto de onde isso está indo.
Nos meus pesadelos, toda uma geração de bibliotecas JavaScript é escrita usando class
, com a expectativa de que ela se comporte de maneira semelhante a outras linguagens populares. Novas classes de bugs (trocadilhos) são descobertas. Os antigos são ressuscitados que poderiam facilmente ter sido deixados no Cemitério do JavaScript Malformado se não tivéssemos caído descuidadamente na armadilha da class
. Desenvolvedores experientes de JavaScript são atormentados por esses monstros, porque o que é popular nem sempre é o que é bom.
Eventualmente, todos nós desistimos de frustração e começamos a reinventar rodas em Rust, Go, Haskell ou quem sabe o que mais, e então compilamos para Wasm para a web, e novas estruturas e bibliotecas da web proliferam em uma infinidade de multilíngues.
Realmente me mantém acordado à noite.