leanmind logo leanmind text logo

Blog

Refactorización Avanzada

Dominar el refactoring productivo para maximizar el retorno de la inversión.

Posibles usos del tipo Either y explicación sobre los genéricos

Por Carlos Blé Jurado

El tipo Either está implementado en librerías para la mayoría de lenguajes de programación. Es un tipo algebráico, un tipo suma, que envuelve un valor. Se usa típicamente para encapsular el resultado de una operación que pudo haber salido mal. Si se prevee que hay motivos cotidianos (no excepcionales) por los que una operación puede fallar, como una validación, entonces Either es una forma muy expresiva de representarlo. En mi opinión, mucho más adecuado que lanzar excepciones, porque el flujo de ejecución es más fácil de seguir.

Vamos a ver unos ejemplos utilizando Java y la librería VAVR:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class Territory {

   /*...*/

   public Either<ImposibleLocation, OccupationSuccess> occupyLocation(Location location) {
      if (location.exceedsLimits(width, height)){
          return Either.left(new LocationOufOfTerritory(location));
      } else if (isAlreadyOccupied(location)) {
          return Either.left(new OccupiedLocation(location));
      } else {
          markLocationAsOccupied(location);
          return Either.right(new OccupationSuccess(location));
      }
  }
   /*...*/
}

El código que usa esta función, puede preguntar por el resultado de la operación. Si no has trabajado antes con Either, la tentación sería usar un condicional:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class ControlPanel {

   /*...*/

   public Either<DeploymentFailure, Rover> deployNewRover(Location location, Direction direction) {
      Either<ImposibleLocation, OccupationSuccess> occupationResult = territory.occupyLocation(location);
      if (occupationResult.isRight()){
          return Either.right(new Rover(location, direction));
      }
      else {
          return Either.left(new DeploymentFailure(occupationResult.getLeft()));
      }
     /*...*/
}

Sin embargo, no es necesario. Este código es equivalente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class ControlPanel {

   /*...*/

  public Either<DeploymentFailure, Rover> deployNewRover(Location location, Direction direction) {
      Either<ImposibleLocation, OccupationSuccess> occupationResult = territory.occupyLocation(location);
      return occupationResult
              .map(success -> new Rover(location, direction))
              .mapLeft(imposible -> new DeploymentFailure(imposible));
  }
}

Mismo resultado, sin condicionales. ¿Cómo es posible? Para comprenderlo vamos a ver la implementación de Either y vamos a comprender cómo funcionan los genéricos. Por cierto, en el caso de Java, la documentación de Oracle es bastante concisa pero muy válida, al grano. Además de eso, los artículos de Baeldung siempre ayudan. Aunque la sintaxis puede abrumar un poco, todos los símbolos están ahí por alguna razón, no sobra ninguno y todo se puede explicar. Vamos a ello:

1
2
3
4
5
public abstract class Either<L, R> implements Iterable<R>, io.vavr.Value<R>, Serializable {

  // ....

}

Los símbolos mayor y menor, indican que la clase opera con dos tipos genéricos, L y R, que serán resueltos en el momento en que se utilice la clase. El compilador sustituirá los tipos L y R por los que se le pasen, encargándose de que haya consistencia. Esta clase implementa dos interfaces, pero eso es poco relevante para lo que quiero explicar en este artículo. Véamos el método map:

1
2
3
4
5
6
7
8
9
@Override
public final <U> Either<L, U> map(Function<? super R, ? extends U> mapper) {
    Objects.requireNonNull(mapper, "mapper is null");
    if (isRight()) {
        return Either.right(mapper.apply(get()));
    } else {
        return (Either<L, U>) this;
    }
}

Justo detrás de public final aparece <U>, lo que quiere decir que se va a usar otro tipo genérico en la firma, además de L y R. A continuación, aparece el tipo devuelto por la función, que es otro Either, que mantiene el mismo tipo L para la izquierda, pero que tiene un tipo U en su lado derecho. Es decir, que se produce una transformación en el lado derecho (un mapeo de los tipos). Luego viene el nombre de la función y su argumento. El argumento está definido como una función, que a su vez tiene un argumento de tipo R. Bueno, en realidad podría ser de tipo R o alguno superior en la jerarquía de R (por eso pone super). Además, esa función devuelve un objeto de tipo U. Bueno, en realidad podría ser un subtipo de U (por eso pone extends).

¿Por qué hace un typecast para el caso de ir por el lado izquierdo? Porque en ese punto se sabe que el valor derecho está vacío, con lo cual es inócuo, al no haber transformación real. Veámos ahora el código de mapLeft:

1
2
3
4
5
6
7
8
9
@Override
public final <U> Either<U, R> mapLeft(Function<? super L, ? extends U> leftMapper) {
    Objects.requireNonNull(leftMapper, "leftMapper is null");
    if (isLeft()) {
         return Either.left(leftMapper.apply(getLeft()));
     } else {
         return (Either<U, R>) this;
     }
 }

Es totalmente simétrico a map, cambiando el tipo de L, en vez del tipo de R. Por tanto, la función map también se podría llamar mapRight. Por tanto, al encadenar las llamadas a map y mapLeft, primero estamos transformando el tipo derecho y luego el tipo izquierdo. Así conseguimos que el tipo resultante sea el que queremos y que todo eso se compruebe en tiempo de compilación.

El código queda libre de condicionales explícitas, pero sigue estando bien tipado y respaldado por el compilador:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class ControlPanel {

   /*...*/

  public Either<DeploymentFailure, Rover> deployNewRover(Location location, Direction direction) {
      Either<ImposibleLocation, OccupationSuccess> occupationResult = territory.occupyLocation(location);
      return occupationResult
              .map(success -> new Rover(location, direction))
              .mapLeft(DeploymentFailure::new);
  }
}

Como nota curiosa, decir que estuve un buen rato sin encontrar cómo combinar map y mapLeft, incluso le pregunté a ChatGPT 4, pero me daba respuestas incorrectas, que no compilaban. Cuando empecé a leer el código fuente de Either, ya me puse a probar combinaciones con más sentido y fue entonces cuando Copilot me dió la opción que estaba buscando. Muy buen ahorro de tiempo gracias a Copilot, en este caso.

Si estás usando una librería open source, entrar a mirar su código fuente te ayuda a comprender cómo funciona. Si el código es intuitivo, si es sostenible, puede resultar mucho más práctico que buscar la documentación de la librería.

Publicado el 23/10/2023 por

¿Quieres más? te invitamos a suscribirte a nuestro boletín para avisarte cada vez que recopilemos contenido de calidad que compartir.

Si disfrutas leyendo nuestro blog, ¿imaginas lo divertido que sería trabajar con nosotros? ¿te gustaría?

Impulsamos el crecimiento profesional de tu equipo de developers