Introducción a la POO en Javascript moderno: las nuevas clases en ES6

02 Dic 2016

Introducción

El paradigma de la Programación Orientada a Objetos ha ido perdiendo peso en Javascript con el paso del tiempo.

Yo mismo, cuando me he referido en el pasado a su teoría más purista (clases, instancias, constructores…), he comentado que rara vez la veríamos aplicada en un entorno real de producción (con la excepción del mundo de desarrollo de vídeo juegos donde siempre ha estado presente y viva). Hablaba desde una escena dominada por frameworks y bibliotecas con enfoques más funcionales donde el propio lenguaje, consciente de estas tendencias, había ido introduciendo de forma progresiva muchas novedades en este sentido.

Sin embargo, Javascript no solo ha sabido mejorar su perfil funcional, sino que atendiendo a las críticas y peticiones de los desarrolladores, ha reformulado su enfoque sobre los objetos introduciendo una suerte de azúcar sintáctica más tradicional. Estos cambios han motivado a su vez que las bibliotecas y herramientas actuales las recojan e implementen presentando sintaxis completamente nuevas en sus códigos fuente y documentación. En este sentido, ReactJS es un claro ejemplo de ello.

Como desarrolladores, que debemos saber manejarnos en nuestro día a día con todas estas modas, no podemos dejar de repasar las novedades dentro de un paradigma tan potente como lo es la POO…

Clases

Los prototipos originales del lenguaje Javascript siempre fueron unos incomprendidos frente a las clases tradicionales de la Programación Orientada a Objetos. Es por ello que finalmente el ECMA ha elaborado una sintaxis alternativa en su definición que nos acerca más al enfoque clásico.

Es importante resaltar que estas fórmulas recientes no introducen nuevos modelos sino que únicamente se limitan a proveer de definiciones más claras y simples para la creación y el trabajo con objetos.

NOTA: El objetivo de este artículo no es explicar la teoría tras la POO, ni discutir las diferencias prácticas entre clases y prototipos. Para profundizar en ello, se recomienda la lectura de cualquier título dentro de la extensa bibliografía al respecto (selección de títulos sobre POO gratuitos en OpenLibra).

Asumida la advertencia anterior, pasemos a analizar las nuevas clases desde su forma más básica:

class Foo {
 
}
 
let myObj = new Foo;

Ahí tenemos la definición e instancia más simple posible de una clase en Javascript. Resaltemos algunos aspectos rápidos que podemos observar a simple vista:

  • Hemos introducido la palabra reservada class (documentación aquí) para declarar nuestra clase.
  • Una clase no precisa de argumentos (parámetros) en su definición. Por lo tanto, no la acompañan los paréntesis habituales.
  • No es necesario el punto y coma ‘;’ (semicolon) final.
  • Opcional, pero como práctica habitual en la POO, el nombre de la clase comienza con una letra mayúscula.

Señalemos además otros detalles que no vemos, pero que se producen ‘tras el telón’:

  • Al tratarse de una clase, el sistema no permite su uso como una función, sino que se reserva como un constructor.
  • El contenido de una clase se ejecuta en modo estricto de forma automática.
  • Las declaraciones de clases no siguen las reglas de hoisting como sí lo hacen las declaraciones de funciones. Esto quiere decir que solo existen tras ser declaradas.
  • De forma implícita un clase se comporta como una constante, no siendo posible redeclararla más adelante en un mismo ámbito o scope.

Como hemos comentado, no se trata de un nuevo modelo, sino de únicamente de un decorador sintáctico para facilitar el trabajo. Su equivalente en Javascript tradicional sería (compilación automática hecha por Babel):

function _classCallCheck ( instance, Constructor ) {
    if ( ! ( instance instanceof Constructor ) ) {
        throw new TypeError( 'Cannot call a class as a function' );
    }
}
 
var Foo = function Foo () {
    _classCallCheck( this, Foo );
};

Obsérvese que se ha introducido una función _classCallCheck para garantizar que la clase sea utilizada como un constructor, y no como una función.

Anatomía de una clase

Una clase se compone de diversos métodos internos que permiten desde asignar valores iniciales hasta estructurar su contenido creando relaciones de dependencias.

En su implementación bajo Javascript, encontraremos un constructor (extensible con un super constructor), getters, setters y métodos para el desarrollo de su lógica de negocio (ya sean estáticos o públicos).

Un ejemplo más completo de clase, aunando todo lo comentado anteriormente, quedaría como sigue:

class Foo {
    constructor ( ...args ) {
        // super( ...args );
    }
 
    get foo () {
        return this.foo;
    }
 
    set foo ( value ) {
        this.foo = value;
    }
 
    static staticMethod () {
        return 'Static method has been called.';
    }
 
    toString () {
        return 'Public method toString has been called.';
    }
}

Gracias a estas novedades que estamos estudiando, el ejemplo anterior resulta autoexplicativo: su sintaxis es simple y clara. No obstante, se pueden destacar algunas consideraciones que quizá pasen inadvertidas:

  • Los métodos no se declaran de forma explícita con var, let o const.
  • Al tratarse de un constructor y no una función, no hay una salida de datos explícita con return.
  • Encontramos nuevas palabras reservadas: constructor, super, get, set y static.

Instanciar una clase

Una vez definida una clase, para instanciarla utilizamos el operador new (documentación aquí):

class Foo {};

let foo = new Foo;

typeof foo; // "object"
foo instanceof Foo;

Si necesitamos enviar parámetros al constructor de la clase, los añadimos durante su creación (en el ejemplo anterior omitimos estos parámetros y por ello no eran necesario los paréntesis):

var foo = new Foo( param1, param2 );

Como ya se ha comentado, si tratamos de utilizar nuestra clase como una función, el sistema devuelve un error:

class Foo {};

let foo = Foo();

Las clases en el Mundo Real™

Siempre que se habla de POO, se recurren a los mismos ejemplos teóricos: una casa, un gato, la geometría…

Pese a que no son ejemplos realistas para un programador (¿alguien ha tenido alguna vez que definir una clase para crear gatos en su trabajo?), resultan sencillos de entender. Mis disculpas con antelación si alguien no se siente cómodo con este nivel de abstracción.

Recurriremos momentáneamente a la geometría que, aunque es peligrosa (y más adelante explicaremos el porqué), al menos es más afín a la programación que los felinos.

Designemos una clase que represente un punto en un plano:

class Point {
    constructor ( x = 0, y = 0 ) {
        this.x = x;
        this.y = y;
    }

    toString () {
        return '(' + this.x + ', ' + this.y + ')';
    }
}

// Creating points
let p1 = new Point( 10, 20 );
let p2 = new Point();

// Output p1: (with params)
// p1.toString(); // (10, 20)

// Output p2: (without params)
p2.toString(); // (0, 0)

Un aspecto importante de las clases es que, al no obedecer las reglas de hoisting, éstas no pueden instanciarse antes de ser declaradas:

var p = new Point();

class Point {
    // ...
}

Una vez presentados todos los elementos, pasemos a ver los métodos disponibles con más detalle:

El constructor

En programación orientada a objetos (POO), un constructor es una subrutina cuya misión es inicializar un objeto de una clase. En el constructor se asignan los valores iniciales del nuevo objeto.

Wikipedia, Constructor (informática)

Retomemos el ejemplo anterior:

class Point {
    constructor ( x = 0, y = 0 ) {
        this.x = x;
        this.y = y;
    }
}

Vemos ahí el uso de parámetros por defecto, y su posterior asignación al valor contextual de la clase mediante this.

Como con cualquier argumento de función en ES6, podemos jugar en el constructor con el parámetro de arrastre, o la desestructuración más exótica:

class Point {
    constructor( args = {} ) {
        ( { x: this.x = 0, y: this.y = 0 } = args );
    }

    toString () {
        return '(' + this.x + ', ' + this.y + ')';
    }
}

let p1 = new Point( { x: 10, y: 20 } );
let p2 = new Point();

// Output p1:
// p1.toString(); // (10, 20)

// Output p2:
p2.toString(); // (0, 0)

NOTA: El concepto de ‘super constructor’ lo veremos más adelante, cuando toquemos la herencia.

Setters y Getters

Habituales en programación, los métodos de acceso denominados ‘getters‘ y ‘setters‘ se utilizan para obtener y asignar valores a los atributos de nuestros objetos respectivamente.

En Javascript, ambos métodos se corresponde con atajos sintácticos (funciones finalmente) que no añaden funcionalidad adicional alguna.

Su implementación, tal como vimos más arriba, resulta muy natural, pero hay que tener cuidado con las referencias circulares:

class Rectangle {
    constructor ( height = 0, width = 0 ) {
        this._height = height;
        this._width = width;
    }

    set height ( value ) {
        this._height = value;
    }

    set width ( value = 0 ) {
        this._width = value;
    }

    get area() {
        return this._height * this._width;
    }
}

let obj = new Rectangle();

obj.height = 10;
obj.width = 20;
obj.area;

Analicemos el código por partes.

Definición de la clase

  • En el constructor, las propiedades height y width se han asignado a sendas propiedades del contextual this utilizando un guión bajo ( _heigth y _width). Esto evita las referencias circulares que veremos a continuación.
  • Los dos métodos ‘setters‘ funcionan como funciones normales desde la que modificar el valor de las propiedades anteriores.
  • El método ‘get‘ calcula una salida a partir de los datos previamente almacenados.

Uso de la clase

  • Los ‘setters‘ funcionan mediante asignación, no como funciones. Son un canal directo para modificar el valor de las propiedades del objeto instanciado.
  • El ‘get‘, como los anteriores, no es una función que tenga que ser ejecutada, sino un acceso a la propiedad del objeto instanciado.

Referencias circulares

Consideremos el siguiente ejemplo simplificado derivado del anterior:

class Artifact {
    set brand ( str ) {
        this.brand = str || 'ACME';
    }
}

let obj = new Artifact();
obj.brand = 'Nexus';

Este fragmento arroja un error de recursión (desbordamiento de pila) debido a que, cada vez que se llama al método set brand, éste se invoca a sí mismo desde el cuerpo de la función, entrando en bucle:

Acceder a un ‘setter‘ que modifica una propiedad con su mismo nombre, crea una llamada de función recursiva infinita.

Es por ello que en la práctica, llamamos a los métodos ‘setters‘ de un modo diferente a las propiedades que están modificando para evitar estos problemas.

NOTA: Más adelante ofreceremos una solución alternativa cuando tratemos el tema de la privacidad.

Métodos estáticos

Los métodos estáticos son aquellos que se ejecutan a través/desde de la propia clase, no desde sus instancias.

Por lo general, este tipo de métodos se reservan a clases que coleccionan utilidades y que no se espera de ellas que sean instanciadas. Estamos hablando de las típicas bibliotecas tipo ‘helpers’ habituales en la mayoría de frameworks:

class Tools {
    static strToURL ( str ) {
        return encodeURIComponent( str )
          .replace( /%20/g,'+' );
    }
}

Tools.strToURL( 'La donna e mobile' );

Como se puede ver, la función para codificar URLs se estructura de forma ordenada dentro de una clase ‘Tools’ (aquí se puede utilizar cualquier nivel de abstracción) para ser lanzada directamente sin necesidad de una instancia previa.

Si tratamos de llamar a una función estática desde una instancia, el sistema nos informa de que dicho método no existe:

class Tools {
    static strToURL ( str ) {
        return encodeURIComponent( str )
          .replace( /%20/g,'+' );
    }
}

var toolkit = new Tools();
toolkit.strToURL( 'La donna e mobile' );

Métodos públicos

Cualquier otro método que complemente nuestra clase, será un método público, ejecutable únicamente desde sus instancias (directas o heredadas).

class Rectangle {
    constructor ( height = 0, width = 0 ) {
        this._height = height;
        this._width = width;
    }

    area () {
        return this._height * this._width;
    }
}

let rectangleOne = new Rectangle( 10, 20 );
rectangleOne.area();

En este caso, nuestro método público tiene que ser llamado, a diferencia del ‘getter‘ anterior, como una función (la cual permitiría además el paso de parámetros).

Privacidad

Llegamos a un delicado punto histórico cuando trabajamos con Javascript y sus objetos: la privacidad de sus propiedades.

En una clase, al igual que ocurre con los objetos, tanto sus atributos, como propiedades, son públicos:

class Player {
    constructor ( life = 100 ) {
        this._life = life;
    }
}

let playerOne = new Player;

// Updating (setting) properties
playerOne._life -= 20;

// Accessing (getting) properties
playerOne._life;

Como vemos, en una estructura simple como la anterior, no precisamos de ‘setters‘ o ‘getters, ya que podemos modificar las propiedades directamente a partir de la instancia. Y esto es posible porque son públicas.

Para aislar ciertas propiedades y evitar que puedan ser modificadas de forma externa, tenemos que recurrir a diversos malabares como por ejemplo, el uso de WeakMap:

let playerMap = new WeakMap();

class Player {
    constructor ( life = 100 ) {
        playerMap.set( this, {
            life: life
        } );
    }

    set publicLife ( value ) {
        playerMap.get( this ).life = value;
    }

    get life () {
        return playerMap.get( this ).life;
    }
}

let playerOne = new Player;

// We can not change 'private' properties
playerOne.life = 50;
playerOne.life; //100

// We need the public setter:
playerOne.publicLife = 20;
playerOne.life;

NOTA: No es objetivo de este artículo explicar el funcionamiento interno de WeakMap por lo que sirva el ejemplo anterior como demostración práctica de uso en este contexto de la POO.

NOTA 2: Además de la anterior, existen varias formas de conseguir privatizar propiedades y métodos en una clase; sin embargo, para no extender este artículo en exceso, se tratarán en otra entrada de forma independiente.

Herencia: subclases o clases hijas

Como en toda Programación Orientada a Objetos, resulta natural poder crear clases a partir de otras clases. En el argot habitual hablamos de crear clases que extiendan a otras.

En Javascript, conseguimos este comportamiento utilizando la palabra reservada extends (documentación aquí) al definir una clase hija.

Para ilustrar la herencia tomemos el siguiente código:

NOTA: Este ejemplo es completamente editable, por lo que se invita al lector a que juegue con él y compruebe por sí mismo las diferentes salidas (outputs) de cada instancia o instrucción.

class Device {
    constructor ( params = {} ) {
        ( {
            status: this._status = 'off',
            brand: this._brand = 'ACME',
            firmware: this._firmware = 'unknown'
        } = params );
    }

    start () {
        this._status = 'on';
    }

    get status () {
        return this._status;
    }

    get brand () {
        return this._brand;
    }

    get firmware () {
        return this._firmware;
    }
}

class VideoDevice extends Device {
    start ( source = '' ) {
        super.start();

        return 'Rendering source...';
    }
}

class DiskDevice extends Device {
    constructor ( params = {} ) {
        super( { status: 'on' } );
    }

    format ( size = 0 ) {
        return 'Formatting device | Firmware: ' + this._firmware;
    }
}

let d1 = new Device;
d1.status; // "off"

let player = new VideoDevice( { brand: 'Videodrome' } );
player.brand; // "Videodrome"
player.status; // "off"
player.start(); // "Rendering source..."
player.status; // "on"

let hdd = new DiskDevice;
hdd.status; // "on"
hdd.format(); // "Formatting device | Firmware: unknown"

Antes de diseccionar el bloque anterior, es interesante resaltar cómo hemos optado por desestructurar el constructor principal explotando al máximo la nueva sintaxis ES6:

constructor ( params = {} ) {
    ( {
        status: this._status = 'off',
        brand: this._brand = 'ACME',
        firmware: this._firmware = 'unknown'
    } = params );
}

Sin duda, se trata de una formulación interesante que nos muestra la flexibilidad del Javascript actual 🙂

Super constructores

Javascript permite el uso del concepto super constructor para elevar una propiedad desde el objeto hijo directamente al padre a través de su propio constructor.

Esa funcionalidad posibilita que nuestro dispositivo DiskDevice inicie siempre la propiedad status con valor on:

class DiskDevice extends Device {
    constructor ( params = {} ) {
        super( { status: 'on' } );
    }
 
    format ( size = 0 ) {
        return 'Formating device | Firmware: ' + this._firmware;
    }
}

El super constructor se define mediante la palabra reservada super (documentación aquí), que se invoca como una función.

Es importante resaltar que el objeto que enviamos ahí, sobreescribe el original por lo que, si instanciamos un DiskDevice con parámetros, éstos no llegarían al constructor padre (porque en el super constructor estamos enviando un objeto ya predefinido sin tener en cuenta los que puedan llegar con la instancia).

let hdd = new DiskDevice( { firmware: '2,0' } );
hdd.firmware; // "unknown"

Super llamadas

Del mismo modo que el super constructor envía valores desde la clase hija a la clase padre, disponemos también de ‘super llamadas‘ que permiten llamar a un método de la clase padre desde la clase hija.

De nuevo se utiliza la palabra reservada super:

class VideoDevice extends Device {
    start ( source = '' ) {
        super.start();
 
        return 'Rendering source...';
    }
}

Con esta ‘super llamada’, podemos acceder a métodos con el mismo nombre tanto en la instancia como en la clase padre, evitando así un error de recursión que se daría si intentamos utilizar this en su lugar:

class VideoDevice extends Device {
    start ( source = '' ) {
        this.start(); // InternalError: too much recursion
 
        return 'Rendering source...';
    }
}

Importante

En el momento de definir una clase, es importante tener en cuenta un par de reglas obligatorias:

  • En el constructor de una clase hija, es obligatorio llamar a super antes de utilizar this.
  • Los constructores de clases en ES6 DEBEN llamar a super siempre que sean subclases, o deben devolver de forma explícita algún objeto que reemplace a aquel que no ha sido inicializado.

Apunte teórico

Cuando nos referimos al concepto de herencia en la POO, muchas veces se recurre a ejemplos abstractos ‘peligrosos’. Es el caso, por ejemplo, de las formas geométricas.

Resulta trivial encontrar tutoriales y teoría donde se utiliza la herencia para crear, por ejemplo, una clase ‘Cuadrado’ como subclase de otra ‘Rectángulo’. Esto es un error formal dado que la supuesta subclase posee restricciones (la ligadura entre su altura y anchura) que no se corresponden con su clase padre.

El usuario Rik lo explica perfectamente en un hilo de stackoverflow digno de traer aquí a colación:

Podemos pedir a un objeto Rectángulo que cambie su altura. Si un Cuadrado es una subclase de Rectángulo, eso significa que debemos ser capaces de solicitar la misma operación para él. Sin embargo, ¡cambiar la altura de un cuadrado significa que éste deja de ser un cuadrado! Por supuesto, podemos modificar también su anchura en consecuencia, pero eso no es lo que se espera de un objeto declarado como Rectángulo (que es en realidad la clase que subyace en nuestro Cuadrado) cuando se modifica su altura.

Esto es lo que se conoce como el Principio de Substitución Liskov, el cual hay que evitar cuando se realiza una POO seria.

Los cuadrados son, por supuesto, un subconjunto de los rectángulos, no una subclase. Esta es la diferencia entre las aproximaciones de ‘orientación por datos’ y la ‘orientación por comportamientos’.

Conclusión

Actualmente, podemos hablar de Javascript como un lenguaje multiparadigma perfectamente válido tanto para una Programación Funcional, una Orientada a Objetos, o un enfoque híbrido de ambas.

Y esto es posible gracias a que el estándar ha sabido trabajar tanto el aspecto funcional con la introducción de métodos inmutables, como el de los objetos mediante nuevas fórmulas sintácticas.

Curiosamente, gracias a estos cambios, estamos viendo una interesante evolución en nuestras herramientas del día a día: las bibliotecas de moda se están beneficiando de la nueva sintaxis POO de Javascript implementándolas dentro de sus flujos de trabajo de forma natural. Y esa tendencia arrastra a los desarrolladores a experimentar y actualizar sus conocimientos básicos sobre el lenguaje.

Con este artículo introductorio esperamos cubrir las bases mínimas para una programación más estructurada de nuestros proyectos. Al mismo tiempo, presentamos la sintaxis renovada de clases que cada vez está más presente entre las herramientas modernas (bibliotecas y frameworks) con las que, en algún u otro momento, nos tocará trabajar. Conviene estar al día 🙂

Más:

{3} Comentarios.

  1. Danielo

    Sinceramente, la gestión de las propiedades privadas lo veo un paso atrás con respecto a la herencia prototipica más tradicional.

    function Punto ( x, y ) {
        this.x = ()  => x;
        this.y = () => y;
    } 
    

    Ya está, propiedades privadas perfectas.

    Gracias por el artículo!

    • Carlos Benítez

      La privacidad no es un concepto tan sencillo como la fórmula que has propuesto, perfectamente válida cuando no tienes que disponer de ‘setters’.

      Ese modelo, en un constructor, funciona. Pero cuando necesitas mecanismos para poder alterar esos valores dentro de la clase, cualquier método que definas para ello se vuelve inmediatamente público. Con una función tradicional, eso no pasaba porque era la lógica de negocio la que definía la API del objeto a través del ‘return’. En ese contexto, tu ejemplo es una muy buena aproximación al problema. Con las clases, sin embargo, no podemos jugar con esa instrucción (porque no existe) y eso nos lleva a lo anterior: que todo método interno es público.

      Esa falta de privacidad, como bien comentas, es un paso atrás teórico con respecto al modelo ‘clásico de Javascript’ y, para corregirlo, en la práctica hay que hacer grandes malabares. El WeakMap es solo una posible solución, pero hay otras: por ejemplo, la nueva primitiva Symbol (aunque personalmente no he conseguido asegurar la privacidad con esta última).

      Sea como fuere, este problema no se ha resuelto plenamente con la nueva sintaxis. Es obvio que se echa de menos una definición de tipo ‘private’ que tiene que llegar sin duda en un futuro.
      De momento, tenemos que seguir forzando el lenguaje para conseguir algo parecido.

      Gracias por tu comentario!

  2. Danielo

    Gracias por contestar Carlos Benítez.

    Efectivamente lo que propongo funciona solo para métodos orientados a modificar las variables privadas, que paradójicamente acaban siendo inevitablemente públicas. En cualquier caso si el uso de memoria no es un problema (pocas instancias) un buen patrón módulo o revealing module pattern soluciona todos estos problemas. En cualquier caso no se por qué me obsesiono con la privacidad en javascript,yo mismo me he beneficiado muchas veces del monkey patching haciendo auténticas virguerías gracias a que tengo acceso a los internals de la instancia.

    Un saludo.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *