Ir al contenido

Lectura previa · S5 M · miércoles 3 de junio

Singletons are
Pathological Liars

Por qué el patrón Singleton es polémico · ~12 minutos de lectura

Un título provocador con un punto serio

En 2008, Misko Hevery —entonces en el equipo de testing de Google— publicó un post con un título deliberadamente provocador: "Singletons are Pathological Liars" (los Singletons son mentirosos patológicos). No es un ataque gratuito al patrón. Es una crítica precisa sobre qué rompe el Singleton y por qué.

El Singleton es uno de los 23 patrones del catálogo GoF, y de los más usados. Pero también es el más discutido: muchos equipos lo tratan como un anti-patrón. Esta lectura no busca que lo descartes de plano, sino que entiendas con precisión la objeción de Hevery, para que decidas con criterio cuándo conviene y cuándo no.

La tesis en una línea

El problema no es que exista una sola instancia.
El problema es cómo la conseguís: un punto de acceso global que esconde dependencias.

Tip de lectura: tocá, enfocá o pasá el mouse sobre las líneas marcadas para ver por qué importan.

Conceptos rápidos

En las anotaciones van a aparecer algunos términos. Si no los tenés frescos, esta es la versión corta:

  • Dependencia: otro objeto que una clase necesita para hacer su trabajo (por ejemplo, una CreditCard necesita una Database para guardar el cobro).
  • Estado global: datos accesibles desde cualquier parte del programa, sin pasarlos explícitamente. Cómodo de alcanzar, difícil de razonar y de aislar.
  • getInstance(): el método estático clásico del Singleton que devuelve siempre la misma instancia. Es el punto de acceso global del patrón.
  • Inyección de dependencias (DI): en vez de que una clase busque lo que necesita, se lo pasan desde afuera (normalmente por el constructor).
  • Contenedor de inyección: componente que arma el grafo de objetos al arrancar la app y decide qué dependencia recibe cada quién.
  • Seam (costura): un punto del diseño donde podés sustituir una pieza por otra —por ejemplo, una base de datos real por una falsa— sin modificar el código que la usa.
  • Mock / fake: una versión falsa y controlable de una dependencia, usada en pruebas para no tocar recursos reales.

No tenés que memorizarlos. Si una anotación menciona uno y no lo recordás, volvé acá un momento y seguís.

1 — APIs mentirosas

Lying APIs

El argumento central de Hevery: un Singleton hace que las firmas de tus métodos mientan. La firma de un constructor o de un método es un contrato: dice qué necesita ese código para funcionar. Cuando una clase pide sus dependencias por dentro, vía getInstance(), el contrato calla las dependencias reales.

Mirá la firma de abajo: CreditCard(number, expiry). Parece que para cobrar una tarjeta solo necesitás un número y una fecha. Pero el método charge() alcanza el estado global por su cuenta. La API te mintió sobre lo que de verdad hace falta.

// Ejemplo de Misko Hevery — "Singletons are Pathological Liars"
class CreditCard
{
    public function __construct(private string $number, private string $expiry) {}

    public function charge(int $amount): void
    {
        Database::getInstance()->save($this->number, $amount);
    }
}

// Quien lee esto cree que basta con la tarjeta:
$card = new CreditCard('1234', '12/27');
$card->charge(100);

Hevery lo resume así: el problema no es tener una sola instancia. El problema es cómo la conseguís. Cuando una clase la pide a un punto de acceso global, oculta una dependencia que debería estar a la vista.

Señales rápidas
  • La firma del constructor parece simple, pero los métodos llaman a getInstance() por dentro.
  • No podés saber qué necesita una clase sin leer el cuerpo de todos sus métodos.
  • Construís un objeto y "funciona" o "revienta" según un estado global que no controlás.
Autoevaluación

Mirando CreditCard de arriba, ¿en qué sentido "miente" la API?

2 — Acoplamiento global y tests frágiles

Global State / Hard to Test

El getInstance() introduce estado global: un único objeto compartido por todo el programa. Y el estado global tiene un costo que se paga en las pruebas. Para testear charge() de forma aislada querrías sustituir la base de datos por una falsa (un mock) que no toque la red ni el disco. Pero no hay por dónde meterla.

No existe un seam —una costura— donde inyectar la dependencia falsa. La clase va directo al getInstance() real. Tu test queda atado a la base de datos de verdad, lento y frágil; y si dos tests comparten ese Singleton, el orden en que corren empieza a importar.

// El test que te gustaría escribir...
public function test_charge_guarda_la_transaccion(): void
{
    $fakeDb = new InMemoryDatabase();

    $card = new CreditCard('1234', '12/27');
    $card->charge(100); // usa Database::getInstance(), no $fakeDb

    // ¿Cómo afirmo sobre $fakeDb si nunca se usó?
    $this->assertCount(1, $fakeDb->all()); // falla: sigue vacío
}

Hevery trabajaba en el equipo de testing de Google, y por eso su crítica es práctica antes que teórica: el Singleton no es feo "en abstracto", es que vuelve el código difícil de probar. Y código difícil de probar suele ser código difícil de cambiar.

Señales rápidas
  • No podés escribir un test unitario sin levantar la base de datos, la red o un servicio real.
  • Los tests fallan según el orden en que corren, o "se arreglan" volviéndolos a correr.
  • Necesitás un método como resetInstance() solo para que los tests no se pisen entre sí.
Ojo: tener que agregar un resetInstance() o un setInstance() "para los tests" es una señal, no una solución. Estás parchando el síntoma del estado global en vez de quitarlo.
Autoevaluación

El test de arriba falla porque charge() usa el Singleton real. ¿Cuál es la causa raíz?

3 — Singleton (GoF) vs "una sola instancia"

Singleton vs singleton

Acá está la distinción que Hevery insiste en hacer, y que evita malentender su argumento. Hay dos cosas distintas que se llaman parecido:

Singleton (con mayúscula, el patrón GoF): una clase que se encarga de garantizar su propia instancia única y la ofrece por un método global estático, getInstance(). Eso es lo polémico.

"un singleton" (con minúscula, una propiedad de la aplicación): que de cierto objeto exista una sola instancia en todo el sistema. Eso es perfectamente legítimo y a menudo deseable. La diferencia es quién garantiza esa unicidad.

// ✗ Singleton (GoF): la clase se autogestiona y se expone global.
class Database
{
    private static ?Database $instance = null;
    private function __construct() {}

    public static function getInstance(): static
    {
        return self::$instance ??= new self();
    }
}

// ✓ "un singleton": clase normal. Una sola instancia, pero la
// crea y comparte el contenedor de la app, no la clase misma.
$container->singleton(Database::class);

La frase de Hevery: "el problema no es que exista una sola instancia, sino que la clase se encargue de su propia construcción y se ofrezca globalmente". Quitá el constructor privado y el getInstance(), dejá que el contenedor administre la unicidad, y conservás el beneficio sin el acoplamiento.

Señales rápidas
  • La clase tiene constructor privado y un método estático getInstance().
  • La unicidad la garantiza la propia clase, no la configuración de la aplicación.
  • Para usar el objeto, el código consumidor lo busca él mismo en vez de recibirlo.
Autoevaluación

Según Hevery, ¿qué hace problemático al Database de la izquierda y no al de la derecha?

4 — La salida: inyección de dependencias

Dependency Injection

La cura que propone Hevery es simple y se nota en la firma: pedí las dependencias por el constructor. En lugar de que charge() vaya a buscar la base de datos al estado global, CreditCard la recibe al construirse. La dependencia deja de estar escondida y pasa a estar declarada.

Fijate cómo la API deja de mentir: ahora la firma dice exactamente qué hace falta. Y como la base de datos entra por afuera, en los tests podés pasar una falsa sin pelear con nada.

// La dependencia entra por el constructor: la firma ya no miente.
class CreditCard
{
    public function __construct(
        private string $number,
        private Database $db,
    ) {}

    public function charge(int $amount): void
    {
        $this->db->save($this->number, $amount);
    }
}

// En el test, una base falsa entra sin fricción:
$card = new CreditCard('1234', new InMemoryDatabase());

Quién arma el grafo de objetos —quién decide qué Database recibe cada CreditCard— es trabajo del contenedor de inyección o de una fábrica en el arranque de la app. Acá enlazamos con el bloque B del curso (Factory Method · Singleton, S5): la creación de objetos es una responsabilidad que conviene aislar, no esparcir por todo el código vía getInstance().

Señales rápidas
  • Las dependencias de una clase se leen completas en la firma del constructor.
  • Podés construir el objeto con una dependencia real o una falsa, sin tocar la clase.
  • Quién provee cada dependencia se decide en un solo lugar: el arranque de la app.
Autoevaluación

Pasar Database por el constructor arregla el problema de testing. ¿Por qué?

Para ir más lejos

La fuente y material para seguir:

  • Hevery, M. (2008). "Singletons are Pathological Liars" — el post original en el Google Testing Blog.
  • Hevery, M. (2008). "Where Have All the Singletons Gone?" y "Root Cause of Singletons" — los dos posts que continúan el argumento.
  • Gamma, Helm, Johnson, Vlissides (1994). Design Patterns. El capítulo del Singleton, para contrastar con la intención original del patrón.