Cómo funcionan realmente las clases de ES6 y cómo construir el suyo propio

La sexta edición de ECMAScript (o ES6 para abreviar) revolucionó el lenguaje, agregando muchas características nuevas, incluidas las clases y la herencia basada en clases. La nueva sintaxis es fácil de usar sin comprender los detalles y, en su mayoría, hace lo que esperarías, pero si eres como yo, eso no es muy satisfactorio. ¿Cómo funciona la sintaxis aparentemente mágica realmente bajo el capó? ¿Cómo interactúa con otras características en el idioma? ¿Es posible emular clases sin usar la sintaxis de clase? Aquí, responderé estas preguntas con detalles gratuitos.

Pero primero, para comprender las clases, debe comprender lo que vino antes de ellas y el modelo de objetos subyacente de Javascript.

Modelo de objeto

El modelo de objetos Javascript es bastante simple. Cada objeto es solo un mapeo de cadenas y símbolos a descriptores de propiedades. Cada descriptor de propiedad a su vez contiene un par de captador / definidor para propiedades calculadas o un valor de datos para propiedades de datos ordinarias.

Cuando ejecuta el código foo [bar], convierte la barra en una cadena si aún no es una cadena o símbolo, luego busca esa clave entre las propiedades de foo y devuelve el valor de la propiedad correspondiente (o llama a su función getter como aplicable). Para las claves de cadena literales que son identificadores válidos, existe la sintaxis abreviada foo.bar que es equivalente a foo ["bar"]. Hasta ahora, muy simple.

Herencia prototípica

Javascript tiene lo que se llama herencia prototípica, lo que suena aterrador, pero en realidad es más simple que la herencia tradicional basada en clases una vez que la dominas. Cada objeto puede tener un puntero implícito a otro objeto, denominado prototipo. Cuando intenta acceder a una propiedad en un objeto donde no existe ninguna propiedad con esa clave, en su lugar, busca la clave en el objeto prototipo y devuelve la propiedad del prototipo para esa clave si existe. Si no existe en el prototipo, verifica recursivamente el prototipo del prototipo y así sucesivamente, hasta la cadena hasta que se encuentra una propiedad o se alcanza un objeto sin un prototipo.

Si ha usado Python anteriormente, el proceso de búsqueda de atributos es similar. En Python, cada atributo se busca primero en el diccionario de instancias. Si no está presente allí, el tiempo de ejecución comprueba el diccionario de la clase, luego el diccionario de la superclase, y así sucesivamente, hasta la jerarquía de herencia. En Javascript, el proceso es similar, excepto que no hay distinción entre objetos de tipo y objetos de instancia: cualquier objeto puede ser el prototipo de cualquier otro objeto. Por supuesto, en el mundo real, las personas rara vez usan este hecho y en su lugar organizan su código en jerarquías de clase, porque es más fácil de administrar de esa manera, por lo que Javascript agregó la sintaxis de clase en primer lugar.

Ranuras internas

Si todo un objeto consiste en una asignación de claves a propiedades, ¿dónde se almacena el prototipo? La respuesta es que, además de las propiedades, los objetos también tienen métodos internos y ranuras internas que se utilizan para implementar una semántica de nivel de lenguaje especial. Las ranuras internas no se pueden acceder directamente desde el código Javascript de ninguna manera, pero en algunos casos, hay formas de acceder indirectamente a ellas. Por ejemplo, los prototipos de objetos están representados por la ranura [[Prototype]] que se puede leer y escribir usando Object.getPrototypeOf () y Object.setPrototypeOf () respectivamente. Por convención, los espacios y métodos internos están escritos en [[corchetes dobles]] para distinguirlos de las propiedades ordinarias.

Clases de estilo antiguo

En las primeras versiones de Javascript, era común simular clases usando código como el siguiente.

¿De dónde viene esto? ¿De dónde vino el prototipo? ¿Qué hace nuevo? Como resultado, incluso las primeras versiones de Javascript no querían ser demasiado poco convencionales, por lo que incluían alguna sintaxis que le permitía codificar cosas que eran como clases.

En términos técnicos, las funciones en Javascript están definidas por los dos métodos internos [[Call]] y [[Construct]]. Cualquier objeto con un método [[Call]] se llama una función, y cualquier función que tenga adicionalmente un método [[Construct]] se llama constructor¹. El método [[Call]] determina qué sucede cuando invocas un objeto como una función, p. foo (args), mientras que [[Construct]] determina qué sucede cuando lo invocas como una nueva expresión, es decir, new foo o new foo (args).

Para las definiciones de funciones ordinarias², llamar a [[Construct]] creará implícitamente un nuevo objeto cuyo [[Prototype]] es la propiedad prototipo de la función constructora si esa propiedad existe y tiene un valor de objeto, o Object.prototype de lo contrario. El objeto recién creado está vinculado a este valor dentro del entorno local de la función. Si la función devuelve un objeto, la nueva expresión evaluará ese objeto; de lo contrario, la nueva expresión evalúa el valor creado implícitamente.

En cuanto a la propiedad del prototipo, se crea implícitamente cada vez que define una función ordinaria. Cada función recién definida tiene una propiedad llamada "prototipo" definida con un objeto recién creado como su valor. Ese objeto a su vez tiene una propiedad de constructor que apunta de nuevo a la función original. Tenga en cuenta que esta propiedad de prototipo no es la misma que la ranura [[Prototipo]]. En el ejemplo de código anterior, Foo sigue siendo solo una función, por lo que su [[Prototipo]] es el objeto predefinido Function.prototype.

Aquí hay un diagrama para ilustrar la muestra de código anterior con relaciones [[Prototipo]] en negro y relaciones de propiedad en verde y azul.

diagrama de la jerarquía del prototipo para la muestra de código anterior

[1] Posiblemente podría tener objetos con un método [[Construct]] y ningún método [[Call]], pero la especificación ECMAScript no define ninguno de estos objetos. Por lo tanto, todos los constructores también son funciones.

[2] Por definiciones de funciones ordinarias, me refiero a las funciones definidas usando la palabra clave de función regular y nada más, en lugar de => funciones, funciones generadoras, funciones asíncronas, métodos, etc. Por supuesto, antes de ES6, este era el único tipo de definición de la función

Nuevas clases de estilo

Con ese fondo fuera del camino, es hora de examinar la sintaxis de la clase ES6. El ejemplo de código anterior se traduce directamente a la nueva sintaxis de la siguiente manera:

Como antes, cada clase consta de una función constructora y un objeto prototipo que se refieren entre sí a través de las propiedades prototipo y constructor. Sin embargo, el orden de definición de los dos se invierte. Con una clase de estilo antigua, usted define la función del constructor, y el objeto prototipo se crea para usted. Con una nueva clase de estilo, el cuerpo de la definición de clase se convierte en el contenido del objeto prototipo (a excepción de los métodos estáticos), y entre ellos, define un constructor. El resultado final es el mismo en ambos sentidos.

Entonces, si la sintaxis de la clase ES6 es solo azúcar para las "clases" de estilo antiguo, ¿cuál es el punto? Además de verse mucho mejor y agregar controles de seguridad, la nueva sintaxis de clase también tiene una funcionalidad que era imposible antes de ES6, específicamente, herencia basada en clase. Cuando define una clase con la nueva sintaxis, opcionalmente puede proporcionar una superclase para que la clase herede como se muestra a continuación:

Este ejemplo en sí mismo todavía se puede emular sin sintaxis de clase, aunque el código requerido es mucho más feo.

Con una herencia basada en clases, la regla es simple: cada parte del par tiene como prototipo la parte correspondiente de la superclase. Entonces, el constructor de la superclase es el [[Prototipo]] del constructor de la subclase y el objeto prototipo de la superclase es el [[Prototipo]] del objeto prototipo de la subclase. Aquí hay un diagrama para ilustrar (solo se muestran los [[Prototipos]]; las propiedades se omiten para mayor claridad).

No hay una forma directa y conveniente de configurar estas relaciones [[Prototipo]] sin usar la sintaxis de clase, pero puede configurarlas manualmente usando Object.setPrototypeOf (), introducido en ES5.

Sin embargo, el ejemplo anterior evita notablemente hacer algo en los constructores. En particular, evita super, una nueva sintaxis que permite a las subclases acceder a las propiedades y al constructor de la superclase. Esto es mucho más complicado y, de hecho, es imposible emularlo completamente en ES5, aunque se puede emular en ES6 sin usar la sintaxis de clase o super mediante el uso de Reflect.

Acceso a la propiedad de superclase

Hay dos usos para super - llamar a un constructor de superclase, o acceder a las propiedades de la superclase. El segundo caso es más simple, así que lo cubriremos primero.

La forma en que funciona súper es que cada función tiene una ranura interna llamada [[HomeObject]], que contiene el objeto dentro del cual se definió originalmente la función si se definió originalmente como un método. Para una definición de clase, este objeto será el objeto prototipo de la clase, es decir, el prototipo Foo. Cuando accede a una propiedad a través de super.foo o super ["foo"], es equivalente a [[HomeObject]]. [[Prototype]]. Foo.

Con esta comprensión de cómo Super funciona detrás de escena, puede predecir cómo se comportará incluso en circunstancias complicadas e inusuales. Por ejemplo, el [[HomeObject]] de una función se fija en el momento de la definición y no cambiará incluso si luego asigna la función a otros objetos como se muestra a continuación.

En el ejemplo anterior, tomamos una función originalmente definida en D.prototype y la copiamos en B.prototype. Dado que [[HomeObject]] todavía apunta a D.prototype, el super acceso se ve en el [[Prototype]] de D.prototype, que es C.prototype. El resultado es que la copia de foo de C se llama a pesar de que C no está en ninguna parte de la cadena de prototipos de b.

Del mismo modo, el hecho de que [[HomeObject]]. [[Prototype]] se busque en cada evaluación de la súper expresión significa que verá cambios en [[Prototype]] y devolverá nuevos resultados, como se muestra a continuación.

Como nota al margen, super no se limita a las definiciones de clase. También se puede usar desde cualquier función definida dentro de un literal de objeto utilizando la nueva sintaxis de método abreviado, en cuyo caso [[HomeObject]] será el literal de objeto adjunto. Por supuesto, el [[Prototipo]] de un objeto literal siempre será Object.prototype, por lo que esto no es terriblemente útil a menos que reasigne manualmente el prototipo como se hace a continuación.

Emulando super propiedades

No hay forma de configurar manualmente [[HomeObject]] en nuestros métodos, pero podemos emularlo simplemente guardando el valor y haciendo la resolución manualmente como se muestra a continuación. No es tan conveniente como simplemente escribir super, pero al menos funciona.

Tenga en cuenta que necesitamos usar .call (this) para asegurarnos de que se llame al súper método con el valor correcto. Si el método tiene una propiedad que sombrea Function.prototype.call por alguna razón, podríamos usar Function.prototype.call.call (foo, this) o Reflect.apply (foo, this), que son más confiables pero detallados.

Super en métodos estáticos

También puedes usar super desde métodos estáticos. Los métodos estáticos son los mismos que los métodos normales, excepto que se definen como propiedades en la función constructora en lugar de en el objeto prototipo.

super se puede emular dentro de métodos estáticos de la misma manera que con los métodos normales. La única diferencia es que [[HomeObject]] ahora es la función de constructor en lugar del objeto prototipo.

Súper constructores

Cuando se invoca el método [[Construct]] de una función de constructor ordinario, se crea implícitamente un nuevo objeto y se vincula al valor de este dentro de la función. Sin embargo, los constructores de subclase siguen diferentes reglas. No se crea automáticamente este valor e intentar acceder a este da como resultado un error. En su lugar, debe llamar al constructor de la superclase a través de super (args). El resultado del constructor de la superclase se vincula al valor local de este valor, después de lo cual puede acceder a él en el constructor de la subclase de forma normal.

Por supuesto, esto presenta problemas si desea crear una clase de estilo antigua que pueda interoperar correctamente con las nuevas clases de estilo. No hay ningún problema al subclasificar una clase de estilo antigua con una nueva clase de estilo, ya que el constructor de la clase base es solo una función de constructor ordinario en ambos sentidos. Sin embargo, subclasificar una nueva clase de estilo con una clase de estilo antigua no funcionará correctamente, ya que los constructores de estilo antiguo siempre son constructores base y no tienen el comportamiento especial de constructor de subclase.

Para concretar el desafío, supongamos que tenemos una nueva clase de estilo Base cuya definición es desconocida y no se puede cambiar, y deseamos subclasificarla sin usar la sintaxis de la clase, mientras seguimos siendo compatibles con cualquier código en Base que espera una subclase verdadera.

En primer lugar, asumiremos que Base no está usando proxies, propiedades computacionales no deterministas, o cualquier otra cosa extraña como esa, ya que nuestra solución probablemente accederá a las propiedades de Base un número diferente de veces o en un orden diferente al que haría una subclase real , y no hay nada que podamos hacer al respecto.

Después de eso, la pregunta se convierte en cómo configurar la cadena de llamadas del constructor. Al igual que con las súper propiedades regulares, podemos obtener fácilmente el constructor de la superclase usando Object.getPrototypeOf (homeObject) .constructor. ¿Pero cómo invocarlo? Afortunadamente, podemos usar Reflect.construct () para invocar manualmente el método interno [[Construct]] de cualquier función constructora.

No hay forma de emular el comportamiento especial del enlace this, pero podemos ignorar esto y usar una variable local para almacenar el valor "real" de este, llamado $ this en el ejemplo a continuación.

Tenga en cuenta la devolución $ this; línea arriba. Recuerde que si una función constructora devuelve un objeto, ese objeto se usará como el valor de la nueva expresión en lugar del valor creado implícitamente.

Entonces, ¿misión cumplida? No exactamente. El valor obj en el ejemplo anterior no es en realidad una instancia de Child, es decir, no tiene Child.prototype en su cadena de prototipos. Esto se debe a que el constructor de Base no sabía nada sobre Child y, por lo tanto, devolvió un objeto que era solo una instancia simple de Base (su [[Prototype]] es Base.prototype).

Entonces, ¿cómo se resuelve este problema para las clases reales? [[Construir]] y, por extensión, Reflect.construct, en realidad toman tres parámetros. El tercer parámetro, newTarget, es una referencia al constructor que se invocó originalmente en la nueva expresión y, por lo tanto, al constructor de la clase más inferior (más derivada) en la jerarquía de herencia. Una vez que el flujo de control llega al constructor de la clase base, el objeto creado implícitamente tendrá newTarget como su [[Prototipo]].

Por lo tanto, podemos hacer que Base construya una instancia de Child invocando al constructor a través de Reflect.construct (constructor, args, Child). Sin embargo, esto todavía no es del todo correcto, porque se romperá cuando alguien más subclasifique Child. En lugar de codificar la clase secundaria, debemos pasar por newTarget sin cambios. Afortunadamente, se puede acceder dentro de los constructores utilizando la sintaxis especial new.target. Esto lleva a la solución final a continuación:

Toques finales

Esto cubre todas las funciones principales de las clases, pero hay algunas otras diferencias menores, en su mayoría comprobaciones de seguridad agregadas a la nueva sintaxis de clase. Por ejemplo, la propiedad prototipo que se agrega automáticamente a las definiciones de funciones se puede escribir de manera predeterminada, pero la propiedad prototipo de los constructores de clases no se puede escribir. También podemos hacer que el nuestro no se pueda escribir fácilmente llamando a Object.defineProperty (). Alternativamente, puede llamar a Object.freeze () si desea que todo sea inmutable.

Otra nueva protección es que los constructores de clases lanzarán un TypeError si intentas [[Llamar]] en lugar de construirlos con new. Nuestro constructor anterior también arroja un TypeError, pero solo indirectamente, porque new.target no está definido cuando la función es [[Call]] ed y Reflect.construct () arroja un TypeError si pasa explícitamente undefined como último argumento. Dado que TypeError es una coincidencia aquí, el mensaje de error resultante es bastante confuso. Puede ser útil agregar una comprobación explícita para new.target que arroje un error con un mensaje de error más útil.

De todos modos, espero que hayan disfrutado esta publicación y hayan aprendido tanto como yo en el proceso de investigación. Las técnicas anteriores rara vez son útiles en el código del mundo real, pero aún es importante comprender cómo funcionan las cosas bajo el capó en caso de que tenga un caso de uso inusual que requiera alcanzar la magia negra, o más probablemente, esté atrapado teniendo que depurar la magia negra de alguien más.

PD Si, como yo, está molesto por el gigantesco banner inaccesible de Medium en la parte inferior de la pantalla que le insta a registrarse, o la búsqueda general de sitios web para hacer que leer su contenido sea tan difícil y molesto como sea posible, le recomiendo que revise Kill Pegajoso. Es un fragmento de Javascript simple que puede marcar que elimina los elementos "adhesivos" de la página. Suena simple, pero navegar con Kill Sticky cambia la vida. Y dado que es solo un marcador, no tiene que preocuparse por matar accidentalmente elementos de página importantes para siempre como lo haría con un filtro uBlock. En el peor de los casos, siempre puede actualizar la página.