Estado Global e Singletons
Aviso: As seguintes construções são um sinal de código mal projetado:
Foo::getInstance()
DB::insert(...)
Article::setDb($db)
ClassName::$var
oustatic::$var
Alguma dessas construções ocorre em seu código? Então você tem a oportunidade de melhorá-lo. Você pode pensar que são construções comuns que você vê até mesmo em soluções de exemplo de várias bibliotecas e frameworks. Se for esse o caso, então o design do código deles não é bom.
Agora, definitivamente não estamos falando de alguma pureza acadêmica. Todas essas construções têm uma coisa em comum: elas usam estado global. E isso tem um impacto destrutivo na qualidade do código. As classes mentem sobre suas dependências. O código se torna imprevisível. Confunde os programadores e reduz sua eficiência.
Neste capítulo, explicaremos por que isso acontece e como evitar o estado global.
Acoplamento Global
Em um mundo ideal, um objeto só deveria ser capaz de se comunicar com objetos que lhe foram passados diretamente. Se eu criar dois objetos
A
e B
e nunca passar uma referência entre eles, então nem A
nem B
podem
acessar o outro objeto ou alterar seu estado. Esta é uma propriedade muito desejável do código. É semelhante a ter uma
bateria e uma lâmpada; a lâmpada não acenderá até que você a conecte à bateria com um fio.
Mas 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 qualquer passagem de referência, chamando C::changeSomething()
.
Se o objeto B
também pegar o C
global, então A
e B
podem se influenciar
mutuamente através de C
.
O uso de variáveis globais introduz no sistema uma nova forma de acoplamento sem fio, que não é visível de fora.
Cria uma cortina de fumaça complicando a compreensão e o uso do código. Para que os desenvolvedores realmente entendam as
dependências, eles precisam ler cada linha do código-fonte. Em vez de apenas se familiarizarem com as interfaces das classes.
Além disso, é um acoplamento completamente desnecessário. O estado global é usado porque é facilmente acessível de qualquer
lugar e permite, por exemplo, escrever no banco de dados através do método global (estático) DB::insert()
. Mas,
como mostraremos, a vantagem que isso traz é insignificante, enquanto as complicações que causa são fatais.
Do ponto de vista do comportamento, não há diferença entre uma variável global e estática. Elas são igualmente prejudiciais.
Ação fantasmagórica à distância
“Ação fantasmagórica à distância” – foi assim que Albert Einstein famosamente chamou, em 1935, um fenômeno na física quântica que lhe causava arrepios. Trata-se do emaranhamento quântico, cuja peculiaridade é que, quando você mede a informação sobre uma partícula, influencia instantaneamente a outra partícula, mesmo que estejam a milhões de anos-luz de distância. Isso aparentemente viola a lei fundamental do universo de que nada pode se propagar mais rápido que a luz.
No mundo do software, podemos chamar de “ação fantasmagórica à distância” a situação em que iniciamos um processo que acreditamos ser isolado (porque não passamos nenhuma referência a ele), mas em locais remotos do sistema ocorrem interações inesperadas e mudanças de estado das quais não tínhamos conhecimento. Isso só pode acontecer através do estado global.
Imagine que você se junta a uma equipe de desenvolvedores de um projeto que tem uma base de código extensa e madura. Seu novo líder pede que você implemente uma nova funcionalidade e você, como um bom desenvolvedor, começa escrevendo um teste. Mas como você é novo no projeto, faz muitos testes exploratórios do tipo “o que acontece se eu chamar este método”. E tenta escrever o seguinte teste:
function testCreditCardCharge()
{
$cc = new CreditCard('1234567890123456', 5, 2028); // número do seu cartão
$cc->charge(100);
}
Você executa o código, talvez várias vezes, e depois de um tempo percebe notificações do banco no seu celular informando que a cada execução foram debitados 100 dólares do seu cartão de crédito 🤦♂️
Como diabos o teste pôde causar um débito real de dinheiro? Operar com um cartão de crédito não é fácil. Você precisa se comunicar com um serviço web de terceiros, precisa saber a URL desse serviço web, precisa fazer login e assim por diante. Nenhuma dessas informações está contida no teste. Pior ainda, você nem sabe onde essas informações estão presentes e, portanto, nem como mockar as dependências externas para que cada execução não leve a um novo débito de 100 dólares. E como você, como novo desenvolvedor, deveria saber que o que estava prestes a fazer resultaria em ficar 100 dólares mais pobre?
Isso é ação fantasmagórica à distância!
Você não tem escolha a não ser vasculhar longamente um monte de código-fonte, perguntar aos colegas mais velhos e
experientes, até entender como as ligações no projeto funcionam. Isso ocorre porque, ao olhar para a interface da classe
CreditCard
, não é possível identificar o estado global que precisa ser inicializado. Mesmo olhar para
o código-fonte da classe não revela qual método de inicialização você deve chamar. Na melhor das hipóteses, você pode
encontrar uma variável global que está sendo acessada e, a partir dela, tentar adivinhar como inicializá-la.
As classes em tal projeto são mentirosas patológicas. O cartão de crédito finge que basta instanciá-lo e chamar
o método charge()
. Secretamente, porém, ele colabora com outra classe PaymentGateway
, que representa
o gateway de pagamento. Sua interface também diz que pode ser inicializada separadamente, mas na realidade ela extrai
credenciais de algum arquivo de configuração e assim por diante. Para os desenvolvedores que escreveram este código, está
claro que CreditCard
precisa de PaymentGateway
. Eles escreveram o código desta forma. Mas para
qualquer pessoa nova no projeto, é um mistério absoluto e impede o aprendizado.
Como consertar a situação? Facilmente. 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 interconexões dentro do código se tornam repentinamente óbvias. Como o método charge()
declara que precisa de PaymentGateway
, você não precisa perguntar a ninguém como o código está interconectado.
Você sabe que precisa criar sua instância e, ao tentar fazê-lo, descobrirá que precisa fornecer parâmetros de acesso. Sem
eles, o código nem sequer seria executado.
E, o mais importante, agora você pode mockar o gateway de pagamento, para não ser cobrado 100 dólares toda vez que executar o teste.
O estado global faz com que seus objetos possam acessar secretamente coisas que não são declaradas em sua API e, como resultado, tornam suas APIs mentirosas patológicas.
Talvez você não tenha pensado nisso antes, mas sempre que usa estado global, está criando canais de comunicação secretos sem fio. A ação fantasmagórica à distância força os desenvolvedores a ler cada linha de código para entender as interações potenciais, reduz a produtividade dos desenvolvedores e confunde os novos membros da equipe. Se você foi quem criou o código, conhece as dependências reais, mas qualquer pessoa que vier depois de você ficará perdida.
Não escreva código que utilize estado global, prefira passar dependências. Ou seja, injeção de dependência.
Fragilidade do estado global
No código que usa estado global e singletons, nunca é certo quando e quem alterou esse estado. Esse risco surge já na inicialização. O código a seguir deve criar uma conexão com o banco de dados e inicializar o gateway de pagamento, mas lança constantemente uma exceção e encontrar a causa é extremamente demorado:
PaymentGateway::init();
DB::init('mysql:', 'user', 'password');
Você precisa percorrer detalhadamente o código para descobrir que o objeto PaymentGateway
acessa sem fio
outros objetos, alguns dos quais requerem uma conexão com o banco de dados. Portanto, é necessário 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 das classes individuais não mentisse e declarasse suas dependências?
$db = new DB('mysql:', 'user', 'password');
$gateway = new PaymentGateway($db, ...);
Um problema semelhante surge também ao usar acesso global à conexão do 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 a conexão com o banco de dados já foi criada e quem é
responsável por sua criação. Se quisermos, por exemplo, alterar a conexão com o banco de dados em tempo de execução, talvez
para testes, provavelmente teríamos que criar outros métodos como DB::reconnect(...)
ou
DB::reconnectForTest()
.
Considere o exemplo:
$article = new Article;
// ...
DB::reconnectForTest();
Foo::doSomething();
$article->save();
Onde temos certeza de que ao chamar $article->save()
o banco de dados de teste está realmente sendo usado? E
se o método Foo::doSomething()
alterou a conexão global do banco de dados? Para descobrir, teríamos que examinar
o código-fonte da classe Foo
e provavelmente de muitas outras classes. Essa abordagem, no entanto, traria apenas
uma resposta de curto prazo, pois a situação pode mudar no futuro.
E se movermos a conexão com o 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(/* ... */);
}
}
Isso não mudou nada. O problema é o estado global e é completamente irrelevante em qual classe ele está escondido. Neste
caso, assim como no anterior, ao chamar o método $article->save()
, não temos nenhuma pista sobre em qual banco
de dados ele será escrito. Qualquer pessoa do outro lado da aplicação poderia ter alterado o banco de dados a qualquer momento
usando Article::setDb()
. Sob nossos narizes.
O estado global torna nossa aplicação extremamente frágil.
No entanto, existe uma maneira simples de lidar com esse problema. Basta deixar a API declarar as dependências, garantindo assim a funcionalidade correta.
class Article
{
public function __construct(
private DB $db,
) {
}
public function save(): void
{
$this->db->insert(/* ... */);
}
}
$article = new Article($db);
// ...
Foo::doSomething();
$article->save();
Graças a essa abordagem, elimina-se a preocupação com alterações ocultas e inesperadas na conexão do banco de dados. Agora temos certeza de onde o artigo está sendo salvo e nenhuma modificação no 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 estado global, prefira passar dependências. Ou seja, injeção de dependência.
Singleton
Singleton é um padrão de projeto que, de acordo com a definição da conhecida publicação Gang of Four, restringe uma classe a uma única instância e oferece acesso global a ela. A implementação desse 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 cumprem as funções da classe dada
}
Infelizmente, o singleton introduz estado global na aplicação. E como mostramos acima, o estado global é indesejável. Portanto, 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. No
entanto, se precisar garantir a existência de uma única instância de uma classe para toda a aplicação, deixe isso para o contêiner DI. Crie assim um singleton de aplicação, ou seja,
um serviço. Com isso, a classe deixa de se preocupar em garantir sua própria unicidade (ou seja, não terá o método
getInstance()
e a variável estática) e cumprirá apenas suas funções. Assim, deixará de violar o princípio da
responsabilidade única.
Estado global versus testes
Ao escrever testes, assumimos que cada teste é uma unidade isolada e que nenhum estado externo entra nele. E nenhum estado sai dos testes. Após a conclusão do teste, todo o estado relacionado ao teste deve ser removido automaticamente pelo coletor de lixo. Graças a isso, os testes são isolados. Portanto, podemos executar os testes em qualquer ordem.
No entanto, se houver estados globais/singletons, todas essas suposições agradáveis desmoronam. O estado pode entrar e sair do teste. De repente, a ordem dos testes pode importar.
Para poder testar singletons, os desenvolvedores muitas vezes precisam afrouxar suas propriedades, talvez permitindo que a
instância seja substituída por outra. Tais soluções são, na melhor das hipóteses, um hack que cria código difícil de
manter e entender. Cada teste ou método tearDown()
, que afeta qualquer estado global, deve reverter essas
alterações.
O estado global é a maior dor de cabeça nos testes unitários!
Como consertar a situação? Facilmente. 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 apenas ao uso de singletons e variáveis estáticas, mas também pode se referir a constantes globais.
Constantes cujo valor não nos traz nenhuma informação nova (M_PI
) ou útil
(PREG_BACKTRACK_LIMIT_ERROR
) são claramente aceitáveis. Por outro lado, constantes que servem como uma forma de
passar informações sem fio para dentro do código não são nada mais do que uma dependência oculta. Como
LOG_FILE
no exemplo a seguir. O uso da constante FILE_APPEND
é totalmente correto.
const LOG_FILE = '...';
class Foo
{
public function doSomething()
{
// ...
file_put_contents(LOG_FILE, $message . "\n", FILE_APPEND);
// ...
}
}
Neste caso, deveríamos declarar um parâmetro no construtor da classe Foo
, para que ele se torne 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 a informação sobre o caminho do arquivo para log e alterá-la facilmente conforme necessário, o que facilita 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 em si não é problemático. Explicamos por que o uso
de DB::insert()
e métodos semelhantes é inadequado, mas sempre foi apenas uma questão de estado global armazenado
em alguma variável estática. O método DB::insert()
requer a existência de uma variável estática porque a
conexão com o banco de dados está armazenada nela. Sem essa variável, seria impossível implementar o método.
O uso de métodos estáticos e funções determinísticas, como DateTime::createFromFormat()
,
Closure::fromCallable
, strlen()
e muitas outras, está em total conformidade com a injeção de
dependência. Essas funções sempre retornam os mesmos resultados para os mesmos parâmetros de entrada e são, portanto,
previsíveis. Elas não usam nenhum estado global.
Existem, porém, também funções no PHP que não são determinísticas. Entre elas está, por exemplo, a função
htmlspecialchars()
. Seu terceiro parâmetro $encoding
, se não for especificado, tem como valor padrão
o valor da opção de configuração ini_get('default_charset')
. Portanto, recomenda-se sempre especificar este
parâmetro para evitar possíveis comportamentos imprevisíveis da função. A Nette faz isso consistentemente.
Algumas funções, como strtolower()
, strtoupper()
e semelhantes, comportaram-se de forma não
determinística no passado recente e dependiam da configuração setlocale()
. Isso causou muitas complicações, mais
frequentemente ao trabalhar com a língua turca. Isso porque o turco distingue entre letras I
maiúsculas e
minúsculas com e sem ponto. Assim, strtolower('I')
retornava o caractere ı
e
strtoupper('i')
o caractere İ
, o que levou as aplicações a causar uma série de erros misteriosos.
No entanto, esse problema foi corrigido na versão 8.2 do PHP e as funções já não dependem do locale.
Este é um bom exemplo de como o estado global atormentou milhares de desenvolvedores em todo o mundo. A solução foi substituí-lo por injeção de dependência.
Quando é possível usar estado global?
Existem certas situações específicas em que é possível utilizar o estado global. Por exemplo, ao depurar código, quando você precisa imprimir o valor de uma variável ou medir a duração de uma determinada parte do programa. Nesses 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 globalmente disponível. Essas ferramentas não fazem parte do design do código.
Outro exemplo são as funções para trabalhar com expressões regulares preg_*
, que internamente armazenam
expressões regulares compiladas em um cache estático na memória. Assim, 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, ao mesmo tempo, é
completamente invisível para o usuário, portanto, tal uso pode ser considerado legítimo.
Resumo
Discutimos por que faz sentido:
- Remover todas as variáveis estáticas do código
- Declarar dependências
- E usar injeção de dependência
Ao pensar no design do código, lembre-se de que cada static $foo
representa um problema. Para que seu código
seja um ambiente que respeite DI, é essencial erradicar completamente o estado global e substituí-lo por injeção de
dependência.
Durante esse processo, você pode descobrir que é necessário dividir a classe porque ela tem mais de uma responsabilidade. Não tenha medo disso; busque o princípio da responsabilidade única.
Gostaria de agradecer a Miško Hevery, cujos artigos, como Flaw: Brittle Global State & Singletons, são a base deste capítulo.