Capítulo 4. La obsesión primitiva

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

La obsesión por lo primitivo es una obsesión primitiva.

Rich Hickey

4.0 Introducción

Muchos ingenieros de software piensan que el software consiste en "mover datos"; las escuelas y los libros de texto orientados a objetos se centran en los datos y los atributos cuando enseñan a modelar el mundo real. Éste era un sesgo cultural que se enseñaba en las universidades durante los años 80 y 90. Las tendencias del sector empujaron a los ingenieros a crear diagramas entidad-relación (ERD) y a razonar sobre los datos empresariales en lugar de centrarse en el comportamiento.

Los datos son más relevantes que nunca. La ciencia de datos está creciendo y el mundo gira en torno a los datos. Necesitas crear un simulador para gestionar y proteger los datos y exponer el comportamiento, ocultando al mismo tiempo la información y la representación accidental para evitar el acoplamiento. Las recetas de este capítulo te ayudarán a identificar los objetos pequeños y a ocultar la representación accidental. Descubrirás muchos objetos pequeños cohesionados y los reutilizarás en muchos contextos diferentes.

Cohesión

La cohesión es una medida de el grado en que los elementos de una misma clase o módulo de software trabajan juntos para lograr un propósito único y bien definido. Se refiere a lo estrechamente relacionados que están los objetos entre sí y con el objetivo general del módulo. Puedes considerar que una cohesión alta es una propiedad deseable en el diseño de software, ya que los elementos de un módulo están estrechamente relacionados y trabajan juntos con eficacia para lograr un objetivo específico.

4.1 Crear objetos pequeños

Problema

Tienes objetos grandes que sólo contienen tipos primitivos como campos.

Solución

Busca responsabilidades para objetos pequeños en el MAPPER y reifícalos.

Debate

Desde los primeros días de la informática, los ingenieros mapean todo lo que ven a los familiares tipos de datos primitivos, como String, Entero y Colección. El mapeo a esos tipos de datos viola a veces los principios de abstracción y fail fast. El nombre de una Persona tiene un comportamiento diferente al de una cadena, como puedes ver en el siguiente ejemplo:

public class Person {
    private final String name;

    public Person(String name) {
        this.name = name;
    }
}

El concepto de nombre está cosificado:

public class Name {
    private final String name;

    public Name(String name) {
        this.name = name;
        // Name has its own creation rules, comparison, etc.
        // Might be different than a string
    }
}

public class Person {
    private final Name name;

    public Person(Name name) {
        // Name is created as a valid one,
        // you don't need to add validations here
        this.name = name;
    }
}

Toma como ejemplo la palabra de cinco letras del juego Wordle. Una palabra Wordle no tiene las mismas responsabilidades que un char(5) y no se mapea en la bijección. Si quieres crear un juego Wordle, verás una bijección entre una palabra Wordle distinta de una String o char(5) , ya que no tienen las mismas responsabilidades. Por ejemplo, no es responsabilidad de un Stringaveriguar cuántas coincidencias tiene con la palabra Wordle secreta. Y no es responsabilidad de una palabra Wordle concatenar.

Wordle

Wordle es un popular juego online de adivinar palabras en el que tienes seis intentos para adivinar una palabra de cinco letras seleccionada por el juego. Cada vez que adivines una palabra de cinco letras, el juego te indicará qué letras son correctas y están en la posición correcta (marcadas con un cuadrado verde) y qué letras son correctas pero están en la posición incorrecta (marcadas con un cuadrado amarillo).

En un número muy reducido de sistemas de misión crítica, existe un compromiso entre abstracción y rendimiento. Pero para evitar una optimización prematura (véase el Capítulo 16, "Optimización prematura"), debes confiar en los ordenadores modernos y en las optimizaciones de las máquinas virtuales y, como siempre, debes ceñirte a las pruebas en escenarios del mundo real. Encontrar objetos pequeños es una tarea muy difícil, que requiere experiencia para hacer un buen trabajo y evitar el sobrediseño. No hay una bala de plata a la hora de elegir cómo y cuándo mapear algo.

No hay balas de plata

El concepto de "ninguna bala de plata" es una frase acuñada por el informático y pionero de la ingeniería de software Fred Brooks en su ensayo de 1986 "Ninguna bala de plata: Esencia y accidentes de la ingeniería de software". Brooks argumenta que no existe una única solución o enfoque que pueda resolver todos los problemas o mejorar significativamente la productividad y eficacia del desarrollo de software.

4.2 Reificar los datos primitivos

Problema

Tienes objetos que utilizan demasiados tipos primitivos.

Solución

Utiliza objetos pequeños en lugar de primitivos.

Debate

Supón que estás construyendo un servidor web:

  int port = 8080;
  InetSocketAddress in = open("example.org", port);
  String uri = urifromPort("example.org", port);
  String address = addressFromPort("example.org", port);
  String path = pathFromPort("example.org", port);

Este ejemplo ingenuo tiene muchos problemas. Viola el principio "Dilo, no lo preguntes" (véase la Receta 3.3, "Eliminar los definidores de los objetos") y el principio de fallo rápido. Además, no sigue la regla de diseño MAPPER y viola el principio del subconjunto. Hay manipulación de código duplicado por todas partes que se necesita para utilizar estos objetos, ya que no separa claramente el "qué" del "cómo".

La industria es muy perezosa cuando se trata de crear objetos pequeños y también de separar el qué y el cómo, ya que requiere un esfuerzo adicional descubrir tales abstracciones. Es importante fijarse en el protocolo y el comportamiento de los componentes pequeños y olvidarse de intentar comprender las interioridades de cómo funcionan las cosas. Una solución conforme a la biyección podría ser:

Port server = Port.parse(this, "www.example.org:8080");
// Port is a small object with responsibilities and protocol

Port in = server.open(this); // returns a port, not a number
URI uri = server.asUri(this); // returns an URI
InetSocketAddress address = server.asInetSocketAddress();
// returns an Address
Path path = server.path(this, "/index.html"); // returns a Path
// all of them are validated small bijection objects with very few and precise
// responsibilities

4.3 Reificar matrices asociativas

Problema

Tienes anémicas matrices asociativas (clave/valor ) que representan objetos del mundo real.

Solución

Utiliza matrices para la creación rápida de prototipos y utiliza objetos para las cosas serias.

Debate

Prototipado rápido

El prototipado rápido se utiliza en el desarrollo de productos para crear rápidamente prototipos que funcionen y validarlos con el usuario final. Esta técnica permite a diseñadores e ingenieros probar y refinar un diseño antes de crear un código limpio coherente, robusto y elegante.

Las matrices asociativas son una forma práctica de representar objetos anémicos. Si los encuentras en el código, esta receta te ayudará a reificar el concepto y sustituirlos. Tener objetos ricos es beneficioso para limpiar el código, de modo que puedas fallar rápido, mantener la integridad, evitar la duplicación de código y ganar cohesión.

Mucha gente sufre de obsesión por lo primitivo y cree que esto es sobrediseño. Diseñar software consiste en tomar decisiones y comparar compensaciones. El argumento del rendimiento no es válido hoy en día, ya que las máquinas virtuales modernas pueden tratar eficazmente pequeños objetos de corta duración.

He aquí un ejemplo de código de obsesión anémico y primitivo:

$coordinate = array('latitude'=>1000, 'longitude'=>2000);
// They are just arrays. A bunch of raw data

Esto es más exacto según el concepto de biyección:

final class GeographicCoordinate {
    function __construct($latitudeInDegrees, $longitudeInDegrees) {
        $this->longitude = $longitudeInDegrees;
        $this->latitude = $latitudeInDegrees;
    }
}

$coordinate = new GeographicCoordinate(1000, 2000);
// Should throw an error since these values don’t exist on Earth

Necesitas tener objetos que sean válidos desde el principio:

final class GeographicCoordinate {
    function __construct($latitudeInDegrees, $longitudeInDegrees) {
        $this->longitude = $longitudeInDegrees;
        $this->latitude = $latitudeInDegrees;
    }
}

$coordinate = new GeographicCoordinate(1000, 2000);
// Should throw an error since these values don't exist on Earth

final class GeographicCoordinate {
    function __construct($latitudeInDegrees, $longitudeInDegrees) {
        if (!$this->isValidLatitude($latitudeInDegrees)) {
            throw new InvalidLatitudeException($latitudeInDegrees);
            }
         $this->longitude = $longitudeInDegrees;
         $this->latitude = $latitudeInDegrees;
        }
    }
}

$coordinate = new GeographicCoordinate(1000, 2000);
// throws an error since these values don't exist on Earth

Hay un objeto pequeño oscuro (ver Receta 4.1, "Crear objetos pequeños") para modelar la latitud:

final class Latitude {
    function __construct($degrees) {
        if (!$degrees->between(-90, 90)) {
            throw new InvalidLatitudeException($degrees);
        }
    }
}

final class GeographicCoordinate {

    function distanceTo(GeographicCoordinate $coordinate) { }
    function pointInPolygon(Polygon $polygon) { }
}

// Now you are in the geometry world (and not in the world of arrays anymore).
// You can safely do many exciting things.

Al crear objetos, no debes pensar en ellos como datos. Se trata de un error muy común. Debes mantenerte fiel al concepto de biyección y descubrir objetos del mundo real.

4.4 Eliminar los abusos de las cadenas

Problema

Tienes también muchas funciones de análisis sintáctico, explosión, regex, comparación de cadenas, búsqueda de subcadenas y otras funciones de manipulación de cadenas.

Solución

Utiliza abstracciones reales y objetos reales en lugar de manipulaciones accidentales de cadenas.

Debate

No abuses de las cuerdas. Favorece los objetos reales. Encuentra protocolos ausentes para distinguirlos de las cadenas. Este código hace muchas manipulaciones primitivas de cadenas:

$schoolDescription = 'College of Springfield';

preg_match('/[^ ]*$/', $schoolDescription, $results);
$location = $results[0]; // $location = 'Springfield'.

$school = preg_split('/[\s,]+/', $schoolDescription, 3)[0]; //'College'

Puedes convertir el código en una versión más declarativa:

class School {
    private $name;
    private $location;

    function description() {
        return $this->name . ' of ' . $this->location->name;
    }
}

Al encontrar objetos presentes en el MAPPER, tu código es más declarativo, más comprobable y puede evolucionar y cambiar más rápidamente. También puedes añadir restricciones a las nuevas abstracciones. Utilizar cadenas para mapear objetos reales es una obsesión primitiva y un síntoma de optimización prematura(véase el Capítulo 16, "Optimización prematura"). A veces, la versión de cadenas es un poco más eficaz. Si tienes que decidir entre aplicar esta receta o hacer manipulaciones de bajo nivel, crea siempre escenarios de uso reales y encuentra mejoras concluyentes y significativas.

4.5 Reificar marcas de tiempo

Problema

Tu código se basa en marcas de tiempo mientras que tú sólo necesitas secuenciación.

Solución

No utilices marcas de tiempo para secuenciar. Centraliza y bloquea tu emisor de tiempo.

Debate

Gestionar las marcas de tiempo en distintas zonas horarias y con escenarios de gran concurrencia es un problema bien conocido. A veces, puedes confundir el problema de tener elementos secuenciales y ordenados con la (posible) solución de ponerles marcas de tiempo. Como siempre, necesitas comprender los problemas esenciales que hay que resolver antes de adivinar implementaciones accidentales.

Una posible solución es utilizar una autoridad centralizada o algunos complejos algoritmos de consenso descentralizados. Esta receta cuestiona la necesidad de los sellos de tiempo cuando sólo necesitas una secuencia ordenada. Los sellos de tiempo son muy populares en muchos idiomas y están omnipresentes. Necesitas utilizar timestamps nativos sólo para modelar timestamps si los encuentras en la bijección.

Aquí tienes algunos problemas con las marcas de tiempo:

import time

# ts1 and ts2 stores the time in seconds
ts1 = time.time()
ts2 = time.time() # might be the same!!

Aquí tienes una solución mejor sin marcas de tiempo, ya que sólo necesitas un comportamiento secuencial:

numbers = range(1, 100000)
# create a sequence of numbers and use them with a hotspot

# or
sequence = nextNumber()

4.6 Reificar subconjuntos como objetos

Problema

Modelas objetos en un dominio superconjunto y tienen mucha duplicación de validación.

Soluciones

Crea objetos pequeños y valida un dominio restringido.

Debate

Los subconjuntos son un caso especial de un olor primitivo de obsesión. Los objetos subconjunto están presentes en la biyección, por lo que debes crearlos en tu simulador. Además, cuando intentes crear un objeto no válido, debe romperse inmediatamente, siguiendo el principio de fail fast (ver Capítulo 13, "Fail Fast"). Algunos ejemplos de violaciones de subconjuntos son: los correos electrónicos son un subconjunto de las cadenas, las edades válidas son un subconjunto de los números reales y los puertos son un subconjunto de los números enteros. Los objetos invisibles tienen reglas que debes hacer cumplir en un único punto.

Mira este ejemplo:

validDestination = "destination@example.com"
invalidDestination = "destination.example.com"
// No error is thrown

Aquí tienes una restricción de dominio mejor:

public class EmailAddress {
    public String emailAddress;

    public EmailAddress(String address) {
        string expressions = @"^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$";
        if (!Regex.IsMatch(email, expressions) {
          throw new Exception('Invalid email address');
        }
        this.emailAddress = address;
    }
}

destination = new EmailAddress("destination@example.com");

Esta solución no debe confundirse con la versión anémica de Java. Debes ser fiel a la biyección del mundo real.

4.7 Reificar las validaciones de cadenas

Problema

Estás validando un subconjunto de strings.

Solución

Busca los objetos de dominio que faltan al validar cadenas y reifícalos.

Debate

El software serio tiene muchas validaciones de cadenas. A menudo, no están en los lugares correctos, lo que conduce a un software frágil y corrupto. La solución sencilla es construir sólo abstracciones válidas y del mundo real:

// First Example: Address Validation
class Address {
  function __construct(string $emailAddress) {
     // String validation on Address class violates
     // Single Responsibility Principle
     $this->validateEmail($emailAddress);
     // ...
   }

  private function validateEmail(string $emailAddress) {
    $regex = "/[a-zA-Z0-9_-.+]+@[a-zA-Z0-9-]+.[a-zA-Z]+/";
    // Regex is a sample / It might be wrong
    // Emails and Urls should be first class objects

    if (!preg_match($regex, $emailAddress))
    {
      throw new Exception('Invalid email address ' . emailAddress);
    }   
  }
}

// Second Example: Wordle

class Wordle {
  function validateWord(string $wordleword) {
    // Wordle word should be a real world entity. Not a subset of Strings
  }
 }

He aquí una solución mejor:

// First Example: Address Validation
class Address {
  function __construct(EmailAddress $emailAddress) {
     // Email is always valid / Code is cleaner and not duplicated
     // ...
   }
}

class EmailAddress {
  // You can reuse this object many times avoiding copy-pasting
  string $address;
  private function __construct(string $emailAddress) {
    $regex = "/[a-zA-Z0-9_-.+]+@[a-zA-Z0-9-]+.[a-zA-Z]+/";
    // Regex is a sample / It might be wrong
    // Emails and Urls are first class objects

    if (!preg_match($regex, $emailAddress))
    {
      throw new Exception('Invalid email address ' . emailAddress);
    }  
    $this->address = $emailAddress;
  }
}

// Second Example: Wordle

class Wordle {
  function validateWord(WordleWord $wordleword) {
    // Wordle word is a real world entity. Not a subset of string
  }
 }

class WordleWord {
  function __construct(string $word) {
    // Avoid building invalid Wordle words
    // For example length != 5
  }
 }

Principio de Responsabilidad Única

El principio de responsabilidad única establece que cada módulo o clase de un sistema de software debe tener responsabilidad sobre una única parte de la funcionalidad proporcionada por el software y esa responsabilidad debe estar totalmente encapsulada por la clase. En otras palabras, una clase sólo debe tener una razón para cambiar.

Los objetos pequeños son difíciles de encontrar. Pero siguen el principio de "falla rápido" cuando intentas crear objetos no válidos. El nuevo objeto reificado también sigue el principio de responsabilidad única y el principio de no repetirse. Tener estas abstracciones te obliga a implementar un comportamiento específico que ya está disponible en los objetos que encapsula. Por ejemplo, un WordleWord no es un String, pero puede que necesites algunas funciones.

Principio de no repetirse

El principio de no repetirse (DRY) establece que los sistemas de software deben evitar la redundancia y la repetición de código. El objetivo del principio DRY es mejorar la mantenibilidad, flexibilidad y comprensibilidad del software reduciendo la cantidad de conocimientos, código e información duplicados.

Un contraargumento sobre la eficiencia evitando estas nuevas indirecciones es un signo deoptimización prematura, a menos que tengas pruebas concretas de una penalización sustancial con escenarios de uso real de tus clientes. Crear estos pequeños conceptos nuevos mantiene el modelo fiel a la biyección y garantiza que tus modelos estén siempre sanos.

Principios SÓLIDOS

SOLID es un mnemotécnico que significa cinco principios de programación orientada a objetos. Fueron definidos por Robert Martin y son directrices y heurísticos, no reglas rígidas. Se definen en los capítulos relacionados:

4.8 Eliminar propiedades innecesarias

Problema

Tienes objetos creados en función de sus propiedades en lugar de su comportamiento.

Solución

Elimina las propiedades accidentales. Añade el comportamiento necesario y luego añade propiedades accidentales para apoyar el comportamiento definido.

Debate

Muchas escuelas de programación te dicen que identifiques rápidamente las partes de los objetos y luego construyas funciones a su alrededor. Tales modelos suelen estar acoplados y son menos mantenibles que los creados en función del comportamiento deseado. Siguiendo la premisa de YAGNI(ver Capítulo 12, "YAGNI"), verás que muchas veces no necesitas estos atributos.

Cada vez que quieren modelar a una persona o a un empleado, los programadores junior o los estudiantes añaden un atributo id o nombre sin pensar si realmente los van a necesitar. Tienes que añadir atributos "bajo demanda" cuando haya suficientes pruebas de comportamiento. Los objetos no son "soportes de datos".

Este es un ejemplo clásico de enseñanza:

class PersonInQueue
  attr_accessor :name, :job

  def initialize(name, job)
    @name = name
    @job = job
  end
end

Si empiezas a centrarte en el comportamiento, podrás construir mejores modelos:

class PersonInQueue

  def moveForwardOnePosition
    # implement protocol
  end
end

Una técnica asombrosa para descubrir comportamientos es el desarrollo dirigido por pruebas, en el que te ves obligado a empezar a iterar el comportamiento y el protocolo y a aplazar todo lo que puedas la implementación accidental.

Desarrollo basado en pruebas

El desarrollo dirigido por pruebas (TDD) es un proceso de desarrollo de software que se basa en la repetición de un ciclo de desarrollo muy corto: primero, el desarrollador escribe un caso de prueba automatizado que falla y que define una mejora deseada o un nuevo comportamiento, luego produce un código de producción mínimo para superar esa prueba y, por último, refactoriza el nuevo código para que cumpla unos estándares aceptables. Uno de los principales objetivos de TDD es facilitar el mantenimiento del código asegurándose de que está bien estructurado y sigue unos buenos principios de diseño. También ayuda a detectar defectos en una fase temprana del proceso de desarrollo, ya que cada nuevo fragmento de código se prueba en cuanto se escribe.

4.9 Crear intervalos de fechas

Problema

Tienes que modelar intervalos del mundo real y tienes información como "desde la fecha" y "hasta la fecha", pero no invariantes como "desde la fecha debe ser menor que hasta la fecha".

Solución

Reifica este pequeño objeto y respeta la regla MAPPER.

Debate

Esta receta presenta una abstracción muy común que podrías pasar por alto y tiene los mismos problemas que has visto en las otras recetas de este capítulo: abstracciones que faltan, código duplicado, invariante no aplicada (consulta la Receta 13.2, "Aplicar precondiciones"), obsesión primitiva y violación del principio "fail fast". La restricción "la fecha de inicio debe ser inferior a la fecha de finalización" significa que la fecha de inicio de un determinado intervalo debe ser anterior a la fecha de finalización del mismo intervalo.

La "fechadesde" debe ser una fecha anterior en el tiempo a la "fechahasta ". Esta restricción existe para garantizar que el intervalo que se define tiene sentido lógico y que las fechas utilizadas para definirlo están en el orden correcto. Lo sabes, pero te olvidas de crear el objeto Interval. ¿Crearías un Date como un par de tres números enteros? Desde luego que no.

He aquí un ejemplo anémico:

val from = LocalDate.of(2018, 12, 9)
val to = LocalDate.of(2022, 12, 22)

val elapsed = elapsedDays(from, to)

fun elapsedDays(fromDate: LocalDate, toDate: LocalDate): Long {
    return ChronoUnit.DAYS.between(fromDate, toDate)
}

// You need to apply this short function
// or the inline version many times in your code
// You don't check fromDate to be less than toDate
// You can make accounting numbers with a negative value

Después de reificar el objeto Interval:

data class Interval(val fromDate: LocalDate, val toDate: LocalDate) {
    init {
        if (fromDate >= toDate) {
            throw IllegalArgumentException("From date must be before to date")
        }
        // Of course the Interval must be immutable
        // By using the keyword 'data'
    }

    fun elapsedDays(): Long {
        return ChronoUnit.DAYS.between(fromDate, toDate)
    }
}

val from = LocalDate.of(2018, 12, 9)
val to = LocalDate.of(2002, 12, 22)

val interval = Interval(from, to) // Invalid

Este es un olor a obsesión primitiva y está relacionado con la forma en que modelas las cosas. Si encuentras un software al que le faltan validaciones sencillas, sin duda necesita alguna reificación.

Get Libro de cocina de código limpio now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.