En una conversación reciente, surgió repetidamente la pregunta acerca de qué es exactamente la programación funcional. Para mí, resulta más difícil intentar dar una definición precisa que mostrar ejempos de código más o menos funcional. Lo que sigue es un caso práctico en el que tomamos un código que necesita claramente ser refactorizado, para lo que se siguen distintas aproximaciones, algunas más funcionales que otras, comparando las características de cada una de ellas que las hacen más o menos coherentes con el paradigma de programación funcional.

Espero que sirva de ejemplo para entender mejor qué es realmente la programación funcional y poder distinguir cuándo nuestra solución se acerca o se desvía de ésta.

Validando URLs

Supongamos que queremos validar direcciones URL que van a ser proporcionadas por usuarios a través de un sencillo formulario. En nuestro dominio, las direcciones válidas tienen las siguientes características:

  • Tienen una logitud máxima de 300 caracteres
  • Incluyen el protocolo de conexión que, además, está restringido a dos opciones: HTTP o HTTPS
  • No pueden ser direcciones IP (más concretamente, IPv4)
  • No puede ser localhost
  • No pueden contener nombre de usuario y contraseña en ellas

Para validar las URLs proporcionadas por los usuarios, hemos creado un pequeño módulo, UrlValidator, que proporciona una función que implementa cada una de las reglas anteriormente mencionadas de forma separada:

class UrlValidator {

  def validateLength(uri: Uri): Boolean = {
    val urlString = uri.toStringRaw
    if (urlString.length() <= 300) {
      true
    }
    else {
      false
    }
  }

  def validateProtocol(uri: Uri): Boolean = {
    uri.protocol match {
      case Some(protocol) if protocol.equalsIgnoreCase("http") || protocol.equalsIgnoreCase("https") => true
      case _ => false
    }
  }

  def validateIsNotAnIPv4Address(uri: Uri): Boolean = {
    uri.host match {
      case Some(host) if host.matches("[0-9.]+") => false
      case _ => true
    }
  }

  def validateIsNotLocalhost(uri: Uri): Boolean = {
    val urlToLocalhost = uri.host.contains("localhost")
    if (!urlToLocalhost) {
      true
    } else {
      false
    }
  }

  def validateNoUserAndPassword(uri: Uri): Boolean = {
    (uri.user, uri.password) match {
      case (Some(_), None) => false
      case (Some(_), Some(_)) => false
      case _ => true
    }
  }
}

Para representar las direcciones, hemos optado por utilizar la clase Uri proporcionada por la librería scala-uri, que nos ofrece utilidades para parsear URIs (más adelante veremos cómo se usa dicha utilidad).

Este código es comprobado con las especificaciones basadas en propiedades universales que pueden verse a continuación:

class UrlValidationsSpec extends PropSpec with GeneratorDrivenPropertyChecks with MustMatchers {

  private implicit val config = UriConfig.conservative

  property("validateLength accepts URLs with 300 chars or less") {
    forAll(stringsWithLengthFromToGenerator(1, 299)) {
      (s: String) => validator.validateLength(parse(s)) must be(true)
    }
  }

  property("validateLength rejects URLs with more than 300 chars") {
    forAll(stringsWithLengthFromToGenerator(300, 1000)) {
      (s: String) => validator.validateLength(parse(s)) must be(false)
    }
  }

  property("validateProtocol accepts URLs with HTTP protocol") {
    forAll(stringsWithLengthFromToGenerator(1, 200)) {
      (s: String) => validator.validateProtocol(parse(withHttpsProtocol(s))) must be(true)
    }
  }

  property("validateProtocol accepts URLs with HTTPS protocol") {
    forAll(stringsWithLengthFromToGenerator(1, 200)) {
      (s: String) => validator.validateProtocol(parse(withHttpsProtocol(s))) must be(true)
    }
  }

  property("validateProtocol accepts URLs without HTTP and HTTPS protocol") {
    forAll(stringsWithLengthFromToGeneratorNotContainingGenerator(1, 200, Seq("http://", "https://"))) {
      (s: String) => validator.validateProtocol(parse(s)) must be(false)
    }
  }

  property("validateIsNotLocalhost accepts URLs with containing 'localhost' if it is not the only word in host name") {
    forAll(stringsWithLengthFromToGenerator(1, 5), stringsWithLengthFromToGenerator(1, 5)) {
      (prefix: String, suffix: String) => validator.validateIsNotLocalhost(parse(withHttpProtocol(prefix + "localhost" + suffix))) must be(true)
    }
  }

  property("validateIsNotLocalhost accepts URLs not containing 'localhost'") {
    forAll(stringsWithLengthFromToGeneratorNotContainingGenerator(1, 200, Seq("localhost"))) {
      (s: String) => validator.validateIsNotLocalhost(parse(withHttpProtocol(s))) must be(true)
    }
  }

  property("validateIsNotLocalhost rejects URLs if host name is only 'localhost'") {
    validator.validateIsNotLocalhost(parse(withHttpProtocol("localhost"))) must be(false)
  }

  property("validateNoUserAndPassword accepts URLs not containing username and password") {
    forAll(stringsWithLengthFromToGeneratorNotContainingGenerator(1, 200, Seq(":", "@"))) {
      (s: String) => validator.validateIsNotLocalhost(parse(withHttpProtocol(s))) must be(true)
    }
  }

  property("validateNoUserAndPassword rejects URLs with containing username and password information") {
    forAll(stringsWithLengthFromToGenerator(1, 5), stringsWithLengthFromToGenerator(1, 5), stringsWithLengthFromToGenerator(1, 200)) {
      (username: String, password: String, host: String) => validator.validateNoUserAndPassword(parse(withHttpProtocol(username + ":" + password + "@" + host))) must be(false)
    }
  }

}

Aquí puede verse cómo usamos la función parse proporcionada por la clase Uri de scala-uri para construir instancias de dicha clase a partir de las cadenas que estamos utilizando en nuestras propiedades.

Vamos a ver ahora cómo se ha implementado la función que realiza la validación completa de una URL aplicando las reglas que se han mencionado. Esto se hace en la función validate de nuestro módulo UrlValidator:

class UrlValidator {

  def validate(urlString: String): \/[UrlValidationSuccess, UrlValidationError] = {
    try {
      if(validateLength(parse(urlString))) {
        if(validateProtocol(parse(urlString))) {
          if(validateIsNotLocalhost(parse(urlString))) {
            if(validateNoUserAndPassword(parse(urlString))) {
              if(validateIsNotAnIPv4Address(parse(urlString))) {
                -\/(UrlValidationSuccess(parse(urlString).toString))
              } else \/-(UrlValidationError("Url can not contain IPv4 addresses"))
            } else \/-(UrlValidationError("Url can't contain user and password"))
          } else \/-(UrlValidationError("Url should not point to localhost"))
        } else \/-(UrlValidationError("Url does not have a supported protocol"))
      } else \/-(UrlValidationError("Url too long"))
    } catch {
      case _: java.net.URISyntaxException => \/-(UrlValidationError("Url does not have a valid structure"))
    }
  }
  ...
}

Hemos representado la respuesta de nuestra función de validación mediante el tipo disjunto \/ de scalaz, con dos posibles tipos de retorno: uno para el caso en que la validación se lleva a cabo con éxito, UrlValidationSuccess, y otro para el caso en que la validación falla, UrlValidationError. Este último tipo también contiene un mensaje indicando por qué falló dicha validación:

case class UrlValidationSuccess(validatedUrl: String)
case class UrlValidationError(message: String)

Esta función, validate, está probada mediante especificaciones escritas en formato WordSpec de ScalaTest:

class UrlValidatorSpec extends WordSpec with MustMatchers {

  val validator = new UrlValidator

  "validate" should {

    "return UrlValidationError" when {
      "protocol is missing" in {
        val urlWithoutProtocol = "www.google.com"

        val result = validator.validate(urlWithoutProtocol)

        result mustBe \/-(UrlValidationError("Url does not have a supported protocol"))
      }

      "url is longer than 300 characters" in {
        val tooLongUrl = "a" * 301

        val result = validator.validate(tooLongUrl)

        result mustBe \/-(UrlValidationError("Url too long"))
      }

      "url contains user" in {
        val urlWithUsernameAndPassword = "https://user@my.domain.com?asdf=qwer"

        val result = validator.validate(urlWithUsernameAndPassword)

        result mustBe \/-(UrlValidationError("Url can't contain user and password"))
      }

      "url contains user and password" in {
        val urlWithUsernameAndPassword = "https://user:password@my.domain.com?asdf=qwer"

        val result = validator.validate(urlWithUsernameAndPassword)

        result mustBe \/-(UrlValidationError("Url can't contain user and password"))
      }

      "the url can NOT be parsed" in {
        val invalidUrl = "https://:password@my.domain.com?asdf=qwer"

        val validationResult = validator.validate(invalidUrl)

        validationResult mustBe \/-(UrlValidationError("Url does not have a valid structure"))
      }

      "the url points to localhost" in {
        val invalidUrl = "https://localhost"

        val validationResult = validator.validate(invalidUrl)

        validationResult mustBe \/-(UrlValidationError("Url should not point to localhost"))
      }

      "the host of the URL is empty (IPv6)" in {
        val ipv6Url = "2001:0db8:0000:0042:0000:8a2e:0370:7334"

        val validationResult = validator.validate(ipv6Url)

        validationResult mustBe \/-(UrlValidationError("Url does not have a supported protocol"))
      }

      "the host of the URL contains only numbers (IPv4)" in {
        val ipv4Url = "http://223.255.255.254"

        val validationResult = validator.validate(ipv4Url)

        validationResult mustBe \/-(UrlValidationError("Url can not contain IPv4 addresses"))
      }
    }

    "return UrlValidationSuccess" when {
      "protocol is http" in {
        val httpUrl = "http://www.google.com"

        val result = validator.validate(httpUrl)

        result mustBe -\/(UrlValidationSuccess(httpUrl))
      }

      "protocol is https" in {
        val httpUrl = "HTTPS://www.google.com"

        val result = validator.validate(httpUrl)

        result mustBe -\/(UrlValidationSuccess(httpUrl))
      }

      "the url has parameters" in {
        val url = "HTTPs://उदाहरण.älteren.com?key=value?+<>\" \'"
        val encodedUrl = "HTTPs://उदाहरण.älteren.com?key=value%3F%2B%3C%3E%22%20%27"

        val validationResult = validator.validate(url)

        validationResult mustBe -\/(UrlValidationSuccess(encodedUrl))
      }

      "the url contains localhost but is another domain" in {
        val url = "https://xalocalhost"

        val validationResult = validator.validate(url)

        validationResult mustBe -\/(UrlValidationSuccess(url))
      }
    }
  }

}

Como podemos ver, la función validate combina los resultados obtenidos de cada una de las funciones de validación individuales, terminando con el primer error que encuentre o bien devolviendo éxito si ninguna validación ha fallado.

Resulta doloroso leer el código en el formato actual debido a la anidación de condicionales unas dentro de otras. Este formato tampoco nos hace ser muy optimistas en el futuro en caso de que queramos añadir nuevas reglas de validación, lo que supondrá anidar más lógica condicional, haciendo la función todavía más ilegible. Nuestro código necesita ser refactorizado.

Eliminando complejidad de nuestra función de validación

El primer problema que vamos a atacar es la elevada complejidad de la función validate. En concreto, la sucesión de expresiones condicionales que calculan el resultado final de la validación. Como queremos obtener de validate qué regla de validación se incumplió, no es posible combinar todas las reglas existentes en una única expresión lógica:

  //this will not work!!!
  def validate(urlString: String): \/[UrlValidationSuccess, UrlValidationError] = {
    try {
      val uri = parse(urlString)
      if (
        validateLength(uri) && validateProtocol(uri) && validateIsNotLocalhost(uri)
          && validateNoUserAndPassword(uri) && validateIsNotAnIPv4Address(uri))
        -\/(UrlValidationSuccess(parse(urlString).toString))
      else
        \/-(UrlValidationError("Url is not valid"))
    } catch {
      case _: java.net.URISyntaxException => \/-(UrlValidationError("Url does not have a valid structure"))
    }
  }

El cambio anterior reduce la complejidad de la función, pero cambia el sentido de validate, al ocultarnos la razón por la que una URL es inválida, como podemos ver al ejecutar nuestros tests.

Una opción que preservaría la semántica original de validate podría ser recopilar los resultados de cada una de las reglas de validación junto con el error correspondiente en una estructura que nos permita examinar dichos resultados y encontrar errores aplicando una única expresión condicional:

def validate(urlString: String): \/[UrlValidationSuccess, UrlValidationError] = {
  try {
    val uri = parse(urlString)

    val validationResults: Seq[(Boolean, \/-[UrlValidationError])] = Seq(
      (validateLength(uri), \/-(UrlValidationError("Url too long"))),
      (validateProtocol(uri), \/-(UrlValidationError("Url does not have a supported protocol"))),
      (validateIsNotLocalhost(uri), \/-(UrlValidationError("Url should not point to localhost"))),
      (validateNoUserAndPassword(uri), \/-(UrlValidationError("Url can't contain user and password"))),
      (validateIsNotAnIPv4Address(uri), \/-(UrlValidationError("Url can not contain IPv4 addresses"))))

    for (result <- validationResults) {
      if (!result._1)
        return result._2
    }

    -\/(UrlValidationSuccess(parse(urlString).toString))
  } catch {
    case _: java.net.URISyntaxException => \/-(UrlValidationError("Url does not have a valid structure"))
  }
}

Esta solución resulta mucho menos compleja que la original y vuelve a poner en verde todos nuestros tests, ya que ahora se devuelve un error específico por cada regla de validación. La única condición está dentro de un bucle for y el código es más fácil de leer.

Tenemos ahora otro tipo de problema: estamos diciendo cómo encontrar el resultado (itera sobre los elementos de validationResults y cuando encuentres uno cuyo resultado sea falso, devuelve el error correspondiente) paso a paso. Nuestro código ha pasado a ser procedural en lugar de declarativo, es decir, está en las antípodas de una solución funcional.

Volviendo a una solución funcional

Podemos eliminar la necesidad de iterar sobre validationResults y llamar explícitamente a return usando algunas de las funciones sobre secuencias para encontrar un resultado de validacón falso:

  val possibleValidationError = validationResults.find(!_._1)

  if (possibleValidationError.isDefined)
    possibleValidationError.get._2
  else
    -\/(UrlValidationSuccess(parse(urlString).toString))

Por supuesto, podemos compactar el código usando getOrElse, disponible en Option, con lo que validate quedaría así:

def validate(urlString: String): \/[UrlValidationSuccess, UrlValidationError] = {
  try {
    val uri = parse(urlString)

    val validationResults: Seq[(Boolean, \/-[UrlValidationError])] = Seq(
      (validateLength(uri), \/-(UrlValidationError("Url too long"))),
      (validateProtocol(uri), \/-(UrlValidationError("Url does not have a supported protocol"))),
      (validateIsNotLocalhost(uri), \/-(UrlValidationError("Url should not point to localhost"))),
      (validateNoUserAndPassword(uri), \/-(UrlValidationError("Url can't contain user and password"))),
      (validateIsNotAnIPv4Address(uri), \/-(UrlValidationError("Url can not contain IPv4 addresses"))))

    validationResults.find(!_._1).map(_._2).getOrElse(-\/(UrlValidationSuccess(parse(urlString).toString)))
  } catch {
    case _: java.net.URISyntaxException => \/-(UrlValidationError("Url does not have a valid structure"))
  }
}

Ahora ya podemos decir que tenemos una solución puramente funcional para nuestro problema que evita la complejidad de la primera versión de la función validate.

Sin embargo, el código todavía resulta difícil de leer. Haber utilizado una tupla de tipo (Boolean, \/-[UrlValidationError]) para encapsular los resultados de cada de las reglas de validación ha sido útil para poder encontrar la validación que falla y poder acceder al error correspondiente de forma sencilla (find y map se encargan de eso) pero el tipo Tuple no es demasiado descriptivo. Básicamente, Tuple es un tipo que nos permite poner dos cosas juntas, sean lo que sea, y acceder a ellas con _1 y _2, nada más. Por eso, la estructura Seq[(Boolean, \/-[UrlValidationError])] no nos revela claramente lo que queremos hacer. Debe ser el lector quien deduzca su significado a partir del uso que se hace de ella más adelante. find, map y getOrElse nos dicen más adelante que queremos el error del primer elemento que sea falso o un resultado exitoso en caso de no existir ninguno.

Todo esto es una construcción que hemos tenido que hacer por encima del tipo Seq[(Boolean, \/-[UrlValidationError])]. Sin embargo, esta misma semática se puede expresar de una forma más sencilla y fácil. Sólo tenemos que sacarle partido a uno de los elementos de nuestro código, claramente infrautilizado: la mónada \/ de Scalaz.

Liberando el poder de la mónada \/

Si leemos la documentación del tipo \/ veremos que dicho tipo sirve para representar una disjunción de tipos. En ese sentido, es equivalente al tipo Either ya presente en el lenguaje.

¿Qué tiene de especial \/? Sólo tenemos que seguir leyendo para darnos cuenta. Éste es el extracto de la documentación que nos interesa:

`A` [[\/]] `B` is isomorphic to `scala.Either[A, B]`, but [[\/]] is right-biased
for all Scala versions...

¿Qué significa que \/ tiene un sesgo hacia la opción right sobre la opción left? Significa que operaciones como map o flatMap sólo se aplican en el contexto del caso right. ¿Qué devuelve map aplicado a una instancia de tipo left? La instancia misma. Esto nos proporciona lógica de cortocircuito del tipo “continúa sólo si…” dentro de la mónada \/ que podemos aprovechar dentro de una composición monádica para hacer nuestro código más legible.

¿Cómo podemos usar las propiedades de \/ dentro de nuestro código? En primer lugar, necesitamos obtener instancias de \/ de para cada una de nuestras reglas de validación. Por ejemplo, validateLength pasaría a ser:

  def validateLength(uri: Uri): \/[UrlValidationError, UrlValidationSuccess] = {
    val urlString = uri.toStringRaw
    if (urlString.length() <= 300) {
      \/-(UrlValidationSuccess(uri.toString))
    }
    else {
      -\/(UrlValidationError("Url too long"))
    }
  }

Una vez hecho el cambio para que todas las validaciones parciales pasen a devolver \/[UrlValidationError, UrlValidationSuccess] en lugar de simplemente Boolean, ya podemos escribir validate haciendo uso de la composición de mónadas del tipo \/:

def validate(urlString: String): \/[UrlValidationError, UrlValidationSuccess] = {
  try {
    val uri = parse(urlString)

    for {
      _ <- validateLength(uri)
      _ <- validateProtocol(uri)
      _ <- validateIsNotLocalhost(uri)
      _ <- validateNoUserAndPassword(uri)
      r <- validateIsNotAnIPv4Address(uri)
    } yield {
      r
    }
  } catch {
    case _: java.net.URISyntaxException => -\/(UrlValidationError("Url does not have a valid structure"))
  }
}

Esta composición monádica contiene una secuencia de pasos de validación que sustituye a:

  • La secuencia de tuplas que almacenaban los resultados de cada una de las reglas de validación
  • La búsqueda de algún resultado que fuera falso
  • La extracción del error específico a la regla de validación que se ha incumplido
  • El resultado de éxito en caso de que ninguna regla de validación de incumpla

Todo esto ha sido posible a que \/ tiene un comportamiento diferente para right que para left. En el primer caso, flatMap la función que sea con normalidad pero, en el segundo, no aplica nada y sencillamente devuelve la instancia original de \/, como se puede ver en su implementación:

def flatMap[AA >: A, D](g: B => (AA \/ D)): (AA \/ D) =
  this match {
    case a @ -\/(_) => a         //left: no se aplica la función
    case \/-(b) => g(b)          //right: devuelve el resultado de aplicar la función
  }

Este es el equivalente monádico a atravesar una colección y parar cuando encontramos un elemento que cumple una condición para devolverlo inmediatamente como hacíamos en nuestro primer cambio:

for (result <- validationResults) {
  if (!result._1)
    return result._2
}

Refinando nuestra solución con funciones de orden superior

Gracias a la composición que hemos realizado con los resultados de las validaciones parciales hemos podido hacer nuestra función validate más legible. La contrapartida es que hemos tenido que cambiar cada una de las funciones que implementaban las reglas de validación individuales para devolver \/[UrlValidationError, UrlValidationSuccess] en lugar de Boolean, lo que supone un cambio demasiado muy grande en nuestro módulo de validación.

Además, resulta artifical e innecesario que, por ejemplo, la función validateLength devuelva \/[UrlValidationError, UrlValidationSuccess]. Si alguien utiliza validateLength, no necesita un objeto de tipo UrlValidationError con un mensaje explicando la validación que ha fallado. Esa información ya la tenemos porque sabemos que hemos llamado a validateLength (¿qué otra cosa a parte de la longitud de la URL podría fallar).

La intuición nos dice que cada una de las reglas individuales debería ser implementada por una función que sencillamente devuelva Boolean. ¿Cómo podemos reconciliar nuestra intuición con la necesidad de obtener resultados de tipo \/[UrlValidationError, UrlValidationSuccess] para formar nuestra composición monádica en validate?

La respuesta la tenemos en las funciones de orden superior. ¿Por qué no crear una única función que convierta funciones de tipo Uri => Boolean en funciones de tipo Uri => \/[UrlValidationError, UrlValidationSuccess]? Una función que hiciera esto sólo necesitaría la función original y, en todo caso, el mensaje de error que debe contener UrlValidationError. Podemos llamar a nuestra nueva función liftToEither:

private def liftToEither(validation: Uri => Boolean, errorMsg: String): Uri => \/[UrlValidationError, UrlValidationSuccess] = {
  uri: Uri => {
    if (validation(uri)) \/-(UrlValidationSuccess(uri.toString))
    else -\/(UrlValidationError(errorMsg))
  }
}

Hemos hecho esta función privada ya que solamente la vamos a utilizar dentro de nuestro módulo para elevar nuestras reglas de validación individuales a funciones que devuelven el tipo que necesitamos para hacer la composición dentro de validate, que quedaría de esta forma:

def validate(urlString: String): \/[UrlValidationError, UrlValidationSuccess] = {
  try {
    val uri = parse(urlString)

    for {
      _ <- liftToEither(validateLength, "Url too long")(uri)
      _ <- liftToEither(validateProtocol, "Url does not have a supported protocol")(uri)
      _ <- liftToEither(validateIsNotLocalhost, "Url should not point to localhost")(uri)
      _ <- liftToEither(validateNoUserAndPassword, "Url can't contain user and password")(uri)
      r <- liftToEither(validateIsNotAnIPv4Address, "Url can not contain IPv4 addresses")(uri)
    } yield {
      r
    }
  } catch {
    case _: java.net.URISyntaxException => -\/(UrlValidationError("Url does not have a valid structure"))
  }
}

Ahora podemos dejar las reglas de validación como habían sido originalmete implementadas y utilizar una única función para hacer el paso de Boolean a \/[UrlValidationError, UrlValidationSuccess].

Un efecto lateral, menor pero beneficioso, que tenemos con esta solución es que son los clientes de las reglas de validación quienes deciden los mensajes de error que se proporcionan en lugar de que vengan impuestos por las propias reglas, lo que hace que éstas puedan ser reutilizables en más contextos.

Solamente nos queda un elemento exógeno a nuestra composición monádica dentro de la función validate: la construcción try-catch. Sin embargo, dicha construcción no es necesaria y también puede ser incluido en nuestra composición, dando como resultado una función mucho más legible.

Convirtiendo try-catch en una mónada

Lo cierto es que try-catch es una construcción poco funcional ya que lo que hacemos con ella es definir un bloque de código donde se pueden producir errores y define cómo se manejan dichos errores. Esto no concuerda con el enfoque orientado a expresiones (que definen qué queremos conseguir en lugar de cómo obtenerlo) que tenemos dentro de dicho bloque.

Scala ya proporciona un tipo con el que podemos construir expresiones equivalentes a bloques try-catch. Dicho tipo es Try y nos permite indicar la posibilidad de que la expresión con la que construimos instancias de dicho tipo puede resultar en errores. Sólo tenemos que elevar instancias de Try a \/ para poder incluirlas en la misma composición que ya usamos con cada una de las reglas de validación.

Esto lo haremos con una nueva función, buildUri, que va a intentar parsear una cadena para obtener un objeto de tipo Uri. En función del éxito de este paso, tendremos bien una instancia de Uri o una instancia de UrlValidationError, como podemos ver a continuación:

  private def buildUri(urlString: String): \/[UrlValidationError, Uri] = {
    Try(parse(urlString)) match {
      case Success(uri) => \/-(uri)
      case _ => -\/(UrlValidationError("Url does not have a valid structure"))
    }
  }

Haciedo uso de esta nueva función auxiliar, que mantendremos visible únicamente dentro de nuestro módulo, obtenemos una versión de validate en la que todos los pasos para validar una URL quedan dentro de una única composición basada en la mónada \/:

def validate(urlString: String): \/[UrlValidationError, UrlValidationSuccess] = {
  for {
    uri <- buildUri(urlString)
    _ <- liftToEither(validateLength, "Url too long")(uri)
    _ <- liftToEither(validateProtocol, "Url does not have a supported protocol")(uri)
    _ <- liftToEither(validateIsNotLocalhost, "Url should not point to localhost")(uri)
    _ <- liftToEither(validateNoUserAndPassword, "Url can't contain user and password")(uri)
    r <- liftToEither(validateIsNotAnIPv4Address, "Url can not contain IPv4 addresses")(uri)
  } yield {
    r
  }
}

Conclusión

Hemos terminado nuestro trabajo para refactorizar validate en una versión menos compleja y fácil de leer. El resultado final es más funcional que los obtenidos en los pasos intermedios. Veamos cuáles son los elementos que hacen que esto sea así:

  • Evitamos el uso de construcciones procedurales, como iteraciones sobre secuencias y sentencias de retorno o bloques try-catch. ¡Todo son expresiones!
  • Reutilizamos las originales funciones que implementan nuestras reglas de validación gracias a funciones de orden superior que nos permiten definir una única vez el cambio de tipo de retorno de Boolean a \/[UrlValidationError, UrlValidationSuccess].
  • Usamos la mónada \/ para hacer cortocircuito y devolver el primer error de validación encontrado gracias a su implementación sesgada hacia right de la función flatMap.

Al final, una única expresión for-comprehension nos explica, paso a paso, qué hay que hacer para saber si una URL es válida o no, sin necesidad de utilizar expresiones condicionales. Resulta fácil ver qué tendríamos que hacer para añadir nuevas reglas de validación, cambiar el orden en el que deben aplicarse o cambiar las reglas existentes.

El resultado es una función validate legible y un módulo fácilmente mantenible, que ilustra algunos de los beneficios que hacen la programación funcional tan interesante.

Referencias

Puedes encontrar el código utilizado como ejemplo en este artículo aquí.