Herramientas de usuario

Herramientas del sitio


informatica:programacion:cursos:programacion_avanzada_javascript:clases_programacion_orientada_objetos_es6

Clases y Programación Orientada a Objetos con ES6+

Módulo perteneciente al curso Programación avanzada con JavaScript y ECMAScript.

Introducción

Una de las grandes novedades en ECMAScript 2015 es la aparición del concepto de clases. Debe recalcarse que, en el fondo, las clases en ECMAScript no añaden nada realmente nuevo al lenguaje. Y es que, al contrario de lo que podría pensarse, el lenguaje sigue siendo dinámico y la herencia sigue siendo entre objetos y basada en prototipos. El concepto de clase no existe en tiempo de ejecución.

Las clases son “poco más” que azúcar sintáctico para declarar funciones constructoras y definir relaciones entre prototipos.

La ventaja de las clases es que proporcionan una sintaxis integrada en el lenguaje para realizar tareas que hasta ahora no había una forma “oficial” de realizar. Además la sintaxis es lo suficientemente sencilla y “cercana” a los lenguajes basados en clases (como C# o Java) que si vienes de esos lenguajes te sentirás como en casa.

Pero ¡no te engañes! debes recordar que por debajo de las clases de JavaScript sigue subyaciendo la herencia basada en prototipos. Y debes entenderla si quieres comprender cómo funcionan las clases y aprovechar todo su potencial.

Transpiladores

Para poder ejecutar código ES2015 en un entorno que no lo soporte, necesitamos un transpilador.

Un transpilador es una herramienta que convierte código en una sintaxis a otra sintaxis distinta. En nuestro caso necesitamos un transpilador de ES2015 a ES5.

El transpilador más conocido y utilizado de ES2015 a ES5 es Babel.

Un transpilador por sí solo no es suficiente. Los transpiladores realizan solo cambios sintácticos, pero no pueden ofrecer funcionalidad adicional. Supongamos p. ej. que usas un Map. El código para crear un Map es:

let map = new Map();

El transpilador lo único que puede hacer aquí es cambiar el let por var. Pero este código seguirá sin funcionar en un entorno ES5, ya que el tipo Map no existe en ES5. Para ello necesitamos un polyfill.

Llamamos polyfill a una librería JavaScript que nos simula tipos o funcionalidades no existentes. Así necesitaremos un polyfill para Map, otro para Promise y, en general, uno para cada tipo o método incorporado en ES2015 que no exista en ES5.

Pero incluso el uso de un transpilador y un conjunto de polyfills puede no ser suficiente. Si usamos módulos (que veremos más adelante) vamos a necesitar un bundler o un loader para simular los módulos. Hablaremos de bundlers y loaders cuando tratemos los módulos de ES2015.

La idea es que tengas presente que para simular ES2015 en un entorno ES5, no te basta con una sola herramienta, sino que necesitarás un transpilador (para la sintaxis), varios polyfills (para los tipos nuevos) y un bundler o un loader para los módulos.

E incluso así hay aspectos de ES2015 que no son ejecutables en un entorno ES5. Son aquellos aspectos y comportamientos para los que no es posible crear un polyfill porque son imposibles de replicar en ES5. Aunque no hay muchos, conviene que lo tengas presente.

DEMO: Babel - Uso del REPL

Para los casos en que los navegadores no soporten versiones nuevas de JavaScript, usamos transpiladores. Podemos “traducir” código ES6 a ES5, por ejemplo.

Una herramienta para hacer estos cambios en directo en un entorno de prueba es https://babeljs.io/repl

En la parte de la izquierda escribimos código ES6 y en la derecha veremos la traducción a ¿?¿?¿?

Ha cambiado mucho la herramienta de Babel desde este curso y ya no se puede transpilar a ES5

DEMO: Babel - Uso de la línea de comandos

Babel es un paquete npm de Node.js.

Primero crearemos un fichero package.json:

npm --init

Para instalar Babel en un proyecto / carpeta:

npm install babel-cli babel-preset-es2015 --save-dev
  • babel-cli: herramientas de línea de comandos de Babel.
  • babel-preset-es2015: paquete de transformación de ES6 a ES5.
  • save-dev: que guarde las dependencias en el fichero package.json.

Esto seguramente no funcionará a día de hoy porque Babel ya no soporta el preset es2015 desde su versión 6. Los vídeos de este curso tienen pinta de ser de 2018 o así.

Todo esto se instalará en el directorio node_modules, en la carpeta de nuestro proyecto.

Ahora el package.json habrá cambiado para reflejar estas dependencias:

{
    "name": "demo-babel",
    "version": "1.0.0",
    "description": "",
    "main": "classes.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
    },
    "author": "",
    "license": "ISC",
    "devDependencies": {
        "babel-cli": "^6.6.5",
        "babel-preset-es2015": "^6.6.0"
    }
}

Tenemos el siguiente código JavaScript:

class Animal {
    constructor(name) {
        this.name = name;
    }
 
    correr() {
        console.log(this.name + " está corriendo.");
    }
}
 
let milu = new Animal("milu");
milu.correr();

Si ahora queremos transformarlo a ECMAScript 2015:

./node_modules/.bin/babel --presets es2015 classes.js -d lib

Babel transformará el fichero anterior a una versión ECMAScript 2015 guardado en el directorio lib.

La idea es que nosotros trabajamos con ES6, convertimos todo a ES5 usando Babel y será este último código el que se suba a producción.

DEMO: Babel - Uso de la línea de comandos (2)

Veremos cómo invocar Babel desde línea de comandos, pero usando npm.

Supongamos en tenemos dentro de nuestro directorio del proyecto, otro directorio llamado js con todo el código JavaScript de nuestro proyecto.

Mediante un solo comando, podremos transpilar todo un directorio:

./node_modules/.bin/babel --preset es2015 js -d lib

Los ficheros transpilados quedarán guardados en lib.

Veamos ahora cómo invocar Babel desde npm. Primero haremos una pequeña modificación en el fichero package.json, en la parte de scripts:

{
    "name": "demo-babel",
    "version": "1.0.0",
    "description": "",
    "main": "classes.js",
    "scripts": {
        "build": "babel --preset es2015 js -d lib"
    },
    "author": "",
    "license": "ISC",
    "devDependencies": {
        "babel-cli": "^6.6.5",
        "babel-preset-es2015": "^6.6.0"
    }
}

Para ejecutarlo:

npm run build

Se ejecutará Babel de la misma manera que si hubiésemos ejecutado ./node_modules/.bin/babel --preset es2015 js -d lib

DEMO: Babel - Depuración con sourcemaps

Ejemplo de cómo se muestra en la herramienta para desarrolladores del navegador web el código transpilado anteriormente.

Además, tenemos el siguiente fichero HTML:

<!DOCTYPE html>
<html lang="es">
    <head>
        <script src="./lib/classes.js"></script>
    </head>
    <body>
        <input type="button" id="button" value="Correr!">
        <script>
            document.getElementById("button").
                addEventListener("click", function(event) {
                    milu.correr();
                }, false);
        </script>
    </body>
</html>    

Por la Consola de las herramientas para desarrolladores veremos el mensaje milu está corriendo. Funciona todo. El problema es cuando queremos usar el depurador estaremos viendo el código transpilado y no se hace tan sencillo ver los errores.

Lo ideal es que el navegador nos mostrase el código sin transpilar. Esto es posible con los sourcemaps que son ficheros que realizan correspondencias (mapeos) entre dos ficheros de código fuente. Los navegadores que lo soporten, nos mostrarán el código original.

Primero debemos indicar a Babel que nos genere los sourcemaps con la opción --source-maps en el fichero package.json:

{
    "name": "demo-babel",
    "version": "1.0.0",
    "description": "",
    "main": "classes.js",
    "scripts": {
        "build": "babel --preset es2015 js -d lib --source-maps"
    },
    "author": "",
    "license": "ISC",
    "devDependencies": {
        "babel-cli": "^6.6.5",
        "babel-preset-es2015": "^6.6.0"
    }
}

Ejecutamos:

npm run build

Si nos movemos al directorio lib, veremos un nuevo fichero llamado classes.js.map. Este fichero lo usarán los navegadores y lo utilizarán para realizar las transformaciones del código transpilado al original. En la pestaña Sources (Google Chrome), al ver el contenido de classes.js, se nos mostrará el contenido original y no el transpilado.

Herencia por prototipo

En JavaScript la herencia es una relación entre objetos. En los lenguajes orientados a clases la relación de herencia se establece entre las clases, pero en JavaScript la herencia se establece siempre entre objetos.

En el módulo dedicado a las novedades de notación de objeto se habla de los prototipos con más detalle. Aquí se incluye un pequeño resumen para completitud de este módulo.

Cadena de prototipos

Todo objeto en JavaScript tiene un prototipo asignado. El prototipo es un objeto tradicional, por lo que tiene a la vez su propio prototipo, lo que forma una cadena de objetos, cada uno prototipo del anterior. A esta cadena la llamamos “cadena de prototipado” (o cadena de prototipos). A pesar de que pueda parecer que es infinita, no lo es porque al final siempre hay un objeto cuyo prototipo es null.

Para implementar esta herencia prototípica, cuando llamamos a una propiedad o a un método de un objeto, el motor de JavaScript mira si dicha propiedad o método existe en el propio objeto, y si no lo encuentra lo buscará en su prototipo. Si en el prototipo no se encontrase esa propiedad (o método) se buscaría en el prototipo del prototipo, y así sucesivamente recorriendo toda la cadena de prototipado.

Ese es el mecanismo de herencia que tiene JavaScript: los métodos (y propiedades) de un objeto son heredados por cualquier otro objeto que esté “más abajo” en la cadena de prototipado.

Herencia en funciones constructoras

Un objeto construido a partir de la función constructora Foo (es decir usando new Foo()) tiene como prototipo al objeto Foo.prototype. Eso significa que todas las funciones o propiedades que estén en Foo.prototype se reciben por herencia a todos los objetos creados mediante esa función constructora. Observa que puedes añadir esas propiedades a Foo.prototype incluso después de haber creados objetos mediante la función Foo.

const Animal = function(name) {
    this.name = name;
}
let perro = new Animal("milu");
Animal.prototype.correr = function() {
    console.log(this.name + " está corriendo.");
}
perro.correr();

Este código imprime “milu está corriendo.” por la consola. Observa que el método correr se define en el objeto Animal.prototype y se hace después de haber creado el objeto perro.

Dada una función constructora (como Animal) la propiedad Animal.prototype es asignable. Es decir podemos crear un objeto y hacer que sea el prototipo de todos los objetos creados mediante una función constructora. Eso permite definir jerarquías de funciones constructoras:

const Animal = function(name) {
    this.name = name;
}
Animal.prototype.correr = function() {
    console.log(this.name + " está corriendo.");
}
const Perro = function(name) {
   this.name = name;
};
Perro.prototype = new Animal();
let milu = new Perro("milu");
milu.correr();

Este código también imprime “milu está corriendo.” por la consola. Observa que el método correr está disponible para un objeto creado a partir de Perro debido a que un objeto Animal se ha establecido como valor de Perro.prototype. Hemos simulado la herencia que tienen lenguajes basados en clases (es como si Perro heredase de Animal).

Hay ciertas peculiaridades en esta herencia: básicamente las llamadas a los constructores padres no se heredan (lo que sí ocurre en lenguajes basados en clases) así que tenemos una cierta “duplicación” de código. Observa como tanto la función Perro como la función Animal deben establecer la propiedad name. No tenemos manera, desde la función Perro de llamar al “constructor” base (que sería Animal). No existe, en este modelo, una palabra clave tipo base o super que sí tienen C# o Java.

En la siguiente lección veremos el modelo de clases que propone ECMAScript 2015 y cómo nos permitirá crear de forma más sencilla relaciones de herencia.

Clases en ECMAScript

El concepto de clases es de los que está menos soportado entre los navegadores. Si quieres probarlo en algún navegador que no lo soporte, repasa lo dicho sobre los transpiladores (como Babel).

Como se ha comentado, las clases son un mecanismo alternativo para definir cadenas de prototipado. Empecemos por ver la definición más sencilla de una clase:

class Foo {}

Esto declara una clase Foo vacía. Para crear objetos de una clase debemos usar forzosamente el operador new:

var foo = new Foo();

Para demostrar que realmente por debajo tenemos la herencia por prototipo clásica, observa la siguiente imagen:

Se define una clase A y luego se crea un objeto (a). Y finalmente se comprueba que:

  • En tiempo de ejecución el tipo de A es function (no existen clases en ejecución)
  • El tipo de a es object (no hay un tipo “A” en ejecución)
  • Y lo más relevante: el prototipo del objeto a es el objeto A.prototype que es el prototipo de todos los objetos creados mediante la función constructora A… o la clase A, ya que una clase no es nada más que un mecanismo alternativo para definir funciones constructoras.

Constructores

Las clases definen la pseudo-función constructor que es ejecutada al crear un nuevo objeto con new. Esa idea encaja con la idea de los lenguajes basados en clases, pero es nueva en ECMAScript. De todos modos, en el fondo el constructor viene siendo equivalente a la función constructora, cuyo código se ejecuta también al crear un objeto con new. La diferencia fundamental entre el constructor de una clase y una función constructora de JavaScript es que la segunda suele añadir las funciones y propiedades del objeto a this, mientras que con un constructor eso no es necesario. Veamos un ejemplo:

let Square = function(r) {
    this.size = r;
    this.area = function() {
        return this.size * this.size;
    }
}
let sq = new Square(10);
let area = sq.area();

Este código define una función constructora Square. Es un código clásico ECMAScript 5 y se puede ver cómo la función agrega a this la propiedad size y el método area(). Veamos ahora el código equivalente usando clases:

class Square {
    constructor(r) {
        this.size = r;
    }
    area() {
        return this.size * this.size
    }
}
let sq = new Square(10);
let area = sq.area();

Ambos códigos son “casi” equivalentes. La diferencia está que usando clases, el constructor lo dejamos solo para inicializar las propiedades de los objetos, pero las funciones (como area) no se declaran dentro del constructor, sino dentro de la clase.

Observa la definición reducida de funciones. No es necesario poner function para declarar una función: basta con el nombre y la lista de parámetros.

He dicho que los códigos son “casi” equivalentes. Hay de hecho en ellos una diferencia importante que se discutirá más adelante en la lección “Métodos tradicionales y propiedades”.

El operador instanceof

El uso de clases preserva el operador instanceof. Dicho operador espera un objeto y una función constructora o una clase y devuelve true si el objeto ha sido creado mediante dicha función constructora o es un objeto de dicha clase. Así, dado cualquiera de los códigos anteriores, la siguiente expresión devuelve true:

sq instanceof Square

El operador instanceof recorre la cadena de prototipo de un objeto, mirando si alguno de los prototipos es igual al prototipo de la clase o función constructora. En este caso nos devuelve true porque el prototipo de sq es Square.prototype. Del mismo modo la siguiente expresión también es true:

sq instanceof Object

El prototipo de sq es, como ya se ha dicho, Square.prototype que es un objeto cuyo prototipo es Object.prototype:

En resumen, hemos visto cómo las clases de ECMAScript ofrecen una sintaxis alternativa para las funciones constructoras. Veamos ahora algunas otras características.

DEMO: Definiendo clases y objetos

Veremos cómo crear clases en ECMAScript 2015.

class Uri {
    constructor(str) {
        this.rawUri = str
    }
}

Para crear objetos de esta clase, necesitaremos el operador new:

var url = new Uri('https://www.google.es');
typeof(url); // -> 'object'

// Las clases no existen en ejecución, todo son objetos.

// La herencia por prototipo:

url instanceof Uri; // -> true

uri.__proto__ === Uri.prototype

DEMO: Simulando la sobrecarga

JavaScript no tiene el concepto de sobrecarga de funciones, es decir, no podemos definir una misma función con parámetros distintos.

Gracias al uso de la desestructuración podemos simularlo.

Modificamos la clase para que se puedan crear objetos de otra manera:

class Uri {
    // Utilizaremos la desestructuración
    constructor({protocol, address}) {
        if(typeof(arguments[0]) === "string") {
            this.rawUri = arguments[0];
        } else {
            this.rawUri = protocol + '://' + address;        
        }
    }
}

Ahora podremos crear objetos de dos maneras:

var url = new Uri('https://www.google.es');
 
var url2 = new Uri({
    protocol: 'https',
    address: 'www.google.es'
)};  
 
console.log(url.rawUri); // -> 'https://www.google.es'
console.log(url2.rawUri); // -> 'https://www.google.es'

Métodos estáticos

Una de las características típicas de los lenguajes basados en clases es el uso de métodos estáticos. Éstos son métodos que pertenecen a la clase y no a un objeto en particular. Se pueden ejecutar sin necesidad de tener ninguna instancia de la clase y no pueden acceder a ningún método (o propiedad) que no sea estático.

JavaScript también permite definir métodos estáticos, pero del mismo modo que las clases de JavaScript son de naturaleza muy distinta a las clases de C++, Java o C#, también los métodos estáticos en JavaScript funcionan de forma muy distinta.

Funcionamiento de los métodos estáticos

Lo primero que uno se pregunta sobre los métodos estáticos es ¿dónde existen? Recuerda que las clases no existen en tiempo de ejecución, así que los métodos estáticos ¿dónde quedan definidos? Veamos, primero, un código que define un método estático:

class Square {
    static Dump() { console.log("I am a Square")}
}

¿Cómo podemos llamar al método Dump()? Si creamos un objeto Square y llamamos al método no va a funcionar:

let sq = new Square();
sq.Dump();          // TypeError: sq.Dump() is not a function

Los métodos estáticos no se llaman nunca a través de un objeto, sino directamente a través de la clase. Así el código correcto es:

Square.Dump();       // I am a Square

Volviendo a nuestra pregunta, al final en JavaScript todo son objetos. Así que… ¿Cuál es el objeto que termina conteniendo a la función estática?

Pues sencillamente en la propia función constructora definida por la clase. Las funciones en JavaScript son objetos y por lo tanto pueden tener propiedades, que pueden ser métodos (es decir otras funciones). Observa el siguiente código ECMAScript 5:

let Square = function() {};
Square.Dump = function() {
    console.log("I am a Square"); 
}
let sq = new Square();
sq.Dump();              // Type Error: sq.Dump() is not a function
Square.Dump();          // I am a Square

Este código simula lo que hace un método static en una clase ECMAScript 2015. De hecho, en ambos códigos (tanto este como el anterior con la clase y el método estático), el siguiente código devuelve true:

Square.hasOwnProperty("Dump")

Recuerda, las funciones en JavaScript son objetos. Y un objeto puede tener propiedades y métodos. Así pues, las funciones pueden tener métodos asociados. La palabra clave static añade un método (o propiedad) a la propia función constructora definida por la clase.

this en métodos estáticos

Una pregunta legítima que nos podemos hacer es ¿cuál es el valor de this dentro de un método estático?. Dentro de un método normal de una clase, el valor de this es el propio objeto, pero en un método estático no hay objeto.

La respuesta, en el fondo es muy lógica y simple: El valor de this dentro de un método estático es la propia función constructora generada por la clase. Observa el siguiente código:

class Square {
    static WhoAmI() { return this; }
}
Square.WhoAmI() === Square;         // true

En resumen, una clase nos permite definir una función constructora con otra sintaxis, y los métodos estáticos de una clase son métodos que se agregan a la función constructora definida por la clase, en lugar de a los objetos.

Métodos tradicionales y propiedades

Entremos ahora a considerar algunas preguntas referentes a los métodos tradicionales y a las clases. Retomemos el código visto anteriormente:

class Square {
    constructor(r) {
        this.size = r;
    }
    area() {
        return this.size * this.size
    }
}

¿Dónde se define la propiedad size? En este caso responder te debería resultar sencillo: estamos asignando la propiedad a this, por lo que la propiedad se define en el objeto que estamos creando. Pero… ¿y la función area()? ¿Dónde se define? Observa que hay dos posibilidades: La primera que se defina en el propio objeto que se está creando y la segunda es que se defina en Square.prototype

La respuesta es que el método se define en el objeto Square.prototype.

Eso tiene toda la lógica del mundo: no hay necesidad de duplicar el método en todos los objetos Square, no es necesario que cada objeto Square contenga una copia idéntica del mismo método. Lo más óptimo es colocar el método en Square.prototype que es el prototipo de todos los objetos creados con new Square.

En la lección “Clases en ECMAScript” se mostró el siguiente código ECMAScript 5 y se comentó que era equivalente al de esta clase Square:

let Square = function(r) {
    this.size = r;
    this.area = function() {
        return this.size * this.size;
    }
}

También se mencionaba que había una diferencia importante entre el código ECMAScript 5 y la versión usando clases. Bien, ahora ya sabes la diferencia: este código ECMAScript 5 está creando el método area en cada objeto. El código realmente equivalente sería:

let Square = function(r) {
    this.size = r;
}
Square.prototype.area = function() {
    return this.size * this.size;
}

Resumiendo, usar clases es equivalente a usar funciones constructoras, teniendo presente que los métodos se añaden al objeto prototipo de la función constructora y no a cada uno de los objetos creados.

DEMO: Definiendo métodos de instancia

Partimos de la siguiente clase definida anteriormente:

class Uri {
    constructor({protocol, address}) {
        if(typeof(arguments[0]) === "string") {
            this.rawUri = arguments[0];
        } else {
            this.rawUri = protocol + '://' + address;        
        }
    }
}

Crearemos un método que nos permita navegar a la URI. La creación de métodos de instancia de una clase es tan simple como declarar el nombre de método, parámetros y su cuerpo:

class Uri {
    constructor({protocol, address}) {
        if(typeof(arguments[0]) === "string") {
            this.rawUri = arguments[0];
        } else {
            this.rawUri = protocol + '://' + address;        
        }
    }
 
    navigate() {
        document.location.href = this.rawUri;
    }
}

Probamos:

var url = new Uri("https://www.google.es")
 
var url2 = new Uri({
    protocol: 'https',
    address: 'www.google.es'
});
 
url.rawUri; // 'https://www.google.es'
 
url.navigate(); // Se abrirá la web de Google.

Uri.protoype es el objeto que se utiliza como prototipo para todos los objetos de la clase Uri, y al agregar un método a la clase Uri, estamos agregando el método a Uri.prototype:

url.__proto__ === Uri.prototype

DEMO: Definiendo getters

Veremos cómo declarar propiedades y sus getters para obtener sus valores.

Partimos de nuestra definición de la clase Uri y añadiremos getters:

class Uri {
    constructor({protocol, address}) {
        if(typeof(arguments[0]) === "string") {
            this.rawUri = arguments[0];
            var idx = this.rawUri.indexOf("://");
            this._address = this.rawUri.substring(idx + 3);
            this._protocol = this.rawUri.substring(0, idx);
 
        } else {
            this.rawUri = protocol + '://' + address;        
 
            this.protocol = protocol;
            this.address = address;
        }
    }
 
    navigate() {
        document.location.href = this.rawUri;
    }
 
    // getter
    get protocol() {
        return this._protocol;
    }
 
    get address() {
        return this._address;
    }    
}

Probamos:

var url = new Uri("https://www.google.es");
 
url.address; // 'www.google.es'

DEMO: Definiendo setters

Veremos cómo declarar setters para establecer el valor de una propiedad.

Partimos de nuestra definición de la clase Uri:

class Uri {
    constructor({protocol, address}) {
        if(typeof(arguments[0]) === "string") {
            this.rawUri = arguments[0];
            var idx = this.rawUri.indexOf("://");
            this._address = this.rawUri.substring(idx + 3);
            this._protocol = this.rawUri.substring(0, idx);
 
        } else {
            this.rawUri = protocol + '://' + address;        
 
            this.protocol = protocol;
            this.address = address;
        }
    }
 
    navigate() {
        document.location.href = this.rawUri;
    }
 
    // getter
    get protocol() {
        return this._protocol;
    }
 
    get address() {
        return this._address;
    }    
}

Vamos a crear un setters que permita cambiar la dirección de una Uri, pero no su protocolo:

class Uri {
    constructor({protocol, address}) {
        if(typeof(arguments[0]) === "string") {
            this.rawUri = arguments[0];
            var idx = this.rawUri.indexOf("://");
            this._address = this.rawUri.substring(idx + 3);
            this._protocol = this.rawUri.substring(0, idx);
 
        } else {
            this.rawUri = protocol + '://' + address;        
 
            this.protocol = protocol;
            this.address = address;
        }
    }
 
    navigate() {
        document.location.href = this.rawUri;
    }
 
    // getter
    get protocol() {
        return this._protocol;
    }
 
    get address() {
        return this._address;
    }    
 
    // setter
    set address(value) {
        if (typeof(value) === 'string' && value !== "") {
            this._address = value;
            return this._address;
        }
 
    }
}

Probamos:

var url = new Uri("https://www.google.es");
 
url.address; // 'www.google.es'
 
url.address = "www.bing.com";
 
url.address; // 'www.bing.com'

La propiedad protocol sigue siendo de solo lectura porque no hemos definido setter para dicha propiedad.

Herencia con clases

Una de las características más interesantes de las clases en JavaScript es que hace explícita la herencia. Hemos visto cómo funciona la herencia con funciones constructoras, pero al no ser un mecanismo incorporado de por sí en el lenguaje, hace que muchos desarrolladores lo desconozcan. Las clases incorporan la herencia dentro del lenguaje y simplifican su uso.

La sintaxis para definir una relación de herencia es muy simple:

class Square {
    constructor(r) {
        this.size = r;
    }
    area() {
        return this.size * this.size
    }
}
class PaintedSquare extends Square {}

Hemos definido una relación de jerarquía entre la clase PaintedSquare y la clase Square utilizando la palabra clave extend. A nivel de prototipos lo que tenemos es lo siguiente:

  • El prototipo de un objeto creado con new PaintedSquare es el objeto PaintedSquare.prototype
  • El prototipo de PaintedSquare.prototype es el objeto Square.prototype

Cadena de constructores

El constructor es el método encargado de inicializar un determinado objeto. En el caso de JavaScript el constructor crea las propiedades que tiene el objeto y las inicializa. Esas propiedades están creadas en el propio objeto (se asignan a this) y por lo tanto no son heredadas por un objeto de una clase derivada (recuerda que un objeto PaintedSquare no hereda realmente de un objeto Square sino del objeto Square.prototype).

Pero se quiere que el objeto PaintedSquare tenga también las propiedades definidas en Square (como size). Para ello necesitamos que cuando se cree un objeto PaintedSquare se invoque también el constructor de la clase base, es decir el constructor de Square. De esa manera al invocarse el constructor de la clase base (Square) se crearán las propiedades “heredadas” (en este caso size).

Al hecho de que el constructor de una clase derivada llame al constructor de su clase base es a lo que nos referimos con el concepto de “cadena de constructores”.

Llamando al constructor de la clase base

Si la clase derivada no define un constructor, la llamada al constructor de la clase base se realiza automáticamente.

Si en una clase derivada no definimos un constructor, entonces es como si tuviese el siguiente constructor definido:

constructor(...args) {
    super(...args);
}

Pero si definimos un constructor en la clase derivada, debemos llamar nosotros al constructor de la clase base. Para ello usamos la palabra clave super:

class PaintedSquare extends Square {
    constructor(r, color) {
        super(r);
        this.color = color;
    }
}

Observa la llamada al constructor de la clase base (Square) usando super en el constructor de la clase derivada.

Esa llamada “no es opcional”. Si no la realizamos, el valor de this no está definido (lo que provocará un error si intentamos crear un objeto del tipo derivado).

Por lo tanto… ¡no te olvides de invocar al constructor de la clase base desde el constructor de la clase derivada!. Recuerda: no es opcional.

DEMO: Herencia con clases

Partimos de nuestra clase:

class Uri {
    constructor({protocol, address}) {
        if(typeof(arguments[0]) === "string") {
            this.rawUri = arguments[0];
            var idx = this.rawUri.indexOf("://");
            this._address = this.rawUri.substring(idx + 3);
            this._protocol = this.rawUri.substring(0, idx);
 
        } else {
            this.rawUri = protocol + '://' + address;        
 
            this.protocol = protocol;
            this.address = address;
        }
    }
 
    navigate() {
        document.location.href = this.rawUri;
    }
 
    // getter
    get protocol() {
        return this._protocol;
    }
 
    get address() {
        return this._address;
    }    
 
    // setter
    set address(value) {
        if (typeof(value) === 'string' && value !== "") {
            this._address = value;
            return this._address;
        }
 
    }
}

Ahora crearemos otra clase que derive de ella, por ejemplo, una clase que haga que una URI vaya siempre a través del protocolo HTTP:

class HttpUri extends Uri {
    constructor(address) {
        // llamamos al constructor de la clase "base"
        super({protocol: "http", address: address});
    }
 
}

Probamos:

var campusmvp = new HttpUri("www.campusmvp.es");
 
campusmvp.navigate(); // La clase "hija" hereda métodos del padre.
 
campusmvp instanceof HttpUri; // true
 
campusmvp instanceof Uri; // true
 
campusmvp.__proto__ === HttpUri.prototype; // true
 
campusmvp.__proto__.__proto__ === Uri.prototype; // true

Herencia de métodos estáticos

Los métodos estáticos también se heredan. Así, el siguiente código funciona correctamente e imprime “I am a Square” por la consola:

class Square {
    constructor(r) {
        this.size = r;
    }
    area() {
        return this.size * this.size
    }
    static WhoAmI() {console.log("I am a Square")}
}
class PaintedSquare extends Square {
    constructor(r, color) {
        super(r);
        this.color = color;
    }
}
PaintedSquare.WhoAmI();

El método WhoAmI es estático en la clase Square pero se hereda en la clase PaintedSquare. Eso es interesante porque nos revela otra relación entre los objetos finales que se terminan creando. Recuerda que los métodos estáticos son métodos que se agregan directamente a la función constructora (en este caso Square), a diferencia de los métodos no estáticos que se agregan al prototipo de la función (p. ej. Square.prototype). Así, para que la herencia de métodos estáticos funcione es necesario que la función Square sea el prototipo de la función PaintedSquare.

Conseguir un efecto similar en ES5 (que una función sea prototipo de otra) se puede lograr (con un poco de “trampa”). El siguiente código ES5 intenta imitar el código anterior:

var Square = function(r) {
  this.size = r;
}
 
Square.prototype.area = function() {
  return this.size * this.size;
}
 
Square.WhoAmI = function()  {
  console.log("I am Square");
}
 
var PaintedSquare = function(r, color) {
  Square.call(this, r);
  this.color = color;
};
 
PaintedSquare.prototype = Object.create(Square.prototype);
PaintedSquare.__proto__ = Square;
 
var painted = new PaintedSquare(10, "red");
console.log(painted.area());    // 100
PaintedSquare.WhoAmI();         // "I am Square"

La línea que simula la herencia de los métodos estáticos es la que utiliza la propiedad __proto__ para asignar el prototipo de la función PaintedSquare para que ésta apunte a la propia función Square.

Como he dicho antes, eso es hacer un poco de “trampa”, porque realmente la propiedad __proto__ no forma parte del estándar de ES5, así que no tiene por qué estar soportada y si lo está no tiene por qué ser de lectura y escritura.

En ECMAScript 2015, __proto__ sí que está definido, pero está considerada obsoleta, ya que los métodos Object.getPrototypeOf y Object.setPrototypeOf nos dan la misma funcionalidad y son los que deben usarse.

Si no se puede usar __proto__ para cambiar el prototipo de la función, entonces deberíamos recurrir a técnicas más complejas para simular los métodos estáticos en ES5.

Así pues observa que, dadas las dos clases anteriores, si creamos un objeto painted de la clase PaintedSquare el conjunto de objetos que tenemos es:

  • El objeto painted creado
  • El objeto PaintedSquare.prototype que es el prototipo de painted
  • El objeto Square.prototype que es el prototipo del PaintedSquare.prototype
  • La función PaintedSquare
  • La función Square que es prototipo de la función PaintedSquare

Clases y funciones constructoras

Hemos visto como las clases en ECMAScript 2015 terminan definiendo funciones constructoras. Así, pues, dado que una clase es realmente una función, no debe sorprenderte que lo siguiente sea posible:

const Square = class {
    constructor(r) {
        this.size = r;
    }
}
let sq = new Square(10);

Este código crea una clase anónima y la asignamos a la variable (en este caso a la constante) Square. Eso mismo lo venimos haciendo habitualmente en funciones, así que… ¿por qué no hacerlo en clases? La diferencia entre ambos métodos es que uno crea una variable y el otro no. Pero en ambos casos no podrás crear objetos de la clase hasta después de que ésta haya sido declarada.

Herencia de función constructora

Si una clase termina generando una función constructora, en el fondo la herencia entre clases es herencia entre funciones constructoras. Así que no nos debe extrañar que una clase pueda derivar de una función constructora:

const Square = function(r) {
    this.size = r;
}
Square.prototype.area = function() {
    return this.size * this.size;
}
const PaintedSquare = class extends Square {
    constructor(r, color) {
         super(r);
         this.color = color;
    }
}
let painted = new PaintedSquare(10, "red");
painted.area();         // 100

Observa como Square es una función constructora tradicional (a lo ES5) y PaintedSquare es una clase de ES6, que hereda de ésta. No hay ningún problema en ello, ya que (permíteme que insista) una clase termina siendo realmente una función constructora.

Heredando de los tipos built-in

Hasta ahora hemos visto lo fundamental de las clases: cómo se declaran, cómo funcionan internamente, que realmente son funciones constructoras “disfrazadas” y cómo funciona la herencia y los métodos estáticos. Si conoces Java, C# o C++, a esas alturas ya debes tener claro que las clases en JavaScript son “otra historia”.

Ahora vamos a centrarnos en ostros aspectos más “avanzados” de las clases.

Algunos de los ejemplos que vamos a ver requieren realmente un soporte por parte del motor de JavaScript que no se puede imitar o reproducir. Así ni con transpiladores ni con polyfills vas a poder reproducir algunos de esos ejemplos. Necesitarás un entorno ES2015 que soporte clases para probarlos.

Herencia de Arrays

Derivar de Arrays siempre ha sido posible “a priori”, incluso en ES5:

var myArr = Object.create(Array.prototype);
myArr.push(10);
myArr.push(20);
console.log(myArr.length);          // 2

A priori parece que el código funciona, pero solo a priori:

var myArr = Object.create(Array.prototype);
myArr[0] = 10;
myArr[1] = 20;
console.log(myArr.length);        // 0!!!!!!

Este código imprime 0, en lugar de 2 por la consola. ¿Qué ha sucedido aquí? El problema es que los arrays en JavaScript son lo que llamamos “objetos exóticos”. Esos objetos son objetos que poseen “características especiales” fuera de los objetos tradicionales. En el caso de los Arrays esa característica es la propiedad length. Esa propiedad hace un seguimiento “automático” del número de elementos del array.

En general no es posible convertir un objeto normal en un objeto exótico, ya que esos dependen del soporte especial del motor de JavaScript. En este caso el objeto myArr es un objeto normal, por lo que nunca se podrá comportar como un array “real”, por más que comparta el mismo prototipo. Simplemente, no es un array. Y no hay nada que se pueda hacer…

… hasta la llegada de ECMAScript 2015 y las clases. Puesto que una clase puede extender un tipo built-in, aunque ese tipo sea exótico, y heredará todo el comportamiento exótico:

const MyArray  = class extends Array {
    constructor(len) {
        super(len);
    }
}
let myArr = new MyArray(0);
myArr[0] = 10;
myArr[1] = 20;
console.log(myArr.length);      // 2 (¡Viva ECMAScript 2015!)

Este código se comporta como se espera e imprime 2 en la consola. Un hurra por ECMAScript 2015 porque eso en ECMAScript 5 es imposible de hacer. Sí, las clases son “azúcar sintáctico” sobre las funciones constructoras, pero ¡vienen con sus pequeños añadidos!

En general puedes heredar de cualquier tipo built-in y ganar sus peculiaridades (p. ej. el tipo Error en muchos motores contiene la pila de llamadas, que también es un comportamiento exótico).

Expresiones en extends

Hasta ahora hemos usado extends de la manera más simple posible: para indicar la clase o función constructora de la cual heredar. Pero extends puede referirse a cualquier expresión, siempre y cuando el resultado de la misma se evalúe a una clase o a una función constructora.

Veamos un ejemplo de ello:

const Triangle = class {}
const Square = class {}
const BaseFig = function(i) {
  if (i == 2) return Square;
  else return Triangle;
}
const Painted = class extends BaseFig(2) {
  constructor() {
    super();
    console.log('I am square ->', this instanceof Square);
    console.log('I am triangle ->' ,this instanceof Triangle);
  }
}
var p = new Painted();

Este código imprime "I am square -> true" por la consola, ya que la clase Painted hereda de la clase Square (y no Triangle) porque eso es lo que devuelve la llamada a BaseFig(2).

Esta característica tiene numerosas posibilidades, pero la más llamativa es que eso nos permite tener un sistema integrado en el lenguaje para crear mixins.

En los lenguajes de programación orientada a objetos, un mixin es una clase que ofrece cierta funcionalidad diseñada para extender a otra clase para dotarla de especialización, pero sin realizar verdadera herencia entre ellas. Es una manera de “mezclar” funcionalidad de una clase en otra, pero sin extenderla realmente.

Creando mixins con clases ES2015

Existen varias maneras de crear mixins en ES5, cada una con sus peculiaridades, pero las clases nos proporcionan un nuevo mecanismo, que nos permite de forma sencilla crear mixins, combinarlos y todo ello manteniendo relaciones de herencia.

Supongamos que tenemos un mixin, llamado Button que añade una función para notificar eventos de pulsación. Queremos aplicar este mixin a cualquier posible clase. Para ello empecemos por definir una función que reciba una clase y devuelva otra clase:

const Button = (sc) => class extends sc {
    OnClick(cc) {
        console.log('clicked'); 
        cc();
    }
}

A esas alturas ya no debería sorprenderte que una función reciba una clase (sc) como parámetro y devuelva otra como valor de retorno: recuerda que las clases son al final funciones y en JavaScript pasar funciones como parámetro y devolverlas es lo más natural.

Una vez tenemos esa función podemos usarla en una cláusula extends:

const Square = class {
    constructor(r) {
        this.size = r;
    }
    area() {
        return this.size * this.size;
    }
}
const ButtonSquare = class extends Button(Square) {}
 
let bsquare = new ButtonSquare(10);
console.log(bsquare.area());     // 100;
bsquare.OnClick(() => {});      // 'clicked'

Observa como la clase ButtonSquare es el resultado de aplicar el mixin Button a la clase Square. Ese sistema es muy potente y sencillo y permite de forma fácil crear mixins reutilizables y aplicarlos en varias clases.

Métodos generadores

Una clase en ES2015 puede definir un iterador. Eso convierte a los objetos de esa clase en iterables:

const Line = class {
    constructor(...points) {
        this.points = points;
    }
 
    *[Symbol.iterator]() {
        for (let p of this.points) {
            yield p;
        }
    }
} 
var l = new Line(10, 20, 30);
for (let p of l) {
    console.log(p)
}

Observa que hemos definido el iterador (método Symbol.iterator) usando un generador (observa el asterisco antes del nombre del método), lo que nos permite usar yield para ir generando los valores del iterador. Observa también que en el constructor usamos un parámetro rest (…points) para convertir los n parámetros pasados al construir el objeto en un array de n valores. Finalmente usamos un bucle Symbol.iterator).

Supongamos ahora que además de la clase Line definimos una clase Figure como sigue:

const Figure = class {
    constructor(...lines) {
        this.lines = lines
    }
        *[Symbol.iterator]() {
        for (let l of this.lines) {
            yield l;
        }
    }
 
    get points() {
      return (function *() {
        for (let l of this.lines) {
           yield* l 
        }
      }.bind(this))();
    }
}

A pesar de que el código pueda parecer complejo, no muestra nada que no hayamos visto ya.

Por un lado la clase Figure toma en el constructor un conjunto de líneas (y las guarda en la propiedad lines, que es un array). Por otro lado define un iterador (usando un generador) que permite iterar sobre las líneas de la figura, y luego define un getter llamado points. Centremos nuestra atención en este getter.

El código es un poco confuso porque la sintaxis de ECMAScript 2015 no permite crear getters usando generadores. Así el getter es una función normal (observa que no hay asterisco antes de points). Esa función devuelve el resultado de invocar una función anónima interna. Esa función interna sí que es un generador (observa el uso de function*) que itera sobre cada una de las líneas, y devuelve todos los valores de cada línea usando su iterador (yield*). Eso nos devuelve una colección de todos los puntos de la figura.

La llamada a bind es necesaria para que dentro de la función anónima el valor de this sea el correcto (lamentablemente no podemos usar una arrow function para declarar un generador lo que nos ahorraría el bind).

Así podemos iterar por todos los puntos de todas las líneas de una figura:

for (let p of f.points) {
    console.log('point ->', p)
}

Si en lugar de querer usar un getter nos basta con un método normal, entonces la sintaxis se simplifica y la declaración de points nos quedaría como la siguiente:

*points() {
    for (let l of this.lines) {
        yield* l 
    }    
}

La diferencia es que ahora debemos iterar por f.points() en lugar de por f.points.

Métodos y propiedades privadas

Hasta ahora no hemos hablado de la visibilidad de los métodos y/o propiedades declaradas en una clase. La verdad es que el lenguaje no nos da mecanismo alguno para definir la visibilidad: todos los métodos o propiedades de una clase son públicos.

Pero por supuesto, que no exista un mecanismo incorporado no significa que no se puedan simular los métodos o propiedades privadas. Vamos a ver tres maneras de hacerlo.

Ocultando propiedades al estilo ECMAScript 5

Antes que nada: esta técnica se muestra por completitud, pues no es recomendable su uso, por existir mejores maneras de hacerlo.

En este método usamos el constructor como ámbito, las variables y métodos privados son declarados dentro del constructor y las funciones del objeto son declarados como clausuras:

const Square = class {
    constructor(r) {
        var size = r;
        Object.defineProperty(this, 'size', {
            get() { return size;}
        });
        this.area = function() {
            return size * size;
        }
    }
}
let sq = new Square(10);
console.log(sq.area());      // 100
console.log(sq.size);        // 10
sq.size = 20;   // Ignorado
console.log(sq.size);        // 10

Acabamos de declarar la variable size como privada. En su lugar tenemos una propiedad (llamada también size) para acceder al valor de dicha variable, pero solo tiene getter, por lo que no se puede modificar el valor. Finalmente el método area() está agregado a this por lo que es público.

Este método tiene un hecho a comentar y es que agrega los métodos (y propiedades) públicas a this. La herencia funciona debido a que el constructor de la clase derivada llama al constructor de la clase base:

const PaintedSquare = class extends Square {}
let painted = new PaintedSquare(10);
console.log(painted.area());

No obstante ahora cada objeto de tipo Square y de tipo PaintedSquare tiene su propia copia de los métodos. Deberíamos colocar los métodos en Square.prototype en lugar de en this:

const Square = class {
    constructor(r) {
        var size = r;
        Object.defineProperty(Square.prototype, 'size', {
            get() { return size;}
        });
        Square.prototype.area = function() {
            return size * size;
        }
    }
}
const PaintedSquare = class extends Square {}

De todos modos como se ha comentado antes, no uses este sistema. ¡Hay mejores maneras en ECMAScript 2015 de hacerlo y vamos a verlas ahora!

Métodos y propiedades privadas (ii) - Símbolos

Otra posibilidad para simular métodos y propiedades privadas es usando símbolos. Dado que un símbolo se puede utilizar como nombre de propiedad y solo es igual a sí mismo, si usamos símbolos para propiedades o métodos y ocultamos esos símbolos, no se podrá acceder a las propiedades o métodos cuyo nombre sea uno de esos símbolos.

Ya vimos el uso de símbolos para crear variables privadas en el módulo dedicado a los símbolos. En esta lección se cuenta cómo aplicarlo a clases.

Eso plantea el interrogante de cómo ocultar los símbolos: necesitamos acceder a ellos cuando declaramos la clase (puesto que vamos a usarlos como nombre de las propiedades) pero fuera de la declaración de la clase no queremos que se pueda acceder a dichos símbolos.

La solución pasa por emplear una función que envuelva la declaración de los símbolos y la declaración de la clase. Esa función proporciona el ámbito local en el que se declaran los símbolos. Fuera de la función los símbolos no son accesibles, pero dentro de la función (que es donde declaramos la clase) sí:

const Square = (function() {
    var SizeSym = Symbol("size");
    return class {
        constructor(r) {
            this[SizeSym] = r;
        }
 
        area() { 
            return this[SizeSym] * this[SizeSym];
        }
 
        get size() {
            return this[SizeSym];
        }
    }
})();

Observa que la función es anónima y autoejecutable y que devuelve una clase (anónima). El valor devuelto por la función (la clase) lo guardamos en la constante Square. Dentro de la función anónima el símbolo SizeSym existe y lo usamos para guardar la variable privada. Luego definimos el método area y el getter size. Ahora ya podemos usar la sintaxis normal de clases y no necesitamos usar Object.defineProperty para el getter como nos sucedía en el método anterior. Ahora podemos utilizar la clase Square de forma totalmente normal:

let sq = new Square(10);
console.log(sq.area());      // 100
sq.size = 20;      // ignorado
console.log(sq.size);      // 10

No hay manera de acceder a la variable almacenada en sq[SizeSym] desde fuera de la función anónima autoejecutable porque necesitamos el símbolo SizeSym para ello, al cual no podemos acceder.

Accediendo a las variables "privadas"

Aunque te parezca que el uso de símbolos evita que cualquiera pueda acceder a las variables privadas de los objetos eso no es cierto. La propiedad existe en el objeto, solo que oculta bajo un nombre que no podemos replicar (el símbolo). El método Object.getOwnPropertyNames no itera sobre los símbolos, así que estamos protegidos de este método también. Pero existe el método Object.getOwnPropertySymbols que nos devuelve todos los símbolos usados como nombres de propiedades o métodos de un objeto:

let symbols = Object.getOwnPropertySymbols(sq);
sq[symbols[0]] = 2;         // muahahahaha!!!
console.log(sq.area());     // 4

Mediante Object.getOwnPropertySymbols obtenemos un array con todos los símbolos empleados y en este caso usamos el único que existe para acceder a la variable “privada” y modificarla.

Así, la ocultación por símbolos te evita que alguien pueda acceder accidentalmente a una propiedad o método privado y lo oculta de la lista de propiedades y métodos que se pueden invocar, pero no impide, que un desarrollador que realmente quiera pueda, acceder a la propiedad o método supuestamente “privado”.

Si eso es una preocupación y quieres variables totalmente privadas a las que sea totalmente imposible acceder desde fuera de la declaración de la clase… hay una manera de hacerlo en ECMAScript 2015.

Métodos y propiedades privadas (iii) - Weak maps

Vamos a ver ahora la tercera opción para crear métodos o propiedades privadas en nuestras clases. Este método crea propiedades o métodos totalmente privados (a los que no se puede acceder de ninguna manera) y se basa en el uso de un objeto WeakMap.

Este objeto va a mantener todo el estado privado de todos los objetos de un determinado tipo:

const Square = (function() {
    const privates = new WeakMap();
    return class {
        constructor(r) {
            privates.set(this, {size: r});
        }
 
        area() {
            let size = privates.get(this).size;
            return size * size;
        }
 
        get size() {
            return privates.get(this).size;
        }
    }
})();

Al igual que antes, necesitamos una función anónima autoejecutable para ocultar el WeakMap. Como claves del WeakMap usamos el propio objeto que se crea. Y como valor usamos un objeto que contendrá todas las propiedades y métodos privados del objeto.

¿Y esto… no tiene memory leaks? Es decir, estamos agregando elementos a un WeakMap, pero nunca los quitamos. La clave está en recordar que un WeakMap no impide que el garbage collector de ECMAScript elimine un objeto si solo se está usando como clave del mapa. Eso significa que cuando el objeto deja de ser accesible por el código (es decir cuando ya no hay ninguna variable que acceda a él) el garbage collector lo podrá eliminar y la entrada será eliminada del WeakMap. Observa que si en lugar de un WeakMap usaras un Map normal, entonces sí que tendríamos un memory leak ya que cada objeto creado ¡quedaría guardado para siempre en el mapa!

Por supuesto la clase Square la usamos de forma normal y ahora no hay manera alguna de acceder a la variable privada:

let sq = new Square(10);
console.log(sq.area());      // 100
sq.size = 20;      // ignorado
console.log(sq.size);      // 10

DEMO: Miembros privados

Veamos cómo podemos declarar variables completamente privadas, que nadie pueda cambiar.

Partimos de nuestra clase:

class Uri {
    constructor({protocol, address}) {
        if(typeof(arguments[0]) === "string") {
            this.rawUri = arguments[0];
            var idx = this.rawUri.indexOf("://");
            this._address = this.rawUri.substring(idx + 3);
            this._protocol = this.rawUri.substring(0, idx);
 
        } else {
            this.rawUri = protocol + '://' + address;        
 
            this._protocol = protocol;
            this._address = address;
        }
    }
 
    navigate() {
        document.location.href = this.rawUri;
    }
 
    // getter
    get protocol() {
        return this._protocol;
    }
 
    get address() {
        return this._address;
    }    
 
    // setter
    set address(value) {
        if (typeof(value) === 'string' && value !== "") {
            this._address = value;
            return this._address;
        }
 
    }
}

Primero debemos tener un ámbito por encima utilizando una función anónima autoejecutable:

(function() {
 
// aquí iría nuestra clase
 
})();

Y ahora utilizaríamos un WeakMap para mantener las variables privadas de nuestra clase:

(function() {
 
    let _privates = new WeakMap();
 
    // la clase Uri
 
})();

Y ahora haremos las modificaciones necesarias en la clase:

// Utilizamos desestructuración para conseguir el objeto con las dos
// funciones constructoras
var {Uri, HttpUri} = 
 
(function() {
 
    let _privates = new WeakMap();
 
    class Uri {
        constructor({protocol, address}) {
            // El objeto vacío contendrá todos los valores privados del objeto
            // que estamos creando. En este caso queremos que sean 'address' y 
            // 'protocol'
            _privates.set(this, {});
            if(typeof(arguments[0]) === "string") {
                this.rawUri = arguments[0];
                var idx = this.rawUri.indexOf("://");
 
                let pr = _privates.get(this);
                pr._address = this.rawUri.substring(idx + 3);
                pr._protocol = this.rawUri.substring(0, idx);
 
            } else {
                this.rawUri = protocol + '://' + address;        
 
                let pr = _privates.get(this);
                pr._address = this.rawUri.substring(idx + 3);
                pr._protocol = this.rawUri.substring(0, idx);
            }
        }
 
        navigate() {
            document.location.href = this.rawUri;
        }
 
        // getter
        get protocol() {
            return = _privates.get(this)._protocol;
        }
 
        get address() {
            return = _privates.get(this)._address;
        }    
 
        // setter
        set address(value) {
            if (typeof(value) === 'string' && value !== "") {
                let pr = _privates.get(this);
                pr._address = value;
                return pr._address;
            }
 
        }
    }
 
    // Devolvemos un objeto con dos propiedades, que serán las clases
    return {Uri: Uri, HttpUri: HttpUri};
 
})();

Probamos:

var url = new Uri("http://www.google.es");
 
url._address; // undefined
 
url.address; // 'www.google.es'
 
url.protocol; // 'http'
 
// No podemos modificar 'protocol' porque no tenemos un setter:
url.protocol = "ftp";
 
url.protocol; // 'http'

Recursos

informatica/programacion/cursos/programacion_avanzada_javascript/clases_programacion_orientada_objetos_es6.txt · Última modificación: por tempwin