Livro Código Limpo: Capítulo 3 - Funções

Livro Código Limpo: Capítulo 3 - Funções

As informações extras não estão no livro. Eu adicionei para facilitar a compreensão dos itens.


Continuando a leitura do livro Código Limpo - Habilidades práticas do Agile Software escrito por Robert C. Martin, segue o resumo do terceiro capítulo. Sugiro separar um tempo porque esse capítulo é longo!


As funções são a primeira linha de organização em qualquer programa. Escrevê-las bem é o assunto deste capítulo.

Como fazer uma função transmitir seu propósito? Quais atributos dar às nossas funções que permitirão um leitor comum deduzir o tipo do programa ali contido?

Pequenas:

A primeira regra para funções é que elas devem ser pequenas. A segunda é que precisam ser mais espertas do que isso. O mais adequado é as funções não ultrapassarem 20 linhas, terem uma obviedade transparente e cada chamada de função deve levar você a próxima em uma ordem atraente.

Blocos e Indentação:

Em casos de blocos dentro de instruções if, else, while e outros devem ter apenas uma linha. Possivelmente uma chamada de função. Além de manter a função pequena, isso adiciona um valor significativo, pois a função chamada de dentro do bloco pode receber um nome descritivo. Isso também implica que as funções não devem ser grandes e ter estruturas aninhadas. Portanto, o nível de indentação de uma função deve ser de, no máximo, um ou dois, facilitando a leitura e compreensão das funções.

Faça apenas uma coisa:

As funções devem fazer uma coisa. Devem fazê-la bem. Devem fazer apenas ela.

O motivo de criarmos função é para decompor um conceito maior (nome da função) em uma série de passos no próximo nível de abstração. Outra forma de saber se uma função faz mais de uma coisa é se você pode extrair outra função dela a partir de seu nome que não seja apenas uma reformulação de sua implementação.

Se a função estiver dividida em seções como declarações, inicializações e seleção (declarations, initializations and sieve), é um indício de estar fazendo mais de uma coisa. Não dá para, de forma significativa, dividir em seções as funções que fazem apenas uma coisa.

Um nível de abstração por função:

A fim de confirmar se nossas funções fazem só uma coisa, precisamos verificar se todas as instruções dentro da função estão no mesmo nível de abstração. Vários níveis de abstração dentro de uma função sempre geram confusão. Os leitores podem não conseguir dizer se uma expressão determinada é um conceito essencial ou um mero detalhe. Uma vez misturados os detalhes aos conceitos, mais e mais detalhes tendem a se agregar dentro da função.

Informações extras

Nesse princípio, o autor enfatiza que cada função deve ter um único nível de abstração, o que significa que todas as instruções dentro da função devem estar no mesmo nível de detalhe.

Quando várias camadas de abstração são misturadas dentro de uma função, isso pode levar à confusão para os leitores do código. Os leitores podem ter dificuldade em distinguir o que é um conceito essencial e o que é apenas um detalhe irrelevante. Além disso, quando detalhes e conceitos são misturados, é mais provável que mais detalhes sejam adicionados à função, aumentando sua complexidade.

O objetivo de manter um único nível de abstração em cada função é facilitar a compreensão do código. Ao separar claramente os conceitos essenciais dos detalhes específicos, torna-se mais fácil para os desenvolvedores entenderem o propósito da função e realizar modificações ou manutenção no código de forma mais eficiente.

Exemplo:

Suponha que temos uma função chamada calcularSalarioTotal, que recebe um array de funcionários e calcula o salário total de todos eles, considerando seus salários base e bônus.

def calcularSalarioTotal(funcionarios):
    totalSalario = 0

    for funcionario in funcionarios:
        salarioBase = funcionario['salarioBase']
        bonus = funcionario['bonus']
        salarioTotal = salarioBase + bonus

        totalSalario += salarioTotal

    return totalSalario        

Neste exemplo, a função calcularSalarioTotal está misturando diferentes níveis de abstração. Ela realiza o cálculo do salário total para cada funcionário dentro de um loop, mas também acessa os detalhes específicos de cada funcionário (salário base e bônus) dentro do loop.

Podemos refatorar essa função para seguir o princípio de "Um nível de abstração por função". Vamos separar a lógica de cálculo do salário total em uma função auxiliar chamada calcularSalarioIndividual, que será responsável apenas por calcular o salário total de um único funcionário.

def calcularSalarioTotal(funcionarios):
    totalSalario = 0

    for funcionario in funcionarios:
        salarioTotal = calcularSalarioIndividual(funcionario)
        totalSalario += salarioTotal

    return totalSalario


def calcularSalarioIndividual(funcionario):
    salarioBase = funcionario['salarioBase']
    bonus = funcionario['bonus']
    salarioTotal = salarioBase + bonus

    return salarioTotal        

Nessa nova versão, a função calcularSalarioTotal agora chama a função calcularSalarioIndividual para cada funcionário, delegando a responsabilidade de calcular o salário total individual. Dessa forma, cada função possui um único nível de abstração: a função calcularSalarioTotal lida com a iteração sobre a lista de funcionários, enquanto a função calcularSalarioIndividual lida exclusivamente com o cálculo do salário total de um funcionário.

Essa separação de responsabilidades torna o código mais legível, facilitando a compreensão do propósito de cada função e permitindo uma manutenção mais fácil no futuro.

Ler o código de cima para baixo: Regra decrescente:

Queremos que o código seja lido de cima para baixo, como uma narrativa. Desejamos que cada função seja seguida pelas outras no próximo nível de abstração de modo que possamos ler o programa descendo um nível de abstração de cada vez conforme percorremos a lista de funções. Chamamos isso de Regra Decrescente.

É difícil para os programadores aprenderem a seguir essa regra e criar funções que fiquem em apenas um nível de abstração, mas esse truque é o segredo para manter as funções curtas e garantir que façam apenas uma coisa.

Instruções switch:

É difícil criar uma estrutura switch pequena, pois mesmo uma switch com apenas dois cases é maior do que eu gostaria que um bloco ou uma função fossem. Também é difícil construir uma switch que fala apenas uma coisa. Por padrão, as estruturas switch sempre fazem N coisas. Infelizmente. nem sempre conseguimos evitar o uso da estrutura switch, mas podemos nos certificar se cada switch está em uma classe de baixo nível e nunca é repetido. Para isso, usamos o polimorfismo.

Minha regra geral para estruturas switch é que são aceitáveis se aparecerem apenas uma vez, como para a criação de objetos polimórficos, e estiverem escondidas atrás de uma relação de herança de modo que o resto do sistema não possa enxergá-las. Porém, em alguns casos não é possível respeitar essa regra.

Informações extras

Exemplo:

Suponha que estamos desenvolvendo um sistema de um jogo de luta, onde temos diferentes personagens com habilidades especiais. Cada personagem pode executar um conjunto específico de habilidades. Podemos usar uma estrutura switch para determinar qual habilidade deve ser executada com base no personagem selecionado. No entanto, esse uso direto de switch pode violar o princípio do polimorfismo e dificultar a extensibilidade do código.

class Personagem {
  usarHabilidadeEspecial(habilidade: string): void {
    switch (habilidade) {
      case 'ataque_especial':
        this.executarAtaqueEspecial();
        break;
      case 'defesa_especial':
        this.executarDefesaEspecial();
        break;
      default:
        console.log('Habilidade desconhecida.');
    }
  }

  executarAtaqueEspecial(): void {
    console.log('Executando ataque especial.');
  }

  executarDefesaEspecial(): void {
    console.log('Executando defesa especial.');
  }
}

const personagem = new Personagem();
personagem.usarHabilidadeEspecial('ataque_especial'); // Executa o ataque especial
personagem.usarHabilidadeEspecial('defesa_especial'); // Executa a defesa especial
personagem.usarHabilidadeEspecial('habilidade_desconhecida'); // Exibe "Habilidade desconhecida."        

Neste exemplo, temos a classe Personagem que possui o método usarHabilidadeEspecial, que utiliza a estrutura switch para determinar qual habilidade deve ser executada com base no parâmetro habilidade. As habilidades disponíveis são 'ataque_especial' e 'defesa_especial'. Caso seja fornecida uma habilidade desconhecida, é exibida uma mensagem de "Habilidade desconhecida".

Embora este exemplo funcione, ele viola o princípio de "Um nível de abstração por função" mencionado anteriormente. A função usarHabilidadeEspecial contém a lógica de controle de fluxo com o switch e está misturada com a lógica de execução das habilidades.

Agora, vamos refatorar o código para utilizar o polimorfismo e seguir o princípio de "Um nível de abstração por função":

abstract class Personagem {
  abstract usarHabilidadeEspecial(): void;
}

class PersonagemA extends Personagem {
  usarHabilidadeEspecial(): void {
    this.executarAtaqueEspecial();
  }

  executarAtaqueEspecial(): void {
    console.log("Executando ataque especial do Personagem A.");
  }
}

class PersonagemB extends Personagem {
  usarHabilidadeEspecial(): void {
    this.executarDefesaEspecial();
  }

  executarDefesaEspecial(): void {
    console.log("Executando defesa especial do Personagem B.");
  }
}

// Adicione outras subclasses para mais personagens...

const personagemA: Personagem = new PersonagemA();
personagemA.usarHabilidadeEspecial(); // Executa o ataque especial do Personagem A

const personagemB: Personagem = new PersonagemB();
personagemB.usarHabilidadeEspecial(); // Executa a defesa especial do Personagem B        

As classes Personagem, PersonagemA e PersonagemB são definidas usando a palavra-chave class. A classe abstrata Personagem define o método usarHabilidadeEspecial como um método abstrato usando a palavra-chave abstract. As subclasses PersonagemA e PersonagemB estendem a classe abstrata Personagem e implementam seus próprios métodos usarHabilidadeEspecial, que executam as habilidades especiais correspondentes.

Em seguida, criamos instâncias das classes PersonagemA e PersonagemB, e as atribuímos às variáveis personagemA e personagemB, respectivamente, com o tipo Personagem. Podemos chamar o método usarHabilidadeEspecial em cada instância para executar as habilidades especiais específicas de cada personagem.

Esse uso de polimorfismo permite que cada personagem tenha sua própria implementação da habilidade especial, eliminando a necessidade de uma estrutura switch extensa e melhorando a extensibilidade do código.

Use nomes Descritivos:

Use nomes descritivos, para descrever o que a função faz.

Princípio de Ward: Você sabe que está criando um código limpo quando cada rotina que você lê é como você esperava.

Metade do esforço para satisfazer esse princípio é escolher bons nomes para funções pequenas que fazem apenas uma coisa. Quanto menor e mais centralizada for a função, mais fácil será pensar em um nome descritivo.

Não tenha medo de criar nomes extensos, pois eles são melhores do que um pequeno e enigmático. Um nome longo e descritivo é melhor do que um comentário extenso e descritivo. Use uma convenção de nomenclaturas que possibilite uma fácil leitura de nomes de funções com várias palavras, e use estas para dar a função um nome que explique o que ela faz. Selecionar nomes descritivos esclarecerá o modelo do módulo em sua mente e lhe ajudará a melhorá-lo. É comum que ao buscar nomes adequados resulte numa boa reestruturação do código. Seja consistente nos nomes. Use as mesmas frases, substantivos e verbos nos nomes de funções de seu módulo.

Parâmetros de funções:

A quantidade ideal de parâmetros para um função é zero (nulo). depois vem um (mônade), seguido de dois (díade). Sempre que possível devem-se evitar três parâmetros (tríade). Para mais de três deve-se ter um motivo muito especial (políade). E mesmo assim não devem ser usados.

Parâmetros são complicados. Eles requerem bastante conceito e são mais difíceis ainda a partir de um ponto de vista de testes. Imagine a dificuldade de escrever todos os casos de teste para se certificar de que todas as várias combinações de parâmetros funcionem adequadamente.

Os parâmetros de saída são ainda mais difíceis de entender do que os de entrada. Quando lemos uma função, estamos acostumados à ideia de informações entrada na função através de parâmetros e saindo através do valor retornado. Geralmente não esperamos dados saindo através de parâmetros. Portanto, parâmetros de saída costuma nos deixar surpresos e fazer com que leiamos novamente.

Formas Mônades Comuns

Você deve escolher nomes que tornem clara a distinção e sempre use duas formas em um contexto consistente. Uma forma menos comum mas ainda bastante útil de um parâmetro para uma função é um evento. Nesta forma, há um parâmetro de entrada, mas nenhum de saída. O programa em si serve para interpretar a chamada da função como um evento e usar o parâmetro para alterar o estado do sistema. Deve ficar claro para o leitor que se trata de um evento. Escolha os nomes e os contextos com atenção. Tente evitar funções mônades. Usar um parâmetro de saída em vez de um valor de retorno para uma modificação fica confuso. Se uma função vai transformar seu parâmetro de entrada, a alteração deve aparecer como o valor retornado.

Parâmetros lógicos:

Esses parâmetros são feios. Passar um booleano para uma função certamente é uma prática horrível, pois ele complica imediatamente a assinatura do método, mostrando explicitamente que a função faz mais de uma coisa. Ela faz uma coisa se o valor for verdadeiro e outra se for falso.

Funções Díades:

Uma função com dois parâmetros é mais difícil de entender do que uma com um parâmetro. A função com dois parâmetros requer uma pausa para compreendermos o que está acontecendo e pode acontecer de ignorarmos um dos parâmetros e resultar em problemas, porque ignoramos uma parte do código. E o local que ignoramos é justamente aonde se esconderão os bugs.

Há casos, em que os dois parâmetros são necessários e podem ser componentes de um único valor. Entretanto, deve-se estar ciente de que haverá um preço a pagar e, portanto, deve-se tirar proveito dos mecanismos disponíveis para convertê-los em mônades.

Tríades:

Funções que recebem três parâmetros são consideravelmente mais difíceis de entender do que as díades. A questão de ordenação, pausa e ignoração apresentam mais do que o dobro de dificuldades, ou seja, pela questão de realizar a pausa, ler novamente e depois ignorar os parâmetros.

Objetos como Parâmetros:

Quando uma função parece precisar de mais de dois ou três parâmetros, é provável que alguns deles podem ser colocados em uma classe própria. Reduzir o número de parâmetros através da criação de objetos a partir deles pode parecer uma trapaça, mas não é. Quando grupos de variáveis são passados juntos, é mais provável que sejam parte de um conceito que mereça um nome só para ele.

Informações extras

Exemplo:

Suponha que estamos desenvolvendo um sistema de gerenciamento de pedidos em uma loja online. Ao criar um novo pedido, temos várias informações relacionadas ao pedido, como o ID do cliente, os itens do pedido e o endereço de entrega. Em vez de passar cada uma dessas informações separadamente como parâmetros para uma função, podemos criar um objeto chamado Pedido para encapsular essas informações.

class Pedido {
  constructor(
    public idCliente: number,
    public itens: string[],
    public enderecoEntrega: string
  ) {}
}

function criarPedido(pedido: Pedido): void {
  // Lógica para criar o pedido
  console.log('Pedido criado com sucesso!');
  console.log('ID do cliente:', pedido.idCliente);
  console.log('Itens:', pedido.itens);
  console.log('Endereço de entrega:', pedido.enderecoEntrega);
}

// Exemplo de uso
const novoPedido = new Pedido(123, ['Produto 1', 'Produto 2'], 'Rua Principal, 123');
criarPedido(novoPedido);        

Nesse exemplo, temos a classe Pedido, que possui propriedades para o ID do cliente, os itens do pedido e o endereço de entrega. Em vez de passar cada uma dessas informações separadamente como parâmetros para a função criarPedido, criamos um objeto novoPedido do tipo Pedido e passamos esse objeto como parâmetro.

Ao encapsular as informações relacionadas em um objeto Pedido, reduzimos o número de parâmetros da função e tornamos o código mais legível e intuitivo. Em vez de passar uma série de variáveis separadas, temos um único objeto que representa o conceito de um pedido.

Essa abordagem também facilita a manutenção e extensibilidade do código. Se precisarmos adicionar mais informações ao pedido no futuro, podemos simplesmente adicionar novas propriedades à classe Pedido, sem precisar alterar a assinatura da função criarPedido ou seu comportamento.

Em resumo, ao criar objetos a partir de parâmetros relacionados, podemos encapsular conceitos específicos e reduzir a complexidade dos parâmetros das funções, tornando o código mais legível, organizado e extensível.

Listas como parâmetros:

Às vezes, queremos passar um número variável de parâmetros para uma função. Se os parâmetros variáveis forem todos da mesma forma, então eles serão equivalentes a um único parâmetro do tipo list. A declaração abaixo deixa claro que uma díade.

public String format( String format, Object… args )        

Nesse caso, as funções que recebem argumentos variáveis podem ser mônades, díades ou mesmo tríades, porém seria um erro passar mais parâmetros do que isso.

Informações extras

Exemplo:

Suponha que estamos desenvolvendo uma função format que recebe uma string de formato e um número variável de argumentos. A string de formato contém marcadores especiais, como "{0}", "{1}", "{2}", etc., que serão substituídos pelos valores fornecidos nos argumentos.

public String format(String format, Object... args) {
    StringBuilder sb = new StringBuilder();
    int argIndex = 0;

    for (char c : format.toCharArray()) {
        if (c == '{') {
            StringBuilder argNumber = new StringBuilder();
            c = format.charAt(++argIndex);

            while (Character.isDigit(c)) {
                argNumber.append(c);
                c = format.charAt(++argIndex);
            }

            if (c == '}') {
                int index = Integer.parseInt(argNumber.toString());

                if (index >= 0 && index < args.length) {
                    sb.append(args[index]);
                } else {
                    sb.append("{").append(argNumber).append("}");
                }
            } else {
                sb.append("{").append(argNumber);
                sb.append(c);
            }
        } else {
            sb.append(c);
        }
    }

    return sb.toString();
}

// Exemplo de uso
String message = format("Olá, {0}! Seu saldo é {1}.", "João", 1000);
System.out.println(message);        

Nesse exemplo, temos a função format que recebe uma string de formato e um número variável de argumentos do tipo Object. Dentro da função, usamos um loop para percorrer cada caractere da string de formato.

Quando encontramos um marcador "{0}", "{1}", "{2}", etc., procuramos o índice correspondente na string de formato e substituímos pelo valor fornecido no argumento correspondente. Caso o índice esteja fora do intervalo dos argumentos fornecidos, mantemos o marcador original.

No exemplo de uso, chamamos a função format com a string de formato "Olá, {0}! Seu saldo é {1}.". Passamos os argumentos "João" e 1000, que serão substituídos nos marcadores "{0}" e "{1}", respectivamente. O resultado é a string "Olá, João! Seu saldo é 1000.".

Ao usar listas como parâmetros e permitir um número variável de argumentos, tornamos a função flexível e capaz de lidar com diferentes quantidades de valores. Isso é útil em situações em que não sabemos antecipadamente quantos valores serão necessários para preencher os marcadores de uma string de formato.

Verbos e palavras-chave:

Escolher bons nomes para funções pode ir desde explicar o propósito da função à ordem e a finalidade dos parâmetros. No caso de uma mônade, a função e o parâmetro devem formar um belo par verbo/substantivo. Por exemplo, write(name) é bastante claro. Seja o que for, esse nome será escrito. Um exemplo de formato palavra-chave no nome de uma função é assertEquals(expected, actual).

Evite efeitos colaterais:

Efeitos colaterais são mentiras. Sua função promete fazer apenas uma coisa, mas ela também faz outras coisas escondidas. Às vezes, ela fará alterações inesperadas nas variáveis de sua própria classe. Às vezes, ela adicionará as variáveis aos parâmetros passados à função ou às globais do sistema. Em ambos os casos elas são verdades enganosas e prejudiciais, que geralmente resultam em acoplamentos temporários estranhos e dependências.

Informações extras

Exemplo:

Suponha que estamos desenvolvendo um sistema de carrinho de compras em uma loja online. Temos uma classe CarrinhoDeCompras que contém uma lista de itens e um método adicionarItem para adicionar itens ao carrinho.

import java.util.ArrayList;
import java.util.List;

public class CarrinhoDeCompras {
    private List<String> itens;

    public CarrinhoDeCompras() {
        this.itens = new ArrayList<>();
    }

    public void adicionarItem(String item) {
        this.itens.add(item);
        System.out.println("Item adicionado: " + item);
        atualizarTotal();
    }

    private void atualizarTotal() {
        // Lógica para atualizar o total do carrinho
        System.out.println("Total atualizado.");
    }

    public List<String> getItens() {
        return this.itens;
    }
}

// Exemplo de uso
CarrinhoDeCompras carrinho = new CarrinhoDeCompras();
carrinho.adicionarItem("Produto A");
carrinho.adicionarItem("Produto B");
List<String> itens = carrinho.getItens();
System.out.println("Itens no carrinho: " + itens);        

Nesse exemplo, a classe CarrinhoDeCompras possui um método adicionarItem que adiciona um item à lista de itens do carrinho. Além disso, dentro do método adicionarItem, chamamos o método atualizarTotal para atualizar o total do carrinho.

Porém, o método adicionarItem também imprime mensagens de log e chama o método atualizarTotal, introduzindo efeitos colaterais. Essas ações adicionais não são explicitamente indicadas pelo nome ou pela assinatura do método, o que pode levar a surpresas e acoplamentos indesejados.

Imagine que em algum momento decidimos remover a impressão das mensagens de log dentro do método adicionarItem. Isso parece uma alteração simples e isolada, mas, sem perceber, outros trechos de código podem estar contando com essas mensagens de log para fins de depuração ou auditoria. Ao remover a impressão das mensagens de log, podemos quebrar funcionalidades ou tornar a depuração mais difícil.

Além disso, ao chamar o método adicionarItem e acessar a lista de itens do carrinho através do método getItens, estamos expondo diretamente a lista interna do carrinho, permitindo alterações diretas fora da classe CarrinhoDeCompras. Isso aumenta o acoplamento e pode levar a problemas de consistência e integridade dos dados.

Para evitar esses efeitos colaterais e promover uma maior coesão e encapsulamento, é recomendado restringir o escopo e a responsabilidade das funções. Nesse caso, poderíamos separar a lógica de atualização do total em um método separado e remover as ações adicionais do método adicionarItem, deixando-o responsável apenas por adicionar itens ao carrinho.

import java.util.ArrayList;
import java.util.List;

public class CarrinhoDeCompras {
    private List<String> itens;
    private double total;

    public CarrinhoDeCompras() {
        this.itens = new ArrayList<>();
        this.total = 0.0;
    }

    public void adicionarItem(String item) {
        this.itens.add(item);
    }

    public double calcularTotal() {
        // Lógica para calcular o total do carrinho
        return this.total;
    }

    public List<String> getItens() {
        return new ArrayList<>(this.itens);
    }
}

// Exemplo de uso
CarrinhoDeCompras carrinho = new CarrinhoDeCompras();
carrinho.adicionarItem("Produto A");
carrinho.adicionarItem("Produto B");
List<String> itens = carrinho.getItens();
System.out.println("Itens no carrinho: " + itens);
double total = carrinho.calcularTotal();
System.out.println("Total do carrinho: " + total);        

Nesse exemplo refatorado, removemos as ações adicionais do método adicionarItem e extraímos a lógica de atualização do total para um método separado chamado calcularTotal. Agora, o método adicionarItem tem a responsabilidade exclusiva de adicionar itens à lista.

Além disso, ao acessar a lista de itens através do método getItens, retornamos uma cópia da lista interna do carrinho, em vez de retornar diretamente a referência à lista original. Isso evita que alterações diretas sejam feitas fora da classe e preserva a integridade dos dados.

Dessa forma, reduzimos os efeitos colaterais, deixando as funções mais focadas em suas tarefas específicas, melhorando a legibilidade, manutenção e evitando dependências ocultas e acoplamentos indesejados.

Parâmetros de saída:

Os parâmetros são comumente interpretados como entradas de uma função. Em algum momento você já voltou até a declaração de uma função para verificar o parâmetro, e então perceber que na verdade, o parâmetro era de saída ao invés de entrada. Essa verificação de assinatura de uma função, é considerado uma releitura, causando uma interrupção do raciocínio e que deve ser evitado. Com as linguagens Orientadas a Objeto essa necessidade de parâmetro de saída perdeu o sentido, pois o this tem o propósito de servir como um parâmetro de saída. De modo geral, deve-se evitar parâmetros de saída. Caso a função precise alterar o estado de algo, faça-o mudar o estado do objeto que a pertence.

Separação comando-consulta (Command Query Separation):

As funções devem fazer ou responder algo, mas não ambos. Sua função ou altera o estado de um objeto ou retorna informações sobre ele. Efetuar as duas tarefas costuma gerar confusão.

Informações extras

Exemplo:

Suponha que estamos desenvolvendo um sistema de gestão de estoque para uma loja. Temos uma classe chamada Estoque que possui um array de produtos e métodos para adicionar um novo produto ao estoque e obter a quantidade disponível de um determinado produto.

import java.util.HashMap;
import java.util.Map;

public class Estoque {
    private Map<String, Integer> produtos;

    public Estoque() {
        this.produtos = new HashMap<>();
    }

    public void adicionarProduto(String nome, int quantidade) {
        this.produtos.put(nome, quantidade);
        System.out.println("Produto adicionado: " + nome);
    }

    public int consultarQuantidade(String nome) {
        if (this.produtos.containsKey(nome)) {
            return this.produtos.get(nome);
        } else {
            return 0;
        }
    }
}

// Exemplo de uso
Estoque estoque = new Estoque();
estoque.adicionarProduto("Produto A", 10);
estoque.adicionarProduto("Produto B", 5);
int quantidadeProdutoA = estoque.consultarQuantidade("Produto A");
System.out.println("Quantidade de Produto A: " + quantidadeProdutoA);
int quantidadeProdutoB = estoque.consultarQuantidade("Produto B");
System.out.println("Quantidade de Produto B: " + quantidadeProdutoB);        

Nesse exemplo, a classe Estoque possui o método adicionarProduto para adicionar um novo produto ao estoque e o método consultarQuantidade para obter a quantidade disponível de um produto específico.

Observe que o método adicionarProduto é um comando, pois altera o estado do objeto Estoque ao adicionar um novo produto ao mapa produtos. Ele também exibe uma mensagem de log informando que o produto foi adicionado.

Já o método consultarQuantidade é uma consulta, pois retorna informações sobre o objeto Estoque. Ele verifica se o produto especificado está presente no mapa produtos e, se estiver, retorna a quantidade disponível; caso contrário, retorna 0.

Ao separar claramente os comandos das consultas, evitamos confusão e tornamos as funções mais claras em sua finalidade. Isso facilita a compreensão do código e ajuda a evitar efeitos colaterais indesejados.

Além disso, ao utilizar a separação entre comandos e consultas, seguimos o princípio da responsabilidade única, onde cada função tem uma única responsabilidade bem definida, tornando o código mais organizado e mais fácil de manter e evoluir.

Prefira exceções a retorno de códigos de erro:

Fazer funções retornarem códigos de erros é uma leve violação da separação comando-consulta, pois os comandos são usados como expressões de comparação em estruturas if. Isso gera um problema de criação de estruturas aninhadas. Ao retornar um código de erro, você cria um problema para o chamador, que deverá lidar imediatamente com o erro. Usando exceções, o código de tratamento de erro poderá ficar separado do código e ser simplificado.

Extraia os blocos try/catch:

Esses blocos são feios por si só. Eles confundem a estrutura do código e misturam o tratamento de erro com o processamento norma do código. Portanto, é melhor colocar as estruturas try e catch em suas próprias funções.

public void delete(Page page) {
  try {
    deletePageAndAllReferences(page);
  }
  catch (Exception e ) {
    logError(e);
  }
}

private void deletePageAndAllReferences(Page page) throws Exception {
  …
}

private void logError(Exception e) {
  …
}        

Tratamento de erro é uma coisa só:

As funções devem fazer uma coisa só. Tratamento de erro é uma coisa só. Portanto, uma função que trata de erros não deve fazer mais nada. Isso implica que a palavra try está dentro de uma função e deve ser a primeira instrução e nada mais deve vir após os blocos catch/finally.

Error.java, a dependência magnética:

Retornar códigos de erro costuma implicar que há classes ou enum nos quais estão definidos todos os códigos de erro. Classes como estas são dependências magnéticas. Muitas outras classes devem importá-las e usá-las. Portanto, quando o enum Error precisa recompilar todas as outras classes e reimplantá-las, coloca uma pressão negativa na classe Error. Os programadores não querem adicionar novos erros porque senão precisam compilar e distribuir tudo novamente. Por isso, eles reutilizam códigos de erros antigos em vez de adicionar novos. Quando se usam exceções em vez de códigos de erro, as novas exceções são derivadas da classe de exceções. Podem-se adicioná-las sem ter de recompilar ou redistribuir.

public enum Error {
  OK,
  INVALID,
  LOCKED,
  WAITING_FOR_EVENT,
  ...
}        

Informações extras

Exemplo:

Suponha que estamos desenvolvendo um sistema de processamento de pagamentos para uma aplicação financeira. Temos uma classe chamada ProcessadorPagamento que possui um método realizarPagamento responsável por processar um pagamento.

public class ProcessadorPagamento {
    public int realizarPagamento(String numeroCartao, double valor) {
        // Lógica para processar o pagamento

        if (validarCartao(numeroCartao)) {
            if (verificarSaldoSuficiente(numeroCartao, valor)) {
                realizarDebito(numeroCartao, valor);
                return 0; // Código de sucesso
            } else {
                return 1; // Código de erro: saldo insuficiente
            }
        } else {
            return 2; // Código de erro: cartão inválido
        }
    }

    private boolean validarCartao(String numeroCartao) {
        // Lógica para validar o número do cartão
        return true;
    }

    private boolean verificarSaldoSuficiente(String numeroCartao, double valor) {
        // Lógica para verificar se o saldo é suficiente
        return true;
    }

    private void realizarDebito(String numeroCartao, double valor) {
        // Lógica para realizar o débito
    }
}

// Exemplo de uso
ProcessadorPagamento processador = new ProcessadorPagamento();
int resultado = processador.realizarPagamento("1234567890", 100.0);

if (resultado == 0) {
    System.out.println("Pagamento realizado com sucesso.");
} else if (resultado == 1) {
    System.out.println("Saldo insuficiente.");
} else if (resultado == 2) {
    System.out.println("Cartão inválido.");
}        

Nesse exemplo, o método realizarPagamento da classe ProcessadorPagamento processa um pagamento com base no número do cartão e no valor fornecidos. Ele executa uma série de verificações, como validar o cartão, verificar se há saldo suficiente e realizar o débito. Em seguida, retorna um código de erro específico caso ocorra algum problema.

Ao retornar códigos de erro específicos, criamos dependências em classes ou enums que definem todos esses códigos de erro. Isso pode levar a dependências fortes e acoplamentos desnecessários. Se a lista de códigos de erro crescer ou mudar, todas as partes do código que verificam esses códigos precisarão ser atualizadas.

Uma abordagem mais recomendada seria usar exceções em vez de retornar códigos de erro. Isso permite capturar e tratar erros de forma mais flexível, além de não criar dependências específicas de códigos de erro.

Aqui está um exemplo refatorado usando exceções:

public class ProcessadorPagamento {
    public void realizarPagamento(String numeroCartao, double valor) throws PagamentoException {
        // Lógica para processar o pagamento

        if (!validarCartao(numeroCartao)) {
            throw new CartaoInvalidoException();
        }

        if (!verificarSaldoSuficiente(numeroCartao, valor)) {
            throw new SaldoInsuficienteException();
        }

        realizarDebito(numeroCartao, valor);
    }

    private boolean validarCartao(String numeroCartao) {
        // Lógica para validar o número do cartão
        return true;
    }

    private boolean verificarSaldoSuficiente(String numeroCartao, double valor) {
        // Lógica para verificar se o saldo é suficiente
        return true;
    }

    private void realizarDebito(String numeroCartao, double valor) {
        // Lógica para realizar o débito
    }
}

// Exemplo de uso
ProcessadorPagamento processador = new ProcessadorPagamento();

try {
    processador.realizarPagamento("1234567890", 100.0);
    System.out.println("Pagamento realizado com sucesso.");
} catch (CartaoInvalidoException e) {
    System.out.println("Cartão inválido: " + e.getMessage());
} catch (SaldoInsuficienteException e) {
    System.out.println("Saldo insuficiente: " + e.getMessage());
} catch (PagamentoException e) {
    System.out.println("Erro no pagamento: " + e.getMessage());
}        

Nesse exemplo refatorado, as exceções CartaoInvalidoException e SaldoInsuficienteException são lançadas quando ocorrem problemas específicos durante o processamento do pagamento. O código de uso captura essas exceções separadamente e trata cada uma delas de acordo.

Essa abordagem oferece maior flexibilidade, pois não cria dependências em códigos de erro específicos e permite lidar com os erros de forma mais granular e personalizada.

Evite repetição:

A duplicação é um problema, pois ela amontoa o código e serão necessárias várias modificações se o algoritmo mudar, além de omitir erros também. A duplicação pode ser a raiz de todo o mal no software. Muitos princípios e práticas tem sido criados com a finalidade de controlar ou eliminar a duplicação dos códigos fonte.

Programação estruturada:

Alguns programadores seguem as regras programação estruturada de Edsger Dijkstra que disse que cada função e bloco dentro de uma função deve ter uma entrada e uma saída. Cumprir essas regras significa que deveria haver apenas uma instrução return na função, nenhum break ou continue num loop e jamais um goto.

Enquanto somos solidários com os objetivos e disciplinas da programação estruturada, tais regras oferecem pouca vantagem quando as funções são muito pequenas. Apenas em funções maiores tais regras proporcionam benefícios significativos. Portanto, se você mantiver suas funções pequenas, então as várias instruções return, break ou continue casuais não trarão problemas e poderão ser até mesmo mais expressivas do que a simples regra de uma entrada e uma saída. Por outro lado, o goto só faz sentido em funções grandes e deve ser evitado.

Como escrever funções como essa?

Criar um software é como escrever um artigo. Primeiro você coloca seus pensamentos no papel e depois os organiza de modo que fiquem fáceis de ler. Escreva as funções mesmo que sejam longas e complexas. Depois organize e refina o código, divida em funções, troque os nomes , elimine duplicações, reduza métodos e reorganize o código. Se tiver teste de unidade, será um bom auxilio para analisar cada linha de código desorganizado.

Conclusão

Cada sistema é construído a partir de uma linguagem específica. Programadores utilizam os recursos da linguagem de programação para construir uma linguagem mais rica e expressiva do que a usada para contar a estória. Parte da linguagem específica a um domínio é a hierarquia de funções que descreve todas as ações que ocorrem dentro daquele sistema. Se seguir as regras descritas no livro, suas funções serão curtas, bem nomeadas e bem organizadas. Mas jamais esqueça de que seu objetivo é contar a história do sistema, e que as funções que você escrever precisam estar em perfeita sincronia e formar uma linguagem clara e precisa para lhe ajudar na narração.

Entre para ver ou adicionar um comentário

Outros artigos de Simone Auler

Outras pessoas também visualizaram

Conferir tópicos