Estado global y singletons

Advertencia: Las siguientes construcciones son un signo de código mal diseñado:

  • Foo::getInstance()
  • DB::insert(...)
  • Article::setDb($db)
  • ClassName::$var o static::$var

¿Aparece alguna de estas construcciones en su código? Entonces tiene la oportunidad de mejorarlo. Quizás piense que son construcciones comunes que ve incluso en soluciones de ejemplo de varias bibliotecas y frameworks. Si es así, entonces el diseño de su código no es bueno.

Ahora definitivamente no estamos hablando de algún tipo de pureza académica. Todas estas construcciones tienen una cosa en común: utilizan estado global. Y eso tiene un impacto destructivo en la calidad del código. Las clases mienten sobre sus dependencias. El código se vuelve impredecible. Confunde a los programadores y reduce su eficiencia.

En este capítulo explicaremos por qué es así y cómo evitar el estado global.

Acoplamiento global

En un mundo ideal, un objeto debería poder comunicarse solo con los objetos que le han sido pasados directamente. Si creo dos objetos A y B y nunca paso una referencia entre ellos, entonces ni A ni B pueden acceder al otro objeto o cambiar su estado. Esta es una propiedad muy deseable del código. Es similar a tener una batería y una bombilla; la bombilla no se encenderá hasta que la conecte a la batería con un cable.

Pero esto no se aplica a las variables globales (estáticas) o singletons. El objeto A podría acceder inalámbricamente al objeto C y modificarlo sin pasar ninguna referencia, llamando a C::changeSomething(). Si el objeto B también toma el C global, entonces A y B pueden influenciarse mutuamente a través de C.

El uso de variables globales introduce en el sistema una nueva forma de acoplamiento inalámbrico que no es visible desde el exterior. Crea una cortina de humo que complica la comprensión y el uso del código. Para que los desarrolladores comprendan realmente las dependencias, deben leer cada línea del código fuente. En lugar de simplemente familiarizarse con la interfaz de las clases. Además, es un acoplamiento completamente innecesario. El estado global se usa porque es fácilmente accesible desde cualquier lugar y permite, por ejemplo, escribir en la base de datos a través del método global (estático) DB::insert(). Pero como mostraremos, la ventaja que esto aporta es insignificante, mientras que las complicaciones que causa son fatales.

Desde el punto de vista del comportamiento, no hay diferencia entre una variable global y una estática. Son igualmente dañinas.

Acción fantasmal a distancia

“Acción fantasmal a distancia” – así llamó famosamente Albert Einstein en 1935 a un fenómeno de la física cuántica que le ponía la piel de gallina. Se trata del entrelazamiento cuántico, cuya peculiaridad es que cuando mides información sobre una partícula, influyes instantáneamente en la otra partícula, incluso si están separadas por millones de años luz. Lo cual aparentemente viola la ley fundamental del universo de que nada puede propagarse más rápido que la luz.

En el mundo del software, podemos llamar “acción fantasmal a distancia” a una situación en la que iniciamos un proceso que creemos que está aislado (porque no le pasamos ninguna referencia), pero en lugares remotos del sistema ocurren interacciones y cambios de estado inesperados de los que no teníamos ni idea. Esto solo puede ocurrir a través del estado global.

Imagine que se une a un equipo de desarrolladores de un proyecto que tiene una base de código extensa y madura. Su nuevo jefe le pide que implemente una nueva función y usted, como buen desarrollador, comienza escribiendo una prueba. Pero como es nuevo en el proyecto, realiza muchas pruebas exploratorias del tipo “¿qué pasa si llamo a este método?”. E intenta escribir la siguiente prueba:

function testCreditCardCharge()
{
	$cc = new CreditCard('1234567890123456', 5, 2028); // número de su tarjeta
	$cc->charge(100);
}

Ejecuta el código, quizás varias veces, y después de un tiempo nota notificaciones en su móvil del banco de que cada vez que se ejecuta, se cargan 100 dólares a su tarjeta de crédito 🤦‍♂️

¿Cómo diablos pudo la prueba causar un cargo real de dinero? Operar con una tarjeta de crédito no es fácil. Debe comunicarse con un servicio web de terceros, debe conocer la URL de este servicio web, debe iniciar sesión, etc. Ninguna de esta información está contenida en la prueba. Peor aún, ni siquiera sabe dónde está presente esta información y, por lo tanto, tampoco cómo simular (mock) las dependencias externas para que cada ejecución no resulte en que se carguen nuevamente 100 dólares. ¿Y cómo se suponía que usted, como nuevo desarrollador, supiera que lo que estaba a punto de hacer le haría 100 dólares más pobre?

¡Eso es acción fantasmal a distancia!

No le queda más remedio que rebuscar durante mucho tiempo en un montón de código fuente, preguntar a colegas más antiguos y experimentados, antes de comprender cómo funcionan las conexiones en el proyecto. Esto se debe a que al mirar la interfaz de la clase CreditCard, no se puede determinar el estado global que debe inicializarse. Incluso mirar el código fuente de la clase no le dice qué método de inicialización debe llamar. En el mejor de los casos, puede encontrar una variable global a la que se accede y, a partir de ella, intentar adivinar cómo inicializarla.

Las clases en tal proyecto son mentirosas patológicas. La tarjeta de crédito finge que basta con instanciarla y llamar al método charge(). En secreto, sin embargo, colabora con otra clase PaymentGateway, que representa la pasarela de pago. Incluso su interfaz dice que se puede inicializar por separado, pero en realidad extrae credenciales de algún archivo de configuración, etc. Para los desarrolladores que escribieron este código, está claro que CreditCard necesita PaymentGateway. Escribieron el código de esta manera. Pero para cualquiera que sea nuevo en el proyecto, es un completo misterio y dificulta el aprendizaje.

¿Cómo arreglar la situación? Fácilmente. Deje que la API declare las dependencias.

function testCreditCardCharge()
{
	$gateway = new PaymentGateway(/* ... */);
	$cc = new CreditCard('1234567890123456', 5, 2028);
	$cc->charge($gateway, 100);
}

Observe cómo de repente las interconexiones dentro del código son obvias. Al declarar el método charge() que necesita PaymentGateway, no tiene que preguntar a nadie cómo está interconectado el código. Sabe que debe crear su instancia, y cuando intenta hacerlo, se da cuenta de que debe proporcionar los parámetros de acceso. Sin ellos, el código ni siquiera se ejecutaría.

Y lo más importante, ahora puede simular (mock) la pasarela de pago, para que no se le cobren 100 dólares cada vez que ejecute la prueba.

El estado global hace que sus objetos puedan acceder en secreto a cosas que no están declaradas en su API y, como resultado, convierten sus API en mentirosos patológicos.

Quizás no lo había pensado así antes, pero cada vez que usa estado global, está creando canales de comunicación inalámbricos secretos. La acción fantasmal a distancia obliga a los desarrolladores a leer cada línea de código para comprender las interacciones potenciales, reduce la productividad de los desarrolladores 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 de usted está perdido.

No escriba código que utilice estado global, dé preferencia al paso de dependencias. Es decir, inyección de dependencias.

Fragilidad del estado global

En el código que utiliza estado global y singletons, nunca se sabe cuándo y quién cambió este estado. Este riesgo aparece ya durante la inicialización. El siguiente código debe crear una conexión a la base de datos e inicializar la pasarela de pago, pero constantemente lanza una excepción y encontrar la causa es extremadamente tedioso:

PaymentGateway::init();
DB::init('mysql:', 'user', 'password');

Debe examinar detenidamente el código para descubrir que el objeto PaymentGateway accede de forma inalámbrica a otros objetos, algunos de los cuales requieren una conexión a la base de datos. Por lo tanto, es necesario inicializar la base de datos antes que PaymentGateway. Sin embargo, la cortina de humo del estado global le oculta esto. ¿Cuánto tiempo habría ahorrado si la API de las clases individuales no engañara y declarara sus dependencias?

$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, ...);

Un problema similar surge también al usar 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 está claro si ya se ha creado la conexión a la base de datos y quién es responsable de su creación. Si quisiéramos, por ejemplo, cambiar la conexión a la base de datos sobre la marcha, por ejemplo, para pruebas, probablemente tendríamos que crear métodos adicionales como DB::reconnect(...) o DB::reconnectForTest().

Consideremos un ejemplo:

$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();

¿Dónde tenemos la certeza de que al llamar a $article->save() se está utilizando realmente la base de datos de prueba? ¿Qué pasa si el método Foo::doSomething() cambió la conexión global a la base de datos? Para averiguarlo, tendríamos que examinar el código fuente de la clase Foo y probablemente de muchas otras clases. Sin embargo, este enfoque solo proporcionaría una respuesta a corto plazo, ya que la situación puede cambiar en el futuro.

¿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(/* ... */);
	}
}

Esto no cambia nada en absoluto. El problema es el estado global y es completamente irrelevante en qué clase se esconde. En este caso, al igual que en el anterior, al llamar al método $article->save() no tenemos ninguna pista sobre en qué base de datos se escribirá. Cualquiera en el otro extremo de la aplicación podría haber cambiado la base de datos en cualquier momento usando Article::setDb(). Bajo nuestras narices.

El estado global hace que nuestra aplicación sea extremadamente frágil.

Sin embargo, existe una forma sencilla de abordar este problema. Simplemente deje que la API declare las dependencias, lo que garantizará la funcionalidad correcta.

class Article
{
	public function __construct(
		private DB $db,
	) {
	}

	public function save(): void
	{
		$this->db->insert(/* ... */);
	}
}

$article = new Article($db);
// ...
Foo::doSomething();
$article->save();

Gracias a este enfoque, desaparece la preocupación por cambios ocultos e inesperados en la conexión a la base de datos. Ahora tenemos la certeza de dónde se guarda el artículo y ninguna modificación del código dentro de otra clase no relacionada puede cambiar la situación. El código ya no es frágil, sino estable.

No escriba código que utilice estado global, dé preferencia al paso de dependencias. Es decir, inyección de dependencias.

Singleton

Singleton es un patrón de diseño que, según la definición de la conocida publicación Gang of Four, restringe una clase a una única instancia y ofrece acceso global a ella. La implementación de este patrón generalmente se asemeja 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 cumplen las funciones de la clase dada
}

Desafortunadamente, singleton introduce estado global en la aplicación. Y como hemos mostrado anteriormente, el estado global es indeseable. Por lo tanto, singleton se considera un antipatrón.

No use singletons en su código y reemplácelos con otros mecanismos. Realmente no necesita singletons. Sin embargo, si necesita garantizar la existencia de una única instancia de una clase para toda la aplicación, déjelo en manos del contenedor DI. Cree así un singleton de aplicación, o servicio. De esta manera, la clase dejará de ocuparse de garantizar su propia unicidad (es decir, no tendrá el método getInstance() ni la variable estática) y cumplirá solo sus funciones. Así dejará de violar el principio de responsabilidad única.

Estado global versus pruebas

Al escribir pruebas, asumimos que cada prueba es una unidad aislada y que ningún estado externo entra en ella. Y ningún estado sale de las pruebas. Después de completar una prueba, todo el estado relacionado con la prueba debería ser eliminado automáticamente por el recolector de basura. Gracias a esto, las pruebas están aisladas. Por lo tanto, podemos ejecutar las pruebas en cualquier orden.

Sin embargo, si hay estados globales/singletons presentes, todas estas agradables suposiciones se desmoronan. El estado puede entrar y salir de la prueba. De repente, el orden de las pruebas puede importar.

Para poder probar los singletons, los desarrolladores a menudo tienen que relajar sus propiedades, por ejemplo, permitiendo que la instancia sea reemplazada por otra. Tales soluciones son, en el mejor de los casos, un hack que crea código difícil de mantener y comprender. Cada prueba o método tearDown() que afecte a cualquier estado global debe revertir estos cambios.

¡El estado global es el mayor dolor de cabeza en las pruebas unitarias!

¿Cómo arreglar la situación? Fácilmente. No escriba código que utilice singletons, dé preferencia al paso de dependencias. Es decir, inyección de dependencias.

Constantes globales

El estado global no se limita solo al uso de singletons y variables estáticas, sino que también puede referirse a constantes globales.

Las constantes cuyo valor no nos aporta ninguna información nueva (M_PI) o útil (PREG_BACKTRACK_LIMIT_ERROR) están claramente bien. Por el contrario, las constantes que sirven como una forma de pasar información inalámbricamente al código no son más que una dependencia oculta. Como LOG_FILE en el siguiente ejemplo. El uso de la constante FILE_APPEND es completamente correcto.

const LOG_FILE = '...';

class Foo
{
	public function doSomething()
	{
		// ...
		file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
		// ...
	}
}

En este caso, deberíamos declarar un parámetro en el constructor de la clase Foo para que se convierta en parte de la API:

class Foo
{
	public function __construct(
		private string $logFile,
	) {
	}

	public function doSomething()
	{
		// ...
		file_put_contents($this->logFile, $message . "\n", FILE_APPEND);
		// ...
	}
}

Ahora podemos pasar la información sobre la ruta al archivo de registro y cambiarla fácilmente según sea necesario, lo que facilita las pruebas y el mantenimiento del código.

Funciones globales y métodos estáticos

Queremos enfatizar que el uso de métodos estáticos y funciones globales en sí mismo no es problemático. Explicamos por qué el uso de DB::insert() y métodos similares es inapropiado, pero siempre se trató solo de una cuestión de estado global, que se almacena en alguna variable estática. El método DB::insert() requiere la existencia de una variable estática porque la conexión a la base de datos se almacena en ella. Sin esta variable, sería imposible implementar el método.

El uso de métodos estáticos y funciones deterministas, como DateTime::createFromFormat(), Closure::fromCallable, strlen() y muchas otras, está en perfecta consonancia con la inyección de dependencias. Estas funciones siempre devuelven los mismos resultados para los mismos parámetros de entrada y, por lo tanto, son predecibles. No utilizan ningún estado global.

Sin embargo, también existen funciones en PHP que no son deterministas. Entre ellas se encuentra, por ejemplo, la función htmlspecialchars(). Su tercer parámetro $encoding, si no se especifica, tiene como valor predeterminado el valor de la opción de configuración ini_get('default_charset'). Por lo tanto, se recomienda especificar siempre este parámetro y evitar así un posible comportamiento impredecible de la función. Nette lo hace consistentemente.

Algunas funciones, como strtolower(), strtoupper() y similares, en el pasado reciente se comportaron de forma no determinista y dependían de la configuración de setlocale(). Esto causó muchas complicaciones, más comúnmente al trabajar con el idioma turco. Este distingue entre las letras I mayúscula y minúscula con y sin punto. Así que strtolower('I') devolvía el carácter ı y strtoupper('i') el carácter İ, lo que provocó que las aplicaciones comenzaran a causar una serie de errores misteriosos. Sin embargo, este problema se solucionó en la versión 8.2 de PHP y las funciones ya no dependen de la configuración regional (locale).

Este es un buen ejemplo de cómo el estado global atormentó a miles de desarrolladores en todo el mundo. La solución fue reemplazarlo por inyección de dependencias.

¿Cuándo es posible usar estado global?

Existen ciertas situaciones específicas en las que es posible utilizar el estado global. Por ejemplo, al depurar código, cuando necesita imprimir el valor de una variable o medir la duración de una parte específica del programa. En tales casos, que se refieren a acciones temporales que luego se eliminarán del código, es legítimo utilizar un dumper o cronómetro globalmente disponible. Estas herramientas no forman parte del diseño del código.

Otro ejemplo son las funciones para trabajar con expresiones regulares preg_*, que almacenan internamente las expresiones regulares compiladas en una caché estática en memoria. Por lo tanto, cuando llama a la misma expresión regular varias veces en diferentes lugares del código, solo se compila una vez. La caché ahorra rendimiento y, al mismo tiempo, es completamente invisible para el usuario, por lo que dicho uso puede considerarse legítimo.

Resumen

Hemos discutido por qué tiene sentido:

  1. Eliminar todas las variables estáticas del código
  2. Declarar dependencias
  3. Y usar inyección de dependencias

Al pensar en el diseño del código, tenga en cuenta que cada static $foo representa un problema. Para que su código sea un entorno que respete DI, es esencial erradicar por completo el estado global y reemplazarlo mediante inyección de dependencias.

Durante este proceso, puede descubrir que es necesario dividir la clase porque tiene más de una responsabilidad. No tenga miedo de eso; esfuércese por el principio de responsabilidad única.

Me gustaría agradecer a Miško Hevery, cuyos artículos, como Flaw: Brittle Global State & Singletons, son la base de este capítulo.

versión: 3.x