Mastering OOP: una guía práctica sobre herencia, interfaces y clases abstractas

Publicado: 2022-03-10
Resumen rápido ↬ Podría decirse que la peor manera de enseñar los fundamentos de la programación es describir qué es algo, sin mencionar cómo o cuándo usarlo. En este artículo, Ryan M. Kay analiza tres conceptos básicos de programación orientada a objetos en los términos menos ambiguos para que nunca más se pregunte cuándo usar la herencia, las interfaces o las clases abstractas. Los ejemplos de código proporcionados están en Java con algunas referencias a Android, pero solo se requiere un conocimiento básico de Java para seguir.

Por lo que puedo decir, es poco común encontrar contenido educativo en el campo del desarrollo de software que proporcione una combinación adecuada de información teórica y práctica. Si tuviera que adivinar por qué, supongo que es porque las personas que se enfocan en la teoría tienden a dedicarse a la enseñanza, y las personas que se enfocan en la información práctica tienden a cobrar para resolver problemas específicos, utilizando lenguajes y herramientas específicos.

Esta es, por supuesto, una generalización amplia, pero si la aceptamos brevemente por el bien de los argumentos, se deduce que muchas personas (de ninguna manera todas las personas) que asumen el papel de maestro, tienden a ser deficientes o completamente incapaces. de explicar el conocimiento práctico relevante para un concepto particular.

En este artículo, haré todo lo posible para discutir tres mecanismos centrales que encontrará en la mayoría de los lenguajes de programación orientada a objetos (POO): herencia , interfaces (también conocidos como protocolos ) y clases abstractas . En lugar de darle explicaciones verbales técnicas y complejas de lo que es cada mecanismo, haré todo lo posible para centrarme en lo que hacen y cuándo usarlos.

Sin embargo, antes de abordarlos individualmente, me gustaría discutir brevemente lo que significa dar una explicación teóricamente sólida, pero prácticamente inútil. Mi esperanza es que pueda usar esta información para ayudarlo a examinar diferentes recursos educativos y evitar culparse a sí mismo cuando las cosas no tienen sentido.

¡Más después del salto! Continúe leyendo a continuación ↓

Diferentes grados de conocimiento

Sabiendo nombres

Saber el nombre de algo es posiblemente la forma más superficial de saber. De hecho, un nombre generalmente solo es útil en la medida en que muchas personas lo usan comúnmente para referirse a la misma cosa y/o ayuda a describir la cosa. Desafortunadamente, como ha descubierto cualquiera que haya pasado tiempo en este campo, muchas personas usan nombres diferentes para lo mismo (por ejemplo , interfaces y protocolos ), los mismos nombres para cosas diferentes (por ejemplo, módulos y componentes ), o nombres que son esotéricos para el hasta el punto de ser absurdo (p. ej., O bien de la mónada ). En última instancia, los nombres son solo indicadores (o referencias) a modelos mentales, y pueden tener diversos grados de utilidad.

Para hacer que este campo sea aún más difícil de estudiar, me atrevería a suponer que para la mayoría de las personas, escribir código es (o al menos fue) una experiencia única. Aún más complicado es comprender cómo ese código finalmente se compila en lenguaje de máquina y se representa en la realidad física como una serie de impulsos eléctricos que cambian con el tiempo. Incluso si uno puede recordar los nombres de los procesos, conceptos y mecanismos que se emplean en un programa, no hay garantía de que los modelos mentales que uno crea para tales cosas sean consistentes con los modelos de otro individuo; y mucho menos si son objetivamente precisos.

Es por estas razones, junto con el hecho de que no tengo una buena memoria natural para la jerga, que considero que los nombres son el aspecto menos importante de saber algo. Eso no quiere decir que los nombres sean inútiles, pero en el pasado aprendí y empleé muchos patrones de diseño en mis proyectos, solo para enterarme del nombre de uso común meses o incluso años después.

Conocer definiciones y analogías verbales

Las definiciones verbales son el punto de partida natural para describir un nuevo concepto. Sin embargo, al igual que con los nombres, pueden tener diversos grados de utilidad y relevancia; gran parte de eso depende de cuáles sean los objetivos finales del alumno. El problema más común que veo en las definiciones verbales es el conocimiento asumido típicamente en forma de jerga.

Supongamos, por ejemplo, que fuera a explicar que un subproceso es muy parecido a un proceso , excepto que los subprocesos ocupan el mismo espacio de direcciones de un proceso determinado. Para alguien que ya está familiarizado con procesos y espacios de direcciones , esencialmente he dicho que los subprocesos se pueden asociar con su comprensión de un proceso (es decir, poseen muchas de las mismas características), pero se pueden diferenciar en función de una característica distinta.

Para alguien que no posee ese conocimiento, en el mejor de los casos, no he tenido ningún sentido y, en el peor de los casos, he hecho que el alumno se sienta inadecuado de alguna manera por no saber las cosas que supuse que deberían saber. Para ser justos, esto es aceptable si sus alumnos realmente deberían poseer dicho conocimiento (como enseñar a estudiantes graduados o desarrolladores experimentados), pero considero que es un fracaso monumental hacerlo en cualquier material de nivel introductorio.

A menudo es muy difícil dar una buena definición verbal de un concepto cuando no se parece a nada que el alumno haya visto antes. En este caso, es muy importante que el maestro seleccione una analogía que probablemente sea familiar para la persona promedio y también relevante en la medida en que transmita muchas de las mismas cualidades del concepto.

Por ejemplo, es sumamente importante que un desarrollador de software comprenda lo que significa cuando las entidades de software (diferentes partes de un programa) están estrechamente acopladas o débilmente acopladas . Al construir un cobertizo de jardín, un carpintero joven puede pensar que es más rápido y más fácil armarlo usando clavos en lugar de tornillos. Esto es cierto hasta el punto en que se comete un error, o un cambio en el diseño del cobertizo del jardín requiere la reconstrucción de parte del cobertizo.

En este punto, la decisión de utilizar clavos para unir firmemente las partes del cobertizo del jardín ha hecho que el proceso de construcción en su conjunto sea más difícil, probablemente más lento, y extraer los clavos con un martillo corre el riesgo de dañar la estructura. Por el contrario, los tornillos pueden tardar un poco más en ensamblarse, pero son fáciles de quitar y presentan poco riesgo de dañar las partes cercanas del cobertizo. Esto es lo que quiero decir con acoplamiento débil . Naturalmente, hay casos en los que realmente solo necesitas un clavo, pero esa decisión debe estar guiada por el pensamiento crítico y la experiencia.

Como discutiré en detalle más adelante, existen diferentes mecanismos para conectar partes de un programa que proporcionan diversos grados de acoplamiento ; como clavos y tornillos . Si bien mi analogía puede haberlo ayudado a comprender lo que significa este término de importancia crítica, no le he dado ninguna idea de cómo aplicarlo fuera del contexto de la construcción de un cobertizo de jardín. Esto me lleva al tipo de conocimiento más importante y la clave para comprender profundamente conceptos vagos y difíciles en cualquier campo de investigación; aunque nos limitaremos a escribir código en este artículo.

Saber en código

En mi opinión, estrictamente en lo que respecta al desarrollo de software, la forma más importante de conocer un concepto proviene de poder usarlo en el código de la aplicación en funcionamiento. Esta forma de conocimiento se puede lograr simplemente escribiendo mucho código y resolviendo muchos problemas diferentes; No es necesario incluir nombres de jerga ni definiciones verbales.

En mi propia experiencia, recuerdo haber resuelto el problema de comunicarme con una base de datos remota y una base de datos local a través de una sola interfaz (sabrá lo que eso significa pronto si aún no lo sabe); en lugar de que el cliente (cualquiera que sea la clase que hable con la interfaz ) necesite llamar explícitamente a la base de datos remota y local (o incluso a una base de datos de prueba). De hecho, el cliente no tenía idea de lo que había detrás de la interfaz, por lo que no necesitaba cambiarla independientemente de si se estaba ejecutando en una aplicación de producción o en un entorno de prueba. Aproximadamente un año después de que resolví este problema, me encontré con el término "Patrón de fachada", y no mucho después con el término "Patrón de repositorio", que son nombres que la gente usa para la solución descrita anteriormente.

Todo este preámbulo es para iluminar algunas de las fallas que se cometen con mayor frecuencia al explicar temas como la herencia , las interfaces y las clases abstractas . De los tres, la herencia es probablemente la más simple de usar y comprender. En mi experiencia tanto como estudiante de programación como profesor, los otros dos son casi invariablemente un problema para los estudiantes a menos que se preste especial atención a evitar los errores discutidos anteriormente. De ahora en adelante, haré todo lo posible para que estos temas sean tan simples como deberían ser, pero no más simples.

Una nota sobre los ejemplos

Como soy más fluido en el desarrollo de aplicaciones móviles de Android, usaré ejemplos tomados de esa plataforma para poder enseñarle sobre la creación de aplicaciones GUI al mismo tiempo que presento las características del lenguaje de Java. Sin embargo, no entraré en tantos detalles que los ejemplos deberían ser ininteligibles para alguien con un conocimiento superficial de Java EE, Swing o JavaFX. Mi objetivo final al discutir estos temas es ayudarlo a comprender lo que significan en el contexto de resolver un problema en casi cualquier tipo de aplicación.

También me gustaría advertirle, querido lector, que a veces puede parecer que estoy siendo innecesariamente filosófico y pedante sobre palabras específicas y sus definiciones. La razón de esto es que realmente se requiere un fundamento filosófico profundo para comprender la diferencia entre algo que es concreto (real) y algo que es abstracto (menos detallado que una cosa real). Esta comprensión se aplica a muchas cosas fuera del campo de la informática, pero es particularmente importante para cualquier desarrollador de software comprender la naturaleza de las abstracciones . En cualquier caso, si mis palabras le fallan, espero que los ejemplos en código no lo hagan.

herencia e implementación

Cuando se trata de crear aplicaciones con una interfaz gráfica de usuario (GUI), se puede decir que la herencia es el mecanismo más importante para hacer posible la creación rápida de una aplicación.

Aunque hay un beneficio menos conocido del uso de herencia que se discutirá más adelante, el beneficio principal es compartir la implementación entre clases . Esta palabra “implementación”, al menos a los efectos de este artículo, tiene un significado distinto. Para dar una definición general de la palabra en inglés, podría decir que implementar algo es hacerlo real .

Para dar una definición técnica específica del desarrollo de software, podría decir que implementar una pieza de software es escribir líneas concretas de código que satisfagan los requisitos de dicha pieza de software. Por ejemplo, supongamos que estoy escribiendo un método de suma : private double sum(double first, double second){

 private double sum(double first, double second){ //TODO: implement }

El fragmento anterior, aunque he llegado a escribir un tipo de retorno ( double ) y una declaración de método que especifica los argumentos ( first, second ) y el nombre que se puede usar para llamar a dicho método ( sum ), tiene no ha sido implementado . Para implementarlo , debemos completar el cuerpo del método así:

 private double sum(double first, double second){ return first + second; }

Naturalmente, el primer ejemplo no compilaría, pero veremos momentáneamente que las interfaces son una forma en la que podemos escribir este tipo de funciones no implementadas sin errores.

Herencia en Java

Presumiblemente, si está leyendo este artículo, ha utilizado la palabra clave extends de Java al menos una vez. La mecánica de esta palabra clave es simple y se describe con mayor frecuencia mediante ejemplos relacionados con diferentes tipos de animales o formas geométricas; Dog y Cat extienden Animal , y así sucesivamente. Asumiré que no necesito explicarle la teoría de tipos rudimentaria, así que entremos directamente en el beneficio principal de la herencia en Java, a través de la palabra clave extends .

Crear una aplicación "Hello World" basada en consola en Java es muy simple. Suponiendo que posee un compilador de Java ( javac ) y un entorno de tiempo de ejecución ( jre ), puede escribir una clase que contenga una función principal como esta:

 public class JavaApp{ public static void main(String []args){ System.out.println("Hello World"); } }

Crear una aplicación GUI en Java en casi cualquiera de sus plataformas principales (Android, Enterprise/Web, Desktop), con un poco de ayuda de un IDE para generar el código básico/repetitivo de una nueva aplicación, también es relativamente fácil gracias a la extends la palabra clave.

Supongamos que tenemos un diseño XML llamado activity_main.xml (generalmente construimos interfaces de usuario declarativamente en Android, a través de archivos de diseño) que contiene un TextView (como una etiqueta de texto) llamado tvDisplay :

 <?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android: android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" /> </FrameLayout>

Además, supongamos que nos gustaría que tvDisplay diga "¡Hola mundo!" Para hacerlo, simplemente necesitamos escribir una clase que use la palabra clave extends para heredar de la clase Activity :

 import android.app.Activity; import android.os.Bundle; import android.widget.TextView; public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ((TextView)findViewById(R.id.tvDisplay)).setText("Hello World"); }

El efecto de heredar la implementación de la clase Activity se puede apreciar mejor echando un vistazo rápido a su código fuente. Dudo mucho que Android se hubiera convertido en la plataforma móvil dominante si se necesitara implementar incluso una pequeña porción de las más de 8000 líneas necesarias para interactuar con el sistema, solo para generar una ventana simple con algo de texto. La herencia es lo que nos permite no tener que reconstruir el marco de trabajo de Android , o cualquier plataforma con la que esté trabajando, desde cero.

La herencia se puede utilizar para la abstracción

En la medida en que se puede usar para compartir la implementación entre clases, la herencia es relativamente simple de entender. Sin embargo, hay otra forma importante en la que se puede usar la herencia , que está relacionada conceptualmente con las interfaces y las clases abstractas que discutiremos pronto.

Por favor, supongamos por un momento que una abstracción, usada en el sentido más general, es una representación menos detallada de una cosa . En lugar de matizar eso con una larga definición filosófica, intentaré señalar cómo funcionan las abstracciones en la vida diaria, y poco después las discutiré expresamente en términos de desarrollo de software.

Supongamos que viaja a Australia y sabe que la región que está visitando alberga una densidad particularmente alta de serpientes taipán del interior (aparentemente son bastante venenosas). Decide consultar Wikipedia para aprender más sobre ellos mirando imágenes y otra información. Al hacerlo, ahora eres muy consciente de un tipo particular de serpiente que nunca antes habías visto.

Las abstracciones, ideas, modelos, o como quieras llamarlos, son representaciones menos detalladas de una cosa. Es importante que sean menos detallados que los reales porque una serpiente real puede morderte; las imágenes en las páginas de Wikipedia normalmente no lo hacen. Las abstracciones también son importantes porque tanto las computadoras como los cerebros humanos tienen una capacidad limitada para almacenar, comunicar y procesar información. Tener suficientes detalles para usar esta información de manera práctica, sin ocupar demasiado espacio en la memoria, es lo que hace posible que las computadoras y los cerebros humanos resuelvan problemas.

Para volver a vincular esto con la herencia , los tres temas principales que estoy discutiendo aquí pueden usarse como abstracciones o mecanismos de abstracción . Supongamos que en el archivo de diseño de nuestra aplicación "Hello World", decidimos agregar ImageView , Button e ImageButton :

 <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <Button android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageButton android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageView android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> </LinearLayout>

Suponga también que nuestra Actividad ha implementado View.OnClickListener para manejar los clics:

 public class MainActivity extends Activity implements View.OnClickListener { private Button b; private ImageButton ib; private ImageView iv; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); //... b = findViewById(R.id.imvDisplay).setOnClickListener(this); ib = findViewById(R.id.btnDisplay).setOnClickListener(this); iv = findViewById(R.id.imbDisplay).setOnClickListener(this); } @Override public void onClick(View view) { final int id = view.getId(); //handle click based on id... } }

El principio clave aquí es que Button , ImageButton e ImageView heredan de la clase View . El resultado es que esta función onClick puede recibir eventos de clic de elementos de interfaz de usuario dispares (aunque relacionados jerárquicamente) al hacer referencia a ellos como su clase principal menos detallada. Esto es mucho más conveniente que tener que escribir un método distinto para manejar cada tipo de widget en la plataforma Android (sin mencionar los widgets personalizados).

Interfaces y Abstracción

Es posible que haya encontrado el ejemplo de código anterior un poco aburrido, incluso si entendió por qué lo elegí. Ser capaz de compartir la implementación a través de una jerarquía de clases es increíblemente útil, y diría que es la principal utilidad de la herencia . En cuanto a permitirnos tratar un conjunto de clases que tienen una clase principal común como del mismo tipo (es decir, como la clase principal ), esa función de herencia tiene un uso limitado.

Por limitado, me refiero al requisito de que las clases secundarias estén dentro de la misma jerarquía de clases para que se haga referencia a ellas o se las conozca como la clase principal. En otras palabras, la herencia es un mecanismo muy restrictivo para la abstracción . De hecho, si supongo que la abstracción es un espectro que se mueve entre diferentes niveles de detalle (o información), podría decir que la herencia es el mecanismo menos abstracto para la abstracción en Java.

Antes de continuar con la discusión de las interfaces , me gustaría mencionar que a partir de Java 8 , se agregaron a las interfaces dos funciones llamadas Métodos predeterminados y Métodos estáticos . Los discutiré eventualmente, pero por el momento me gustaría que fingiéramos que no existen. Esto es para que me resulte más fácil explicar el propósito principal de usar una interfaz , que inicialmente fue, y podría decirse que sigue siendo, el mecanismo más abstracto para la abstracción en Java .

Menos detalle significa más libertad

En la sección sobre herencia , di una definición de la palabra implementación , que pretendía contrastar con otro término que ahora abordaremos. Para ser claros, no me importan las palabras en sí, o si estás de acuerdo con su uso; solo que entiendas a lo que apuntan conceptualmente.

Mientras que la herencia es principalmente una herramienta para compartir la implementación en un conjunto de clases, podríamos decir que las interfaces son principalmente un mecanismo para compartir el comportamiento en un conjunto de clases. El comportamiento utilizado en este sentido es realmente solo una palabra no técnica para métodos abstractos . Un método abstracto es un método que, de hecho, no puede contener un cuerpo de método :

 public interface OnClickListener { void onClick(View v); }

La reacción natural para mí y varias personas a las que he instruido, después de mirar por primera vez una interfaz , fue preguntarme cuál podría ser la utilidad de compartir solo un tipo de retorno , un nombre de método y una lista de parámetros . En la superficie, parece una excelente manera de crear trabajo adicional para usted o para cualquier otra persona que pueda estar escribiendo la clase que implements la interfaz . La respuesta es que las interfaces son perfectas para situaciones en las que desea que un conjunto de clases se comporte de la misma manera (es decir, que posean los mismos métodos abstractos públicos), pero espera que implementen ese comportamiento de diferentes maneras.

Para tomar un ejemplo simple pero relevante, la plataforma Android posee dos clases que se dedican principalmente a crear y administrar parte de la interfaz de usuario: Activity y Fragment . De ello se deduce que estas clases muy a menudo tendrán el requisito de escuchar los eventos que aparecen cuando se hace clic en un widget (o cuando un usuario interactúa con él). Por el bien del argumento, tomemos un momento para apreciar por qué la herencia casi nunca resolverá tal problema:

 public class OnClickManager { public void onClick(View view){ //Wait a minute... Activities and Fragments almost never //handle click events exactly the same way... } }

Hacer que nuestras Actividades y Fragmentos hereden de OnClickManager no solo haría imposible manejar los eventos de una manera diferente, sino que el truco es que ni siquiera podríamos hacer eso si quisiéramos. Tanto Activity como Fragment ya amplían una clase principal , y Java no permite varias clases principales . Entonces, nuestro problema es que queremos que un conjunto de clases se comporte de la misma manera, pero debemos tener flexibilidad sobre cómo la clase implementa ese comportamiento . Esto nos lleva de vuelta al ejemplo anterior de View.OnClickListener :

 public interface OnClickListener { void onClick(View v); }

Este es el código fuente real (que está anidado en la clase View ), y estas pocas líneas nos permiten garantizar un comportamiento coherente en diferentes widgets ( Vistas ) y controladores de IU ( Actividades, Fragmentos, etc. ).

La abstracción promueve el acoplamiento débil

Espero haber respondido la pregunta general sobre por qué existen las interfaces en Java; entre muchos otros idiomas. Desde una perspectiva, son solo un medio para compartir código entre clases, pero son deliberadamente menos detallados para permitir diferentes implementaciones . Pero así como la herencia se puede usar tanto como un mecanismo para compartir código como para la abstracción (aunque con restricciones en la jerarquía de clases), las interfaces proporcionan un mecanismo más flexible para la abstracción .

En una sección anterior de este artículo, introduje el tema del acoplamiento flojo/ajustado por analogía con la diferencia entre usar clavos y tornillos para construir algún tipo de estructura. En resumen, la idea básica es que querrá usar tornillos en situaciones en las que es probable que cambie la estructura existente (que puede ser el resultado de errores de reparación, cambios de diseño, etc.). Los clavos están bien para usar cuando solo necesita unir partes de la estructura y no está particularmente preocupado por desarmarlos en un futuro cercano.

Los clavos y los tornillos pretenden ser análogos a las referencias concretas y abstractas (también se aplica el término dependencias ) entre clases. Para que no haya confusión, el siguiente ejemplo demostrará lo que quiero decir:

 class Client { private Validator validator; private INetworkAdapter networkAdapter; void sendNetworkRequest(String input){ if (validator.validateInput(input)) { try { networkAdapter.sendRequest(input); } catch (IOException e){ //handle exception } } } } class Validator { //...validation logic boolean validateInput(String input){ boolean isValid = true; //...change isValid to false based on validation logic return isValid; } } interface INetworkAdapter { //... void sendRequest(String input) throws IOException; }

Aquí tenemos una clase llamada Client que posee dos tipos de referencias . Tenga en cuenta que, asumiendo que el Client no tiene nada que ver con la creación de sus referencias (realmente no debería), se desvincula de los detalles de implementación de cualquier adaptador de red en particular.

Hay algunas implicaciones importantes de este acoplamiento flojo . Para empezar, puedo compilar Client en un aislamiento absoluto de cualquier implementación de INetworkAdapter . Imagina por un momento que estás trabajando en un equipo de dos desarrolladores; uno para construir la parte delantera, uno para construir la parte trasera. Siempre que ambos desarrolladores estén al tanto de las interfaces que unen sus respectivas clases, pueden continuar con el trabajo de forma prácticamente independiente el uno del otro.

En segundo lugar, ¿qué pasaría si les dijera que ambos desarrolladores pudieron verificar que sus respectivas implementaciones funcionaron correctamente, también independientemente del progreso de cada uno? Esto es muy fácil con interfaces; simplemente cree un doble de prueba que implements la interfaz adecuada:

 class FakeNetworkAdapter implements INetworkAdapter { public boolean throwError = false; @Override public void sendRequest(String input) throws IOException { if (throwError) throw new IOException("Test Exception"); } }

En principio, lo que se puede observar es que trabajar con referencias abstractas abre la puerta a una mayor modularidad, capacidad de prueba y algunos patrones de diseño muy poderosos, como el Patrón de fachada, el Patrón de observador y más. También pueden permitir a los desarrolladores encontrar un feliz equilibrio entre el diseño de diferentes partes de un sistema basado en el comportamiento ( Programa a una interfaz ), sin atascarse en los detalles de implementación .

Un punto final sobre las abstracciones

Las abstracciones no existen de la misma manera que una cosa concreta . Esto se refleja en el lenguaje de programación Java por el hecho de que las clases e interfaces abstractas no se pueden instanciar.

Por ejemplo, esto definitivamente no compilaría:

 public class Main extends Application { public static void main(String[] args) { launch(args); } @Override public void start(Stage primaryStage) { //ERROR x2: Foo f = new Foo(); Bar b = new Bar() } private abstract class Foo{} private interface Bar{} }

De hecho, la idea de esperar que una interfaz no implementada o una clase abstracta funcione en tiempo de ejecución tiene tanto sentido como esperar que un uniforme de UPS flote entregando paquetes. Algo concreto debe estar detrás de la abstracción para que sea de utilidad; incluso si la clase que llama no necesita saber qué hay realmente detrás de las referencias abstractas .

Clases abstractas: poniéndolo todo junto

Si ha llegado hasta aquí, me complace decirle que no tengo más tangentes filosóficas ni frases de jerga para traducir. En pocas palabras, las clases abstractas son un mecanismo para compartir la implementación y el comportamiento en un conjunto de clases. Ahora, admitiré de inmediato que no me encuentro usando clases abstractas con tanta frecuencia. Aun así, mi esperanza es que al final de esta sección sepa exactamente cuándo se requieren.

Estudio de caso de registro de entrenamiento

Aproximadamente un año después de crear aplicaciones de Android en Java, estaba reconstruyendo mi primera aplicación de Android desde cero. La primera versión era el tipo de masa horrenda de código que cabría esperar de un desarrollador autodidacta con poca orientación. Cuando quise agregar una nueva funcionalidad, quedó claro que la estructura estrechamente acoplada que había construido exclusivamente con clavos era tan imposible de mantener que debía reconstruirla por completo.

La aplicación era un registro de entrenamiento que fue diseñado para permitir un fácil registro de sus entrenamientos y la capacidad de generar los datos de un entrenamiento anterior como un archivo de texto o imagen. Sin entrar en demasiados detalles, estructuré los modelos de datos de la aplicación de modo que hubiera un objeto de Workout , que constaba de una colección de objetos de Exercise (entre otros campos que son irrelevantes para esta discusión).

Mientras implementaba la función para enviar datos de entrenamiento a algún tipo de medio visual, me di cuenta de que tenía que lidiar con un problema: diferentes tipos de ejercicios requerirían diferentes tipos de salidas de texto.

Para darte una idea aproximada, quería cambiar las salidas dependiendo del tipo de ejercicio así:

  • Barra: 10 REPETICIONES @ 100 LBS
  • Mancuerna: 10 REPETICIONES @ 50 LBS x2
  • Peso corporal: 10 REPS @ Peso corporal
  • Peso corporal +: 10 REPS @ Peso corporal + 45 LBS
  • Temporizado: 60 SEG @ 100 LBS

Antes de continuar, tenga en cuenta que había otros tipos (hacer ejercicio puede volverse complicado) y que el código que mostraré se ha recortado y cambiado para encajar perfectamente en un artículo.

De acuerdo con mi definición anterior, el objetivo de escribir una clase abstracta es implementar todo (incluso el estado , como variables y constantes ) que se comparte en todas las clases secundarias en la clase abstracta . Luego, para cualquier cosa que cambie en dichas clases secundarias , cree un método abstracto :

 abstract class Exercise { private final String type; protected final String name; protected final int[] repetitionsOrTime; protected final double[] weight; protected static final String POUNDS = "LBS"; protected static final String SECONDS = "SEC "; protected static final String REPETITIONS = "REPS "; public Exercise(String type, String name, int[] repetitionsOrTime, double[] weight) { this.type = type; this.name = name; this.repetitionsOrTime = repetitionsOrTime; this.weight = weight; } public String getFormattedOutput(){ StringBuilder sb = new StringBuilder(); sb.append(name); sb.append("\n"); getSetData(sb); sb.append("\n"); return sb.toString(); } /** * Append data appropriately based on Exercise type * @param sb - StringBuilder to Append data to */ protected abstract void getSetData(StringBuilder sb); //...Getters }

Puede que esté diciendo lo obvio, pero si tiene alguna pregunta sobre lo que debe o no debe implementarse en la clase abstracta , la clave es observar cualquier parte de la implementación que se haya repetido en todas las clases secundarias.

Ahora que hemos establecido lo que es común entre todos los ejercicios, podemos comenzar a crear clases secundarias con especializaciones para cada tipo de salida de cadena:

Ejercicio con barra:

 class BarbellExercise extends Exercise { public BarbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append("\n"); } } }

Ejercicio con mancuernas:

 class DumbbellExercise extends Exercise { private static final String TIMES_TWO = "x2"; public DumbbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append(TIMES_TWO); sb.append("\n"); } } }

Ejercicio de peso corporal:

 class BodyweightExercise extends Exercise { private static final String BODYWEIGHT = "Bodyweight"; public BodyweightExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(BODYWEIGHT); sb.append("\n"); } } }

Estoy seguro de que algunos lectores astutos encontrarán cosas que podrían haberse resumido de una manera más eficiente, pero el propósito de este ejemplo (que se ha simplificado de la fuente original) es demostrar el enfoque general. Por supuesto, ningún artículo de programación estaría completo sin algo que se pueda ejecutar. Hay varios compiladores de Java en línea que puede usar para ejecutar este código si desea probarlo (a menos que ya tenga un IDE):

 public class Main { public static void main(String[] args) { //Note: I actually used another nested class called a "Set" instead of an Array //to represent each Set of an Exercise. int[] reps = {10, 10, 8}; double[] weight = {70.0, 70.0, 70.0}; Exercise e1 = new BarbellExercise( "Barbell", "Barbell Bench Press", reps, weight ); Exercise e2 = new DumbbellExercise( "Dumbbell", "Dumbbell Bench Press", reps, weight ); Exercise e3 = new BodyweightExercise( "Bodyweight", "Push Up", reps, weight ); System.out.println( e1.getFormattedOutput() + e2.getFormattedOutput() + e3.getFormattedOutput() ); } }

Executing this toy application yields the following output: Barbell Bench Press

 10 REPS @ 70.0LBS 10 REPS @ 70.0LBS 8 REPS @ 70.0LBS Dumbbell Bench Press 10 REPS @ 70.0LBSx2 10 REPS @ 70.0LBSx2 8 REPS @ 70.0LBSx2 Push Up 10 REPS @ Bodyweight 10 REPS @ Bodyweight 8 REPS @ Bodyweight

Further Considerations

Earlier, I mentioned that there are two features of Java interfaces (as of Java 8) which are decidedly geared towards sharing implementation , as opposed to behavior . These features are known as Default Methods and Static Methods .

I have decided not to go into detail on these features for the reason that they are most typically used in mature and/or large code bases where a given interface has many inheritors. Despite the fact that this is meant to be an introductory article, and I still encourage you to take a look at these features eventually, even though I am confident that you will not need to worry about them just yet.

I would also like to mention that there are other ways to share implementation across a set of classes (or even static methods ) in a Java application that does not require inheritance or abstraction at all. For example, suppose you have some implementation which you expect to use in a variety of different classes, but does not necessarily make sense to share via inheritance . A common pattern in Java is to write what is known as a Utility class, which is a simple class containing the requisite implementation in a static method :

 public class TimeConverterUtil { /** * Accepts an hour (0-23) and minute (0-59), then attempts to format them into an appropriate * format such as 12, 30 -> 12:30 pm */ public static String convertTime (int hour, int minute){ String unformattedTime = Integer.toString(hour) + ":" + Integer.toString(minute); DateFormat f1 = new SimpleDateFormat("HH:mm"); Date d = null; try { d = f1.parse(unformattedTime); } catch (ParseException e) { e.printStackTrace(); } DateFormat f2 = new SimpleDateFormat("h:mm a"); return f2.format(d).toLowerCase(); } }

Using this static method in an external class (or another static method ) looks like this:

 public class Main { public static void main(String[] args){ //... String time = TimeConverterUtil.convertTime(12, 30); //... } }

Hoja de trucos

We have covered a lot of ground in this article, so I would like to spend a moment summarizing the three main mechanisms based on what problems they solve. Since you should possess a sufficient understanding of the terms and ideas I have either introduced or redefined for the purposes of this article, I will keep the summaries brief.

I Want A Set Of Child Classes To Share Implementation

Classic inheritance , which requires a child class to inherit from a parent class , is a very simple mechanism for sharing implementation across a set of classes. An easy way to decide if some implementation should be pulled into a parent class , is to see whether it is repeated in a number of different classes line for line. The acronym DRY ( Don't Repeat Yourself ) is a good mnemonic device to watch out for this situation.

While coupling child classes together with a common parent class can present some limitations, a side benefit is that they can all be referenced as the parent class , which provides a limited degree of abstraction .

I Want A Set Of Classes To Share Behavior

Sometimes, you want a set of classes to be capable of possessing certain abstract methods (referred to as behavior ), but you do not expect the implementation of that behavior to be repeated across inheritors.

By definition, Java interfaces may not contain any implementation (except for Default and Static Methods ), but any class which implements an interface , must supply an implementation for all abstract methods, otherwise, the code will not compile. This provides a healthy measure of flexibility and restriction on what is actually shared and does not require the inheritors to be of the same class hierarchy .

I Want A Set Of Child Classes To Share Behavior And Implementation

Although I do not find myself using abstract classes all over the place, they are perfect for situations when you require a mechanism for sharing both behavior and implementation across a set of classes. Anything which will be repeated across inheritors may be implemented directly in the abstract class , and anything which requires flexibility may be specified as an abstract method .