Design diário

Design diário


Vou mostrar dois trechos de código que resolvem o mesmo problema de comparação de duas entradas. Um leva dez linhas. O outro leva setenta. Qual você escreveria?

Aqui está a versão de dez linhas:

Python
def compare_values() -> None:
    a = int(input("Enter the first value: "))
    b = int(input("Enter the second value: "))

    if a > b:
        print("The first value is bigger")
    elif a < b:
        print("The second value is bigger")
    else:
        print("The values are equal")

Aqui está a versão com 78 linhas:

Python
from enum import IntEnum
from unittest import TestCase, main

class ValueComparison(IntEnum):
    Bigger = 0
    Smaller = 1
    Equal = 2

class Console:
    def read_int(self, prompt: str) -> int:
        return int(input(prompt))

    def write(self, message: str) -> None:
        print(message)

class FakeConsole(Console):
    def __init__(self, inputs: list(int)) -> None:
        self.index = 0
        self.inputs = inputs
        self.messages = ()

    def read_int(self, prompt: str) -> int:
        value = self.inputs(self.index)
        self.index += 1
        return value

    def write(self, message: str) -> None:
        self.messages.append(message)

def compare_values(a: int, b: int) -> ValueComparison:
    if a > b:
        return ValueComparison.Bigger
    if a < b:
        return ValueComparison.Smaller

    return ValueComparison.Equal

def program(console: Console) -> None:
    a = console.read_int("Enter the first value: ")
    b = console.read_int("Enter the second value: ")

    match compare_values(a, b):
        case ValueComparison.Bigger:
            console.write("The first value is bigger")
        case ValueComparison.Smaller:
            console.write("The second value is bigger")
        case ValueComparison.Equal:
            console.write("The values are equal")

class TestShell(TestCase):
    """Unit tests for the program"""

    def test_first_value_is_bigger(self):
        console = FakeConsole((2, 1))

        program(console)

        assert len(console.messages) == 1
        assert "The first value is bigger" in console.messages

    def test_second_value_is_bigger(self):
        console = FakeConsole((1, 2))

        program(console)

        assert len(console.messages) == 1
        assert "The second value is bigger" in console.messages

    def test_values_are_equal(self):
        console = FakeConsole((1, 1))

        program(console)

        assert len(console.messages) == 1
        assert "The values are equal" in console.messages

if __name__ == "__main__":
    main()

A maioria dos desenvolvedores criaria a primeira versão sem hesitação. Abrangendo dez linhas, é fácil de entender. Mesmo pessoas não familiarizadas com Python podem compreendê-lo imediatamente.

A segunda versão parece intimidante, cheia de classes e abstrações que parecem excessivas para um problema tão simples. No entanto, decisões cruciais de design os diferenciam na qualidade do código.

O primeiro trecho é o que eu chamo design do dia a dia. Agradável de ver, fácil de mudar, obviamente correto. Isto deve ser um código de alta qualidade, não?

Um desenvolvedor que pratica design no dia a dia escreverá código semelhante ao primeiro trecho em minutos e ficará satisfeito.

Então os requisitos mudam. O proprietário do produto deseja buscar valores da API REST de outra equipe. Ou querem escrever a saída no formato JSON. Ou desejam armazenar o resultado da comparação em um banco de dados.

E agora? Você não pode testar facilmente a lógica de comparação isoladamente: ela está emaranhada com E/S. Você não pode reutilizar a comparação em contextos diferentes sem copiar e modificar toda a função. Cada mudança requer abordar várias preocupações ao mesmo tempo.

Então, você adiciona uma instrução condicional aqui, um novo parâmetro ali, talvez um sinalizador para controlar o formato de saída. A função cresce. Em pouco tempo, você estará lutando com uma lógica que faz tudo e pode ser testada apenas simulando a E/S do terminal ou executando testes de ponta a ponta.

Se isso lhe parece familiar, você não está sozinho. O apelo do design cotidiano é o imediatismo, enquanto o custo é a flexibilidade. Quer saber como melhorar? Continue lendo.

Não é ciência

Um engenheiro de software praticando design limpo escreve primeiro os testes para o segundo trecho, depois escreve uma solução bruta para fazê-los passar e, finalmente, refatora-a. Sim, isso me lembra uma sigla, mas isso não é importante agora.

Toda a atividade leva minutos. No entanto, a velocidade importa menos do que entender por que a maioria dos desenvolvedores volta ao design cotidiano. Muitos desenvolvedores com quem trabalhei explicam que precisam visualizar a solução mentalmente, carregá-la em seu editor e ver se funciona. Ou melhor, se não trava imediatamente.

Esta abordagem experimental é perfeitamente sensata devido à sua natureza científica:

  • Primeiro, montamos o experimento.
  • Em seguida, nós o executamos.
  • Finalmente, verificamos a hipótese.

No entanto, o design limpo funciona de maneira diferente. Pode-se até dizer que funciona de maneira não científica, e esse fato por si só torna a ingestão particularmente difícil para a maioria.

O primeiro obstáculo: devemos parar de pensar de forma imperativa e focar na especificação declarativa, ou comportamento.

Isto é, em vez de pensar nas seguintes etapas:

  • Peça ao usuário a primeira entrada e armazene-a.
  • Peça ao usuário a segunda entrada e armazene-a.
  • Compare as entradas.
  • Se a primeira entrada for maior, imprima “O primeiro valor é maior”.
  • Se a segunda entrada for maior, imprima “O segundo valor é maior”.
  • Se as entradas forem iguais, imprima “Os valores são iguais”.

Você deve virar de cabeça para baixo:

Para todos os números (um,b)(a, b)

  • Dado um>buma > b
  • Dado um<buma
  • Dado um=buma = b

O que foi dito acima não está mais tão claro, não é? Em vez de dizer o que fazer, estou especificando o comportamento esperado do programa.

Você pode me perguntar de onde obtenho as informações e onde escrevo o resultado. Não importa tanto quanto você pensa. Você poderia escolher praticamente qualquer coisa. Você pode ler e gravar em um terminal, banco de dados, API REST ou uma combinação deles. O comportamento esperado não muda.

No entanto, se você combinar comportamento com instruções literais, as decisões de infraestrutura ditarão repentinamente seu projeto. Instruções não são iguais a comportamento.

O que importa é como a declaração do comportamento faz você pensar em casos de teste em vez de instruções, capacitando-o a começar a escrever os testes imediatamente.

Quando você começa a escrever testes, você trabalha nesta realidade estranha, não científica e atrasada, onde você:

  • Estabeleça a hipótese – mesmo que ela esteja sempre correta.
  • Execute o experimento.
  • Configure o experimento.

Eu sei, eu sei, você pode sentir náuseas. Pratique isso por um dia e você se sentirá bastante desconfortável. Continue praticando e, quanto mais desafiador for o problema, mais você estará inclinado a voltar aos seus antigos métodos científicos e ao conforto do design cotidiano. Mas, como qualquer nova dieta, pratique-a diligentemente durante meses e você notará uma diferença sutil, mas notável.

Você começa a distinguir o comportamento dos detalhes da implementação com mais facilidade, resultando em um design mais limpo. Detalhes como recuperar entradas e escrever saídas tornam-se menos urgentes. Eles ainda são importantes na integração com sistemas externos, mas eles não determinam mais o seu design. Assim, você pode adiar essas decisões até o último momento responsável.

Com certeza você acaba escrevendo mais linhas de código. Às vezes, ao trabalhar com linguagens detalhadas, são necessárias quantidades ridículas de código extra. No entanto, qualquer sistema que você escrever é muito mais fácil de escalar sem exigir uma reescrita completa.

Como começar

Deixe-me mostrar como escrever microtestes usando o pensamento comportamental. Começaremos pequenos e aumentaremos.

Primeiro, pense no que é verdadeiro. Não como implementar a solução.

Python
def test_first_value_is_bigger():
    assert compare_values(2, 1) == ValueComparison.Bigger

Isso ainda não passa. Você não tem o compare_values() função, ou ValueComparison enum. Isso é bom. Escreva o teste primeiro.

Agora faça passar:

Python
from enum import IntEnum

class ValueComparison(IntEnum):
    Bigger = 0
    Smaller = 1
    Equal = 2

def compare_values(a: int, b: int) -> ValueComparison:
    if a > b:
        return ValueComparison.Bigger

O teste é verde. Prossiga para o próximo caso.

Python
def test_second_value_is_bigger():
    assert compare_values(1, 2) == ValueComparison.Smaller

Isso ainda não passa porque a função retorna None valor para este caso. Vamos consertar:

Python
def compare_values(a: int, b: int) -> ValueComparison:
    if a > b:
        return ValueComparison.Bigger
    if a < b:
        return ValueComparison.Smaller

Ambos os testes passam.

Finalmente, adicione o teste de igualdade e faça-o passar. Agora você tem um compare_values() função totalmente testada e completamente independente de E/S e utilizável em qualquer lugar.

Eventualmente, você precisará integrar a lógica à infraestrutura. Em outras palavras, cole o núcleo funcional ao shell imperativo. Os wrappers de E/S do segundo trecho (Console e FakeConsole) entram em jogo aqui. Você escreve outro conjunto de testes usando os wrappers de E/S e pode até descartar os primeiros testes, pois eles serviram apenas como estrutura de design.

A chave para superar as dificuldades de criar um design limpo é repetir esse processo vigorosamente. Cada vez que você projeta um novo recurso, comece pensando no comportamento esperado. Escreva os testes primeiro. Em seguida, escreva a implementação para fazê-los passar. Finalmente, refatore o design, se necessário.

Aliás, é precisamente por isso que apoio o trabalho suco de programação regularmente. Design não é diferente de malhar. Você precisa continuar praticando seus músculos de design para permitir que cresçam.

Mas Niko, qual é o benefício disso design limpo? No momento, posso modificar rapidamente meu código e ver as alterações funcionarem.

Sim, esse é o apelo do design do dia a dia. Mas aqui está o que aprendi depois de anos de ambas as abordagens:

O design do dia a dia é mais rápido de escrever até a primeira solicitação de mudança. Então é mais lento para cada alteração subsequente. O design limpo requer uma reflexão mais inicial, mas cada alteração posterior é isolada, testada e segura.

Quando o proprietário do produto diz: “Podemos fazer isso funcionar com um banco de dados em vez de entrada de terminal?”, a segunda versão muda em um só lugar. A primeira versão requer desembaraçar a E/S da lógica, adicionar abstrações que você deveria ter desde o início e reescrever testes que foram acoplados aos detalhes de implementação.

Quando você precisa alterar o formato de saída, a segunda versão muda em uma função com seus testes já implementados. A primeira versão requer a edição cuidadosa de uma função que faz tudo, esperando que você não quebre as preocupações interligadas.

Quando um bug aparece na produção, a segunda versão permite escrever um teste com falha para a lógica de comparação em segundos. A primeira versão requer simulação de E/S apenas para testar se 5 > 3.

Volte para esses dois trechos no início. Qual deles você pode modificar sem quebrar? Qual deles você pode testar isoladamente? Qual deles você ainda entenderá quando estiver depurando às 3 da manhã?

O design do dia a dia é mais rápido de escrever uma vez e jogar fora. Seu único valor é o de um protótipo. O design limpo é mais resistente a mudanças, mais fácil de testar e agradável de manter. Quando você está construindo um software que precisa durar mais do que um fim de semana, essa diferença aumenta exponencialmente.

Você não precisa escrever um código perfeito no primeiro dia. Mas é preciso separar o comportamento da infraestrutura. Teste o comportamento. Torne a infraestrutura trocável. Todo o resto decorre disso.

Comece pequeno. Escolha uma função amanhã. Escreva o teste primeiro. Veja qual design surge. Você pode se surpreender com o quão natural é a sensação depois que o desconforto inicial desaparece.



Source link

Postagens Similares

Deixe um comentário

O seu endereço de email não será publicado. Campos obrigatórios marcados com *