Javascript, parte 8: Funciones constuctoras y clases

En la publicación pasada vimos cómo crear objetos mediante el formato JSON:

let gato = {
    nombre: "Mikorin",
    peso: 5,
    castrado: true,
    maullar(){
        console.log("Miau");
    },
    comer(){
        this.peso += 0.5;
    },
    jugar(){
        this.peso -= 0.5;
    }
};

Ésta es una forma rápida que nos provee Javascript de crear objetos, aunque en ciertas ocasiones necesitamos moldear estructuras más complejas, de forma que incluso puedan ser reutilizadas en objetos con las mismas funcionalidades, pero distinta información.

Funciones contructoras

Las funciones tradicionales (con la palabra reservada function) fueron una alternativa a las clases, que hace unos años no existían en javascript.

Podemos diseñar una estructura con una función y luego mediante la palabra reservada new, crear objetos basados en ésta, como si se tratara de clases (si no sabés lo que es una clase, más abajo lo vemos):

function NombreDeFuncion(){
    //Acá van las propiedades y métodos.
}

let objeto = new NombreDeFuncion();

Por ejemplo podríamos replicar lo que habíamos hecho en la publicación pasada:

function Gato(nombre, peso){

    this.nombre = nombre;
    this.peso = peso;

    this.maullar = function(){
        console.log("Miau");
    }

    this.comer = function(){
        this.peso += 0.5;
    }

    this.jugar = function(){
        this.peso -= 0.5;
    }

}

Como podemos ver en el ejemplo, mediante la palabra reservada this podemos acceder a las propiedades y métodos del objeto. Y luego crearlos de la siguiente manera:

let gato1 = new Gato("Mikorin", 7);
let gato2 = new Gato("Leona", 3);

De esta manera instanciamos dos objetos que compartirán propiedades y métodos, aunque los valores de las propiedades serán distintos (tienen nombres y pesos distintos)

//Creamos dos objetos de tipo gato y les pasamos como parámetros el nombre y peso respectivamente.
let gato1 = new Gato("Mikorin", 7);
let gato2 = new Gato("Leona", 3);

//Mostramos los nombres y pesos de ambos gatos.
console.log("La/el gata/o 1 se llama: " + gato1.nombre + " y pesa: " + gato1.peso + "kg.");
console.log("La/el gata/o 2 se llama: " + gato2.nombre + " y pesa: " + gato2.peso + "kg.");

//Hacemos que el gato 1 coma, lo que incrementará su peso.
gato1.comer();
//Hacemos que el gato 1 juegue, lo que decrementará su peso.
gato2.jugar();

//Mostramos los pesos actualizados de ambos gatos.
console.log("La/el gata/o 1 ahora pesa: " + gato1.peso);
console.log("La/el gata/o 2 ahora pesa: " + gato2.peso);

Clases

Se venís de otro lenguaje de programación como Java o C# habrás notado que hasta ahora hemos creado objetos, pero nunca clases, la realidad es que, como aclaré antes, no existían en Javascript. Ante la ausencia de clases durante muchos años se simuló su uso mediante funciones constructoras.

Afortunadamente a partir de la versión ecmascript 6 se introdujeron las clases, que traen consigo varias características que nos van a permitir moldear objetos como en otros lenguajes.

Podemos reemplazar la función constructora por la siguiente clase:

class Gato {

    //Propiedades.
    nombre;
    peso;

    //Constructor, método que se va a disparar cuando se cree una instancia de la clase.
    constructor(nombre, peso){
        this.nombre = nombre;
        this.peso = peso;
    }

    //Resto de métodos.
    maullar(){
        console.log("Miau");
    }
    comer(){
        this.peso += 0.5;
    }
    jugar(){
        this.peso -= 0.5;
    }

}

Método constructor

Dentro de una clase tendremos que especificar algo llamado método constructor:

constructor(nombre, peso){
    this.nombre = nombre;
    this.peso = peso;
}

El cual si bien no es obligatorio, es muy útil para indicarle a la clase que cuando un objeto se instancia (mediante new) se ejecutarán las líneas de código que hay dentro de este método.

Podemos aprovechar para ingresar los valores iniciales de las propiedades, para cuando se instancien los objetos:

let gato1 = new Gato("Mikorin", 7);
let gato2 = new Gato("Leona", 3);

Visibilidad

Normalmente la programación orientada a objetos recomienda que las propiedades sean privadas ¿Qué significa esto? Que éstas no puedan accederse directamente desde el objeto.

Para que una propiedad sea privada, debemos agregar el signo numeral (#) delante de las propiedades:

class Gato {

    //Propiedades privadas.
    #nombre;
    #peso;

    //Constructor, método que se va a disparar cuando se cree una instancia de la clase.
    constructor(nombre, peso){
        this.#nombre = nombre;
        this.#peso = peso;
    }

    //Resto de métodos.
    maullar(){
        console.log("Miau");
    }
    comer(){
        this.#peso += 0.5;
    }
    jugar(){
        this.#peso -= 0.5;
    }

}

De esta forma si quisiéramos acceder directamente desde el objeto a una de las propiedades nos devolverá un error:

let gato = new Gato("Garfield", 5);

//Esto devuelve error porque la propiedad es privada.
gato.#nombre;

Métodos getter y setter

Las propiedades ahora son privadas y no podrán accederse directamente desde el objeto. Ahora ¿qué pasa si necesitamos obtener el valor de una propiedad o bien modificarlo?

Si quisiéramos obtener el nombre del gato deberíamos crear un método getter:

getNombre(){
    return this.#nombre;
}

Y para modificar esta propiedad un setter:

setNombre(nombre_nuevo){
    this.#nombre = nombre_nuevo;
}

Podemos probar esto mostrando el nombre del gato, cambiándolo y luego volviendo a mostrarlo:

let gato = new Gato("Garfield", 5);
console.log("El gato se llama: " + gato.getNombre());
gato.setNombre("Nyanko-Sensei");
console.log("El gato cambió el nombre y ahora se llama: " + gato.getNombre());

Aunque esto pueda parecer complejo al principio, y llegado a este punto muchos nos preguntamos en su momento: ¿entonces todas las propiedades deberían ser privadas y tener un método getter y setter por cada una? La respuesta en realidad es: NO.

Si bien es recomendable que todas las propiedades sean privadas, la realidad es que el que una propiedad tenga un método getter y uno setter dependerá del diseño de la clase.

Por ejemplo si tuviésemos una clase Auto con propiedades como marca o color, podríamos obtener su color o modificarlo, pero en cuanto a su marca sólo podríamos obtener el valor, es decir, no podríamos tener un Peugeot y luego tener un Volkswagen.

Es decir que en este caso la clase tendría un getMarca(), pero no un setMarca():

class Auto {

    #marca;
    #color;

    constructor(marca, color){
        this.#marca = marca;
        this.#color = color;
    }

    getMarca(){
        return this.#marca;
    }

    //La marca NO se puede setear.

    getColor(){
        return this.#color
    }

    setColor(color){
        this.#color = color;
    }

}

Herencia

Imaginemos que tenemos una clase Gato y una clase Perro:

class Gato {

    //Propiedades privadas.
    #nombre;
    #peso;

    //Constructor, método que se va a disparar cuando se cree una instancia de la clase.
    constructor(nombre, peso){
        this.#nombre = nombre;
        this.#peso = peso;
    }

    //Métodos getter y setter.
    getNombre(){
        return this.#nombre;
    }
    setNombre(nombre_nuevo){
        this.#nombre = nombre_nuevo;
    }
    getPeso(){
        return this.#peso;
    }
    
    comer(){
        this.#peso += 0.5;
    }
    jugar(){
        this.#peso -= 0.5;
    }

    maullar(){
        console.log("Miau");
    }

}

class Perro {

    //Propiedades privadas.
    #nombre;
    #peso;

    //Constructor, método que se va a disparar cuando se cree una instancia de la clase.
    constructor(nombre, peso){
        this.#nombre = nombre;
        this.#peso = peso;
    }

    //Métodos getter y setter.
    getNombre(){
        return this.#nombre;
    }
    setNombre(nombre_nuevo){
        this.#nombre = nombre_nuevo;
    }

    getPeso(){
        return this.#peso;
    }
    
    comer(){
        this.#peso += 0.5;
    }
    jugar(){
        this.#peso -= 0.5;
    }
    
    ladrar() {
        console.log("Guau");
    }

}

Hasta acá nada nuevo, sin embargo, tanto un gato como un perro tienen algo en común: son mascotas.

Por tanto, van a necesitar guardar información como su nombre y peso. Pero también van a tener comportamientos de toda mascota como jugar o comer. La única diferencia entre ambos es que un gato maúlla y un perro ladra.

Para evitar la redundancia de código, muchas veces se utiliza algo llamado herencia.

Vamos a crear un clase Mascota:

class Mascota {

    //Propiedades privadas.
    #nombre;
    #peso;

    //Constructor, método que se va a disparar cuando se cree una instancia de la clase.
    constructor(nombre, peso){
        this.#nombre = nombre;
        this.#peso = peso;
    }

    //Métodos getter y setter.
    getNombre(){
        return this.#nombre;
    }
    setNombre(nombre_nuevo){
        this.#nombre = nombre_nuevo;
    }
    getPeso(){
        return this.#peso;
    }
    
    comer(){
        this.#peso += 0.5;
    }
    jugar(){
        this.#peso -= 0.5;
    }

}

Y vamos a hacer que las clases Gato y Perro hereden de esta última mediante la palabra reservada extends:

class Gato extends Mascota {    

    maullar(){
        console.log("Miau");
    }

}

class Perro extends Mascota {

    ladrar(){
        console.log("Guau");
    }

}

Como podemos ver ya no es necesario agregar propiedades como nombre, ni métodos como jugar(), porque ya las hereden de esta super clase llamada Mascota. Sólo agregamos lo que es propio: maullar() para Gato y ladrar() para Perro.

Podemos probar su funcionamiento:

//Creamos las instancias del gato y el perro que heredan de mascota.
let gato = new Gato("Nyanko-Sensei", 3);
let perro = new Perro("Wolfie", 10);

//El gato puede comer y jugar porque lo heredó de la clase Mascota. También puede maullar.
gato.comer();
console.log(gato.getNombre() + " ahora pesa: " + gato.getPeso());
gato.maullar();

//El perro puede comer y jugar porque lo heredó de la clase Mascota. También puede ladrar.
perro.jugar();
console.log(perro.getNombre() + " ahora pesa: " + perro.getPeso());
perro.ladrar();

Sobreescritura de métodos

Las clases que heredan de otras pueden sobrescribir los métodos de la clase heredada para poder adaptar su uso.

Supongamos que la clase Mascota nos pidiese que al instanciarla le tuviésemos que pasar la lista de vacunas obligatorias:

class Mascota {

    //Propiedades privadas.
    #nombre;
    #peso;
    #vacunas_obligatorias;

    //Constructor, método que se va a disparar cuando se cree una instancia de la clase.
    constructor(nombre, peso, vacunas_obligatorias){
        this.#nombre = nombre;
        this.#peso = peso;
        this.#vacunas_obligatorias = vacunas_obligatorias;
    }

}

class Gato extends Mascota {    

    //

}

Y luego creamos dos instancias de la clase Gato:

//Creamos dos instancias de gatos.
let gato1 = new Gato("Mikorin", 5, ["Triple felina", "Antirrábica"]);
let gato2 = new Gato("Nyanko-Sensei", 7, ["Triple felina", "Antirrábica"])

El problema es que esto es redundante, porque si bien al crear una instancia de Gato estamos obligados a indicarle las vacunas obligatorias de éste, siempre serán las mismas.

Entonces el constuctor de gato debería ser diferente, sólo debería pedirnos que ingresemos el nombre y el peso del gato ¿Podemos cambiar este constructor? La respuesta es sí:

class Gato extends Mascota {    

    //El constructor de gato sólo nos pedirá su nombre y peso.
    constructor(nombre, peso){
        //Reutilizamos el constructor padre y le pasamos directamente las vacunas obligatorias.
        super(nombre, peso, ["Triple felina", "Antirrábica"]);
    }

}

De esta forma, al crear una instancia de Gato directamente se agregarán las vacunas obligatorias, nosotros sólo tenemos que pasarle los valores de las propiedades nombre y peso:

//Creamos dos instancias de gatos.
let gato1 = new Gato("Mikorin", 5);
let gato2 = new Gato("Nyanko-Sensei", 7);