Programación Orientada a Objetos (POO)

Hasta el anterior capítulo se ha hecho un repaso y se ha profundizado en algunos de los principios y herramientas de la Programación Estructurada, con esa base, a partir de este capítulo inicia el estudio de la Programación Orientada a Objetos (POO).

La programación orientada a objetos es un paradigma de programación, donde la solución de los problemas se concibe como conjuntos de objetos que cooperan entre sí para solucionar los problemas. Un paradigma de programación es un estilo, un enfoque, una forma de ver y modelar el mundo real dentro del código.

Un objeto en la POO es la representación simplificada de una "cosa" (algo o alguien). Esa "cosa" puede ser un objeto de la vida real (un auto, una casa, una persona, etc.), o un concepto simple o complejo (una venta, un préstamo, una hipoteca, etc.).

En analogía con los objetos de la vida real, como un auto, por ejemplo, que tiene un conjunto de características como marca, modelo, color, cilindrada, potencia, etc., y algunas funcionalidades como encender, acelerar, frenar, girar, apagar, etc., un objeto de la POO tienen también un conjunto de datos, denominadps atributos (o propiedades) y algunas funciones, denominadas métodos.

El conjunto de valores que tienen los atributos de un objeto en cualquier momento se conoce como el estado del objeto.

La diferencia fundamental entre la Programación Estructurada (PE) y la Orientada a Objetos (POO) es que en la PE los datos y las funciones están separadas, mientras que, en la POO, los datos (los atributos) y las funciones (los métodos) están dentro de una misma estructura: El objeto. Los atributos de un objeto son el equivalente a las variables de la PE y los métodos de un objeto son el equivalente a las funciones de la PE.

En resumen, en la POO, los datos y las funciones están dentro de una misma estructura: El objeto. En la POO los datos se conocen como atributos y las funciones como métodos y el estado del objeto es el conjunto de valores que tienen esos atributos en un momento dado.

Programación Estructurada y Orientada a Objetos

Tanto la Programación Estructurada, como la Programación Orientada a Objetos, son paradigmas, es decir formas de ver y modelar el mundo real dentro del código.

En la PE se modela el mundo identificando las funciones que se requieren para resolver el problema, así como los datos que necesitan esas funciones para realizar sus tareas. En la POO se modela el mundo identificando el conjunto de objetos con los que se resuelve el problema, así como los métodos y atributos de esos objetos.

Debido a la diferencia de enfoques, en la PE la solución consta de un conjunto de funciones y un conjunto de datos/estructuras de datos, mientras que, en la POO, la solución consta de un conjunto de objetos, cada uno con sus propios atributos y métodos.

En consecuencia, al resolver un problema con el enfoque estructurado se piensa en las funciones que se requieren para resolver el problema y en los datos que requieren esas funciones. En el enfoque orientado a objetos, se piensa en los objetos con los que se puede resolver el problema y en los atributos y métodos de esos objetos.

Clases y objetos

En el paradigma de la Programación Orientada a Objetos, se emplean los términos clase y objeto y frecuentemente se emplean como sinónimos, sin embargo, son dos conceptos diferentes.

Como se dijo un objeto es la estructura que contiene tanto los datos (atributos) como las funciones (métodos), en consecuencia, si se crea una estructura que contiene tanto los datos como las funciones, se ha crea un objeto.

Las clases son un tipo especial de objetos que permiten crear objetos de un determinado tipo. Son como fábricas que producen, cada una de ellas, un determinado tipo de objeto. Por esa razón son conocidas también como Objetos Constructores.

En la clase se declaran todos los métodos y atributos que tendrán los objetos creados a partir de la misma, pero no se asignan los valores a los atributos (aunque sí es posible asignar valores por defecto). Los verdaderos valores de los atributos se asignan recién cuando se crea un objeto a partir de una clase.

La razón principal por la que se emplean clases en la POO es porque los objetos creados a partir de una clase pueden utilizar los métodos de esa clase. Todos los objetos de una clase (y pueden ser cientos o miles de objetos) emplean directamente los métodos de la clase, no se crean copias para cada objeto.

Si no se emplearían clases (u objetos constructores), se tendrían que volver a declarar los métodos en cada uno de los objetos del mismo tipo, lo que por una parte implicaría la duplicación irracional de código (con el consiguiente consumo de memoria y tiempo) y por otra, haría al sistema vulnerable a errores, porque cuando se corrija o modifique un método (algo que casi siempre ocurre) esa modificación/corrección tendría que ser replicada (manualmente) en cada uno de los objetos.

Los métodos de una clase trabajan exclusivamente con los atributos del objeto desde el cual son llamados, por lo que deben tener acceso al objeto. En el caso de JavaScript, los métodos tienen acceso al objeto mediante la variable this. Esta variable, que es creada automáticamente, es el nombre que le da JavaScript al objeto desde el cual ha sido llamado el método.

En conclusión, una clase es un objeto especializado en la creación de objetos de un determinado tipo. En una clase se declaran los métodos y los atributos del objeto, pero no se asignan los valores finales. Un objeto de una clase, por otra parte, es el resultado de crear un objeto a partir de una clase. El objeto, conocido como instancia de la clase, tiene sus atributos con los valores asignados (tiene un estado) y puede llamar directamente a los métodos de la clase, aunque esos métodos no le pertenecen (pertenecen a la clase).

Fundamentos de la POO

La Programación Orientada a Objetos se rige por 4 fundamentos, 4 pilares que guían el diseño de software cuando se aplica este paradigma.

Abstracción

Este principio dice que al identificar un objeto el mismo debe ser representado de manera simplificada, mostrando únicamente los aspectos esenciales para el problema, pero ocultando los aspectos irrelevantes, así como los detalles de su implementación. Se debe pensar en "que hace el objeto" en lugar del "cómo lo hace".

Encapsulación

Este principio dice que sólo debe ser posible acceder/modificar los atributos de un objeto mediante los métodos del objeto, protegiendo así su estado.

Herencia

Este principio dice que una clase (la clase hija) puede heredar los atributos y métodos de otra (la clase padre) evitando así la duplicación de código, extendiendo y especializando su comportamiento a través de nuevos métodos y atributos.

Polimorfismo

Este principio dice que cuando un método es llamado desde un objeto, debe responder de diferentes formas en función a la clase a la que pertenece el objeto. El principio instruye implementar los métodos (sobre todo en las clases heredadas) de manera que repondan a las características específicas de la clase.

Objetos literales

En JavaScript es posible declara un objeto sin que sea creado explícitamente a partir de una clase. Los objetos creados de esta manera se conocen como objetos literales y sen de utilidad práctica en aquellos casos donde sólo se requiere un objeto de un determinado tipo, es decir cuando sólo se necesita una instancia del objeto y en consecuencia, no se justifica crear una clase que será empleada una sola vez.

Para crear un objeto literal simplemente se escribe, entre llaves y separados con comas, los atributos y métodos del objeto. Con frecuencia, los objetos literales constan únicamente o de atribustos (datos) o de métodos (funciones) y pueden ser inclusive objetos vacíos.

Un objeto literal vacío se crea simplemente escribiendo llaves sin nada en su interior:

persona = {}; persona

Una vez creado el objeto vacío, es posible añadir atributos y métodos empleando la notación de punto. Por ejemplo se pueden añadir los atributos "nombres" y "apellidos", así como el método "nombreCompleto" que empleando una plantilla literal muestre el nombre completo:

persona.nombres = "Juan Carlos"; persona.apellidos = "Perez Berríos"; persona.nombreCompleto = function() { return `${this.apellidos} ${this.nombres}`; };
persona.nombreCompleto()

Note que en el método "nombreCompleto" se emplea this para acceder a los atributos del objeto. Como se dijo "this" es el nombre que JavaScript asigna automáticamente al objeto que llama al método (en este caso el objeto "persona").

Por supuesto, en lugar de crear un objeto literal vacío y luego añadirle atributos y métodos, se puede crear directamente el objeto con esos atributos y métodos, como sucede con el objeto "persona2":

persona2 = { nombres: "José Rodrigo", apellidos: "Torres Albornoz", nombreCompleto: function() { return `${this.apellidos} ${this.nombres}` } };
persona2.nombreCompleto()

Sin embargo, lo más frecuente en la práctica es crear objetos a partir de clases y solo ocasionalmente objetos literales. Pero incluso un objeto literal, aunque no se crea explícitamente a partir de una clase, internamente es creada por JavaScript a partir de la clase "Object" (la clase padre de todos los objetos en JavaScript).

Clases en JavaScript

En JavaScript las clases pueden ser creadas en diferentes formas, sin embargo, actualmente se crea principalmente con la palabra reservada class, de acuerdo a la siguiente sintaxis:

class nombre_de_la_clase { ... constructor(...) { } ... métodos getter y setter (dinámicos y estáticos) ... propiedades y métodos dinámicos (públicos y privados) ... propiedades y métodos estáticos (públicos y privados) ... bloques de inicialización estáticos ... }

Todos los elementos de una clase (los elementos mostrados en la sintaxis) son opcionales. Así, al igual que ocurre con un objeto literal, es posible crear una clase sin ningún elemento: una clase vacía:

class Vacío {
};

Y, aunque es una clase vacía, se pueden crear objetos (instancias) de esa clase.

Para crear un objeto (una instancia) de una clase se emplea la palabra reservada new, de acuerdo a la siguientes sintaxis:


var|let|const variable_objeto = new nombre_de_la_clase(valores_iniciales);

Así, para crear el objeto 'obj1' de la clase Vacío se escribe:

obj1 = new Vacío();
obj1

Obviamente, al ser una clase vacía, se obtiene un objeto vacío. Cada uno de los objetos que se crean a partir de una clase son únicos, aunque tengan los mismos atributos, así, por ejemplo, si se crea otro objeto vacío:

var obj2 = new Vacío();
obj2

Y se compara con el objeto creado previamente:

obj1 == obj2
obj1 === obj2

El resultado es falso (con los dos operadores de igualdad) porque, aunque ambos son objetos vacíos de la misma clase, cada uno tiene su propia dirección de memoria (está guardado en su propio espacio de memoria) y cuando se comparan objetos, se comparan esas direcciones de memoria. En consecuencia dos objetos son iguales únicamente cuando en realidad se trata del mismo objeto. Por ejemplo si el objeto "obj2" se guarda en la variable "obj3" y se compara con "obj2":

obj3 = obj2
obj2 == obj3
obj2 === obj3

El resultado es verdadero (true), porque, aunque son dos variables diferentes, ambas apuntan a la misma dirección de memoria (ambas hacen referencia al mismo objeto, se trata de un objeto con dos nombres: "obj2" y "obj3").

Al igual que ocurre con un objeto literal, una vez creado un objeto a partir de una clase, es posible (en JavaScript) añadirle propiedades y métodos. Por ejemplo en las siguientes instrucciones se añaden los atributos "r=2.3" y "s=4.1" al objeto "obj1" y los atributos "x=5.2" y "y=7.1" al objeto "obj2":

obj1.r = 2.3; obj1.s = 4.1; obj1
obj2.x = 5.2; obj2.y = 7.1; obj2

Sin embargo, esa no es una buena práctica de programación. Sí es necesario añadir un atributo o método a un objeto, entonces se tienen que revisar el código para ver si ese atributo/método debería ser añadido a la clase.

Cuando se declara una clase (cuando se crea una clase) no puede volver a ser declarada. En ese sentido, las clases se comportan de manera similar a las variables declaradas con let o const.

Debido a que todo el código JavaScript en una página WEB corresponde a una sola sesión, todos los elementos que se declaran: variables, funciones, clases, etc., permanecen en memoria hasta que la página es cerrada o recargada, por lo tanto, todas las clases declaradas permanecen en memoria y como no pueden volver a ser declaradas, cuando se intenta modificarlas (algo que es necesario casi siempre), JavaScript lanza un error informado que la clase ya fue declarada..

Por esa razón, en la asignatura, las clases se declaran como funciones expresión en lugar de la forma estándar (de manera análoga a una función estándar y una función anónima):

nombre_de_la_clase = class { ... constructor(...) { } ... métodos getter y setter (dinámicos y estáticos) ... propiedades y métodos dinámicos (públicos y privados) ... propiedades y métodos estáticos (públicos y privados) ... bloques de inicialización estáticos ... }

El constructor y los métodos getter y setter

Normalmente, cuando se crea una clase, se declara también su constructor. El constructor es un método especial (una función) que se ejecuta automáticamente cuando se crea una instancia de la clase (cuando se crea un objeto) y es empleado para asignar valores iniciales a los atributos del objeto, así como realizar algunas validaciones y ejecutar los procedimientos iniciales que sean necesarios para dejar el objeto listo para su uso.

Así para representar a una persona en la POO, se debe crear una clase, por ejemplo, la clase "Persona" y en aplicación del principio de la abstracción, debe ser una representación simplificada de una persona, que en este caso tendrá solo los siguientes atributos: nombres, apellidos, carnet de identidad y fecha de nacimiento, el constructor de la clase se emplea para permitir asignar valores a estos atributos cuando se crea un objeto.

Por otra parte, para aplicar correctamente el principio de la encapsulación los atributos deben ser privados, es decir que sólo deben poder ser leídos y modificados por los métodos de la clase. En JavaScript, para declarar un atributo como privado, el nombre del atributo debe estar precedido por el símbolo #.

Para que los atributos puedan ser leídos y modificados desde los objetos de la clase, se deben programar dos tipos de métodos especiales: los métodos getter y los métodos setter. Los métodos getter se declaran anteponiendo la palabra get al nombre del método y se caracterizan porque no tienen ningún parámetro (no reciben ningún dato) y devuelven un resultado (normalmente el valor de un atributo o el resultado de alguna operación). Los métodos setter, se declaran anteponiendo la palabra set al nombre del método y se caracterizan porque sólo tienen un parámetro (donde reciben el nuevo valor del atributo). Casi siempre, antes de asignar el nuevo valor, se debe validar que el valor recibido sea un valor válido.

Además, la clase contará con dos métodos: uno para devolver el nombre completo de la persona y otro para calcular y devolver la edad de la persona.

Finalmente, siguiendo el principio del polimorfismo, se sobrescriben los métodos "toString" y "valueOf" (del objeto "Object") que por defecto devuelven "[object Object]" y "{}", que no son de utilidad práctica, por lo que se sobrescriben para que devuelvan una cadena (toString) y un objeto literal (valueOf) con los datos de la persona.

Al programar con el paradigma Orientado a Objetos, se hace uso frecuente de las clases disponibles en el lenguaje. Así, para trabajar con fechas (y tiempos) se emplea la clase Date. Si no se le manda ningún dato crea un objeto con la fecha actual, si se le manda un texto con el formato "aaaa/mm/dd" crea un objeto con la fecha actual. Los años, los meses (con base 0) y los días, se obtienen con los métodos "getFullYear", "getMonth" y "getDate" y el método "toLocaleDateString" devuelve la fecha como un texto con el formato "dd/mm/aaaa".

Por otra parte, para que sea válida la fecha de nacimiento en formato local ("dd/mm/aaa") debe ser transformada a formato internacional ("aaaa/mm/dd"). Con ese fin se emplean los métodos "split", "reverse" y "join" de la clase String. "Split" divide la cadena en los lugares que coinciden con su cadena ("/") y devuelve un vector con las cadenas resultantes ([aaaa,mm,dd]), "reverse" invierte los elementos del vector ([dd,mm,aaaa]) y "join" vuelve a unir las cadenas del vector, intercalándolas con su cadena ("/"). Estos y otros métodos serán estudiados con más detalle en capítulos posteriores.

También, para validar que el número sea entero se emplea el método estático Number.prototype, que devuelve verdadero si el número que recibe es entero (positivo o negativo). Los métodos estáticos se caracterizan porque sólo pertenecen a la clase, no a los objetos de la clase (como ocurre con los métodos dinámicos). Por esa razón son llamados desde la clase (en este caso la clase Number) y no desde el objeto (en este caso, no desde un número). Los métodos estáticos se comportan más como funciones estructuradas que como métodos: No tienen acceso a los atributos, reciben sus datos en forma de parámetros y devuelven los resultados igual que una función estructurada (todos los métodos de la clase Math son métodos estáticos).

Persona = class { #nombres; #apellidos; #cIdentidad; #fNacimiento; constructor(nombres, apellidos, cIdentidad, fNacimiento) { this.nombres = nombres; this.apellidos = apellidos; this.cIdentidad = cIdentidad; this.fNacimiento = fNacimiento; } set nombres(nombres) { this.#nombres = nombres; } set apellidos(apellidos) { this.#apellidos = apellidos; } set cIdentidad(ci) { // El carnet debe ser entero positivo con 7 o más dígitos if (!(Number.isInteger(ci) && ci>0) || ci<1e6) throw new Error("Carnet de identidad no válido"); this.#cIdentidad = ci; } set fNacimiento(fecha) { // Cambia el formato de la fecha de "dd/mm/aaaa" a "aaaa/mm/dd" fecha = fecha.split("/").reverse().join("/"); const nacimiento = new Date(fecha); // Fecha de nacimiento const hoy = new Date(); // Fecha actual // Si la fecha de nacimiento no tiene el formato válido if (!nacimiento.valueOf()) throw new Error("Formato erróneo, formato válido: aaaa/mm/dd"); // Si la fecha de nacimiento es mayor o igual a la fecha actual if (nacimiento>=hoy) throw new Error("La fecha de nacimiento debe ser menor a la fecha actual"); // La fecha de nacimiento se guarda como un objeto de la clase Date this.#fNacimiento = nacimiento; } get nombres() { return this.#nombres; } get apellidos() { return this.#apellidos; } get cIdentidad() { return this.#cIdentidad; } get fNacimiento() { return this.#fNacimiento.toLocaleDateString(); } get nombreCompleto() { return `${this.nombres} ${this.apellidos}`; } get edad() { const hoy = new Date(); // Fecha actual // Año, mes y día actual const [aa, ma, da] = [hoy.getFullYear(), hoy.getMonth(), hoy.getDate()]; // Se trabaja con la fecha de nacimiento (no con el texto) const nacimiento = this.#fNacimiento; // Año, mes y día de nacimiento const [an, mn, dn] = [nacimiento.getFullYear(), nacimiento.getMonth(), nacimiento.getDate()]; // Años entre la fecha actual y de nacimiento let edad = aa-an; // Si el mes actual es menor al de nacimiento, no ha cumplido años if (ma<mn) edad--; // Si es el mismo mes pero el día actual es menor al de nacimiento, no ha cumplido años else if(ma===mn && da<dn) edad--; return edad; } toString() { return `nombre: ${this.nombreCompleto} carnet de identidad: ${this.cIdentidad} fecha de nacimiento: ${this.fNacimiento} edad: ${this.edad} años`; } valueOf() { return { nombre: this.nombreCompleto, cIdentidad: this.cIdentidad, fNacimiento: this.fNacimiento, edad: this.edad }; } };

Ahora, con la clase elaborada, se pueden crear objetos de tipo "Persona". Por ejemplo, con la siguiente instrucción se crea el objeto "persona1":

persona1 = new Persona("Juan Carlos", "Carranza Siles", 3821245, "23/10/1998");

Llamando desde este objeto a los métodos getter disponibles, se obtiene:

persona1.nombres
persona1.apellidos
persona1.cIdentidad
persona1.fNacimiento
persona1.nombreCompleto
persona1.edad

Con el método "valueOf" se obtiene:

persona1.valueOf()

El método "toString" es llamado automáticamente por JavaScript, cuando requiere una cadena que represente al objeto. Por ejemplo cuando se quiere mostrar el objet con la función "print":

print(persona1)

Los métodos setter disponibles, permiten asignar nuevos valore a los atributos del objeto. Así, las siguientes instrucciones cambian los valores del objeto:

persona1.nombres = "José Luis"
persona1.apellidos = "Romero Alvis"
persona1.cIdentidad = 1123453
persona1.fNacimiento = "14/08/2006"
persona1.nombreCompleto
persona1.edad
print(persona1)

Extends

Como se dijo, uno de los fundamentos de la POO es la herencia. En JavaScript, para que una clase herede de otra se escribe la palabra reservada extends, de acuerdo a la siguiente sintaxis:

class nombre_clase_extendida extends nombre_clase_base { ... }

Si la clase padre (la clase base) tiene un constructor y la clase hija (la clase extendida) tiene atributos adicionales, se debe declarar un constructor en la clase hija y en ese constructor se debe llamar al constructor de la clase padre empleando la palabra reservada super: Ese es el nombre que JavaScript asigna automáticamente a la clase padre (por esa razón, super también permite llamar a otros métodos de la clase padre con la sintaxis: super.método()).

Por ejemplo, en la siguiente instrucción se crea la clase Estudiante, que hereda de la clase Persona (extiende a la clase Persona) añadiendo dos nuevos atributos: "carrera" y "cUniversitario":

Estudiante = class extends Persona { #carrera; #cUniversitario; constructor(nombres, apellidos, cIdentidad, fNacimiento, carrera, cUniversitario) { super(nombres, apellidos, cIdentidad, fNacimiento); this.carrera = carrera; this.cUniversitario = cUniversitario; } set carrera(carrera) { this.#carrera = carrera; } set cUniversitario(cUniversitario) { this.#cUniversitario = cUniversitario; } get carrera() { return this.#carrera; } get cUniversitario() { return this.#cUniversitario; } toString() { // Se añaden a la cadena los nuevos atributos return super.toString()+` carrera: ${this.carrera} carnet universitario: ${this.cUniversitario}`; } valueOf() { // Se añaden al objeto los nuevos atributos const value = super.valueOf(); value.carrera = this.carrera; value.cUniversitario = this.cUniversitario; return value; } };

Con esta clase se pueden crear objetos del tipo Estudiante, como el objeto "estudiante1":

estudiante1 = new Estudiante("Alberto", "Sandi Carballo", 32883235, "07/03/2007", "Ingeniería de Sistemas", "35-7325");

Ahora el método "toString" devuelve:

print(estudiante1);

Y el método "valueOf":

estudiante1.valueOf()

Y por supuesto, siguen funcionando correctamente los métodos de la clase padre:

estudiante1.nombres
estudiante1.apellidos
estudiante1.nombreCompleto
estudiante1.edad
estudiante1.fNacimiento

Y como se han escrito los métodos setter respectivos, se puede cambiar también el valor de los nuevos atributos:

estudiante1.carrera = "Ingeniería de Software"; estudiante1.cUniversitario = "115-3261";
print(estudiante1);
estudiante1.valueOf()