Inversión de control

18 de Noviembre de 2019

Esta es una traducción a español del post Inversion of control de Kent C. Dodds.

óptica invertida

Mira "Implementando inversión de control” en egghead.io

Si alguna vez has escrito código que fué usado en mas de un lugar, probablemente estés familiarizado con la siguiente historia:

  1. Escribes un fragmento de código reutilizable (funciones, componentes de React, un hook, etc.) y lo compartes (a tus compañeros de trabajo o lo publicas como código abierto).
  2. Alguien te contacta por un nuevo caso de uso que el código que escribiste no soporta, pero podría hacerlo con una pequeña modificación.
  3. Agregas un argumento/propiedad/opción a tu código reutilizable y asocias la lógica necesaria para poder soportar ese caso.
  4. Repites los pasos 2 y 3 algunas veces (o varias 😬)
  5. El código reutilizable, es ahora una pesadilla para utilizar y mantener 😭

¿Qué es específicamente lo que hace que el código sea una pesadilla para usar y mantener?

Puede haber algunas cosas que causen este problema:

  1. 😵 Performance o tamaño del bundle: Ahora hay más codigo para ejecutar por los dispositivos y eso puede impactar negativamente en la performance de varias formas. A veces puede ser tan malo que la gente decida ni siquiera investigar cómo usar tu codigo debido a estos problemas.
  2. 😖 Sobrecarga de mantenimiento: Antes, tu código reutilizable solo tenía algunas opciones y estaba enfocado en hacer una cosa cosa bien, pero ahora puede hacer muchas cosas diferentes y necesita documentación para cada una. Además, tendrás un montón de gente preguntándote como usarlo para su caso de uso específico que puede encajar bien o no con los casos de uso que ya soportas. Podrías tener incluso dos funcionalidades que hagan lo mismo, pero con una pequeña diferencia asi que terminarás respondiendo preguntas sobre cuál es la mejor para cada situación.
  3. 🐛 Complejidad en la implementación: Nunca es “solo un if”. Cada ramificación de lógica en tu código se compone con las ramificaciones ya existentes. De hecho, hay situaciones donde pordías estar soportando una combinación de parámetros que nadie esté utilizando, pero debes asegurarte de que no se rompa cuando agregas nuevas funcionalidades porque en realidad nunca sabes si alguien la está usando o no.
  4. 😕 Complejidad de la API: Cada nuevo argumento/opción/propiedad que agregas a tu código reutilizable hace mas difícil de usar para los usuarios finales porque ahora tienes una documentación enorme que describe todas las funcionalidades disponibles y la gente tiene que aprender todas para poder usarlas de forma efectiva. Es menos agradable de usar porque a menudo la complejidad de tu API se traslada al código de la aplicación de los desarrolladores de forma que también complejiza su código.

Ahora todos están tristes por esto. Hay que decir que el despliegue es de suma importancia cuando estamos desarrollando apps. Creo que sería genial si pudieramos generar abstracciones (lean AHA Programming) y poder desplegar nuestras applicaciones. Si hubiera algo que podamos hacer para reducir los problemas con el código reutilizable sin dejar de aprovechar los beneficios de esas abstracciones...

Enter: Inversión de control

Uno de los principios que he aprendido y que es un mecanismo realmente efectivo para lograr simplicidad en las abstracciones es la "inversión de control". Aquí está lo que wikipedia dice sobre inversión de control.

"... en la programación tradicional, el código personalizado que expresa el propósito del programa llama a bibliotecas reutilizables para que se encarguen de tareas genéricas, pero con la inversión de control, es la biblioteca quien llama al código personalizado o especifico para una tarea.”

Puedes pensar en esto como: "Has que tu abstracción haga menos cosas, y has que tus usuarios lo hagan en su lugar." Esto puede parecer contra intuitivo porque parte de lo que hace a la abstracción tan genial es que puede manejar toda la complajidad y las tareas repetitivas dentro de ella para que el resto del código pueda mantenerse “simple", "ordenado" o "limpio". Pero como ya hemos experimentado, las abstraccionas tradicionales, usualmente no funcionan asi.

¿Qué es la inversión de control en el código?

Primero, veamos un ejemplo:


// let's pretend that Array.prototype.filter does not exist
function filter(array) {
  let newArray = []
  for (let index = 0; index < array.length; index++) {
    const element = array[index]
    if (element !== null && element !== undefined) {
      newArray[newArray.length] = element
    }
  }
  return newArray

  // use case:

  filter([0, 1, undefined, 2, null, 3, 'four', ''])
  // [0, 1, 2, 3, 'four', '']
}

Ahora reproduzcamos el típico “ciclo de vida de una abstracción", agreguemos algunos casos de usos nuevos relacionados a esta abstracción y "mejoremosla" para que soporte esos nuevos casos.


// let's pretend that Array.prototype.filter does not exist
function filter(
  array,
  {
    filterNull = true,
    filterUndefined = true,
    filterZero = false,
    filterEmptyString = false,
  } = {},
) {
  let newArray = []
  for (let index = 0; index < array.length; index++) {
    const element = array[index]
    if (
      (filterNull && element === null) ||
      (filterUndefined && element === undefined) ||
      (filterZero && element === 0) ||
      (filterEmptyString && element === '')
    ) {
      continue
    }

    newArray[newArray.length] = element
}
return newArray
}

filter([0, 1, undefined, 2, null, 3, 'four', ''])
// [0, 1, 2, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], {filterNull: false})
// [0, 1, 2, null, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], {filterUndefined: false})
// [0, 1, 2, undefined, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], {filterZero: true})
// [1, 2, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], {filterEmptyString: true})
// [0, 1, 2, 3, 'four']

Ok, en realidad solo hay 6 casos de uso que nos importan, pero aún asi debemos soportar una combinación de 25 combinatorias (si no me equivoco).

Y esta es una abstracción relativamente simple, estoy seguro que se podría simplificar. A menudo cuando vuelves a ver una abstracción luego de un tiempo, te das cuenta de que podrías simplificarla mucho más para los casos de uso que en realidad debería soportar. Desafortunadamente, tan pronto como la abstracción soporta una nueva funcionalidad (como {filterZero: true, filterUndefined: false}), no querrás removerla por miedo a romper la aplicación de algun desarrollador que la esté usando.

Incluso acabaremos escribiendo tests para esos casos de uso que en realidad no necesitamos, solo por el hecho de que nuestra abstracción los soporta y "podríamos” necesitarlos en el futuro. Entonces, cuando esos casos de uso no son más necesarios, los seguiremos soportando porque nos olvidaremos, pensamos que los necesitaremos en un futuro, o simplemente porque tenemos muedo de tocar ese código.

De acuerdo, ahora agreguemos algunas abstracciones en esta función y apliquemos inversión de control para soportar todos estos casos de uso:


// let's pretend that Array.prototype.filter does not exist
function filter(array, filterFn) {
  let newArray = []
  for (let index = 0; index < array.length; index++) {
    const element = array[index]
    if (filterFn(element)) {
      newArray[newArray.length] = element
    }
  }
  return newArray
}

filter(
  [0, 1, undefined, 2, null, 3, 'four', ''],
  el => el !== null && el !== undefined,
)
// [0, 1, 2, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], el => el !== undefined)
// [0, 1, 2, null, 3, 'four', '']

filter([0, 1, undefined, 2, null, 3, 'four', ''], el => el !== null)
// [0, 1, 2, undefined, 3, 'four', '']

filter(
  [0, 1, undefined, 2, null, 3, 'four', ''],
  el => el !== undefined && el !== null && el !== 0,
)
// [1, 2, 3, 'four', '']

filter(
  [0, 1, undefined, 2, null, 3, 'four', ''],
  el => el !== undefined && el !== null && el !== '',
)
// [0, 1, 2, 3, 'four']

Que bonito! Eso es mucho mas simple. Lo que hemos hecho es invertir el control! Delegamos la responsabilidad de decidir que elementos permanecen en el nuevo array de la función filter a quien llama a la función filter. Cabe destacar que la función filter es aún una abstracción útil, pero es mucho más capaz.

Pero, ¿La abstracción anterior era tan mala? Tal vez no. Pero por el hecho de haber invertido el control, ahora podemos soportar mucho mas casos de usos particulares.


filter(
  [
    {name: 'dog', legs: 4, mammal: true},
    {name: 'dolphin', legs: 0, mammal: true},
    {name: 'eagle', legs: 2, mammal: false},
    {name: 'elephant', legs: 4, mammal: true},
    {name: 'robin', legs: 2, mammal: false},
    {name: 'cat', legs: 4, mammal: true},
    {name: 'salmon', legs: 0, mammal: false},
  ],
  animal => animal.legs === 0,
)
// [
//   {name: 'dolphin', legs: 0, mammal: true},
//   {name: 'salmon', legs: 0, mammal: false},
// ]

Imagina haber tenido que soportar todo esto antes de haber invertido el control. Hubiera sido una locura...

¿Una API peor?

Una de las quejas mas comunes que escucho de la gente acerca de las APIs de inversión de control que he construido es: "Si, pero ahora es mas difícil de usar que antes“. Por ejemplo:


// before
filter([0, 1, undefined, 2, null, 3, 'four', ''])

// after
filter(
  [0, 1, undefined, 2, null, 3, 'four', ''],
  el => el !== null && el !== undefined,
)

Si, una de ellas es claramente más facil de utilizar que la otra. Pero aqui está la cuestión de las APIs de inversión de control, puedes utilizarlas para re implementar la API anterior y generalmente es bastante trivial hacerlo. Por ejemplo:


function filterWithOptions(
  array,
  {
    filterNull = true,
    filterUndefined = true,
    filterZero = false,
    filterEmptyString = false,
  } = {},
) {
  return filter(
    array,
    element =>
      !(
        (filterNull && element === null) ||
        (filterUndefined && element === undefined) ||
        (filterZero && element === 0) ||
        (filterEmptyString && element === '')
      ),
  )
}

¿Genial no? Asi que podemos construir abstracciones sobre la API de inversión de control logrando la API simple que la gente estaba buscando. Y aún mejor, si nuestra "simple" API no es suficiente para su caso de uso, pueden usar los mismos bloques (building blocks) que utilizamos para construis la API de más alto nivel para lograr el cometido de su tarea mas compleja. Ya no necesitan pedirnos que agreguemos una nueva funcionalidad como filterWithOptions y esperar a que la terminemos. Ahora tienen los bloques necesarios para construira ellos mismos porque les hemos dado las herramientas para hacerlo.

Ah, y solo por diversión:


function filterByLegCount(array, legCount) {
  return filter(array, animal => animal.legs === legCount)
}

filterByLegCount(
  [
    {name: 'dog', legs: 4, mammal: true},
    {name: 'dolphin', legs: 0, mammal: true},
    {name: 'eagle', legs: 2, mammal: false},
    {name: 'elephant', legs: 4, mammal: true},
    {name: 'robin', legs: 2, mammal: false},
    {name: 'cat', legs: 4, mammal: true},
    {name: 'salmon', legs: 0, mammal: false},
  ],
  0,
)
// [
//   {name: 'dolphin', legs: 0, mammal: true},
//   {name: 'salmon', legs: 0, mammal: false},
// ]

Puedes componer de la forma que te guste para resolver casos de uso comunes que tengas.

Bueno pero, ¿hablando en serio?

Eso funciona para casos de uso simples, pero ¿qué tan bueno es este concepto en el mundo real?

Bueno, lo mas probable es que hayas usado alguna API de inversión de control sin darte cuenta. Por ejemplo, la función Array.prototype.filter invierte el control. Como también lo hace la función Array.prototype.map.

También hay patrones con los que quizas estés familiarizado que son basicamente una forma de inversión de control.

Mis dos patrones favoritos para esto son "Componentes compuestos” y "Reductor de estado”. Aqui hay un pequeño ejemplo de como estos patrones pueden ser utilizados.

Componentes compuestos

Supongamos que quieres construir un componente Menú que tiene un botón para abrir para abrirse y mostrar una lista de items cuando se clickea. Luego, cuando se selecciona un item, este ejecuta alguna acción. Un enfoque común para este tipo de compnentes es crear propiedades para cada una de estas características:


function App() {
  return (
    <Menu
      buttonContents={
        <>
          Actions <span aria-hidden></span>
        </>
      }
      items={[
        {contents: 'Download', onSelect: () => alert('Download')},
        {contents: 'Create a Copy', onSelect: () => alert('Create a Copy')},
        {contents: 'Delete', onSelect: () => alert('Delete')},
      ]}
    />
  )
}

Esto permite personalizar muchas cosas de los items del Menú. Pero ¿que pasa si quisieramos insertar una línea antes del botón Delete? ¿Deberíamos agregar una opción más a los items? Como por ejemplo: precedeWithLine? Hug. Tal vez tendríamos un tipo especial de item del tipo {contents: <hr />}. Supongo que eso funcionaría, pero entonces también deberíamos considerar el caso en el que la propiedad onSelect no sea provista. Y sinceramente, esta sería una API rara.

Cuando estás pensando en como crear una buena API para gente que está intentando hacer las cosas de diferente formas, en lugar de terminar en ifs y ternarios, considera la posibilidad de invertir el control. En este caso, ¿qué pasaría si le dieramos al usuario la responsabilidad de renderizar el menú? Usemos una de las fortalezas mas gandes de la composición de React:


function App() {
  return (
    <Menu>
      <MenuButton>
        Actions <span aria-hidden></span>
      </MenuButton>
      <MenuList>
        <MenuItem onSelect={() => alert('Download')}>Download</MenuItem>
        <MenuItem onSelect={() => alert('Copy')}>Create a Copy</MenuItem>
        <MenuItem onSelect={() => alert('Delete')}>Delete</MenuItem>
      </MenuList>
    </Menu>
  )
}

La clave a notar aquí es que no hay un estado visible para el usuario de los componentes. El estado está implicitamente compartido entre estos componentes. Ese es el valor principal provisto por el patrón de componentes compuestos. Utilizando esta característica, le hemos delegado el control del renderizado a los usuarios de nuestros componentes y ahora agregar una línea extra es bastante trivial e intuitivo. No hay ninguna documentación que leer ni funcionalidades extra, ni códido ni tests. Gran ganancia para todos.

Puedes leer más acerca de este patrón en el blog de Kent. Grácias a Ryan Florence que le enseño este patrón a Kent.

Reductor de estados (state reducer)

Este es un patrón que creé Kent para resolver un problema de personalización de lógica. Puedes leer más acerca de este patron en mi blog "The state reducer pattern” pero en esencia es que tenía una biblioteca de inputs de búsqueda / autocompletado que se llamaba Downshift y alguien estaba construyendo una versión de selección múltiple del componente, por lo que necesitaban que el menú se mantenga abierto incluso luego de haber selecionado un elemento.

En Downshift teníamos lógica que indicaba que el menú debía cerrarse cuando una se realizaba una selección. La persona que necesitaba esta funcionalidad sugirió una prop closeOnSelection. Yo no estaba a favor de eso porque ya había estado en ese camino apocaliptico y quería evitarlo.

Asi que en su lugar, propuse una API para que los devs puedan controlar como suceden los cambios de estado. Puedes pensar en el reductor de estados como una función que es invocada cada vez que un componente está por cambiar de estado y le da al desarrollador la chance de modificar el cambio de estado que está suceder.

Aquí hay un ejemplo de como podrías hacer para que downshift no cierre el menú luego de haber seleccionado un item.


function stateReducer(state, changes) {
  switch (changes.type) {
    case Downshift.stateChangeTypes.keyDownEnter:
    case Downshift.stateChangeTypes.clickItem:
      return {
        ...changes,
        // we're fine with any changes Downshift wants to make
        // except we're going to leave isOpen and highlightedIndex as-is.
        isOpen: state.isOpen,
        highlightedIndex: state.highlightedIndex,
      }
    default:
      return changes
  }
}

// then when you render the component
// <Downshift stateReducer={stateReducer} {...restOfTheProps} />

Una vez que agregamos esta propiedad, obtuvimos muchisimos menos pedidos de personalización para este componente. Se volvió muchisimo mas capaz y mucho mas simple para que la gente pudiera hacer lo que quisiera.

Render props

Solo quería hacer una rápida mención al patrón render props que es un ejemplo perfecto de inversión de control, pero no lo necesitamos tan seguido como antes, asi que no voy a hablar de él.

Lee el articlo de por qué no necesitamos más render props

Un mensaje de precaución

La inversión de control es una manera fantástica de evitar hacer asunciones incorrectas acerca de casos de uso futuros en nuestro código reutilizable. Pero antes de que lo hagas, quisiera darte un consejo. Volvamos rápidamente a nuestro ejemplo:


// let's pretend that Array.prototype.filter does not exist
function filter(array) {
  let newArray = []
  for (let index = 0; index < array.length; index++) {
    const element = array[index]
    if (element !== null && element !== undefined) {
      newArray[newArray.length] = element
    }
  }
  return newArray
}

// use case:

filter([0, 1, undefined, 2, null, 3, 'four', ''])
// [0, 1, 2, 3, 'four', '']

¿Qué tal si eso es todo lo que necesitaramos que filter hiciera y nos encontramos con una situación en la que necesitamos filtrar todo excepto los valores null y undefined? En este caso agregar inversión de control para un solo caso de uso solo haría nuestro código mas complejo y no agregaría mucho valor.

Tal y como con las abstracciones, por favor seamos conscientes y apliquemos el principio de Programación AHA para evitar abstracciones prematuras.

Conslusión

Espero haber sido útil. Les he mostrado algunos patrones en la comunidad de React que sacan provecho del concepto de inversión de control. Hay más dando vueltas, y el concepto aplica para mucho más que solo React (como vimos con el ejemplo de filter). La próxima vez que estés por agregar un if a la funciónPrincipal de tu aplicación, considera como podrías inversir el control y mover la lógica a donde esta esté siendo usada (o si está siendo utilizada en muchos lugares, puedes construir una abstracción mas personalizada para esos caso de uso específicos).

Si te gustaría jugar con los ejemplos de este post, siéntete libre:

Editar en CodeSandbox

P.D. Si te gusto este post, probablemente te guste esta charla:

Kent C Dodds - Simply React
Star on Github