jueves, 5 de marzo de 2020

Composición de software - Canalización de funciones con aridad heterogénea en JavaScript

La canalización de funciones es una de las partes más importantes para el desarrollo de software usando el paradigma de programación funcional.

Si revisas las entradas del gran Eric Elliott en Medium verás una línea de código extremadamente sagaz:

const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);


Como se puede observar; la función pipe retorna una función que aplica una reducción ejecutadora al arreglo de funciones pasadas por parámetro partiendo del valor inicial.
La idea es bastante guay pero; para que el efecto "enlightenment" sea más pronunciado gracias a que lo ha conseguido hacer en una sola línea de código; el autor ha omitido unos cuantos matices que resultan en que la utilidad de esta función se reduzca considerablemente hasta un límite casi marginal.

Lo ideal es que las funciones no tengan por que saber que van a ser consumidas siguiendo un contrato preestablecido (en este caso, que van a ser usadas mediante la canalización) y por lo tanto puedan cumplir con su responsabilidad única sin preocuparse del exterior.

Como se puede apreciar en el código de arriba, para que las funciones se puedan canalizar; la salida de una función tiene que ser de cierta manera "compatible" con la entrada de la siguiente función. En javaScript siempre puedes aceptar y retornar un objeto como "bolsa de información" y cada función puede acceder solo a la parte de la "bolsa" que necesita y retornar otra bolsa modificada aplicando sus reglas pertinentes pero eso implica que la función tiene que ser modelada explícitamente para manejar la "bolsa" de entrada y para retornar la "bolsa" modificada (u otra bolsa nueva con la nueva información si vamos en plan inmutable).

A mi me gustaría que la función pudiese solo recibir la información en la que está interesada y pudiese devolver la información que ha calculado y/o modificado. Esto se puede solucionar decorando la función original sin tener que modificarla.

Otro problema que tiene es el control de errores. La propuesta de Eric no tiene ningún control para el cortocircuito del flujo de ejecución y asume que todas las salidas de las funciones van a ser operaciones correctas o por lo menos salidas procesables como monadas Maybe o Either.

Desgraciadamente JavaScript no ofrece todas las facilidades necesarias para cumplir al 100% estas características pero de seguro que se puede mejorar utilizando la desestructuración de objetos literales y la asignación de propiedades enumerables.

Para que podamos obtener los resultados debemos crear funciones de orquestación que decoren las funciones del nucleo principal del dominio y debemos modificar ligeramente la función presentada por Eric para el cortocircuitado.

//module Rules
function isPlayerTurn(gameState, player) {
  return gameState.currentPlayerTurn === player;
}
 
function operationCost(operation) {
  return 2//fake value for example purposes
}
 
function playerHasEnergy(gameState, cost) {
  return gameState.currentPlayerTurn.energy >= cost;
}
 
function executeGameOperation(gameState, operation, target) {
  //make changes needed for the operation and target
  return gameState;
}
 
function endGamePlayerTurn(gameState, player) {
  //make changes for end turn
  return gameState;
}
 
//module orchestation code
const checkIsNotPlayerTurn = ({ gameState, player }) =>
  !isPlayerTurn(gameState, player) ? null : {};
 
const obtainOperationCost = ({ operation }) =>
  ({ cost: operationCost(operation) });
 
const checkNotEnoughEnergy = ({ gameState, cost }) =>
  !playerHasEnergy(gameState, cost) ? null : {};
 
const executeOperation = ({ gameState, operation, target }) =>
  executeGameOperation(gameState, operation, target); //will modify gameState

const endTurn = ({ gameState, player }) =>
  endGamePlayerTurn(gameState, player); //will modify gameState
  
//until: (value:any) => boolean - on true pipe will shortcircuit
function pipe(until, ...fns) {
 
  return (input) => {
 
    for (fnc of fns) {
      //execute fnc with the output of the previous fnc or with the initial value
      temp = fnc(input);
      if (until(temp)) return temp; //shortcircuit if true
      input = Object.assign(input, temp); //merge outputs to pass it to next fncs
    }
    return input;
  };
}
 
const pipeUntilNull = pipe.bind(undefined, value => value === null);
 
const executeOperationWorkFlow = pipeUntilNull(
  checkIsNotPlayerTurn
  , obtainOperationCost
  , checkNotEnoughEnergy
  , executeOperation
);
 
const endTurnWorkFlow = pipeUntilNull(
  checkIsNotPlayerTurn //reusing orchestation fncs for any workflow we need
  , endTurn
);
 
//how to use
let result = executeOperationWorkFlow({ gameState, player, operation, target });
if (!result) { console.log('unable to perform the requested operation'); }
else { console.log('requested operation succeful applied'); }
 
result = endTurnWorkFlow({ gameState, player });
if (!result) { console.log('you can not end turn if it is not your turn!!!'); }
else { console.log('player ends turn'); }




Repasemos como nos ha quedado:

- Mantenemos las funciones principales de las reglas del juego con una responsabilidad única e independientes de como van a ser consumidas.
- Podemos cortar el flujo de trabajo en cualquier momento.
- Las funciones de orquestación solo usan la información en la que están interesadas.
- La cadena de inputs/outputs se puede extender. Fijaos como inicialmente no tenemos el valor del coste de la operación (cost) pero la podemos añadir en cualquier momento y a partir de ahí está disponible para cualquier otra función que la necesite.
- Se pueden seguir usando las funciones del Nucleo en cualquier sitio que necesitemos sin que estén condicionadas a como van a ser consumidas.
- Se pueden reutilizar las funciones de orquestación en cualquier otro flujo de trabajo canalizado.
- La programación defensiva esta segregada de la operación principal. Nos aseguramos que si alcanzamos la ejecución de una operación; ésta tiene todo los valores que necesita y que además los valores son correctos y su estado válido para realizar la operación alcanzada.
- La posibilidad de inmutabilidad de la información que maneja  (siempre podemos devolver un nuevo gameState en cada paso) aporta confianza ya que sabemos que no se nos filtraran efectos colaterales.
- La testeabilidad es increíblemente trivial y de resultados muy fiables. Podemos hacer test del módulo de reglas; el dominio principal. Podemos hacer test de las funciones orquestadoras independientes. Y podemos hacer test de la función final que se compone de la canalización de las funciones orquestadoras. Y todo con una responsabilidad única absoluta (Nucleo, orquestación y canalización).
- La facilidad de lectura del flujo de trabajo es impresionante. Tienes una descripción pasito a pasito de lo que hace el flujo de trabajo de principio a fin. Yo personalmente le he dado a leer a mi señora madre; la cual no tiene ni repajolera idea de software; la función executeOperationWorkFlow y la ha entendido perfectamente.
- Todo lo anterior repercute en una gran mantenibilidad del código.

Y esto solo con la implementación más necia y tontuela. En entradas posteriores agregaré el uso de mónadas para poder capturar el contexto de cualquier cortocircuito que se produzca y actuar en consecuencia o poder devolver un valor de fallback en caso de necesitarlo; entre otras cosas.

No hay comentarios:

Publicar un comentario