¿Calisthenics? ¡ A dónde vamos no necesitamos calisthenics!

07 Feb 2022
Intento mantenerme consistente y con frecuencia hago afirmaciones 
salvajes con la secreta esperanza de que alguien las ponga en duda y
tenga la oportunidad de explicarme o de aprender algo nuevo.
¿A qué viene esto? Pues a que afirmé, en cierto contexto, el título del artículo y
hubo una suerte de reto (gracias a Christopher Eyre).

Buscaba uns kata que hacer como parte de mi puesta en marcha en Codurance y debido a que hacer katas y TDD ha sido nuclear en mi carrera (descubrí el movimiento Software Craftmanship hace bastantes años, a la salida del libro de Sandro), Chris sugirió hacer primitive obsession con object calisthenics.

Claro que en ocasiones (muchas, muchas ocasiones) puedo ser un bocachancla de talla mundial y no se me ocurrió mejor forma de dispararme en el pie que afirmar que, haciendo programación funcional, los puntos de object calisthenics se cumplen de forma automática. Así que sugirió que lo hiciese, que escribiese un artículo y que, para darle un giro, lo hiciese con un lenguaje funcional, siendo F# mi favorito, el rumbo de colisión estaba establecido.

Hice el ejercicio y en caso de que seas de esos que echan un ojo al final del libro antes de comprarlo: No, no terminé con código “calisténico”.

Si no eres de los que echan un vistazo al final del libro: Los libros son largos y el número de libros que tienes tiempo de leer en tu vida es limitado, empieza a hacerlo, te ahorrarás un montón de tiempo.

No sería un auténtico bocachancla si no echase sal en la herida cuando me sale el tiro por la culata, así que: Tras guiarte en el proceso que seguí, miraremos el resultado e intentaré explicar por qué los puntos que no se cumplen son no significativos. Me saltaré los cambios en los test, dado que son los mismos que haríamos en programación funcional, con la notable excepción del último paso.

El ejercicio

Aviso a navegantes: La estructura y alineación del código es cosa de Fantomas, siguiendo los estándares por defecto. No tabulo yo mismo, una máquina puede hacerlo mejor y más rápido.

También: F# es un lenguaje estático con una fuerte capacidad de inferencia. No es necesaria la anotación de tipos si el compilador puede inferir. Hay escuelas que abogan por anotar todo en guerra con los que abogan por usar la inferencia siempre que se pueda. Los argumentos sobre legibilidad son débiles tanto a favor como en contra, pero parecen fuertes a favor cuando se viene de fuera y fuertes en contra cuando se está dentro. Hay una mejora en el tiempo de compilación al anotar, pero es negligible. Los puntos más fuertes a favor y en contra son: “La inferencia puede, en ocasiones poco comunes, provocar efectos inesperados que son difíciles de detectar”; “Con una sintaxis tan limpia, añadir anotaciones diluye el protagonismo de la lógica”; “Anotar hace más difícil refactorizar, porque con frecuencia se refactoriza haciendo elevación de tipos con comportamientos similares y la inferencia es capaz de usar los nuevos tipos con poco o ningún cambio”. Dado que la utilidad de las herramientas para refactorizar en F# se encuentran en algún punto entre la de un martillo sin la parte de sacar clavos y la de una escopeta de feria, me veo en la escuela de no anotar. No puedo, no obstante, negar que es mayormente una cuestión de gustos y la única cosa que puedo afirmar es que la consistencia tiene que triunfar, elige una línea y cíñete a ella por todo tu código.

Empecé replicando el andamiaje del ejercicio en F#, dado que el lenguaje no soporta retorno prematuro, algunas cosas parecen diferentes, pero el comportamiento está replicado:

type ProfitCalculatorOriginal(localCurrency: string) =

 let rates =

   [ ("GBP", 1.0)

     ("USD", 1.6)

     ("EUR", 1.2) ]

   |> Map.ofSeq

 

 let mutable localAmount = 0

 let mutable foreignAmount = 0

 

 do

   try

     rates.[localCurrency] |> ignore

   with

   | _ -> invalidArg (nameof localCurrency) "Was not a valid currency"

 

 member _.add amount currency incoming =

   let mutable realAmount: int = amount

 

   let exchangeRate =

     rates.TryFind currency

     |> Option.map (fun incomingRate -> incomingRate / rates.[localCurrency])

 

   realAmount <-

     exchangeRate

     |> Option.map (fun rate -> ((realAmount |> float) / rate) |> int)

     |> Option.defaultValue realAmount

 

   if not incoming then

     do realAmount <- -realAmount

 

   if localCurrency = currency then

     do localAmount <- localAmount + realAmount

   else

     do foreignAmount <- foreignAmount + realAmount

 

 member _.calculateTax =

   match localAmount with

   | amount when amount < 0 -> 0

   | amount -> ((amount |> float) * 0.2) |> int

 

 member this.calculateProfit =

   localAmount - this.calculateTax + foreignAmount

1. Introduce un tipo “Currency” como clase o enumeración; Úsalo en ProfitCalculator

En estos casos, se usan uniones discriminadas en F#:

type Currency =

 | GBP

 | USD

 | EUR

 

type ProfitCalculator(localCurrency: Currency) =

 // Dado que ahora usamos una unión discriminada

 // la colapsamos en lugar de usar un índice.

 let getRate =

   function

   | GBP -> 1.0

   | USD -> 1.6

   | EUR -> 1.2

 

 let mutable localAmount = 0

 let mutable foreignAmount = 0

 

 member _.add amount currency incoming =

   let mutable realAmount: int = amount

 

   let exchangeRate = (getRate currency) / (getRate localCurrency)

 

   realAmount <- ((realAmount |> float) / exchangeRate) |> int

 

   if not incoming then

     do realAmount <- -realAmount

 

   if localCurrency = currency then

     do localAmount <- localAmount + realAmount

   else

     do foreignAmount <- foreignAmount + realAmount

//...

 

2. Crea un tipo “ExchangeRates” para la colección; Úsala en “ProfitCalculator”

 

Esto no se aplica a las uniones discriminadas, dado que no necesitamos una colección, el reconocimiento de patrones actúa como un mapa para los valores con exhaustividad forzada en tiempo de compilación. Aproveché, no obstante, para introducir un tipo para el ratio de conversión.

// La facilidad para tipar primitivos es uno de los puntos fuertes de F#.

type ExchangeRate =

 | Rate of float

 // Esto es una sobrecarga del operador división con reconocimiento de patrones

 // (Rate a, Rate b) se aplica cuando ambos elementos son del caso `Rate`,

 // lo que siempre pasará, porque solamente tenemos un caso.

 // La belleza de este sistema es que este operador no compilará si añadimos más

 // casos y no añadimos sobrecargas del operador para cada pareja

 // posible de casos.

 static member (/)(Rate a, Rate b) = (a / b) |> Rate

 static member get =

   function

   | GBP -> Rate 1.0

   | USD -> Rate 1.6

   | EUR -> Rate 1.2

//...

type ProfitCalculator(localCurrency: Currency) =

 let mutable localAmount = 0

 let mutable foreignAmount = 0

 let applyRate amount =

   function

   | Rate rate -> ((amount |> float) / rate) |> int

 member _.add amount currency incoming =

   let mutable realAmount: int = amount

   let exchangeRate =

     (ExchangeRate.get currency)

     / (ExchangeRate.get localCurrency)

   realAmount <- applyRate realAmount exchangeRate

//...

3. Crea una clase “Money”. Identifica todas las operaciones de cantidades usadas en “ProfitCalculator” y añádelas

 

// Para hacer esto, necesito necesito una operación para obtener el ratio de

// entre monedas.

// Me di cuenta de que esto no pertenece a una calculadora de beneficios, sino

// a un módulo financiero, “Finance” y empecé a mover datos y lógica allí.

module Finance =

 type Currency =

   | GBP

   | USD

   | EUR

 

 type ExchangeRate =

   | Rate of float

 

   static member (/)(Rate a, Rate b) = (a / b) |> Rate

 

   static member get =

     function

     | GBP -> Rate 1.0

     | USD -> Rate 1.6

     | EUR -> Rate 1.2

 

 // El susodicho operador para obtener el ratio:

 let (>>=>) (a: Currency) (b: Currency) = ExchangeRate.get a / ExchangeRate.get b

 

 type Money = { Amount: int; Currency: Currency }

 

 let add local other =

   other.Currency >>=> local.Currency

   |> applyRate other.Amount

   |> fun amount -> { Amount = local.Amount + amount; Currency = local.Currency }

4. Cambia “ProfitCalculator” y sus tests para usar la clase “Money”

Simple y reduce el código utilizado.

open Finance

type ProfitCalculator(localCurrency: Currency) =

 let mutable localAmount = { Amount = 0; Currency = localCurrency }

 let mutable foreignAmount = { Amount = 0; Currency = localCurrency }

 member _.add money incoming =

   let money = if incoming then money else { money with Amount = -money.Amount }

   if money.Currency = localAmount.Currency then

     do localAmount <- add localAmount money

   else

     do foreignAmount <- add foreignAmount money

 member _.calculateTax =

   match localAmount.Amount with

   | amount when amount < 0 -> { Amount = 0; Currency = localCurrency }

   | amount -> { Amount = ((amount |> float) * 0.2) |> int; Currency = localCurrency }

 member this.calculateProfit =

   foreignAmount

   // Esta es la operación "add" del módulo “Finance”.

   |> add localAmount

   |> add { this.calculateTax with Amount = -this.calculateTax.Amount }

 

5. Crea una clase abstracta “Item” con el método “Money amount();”

 

6. Crea las clases “Outgoing” e “Incoming” implementando “Item”. “Outgoing” tiene una cantidad negativa

La herencia no es la forma preferida de trabajar en programación funcional, las uniones discriminadas, por otro lado…

// Decidí llamarla “Transaction”

type Transaction =

| Incoming of Money

| Outgoing of Money

De verdad que este es todo el código necesario para hacer los puntos 5 y 6.

7. Cambia “ProfitCalculator” y sus tests para usar “Item”

type ProfitCalculator(localCurrency: Currency) =

//...

 member _.add transaction =

   let money =

     match transaction with

     | Incoming i -> i

     | Outgoing o -> { o with Amount = -o.Amount }

   if money.Currency = localAmount.Currency then

     do localAmount <- add localAmount money

   else

     do foreignAmount <- add foreignAmount money

//...

Incluyo uno de los tests para aquellos no familiarizados con las uniones discriminadas:

[<Fact>]

let ``Handles outgoins`` () =

 let calculator = ProfitCalculator(GBP)

 calculator.add

 <| Incoming { Amount = 500; Currency = GBP }

 calculator.add

 <| Incoming { Amount = 80; Currency = USD }

 calculator.add

 <| Outgoing { Amount = 360; Currency = EUR }

 Assert.Equal({ Amount = 150; Currency = GBP }, calculator.calculateProfit)

 Assert.Equal({ Amount = 100; Currency = GBP }, calculator.calculateTax)

 

8. Crea una clase “Items” que abstraiga una colección de “Item” y úsala en “ProfitCalculator”

Hice trampas, vi venir hacia donde iba y asumí que las ventajas de tener la clase “Items” las tendríamos automáticamente (me equivocaba).

9. Crea el método “boolean isIn(Currency)” en la clase “Item”

En programación funcional separamos los datos de la lógica, así que implementé una función en el módulo de finanzas que recibe los tipos “Currency” y ”Transaction”.

module Finance =

//...

 let isIn currency =

   function

   | Incoming i -> i.Currency = currency

   | Outgoing o -> o.Currency = currency

 

 // Que es una abreviatura de:

 let isIn currency transaction =

   match transaction with

   | Incoming i -> i.Currency = currency

   | Outgoing o -> o.Currency = currency

 

// Y sí, no me dí cuenta de que estaba abreviando.

// Tengo a la policía calisténica rondando mi casa.

//...

Para aquellos que vienen de un trasfondo orientado a objetos, me explico un poco. El tipo de esta función es:

Currency -> Transaction -> bool

 

// Este orden de parámetros es duro de leer si se viene desde la perspectiva de

// clases con métodos, porque un método pintaría tal que así:

let inEuros = transaction.isIn(EUR)

 

// Y una función con parámetros en tupla (lo normal en OOP) sería:

let inEuros = isIn EUR transaction

 

// Perdón por el anglicismo, pero me verán muerto antes de que hable

// de “entubar” parámetros.

// F# soporta “piping”:

let inEuros =

 transaction

 |> isIn EUR

// Y la aplicación parcial de funciones

let isInEur = isIn EUR

let inEuros =

 transaction

 |> isInEur

 

10. Crea “Money amountIn(Currency)” en “Items”

module Finance =

//...

 let amountIn currency transactions =

   ({ Amount = 0; Currency = currency }, transactions |> List.filter (isIn currency))

   ||> List.fold

         (fun acc trx ->

           let money =

             match trx with

             | Incoming i -> i

             | Outgoing o -> o

 

           add acc money)

//...

11. Cambia “ProfitCalculator.calculateTax()” para consumir los pasos 9 y 10

type ProfitCalculator(localCurrency: Currency) =

//...

 member _.calculateTax =

   match amountIn localAmount.Currency transactions with

   | money when money.Amount < 0 -> { money with Amount = 0 }

   | money ->

     { money with

         Amount = ((money.Amount |> float) * 0.2) |> int }

//...

12. Elimina “localAmount” de “ProfitCalculator”

module Finance =

//...

 type Money =

   { Amount: int

     Currency: Currency }

 

   // Cambié la función "add" por un operador "+".

   static member (+)(local: Money, other: Money) =

     other.Currency >>=> local.Currency

     |> applyRate other.Amount

     |> fun amount ->

          { Amount = local.Amount + amount

            Currency = local.Currency }

//...

open Finance

 

type ProfitCalculator(localCurrency: Currency) =

 

 let mutable foreignAmount = { Amount = 0; Currency = localCurrency }

 let mutable transactions: Transaction list = []

 

 member _.add transaction =

   transactions <- transaction :: transactions

 

   let money =

     match transaction with

     | Incoming i -> i

     | Outgoing o -> o

 

   if money.Currency = localCurrency |> not then

     do foreignAmount <- foreignAmount + money

 

 member _.calculateTax =

   match amountIn localCurrency transactions with

   | money when money.Amount < 0 -> { money with Amount = 0 }

   | money ->

     { money with

         Amount = ((money.Amount |> float) * 0.2) |> int }

 

 member this.calculateProfit =

   let tax = this.calculateTax

   amountIn localCurrency transactions

   + foreignAmount

   + { tax with

         Amount = -tax.Amount }

Salvando la parte de “Transaction list”, cumplimos con los mandamientos principios de object calisthenics.

13. Crea “Items notIn(currency)” y “Money amountIn(Currency, ExchangeRates)” en “Items”

module Finance =

//...

 let isIn currency =

   function

   | Incoming i -> i.Currency = currency

   | Outgoing o -> o.Currency = currency

 

 // Al componer (>>) la función "isIn currency" con la función "not"

 // tenemos "isNotIn currency", legibilidad para aburrir.

 let isNotIn currency = isIn currency >> not

 

  // Si te sigue doliendo leer esto, aguanta, que luego empezó

 // a molestarme y lo terminé arreglando.

 let private amount currency transactions =

   ({ Amount = 0; Currency = currency }, transactions)

   ||> List.fold

         (fun acc trx ->

           let money =

             match trx with

             | Incoming i -> i

             | Outgoing o -> o

 

           acc + money)

 

 let amountIn currency transactions =

   amount currency (transactions |> List.filter (isIn currency))

 

 let amountNotIn currency transactions =

   amount currency (transactions |> List.filter (isNotIn currency))

//...

14. Simplifica “ProfitCalculator”, eliminando toda la lógica de “add(Item)”. Simplifica “calculateProfit()”

 

type ProfitCalculator(localCurrency: Currency) =

 let mutable transactions: Transaction list = []

 

 member _.add transaction =

   transactions <- transaction :: transactions

 

 member _.calculateTax =

   match amountIn localCurrency transactions with

   | money when money.Amount < 0 -> { money with Amount = 0 }

   | money ->

     { money with

         Amount = ((money.Amount |> float) * 0.2) |> int }

 

 member this.calculateProfit =

   let tax = this.calculateTax

 

   amountIn localCurrency transactions

   + amountNotIn localCurrency transactions

   + { tax with

         Amount = -this.calculateTax.Amount }

Fácil, rápido y para toda la familia, pero aún no estamos listos, porque esto no es funcional, llamar a “add” varias veces nos daría resultados distintos, lo que nos lleva al paso final:

15. Hacerlo funcional y no sólo que funcione

La programación funcional gira en torno al concepto de transparencia referencial, que es la capacidad de reemplazar cualquier llamada a una función con ciertos parámetros con su resultado. No podemos hacer eso con un objeto que encapsula estado, así que creamos un nuevo tipo “Balance” que representará nuestro estado y convertiremos “ProfitCalculator” en un módulo con un conjunto de operaciones sobre el susodicho tipo, retornando una copia con el nuevo “Balance” cada vez que realicemos una transacción.

Esto sacó a la luz algunos problemas que fueron resueltos.

Finalmente, convertí “Finance” en un espacio de nombres y creé sub módulos, los módulos están divididos en ficheros en el repositorio, aquí los dejé juntos.

module Finance =

 module Currencies =

   type Currency =

     | GBP

     | USD

     | EUR

 

   type ExchangeRate =

     | Rate of float

 

     static member (/)(Rate a, Rate b) = (a / b) |> Rate

 

   module ExchangeRate =

     let get =

       function

       | GBP -> Rate 1.0

       | USD -> Rate 1.6

       | EUR -> Rate 1.2

 

   let private (>>=>) source destination =

     ExchangeRate.get source

     / ExchangeRate.get destination

 

   type Money =

     { Amount: int

       Currency: Currency }

     static member (+)(local: Money, other: Money) =

       let applyRate amount =

         function

         | Rate rate -> ((amount |> float) / rate) |> int

 

       other.Currency >>=> local.Currency

       |> applyRate other.Amount

       |> fun amount ->

             { Amount = local.Amount + amount

               Currency = local.Currency }

 

 

 module Trading =

   open Currencies

 

   type Transaction =

     | Incoming of Money

     | Outgoing of Money

 

   // Antes me salté la creación de "money".

   let money =

     function

     | Incoming incoming -> incoming

     | Outgoing outgoing -> outgoing

 

   // Aquí me di cuenta de que en el punto 8 no veía hacia donde iba, no.

   type Transactions = Transactions of Transaction list

 

   let transactionList =

     function

     | Transactions transactions -> transactions

 

 

 module Accounting =

   open Currencies

   open Trading

 

   //  La susodicha entidad, el estado de una cuenta, abstraído.

   type Balance =

     { Transactions: Transactions

       LocalCurrency: Currency }

 

   let isIn currency transaction =

     transaction

     |> money

     |> fun money -> money.Currency = currency

 

   let isNotIn currency = isIn currency >> not

 

   // Dije que lo arreglaría. ¿No?

   let amount currency transactions =

     let aggregate moneySoFar transaction = moneySoFar + (transaction |> money)

 

     ({ Amount = 0; Currency = currency }, transactions)

     ||> List.fold aggregate

 

   let add transaction balance =

     balance.Transactions

     |> transactionList

     |> fun transactions ->

         { balance with

             Transactions = Transactions <| transaction :: transactions }

 

 

 module Taxes =

   open Accounting

   open Trading

 

   // Se volvió obvio que el propósito de estas funciones era calcular

   // la cantidad gravable y la libre de impuestos.

   let taxableAmount balance =

     amount

       balance.LocalCurrency

       (balance.Transactions

        |> transactionList

        |> List.filter (isIn balance.LocalCurrency))

 

   let taxFreeAmount balance =

     amount

       balance.LocalCurrency

       (balance.Transactions

        |> transactionList

        |> List.filter (isNotIn balance.LocalCurrency))

 

 

 module Profits =

   open Accounting

   open Taxes

 

   let add transaction balance = balance |> add transaction

 

   let calculateTax balance =

     match taxableAmount balance with

     | money when money.Amount < 0 -> { money with Amount = 0 }

     | money ->

       { money with

           Amount = ((money.Amount |> float) * 0.2) |> int }

 

   let calculateProfit balance =

     let tax = calculateTax balance

 

     taxableAmount balance

     + taxFreeAmount balance

     + { tax with Amount = -tax.Amount }

 

// Aquí, el test más largo para ilustrar un punto de más abajo:

// En la programación funcional, controlamos el flujo de los datos.

[<Fact>]

let ``Everything is reported in the local currency`` () =

 // Cada “piping” toma el resultado del paso anterior y se lo pasa

 // a la siguiente función. Tenemos el control del flujo de datos, hacemos

 // cada nueva operación con el resultado de la anterior.

 let balance =

   eurBalance

   |> Profits.add (Incoming { Amount = 400; Currency = GBP })

   |> Profits.add (Outgoing { Amount = -200; Currency = USD })

   |> Profits.add (Incoming { Amount = 200; Currency = EUR })

 

 Assert.Equal({ Amount = 491; Currency = EUR }, Profits.calculateProfit balance)

 Assert.Equal({ Amount = 40; Currency = EUR }, Profits.calculateTax balance)

¿Y bien? ¿Qué pasa con el asunto object calisthenics?

  1. Sólo un nivel de tabulación por método ✅

    En cuanto al problema en el que se centra este punto: Carga cognitiva, cumple. Cada línea es una expresión que colapsa en un valor de un mismo tipo y cada rama puede leerse de forma independiente al resto de la función. De hecho todo lo que está tabulado es, técnicamente, una sola línea, pero Fantomas la rompe para cumplir convenciones.

    Me planteé hacer “trampas” y unirlas en una sola línea, pero hace el código menos legible, yendo en contra del propósito de reducir la carga cognitiva.


  2. No usar “else”❔

    Técnicamente no se cumple, porque hay bastante reconocimiento de patrones, lo que es equivalente a expresiones switch con un único “return” en cada caso. De nuevo, esta regla va sobre reducir carga cognitiva, un objetivo que el reconocimiento de patrones cumple: Cargar el mínimo de código en tu cerebro para entender qué diantres está pasando.


  3. Envolver todos los primitivos ✅

     Un paseo por el campo con las uniones discriminadas de un sólo caso.


  4. Un tipo para cada colección ✅


  5. Un punto por línea ✅


  6. No abreviar ✅

    No he abreviado, pero este punto es, y no intento empezar una guerra santa con ello, probablemente un ejercicio de futilidad. En un estudio impresionantemente exhaustivo de 2017 el equipo no encontró diferencias significativas en el tiempo invertido o en la calidad cuando se trataba de arreglar bugs. Ojo, el estudio sólo habla de arreglar bugs, pero es el único estudio empírico que he encontrado al respecto.

    Partiendo de esto, parece que es cosa de gustos. No abogo por abreviar, ojo, si no hay diferencia y ya estamos acostumbrados a ello, es un desperdicio hacer cambios, citando a Miyamoto Musashi en El libro de los cinco anillos: “No hagas nada que no sirva a propósito alguno”.


  7. Mantener todas las entidades pequeñas ✅


  8. Ninguna clase con más de dos variables de instancia ✅

    La mutación se evita, ergo, no variables de instancia en absoluto.


  9. Ni “getters”, ni “setters”, ni propiedades❌

    No hay “setters”, dado que los registros son inmutables, pero dado que la lógica y los datos viven en espacios separados, estos tienen que hacer que sus datos sean accesibles para lectura.

    De nuevo, el propósito del punto no se viola. La encapsulación protege del acceso no deseado a los datos, pero con inmutabilidad no hay acceso real a los datos, sino la creación de copias. El control está siempre en el consumidor de la lógica y las funciones son cajas negras que reciben datos y retornan otros datos.


Siete de nueve puntos se cumplen con precisión y de forma casual, el que falta se cumple de forma efectiva y del último, que es incompatible en el paradigma funcional, se cumple su meta de otra forma.

La programación funcional es un paradigma con un precio. El código funcional es, con frecuencia, más lento y consume más memoria, la curva de aprendizaje inicial es brusca y requiere desaprender bastantes perspectivas que son prácticamente reflejos, pero el valor que obtenemos de ello de forma regular es comparable a lo obtenido con prácticas de diseño extremo como object calisthenics.

Puede que sea un bocachancla, pero de esta, me da que me he librado.