Acoplamento de Software

Rafael Amadigi Dal Santo
mercos-engineering
Published in
9 min readMay 17, 2019

Você já se perdeu navegando entre classes e métodos, para frente e para trás, tentando entender como um código funciona? Já alterou uma função e gerou bug em outro lugar? Eu já, e aposto que você também! Neste artigo vamos conhecer o conceito de acoplamento e como podemos lidar com ele.

Segundo a engenharia de software, acoplamento é a medida de interdependência entre os componentes de um sistema. A palavra componente, neste contexto, significa qualquer "pedaço" de código. Pode ser uma função, método, classe, módulo, pacote, etc. Quando uma destas coisas usa código de outra, dizemos que elas estão acopladas, pois uma depende da outra.

Na prática, o acoplamento é uma das causas de complexidade em software. Quanto maior ele for, mais complexo o sistema se torna, pois componentes que são dependentes entre si, normalmente, não podem ser compreendidos, modificados ou corrigidos sem levar em consideração suas dependências. Entretanto, não há como eliminá-lo totalmente, pois sempre dependeremos de componentes de terceiros (frameworks, libs, etc.) e de outros componentes do próprio sistema. Assim, nosso objetivo é desenvolver código com o menor acoplamento possível.

Mas como fazer isso?

Não existe uma receita de bolo para desenvolver sistemas com baixo acoplamento. Eu acredito que conhecer como ele se manifesta, seus tipos e características, nos ajuda a tomar decisões mais conscientes durante o desenvolvimento e, por consequência, produzir softwares melhores.

Neste artigo vamos falar sobre os seguintes tipos de acoplamento: routine call, data, stamp, control, external, common e content. Os exemplos que utilizei foram escritos em Python e Django.

Routine Call Coupling

Ocorre quando um componente chama uma função de outro componente. O acoplamento existe porque o primeiro componente se torna dependente do trabalho realizado pelo segundo. Este é o tipo de acoplamento mais comum que existe e não temos como evitá-lo porque é inviável escrever software sem quebrá-lo em componentes.

Evitamos o acoplamento desnecessário quando projetamos as APIs¹ de nossos componentes pensando no que eles vão fazer e não como eles farão. Ou seja, devemos pensá-la do ponto de vista de quem vai utilizar o código e não de quem vai implementá-lo.

Essa diferença entre o “que” e o “como” é o que chamamos de abstração, outro conceito fundamental na engenharia de software. Uma abstração bem feita esconde os detalhes de implementação e revela apenas o que é essencial. Esta é a chave para o verdadeiro encapsulamento. Veja que abstração e encapsulamento são duas faces da mesma moeda.

Vou dar um exemplo para ajudar a entender. Imagine que precisamos recuperar da base de dados as informações de um cliente, pesquisando pelo seu ID. Poderíamos implementar esta função da seguinte forma:

A função obter_por_id não separa claramente o “que” do “como”. Veja que ela recebe como parâmetro uma classe que herda de models.Model (do Django). Ela também retorna um objeto dessa mesma classe. Isto faz com que o código cliente desta função também se torne dependente do Django.

O ideal seria que esta dependência fosse um detalhe de implementação, afinal de contas existem outras maneiras de se recuperar informações da base de dados (ex: SQL puro ou outro ORM). Assim, recuperar informações de um cliente gravado na base de dados é o que queremos/precisamos fazer. Usar o Django ORM é como escolhemos fazer isso.

Talvez, um design melhor fosse este:

Veja que agora a função exige uma string que representa o nome da tabela. Internamente ela converte essa string na classe de model. Além disso, o retorno agora é um dicionário. Poderíamos usar uma classe, se desejássemos, desde que ela não dependesse do framework.

A partir de agora, o cliente desta função não depende mais do Django. Tanto os valores recebidos quanto o retornado são de tipos nativos da linguagem. Veja que até a exceção foi convertida, pois ela também faz parte da API do componente. Nós encapsulamos todos os detalhes de implementação dentro da função. Se um dia optarmos por mudar a estratégia de consulta à base de dados, a assinatura da função não precisará ser modificada e, consequentemente, quem a utiliza também não.

Talvez você tenha achado estranho o fato de a função receber uma string como parâmetro para representar o nome da tabela. Se isso te incomoda, talvez essa implementação seja melhor:

Esta função mantém a mesma independência de framework que a anterior, mas não é tão genérica. Enquanto que a anterior poderia obter registros de qualquer tabela, esta só funciona com a tabela de clientes. O lado positivo é que ela é bem mais simples, pois não precisamos implementar a função converter_nome_da_tabela_em_model.

Esse é um trade-off bastante comum: trocar o genérico pelo específico em nome da simplicidade. Em contrapartida precisamos escrever mais código, pois será preciso criar uma função como esta para cada tabela. Volume de código, assim como o acoplamento, é uma das causas de complexidade e, portanto, também deve ser levado em consideração ao se tomar decisões.

¹ A expressão API neste contexto significa a interface pública do componente, ou seja, tudo o que o cliente pode tocar. Em uma função, a API seria formada pelo seu nome, parâmetros, retorno e exceções que ela pode disparar; em uma classe, seriam seus métodos públicos, junto com os respectivos parâmetros, retornos e exceções; em um pacote, seriam todas as classes e/ou funções disponíveis; e assim por diante.

Data Coupling

Ocorre quando dois componentes compartilham informações entre si, via parâmetros. Por exemplo, se a função calculadora invoca a função soma, ela precisa fornecer dois números, digamos 1 e 2, como argumentos. Estas duas funções compartilham o conhecimento a respeito destes dados e, portanto, são dependentes deles. Uma delas é dependente porque precisa reunir as informações para passar para a próxima. A outra precisa dos dados para realizar sua tarefa.

De modo geral, este tipo de acoplamento é tão inofensivo quanto o primeiro, pois ele é natural. Se desejo somar, tenho que saber quais números devem ser somados. Se quero obter um cliente por ID, preciso fornecer o seu ID, e assim por diante. Entretanto, o ideal é que cada componente receba apenas os dados necessários para que ele realize seu trabalho, nada além disso, minimizando assim a área de dependência entre eles.

Stamp Coupling

Este tipo de acoplamento também é conhecido como data-structured coupling, pois ocorre quando componentes compartilham uma mesma estrutura de dados composta e usam apenas parte dela. Gosto de pensar que este é um tipo especial de data coupling pois, além dos dados em si, também dependemos do formato/schema que eles possuem.

Como exemplo, vamos implementar uma função que calcula o total de um item de pedido:

Esta função recebe um objeto do tipo ItemDePedido e acessa duas informações dele: a quantidade e o preço unitário. Entretanto, as outras informações (id e produto_id) não são usadas. Eventuais mudanças na classe ItemDePedido podem impactar o funcionamento da função, já que ela depende da estrutura interna deste tipo de objeto.

Uma implementação menos suscetível a este problema seria a seguinte:

Essa nova função possui algumas vantagens em relação a anterior. Primeiro, é mais fácil de testar pois ao invés de criar um objeto ItemDePedido inteiro, podemos fornecer valores simples para a quantidade e para o preço unitário. Segundo, ela deixa explícito na sua interface quais são as informações que afetam o total de um item, o que ajuda na clareza de código. Antes isso ficava oculto. Terceiro, esta função pode ser utilizada em locais do código onde a quantidade e o preço unitário não estão envelopados em um objeto do tipo ItemDePedido.

Control Coupling

O quarto tipo de acoplamento é chamado de control coupling. Ele ocorre quando um componente passa uma flag a outro, indicando o que fazer. Vejamos um exemplo:

Neste exemplo, o parâmetro atualizar_comissoes é utilizado para controlar o que a função faz. Se ele for False, ela só irá salvar o pedido na base de dados, mas se for True, além de salvar o pedido, atualizará os valores das comissões. Podemos evitar este if criando funções separadas para cada etapa do processo:

Organizando o código desta maneira, podemos chamar a função salvar_pedido nos lugares onde queremos salvar o pedido sem atualizar as comissões e a função salvar_pedido_atualizando_comissoes onde queremos fazer as duas coisas. De quebra ainda eliminamos uma condicional do código.

External Coupling

Este tipo de acoplamento ocorre quando nosso código depende de componentes de terceiros, como frameworks, libs, bancos de dados, etc. Isto se torna um risco pois não podemos controlar as mudanças destes componentes. Por exemplo, ao atualizar a versão do banco de dados nosso código pode parar de funcionar.

Já que na maioria das vezes não podemos evitar tais dependências, devemos, pelo menos, criar abstrações que as tornem detalhes de implementação. E como detalhes que são, podem ser substituídas quando necessário.

A função obter_cliente_por_id(cliente_id), que mostramos anteriormente, é um bom exemplo disso. A interface dela é independente de framework e tecnologia de banco de dados. A implementação sim depende destas coisas. Entretanto, por meio de testes automatizados podemos alcançar um grau de confiança bastante elevado de que, se o componente deixar de atender alguma de nossas expectativas, nós seremos alertados e quando isso ocorrer teremos um lugar único para corrigir.

Common Coupling

Common coupling ocorre quando vários componentes compartilham um estado global. Considera-se que este tipo de acoplamento é pior do que o data coupling porque aqui a dependência é implícita, enquanto que lá é explícita (as informações são passadas como parâmetros).

Um uso muito comum para estado global são as configurações da aplicação como, por exemplo, detalhes para conexão com a base de dados (url, usuário, senha, etc.). Ao invés de passar estas informações função por função, preferimos acessar a variável global apenas na função que acessa o banco de dados. Isto torna a interface das demais funções mais limpa e coesa.

Como estas configurações, normalmente, não são modificadas durante a execução do programa, esta prática não causa grandes problemas. No entanto, a utilização de estado global mutável aumenta consideravelmente o grau de complexidade do sistema. Imagine invocar a mesma função duas vezes, com os mesmos argumentos, e receber resultados diferentes. Isto é perfeitamente possível se o comportamento de tal função depender deste estado global mutável. Entre as duas chamadas o estado pode ser alterado e, com isso, o resultado da função também.

Portanto, a dica é a seguinte: utilize o mínimo possível de estado global. Dê preferência para passar explicitamente as informações via parâmetros. Para os casos onde isto não é possível, ou é muito trabalhoso (como no exemplo das configurações), pelo menos faça com que tais informações sejam imutáveis.

Content Coupling

Este tipo de acoplamento ocorre quando um componente conhece, modifica ou é dependente de detalhes internos de outro componente, quebrando seu encapsulamento. Um bom exemplo disso ocorre quando usamos mocks em testes unitários.

Para ilustrar esta situação vamos criar uma função para importar clientes a partir de uma string no formato CSV:

Até aqui tudo bem, criamos a função importar_clientes que recebe por parâmetro uma string em formato CSV, extrai suas informações e, para cada cliente, chama a função inserir_no_bd para persistir os dados.

Agora vamos ao teste:

Neste teste, substituímos a função inserir_no_bd por um mock e esperamos que ele seja chamado duas vezes, uma vez para cada linha do CSV que passamos por parâmetro.

Agora imagine que começamos a ter problemas de desempenho nesta rotina e, após investigar as causas da lentidão, descobrimos que o problema estava nesta inserção de clientes linha a linha. Para nossa sorte, encontramos uma função capaz de realizar inserções em lote, que possui um desempenho muito superior ao da função anterior. Então, refatoramos o código da seguinte maneira:

Perceba que, apesar da mudança na implementação, a interface da função importar_clientes não mudou. Ela continua com o mesmo nome e recebe o mesmo parâmetro. O problema é que o seu teste precisa ser reescrito pois, pelo uso do mock, ele está fortemente acoplado à implementação. Em outras palavras, o teste tem conhecimento do funcionamento interno da função, que outras funções ela chama, que parâmetros passa, etc.

O teste atualizado fica assim:

Esta alteração pode parecer boba em um exemplo simples como esse, mas pode gerar muita dor de cabeça nas situações mais complexas do mundo real.

Não me entenda mal, não estou dizendo que mocks são ruins e devem ser evitados. Existem situações em que eles são úteis. Entretanto, temos que estar cientes das consequências desta escolha.

Como vimos, o acoplamento se manifesta de muitas formas. Existem outros tipos de acoplamento que não descrevi aqui, mas estes que falei já nos fornecem bons insights sobre o que devemos evitar. Como disse no início, não é possível eliminar completamente as dependências entre os componentes, mas podemos fazer escolhas conscientes do que consideramos melhor em cada situação. Espero que este artigo tenha te ajudado a atingir este objetivo.

--

--