Estado global e Singletons

Advertência: As construções a seguir são sintomas de código mal projetado:

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

Você encontra alguma dessas construções em seu código? Se sim, você tem a oportunidade de aprimorá-lo. Você pode pensar que essas construções são comuns, frequentemente vistas em soluções de exemplo de várias bibliotecas e estruturas. Se esse for o caso, o design do código é falho.

Não estamos falando de pureza acadêmica. Todas essas construções têm uma coisa em comum: elas utilizam o estado global. E isso tem um impacto destrutivo na qualidade do código. As classes são enganosas quanto às suas dependências. O código se torna imprevisível. Isso confunde os desenvolvedores e reduz sua eficiência.

Neste capítulo, explicaremos por que isso acontece e como evitar o estado global.

Interligação global

Em um mundo ideal, um objeto só deve se comunicar com objetos que foram passados diretamente a ele. Se eu criar dois objetos A e B e nunca passar uma referência entre eles, então nem A nem B poderão acessar ou modificar o estado do outro. Essa é uma propriedade altamente desejável do código. É como ter uma bateria e uma lâmpada; a lâmpada não acenderá até que você a conecte à bateria com um fio.

Entretanto, isso não se aplica a variáveis globais (estáticas) ou singletons. O objeto A poderia acessar sem fio o objeto C e modificá-lo sem nenhuma passagem de referência, chamando C::changeSomething(). Se o objeto B também acessar o objeto global C, então A e B poderão se influenciar mutuamente por meio de C.

O uso de variáveis globais introduz uma nova forma de acoplamento sem fio que não é visível externamente. Isso cria uma cortina de fumaça que complica a compreensão e o uso do código. Para realmente entender as dependências, os desenvolvedores precisam ler cada linha do código-fonte, em vez de apenas se familiarizarem com as interfaces de classe. Além disso, esse emaranhado é totalmente desnecessário. O estado global é usado porque é facilmente acessível de qualquer lugar e permite, por exemplo, gravar em um banco de dados por meio de um método global (estático) DB::insert(). Entretanto, como veremos, o benefício que ele oferece é mínimo, enquanto as complicações que ele introduz são graves.

Em termos de comportamento, não há diferença entre uma variável global e uma variável estática. Elas são igualmente prejudiciais.

A ação assustadora à distância

“Ação assustadora à distância” – é o que Albert Einstein chamou famoso fenômeno na física quântica que lhe deu arrepios em 1935. É um emaranhado quântico, cuja peculiaridade é que quando você mede informações sobre uma partícula, você afeta imediatamente outra partícula, mesmo que elas estejam a milhões de anos-luz de distância. o que aparentemente viola a lei fundamental do universo de que nada pode viajar mais rápido do que a luz.

No mundo do software, podemos chamar uma “ação assustadora à distância” de uma situação em que executamos um processo que pensamos estar isolado (porque não passamos nenhuma referência), mas interações e mudanças de estado inesperadas acontecem em locais distantes do sistema, das quais não falamos ao objeto. Isto só pode acontecer através do estado global.

Imagine se juntar a uma equipe de desenvolvimento de projetos que tenha uma base de código grande e madura. Sua nova liderança lhe pede para implementar uma nova funcionalidade e, como um bom desenvolvedor, você começa escrevendo um teste. Mas como você é novo no projeto, você faz um monte de testes exploratórios do tipo “o que acontece se eu chamar este método”. E você tenta escrever o seguinte teste:

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

Você executa o código, talvez várias vezes, e depois de um tempo você nota em seu telefone notificações do banco de que cada vez que você o executa, $100 foram cobrados em seu cartão de crédito 🤦♂️

Como diabos o teste poderia causar uma carga real? Não é fácil de operar com cartão de crédito. Você tem que interagir com um serviço web de terceiros, você tem que conhecer o URL desse serviço web, você tem que fazer o login, e assim por diante. Nenhuma destas informações está incluída no teste. Pior ainda, você nem sabe onde estas informações estão presentes e, portanto, como zombar das dependências externas para que cada execução não resulte na cobrança de US$ 100 novamente. E como um novo desenvolvedor, como você deveria saber que o que você estava prestes a fazer o levaria a ser $100 mais pobre?

Isso é uma ação assustadora à distância!

Você não tem escolha a não ser cavar muito código fonte, perguntando aos colegas mais velhos e mais experientes, até entender como funcionam as conexões no projeto. Isto se deve ao fato de que, ao olhar para a interface da classe CreditCard, você não pode determinar o estado global que precisa ser inicializado. Mesmo olhando para o código-fonte da classe, você não dirá qual método de inicialização deve ser chamado. Na melhor das hipóteses, você pode encontrar a variável global a ser acessada e tentar adivinhar como inicializá-la a partir disso.

As aulas em tal projeto são mentirosos patológicos. O cartão de pagamento finge que você pode simplesmente instanciá-lo e ligar para o método charge(). No entanto, ele interage secretamente com outra classe, PaymentGateway. Mesmo sua interface diz que pode ser inicializada independentemente, mas na realidade, ela retira credenciais de algum arquivo de configuração e assim por diante. É claro para os desenvolvedores que escreveram este código que CreditCard precisa PaymentGateway. Eles escreveram o código desta forma. Mas para qualquer novato no projeto, isto é um mistério completo e dificulta o aprendizado.

Como consertar a situação? Fácil. Deixe a API declarar as dependências.

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

Observe como as relações dentro do código são subitamente óbvias. Ao declarar que o método charge() precisa PaymentGateway, você não precisa perguntar a ninguém como o código é interdependente. Você sabe que tem que criar uma instância dele, e quando você tenta fazer isso, você se depara com o fato de que tem que fornecer parâmetros de acesso. Sem eles, o código não funcionaria.

E o mais importante, agora você pode zombar da porta de pagamento para que não lhe sejam cobrados 100 dólares cada vez que fizer um teste.

O estado global faz com que seus objetos possam acessar secretamente coisas que não estão declaradas em seus APIs e, como resultado, torna seus APIs mentirosos patológicos.

Você pode não ter pensado nisso antes, mas sempre que você usa o estado global, você está criando canais secretos de comunicação sem fio. A ação remota assustadora força os desenvolvedores a ler cada linha de código para entender as possíveis interações, reduz a produtividade dos desenvolvedores e confunde os novos membros da equipe. Se foi você quem criou o código, você conhece as dependências reais, mas qualquer um que venha atrás de você não tem a menor idéia.

Não escreva código que use o estado global, prefira passar dependências. Ou seja, injeção de dependência.

Brittleness do Estado Global

Em código que usa estado global e singletons, nunca é certo quando e por quem esse estado mudou. Este risco já está presente na inicialização. O código a seguir deve criar uma conexão de banco de dados e inicializar o gateway de pagamento, mas ele continua lançando uma exceção e encontrar a causa é extremamente enfadonho:

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

Você tem que percorrer o código em detalhes para descobrir que o objeto PaymentGateway acessa outros objetos sem fio, alguns dos quais requerem uma conexão de banco de dados. Portanto, você deve inicializar o banco de dados antes de PaymentGateway. No entanto, a cortina de fumaça do estado global esconde isso de você. Quanto tempo você economizaria se a API de cada classe não mentisse e declarasse suas dependências?

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

Um problema semelhante surge quando se utiliza o acesso global a uma conexão de banco de dados:

use Illuminate\Support\Facades\DB;

class Article
{
	public function save(): void
	{
		DB::insert(/* ... */);
	}
}

Ao chamar o método save(), não é certo se já foi criada uma conexão de banco de dados e quem é o responsável por criá-la. Por exemplo, se quiséssemos mudar a conexão de banco de dados na hora, talvez para fins de teste, provavelmente teríamos que criar métodos adicionais, como DB::reconnect(...) ou DB::reconnectForTest().

Considere um exemplo:

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

Onde podemos ter certeza de que o banco de dados de testes está realmente sendo usado quando se liga para $article->save()? E se o método Foo::doSomething() mudou a conexão global do banco de dados? Para descobrir, teríamos que examinar o código fonte da classe Foo e provavelmente muitas outras classes. Entretanto, esta abordagem forneceria apenas uma resposta a curto prazo, pois a situação pode mudar no futuro.

E se movermos a conexão de banco de dados para uma variável estática dentro da classe Article?

class Article
{
	private static DB $db;

	public static function setDb(DB $db): void
	{
		self::$db = $db;
	}

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

Isto não muda absolutamente nada. O problema é um estado global e não importa em qual classe ele se esconde. Neste caso, como no anterior, não temos nenhuma pista de qual banco de dados está sendo escrito quando o método $article->save() é chamado. Qualquer pessoa no extremo distante da aplicação poderia alterar o banco de dados a qualquer momento usando Article::setDb(). Sob nossas mãos.

O estado global torna nossa aplicação extremamente frágil.

Entretanto, há uma maneira simples de lidar com este problema. Basta que o API declare as dependências para garantir a funcionalidade adequada.

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

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

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

Esta abordagem elimina a preocupação de mudanças ocultas e inesperadas nas conexões de banco de dados. Agora temos certeza de onde o artigo é armazenado e nenhuma modificação de código dentro de outra classe não relacionada pode mais alterar a situação. O código não é mais frágil, mas estável.

Não escreva código que utilize o estado global, prefira passar dependências. Portanto, injeção de dependência.

Singleton

Singleton é um padrão de design que, por definição da famosa publicação Gang of Four, restringe uma classe a uma única instância e oferece acesso global a ela. A implementação deste padrão geralmente se assemelha ao seguinte código:

class Singleton
{
	private static self $instance;

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

	// e outros métodos que desempenham as funções da classe
}

Infelizmente, o singleton introduz o estado global na aplicação. E como demonstramos acima, o estado global é indesejável. É por isso que o singleton é considerado um antipadrão.

Não use singletons em seu código e substitua-os por outros mecanismos. Você realmente não precisa de singletons. Entretanto, se você precisar garantir a existência de uma única instância de uma classe para toda a aplicação, deixe-a para o container DI. Assim, crie um singleton de aplicação, ou serviço. Isto impedirá a classe de fornecer sua própria singularidade (ou seja, ela não terá um método getInstance() e uma variável estática) e só desempenhará suas funções. Assim, deixará de violar o princípio da responsabilidade única.

Testes de estado global versus testes

Ao escrever testes, assumimos que cada teste é uma unidade isolada e que nenhum estado externo entra nele. E que nenhum estado deixa os testes. Quando um teste é concluído, qualquer estado associado com o teste deve ser removido automaticamente pelo coletor de lixo. Isto faz com que os testes sejam isolados. Portanto, podemos executar os testes em qualquer ordem.

Entretanto, se estados/cingilões globais estiverem presentes, todas essas simpáticas suposições se desmoronam. Um estado pode entrar e sair de um teste. De repente, a ordem dos testes pode ser importante.

Para testar singletons, os desenvolvedores muitas vezes têm que relaxar suas propriedades, talvez permitindo que uma instância seja substituída por outra. Tais soluções são, na melhor das hipóteses, hacks que produzem códigos difíceis de manter e de entender. Qualquer teste ou método tearDown() que afete qualquer estado global deve desfazer essas mudanças.

O estado global é a maior dor de cabeça em testes unitários!

Como consertar a situação? Fácil. Não escreva código que utilize singletons, prefira passar dependências. Ou seja, injeção de dependência.

Constantes globais

O estado global não se limita ao uso de singletons e variáveis estáticas, mas também pode se aplicar a constantes globais.

Constantes cujo valor não nos fornece nenhuma informação nova (M_PI) ou útil (PREG_BACKTRACK_LIMIT_ERROR) são claramente OK. Por outro lado, constantes que servem como uma forma de sem fio passar informações dentro do código nada mais são do que uma dependência oculta. Como LOG_FILE no exemplo a seguir. O uso da constante FILE_APPEND é perfeitamente correto.

const LOG_FILE = '...';

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

Neste caso, devemos declarar o parâmetro no construtor da classe Foo para torná-la parte da API:

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

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

Agora podemos passar informações sobre o caminho para o arquivo de registro e facilmente alterá-lo conforme necessário, facilitando o teste e a manutenção do código.

Funções globais e métodos estáticos

Queremos enfatizar que o uso de métodos estáticos e funções globais não é, por si só, problemático. Explicamos a inadequação do uso de DB::insert() e métodos similares, mas sempre foi uma questão de estado global armazenada em uma variável estática. O método DB::insert() requer a existência de uma variável estática porque armazena a conexão de banco de dados. Sem esta variável, seria impossível implementar o método.

O uso de métodos e funções estáticas determinísticas, tais como DateTime::createFromFormat(), Closure::fromCallable, strlen() e muitas outras, é perfeitamente consistente com a injeção de dependência. Estas funções sempre retornam os mesmos resultados a partir dos mesmos parâmetros de entrada e são, portanto, previsíveis. Elas não utilizam nenhum estado global.

No entanto, há funções no PHP que não são determinísticas. Estas incluem, por exemplo, a função htmlspecialchars(). Seu terceiro parâmetro, $encoding, se não for especificado, é o valor padrão da opção de configuração ini_get('default_charset'). Portanto, recomenda-se sempre especificar este parâmetro para evitar um possível comportamento imprevisível da função. A Nette faz isto de forma consistente.

Algumas funções, tais como strtolower(), strtoupper(), e similares, tiveram um comportamento não determinístico no passado recente e dependeram da configuração setlocale(). Isto causou muitas complicações, na maioria das vezes quando se trabalha com o idioma turco. Isto porque o idioma turco distingue entre maiúsculas e minúsculas I com e sem um ponto. Assim, strtolower('I') devolveu o caracter ı e strtoupper('i') devolveu o caracter İ, o que levou a aplicações que causaram uma série de erros misteriosos. Entretanto, este problema foi corrigido na versão 8.2 do PHP e as funções não são mais dependentes do locale.

Este é um bom exemplo de como o estado global tem atormentado milhares de desenvolvedores em todo o mundo. A solução foi substituí-lo por uma injeção de dependência.

Quando é possível usar o Estado Global?

Existem certas situações específicas em que é possível utilizar o estado global. Por exemplo, quando se depura o código e é necessário descarregar o valor de uma variável ou medir a duração de uma parte específica do programa. Em tais casos, que dizem respeito a ações temporárias que serão posteriormente removidas do código, é legítimo usar um dumper ou cronômetro disponível globalmente. Estas ferramentas não fazem parte do projeto do código.

Outro exemplo são as funções para trabalhar com expressões regulares preg_*, que armazenam internamente expressões regulares compiladas em um cache estático na memória. Quando você chama a mesma expressão regular várias vezes em diferentes partes do código, ela é compilada apenas uma vez. O cache economiza desempenho e também é completamente invisível para o usuário, de modo que tal uso pode ser considerado legítimo.

Sumário

Mostramos porque faz sentido

  1. Remover todas as variáveis estáticas do código
  2. Declarar as dependências
  3. E usar injeção de dependência

Ao contemplar o projeto do código, tenha em mente que cada static $foo representa um problema. Para que seu código seja um ambiente respeitador do DI, é essencial erradicar completamente o estado global e substituí-lo por injeção de dependência.

Durante este processo, você pode descobrir que precisa dividir uma classe porque ela tem mais de uma responsabilidade. Não se preocupe com isso; esforce-se pelo princípio de uma responsabilidade.

Gostaria de agradecer a Miško Hevery, cujos artigos como Flaw: Brittle Global State & Singletons formam a base deste capítulo.

versão: 3.x