Programación Orientada a Objetos en JavaScript
Si bien hemos visto como declarar objetos en JavaScript, no hemos definido nuestras propias clases. Siendo POO uno de los paradigmas más utilizados en la actualidad, es importante aprender este concepto en JavaScript.
Clases
Como sabemos, las clases nos permiten encapsular datos y comportamiento en una misma estructura, de la cual podemos crear múltiples instancias.
Debido a continuas actualizaciones del lenguaje, es importante mencionar que existen al menos tres formas de definir clases en JavaScript:
-
Declaración de clase: esta es la manera más sencilla de definir una clase. Para ello, usamos la palabra reservada
class
seguida del nombre de la clase, y luego definimos el cuerpo de la clase entre llaves{}
. Por ejemplo:1class Persona {2// cuerpo de la clase3} -
Expresión de clase (Anónima): en este caso, definimos una clase como una expresión, y la asignamos a una variable. Por ejemplo:
1const Persona = class {2// cuerpo de la clase3}Al ser anónima, no podemos usar el nombre de la clase dentro de la misma. Por ejemplo:
1const Persona = class {2nombre = 'Pedro';3saludar() {4console.log(`Hola, soy ${Persona.nombre}`);5}6}78const pedro = new Persona();9pedro.saludar(); // Hola, soy undefinedEn este caso,
Persona.nombre
esundefined
, ya que la clase no tiene nombre. -
Expresión de clase (Nombrada): en este caso, definimos una clase como una expresión, y la asignamos a una variable. Por ejemplo:
1const Persona = class Persona {2// cuerpo de la clase3}En este caso, al ser nombrada, podemos usar el nombre de la clase dentro de la misma, lo cual puede ser especialmente útil para crear clases recursivas como árboles, listas enlazadas, grafos, entre otras estructuras de datos.
En cualquiera de los casos, una vez definida la clase, podemos crear instancias de la misma usando la palabra reservada new
. Por ejemplo:
1const pedro = new Persona();2const ana = new Persona();3const juan = new Persona();
Atributos
Por otro lado, los atributos de una clase se definen dentro del cuerpo de la misma, y se pueden inicializar de dos maneras diferentes:
-
ES6 (2015): en este caso, los atributos se definen dentro del cuerpo de la clase, y se inicializan en el constructor (método
constructor
). Por ejemplo:1class Persona {2constructor(nombre, apellido) {3this.nombre = nombre;4this.apellido = apellido;5}6}En este caso, los atributos
nombre
yapellido
se inicializan en el constructor, y se pueden acceder desde cualquier método de la clase usando la palabra reservadathis
. La cual hace referencia a la instancia que ha invocado al método.Este tipo de inicialización puede resultarnos familiar, ya que es similar a la que usamos en Python en el método
__init__
, y la referenciaself
. -
ES11 - 2020: a partir de esta versión, los atributos pueden definirse dentro del cuerpo de la clase, pero sin la necesidad de emplear el método
constructor
. Por ejemplo:1class Persona {2nombre = '';3apellido = '';4}En este caso, los atributos
nombre
yapellido
se inicializan directamente con un valor por defecto (una cadena vacía''
).Veremos que al definir los atributos de esta manera, aún podemos acceder a ellos desde cualquier método de la clase usando la palabra reservada
this
o desde fuera de la clase mediante una instancia de la misma.1const pedro = new Persona();23console.log(pedro.nombre); // ''4console.log(pedro.apellido); // ''56pedro.nombre = 'Pedro';7pedro.apellido = 'Pérez';89console.log(pedro.nombre); // Pedro10console.log(pedro.apellido); // PérezEs decir que, al emplear esta sintaxis, los atributos se inicializan automáticamente con los valores por defecto. Sin embargo, si queremos inicializarlos con un valor específico durante la instanciación, debemos hacerlo siempre en el constructor como lo hacíamos en ES6.
Es importante resaltar que la sintaxis de ES11 es retrocompatible con la sintaxis de ES6, por lo que podemos usar cualquiera de las dos formas de inicialización a partir de la versión 11 del lenguaje.
De esta manera podemos instanciar objetos de la clase Persona
con o sin argumentos, dependiendo de la forma en que se haya definido la clase.
1const pedro = new Persona('Pedro', 'Pérez');2const ana = new Persona('Ana', 'González');3const juan = new Persona();
Debemos tener en cuenta que, las clases definidas sin constructor, no servirán para inicializar valores a los atributos de la clase. Pero, si queremos que los atributos de la clase tengan un valor por defecto, podemos hacerlo mediante la siguiente estrategia:
1class Persona {2 constructor(nombre, apellido) {3 this.nombre = nombre || '';4 this.apellido = apellido || '';5 }6}
Empleando esta estrategia, podemos garantizar que los atributos de la clase tengan un valor por defecto, en caso de no ser inicializados.
Este tipo de comparaciones se conocen como short-circuit evaluation (evaluación de cortocircuito), y son muy comunes en JavaScript.
Por supuesto, podemos combinar ambas formas de inicialización de atributos para establecer valores por defecto y permitir la personalización de los mismos. Por ejemplo:
1class Persona {2 nombre = '';3 apellido = '';4 constructor(nombre, apellido) {5 this.nombre = nombre || this.nombre;6 this.apellido = apellido || this.apellido;7 }8}
En este caso, si no se pasan argumentos al constructor, los atributos nombre
y apellido
se inicializan con los valores por defecto. Por el contrario, si se pasan argumentos al constructor, estos valores reemplazan a los valores por defecto.
Métodos
Por otro lado, el comportamiento de una clase se define a través de métodos. Los cuales son funciones que se definen en el ámbito de la clase. Por ejemplo:
1class Persona {2 constructor(nombre, apellido) {3 this.nombre = nombre || '';4 this.apellido = apellido || '';5 }6
7 saludar() {8 console.log(`Hola, soy ${this.nombre} ${this.apellido}`);9 }10}
En este caso, la clase Persona
define un método saludar
, el cual muestra un mensaje por consola. Para invocar un método de una clase, debemos hacerlo a través de una instancia de la misma, anteponiendo el nombre del método con un punto .
. Por ejemplo:
1const pedro = new Persona('Pedro', 'Pérez');2const ana = new Persona('Ana', 'González');3const juan = new Persona();4
5pedro.saludar(); // Hola, soy Pedro Pérez6ana.saludar(); // Hola, soy Ana González7juan.saludar(); // Hola, soy
Métodos estáticos
Por otro lado, es importante mencionar que podemos definir métodos estáticos en una clase. Los cuales son métodos que no requieren una instancia de la clase para ser invocados. Para ello, debemos usar la palabra reservada static
antes del nombre del método. Por ejemplo:
1class Persona {2 constructor(nombre, apellido) {3 this.nombre = nombre || '';4 this.apellido = apellido || '';5 }6
7 saludar() {8 console.log(`Hola, soy ${this.nombre} ${this.apellido}`);9 }10 //11 static especie() {12 console.log(`Soy un ser humano`);13 }14}
Veremos que podemos emplear el método especie
mediante la clase Persona
, pero no mediante una instancia de la misma. Por ejemplo:
1Persona.especie(); // Soy un ser humano2
3const pedro = new Persona('Pedro', 'Pérez');4pedro.especie(); // TypeError: pedro.especie is not a function
Encapsulamiento
Cabe mencionar que, en JavaScript, existe una manera de simular el encapsulamiento de datos y garantizar el acceso a los mismos únicamente a través de métodos.
Bajo esta premisa, podemos definir atributos y métodos privados. Para ello, debemos usar el prefijo #
antes del nombre del atributo o método, dentro del cuerpo de la clase. Por ejemplo:
1class Persona {2 #fechaNacimiento;3 constructor(nombre, apellido, fechaNacimiento) {4 this.nombre = nombre;5 this.apellido = apellido;6 this.#fechaNacimiento = fechaNacimiento;7 }8
9 saludar() {10 console.log(`Hola, soy ${this.nombre} ${this.apellido} y nací el ${this.#fechaNacimiento.toLocaleDateString()}`);11 }12}
En este ejemplo, el atributo #fechaNacimiento
es privado, por lo que no podemos acceder a él desde fuera de la clase. Si lo intentamos, obtendremos un error de sintaxis. Por ejemplo:
1const pedro = new Persona('Pedro', 'Pérez', new Date(1990, 0, 1));2
3pedro.saludar(); // Hola, soy Pedro Pérez y nací el 1/1/19904
5console.log(pedro.nombre); // Pedro6console.log(pedro.apellido); // Pérez7console.log(pedro.#fechaNacimiento); // Property '#fechaNacimiento' is not accessible outside class 'Persona' because it has a private identifier.
De manera similar, podemos definir métodos privados. Es decir, métodos que solo pueden ser invocados desde dentro de la misma clase. Por ejemplo:
1class Persona {2 #fechaNacimiento;3 constructor(nombre, apellido, fechaNacimiento) {4 this.nombre = nombre;5 this.apellido = apellido;6 this.#fechaNacimiento = fechaNacimiento;7 }8
9 saludar() {10 console.log(`Hola, soy ${this.nombre} ${this.apellido} y nací el ${this.#fechaNacimiento.toLocaleDateString()}`);11 }12
13 #calcularEdad() {14 const hoy = new Date();15 const edad = hoy.getFullYear() - this.#fechaNacimiento.getFullYear();16 return edad;17 }18
19 edad() {20 return this.#calcularEdad();21 }22}
El método #calcularEdad
es privado, por lo que se produce un error de sintaxis si intentamos acceder a él desde fuera de la clase. Por ejemplo:
1const pedro = new Persona('Pedro', 'Pérez', new Date(1990, 0, 1));2
3console.log(pedro.nombre); // Pedro4console.log(pedro.apellido); // Pérez5
6console.log(pedro.#calcularEdad()); // Property '#calcularEdad' is not accessible outside class 'Persona' because it has a private identifier.
En resumen, si empleamos el prefijo #
antes del nombre de un atributo o método, estos serán privados, y no podremos acceder a ellos desde fuera de la clase. Por el contrario, si no empleamos el prefijo #
, estos serán públicos. Ante esta situación, es importante mencionar a las propiedades computadas get
y set
.
Estas son un tipo de propiedades que se declaran como métodos y, como lo indica su nombre, calculan un valor en el momento de ser invocadas. Por ejemplo, podemos redefinir el método edad
mediante la propiedad computada get
.
1class Persona {2 #fechaNacimiento;3 constructor(nombre, apellido, fechaNacimiento) {4 this.nombre = nombre;5 this.apellido = apellido;6 this.#fechaNacimiento = fechaNacimiento;7 }8
9 saludar() {10 console.log(`Hola, soy ${this.nombre} ${this.apellido}`);11 }12
13 #calcularEdad() {14 // Cálculo de la edad15 }16
17 get edad() {18 return this.#calcularEdad();19 }20}
Para acceder a una propiedad computada, debemos invocarla sin paréntesis como si de un atributo se tratase.
1const pedro = new Persona('Pedro', 'Pérez', new Date(1990, 0, 1));2
3console.log(pedro.edad); // 31
Por otro lado, la propiedad computada set
nos permite definir un método que permite la modificación de un atributo. Por ejemplo, podemos definir un método set fechaNacimiento
:
1class Persona {2 #fechaNacimiento;3 constructor(nombre, apellido, fechaNacimiento) {4 this.nombre = nombre;5 this.apellido = apellido;6 this.#fechaNacimiento = fechaNacimiento;7 }8
9 saludar() {10 console.log(`Hola, soy ${this.nombre} ${this.apellido}`);11 }12
13 #calcularEdad() {14 const hoy = new Date();15 const edad = hoy.getFullYear() - this.#fechaNacimiento.getFullYear();16 return edad;17 }18
19 get edad() {20 return this.#calcularEdad();21 }22
23 set fechaNacimiento(fechaNacimiento) {24 this.#fechaNacimiento = fechaNacimiento;25 }26}
Para acceder a una propiedad computada set
lo haremos mediante la asignación de un valor. Por ejemplo:
1const pedro = new Persona('Pedro', 'Pérez', new Date(1990, 0, 1));2
3console.log(pedro.edad); // 314
5pedro.fechaNacimiento = new Date(1991, 0, 1);6
7console.log(pedro.edad); // 30
Podemos definir propiedades computadas get
y set
para cualquier atributo de la clase, estos son también conocidas como setters y getters.
Herencia
Como sabemos, la herencia es el mecanismo por el cual una clase puede extender sus características y comportamiento a otras clases, y así especializarlas.
Para heredar de una clase en Javascript, debemos usar la palabra reservada extends
seguida del nombre de la clase de la cual queremos heredar. Por ejemplo:
1class Persona {2 constructor(nombre, apellido) {3 this.nombre = nombre;4 this.apellido = apellido;5 }6
7 saludar() {8 console.log(`Hola, soy ${this.nombre} ${this.apellido}`);9 }10}11
12class Estudiante extends Persona {13 constructor(nombre, apellido, carrera) {14 super(nombre, apellido);15 this.carrera = carrera;16 }17}
Como podemos apreciar, la clase Estudiante
hereda de la clase Persona
, y podemos acceder a los atributos y métodos de la clase padre mediante la palabra reservada super
. Por ejemplo:
1const pedro = new Estudiante('Pedro', 'Pérez', 'Ingeniería en Sistemas');2
3console.log(pedro.nombre); // Pedro4console.log(pedro.apellido); // Pérez5console.log(pedro.carrera); // Ingeniería en Sistemas
Con esto en mente, deberemos tener siempre presente la necesidad de invocar al constructor de la clase padre mediante super
, ya que de lo contrario no podremos acceder a los atributos de la clase padre.
Así mismo, podemos redefinir métodos de la clase padre en la clase hija y/o agregar nuevos métodos. Por ejemplo:
1class Estudiante extends Persona {2 constructor(nombre, apellido, carrera) {3 super(nombre, apellido);4 this.carrera = carrera;5 }6
7 saludar() {8 console.log(`Hola, soy ${this.nombre} ${this.apellido}, y estudio ${this.carrera}`);9 }10
11 estudiar() {12 console.log(`Estoy estudiando ${this.carrera}`);13 }14}
En este caso, el método saludar
de la clase Estudiante
reemplaza al método saludar
de la clase Persona
, y el método estudiar
es un método exclusivo de la clase Estudiante
. Por ejemplo:
1const pedro = new Estudiante('Pedro', 'Pérez', 'Ingeniería en Sistemas');2
3pedro.saludar(); // Hola, soy Pedro Pérez, y estudio Ingeniería en Sistemas4pedro.estudiar(); // Estoy estudiando Ingeniería en Sistemas