Hay pequeños problemas que pueden llegar a convertirse en verdaderos retos cuando los lenguajes de programación no proporcionan el soporte adecuado. Por ejemplo, la siguiente función Java recibe como argumentos una contraseña y un hash, y verifica si el hash se corresponde con la contraseña:


abstract boolean validateHash(String: password, String: hash)

Dado que tanto la contraseña como el hash son simplemente datos textuales, en la función se le asigna el mismo tipo String a los dos. El problema con este enfoque es que es muy propenso a errores: alguien puede pasar la contraseña en el lugar del hash y el hash en el lugar de la contraseña, en cuyo caso podría obtener un resultado incorrecto. Esta función obliga a pensar en el orden correcto de los argumentos cada vez que alguien la llama.

Al menos yo suelo equivocarme bastante con estos detalles, y por eso prefiero tener ayuda del lenguaje de programación.

Code Smells

Existe un code smell conocido como Primitive Obsession que ayuda a identificar este patrón en el código, y ofrece un refactor para sustituir el tipo de dato primitivo por una nueva clase:


public final class Password {
  public final String value;

  public Password(String value) {
    this.value = value;
  }
}

public final class PasswordHash {
  public final String value;

  public PasswordHash(String value) {
    this.value = value;
  }
}

abstract boolean validateHash(Password: password, PasswordHash: hash)

Una desventaja de este código es que necesita un boilerplate considerable para duplicar el comportamiento del tipo subyacente, especialmente si necesitamos sobrescribir equals() y hashCode().

Sin embargo, el problema principal es que se pierden todas las ventajas de los tipos de datos primitivos: los objetos tienen una cierta sobrecarga con respecto a sus contrapartes primitivas, que les hacen usar más memoria y tener un acceso más lento.

Llevado al extremo, hay una regla de Object Calisthenics que establece que todas las primitivas y String tienen que ser encapsuladas dentro de objetos.

Connascence

Connascence ofrece un enfoque alternativo que ayuda a identificar este patrón con el nombre de Connascence of Position. Ésta es la forma más fuerte de connascence estática, que se puede convertir en otra forma más débil: Connascence of Name, aplicando este tipo de refactor.

Un problema de esta solución es que solamente se puede aplicar en aquellos lenguajes de programación que tienen soporte para named parameters. Por ejemplo, en Scala podemos escribir la siguiente función:


def validateHash(password: String, hash: String): Boolean = ???

validateHash(password = "changeme", hash = "6ac371cc3dc9d38cf33e5c146617df75")

El resultado es un código más claro que seguramente será más fácil de mantener. Al no usar wrapper classes evita la sobrecarga de la versión anterior.

Aún y todo, los tipos predefinidos como String no pueden aprovechar el sistema de tipos para darle contexto a los datos.

Newtype

Haskell proporciona newtype para crear nuevos tipos a partir de los existentes. Los nuevos tipos creados de esta forma no tienen sobrecarga en tiempo de ejecución. Aprovechando esta característica, la función validateHash se podría reescribir así:


newtype Password = Password
  { unPassword :: ByteString
  }

newtype PasswordHash = PasswordHash
  { unPasswordHash :: ByteString
  }

validateHash :: Password -> PasswordHash -> Bool

Scala 3 traerá esta característica -llamada opaque types- de manera nativa al lenguaje, mientras tanto la librería NewType ofrece una solución equivalente para Scala 2.13.x:


import io.estatico.newtype.macros.newtype

object PasswordSyntax {
  @newtype case class Password(unPassword: String)

  @newtype case class PasswordHash(unPasswordHash: String)
}

def validateHash(password: Password, hash: PasswordHash): Boolean = ???

¿Y por qué esta versión es mejor que las anteriores? El nuevo tipo definido con newtype se comporta como un tipo más del lenguaje, y como tal puede ser extendido con un companion object para crear nuevos comportamientos. Por ejemplo, newtype puede ser combinado con un smart constructor para hacer que los estados ilegales no puedan ser representados:


import cats.implicits._
import io.estatico.newtype.macros.newtype
import io.estatico.newtype.ops._

object PasswordSyntax {
  @newtype class Password(val unPassword: String)

  @newtype class PasswordHash(val unPasswordHash: String)

  object Password {
    def fromString(str: String): Either[String, Password] =
      if (str.isEmpty) "Password cannot be empty".asLeft
      else str.coerce.asRight
  }

  object PasswordHash {
    def fromString(str: String): Either[String, PasswordHash] =
      if (!"^([a-f0-9]{32})$".r.matches(str)) "Expected a base16 text of length 32".asLeft
      else str.coerce.asRight
  }
}

def validateHash(password: Password, hash: PasswordHash): Boolean = ???

Una limitación de los nuevos tipos creados con newtype es que no se pueden componer. Por ejemplo, si tuviésemos que elegir con pattern matching exhaustivo el algorimo que se aplica a cada tipo de hash, tendríamos que cambiar newtype por case classes para luego crear los sum types necesarios como sigue:


import cats.implicits._
import io.estatico.newtype.ops._
import org.apache.commons.codec.digest.DigestUtils.{md5Hex, sha256Hex}

sealed trait PasswordHash extends Product with Serializable

final case class Md5PasswordHash(hashStr: String) extends PasswordHash
final case class Sha256PasswordHash(hashStr: String) extends PasswordHash

def validateHash(password: Password, hash: PasswordHash): Boolean = hash match {
  case Md5PasswordHash(hashStr) => md5Hex(password.coerce) === hashStr
  case Sha256PasswordHash(hashStr) => sha256Hex(password.coerce) === hashStr
}

Por último, aunque newtype crea nuevos tipos sin sobrecarga en tiempo de ejecución, la comprobación de los tipos añade una carga adicional durante la compilación.

Pero si bien el compilador de Scala es más lento que el de Java:

No rush

(Imagen No rush de Impure Pics).

La Percepción de la Dificultad

A veces parece que los conceptos difíciles de dominar como Monad sólo existen en programación funcional. La realidad es que existen independientemente del paradigma o lenguaje. En programación orientada a objetos también hay que dominar conceptos como Code Smells, Object Calisthenics y Connascence. Los mismos ayudan a identificar debilidades en el diseño y ofrecen posibles soluciones. Ignorarlos puede ralentizar el desarrollo y aumentar el riesgo de que ocurran errores en el futuro.

Cualquiera de los caminos elegidos dentro de la programación conlleva una inversión de tiempo para alcanzar un dominio razonable de sus principios. Es decisión de cada cual cómo prefiere invertir ese tiempo.

Sin embargo, hay algunos programadores que creen que sólo existe la opción de programar orientado a objetos porque de todos modos: ¿cuántas empresas hay que hacen programación funcional? Detrás de este razonamiento hay una actitud de: no quiero elegir, prefiero dejarme llevar por la tendencia dominante.

Esta actitud oculta otra posible elección que es: aprenderlos todos, para luego poder usar lo mejor de cada uno.

Agradecimientos

Al equipo de Kowainik porque su libro Haskell mini-patterns handbook me ha servido de inspiración para escribir algunos ejemplos y explicaciones de este post.

Haskell