Estado global y singletons
Advertencia: las siguientes construcciones son síntomas de un código mal diseñado:
Foo::getInstance()DB::insert(...)Article::setDb($db)ClassName::$varostatic::$var
¿Aparece alguna de estas construcciones en su código? Si es así, tiene una oportunidad de mejora. Quizá piense que son construcciones habituales, que quizá ha visto en soluciones de ejemplo de distintas bibliotecas y frameworks. Si es así, el diseño de su código es defectuoso.
No estamos hablando aquí de una pureza académica. Todas estas construcciones comparten un rasgo común: usan estado global. Y el estado global tiene un efecto pernicioso sobre la calidad del código. Las clases pasan a mentir sobre sus dependencias. El código se vuelve impredecible. Confunde a los desarrolladores y reduce su eficiencia.
En este capítulo explicaremos por qué es así y cómo evitar el estado global.
Interconexión global
En un mundo ideal, un objeto solo debería comunicarse con los objetos que se le pasaron directamente. Si creo dos objetos
A y B y nunca les paso una referencia el uno del otro, ni A ni B pueden
acceder al estado del otro ni modificarlo. Es una propiedad muy deseable del código. Es como tener una pila y una bombilla: la
bombilla no se encenderá hasta que la conecte a la pila con un cable.
Eso, sin embargo, no vale para las variables globales (estáticas) ni para los singletons. El objeto A podría
acceder de forma inalámbrica al objeto C y modificarlo sin que se le pase ninguna referencia, llamando a
C::changeSomething(). Y si el objeto B se conecta también al C global, A y
B pueden influirse mutuamente a través de C.
Usar variables globales introduce una nueva forma de acoplamiento inalámbrico, invisible desde fuera. Crea una cortina
de humo que hace el código más difícil de entender y de usar. Para captar de verdad las dependencias, los desarrolladores
tienen que leer cada línea del código fuente en lugar de basarse en las interfaces de las clases. Y además ese acoplamiento es
del todo innecesario. El estado global se usa porque es fácilmente accesible desde cualquier sitio y permite, por ejemplo,
escribir en la base de datos mediante un método global (estático) DB::insert(). Pero, como demostraremos, la
comodidad aparente es mínima frente a las graves complicaciones que introduce.
En cuanto al comportamiento no hay diferencia entre una variable global y una estática. Son igual de dañinas.
La fantasmagórica acción a distancia
“Fantasmagórica acción a distancia” es como llamó célebremente Albert Einstein a un fenómeno de la física cuántica que le producía escalofríos. Se refiere al entrelazamiento cuántico, en el que medir una propiedad de una partícula afecta instantáneamente a otra partícula entrelazada, sin importar la distancia que las separe, aunque sea de millones de años luz, lo que en apariencia viola la ley fundamental del universo de que nada puede viajar más rápido que la luz.
En el mundo del software, la “fantasmagórica acción a distancia” describe una situación en la que ejecutamos un proceso que creemos aislado (porque no se le pasó explícitamente ninguna dependencia) y, sin embargo, se producen interacciones y cambios de estado inesperados en partes lejanas del sistema, sin que lo sepamos. Esto solo puede ocurrir mediante el estado global.
Imagine que se incorpora a un equipo de desarrollo en un proyecto con una base de código grande y madura. Su nuevo jefe le pide que implemente una nueva funcionalidad y usted, como buen desarrollador, empieza escribiendo un test. Pero, como es nuevo en el proyecto, hace un montón de pruebas exploratorias del tipo “qué pasa si llamo a este método”. Y prueba a escribir el siguiente test:
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // el número de su tarjeta
$cc->charge(100);
}
Ejecuta el código, quizá varias veces, y al cabo de un rato ve en el móvil notificaciones del banco: ¡se han cargado 100 $ a su tarjeta de crédito en cada ejecución! 🤦♂️
¿Cómo demonios pudo el test provocar un cargo real? Operar con una tarjeta de crédito no es sencillo. Hay que comunicarse con un servicio web de terceros, conocer su URL, autenticarse, etc. Nada de esa información está en el test. Peor aún: no sabe dónde reside esa información, lo que hace imposible simular las dependencias externas para evitar el cargo de 100 $ en cada ejecución del test. Y, como desarrollador nuevo, ¿cómo iba a saber que lo que estaba a punto de hacer le dejaría 100 $ más pobre?
¡Eso es una fantasmagórica acción a distancia!
Se ve obligado a rebuscar en un código fuente extenso y a consultar a compañeros veteranos para entender las interconexiones
del proyecto. Esta dificultad surge porque la interfaz de la clase CreditCard no revela la necesaria inicialización
del estado global. Ni siquiera examinar el código fuente de la clase revela a qué método de inicialización hay que llamar. En
el mejor de los casos encontrará la variable global a la que se accede e intentará deducir cómo inicializarla.
Las clases de un proyecto así son mentirosas patológicas. La clase CreditCard finge que basta con instanciarla y
llamar a su método charge(). Pero en secreto se comunica con otra clase, PaymentGateway, que representa
la pasarela de pago. Incluso la interfaz de PaymentGateway puede sugerir una inicialización independiente cuando en
realidad quizá saque las credenciales de un archivo de configuración, etc. Los desarrolladores originales saben que
CreditCard necesita PaymentGateway. Escribieron el código así. Pero para los recién llegados es un
misterio absoluto que les impide aprender y contribuir con eficacia.
¿Cómo arreglar la situación? Fácil. Deje que la API declare las dependencias.
function testCreditCardCharge()
{
$gateway = new PaymentGateway(/* ... */);
$cc = new CreditCard('1234567890123456', 5, 2028);
$cc->charge($gateway, 100);
}
Fíjese en cómo las interdependencias del código se vuelven evidentes de inmediato. Como el método charge()
declara que necesita un PaymentGateway, ya no tiene que adivinar ni preguntar por esa dependencia. Sabe que tiene que
crear una instancia y, al hacerlo, descubrirá los parámetros de acceso necesarios. Sin ellos el código ni siquiera
funcionaría.
Y, lo más importante, ahora puede simular la pasarela de pago para que no le cobren 100 $ cada vez que ejecuta un test.
El estado global permite a los objetos acceder en secreto a dependencias no declaradas en sus API, lo que convierte sus API en mentirosas patológicas.
Puede que no lo hubiera pensado así antes, pero siempre que usa estado global está creando canales secretos de comunicación inalámbrica. Esa fantasmagórica acción a distancia obliga a los desarrolladores a leer cada línea de código para entender las posibles interacciones, lo que reduce la productividad y confunde a los nuevos miembros del equipo. Si usted es quien creó el código, conoce las dependencias reales; pero cualquiera que venga después no tiene ni idea.
Evite escribir código que dependa del estado global; prefiera pasar las dependencias explícitamente. Adopte la inyección de dependencias.
Fragilidad del estado global
En un código que usa estado global y singletons nunca puede estar seguro de cuándo ni por quién se modificó el estado. Ese riesgo se manifiesta ya en la inicialización. El siguiente código pretende crear una conexión a la base de datos e inicializar una pasarela de pago, pero lanza excepciones una y otra vez, y depurar la causa es extremadamente tedioso:
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
Tiene que rastrear el código meticulosamente para descubrir que el objeto PaymentGateway accede de forma
inalámbrica a otros objetos, algunos de los cuales necesitan una conexión a la base de datos. Por tanto, la base de datos debe
inicializarse antes que PaymentGateway. Pero la cortina de humo del estado global se lo oculta. ¿Cuánto tiempo se
ahorraría si las API de esas clases fueran honestas y declararan sus dependencias?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, /* ... */);
Un problema parecido surge al usar un acceso global a la conexión de la base de datos:
use Illuminate\Support\Facades\DB;
class Article
{
public function save(): void
{
DB::insert(/* ... */);
}
}
Al llamar al método save() no se sabe si se ha establecido una conexión a la base de datos ni quién es
responsable de establecerla. Si necesitamos cambiar la conexión dinámicamente (p. ej. para las pruebas), quizá acabemos
añadiendo métodos como DB::reconnect(...) o DB::reconnectForTest().
Considere un ejemplo:
$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();
¿Cómo podemos estar seguros de que al llamar a $article->save() se usa realmente la base de datos de pruebas?
¿Y si el método Foo::doSomething() cambió la conexión global a la base de datos? Para averiguarlo tendríamos que
inspeccionar el código fuente de Foo y quizá de muchas otras clases. Y esa investigación solo daría una respuesta
temporal, porque la situación podría cambiar más adelante.
¿Y si movemos la conexión a la base de datos a una variable estática dentro de la clase Article?
class Article
{
private static DB $db;
public static function setDb(DB $db): void
{
self::$db = $db;
}
public function save(): void
{
self::$db->insert(/* ... */);
}
}
Eso no cambia nada en absoluto. El problema es el estado global en sí, sin importar dentro de qué clase se esconda. En este
escenario, igual que en el anterior, al llamar a $article->save() no tenemos ninguna certeza sobre en qué base de
datos se escribirán los datos. Cualquiera, en cualquier lugar de la aplicación, pudo cambiar la base de datos en cualquier
momento con Article::setDb(). Sin que lo supiéramos.
El estado global hace nuestra aplicación extremadamente frágil.
Pero hay una forma sencilla de tratar este problema. Basta con que la API declare las dependencias necesarias para funcionar correctamente.
class Article
{
public function __construct(
private DB $db,
) {
}
public function save(): void
{
$this->db->insert(/* ... */);
}
}
$article = new Article($db);
// ...
Foo::doSomething();
$article->save();
Este enfoque elimina la preocupación por cambios ocultos o inesperados en la conexión a la base de datos. Ahora tenemos la certeza de dónde se guarda el artículo, y las modificaciones en clases no relacionadas ya no pueden afectarlo. El código deja de ser frágil y pasa a ser estable.
Evite escribir código que dependa del estado global; prefiera pasar las dependencias explícitamente. Adopte la inyección de dependencias.
Singleton
El singleton es un patrón de diseño que, según la definición de la famosa publicación de la Banda de los Cuatro, limita una clase a una única instancia y ofrece acceso global a ella. La implementación de este patrón se parece normalmente al siguiente código:
class Singleton
{
private static self $instance;
public static function getInstance(): self
{
self::$instance ??= new self;
return self::$instance;
}
// y otros métodos que realizan las funciones de la clase
}
Por desgracia, el singleton introduce estado global en la aplicación. Y, como hemos mostrado arriba, el estado global es indeseable. Por eso el singleton se considera un antipatrón.
No use singletons en su código y sustitúyalos por otros mecanismos. Realmente no necesita singletons. Ahora bien, si necesita
asegurarse de que en toda la aplicación exista una única instancia de una clase, delegue esa responsabilidad en el contenedor DI. Así se crea un singleton con el alcance de la
aplicación, al que normalmente se llama servicio. La clase misma queda entonces liberada de gestionar su unicidad (es decir, no
tendrá un método getInstance() ni una propiedad estática con la instancia) y puede centrarse solo en sus
responsabilidades. Así dejará de violar el principio de responsabilidad única.
El estado global frente a los tests
Al escribir tests, lo ideal es suponer que cada test es una unidad aislada, sin que entre ni salga de ella ningún estado externo. Cuando un test termina, todo el estado asociado a él debería limpiarlo automáticamente el recolector de basura. Eso hace que los tests estén aislados. Por eso podemos ejecutarlos en cualquier orden.
Pero cuando hay estado global o singletons, esas suposiciones tan útiles se desmoronan. El estado puede filtrarse hacia dentro y hacia fuera de los tests. De pronto, el orden de los tests puede importar.
Para poder siquiera testear código con singletons, los desarrolladores tienen a menudo que comprometer su integridad, por
ejemplo permitiendo sustituir la instancia del singleton. Esas soluciones son, en el mejor de los casos, apaños que llevan a un
código difícil de mantener y de entender. Cualquier test (o su método tearDown()) que modifique el estado global
debe deshacer meticulosamente esos cambios.
¡El estado global es el mayor quebradero de cabeza de las pruebas unitarias!
¿Cómo arreglarlo? Sencillo. Evite escribir código que use singletons; prefiera pasar las dependencias explícitamente. Adopte la inyección de dependencias.
Constantes globales
El estado global no se limita al uso de singletons y variables estáticas: también puede afectar a las constantes globales.
Las constantes cuyos valores representan verdades universales (M_PI) o aportan información autocontenida
(PREG_BACKTRACK_LIMIT_ERROR) son en general aceptables. En cambio, las constantes usadas como forma de inyectar
información en el código de forma inalámbrica son en la práctica dependencias ocultas. Como LOG_FILE en
el siguiente ejemplo. El uso de la constante FILE_APPEND es perfectamente correcto.
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
En su lugar deberíamos declarar la ruta del archivo de registro como parámetro del constructor de la clase Foo,
convirtiéndola en una parte explícita de su API:
class Foo
{
public function __construct(
private string $logFile,
) {
}
public function doSomething()
{
// ...
file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
// ...
}
}
Ahora pasamos explícitamente la ruta al archivo de registro. Podemos cambiarla con facilidad cuando haga falta, lo que simplifica las pruebas y el mantenimiento del código.
Funciones globales y métodos estáticos
Queremos subrayar que usar métodos estáticos y funciones globales no es de por sí problemático. Hemos explicado los
problemas de métodos como DB::insert(), pero el problema de fondo era siempre el estado global subyacente, guardado
normalmente en una variable estática. El método DB::insert() depende de una variable estática que contiene la
conexión a la base de datos. Sin esa variable sería imposible implementar el método.
Usar métodos y funciones estáticos deterministas como Closure::fromCallable(), strlen() y muchos
otros es perfectamente compatible con la inyección de dependencias. Esas funciones son predecibles porque devuelven siempre el
mismo resultado para los mismos parámetros de entrada. No usan ningún estado global.
Hay, sin embargo, funciones en PHP que no son deterministas. Entre ellas está, por ejemplo, la función
htmlspecialchars(). Su tercer parámetro, $encoding, si se omite, toma por defecto el valor de la
opción de configuración default_charset (ini_get('default_charset')). Por eso se recomienda indicar
siempre este parámetro para evitar un posible comportamiento impredecible. Nette lo hace de forma sistemática.
Algunas funciones, como strtolower() y strtoupper(), mostraban en el pasado reciente un
comportamiento no determinista que dependía de la configuración del locale (setlocale()). Eso causó muchas
complicaciones, sobre todo al trabajar con el turco. El motivo es que el turco distingue entre la “I” con punto y sin punto
tanto en minúscula como en mayúscula. En consecuencia, strtolower('I') devolvía ı (i minúscula sin
punto) y strtoupper('i') devolvía İ (I mayúscula con punto), lo que provocaba numerosos errores
misteriosos en las aplicaciones. Este problema, sin embargo, se corrigió en la versión 8.2 de PHP y las funciones ya no
dependen del locale.
Es un buen ejemplo de cómo el estado global (la configuración del locale) trajo de cabeza a miles de desarrolladores de todo el mundo. La solución definitiva consistió en hacer las funciones independientes del locale, es decir, en eliminar la dependencia oculta.
¿Cuándo es posible usar estado global?
Hay situaciones concretas y limitadas en las que usar estado global puede ser aceptable. Por ejemplo, durante la depuración, cuando necesita volcar el valor de una variable o medir el tiempo de ejecución de un fragmento concreto de código. En esos casos, que implican acciones temporales que después se eliminarán del código, usar un dumper o un cronómetro accesible globalmente puede ser legítimo. Esas herramientas no forman parte del diseño esencial de la aplicación.
Otro ejemplo son las funciones de expresiones regulares de PHP (preg_*), que internamente guardan en memoria
estática una caché de las expresiones regulares compiladas. Cuando llama varias veces a esas funciones con la misma expresión
regular a lo largo de su código, la expresión se compila una sola vez. Esa caché mejora el rendimiento y es completamente
invisible para el usuario, lo que hace que ese uso de estado estático interno sea en general aceptable.
Resumen
Hemos hablado de por qué tiene sentido:
- eliminar de su código todas las propiedades estáticas mutables (el estado global)
- declarar explícitamente las dependencias
- y usar la inyección de dependencias
Al diseñar su código, recuerde que cada static $foo mutable es una fuente potencial de problemas. Para crear un
entorno favorable a la DI es crucial eliminar por completo el estado global y sustituirlo por la inyección de dependencias.
Durante ese proceso quizá descubra la necesidad de dividir clases que tienen varias responsabilidades. No lo dude; aspire al principio de responsabilidad única.
Quiero dar las gracias a Miško Hevery, cuyos artículos, como Flaw: Brittle Global State & Singletons, son la base de este capítulo.