Programación Orientada a Objetos Parte II: Los pilares

Programación Orientada a Objetos Parte II: Los pilares

Encapsulación,abstracción y herencia son algunos dos los conceptos clave de la POO.

Introducción

Sabemos que el paradigma de programación orientada a objetos (POO de ahora en adelante) se centra en objetos, pero antes de que existiera, se usaba lo que llamamos programación procedimental.

En la programación procedimental, se dividian los programas en un conjunto de funciones, por lo que lo que teníamos eran datos almacenados en variables y funciones que operaban sobre esos datos.

Los problemas más destacados de este acercamiento es que facilita la creación de código espagueti, con códigos que se repiten, una fuerte interdependencia entre funciones (por lo que si quisieramos modificar una función estariamos afectando otras funciones que dependiensen de esta primera) etc... por lo que la POO resuelve estos problemas.

let sueldoBase = 30_000;
let horasExtra = 10;
let precioHora = 20;

function getSueldo(sueldoBase,horasExtra,precioHora){
return sueldoBase + (horasExtra * precioHora);
}

getSueldo(sueldoBase,horasExtra,precioHora) // 30200

En este ejemplo, tenemos un ejemplo de programación procedimental, donde tenemos las variables por un lado y funciones por otro, por lo que están disociadas.

OOP: Pilares

Cuando hablamos de OOP, nos referimos a la modelación de un sistema como una colección de objetos, donde cada objeto representa algún aspecto en particular de dicho sistema.

Encapsulación

En OOP, la encapsulación es un mecanismo para reunir/agrupar variables y funciones relacionadas entre sí, dentro de una estructura o unidad a la que llamamos objeto.

Cuando dentro de un objeto, a las variables las llamamos "propiedades" y a las funciones "métodos".

La encapsulación consiste en hacer que los datos sean modificados únicamente por las funciones destinadas a tal efecto haciendo que los datos sean consistentes y que no puedan ser modificados por código de fuera del objeto, evitando inconsistencias.

Los datos y sus estructuras de datos no están accesibles de forma directa, sino que para acceder a ellos o manipularlos, se ha de realizar a través de funciones asociadas.

let empleado = {
  sueldoBase : 30_000,
  horasExtra : 10,
  precioHora : 20,
  getSueldo : function(){
    return this.sueldoBase + (this.horasExtra * this.precioHora);
  }

}

empleado.getSueldo() // 30200

En este ejemplo tenemos un objeto empleado el cual dispone de 3 propiedades y 1 método. Si observamos el método getSueldo, no recibe ningún parámetro, al contrario de la función en nuestro primer ejemplo, que recibe 3 parámetros.

La razón por la que aquí no recibimos ningun parámetro, es porque estos "parámetros" en realidad están encapsulados dentro del objeto como "propiedades". Al formar parte de una unidad, las propiedades y métodos estan altamente relacionados entre sí.

Para una función, cuanto menos parámetros tenga, más facil es de utilizarse y mantenerse.

Abstracción

Como hemos dicho, al modelar un sistema como una colección de objetos, este sistema dispone de vários "módulos".

El objetivo principal de la abstracción es mostrar/disponibilizar solamente los atributos "públicos" a los módulos y al usuario, ocultando toda información innecesaria o información sobre lo que está ocurriendo "por debajo".

Esto reduce la complejidad al ocultar toda la lógica que existe por detrás creando una interfaz más simple, además de reducir el impacto del cambio en los métodos internos de un objeto, por lo que si modificamos sus métodos, estos cambios no se filtrarán hacia el "exterior".

El resumen,un objeto provee una interfaz pública a otras partes del código que necesiten usarla, pero, mantiene su propio estado privado e interno, por lo que las otras partes del código no tienen porque saber qué está ocurriendo dentro del objeto.

La esencia de la abstracción es conservar información que es relevante a un contexto, y olvidar/ocultar información que es irrelevante en dicho contexto.

— John V. Guttag

Propriedades y métodos privados

Imaginemos que disponemos de esta función constructora:

function Empleado (nombre,horasExtra) {
  this.sueldoBase = 30_000,
  this.precioHora = 20,
  this.horasExtra = horasExtra

  this.total = function(){
    return sueldoBase + (horasExtra * precioHora);
  }

  this.getSueldo = function (){
    console.log(this.total)
  }
}

Ahora mismo al crear una instancia usando este constructor, todas las propiedades y métodos están disponibles desde afuera, es decir para quién lo esté consumiendo.

instancia Empleado.png

No todos las propiedades y/o métodos deberían accederse desde el exterior, puesto que si hicieramos algo como por ejemplo instancia.total = false, esto rompería nuestro método completamente afectando el resultado final de éste. Por ejemplo:

function Empleado(horasExtra) {
  this.sueldoBase = 30_000,
  this.precioHora = 20,
  this.horasExtra = horasExtra;

  this.total = () => {
    return this.sueldoBase + this.horasExtra * this.precioHora;
  };

  this.getSueldo = function () {
    return this.total();
  };
}

const instancia = new Empleado(20);

instancia.total = false;

console.log(instancia.getSueldo());

uncaught type error oop.png

Para aplicar la abstracción, queremos que total y horasExtra no sean accesibles desde fuera de esta, es decir, que sean elementos privados, y que sea accesible sólo desde dentro del método getSueldo (gracias a closure).

Ahora mismo,this.total referencia a new Empleado(), es decir, es parte del objeto. Si declaramos una variable sin referenciar a this, dentro de la función constructora, ésta será privada, dado que no la hemos configurado como una propiedad del objeto si no como una variable normal y corriente.

Como sabemos, una variable declarada dentro de una función sólo existe dentro del ámbito (o scope) de dicha función, por lo que si intentamos acceder a ella desde fuera no será posible, con esta técnica podemos esconder propiedades o métodos del exterior

function Empleado(horasExtra) {
  this.sueldoBase = 30_000, 
  this.precioHora = 20;

  let hExtras = horasExtra;

  let total = () => {
    return this.sueldoBase + hExtras * this.precioHora;
  };

  this.getSueldo = function () {
    return total();
  };
}

const instancia = new Empleado(20);

console.log(instancia.getSueldo());

Herencia

La herencia es un procedimiento por el cual una clase B hereda propiedades y métodos de una clase A.

La clase de la cual se hereda tales propiedades y métodos es llamada clase padre, y la clase que los herada se llama clase hija.

Esto nos brinda gran reusabilidad y elude redundancia en nuestro código y, además de heredar propiedades y métodos de una clase padre, la clase "destino" también puede implementar los suyos propios.

Clases y constructores

Podemos declarar una clase usando la palabra-clave class, aquí seguiremos el ejemplo que ya hemos venido dando usando Miembros para describir los miembros de una empresa, donde se distinguiran entre jefes y empleados:

class Miembro {
  nombre;
  cargo;

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

  presentarse(){
    console.log(`Hola, soy ${this.nombre} y soy ${cargo}`)
  }
}

Este código declara una clase de nombre Miembros, y contiene:

  • propiedades nombrey cargo
  • un constructor que toma como parametro los parámetros nombre y cargo, los cuales se usan para inicializar las propiedades
  • un método presentarse() que puede referirse a las propiedades del objeto usando la palabra-clave this

Además, el constructor es definido usando la palabra-clave constructor; este constructor se comporta de manera idéntica a un constructor que es declarado fuera de una clase, es decir, irá:

  • crear un nuevo objeto
  • une this al nuevo objeto, por lo que nos podemos referir a this dentro del constructor
  • ejecuta el código dentro del constructor
  • retorna un nuevo objeto.

Considerado esto, podemos crear una nueva instancia de esta clase usando:

const lucas = new Miembro("Lucas","Desarrollador Web"); //

Para hacer nuestras propiedades y métodos privados dentro de una clase,debemos usar # seguido del nombre de la priopiedad/método

class Miembro {
  nombre;
  cargo;
  #salario;

  constructor(nombre, cargo, salario) {
    this.nombre = nombre;
    this.cargo = cargo;
    this.#salario = salario;
  }

  #presentarse() {
    console.log(`Hola, soy ${this.nombre} y soy ${this.cargo}`);
  }

  mensaje() {
    this.#presentarse();
  }
}

const instancia = new Miembro("Lucas", "Desarrolador Web", 2000);

instancia.mensaje(); // Hola, soy Lucas y soy Desarrolador Web

Herencia entre clases

Dada nuestra clase Miembros, definamos la subclase Jefe.

class Jefe extends Miembro {
  constructor(nombre, cargo, salario, dirigeA) {
    super(nombre, cargo, salario);
    this.dirigeA = dirigeA;
  }

  #presentarse() {
    console.log(
      `Mi nombre es ${this.nombre}, soy ${this.cargo} y voy a dirigir la ${this.dirigeA}`
    );
  }

  mensaje() {
    this.#presentarse();
  }
}

const jefe1 = new Jefe(
  "Pedro",
  "Gerente de operaciones",
  3000,
  "sucursal de Málaga"
);

jefe1.mensaje(); // Mi nombre es Pedro, soy Gerente de operaciones y voy a dirigir la sucursal de Málaga

Usamos la palabra-clave extends para decir que esta clase heredará de otra clase.

Aqui queremos inicializar la variable dirigeA cuando creamos un nuevo Jefe, por lo que la definimos dentro del constructor, el cual toma nombre, cargo y dirigeA como argumentos.

Lo primero que hace el constructor es llamar al constructor de clase padre usando super(), y pasándole los parámetros nombre y cargo y éste se encargará de definirlos. En seguida el constructor define la propiedad dirigeA.

También hemos sobrescrito el método presentarse() que recibimos desde la clase padre, por lo que ahora la frase de presentación se ve modificada según dirigeA.