Este post é continuação da
[Parte 01 de 02]. Recomenda-se a prévia leitura daquele post antes de se aprofundar neste post.
Objetos e Estrutura de Dados
Esconder implementação
não é apenas uma questão de adicionar uma camada de funções entre as
variáveis. Esconder implementação é uma questão de abstração. Uma
classe
não deve expor suas variáveis por meio de métodos de acesso e
mutação
(getters e
setters). Em vez disso, deve expor
interfaces abstratas que permitem aos seus usuários a manipulação dos
seus dados, sem que os usuários sejam obrigados a conhecer sua implementação.
Considere os seguintes exemplos:
public interface Vehicle {
double getFuelTankCapacityInGallons();
double getGallonsOfGasoline();
}
public interface Vehicle {
double getPercentFuelRemaining();
}
O primeiro exemplo se comunica usando termos concretos, enquanto o
segundo se comunica por meio da
abstração da percentagem de combustível.
A segunda forma é preferível, pois o ideal é não expor os detalhes dos
dados.
É melhor expressar a informação em termos abstratos. Isso
não é alcançado pelo mero uso de interfaces acompanhadas de
getters/setters.
É preciso dispensar muita atenção quanto à melhor forma de representar a
informação que um objeto contém. A pior escolha é simplesmente ficar
adicionando
getters e
setters sem um critério cuidadosamente escolhido.
Assimetria entre Dados e Objeto
Objetos escondem seus
dados por trás de abstrações e expõem funções que operam sobre os dados.
Estrutura de dados (
data structure) expõem sua informação e não possuem
funções substanciais ou relevantes. O tipo clássico de estrutura de dados é uma
classe com variáveis públicas e nenhuma função. Vê-se, portanto, que
'objetos' e 'estrutura de dados' são conceitos bastante opostos.
Programação
procedimental (código que usa estrutura de dados) torna fácil a adição
de novas funções sem que seja preciso alterar a estrutura dos dados. Mas
a adição de novas estruturas de dados é difícil porque todas as funções
existentes necessariamente precisarão sofrer alterações.
Já
a programação orientada por objetos torna fácil a adição de novas
classes sem a necessidade de alteração das funções existentes. Mas torna
difícil a adição de novas funções porque muitas classes precisarão
ser alteradas. Assim, tarefas difíceis para a
programação orientada por objetos são fáceis na procedimental, e tarefas
difíceis na procedimental são fáceis na orientada por objetos.
Em
qualquer sistema complexo haverá momentos em que orientação por objetos
será mais apropriada e momentos em que programação procedimental será a melhor
opção. Por isso, é um mito afirmar que "tudo é um objeto". Muitas vezes você realmente quer apenas estrutura de dados simples
com linguagem procedimental operanto sobre elas. Nem sempre haverá a necessidade de adicionar uma camada orientada por objetos.
A Lei de Demeter
Objetos escondem seus dados e expõem
operações. Logo, objetos não devem expor sua
estrutura interna por meio
de métodos de acesso (
getters). A noção fundamental da Lei de Demeter é que um objeto deve saber o mínimo possível a respeito da estrutura
ou propriedade de qualquer outra coisa (outras classes e inclusive subcomponentes).
Esses são os princípios da Lei de Demeter:
- Cada
unidade deve ter conhecimento limitado sobre as outras unidades:
somente sobre unidades intimamente relacionadas com a unidade atual.
- Cada unidade deve conversar somente com os "amigos"; não converse com estranhos.
- Somente converse com seus amigos imediatos.
A criação de
estrutura híbridas,
ou seja, que são metade objeto e metade estrutura de dados, são um
convite para a violação da lei de Demeter. Esses tipos híbridos possuem
funções que fazem coisas significativas e também variáveis públicas ou
então
métodos públicos que acessam e modificam
variáveis privadas (que, por isso mesmo, terminam sendo acessadas como se fossem públicas). Esse tipo híbrido termina facilitando o uso de suas variáveis por
funções externas da mesma forma que a programação procedimental usaria uma estrutura de dados, violando claramente a lei de Demeter.
Tais tipos híbridos devem ser evitados.
Registro Ativo (Active Record)
Registros Ativos são
uma forma especial de Objetos para Transferência de Dados (
Data Transfer
Objects). São estruturas de dados com
variáveis públicas ou
métodos
públicos de acesso às variáveis privadas. Em geral, são traduções de tabelas de
banco de dados ou de outros tipos de fontes de dados.
É
muito comum desenvolvedores tratarem esse tipo de estrutura de dados
como se fossem objetos inserindo métodos de regras de negócios (
business
rules) neles. Ocorre que isso é exatamente o que os transforma no tipo
híbrido acima referido. A solução é tratar o Active Record como se fosse
apenas uma estrutura de dados e criar
objetos separados que contenham
métodos e escondam seus dados internos (os dados internos provavelmente serão apenas
instâncias do
Active Record).
Manipulação de Erros
Erros são inveitáveis. Por isso, o manuseio de erros é algo importante. Porém, se a manipulação do erro
obscurecer a
lógica do programa, é porque está sendo feita de forma pobre
ou inapropriada.
A técnica de retornar "códigos de
erros" (
error codes) era necessária na época em que não havia as exceções (
exceptions).
O problema dessa abordagem é que obriga o usuário da função a lidar com
os erros de forma
imediata, torando-se tarefa de fácil esquecimento, além de
obscurecer a implementação do código-fonte.
Compare os dois exemplos abaixo.
public class DeviceController {
...
public void sendShutDown() {
DeviceHandle handle = getHandle(DEV1);
// Check the state of the device
if (handle != DeviceHandle.INVALID) {
// Save the device status to the record field
retrieveDeviceRecord(handle);
// If not suspended, shut down
if (record.getStatus() != DEVICE_SUSPENDED) {
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
} else {
logger.log("Device suspended. Unable to shut down");
}
} else {
logger.log("Invalid handle for: " + DEV1.toString());
}
}
...
}
Agora com o gerenciamento do erro por meio de exceções:
public class DeviceController {
...
public void sendShutDown() {
try {
tryToShutDown();
} catch (DeviceShutDownError e) {
logger.log(e);
}
}
private void tryToShutDown() throws DeviceShutDownError {
DeviceHandle handle = getHandle(DEV1);
DeviceRecord record = retrieveDeviceRecord(handle);
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
}
private DeviceHandle getHandle(DeviceID id) {
...
throw new DeviceShutDownError(
"Invalid handle for: " + id.toString()
);
...
}
...
}
Perceba
como no segundo caso tudo ficou muito mais claro. Os algorítimos para o
desligamento do dispositivo e a manipulação do erro ficaram
separados.
Não retorne Null
Se você cair na tentação de retornar
null, pare e pense se não seria melhor lançar uma exceção (
thrown exception) ou então retornar um objeto do tipo
Special Case.
O problema de trabalhar com funções que retornam
null
é que você tem que lembrar de lidar com esse retorno toda vez que ele
for possível, o que leva à duplicação de código e torna sua programação
propensa a erros, pois em diversos momentos você terminará esquecendo de
lidar com o
null como valor de retorno.
Se você estiver trabalhando com uma API que possui retorno
null,
o melhor é encapsular esses métodos com outros métodos que lancem
exceções ou, ainda, que retornem objetos de casos especiais (
Special Case Objects).
Não passe Null
Da mesma forma que não é bom retornar
null, não é boa ideia passar
null
como argumentos de funções. Essa prática em geral está associada com
funções que possuem diversos argumentos e eles devem ser
fornecidos na ordem em que foram declarados. Assim, para pular um ou
algum dos argumentos, passa-se o
null. A prática é ruim porque
obriga a que função seja escrita de forma a verificar e testar argumento por argumento. Não é eficiente e
propicia esquecimentos e erros. O melhor é seguir a regra de que as
funções devem ter nenhum, um, ou, quando muito, dois argumentos, de forma
que não seja necessário "pular" argumentos declarados nem ficar verificando argumentos e o tipo deles.
Classes
Classes devem ser pequenas!
Tal como afirmado em
relação às funções, as classes também devem ser pequenas. Porém, não se
deve medir o tamanho de uma classe em linhas, mas sim em
responsabilidades.
O primeiro indicativo é o nome da Classe. Se não conseguir encontrar um nome conciso
para a classe, que expresse sua responsabilidade, é porque
provavelmente a classe é grande demais. Quanto mais ambíguo o nome da classe, maior a chance de ela apresentar muitas responsabilidades. Por exemplo,
classes que levam os nomes
Processor ou
Manager ou
Super em geral apontam o indevido acúmulo de muitas responsabilidades.
O Princípio da Responsabilidade Única (Single Responsibility Principle)
O princípio da responsabilidade única estabelece que uma classe ou módulo deve ter uma, e apenas uma,
razão para mudar.
O princípio fornece tanto a definição de responsabilidade como um guia
para o tamanho da classe. Classes devem ter apenas uma responsabilidade -
uma razão para mudar.
Muitos programadores temem que
um grande números de classes pequenas tornará o sistema difícil de ser
entendido. No entanto, um sistema com diversas classes pequenas não tem
mais partes mutáveis do que um sistema com poucas porém enormes classes.
O sistema deve ser composto, portanto, de diversas classes pequenas.
Cada classe deve ter apenas uma responsabilidade encapsulada, ou seja, deve possuir
apenas uma razão para ser alterada, e deve colaborar com algumas outras
classes para que o sistema alcance a finalidade desejada.
Coesão
Classes
devem ter número pequeno de variáveis (
instance variables). Cada um dos
métodos de uma classe deve manipular uma ou algumas dessas variáveis.
Em geral, quanto mais variáveis um método manipular, mais coeso esse
método será em relação à classe. Porém, nem sempre será possível ou mesmo
aconselhável que uma classe seja criada com tal nível de coesão. Mas, de qualquer forma, é desejável que o nível de coesão seja
alto.
Atenção: a
estratégia de manter as funções pequenas e a lista de parâmetros
pequena ou até inexistente pode levar a uma proliferação de variáveis da
classe que podem terminar sendo usadas apenas por um subgrupo de métodos da classe. Quando isso
acontecer, provavelmente significa que pelo menos uma nova classe está
tentando sair da classe maior. Você deve tentar separar as variáveis e
métodos em duas ou mais classes, de modo que a
nova classe seja mais coesa.
Manter coesão resulta em diversas classes pequenas
O
mero ato de quebrar funções grandes em funções menores, por si só, já leva a uma proliferação de classes. Imagine uma função extensa com diversas
variáveis declaradas dentro da função. Digamos que você queira extrair
uma pequena parte dessa função para uma função separada. No entanto, o
código que você deseja separar usa quatro das variáveis declaradas na
função. Vem a pergunta: você deverá passar essas variáveis para a função extraída por meio
de argumentos?
Com certeza
não!
Se essas quatro
variáveis forem promovidas a variáveis da classe (
instance variables),
você poderá extrair o código sem ter que passar nenhuma variável como
argumento.
No entanto, por vezes isso pode ocasionar um
acúmulo de mais e mais variáveis da classe que existem somente para
permitir que algumas funções as compartilhem, e não que sejam necessariamente usadas
pela maioria das funções da classe. Conforme acima exposto, esse acúmulo de variáveis
não usadas pela maioria dos métodos da classe significa falta de coesão.
Mas pare e pense: se há algumas funções que precisam compartilhar determinadas
variáveis, então isso significa que tais funções e tais variáveis
já merecem mesmo uma classe somente para elas. É o princípo da coesão em ação.
Logo, se uma
classe começar a perder a coesão em razão da separação das funções em
funções mais curtas, separe a própria classe em tantas classes quanto
forem necessárias para que cada uma delas mantenha sua própria coesão.
Organizando para a Alteração
Em
todos os sistemas as mudanças podem ser contínuas. Só que toda mudança
causa o risco de o restante do código não funcionar mais. O programador então, resite em fazer alterações. Por isso, em um sistema
limpo (ou
"clean"), as classes devem ser organizadas de forma a reduzir os riscos
decorrentes de mudanças.
A classe
Sql a seguir é
usada para gerar comandos SQL apropriados a partir de metadados
que forem fornecidos. Para os fins do exemplo, ainda não se trata de classe completa. Falta-lhe, por exemplo, o suporte para comandos do
tipo
update. Quando chegar a hora de implementar tais métodos,
teremos que
abrir a classe para as mudanças. O problema é que ao abrir a
classe introduzimos o risco próprio da mudança, qual seja,
quebrar outras partes do código.
public class Sql {
public Sql(String table, Column[] columns)
public String create()
public String insert(Object[] fields)
public String selectAll()
public String findByKey(String keyColumn, String keyValue)
public String select(Column column, String pattern)
public String select(Criteria criteria)
public String preparedInsert()
private String columnList(Column[] columns)
private String valuesList(
Object[] fields, final Column[] columns
)
private String selectWithCriteria(String criteria)
private String placeholderList(Column[] columns)
}
A
classe
Sql acima deverá sofrer alterações sempre que adicionarmos um
novo tipo de comando. Deverá mudar também quando alterarmos os detalhes de
um único tipo de comando, como por exemplo se precisarmos alterar a
funcionalidade
select para suportar
subselects. Essas
duas razões para mudar indicam que a classe
Sql viola o princípio da responsabilidade única
(single responsibility principle).
Essa violação pode ser facilmente percebida até mesmo pela forma como a classe foi organizada. Existem métodos privados, como por exemplo o
selectWithCriteria, que visivelmente somente se relaciona com os comandos do tipo
select. Métodos privados que se relacionam somente com pequeno subgrupo de
funções da classe são bons indicativos da necessidade de melhoria no
design dessa classe.
Se
não houver a menor necessidade de atualizar a classe (ou "abrir a classe"
para mudança), então em princípipo ela poderia ser deixada como está. Mas
a partir do momento que você se pegar "abrindo a classe" para mudança,
deverá considerar a possibilidade de melhorar o
design dela.
Imagine a
solução a seguir ilustrada. Nela, cada uma das interfaces públicas dos
métodos definidos na classe anterior passou a integrar cada uma
sua
própria classe derivada da classe
Sql. E os métodos privados, tais como
valuesList, são movidos diretamente para onde são necessários. E o comportamento privado
comum foi isolado em um par de classes utilitárias,
Where e
ColumnList.
abstract public class Sql {
public Sql(String table, Column[] columns)
abstract public String generate();
}
public class CreateSql extends Sql {
public CreateSql(String table, Column[] columns)
@Override public String generate()
}
public class SelectSql extends Sql {
public SelectSql(String table, Column[] columns)
@Override public String generate()
}
public class InsertSql extends Sql {
public InsertSql(String table, Column[] columns, Object[] fields)
@Override public String generate()
private String valuesList(
Object[] fields, final Column[] columns
)
}
public class SelectWithCriteriaSql extends Sql {
public SelectWithCriteriaSql(
String table, Column[] columns, Criteria criteria
)
@Override public String generate()
}
public class SelectWithMatchSql extends Sql {
public SelectWithMatchSql(
String table, Column[] columns, Column column, String pattern
)
@Override public String generate()
}
public class FindByKeySql extends Sql {
public FindByKeySql(
String table, Column[] columns,
String keyColumn, String keyValue
)
@Override public String generate()
}
public class PreparedInsertSql extends Sql {
public PreparedInsertSql(String table, Column[] columns)
@Override public String generate() {
private String placeholderList(Column[] columns)
}
}
public class Where {
public Where(String criteria)
public String generate()
}
public class ColumnList {
public ColumnList(Column[] columns)
public String generate()
}
Assim, o
código em cada classe torna-se extremamente
simples. O risco de uma
função quebrar outra ficou reduzido a praticamente zero. Além disso, o
isolamento de cada classe tornou a produção de testes tarefa fácil (
Test Driven Development).
E quando chegar a hora de adicionar o comando
update, nenhuma das classes existentes precisará ser alterada. A lógica para criar comandos
update será escrita em nova subclasse da classe
Sql chamada
UpdateSql.
Nenhum outro código no sistema será quebrado por conta desse acréscimo.
Assim, a
lógica reestruturada representa o melhor dos mundos. Suporta o
princípio da responsabilidade única e também suporta outro fator chave
do desenvolvimento orientado por objetos, que é o "Princípio Aberto-fechado" (
Open-Closed Principle).
Classes devem estar abertas a extensões mas fechadas para modificações. A classe
Sql
reestruturada está aberta para permitir nova funcionalidade por meio de
subclasses, mas isso pode ser feito mantendo-se as demais classes
fechadas. Simplesmente colocaremos a nova classe
UpdateSql no lugar dela.
Em um sistema ideal, incorporamos novas funcionalidades
estendendo o sistema
e não fazendo modificações no código existente.
Isolando das Mudanças
Classes
concretas implementam detalhes (código) e classes abstratas
representam
apenas conceitos. Uma classe cliente que dependa de
detalhes concretos
está em perigo quando esses detalhes mudarem. Por isso,
interfaces e
classes
abstratas podem ser introduzidas para ajudar a ilosar o impacto desses
detalhes.
A dependência de detalhes concretos torna complicados os testes do sistema. Imagine a classe
Portfolio que dependa da API externa
TokyoStockExchange
para obter o valor do portfolio. Testar a classe vai ser extremamente
difícil por conta da mutabilidade do valor retornado. A cada cinco
minutos a resposta será diferente.
Em vez de definir a classe
Portfolio de forma que ela dependa diretamente de
TokyoStockExchange, podemos criar uma interface,
SotckExchange, que declara um único método:
public interface StockExchange {
Money currentPrice(String symbol);
}
Então definimos
TokyoStockExchange como
implementação dessa interface. Também nos certificamos de que o construtor de
Portfolio receba como argumento uma referência do tipo
StockExchange:
public Portfolio {
private StockExchange exchange;
public Portfolio(StockExchange exchange) {
this.exchange = exchange;
}
// ...
}
Agora poderá ser criada uma implementação passível de teste da interface
SotckExchange que
emula a
TokyoStockExchange. Essa implementação de teste irá ajustar o valor atual para qualquer símbolo (variável
symbol
no exemplo) que usemos no teste. Se nosso teste demonstrar a compra de
cinco ações da empresa imaginária MSFT para o nosso portfolio, criamos a
implementação do teste para sempre retornar $100 por ação dessa empresa.
Nossa implementação teste da interface
StockExchange fica
reduzida a uma simples busca de tabela. Podemos então escrever um teste
que espera o total de $500 para nosso valor do portfolio quando são
adicionadas 5 ações.
public class PortfolioTest {
private FixedStockExchangeStub exchange;
private Portfolio portfolio;
@Before
protected void setUp() throws Exception {
exchange = new FixedStockExchangeStub();
exchange.fix("MSFT", 100);
portfolio = new Portfolio(exchange);
}
@Test
public void GivenFiveMSFTTotalShouldBe500() throws Exception {
portfolio.add(5, "MSFT");
Assert.assertEquals(500, portfolio.value());
}
}
Se
um sistema for separado o suficiente para ser testado dessa forma, será
também mais flexível para promover o reuso de código. Assim, os
elementos ficam isolados um dos outros e isolados de mudanças. Essa
classe então atenderá a outro princípio de elaboração de classes
conhecido como Inversão de Dependência (
Dependency Inversion Principle - DIP). Em resumo, esse princípio estabelece que
as classes devem depender de abstrações e não de detalhes concretos.
Em vez de depender da implementação de detalhes da classe
TokyoStockExchange, nossa classe
Portfolio agora depende da interface
StockExchange.
Essa interface representa o conceito abstrato de perguntar qual é o
preço corrente de um símbolo ('symbol' no exemplo). Essa abstração isola
todos os detalhes específicos para se obter tal preço, incluindo de onde e
de que forma esse preço pode ser obtido.
Há muito mais
Nem de longe o resumo contido nesses posts esgota o tema das boas práticas em programação.
Em posts futuros procurarei resumir mais conceitos e práticas interessantes.
De qualquer forma, há muito material disponível para pesquisa e sem dúvida o programador profissional deve se aprimorar constantemente para escrever o desejado "clean code".