Nesta live eu mostro como criar um código que inicia relativamente simples e depois trabalhar para fazer com que ele vá evoluindo conforme as necessidades do cliente.

É interessante que, este tipo de problema que é puramente o desenvolvimento de um algoritmo, é algo vai desafiar até mesmo os mais experientes desenvolvedores. É até por esta razão que muitos odeiam tanto os desafios passados por empresas deste tipo.

A escolha do problema que vou resolver abaixo tem alguns motivos:

1. É algo que não tem dependência alguma externa (internet, hardware ou sistema operacional)

2. È possível resolver o mesmo problema de diversas formas diferentes

3. A forma mais rápida de resolver este problema, assim como a maioria deles, é utilizando uma boa base de qualidade (Unit Tests) e saber onde e como fazer as adaptações certas de acordo com a evolução das necessidades do cliente.

O começo...

Você recebeu uma requisição de um cliente, bastante simples por sinal, de montar um sistema que recebe dois números e deverá retornar a soma dele.

O objetivo é que ele fará parte de um sistema maior que está sendo desenvolvido, sua parte é apenas receber esta informação e fazer o processamento.

Como esta é a sua primeira atividade para o cliente, é importante resolver rápido. Então é criado o seguinte código:

def calculate(n1, n2):    
    return n1 + n2

E os seguintes Unit Tests:

import unittest
from calculator import calculate

class SimpleCalculator(unittest.TestCase):
    def test_simple_sum_return_result(self):
        self.assertEqual(2, calculate(1, 1))

Código simples implementado, fácil de ser usado e resolve o problema do cliente até o momento. Porém, com mais aprendizado sobre o problema, o cliente precisou de outros requisitos:

1. Ele gostaria de somar mais números, não apenas dois

2. Em alguns casos seria necessário subtrair destes números somados

Novos requisitos, talvez seja necessária uma nova abordagem. Já prevendo o que o produto estava se tornando e preocupado em deixar as coisas mais simples para o cliente, a seguinte estratégia começa a ser formada:

def calculate(expression):
    pass

Neste caso, o parâmetro expression poderá receber toda uma expressão matemática que deverá ser resolvida, desta forma é possível expandir e colocar novas operações sem ter que ficar alterando a assinatura da função a cada nova versão.

E claro, já temos mais alguns testes que devemos fazer passar para atender a demanda do cliente:

Com o novo formato mostrado ao cliente e aprovado, vamos trabalhar na implementação para fazer os testes passarem:

import unittest

from calculator import calculate

class SimpleCalculator(unittest.TestCase):
    def test_simple_sum_return_result(self):
        self.assertEqual(2, calculate('1+1'))
    def test_sum_and_subtraction(self):
        self.assertEqual(1, calculate('1+1-1'))

É possível iniciar, de uma forma bastante ingênua e resolver o primeiro teste com um código como o abaixo:

def calculate(expression):
    tmp = ''
    n1 = n2 = None
    last_operation = None
    for c in expression:
        if __is_number(c):
            tmp += c
        else:
            if c == '+':
                last_operation = '+'
                if n1 is None:
                    n1 = int(tmp)
                    tmp = ''
                elif n2 is None:
                    n2 = int(tmp)
                    tmp = ''
        if last_operation == '+':
            if n2 is None:
                n2 = int(tmp)
            return n1 + n2

def __is_number(c):
    try:
        int(c)
        return True
    except ValueError: 
        return False

Porém, logo de cara já temos alguns problemas. Além desta função não estar atendendo a todas as necessidades do cliente, pois não está dando suporte a subtração, o código está virando um enorme conjunto de IFs e duplicação de código.

Este com certeza não é o melhor caminho para o que desejamos e precisamos encontrar uma nova abordagem.

TDD + boa base de algoritmos = entrega rápida com qualidade

Com uma boa bateria de testes e uma base de algoritmos podemos deixar este algoritmo mais eficiente, mais simples e consequentemente menos duplicação de código.

Criando uma lista de Tokens e também fazendo algumas modificações gerais na função calculate somos capazes de fazer os Unit Tests passarem:

def calculate(expression):
    tokens = []
    tmp = ''
    for c in expression:
        if __is_number(c):
            tmp += c
        else:
            if c == '+' or c == '-':
                tokens.append(int(tmp))
                tokens.append(c)
                tmp = ''
    if tmp:
        tokens.append(int(tmp))
    result = None
    operator = None
    for item in tokens:
        if item == '+' or item == '-':
            operator = item
            continue
        if result is None:
            result = item
            continue
        if operator == '+':
            result += item
        if operator == '-':
            result -= item
    return result

Porém este código, apesar de fazer todos os testes acima passarem, ainda não está ideal. Primeiro pela dificuldade de no futuro adicionar novas operações, mas principalmente pelo fato de termos algumas duplicações de código. 

Sendo assim, é importante fazermos algumas mudanças para eliminar o código duplicado e facilitar possíveis inclusões de novas operações:

def calculate(expression):
    tokens = []
    tmp = ''
    operators = {'+': __sum_operator, '-': __subtraction_operator}
    for c in expression:
        if __is_number(c):
            tmp += c
        else:
            if c in operators:
                tokens.append(int(tmp))
                tokens.append(c)
                tmp = ''
    if tmp:
        tokens.append(int(tmp))
    result = None
    operator = None
    for item in tokens:
        if item in operators:
            operator = item
            continue
        if result is None:
            result = item
            continue
        if operator in operators:
            result = operators[operator](result, item)
    return result

def __sum_operator(n1, n2):
    return n1 + n2

def __subtraction_operator(n1, n2):
    return n1 - n2

Com o código acima, nós somos capazes de adicionar um novo operador com mais facilidade, de modo que a gente consiga expandir o código mudando em pouquíssimas partes do mesmo.

Como tempo é um fator importante, vamos entregar este código e aprender mais sobre o problema para fazer melhorias futuras.

Quando a necessidade gera uma alteração maior

Em outro momento do projeto, o cliente resolve adicionar uma operação um pouco mais complexa como multiplicação. 

Digo complexas pois, ela possui prioridade na resolução em relação a soma e subtração, o que faz com que toda a lógica precise ser alterada de modo a simplificar o processo.

Somente este novo requisito, gera a necessidade de fazermos diversas mudanças no nosso projeto.

Desta forma, farei algumas alterações base no nosso projeto de forma iterativa onde:

1- Criaremos um objeto para tratar cada operação matemática, mantendo o que é comum no objeto base de extração dos dados

class SumOperator:
    def calc(self, n1, n2):
        return n1 + n2

class SubOperator:
    def calc(self, n1, n2):
        return n1 - n2

class MulOperator:
    def calc(self, n1, n2):
        return n1 * n2

2- A ordem de execução destes objetos será definida ao adicionar a prioridade da execução do operador antes da execução da análise

def calculate(expression):
    calc = Calculator()
    calc.add(0, '*', MulOperator())
    calc.add(1, '+', SumOperator())
    calc.add(1, '-', SubOperator())
    return calc.run(expression)

3- Utilizaremos todos os casos de testes gerados atualmente para garantir a integridade do projeto ao longo das mudanças e criaremos novos testes

class SimpleCalculator(unittest.TestCase):
    def test_simple_sum_return_result(self):
        self.assertEqual(2, calculate('1+1'))

    def test_sum_and_subtraction(self):
        self.assertEqual(1, calculate('1+1-1'))
        self.assertEqual(0, calculate('2+3-4+5-6'))

    def test_multiply_numbers(self):
        self.assertEqual(4, calculate('2*2'))

    def test_sum_and_multiplication(self):
        self.assertEqual(8, calculate('4+2*2'))

    def test_sum_sub_and_mul_operations(self):
        self.assertEqual(1991, calculate('1*2-9+8-10+250*8'))

Agora somos capazes de implementar nossa classe principal, já com suporte a multiplicação utilizando uma abordagem um pouco diferente, simples e muito eficiente.

O nosso algoritmo irá seguir a seguinte ideia de execução:

Dado que temos a expressão '2+3-4+5-6', iremos tokenizar esta expressão dentro de uma lista para preparar para manipulação:

Passo 1: Transformar a expressão em tokens:

Expressao = '2+3-4+5-6'
Tokens = [2, '+', 3, '-', 4, '+', 5, '-', 6]

Passo 2: Buscar por operadores de acordo com a prioridade atual e eliminar o item anterior e posterior na lista de tokens (definindo para valor nulo), substituindo o último pelo valor da operação

# Primeira alteração

Tokens = [None, None, 5, '-', 4, '+', 5, '-', 6]

# Segunda alteração

Tokens = [None, None, None, None, 1, '+', 5, '-', 6]

# Terceira alteração

Tokens = [None, None, None, None, None, None, 6, '-', 6]

# Quarta alteração

Tokens = [None, None, None, None, None, None, None, None, 0]

Passo 3: Eliminar todos os itens com valores nulos (None)

Tokens = [0] # Valor final

Passo 4: Fazer uma soma dos valores restantes

Sendo assim, temos o seguinte código final (após algum refactoring basico):

class Calculator:
    def __init__(self):
        self._operators = {}
        self._operators_list = []

    def add(self, priority, operator, obj):
        if priority not in self._operators:
            self._operators[priority] = {}
        self._operators[priority][operator] = obj
        self._operators_list.append(operator)

    def run(self, expression):
        tokens = self.__tokenize(expression)
        for priority in self._operators.keys():
            tokens = self.__solve(priority, tokens)
        return sum(tokens)

    def __tokenize(self, expression):
        tokens = []
        tmp = ''
        for c in expression:
            if self.__is_number(c):
                tmp += c
            else:
                if c in self._operators_list:
                    tokens.append(int(tmp))
                    tokens.append(c)
                    tmp = ''
        if tmp:
            tokens.append(int(tmp))
        return tokens

    def __solve(self, priority, tokens):
        for index, element in enumerate(tokens):
            if element in self._operators[priority]:
                n1 = tokens[index - 1]
                n2 = tokens[index + 1]
                tokens[index + 1] = self._operators[priority][element].calc(n1, n2)
                tokens[index - 1] = None
                tokens[index] = None
        return [item for item in tokens if item is not None]

    def __is_number(self, c):
        try:
            int(c)
            return True
        except ValueError:
            return False

class SumOperator:
    def calc(self, n1, n2):
        return n1 + n2

class SubOperator:
    def calc(self, n1, n2):
        return n1 - n2

class MulOperator:
    def calc(self, n1, n2):
        return n1 * n2

def calculate(expression):
    calc = Calculator()
    calc.add(0, '*', MulOperator())
    calc.add(1, '+', SumOperator())
    calc.add(1, '-', SubOperator())
    return calc.run(expression)

E  no fim, o cliente se soltou

Apesar de agora já entendermos a direção dos requisitos, agora o cliente resolveu adicionar outras operações como divisão e potenciação. Porém, com as últimas melhorias que fizemos no projeto a facilidade de adicionar estas novas operações é muito simples.

Temos apenas que fazer duas alterações, a primeira é criar uma classe que é responsável por executar a operação do novo operador e depois adiciona-la com a prioridade correta no código.

Mas claro, antes de tudo precisamos adicionar novos testes que exponham a falta do novo código. Assim garantimos que, após nossas mudanças, nada quebrou e tudo está pronto para ser entregue ao cliente:

def test_div_operations_simple(self):
    self.assertEqual(2, calculate('10/5'))
def test_div_operations_more_priority_than_sum_and_sub(self):
    self.assertEqual(1, calculate('1+10/2-5'))

E agora as mudanças que devemos criar no nosso código final, primeiro a criação das nova classe de divisão:

class DivOperator:
    def calc(self, n1, n2):
        return n1 // n2

E por fim, vamos adicionar este novo objeto na nossa função de calculo:

def calculate(expression):
    calc = Calculator()
    calc.add(0, '/', DivOperator())
    calc.add(0, '*', MulOperator())
    calc.add(1, '+', SumOperator())
    calc.add(1, '-', SubOperator())
    return calc.run(expression)

Pronto, agora ao executar nossos testes. A mágica acontecerá e todos os testes estão passando. Muito mais simples a expansão do código, não?

Ah sim, temos que fazer a exponenciação!!!? Olha como é simples:

Novos testes:

    def test_exponentiation_operations(self):
        self.assertEqual(4, calculate('2^2'))
    def test_exponentiation_with_multioperations(self):
        self.assertEqual(8, calculate('2^2*2'))

Novo objeto da operação:

class ExpOperator:
    def calc(self, n1, n2):
        return n1 ** n2

Adicionando ele na lista de operações com a prioridade correta:

def calculate(expression):
    calc = Calculator()
    calc.add(0, '/', DivOperator())
    calc.add(0, '*', MulOperator())
    calc.add(0, '^', ExpOperator())
    calc.add(2, '+', SumOperator())
    calc.add(2, '-', SubOperator())
    return calc.run(expression)

Veja que as melhorias vão sendo feitas de formas incrementais, de modo que o entendimento do problema vai evoluindo ao longo do tempo e o código também. Você não precisa prever todas as possibilidades logo de cara, talvez o código mais simples atenda e faça o cliente já ver valor e aprender com o que tem.

Entenda que a abordagem usada faz com que nosso código seja cada vez mais simples de ser alterado e expandido, ou seja, a velocidade de entrega aumenta e a complexidade vai sendo mantida sob controle.