Desde ES2015 la programación orientada a objetos en Javascript es similar a como se hace en otros lenguajes, con clases, herencia, …:
class Person {
constructor(name, surname, age) {
this.name = name
this.surname = surname
this.age = age
}
getInfo() {
return 'El alumno ' + this.name + ' ' + this.surname + ' tiene ' + this.age + ' años'
}
}
let persona1 = new Person('Carlos', 'Pérez Ortiz', 19)
console.log(persona1.getInfo()) // imprime 'El alumno Carlos Pérez Ortíz tiene 19 años'
NOTA: en las clases NO es necesario poner 'use strict' porque por defecto todas las clases ya lo tienen.
EJERCICIO: Crea una clase Productos con las propiedades name, category, units y price y los métodos total que devuelve el importe del producto y getInfo que devolverá: ‘Name (category): units uds x price € = total €’. Crea 3 productos diferentes.
Una clase puede heredar de otra utilizando la palabra reservada extends y heredará todas sus propiedades y métodos. Podemos sobrescribirlos en la clase hija (seguimos pudiendo llamar a los métodos de la clase padre utilizando la palabra reservada super -es lo que haremos si creamos un constructor en la clase hija-).
class Student extends Person{
constructor(name, surname, age, cycle) {
super(name, surname, age)
this.cycle = cycle
}
isMediumDegree() {
if (this.cycle.toUpperCase() === 'SMX') return true
return false
}
getInfo() {
return super.getInfo() + ' y estudia el Grado ' + (this.isMediumDegree ? 'Medio' : 'Superior') + ' de ' + this.cycle
}
}
let cpo = new Student('Carlos', 'Pérez Ortiz', 19, 'DAW')
console.log(cpo.getInfo()) // imprime 'El alumno Carlos Pérez Ortíz tiene 19 años y estudia el Grado Superior de DAW'
EJERCICIO: crea una clase Televisores que hereda de Productos y que tiene una nueva propiedad llamada tamaño. El método getInfo mostrará el tamaño junto al nombre
A la hora de encapsular el código de las clases es importante el uso de este tipo de elementos. Javascript los incluyó en ES2019 y van precedidos de la sintaxis # para declaralos:
class Position {
#x = 0;
#y = 0;
constructor(x, y) {
this.#x = x;
this.#y = y;
}
#increaseX() {
this.#x++;
}
#increaseY() {
this.#y++;
}
getPosition() {
return { x: this.#x, y: this.#y };
}
moveRight() {
this.#increaseX();
}
moveUp() {
this.#increaseY();
}
}
const myPosition = new Position(20, 10);
console.log(myPosition.getPosition()); // { x: 20, y: 10 }
myPosition.moveRight();
console.log(myPosition.getPosition()); // { x: 21, y: 10 }
console.log(myPosition.#x); // Error (propiedad privada)
console.log(myPosition.#increaseX); // Error (método privado)
Anteriormente existía una convención de que cualquier propiedad o método que comience por el carácter _ se trata de una propiedad o método protegido y no debería accederse al mismo desde el exterior (aunque en realidad el lenguaje permite hacerlo).
Estas propiedades y métodos privados se heredan como cualquier otro.
Podemos declarar métodos y propiedades estáticas. Se llaman directamente utilizando el nombre de la clase ya que no pertenecen a las instancias sino a la clase misma:
class User {
...
static getRoles() {
return ["user", "guest", "admin"]
}
}
console.log(User.getRoles()) // ["user", "guest", "admin"]
let user = new User("john")
console.log(user.getRoles()) // Uncaught TypeError: user.getRoles is not a function
En estos métodos el objeto this ya no hace referencia a ninguna instancia sino a la clase misma.
Suelen usarse cosas como contadores de instancias, métodos de utilidad, etc. También para usar el patrón Singleton. Por ejemplo para un contador de instancias de la clase User podríamos hacer algo así:
class User {
static #count = 0;
constructor(name) {
this.name = name;
User.count++;
}
static getCount() {
return User.count;
}
}
Al declarar la propiedad count como privada no se puede modificar desde fuera de la clase, sólo a través del constructor.
Al convertir un objeto a string (por ejemplo al concatenarlo con un String) se llama al método .toString() del mismo, que por defecto devuelve la cadena [object Object]. Podemos sobrecargar este método para que devuelva lo que queramos:
class Alumno {
...
toString() {
return this.apellidos + ', ' + this.nombre
}
}
let carPerOrt = new Alumno('Carlos', 'Pérez Ortiz', 19);
console.log('Alumno:' + carPerOrt) // imprime 'Alumno: Pérez Ortíz, Carlos'
// en vez de 'Alumno: [object Object]'
Este método también es el que se usará si queremos ordenar una array de objetos (recordad que .sort() ordena alfabéticamente para lo que llama al método .toString() del objeto a ordenar). Por ejemplo, tenemos el array de alumnos misAlumnos que queremos ordenar alfabéticamente por apellidos. Si la clase Alumno no tiene un método toString habría que hacer como vimos en el tema de Arrays:
misAlumnos.sort((alum1, alum2) => (alum1.apellidos+alum1.nombre).localeCompare(alum2.apellidos+alum2.nombre));
Pero con el método toString que hemos definido antes podemos hacer directamente:
misAlumnos.sort()
EJERCICIO: modifica las clases Productos y Televisores para que el método que muestra los datos del producto se llame de la manera más adecuada
EJERCICIO: Crea 5 productos y guárdalos en un array. Crea las siguientes funciones (todas reciben ese array como parámetro):
- prodsSortByName: devuelve un array con los productos ordenados alfabéticamente
- prodsSortByPrice: devuelve un array con los productos ordenados por importe
- prodsTotalPrice: devuelve el importe total del los productos del array, con 2 decimales
- prodsWithLowUnits: además del array recibe como segundo parámetro un nº y devuelve un array con todos los productos de los que quedan menos de los unidades indicadas
- prodsList: devuelve una cadena que dice ‘Listado de productos:’ y en cada línea un guión y la información de un producto del array
Al comparar objetos (con >, <, …) o realizar operaciones matemáticas se usa el valor devuelto por el método .valueOf() para realizar la comparación:
class Producto {
...
valueOf() {
return this.units * this.price
}
}
let nar = new Producto('Naranjas', 19, 1.5)
let per = new Producto('Peras', 23, 2.0)
console.log(nar < per) // imprime true ya que 19<23
console.log(nar + per) // imprime 19*1.5 + 23*2.0 = 19.5 + 46 = 65.5
Si este método no existiera será .toString() el que se usaría por lo que no funcionaría correctamente.
Lo más conveniente es guardar cada clase en su propio fichero, que llamaremos como la clase con la extensión .class.js. Por ejemplo el fichero de la clase Users seria users.class.js.
En dicho fichero exportamos la clase (con export o mejor export default porque sólo hay una) y donde queramos usarla la importamos (import { Users } from 'users.class' o import Users from 'users.class', según cómo la hayamos exportado).
El valor de la variable this depende del contexto e que se ejecuta el código. Al crear una instancia de una clase con new this hace referencia a la instancia creada. Dentro de una función declarada con function (no si es una arrow function) se crea un nuevo contexto y la variable this pasa a hacer referencia a dicho contexto. Si en el ejemplo anterior hiciéramos algo como esto:
class Product {
...
getInfo() {
function totalImport() {
return this.units * this.price // Aquí this no es la instancia del objeto Product sino el contexto de la función
}
return 'El importe del producto ' + this.nombre + ' es ' + totalImport()
}
}
Este código fallaría porque dentro de la función totalImport la variable this ya no hace referencia a la instancia del objeto Product sino al contexto de la función. Este ejemplo no tiene mucho sentido pero a veces nos pasará en manejadores de eventos.
Si debemos llamar a una función dentro de un método (o de un manejador de eventos) tenemos varias formas de pasarle el valor de this:
getInfo() {
const totalImport = () => this.units * this.price
return 'El importe del producto ' + this.nombre + ' es ' + totalImport()
}
getInfo() { function totalImport(product) { return product.units * product.price }
return 'El importe del producto ' + this.nombre + ' es ' + totalImport(this) } } ```
getInfo() {
let that = this;
function totalImport() {
return that.units * that.price
}
return 'El importe del producto ' + this.nombre + ' es ' + totalImport(this)
}
class Product {
...
getInfo() {
function totalImport() {
return this.units * this.price // Aquí this no es el objeto Product
}
return 'El importe del producto ' + this.nombre + ' es ' + totalImport.bind(this)
}
}
Al llamar a la función totalImport le enlazamos (.bind) el valor que queremos que tenga this dentro de ella, en nuestro caso el this de donde hacemos la llamada.
Un mixin es un patrón de diseño que consiste en una clase o un objeto que contiene una colección de métodos que pueden ser utilizados por otras clases sin necesidad de heredar directamente de él.
Es la solución que tiene JavaScript para resolver la falta de herencia múltiple (ya que en JavaScript una clase solo puede heredar de una única clase base usando extends).
Es por tanto un objeto que contiene métodos que podemos aplicar a una clase para datarla de ciertos comportamientos. Por ejemplo:
// 1. Definimos el Mixin como una función que recibe una clase y devuelve otra clase que hereda de la primera y añade los métodos del Mixin
const MixinSerializable = (ClaseBase) => class extends ClaseBase {
serializar() {
return JSON.stringify(this);
}
deserializar(datos) {
Object.assign(this, JSON.parse(datos));
}
};
// 2. Definimos clases normales con sus propias herencias
class Persona {
constructor(nombre) {
this.nombre = nombre;
}
}
class Articulo {
constructor(titulo, precio) {
this.titulo = titulo;
this.precio = precio;
}
}
// 3. Aplicamos el Mixin a las clases para darles el "superpoder"
class PersonaGuardable extends MixinSerializable(Persona) {}
class ArticuloGuardable extends MixinSerializable(Articulo) {}
// --- CÓMO SE USA ---
const usuario = new PersonaGuardable("Alejandro");
console.log(usuario.serializar()); // '{"nombre":"Alejandro"}'
const producto = new ArticuloGuardable("Teclado", 45);
console.log(producto.serializar()); // '{"titulo":"Teclado","precio":45}'
Las ventajas de usar Mixins son:
Antiguamente se usaba un patrón más simple que consistía en crear un objeto con los métodos y luego asignarlos al prototipo de la clase:
// mixin
const MixinSerializable = {
serializar() {
return JSON.stringify(this);
}
deserializar(datos) {
Object.assign(this, JSON.parse(datos));
}
}
class Persona {
constructor(nombre) {
...
}
...
}
// asignamos el mixin a la clase
Object.assign(Persona.prototype, MixinSerializable);
// Ahora la Persona puede serializarse
const persona = new Persona('Carlos');
console.log(persona.serializar()); // '{"nombre":"Carlos"}'
Pero se trata de una sintaxis menos segura que la anterior por lo que no deberíamos usarla (está aquí sólo para que la entendáis si os encontráis este código).
NOTA: este apartado está sólo para que comprendamos este código si lo vemos en algún programa pero nosotros programaremos como hemos visto antes.
En Javascript un objeto se crea a partir de otro (al que se llama prototipo). Así se crea una cadena de prototipos, el primero de los cuales es el objeto null.
Las versiones de Javascript anteriores a ES2015 no soportan clases ni herencia. Si queremos emular en ellas el comportamiento de las clases lo que se hace es:
function Alumno(nombre, apellidos, edad) {
this.nombre = nombre
this.apellidos = apellidos
this.edad = edad
}
Alumno.prototype.getInfo = function() {
return `El alumno ${this.nombre} ${this.apellidos} tiene ${this.edad} años`
}
let cpo = new Alumno('Carlos', 'Pérez Ortiz', 19)
console.log(cpo.getInfo()) // imprime 'El alumno Carlos Pérez Ortíz tiene 19 años'
Cada objeto tiene un prototipo del que hereda sus propiedades y métodos (es el equivalente a su clase, pero en realidad es un objeto que está instanciado). Si añadimos una propiedad o método al prototipo se añade a todos los objetos creados a partir de él lo que ahorra mucha memoria.