Cómo el marketing cambió la programación orientada a objetos en JavaScript

Al analizar las decisiones que rodean los prototipos de JavaScript, el artículo de Juan Diego Rodríguez analiza su origen, examina errores en el diseño y explora cómo estos factores han afectado la forma en que escribimos JavaScript hoy.
Aunque el nombre de JavaScript se acuñó a partir del lenguaje Java, los dos lenguajes son mundos aparte. JavaScript tiene más en común con Lisp y Scheme , ya que comparte características como funciones de primera clase y alcance léxico.
JavaScript también toma prestada su herencia prototípica del lenguaje Self . Este mecanismo de herencia es quizás lo que muchos (si no la mayoría) de los desarrolladores no dedican suficiente tiempo a comprender, principalmente porque no es un requisito para comenzar a trabajar con JavaScript. Esa característica puede verse como un defecto de diseño o un golpe de genialidad. Dicho esto, la naturaleza prototípica de JavaScript se comercializó y se ocultó detrás de una máscara de "Java para la web". Ampliaremos más sobre esto a medida que avancemos.
JavaScript no confía en su propia naturaleza prototípica, por lo que brinda a los desarrolladores las herramientas para abordar el lenguaje sin tener que tocar un prototipo. Este fue un intento de que todos los desarrolladores lo entendieran fácilmente, especialmente aquellos que provienen de lenguajes basados en clases, como Java, y luego se convertiría en uno de los mayores enemigos de JavaScript en los años venideros: no es necesario comprender cómo funciona JavaScript para código en JavaScript.
¿Qué es la programación clásica orientada a objetos?
La programación orientada a objetos (POO) clásica gira en torno al concepto de clases e instancias y se usa ampliamente en lenguajes como Java, C++, C# y muchos otros. Una clase es un modelo o plantilla para crear objetos. Define la estructura y el comportamiento de los objetos que pertenecen a esa clase y encapsula propiedades y métodos. Por otro lado, los objetos son instancias de clases. Cuando creas un objeto a partir de una clase, básicamente estás creando una instancia específica que hereda la estructura y el comportamiento definidos en la clase y al mismo tiempo le da a cada objeto un estado individual.
La programación orientada a objetos tiene muchos conceptos fundamentales, pero nos centraremos en la herencia , un mecanismo que permite que una clase adopte las propiedades y métodos de otra clase. Esto facilita la reutilización del código y la creación de una jerarquía de clases.
¿Qué es la programación orientada a objetos prototípica en JavaScript?
Explicaré los conceptos detrás del prototipo de programación orientada a objetos en Javascript, pero para una explicación detallada de cómo funcionan los prototipos, MDN tiene una excelente descripción general sobre el tema .
La programación orientada a objetos prototípica se diferencia de la programación orientada a objetos clásica, que se basa en clases e instancias. En la programación orientada a objetos prototípica no hay clases, sólo objetos, y se crean directamente a partir de otros objetos.
Si creamos un objeto, tendrá una propiedad incorporada llamada prototype
que contiene una referencia a su prototipo de objeto "principal" para que podamos acceder a los métodos y propiedades de su prototipo. Esto es lo que nos permite acceder a métodos como .sort()
o .forEach()
desde cualquier matriz, ya que cada matriz hereda métodos del Array.prototype
objeto.
El prototipo en sí es un objeto, por lo que el prototipo tendrá su propio prototipo. Esto crea una cadena de objetos conocida como cadena de prototipos . Cuando accede a una propiedad o método en un objeto, JavaScript primero lo buscará en el objeto mismo. Si no se encuentra, recorrerá la cadena del prototipo hasta encontrar la propiedad o alcanzar el objeto de nivel superior. A menudo terminará en Object.prototype
, que tiene un null
prototipo que indica el final de la cadena.
Una diferencia crucial entre la programación orientada a objetos clásica y la prototípica es que no podemos manipular dinámicamente una definición de clase una vez que se crea un objeto. Pero con los prototipos de JavaScript, podemos agregar, eliminar o cambiar métodos y propiedades del prototipo, lo que afecta a los objetos de la cadena.
“Los objetos heredan de los objetos. ¿Qué podría estar más orientado a objetos que eso?
—Douglas Crockford
¿Cuál es la diferencia en JavaScript? Spoiler: Ninguno
Entonces, sobre el papel, la diferencia es simple. En la programación orientada a objetos clásica, creamos instancias de objetos de una clase, y una clase puede heredar métodos y propiedades de otra clase. En la POO prototipo, los objetos pueden heredar propiedades y métodos de otros objetos a través de su prototipo.
Sin embargo, en JavaScript no existe una sola diferencia más allá de la sintaxis. ¿Puedes detectar la diferencia entre los siguientes dos extractos de código?
// With Classesclass Dog { constructor(name, color) { this.name = name; this.color = color; } bark() { return `I am a ${this.color} dog and my name is ${this.name}.`; }}const myDog = new Dog("Charlie", "brown");console.log(myDog.name); // Charlieconsole.log(myDog.bark()); // I am a brown dog and my name is Charlie.
// With Prototypesfunction Dog(name, color) { this.name = name; this.color = color;}Dog.prototype.bark = function () { return `I am a ${this.color} dog and my name is ${this.name}.`;};const myDog = new Dog("Charlie", "brown");console.log(myDog.name); // Charlieconsole.log(myDog.bark()); // I am a brown dog and my name is Charlie.
No hay diferencia y JavaScript ejecutará el mismo código, pero el último ejemplo es honesto acerca de lo que hace JavaScript bajo el capó, mientras que el primero lo esconde detrás del azúcar sintáctico.
¿Tengo algún problema con el enfoque clásico? Si y no. Se puede argumentar que la sintaxis clásica mejora la legibilidad al tener todo el código relacionado con la clase dentro de un alcance de bloque. Por otro lado, es engañoso y ha llevado a miles de desarrolladores a creer que JavaScript tiene clases verdaderas cuando una clase en JavaScript no es diferente de cualquier otro objeto de función .
Mi mayor problema no es pretender que existan clases verdaderas, sino que no existen prototipos.
“
Considere el siguiente código:
class Dog { constructor(name, color) { this.name = name; this.color = color; } bark() { return `I am a ${this.color} dog and my name is ${this.name}.`; }}const myDog = new Dog("Charlie", "brown");Dog.prototype.bark = function () { return "I am really just another object with a prototype!";};console.log(myDog.bark()); // I am really just another object with a prototype!"
Espera, ¿acabamos de acceder al prototipo de la clase? ¡Sí, porque las clases no existen! Son simplemente funciones que devuelven un objeto (llamadas funciones constructoras) e, inevitablemente, tienen un prototipo, lo que significa que podemos acceder a su .prototype
propiedad.
Casi parece que JavaScript intenta ocultar sus prototipos. ¿Pero por qué?
Hay pistas en la historia de JavaScript
En mayo de 1995, Netscape involucró al creador de JavaScript, Brendan Eich, en un proyecto para implementar un lenguaje de secuencias de comandos en el navegador Netscape. La idea principal era implementar el lenguaje Scheme en el navegador debido a su enfoque mínimo. El plan cambió cuando Netscape cerró un trato con Sun Microsystems, creadores de Java, para implementar Java en la web. Muy pronto, Brendan Eich y el fundador de Sun Microsystems, Bill Joy, vieron la necesidad de un nuevo lenguaje. Un lenguaje accesible para personas cuyo enfoque principal no era sólo la programación. Un lenguaje tanto para un diseñador que intenta crear un sitio web como para un desarrollador experimentado que viene de Java.
Con este objetivo en mente, se creó JavaScript en 10 días de intenso trabajo bajo el nombre inicial de Mocha . Se cambiaría a LiveScript para comercializarlo como un script que se ejecuta "en vivo" en el navegador, pero en diciembre de 1995 finalmente se llamaría JavaScript y se comercializaría junto con Java. Este acuerdo con Sun Microsystems obligó a Brendan a adaptar su lenguaje basado en prototipos a Java. Según Brendan Eich , JavaScript fue tratado como el “lenguaje compañero de Java” y carecía de fondos suficientes en comparación con el equipo de Java:
“Estuve pensando todo el tiempo, ¿cómo debería ser el idioma? ¿Debería ser fácil de usar? ¿Podría la sintaxis parecerse más al lenguaje natural? [...] Bueno, me gustaría hacer eso, pero mi gerencia dijo: "Haz que parezca Java".
La idea de Eich para JavaScript era implementar funciones de primera clase de Scheme (una característica que permitiría devoluciones de llamadas para eventos de usuario) y programación orientada a objetos basada en prototipos de Self. Ya lo ha expresado antes en su blog:
"No estoy orgulloso, pero estoy feliz de haber elegido funciones de primera clase tipo Scheme y prototipos egoístas como ingredientes principales".
La naturaleza prototípica de JavaScript se mantuvo, pero quedaría específicamente oscurecida detrás de una fachada de Java. Los prototipos probablemente permanecieron en su lugar porque Eich implementó prototipos propios desde el principio y luego no pudieron cambiarse, solo ocultarse. Podemos encontrar una explicación mixta en un antiguo comentario en su blog :
“Es irónico que JS no pudiera tener clase en 1995 porque habría rivalizado con Java. Estaba limitado tanto por el tiempo como por el papel de compañero”.
De cualquier manera, JavaScript se convirtió en un lenguaje basado en prototipos y, con diferencia, el más popular.
Si tan solo JavaScript adoptara sus prototipos
En el apuro entre la creación de JavaScript y su adopción masiva, hubo otras decisiones de diseño cuestionables en torno a los prototipos. En su libro, JavaScript: The Good Parts , Crockford explica las partes malas que rodean a JavaScript, como las variables globales y los malentendidos en torno a los prototipos.
Como habrás notado, este artículo está inspirado en el libro de Crockford. Aunque no estoy de acuerdo con muchas de sus opiniones sobre las partes malas de JavaScript, es importante señalar que el libro se publicó en 2008, cuando ECMAScript 4 (ES4) era la versión estable de JavaScript. Han pasado muchos años desde su publicación y JavaScript ha cambiado significativamente durante ese tiempo. Las siguientes son características que creo que podrían haberse salvado del lenguaje si tan solo JavaScript hubiera adoptado sus prototipos.
El thisvalor en diferentes contextos
La this
palabra clave es otra de las cosas que JavaScript agregó para parecerse a Java. En Java, y en la programación orientada a objetos clásica en general, this
se refiere a la instancia actual en la que se invoca el método o constructor, simplemente eso. Sin embargo, en JavaScript, no teníamos sintaxis de clases hasta ES6, pero aun así heredamos la this
palabra clave. ¡Mi problema this
es que pueden ser cuatro cosas diferentes dependiendo de dónde se invoque!
1. this
En el patrón de invocación de funciones
Cuando this
se invoca dentro de una llamada de función, estará vinculado al objeto global. También estará vinculado al objeto global si se invoca desde el ámbito global.
console.log(this); // windowfunction myFunction() { console.log(this);}myFunction(); // window
En modo estricto y mediante el patrón de invocación de funciones, this
será undefined
.
function getThis() { "use strict"; return this;}getThis(); // undefined
2. this
En el patrón de invocación de métodos
Si hacemos referencia a una función como propiedad de un objeto, this
estará vinculada a su objeto principal.
const dog = { name: "Sparky", bark: function () { console.log(`Woof, my name is ${this.name}.`); },};dog.bark(); // Woof, my name is Sparky.
Las funciones de flecha no tienen su propio alcance this
, sino que heredan this
de su alcance principal en el momento de la creación.
const dog = { name: "Sparky", bark: () = { console.log(`Woof, my name is ${this.name}.`); },};dog.bark(); // Woof, my name is undefined.
En este caso, this
estaba vinculado al objeto global en lugar de dog
, por lo tanto this.name
es undefined
.
3. El patrón de invocación del constructor
Si invocamos una función con el new
prefijo, se creará un nuevo objeto vacío y this
estará vinculado a ese objeto.
function Dog(name) { this.name = name; this.bark = function () { console.log(`Woof, my name is ${this.name}.`); };}const myDog = new Dog("Coco");myDog.bark(); // Woof, my name is Coco.
También podríamos emplear this
el prototipo de la función para acceder a las propiedades del objeto, lo que podría darnos una razón más válida para usarlo.
function Dog(name) { this.name = name;}Dog.prototype.bark = function () { console.log(`Woof, my name is ${this.name}.`);};const myDog = new Dog("Coco");myDog.bark(); // Woof, my name is Coco.
4. El apply
patrón de invocación
Por último, cada función hereda un apply
método del prototipo de función que toma dos parámetros. El primer parámetro es el valor que se vinculará this
dentro de la función, y el segundo es una matriz que se utilizará como parámetros de la función.
// Bounding `this` to another objectfunction bark() { console.log(`Woof, my name is ${this.name}.`);}const myDog = { name: "Milo",};bark.apply(myDog); // Woof, my name is Milo.// Using the array parameterconst numbers = [3, 10, 4, 6, 9];const max = Math.max.apply(null, numbers);console.log(max); // 10
Como puede ver, this
puede ser casi cualquier cosa y, en primer lugar, no debería estar en JavaScript. Enfoques como el uso bind()
son soluciones a un problema que ni siquiera debería existir. Afortunadamente, this
es completamente evitable en JavaScript moderno y puedes ahorrarte varios dolores de cabeza si aprendes a esquivarlo; una ventaja que los usuarios de la clase ES6 no pueden disfrutar.
Crockford tiene una bonita anécdota sobre el tema en su libro:
“Este es un pronombre demostrativo. El solo hecho de tener
this
el idioma hace que sea más difícil hablar del mismo. Es como programar en pareja con Abbott y Costello”.
"Pero si queremos crear un constructor de funciones, necesitaremos usar this
". ¡No necesariamente! En el siguiente ejemplo, podemos crear un constructor de funciones que no se utiliza this
ni new
funciona.
function counterConstructor() { let counter = 0; function getCounter() { return counter; } function up() { counter += 1; return counter; } function down() { counter -= 1; return counter; } return { getCounter, up, down, };}const myCounter = counterConstructor();myCounter.up(); // 1myCounter.down(); // 0
Acabamos de crear un constructor de funciones sin usar this
o new
! Y viene con una sintaxis sencilla. Una desventaja que podrías ver es que los objetos creados a partir de counterConstructor
no tendrán acceso a su prototipo, por lo que no podemos agregar métodos o propiedades desde counterConstructor.prototype
.
¿Pero necesitamos esto? Por supuesto, necesitaremos reutilizar nuestro código, pero existen mejores enfoques que veremos más adelante.
El newprefijo
En JavaScript: The Good Parts , Crockford sostiene que no deberíamos usar el new
prefijo simplemente porque no hay garantía de que recordaremos usarlo en las funciones previstas. Creo que es un error fácil de detectar y también se puede evitar capitalizando las funciones del constructor que desea utilizar new
. Y hoy en día, los linters nos avisarán cuando llamemos a una función en mayúscula sin new
, o viceversa.
Un mejor argumento es simplemente que el uso new
nos obliga a utilizar this
dentro de nuestras funciones o “clases” constructoras y, como vimos antes, es mejor evitarlas this
en primer lugar.
Las múltiples formas de acceder a los prototipos
Por las razones históricas que ya revisamos, podemos entender por qué JavaScript no adopta sus prototipos. Por extensión, no tenemos herramientas para mezclarnos con prototipos de forma tan sencilla como nos gustaría, sino más bien intentos tortuosos de manipular la cadena de prototipos. Las cosas empeoran cuando en la documentación podemos leer diferentes jergas en torno a los prototipos.
La diferencia entre [[Prototype]]
, __proto__
y.prototype
Para que la experiencia de lectura sea más placentera, repasemos las diferencias entre estos términos.
[[Prototype]]
es una propiedad interna que contiene una referencia al prototipo del objeto. Está entre corchetes dobles, lo que significa que normalmente no se puede acceder a él utilizando la notación normal.__proto__
puede referirse a dos posibles propiedades:- Puede hacer referencia a una propiedad de cualquier
Object.prototype
objeto que exponga la[[Prototype]]
propiedad oculta. Está en desuso y tiene un mal rendimiento. - Puede hacer referencia a una propiedad opcional que podemos agregar al crear un objeto literal. El prototipo del objeto apuntará al valor que le damos.
- Puede hacer referencia a una propiedad de cualquier
.prototype
es una propiedad exclusiva de funciones o clases (excluyendo funciones de flecha). Cuando se invoca usando elnew
prefijo, el prototipo del objeto instanciado apuntará al.prototype
.
Ahora podemos ver todas las formas en que podemos modificar prototipos en JavaScript. Después de revisarlos, notaremos que todos se quedan cortos al menos en algún aspecto.
Usando la __proto__propiedad literal en la inicialización
Al crear un objeto JavaScript usando literales de objeto, podemos agregar una __proto__
propiedad. El objeto creado apuntará [[Prototoype]]
al valor dado en __proto__
. En un ejemplo anterior, los objetos creados a partir de nuestra función constructora no tenían acceso al prototipo del constructor. Podemos usar la __proto__
propiedad en la inicialización para cambiar esto sin usar this
o new
.
function counterConstructor() { let counter = 0; function getCounter() { return counter; } function up() { counter += 1; return counter; } function down() { counter -= 1; return counter; } return { getCounter, up, down, __proto__: counterConstructor.prototype, };}
La ventaja de vincular el prototipo del nuevo objeto al constructor de la función sería que podemos extender sus métodos desde el prototipo del constructor. Pero ¿de qué nos serviría si necesitáramos this
volver a consumir?
const myCounter = counterConstructor();counterConstructor.prototype.printDouble = function () { return this.getCounter() * 2;};myCounter.up(); // 1myCounter.up(); // 2myCounter.printDouble(); // 4
Ni siquiera modificamos el count
valor interno sino que lo imprimimos doble. Por lo tanto, sería necesario un método de establecimiento para manipular su estado desde fuera de la declaración inicial del constructor de la función. Sin embargo, estamos complicando demasiado nuestro código ya que simplemente podríamos haber agregado un double
método dentro de nuestra función.
function counterConstructor() { let counter = 0; function getCounter() { return counter; } function up() { counter += 1; return counter; } function down() { counter -= 1; return counter; } function double() { counter = counter * 2; return counter; } return { getCounter, up, down, double, };}const myCounter = counterConstructor();myCounter.up(); // 1myCounter.up(); // 2myCounter.double(); // 4
Usarlo __proto__
es excesivo en la práctica.
Es vital tener en cuenta que __proto__
solo debe usarse al inicializar un nuevo objeto a través de un objeto literal. El uso del __proto__
descriptor de acceso Object.prototype.__proto__
cambiará el objeto [[Prototoype]]
después de la inicialización, interrumpiendo muchas optimizaciones realizadas bajo el capó por los motores JavaScript. Es por eso Object.prototype.__proto__
que tiene un mal rendimiento y está en desuso .
Object.create()
Object.create()
devuelve un nuevo objeto cuyo [[Prototype]]
será el primer argumento de la función. También tiene un segundo argumento que le permite definir propiedades adicionales para los nuevos objetos. Sin embargo, es más flexible y legible crear un objeto utilizando un objeto literal. Por lo tanto, su único uso práctico sería crear un objeto sin un prototipo, Object.create(null)
ya que todos los objetos creados usando literales de objeto están automáticamente vinculados a Object.prototype
.
Object.setPrototypeOf()
Object.setPrototypeOf()
toma dos objetos como argumentos y mutará la cadena prototipo del primer argumento al segundo. Como vimos anteriormente, cambiar el prototipo de un objeto después de la inicialización no funciona bien, así que evítelo a toda costa.
Encapsulación y clases privadas
Mi último argumento contra las clases es la falta de privacidad y encapsulación. Tomemos, por ejemplo, la siguiente sintaxis de clase:
class Cat { constructor(name) { this.name = name; } meow() { console.log(`Meow! My name is ${this.name}.`); }}const myCat = new Cat("Gala");myCat.meow(); // Meow! My name is Gala.myCat.name = "Pumpkin";myCat.meow(); // Meow! My name is Pumpkin.
¡No tenemos privacidad! Todas las propiedades son públicas. Podemos intentar mitigar esto con cierres:
class Cat { constructor(name) { this.getName = function () { return name; }; } meow() { console.log(`Meow! My name is ${this.name}.`); }}const myCat = new Cat("Gala");myCat.meow(); // Meow! My name is undefined.
Vaya, ahora this.name
está undefined
fuera del alcance del constructor. Tenemos que cambiar this.name
a this.getName()
para que pueda funcionar correctamente.
class Cat { constructor(name) { this.getName = function () { return name; }; } meow() { console.log(`Meow! My name is ${this.getName()}.`); }}const myCat = new Cat("Gala");myCat.meow(); // Meow! My name is Gala.
Esto es con un solo argumento, por lo que puedes imaginar cuán innecesariamente repetitivo sería nuestro código cuantos más argumentos agreguemos. Además, aún podemos modificar nuestros métodos de objeto:
myCat.meow = function () { console.log(`Meow! ${this.getName()} is a bad kitten.`);};myCat.meow(); // Meow! Gala is a bad kitten.
¡Podemos ahorrar e implementar una mejor privacidad si usamos nuestros propios constructores de funciones e incluso hacemos que nuestros métodos sean inmutables usando Object.freeze()
!
function catConstructor(name) { function getName() { return name; } function meow() { console.log(`Meow! My name is ${name}.`); } return Object.freeze({ getName, meow, });}const myCat = catConstructor("Loaf");myCat.meow(); // Meow! My name is Loaf.
Y intentar modificar los métodos del objeto fallará silenciosamente.
myCat.meow = function () { console.log(`Meow! ${this.getName()} is a bad Kitten.`);};myCat.meow(); // Meow! My name is Loaf.
Y sí, estoy al tanto de la reciente propuesta de campos de clases privadas . Pero, ¿realmente necesitamos aún más sintaxis nueva cuando podríamos lograr lo mismo usando funciones de constructor personalizadas y cierres?
Entonces, ¿clases o prototipos en JavaScript?
En el libro más reciente de Crockford, Cómo funciona JavaScript ( PDF ), podemos ver una mejor opción que usar prototipos o clases para la reutilización de código: ¡Composición !
“
Como dice Crockford en su libro más reciente:
“[E]n lugar de lo mismo excepto que podemos obtener un poco de esto y un poco de aquello ”.
— Douglas Crockford, Cómo funciona JavaScript
En lugar de que un constructor de funciones o una clase herede de otro, podemos tener un conjunto de constructores y combinarlos cuando sea necesario para crear un objeto especializado.
function speakerConstructor(name, message) { function talk() { return `Hi, mi name is ${name} and I want to tell something: ${message}.`; } return Object.freeze({ talk, });}function loudSpeakerConstructor(name, message) { const {talk} = speakerConstructor(name, message); function yell() { return talk().toUpperCase(); } return Object.freeze({ talk, yell, });}const mySpeaker = loudSpeakerConstructor("Juan", "You look nice!");mySpeaker.talk(); // Hi, my name is Juan and I want to tell something: You look nice!mySpeaker.yell(); // HI, MY NAME IS JUAN AND I WANT TO TELL SOMETHING: YOU LOOK NICE!
Sin la necesidad de this
clases new
y prototipos, logramos un constructor de funciones reutilizable con total privacidad y encapsulación.
Conclusión
Sí, JavaScript se creó en 10 días rápidamente; sí, estaba contaminado por el marketing; y sí, tiene un largo conjunto de piezas inútiles y peligrosas. Sin embargo, es un lenguaje hermoso e impulsa gran parte de la innovación que ocurre hoy en día en el desarrollo web, por lo que claramente ha hecho algo bueno.
“
Desafortunadamente, esta decisión consciente de ceñirse a las partes buenas no es exclusiva de la programación orientada a objetos de JavaScript ya que, entre las prisas por
Deja un comentario