Pon tu código en forma con calistenia
Pon tu código en forma con calistenia
Fran Iglesias
Buy on Leanpub

Qué es object calisthenics

La calistenia es una disciplina de entrenamiento físico basada en trabajar con el propio peso buscando desarrollar tanto fuerza como armonía y precisión en el movimiento.

En el campo del software, Jeff Bay introdujo una idea similar en un artículo de la publicación The ThoughtWorks Anthology: Essays on Software Technology and Innovation en 2008. Su propuesta consistía en establecer nueve restricciones al escribir código que tendrán el efecto de mejorar su estructura y diseño.

Del mismo modo que los ejercicios físicos calisténicos no están dirigidos a entrenar una habilidad concreta, las restricciones están pensadas para forzar ciertos buenos hábitos al escribir código orientado a objetos, contribuyendo a desarrollar un código de mejor calidad y a identificar los elementos que hacen bueno su diseño. Por otro lado, es importante tener en cuenta que la mayoría de ellas tienen sentido en este paradigma por lo que podrían no ser aplicables a otros, como el funcional. Aun así, creo que se pueden aprovechar como estilo de programación.

La lista de restricciones es la siguiente:

  • Un solo nivel de indentación por método
  • No usar la palabra clave ELSE
  • Encapsular todas las primitivas y strings
  • Encapsular las colecciones
  • Un punto por línea
  • No usar abreviaturas
  • Mantener todas las entidades pequeñas
  • No más de dos variables de instancia por clase
  • No usar getters/setters o propiedades públicas

Hay algo que llama la atención en esta lista. Son reglas que se aplican sobre características del código independientemente de cualquier propósito, ya sea del código, ya sea tuyo como desarrolladora.

Por ejemplo, la primera es fácil de identificar: ¿hay un bloque que tenga una indentación de dos o más niveles? Pues hacemos algo para aplanarlo, como puede ser extraer el bloque a un método privado. Al hacerlo, posiblemente te darás cuenta de que ese bloque estaba ocupándose de un detalle o aspecto de un algoritmo. Y lo interesante es que no necesitas pensar en eso a priori, pues es el hecho físico de cambiar la estructura del código dirigida por las reglas la que te permitirá descubrirlo.

Calisthenics y refactor

El objetivo de este libro es desarrollar cada una de las restricciones aplicada a un ejemplo de código y tratar de explicar cómo y por qué funcionan. Así que, en este libro tomamos como punto de partida la propuesta de Bay y la convertimos en un pequeño manual de estilo para que nos ayude a escribir mejor código.

Este libro podría considerarse también como un libro acerca de refactoring, ya que vamos a ver cómo aplicar las reglas de calistenia en un código existente.

De hecho, cuando quieres practicar calistenia, lo mejor es hacerlo para mejorar código que ya existe. Por lo general, puedes aplicarla sin mucho riesgo, de modo que puedes usar estas reglas en código que quieras llevar a producción.

Si perseveras en hacerlo, con el tiempo empezarás a escribir código que tiende a cumplir las premisas básicas. Por esta razón decimos que la calistenia de código nos ayuda a desarrollar un sentido de buen diseño: elementos pequeños, altamente significativos, con responsabilidades bien definidas, fáciles de entender y fáciles de reemplazar.

Solo un nivel de indentación

Esta restricción es bastante sencilla de entender, aunque puede que no tanto de aplicar.

La indentación nos ayuda a organizar visualmente el código de modo que cuando un fragmento está, por así decir, contenido en otro se muestra más adentrado en el cuerpo del texto. En Python, la indentación es lo que define los bloques de código mientras que en otros lenguajes estos bloques se definen usando algún tipo de marcador como las llaves {}, palabras clave como begin/end, etc.

El nivel de indentación está fuertemente asociado al nivel de abstracción. Con frecuencia, los bloques de código indentados se ocupan de un cierto nivel de detalle que no se corresponde al nivel de abstracción general del método que los contiene. La mezcla de niveles de abstracción hace que sea más difícil comprender el código debido a que tenemos que cambiar nuestro enfoque al entrar y salir de cada bloque.

Los bloques indentados aparecen en estructuras condicionales y en bucles. El problema de estos bloques surge cuando dentro de uno de ellos, nos encontramos con la necesidad de introducir una nueva condicional o bucle, resultando en una anidación que genera un nivel extra de indentación. Esto incrementa la mezcla de conceptos generales con detalles. Además, hace que tengamos que entrar y salir de distintas ramas del flujo de ejecución. En conjunto, el código así organizado se hace más difícil de leer, de comprender y de mantener en la cabeza.

Veamos un ejemplo no orientado a objetos, tomado de la kata Theatrical Players de Emily Bache:

Figure 1
 1 import math
 2 
 3 
 4 def statement(invoice, plays):
 5     total_amount = 0
 6     volume_credits = 0
 7     result = f'Statement for {invoice["customer"]}\n'
 8 
 9     def format_as_dollars(amount):
10         return f"${amount:0,.2f}"
11 
12     for perf in invoice['performances']:
13         play = plays[perf['playID']]
14         if play['type'] == "tragedy":
15             this_amount = 40000
16             if perf['audience'] > 30:
17                 this_amount += 1000 * (perf['audience'] - 30)
18         elif play['type'] == "comedy":
19             this_amount = 30000
20             if perf['audience'] > 20:
21                 this_amount += 10000 + 500 * (perf['audience'] - 20)
22 
23             this_amount += 300 * perf['audience']
24 
25         else:
26             raise ValueError(f'unknown type: {play["type"]}')
27 
28         # add volume credits
29         volume_credits += max(perf['audience'] - 30, 0)
30         # add extra credit for every ten comedy attendees
31         if "comedy" == play["type"]:
32             volume_credits += math.floor(perf['audience'] / 5)
33         # print line for this order
34         result += f' {play["name"]}: {format_as_dollars(this_amount/100\
35 )} ({perf["audience"]} seats)\n'
36         total_amount += this_amount
37 
38     result += f'Amount owed is {format_as_dollars(total_amount/100)}\n'
39     result += f'You earned {volume_credits} credits\n'
40     return result

En el ejemplo, se puede ver como el bucle for introduce un nivel de indentación en el código. Pero dentro de él podemos ver dos estructuras condicionales que añaden hasta dos nuevos niveles.

Para reducir a un solo nivel de indentación el código de un método lo más habitual es extraer la estructura anidada a un método privado, de manera que en su lugar quede una única línea con esa llamada. Este refactor es el conocido como extract method

Es este ejemplo, la función statement calcula el importe de una factura sobre varias actuaciones de una compañía de teatro. Para ello recorre la lista de actuaciones, calculando el importe de cada actuación basándose en características de la obra y de la audiencia y sumándolo todo. La primera estructura condicional contiene los detalles del cálculo del importe de cada actuación.

Figure 2
 1 import math
 2 
 3 
 4 def statement(invoice, plays):
 5     total_amount = 0
 6     volume_credits = 0
 7     result = f'Statement for {invoice["customer"]}\n'
 8 
 9     def format_as_dollars(amount):
10         return f"${amount:0,.2f}"
11 
12     def calculate_performance_amount(perf, play):
13         if play['type'] == "tragedy":
14             amount = 40000
15             if perf['audience'] > 30:
16                 amount += 1000 * (perf['audience'] - 30)
17         elif play['type'] == "comedy":
18             amount = 30000
19             if perf['audience'] > 20:
20                 amount += 10000 + 500 * (perf['audience'] - 20)
21 
22             amount += 300 * perf['audience']
23 
24         else:
25             raise ValueError(f'unknown type: {play["type"]}')
26         return amount
27 
28     for perf in invoice['performances']:
29         play = plays[perf['playID']]
30         this_amount = calculate_performance_amount(perf, play)
31 
32         # add volume credits
33         volume_credits += max(perf['audience'] - 30, 0)
34         # add extra credit for every ten comedy attendees
35         if "comedy" == play["type"]:
36             volume_credits += math.floor(perf['audience'] / 5)
37         # print line for this order
38         result += f' {play["name"]}: {format_as_dollars(this_amount/100\
39 )} ({perf["audience"]} seats)\n'
40         total_amount += this_amount
41 
42     result += f'Amount owed is {format_as_dollars(total_amount/100)}\n'
43     result += f'You earned {volume_credits} credits\n'
44     return result

Si los niveles de anidación son varios podemos empezar por el primero y luego nos vamos moviendo más hacia adentro. Esto choca con la recomendación de empezar a refactorizar por la rama más profunda, pero en este caso parece mejor despejar cada nivel de abstracción desde fuera hacia adentro. Además, el refactor automático de extract method es lo bastante seguro como para poder realizarlo sin tests.

En nuestro ejemplo, el nuevo método sigue teniendo más de un nivel de indentación, pero volveremos a esto más adelante. Vamos a seguir aplanando la función statement.

El tipo de dificultades que nos podemos encontrar tienen que ver con el uso de variables que se inicializan fuera de la estructura condicional, pero que se modifican en ella. Un paso previo recomendable es agrupar el código estrechamente relacionado, por ejemplo, las líneas en las que se mencionan las mismas variables deberían ir juntas. De este modo, cuando vayamos a extraer la estructura condicional seremos más conscientes de esas dependencias.

La segunda estructura condicional hace lo que parece ser un cálculo de créditos o puntos para futuros espectáculos (volume_credits) y también podemos extraerlo. Como podemos ver, la condicional modifica el valor de una variable que se inicializaba fuera. Por tanto, incluimos todo en la extracción, teniendo en cuenta que volume_credits es una variable acumulativa:

Figure 3
1 # add volume credits
2 volume_credits += max(perf['audience'] - 30, 0)
3 # add extra credit for every ten comedy attendees
4 if "comedy" == play["type"]:
5     volume_credits += math.floor(perf['audience'] / 5)
6 # print line for this order

Nos quedaría así:

Figure 4
 1 import math
 2 
 3 
 4 def statement(invoice, plays):
 5     total_amount = 0
 6     volume_credits = 0
 7     result = f'Statement for {invoice["customer"]}\n'
 8 
 9     def format_as_dollars(amount):
10         return f"${amount:0,.2f}"
11 
12     for perf in invoice['performances']:
13         play = plays[perf['playID']]
14         this_amount = calculate_amount(perf, play)
15         volume_credits += calculate_this_volume_credits(perf, play)
16         # print line for this order
17         result += f' {play["name"]}: {format_as_dollars(this_amount/100\
18 )} ({perf["audience"]} seats)\n'
19         total_amount += this_amount
20 
21     result += f'Amount owed is {format_as_dollars(total_amount/100)}\n'
22     result += f'You earned {volume_credits} credits\n'
23     return result
24 
25 
26 def calculate_this_volume_credits(perf, play):
27     # add volume credits
28     volume_credits = max(perf['audience'] - 30, 0)
29     # add extra credit for every ten comedy attendees
30     if "comedy" == play["type"]:
31         volume_credits += math.floor(perf['audience'] / 5)
32     return volume_credits
33 
34 
35 def calculate_amount(perf, play):
36     if play['type'] == "tragedy":
37         this_amount = 40000
38         if perf['audience'] > 30:
39             this_amount += 1000 * (perf['audience'] - 30)
40     elif play['type'] == "comedy":
41         this_amount = 30000
42         if perf['audience'] > 20:
43             this_amount += 10000 + 500 * (perf['audience'] - 20)
44 
45         this_amount += 300 * perf['audience']
46 
47     else:
48         raise ValueError(f'unknown type: {play["type"]}')
49     return this_amount

Un truco simple cuando usas refactor automático es examinar los parámetros que necesita el nuevo método, ya que el análisis que hace la herramienta de refactor identificará todos los que se necesitan. Esto nos ayuda a descubrir variables temporales que tal vez no tendríamos que pasar o que deberían estar únicamente en el método extraído. Especialmente en el caso de haya que devolver su valor.

Para este ejemplo, tenemos que separar el cálculo parcial del total. Esta es la secuencia de pasos que he seguido para hacerlo manteniendo los tests en verde en todos los pasos:

En primer lugar, voy a introducir una variable performance_credits que contendrá el cálculo parcial:

Figure 5
1 # add volume credits
2 performance_credits = max(perf['audience'] - 30, 0)
3 volume_credits += performance_credits
4 # add extra credit for every ten comedy attendees
5 if "comedy" == play["type"]:
6     volume_credits += math.floor(perf['audience'] / 5)

volumen_credits solo debería actualizarse cuando se haya completado el cálculo parcial, así que lo muevo al final del fragmento:

Figure 6
1 # add volume credits
2 performance_credits = max(perf['audience'] - 30, 0)
3 # add extra credit for every ten comedy attendees
4 if "comedy" == play["type"]:
5     volume_credits += math.floor(perf['audience'] / 5)
6 volume_credits += performance_credits

En la condicional, actualizo performance_credits en lugar de volume_credits:

Figure 7
1 # add volume credits
2 performance_credits = max(perf['audience'] - 30, 0)
3 # add extra credit for every ten comedy attendees
4 if "comedy" == play["type"]:
5     performance_credits += math.floor(perf['audience'] / 5)
6 volume_credits += performance_credits

Ahora ya puedo extraer el cálculo limpiamente:

Figure 8
1 performance_credits = calculate_performance_credits(perf, play)
2 volume_credits += performance_credits

Si te fijas en la parte principal del cuerpo de la función statement verás que es mucho más claro y es fácil entender lo que ocurre en un nivel general. Basta con moverse a la función adecuada para poder acceder a los detalles de cada cálculo.

Queda más o menos así, una vez ordenadas las líneas:

Figure 9
 1 for perf in invoice['performances']:
 2     play = plays[perf['playID']]
 3     this_amount = calculate_performance_amount(perf, play)
 4     performance_credits = calculate_performance_credits(perf, play)
 5 
 6     volume_credits += performance_credits
 7     # print line for this order
 8     result += f' {play["name"]}: {format_as_dollars(this_amount/100)} (\
 9 {perf["audience"]} seats)\n'
10     total_amount += this_amount

Podría argumentarse que para poder extraer las estructuras condicionales y aplanar la indentación tengo que hacer algunos refactors de más. Pero precisamente ese es uno de los beneficios de intentar forzar la regla. Tengo que mejorar la organización del código para tener las condiciones adecuadas que me permitan aplicar la regla de un solo nivel de indentación.

Volvamos ahora a los niveles extra de indentación que aún no hemos tratado. Se han movido todos a la función calculate_amount:

Figure 10
 1 def calculate_performance_amount(perf, play):
 2     if play['type'] == "tragedy":
 3         this_amount = 40000
 4         if perf['audience'] > 30:
 5             this_amount += 1000 * (perf['audience'] - 30)
 6     elif play['type'] == "comedy":
 7         this_amount = 30000
 8         if perf['audience'] > 20:
 9             this_amount += 10000 + 500 * (perf['audience'] - 20)
10 
11         this_amount += 300 * perf['audience']
12 
13     else:
14         raise ValueError(f'unknown type: {play["type"]}')
15     return this_amount

Lo más fácil es mover las patas de las condicionales a sus propios métodos, como se puede ver a continuación.

Figure 11
 1 def calculate_performance_amount(perf, play):
 2 if play['type'] == "tragedy":
 3     amount = calculate_amount_for_tragedy(perf)
 4 elif play['type'] == "comedy":
 5     amount = calculate_amount_for_comedy(perf)
 6 
 7 else:
 8     raise ValueError(f'unknown type: {play["type"]}')
 9 return amount
10 
11 def calculate_amount_for_comedy(perf):
12     amount = 30000
13     if perf['audience'] > 20:
14         amount += 10000 + 500 * (perf['audience'] - 20)
15     amount += 300 * perf['audience']
16     return amount
17 
18 def calculate_amount_for_tragedy(perf):
19     amount = 40000
20     if perf['audience'] > 30:
21         amount += 1000 * (perf['audience'] - 30)
22     return amount

Al fijarnos en el resultado, podemos observar varias cosas. Una de ellas es que el código de calculate_amount sugiere aplicar el patrón early return, que clarifica más aún el cuerpo del método, así como suprimir la palabra clave ELSE, tema que trataríamos en la siguiente regla. También nos abre la puerta a usar una estructura switch/case. Pero si profundizamos, también sugiere fuertemente la posibilidad de introducir orientación a objetos para beneficiarnos del polimorfismo. No lo vamos a hacer en esta ocasión, porque el objetivo del capítulo es centrarnos en una sola regla cada vez.

Este es el resultado hasta el momento. En cada función tenemos un solo nivel de indentación. No es el refactor definitivo, pero ha mejorado sustancialmente la organización y legibilidad del código.

Figure 12
 1 import math
 2 
 3 
 4 def statement(invoice, plays):
 5     total_amount = 0
 6     volume_credits = 0
 7     result = f'Statement for {invoice["customer"]}\n'
 8 
 9     def format_as_dollars(amount):
10         return f"${amount:0,.2f}"
11 
12     def calculate_performance_amount(perf, play):
13         if play['type'] == "tragedy":
14             amount = calculate_amount_for_tragedy(perf)
15         elif play['type'] == "comedy":
16             amount = calculate_amount_for_comedy(perf)
17         else:
18             raise ValueError(f'unknown type: {play["type"]}')
19         return amount
20 
21     def calculate_amount_for_comedy(perf):
22         amount = 30000
23         if perf['audience'] > 20:
24             amount += 10000 + 500 * (perf['audience'] - 20)
25         amount += 300 * perf['audience']
26         return amount
27 
28     def calculate_amount_for_tragedy(perf):
29         amount = 40000
30         if perf['audience'] > 30:
31             amount += 1000 * (perf['audience'] - 30)
32         return amount
33 
34     def calculate_performance_credits(perf, play):
35         # add volume credits
36         credits = max(perf['audience'] - 30, 0)
37         # add extra credit for every ten comedy attendees
38         if "comedy" == play["type"]:
39             credits += math.floor(perf['audience'] / 5)
40         return credits
41 
42     for perf in invoice['performances']:
43         play = plays[perf['playID']]
44         this_amount = calculate_performance_amount(perf, play)
45         performance_credits = calculate_performance_credits(perf, play)
46 
47         volume_credits += performance_credits
48         # print line for this order
49         result += f' {play["name"]}: {format_as_dollars(this_amount/100\
50 )} ({perf["audience"]} seats)\n'
51         total_amount += this_amount
52 
53     result += f'Amount owed is {format_as_dollars(total_amount/100)}\n'
54     result += f'You earned {volume_credits} credits\n'
55     return result

A poco que profundicemos en este ejemplo podemos sentir que está pidiendo a gritos aplicar orientación a objetos, pero precisamente eso es algo que podemos empezar a vislumbrar después de haber ejercitado esta simple regla.

Por qué funciona

La regla de un solo nivel de indentación funciona porque nos ayuda a conseguir que cada método desarrolle su trabajo en un único nivel de abstracción, a la vez que separamos distintas responsabilidades. De este modo, puedes leer cada método y entender qué pasa, sin necesidad de distraerte con detalles que no son relevantes en ese momento. Si necesitas conocer cómo se implementa alguna de las fases no tienes más que revisar el método que se ocupa de ello.

Al separar el comportamiento de ese objeto en pasos implementados por métodos específicos será más fácil también identificar el papel de los colaboradores del objeto, si los hay, así como su aislamiento. De este modo, podremos detectar y solucionar más fácilmente posibles problemas de acoplamiento. A su vez, esta extracción a métodos privados puede ser el primer paso para identificar diferentes responsabilidades en una clase que podrían llevarse a nuevas clases.

Dado que son métodos privados no estamos afectando a la interfaz pública, por lo que es un refactor que podemos aplicar con seguridad.

Más allá

Veamos ahora como llegar más lejos aún con esta regla.

Separar iterador de iteración

Una forma de abordar los bucles es separar el iterador (el bucle) de la iteración (el cuerpo del bucle). Es decir, en lugar de tener un bloque de código, extraemos la totalidad del bloque a un método privado. De este modo, el cuerpo del bucle contendría una sola línea. Una de las ventajas de proceder así es que puede ayudarnos a identificar código que realmente pertenece a la clase del objeto que está siendo procesado en la iteración.

Aplicar esta separación en este ejemplo puede ser un poco complicado, dado que en el bucle for vamos acumulando ni más ni menos que tres variables: total_amount, volume_credits y result.

Figure 13
 1 for perf in invoice['performances']:
 2     play = plays[perf['playID']]
 3     this_amount = calculate_performance_amount(perf, play)
 4     performance_credits = calculate_performance_credits(perf, play)
 5 
 6     volume_credits += performance_credits
 7     # print line for this order
 8     result += f' {play["name"]}: {format_as_dollars(this_amount/100)} (\
 9 {perf["audience"]} seats)\n'
10     total_amount += this_amount

Pensemos si podemos hacer algo al respecto. Lo primero sería extraer una variable para almacenar la línea que estamos calculando en cada iteración:

Figure 14
 1 for perf in invoice['performances']:
 2     play = plays[perf['playID']]
 3     this_amount = calculate_performance_amount(perf, play)
 4     performance_credits = calculate_performance_credits(perf, play)
 5 
 6     volume_credits += performance_credits
 7     # print line for this order
 8     line = f' {play["name"]}: {format_as_dollars(this_amount/100)} ({pe\
 9 rf["audience"]} seats)\n'
10     result += line
11     total_amount += this_amount

Y ahora reunimos las variables acumuladoras:

Figure 15
 1 for perf in invoice['performances']:
 2     play = plays[perf['playID']]
 3     this_amount = calculate_performance_amount(perf, play)
 4     performance_credits = calculate_performance_credits(perf, play)
 5     line = f' {play["name"]}: {format_as_dollars(this_amount/100)} ({pe\
 6 rf["audience"]} seats)\n'
 7 
 8     result += line
 9     total_amount += this_amount
10     volume_credits += performance_credits

Ahora podríamos intentar extraer la parte del cálculo a una nueva función. Sin embargo, en Python las inner functions no son visibles desde dentro de otras inner functions, así que tendríamos que pasarlas junto con los parámetros necesarios por lo que este paso no es muy viable. De nuevo, el refactor nos va mostrando que lo más efectivo sería introducir orientación a objetos para este caso.

Solo un if por método

En el libro Five lines of code, Christian Clausen propone llevar esta restricción un poco más allá. Además de que cada método tenga un único nivel de indentación, sugiere que solo haya una estructura condicional en cada método y que if debería ser siempre la primera línea. Vamos a ver algunos ejemplos en este código y cómo se podrían abordar.

En el primero, podemos ver que se paga un extra si la audiencia supera un cierto umbral. En caso contrario, no se incrementa.

Figure 16
1 def calculate_amount_for_comedy(perf):
2     amount = 30000
3     if perf['audience'] > 20:
4         amount += 10000 + 500 * (perf['audience'] - 20)
5     amount += 300 * perf['audience']
6     return amount

Podríamos hacer una modificación temporal para verlo más claro:

Figure 17
1 def calculate_amount_for_comedy(perf):
2     amount = 30000
3     if perf['audience'] > 20:
4         extra_for_high_audience = 10000 + 500 * (perf['audience'] - 20)
5     else:
6         extra_for_high_audience = 0
7     amount += extra_for_high_audience
8     amount += 300 * perf['audience']
9     return amount

Ahora podemos extraer el bloque condicional:

Figure 18
 1 def calculate_amount_for_comedy(perf):
 2     amount = 30000
 3     extra_for_high_audience = extra_amount_for_high_audience_in_comdey(\
 4 perf)
 5     amount += extra_for_high_audience
 6     amount += 300 * perf['audience']
 7     return amount
 8 
 9 def extra_amount_for_high_audience_in_comedy(perf):
10     if perf['audience'] > 20:
11         extra_for_high_audience = 10000 + 500 * (perf['audience'] - 20)
12     else:
13         extra_for_high_audience = 0
14     return extra_for_high_audience

Esto hace que la condición quede como primera línea en extra_amount_for_high_audience_in_comedy, que es lo que buscábamos. Ahora limpiamos un poco el código para que quede menos redundante, removiendo variables temporales innecesarias.

Figure 19
 1 def calculate_amount_for_comedy(perf):
 2     amount = 30000
 3     amount += extra_amount_for_high_audience_in_comedy(perf)
 4     amount += 300 * perf['audience']
 5     return amount
 6 
 7 def extra_amount_for_high_audience_in_comedy(perf):
 8     if perf['audience'] > 20:
 9         return 10000 + 500 * (perf['audience'] - 20)
10     else:
11         return 0

Podemos aplicar un tratamiento similar para otros tipos de obras. El resultado sería este:

Figure 20
 1 def calculate_amount_for_tragedy(perf):
 2     amount = 40000
 3     amount += extra_amount_for_high_audience_in_tragedy(perf)
 4     return amount
 5 
 6 def extra_amount_for_high_audience_in_tragedy(perf):
 7     if perf['audience'] > 30:
 8         return 1000 * (perf['audience'] - 30)
 9     else:
10         return 0

Y lo mismo en el cálculo de créditos:

Figure 21
1 def calculate_performance_credits(perf, play):
2     # add volume credits
3     credits = max(perf['audience'] - 30, 0)
4     # add extra credit for every ten comedy attendees
5     if "comedy" == play["type"]:
6         credits += math.floor(perf['audience'] / 5)
7     return credits

Que quedaría así:

Figure 22
 1 def calculate_performance_credits(perf, play):
 2     # add volume credits
 3     credits = max(perf['audience'] - 30, 0)
 4     # add extra credit for every ten comedy attendees
 5     credits += extra_volume_credits_for_comedy(perf, play)
 6     return credits
 7 
 8 def extra_volume_credits_for_comedy(perf, play):
 9     if "comedy" == play["type"]:
10         return math.floor(perf['audience'] / 5)
11     else:
12         return 0

El resultado

Y este es el resultado final después de aplicar la regla de un solo nivel de indentación y las reglas extra:

Figure 23
 1 import math
 2 
 3 
 4 def statement(invoice, plays):
 5     total_amount = 0
 6     volume_credits = 0
 7     result = f'Statement for {invoice["customer"]}\n'
 8 
 9     def format_as_dollars(amount):
10         return f"${amount:0,.2f}"
11 
12     def calculate_performance_amount(perf, play):
13         if play['type'] == "tragedy":
14             amount = calculate_amount_for_tragedy(perf)
15         elif play['type'] == "comedy":
16             amount = calculate_amount_for_comedy(perf)
17         else:
18             raise ValueError(f'unknown type: {play["type"]}')
19         return amount
20 
21     def calculate_amount_for_comedy(perf):
22         amount = 30000
23         amount += extra_amount_for_high_audience_in_comedy(perf)
24         amount += 300 * perf['audience']
25         return amount
26 
27     def extra_amount_for_high_audience_in_comedy(perf):
28         if perf['audience'] > 20:
29             return 10000 + 500 * (perf['audience'] - 20)
30         else:
31             return 0
32 
33     def calculate_amount_for_tragedy(perf):
34         amount = 40000
35         amount += extra_amount_for_high_audience_in_tragedy(perf)
36         return amount
37 
38     def extra_amount_for_high_audience_in_tragedy(perf):
39         if perf['audience'] > 30:
40             return 1000 * (perf['audience'] - 30)
41         else:
42             return 0
43 
44     def calculate_performance_credits(perf, play):
45         # add volume credits
46         credits = max(perf['audience'] - 30, 0)
47         # add extra credit for every ten comedy attendees
48         credits += extra_volume_credits_for_comedy(perf, play)
49         return credits
50 
51     def extra_volume_credits_for_comedy(perf, play):
52         if "comedy" == play["type"]:
53             return math.floor(perf['audience'] / 5)
54         else:
55             return 0
56 
57     for perf in invoice['performances']:
58         play = plays[perf['playID']]
59         this_amount = calculate_performance_amount(perf, play)
60         performance_credits = calculate_performance_credits(perf, play)
61         line = f' {play["name"]}: {format_as_dollars(this_amount/100)} \
62 ({perf["audience"]} seats)\n'
63 
64         result += line
65         total_amount += this_amount
66         volume_credits += performance_credits
67 
68     result += f'Amount owed is {format_as_dollars(total_amount/100)}\n'
69     result += f'You earned {volume_credits} credits\n'
70     return result

Como puedes comprobar, se han introducido muchos else, lo que nos llevará a aplicar una nueva regla. Pero eso será en el siguiente capítulo.

No usar la palabra clave ELSE

Una estructura condicional puede dar lugar a varias ramas en el flujo de ejecución de modo que si se cumple la condición se sigue un camino, y si no se cumple… pues se sigue otro. O simplemente no se sigue ninguno y se continúa con la siguiente instrucción.

Pero, ¿qué problema hay con else? Al fin y al cabo, no indica otra cosa que seguir unas instrucciones específicas para el caso de que no se cumplan las condiciones requeridas en el if. Normalmente, el problema no es el hecho de usar else per se, sino el contexto en el que lo usamos o la organización de código que produce. Se podría decir que utilizar else puede ser un smell, un síntoma de que algo podría estar mejor diseñado. Si nos obligamos a eliminarlo, podemos mejorar el código.

Condicionales sencillas, aún más sencillas

Una estructura if tiene este aspecto:

Figure 24
1 // instrucciones previas
2 
3 if (condicion) then
4    // instrucciones si se cumple la condición
5 
6 // instrucciones posteriores

Usamos else cuando queremos ejecutar ciertas instrucciones en caso de no cumplirse la condición, de forma alternativa.

Figure 25
1 // instrucciones previas
2 
3 if (condicion) then
4    // instrucciones si se cumple la condición
5 else
6    // instrucciones alternativas si no se cumple
7 
8 // instrucciones posteriores

Esta estructura ya podría introducir algo de ruido a la hora de leer el programa. Como vimos en el capítulo anterior, nos interesa forzar un solo nivel de indentación como máximo para evitar la sobrecarga de seguir el código anidado. La introducción de else no añade un nivel de indentación extra, pero implica que tenemos que mantener en la cabeza dos flujos alternativos.

Esto se complica si tenemos que hacer seguimiento de variables que son inicializadas fuera de la estructura condicional, pero manipuladas en ella. También se complica la lectura si el tamaño de uno de los bloques es muy grande, ya que podría ofuscar el otro.

Por esa razón, se recomendaba aislar la estructura condicional en un método o función, de modo que el if fuese la primera línea y únicamente hubiese una condicional en ese método:

Figure 26
1 // instrucciones previas
2 
3 // instrucciones cuyo resultado depende de una condición
4 
5 // instrucciones posteriores

Y aquí la condicional aislada en su propia función o método:

Figure 27
1 if (condicion) then
2    // instrucciones si se cumple la condición
3 else
4    // instrucciones alternativas si no se cumple
5    
6 return

Al aislar de esta manera las condicionales, tanto la rama del if como la del else retornarán al punto de llamada, ya que no hay más instrucciones que seguir. De hecho, podríamos retornar desde ambas ramas. Es lo que conocemos como patrón return early,

Figure 28
1 if (condicion) then
2    // instrucciones si se cumple la condición
3    return
4 else
5    // instrucciones alternativas si no se cumple
6    return

Esto hace redundante la palabra clave else, ya que no es necesario asegurar que la condición del if no se cumple.

Figure 29
1 if (condicion) then
2    // instrucciones si se cumple la condición
3    return
4 
5 // instrucciones alternativas si no se cumple
6 return

Retomando el ejemplo del capítulo anterior, tenemos varias situaciones en las que se usa else que podríamos examinar. Recordemos el código:

Figure 30
 1 import math
 2 
 3 
 4 def statement(invoice, plays):
 5     total_amount = 0
 6     volume_credits = 0
 7     result = f'Statement for {invoice["customer"]}\n'
 8 
 9     def format_as_dollars(amount):
10         return f"${amount:0,.2f}"
11 
12     def calculate_performance_amount(perf, play):
13         if play['type'] == "tragedy":
14             amount = calculate_amount_for_tragedy(perf)
15         elif play['type'] == "comedy":
16             amount = calculate_amount_for_comedy(perf)
17         else:
18             raise ValueError(f'unknown type: {play["type"]}')
19         return amount
20 
21     def calculate_amount_for_comedy(perf):
22         amount = 30000
23         amount += extra_amount_for_high_audience_in_comedy(perf)
24         amount += 300 * perf['audience']
25         return amount
26 
27     def extra_amount_for_high_audience_in_comedy(perf):
28         if perf['audience'] > 20:
29             return 10000 + 500 * (perf['audience'] - 20)
30         else:
31             return 0
32 
33     def calculate_amount_for_tragedy(perf):
34         amount = 40000
35         amount += extra_amount_for_high_audience_in_tragedy(perf)
36         return amount
37 
38     def extra_amount_for_high_audience_in_tragedy(perf):
39         if perf['audience'] > 30:
40             return 1000 * (perf['audience'] - 30)
41         else:
42             return 0
43 
44     def calculate_performance_credits(perf, play):
45         # add volume credits
46         credits = max(perf['audience'] - 30, 0)
47         # add extra credit for every ten comedy attendees
48         credits += extra_volume_credits_for_comedy(perf, play)
49         return credits
50 
51     def extra_volume_credits_for_comedy(perf, play):
52         if "comedy" == play["type"]:
53             return math.floor(perf['audience'] / 5)
54         else:
55             return 0
56 
57     for perf in invoice['performances']:
58         play = plays[perf['playID']]
59         this_amount = calculate_performance_amount(perf, play)
60         performance_credits = calculate_performance_credits(perf, play)
61         line = f' {play["name"]}: {format_as_dollars(this_amount/100)} \
62 ({perf["audience"]} seats)\n'
63 
64         result += line
65         total_amount += this_amount
66         volume_credits += performance_credits
67 
68     result += f'Amount owed is {format_as_dollars(total_amount/100)}\n'
69     result += f'You earned {volume_credits} credits\n'
70     return result

Aquí tenemos un ejemplo:

Figure 31
1 def extra_amount_for_high_audience_in_comedy(perf):
2     if perf['audience'] > 20:
3         return 10000 + 500 * (perf['audience'] - 20)
4     else:
5         return 0

Este caso es bastante sencillo porque el else es redundante:

Figure 32
1 def extra_amount_for_high_audience_in_comedy(perf):
2     if perf['audience'] > 20:
3         return 10000 + 500 * (perf['audience'] - 20)
4     
5     return 0

Tenemos formas alternativas. Una de ellas consiste en usar el operador ternario, que funciona especialmente bien cuando queremos expresar un cálculo que se realiza de maneras diferentes. Utilízalo con precaución porque puede ser difícil de leer.

Figure 33
1 def extra_amount_for_high_audience_in_comedy(perf):
2     return 10000 + 500 * (perf['audience'] - 20) if perf['audience'] > \
3 20 else 0

Otra forma de plantearlo es invertir la condición, tratando el caso residual primero. A esto se le suele llamar cláusula de guarda. Es especialmente aplicable si se trata de verificar precondiciones de los parámetros que llegan al método o función. De esta forma, centras la atención en la rama más significativa.

Figure 34
1 def extra_amount_for_high_audience_in_comedy(perf):
2     if perf['audience'] <= 20:
3         return 0
4     
5     return 10000 + 500 * (perf['audience'] - 20)

Cualquiera de las tres técnicas te permite suprimir el else. La más adecuada dependerá del aspecto que necesites acentuar. Para este ejemplo podrían funcionar las tres bastante bien y resulta difícil decidirse por una de ellas. Quizá en este caso optaría por la condicional invertida.

De este modo, las tres funciones que contienen condicionales simples quedarían así:

Figure 35
 1 def extra_amount_for_high_audience_in_comedy(perf):
 2     if perf['audience'] <= 20:
 3         return 0
 4 
 5     return 10000 + 500 * (perf['audience'] - 20)
 6 
 7 def extra_amount_for_high_audience_in_tragedy(perf):
 8     if perf['audience'] <= 30:
 9         return 0
10 
11     return 1000 * (perf['audience'] - 30)
12 
13 def extra_volume_credits_for_comedy(perf, play):
14     if "comedy" != play["type"]:
15         return 0
16 
17     return math.floor(perf['audience'] / 5)

Condicionales complejas

Tenemos otro ejemplo interesante aquí:

Figure 36
1 def calculate_performance_amount(perf, play):
2     if play['type'] == "tragedy":
3         amount = calculate_amount_for_tragedy(perf)
4     elif play['type'] == "comedy":
5         amount = calculate_amount_for_comedy(perf)
6     else:
7         raise ValueError(f'unknown type: {play["type"]}')
8     return amount

Se trata de una serie de condicionales encadenadas a través de la clave else o else if. La estructura condicional maneja un cierto número de condiciones de tal manera, que si no se cumple la inicial, tenemos que verificar si se cumplen otras y actuar en consecuencia.

Esta estructura encadenada se entiende mejor usando switch, lo que esconde el else, aunque realmente no lo elimina. Python no tiene switch, y la forma de hacerlo es encadenando if/elif/else.

De nuevo, podremos usar return early para simplificar la estructura. Primero introducimos el return.

Figure 37
1 def calculate_performance_amount(perf, play):
2     if play['type'] == "tragedy":
3         return calculate_amount_for_tragedy(perf)
4     elif play['type'] == "comedy":
5         return calculate_amount_for_comedy(perf)
6     else:
7         raise ValueError(f'unknown type: {play["type"]}')

Y a continuación, eliminamos los else:

Figure 38
1 def calculate_performance_amount(perf, play):
2     if play['type'] == "tragedy":
3         return calculate_amount_for_tragedy(perf)
4     if play['type'] == "comedy":
5         return calculate_amount_for_comedy(perf)
6     
7     raise ValueError(f'unknown type: {play["type"]}')

Por qué funciona

Eliminar else nos obliga a pensar bien nuestras estructuras condicionales. Una estructura condicional siempre hace al menos dos cosas: decidir si se cumple la condición, hacer algo si es así. En el caso de else, hay que añadir una tercera cosa: la acción alternativa.

De hecho, en orientación a objetos, la mera presencia de una estructura condicional puede significar un problema de diseño. Esto ocurre, por ejemplo, cuando la condicional verifica alguna propiedad de un objeto (o de algún concepto del programa que potencialmente pueda ser un objeto). En ese caso, se pone de manifiesto la necesidad de polimorfismo. Nuestro último refactor lo deja muy claro.

Cuando se toma una decisión basada en el tipo de un concepto, debería abordarse mediante polimorfismo.

Figure 39
1 def calculate_performance_amount(perf, play):
2     if play['type'] == "tragedy":
3         return calculate_amount_for_tragedy(perf)
4     if play['type'] == "comedy":
5         return calculate_amount_for_comedy(perf)
6     
7     raise ValueError(f'unknown type: {play["type"]}')

Sin embargo, cuando la decisión se basa en un valor, podríamos recurrir a otros enfoques

Figure 40
1 def extra_amount_for_high_audience_in_tragedy(perf):
2     if perf['audience'] <= 30:
3         return 0
4 
5     return 1000 * (perf['audience'] - 30)

De todos modos, la introducción de la orientación a objetos vendrá de la mano de las siguientes reglas, que consisten en empaquetar todas nuestras primitivas y colecciones en objetos. Es decir, representar los conceptos usando objetos.

El resultado

Después de eliminar la palabra clave else, el código queda así:

Figure 41
 1 import math
 2 
 3 
 4 def statement(invoice, plays):
 5     total_amount = 0
 6     volume_credits = 0
 7     result = f'Statement for {invoice["customer"]}\n'
 8 
 9     def format_as_dollars(amount):
10         return f"${amount:0,.2f}"
11 
12     def calculate_performance_amount(perf, play):
13         if play['type'] == "tragedy":
14             return calculate_amount_for_tragedy(perf)
15         if play['type'] == "comedy":
16             return calculate_amount_for_comedy(perf)
17 
18         raise ValueError(f'unknown type: {play["type"]}')
19 
20     def calculate_amount_for_comedy(perf):
21         amount = 30000
22         amount += extra_amount_for_high_audience_in_comedy(perf)
23         amount += 300 * perf['audience']
24         return amount
25 
26     def extra_amount_for_high_audience_in_comedy(perf):
27         if perf['audience'] <= 20:
28             return 0
29 
30         return 10000 + 500 * (perf['audience'] - 20)
31 
32     def calculate_amount_for_tragedy(perf):
33         amount = 40000
34         amount += extra_amount_for_high_audience_in_tragedy(perf)
35         return amount
36 
37     def extra_amount_for_high_audience_in_tragedy(perf):
38         if perf['audience'] <= 30:
39             return 0
40 
41         return 1000 * (perf['audience'] - 30)
42 
43     def calculate_performance_credits(perf, play):
44         # add volume credits
45         credits = max(perf['audience'] - 30, 0)
46         # add extra credit for every ten comedy attendees
47         credits += extra_volume_credits_for_comedy(perf, play)
48         return credits
49 
50     def extra_volume_credits_for_comedy(perf, play):
51         if "comedy" != play["type"]:
52             return 0
53 
54         return math.floor(perf['audience'] / 5)
55 
56     for perf in invoice['performances']:
57         play = plays[perf['playID']]
58         this_amount = calculate_performance_amount(perf, play)
59         performance_credits = calculate_performance_credits(perf, play)
60         line = f' {play["name"]}: {format_as_dollars(this_amount/100)} \
61 ({perf["audience"]} seats)\n'
62 
63         result += line
64         total_amount += this_amount
65         volume_credits += performance_credits
66 
67     result += f'Amount owed is {format_as_dollars(total_amount/100)}\n'
68     result += f'You earned {volume_credits} credits\n'
69     return result

Parece muy claro que conceptos como obra (play) y actuación (performance) están pugnando por salir. Y alguno más. Lo veremos en el capítulo siguiente.

Empaquetar primitivas en objetos

Los lenguajes de programación proporcionan tipos de datos básicos, llamados primitivos, con los que podemos representar los diversos conceptos que maneja un programa. Sin embargo, esta representación suele ser imperfecta.

Pensemos por ejemplo, en un precio. El precio se puede representar con un número, pero hay varias características de los números con los que representamos precios que son importantes: son valores positivos, tienen decimales con reglas específicas de redondeo, y suele ser importante conocer la unidad monetaria, entre otros detalles.

Estas características no las proveen los tipos numéricos primitivos habituales. Por esa razón, un tipo específico, que puede estar basado en uno primitivo, pero que encapsule esas reglas es mucho mejor solución. Basta con realizar una encapsulación básica para empezar a obtener beneficios, ya que eso oculta al resto del programa los detalles de implementación del tipo y nos permite que evolucione sin afectar al resto del código. A medida que introducimos comportamiento y validaciones en ese objeto, el programa se beneficia automáticamente.

Encapsular tipos primitivos simples

Así que volvamos a nuestro ejemplo, en el que tenemos un montón de posibles casos. Para empezar, nos encontramos con los parámetros que se pasan a la función statement. Estos nos presentan algunos problemas particulares porque contienen colecciones de cosas, así que vamos a dejarlo para la próxima regla.

Lo primero que nos encontramos es total_amount, que representa el importe de la factura y que va acumulando parciales.

Figure 42
 1 import math
 2 
 3 
 4 def statement(invoice, plays):
 5     total_amount = 0
 6     volume_credits = 0
 7     result = f'Statement for {invoice["customer"]}\n'
 8 
 9     def format_as_dollars(amount):
10         return f"${amount:0,.2f}"
11 
12     def calculate_performance_amount(perf, play):
13         if play['type'] == "tragedy":
14             return calculate_amount_for_tragedy(perf)
15         if play['type'] == "comedy":
16             return calculate_amount_for_comedy(perf)
17 
18         raise ValueError(f'unknown type: {play["type"]}')
19 
20     def calculate_amount_for_comedy(perf):
21         amount = 30000
22         amount += extra_amount_for_high_audience_in_comedy(perf)
23         amount += 300 * perf['audience']
24         return amount
25 
26     def extra_amount_for_high_audience_in_comedy(perf):
27         if perf['audience'] <= 20:
28             return 0
29 
30         return 10000 + 500 * (perf['audience'] - 20)
31 
32     def calculate_amount_for_tragedy(perf):
33         amount = 40000
34         amount += extra_amount_for_high_audience_in_tragedy(perf)
35         return amount
36 
37     def extra_amount_for_high_audience_in_tragedy(perf):
38         if perf['audience'] <= 30:
39             return 0
40 
41         return 1000 * (perf['audience'] - 30)
42 
43     def calculate_performance_credits(perf, play):
44         # add volume credits
45         credits = max(perf['audience'] - 30, 0)
46         # add extra credit for every ten comedy attendees
47         credits += extra_volume_credits_for_comedy(perf, play)
48         return credits
49 
50     def extra_volume_credits_for_comedy(perf, play):
51         if "comedy" != play["type"]:
52             return 0
53 
54         return math.floor(perf['audience'] / 5)
55 
56     for perf in invoice['performances']:
57         play = plays[perf['playID']]
58         this_amount = calculate_performance_amount(perf, play)
59         performance_credits = calculate_performance_credits(perf, play)
60         line = f' {play["name"]}: {format_as_dollars(this_amount/100)} \
61 ({perf["audience"]} seats)\n'
62 
63         result += line
64         total_amount += this_amount
65         volume_credits += performance_credits
66 
67     result += f'Amount owed is {format_as_dollars(total_amount/100)}\n'
68     result += f'You earned {volume_credits} credits\n'
69     return result

total_amount representa una cantidad de dinero. En este ejemplo, la unidad monetaria resulta ser el centavo como se puede apreciar en la forma en que se usa la función format_as_dollars. Básicamente, necesitaremos dos comportamientos: poder acumular y obtener el importe acumulado hasta el momento. Este nuevo tipo se podría llamar Amount.

Para no tener los test rotos mucho tiempo voy a introducir el cambio en paralelo, añadiendo la nueva clase, pero sin introducir el cambio hasta el último momento. Por supuesto, puedo hacer esto con TDD porque es código nuevo.

Figure 43
 1 import unittest
 2 
 3 
 4 class AmountTestCase(unittest.TestCase):
 5     def test_contains_amount(self):
 6         amount_of_300 = Amount(300)
 7         self.assertEqual(300, amount_of_300.current())
 8 
 9 
10 if __name__ == '__main__':
11     unittest.main()
Figure 44
1 class Amount:
2     def __init__(self, initial_amount):
3         self._amount = initial_amount
4 
5     def current(self):
6         return self._amount

Ahora, añadiré un método para acumular importes. Aprovecharé para hacerlo inmutable.

Figure 45
 1 import unittest
 2 
 3 from domain.amount import Amount
 4 
 5 
 6 class AmountTestCase(unittest.TestCase):
 7     def test_contains_amount(self):
 8         amount_of_300 = Amount(300)
 9         self.assertEqual(300, amount_of_300.current())
10 
11     def test_can_accumulate_partial_amounts(self):
12         amount_of_300 = Amount(300)
13         amount_of_500 = amount_of_300.add(Amount(200))
14         self.assertEqual(500, amount_of_500.current())
15 
16 
17 if __name__ == '__main__':
18     unittest.main()
Figure 46
 1 class Amount:
 2     def __init__(self, initial_amount):
 3         self._amount = initial_amount
 4 
 5     def current(self):
 6         return self._amount
 7 
 8     def add(self, other):
 9         new_amount = self._amount + other.current()
10         return Amount(new_amount)

Con esto tengo suficiente para empezar a usarlo. Para ello introduzco una variable invoice_amount.

Figure 47
 1 import math
 2 
 3 from domain.amount import Amount
 4 
 5 
 6 def statement(invoice, plays):
 7     total_amount = 0
 8     invoice_amount = Amount(0)
 9     volume_credits = 0
10     result = f'Statement for {invoice["customer"]}\n'
11 
12     # ...
13 
14     for perf in invoice['performances']:
15         play = plays[perf['playID']]
16         this_amount = calculate_performance_amount(perf, play)
17         performance_credits = calculate_performance_credits(perf, play)
18         line = f' {play["name"]}: {format_as_dollars(this_amount/100)} \
19 ({perf["audience"]} seats)\n'
20 
21         result += line
22         total_amount += this_amount
23         invoice_amount = invoice_amount.add(Amount(this_amount))
24         volume_credits += performance_credits
25 
26     result += f'Amount owed is {format_as_dollars(total_amount/100)}\n'
27     result += f'You earned {volume_credits} credits\n'
28     return result

Y, finalmente, solo tendría que reemplazar la variable total_amount en la línea que imprime el importe final. La idea es que todos los cambios anteriores ya estén mezclados, de modo que este nuevo cambio ocurra en un único commit y se pueda revertir fácilmente en caso de que falle.

Figure 48
 1 import math
 2 
 3 from domain.amount import Amount
 4 
 5 
 6 def statement(invoice, plays):
 7     total_amount = 0
 8     invoice_amount = Amount(0)
 9     volume_credits = 0
10     result = f'Statement for {invoice["customer"]}\n'
11 
12     # ...
13 
14     for perf in invoice['performances']:
15         play = plays[perf['playID']]
16         this_amount = calculate_performance_amount(perf, play)
17         performance_credits = calculate_performance_credits(perf, play)
18         line = f' {play["name"]}: {format_as_dollars(this_amount/100)} \
19 ({perf["audience"]} seats)\n'
20 
21         result += line
22         total_amount += this_amount
23         invoice_amount = invoice_amount.add(Amount(this_amount))
24         volume_credits += performance_credits
25 
26     result += f'Amount owed is {format_as_dollars(invoice_amount.curren\
27 t()//100)}\n'
28     result += f'You earned {volume_credits} credits\n'
29     return result

De hecho, los tests de statement siguen pasando perfectamente, por lo que podemos quitar total_amount ya que ha dejado de usarse.

Figure 49
 1 import math
 2 
 3 from domain.amount import Amount
 4 
 5 
 6 def statement(invoice, plays):
 7     invoice_amount = Amount(0)
 8     volume_credits = 0
 9     result = f'Statement for {invoice["customer"]}\n'
10 
11     # ...
12 
13     for perf in invoice['performances']:
14         play = plays[perf['playID']]
15         this_amount = calculate_performance_amount(perf, play)
16         performance_credits = calculate_performance_credits(perf, play)
17         line = f' {play["name"]}: {format_as_dollars(this_amount/100)} \
18 ({perf["audience"]} seats)\n'
19 
20         result += line
21         invoice_amount = invoice_amount.add(Amount(this_amount))
22         volume_credits += performance_credits
23 
24     result += f'Amount owed is {format_as_dollars(invoice_amount.curren\
25 t()//100)}\n'
26     result += f'You earned {volume_credits} credits\n'
27     return result

Una cosa interesante es que this_amount también debería ser un Amount, así que tendría sentido examinar calculate_performance_amount, para que devuelva un tipo Amount. Eso nos lleva a una serie de cambios con una mecánica muy similar a la que hemos seguido. Introducimos el código nuevo en paralelo y lo consolidamos en un commit. Finalmente, usamos el nuevo cálculo aislado en un único commit para que deshacerlo sea sencillo. Una vez que confirmamos que no se ha roto nada, eliminamos el código viejo.

En este caso, lo que voy a hacer es introducir un objeto Amount en las funciones que realizan el cálculo y, provisionalmente, dejaré que todavía no devuelvan el tipo Amount, sino el primitivo calculado con Amount. En una segunda fase adaptaré el código llamante para que espere el tipo Amount. He aquí un ejemplo:

Figure 50
1 def calculate_amount_for_comedy(perf):
2     amount = 30000
3     amount += extra_amount_for_high_audience_in_comedy(perf)
4     amount += 300 * perf['audience']
5     return amount

El primer paso es calcularlo en paralelo:

Figure 51
 1 def calculate_amount_for_comedy(perf):
 2     base_amount = Amount(30000)
 3     amount_with_extra = base_amount.add(Amount(extra_amount_for_high_au\
 4 dience_in_comedy(perf)))
 5     comedy_amount = amount_with_extra.add(Amount(300 * perf['audience']\
 6 ))
 7     
 8     amount = 30000
 9     amount += extra_amount_for_high_audience_in_comedy(perf)
10     amount += 300 * perf['audience']
11     return amount

Una vez hecho un commit con esos cambios, utilizaré el nuevo cálculo, pero sin devolver todavía el objeto:

Figure 52
 1 def calculate_amount_for_comedy(perf):
 2     base_amount = Amount(30000)
 3     amount_with_extra = base_amount.add(Amount(extra_amount_for_high_au\
 4 dience_in_comedy(perf)))
 5     comedy_amount = amount_with_extra.add(Amount(300 * perf['audience']\
 6 ))
 7 
 8     amount = 30000
 9     amount += extra_amount_for_high_audience_in_comedy(perf)
10     amount += 300 * perf['audience']
11     return comedy_amount.current()

Como los tests siguen pasando puedo consolidar el cambio y eliminar el código que ya no uso.

Figure 53
1 def calculate_amount_for_comedy(perf):
2     base_amount = Amount(30000)
3     amount_with_extra = base_amount.add(Amount(extra_amount_for_high_au\
4 dience_in_comedy(perf)))
5     comedy_amount = amount_with_extra.add(Amount(300 * perf['audience']\
6 ))
7 
8     return comedy_amount.current()

Por supuesto, puedo evitar el uso de variables temporales:

Figure 54
1 def calculate_amount_for_comedy(perf):
2     return Amount(30000)\
3         .add(Amount(extra_amount_for_high_audience_in_comedy(perf)))\
4         .add(Amount(300 * perf['audience']))\
5         .current()

El mismo cambio se puede aplicar en muchos lugares. Usaremos el mismo procedimiento, aunque no lo voy a mostrar para no alargar el capítulo innecesariamente. Así es como quedará, teniendo en cuenta que todavía estoy dejando que las funciones retornen el primitivo.

Figure 55
 1 import math
 2 
 3 from domain.amount import Amount
 4 
 5 
 6 def statement(invoice, plays):
 7     invoice_amount = Amount(0)
 8     volume_credits = 0
 9     result = f'Statement for {invoice["customer"]}\n'
10 
11     def format_as_dollars(amount):
12         return f"${amount:0,.2f}"
13 
14     def calculate_performance_amount(perf, play):
15         if play['type'] == "tragedy":
16             return calculate_amount_for_tragedy(perf)
17         if play['type'] == "comedy":
18             return calculate_amount_for_comedy(perf)
19 
20         raise ValueError(f'unknown type: {play["type"]}')
21 
22     def calculate_amount_for_comedy(perf):
23         return Amount(30000)\
24             .add(Amount(extra_amount_for_high_audience_in_comedy(perf))\
25 )\
26             .add(Amount(300 * perf['audience']))\
27             .current()
28 
29     def extra_amount_for_high_audience_in_comedy(perf):
30         if perf['audience'] <= 20:
31             return Amount(0).current()
32 
33         return Amount(10000 + 500 * (perf['audience'] - 20)).current()
34 
35     def calculate_amount_for_tragedy(perf):
36         return Amount(40000)\
37             .add(Amount(extra_amount_for_high_audience_in_tragedy(perf)\
38 ))\
39             .current()
40 
41 
42     def extra_amount_for_high_audience_in_tragedy(perf):
43         if perf['audience'] <= 30:
44             return Amount(0).current()
45 
46         return Amount(1000 * (perf['audience'] - 30)).current()
47 
48     def calculate_performance_credits(perf, play):
49         credits = max(perf['audience'] - 30, 0)
50         # add extra credit for every ten comedy attendees
51         credits += extra_volume_credits_for_comedy(perf, play)
52         return credits
53 
54     def extra_volume_credits_for_comedy(perf, play):
55         if "comedy" != play["type"]:
56             return 0
57 
58         return math.floor(perf['audience'] / 5)
59 
60     for perf in invoice['performances']:
61         play = plays[perf['playID']]
62         this_amount = calculate_performance_amount(perf, play)
63         performance_credits = calculate_performance_credits(perf, play)
64         line = f' {play["name"]}: {format_as_dollars(this_amount/100)} \
65 ({perf["audience"]} seats)\n'
66 
67         result += line
68         invoice_amount = invoice_amount.add(Amount(this_amount))
69         volume_credits += performance_credits
70 
71     result += f'Amount owed is {format_as_dollars(invoice_amount.curren\
72 t()//100)}\n'
73     result += f'You earned {volume_credits} credits\n'
74     return result

Ahora iré desde dentro hacia afuera cambiando el tipo retornado para dejar de usar el primitivo. Iré paso a paso, para asegurarme de que lo hago bien, pasando los tests cada vez. Este es el primero:

Figure 56
 1 def calculate_amount_for_comedy(perf):
 2     return Amount(30000)\
 3         .add(extra_amount_for_high_audience_in_comedy(perf))\
 4         .add(Amount(300 * perf['audience']))\
 5         .current()
 6 
 7 def extra_amount_for_high_audience_in_comedy(perf):
 8     if perf['audience'] <= 20:
 9         return Amount(0)
10 
11     return Amount(10000 + 500 * (perf['audience'] - 20))

Y sigo paso a paso hasta que los cambio todos. La idea es que solamente use Amount.current cuando sea necesario para imprimir la factura.

Figure 57
 1 import math
 2 
 3 from domain.amount import Amount
 4 
 5 
 6 def statement(invoice, plays):
 7     invoice_amount = Amount(0)
 8     volume_credits = 0
 9     result = f'Statement for {invoice["customer"]}\n'
10 
11     def format_as_dollars(amount):
12         return f"${amount:0,.2f}"
13 
14     def calculate_performance_amount(perf, play):
15         if play['type'] == "tragedy":
16             return calculate_amount_for_tragedy(perf)
17         if play['type'] == "comedy":
18             return calculate_amount_for_comedy(perf)
19 
20         raise ValueError(f'unknown type: {play["type"]}')
21 
22     def calculate_amount_for_comedy(perf):
23         return Amount(30000) \
24             .add(extra_amount_for_high_audience_in_comedy(perf)) \
25             .add(Amount(300 * perf['audience']))
26 
27     def extra_amount_for_high_audience_in_comedy(perf):
28         if perf['audience'] <= 20:
29             return Amount(0)
30 
31         return Amount(10000 + 500 * (perf['audience'] - 20))
32 
33     def calculate_amount_for_tragedy(perf):
34         return Amount(40000) \
35             .add(extra_amount_for_high_audience_in_tragedy(perf))
36 
37     def extra_amount_for_high_audience_in_tragedy(perf):
38         if perf['audience'] <= 30:
39             return Amount(0)
40 
41         return Amount(1000 * (perf['audience'] - 30))
42 
43     def calculate_performance_credits(perf, play):
44         credits = max(perf['audience'] - 30, 0)
45         # add extra credit for every ten comedy attendees
46         credits += extra_volume_credits_for_comedy(perf, play)
47         return credits
48 
49     def extra_volume_credits_for_comedy(perf, play):
50         if "comedy" != play["type"]:
51             return 0
52 
53         return math.floor(perf['audience'] / 5)
54 
55     for perf in invoice['performances']:
56         play = plays[perf['playID']]
57         this_amount = calculate_performance_amount(perf, play)
58         performance_credits = calculate_performance_credits(perf, play)
59         line = f' {play["name"]}: {format_as_dollars(this_amount.curren\
60 t() / 100)} ({perf["audience"]} seats)\n'
61 
62         result += line
63         invoice_amount = invoice_amount.add(this_amount)
64         volume_credits += performance_credits
65 
66     result += f'Amount owed is {format_as_dollars(invoice_amount.curren\
67 t() // 100)}\n'
68     result += f'You earned {volume_credits} credits\n'
69     return result

Como se puede ver, el código no ha cambiado demasiado y seguramente hay espacio para muchas mejoras, pero tenemos que proceder de manera sistemática. Así que vamos a buscar otro primitivo que podamos reemplazar.

volume_credits tiene un funcionamiento similar a Amount, pero significa una cosa distinta, así que vamos a introducir una clase Credits, que representará ese contexto. Y usaremos la misma aproximación: introducir la nueva clase, usarla en paralelo y, finalmente, sustituirla.

Figure 58
 1 import unittest
 2 
 3 from domain.credits import Credits
 4 
 5 
 6 class CreditsTestCase(unittest.TestCase):
 7     def test_contains_credits(self):
 8         self.assertEqual(100, Credits(100).current())  # add assertion \
 9 here
10 
11     def test_accumulates_credits(self):
12         initial_credits = Credits(100)
13         extra = Credits(100)
14         self.assertEqual(200, initial_credits.add(extra).current())
15 
16 
17 if __name__ == '__main__':
18     unittest.main()
Figure 59
 1 class Credits:
 2 
 3     def __init__(self, initial_credits):
 4         self._credits = initial_credits
 5 
 6     def current(self):
 7         return self._credits
 8 
 9     def add(self, more_credits):
10         return Credits(self._credits + more_credits.current())

Los cambios en el código los hacemos de la misma manera que antes. El resultado será más o menos este:

Figure 60
 1 import math
 2 
 3 from domain.amount import Amount
 4 from domain.credits import Credits
 5 
 6 
 7 def statement(invoice, plays):
 8     invoice_amount = Amount(0)
 9     volume_credits = Credits(0)
10     result = f'Statement for {invoice["customer"]}\n'
11 
12     # ...
13 
14     def calculate_performance_credits(perf, play):
15         return Credits(max(perf['audience'] - 30, 0)).\
16             add(extra_volume_credits_for_comedy(perf, play))
17 
18 
19     def extra_volume_credits_for_comedy(perf, play):
20         if "comedy" != play["type"]:
21             return Credits(0)
22 
23         return Credits(math.floor(perf['audience'] / 5))
24 
25     for perf in invoice['performances']:
26         play = plays[perf['playID']]
27         this_amount = calculate_performance_amount(perf, play)
28         performance_credits = calculate_performance_credits(perf, play)
29         line = f' {play["name"]}: {format_as_dollars(this_amount.curren\
30 t() / 100)} ({perf["audience"]} seats)\n'
31 
32         result += line
33         invoice_amount = invoice_amount.add(this_amount)
34         volume_credits = volume_credits.add(performance_credits)
35 
36     result += f'Amount owed is {format_as_dollars(invoice_amount.curren\
37 t() // 100)}\n'
38     result += f'You earned {volume_credits.current()} credits\n'
39     return result

Nuestro siguiente candidato es result, que es un string que va acumulando las líneas que se imprimirán en la factura. De hecho, podríamos incorporar el concepto de Printer como objeto encargado de imprimir las líneas que se le pasan, en lugar de un simple almacén de líneas para devolver al final. Queremos que funcione más o menos como indica este test:

Figure 61
 1 import unittest
 2 
 3 
 4 class PrinterTestCase(unittest.TestCase):
 5     def test_can_print_lines(self):
 6         printer = Printer()
 7 
 8         printer.print("Line 1")
 9         printer.print("Line 2")
10 
11         expected = "Line 1Line 2"
12 
13         self.assertEqual(expected, printer.output())
14 
15 
16 if __name__ == '__main__':
17     unittest.main()

De momento lo implementamos así, que es más o menos como está en el código original y será suficiente para lo que necesitamos:

Figure 62
1 class Printer:
2     def __init__(self):
3         self._lines = ""
4 
5     def print(self, line):
6         self._lines += line
7 
8     def output(self):
9         return self._lines

Par integrarlo, procedemos del mismo modo que antes. Primero lo introducimos en paralelo y dejamos que el último cambio sea muy simple. El resultado, una vez eliminado el código anterior es este:

Figure 63
 1 import math
 2 
 3 from domain.amount import Amount
 4 from domain.credits import Credits
 5 from domain.printer import Printer
 6 
 7 
 8 def statement(invoice, plays):
 9     printer = Printer()
10     invoice_amount = Amount(0)
11     volume_credits = Credits(0)
12     printer.print(f'Statement for {invoice["customer"]}\n')
13 
14     def format_as_dollars(amount):
15         return f"${amount:0,.2f}"
16 
17     def calculate_performance_amount(perf, play):
18         if play['type'] == "tragedy":
19             return calculate_amount_for_tragedy(perf)
20         if play['type'] == "comedy":
21             return calculate_amount_for_comedy(perf)
22 
23         raise ValueError(f'unknown type: {play["type"]}')
24 
25     def calculate_amount_for_comedy(perf):
26         return Amount(30000) \
27             .add(extra_amount_for_high_audience_in_comedy(perf)) \
28             .add(Amount(300 * perf['audience']))
29 
30     def extra_amount_for_high_audience_in_comedy(perf):
31         if perf['audience'] <= 20:
32             return Amount(0)
33 
34         return Amount(10000 + 500 * (perf['audience'] - 20))
35 
36     def calculate_amount_for_tragedy(perf):
37         return Amount(40000) \
38             .add(extra_amount_for_high_audience_in_tragedy(perf))
39 
40     def extra_amount_for_high_audience_in_tragedy(perf):
41         if perf['audience'] <= 30:
42             return Amount(0)
43 
44         return Amount(1000 * (perf['audience'] - 30))
45 
46     def calculate_performance_credits(perf, play):
47         return Credits(max(perf['audience'] - 30, 0)). \
48             add(extra_volume_credits_for_comedy(perf, play))
49 
50     def extra_volume_credits_for_comedy(perf, play):
51         if "comedy" != play["type"]:
52             return Credits(0)
53 
54         return Credits(math.floor(perf['audience'] / 5))
55 
56     for perf in invoice['performances']:
57         play = plays[perf['playID']]
58         this_amount = calculate_performance_amount(perf, play)
59         performance_credits = calculate_performance_credits(perf, play)
60 
61         line = f' {play["name"]}: {format_as_dollars(this_amount.curren\
62 t() / 100)} ({perf["audience"]} seats)\n'
63         printer.print(line)
64 
65         invoice_amount = invoice_amount.add(this_amount)
66         volume_credits = volume_credits.add(performance_credits)
67 
68     printer.print(f'Amount owed is {format_as_dollars(invoice_amount.cu\
69 rrent() // 100)}\n')
70     printer.print(f'You earned {volume_credits.current()} credits\n')
71 
72     return printer.output()

Encapsular estructuras de datos nativas

Nos quedan varios objetos interesantes. En particular Play y Performance, que son centrales en este dominio. Veamos cómo los podemos tratar.

En principio, estos objetos están tratados como diccionarios (o hash, o array asociativo, según el lenguaje). La tentación es intentar crear desde cero un objeto que reproduzca esa estructura. Sin embargo, vamos a seguir un enfoque más simplista. Por el momento solo vamos a encapsular esos diccionarios y añadir métodos que nos permitan acceder recuperar los valores de sus claves.

Una vez hecho esto, que será el primer paso, podremos hacer evolucionar la estructura interna sin que el resto del código tenga que preocuparse de ello. La razón para hacerlo así es evitar mezclar distintos objetivos en una única acción de refactor.

Después de examinar el código pienso que voy a empezar por Performance. En el bucle de la función statement se recorren las distintas Performances que se van a facturar y se opera con sus datos. En principio, una performance tiene las siguientes propiedades:

  • playID, que hace referencia a la obra representada
  • audience, que representa la cantidad de pública asistente

Así que introduciré la clase Performance, que tendrá por el momento dos métodos públicos: play_id y audience. En esta ocasión no voy a hacer tests, ya que son métodos triviales y su comportamiento quedará cubierto por los tests que ya tenemos.

Figure 64
1 class Performance:
2     def __init__(self, perf):
3         self._data = perf
4 
5     def audience(self):
6         return self._data['audience']
7 
8     def play_id(self):
9         return self._data['playID']

Sospecho lo que estás pensando, pero de momento lo único que quiero es estar seguro de que el cambio funcionará. Ten en cuenta que este ejemplo es muy sencillo. En situaciones en que las estructuras de datos sean más complejas, este paso previo sirve para explorar las responsabilidades del objeto sin preocuparnos de su estructura interna.

Ahora toca introducirlo. Será aquí:

Figure 65
 1 for perf in invoice['performances']:
 2     performance = Performance(perf)
 3     play = plays[perf['playID']]
 4     this_amount = calculate_performance_amount(perf, play)
 5     performance_credits = calculate_performance_credits(perf, play)
 6 
 7     line = f' {play["name"]}: {format_as_dollars(this_amount.current() \
 8 / 100)} ({perf["audience"]} seats)\n'
 9     printer.print(line)
10 
11     invoice_amount = invoice_amount.add(this_amount)
12     volume_credits = volume_credits.add(performance_credits)

Es ahora cuando podemos empezar a usarlo. Primero, en este nivel de abstracción:

Figure 66
 1 for perf in invoice['performances']:
 2     performance = Performance(perf)
 3     play = plays[performance.play_id()]
 4     this_amount = calculate_performance_amount(perf, play)
 5     performance_credits = calculate_performance_credits(perf, play)
 6 
 7     line = f' {play["name"]}: {format_as_dollars(this_amount.current() \
 8 / 100)} ({performance.audience()} seats)\n'
 9     printer.print(line)
10 
11     invoice_amount = invoice_amount.add(this_amount)
12     volume_credits = volume_credits.add(performance_credits)
13 
14 printer.print(f'Amount owed is {format_as_dollars(invoice_amount.curren\
15 t() // 100)}\n')
16 printer.print(f'You earned {volume_credits.current()} credits\n')

Y ahora viene algo interesante. Tenemos un par de funciones a las que les pasamos las variables perf (que representa una Performance) y play para hacer cálculos con ellas:

Figure 67
1 def calculate_performance_amount(perf, play):
2     if play['type'] == "tragedy":
3         return calculate_amount_for_tragedy(perf)
4     if play['type'] == "comedy":
5         return calculate_amount_for_comedy(perf)
6 
7     raise ValueError(f'unknown type: {play["type"]}')

De hecho, podemos ver que esta función llama a otras que utilizan perf como único parámetro. Esto nos está indicando que este comportamiento es propio de Performance. Es decir, sería responsabilidad de Performance calcular el importe facturable. Básicamente, me estoy refiriendo a estas funciones:

Figure 68
 1 def calculate_amount_for_comedy(perf):
 2     return Amount(30000) \
 3         .add(extra_amount_for_high_audience_in_comedy(perf)) \
 4         .add(Amount(300 * perf['audience']))
 5 
 6 def extra_amount_for_high_audience_in_comedy(perf):
 7     if perf['audience'] <= 20:
 8         return Amount(0)
 9 
10     return Amount(10000 + 500 * (perf['audience'] - 20))
11 
12 def calculate_amount_for_tragedy(perf):
13     return Amount(40000) \
14         .add(extra_amount_for_high_audience_in_tragedy(perf))
15 
16 def extra_amount_for_high_audience_in_tragedy(perf):
17     if perf['audience'] <= 30:
18         return Amount(0)
19 
20     return Amount(1000 * (perf['audience'] - 30))

¿Cómo voy a hacer este cambio? La verdad es que se me ocurren un par de maneras, aunque muy similares. El objetivo es copiar y adaptar el código que ahora está en funciones internas en statement para que sean métodos en Performance. La dificultad está en cómo hacer esto sin romper el test que tenemos.

Vamos con la primera forma. El primer paso es copiar el código de las funciones en Performance y adaptarlo de manera que no haya errores. Debería quedar más o menos así:

Figure 69
 1 from domain.amount import Amount
 2 
 3 
 4 class Performance:
 5     def __init__(self, perf):
 6         self.data = perf
 7 
 8     def audience(self):
 9         return self.data['audience']
10 
11     def play_id(self):
12         return self.data['playID']
13 
14     def calculate_amount_for_comedy(self):
15         return Amount(30000) \
16             .add(self.extra_amount_for_high_audience_in_comedy()) \
17             .add(Amount(300 * self.audience()))
18 
19     def extra_amount_for_high_audience_in_comedy(self):
20         if self.audience() <= 20:
21             return Amount(0)
22 
23         return Amount(10000 + 500 * (self.audience() - 20))
24 
25     def calculate_amount_for_tragedy(self):
26         return Amount(40000) \
27             .add(self.extra_amount_for_high_audience_in_tragedy())
28 
29     def extra_amount_for_high_audience_in_tragedy(self):
30         if self.audience() <= 30:
31             return Amount(0)
32 
33         return Amount(1000 * (self.audience() - 30))

Ahora tenemos que pasar performance en vez de perf a la función en la línea:

Figure 70
1 this_amount = calculate_performance_amount(perf, play)

En estos casos, lo que suelo hacer es añadir un nuevo parámetro y luego reemplazar su uso, hasta que el viejo parámetro queda sin usar. Cuando verifico que todo funciona correctamente, elimino el viejo.

Figure 71
1 def calculate_performance_amount(perf, play, performance):
2     if play['type'] == "tragedy":
3         return calculate_amount_for_tragedy(perf)
4     if play['type'] == "comedy":
5         return calculate_amount_for_comedy(perf)
6 
7     raise ValueError(f'unknown type: {play["type"]}')

En este punto puedo hacer commit antes de realizar el cambio importante, que sería hacer que performance ejecute el cálculo:

Figure 72
1 def calculate_performance_amount(perf, play, performance):
2     if play['type'] == "tragedy":
3         return performance.calculate_amount_for_tragedy()
4     if play['type'] == "comedy":
5         return performance.calculate_amount_for_comedy()
6 
7     raise ValueError(f'unknown type: {play["type"]}')

He hecho el cambio y los tests siguen pasando, así que puedo eliminar el parámetro perf y también las funciones internas que ya no necesito.

Figure 73
 1 import math
 2 
 3 from domain.amount import Amount
 4 from domain.credits import Credits
 5 from domain.performance import Performance
 6 from domain.printer import Printer
 7 
 8 
 9 def statement(invoice, plays):
10     printer = Printer()
11     invoice_amount = Amount(0)
12     volume_credits = Credits(0)
13     printer.print(f'Statement for {invoice["customer"]}\n')
14 
15     def format_as_dollars(amount):
16         return f"${amount:0,.2f}"
17 
18     def calculate_performance_amount(play, performance):
19         if play['type'] == "tragedy":
20             return performance.calculate_amount_for_tragedy()
21         if play['type'] == "comedy":
22             return performance.calculate_amount_for_comedy()
23 
24         raise ValueError(f'unknown type: {play["type"]}')
25 
26     def calculate_performance_credits(perf, play):
27         return Credits(max(perf['audience'] - 30, 0)). \
28             add(extra_volume_credits_for_comedy(perf, play))
29 
30     def extra_volume_credits_for_comedy(perf, play):
31         if "comedy" != play["type"]:
32             return Credits(0)
33 
34         return Credits(math.floor(perf['audience'] / 5))
35 
36     for perf in invoice['performances']:
37         performance = Performance(perf)
38         play = plays[performance.play_id()]
39         this_amount = calculate_performance_amount(play, performance)
40         performance_credits = calculate_performance_credits(perf, play)
41 
42         line = f' {play["name"]}: {format_as_dollars(this_amount.curren\
43 t() / 100)} ({performance.audience()} seats)\n'
44         printer.print(line)
45 
46         invoice_amount = invoice_amount.add(this_amount)
47         volume_credits = volume_credits.add(performance_credits)
48 
49     printer.print(f'Amount owed is {format_as_dollars(invoice_amount.cu\
50 rrent() // 100)}\n')
51     printer.print(f'You earned {volume_credits.current()} credits\n')
52 
53     return printer.output()

¿Empieza a tener mejor pinta? Parece que sí. Hacemos lo mismo con calculate_performance_credits. No voy a poner todo el detalle del proceso, pero es la misma idea: mover el código a Performance, adaptándolo y cambiando los usos de las funciones internas por llamadas al objeto. Finalmente, eliminar el código que no necesitamos.

Así es como queda Performance:

Figure 74
 1 import math
 2 
 3 from domain.amount import Amount
 4 from domain.credits import Credits
 5 
 6 
 7 class Performance:
 8     def __init__(self, perf):
 9         self.data = perf
10 
11     def audience(self):
12         return self.data['audience']
13 
14     def play_id(self):
15         return self.data['playID']
16 
17     def calculate_amount_for_comedy(self):
18         return Amount(30000) \
19             .add(self.extra_amount_for_high_audience_in_comedy()) \
20             .add(Amount(300 * self.audience()))
21 
22     def extra_amount_for_high_audience_in_comedy(self):
23         if self.audience() <= 20:
24             return Amount(0)
25 
26         return Amount(10000 + 500 * (self.audience() - 20))
27 
28     def calculate_amount_for_tragedy(self):
29         return Amount(40000) \
30             .add(self.extra_amount_for_high_audience_in_tragedy())
31 
32     def extra_amount_for_high_audience_in_tragedy(self):
33         if self.audience() <= 30:
34             return Amount(0)
35 
36         return Amount(1000 * (self.audience() - 30))
37 
38     def calculate_performance_credits(self, play):
39         return Credits(max(self.audience() - 30, 0)). \
40             add(self.extra_volume_credits_for_comedy(play))
41 
42     def extra_volume_credits_for_comedy(self, play):
43         if "comedy" != play["type"]:
44             return Credits(0)
45 
46         return Credits(math.floor(self.audience() / 5))

Y ahora reemplazamos las llamadas a las funciones por Performance:

Figure 75
 1 from domain.amount import Amount
 2 from domain.credits import Credits
 3 from domain.performance import Performance
 4 from domain.printer import Printer
 5 
 6 
 7 def statement(invoice, plays):
 8     printer = Printer()
 9     invoice_amount = Amount(0)
10     volume_credits = Credits(0)
11     printer.print(f'Statement for {invoice["customer"]}\n')
12 
13     def format_as_dollars(amount):
14         return f"${amount:0,.2f}"
15 
16     def calculate_performance_amount(play, performance):
17         if play['type'] == "tragedy":
18             return performance.calculate_amount_for_tragedy()
19         if play['type'] == "comedy":
20             return performance.calculate_amount_for_comedy()
21 
22         raise ValueError(f'unknown type: {play["type"]}')
23 
24     for perf in invoice['performances']:
25         performance = Performance(perf)
26         play = plays[performance.play_id()]
27         this_amount = calculate_performance_amount(play, performance)
28         performance_credits = performance.calculate_performance_credits\
29 (play)
30 
31         line = f' {play["name"]}: {format_as_dollars(this_amount.curren\
32 t() / 100)} ({performance.audience()} seats)\n'
33         printer.print(line)
34 
35         invoice_amount = invoice_amount.add(this_amount)
36         volume_credits = volume_credits.add(performance_credits)
37 
38     printer.print(f'Amount owed is {format_as_dollars(invoice_amount.cu\
39 rrent() // 100)}\n')
40     printer.print(f'You earned {volume_credits.current()} credits\n')
41 
42     return printer.output()

El panorama se ha ido despejando al introducir objetos que han atraído comportamiento y eso que aún nos queda por traer a colación el objeto Play.

Pero ahora nos fijamos en estas líneas. Hay una falta de simetría que ralla un poco:

Figure 76
1 this_amount = calculate_performance_amount(play, performance)
2 performance_credits = performance.calculate_performance_credits(play)

Está claro que calculate_performance_amount es un comportamiento de Performance, es hora de llevarlo a su lugar. Hacemos exactamente lo mismo. Copiar y adaptar. Luego reemplazar.

Figure 77
 1 import math
 2 
 3 from domain.amount import Amount
 4 from domain.credits import Credits
 5 
 6 
 7 class Performance:
 8     def __init__(self, perf):
 9         self.data = perf
10 
11     def audience(self):
12         return self.data['audience']
13 
14     def play_id(self):
15         return self.data['playID']
16 
17     def calculate_amount_for_comedy(self):
18         return Amount(30000) \
19             .add(self.extra_amount_for_high_audience_in_comedy()) \
20             .add(Amount(300 * self.audience()))
21 
22     def extra_amount_for_high_audience_in_comedy(self):
23         if self.audience() <= 20:
24             return Amount(0)
25 
26         return Amount(10000 + 500 * (self.audience() - 20))
27 
28     def calculate_amount_for_tragedy(self):
29         return Amount(40000) \
30             .add(self.extra_amount_for_high_audience_in_tragedy())
31 
32     def extra_amount_for_high_audience_in_tragedy(self):
33         if self.audience() <= 30:
34             return Amount(0)
35 
36         return Amount(1000 * (self.audience() - 30))
37 
38     def calculate_performance_credits(self, play):
39         return Credits(max(self.audience() - 30, 0)). \
40             add(self.extra_volume_credits_for_comedy(play))
41 
42     def extra_volume_credits_for_comedy(self, play):
43         if "comedy" != play["type"]:
44             return Credits(0)
45 
46         return Credits(math.floor(self.audience() / 5))
47 
48     def calculate_performance_amount(self, play):
49         if play['type'] == "tragedy":
50             return self.calculate_amount_for_tragedy()
51         if play['type'] == "comedy":
52             return self.calculate_amount_for_comedy()
53 
54         raise ValueError(f'unknown type: {play["type"]}')

Un detalle que quiero destacar de Performance es el uso de la auto-encapsulación. Esto consiste en no acceder directamente a las propiedades de una clase, sino a través de métodos que podrían ser privados. De este modo, el resto del código de la clase no tiene que saber nada acerca de su estructura y me da libertad para cambiarla en cualquier momento, como veremos más adelante.

Figure 78
 1 from domain.amount import Amount
 2 from domain.credits import Credits
 3 from domain.performance import Performance
 4 from domain.printer import Printer
 5 
 6 
 7 def statement(invoice, plays):
 8     printer = Printer()
 9     invoice_amount = Amount(0)
10     volume_credits = Credits(0)
11     printer.print(f'Statement for {invoice["customer"]}\n')
12 
13     def format_as_dollars(amount):
14         return f"${amount:0,.2f}"
15 
16     for perf in invoice['performances']:
17         performance = Performance(perf)
18         play = plays[performance.play_id()]
19         this_amount = performance.calculate_performance_amount(play)
20         performance_credits = performance.calculate_performance_credits\
21 (play)
22 
23         line = f' {play["name"]}: {format_as_dollars(this_amount.curren\
24 t() / 100)} ({performance.audience()} seats)\n'
25         printer.print(line)
26 
27         invoice_amount = invoice_amount.add(this_amount)
28         volume_credits = volume_credits.add(performance_credits)
29 
30     printer.print(f'Amount owed is {format_as_dollars(invoice_amount.cu\
31 rrent() // 100)}\n')
32     printer.print(f'You earned {volume_credits.current()} credits\n')
33 
34     return printer.output()

Mejoremos un poco el nombre de las cosas:

Figure 79
 1 from domain.amount import Amount
 2 from domain.credits import Credits
 3 from domain.performance import Performance
 4 from domain.printer import Printer
 5 
 6 
 7 def statement(invoice, plays):
 8     printer = Printer()
 9     invoice_amount = Amount(0)
10     volume_credits = Credits(0)
11     printer.print(f'Statement for {invoice["customer"]}\n')
12 
13     def format_as_dollars(amount):
14         return f"${amount:0,.2f}"
15 
16     for perf in invoice['performances']:
17         performance = Performance(perf)
18         play = plays[performance.play_id()]
19         this_amount = performance.amount(play)
20         performance_credits = performance.credits(play)
21 
22         line = f' {play["name"]}: {format_as_dollars(this_amount.curren\
23 t() / 100)} ({performance.audience()} seats)\n'
24         printer.print(line)
25 
26         invoice_amount = invoice_amount.add(this_amount)
27         volume_credits = volume_credits.add(performance_credits)
28 
29     printer.print(f'Amount owed is {format_as_dollars(invoice_amount.cu\
30 rrent() // 100)}\n')
31     printer.print(f'You earned {volume_credits.current()} credits\n')
32 
33     return printer.output()

Nos queda introducir un objeto para representar una obra, que será Play. Por supuesto, hay una relación estrecha entre Performance y Play pero, de momento, no nos vamos a ocupar de eso. Simplemente, queremos introducir el concepto y luego, ya veremos a dónde nos lleva.

Lo primero que hago es revisar qué cosas necesitamos de Play:

  • name, para crear líneas de concepto en la factura.
  • type, para saber qué tipo de obra es, ya que implica precios diferentes.

Esencialmente, hacemos lo mismo que con Performance. Empezamos simplemente encapsulando la estructura de datos de la manera más simple posible:

Figure 80
1 class Play:
2     def __init__(self, data):
3         self._data = data
4 
5     def name(self):
6         return self._data['name']
7 
8     def type(self):
9         return self._data['type']

Como primer paso, reemplazamos la representación actual por el objeto. Play se usa sobre todo en Performance, pero hay un uso en statement que, de momento, necesitamos tener en cuenta:

Figure 81
 1 from domain.amount import Amount
 2 from domain.credits import Credits
 3 from domain.performance import Performance
 4 from domain.play import Play
 5 from domain.printer import Printer
 6 
 7 
 8 def statement(invoice, plays):
 9     printer = Printer()
10     invoice_amount = Amount(0)
11     volume_credits = Credits(0)
12     printer.print(f'Statement for {invoice["customer"]}\n')
13 
14     def format_as_dollars(amount):
15         return f"${amount:0,.2f}"
16 
17     for perf in invoice['performances']:
18         performance = Performance(perf)
19         play = Play(plays[performance.play_id()])
20         this_amount = performance.amount(play)
21         performance_credits = performance.credits(play)
22 
23         line = f' {play.name()}: {format_as_dollars(this_amount.current\
24 () / 100)} ({performance.audience()} seats)\n'
25         printer.print(line)
26 
27         invoice_amount = invoice_amount.add(this_amount)
28         volume_credits = volume_credits.add(performance_credits)
29 
30     printer.print(f'Amount owed is {format_as_dollars(invoice_amount.cu\
31 rrent() // 100)}\n')
32     printer.print(f'You earned {volume_credits.current()} credits\n')
33 
34     return printer.output()
Figure 82
 1 import math
 2 
 3 from domain.amount import Amount
 4 from domain.credits import Credits
 5 
 6 
 7 class Performance:
 8     def __init__(self, perf):
 9         self.data = perf
10 
11     def audience(self):
12         return self.data['audience']
13 
14     def play_id(self):
15         return self.data['playID']
16 
17     def calculate_amount_for_comedy(self):
18         return Amount(30000) \
19             .add(self.extra_amount_for_high_audience_in_comedy()) \
20             .add(Amount(300 * self.audience()))
21 
22     def extra_amount_for_high_audience_in_comedy(self):
23         if self.audience() <= 20:
24             return Amount(0)
25 
26         return Amount(10000 + 500 * (self.audience() - 20))
27 
28     def calculate_amount_for_tragedy(self):
29         return Amount(40000) \
30             .add(self.extra_amount_for_high_audience_in_tragedy())
31 
32     def extra_amount_for_high_audience_in_tragedy(self):
33         if self.audience() <= 30:
34             return Amount(0)
35 
36         return Amount(1000 * (self.audience() - 30))
37 
38     def credits(self, play):
39         return Credits(max(self.audience() - 30, 0)). \
40             add(self.extra_volume_credits_for_comedy(play))
41 
42     def extra_volume_credits_for_comedy(self, play):
43         if "comedy" != play.type():
44             return Credits(0)
45 
46         return Credits(math.floor(self.audience() / 5))
47 
48     def amount(self, play):
49         if play.type() == "tragedy":
50             return self.calculate_amount_for_tragedy()
51         if play.type() == "comedy":
52             return self.calculate_amount_for_comedy()
53 
54         raise ValueError(f'unknown type: {play.type()}')

Esta es únicamente un primer paso. Dentro de un momento, veremos algunas ideas para proseguir con el refactor basándonos en las oportunidades que nos proporciona haber introducido objetos.

Por qué funciona

La regla de encapsular todos los primitivos en objetos funciona porque, de entrada, nos ayuda a separar responsabilidades entre los diversos conceptos que participan en el programa. Además, contribuye a ocultar algunos detalles de implementación, haciendo más fácil entender qué está pasando.

Los objetos nos permiten encapsular reglas de negocio y aislar los detalles de implementación entre las distintas partes del código. Esto ayuda, además, en que esas mismas partes puedan evolucionar de forma independiente, sin afectar al funcionamiento del conjunto del programa. Ninguna parte del programa necesita saber, por ejemplo, los detalles estructurales de Performance o Play. Simplemente, les pasan mensajes para que proporcionen la información solicitada. La forma en que se calcula no es importante para el objeto que envía el mensaje, pero igualmente la obtiene.

A medida que hemos ido introduciendo objetos, hemos podido reducir el tamaño de la función statement y que su código sea mucho más expresivo. Por supuesto, es mejorable, pero ahora no están mezclados la mayor parte de detalles. En conjunto, hay mucha más cantidad de código, pero es mucho más legible y fácil de mantener.

Esto ocurre porque los objetos funcionan como atractores de comportamiento. Una vez que descubrimos un objeto que participa en el programa, resulta fácil asignarle responsabilidades y extraerlas del código inicial. Por otro lado, los objetos nos ayudan a garantizar que los datos que encapsulan cumplen las reglas de dominio requeridas. No necesitamos verificarlo constantemente.

Más allá

Agregación de objetos

Al introducir objetos se va clarificando el escenario del programa y las relaciones entre los distintos conceptos. En nuestro ejercicio, por ejemplo, se aprecia muy bien que Play es un elemento de Performance y, salvo por conocer el nombre de la obra para poder imprimir la factura, la función statement no necesita saber ni que existe.

Así que podemos transformar Performance para usar Play. Sin embargo, antes nos vendría bien cambiar el modo en que Performance guarda su información. Es ahora cuando se pueden apreciar los beneficios de la auto-encapsulación. Solo tengo que cambiar unas pocas líneas:

Figure 83
 1 class Performance:
 2     def __init__(self, perf):
 3         self._audience = perf['audience']
 4         self._play_id = perf['playID']
 5 
 6     def audience(self):
 7         return self._audience
 8 
 9     def play_id(self):
10         return self._play_id
11 
12     # ...

De esta forma, es más fácil añadir una nueva propiedad:

Figure 84
 1 class Performance:
 2     def __init__(self, perf, plays):
 3         self._audience = perf['audience']
 4         self._play_id = perf['playID']
 5         self._play = Play(plays[perf['playID']])
 6         
 7     def audience(self):
 8         return self._audience
 9 
10     def play_id(self):
11         return self._play_id
12 
13     def play(self):
14         return self._play

Y dar soporte al cambio en la instanciación, así como en el único uso directo que hace statement de Play.

Figure 85
 1 for perf in invoice['performances']:
 2     performance = Performance(perf, plays)
 3     play = Play(plays[performance.play_id()])
 4     this_amount = performance.amount(play)
 5     performance_credits = performance.credits(play)
 6 
 7     line = f' {performance.play().name()}: {format_as_dollars(this_amou\
 8 nt.current() / 100)} ({performance.audience()} seats)\n'
 9     printer.print(line)
10 
11     invoice_amount = invoice_amount.add(this_amount)
12     volume_credits = volume_credits.add(performance_credits)

Nos queda eliminar el paso de Play a los métodos amount y credits. Pero será bastante fácil:

Figure 86
 1 import math
 2 
 3 from domain.amount import Amount
 4 from domain.credits import Credits
 5 from domain.play import Play
 6 
 7 
 8 class Performance:
 9     def __init__(self, perf, plays):
10         self._audience = perf['audience']
11         self._play_id = perf['playID']
12         self._play = Play(plays[perf['playID']])
13 
14     def audience(self):
15         return self._audience
16 
17     def play_id(self):
18         return self._play_id
19 
20     def play(self):
21         return self._play
22 
23     def calculate_amount_for_comedy(self):
24         return Amount(30000) \
25             .add(self.extra_amount_for_high_audience_in_comedy()) \
26             .add(Amount(300 * self.audience()))
27 
28     def extra_amount_for_high_audience_in_comedy(self):
29         if self.audience() <= 20:
30             return Amount(0)
31 
32         return Amount(10000 + 500 * (self.audience() - 20))
33 
34     def calculate_amount_for_tragedy(self):
35         return Amount(40000) \
36             .add(self.extra_amount_for_high_audience_in_tragedy())
37 
38     def extra_amount_for_high_audience_in_tragedy(self):
39         if self.audience() <= 30:
40             return Amount(0)
41 
42         return Amount(1000 * (self.audience() - 30))
43 
44     def credits(self, play):
45         return Credits(max(self.audience() - 30, 0)). \
46             add(self.extra_volume_credits_for_comedy(self.play()))
47 
48     def extra_volume_credits_for_comedy(self, play):
49         if "comedy" != self.play().type():
50             return Credits(0)
51 
52         return Credits(math.floor(self.audience() / 5))
53 
54     def amount(self):
55         if self.play().type() == "tragedy":
56             return self.calculate_amount_for_tragedy()
57         if self.play().type() == "comedy":
58             return self.calculate_amount_for_comedy()
59 
60         raise ValueError(f'unknown type: {self.play().type()}')

Y tras eso, eliminar el parámetro innecesario:

Figure 87
 1 from domain.amount import Amount
 2 from domain.credits import Credits
 3 from domain.performance import Performance
 4 from domain.printer import Printer
 5 
 6 
 7 def statement(invoice, plays):
 8     printer = Printer()
 9     invoice_amount = Amount(0)
10     volume_credits = Credits(0)
11     printer.print(f'Statement for {invoice["customer"]}\n')
12 
13     def format_as_dollars(amount):
14         return f"${amount:0,.2f}"
15 
16     for perf in invoice['performances']:
17         performance = Performance(perf, plays)
18         this_amount = performance.amount()
19         performance_credits = performance.credits()
20 
21         line = f' {performance.play().name()}: {format_as_dollars(this_\
22 amount.current() / 100)} ({performance.audience()} seats)\n'
23         printer.print(line)
24 
25         invoice_amount = invoice_amount.add(this_amount)
26         volume_credits = volume_credits.add(performance_credits)
27 
28     printer.print(f'Amount owed is {format_as_dollars(invoice_amount.cu\
29 rrent() // 100)}\n')
30     printer.print(f'You earned {volume_credits.current()} credits\n')
31 
32     return printer.output()

Sí, lo sé. Se pueden ver algunas cosillas cuestionables todavía. Vamos a seguir permitiendo que sean las reglas de Calisthenics las que nos guíen en el proceso y veremos si se arreglan o no.

El resultado

El código ha evolucionado muchísimo tras aplicar la regla de encapsular primitivos en objetos. Sin embargo, todavía nos quedan algunos por atacar. Particularmente invoice y plays, pero los dejaremos para la próxima regla que nos pide hacer encapsular colecciones.

Si observamos el código desde el punto de vista de refactoring está claro que aún nos queda mucho trabajo por hacer y algunos smells son evidentes y no están siendo tratados. Esto tienen un motivo en el contexto de estos capítulos y no es otro que queremos ver si aplicar las reglas de forma sistemática nos conduce eventualmente a un mejor diseño. Hasta ahora creo que puede decirse que sí, con algunas salvedades, pero también es cierto que estamos aplicando cada regla una por una. En otras circunstancias estaríamos usando las reglas allí donde se viesen aplicables sin importar el orden.

En cualquier caso, en este momento podemos observar algunos efectos positivos, ya que las responsabilidades se han ido distribuyendo en objetos y funciones.

Encapsular colecciones

Se trata de encapsular en un objeto toda estructura de datos que represente una colección de tal manera que la única propiedad de este objeto sea esa misma estructura, con los métodos que necesitemos para tener acceso a los datos. Y en el fondo no es más que una extensión de la regla anterior. Unificadas ambas, podríamos decir que cualquier estructura de datos nativa del lenguaje debería ser encapsulada, da igual lo simple (primitivos) o compleja que sea (colecciones).

El motivo es aislarte de la estructura de datos de tal forma que el resto del programa no esté acoplado a la misma. Esto nos permite cambiar la estructura sin tener que tocar el resto del código cuando tengamos alguna razón para ello.

En el ejemplo que estamos usando en esta serie tenemos un par de buenos casos: plays y performances, dentro de invoice.

Colección con acceso por clave

Este es el caso de plays. Accedemos a un elemento de esta colección dada una clave, que en este caso es el ID de la obra. La responsabilidad de plays en este sistema es actuar como una especie de catálogo en el que consultar las obras que la compañía puede representar. Simplemente, necesitamos un método get_by_id, que nos devuelva la obra solicitada.

Figure 88
1 class Plays:
2     def __init__(self, data):
3         self._data = data
4 
5     def get_by_id(self, play_id):
6         return Play(self._data[play_id])

Únicamente tenemos un uso y es fácil reemplazarlo:

Figure 89
1 # ...
2 for perf in invoice['performances']:
3     performance = Performance(perf, Plays(plays))
4     this_amount = performance.amount()
5     performance_credits = performance.credits()
6 # ...
Figure 90
 1 import math
 2 
 3 from domain.amount import Amount
 4 from domain.credits import Credits
 5 
 6 
 7 class Performance:
 8     def __init__(self, perf, plays):
 9         self._audience = perf['audience']
10         self._play_id = perf['playID']
11         self._play = plays.get_by_id(self._play_id)
12 
13 # ...

Fíjate que no se trata de refactorizar la estructura en sí y cambiarla por otra que pueda ser más eficiente o apropiada. Se trata simplemente de no usar directamente ninguna estructura nativa, como si fuese una dependencia de terceros a la que no queremos acoplarnos.

Recuerda también aplicar YAGNI (no lo vas a necesitar), e introduce solo los métodos que tu código necesite para funcionar.

Colección iterable

La única diferencia significativa entre el caso anterior y este, en el que vamos a encapsular la colección de performances, es que queremos poder iterar los elementos de esta colección, ya sea mediante un bucle for como el que tenemos en el ejemplo, ya sea mediante otro enfoque.

En Python podemos hacer iterable una clase definiendo el método __iter__ para que devuelva una clase iteradora, la cual debe contener el método __next__:

Figure 91
 1 class Performances:
 2     def __init__(self, data, plays):
 3         self._data = data
 4         self._plays = plays
 5 
 6     def __iter__(self):
 7         return PerformancesIterator(self)
 8 
 9     def by_index(self, index):
10         return Performance(self._data[index], self._plays)
11 
12     def size(self):
13         return len(self._data)
14 
15 
16 class PerformancesIterator:
17     def __init__(self, performances):
18         self._performances = performances
19         self._current = 0
20 
21     def __next__(self):
22         if self._current >= self._performances.size():
23             raise StopIteration
24 
25         result = self._performances.by_index(self._current)
26         self._current += 1
27         return result

En el cuerpo de statement hacemos de esta manera:

Figure 92
 1 from domain.amount import Amount
 2 from domain.credits import Credits
 3 from domain.performance import Performances
 4 from domain.play import Plays
 5 from domain.printer import Printer
 6 
 7 
 8 def statement(invoice, plays):
 9     printer = Printer()
10     invoice_amount = Amount(0)
11     volume_credits = Credits(0)
12     performances = Performances(invoice['performances'], Plays(plays))
13 
14     printer.print(f'Statement for {invoice["customer"]}\n')
15 
16     def format_as_dollars(amount):
17         return f"${amount:0,.2f}"
18 
19     for performance in performances:
20         this_amount = performance.amount()
21         performance_credits = performance.credits()
22 
23         line = f' {performance.play().name()}: {format_as_dollars(this_\
24 amount.current() / 100)} ({performance.audience()} seats)\n'
25         printer.print(line)
26 
27         invoice_amount = invoice_amount.add(this_amount)
28         volume_credits = volume_credits.add(performance_credits)
29 
30     printer.print(f'Amount owed is {format_as_dollars(invoice_amount.cu\
31 rrent() // 100)}\n')
32     printer.print(f'You earned {volume_credits.current()} credits\n')
33 
34     return printer.output()

Este cambio ha sido un poco más elaborado y ha conllevado algunas modificaciones interesantes. Por ejemplo, la instanciación de Performance ocurre dentro de Performances, así que statement ya no necesita conocer cómo se construye un objeto Performance.Luego profundizaré en algunas consecuencias de esto.

Como he mencionado antes, lo único que hemos hecho ha sido mover la estructura de datos original (un diccionario) dentro de una nueva clase. De esta forma, el código de statement, no conoce los detalles de implementación de Performances (o de Plays) pero sigue pudiendo acceder a la información que necesita. En el futuro podríamos cambiar esto sin necesidad de afectar a statement, lo que es una ventaja importante.

Por eso, aunque ahora mismo el código dentro de Performances nos parezca menos que bueno, podremos cambiarlo en cualquier momento sin miedo de romper cosas en múltiples lugares. Los cambios ocurrirán únicamente en un sitio (dentro de Performance), maximizando la mantenibilidad y minimizando la dispersión de los errores potenciales.

Por qué funciona

Al igual que ocurre con la regla anterior, encapsular colecciones nos permite desacoplarnos de la estructura nativa de datos. Esto es una gran ventaja porque nos aporta libertad a la hora de cambiar esta estructura y la gestión de los datos en ella.

Además, este tipo de cambios suele generar algunas ventajas más. Las estructuras nativas están diseñadas para cubrir numerosos casos de uso, por lo que son genéricas y pueden incluir métodos que no vamos a necesitar o que introducen confusión a la hora de utilizarlos. Al encapsular en una clase, podemos definir cómo queremos que el resto del programa interactúe con ella de forma inequívoca, usando incluso un lenguaje apropiado a nuestro dominio.

Por otro lado, estos procesos de encapsulación ayudan a descubrir y modelar mejor relaciones entre conceptos, sugiriendo dónde deben ir las distintas responsabilidades.

Más allá

A medida que aplicamos las reglas de Object Calisthenics el código no solo va tomando mejor forma, sino que también desvela áreas que pueden mejorar.

Esto ocurre porque, en general, las reglas nos fuerzan a organizar mejor el código. No arreglan los problemas, pero contribuyen a despejar el paisaje de una forma parecida a lo que ocurre cuando, por ejemplo, organizamos las piezas de un puzzle por colores o texturas antes de empezar. Se podría decir, que gracias a esta manera de trabajar conseguimos dividir un problema grande en partes manejables.

Así, por ejemplo, tras el último cambio podemos ver que invoice es la última estructura de datos nativa que nos queda por arreglar. Pero también vemos que podríamos mejorar cosas en la forma en que instanciamos Performance.

Encapsular estructuras de datos

Como he mencionado más arriba, tanto la regla “Encapsular colecciones” como la del capítulo sobre “Encapsular primitivos” son dos caras de una misma moneda: encapsular cualquier estructura de datos nativa. Esto es, cualquier concepto que aparece en nuestro dominio debería ser representado por un objeto que se puede implementar usando la estructura de datos que más nos convenga, pero sin que el resto del código tenga que saber qué estructura en concreto estamos usando.

En este ejercicio he dejado invoice para el final para analizarlo con calma. En principio, un objeto Invoice nos debería proporcionar el nombre del cliente (para imprimir la factura) y la lista de actuaciones.

Figure 93
1 class Invoice:
2     def __init__(self, data):
3         self._data = data
4 
5     def customer(self):
6         return self._data['customer']
7 
8     def performances(self):
9         return self._data['performances']

Y reemplazar sus usos en el código resultaría trivial:

Figure 94
 1 def statement(invoice, plays):
 2     printer = Printer()
 3     invoice_amount = Amount(0)
 4     volume_credits = Credits(0)
 5     inv = Invoice(invoice)
 6 
 7     performances = Performances(inv.performances(), Plays(plays))
 8 
 9     printer.print(f'Statement for {inv.customer()}\n')
10 
11     # ...

De entrada, es fácil ver que Invoice nos pide más responsabilidades. Por ejemplo, la instanciación de Performances debería ocurrir en Invoice. Podríamos hacerlo así:

Figure 95
 1 from domain.performance import Performances
 2 from domain.play import Plays
 3 
 4 
 5 class Invoice:
 6     def __init__(self, data, plays):
 7         self._data = data
 8         self._customer = data['customer']
 9         self._performances = Performances(data['performances'], Plays(p\
10 lays))
11 
12     def customer(self):
13         return self._customer
14 
15     def performances(self):
16         return self._performances

Y usarlo de esta manera:

Figure 96
 1 from domain.amount import Amount
 2 from domain.credits import Credits
 3 from domain.invoice import Invoice
 4 from domain.printer import Printer
 5 
 6 
 7 def statement(invoice, plays):
 8     printer = Printer()
 9     invoice_amount = Amount(0)
10     volume_credits = Credits(0)
11     inv = Invoice(invoice, plays)
12 
13     printer.print(f'Statement for {inv.customer()}\n')
14 
15     def format_as_dollars(amount):
16         return f"${amount:0,.2f}"
17 
18     for performance in inv.performances():
19         this_amount = performance.amount()
20         performance_credits = performance.credits()
21 
22         line = f' {performance.play().name()}: {format_as_dollars(this_\
23 amount.current() / 100)} ({performance.audience()} seats)\n'
24         printer.print(line)
25 
26         invoice_amount = invoice_amount.add(this_amount)
27         volume_credits = volume_credits.add(performance_credits)
28 
29     printer.print(f'Amount owed is {format_as_dollars(invoice_amount.cu\
30 rrent() // 100)}\n')
31     printer.print(f'You earned {volume_credits.current()} credits\n')
32 
33     return printer.output()

Ahora está claro que la lógica para calcular invoice_amount y volume_credits está reclamando fuertemente formar parte de Invoice, cosa que tiene su complicación dada la forma en que se imprime la factura. Ya llegaremos a esto, pero ahora se ve claramente que hay dos responsabilidades diferentes: el cálculo de las líneas y totales de la factura y la impresión de las mismas. Nuestro problema es que ahora aparecen entrelazadas.

¿Hay algo que podamos hacer aquí? Una posibilidad es eliminar variables temporales, lo que reduce bastante el ruido, aclarando algunas cosas, pero ensuciando otras.

Figure 97
 1 from domain.amount import Amount
 2 from domain.credits import Credits
 3 from domain.invoice import Invoice
 4 from domain.printer import Printer
 5 
 6 
 7 def statement(invoice, plays):
 8     printer = Printer()
 9     invoice_amount = Amount(0)
10     volume_credits = Credits(0)
11     inv = Invoice(invoice, plays)
12 
13     printer.print(f'Statement for {inv.customer()}\n')
14 
15     def format_as_dollars(amount):
16         return f"${amount:0,.2f}"
17 
18     for performance in inv.performances():
19         line = f' {performance.play().name()}: {format_as_dollars(perfo\
20 rmance.amount().current() / 100)} ({performance.audience()} seats)\n'
21         printer.print(line)
22 
23         invoice_amount = invoice_amount.add(performance.amount())
24         volume_credits = volume_credits.add(performance.credits())
25 
26     printer.print(f'Amount owed is {format_as_dollars(invoice_amount.cu\
27 rrent() // 100)}\n')
28     printer.print(f'You earned {volume_credits.current()} credits\n')
29 
30     return printer.output()

Evolución interna de los objetos

Hemos dicho que al encapsular estructuras de datos en objetos, la evolución interna de estos se hace de forma transparente para el resto del código. Esto nos permite hacer cambios sin romper funcionalidades, especialmente si estamos protegidas por tests.

Vamos a ver unos ejemplos.

Tras la transformación anterior, alguien podría argumentar que llamamos dos veces a performance.amount(), lo que podría tener consecuencias en, ejem, performance.

Figure 98
 1 # ...
 2 for performance in inv.performances():
 3     line = f' {performance.play().name()}: {format_as_dollars(performan\
 4 ce.amount().current() / 100)} ({performance.audience()} seats)\n'
 5     printer.print(line)
 6 
 7     invoice_amount = invoice_amount.add(performance.amount())
 8     volume_credits = volume_credits.add(performance.credits())
 9 
10 # ...

Si esto te supone mucho problema, un patrón memoization podría ayudar. Básicamente, se trata de mantener una cache del cálculo, de la cual el código que llaman no tiene que saber ni que existe. Por ejemplo, esta implementación bastante ingenua:

Figure 99
 1 import math
 2 
 3 from domain.amount import Amount
 4 from domain.credits import Credits
 5 
 6 
 7 class Performance:
 8     def __init__(self, perf, plays):
 9         self._audience = perf['audience']
10         self._play_id = perf['playID']
11         self._play = plays.get_by_id(self._play_id)
12         self._amount = None
13 
14     # ...
15 
16     def amount(self):
17         if self._amount is not None:
18             return self._amount
19 
20         if self.play().type() == "tragedy":
21             tragedy = self.calculate_amount_for_tragedy()
22             self._amount = tragedy
23             return tragedy
24         if self.play().type() == "comedy":
25             comedy = self.calculate_amount_for_comedy()
26             self._amount = comedy
27             return comedy
28 
29         raise ValueError(f'unknown type: {self.play().type()}')

Alternativamente, podrías utilizar esta clase memoize de Graham Jenson, con lo que te bastaría decorar el método amount con un @memoize.

Como puedes ver, al tener objetos con responsabilidades bien definidas y un contrato claro con sus usuarios, introducir mejoras es muchísimo más fácil y seguro.

Otro asunto interesante es que cuando instanciamos Performance, seguimos pasando la colección completas de obras. Pero no tenemos por qué hacerlo así, ya que ahora es más fácil montar Performance con la obra (Play) que le corresponde. Este es el código que tenemos ahora:

Figure 100
 1 class Performances:
 2     def __init__(self, data, plays):
 3         self._data = data
 4         self._plays = plays
 5 
 6     def __iter__(self):
 7         return PerformancesIterator(self)
 8 
 9     def by_index(self, index):
10         return Performance(self._data[index], self._plays)
11 
12     def size(self):
13         return len(self._data)

Y este el cambio que proponemos:

Figure 101
 1 class Performances:
 2     def __init__(self, data, plays):
 3         self._data = data
 4         self._plays = plays
 5 
 6     def __iter__(self):
 7         return PerformancesIterator(self)
 8 
 9     def by_index(self, index):
10         return Performance(self._data[index], self._plays.get_by_id(sel\
11 f._data[index]['playID']))
12 
13     def size(self):
14         return len(self._data)

Mientras que Performance podría quedar así:

Figure 102
1 class Performance:
2     def __init__(self, perf, play):
3         self._audience = perf['audience']
4         self._play = play
5         self._amount = None
6 
7     # ...

Pero entonces resulta que podemos tener un constructor mucho más natural:

Figure 103
1 class Performance:
2     def __init__(self, audience, play):
3         self._audience = audience
4         self._play = play
5         self._amount = None

Y usarlo de esta otra forma:

Figure 104
1     def by_index(self, index):
2         play = self._data[index]
3         return Performance(play['audience'], self._plays.get_by_id(play\
4 ['playID']))

Resultado

Object Calisthenics nos está ayudando a despejar el diseño del código, identificando objetos y repartiendo responsabilidades. Gracias a ello tenemos un código que, aunque es más grande, está organizado en objetos cada vez más especializados en sus tareas, de modo que la comprensión del sistema es mejor, a la vez que se hace más mantenible y, como acabamos de ver, incluso más fácil de optimizar.

Un punto por línea

Esta regla nos pide no encadenar llamadas a objetos proporcionados por otros objetos, de tal forma que solo tengamos un punto (una flecha en PHP) en cada línea de código. Puede parecer fácil de aplicar, pero vamos a poder identificar varias situaciones en las que la regla no es relevante, así como diferentes soluciones cuando sí lo es.

Las interfaces fluidas son correctas

Las interfaces fluidas no se ven afectadas por esta regla. Las interfaces fluidas devuelven el mismo objeto al que se pasa el mensaje, por lo que podemos seguir enviándole mensajes sin límite, lo que parece una oportunidad de aplicar la regla. Pero no lo es. En todo caso, es cierto que poner un punto por línea mejora mucha la legibilidad. El objetivo, y ventaja, de la interfaz fluida es poder enviar varios mensajes a un mismo objeto en un orden dado y que, además, se pueda entender como una operación unitaria.

En nuestro código, hacemos algo así con Amount, aunque cada vez se devuelva una instancia distinta es semánticamente el mismo objeto:

Figure 105
1 def calculate_amount_for_comedy(self):
2     return Amount(30000) \
3         .add(self.extra_amount_for_high_audience_in_comedy()) \
4         .add(Amount(300 * self.audience()))

Filtración de propiedades

Fijémonos ahora en esta línea:

Figure 106
1 line = f' {performance.play().name()}: {format_as_dollars(performance.a\
2 mount().current() / 100)} ({performance.audience()} seats)\n'

Para saber el título de la actuación, tenemos que pedirle a Performance la obra y obtener su título. De este modo, se revela un detalle de implementación de Performance que el resto del código no tiene por qué conocer. El comportamiento que se quiere de Performance es que sea capaz de decirnos el título de la obra que se representa, da igual si lo tiene guardado, si le pregunta a Play o si tiene alguna otra forma de obtenerlo o construirlo.

Por eso, una forma más adecuada sería:

Figure 107
1 line = f' {performance.title()}: {format_as_dollars(performance.amount(\
2 ).current() / 100)} ({performance.audience()} seats)\n'

De tal forma que ahora el mundo exterior no tiene ningún detalle sobre cómo hace Performance para proporcionar el título de la obra representada:

Figure 108
 1 class Performance:
 2     def __init__(self, audience, play):
 3         self._audience = audience
 4         self._play = play
 5         self._amount = None
 6 
 7     def audience(self):
 8         return self._audience
 9 
10     def play(self):
11         return self._play
12 
13     def title(self):
14         return self._play.name()

De este modo, el resto del código reduce su acoplamiento de Performance y esta puede modificar la forma en que obtiene el título sin afectar a sus consumidores.

Con todo, en este caso concreto podría haber otras soluciones, pero no voy a tratarlas en este momento, ya que me estoy limitando a aplicar las reglas de Calisthenics. Pero, en cualquier caso, creo que se ve muy bien cómo aplicar una regla va desvelando mejores soluciones, pero también problemas de diseño más profundos que requieren soluciones más elaboradas. Es decir: intentar aplicar la regla nos lleva a pensar más a fondo en ciertas decisiones de diseño.

En este caso, la solución es aceptable porque tiene sentido que Performance tenga como una de sus responsabilidades saber el nombre de la obra representada.

Más filtración de conocimiento

Veamos este fragmento:

Figure 109
1 performance.amount().current()

Aquí tenemos un problema aparentemente similar. El método Performance.amount() nos devuelve un objeto y statement invoca un método en ese objeto devuelto. ¿Podemos aplicar la misma solución que antes?

Aparentemente sí, añadiendo a Performance un método que nos proporcione ese valor, algo así como:

Figure 110
 1 class Performance:
 2     def __init__(self, audience, play):
 3         self._audience = audience
 4         self._play = play
 5         self._amount = None
 6 
 7 # ...
 8 
 9     def amount_value(self):
10         return self.amount().current()
11 
12 # ...

Si lo pensamos un poco a fondo, veremos que no es nada correcto. Y eso es porque, de hecho, el método Amount.current() no debería existir, ya que en realidad expone una propiedad del objeto Amount. El método existe porque necesitamos obtener el primitivo contenido en el objeto. En otras palabras: intentar aplicar esta regla va más allá de simplemente encapsular el código en un nuevo método. Debería hacernos reflexionar sobre el diseño.

Una mejor solución es delegar y pasar el objeto a alguien que sepa comunicarse con él, Con todo, todavía presenta problemas, pero los tendremos que examinar en otro momento:

Figure 111
 1     def format_as_dollars(amount):
 2         return f"${amount:0,.2f}"
 3 
 4     def format_line(title, audience, amount):
 5         return f' {title}: {format_as_dollars(amount.current() / 100)} \
 6 ({audience} seats)\n'
 7 
 8     for performance in inv.performances():
 9         line = format_line(performance.title(), performance.audience(),\
10  performance.amount())
11         printer.print(line)
12         invoice_amount = invoice_amount.add(performance.amount())
13         volume_credits = volume_credits.add(performance.credits())

Un caso muy sutil

¿Notas algo problemático aquí?

Figure 112
1     for performance in inv.performances():
2         printer.print(formatted_line(performance.title(), performance.a\
3 udience(), performance.amount()))
4         invoice_amount = invoice_amount.add(performance.amount())
5         volume_credits = volume_credits.add(performance.credits())

Pues es un caso muy sutil de violación de esta regla. statement recibe objetos Performance que no tendría que conocer. Es una situación similar a la que acabamos de describir en el apartado anterior.

Podríamos abordarla así, pero los problemas son evidentes.

Figure 113
 1 def process_performance(performance, invoice_amount, volume_credits, pr\
 2 inter):
 3     printer.print(formatted_line(performance.title(), performance.audie\
 4 nce(), performance.amount()))
 5     invoice_amount = invoice_amount.add(performance.amount())
 6     volume_credits = volume_credits.add(performance.credits())
 7     return invoice_amount, volume_credits
 8 
 9 for performance in invoice.performances():
10     invoice_amount, volume_credits = process_performance(performance, i\
11 nvoice_amount, volume_credits, printer)

Tenemos que pasar variables que serán retornadas, aparte del objeto Performance. Y para completarlo, el nuevo método devuelve dos valores.

Hay varias razones por las que está pasando esto. Por un lado, el hecho de Invoice sea, por el momento, un objeto muy anémico, ya que debería ser responsable de calcular tanto el importe total como los créditos. Por otra parte, en el bucle están pasando varias cosas: se calculan los importes parciales, se van acumulando los dos totales y además se envían las líneas para imprimir.

Nos conviene separar las responsabilidades. Primer paso:

Figure 114
1     for performance in invoice.performances():
2         invoice_amount = invoice_amount.add(performance.amount())
3 
4     for performance in invoice.performances():
5         volume_credits = volume_credits.add(performance.credits())
6 
7     for performance in invoice.performances():
8         printer.print(formatted_line(performance.title(), performance.a\
9 udience(), performance.amount()))

Segundo paso. Pongamos juntas las cosas relacionadas:

Figure 115
 1     invoice_amount = Amount(0)
 2     for performance in invoice.performances():
 3         invoice_amount = invoice_amount.add(performance.amount())
 4 
 5     volume_credits = Credits(0)
 6     for performance in invoice.performances():
 7         volume_credits = volume_credits.add(performance.credits())
 8 
 9     printer.print(f'Statement for {invoice.customer()}\n')
10     for performance in invoice.performances():
11         printer.print(formatted_line(performance.title(), performance.a\
12 udience(), performance.amount()))
13     printer.print(f'Amount owed is {format_as_dollars(invoice_amount.cu\
14 rrent() // 100)}\n')
15     printer.print(f'You earned {volume_credits.current()} credits\n')

Se debería ver claro que esta lógica pertenece a Invoice y la podríamos pasar sin mucha dificultad.

Figure 116
 1 from domain.amount import Amount
 2 from domain.credits import Credits
 3 from domain.performance import Performances
 4 from domain.play import Plays
 5 
 6 
 7 class Invoice:
 8     def __init__(self, data, plays):
 9         self._data = data
10         self._customer = data['customer']
11         self._performances = Performances(data['performances'], Plays(p\
12 lays))
13 
14     def customer(self):
15         return self._customer
16 
17     def performances(self):
18         return self._performances
19 
20     def amount(self):
21         invoice_amount = Amount(0)
22         for performance in self.performances():
23             invoice_amount = invoice_amount.add(performance.amount())
24 
25         return invoice_amount
26 
27     def credits(self):
28         volume_credits = Credits(0)
29         for performance in self.performances():
30             volume_credits = volume_credits.add(performance.credits())
31 
32         return volume_credits

Y así quedaría statement, después de limpiar un poco el código.

Figure 117
 1 from domain.invoice import Invoice
 2 from domain.printer import Printer
 3 
 4 
 5 def statement(invoice_data, plays):
 6     def formatted_line(title, audience, amount):
 7         return f' {title}: {format_as_dollars(amount.current() / 100)} \
 8 ({audience} seats)\n'
 9 
10     def format_as_dollars(amount):
11         return f"${amount:0,.2f}"
12 
13     printer = Printer()
14 
15     invoice = Invoice(invoice_data, plays)
16 
17     printer.print(f'Statement for {invoice.customer()}\n')
18     for performance in invoice.performances():
19         printer.print(formatted_line(performance.title(), performance.a\
20 udience(), performance.amount()))
21     printer.print(f'Amount owed is {format_as_dollars(invoice.amount().\
22 current() // 100)}\n')
23     printer.print(f'You earned {invoice.credits().current()} credits\n')
24 
25     return printer.output()

Por qué funciona

Esta es una regla que nos remite al Principio de Mínimo Conocimiento o Ley de Demeter y su objetivo es evitar acoplarnos a detalles internos de otros objetos. Nos fuerza a considerar los objetos como cajas negras con las que nos podemos comunicar, pero no saber cómo funcionan por dentro.

Cuando un objeto usa otro lo hace a través de su interfaz pública. La interfaz pública define los mensajes que un objeto puede recibir y las respuestas que puede devolver. Este es el máximo de conocimiento que un objeto debería tener sobre otro para minimizar el acoplamiento. Todo conocimiento a mayores incrementa el acoplamiento. Ese conocimiento incluye saber cómo comunicarse con objetos que son devueltos. La acción del consumidor debería limitarse a pasar ese objeto para que sea empleado en otro sitio.

En general, que haya puntos del código en que aplicar esta regla nos revela errores de diseño. Le estamos pidiendo a objetos comportamientos que no les corresponden, usando un conocimiento íntimo de su estructura.

El resultado

Por un lado, esta regla nos ayuda a mover responsabilidades a su lugar adecuado. Pero también suele destapar problemas que requieren reconsiderar nuestro diseño. No basta con introducir un método para ocultar una llamada encadenada.

Por eso, el resultado en este momento resulta un poco insatisfactorio. Tendremos que esperar a las reglas restantes para alcanzar mejores soluciones.

Figure 118
 1 from domain.invoice import Invoice
 2 from domain.printer import Printer
 3 
 4 
 5 def statement(invoice_data, plays):
 6     def formatted_line(title, audience, amount):
 7         return f' {title}: {format_as_dollars(amount.current() / 100)} \
 8 ({audience} seats)\n'
 9 
10     def format_as_dollars(amount):
11         return f"${amount:0,.2f}"
12 
13     printer = Printer()
14 
15     invoice = Invoice(invoice_data, plays)
16 
17     printer.print(f'Statement for {invoice.customer()}\n')
18     for performance in invoice.performances():
19         printer.print(formatted_line(performance.title(), performance.a\
20 udience(), performance.amount()))
21     printer.print(f'Amount owed is {format_as_dollars(invoice.amount().\
22 current() // 100)}\n')
23     printer.print(f'You earned {invoice.credits().current()} credits\n')
24 
25     return printer.output()

No usar abreviaturas

Este capítulo debería ser bastante breve, ya que no hay muchos casos en nuestro ejercicio de ejemplo. No obstante, forzaremos algunos ejemplos para ver los problemas del uso de abreviaturas.

La regla nos pide no usar abreviaturas para nombrar variables, objetos o funciones. El objetivo, por supuesto, es que el código sea lo más autoexplicativo posible. Si nos encontramos una abreviatura puede ocurrir que no conozcamos la referencia, puede que sea ambigua y no pueda entender bien el significado ni siquiera por el contexto, o puede ser simplemente confusa.

Abreviatura por conflicto de nombres

Aquí tenemos un ejemplo de uso de una abreviatura. En este caso para no generar un conflicto de nombres entre el parámetro que pasa los datos y la variable que contiene el objeto Invoice. Este tipo de atajos vienen de no tomar suficiente tiempo para pensar un nombre adecuado.

Figure 119
1 def statement(invoice, plays):
2     printer = Printer()
3     invoice_amount = Amount(0)
4     volume_credits = Credits(0)
5     inv = Invoice(invoice, plays)
6 
7     printer.print(f'Statement for {inv.customer()}\n')

La pregunta es, ¿quién de los dos tiene el derecho a llamarse propiamente invoice? A medida que hemos ido aplicando reglas e introduciendo objetos, también necesitamos cambiar nombres. Al principio, invoice designaba una estructura de datos que representaba una factura. Sin embargo, al introducir el objeto Invoice, el parámetro pasa a ser un simple transporte de datos.

Por esa razón, realmente tiene más sentido hacer algunos cambios en los nombres. Esta es una posible solución:

Figure 120
1 def statement(invoice_data, plays):
2     printer = Printer()
3     invoice_amount = Amount(0)
4     volume_credits = Credits(0)
5     invoice = Invoice(invoice_data, plays)
6 
7     printer.print(f'Statement for {invoice.customer()}\n')

Lo que nos dice un nombre

La abreviatura inv puede ser confusa si no tenemos contexto. Por ejemplo, es habitual que signifique inverso, así que siempre es preferible poner nombres completos, significativos e inequívocos. Es preferible pasarse por nombre largo que por nombre corto.

En Performance tenemos el método extra_amount_for_high_audience_in_comedy, cuyo nombre es extremadamente largo. Sin embargo, es inequívoco y dice exactamente lo que hace. A veces, el contexto nos puede proporcionar suficientes pistas. Este método es llamado desde calculate_amount_for_comedy, por lo que podríamos considerar acortarlo a extra_for_high_audience. Pero existe otro método de nombre similar en la misma clase: extra_amount_for_high_audience_in_tragedy. Así que para diferenciarlos deberíamos mantener la referencia al tipo de obra.

Por supuesto, en realidad estos nombres nos están insistiendo en la necesidad de abordar el polimorfismo de Play, pero es algo que vamos a dejar para otro capítulo más adelante. La lección aquí es que reflexionar sobre los nombres nos ayudará a alcanzar un mejor diseño.

En cualquier caso, si un nombre resulta incómodo por ser demasiado largo, siempre tienes la oportunidad de refactorizar.

Abreviaturas aceptables

Algunas abreviaturas son de uso común. Por ejemplo, vat por value added tax.

Convenciones problemáticas

Existen algunas convenciones que usan nombres abreviados o especialmente cortos. Un ejemplo son los bucles, en los que se suelen usar nombres de variables como i, j o k. En su lugar es recomendable usar alternativas: index, position, counter, son mucho más explícitas y más difíciles de confundir.

Figure 121
1 for i in range(0, 3):
2     print(i)

Frente a:

Figure 122
1 for counter in range(0, 3):
2     print(counter)

En general, usar variables de una sola letra es confuso. ¿Qué es p? Incluso teniendo el contexto, una variable de una única letra nos obliga a pensar dos veces.

Figure 123
1 for p in invoice.performances():
2     printer.print(formatted_line(p.title(), p.audience(), p.amount()))
3     invoice_amount = invoice_amount.add(p.amount())
4     volume_credits = volume_credits.add(p.credits())

Además, es poco práctico. Si tienes que hacer una búsqueda de texto para encontrar la variable puede ser una odisea. Dentro del archivo nos salva que para tareas de refactor los IDE suelen usar el árbol sintáctico, pero si la búsqueda es de texto normal… ¡Buena suerte!

Esto ocurre también con nombres cortos, pero demasiado genéricos, como get, add, etc., que son comunes a infinidad de librerías.

Por qué funciona

No usar abreviaturas nos fuerza a pensar nombres significativos, lo que ayuda a que el código se explique mejor por sí mismo. Esto permite que sea más fácil incorporar más personas a los proyectos y hacerlo más mantenible en el largo plazo. Puede que con el tiempo nos olvidemos de lo que significaban las abreviaturas, por lo que usar nombres completos será una ventaja.

Mantener todas las entidades pequeñas

Esta regla suele generar discusión porque vamos a poner un límite totalmente arbitrario al tamaño de las entidades de código. Esto se refiere a clases, al número de métodos, al cuerpo de funciones, al número de archivos en un paquete, etc. Por ejemplo, esta es una propuesta más o menos típica:

  • 10 archivos por paquete o carpeta
  • 50 líneas por clase
  • 5 líneas por método o función
  • 2 argumentos por método o función

Así que se trata de recorrer el código buscando áreas que superen estos límites.

El objetivo, como ocurre en todas las reglas de Calisthenics, es que tratar de forzar la aplicación de las reglas nos traiga como resultado un código mejor diseñado, más fácil de entender y de mantener. En el caso de esta, lo que buscamos obtener es un sistema de objetos pequeños muy simples.

Lo cierto es que después de todos los cambios resultado de aplicar las reglas anteriores, nos encontramos con relativamente pocos casos problemáticos. Pero alguno hay.

Paquetes y sub-paquetes

Por ejemplo, el paquete domain, que contiene casi todo el código que hemos generado, no llega a 10 archivos. En parte es porque tenemos algunos archivos que contienen dos clases, algo que no está recomendado en todos los lenguajes. Puedes verlo como una forma de contribuir a esta regla, haciendo que el módulo de Python se pueda considerar como un sub-paquete y forzando que no contenga más de 10 clases o funciones.

En general, en el caso de encontrarnos con paquetes de más de 10 archivos, deberíamos plantearnos agruparlos por algún criterio en sub-paquetes cohesivos.

Clases grandes

Tenemos una clase que tiene más de 50 líneas. Performance contiene gran parte de la lógica del programa, pero: ¿podemos reducir su tamaño? O bien, ¿necesita realmente ser tan grande? Además, el método amount tiene unas 10 líneas, con lo cual también supera el límite de cinco que habíamos definido.

Figure 124
 1 class Performance:
 2     def __init__(self, audience, play):
 3         self._audience = audience
 4         self._play = play
 5         self._amount = None
 6 
 7     def audience(self):
 8         return self._audience
 9 
10     def play(self):
11         return self._play
12 
13     def title(self):
14         return self._play.name()
15 
16     def calculate_amount_for_comedy(self):
17         return Amount(30000) \
18             .add(self.extra_amount_for_high_audience_in_comedy()) \
19             .add(Amount(300 * self.audience()))
20 
21     def extra_amount_for_high_audience_in_comedy(self):
22         if self.audience() <= 20:
23             return Amount(0)
24 
25         return Amount(10000 + 500 * (self.audience() - 20))
26 
27     def calculate_amount_for_tragedy(self):
28         return Amount(40000) \
29             .add(self.extra_amount_for_high_audience_in_tragedy())
30 
31     def extra_amount_for_high_audience_in_tragedy(self):
32         if self.audience() <= 30:
33             return Amount(0)
34 
35         return Amount(1000 * (self.audience() - 30))
36 
37     def credits(self):
38         return Credits(max(self.audience() - 30, 0)). \
39             add(self.extra_volume_credits_for_comedy())
40 
41     def extra_volume_credits_for_comedy(self):
42         if "comedy" != self.play().type():
43             return Credits(0)
44 
45         return Credits(math.floor(self.audience() / 5))
46 
47     def amount(self):
48         if self._amount is not None:
49             return self._amount
50 
51         if self.play().type() == "tragedy":
52             tragedy = self.calculate_amount_for_tragedy()
53             self._amount = tragedy
54             return tragedy
55         if self.play().type() == "comedy":
56             comedy = self.calculate_amount_for_comedy()
57             self._amount = comedy
58             return comedy
59 
60         raise ValueError(f'unknown type: {self.play().type()}')

El problema de Performance es que se ocupa de varias cosas. Gran parte de su lógica depende del tipo de obra representada, así que tiene que preguntarle a Play por su tipo y hacer cálculos basados en eso. Esto nos remite a la última regla que nos pide no exponer getters, setters o propiedades públicas de los objetos que, a su vez, se basa en la aplicación del principio “Tell, don’t ask”. En pocas palabras: si tienes que preguntar un objeto por una información, para que actúe con base en esa información, entonces haz que el objeto se encargue de hacerlo.

De hecho, si la lógica estuviese en Play podríamos reducir el tamaño de la clase Performance. Vamos a empezar por ahí.

Fundamentalmente, podemos mover algunos métodos de Performance a Play, así que simplemente los copio y los adapto. Cuando los tenga listos, podré reemplazarlos. Voy con los relacionados con el tipo Comedy. Un detalle importante es que ahora tenemos que pasar el argumento de audiencia para permitir el cálculo.

Figure 125
 1 class Play:
 2     def __init__(self, data):
 3         self._data = data
 4 
 5     def name(self):
 6         return self._data['name']
 7 
 8     def type(self):
 9         return self._data['type']
10 
11     def calculate_amount_for_comedy(self, audience):
12         return Amount(30000) \
13             .add(self.extra_amount_for_high_audience_in_comedy(audience\
14 )) \
15             .add(Amount(300 * audience))
16 
17     def extra_amount_for_high_audience_in_comedy(self, audience):
18         if audience <= 20:
19             return Amount(0)
20 
21         return Amount(10000 + 500 * (audience - 20))

Ahora puedo introducirlos en lugar de los existentes, que puedo eliminar a continuación, una vez que he comprobado que los tests siguen pasando igualmente.

Figure 126
 1     def amount(self):
 2         if self._amount is not None:
 3             return self._amount
 4 
 5         if self.play().type() == "tragedy":
 6             tragedy = self.calculate_amount_for_tragedy()
 7             self._amount = tragedy
 8             return tragedy
 9         if self.play().type() == "comedy":
10             comedy = self.play().calculate_amount_for_comedy(self.audie\
11 nce())
12             self._amount = comedy
13             return comedy
14 
15         raise ValueError(f'unknown type: {self.play().type()}')

Y pasará lo mismo con las obras de tipo Tragedy, moviendo los métodos relacionados y reemplazando las llamadas. Quedará así:

Figure 127
 1 class Play:
 2     def __init__(self, data):
 3         self._data = data
 4 
 5     def name(self):
 6         return self._data['name']
 7 
 8     def type(self):
 9         return self._data['type']
10 
11     def calculate_amount_for_comedy(self, audience):
12         return Amount(30000) \
13             .add(self.extra_amount_for_high_audience_in_comedy(audience\
14 )) \
15             .add(Amount(300 * audience))
16 
17     def extra_amount_for_high_audience_in_comedy(self, audience):
18         if audience <= 20:
19             return Amount(0)
20 
21         return Amount(10000 + 500 * (audience - 20))
22 
23     def calculate_amount_for_tragedy(self, audience):
24         return Amount(40000) \
25             .add(self.extra_amount_for_high_audience_in_tragedy(audienc\
26 e))
27 
28     def extra_amount_for_high_audience_in_tragedy(self, audience):
29         if audience <= 30:
30             return Amount(0)
31 
32         return Amount(1000 * (audience - 30))

Y reduciremos el tamaño de Performance porque nos libramos de bastantes métodos.

Figure 128
 1 def amount(self):
 2     if self._amount is not None:
 3         return self._amount
 4 
 5     if self.play().type() == "tragedy":
 6         tragedy = self.play().calculate_amount_for_tragedy(self.audienc\
 7 e())
 8         self._amount = tragedy
 9         return tragedy
10     if self.play().type() == "comedy":
11         comedy = self.play().calculate_amount_for_comedy(self.audience(\
12 ))
13         self._amount = comedy
14         return comedy
15 
16     raise ValueError(f'unknown type: {self.play().type()}')

De hecho, todavía podemos quitar un poco más de código a Performance puesto que tenemos un cálculo de créditos que depende de la obra:

Figure 129
 1 class Play:
 2     def __init__(self, data):
 3         self._data = data
 4 
 5     def name(self):
 6         return self._data['name']
 7 
 8     def type(self):
 9         return self._data['type']
10 
11     def calculate_amount_for_comedy(self, audience):
12         return Amount(30000) \
13             .add(self.extra_amount_for_high_audience_in_comedy(audience\
14 )) \
15             .add(Amount(300 * audience))
16 
17     def extra_amount_for_high_audience_in_comedy(self, audience):
18         if audience <= 20:
19             return Amount(0)
20 
21         return Amount(10000 + 500 * (audience - 20))
22 
23     def calculate_amount_for_tragedy(self, audience):
24         return Amount(40000) \
25             .add(self.extra_amount_for_high_audience_in_tragedy(audienc\
26 e))
27 
28     def extra_amount_for_high_audience_in_tragedy(self, audience):
29         if audience <= 30:
30             return Amount(0)
31 
32         return Amount(1000 * (audience - 30))
33 
34     def credits(self, audience):
35         if "comedy" != self.type():
36             return Credits(0)
37 
38         return Credits(math.floor(audience / 5))

Con lo que Performance se reduce hasta la mitad de líneas:

Figure 130
 1 class Performance:
 2     def __init__(self, audience, play):
 3         self._audience = audience
 4         self._play = play
 5         self._amount = None
 6 
 7     def audience(self):
 8         return self._audience
 9 
10     def play(self):
11         return self._play
12 
13     def title(self):
14         return self._play.name()
15 
16     def credits(self):
17         return Credits(max(self.audience() - 30, 0)). \
18             add(self.play().extra_volume_credits_for_comedy(self.audien\
19 ce()))
20 
21     def amount(self):
22         if self._amount is not None:
23             return self._amount
24 
25         if self.play().type() == "tragedy":
26             tragedy = self.play().calculate_amount_for_tragedy(self.aud\
27 ience())
28             self._amount = tragedy
29             return tragedy
30         if self.play().type() == "comedy":
31             comedy = self.play().calculate_amount_for_comedy(self.audie\
32 nce())
33             self._amount = comedy
34             return comedy
35 
36         raise ValueError(f'unknown type: {self.play().type()}')

Por supuesto puedes argumentar: pero si has movido el código de una clase a otra. Ahora Play es mucho más grande. Y es cierto, pero ahora contiene casi toda la lógica que le pertenece.

Un método largo

Con todo, el método amount sigue teniendo más de cinco líneas. Hemos adelgazado la clase, pero no el método más grande. Podemos mover parte de este código a Play. Aquí tenemos un pequeño obstáculo, pues implementamos la memoización de una forma que nos complica un poco. Pero podemos arreglarlo. El primer paso es separar la memoización del cálculo:

Figure 131
 1     def amount(self):
 2         if self._amount is not None:
 3             return self._amount
 4 
 5         self._amount = self.calculate_amount()
 6         
 7         return self._amount
 8 
 9     def calculate_amount(self):
10         if self.play().type() == "tragedy":
11             return self.play().calculate_amount_for_tragedy(self.audien\
12 ce())
13         if self.play().type() == "comedy":
14             return self.play().calculate_amount_for_comedy(self.audienc\
15 e())
16 
17         raise ValueError(f'unknown type: {self.play().type()}')

Gracias a este cambio, además resulta que reducimos el tamaño del método amount, y el nuevo método también cumple la limitación a un máximo de cinco líneas. De hecho, ahora amount se encarga básicamente de la memoización y Play del cálculo. Más interesante aún es que se ha reducido el acoplamiento. Play no sabe nada de Performance, pero lo mejor es que esta no sabe nada de Play. Es decir: únicamente sabe que le puede pedir amount y credits, pero no tiene que saber cómo se hace el cálculo.

Este nuevo método es que queremos trasladar a Play, que sigue estando dentro del límite de tamaño.

Figure 132
 1 class Play:
 2     def __init__(self, data):
 3         self._data = data
 4 
 5     def name(self):
 6         return self._data['name']
 7 
 8     def type(self):
 9         return self._data['type']
10 
11     def calculate_amount_for_comedy(self, audience):
12         return Amount(30000) \
13             .add(self.extra_amount_for_high_audience_in_comedy(audience\
14 )) \
15             .add(Amount(300 * audience))
16 
17     def extra_amount_for_high_audience_in_comedy(self, audience):
18         if audience <= 20:
19             return Amount(0)
20 
21         return Amount(10000 + 500 * (audience - 20))
22 
23     def calculate_amount_for_tragedy(self, audience):
24         return Amount(40000) \
25             .add(self.extra_amount_for_high_audience_in_tragedy(audienc\
26 e))
27 
28     def extra_amount_for_high_audience_in_tragedy(self, audience):
29         if audience <= 30:
30             return Amount(0)
31 
32         return Amount(1000 * (audience - 30))
33 
34     def credits(self, audience):
35         if "comedy" != self.type():
36             return Credits(0)
37 
38         return Credits(math.floor(audience / 5))
39 
40     def amount(self, audience):
41         if self.type() == "tragedy":
42             return self.calculate_amount_for_tragedy(audience)
43         if self.type() == "comedy":
44             return self.calculate_amount_for_comedy(audience)
45 
46         raise ValueError(f'unknown type: {self.type()}')

Por supuesto, ahora queda más claro que nunca que Play necesita especializarse en dos clases Tragedy y Comedy. Pero no vamos a abordar ese cambio ahora, sino cuando la última regla nos lo pida.

Solo dos argumentos

La función statement tiene varios problemas relacionados con esta regla. Claramente, tiene más de cinco líneas en el cuerpo, incluso sin contar las inner functions. Además, una de estas funciones recibe más de dos parámetros. Vamos a ver algunas soluciones. Aquí está:

Figure 133
1 def formatted_line(title, audience, amount):
2     return f' {title}: {format_as_dollars(amount.current() / 100)} ({au\
3 dience} seats)\n'

Recuerda que extrajimos esta función porque necesitábamos que alguien pudiese manejar Amount debido a la regla de no más de un punto por línea. Esto nos impediría usar la solución más inmediata que sería pasar el objeto Performance. Pero de hacerlo así volveríamos a romper la regla anterior. Por supuesto, hay más problemas ahí, pero de momento consideremos otras opciones.

Cuando una función recibe muchos parámetros una posibilidad es introducir un Objeto parámetro. Los constructores de los objetos no están limitados por esta regla, así que podríamos introducir algo como esto:

Figure 134
1 class Line:
2     def __init__(self, title, audience, amount):
3         self.title = title
4         self.audience = audience
5         self.amount = amount.current()

Y cambiar la función formatted_line para usarlo:

Figure 135
1 def formatted(line):
2     return f' {line.title}: {format_as_dollars(line.amount / 100)} ({li\
3 ne.audience} seats)\n'

Y se podría usar así:

Figure 136
1 printer.print(f'Statement for {invoice.customer()}\n')
2 for performance in invoice.performances():
3     printer.print(formatted(Line(performance.title(), performance.audie\
4 nce(), performance.amount())))
5 printer.print(f'Amount owed is {format_as_dollars(invoice.amount().curr\
6 ent() // 100)}\n')
7 printer.print(f'You earned {invoice.credits().current()} credits\n')

Pero es que, además, ahora tendría todo el sentido mover esa función a Line.

Figure 137
 1 class Line:
 2     def __init__(self, title, audience, amount):
 3         self.title = title
 4         self.audience = audience
 5         self.amount = amount.current()
 6 
 7     def amount_as_dollars(self):
 8         return f"${self.amount/100:0,.2f}"
 9 
10     def formatted(self):
11         return f' {self.title}: {self.amount_as_dollars()} ({self.audie\
12 nce} seats)\n'

Este cambio genera algún problema porque duplicamos el código que da formato a Amount introduciendo el riesgo de que ocurran divergencias. Una forma de resolverlo podría ser introducir un patrón decorador:

Figure 138
1 class FormattedAmount:
2     def __init__(self, amount):
3         self.amount = amount
4 
5     def dollars(self):
6         return f"${self.amount.current() / 100:0,.2f}"

De modo que se pueda usar cuando sea necesario, haciendo un par de pequeños cambios:

Figure 139
1 class Line:
2     def __init__(self, title, audience, amount):
3         self.title = title
4         self.audience = audience
5         self.amount = amount
6 
7     def formatted(self):
8         return f' {self.title}: {FormattedAmount(self.amount).dollars()\
9 } ({self.audience} seats)\n'
Figure 140
 1 def statement(invoice_data, plays):
 2     invoice = Invoice(invoice_data, plays)
 3 
 4     printer = Printer()
 5     printer.print(f'Statement for {invoice.customer()}\n')
 6 
 7     for performance in invoice.performances():
 8         line = Line(performance.title(), performance.audience(), perfor\
 9 mance.amount())
10         printer.print(line.formatted())
11 
12     printer.print(f'Amount owed is {FormattedAmount(invoice.amount()).d\
13 ollars()}\n')
14     printer.print(f'You earned {invoice.credits().current()} credits\n')
15 
16     return printer.output()

Más oportunidades de acortar métodos

La función statement sigue siendo demasiado larga. Por supuesto, en ocasiones nos encontraremos con que es muy difícil o imposible hacer un método más pequeño por lo que se trata de no obsesionarse. Recordemos que estamos haciendo un ejercicio para entrenar nuestra capacidad de descubrir oportunidades para aplicar las reglas. ¿Tenemos algún punto más que podamos reducir?

Parte del problema con statement es que es una función y tiene un par de líneas de inicialización de objetos. Además, al ser una función nos complica la extracción de bloques de código. Por ejemplo, el bucle que procesa las Performance podría extraerse para mantener un único nivel de abstracción. Quizá podríamos introducir el concepto de StatementPrinter para llevarnos toda esa lógica de ahí y tener más libertad para manipularla.

Figure 141
 1 class StatementPrinter:
 2     def __init__(self, printer):
 3         self.printer = printer
 4 
 5     def print(self, invoice):
 6         self.printer.print(f'Statement for {invoice.customer()}\n')
 7 
 8         for performance in invoice.performances():
 9             line = Line(performance.title(), performance.audience(), pe\
10 rformance.amount())
11             self.printer.print(line.formatted())
12 
13         self.printer.print(f'Amount owed is {FormattedAmount(invoice.am\
14 ount()).dollars()}\n')
15         self.printer.print(f'You earned {invoice.credits().current()} c\
16 redits\n')
17 
18         return self.printer.output()

De este modo, statement simplemente actúa como una especie de caso de uso:

Figure 142
1 def statement(invoice_data, plays):
2     invoice = Invoice(invoice_data, plays)
3 
4     statement_printer = StatementPrinter(Printer())
5 
6     return statement_printer.print(invoice)

Esto nos da algunas opciones. Por ejemplo:

Figure 143
 1 class StatementPrinter:
 2     def __init__(self, printer):
 3         self.printer = printer
 4 
 5     def print(self, invoice):
 6         self.printer.print(f'Statement for {invoice.customer()}\n')
 7 
 8         self.print_lines(invoice)
 9 
10         self.printer.print(f'Amount owed is {FormattedAmount(invoice.am\
11 ount()).dollars()}\n')
12         self.printer.print(f'You earned {invoice.credits().current()} c\
13 redits\n')
14 
15         return self.printer.output()
16 
17     def print_lines(self, invoice):
18         for performance in invoice.performances():
19             self.print_details(performance)
20 
21     def print_details(self, performance):
22         line = Line(performance.title(), performance.audience(), perfor\
23 mance.amount())
24         self.printer.print(line.formatted())

Una cuestión es que Printer ahora se refiere a un mecanismo concreto de impresión, así que es mejor cambiarlo de nombre y ubicación. Por otro lado, StatementPrinter, Line o FormattedAmount son objetos que hemos introducido aunque aún no hemos ubicado correctamente.

Figure 144
 1 from domain.invoice import Invoice
 2 from domain.statement_printer import StatementPrinter
 3 from infrastructure.console_printer import ConsolePrinter
 4 
 5 
 6 def statement(invoice_data, plays):
 7     invoice = Invoice(invoice_data, plays)
 8 
 9     statement_printer = StatementPrinter(ConsolePrinter())
10 
11     return statement_printer.print(invoice)

Por qué funciona

La razón de que esta regla funcione es que al querer reducir el número de líneas que contiene una clase o un método nos obliga a buscar líneas de código muy relacionadas entre sí, o sea que mantengan alta cohesión, y que puedan moverse juntas a un nuevo método o incluso a otra clase. A un nuevo método si contribuyen a la misma responsabilidad de la clase, y a otra clase si representan una responsabilidad ajena.

Cuando separamos un gran bloque de código de una clase en métodos más pequeños altamente cohesivos es fácil identificar responsabilidades, de modo que podemos analizar si realmente corresponden a la clase o deberían irse a otro lugar. Estos métodos y clases más pequeños son más fáciles de testear porque tienden a hacer una sola cosa. También son más fáciles de mantener por su pequeño tamaño, ya que podemos entender de un vistazo su propósito y si algo va mal con ellos.

Por supuesto, no siempre es posible forzar un método a tener un determinado tamaño, incluso cuando tiene una responsabilidad bien definida y sus líneas tienen mucha cohesión. En cualquier caso, siempre es buena idea intentar analizar los métodos largos en busca de oportunidades de hacerlos más pequeños.

El resultado

No más de dos variables de instancia por clase

Otra regla que se presta a mucha discusión es esta y puede ser considerada un auténtico tour de force, porque ¿qué entidad de negocio no necesita una buena cantidad de propiedades? ¿Y pretendes que únicamente sean dos?

De nuevo, una regla de calisthenics nos propone una restricción especialmente artificial que nos obliga a reflexionar sobre nuestro diseño y cómo podríamos mejorarlo. Un artículo del blog planteaba un ejercicio en el que se mostraba un ejemplo de cómo hacerlo en un tipo de datos bastante comunes en muchos negocios.

De hecho, en el ejemplo de las obras teatrales no tenemos más que un par de casos discutibles. Esto es debido en parte a lo reducido del problema, pero también porque hemos ido extrayendo todo el conocimiento a objetos pequeños.

El caso de Performance

La clase Performance contiene tres propiedades o variables de instancia:

Figure 145
1 class Performance:
2     def __init__(self, audience, play):
3         self._audience = audience
4         self._play = play
5         self._amount = None

Lo que nos encontramos en Performance es que las variables de instancia son, por decirlo así, irreconciliables. Representan cosas completamente diferentes. De hecho _amount tiene un significado puramente técnico, siendo una variable que usamos para poder realizar una optimización por lo que podríamos decir que Performance solo tiene dos propiedades: _audience y _play.

Precisamente, Play también tiene dos propiedades, aunque en este momento únicamente muestra una:

Figure 146
1 class Play:
2     def __init__(self, data):
3         self._data = data
4 
5     def name(self):
6         return self._data['name']
7 
8     def type(self):
9         return self._data['type']

Esto es consecuencia de que simplemente hemos encapsulado una estructura de datos nativa, pero no significa que Play tenga una única propiedad. Sus dos propiedades se manifiestan en dos métodos getter, de los que tendremos que hablar en el siguiente capítulo.

Vamos a refactorizar eso:

Figure 147
 1 class Play:
 2     def __init__(self, data):
 3         self._name = data['name']
 4         self._type = data['type']
 5 
 6     def name(self):
 7         return self._name
 8 
 9     def type(self):
10         return self._type

Volvamos por un momento a Performance. Una consecuencia interesante de nuestro diseño es que el resto del programa no necesita saber de la existencia de Play, ya que todo el comportamiento de statement ocurre a través de Performance. Desde este punto de vista, Play sería irrelevante y podríamos haberla fusionado con Performance. De este modo, Performance podría tener este aspecto:

Figure 148
1 class Performance:
2     def __init__(self, audience, play_name, play_type):
3         self._audience = audience
4         self._title = play_name
5         self._type = play_type
6         self._amount = None

¿Recuerdas cuando Performance era demasiado grande porque se ocupaba de responsabilidades de Play? En aquel momento hubiésemos podido prescindir del objeto Play que entonces no era más que una simple Data Class (un objeto que solo tiene datos pero no comportamiento) y podríamos haber fusionado sus propiedades con las de Performance.

Esencialmente, lo que quiero decir es que cuando una clase tiene muchas propiedades, es muy probable que esté tratando de ocuparse de demasiadas responsabilidades. Si agrupamos propiedades cohesivas y extraemos nuevas clases a partir de ellas, lo más seguro es que se llevarán consigo comportamientos de la clase contenedora.

Más pequeño y más simple.

El caso de Line

Otra clase con más de dos propiedades es Line:

Figure 149
1 class Line:
2     def __init__(self, title, audience, amount):
3         self.title = title
4         self.audience = audience
5         self.amount = amount

Line tiene tres propiedades por una buena razón, su tarea es algo así como representar un registro que tiene tres campos. Se trata de un ejemplo bastante claro de no poder reducir el número de variables por debajo del límite marcado.

Pero, ¿acaso Line no es la versión impresa de Performance? A lo mejor no necesitamos pasar las tres propiedades separadas, sino que Performance ya las agrupa. Line es como un decorador.

Figure 150
1 class FormattedPerformance:
2     def __init__(self, performance):
3         self._performance = performance
4 
5     def formatted(self):
6         return f' {self._performance.title()}: {FormattedAmount(self._p\
7 erformance.amount()).dollars()} ({self._performance.audience()} seats)\
8 n'

Y la usaríamos así:

Figure 151
1 def print_lines(self, invoice):
2     for performance in invoice.performances():
3         self.print_details(performance)
4 
5 def print_details(self, performance):
6     line = FormattedPerformance(performance)
7     self.printer.print(line.formatted())

Este enfoque es interesante. Nos permite cumplir la regla de las dos variables de instancia reemplazando Line que tiene tres por FormattedPerformance que solo tiene una.

Pero todavía nos queda una regla que aplicar y va a poner en cuestión muchas de estas decisiones.

Por qué funciona

Tanto esta como la regla anterior ponen énfasis en que las clases se ocupen de pocas cosas a la vez. Cuantas menos mejor. Para lograr eso nos fuerza a intentar cumplir con unos límites totalmente arbitrarios, que nos obligan a pensar en la cohesión de nuestro código.

La cohesión es el grado en que cada línea de código se relaciona con las demás dentro de su misma unidad (blqque, método, clase…). Cuando la cohesión es máxima, todas las líneas de código tienen que estar ahí, ninguna sobra. Para que esto ocurra, los bloques de código tienen que ser pequeños, minimizando la posibilidad de una parte del código realmente no esté contribuyendo a las responsabilidades de esa unidad.

Con las propiedades (o variables de instancia) ocurre lo mismo. Cuantas más haya en una clase, más probable es que exista una falta de cohesión. En algunos casos, el problema vendrá dado porque esas propiedades no corresponden realmente a esa clase. En otros casos, lo que ocurre es que algunas de esas propiedades son altamente cohesivas entre ellas, indicando que pueden agruparse en un objeto que represente un concepto al que contribuyen y que podemos extraer.

El resultado

No user getters, setters o propiedades públicas

El objetivo de la regla es evitar que te bases en tu conocimiento del estado de los objetos de forma que acoples el resto del código a ese estado. En su lugar, los objetos solo deberían exponer comportamiento, minimizando la posibilidad de acoplarse a detalles de implementación. Por lo general, intentamos aplicar un principio llamado Tell, don’t ask, de modo que en lugar de preguntar a un objeto sobre su estado (ask), le pedimos que haga cosas.

En nuestro ejemplo hay varios casos de estos. Vamos a verlos y plantear posibles soluciones.

El caso de Invoice y StatementPrinter

En este código podemos ver que StatementPrinter le pregunta un montón de cosas a Invoice. Podríamos decir que el comportamiento de Invoice parece ser darle información sobre su estado a StatementPrinter.

Figure 152
 1 class StatementPrinter:
 2     def __init__(self, printer):
 3         self.printer = printer
 4 
 5     def print(self, invoice):
 6         self.printer.print(f'Statement for {invoice.customer()}\n')
 7 
 8         self.print_lines(invoice)
 9 
10         self.printer.print(f'Amount owed is {FormattedAmount(invoice.am\
11 ount()).dollars()}\n')
12         self.printer.print(f'You earned {invoice.credits().current()} c\
13 redits\n')
14 
15         return self.printer.output()
16 
17     def print_lines(self, invoice):
18         for performance in invoice.performances():
19             self.print_details(performance)
20 
21     def print_details(self, performance):
22         self.printer.print(FormattedPerformance(performance).formatted(\
23 ))

De hecho, StatementPrinter sabe muchas cosas de Invoice. Por ejemplo, sabe que Invoice tiene Customer, Amount, Credits e incluso Performances. Literalmente, conoce su estructura interna.

Para intentar aligerar ese conocimiento voy a empezar a separar cosas. Haré un ejemplo paso a paso con Customer. Lo primero es extraer una variable customer para no usar directamente la invocación a Invoice.customer(). Lo que quiero es que haya un método en StatementPrinter que pueda imprimir la línea del cliente sin saber nada directamente de Invoice.

Figure 153
 1 def print(self, invoice):
 2     customer = invoice.customer()
 3     self.printer.print(f'Statement for {customer}\n')
 4 
 5     self.print_lines(invoice)
 6 
 7     self.printer.print(f'Amount owed is {FormattedAmount(invoice.amount\
 8 ()).dollars()}\n')
 9     self.printer.print(f'You earned {invoice.credits().current()} credi\
10 ts\n')
11 
12     return self.printer.output()

Ahora extraigo el método:

Figure 154
 1 def print(self, invoice):
 2     customer = invoice.customer()
 3     self.fill_customer(customer)
 4 
 5     self.print_lines(invoice)
 6 
 7     self.printer.print(f'Amount owed is {FormattedAmount(invoice.amount\
 8 ()).dollars()}\n')
 9     self.printer.print(f'You earned {invoice.credits().current()} credi\
10 ts\n')
11 
12     return self.printer.output()
13 
14 def fill_customer(self, customer):
15     self.printer.print(f'Statement for {customer}\n')

Y me deshago de la variable temporal:

Figure 155
 1 def print(self, invoice):
 2     self.fill_customer(invoice.customer())
 3 
 4     self.print_lines(invoice)
 5 
 6     self.printer.print(f'Amount owed is {FormattedAmount(invoice.amount\
 7 ()).dollars()}\n')
 8     self.printer.print(f'You earned {invoice.credits().current()} credi\
 9 ts\n')
10 
11     return self.printer.output()
12 
13 def fill_customer(self, customer):
14     self.printer.print(f'Statement for {customer}\n')

Hago lo mismo con las demás datos:

Figure 156
 1 def print(self, invoice):
 2     self.fill_customer(invoice.customer())
 3 
 4     self.print_lines(invoice)
 5 
 6     self.fill_amount(invoice.amount())
 7     self.fill_credits(invoice.credits())
 8 
 9     return self.printer.output()
10 
11 def fill_credits(self, credits):
12     self.printer.print(f'You earned {credits.current()} credits\n')
13 
14 def fill_amount(self, amount):
15     self.printer.print(f'Amount owed is {FormattedAmount(amount).dollar\
16 s()}\n')
17 
18 def fill_customer(self, customer):
19     self.printer.print(f'Statement for {customer}\n')

También modifico el método print_lines para mantener el paralelismo:

Figure 157
 1 class StatementPrinter:
 2     def __init__(self, printer):
 3         self.printer = printer
 4 
 5     def print(self, invoice):
 6         self.fill_customer(invoice.customer())
 7         self.fill_lines(invoice.performances())
 8         self.fill_amount(invoice.amount())
 9         self.fill_credits(invoice.credits())
10 
11         return self.printer.output()
12 
13     def fill_credits(self, credits):
14         self.printer.print(f'You earned {credits.current()} credits\n')
15 
16     def fill_amount(self, amount):
17         self.printer.print(f'Amount owed is {FormattedAmount(amount).do\
18 llars()}\n')
19 
20     def fill_customer(self, customer):
21         self.printer.print(f'Statement for {customer}\n')
22 
23     def fill_lines(self, performances):
24         for performance in performances:
25             self.print_details(performance)
26 
27     def print_details(self, performance):
28         self.printer.print(FormattedPerformance(performance).formatted(\
29 ))

Es cierto que seguimos haciendo llamadas de tipo getter a Invoice, pero esto nos prepara para los siguientes pasos. Queremos no preguntarle cosas a Invoice. En su lugar, Invoice podría darle a StatementPrinter la información, sin desvelar sus detalles. Para ello usaremos un patrón Visitor.

Así que en lugar de preguntarle a Invoice por su información, esta rellena los datos que StatementPrinter necesita.

Figure 158
1 def print(self, invoice):
2     invoice.fill(self)
3     
4     return self.printer.output()

De esta manera:

Figure 159
1 def fill(self, statement_printer):
2     statement_printer.fill_customer(self.customer())
3     statement_printer.fill_lines(self.performances())
4     statement_printer.fill_amount(self.amount())
5     statement_printer.fill_credits(self.credits())

Este cambio aún está incompleto porque todavía StatementPrinter sigue preguntando a Performance.

Figure 160
1 def fill_lines(self, performances):
2     for performance in performances:
3         self.print_details(performance)
4 
5 def print_details(self, performance):
6     self.printer.print(FormattedPerformance(performance).formatted())

En parte tendríamos que deshacer lo que hicimos al aplicar reglas anteriores porque no queremos que StatementPrinter sepa ningún detalle. Así que vamos a reintroducir un método que imprima una línea de detalles de la performance:

Figure 161
1 def fill_line(self, title, amount, audience):
2     self.printer.print(f' {title}: {FormattedAmount(amount).dollars()} \
3 ({audience} seats)\n')

De este modo, Invoice puede controlar el modo que se rellena StatementPrinter, que ya no necesita saber ni siquiera cuantas líneas necesitará imprimir, pues de eso se encargará Invoice.

Figure 162
1 def fill(self, statement_printer):
2     statement_printer.fill_customer(self.customer())
3     for performance in self.performances():
4         statement_printer.fill_line(performance.title(), performance.am\
5 ount(), performance.audience())
6     statement_printer.fill_amount(self.amount())
7     statement_printer.fill_credits(self.credits())

Así es como queda StatementPrinter:

Figure 163
 1 class StatementPrinter:
 2     def __init__(self, printer):
 3         self.printer = printer
 4 
 5     def print(self, invoice):
 6         invoice.fill(self)
 7 
 8         return self.printer.output()
 9 
10     def fill_credits(self, credits):
11         self.printer.print(f'You earned {credits.current()} credits\n')
12 
13     def fill_amount(self, amount):
14         self.printer.print(f'Amount owed is {FormattedAmount(amount).do\
15 llars()}\n')
16 
17     def fill_customer(self, customer):
18         self.printer.print(f'Statement for {customer}\n')
19 
20     def fill_line(self, title, amount, audience):
21         self.printer.print(f' {title}: {FormattedAmount(amount).dollars\
22 ()} ({audience} seats)\n')
23 
24 
25 class FormattedAmount:
26     def __init__(self, amount):
27         self.amount = amount
28 
29     def dollars(self):
30         return f"${self.amount.current() / 100:0,.2f}"

Y así queda Invoice:

Figure 164
 1 class Invoice:
 2     def __init__(self, data, plays):
 3         self._data = data
 4         self._customer = data['customer']
 5         self._performances = Performances(data['performances'], Plays(p\
 6 lays))
 7 
 8     def customer(self):
 9         return self._customer
10 
11     def performances(self):
12         return self._performances
13 
14     def amount(self):
15         amount = Amount(0)
16         for performance in self.performances():
17             amount = amount.add(performance.amount())
18 
19         return amount
20 
21     def credits(self):
22         volume_credits = Credits(0)
23         for performance in self.performances():
24             volume_credits = volume_credits.add(performance.credits())
25 
26         return volume_credits
27 
28     def fill(self, statement_printer):
29         statement_printer.fill_customer(self.customer())
30         for performance in self.performances():
31             statement_printer.fill_line(performance.title(), performanc\
32 e.amount(), performance.audience())
33         statement_printer.fill_amount(self.amount())
34         statement_printer.fill_credits(self.credits())

Algunos comentarios sobre lo que acabamos de hacer:

  • Ahora tenemos que los métodos de Invoice solo son llamados por Invoice, así que los podríamos marcar como privados. En Python podemos hacer esto prefijando sus nombres.
  • Una pregunta legítima que podemos hacer es si Invoice ahora sabe demasiado de StatementPrinter dato que hay cuatro métodos que tiene que conocer para poder usarlo.
  • Para este caso específico podemos plantear esta solución. Al fin y al cabo, lo que hacemos con StatementPrinter es rellenar una plantilla. Podríamos tener entonces un método fill más genérico en el que indicamos que plantilla queremos rellenar. Algo similar a lo que se muestra a continuación. Invoice solo tiene que conocer un método:
Figure 165
1 def fill(self, statement_printer):
2     statement_printer.fill('customer', self.customer())
3     for performance in self.performances():
4         statement_printer.fill('line', performance.title(), performance\
5 .amount(), performance.audience())
6     statement_printer.fill('amount', self.amount())
7     statement_printer.fill('credits', self.credits())

Y StatementPrinter ya no tiene que exponer detalles tampoco:

Figure 166
1 def fill(self, template, *args):
2     getattr(self, 'fill_' + template)(*args)

¿Y qué pasa con Performance? Sigue exponiendo getters. Así que podríamos hacer algo similar:

Figure 167
1 def fill(self, statement_printer):
2     statement_printer.fill('line', self.title(), self.amount(), self.au\
3 dience())

Y ahora Invoice no tiene más que decirle a Performance que rellene su parte:

Figure 168
1 def fill(self, statement_printer):
2     statement_printer.fill('customer', self.customer())
3     for performance in self.performances():
4         performance.fill(statement_printer)
5     statement_printer.fill('amount', self.amount())
6     statement_printer.fill('credits', self.credits())

A continuación, lo suyo sería hacer privados todos estos getters o incluso eliminarlos.

El patrón de relación que nos ha quedado entre Invoice y StatementPrinter se llama Double Dispatch, pero podemos simplificar un poco las cosas de esta manera. StatementPrinter ya no sabe nada de Invoice:

Figure 169
 1 class StatementPrinter:
 2     def __init__(self, printer):
 3         self.printer = printer
 4 
 5     def print(self):
 6         return self.printer.output()
 7 
 8     def fill(self, template, *args):
 9         getattr(self, '_fill_' + template)(*args)
10 
11 # ...

Y la función statement queda así, and I think it’s beautiful:

Figure 170
 1 from domain.invoice import Invoice
 2 from domain.statement_printer import StatementPrinter
 3 from infrastructure.console_printer import ConsolePrinter
 4 
 5 
 6 def statement(invoice_data, plays):
 7     statement_printer = StatementPrinter(ConsolePrinter())
 8 
 9     invoice = Invoice(invoice_data, plays)
10     invoice.fill(statement_printer)
11 
12     return statement_printer.print()

El caso especial de Play

El problema con Play está aquí:

Figure 171
 1 def credits(self, audience):
 2     if "comedy" != self.type():
 3         return Credits(0)
 4 
 5     return Credits(math.floor(audience / 5))
 6 
 7 def amount(self, audience):
 8     if self.type() == "tragedy":
 9         return self.calculate_amount_for_tragedy(audience)
10     if self.type() == "comedy":
11         return self.calculate_amount_for_comedy(audience)
12 
13     raise ValueError(f'unknown type: {self.type()}')

Play tiene que preguntarse “¿qué tipo de obra soy?”, para decidir como realizar el cálculo que le piden. Esto es muy similar a una violación del principio Tell, don’t ask, ya que tiene que consultar una propiedad para poder escoger el algoritmo adecuado.

Los objetos tienen propiedades por algo. Normalmente, la razón de ser de esas propiedades es ser capaces de regular el comportamiento del objeto. Las propiedades tienen un papel similar al de los coeficientes de una ecuación y operan junto con los parámetros que se pasan a los métodos para calcular un resultado.

Sin embargo, propiedades que modelan el tipo de un objeto son harina de otro costal. Aportan el criterio para decidir qué algoritmo utilizar al realizar el cálculo. Pero si un objeto es de un tipo, esto debería reflejarse en el código por su clase. Cuando un objeto de una clase tiene tipo, y ese tipo determina la forma en que efectúa su comportamiento, lo que ocurre es que la clase debería tener variantes especializadas basadas en su tipo, ejecutando su comportamiento en su forma particular.

En nuestro ejemplo, está muy claro que hay dos tipos de obras: comedias y tragedias. Ambos tipos son obras teatrales, pero para los efectos de nuestro ejemplo, calculan sus importes y sus créditos de forma diferente.

¿Cómo podemos refactorizar Play para extraer las dos subclases? Vamos a ver un procedimiento bastante mecánico. En primer lugar duplicamos Play para crear la clase Tragedy, que extenderá de la misma Play:

Figure 172
 1 class Tragedy(Play):
 2     def __init__(self, data):
 3         self._name = data['name']
 4         self._type = data['type']
 5 
 6     def name(self):
 7         return self._name
 8 
 9     def type(self):
10         return self._type
11 
12     def calculate_amount_for_comedy(self, audience):
13         return Amount(30000) \
14             .add(self.extra_amount_for_high_audience_in_comedy(audience\
15 )) \
16             .add(Amount(300 * audience))
17 
18     def extra_amount_for_high_audience_in_comedy(self, audience):
19         if audience <= 20:
20             return Amount(0)
21 
22         return Amount(10000 + 500 * (audience - 20))
23 
24     def calculate_amount_for_tragedy(self, audience):
25         return Amount(40000) \
26             .add(self.extra_amount_for_high_audience_in_tragedy(audienc\
27 e))
28 
29     def extra_amount_for_high_audience_in_tragedy(self, audience):
30         if audience <= 30:
31             return Amount(0)
32 
33         return Amount(1000 * (audience - 30))
34 
35     def credits(self, audience):
36         if "comedy" != self.type():
37             return Credits(0)
38 
39         return Credits(math.floor(audience / 5))
40 
41     def amount(self, audience):
42         if self.type() == "tragedy":
43             return self.calculate_amount_for_tragedy(audience)
44         if self.type() == "comedy":
45             return self.calculate_amount_for_comedy(audience)
46 
47         raise ValueError(f'unknown type: {self.type()}')

El siguiente paso es reemplazar todas las condicionales sobre self.type() por True. En nuestro ejemplo, solo tenemos un caso en el método amount:

Figure 173
1 def amount(self, audience):
2     if True:
3         return self.calculate_amount_for_tragedy(audience)
4     if self.type() == "comedy":
5         return self.calculate_amount_for_comedy(audience)
6 
7     raise ValueError(f'unknown type: {self.type()}')

Probablemente, el IDE habrá empezado a señalar que la condicional es redundante porque ahora siempre se cumple. En mi caso está señalando que el resto del código del método no se ejecutará nunca. Así que podemos borrarlo:

Figure 174
1 def amount(self, audience):
2     if True:
3         return self.calculate_amount_for_tragedy(audience)

De hecho, nos sobra la condición:

Figure 175
1 def amount(self, audience):
2     return self.calculate_amount_for_tragedy(audience)

Al hacer esto, dejamos de llamar a varios métodos, los que ejecutaríamos si el tipo fuese comedy. También los borramos porque no se llaman en más sitios. Nos va quedando esto:

Figure 176
 1 class Tragedy(Play):
 2     def __init__(self, data):
 3         self._name = data['name']
 4         self._type = data['type']
 5 
 6     def name(self):
 7         return self._name
 8 
 9     def type(self):
10         return self._type
11 
12     def calculate_amount_for_tragedy(self, audience):
13         return Amount(40000) \
14             .add(self.extra_amount_for_high_audience_in_tragedy(audienc\
15 e))
16 
17     def extra_amount_for_high_audience_in_tragedy(self, audience):
18         if audience <= 30:
19             return Amount(0)
20 
21         return Amount(1000 * (audience - 30))
22 
23     def credits(self, audience):
24         if "comedy" != self.type():
25             return Credits(0)
26 
27         return Credits(math.floor(audience / 5))
28 
29     def amount(self, audience):
30         return self.calculate_amount_for_tragedy(audience)

El siguiente paso es cambiar todas las condicionales que buscan tipos que no sean “tragedy” para reemplazarlas por False. En Tragedy ya no se da ese caso. Sin embargo, en credits tenemos una condición inversa que en el contexto de Tragedy equivale a comprobar si el tipo es tragedy. Así que en realidad, la condición siempre se cumplirá:

Figure 177
1 def credits(self, audience):
2     if True:
3         return Credits(0)
4 
5     return Credits(math.floor(audience / 5))

Todo el código fuera de la condición no se ejecuta y lo borramos, por lo que el resultante será:

Figure 178
1 def credits(self, audience):
2     return Credits(0)

El método amount llama sin más a otro método, así que podríamos integrar este último, así como eliminar referencias superfluas en el nombre del método que nos dice el importe extra. Tragedy quedará así y podremos eliminar también la propiedad type y todo lo relacionado con ella:

Figure 179
 1 class Tragedy(Play):
 2     def __init__(self, data):
 3         self._name = data['name']
 4 
 5     def name(self):
 6         return self._name
 7 
 8     def extra_amount_for_high_audience(self, audience):
 9         if audience <= 30:
10             return Amount(0)
11 
12         return Amount(1000 * (audience - 30))
13 
14     def credits(self, audience):
15         return Credits(0)
16 
17     def amount(self, audience):
18         return Amount(40000).add(self.extra_amount_for_high_audience(au\
19 dience))

Aplicamos el mismo tratamiento a Comedy. Empezamos duplicando Play y reemplazando todas las condicionales que verifican el tipo de tal modo que aquellas que chequean que el tipo es comedy sean siempre True y las que no siempre False:

Figure 180
 1 class Comedy(Play):
 2     def __init__(self, data):
 3         self._name = data['name']
 4         self._type = data['type']
 5 
 6     def name(self):
 7         return self._name
 8 
 9     def type(self):
10         return self._type
11 
12     def calculate_amount_for_comedy(self, audience):
13         return Amount(30000) \
14             .add(self.extra_amount_for_high_audience_in_comedy(audience\
15 )) \
16             .add(Amount(300 * audience))
17 
18     def extra_amount_for_high_audience_in_comedy(self, audience):
19         if audience <= 20:
20             return Amount(0)
21 
22         return Amount(10000 + 500 * (audience - 20))
23 
24     def calculate_amount_for_tragedy(self, audience):
25         return Amount(40000) \
26             .add(self.extra_amount_for_high_audience_in_tragedy(audienc\
27 e))
28 
29     def extra_amount_for_high_audience_in_tragedy(self, audience):
30         if audience <= 30:
31             return Amount(0)
32 
33         return Amount(1000 * (audience - 30))
34 
35     def credits(self, audience):
36         if False:
37             return Credits(0)
38 
39         return Credits(math.floor(audience / 5))
40 
41     def amount(self, audience):
42         if False:
43             return self.calculate_amount_for_tragedy(audience)
44         if True:
45             return self.calculate_amount_for_comedy(audience)
46 
47         raise ValueError(f'unknown type: {self.type()}')

A continuación, eliminaríamos todo el código muerto y que no se ejecuta porque ya no será llamado nunca.

Figure 181
 1 class Comedy(Play):
 2     def __init__(self, data):
 3         self._name = data['name']
 4 
 5     def name(self):
 6         return self._name
 7 
 8     def calculate_amount_for_comedy(self, audience):
 9         return Amount(30000) \
10             .add(self.extra_amount_for_high_audience_in_comedy(audience\
11 )) \
12             .add(Amount(300 * audience))
13 
14     def extra_amount_for_high_audience_in_comedy(self, audience):
15         if audience <= 20:
16             return Amount(0)
17 
18         return Amount(10000 + 500 * (audience - 20))
19 
20     def credits(self, audience):
21         return Credits(math.floor(audience / 5))
22 
23     def amount(self, audience):
24             return self.calculate_amount_for_comedy(audience)

Y rematamos integrando y cambiando nombres:

Figure 182
 1 class Comedy(Play):
 2     def __init__(self, data):
 3         self._name = data['name']
 4 
 5     def name(self):
 6         return self._name
 7 
 8     def extra_amount_for_high_audience(self, audience):
 9         if audience <= 20:
10             return Amount(0)
11 
12         return Amount(10000 + 500 * (audience - 20))
13 
14     def credits(self, audience):
15         return Credits(math.floor(audience / 5))
16 
17     def amount(self, audience):
18         return Amount(30000) \
19             .add(self.extra_amount_for_high_audience(audience)) \
20             .add(Amount(300 * audience))

Ahora vamos a ver como utilizar las nuevas clases especializadas. El lugar en el que se instancian objetos Play es aquí:

Figure 183
1 class Plays:
2     def __init__(self, data):
3         self._data = data
4 
5     def get_by_id(self, play_id):
6         return Play(self._data[play_id])

Una forma sencilla sería introducir un método factoría en Play que nos entregue la subclase adecuada:

Figure 184
 1 class Play:
 2     # ...
 3 
 4     @staticmethod
 5     def create(data):
 6         if data['type'] == "tragedy":
 7             return Tragedy(data)
 8         if data['type'] == "comedy":
 9             return Comedy(data)
10 
11         raise ValueError(f'unknown type: {data["type"]}')
12 
13     # ...

Y usarla:

Figure 185
1 class Plays:
2     def __init__(self, data):
3         self._data = data
4 
5     def get_by_id(self, play_id):
6         return Play.create(self._data[play_id])

Ahora, no queda más que eliminar todos los métodos y propiedades innecesarias en Play:

Figure 186
 1 class Play:
 2     @staticmethod
 3     def create(data):
 4         if data['type'] == "tragedy":
 5             return Tragedy(data)
 6         if data['type'] == "comedy":
 7             return Comedy(data)
 8 
 9         raise ValueError(f'unknown type: {data["type"]}')
10 
11     def credits(self, audience):
12         pass
13 
14     def amount(self, audience):
15         pass

El método factoría create decide qué subtipo concreto de Play se usará. Si en el futuro necesitamos dar soporte a más tipos no tenemos más que añadir una nueva clase y una nueva condición.

Por qué funciona

Esta regla suele ser más difícil de aceptar o entender si vienes de un estilo de programación procedural en el que conocer y controlar el estado lo es todo. Pero en programación orientada a objetos, cada objeto es responsable de su propio estado y de como implementa sus comportamientos. Por tanto, lo más importante es saber quién debe encargarse de qué, en lugar de tratar de obtener su estado y operar con él.

Cada objeto debe operar con su estado y comunicarse con otros objetos cuando necesite algo, o cuando quiera enviarles algo.

Al preguntar por una propiedad de otro objeto estamos acoplándonos a ese objeto, porque sabemos qué propiedad nos interesa y cómo obtenerla. Si usamos ese dato para un cálculo, es muy posible que ese objeto al que le preguntamos deba ejecutar ese cálculo. Por supuesto, puede ocurrir que el cálculo requiera alguna información del objeto que llama. Pero en ese caso la puede pasar como parámetro.

La regla de no usar getters, setters o propiedades públicas, nos fuerza a pensar en los objetos como cajas negras a las que podemos pedirles que hagan cosas. En pocas palabras, la regla nos dice que no debemos acceder al estado interno los objetos del sistema. Si necesitamos algo de ellos, tenemos que poder pedirles que lo hagan, aportando información si es necesario. Algunos lenguajes como Ruby fuerzan que todas las propiedades de un objeto sean privadas por defecto, aunque es fácil introducir getters o setters.

El hecho de no poder acceder al estado de los objetos es beneficioso para evitar el acoplamiento. Nos permite cambiar las implementaciones de los objetos de forma transparente al resto del sistema.

El resultado

¿Fin? No todavía

Este capítulo nos ha presentado la última regla de las Object Calisthenics. Pero todavía nos queda un capítulo más, en el que daremos algunos consejos y exploraremos algunas ideas más.

Comentarios finales

Aplicar object calisthenics, ¿mejora el diseño?

Sí, aplicando las reglas de object calisthenics el diseño del código mejora aunque no lleguemos a introducir patrones de diseño avanzados. En otras palabras, calisthenics te ayuda incluso si no tienes mucha experiencia en diseño de software.

En líneas generales, reducir el tamaño de los bloques de código y aplanar las estructuras indentadas ayuda a tener bloques y métodos más cohesivos centrados en torno a una sola responsabilidad bien acotada.

Encapsular primitivos y estructuras de datos nativas abre la puerta a una mejor asignación de las responsabilidades, moviendo comportamientos a los objetos a los que corresponden.

¿Hay un orden adecuado para aplicar las reglas?

No. Las reglas se aplican según lo necesitamos o nos parece más evidente que se pueden aplicar. Muchas veces, aplicar una regla genera situaciones que se abordan aplicando otra. Así que en realidad, lo que hacemos es observar fragmentos de código que violan una u otra regla y los arreglamos lo mejor posible.

El proceso es, por tanto, iterativo. Empiezas aplicando una regla cuya utilidad ves clara y vas haciendo pequeños commits con los cambios que ves que mejoran tu código. En algún momento, descubrirás oportunidades para aplicar otras reglas u otros refactorings y así sucesivamente.

¿Por dónde empezar?

Empieza aplicando la regla que te resulte más fácil o cuyos casos sean más evidentes. Por ejemplo, no usar abreviaturas es fácil de aplicar en casi cualquier código. Aplanar estructuras condicionales suele ser muy evidente y el refactor extraer método es sencillo de aplicar en un IDE moderno.

Encapsular tipos primitivos y estructuras de datos no es difícil, pero ya supone un trabajo extra porque tenemos que asegurar que en todos sus usos podemos hacer la sustitución. Sin embargo, una vez introducido un concepto como objeto, mover comportamiento viene de forma casi natural. En muchos caos puede ser la mejor regla para empezar, ya que luego tienes que estar menos pendiente si extraes métodos.

Eliminar la palabra clave else puede ser complicado si no aislamos las estructuras condicionales previamente, para lo cual es bueno haber aplicado antes la regla de un solo nivel de indentación.

No usar getters o setters puede ser muy sencillo en algunos casos, pero no ser evidente como hacerlo en otros. En uno de los ejemplos de estos artículos, introdujimos el patrón Visitor para hacerlo, pero no es uno de los más sencillos de aplicar precisamente.

¿Debo aplicar las reglas exhaustivamente en todo el código?

No. Céntrate sobre todo en la lógica de dominio, que es la que más te interesa que sea fácil de entender y de mantener en el futuro. Las mejoras del código en esta área son más prioritarias, porque los objetos tienen mayor significación. En las partes de implementación de infraestructura, los beneficios pueden no ser tan importantes, lo que no debería justificar un diseño chapucero.

Usa tu buen juicio. Céntrate en el código que sea importante.

Más consideraciones y ejemplos sobre algunas reglas

Más sobre encapsular primitivas

Me he dejado algunos valores primitivos sin encapsular. El criterio de prioridad para encapsular primitivas sería algo así como: Encapsula primitivos en objetos cuando:

  • El primitivo representa un concepto relevante del dominio o negocio de la aplicación.
  • El primitivo tiene reglas validación o comportamiento asociado que no es soportado por el propio tipo, lo que básicamente indica que el concepto es importante para el dominio.

Por ejemplo, tras aplicar la última regla a Play y extender en dos subclases, quedó de manifiesto que el cálculo de importe extra en relación con la audiencia era un comportamiento asociado al concepto de Audiencia. De hecho, el IDE señala esos métodos como candidatos a ser métodos estáticos. Por ejemplo, en Tragedy es así:

Figure 187
 1 class Tragedy(Play):
 2     # ...
 3 
 4     def extra_amount_for_high_audience(self, audience):
 5         if audience <= 30:
 6             return Amount(0)
 7 
 8         return Amount(1000 * (audience - 30))
 9 
10     #...

Y en Comedy, así:

Figure 188
 1 class Comedy(Play):
 2     # ...
 3 
 4     def extra_amount_for_high_audience(self, audience):
 5         if audience <= 20:
 6             return Amount(0)
 7 
 8         return Amount(10000 + 500 * (audience - 20))
 9 
10     # ...

Como se puede ver ninguno de los dos métodos depende de la clase que los contiene. Es cierto que podríamos extraer sus valores como propiedades de su clase. Sin embargo, fíjate que todo el cálculo se refiere solo al concepto de Audience. Hay un límite por encima del cual se genera un Amount extra. Si no se supera el límite Amount es cero.

Si igualamos la estructura de los métodos para que se parezcan lo más posible, quedaría una cosa así. Para Tragedy:

Figure 189
1 def extra_amount_for_high_audience(self, audience):
2     if audience <= 30:
3         return Amount(0)
4 
5     return Amount(0 + 1000 * (audience - 30))

Y para Comedy:

Figure 190
1 def extra_amount_for_high_audience(self, audience):
2     if audience <= 20:
3         return Amount(0)
4 
5     return Amount(10000 + 500 * (audience - 20))

Podríamos introducir una clase Audience que nos calcule el Amount extra, pasándole los parámetros necesarios:

Figure 191
 1 class Audience:
 2     def __init__(self, audience):
 3         self.audience = audience
 4 
 5     def amount(self, threshold, minimum, coeficient):
 6         if self.audience <= threshold:
 7             return Amount(0)
 8 
 9         return Amount(minimum + coeficient * (self.audience - threshold\
10 ))

Y podemos usarlo:

Figure 192
 1 class Tragedy(Play):
 2     def __init__(self, data):
 3         self._name = data['name']
 4 
 5     def name(self):
 6         return self._name
 7 
 8     def extra_amount_for_high_audience(self, audience):
 9         return Audience(audience).amount(30, 0, 1000)
10     
11     def credits(self, audience):
12         return Credits(0)
13 
14     def amount(self, audience):
15         return Amount(40000).add(self.extra_amount_for_high_audience(au\
16 dience))

O más simplificado:

Figure 193
 1 class Tragedy(Play):
 2     def __init__(self, data):
 3         self._name = data['name']
 4 
 5     def name(self):
 6         return self._name
 7 
 8     def credits(self, audience):
 9         return Credits(0)
10 
11     def amount(self, audience):
12         return Amount(40000).add(Audience(audience).amount(30, 0, 1000))

Ahora tendría sentido introducir las propiedades de Tragedy que representan los parámetros:

Figure 194
 1 class Tragedy(Play):
 2     def __init__(self, data):
 3         self._name = data['name']
 4         self.threshold = 30
 5         self.minimum_amount = 0
 6         self.coefficient = 1000
 7 
 8     def name(self):
 9         return self._name
10 
11     def credits(self, audience):
12         return Credits(0)
13 
14     def amount(self, audience):
15         return Amount(40000).add(Audience(audience).amount(self.thresho\
16 ld, self.minimum_amount, self.coefficient))

Nos quedaría código relacionado con Audience y la posibilidad de instanciar el objeto desde el principio. Personalmente, cuando se trata de refactors suele empezar a introducirlo lo más adentro y voy “sacando” el objeto un paso cada vez. Así, por ejemplo, en el caso de credits, la lógica tiene que ver con Audience, pero no está tan claro como aplicar la relación.

Más sobre límites de tamaño: parámetros y propiedades

Introducir Audience ha generado un problema, ya que la función para calcular el extra requiere tres parámetros y además hemos introducido tres propiedades más en las clases Play, ni más ni menos. En este caso, puede ser de aplicación el patrón Parameter Object para agruparlos. Sería algo así como ExtraAmountData:

Figure 195
 1 class ExtraAmountData:
 2     def __init__(self, threshold, minimum_amount, coefficient):
 3         self._threshold = threshold
 4         self._minimum_amount = minimum_amount
 5         self._coefficient = coefficient
 6 
 7     def threshold(self):
 8         return self._threshold
 9 
10     def minimum_amount(self):
11         return self._minimum_amount
12 
13     def coefficient(self):
14         return self._coefficient

Esto se tendría que aplicar más o menos así. En Audience:

Figure 196
1 def extra_amount(self, extra_amount_data):
2     if self.audience <= extra_amount_data.threshold():
3         return Amount(0)
4 
5     return Amount(extra_amount_data.minimum_amount() 
6                   + extra_amount_data.coeficient() 
7                   * (self.audience - extra_amount_data.threshold()))

Pero esto, sin embargo, no pinta bien. Audience no debería ser la responsable de calcular el extra, sino que es un dato necesario para hacerlo. Tendría más sentido que otro objeto dirija el cálculo sin exponer todos sus datos. Se podría considerar una especie de calculadora del importe extra basada en la audiencia, con coeficientes definidos por cada tipo de obra. Así que vamos a cambiar el concepto por completo.

Figure 197
 1 class ExtraAmountByAudience:
 2     def __init__(self, threshold, minimum_amount, coefficient):
 3         self._threshold = threshold
 4         self._minimum_amount = minimum_amount
 5         self._coefficient = coefficient
 6 
 7     def amount(self, audience):
 8         if audience <= self._threshold:
 9             return Amount(0)
10         return Amount(self._minimum_amount + self._coefficient * (audie\
11 nce - self._threshold))

Y esto se usaría así:

Figure 198
 1 class Tragedy(Play):
 2     def __init__(self, data):
 3         self._name = data['name']
 4 
 5     def name(self):
 6         return self._name
 7 
 8     def credits(self, audience):
 9         return Credits(0)
10 
11     def amount(self, audience):
12         return Amount(40000).add(ExtraAmountByAudience(30, 0, 1000).amo\
13 unt(audience))

Los tres parámetros de ExtraAmountByAudience son bastante crípticos. Una posible solución es usar un patrón builder:

Figure 199
 1 class ExtraAmountByAudience:
 2     def __init__(self):
 3         self._threshold = 0
 4         self._minimum_amount = 0
 5         self._coefficient = 1
 6 
 7     def when_audience_greater_than(self, threshold):
 8         self._threshold = threshold
 9         return self
10 
11     def minimum_amount_of(self, minimum_amount):
12         self._minimum_amount = minimum_amount
13         return self
14 
15     def and_coefficient(self, coefficient):
16         self._coefficient = coefficient
17         return self

Con lo cual, podemos hacer una construcción más expresiva:

Figure 200
 1 class Comedy(Play):
 2     def __init__(self, data):
 3         self._name = data['name']
 4 
 5     def name(self):
 6         return self._name
 7 
 8     def credits(self, audience):
 9         return Credits(math.floor(audience / 5))
10 
11     def amount(self, audience):
12         calculator = ExtraAmountByAudience().
13             when_audience_greater_than(20).
14             minimum_amount_of(10000).
15             and_coefficient(500)
16 
17         return Amount(30000)
18             .add(calculator.amount(audience))
19             .add(Amount(300 * audience))

¿Sobre-ingeniería?

Comentarios

El objetivo de este libro no era tanto llegar a un diseño de código final, como mostrar que aplicando las reglas de Calisthenics es posible mejorar el diseño del software a través de dos caminos. El más simple consiste en aplicar las reglas tal cual. El segundo consiste en avanzar a partir de ese punto, descubriendo oportunidades para aplicar patrones de refactoring más avanzados.

Acoplamiento temporal al imprimir la factura

Cuando se publicó este proyecto en el blog, Josemi, un lector, señaló un caso de acoplamiento temporal dado que StatementPrinter no controla el orden en que se imprimen los elementos del Statement. Esto es debido a que no hay una separación entre la obtención de los datos y su impresión. El método fill obtiene el dato e imprime la línea. De ese modo, el control lo tiene Invoice, así que bastaría cambiar el orden de las llamadas en Invoice para romper la impresión del Statement.

Esta es una primera aproximación muy basta, pero suficiente para hacernos a la idea y que elimina el acoplamiento temporal:

Figure 201
 1 class StatementPrinter:
 2     def __init__(self, printer):
 3         self._printer = printer
 4         self._customer = None
 5         self._amount = None
 6         self._credits = None
 7         self._lines = []
 8 
 9     def print(self):
10         self._printer.print(f'Statement for {self._customer}\n')
11         for line in self._lines:
12             self._printer.print(f' {line["title"]}: {FormattedAmount(li\
13 ne["amount"]).dollars()} ({line["audience"]} seats)\n')
14 
15         self._printer.print(f'Amount owed is {FormattedAmount(self._amo\
16 unt).dollars()}\n')
17         self._printer.print(f'You earned {self._credits.current()} cred\
18 its\n')
19 
20         return self._printer.output()
21 
22     def fill(self, template, *args):
23         getattr(self, '_fill_' + template)(*args)
24 
25     def _fill_credits(self, credits):
26         self._credits = credits
27 
28     def _fill_amount(self, amount):
29         self._amount = amount
30 
31     def _fill_customer(self, customer):
32         self._customer = customer
33 
34     def _fill_line(self, title, amount, audience):
35         self._lines.append({"title": title, "amount": amount, "audience\
36 ": audience})

Ahora podría cambiar el orden de las líneas en Invoice, sin afectar al resultado:

Figure 202
1     def fill(self, statement_printer):
2         for performance in self._performances:
3             performance.fill(statement_printer)
4         statement_printer.fill('credits', self._credits())
5         statement_printer.fill('amount', self._amount())
6         statement_printer.fill('customer', self._customer)

Más sobre colecciones de primera clase

Otra sugerencia de Josemi es que la clase Performances, que contiene la colección de actuaciones se encargue también de controlar el orden en que se envían las líneas a StatementPrinter, en lugar de Invoice. Me parece una propuesta interesante. Sería una aplicación del principio Tell, don’t ask. Invoice le pide a Performances que realice la coordinación y cálculos que ahora mismo se hacen en Invoice, que quedaría así:

Figure 203
 1 from domain.performance import Performances
 2 from domain.play import Plays
 3 
 4 
 5 class Invoice:
 6     def __init__(self, data, plays):
 7         self._data = data
 8         self._customer = data['customer']
 9         self._performances = Performances(data['performances'], Plays(p\
10 lays))
11 
12     def _amount(self):
13         return self._performances.amount()
14 
15     def _credits(self):
16         return self._performances.credits()
17 
18     def fill(self, statement_printer):
19         statement_printer.fill('credits', self._credits())
20         statement_printer.fill('amount', self._amount())
21         statement_printer.fill('customer', self._customer)
22         self._performances.fill(statement_printer)

Mientras que Performances podría quedar así, una vez eliminado el código para hacerla iterable que ya no es necesario:

Figure 204
 1 class Performances:
 2     def __init__(self, data, plays):
 3         self._data = data
 4         self._plays = plays
 5 
 6     def amount(self):
 7         amount = Amount(0)
 8         for data in self._data:
 9             performance = self._performance(data)
10             amount = amount.add(performance.amount())
11 
12         return amount
13 
14     def _performance(self, data):
15         return Performance(data['audience'], self._plays.get_by_id(data\
16 ['playID']))
17 
18     def credits(self):
19         volume_credits = Credits(0)
20         for data in self._data:
21             performance = self._performance(data)
22             volume_credits = volume_credits.add(performance.credits())
23 
24         return volume_credits
25 
26     def fill(self, statement_printer):
27         for data in self._data:
28             performance = self._performance(data)
29             performance.fill(statement_printer)

El resultado

¿Fin?

Esta vez, sí. Lo cierto es que probablemente todavía podríamos introducir mejoras en el código. Hay algunas clases que no me convencen del todo, como ExtraAmountByAudience o StatementPrinter. Seguramente esconden aún problemas en el diseño que no he sido capaz de ver.