Siete virtudes de un buen objeto
Texto original en inglés por Yegor Bugayenko.
Martin Fowler menciona:
Una librería es esencialmente un conjunto de funciones que se pueden llamar, y que en estos días suelen estar organizadas en clases.
¿Funciones organizadas en clases? Con todo el debido respeto, esto me parece inapropiado. Ésta es una idea muy equivocada de lo que una clase es en la programación orientada a objetos 1. Las clases no son organizadores de funciones. Y los objetos no son estructuras de datos.
Así que digamos, ¿qué es un objeto "genuino"? ¿Cuál no? ¿Cuál es la diferencia? Aunque éste tema es muy polémico, es de mucha relevancia. Y si no entendemos qué es un objeto, ¿cómo es que podemos escribir software orientado a objetos? Bien, gracias a Java, Ruby, y otros, podemos. Pero, ¿qué tan bueno puede ser? Desafortunadamente, esto no es una ciencia exacta, y existen muchas opiniones al respecto. Así es que aquí está mi lista de cualidades de un buen objeto.
Clase v.s. objeto
Antes de empezar a hablar de los objetos, vamos a definir lo que es una clase. Es el lugar en el que nacen los objetos, es decir, donde se crean las instancias o ejemplares. La responsabilidad principal de una clase es construir nuevos objetos conforme son requeridos y destruirlos cuando ya no son necesarios. Una clase debería saber cómo lucirán y se comportarán sus hijos. En otras palabras, se encuentra consciente de los acuerdos con los que sus hijos deben cumplir.
Algunas veces escucho que las clases son llamadas "plantillas de objetos", (por ejemplo, la Wikipedia lo afirma2). Esta definición no es correcta porque coloca a las clases en una posición pasiva. Y asume, que alguien va a obtener una plantilla y construir un objeto aplicándola. Esto puede ser cierto, técnicamente hablando, pero conceptualmente incorrecto 3. Nadie más debería estar involucrado --Sólo la clase y sus hijos. Es decir, un objeto solicita a una clase que construya un objeto, y ésta lo hace; eso es todo. Ruby expresa este concepto mucho mejor que Java o C++:
photo = File.new('/tmp/photo.png')
El objeto photo es construido por la clase File (new es la puerta de entrada a la clase). Una vez construido, éste actuará por su propia cuenta y no sería conveniente que sepa ni quién lo creó ni cuantos hermanos y hermanas tiene. Sí, lo que quiero decir es que la reflexión es una mala idea, pero escribiré más acerca de esto en uno de mis siguientes artículos :) Mientras tanto, vamos a hablar acerca de los objetos, de lo mejor y lo peor de ellos.
1. Aquí el autor trata de ser contundente con su rechazo ya que va en contra del pensamiento de los creadores del paradigma orientado a objetos. (West, 2009). ↩
2. Cada que el autor afirma que algo es terrible o incorrecto, lo hace desde del contexto del paradigma orientado a objetos. (West, 2009). ↩
3. (2016). Clase (informática). 31 may 2016 a las 17:02, de Wikipedia. ↩
1. Existe en la vida real
Primero antes que nada, un objeto es un organismo viviente. Más aún, un objeto debería ser antropomorfizado. P.ej.: tratado como un ser humano (o una mascota, si te parece más apropiado). Con esto básicamente quiero decir que un objeto no es una estructura de datos o una colección de funciones. En lugar de eso, es una entidad independiente con su propio ciclo de vida, su propio comportamiento, y sus propios hábitos.
Un empleado, un departamento, una petición HTTP, una tabla en MySQL, una línea en un archivo, o un archivo por sí mismo son objetos apropiados --porque existen en la vida real, aún cuando nuestro software no esté operando. Para ser más precisos, un objeto es una criatura de la vida real de frente a todos los demás objetos. Sin esa criatura, --obviamente-- no hay objeto.
photo = File.new('/tmp/photo.png')
puts photo.width()
En este ejemplo, le estoy pidiendo a la clase File que construya un objeto photo nuevo, el cual será una representación de un archivo real en un disco. Es probable que digas que el archivo es algo virtual y que existe únicamente cuando la computadora se encuentra encendida. Estoy de acuerdo, así que precisaré la definición de "vida real" como sigue: Es cualquier cosa que existe más allá del alcance de un objeto dentro de un programa (el autor busca que pensamos en términos del mundo real y no de datos). Por lo cual, es completamente correcto crear la representación de un archivo.
Un controlador, un analizador de texto o parser, un filtrador, un validador, un localizador de servicio, un singleton o una fábrica no son buenos objetos (si, la mayoría de los patrones de los GoF ¡son anti-patrones!). Estos no existen en la vida real mas que en tu software. Son sólo invenciones para acoplar otros objetos. Son criaturas falsas y artificiales. No representan a nadie. En serio, un analizador de XML --¿a quién representa? A nadie.
Algunos de estos pueden llegar a ser buenos con sólo cambiar sus nombres; otros, jamás podrán justificar su existencia. Por ejemplo, aquel "XML parser"/"Analizador XML" puede ser renombrado a "parseable XML"/"XML analizable" y empezar a representar un documento XML que existe fuera de nuestro alcance.
Siempre pregúntate, "¿Cuál es la entidad real detrás de mi objeto?" Si no puedes encontrar una respuesta, es hora de considerar una re-factorización.
2. Trabaja por contrato
Un buen objeto siempre trabaja por contrato. No espera ser reclutado por sus méritos personales sino porque cumple con los términos de un acuerdo. Por otro lado, cuando contratamos un objeto, no deberíamos discriminar y esperar a que éste venga de una clase específica y haga el trabajo por nosotros. Por el contrario, deberíamos esperar que cualquier objeto haga lo que su contrato expresa. Siempre que el objeto entregue lo acordado, no deberíamos estar interesados en el origen de su clase, sexo, o religión.
Por ejemplo, si necesito mostrar una foto en la pantalla y quiero que ésta sea leída desde un archivo en formato PNG, contrato un objeto desde su clase DataFile y le solicito su contenido binario.
Pero espera, ¿me importa con precisión su procedencia --ya sea que el archivo venga de un disco, o de una petición HTTP, o quizás de un documento en Dropbox? En realidad, no. Lo único que me interesa es que algún objeto me de un contenido PNG en un arreglo de bytes. Este sería el contrato:
interface Binary {
byte[] read();
};
Ahora, cualquier objeto de cualquier clase (no solamente DataFile) puede trabajar para uno. Todo lo que tienen que hacer es cumplir con el contrato, mediante la implementación de la interfaz Binary.
La regla aquí es simple: Cada método público, sin excepción, debe implementarse conforme a una interfaz (un contrato), de lo contrario, tendrás un mal diseño.
Existen dos razones prácticas para esto. Primero, es imposible sustituir el objeto real por uno diseñado para las pruebas de unidad. Segundo, no se puede extender vía decoración.
3. Es único
Para ser único, un buen objeto siempre debería encapsular algo, si no hay nada, entonces un objeto podría tener clones idénticos, lo cual creo, es inesperado. Aquí muestro un ejemplo:
class HTTPStatus implements Status {
private URL page = new URL("http://www.google.com");
@Override
public int read() throws IOException {
return HttpURLConnection.class.cast(
this.page.openConnection()
).getResponseCode();
}
}
Así que puedo crear unas cuantas instancias de la clase HTTPStatus, y nos encontraríamos que todos son idénticos:
first = new HTTPStatus();
second = new HTTPStatus();
asert first.equals(second);
Obviamente son, utility classes/bolsas de herramientas, las cuales sólo tienen métodos estáticos y no pueden crear lo que llamamos buenos objetos. Para ser más precisos, las clases utilitarias no tienen ninguno de los méritos antes mencionados y no pueden ni siquiera llamarse "clases". Éstas han terminado abusando del paradigma orientado a objetos, desde que los creadores de los lenguages modernos introdujeron los métodos estáticos.
4. Es Inmutable
Un buen objeto nunca debería cambiar su estado encapsulado. Recuerda, un objeto es una representación de una entidad en la vida real, y debería mantenerse igual a lo largo de toda su vida. En otras palabras, un objeto nunca debería traicionar a quién representa. Y menos aún cambiar de "jefe" :)
Pero cuidado, se consciente que inmutabilidad no significa que todos los métodos siempre regresen los mismo valores. Al contrario, un buen objeto inmutable es muy dinámico. Sin embargo, éste nunca cambiará su estado interno. Veamos este ejemplo:
@Immutable
final class HTTPStatus implements Status {
private URL page;
public HTTPStatus(URL url) {
this.page = url;
}
@Override
public int read() throws IOException {
return HttpURLConnection.class.cast(
this.page.openConnection()
).getResponseCode();
}
}
Aunque el método read() puede regresar diferentes valores, el objeto es inmutable. Éste apunta a determinada página web y nunca apuntará a ningún otro lugar. Éste nunca cambiará su estado encapsulado, y nunca traicionará al URL que representa.
¿Por qué la inmutabilidad es una virtud? Este artículo lo explica en detalle: Los objetos deberían ser inmutables. En pocas palabras, los objetos inmutables tienen más ventajas porque:
- Son más simples de construir, probar y utilizar.
- Los que en verdad lo consiguen son thread-safe. (es decir, íntegros en ambientes de multi-procesamiento).
- Estos ayudan a evitar acoplamientos temporales.
- Su uso no tiene efectos secundarios (no hay necesidad de copias de respaldo).
- Siempre fallan de manera atómica (failure atomicity).
- Son mucho más simples de enviar a un caché.
- Y previenen referencias NULL.
Por supuesto, un buen objeto no tiene setters/mutadores que cambien su estado y lo obliguen a traicionar la URL. En otras palabras, introducir un método setURL() sería un error terrible en la clase HTTPStatus.
Además de todo esto, los objetos inmutables te obligarán a realizar diseños más cohesivos, sólidos y comprensibles, como lo explica el artículo: Cómo la inmutabilidad ayuda.
5. Su clase no tiene nada estático
Un método estático implementa el comportamiento de una clase, y no de un objeto. Digamos que tenemos una clase File, y sus hijos tienen el método size():
final class File implements Measurable {
@Override
public int size() {
// calcula y regresa el tamaño del archivo.
}
}
Hasta aquí, todo va bien; el método size() se encuentra ahí por su contrato Measurable. Así es que cada objeto de la clase File podrá medir su tamaño. Sin embargo, diseñar esta clase con un método estático (también conocido como utility class) tendría indeseables repercusiones. Veamos, esta práctica es muy popular en Java, Ruby, y en casi cualquier otro lenguaje de programación orientado a objetos:
// ¡DISEÑO TERRIBLE, NO LO USES!
class File {
public static int size(String file) {
// calcula y regresa el tamaño del archivo.
}
}
Sin embargo, este diseño va completamente en contra del paradigma orientado a objetos. ¿Por qué? porque lo convierte en orientado a clases.Ya que la clase expone el comportamiento size() en lugar de que lo hagan sus objetos. ¿Qué hay de malo con todo esto, te preguntarás? ¿Por qué no podemos tener tanto a los objetos como a las clases como ciudadanos de primera clase en nuestro código? ¿Por qué ambos no pueden tener métodos y propiedades?
El problema es que la descomposición ya no funciona con una programación orientada a clases, porque con un sólo método estático será imposible descomponer un problema complejo en sus partes. El poder de la POO es que nos permite utilizar los objetos como un instrumento de descomposición de algún propóstio. Cuando creo una instancia dentro de un método, el objeto está dedicado a una tarea específica. Éste se encuentra perfectamente aislado del resto de los objetos con los que convive. Este objeto es una variable local en el alcance del método. Mientras que una clase, con métodos estáticos, siempre es una variable global, no importa dónde se utilice. Por tanto, no se puede aislar la interacción de esta variable de las otras.
Además de ir en contra de los principios de la POO, los métodos estáticos y públicos, tienen algunos inconvenientes prácticos:
Primero, es imposible reemplazarlos por sus dobles para las pruebas, "mock them" (Bueno, puedes usar PowerMock, pero sería la decisión menos apropiada que hayas hecho en un proyecto en Java... Lo hice una vez, hace unos años).
Segundo, no son thread-safe por definición, porque siempre trabajan con variables estáticas, las cuales son accesibles desde todos los hilos (threads). Los puedes hacer thread-safe, pero siempre requerirán sincronización explícita.
Cada vez que veas un método estático, empieza a re-escribirlo de inmediato. No quiero ni mencionar lo terribles que son las variables estáticas (o globales). Simplemente pienso que es obvio.
6. Su nombre no es el título de un puesto
El nombre de un objeto debería decirnos lo que el objeto es y no lo que hace, tal y como nombramos objetos en la vida real: libro en lugar de "coleccionador" de páginas, vaso en lugar de "contenedor" de agua, camiseta en lugar de "vestidor" del cuerpo. Hay excepciones, por su puesto, como los términos en inglés: "printer" y "computer"; pero estos términos fueron inventados por decirlo así, de manera reciente y por aquellos que no leyeron este artículo. :) 4
Por ejemplo, estos nombres nos dicen lo que son por quienes los utilizan: una manzana, un archivo, una serie de peticiones HTTP, un socket, un documento XML, una lista de usuarios, una expresión regular, un entero, una tabla de PostgresSQL, o Jeffrey Lebowski. Un objeto que es nombrado con propiedad siempre puede ser representado por una imagen5. Incluso, una expresión regular se puede dibujar.
El el lado opuesto, aquí hay un ejemplo de nombres que les dicen a sus usuarios lo que hacen: Un lector de archivos, un analizador/parser de texto, un validador de URL, un impresor de XML, un localizador de servicio, un singleton, un corredor de scripts, o un programador de Java. ¿Puedes dibujar alguno de ellos? No, no puedes. Por tanto, estos objetos no son candidatos para ser buenos objetos. Tienen nombres horrendos que te llevan a un diseño terrible6.
En general, evita nombres en inglés que terminen con "-er" --La mayoría son malos7.
¿Cuál es la alternativa a un FileReader? Ya te escucho haciéndote la pregunta ¿Cuál sería un mejor nombre? Veamos, ya tenemos un archivo File, el cual es la representación de un archivo en disco. Esta representación no es lo suficientemente poderosa para nosotros, porque no sabe cómo leer el contenido del archivo. Queremos crear una más poderosa que tenga esa habilidad. ¿Cómo la llamaríamos? Recuerda, el nombre debería decirnos lo que es, no lo que hace. ¿Qué es esto? Es un archivo que tiene datos; no sólo es un archivo, como File, sino que es aún más sofisticado, porque tiene datos. ¿Qué te parece FileWithData (archivo con datos) o simplemente DataFile (archivo de datos)?
El mismo razonamiento debería ser aplicable para el resto de los nombres. Siempre piensa acerca de lo que es en lugar de lo que hace. Dale a tus objetos un semántica real, nombres con significado en lugar de títulos de trabajo o puestos.
Puedes leer más acerca de esto en No crees objetos que terminen con -er.
4. Aquí el autor está a favor del uso de los sustantivos para nombrar las cosas por lo que son. ↩
5. Una imagen (o símbolo). ↩
6. Para el autor es un diseño terrible porque te lleva a diseños que no se pueden descomponer en partes, re-utilizarse y mantenerse. ↩
7. El autor considera que son malos porque hacen que nuestra estructura de pensamiento produzca procedimientos en lugar de objetos "vivientes". En español tendríamos que considerar las terminaciones -ar, -er, -ir, -or y -ur; Sin embargo, tendria más sentido buscar sustantivos que reflejen lo que son en lugar de lo hacen. ↩
7. Su clase sólo puede ser final o abstracta
Un buen objeto es de origen final o abstracto. Una clase final es aquella que no puede extenderse mediante la herencia. Y una abstracta es aquella que no puede tener hijos. En pocas palabras, una clase puede decir, "Tú nunca podrás hacerme daño; soy inquebrantable para ti" o bien, "Me encuentro mal; repárame y podrás contar conmigo".
No hay nada en medio. Una clase final es una caja negra que no puedes modificar por ningún medio. Ésta trabaja como tal y tu la puedes contratar o no. Tu no podrás crear otra clase que herede sus propiedades. Esto no es permitido por causa de la palabra reservada final. La única manera de extenderla es a través de la decoración de sus hijos. Digamos que tenemos la clase HTTPStatus (ver la figura previa), y no me gusta. Bueno, si me gusta, pero no es lo suficientemente poderosa para mi. Quiero que arroje una excepción si el código de estado HTTP se encuentra después del 400. Quiero que su método read() haga más de lo que actualmente hace. La manera tradicional de hacerlo es mediante la herencia y la sobre-escritura 8 de su método.
class OnlyValidStatus extends HTTPStatus {
public OnlyValidStatus(URL url) {
super(url);
}
@Override
public int read() throws IOException {
int code = super.read();
if (code > 400) {
throw new RuntimeException("unsuccessful HTTP code");
}
return code;
}
}
¿Por qué está mal? Esto está muy mal porque corremos el riesgo de descomponer completamente la lógica de la clase padre con el simple hecho de sobre-escribir uno de sus métodos. Recuerda que una vez que sobre-escribimos el método read() todos los métodos de la clase origen empezarán a utilizar esta nueva versión. Estamos inyectando literalmente una nueva "pieza de implementación" justo en el interior de la clase. Filosóficamente hablando, esto es intrusivo.
Por otro lado, para ampliar el comportamiento de una clase final, tendrás que tratarla como una caja negra y decorarla con tu propia implementación9 (También conocido como Patrón decorador):
final class OnlyValidStatus implements Status {
private final Status origin;
public OnlyValidStatus(Status status) {
this.origin = status;
}
@Override
public int read() throws IOException {
int code = this.origin.read();
if (code > 400) {
throw new RuntimeException("unsuccessful HTTP code");
}
return code;
}
}
Asegúrate de que esta clase implemente el mismo contrato (interface) que la original: Status. La instancia de HTTStatus será pasada en el constructor de OnlyValidStatus de manera encapsulada. De tal manera que cada llamada será interceptada e implementada de manera diferente, si fuera necesario. En este diseño, tratamos al objeto origen como una caja negra en la que no intervenimos en su lógica interna.
Si no utilizas la palabra reservada final, cualquiera (incluido tú) podrá extenderla y... dañarla :( Así es que una clase sin final da como resultado un mal diseño.
Una clase abstracta es exactamente el caso opuesto --Nos indica que está incompleta y que podemos contar con ella "tal y como es". Y por tanto, tenemos que inyectarle nuestra propia lógica de implementación, pero solamente en los lugares que nos lo permita. Estos lugares estarán marcados como métodos abstract. Por ejemplo, nuestro HTTPStatus podría tener la siguiente forma:
abstract class ValidatedHTTPStatus implements Status {
@Override
public final int read() throws IOException {
int code = this.origin.read();
if (!this.isValid()) {
throw new RuntimeException("unsuccessful HTTP code");
}
return code;
}
protected abstract boolean isValid();
}
Como puedes ver, la clase no sabe exactamente como validar el código HTTP, y espera que nosotros nos encarguemos de esa lógica mediante la sobre-escritura del método abstracto isValid() el cual inyectará el algoritmo pertinente. Por tanto, no vamos a dañarla u ofenderla mediante la herencia, ya que protege el resto de sus métodos con final (pon especial atención a los modificadores de sus métodos). Por lo tanto, la clase se encuentra lista y protegida contra intrusiones.
En resumen, tu clase debería ser final o abstracta --nada en medio.
8. Overload se traduce comúnmente en los textos técnicos en español como sobre carga. Pero esta traducción literal no se entiende como en su lenguaje original. Por tanto, utilizaré sobre-escritura. ↩
9. Tratarla como una caja negra implica que tendrás que invitarla, abrirle la puerta y tratarla como a alguien respetable a la que tendrás que pedirle amablemente que haga algo por ti, sin que por esto te revele cómo lo hará. ↩