Artigo original: Clear Code – How to Write Code That Is Easy to Read
Este artigo é uma continuação de um tweet que fiz sobre como lido com minha péssima capacidade de lembrar código. Pode parecer engraçado para você, mas eu realmente tendo a esquecer o que escrevo logo depois de escrever.
If you have a bad memory for code like I do, then I suggest you make your code read like a book.
— Ryan Kay (@wiseAss301) November 11, 2022
That way you don't need to remember what you wrote yesterday, or a month ago; the code will tell you as you read it.
Primeiro, discutiremos por que você pode querer escrever um código mais legível em vez de um código curto e conciso. Depois, veremos as seguintes estratégias sobre como fazer isso com:
- Nomenclatura de variáveis, valores, referências, classes, objetos e funções
- Funções auxiliares
- Comentários de código
- Enums/dicionários/classes seladas etc.
- Organização e nomenclatura de pacotes
Conhecimento básico de código é recomendado para aproveitar ao máximo este artigo. No entanto, tentei torná-lo acessível para iniciantes, sempre que possível.
A eficiência vem de escrever menos código?
Lembro-me de quando era um desenvolvedor júnior, pensando que nomes curtos ou abreviados para identificadores – basicamente, qualquer código que nós, desenvolvedores, podemos nomear – eram mais eficientes.
Minha lógica era simples: se eu levo menos tempo para escrever, posso terminar o trabalho mais rápido.
Essa lógica faria sentido se as seguintes coisas fossem verdadeiras:
- Eu, ou outra pessoa, nunca teria que ler ou corrigir o que escrevi no passado
- Eu não esqueceria com frequência o que era uma variável, ou várias variáveis, enquanto lia uma função
- Eu não teria que escrever ocasionalmente algum código que fosse realmente complexo e obscuro
- Eu poderia renomear funções, classes ou propriedades de bibliotecas externas ridículas ou obscuras para algo mais sensato
O ponto é que, para mim, encontro poucas situações em que ser conciso realmente economiza tempo. Além disso, os IDEs modernos têm esse recurso útil chamado preenchimento automático de código que economiza a maior parte da digitação de qualquer maneira.
Você pode não se sentir da mesma maneira – e tudo bem! Pegue o que funcionar para você neste artigo e descarte o resto.
Como nomear classes, variáveis e funções
Agora, compartilharei o que faço para tornar meu código mais fácil de ler para mim e para os outros. Os exemplos de código que usarei serão em Kotlin, mas os pontos que levantarei devem ser aplicáveis à maioria das plataformas e linguagens.
Existem duas coisas importantes para saber ao aprender, como nomear entidades de software. Antes de chegar a isso, o termo entidades de software se refere a qualquer um dos seguintes:
- Classes, structs, objetos
- Variáveis, valores, referências, ponteiros
- Funções, métodos, algoritmos, comandos
- Interfaces, protocolos, abstrações
Essencialmente, qualquer coisa que um programador precisa nomear ao escrever um programa.
Que nível de descrição devemos usar para os nomes
Meu objetivo para nomear entidades de software é este: o nome deve reduzir qualquer confusão sobre o que uma entidade de software faz ou é.
Os detalhes de como ela faz algo geralmente não são necessários.
O contexto, ou tudo ao redor, de uma entidade de software é importante ao decidir sobre um nome. Algo pode exigir mais ou menos detalhes dependendo do seu contexto.
Vamos considerar três exemplos:
obterDataFormatada(data: String) : String
obterDataFormatadaAAAAMMDD(data: String) : String
obterDataFormatadaAAAAMMDDdeFormatoISO8601(data: String) : String
A aplicação em produção na qual estou trabalhando atualmente exige frequentemente a transformação de datas de e para diferentes formatos.
Nesse contexto, eu simplesmente uso nomes como o do exemplo 3, que é muito mais claro do que o do exemplo 1.
Outra opção pode ser alterar o nome do parâmetro no exemplo 2 para algo como dataISO8601
.
Embora eu sugira que você seja consistente em sua abordagem em uma determinada base de código, sinta-se à vontade para experimentar o que funciona para você. O ponto é adicionar o máximo de informações necessárias para esclarecer qualquer ambiguidade.
Se eu estivesse escrevendo um programa único que apenas converte um formato para outro, o exemplo 1 seria bom. Adicionar mais informações do que o necessário não é o que estou defendendo aqui.
Quanto mais algo faz, mais difícil é nomeá-lo
Se você está tendo problemas para nomear algo, geralmente (embora nem sempre) é porque ele faz muitas coisas que não estão conceitualmente relacionadas.
O grau em que as entidades de software estão conceitualmente relacionadas é conhecido como coesão (texto em inglês).
Ao observar quais partes de um programa são coesas ou não, você pode começar a entender o que deve ser separado ou agrupado.
Esse processo pode ser feito de várias perspectivas, que tentarei explicar por meio de exemplos.
Suponha que você tenha quatro entidades de software:
ArmazenarUsuárioNaNuvem
ArmazenarUsuárioEmDisco
ArmazenarMensagem
EditarIUDoUsuário
A primeira perspectiva que podemos considerar é a informação do mundo real com a qual essas entidades estão preocupadas. Dessa perspectiva, podemos ver que ArmazenarUsuárioNaNuvem
, ArmazenarUsuárioEmDisco
e EditarIUDoUsuário
usam o mesmo modelo de informação: um usuário.
No entanto, há outra perspectiva que devemos ter em mente, particularmente ao projetar programas de interface gráfica do usuário (GUI).
Todo programa de GUI pode ser dividido em três camadas principais:
- Interface do usuário (comumente chamada de "View" – em português, visão ou visualização)
- Lógica (comumente se refere a coisas como controladores e apresentadores)
- Modelo (armazenamento e acesso a dados, ou o próprio estado, dependendo da sua definição)
Isso não significa que você deva sempre olhar para um programa como tendo apenas essas três camadas! A abordagem de três camadas é uma generalização que frequentemente é insuficiente.
De qualquer modo, a partir dessa perspectiva, ArmazenarMensagem
tem mais em comum com as outras entidades de armazenamento do que EditarIUDoUsuário
.
Ser capaz de olhar para seus programas de várias perspectivas é algo que virá à medida que você construir programas mais complexos.
A principal lição é que separar sua base de código em partes coesas e relacionadas geralmente tornará as entidades de software mais fáceis de nomear.
Como usar funções auxiliares
Funções auxiliares, particularmente, quando combinadas com boas práticas de nomenclatura de funções, podem melhorar muito a legibilidade do seu código.
As funções auxiliares também são uma oportunidade para aplicar um princípio fundamental da arquitetura de software: a separação de responsabilidades.
Como criar quebra-cabeças de Sudoku com funções auxiliares
Agora, veremos um exemplo prático para demonstrar o uso extensivo de funções auxiliares. Tente imaginar a dificuldade que seria seguir este código se tudo estivesse em uma única função gigante!
No passado, trabalhei em uma parte grande, mas coesa, de um programa: um construtor de Sudoku que usa estruturas de dados e algoritmos de grafos. Mesmo que você não esteja familiarizado com Sudoku ou grafos, acredito que ainda seja capaz de acompanhar o ponto principal.
Você pode encontrar o código-fonte completo aqui.
Podemos dividir o processo de geração de um quebra-cabeça de Sudoku jogável em cinco etapas:
- Criar os nós do quebra-cabeça (representando as peças)
- Criar as arestas do quebra-cabeça (arestas, neste caso, é outra palavra para relacionamentos/referências entre as peças: linha, coluna ou subgrade)
- Semear (adicionar) alguns valores à estrutura de dados para tornar a resolução mais rápida
- Resolver o quebra-cabeça
- Desfazer um certo número de peças para que o jogo seja realmente jogável por um usuário
Usei algo semelhante ao padrão construtor para representar essas etapas na função que chamo para criar o quebra-cabeça:
internal fun construirNovoSudoku(
limite: Int,
dificuldade: Dificuldade
): QuebraCabecaSudoku = construirNos(limite, dificuldade)
.construirArestas()
.semearCores()
.resolver()
.desfazer()
Embora a ideia de "nós" e "arestas" seja a de definições técnicas dentro da teoria dos grafos, esse código reflete claramente as cinco etapas que decidi.
Não veremos toda a base de código, mas quero destacar como as funções auxiliares continuam a decompor a lógica e promover a legibilidade:
internal fun QuebraCabecaSudoku.construirArestas(): QuebraCabecaSudoku {
this.grafo.forEach {
val x = it.value.first.x
val y = it.value.first.y
it.value.mesclarSemRepeticoes(
obterNosPorColuna(this.grafo, x)
)
it.value.mesclarSemRepeticoes(
obterNosPorLinha(this.grafo, y)
)
it.value.mesclarSemRepeticoes(
obterNosPorSubgrade(this.grafo, x, y, limite)
)
}
return this
}
internal fun LinkedList<NoSudoku>.mesclarSemRepeticoes(novo: List<NoSudoku>) {
val hashes: MutableList<Int> = this.map { it.hashCode() }.toMutableList()
novo.forEach {
if (!hashes.contains(it.hashCode())) {
this.add(it)
hashes.add(it.hashCode())
}
}
}
internal fun obterNosPorColuna(grafo: LinkedHashMap<Int,
LinkedList<NoSudoku>>, x: Int): List<NoSudoku> {
val listaDeArestas = mutableListOf<NoSudoku>()
grafo.values.filter {
it.first.x == x
}.forEach {
listaDeArestas.add(it.first)
}
return listaDeArestas
}
//...
Para resumir esse processo, as funções auxiliares fornecem dois benefícios:
- Elas são um substituto para um bloco de código que faz algo
- Esse bloco de código pode receber um nome descritivo
Ambos os benefícios podem levar a uma maior legibilidade, pois o código se torna menos confuso e mais descritivo.
Se você está se perguntando o que deve e o que não deve ser uma função auxiliar, sugiro que pratique diferentes abordagens para ver o que funciona para você.
Como usar comentários de código
Minha preferência pessoal em comentários de código é que eles tenham dois usos principais: primeiro, os comentários ajudam a descrever funções complexas em detalhes.
Segundo, para esclarecer qualquer confusão sobre uma linha ou bloco de código.
Como usar comentários para projetar novas funções
Quando encontro funções que espero que sejam difíceis de escrever, descrevo o que a função faz usando linguagem simples ou pseudocódigo.
A forma como faço isso mudou ao longo dos anos, então encorajo você a tentar diferentes abordagens.
Nos exemplos da seção anterior, omiti os comentários de código:
/** * 1. Gere um Mapa que contém n*n nós. * 2. para cada nó adjacente (conforme as regras do Sudoku), adicione uma Aresta ao conjunto hash * - Por coluna * - Por linha * - Por subgrade de tamanho n * * LinkedHashMap: Escolhi usar um LinkedHashMap porque ele preserva a ordem dos * elementos colocados dentro do Mapa, mas também permite pesquisas por código hash, que são * gerados por valores x e y. * * Quanto ao LinkedList em cada bucket (elemento) do mapa, assuma que o primeiro elemento * é o nó em hashCode(x, y), e os elementos subsequentes são arestas desse elemento. * Além da ordenação do primeiro elemento como Cabeça do LinkedList, o resto dos * elementos não precisa ser ordenado de nenhuma maneira específica. * * * */
internal fun construirNos(n: Int, dificuldade: Dificuldade): QuebraCabecaSudoku {
val novoMapa = LinkedHashMap<Int, LinkedList<NoSudoku>>()
(1..n).forEach { indiceX ->
(1..n).forEach { indiceY ->
val novoNo = NoSudoku(
indiceX,
indiceY,
0
)
val novaLista = LinkedList<NoSudoku>()
novaLista.add(novoNo)
novoMapa.put(
novoNo.hashCode(),
novaLista
)
}
}
return QuebraCabecaSudoku(n, dificuldade, novoMapa)
}
A quantidade de detalhes que adiciono a esses comentários depende do contexto. Se estou trabalhando em equipe, geralmente tento mantê-los muito mais curtos do que o que você vê acima e incluo apenas informações que considero necessárias.
O exemplo acima foi um projeto de aprendizado pessoal que eu esperava compartilhar com outras pessoas. É por isso que até incluí meu processo de tomada de decisão sobre os tipos usados para representar um quebra-cabeça de Sudoku.
Para os fãs de desenvolvimento orientado a testes, você pode tentar escrever as etapas de pseudocódigo de um algoritmo antes de escrever o teste:
/** * No processo de vinculação, chamado pela visualização em onCreate. Verifique o estado atual do usuário, grave esse resultado em * vModel, mostre o gráfico de carregamento, execute alguma inicialização * * a. Usuário é Anônimo * b. Usuário é Registrado * * a: * 1. Exibir Visualização de Carregamento * 2. Verificar se há um usuário conectado da autenticação: nulo * 3. gravar nulo no estado do usuário vModel * 4. chamar o processo On start */
@Test
fun `On bind Usuário anônimo`() = runBlocking {
//...
}
Isso permite que você projete a unidade em um nível mais alto de abstração antes de escrever a implementação. O tempo que você gasta projetando em níveis mais altos de abstração pode economizar tempo a longo prazo.
Como usar comentários de código em linha de modo eficaz
Existem duas situações principais em que escreverei um comentário de código em linha:
- Quando sinto que o propósito de uma linha ou bloco de código não ficará claro para mim ou para qualquer outra pessoa que o leia mais tarde
- Quando tenho que chamar alguma função de biblioteca mal nomeada que tem um nome confuso ou enganoso
De longe, o algoritmo de Sudoku mais complexo no meu programa é o algoritmo de resolução. Na verdade, é tão longo que postarei apenas um trecho dele aqui:
internal fun QuebraCabecaSudoku.resolver()
: QuebraCabecaSudoku {
//nós que foram atribuídos (não incluindo nós semeados de semearCores())
val atribuicoes = LinkedList<NoSudoku>()
//acompanhe as tentativas de atribuição com falha para observar loops infinitos
var tentativasDeAtribuicao = 0
//Duas etapas de retrocesso, parcial é metade do conjunto de dados, completo é uma reinicialização completa
var retrocessoParcial = false
var contadorDeRetrocessoCompleto = 0
//de 0 - limite, representa o quão "exigente" o algoritmo é sobre atribuir novos valores
var valorLegal: Int = (limite / 2)
//para evitar ser muito legal muito cedo
var contadorLegal = 0
//trabalhe com uma cópia
var novoGrafo = LinkedHashMap(this.grafo)
//todos os nós que são de valor 0 (não coloridos)
val nosNaoColoridos = LinkedList<NoSudoku>()
novoGrafo.values.filter { it.first.cor == 0 }.forEach { nosNaoColoridos.add(it.first) }
while (nosNaoColoridos.size > 0) {
//...
}
//...
}
Neste caso, os comentários em linha eram necessários, pois eu frequentemente esquecia o que algumas dessas variáveis eram enquanto lia este algoritmo gigante.
Outro caso em que adicionarei um comentário em linha é quando tenho que explicar ou me lembrar sobre o código sobre o qual não tenho controle.
Por exemplo, a infame API Java Calendar usa indexação baseada em zero para meses. Isso é indiscutivelmente muito estúpido, pois não estou ciente de nenhum padrão que represente janeiro com 0, nem me importo se existe um!
Não posso compartilhar o código com você, pois é proprietário, mas basta dizer que tenho comentários na base de código da minha equipe atual que explicam instruções aleatórias - 1
para se conformar com a API Calendar.
Como usar enums e dicionários
Existem outros nomes para esses tipos de construções de código, mas esses são os dois com os quais estou familiarizado. Suponha que você tenha um conjunto restrito ou limitado de valores que você usa para representar algo.
Por exemplo, eu precisava de uma maneira de limitar o número de peças que são incluídas em um novo quebra-cabeça de Sudoku, com base:
- No tamanho do quebra-cabeça (4, 9 ou 16 peças por coluna/linha/subgrade)
- Na dificuldade do quebra-cabeça (fácil, médio ou difícil)
Por meio de testes extensivos, cheguei aos seguintes valores como modificadores:
enum class Dificuldade(val modificador:Double) {
FACIL(0.50),
MEDIO(0.44),
DIFICIL(0.38)
}
data class QuebraCabecaSudoku(
val limite: Int,
val dificuldade: Dificuldade,
val grafo: LinkedHashMap<Int, LinkedList<NoSudoku>>
= construirNovoSudoku(limite, dificuldade).grafo,
var tempoDecorrido: Long = 0L
)//...
Esses valores são usados em vários lugares onde a lógica deve mudar com base na dificuldade.
Às vezes, você nem precisa ter valores associados a nomes legíveis por humanos. Usei um enum diferente para representar diferentes estratégias de resolução para garantir que um quebra-cabeça seja jogável em relação à dificuldade selecionada:
enum class EstrategiaDeResolucao {
BASICA,
AVANCADA,
INSOLUVEL
}
internal fun determinarDificuldade(
quebraCabeca: QuebraCabecaSudoku
): EstrategiaDeResolucao {
val resolucaoBasica = ehBasica(
quebraCabeca
)
val resolucaoAvancada = ehAvancada(
quebraCabeca
)
//se o quebra-cabeça não for mais solucionável, retornamos a estratégia atual
if (resolucaoBasica) return EstrategiaDeResolucao.BASICA
else if (resolucaoAvancada) return EstrategiaDeResolucao.AVANCADA
else {
quebraCabeca.imprimir()
return EstrategiaDeResolucao.INSOLUVEL
}
}
Um bom princípio no projeto de qualquer sistema é este: menos partes móveis geralmente têm menos coisas que podem dar errado.
Colocar restrições em valores e tipos e dar a eles bons nomes não apenas torna seu código mais fácil de ler, mas também pode protegê-lo de erros.
Como organizar e nomear pacotes, pastas e diretórios
Nenhum guia sobre legibilidade de código estaria completo sem alguma discussão sobre pacotes. Se a plataforma e a linguagem de sua preferência não usam esse termo, assuma que quero dizer pasta ou diretório.
Já mudei de opinião sobre isso várias vezes. Isso se reflete em meus projetos mais antigos.
Duas abordagens comuns para a organização de pacotes são:
- Pacote por camada arquitetônica
- Pacote por recurso
Como fazer pacote por camada
Pacote por camada é o primeiro e pior sistema que já usei. A ideia geralmente é construir sua estrutura de pacotes em torno de algum padrão arquitetônico como MVC, MVP, MVVM e assim por diante.
Para tomar o MVC como exemplo, sua estrutura de pacotes de nível superior seria assim:
- modelo
- visão
- controlador
O primeiro problema com essa abordagem é que ela assume que toda classe ou função se encaixa confortavelmente em uma dessas camadas. Isso raramente é o caso na prática.
Também acho essa abordagem a menos legível, pois o nível superior informa apenas os detalhes mais gerais sobre o que esperar dentro de cada pacote.
Essa abordagem geralmente pode ser aprimorada adicionando mais "camadas" para ser mais específica:
- ui
- modelo
- api
- logicaDeConstrucao/di
- repositorio
- dominio
- comum
Isso pode funcionar razoavelmente bem em bases de código menores, onde todos os desenvolvedores estão familiarizados com o padrão geral e o estilo usado.
Como fazer pacote por recurso
Pacote por recurso tem suas próprias falhas, mas geralmente é mais fácil de ler e navegar. Isso supondo que você dê bons nomes aos pacotes.
O termo recurso é difícil de descrever, mas eu geralmente o definiria assim: uma tela/página ou conjunto de telas/páginas que definem uma parte principal da funcionalidade para usuários ou clientes.
Para uma aplicação de mídia social, podemos ver uma estrutura como:
- linhaDoTempo
- amigos
- perfilDoUsuario
- mensagens
- detalheDaMensagem
O problema principal com o pacote por recurso é o oposto do pacote por camada: quase sempre haverá entidades de software que são usadas em vários recursos.
Existem duas soluções para esse problema. A primeira seria ter código duplicado em cada recurso.
Acredite ou não, duplicar entidades de software pode ser incrivelmente útil em ambientes corporativos em situações específicas.
No entanto, não é algo que eu recomendaria como regra geral.
Como fazer uma estrutura de pacote híbrida
A solução que geralmente recomendo aos desenvolvedores é o que gosto de chamar de abordagem híbrida. É muito simples, flexível e deve cobrir a maioria dos seus requisitos:
- linhaDoTempo
- amigos
- mensagens
- todasAsMensagens
- conversa
- detalheDaMensagem
- api
- linhaDoTempo
- usuario
- mensagem
- componentesDeIU
Não leve esse exemplo muito a sério; estou tentando transmitir a ideia geral: qualquer coisa que seja específica do recurso vai para o pacote desse recurso. Qualquer coisa que seja compartilhada entre os recursos vai para um pacote separado aninhado no mesmo nível ou em um nível superior.
Novamente, o que define uma camada é um conceito vago para começar. Então, não siga uma convenção cegamente. Pense criticamente sobre o que é claro, particularmente para alguém que não está familiarizado com o projeto.
Considerações finais
A maioria das minhas preferências em legibilidade e estilo de código veio de muitas tentativas de diferentes abordagens. Às vezes, essas eram abordagens que eu vi outras pessoas usarem e algumas delas surgiram naturalmente.
Se você for capaz de se colocar na posição de alguém menos familiarizado com o código ou programa que está olhando, terá mais facilidade em fazer seu código ser lido como se fosse um livro.