¿Por qué se extiende el mal?

La extendspalabra clave es malvada; tal vez no al nivel de Charles Manson, pero lo suficientemente malo como para evitarlo siempre que sea posible. El libro Gang of Four Design Patterns analiza detalladamente la sustitución de la herencia de implementación ( extends) por la herencia de interfaz ( implements).

Los buenos diseñadores escriben la mayor parte de su código en términos de interfaces, no de clases base concretas. Este artículo describe por qué los diseñadores tienen hábitos tan extraños y también presenta algunos conceptos básicos de programación basada en interfaces.

Interfaces versus clases

Una vez asistí a una reunión de un grupo de usuarios de Java donde James Gosling (inventor de Java) fue el orador destacado. Durante la memorable sesión de preguntas y respuestas, alguien le preguntó: "Si pudieras volver a hacer Java, ¿qué cambiarías?" "Dejaría las clases", respondió. Después de que la risa se calmó, explicó que el problema real no eran las clases per se, sino la herencia de implementación (la extendsrelación). implementsEs preferible la herencia de interfaz (la relación). Debe evitar la herencia de implementación siempre que sea posible.

Perdiendo flexibilidad

¿Por qué debería evitar la herencia de implementación? El primer problema es que el uso explícito de nombres de clases concretos lo bloquea en implementaciones específicas, lo que dificulta innecesariamente los cambios en el futuro.

En el núcleo de las metodologías de desarrollo ágiles contemporáneas se encuentra el concepto de diseño y desarrollo paralelo. Empiece a programar antes de especificar completamente el programa. Esta técnica va en contra de la sabiduría tradicional (que un diseño debe estar completo antes de que comience la programación), pero muchos proyectos exitosos han demostrado que puede desarrollar código de alta calidad más rápidamente (y de manera rentable) de esta manera que con el enfoque tradicional por canalización. Sin embargo, en el centro del desarrollo paralelo está la noción de flexibilidad. Debe escribir su código de tal manera que pueda incorporar los requisitos recién descubiertos en el código existente de la manera más sencilla posible.

En lugar de implementar las funciones que podría necesitar, implementa solo las funciones que definitivamente necesita, pero de una manera que se adapte a los cambios. Si no tiene esta flexibilidad, el desarrollo paralelo simplemente no es posible.

La programación de interfaces es el núcleo de una estructura flexible. Para ver por qué, veamos qué sucede cuando no los usa. Considere el siguiente código:

f () {lista LinkedList = nueva LinkedList (); // ... g (lista); } g (lista LinkedList) {list.add (...); g2 (lista)}

Ahora suponga que ha surgido un nuevo requisito para la búsqueda rápida, por lo LinkedListque no está funcionando. Necesita reemplazarlo con un HashSet. En el código existente, ese cambio no está localizado ya que debe modificar no solo f()sino también g()(que toma un LinkedListargumento), y cualquier cosa g()pasa la lista a.

Reescribiendo el código así:

f () {Lista de colecciones = new LinkedList (); // ... g (lista); } g (Lista de colecciones) {list.add (...); g2 (lista)}

hace posible cambiar la lista vinculada a una tabla hash simplemente reemplazando el new LinkedList()con un new HashSet(). Eso es. No son necesarios otros cambios.

Como otro ejemplo, compare este código:

f () {Colección c = new HashSet (); // ... g (c); } g (Colección c) {for (Iterator i = c.iterator (); i.hasNext ();) do_something_with (i.next ()); }

a esto:

f2 () {Colección c = new HashSet (); // ... g2 (c.iterator ()); } g2 (Iterador i) {while (i.hasNext ();) do_something_with (i.next ()); }

El g2()método ahora puede atravesar Collectionderivados, así como las listas de claves y valores que puede obtener de un Map. De hecho, puede escribir iteradores que generen datos en lugar de atravesar una colección. Puede escribir iteradores que proporcionen información desde un andamio de prueba o un archivo al programa. Aquí hay una enorme flexibilidad.

Acoplamiento

Un problema más crucial con la herencia de la implementación es el acoplamiento: la indeseable dependencia de una parte de un programa en otra parte. Las variables globales proporcionan el ejemplo clásico de por qué un acoplamiento fuerte causa problemas. Si cambia el tipo de la variable global, por ejemplo, todas las funciones que usan la variable (es decir, están acopladas a la variable) podrían verse afectadas, por lo que todo este código debe ser examinado, modificado y vuelto a probar. Además, todas las funciones que utilizan la variable se acoplan entre sí a través de la variable. Es decir, una función podría afectar incorrectamente el comportamiento de otra función si el valor de una variable se cambia en un momento incómodo. Este problema es particularmente espantoso en los programas multiproceso.

Como diseñador, debe esforzarse por minimizar las relaciones de acoplamiento. No puede eliminar el acoplamiento por completo porque una llamada a un método de un objeto de una clase a un objeto de otra es una forma de acoplamiento flexible. No se puede tener un programa sin algún acoplamiento. No obstante, puede minimizar considerablemente el acoplamiento siguiendo servilmente los preceptos OO (orientados a objetos) (lo más importante es que la implementación de un objeto debe estar completamente oculta a los objetos que lo utilizan). Por ejemplo, las variables de instancia de un objeto (campos miembros que no son constantes), siempre deberían ser private. Período. Sin excepciones. Nunca. Lo digo en serio. (En ocasiones, puede utilizar protectedmétodos de forma eficaz, peroprotected las variables de instancia son una abominación.) Nunca debe usar las funciones get / set por la misma razón; son formas demasiado complicadas de hacer público un campo (aunque las funciones de acceso que devuelven objetos completos en lugar de un valor de tipo básico son razonable en situaciones donde la clase del objeto devuelto es una abstracción clave en el diseño).

No estoy siendo pedante aquí. Encontré una correlación directa en mi propio trabajo entre el rigor de mi enfoque OO, el desarrollo rápido de código y el fácil mantenimiento del código. Cada vez que violé un principio central de OO como el ocultamiento de la implementación, termino reescribiendo ese código (generalmente porque el código es imposible de depurar). No tengo tiempo para reescribir programas, así que sigo las reglas. Mi preocupación es totalmente práctica: no me interesa la pureza por el bien de la pureza.

El frágil problema de la clase baja

Ahora, apliquemos el concepto de acoplamiento a la herencia. En un sistema de implementación-herencia que utiliza extends, las clases derivadas están muy estrechamente acopladas a las clases base, y esta estrecha conexión no es deseable. Los diseñadores han aplicado el apodo de "el frágil problema de la clase base" para describir este comportamiento. Las clases base se consideran frágiles porque puede modificar una clase base de una manera aparentemente segura, pero este nuevo comportamiento, cuando es heredado por las clases derivadas, puede hacer que las clases derivadas funcionen mal. No se puede saber si un cambio de clase base es seguro simplemente examinando los métodos de la clase base de forma aislada; también debe mirar (y probar) todas las clases derivadas. Además, debe verificar todo el código que usa tanto la clase base comoobjetos de clase derivada también, ya que este código también podría romperse por el nuevo comportamiento. Un simple cambio en una clase base clave puede hacer que todo un programa sea inoperable.

Examinemos juntos los frágiles problemas de acoplamiento de la clase base y la clase base. La siguiente clase extiende la ArrayListclase de Java para que se comporte como una pila:

class Stack extiende ArrayList {private int stack_pointer = 0; push public void (artículo de objeto) {add (stack_pointer ++, artículo); } public Object pop () {return remove (--stack_pointer); } public void push_many (Object [] artículos) {for (int i = 0; i <articles.length; ++ i) push (artículos [i]); }}

Incluso una clase tan simple como esta tiene problemas. Considere lo que sucede cuando un usuario aprovecha la herencia y utiliza el ArrayList's clear()método para hacer estallar todo lo de la pila:

Pila a_stack = new Stack (); a_stack.push ("1"); a_stack.push ("2"); a_stack.clear ();

El código se compila correctamente, pero como la clase base no sabe nada sobre el puntero de la pila, el Stackobjeto ahora está en un estado indefinido. La siguiente llamada a push()coloca el nuevo elemento en el índice 2 (el stack_pointervalor actual de the), por lo que la pila tiene tres elementos, los dos de abajo son basura. (La Stackclase de Java tiene exactamente este problema; no la use).

Una solución al problema indeseable de herencia de métodos es Stackanular todos los ArrayListmétodos que pueden modificar el estado de la matriz, por lo que las anulaciones manipulan el puntero de la pila correctamente o lanzan una excepción. (El removeRange()método es un buen candidato para lanzar una excepción).

Este enfoque tiene dos desventajas. Primero, si anula todo, la clase base debería ser realmente una interfaz, no una clase. No tiene sentido la herencia de implementación si no usa ninguno de los métodos heredados. En segundo lugar, y lo que es más importante, no desea que una pila admita todos los ArrayListmétodos. Ese removeRange()método molesto no es útil, por ejemplo. La única forma razonable de implementar un método inútil es hacer que arroje una excepción, ya que nunca debería llamarse. Este enfoque mueve efectivamente lo que sería un error en tiempo de compilación al tiempo de ejecución. No está bien. Si el método simplemente no se declara, el compilador lanza un error de método no encontrado. Si el método está ahí, pero arroja una excepción, no se enterará de la llamada hasta que el programa se ejecute.

Una mejor solución al problema de la clase base es encapsular la estructura de datos en lugar de utilizar la herencia. Aquí hay una versión nueva y mejorada de Stack:

class Stack {private int stack_pointer = 0; Private ArrayList the_data = new ArrayList (); push public void (artículo de objeto) {the_data.add (stack_pointer ++, artículo); } objeto público pop () {return the_data.remove (--stack_pointer); } public void push_many (Object [] artículos) {for (int i = 0; i <o.length; ++ i) push (artículos [i]); }}

Hasta ahora todo va bien, pero considere el frágil problema de la clase base. Supongamos que desea crear una variante Stackque rastree el tamaño máximo de pila durante un período de tiempo determinado. Una posible implementación podría verse así:

class Monitorable_stack extiende Stack {private int high_water_mark = 0; private int current_size; public void push (artículo de objeto) {if (++ tamaño_actual> marca_alta_agua) marca_alta_agua = tamaño_actual; super.push (artículo); } objeto público pop () {- tamaño_actual; return super.pop (); } public int tamaño_máximo_so_far () {return high_water_mark; }}

Esta nueva clase funciona bien, al menos por un tiempo. Desafortunadamente, el código aprovecha el hecho de que push_many()hace su trabajo llamando push(). Al principio, este detalle no parece una mala elección. Simplifica el código y obtiene la versión de clase derivada de push(), incluso cuando Monitorable_stackse accede a él a través de una Stackreferencia, por lo que se high_water_markactualiza correctamente.

One fine day, someone might run a profiler and notice the Stack isn't as fast as it could be and is heavily used. You can rewrite the Stack so it doesn't use an ArrayList and consequently improve the Stack's performance. Here's the new lean-and-mean version:

class Stack { private int stack_pointer = -1; private Object[] stack = new Object[1000]; public void push( Object article ) { assert stack_pointer = 0; return stack[ stack_pointer-- ]; } public void push_many( Object[] articles ) { assert (stack_pointer + articles.length) < stack.length; System.arraycopy(articles, 0, stack, stack_pointer+1, articles.length); stack_pointer += articles.length; } } 

Notice that push_many() no longer calls push() multiple times—it does a block transfer. The new version of Stack works fine; in fact, it's better than the previous version. Unfortunately, the Monitorable_stack derived class doesn't work any more, since it won't correctly track stack usage if push_many() is called (the derived-class version of push() is no longer called by the inherited push_many() method, so push_many() no longer updates the high_water_mark). Stack is a fragile base class. As it turns out, it's virtually impossible to eliminate these types of problems simply by being careful.

Tenga en cuenta que no tiene este problema si usa la herencia de la interfaz, ya que no hay una funcionalidad heredada que le salga mal. Si Stackes una interfaz, implementada por ay Simple_stacka Monitorable_stack, entonces el código es mucho más robusto.