Programación Orientada a Objetos: Abstracción

9 minuto(s) de lectura

Este Post es la tercera parte de la serie “Programación Orientada a Objetos”. Esta serie tiene como objetivo establecer los fundamentos de la POO, utilizando Java como ejemplo. Los temas de esta serie son los siguientes:

  1. Definición de POO
  2. Clases y Objetos
  3. Herencia
  4. Abstracción
  5. Encapsulamiento
  6. Polimorfismo
  7. Composición
  8. Principios utilizados en la POO

Todo el código de la serie está disponible en GitHub.

El código correspondiente a esta tercera parte lo pueden encontrar aquí.

Todos los ejemplos están hechos en Netbeans.

Introducción

Cuando hacemos un programa traducimos los requerimientos o ideas en código. Usando la POO traducimos o modelamos estas ideas en objetos. Para hacer esto, necesitamos identificar las características de los objetos y sus comportamientos que queremos programar y que serían importantes en la ejecución de nuestro programa. El proceso mediante el cual se identifican los componentes necesarios para tus objetos, y los que no son necesarios, se conoce como abstracción.

La abstracción es un proceso de suma importancia en el diseño de nuestros sistemas ya que debemos de identificar adecuadamente las características del objeto que necesitamos implementar. La selección de estas características depende del contexto en el cual se utilizará tu objeto.

Como ejemplo, vamos a diseñar un pequeño sistema para llevar registro de alumnos que entran en una escuela y una manera de calcular su calificación. Podemos modelar un alumno en una clase Alumno. De igual manera, nuestros objetos Alumno tendrán algunas de las siguientes características:

  • Nombre
  • Grado escolar
  • Lista de materias cursadas
  • Promedio

De igual manera, en este contexto, no nos interesan algunas características que podrían tener los alumnos, como lo son estatura, color de ojos, peso, etc.

Si bien en la sección de Herencia realizamos este proceso de abstracción, Java y la POO nos ofrecen otras herramientas para realizar este proceso y hacer nuestro diseño de software de una manera más adecuada.

Nuestro ejemplo contará con dos tipos de alumnos, alumnos de primaria y alumnos de universidad. Ambos tipos de alumnos contarán con una lista de materias, su nombre, el grado escolar que cursan y el promedio que obtuvieron.

La diferencia va a ser que el promedio se calculará de una manera diferente para los dos: el promedio del alumno de primaria será de esta manera

  • Promedio de las materias - 80%
  • Conducta - 10%
  • Esfuerzo - 10%

Mientras que el promedio del alumno de universidad será solamente el promedio de las materias.

También contará con una clase Materia, que tendrá solamente el nombre y el promedio del alumno.

Para esto utilizaremos Clases y Métodos abstractos.

Clases y Métodos Abstractos

En nuestro ejemplo que realizaremos, vemos que existen dos tipos de alumnos. Por lo cual, podemos crear dos objetos diferentes de tipo AlumnoPrimaria y AlumnoUniversidad. Pero también podemos ver que nuestro proceso de abstracción nos llevó a observar que prácticamente todas las características de ambos objetos son iguales y que también tienen las mismas operaciones. Lo único que cambia entre ambos es la manera de realizar la operación de calcular el promedio.

Java nos da la opción de crear clases y métodos abstractos. Una clase abstracta se define de la siguiente manera:

public abstract class Alumno {

}

Una clase abstracta deberá ser extendida por otra clase no abstracta. Si se trata de crear un objeto con la clase abstracta, marcará error ya que no es posible hacer esto debido a la falta de implementación de los métodos abstractos. Nuestra clase abstracta Alumno contendrá todas las propiedades de nuestro alumno y sus métodos para acceder a ellas, así como un constructor que nos permita crear a nuestro alumno:

public abstract class Alumno {
    protected String nombre;
    protected List<Materia> materias;
    protected double promedio;
    protected String grado;

    public Alumno(String nombre, String grado) {
        this.nombre = nombre;
        this.grado = grado;
        this.materias = new ArrayList<>();
    }

    public String getNombre() {
        return nombre;
    }

    public List<Materia> getMaterias() {
        return materias;
    }

    public void agregarMateria(Materia materia) {
        this.materias.add(materia);
    }

    public String getGrado() {
        return grado;
    }

    public double getPromedio() {
        return promedio;
    }
}

Con esto, podemos crear otras dos clases específicas, que heredarán de nuestra clase abstracta:

public class AlumnoPrimaria extends Alumno {

    public AlumnoPrimaria(String nombre, String grado) {
        super(nombre, grado);
    }
}

Al heredar de una clase abstracta, Java nos pide que creemos un constructor que mande llamar al constructor de la clase padre. Así mismo, si intentamos crear un objeto directamente con la clase Alumno, nos mandará un error:

Alumno alumno = new Alumno(...);  ---> No está permitido.

De igual manera podemos crear nuestra clase AlumnoUniversidad:

public class AlumnoUniversidad extends Alumno {

     public AlumnoUniversidad(String nombre, String grado) {
          super(nombre, grado);
     }
}

Con esto, podemos agregar materias a cada objeto que creemos de las dos clases, así como obtener su nombre y el grado en el que está (todo este código está implementado en la clase abstracta Alumno).

Ahora necesitamos implementar nuestro método para calcular el promedio de cada alumno. Para esto, crearemos en nuestra clase Alumno un método abstracto:

public abstract void calcularCalificacionDelGrado();

Un método abstracto solamente es la definición del método sin implementación. Cuando se agrega un método abstracto a nuestra clase, la clase forzosamente debe de ser una clase abstracta.

Al agregar este método en nuestra clase Alumno, podemos ver que nuestras clases específicas AlumnoPrimaria y AlumnoUniversidad, nos piden implementar nuestro nuevo método.

Implementemos este método para la clase AlumnoPrimaria:

@Override
public void calcularCalificacionDelGrado() {
    double total = 0.0;
    double conducta = 0.0;
    double esfuerzo = 0.0;
    int materiasAcademicas = 0;

    for (int i = 0; i < materias.size(); i++) {
        Materia materia = materias.get(i);
        if (materia.getNombre().toLowerCase().equals("conducta")) {
            conducta += materia.getCalificacion();
        } else if (materia.getNombre().toLowerCase().equals("esfuerzo")) {
            esfuerzo += materia.getCalificacion();
        } else {
            total += materia.getCalificacion();
            materiasAcademicas++;
        }
    }

    promedio = ((total / materiasAcademicas) * 0.8) + (conducta * 0.1) + (esfuerzo * 0.1);
}

Podemos ver que es necesario agregar la anotación @Override a nuestro método y que puede tener una implementación propia para cada clase.

Ahora implementemos este método para nuestra clase AlumnoUniversidad

@Override
public void calcularCalificacionDelGrado() {
    double total = 0.0;
    for (int i = 0; i < materias.size(); i++) {
        Materia materia = materias.get(i);
        total += materia.getCalificacion();
    }
    promedio = total / materias.size();
}

Para hacer uso de nuestras clases, crearemos un alumno de cada tipo y le agregaremos sus materias con su calificación correspondiente:

Alumno alumnoPrimaria = new AlumnoPrimaria("Juan", "Primero");
alumnoPrimaria.agregarMateria(new Materia("Matemáticas", 10));
alumnoPrimaria.agregarMateria(new Materia("Español", 8));
alumnoPrimaria.agregarMateria(new Materia("Conducta", 9));
alumnoPrimaria.agregarMateria(new Materia("Esfuerzo", 10));

Alumno alumnoUniversidad = new AlumnoUniversidad("Pablo", "Sexto Semestre");
alumnoUniversidad.agregarMateria(new Materia("Matemáticas Aplicadas", 7));
alumnoUniversidad.agregarMateria(new Materia("Diseño de Software", 8));
alumnoUniversidad.agregarMateria(new Materia("Sistemas Operativos", 8));
alumnoUniversidad.agregarMateria(new Materia("Física Avanzada", 9));

Podemos hacer uso ahora del método calcularCalificacionDelGrado() de cada objeto:

alumnoPrimaria. calcularCalificacionDelGrado();
alumnoUniversidad. calcularCalificacionDelGrado();

Pero para ver el alcance y el uso de nuestras clases abstractas, crearemos una lista de Alumno y agregaremos nuestros dos alumnos a la lista:

List<Alumno> alumnos = new ArrayList<>();
alumnos.add(alumnoPrimaria);
alumnos.add(alumnoUniversidad);

Si bien tenemos nuestras clases AlumnoPrimaria y AlumnoSecundaria, ambas clases también son de la clase Alumno, por eso podemos realizar esto.

Y por último, calcularemos el promedio para cada alumno y lo imprimiremos en la pantalla:

for (int i = 0; i < alumnos.size(); i++) {
    Alumno alumno = alumnos.get(i);
    alumno.calcularCalificacionDelGrado();
    System.out.println(alumno.getNombre() + ": " + alumno.getPromedio());
}

Cada alumno sabe de qué tipo específico de alumno se trata, por eso cuando se ejecuta la línea de código

alumno.calcularCalificacionDelGrado();

dependiendo del tipo de alumno que se trate, se ejecuta su método específico.

Para Juan, el alumno de primaria esperaríamos esta calificación:

  • Materias académicas: 9 * 0.8 = 7.2
  • Esfuerzo: 10 * 0.1 = 1.0
  • Conducta: 9 * 0.1 = 0.9
  • Total = 9.1

Para Pablo, el alumno de universidad:

  • Materias: (7 + 8 + 8 + 9) / 4 = 8.0

Podemos ver en la consola el siguiente resultado:

Juan: 9.1
Pablo: 8.0

Las ventajas de utilizar clases abstractas es que podemos tener implementación dentro de la clase abstracta, en nuestro caso los métodos para obtener y modificar las variables del objeto. También podemos utilizar las clases abstractas cuando tenemos varias clases que comparten cierta información y que difieren solamente en la implementación de algún método o comportamiento.

Interfaces

Las clases abstractas son muy útiles, como ya lo vimos, pero tienen una limitación: en Java solamente se permite herencia simple - heredar de una sola clase. Al heredar de la clase abstracta, ocupamos este único lugar que tenemos disponible. Si en cambio queremos que cualquier clase, aún cuando no estén relacionadas, pueda implementar algunos métodos, especificar el comportamiento de una clase y además permitir que se hereden de varios tipos, se puede utilizar una interfaz.

Una interfaz se puede ver como un contrato que debe de cumplir la clase. Todas las clases que implementen una interfaz tendrán disponibles los métodos que se declaran en la interfaz.

Para definir una interfaz se utiliza la palabra interface y el nombre de la interfaz. Dentro de ella, se declaran los métodos, sin implementación, que deberán implementar las clases que implementen la interfaz.

A nuestro ejemplo de Alumnos y Materias, agregaremos una interfaz, IAlumno, la cual tendrá dos métodos: obtenerInformacion(); e imprimirMaterias();

public interface IAlumno {
    String obtenerInformacion();
    void imprimirMaterias();
}

Ahora haremos que ambos objetos AlumnoPrimaria y AlumnoUniversidad implementen esta interfaz:

public class AlumnoPrimaria extends Alumno implements IAlumno {
    ......

    @Override
    public String obtenerInformacion() {
        ....
    }

    @Override
    public void imprimirMaterias() {
        ....
    }

    ....
}
public class AlumnoUniversidad extends Alumno implements IAlumno {
    ......

    @Override
    public String obtenerInformacion() {
        ....
    }

    @Override
    public void imprimirMaterias() {
        ....
    }

    ....
}

En ambas clases debemos de implementar los métodos de la interfaz IAlumno. Para este ejemplo, ambas implementaciones harán lo mismo.

@Override
public String obtenerInformacion() {
    return "Nombre: " + nombre + ", Grado: " + grado;
}

@Override
public void imprimirMaterias() {
    for (int i = 0; i < materias.size(); i++) {
        Materia materia = materias.get(i);
        System.out.println("Nombre: " + materia.getNombre() + " - " + materia.getCalificacion());
    }
}

Para hacer uso de esta interfaz, haremos que nuestros objetos alumnos que están en la lista muestren la información y las materias.

for (int i = 0; i < alumnos.size(); i++) {
    IAlumno alumno = (IAlumno) alumnos.get(i);
    System.out.println(alumno.obtenerInformacion());
    alumno.imprimirMaterias();
}

Como podemos ver, la lista de alumnos es de tipo Alumno, pero sabemos que cada alumno también es de tipo IAlumno, por lo tanto el cast (IAlumno) se puede hacer. También podemos ver que el objeto IAlumno alumno, tendrá disponibles los métodos obtenerInformacion() e imprimirMaterias().

El resultado que vemos en la consola es el siguiente:

=============== RESULTADOS DE LA INTERFAZ IALUMNO ===============
Nombre: Juan, Grado: Primero
Matemáticas - 10.0
Español - 8.0
Conducta - 9.0
Esfuerzo - 10.0

Nombre: Pablo, Grado: Sexto Semestre
Matemáticas Aplicadas - 7.0
Diseño de Software - 8.0
Sistemas Operativos - 8.0
Física Avanzada - 9.0

Una de las ventajas de las interfaces es que las clases pueden implementar el número que quieran de interfaces:

public class AlumnoUniversidad implements interfaz1, interfaz2, interfaz3....

Conclusión

La abstracción es un proceso de suma importancia a la hora de diseñar sistemas de software. Un buen proceso de abstracción lleva a un buen diseño del sistema. Para poder lograr esto de una mejor manera, contamos con las clases abstractas y las interfaces. Las clases abstractas definen métodos abstractos, los cuales deben de ser implementados por las clases que hereden la clase abstracta. De igual manera, contamos con Interfaces, que son contratos que las clases deben de cumplir, al implementar siempre los métodos definidos en la interfaz.

Etiquetas:

Categorías:

Actualizado:

Deja un comentario