terça-feira, 27 de setembro de 2022

Como fazer refatoração de codigo usando Python

❝Depois de ter tocado uma grande quantidade de notas e mais notas, é a simplicidade que surge como a recompensa máxima da arte.❞
Frédéric Chopin

Mergulhando

Goste ou não, bugs acontecem. Apesar de seus melhores esforços para escrever testes de unidade abrangentes, bugs acontecem. O que quero dizer com “bug”? Um bug é um caso de teste que você ainda não escreveu.


>>> import roman7
>>> roman7.from_roman('') ①
0
  • ① Este é um erro. Uma string vazia deve gerar uma exceção InvalidRomanNumeralError, assim como qualquer outra sequência de caracteres que não representa um numeral romano válido.

Após reproduzir o bug, e antes de corrigi-lo, você deve escrever um caso de teste que falhe, ilustrando assim o bug.


class FromRomanBadInput(unittest.TestCase):  
    .
    .
    .
    def testBlank(self):
        '''from_roman should fail with blank string'''
        self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, '') ①
① Coisas bem simples aqui. Chame from_roman() com uma string vazia e verifique se ela gera uma exceção InvalidRomanNumeralError. A parte difícil foi encontrar o bug; agora que você sabe sobre isso, testá-lo é a parte mais fácil.

Como seu código tem um bug e agora você tem um caso de teste que testa esse bug, o caso de teste falhará:

you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v
from_roman should fail with blank string ... FAIL
from_roman should fail with malformed antecedents ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

======================================================================
FAIL: from_roman should fail with blank string
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest8.py", line 117, in test_blank
    self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, '')
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 11 tests in 0.171s

FAILED (failures=1)

Agora você pode corrigir o bug.


def from_roman(s):
    '''convert Roman numeral to integer'''
    if not s:                                                                  ①
        raise InvalidRomanNumeralError('Input can not be blank')
    if not re.search(romanNumeralPattern, s):
        raise InvalidRomanNumeralError('Invalid Roman numeral: {}'.format(s))  ②

    result = 0
    index = 0
    for numeral, integer in romanNumeralMap:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result
  • ① Apenas duas linhas de código são necessárias: uma verificação explícita de uma string vazia e uma instrução raise.
  • ② Acho que ainda não mencionei isso em nenhum lugar deste livro, então deixe isso servir como sua lição final sobre formatação de strings. A partir do Python 3.1, você pode pular os números ao usar índices posicionais em um especificador de formato. Ou seja, em vez de usar o especificador de formato {0} para se referir ao primeiro parâmetro do método format(), você pode simplesmente usar {} e o Python preencherá o índice posicional adequado para você. Isso funciona para qualquer número de argumentos; o primeiro {} é {0}, o segundo {} é {1}, e assim por diante.
you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v
from_roman should fail with blank string ... ok  ①
from_roman should fail with malformed antecedents ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 11 tests in 0.156s

OK  ②
  • ① O caso de teste de string em branco agora passa, então o bug foi corrigido.
  • ② Todos os outros casos de teste ainda passam, o que significa que essa correção de bug não quebrou mais nada. Pare de codificar.

Codificar dessa maneira não facilita a correção de bugs. Bugs simples (como este) requerem casos de teste simples; bugs complexos exigirão casos de teste complexos. Em um ambiente centrado em testes, pode parecer que leva mais tempo para corrigir um bug, já que você precisa articular no código exatamente o que é o bug (para escrever o caso de teste) e, em seguida, corrigir o próprio bug. Então, se o caso de teste não passar imediatamente, você precisa descobrir se a correção estava errada ou se o próprio caso de teste tem um bug nele. No entanto, a longo prazo, esse vai-e-vem entre o código de teste e o código testado se paga, porque torna mais provável que os bugs sejam corrigidos corretamente na primeira vez. Além disso, como você pode facilmente executar novamente todos os casos de teste junto com o novo, é muito menos provável que você quebre o código antigo ao corrigir o novo. O teste de unidade de hoje é o teste de regressão de amanhã.

Manipulação de Requisitos de Alteração

Apesar de seus melhores esforços para prender seus clientes no chão e extrair os requisitos exatos deles sob pena de coisas horríveis e desagradáveis envolvendo tesoura e cera quente, os requisitos mudarão. A maioria dos clientes não sabe o que quer até vê-lo e, mesmo que saiba, não é tão bom em articular o que quer com precisão suficiente para ser útil. E mesmo se o fizerem, eles vão querer mais no próximo lançamento de qualquer maneira. Portanto, esteja preparado para atualizar seus casos de teste à medida que os requisitos mudam.

Suponha, por exemplo, que você queira expandir o intervalo das funções de conversão de numeral romano. Normalmente, nenhum caractere em um numeral romano pode ser repetido mais de três vezes seguidas. Mas os romanos estavam dispostos a abrir uma exceção a essa regra, tendo 4 caracteres M seguidos para representar 4000. Se você fizer essa alteração, poderá expandir o intervalo de números conversíveis de 1..3999 para 1..4999. Mas primeiro, você precisa fazer algumas alterações em seus casos de teste.


class KnownValues(unittest.TestCase):
  known_values = ( (1, 'I'),
                    .
                    .
                    .
                   (3999, 'MMMCMXCIX'),
                   (4000, 'MMMM'),                                      ①
                   (4500, 'MMMMD'),
                   (4888, 'MMMMDCCCLXXXVIII'),
                   (4999, 'MMMMCMXCIX') )

class ToRomanBadInput(unittest.TestCase):
  def test_too_large(self):
      '''to_roman should fail with large input'''
      self.assertRaises(roman8.OutOfRangeError, roman8.to_roman, 5000)  ②

  .
  .
  .

class FromRomanBadInput(unittest.TestCase):
  def test_too_many_repeated_numerals(self):
      '''from_roman should fail with too many repeated numerals'''
      for s in ('MMMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):     ③
          self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, s)

  .
  .
  .

class RoundtripCheck(unittest.TestCase):
  def test_roundtrip(self):
      '''from_roman(to_roman(n))==n for all n'''
      for integer in range(1, 5000):                                    ④
          numeral = roman8.to_roman(integer)
          result = roman8.from_roman(numeral)
          self.assertEqual(integer, result)
  • ① Os valores conhecidos existentes não mudam (todos ainda são valores razoáveis para testar), mas você precisa adicionar mais alguns no intervalo 4000. Aqui eu incluí 4000 (o mais curto), 4500 (o segundo mais curto), 4888 (o mais longo) e 4999 (o maior).
  • ② A definição de “grande entrada” mudou. Esse teste costumava chamar to_roman() e esperar 4000 um erro; agora que 4000-4999 são bons valores, você precisa aumentar isso para 5000.
  • ③ A definição de “muitos numerais repetidos” também mudou. Esse teste costumava chamar from_roman() e esperar 'MMMM' um erro; agora que MMMM é considerado um numeral romano válido, você precisa aumentar isso para 'MMMMM'.
  • ④ A verificação de sanidade percorre todos os números no intervalo, de 1 a 3999. Como o intervalo agora foi expandido, esse loop for também precisa ser atualizado para ir até 4999.

Agora seus casos de teste estão atualizados com os novos requisitos, mas seu código não está, então você espera que vários dos casos de teste falhem.

you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ERROR          ①
to_roman should give known result with known input ... ERROR            ②
from_roman(to_roman(n))==n for all n ... ERROR                          ③
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

======================================================================
ERROR: from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 82, in test_from_roman_known_values
    result = roman9.from_roman(numeral)
  File "C:\home\diveintopython3\examples\roman9.py", line 60, in from_roman
    raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
roman9.InvalidRomanNumeralError: Invalid Roman numeral: MMMM

======================================================================
ERROR: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 76, in test_to_roman_known_values
    result = roman9.to_roman(integer)
  File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman
    raise OutOfRangeError('number out of range (must be 0..3999)')
roman9.OutOfRangeError: number out of range (must be 0..3999)

======================================================================
ERROR: from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest9.py", line 131, in testSanity
    numeral = roman9.to_roman(integer)
  File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman
    raise OutOfRangeError('number out of range (must be 0..3999)')
roman9.OutOfRangeError: number out of range (must be 0..3999)

----------------------------------------------------------------------
Ran 12 tests in 0.171s

FAILED (errors=3)
  • ① O teste from_roman() de valores conhecidos falhará assim que atingir 'MMMM', porque from_roman() ainda acha que este é um numeral romano inválido.
  • ② O teste to_roman() de valores conhecidos falhará assim que atingir 4000, porque to_roman() ainda acha que isso está fora do intervalo.
  • ③ A verificação de ida e volta também falhará assim que atingir 4000, porque to_roman() ainda acha que isso está fora do alcance.

Agora que você tem casos de teste que falham devido aos novos requisitos, você pode pensar em corrigir o código para alinhá-lo com os casos de teste. (Quando você começa a codificar testes de unidade, pode parecer estranho que o código que está sendo testado nunca esteja “à frente” dos casos de teste. casos, você para de codificar. Depois de se acostumar, você vai se perguntar como você programou sem testes.)


roman_numeral_pattern = re.compile('''
    ^                   # beginning of string
    M{0,4}              # thousands - 0 to 4 Ms  ①
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
                        #            or 500-800 (D, followed by 0 to 3 Cs)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
                        #        or 50-80 (L, followed by 0 to 3 Xs)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
                        #        or 5-8 (V, followed by 0 to 3 Is)
    $                   # end of string
    ''', re.VERBOSE)

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not isinstance(n, int):
        raise NotIntegerError('non-integers can not be converted')
    if not (0 < n < 5000):                        ②
        raise OutOfRangeError('number out of range (must be 1..4999)')

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result

def from_roman(s):
    .
    .
    .
  • ① Você não precisa fazer nenhuma alteração na função from_roman(). A única mudança é para roman_numeral_pattern. Se você observar atentamente, notará que alterei o número máximo de caracteres M opcionais de 3 para 4 na primeira seção da expressão regular. Isso permitirá que os números romanos equivalentes de 4999 em vez de 3999. A função from_roman() real é completamente genérica; ela apenas procura por caracteres numéricos romanos repetidos e os soma, sem se importar com quantas vezes eles se repetem. A única razão pela qual ele não lidou com 'MMMM' antes é que você o interrompeu explicitamente com a correspondência de padrões de expressão regular.
  • ② A função to_roman() precisa apenas de uma pequena alteração, na verificação de intervalo. Onde você costumava checar 0 < n < 4000, agora você checa 0 < n < 5000. E você altera a mensagem de erro raise para refletir o novo intervalo aceitável (1..4999 em vez de 1..3999). Você não precisa fazer nenhuma alteração no restante da função; ele já lida com os novos casos. (Ele adiciona alegremente 'M' para cada mil que encontra; dado 4000, ele cuspirá 'MMMM'. A única razão pela qual não fez isso antes é que você o interrompeu explicitamente com a verificação de intervalo.)

Você pode estar cético de que essas duas pequenas mudanças são tudo o que você precisa. Ei, não acredite na minha palavra; Veja por si mesmo.

you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 0.203s

OK  ①
  • ① Todos os casos de teste passam. Pare de codificar.

Testes unitários abrangentes significam nunca ter que confiar em um programador que diz “Confie em mim”.

Refatoração

A melhor coisa sobre o teste de unidade abrangente não é a sensação que você tem quando todos os seus casos de teste finalmente passam, ou mesmo a sensação que você tem quando alguém o culpa por quebrar seu código e você pode realmente provar que não o fez. A melhor coisa sobre o teste de unidade é que ele lhe dá a liberdade de refatorar impiedosamente.

A refatoração é o processo de pegar o código de trabalho e fazê-lo funcionar melhor. Normalmente, “melhor” significa “mais rápido”, embora também possa significar “usando menos memória”, ou “usando menos espaço em disco”, ou simplesmente “com mais elegância”. Seja o que for que isso signifique para você, para seu projeto, em seu ambiente, a refatoração é importante para a saúde de longo prazo de qualquer programa.

Aqui, “melhor” significa “mais rápido” e “mais fácil de manter”. Especificamente, a função from_roman() é mais lenta e mais complexa do que eu gostaria, por causa daquela grande expressão regular desagradável que você usa para validar numerais romanos. Agora, você pode pensar: “Claro, a expressão regular é grande e cabeluda, mas de que outra forma eu posso validar que uma string arbitrária é um numeral romano válido?”

Resposta: há apenas 5.000 deles; por que você não cria uma tabela de pesquisa? Essa ideia fica ainda melhor quando você percebe que não precisa usar expressões regulares. À medida que você cria a tabela de pesquisa para converter números inteiros em numerais romanos, você pode construir a tabela de pesquisa inversa para converter numerais romanos em números inteiros. Quando você precisar verificar se uma string arbitrária é um numeral romano válido, você terá coletado todos os algarismos romanos válidos. A “validação” é reduzida a uma única pesquisa de dicionário.

E o melhor de tudo, você já tem um conjunto completo de testes unitários. Você pode alterar mais da metade do código no módulo, mas os testes de unidade permanecerão os mesmos. Isso significa que você pode provar – para si mesmo e para os outros – que o novo código funciona tão bem quanto o original.


class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass
class InvalidRomanNumeralError(ValueError): pass

roman_numeral_map = (('M',  1000),
                     ('CM', 900),
                     ('D',  500),
                     ('CD', 400),
                     ('C',  100),
                     ('XC', 90),
                     ('L',  50),
                     ('XL', 40),
                     ('X',  10),
                     ('IX', 9),
                     ('V',  5),
                     ('IV', 4),
                     ('I',  1))

to_roman_table = [ None ]
from_roman_table = {}

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]

def build_lookup_tables():
    def to_roman(n):
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)
        to_roman_table.append(roman_numeral)
        from_roman_table[roman_numeral] = integer

build_lookup_tables()

Vamos quebrar isso em pedaços digeríveis. Indiscutivelmente, a linha mais importante é a última:

build_lookup_tables()

Você notará que é uma chamada de função, mas não há nenhuma instrução if em torno dela. Este não é um bloco if __name__ == '__main__'; ele é chamado quando o módulo é importado. (É importante entender que os módulos são importados apenas uma vez e, em seguida, armazenados em cache. Se você importar um módulo já importado, ele não fará nada. Portanto, esse código só será chamado na primeira vez que você importar este módulo.)

Então, o que a função build_lookup_tables() faz? Estou feliz que você perguntou.


to_roman_table = [ None ]
from_roman_table = {}
.
.
.
def build_lookup_tables():
    def to_roman(n):                                ①
        result = ''
        for numeral, integer in roman_numeral_map:
            if n >= integer:
                result = numeral
                n -= integer
                break
        if n > 0:
            result += to_roman_table[n]
        return result

    for integer in range(1, 5000):
        roman_numeral = to_roman(integer)          ②
        to_roman_table.append(roman_numeral)       ③
        from_roman_table[roman_numeral] = integer
  • ① Esta é uma programação inteligente... talvez inteligente demais. A função to_roman() é definida acima; ela procura valores na tabela de pesquisa e os retorna. Mas a função build_lookup_tables() redefine a função to_roman() para realmente funcionar (como os exemplos anteriores fizeram, antes de você adicionar uma tabela de pesquisa). Dentro da função build_lookup_tables(), chamará to_roman() esta versão redefinida. Uma vez que a função build_lookup_tables() é encerrada, a versão redefinida desaparece — ela é definida apenas no escopo local da função build_lookup_tables().
  • ② Essa linha de código chamará a função to_roman() redefinida, que na verdade calcula o numeral romano.
  • ③ Depois de obter o resultado (da função redefinida to_roman()), você adiciona o inteiro e seu equivalente em numeral romano a ambas as tabelas de pesquisa.

Depois que as tabelas de pesquisa são criadas, o restante do código é fácil e rápido.


def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 5000):
        raise OutOfRangeError('number out of range (must be 1..4999)')
    if int(n) != n:
        raise NotIntegerError('non-integers can not be converted')
    return to_roman_table[n]                                            ①

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not isinstance(s, str):
        raise InvalidRomanNumeralError('Input must be a string')
    if not s:
        raise InvalidRomanNumeralError('Input can not be blank')
    if s not in from_roman_table:
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
    return from_roman_table[s]                                          ②
  • ① Depois de fazer a mesma verificação de limites de antes, a função to_roman() simplesmente encontra o valor apropriado na tabela de pesquisa e o retorna.
  • ② Da mesma forma, a função from_roman() é reduzida a algumas verificações de limites e uma linha de código. Não há mais expressões regulares. Não há mais looping. O(1) conversão de e para algarismos romanos.

Mas isso funciona? Por que sim, sim ele faz. E eu posso provar isso.

you@localhost:~/diveintopython3/examples$ python3 romantest10.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 12 tests in 0.031s                                                  ①

OK
  • ① Não que você tenha pedido, mas é rápido também! Tipo, quase 10 vezes mais rápido. Claro, não é uma comparação totalmente justa, porque esta versão demora mais para importar (quando constrói as tabelas de pesquisa). Mas como a importação é feita apenas uma vez, o custo de inicialização é amortizado sobre todas as chamadas para as funções to_roman() e from_roman(). Como os testes fazem vários milhares de chamadas de função (somente o teste de ida e volta faz 10.000), essa economia aumenta rapidamente!

A moral da história?

  • A simplicidade é uma virtude.
  • Especialmente quando expressões regulares estão envolvidas.
  • Os testes de unidade podem dar a você a confiança para fazer refatoração em larga escala.

Resumo

O teste unitário é um conceito poderoso que, se implementado corretamente, pode reduzir os custos de manutenção e aumentar a flexibilidade em qualquer projeto de longo prazo. Também é importante entender que o teste de unidade não é uma panacéia, um Magic Problem Solver ou uma bala de prata. Escrever bons casos de teste é difícil, e mantê-los atualizados exige disciplina (especialmente quando os clientes estão gritando por correções críticas de bugs). O teste de unidade não substitui outras formas de teste, incluindo testes funcionais, testes de integração e testes de aceitação do usuário. Mas é viável, e funciona, e uma vez que você o tenha visto funcionar, você se perguntará como conseguiu viver sem ele.

Esses poucos capítulos cobriram muito terreno, e muitos deles nem eram específicos do Python. Existem estruturas de teste de unidade para muitas linguagens, todas as quais exigem que você entenda os mesmos conceitos básicos:

  • Projetar casos de teste específicos, automatizados e independentes
  • Escrevendo casos de teste antes do código que estão testando
  • Escrevendo testes que testam uma boa entrada e verificam os resultados adequados
  • Escrevendo testes que testam entradas incorretas e verificam as respostas de falhas adequadas
  • Escrevendo e atualizando casos de teste para refletir novos requisitos
  • Refatorando impiedosamente para melhorar o desempenho, escalabilidade, legibilidade, manutenibilidade ou qualquer outra -ilidade que esteja faltando

Traduzido por Acervo Lima. O original pode ser acessado aqui.

sábado, 24 de setembro de 2022

Como fazer testes unitários com Python

A certeza não é o teste da certeza. Temos sido convencidos de muitas coisas que não eram assim.
Oliver Wendell Holmes Jr.

(Não) Mergulhando

Crianças hoje. Tão estragado por esses computadores rápidos e linguagens “dinâmicas” sofisticadas. Escreva primeiro, envie em segundo, depure em terceiro (se alguma vez). Na minha época, tínhamos disciplina. Disciplina, eu digo! Tínhamos que escrever programas à mão, no papel, e alimentá-los no computador em cartões perfurados. E nós gostavamos!

Neste capítulo, você escreverá e depurará um conjunto de funções utilitárias para converter para algarismos romanos. Você viu a mecânica de construção e validação de algarismos romanos em “Estudo de caso: algarismos romanos”. Agora dê um passo para trás e considere o que seria necessário para expandir isso em um utilitário de mão dupla.

As regras para algarismos romanos levam a uma série de observações interessantes:

  1. Existe apenas uma maneira correta de representar um número específico como um numeral romano.
  2. O inverso também é verdadeiro: se uma sequência de caracteres for um numeral romano válido, ela representa apenas um número (ou seja, só pode ser interpretada de uma maneira).
  3. Há um intervalo limitado de números que podem ser expressos como algarismos romanos, especificamente de 1 a 3999. Os romanos tinham várias maneiras de expressar números maiores, por exemplo, colocando uma barra sobre um numeral para representar que seu valor normal deveria ser multiplicado por 1000. Para os propósitos deste capítulo, vamos estipular que os algarismos romanos vão de 1 até 3999.
  4. Não há como representar 0 em algarismos romanos.
  5. Não há como representar números negativos em algarismos romanos.
  6. Não há como representar frações ou números não inteiros em algarismos romanos.

Vamos começar a mapear o que um módulo roman.py deve fazer. Ele terá duas funções principais, to_roman() e from_roman(). A função to_roman() deve receber um inteiro de 1 ate 3999 e retornar a representação de numeral romano como uma string…

Pare aí mesmo. Agora vamos fazer algo um pouco inesperado: escrever um caso de teste que verifique se a função to_roman() faz o que você quer. Você leu certo: você vai escrever um código que testa o código que você ainda não escreveu.

Isso é chamado de desenvolvimento orientado a testes, ou TDD. O conjunto de duas funções de conversão —  to_roman(), e posteriores from_roman() — pode ser escrito e testado como uma unidade, separada de qualquer programa maior que as importe. Python tem uma estrutura para teste de unidade, o módulo unittest com o nome apropriado.

O teste de unidade é uma parte importante de uma estratégia geral de desenvolvimento centrada em testes. Se você escrever testes de unidade, é importante escrevê-los antecipadamente e mantê-los atualizados à medida que o código e os requisitos mudam. Muitas pessoas defendem a escrita de testes antes de escreverem o código que estão testando, e esse é o estilo que vou demonstrar neste capítulo. Mas os testes de unidade são benéficos, não importa quando você os escreve.

  • Antes de escrever código, escrever testes de unidade força você a detalhar seus requisitos de maneira útil.
  • Ao escrever o código, os testes de unidade evitam que você codifique demais. Quando todos os casos de teste forem aprovados, a função estará concluída.
  • Ao refatorar o código, eles podem ajudar a provar que a nova versão se comporta da mesma maneira que a versão antiga.
  • Ao manter o código, fazer testes o ajudará a se proteger quando alguém vier gritando que sua última alteração quebrou o código antigo. (“Mas senhor, todos os testes de unidade passaram quando eu fiz o check-in...”)
  • Ao escrever código em uma equipe, ter um conjunto de testes abrangente diminui drasticamente as chances de seu código quebrar o código de outra pessoa, porque você pode executar seus testes de unidade primeiro. (Já vi esse tipo de coisa em sprints de código. Uma equipe divide a tarefa, todos pegam as especificações de sua tarefa, escrevem testes de unidade para ela e depois compartilham seus testes de unidade com o resto da equipe. Dessa forma, ninguém vai longe demais no desenvolvimento de código que não funciona bem com os outros.)

Uma única pergunta

Um caso de teste responde a uma única pergunta sobre o código que está testando. Um caso de teste deve ser capaz de ser...

  • ...executado completamente sozinho, sem qualquer intervenção humana. O teste de unidade é sobre automação.
  • ...determinar por si só se a função que está testando passou ou falhou, sem um humano interpretando os resultados.
  • ...executar isoladamente, separado de quaisquer outros casos de teste (mesmo que testem as mesmas funções). Cada caso de teste é uma ilha.

Dado isso, vamos construir um caso de teste para o primeiro requisito:

  1. A função to_roman() deve retornar a representação de numeral romano para todos os inteiros de 1 ate 3999.

Não é imediatamente óbvio como esse código funciona... bem, qualquer coisa. Ele define uma classe que não tem o método __init__(). A classe tem outro método, mas nunca é chamada. O script inteiro tem um bloco __main__, mas não faz referência à classe ou ao seu método. Mas faz alguma coisa, eu prometo.


import roman1
import unittest

class KnownValues(unittest.TestCase):               ①
    known_values = ( (1, 'I'),
                     (2, 'II'),
                     (3, 'III'),
                     (4, 'IV'),
                     (5, 'V'),
                     (6, 'VI'),
                     (7, 'VII'),
                     (8, 'VIII'),
                     (9, 'IX'),
                     (10, 'X'),
                     (50, 'L'),
                     (100, 'C'),
                     (500, 'D'),
                     (1000, 'M'),
                     (31, 'XXXI'),
                     (148, 'CXLVIII'),
                     (294, 'CCXCIV'),
                     (312, 'CCCXII'),
                     (421, 'CDXXI'),
                     (528, 'DXXVIII'),
                     (621, 'DCXXI'),
                     (782, 'DCCLXXXII'),
                     (870, 'DCCCLXX'),
                     (941, 'CMXLI'),
                     (1043, 'MXLIII'),
                     (1110, 'MCX'),
                     (1226, 'MCCXXVI'),
                     (1301, 'MCCCI'),
                     (1485, 'MCDLXXXV'),
                     (1509, 'MDIX'),
                     (1607, 'MDCVII'),
                     (1754, 'MDCCLIV'),
                     (1832, 'MDCCCXXXII'),
                     (1993, 'MCMXCIII'),
                     (2074, 'MMLXXIV'),
                     (2152, 'MMCLII'),
                     (2212, 'MMCCXII'),
                     (2343, 'MMCCCXLIII'),
                     (2499, 'MMCDXCIX'),
                     (2574, 'MMDLXXIV'),
                     (2646, 'MMDCXLVI'),
                     (2723, 'MMDCCXXIII'),
                     (2892, 'MMDCCCXCII'),
                     (2975, 'MMCMLXXV'),
                     (3051, 'MMMLI'),
                     (3185, 'MMMCLXXXV'),
                     (3250, 'MMMCCL'),
                     (3313, 'MMMCCCXIII'),
                     (3408, 'MMMCDVIII'),
                     (3501, 'MMMDI'),
                     (3610, 'MMMDCX'),
                     (3743, 'MMMDCCXLIII'),
                     (3844, 'MMMDCCCXLIV'),
                     (3888, 'MMMDCCCLXXXVIII'),
                     (3940, 'MMMCMXL'),
                     (3999, 'MMMCMXCIX'))           ②

    def test_to_roman_known_values(self):           ③
        '''to_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman1.to_roman(integer)       ④
            self.assertEqual(numeral, result)       ⑤

if __name__ == '__main__':
    unittest.main()
  
  • ① Para escrever um caso de teste, primeiro subclasse a classe TestCase do módulo unittest. Esta classe fornece muitos métodos úteis que você pode usar em seu caso de teste para testar condições específicas.
  • ② Esta é uma tupla de pares inteiros/numerais que verifiquei manualmente. Inclui os dez números mais baixos, o número mais alto, cada número que se traduz em um numeral romano de um caractere e uma amostragem aleatória de outros números válidos. Você não precisa testar todas as entradas possíveis, mas deve tentar testar todos os casos extremos óbvios.
  • ③ Cada teste individual é seu próprio método. Um método de teste não aceita parâmetros, não retorna nenhum valor e deve ter um nome começando com as quatro letras test. Se um método de teste sair normalmente sem gerar uma exceção, o teste será considerado aprovado; se o método gerar uma exceção, o teste será considerado com falha.
  • ④ Aqui você chama a função to_roman() real. (Bem, a função ainda não foi escrita, mas assim que for, esta é a linha que a chamará.) Observe que você definiu agora a API para a função to_roman(): ela deve receber um inteiro (o número a ser convertido) e retornar uma string (a representação em numeral romano). Se a API for diferente disso, este teste é considerado reprovado. Observe também que você não está interceptando nenhuma exceção ao chamar to_roman(). Isso é intencional. to_roman() não deve gerar uma exceção quando você o chama com entrada válida e esses valores de entrada são todos válidos. Se to_roman() gerar uma exceção, este teste é considerado reprovado.
  • ⑤ Assumindo que a função to_roman() foi definida corretamente, chamada corretamente, concluída com sucesso e retornou um valor, a última etapa é verificar se ela retornou o valor correto. Essa é uma pergunta comum, e a classe TestCase fornece um método, assertEqual, para verificar se dois valores são iguais. Se o resultado retornado de to_roman() (result) não corresponder ao valor conhecido que você esperava (numeral), assertEqual vai gerar uma exceção e o teste falhará. Se os dois valores forem iguais, assertEqual não fará nada. Se cada valor retornado de to_roman() corresponder ao valor conhecido que você espera, assertEqual nunca gera uma exceção, então test_to_roman_known_values eventualmente sai normalmente, o que significa que a função to_roman() passou neste teste.

Depois de ter um caso de teste, você pode começar a codificar a função to_roman(). Primeiro, você deve encerrá-lo como uma função vazia e certificar-se de que os testes falhem. Se os testes forem bem-sucedidos antes de você escrever qualquer código, seus testes não estão testando seu código! O teste de unidade é uma dança: os testes conduzem, o código segue. Escreva um teste que falhe, então codifique até que ele passe.


# roman1.py

def to_roman(n):
    '''convert integer to Roman numeral'''
    pass                                   ①
  
  • ① Neste estágio, você deseja definir a API da função to_roman(), mas ainda não deseja codificá-la. (Seu teste precisa falhar primeiro.) Para stub-lo, use a palavra reservada pass do Python, que não faz exatamente nada.

Execute romantest1.py na linha de comando para executar o teste. Se você chamá-lo com a opção -v de linha de comando, ele fornecerá uma saída mais detalhada para que você possa ver exatamente o que está acontecendo à medida que cada caso de teste é executado. Com alguma sorte, sua saída deve ficar assim:

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)                      ①
to_roman should give known result with known input ... FAIL            ②

======================================================================
FAIL: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest1.py", line 73, in test_to_roman_known_values
    self.assertEqual(numeral, result)
AssertionError: 'I' != None                                            ③

----------------------------------------------------------------------
Ran 1 test in 0.016s                                                   ④

FAILED (failures=1)                                                    ⑤
  • ① A execução do script executa unittest.main(), que executa cada caso de teste. Cada caso de teste é um método dentro de uma classe no romantest.py. Não há organização obrigatória dessas classes de teste; cada um deles pode conter um único método de teste ou você pode ter uma classe que contém vários métodos de teste. O único requisito é que cada classe de teste deve herdar de unittest.TestCase.
  • ② Para cada caso de teste, o módulo unittest imprimirá a docstring do método e se esse teste foi aprovado ou reprovado. Como esperado, este caso de teste falha.
  • ③ Para cada caso de teste com falha, unittest exibe as informações de rastreamento mostrando exatamente o que aconteceu. Nesse caso, a chamada para assertEqual() levantou um AssertionError porque esperava que to_roman(1) retornasse 'I', mas não retornou. (Como não havia uma instrução de retorno explícita, a função retornou None, o valor nulo do Python.)
  • ④ Após o detalhamento de cada teste, unittest exibe um resumo de quantos testes foram realizados e quanto tempo levou.
  • ⑤ No geral, a execução de teste falhou porque pelo menos um caso de teste não foi aprovado. Quando um caso de teste não passa, unittest distingue entre falhas e erros. Uma falha é uma chamada para um método assertXYZ, como assertEqual ou assertRaises, que falha porque a condição declarada não é verdadeira ou a exceção esperada não foi gerada. Um erro é qualquer outro tipo de exceção gerado no código que você está testando ou no próprio caso de teste de unidade.

Agora, finalmente, você pode escrever a função to_roman().


roman_numeral_map = (('M',  1000),
    ('CM', 900),
    ('D',  500),
    ('CD', 400),
    ('C',  100),
    ('XC', 90),
    ('L',  50),
    ('XL', 40),
    ('X',  10),
    ('IX', 9),
    ('V',  5),
    ('IV', 4),
    ('I',  1))                 ①

def to_roman(n):
    '''convert integer to Roman numeral'''
    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:                     ②
            result += numeral
            n -= integer
    return result
  
  • roman_numeral_map é uma tupla de tuplas que define três coisas: as representações de caracteres dos numerais romanos mais básicos; a ordem dos algarismos romanos (em ordem decrescente de valor, de M ate todo o caminho até I); o valor de cada numeral romano. Cada tupla interna é um par de (numeral, value). Não são apenas algarismos romanos de um caractere; também define pares de dois caracteres como CM (“cem menos que mil”). Isso torna o código da função to_roman() mais simples.
  • ② Aqui é onde a rica estrutura de dados de roman_numeral_map compensa, porque você não precisa de nenhuma lógica especial para lidar com a regra de subtração. Para converter para algarismos romanos, basta iterar roman_numeral_map procurando o maior valor inteiro menor ou igual à entrada. Uma vez encontrado, adicione a representação em numeral romano ao final da saída, subtraia o valor inteiro correspondente da entrada, ensaboe, enxágue, repita.

Se você ainda não entendeu como a função to_roman() funciona, adicione uma chamada print() ao final do loop while:


while n >= integer:
    result += numeral
    n -= integer
    print('subtracting {0} from input, adding {1} to output'.format(integer, numeral))
  

Com as instruções de depuração print(), a saída se parece com isso:


>>> import roman1
>>> roman1.to_roman(1424)
subtracting 1000 from input, adding M to output
subtracting 400 from input, adding CD to output
subtracting 10 from input, adding X to output
subtracting 10 from input, adding X to output
subtracting 4 from input, adding IV to output
'MCDXXIV'
  

Portanto, a função to_roman() parece funcionar, pelo menos nesta verificação manual. Mas ele passará no caso de teste que você escreveu?

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok               ①

----------------------------------------------------------------------
Ran 1 test in 0.016s

OK
  
  • ① Viva! A função to_roman() passa no caso de teste de “valores conhecidos”. Não é abrangente, mas coloca a função em prática com uma variedade de entradas, incluindo entradas que produzem todos os algarismos romanos de um único caractere, a maior entrada possível (3999) e a entrada que produz o maior número romano possível (3888). Neste ponto, você pode estar razoavelmente confiante de que a função funciona para qualquer bom valor de entrada que você possa lançar nela.

Entrada “boa”? Hum. E a entrada ruim?

“Pare e pegue fogo”

Não é suficiente testar se as funções são bem-sucedidas quando recebem uma boa entrada; você também deve testar se eles falham quando recebem uma entrada incorreta. E não qualquer tipo de fracasso; eles devem falhar da maneira que você espera.


>>> import roman1
>>> roman1.to_roman(4000)
'MMMM'
>>> roman1.to_roman(5000)
'MMMMM'
>>> roman1.to_roman(9000)  ①
'MMMMMMMMM'
  
  • ① Isso definitivamente não é o que você queria - isso nem é um numeral romano válido! Na verdade, cada um desses números está fora do intervalo de entrada aceitável, mas a função retorna um valor falso de qualquer maneira. Retornar valores ruins silenciosamente é ruim; se um programa vai falhar, é muito melhor se ele falhar rápida e ruidosamente. “Pare e pegue fogo”, como diz o ditado. A maneira Pythonic de parar e pegar fogo é gerar uma exceção.

A pergunta a se fazer é: “Como posso expressar isso como um requisito testável?” Como é isso para iniciantes:

A função to_roman() deve gerar um OutOfRangeError quando dado um número inteiro maior que 3999.

Como seria esse teste?


import unittest, roman2
class ToRomanBadInput(unittest.TestCase):                                 ①
    def test_too_large(self):                                             ②
        '''to_roman should fail with large input'''
        self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)  ③
  
  • ① Como no caso de teste anterior, você cria uma classe que herda de unittest.TestCase. Você pode ter mais de um teste por classe (como você verá mais adiante neste capítulo), mas optei por criar uma nova classe aqui porque este teste é algo diferente do anterior. Manteremos todos os bons testes de entrada juntos em uma classe e todos os testes de entrada ruins juntos em outra.
  • ② Como no caso de teste anterior, o teste em si é um método da classe, com um nome começando com test.
  • ③ A classe unittest.TestCase fornece o método assertRaises, que recebe os seguintes argumentos: a exceção que você espera, a função que você está testando e os argumentos que você está passando para essa função. (Se a função que você está testando receber mais de um argumento, passe-os todos para assertRaises, em ordem, e ele os passará diretamente para a função que você está testando.)

Preste muita atenção a esta última linha de código. Em vez de chamar to_roman() direta e manualmente verificando se ele gera uma exceção específica (envolvendo-a em um bloco try...except), o método assertRaises encapsula tudo isso para nós. Tudo o que você faz é dizer qual exceção você está esperando (roman2.OutOfRangeError), a função (to_roman()) e os argumentos da função (4000). O método assertRaises se encarrega de chamar to_roman() e verificar se ele levanta roman2.OutOfRangeError.

Observe também que você está passando a própria função to_roman() como um argumento; você não está chamando e não está passando o nome dela como uma string. Mencionei recentemente como é útil que tudo em Python seja um objeto?

Então, o que acontece quando você executa o conjunto de testes com esse novo teste?

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ERROR                         ①

======================================================================
ERROR: to_roman should fail with large input                          
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest2.py", line 78, in test_too_large
    self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AttributeError: 'module' object has no attribute 'OutOfRangeError'      ②

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (errors=1)
  
  • ① Você deveria ter esperado que isso falhasse (já que você ainda não escreveu nenhum código para passá-lo), mas... na verdade não “falhou”, teve um “erro”. Esta é uma distinção sutil, mas importante. Na verdade, um teste de unidade tem três valores de retorno: aprovado, reprovado e erro. Aprovar, é claro, significa que o teste foi aprovado – o código fez o que você esperava. “Fail” é o que o caso de teste anterior fez (até que você escreveu o código para fazê-lo passar) – ele executou o código, mas o resultado não foi o que você esperava. “Erro” significa que o código nem foi executado corretamente.
  • ② Por que o código não foi executado corretamente? O traceback diz tudo. O módulo que você está testando não tem uma exceção chamada OutOfRangeError. Lembre-se, você passou essa exceção para o método assertRaises(), porque é a exceção que você deseja que a função levante com uma entrada fora do intervalo. Mas a exceção não existe, então a chamada para o método assertRaises() falhou. Ele nunca teve a chance de testar a função to_roman(); não foi tão longe.

Para resolver esse problema, você precisa definir a exceção OutOfRangeError no roman2.py.


class OutOfRangeError(ValueError):  ①
    pass                            ②
  
  • ① As exceções são as classes. Um erro “fora do intervalo” é um tipo de erro de valor — o valor do argumento está fora de seu intervalo aceitável. Portanto, essa exceção herda da exceção interna ValueError. Isso não é estritamente necessário (pode apenas herdar da classe base Exception), mas parece certo.
  • ② As exceções não fazem nada, mas você precisa de pelo menos uma linha de código para criar uma classe. A chamada pass não faz exatamente nada, mas é uma linha de código Python, então isso a torna uma classe.

Agora execute o conjunto de testes novamente.

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... FAIL                          ①

======================================================================
FAIL: to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest2.py", line 78, in test_too_large
    self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AssertionError: OutOfRangeError not raised by to_roman                 ②

----------------------------------------------------------------------
Ran 2 tests in 0.016s

FAILED (failures=1)
  
  • ① O novo teste ainda não está passando, mas também não está retornando um erro. Em vez disso, o teste está falhando. Isso é progresso! Isso significa que a chamada para o método assertRaises() foi bem-sucedida desta vez e a estrutura de teste de unidade realmente testou a função to_roman().
  • ② Claro, a função to_roman() não está levantando a exceção OutOfRangeError que você acabou de definir, porque você ainda não disse para fazer isso. Isso é uma excelente notícia! Isso significa que este é um caso de teste válido - ele falha antes de você escrever o código para fazê-lo passar.

Agora você pode escrever o código para fazer este teste passar.


def to_roman(n):
    '''convert integer to Roman numeral'''
    if n > 3999:
        raise OutOfRangeError('number out of range (must be less than 4000)')  ①

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  
  • ① Isso é direto: se a entrada fornecida (n) for maior que 3999, lance uma exceção OutOfRangeError. O teste de unidade não verifica a string legível que acompanha a exceção, embora você possa escrever outro teste que a verifique (mas fique atento a problemas de internacionalização para strings que variam de acordo com o idioma ou ambiente do usuário).

Isso faz o teste passar? Vamos descobrir.

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok                            ①

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
  
  • ① Viva! Ambos os testes passam. Como você trabalhou iterativamente, alternando entre o teste e a codificação, você pode ter certeza de que as duas linhas de código que você acabou de escrever foram a causa daquele teste que passou de “falha” para “aprovado”. Esse tipo de confiança não sai barato, mas se pagará durante a vida útil do seu código.

Mais Parada, Mais Fogo

Além de testar números muito grandes, você precisa testar números muito pequenos. Como observamos em nossos requisitos funcionais, os algarismos romanos não podem expressar 0 ou números negativos.


>>> import roman2
>>> roman2.to_roman(0)
''
>>> roman2.to_roman(-1)
''
  

Bem, isso não é bom. Vamos adicionar testes para cada uma dessas condições.


class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 4000)  ①

    def test_zero(self):
        '''to_roman should fail with 0 input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)     ②

    def test_negative(self):
        '''to_roman should fail with negative input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)    ③
  
  • ① O método test_too_large() não mudou desde a etapa anterior. Estou incluindo aqui para mostrar onde o novo código se encaixa.
  • ② Aqui está um novo teste: o método test_zero(). Assim como o método test_too_large(), ele diz ao método assertRaises() definido em unittest.TestCase para chamar nossa função to_roman() com um parâmetro de 0 e verificar se ele gera a exceção apropriada, OutOfRangeError.
  • ③ O método test_negative() é quase idêntico, exceto que passa -1 para a função to_roman(). Se um desses novos testes não gerar um OutOfRangeError (seja porque a função retorna um valor real ou porque gera alguma outra exceção), o teste é considerado com falha.

Agora verifique se os testes falham:

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... FAIL

======================================================================
FAIL: to_roman should fail with negative input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 86, in test_negative
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)
AssertionError: OutOfRangeError not raised by to_roman

======================================================================
FAIL: to_roman should fail with 0 input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 82, in test_zero
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)
AssertionError: OutOfRangeError not raised by to_roman

----------------------------------------------------------------------
Ran 4 tests in 0.000s

FAILED (failures=2)
  

Excelente. Ambos os testes falharam, como esperado. Agora vamos passar para o código e ver o que podemos fazer para que eles passem.


def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 4000):                                              ①
        raise OutOfRangeError('number out of range (must be 1..3999)')  ②

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  
  • ① Este é um bom atalho Pythonic: várias comparações ao mesmo tempo. Isso é equivalente a if not ((0 < n) and (n < 4000)), mas é muito mais fácil de ler. Essa linha de código deve capturar entradas muito grandes, negativas ou zero.
  • ② Se você alterar suas condições, certifique-se de atualizar suas strings de erro legíveis para que correspondam. A estrutura unittest não se importará, mas dificultará a depuração manual se seu código estiver lançando exceções descritas incorretamente.

Eu poderia mostrar a você uma série inteira de exemplos não relacionados para mostrar que o atalho de várias comparações de uma vez funciona, mas em vez disso, vou apenas executar os testes de unidade e provar isso.

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.016s

OK
  

E mais uma coisa…

Havia mais um requisito funcional para converter números em algarismos romanos: lidar com números não inteiros.


>>> import roman3
>>> roman3.to_roman(0.5)  ①
''
>>> roman3.to_roman(1.0)  ②
'I'
  
  • ① Oh, isso é ruim.
  • ② Ah, isso é ainda pior. Ambos os casos devem levantar uma exceção. Em vez disso, eles dão resultados falsos.

O teste para não inteiros não é difícil. Primeiro, defina uma exceção NotIntegerError.


# roman4.py
class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass
  

Em seguida, escreva um caso de teste que verifique a exceção NotIntegerError.


class ToRomanBadInput(unittest.TestCase):
    .
    .
    .
    def test_non_integer(self):
        '''to_roman should fail with non-integer input'''
        self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)
  

Agora verifique se o teste falha corretamente.

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

======================================================================
FAIL: to_roman should fail with non-integer input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest4.py", line 90, in test_non_integer
    self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)
AssertionError: NotIntegerError not raised by to_roman

----------------------------------------------------------------------
Ran 5 tests in 0.000s

FAILED (failures=1)
  

Escreva o código que faz o teste passar.


def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 4000):
        raise OutOfRangeError('number out of range (must be 1..3999)')
    if not isinstance(n, int):                                          ①
        raise NotIntegerError('non-integers can not be converted')      ②

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  
  • ① A função isinstance() interna testa se uma variável é um tipo específico (ou, tecnicamente, qualquer tipo descendente).
  • ② Se o argumento n não for um int, levante nossa exceção NotIntegerError recém-criada.

Finalmente, verifique se o código realmente faz o teste passar.

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK
  

A função to_roman() passa em todos os seus testes e não consigo pensar em mais nenhum teste, então é hora de passar para a função from_roman().

Uma Simetria Agradável

Converter uma string de um numeral romano para um inteiro parece mais difícil do que converter um inteiro para um numeral romano. Certamente há a questão da validação. É fácil verificar se um inteiro é maior que 0, mas um pouco mais difícil verificar se uma string é um numeral romano válido. Mas já construímos uma expressão regular para verificar numerais romanos, então essa parte está feita.

Isso deixa o problema de converter a própria string. Como veremos em um minuto, graças à rica estrutura de dados que definimos para mapear numerais romanos individuais para valores inteiros, o âmago da função from_roman() é tão direto quanto a função to_roman().

Mas primeiro, os testes. Precisaremos de um teste de “valores conhecidos” para verificar a precisão. Nosso conjunto de testes já contém um mapeamento de valores conhecidos; vamos reutilizar isso.


    def test_from_roman_known_values(self):
        '''from_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)
  

Há uma simetria agradável aqui. As funções to_roman() e from_roman() são inversas uma da outra. O primeiro converte inteiros em strings especialmente formatados, o segundo converte strings especialmente formatados em inteiros. Em teoria, deveríamos ser capazes de “retornar” um número passando para a função to_roman() para obter uma string, depois passando essa string para a função from_roman() para obter um número inteiro e terminar com o mesmo número.


n = from_roman(to_roman(n)) for all values of n

Nesse caso, “todos os valores” significa qualquer número entre 1..3999, pois esse é o intervalo válido de entradas para a função to_roman(). Podemos expressar essa simetria em um caso de teste que percorre todos os valores 1..3999, fazendo chamadas para to_roman(), from_roman() e verifica se a saída é igual à entrada original.


class RoundtripCheck(unittest.TestCase):
    def test_roundtrip(self):
        '''from_roman(to_roman(n))==n for all n'''
        for integer in range(1, 4000):
            numeral = roman5.to_roman(integer)
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)
  

Esses novos testes ainda nem falharão. Nós ainda não definimos uma função from_roman(), então eles apenas irão gerar erros.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
E.E....
======================================================================
ERROR: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 78, in test_from_roman_known_values
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

======================================================================
ERROR: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 103, in test_roundtrip
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

----------------------------------------------------------------------
Ran 7 tests in 0.019s

FAILED (errors=2)

Uma função de stub rápida resolverá esse problema.


# roman5.py
def from_roman(s):
    '''convert Roman numeral to integer'''
  

(Ei, você notou isso? Eu defini uma função com nada além de uma docstring. Isso é Python legal. Na verdade, alguns programadores juram por isso. “Não stub; document!”)

Agora, os casos de teste realmente falharão.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
F.F....
======================================================================
FAIL: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 79, in test_from_roman_known_values
    self.assertEqual(integer, result)
AssertionError: 1 != None

======================================================================
FAIL: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 104, in test_roundtrip
    self.assertEqual(integer, result)
AssertionError: 1 != None

----------------------------------------------------------------------
Ran 7 tests in 0.002s

FAILED (failures=2)
  

Agora é hora de escrever a função from_roman().


def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:  ①
            result += integer
            index += len(numeral)
    return result
  
  • ① O padrão aqui é o mesmo que a função to_roman(). Você itera através de sua estrutura de dados de numeral romano (uma tupla de tuplas), mas em vez de corresponder os valores inteiros mais altos com a maior frequência possível, você corresponde às cadeias de caracteres de numeral romano “mais alto” com a maior frequência possível.

Se você não está claro como funciona from_roman(), adicione uma instrução print ao final do loop while:


def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
            print('found', numeral, 'of length', len(numeral), ', adding', integer)
  

>>> import roman5
>>> roman5.from_roman('MCMLXXII')
found M of length 1, adding 1000
found CM of length 2, adding 900
found L of length 1, adding 50
found X of length 1, adding 10
found X of length 1, adding 10
found I of length 1, adding 1
found I of length 1, adding 1
1972
  

Hora de refazer os testes.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
.......
----------------------------------------------------------------------
Ran 7 tests in 0.060s

OK
  

Duas notícias interessantes aqui. A primeira é que a função from_roman() funciona para uma boa entrada, pelo menos para todos os valores conhecidos. A segunda é que o teste de “ida e volta” também passou. Combinado com os testes de valores conhecidos, você pode estar razoavelmente certo de que as funções to_roman() e from_roman() funcionam corretamente para todos os valores válidos possíveis. (Isso não é garantido; é teoricamente possível que to_roman() tenha um bug que produza o numeral romano errado para algum conjunto específico de entradas, e que from_roman() tenha um bug recíproco que produza os mesmos valores inteiros errados para exatamente esse conjunto de algarismos romanos que to_roman() tenha gerado incorretamente. Dependendo da sua aplicação e dos seus requisitos, essa possibilidade pode incomodá-lo; em caso afirmativo, escreva casos de teste mais abrangentes até que isso não o incomode.)

Mais entradas incorretas

Agora que a função from_roman() funciona corretamente com uma boa entrada, é hora de encaixar a última peça do quebra-cabeça: fazê-la funcionar corretamente com uma entrada ruim. Isso significa encontrar uma maneira de olhar para uma string e determinar se é um numeral romano válido. Isso é inerentemente mais difícil do que validar a entrada numérica na função to_roman(), mas você tem uma ferramenta poderosa à sua disposição: expressões regulares. (Se você não estiver familiarizado com expressões regulares, agora seria um bom momento para ler o capítulo sobre expressões regulares em python.)

Como você viu em Estudo de caso: algarismos romanos, existem várias regras simples para construir um numeral romano, usando as letras M, D, C, L, X, V e I. Vamos rever as regras:

  • Às vezes, os caracteres são aditivos. I é 1, II é 2 e III é 3. VI é 6 (literalmente, “5 e 1”), VII é 7, e VIII é 8.
  • Os caracteres de dezenas (I, X, C e M) podem ser repetidos até três vezes. Em 4, você precisa subtrair do próximo caractere cinco mais alto. Você não pode representar 4 como IIII; em vez disso, é representado como IV (“1 menor que 5”). 40 é escrito como XL (“10 menor que 50”), 41 como XLI, 42 como XLII, 43 como XLIII e depois 44 como XLIV (“10 menor que 50, então 1 menor que 5”).
  • Às vezes os caracteres são... o oposto de aditivos. Ao colocar certos caracteres antes de outros, você subtrai do valor final. Por exemplo, em 9, você precisa subtrair do próximo caractere de dezena mais alto: 8 é VIII, mas 9 é IX (“1 menor que 10”), não VIIII(já que o caractere I não pode ser repetido quatro vezes). 90 é XC, 900 é CM.
  • Os cinco caracteres não podem ser repetidos. 10 é sempre representado como X, nunca como VV. 100 é sempre C, nunca LL.
  • Os algarismos romanos são lidos da esquerda para a direita, então a ordem dos caracteres importa muito. DC é 600; CD é um número completamente diferente (400, “100 menor que 500”). CI é 101; IC nem é um numeral romano válido (porque você não pode subtrair 1 diretamente de 100; você precisaria escrevê-lo como XCIX, “10 menor que 100, então 1 menor que 10”).

Assim, um teste útil seria garantir que a função from_roman() falhe quando você passar uma string com muitos numerais repetidos. Quanto é "muitos" depende do numeral.


class FromRomanBadInput(unittest.TestCase):
    def test_too_many_repeated_numerals(self):
        '''from_roman should fail with too many repeated numerals'''
        for s in ('MMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
  

Outro teste útil seria verificar se certos padrões não se repetem. Por exemplo, IX é 9, mas IXIX nunca é válido.


    def test_repeated_pairs(self):
        '''from_roman should fail with repeated pairs of numerals'''
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
  

Um terceiro teste poderia verificar se os numerais aparecem na ordem correta, do maior para o menor valor. Por exemplo, CL é 150, mas LC nunca é válido, porque o numeral para 50 nunca pode vir antes do numeral para 100. Esse teste inclui um conjunto de antecedentes inválidos escolhidos aleatoriamente: I antes de M, V antes de X e assim por diante.


    def test_malformed_antecedents(self):
        '''from_roman should fail with malformed antecedents'''
        for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
                  'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
  

Cada um desses testes depende da função from_roman() que gera uma nova exceção, InvalidRomanNumeralError, que ainda não definimos.


# roman6.py
class InvalidRomanNumeralError(ValueError): pass
  

Todos esses três testes devem falhar, pois a função from_roman() atualmente não possui nenhuma verificação de validade. (Se eles não falharem agora, então o que diabos eles estão testando?)

you@localhost:~/diveintopython3/examples$ python3 romantest6.py
FFF.......
======================================================================
FAIL: test_malformed_antecedents (__main__.FromRomanBadInput)
from_roman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 113, in test_malformed_antecedents
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_repeated_pairs (__main__.FromRomanBadInput)
from_roman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 107, in test_repeated_pairs
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_too_many_repeated_numerals (__main__.FromRomanBadInput)
from_roman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 102, in test_too_many_repeated_numerals
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 10 tests in 0.058s

FAILED (failures=3)
  

Bom negócio. Agora, tudo o que precisamos fazer é adicionar a expressão regular para testar numerais romanos válidos na função from_roman().


roman_numeral_pattern = re.compile('''
    ^                   # beginning of string
    M{0,3}              # thousands - 0 to 3 Ms
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
                        #            or 500-800 (D, followed by 0 to 3 Cs)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
                        #        or 50-80 (L, followed by 0 to 3 Xs)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
                        #        or 5-8 (V, followed by 0 to 3 Is)
    $                   # end of string
    ''', re.VERBOSE)

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not roman_numeral_pattern.search(s):
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))

    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index : index + len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result
  

E refaça os testes...

you@localhost:~/diveintopython3/examples$ python3 romantest7.py
..........
----------------------------------------------------------------------
Ran 10 tests in 0.066s

OK
  

E o prêmio anticlímax do ano vai para… a palavra “OK”, que é impressa pelo módulo unittest quando todas os testes passam.

Traduzido por Acervo Lima. O original pode ser acessado aqui.

segunda-feira, 11 de julho de 2022

Iteradores avançados em Python

❝ Grandes pulgas têm pequenas pulgas nas costas para mordê-las, E as pulgas pequenas têm pulgas menores, e assim ad infinitum. ❞
— Augusto De Morgan

Mergulho

Assim como expressões regulares colocam esteróides em strings, o módulo itertools coloca esteróides nos iteradores. Mas primeiro, quero mostrar um quebra-cabeça clássico.

HAWAII + IDAHO + IOWA + OHIO == STATES
510199 + 98153 + 9301 + 3593 == 621246

H = 5
A = 1
W = 0
I = 9
D = 8
O = 3
S = 6
T = 2
E = 4

Quebra-cabeças como esse são chamados de criptaritmos ou alfaméticos. As letras formam palavras reais, mas se você substituir cada letra por um dígito de 0 a 9, ela também “soletra” uma equação aritmética. O truque é descobrir qual letra mapeia para cada dígito. Todas as ocorrências de cada letra devem ser mapeadas para o mesmo dígito, nenhum dígito pode ser repetido e nenhuma “palavra” pode começar com o dígito 0.

Neste capítulo, vamos mergulhar em um incrível programa Python originalmente escrito por Raymond Hettinger. Este programa resolve quebra-cabeças alfaméticos em apenas 14 linhas de código.

O quebra-cabeça alfamético mais conhecido é SEND + MORE = MONEY.

import re
import itertools

def solve(puzzle):
    words = re.findall('[A-Z]+', puzzle.upper())
    unique_characters = set(''.join(words))
    assert len(unique_characters) <= 10, 'Too many letters'
    first_letters = {word[0] for word in words}
    n = len(first_letters)
    sorted_characters = ''.join(first_letters) + \
        ''.join(unique_characters - first_letters)
    characters = tuple(ord(c) for c in sorted_characters)
    digits = tuple(ord(c) for c in '0123456789')
    zero = digits[0]
    for guess in itertools.permutations(digits, len(characters)):
        if zero not in guess[:n]:
            equation = puzzle.translate(dict(zip(characters, guess)))
            if eval(equation):
                return equation

if __name__ == '__main__':
    import sys
    for puzzle in sys.argv[1:]:
        print(puzzle)
        solution = solve(puzzle)
        if solution:
            print(solution)

Você pode executar o programa a partir da linha de comando. No Linux, ficaria assim. (Isso pode levar algum tempo, dependendo da velocidade do seu computador, e não há barra de progresso. Apenas seja paciente!)

you@localhost:~/diveintopython3/examples$ python3 alphametics.py "HAWAII + IDAHO + IOWA + OHIO == STATES"
HAWAII + IDAHO + IOWA + OHIO = STATES
510199 + 98153 + 9301 + 3593 == 621246
you@localhost:~/diveintopython3/examples$ python3 alphametics.py "I + LOVE + YOU == DORA"
I + LOVE + YOU == DORA
1 + 2784 + 975 == 3760
you@localhost:~/diveintopython3/examples$ python3 alphametics.py "SEND + MORE == MONEY"
SEND + MORE == MONEY
9567 + 1085 == 10652

Encontrando todas as ocorrências de um padrão

A primeira coisa que esse solucionador alfamético faz é encontrar todas as letras (A–Z) no quebra-cabeça.

>>> import re
>>> re.findall('[0-9]+', '16 2-by-4s in rows of 8')  ①
['16', '2', '4', '8']
>>> re.findall('[A-Z]+', 'SEND + MORE == MONEY')     ②
['SEND', 'MORE', 'MONEY']
  1. O módulo re é a implementação de expressões regulares do Python. Ele tem uma função bacana chamada findall() que pega um padrão de expressão regular e uma string e encontra todas as ocorrências do padrão dentro da string. Nesse caso, o padrão corresponde a sequências de números. A função findall() retorna uma lista de todas as substrings que corresponderam ao padrão.
  2. Aqui o padrão de expressão regular corresponde a sequências de letras. Novamente, o valor de retorno é uma lista e cada item na lista é uma string que corresponde ao padrão de expressão regular.

Aqui está outro exemplo que vai esticar um pouco o seu cérebro.

>>> re.findall(' s.*? s', "The sixth sick sheikh's sixth sheep's sick.")
[' sixth s', " sheikh's s", " sheep's s"]

Surpreso? A expressão regular procura um espaço, um s e, em seguida, a série mais curta possível de qualquer caractere (.*?), um espaço e outro s. Bem, olhando para essa string de entrada, vejo cinco correspondências:

  1. The sixth sick sheikh's sixth sheep's sick.
  2. The sixth sick sheikh's sixth sheep's sick.
  3. The sixth sick sheikh's sixth sheep's sick.
  4. The sixth sick sheikh's sixth sheep's sick.
  5. The sixth sick sheikh's sixth sheep's sick.

Este é o trava-línguas mais difícil do idioma inglês.

Mas a função re.findall() retornou apenas três correspondências. Especificamente, ele retornou o primeiro, o terceiro e o quinto. Por que isso? Porque não retorna correspondências sobrepostas. A primeira correspondência se sobrepõe à segunda, de modo que a primeira é retornada e a segunda é ignorada. Em seguida, o terceiro se sobrepõe a quarta, de modo que a terceira é retornada e a quarta é ignorada. Finalmente, a quinta é retornada. Três corresponências, não cinco.

Isso não tem nada a ver com o solucionador alfamético; Eu só pensei que isso era interessante.

Encontrando os itens únicos em uma lista do Python

Os conjuntos do python tornam trivial encontrar os itens exclusivos em uma lista do python.


>>> a_list = ['The', 'sixth', 'sick', "sheik's", 'sixth', "sheep's", 'sick']
>>> set(a_list)                      ①
{'sixth', 'The', "sheep's", 'sick', "sheik's"}
>>> a_string = 'EAST IS EAST'
>>> set(a_string)                    ②
{'A', ' ', 'E', 'I', 'S', 'T'}
>>> words = ['SEND', 'MORE', 'MONEY']
>>> ''.join(words)                   ③
'SENDMOREMONEY'
>>> set(''.join(words))              ④
{'E', 'D', 'M', 'O', 'N', 'S', 'R', 'Y'}
  1. Dada uma lista de várias strings, a função set() retornará um conjunto de strings únicas da lista. Isso faz sentido se você pensar nisso como um loop for. Pegue o primeiro item da lista, coloque-o no conjunto. Segundo. Terceiro. Quarto. Quinto – espere, isso já está no conjunto, então só é listado uma vez, porque os conjuntos do Python não permitem duplicatas. Sexto. Sétimo – novamente, uma duplicata, então só é listada uma vez. O resultado final? Todos os itens exclusivos da lista original, sem duplicatas. A lista original nem precisa ser classificada primeiro.
  2. A mesma técnica funciona com strings, pois uma string é apenas uma sequência de caracteres.
  3. Dada uma lista de strings, ''.join(a_list) concatena todas as strings em uma.
  4. Assim, dada uma lista de strings, esta linha de código retorna todos os caracteres únicos em todas as strings, sem duplicatas.

O solucionador alfamético usa essa técnica para construir um conjunto de todos os caracteres únicos do quebra-cabeça.

unique_characters = set(''.join(words))

Essa lista é usada posteriormente para atribuir dígitos a caracteres à medida que o solucionador percorre as soluções possíveis.

Fazendo afirmações

Como muitas linguagens de programação, Python tem uma declaração assert. Aqui está como funciona.

>>> assert 1 + 1 == 2                                     ①
>>> assert 1 + 1 == 3                                     ②
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError
>>> assert 2 + 2 == 5, "Only for very large values of 2"  ③
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError: Only for very large values of 2
  1. A declaração assert é seguida por qualquer expressão Python válida. Nesse caso, a expressão 1 + 1 == 2 é avaliada como True, portanto, a instrução assert não faz nada.
  2. No entanto, se a expressão Python for avaliada como False, a instrução assert gerará um AssertionError.
  3. Você também pode incluir uma mensagem legível que é impressa se o AssertionError for gerado.

Portanto, esta linha de código:

assert len(unique_characters) <= 10, 'Too many letters'

…é equivalente a isso:

if len(unique_characters) > 10:
   raise AssertionError('Too many letters')

O solucionador alfamético usa essa declaração assert exata para sair mais cedo se o quebra-cabeça contiver mais de dez letras únicas. Como a cada letra é atribuído um dígito único e existem apenas dez dígitos, um quebra-cabeça com mais de dez letras únicas não pode ter uma solução.

Expressões do gerador

Uma expressão geradora é como uma função geradora sem a função.

>>> unique_characters = {'E', 'D', 'M', 'O', 'N', 'S', 'R', 'Y'}
>>> gen = (ord(c) for c in unique_characters)  ①
>>> gen                                        ②
<generator object <genexpr> at 0x00BADC10>
>>> next(gen)                                  ③
69
>>> next(gen)
68
>>> tuple(ord(c) for c in unique_characters)   ④
(69, 68, 77, 79, 78, 83, 82, 89)
    1. Uma expressão geradora é como uma função anônima que produz valores. A expressão em si parece uma compreensão de lista, mas está entre parênteses em vez de colchetes.
    2. A expressão geradora retorna… um iterador.
    3. Chamar next(gen) retorna o próximo valor do iterador.
    4. Se desejar, você pode percorrer todos os valores possíveis e retornar uma tupla, lista ou conjunto, passando a expressão geradora para tuple(), list() ou set(). Nesses casos, você não precisa de um conjunto extra de parênteses — apenas passe a expressão “nua” ord(c) para c em unique_characters para a função tuple(), e o Python descobre que é uma expressão geradora.

Observação

Usar uma expressão geradora em vez de uma compreensão de lista pode economizar CPU e RAM. Se você está construindo uma lista apenas para jogá-la fora (por exemplo, passando para tuple() ou set()), use uma expressão geradora!

Aqui está outra maneira de fazer a mesma coisa, usando uma função de gerador:

def ord_map(a_string):
    for c in a_string:
        yield ord(c)

gen = ord_map(unique_characters)

A expressão do gerador é mais compacta, mas funcionalmente equivalente.

Calculando Permutações… A Maneira Preguiçosa!

Em primeiro lugar, o que diabos são permutações? Permutações são um conceito matemático. (Na verdade, existem várias definições, dependendo do tipo de matemática que você está fazendo. Aqui estou falando de combinatória, mas se isso não significa nada para você, não se preocupe. Como sempre, a Wikipedia é sua amigo.)

A ideia é que você pegue uma lista de coisas (podem ser números, podem ser letras, podem ser ursos dançantes) e encontrar todas as maneiras possíveis de dividi-las em listas menores. Todas as listas menores têm o mesmo tamanho, que pode ser tão pequeno quanto 1 e tão grande quanto o número total de itens. Ah, e nada pode ser repetido. Os matemáticos dizem coisas como “vamos encontrar as permutações de 3 itens diferentes tomados 2 de cada vez”, o que significa que você tem uma sequência de 3 itens e deseja encontrar todos os pares ordenados possíveis.

>>> import itertools                              ①
>>> perms = itertools.permutations([1, 2, 3], 2)  ②
>>> next(perms)                                   ③
(1, 2)
>>> next(perms)
(1, 3)
>>> next(perms)
(2, 1)                                            ④
>>> next(perms)
(2, 3)
>>> next(perms)
(3, 1)
>>> next(perms)
(3, 2)
>>> next(perms)                                   ⑤
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
  1. O módulo itertools tem todos os tipos de coisas divertidas nele, incluindo uma função permutations() que faz todo o trabalho duro de encontrar permutações.
  2. A função permutations() recebe uma sequência (aqui uma lista de três inteiros) e um número, que é o número de itens que você deseja em cada grupo menor. A função retorna um iterador, que você pode usar em um loop for ou em qualquer lugar antigo que itere. Aqui vou percorrer o iterador manualmente para mostrar todos os valores.
  3. A primeira permutação de [1, 2, 3] pegando 2 de cada vez é (1, 2).
  4. Observe que as permutações são ordenadas: (2, 1) é diferente de (1, 2).
  5. É isso! Essas são todas as permutações de [1, 2, 3] pegando 2 de cada vez. Pares como (1, 1) e (2, 2) nunca aparecem, porque contêm repetições, então não são permutações válidas. Quando não há mais permutações, o iterador gera uma exceção StopIteration.

A função permutations() não precisa receber uma lista. Pode levar qualquer sequência – até mesmo uma string.

>>> import itertools
>>> perms = itertools.permutations('ABC', 3)  ①
>>> next(perms)
('A', 'B', 'C')                               ②
>>> next(perms)
('A', 'C', 'B')
>>> next(perms)
('B', 'A', 'C')
>>> next(perms)
('B', 'C', 'A')
>>> next(perms)
('C', 'A', 'B')
>>> next(perms)
('C', 'B', 'A')
>>> next(perms)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>> list(itertools.permutations('ABC', 3))    ③
[('A', 'B', 'C'), ('A', 'C', 'B'),
 ('B', 'A', 'C'), ('B', 'C', 'A'),
 ('C', 'A', 'B'), ('C', 'B', 'A')]

O módulo itertools tem todos os tipos de coisas divertidas.

  1. Uma string é apenas uma sequência de caracteres. Para fins de encontrar permutações, a string 'ABC' é equivalente à lista ['A', 'B', 'C'].
  2. A primeira permutação dos 3 itens ['A', 'B', 'C'], pegando 3 de cada vez, é ('A', 'B', 'C'). Existem cinco outras permutações – os mesmos três caracteres em todas as ordens concebíveis.
  3. Como a função permutations() sempre retorna um iterador, uma maneira fácil de depurar permutações é passar esse iterador para a função list() integrada para ver todas as permutações imediatamente.

Outras coisas divertidas no módulo itertools

>>> import itertools
>>> list(itertools.product('ABC', '123'))   ①
[('A', '1'), ('A', '2'), ('A', '3'), 
 ('B', '1'), ('B', '2'), ('B', '3'), 
 ('C', '1'), ('C', '2'), ('C', '3')]
>>> list(itertools.combinations('ABC', 2))  ②
[('A', 'B'), ('A', 'C'), ('B', 'C')]
  1. A função itertools.product() retorna um iterador contendo o produto cartesiano de duas sequências.
  2. A função itertools.combinations() retorna um iterador contendo todas as combinações possíveis da sequência dada do comprimento dado. É como a função itertools.permutations(), exceto que as combinações não incluem itens duplicados de outros itens em uma ordem diferente. Portanto, itertools.permutations('ABC', 2) retornará ('A', 'B') e ('B', 'A') (entre outros), mas itertools.combinations('ABC', 2) não retornará ('B', 'A') porque é uma duplicata de ('A', 'B') em uma ordem diferente.
>>> names = list(open('examples/favorite-people.txt', encoding='utf-8'))  ①
>>> names
['Dora\n', 'Ethan\n', 'Wesley\n', 'John\n', 'Anne\n',
'Mike\n', 'Chris\n', 'Sarah\n', 'Alex\n', 'Lizzie\n']
>>> names = [name.rstrip() for name in names]                             ②
>>> names
['Dora', 'Ethan', 'Wesley', 'John', 'Anne',
'Mike', 'Chris', 'Sarah', 'Alex', 'Lizzie']
>>> names = sorted(names)                                                 ③
>>> names
['Alex', 'Anne', 'Chris', 'Dora', 'Ethan',
'John', 'Lizzie', 'Mike', 'Sarah', 'Wesley']
>>> names = sorted(names, key=len)                                        ④
>>> names
['Alex', 'Anne', 'Dora', 'John', 'Mike',
'Chris', 'Ethan', 'Sarah', 'Lizzie', 'Wesley']
  1. Este idioma retorna uma lista das linhas em um arquivo de texto.
  2. Infelizmente (para este exemplo), o idioma list(open(filename)) também inclui os retornos de carro no final de cada linha. Essa compreensão de lista usa o método de string rstrip() para remover os espaços em branco à direita de cada linha. (Strings também têm um método lstrip() para remover o espaço em branco principal e um método strip() que remove ambos.)
  3. A função sorted() pega uma lista e a retorna ordenada. Por padrão, ele classifica em ordem alfabética.
  4. Mas a função sorted() também pode receber uma função como parâmetro de chave e classifica por essa chave. Nesse caso, a função de classificação é len(), então ela classifica por len(cada item). Nomes mais curtos vêm primeiro, depois mais longos e depois mais longos.

O que isso tem a ver com o módulo itertools? fico feliz que tenha perguntado.

…continuando do shell interativo anterior…

>>> import itertools
>>> groups = itertools.groupby(names, len)  ①
>>> groups
>itertools.groupby object at 0x00BB20C0>
>>> list(groups)
[(4, >itertools._grouper object at 0x00BA8BF0>),
 (5, >itertools._grouper object at 0x00BB4050>),
 (6, >itertools._grouper object at 0x00BB4030>)]
>>> groups = itertools.groupby(names, len)   ②
>>> for name_length, name_iter in groups:    ③
...     print('Names with {0:d} letters:'.format(name_length))
...     for name in name_iter:
...         print(name)
... 
Names with 4 letters:
Alex
Anne
Dora
John
Mike
Names with 5 letters:
Chris
Ethan
Sarah
Names with 6 letters:
Lizzie
Wesley
  1. A função itertools.groupby() recebe uma sequência e uma função de chave e retorna um iterador que gera pares. Cada par contém o resultado de key_function (cada item) e outro iterador contendo todos os itens que compartilharam esse resultado de chave.
  2. Chamar a função list() “esgotou” o iterador, ou seja, você já gerou todos os itens no iterador para fazer a lista. Não há botão “reset” em um iterador; você não pode simplesmente recomeçar depois de esgotar. Se você quiser fazer um loop novamente (digamos, no próximo loop for), você precisa chamar itertools.groupby() novamente para criar um novo iterador.
  3. Neste exemplo, dada uma lista de nomes já ordenados por comprimento, itertools.groupby(names, len) colocará todos os nomes de 4 letras em um iterador, todos os nomes de 5 letras em outro iterador e assim por diante. A função groupby() é completamente genérica; ele pode agrupar strings pela primeira letra, números por seu número de fatores ou qualquer outra função chave que você possa imaginar.

A função itertools.groupby() só funciona se a sequência de entrada já estiver ordenada pela função de agrupamento. No exemplo acima, você agrupou uma lista de nomes pela função len(). Isso só funcionou porque a lista de entrada já estava classificada por comprimento.

Você está observando de perto?

>>> list(range(0, 3))
[0, 1, 2]
>>> list(range(10, 13))
[10, 11, 12]
>>> list(itertools.chain(range(0, 3), range(10, 13)))        ①
[0, 1, 2, 10, 11, 12]
>>> list(zip(range(0, 3), range(10, 13)))                    ②
[(0, 10), (1, 11), (2, 12)]
>>> list(zip(range(0, 3), range(10, 14)))                    ③
[(0, 10), (1, 11), (2, 12)]
>>> list(itertools.zip_longest(range(0, 3), range(10, 14)))  ④
[(0, 10), (1, 11), (2, 12), (None, 13)]
  1. A função itertools.chain() recebe dois iteradores e retorna um iterador que contém todos os itens do primeiro iterador, seguidos por todos os itens do segundo iterador. (Na verdade, pode levar qualquer número de iteradores e encadeia todos eles na ordem em que foram passados para a função.)
  2. A função zip() faz algo prosaico que acaba sendo extremamente útil: pega qualquer número de sequências e retorna um iterador que retorna tuplas dos primeiros itens de cada sequência, depois os segundos itens de cada, depois o terceiro e assim por diante.
  3. A função zip() para no final da sequência mais curta. range(10, 14) tem 4 itens (10, 11, 12 e 13), mas range(0, 3) tem apenas 3, então a função zip() retorna um iterador de 3 itens.
  4. Por outro lado, a função itertools.zip_longest() para no final da sequência mais longa, inserindo valores None para itens após o final das sequências mais curtas.

OK, tudo isso foi muito interessante, mas como isso se relaciona com o solucionador alfamético? Veja como:

>>> characters = ('S', 'M', 'E', 'D', 'O', 'N', 'R', 'Y')
>>> guess = ('1', '2', '0', '3', '4', '5', '6', '7')
>>> tuple(zip(characters, guess))  ①
(('S', '1'), ('M', '2'), ('E', '0'), ('D', '3'),
 ('O', '4'), ('N', '5'), ('R', '6'), ('Y', '7'))
>>> dict(zip(characters, guess))   ②
{'E': '0', 'D': '3', 'M': '2', 'O': '4',
 'N': '5', 'S': '1', 'R': '6', 'Y': '7'}
  1. Dada uma lista de letras e uma lista de dígitos (cada um representado aqui como strings de 1 caractere), a função zip criará um par de letras e dígitos, em ordem.
  2. Por que isso é legal? Porque essa estrutura de dados é exatamente a estrutura certa para passar para a função dict() para criar um dicionário que usa letras como chaves e seus dígitos associados como valores. (Esta não é a única maneira de fazer isso, é claro. Você pode usar uma compreensão de dicionário para criar o dicionário diretamente.) Embora a representação impressa do dicionário liste os pares em uma ordem diferente (os dicionários não têm “ordem” por se), você pode ver que cada letra está associada ao dígito, com base na ordenação dos caracteres originais e nas sequências de adivinhação.

O solucionador alfamético usa essa técnica para criar um dicionário que mapeia letras no quebra-cabeça para dígitos na solução, para cada solução possível.

characters = tuple(ord(c) for c in sorted_characters)
digits = tuple(ord(c) for c in '0123456789')
...
for guess in itertools.permutations(digits, len(characters)):
    ...
    equation = puzzle.translate(dict(zip(characters, guess)))

Mas o que é esse método translate()? Ah, agora você está chegando à parte realmente divertida.

Um novo tipo de manipulação de strings

As strings do Python têm muitos métodos. Você aprendeu sobre alguns desses métodos no capítulo sobre strings do python: lower(), count() e format(). Agora quero apresentar a você uma técnica de manipulação de strings poderosa, mas pouco conhecida: o método translate().

>>> translation_table = {ord('A'): ord('O')}  ①
>>> translation_table                         ②
{65: 79}
>>> 'MARK'.translate(translation_table)       ③
'MORK'
  1. A tradução de strings começa com uma tabela de tradução, que é apenas um dicionário que mapeia um caractere para outro. Na verdade, "caractere" está incorreto - a tabela de tradução realmente mapeia um byte para outro.
  2. Lembre-se, os bytes no Python 3 são inteiros. A função ord() retorna o valor ASCII de um caractere, que, no caso de A–Z, é sempre um byte de 65 a 90.
  3. O método translate() em uma string pega uma tabela de tradução e executa a string nela. Ou seja, ele substitui todas as ocorrências das chaves da tabela de conversão pelos valores correspondentes. Neste caso, “traduzindo” MARK para MORK.

O que isso tem a ver com a resolução de quebra-cabeças alfaméticos? Como se vê, tudo.

>>> characters = tuple(ord(c) for c in 'SMEDONRY')       ①
>>> characters
(83, 77, 69, 68, 79, 78, 82, 89)
>>> guess = tuple(ord(c) for c in '91570682')            ②
>>> guess
(57, 49, 53, 55, 48, 54, 56, 50)
>>> translation_table = dict(zip(characters, guess))     ③
>>> translation_table
{68: 55, 69: 53, 77: 49, 78: 54, 79: 48, 82: 56, 83: 57, 89: 50}
>>> 'SEND + MORE == MONEY'.translate(translation_table)  ④
'9567 + 1085 == 10652'

Agora você está chegando à parte realmente divertida.

  1. Usando uma expressão geradora, calculamos rapidamente os valores de byte para cada caractere em uma string. caracteres é um exemplo do valor de sorted_characters na função alphametics.solve().
  2. Usando outra expressão geradora, calculamos rapidamente os valores de byte para cada dígito nesta string. O resultado, palpite, é da forma retornada pela função itertools.permutations() na função alphametics.solve().
  3. Esta tabela de tradução é gerada compactando caracteres e adivinhando juntos e construindo um dicionário a partir da sequência de pares resultante. Isso é exatamente o que a função alphametics.solve() faz dentro do loop for.
  4. Finalmente, passamos esta tabela de tradução para o método translate() da string original do quebra-cabeça. Isso converte cada letra na string para o dígito correspondente (com base nas letras em caracteres e nos dígitos em palpite). O resultado é uma expressão Python válida, como uma string.

Isso é bem impressionante. Mas o que você pode fazer com uma string que é uma expressão Python válida?

Avaliando strings arbitrárias como expressões Python

Esta é a peça final do quebra-cabeça (ou melhor, a peça final do solucionador de quebra-cabeças). Depois de toda essa manipulação sofisticada de strings, ficamos com uma string como '9567 + 1085 == 10652'. Mas isso é uma string, e para que serve uma string? Digite eval(), a ferramenta de avaliação universal do Python.

>>> eval('1 + 1 == 2')
True
>>> eval('1 + 1 == 3')
False
>>> eval('9567 + 1085 == 10652')
True

Mas espere, tem mais! A função eval() não se limita a expressões booleanas. Ele pode lidar com qualquer expressão Python e retornar qualquer tipo de dados.

>>> eval('"A" + "B"')
'AB'
>>> eval('"MARK".translate({65: 79})')
'MORK'
>>> eval('"AAAAA".count("A")')
5
>>> eval('["*"] * 5')
['*', '*', '*', '*', '*']

Mas espere, isso não é tudo!

>>> x = 5
>>> eval("x * 5")         ①
25
>>> eval("pow(x, 2)")     ②
25
>>> import math
>>> eval("math.sqrt(x)")  ③
2.2360679774997898
  1. A expressão que eval() usa pode referenciar variáveis globais definidas fora de eval(). Se chamado dentro de uma função, também pode referenciar variáveis locais.
  2. E funções.
  3. E módulos.

Ei, espere um minuto...

>>> import subprocess
>>> eval("subprocess.getoutput('ls ~')")                  ①
'Desktop         Library         Pictures \
 Documents       Movies          Public   \
 Music           Sites'
>>> eval("subprocess.getoutput('rm /some/random/file')")  ②
  1. O módulo subprocess permite que você execute comandos shell arbitrários e obtenha o resultado como uma string Python.
  2. Comandos shell arbitrários podem ter consequências permanentes.

É ainda pior do que isso, porque há uma função global __import__() que recebe um nome de módulo como uma string, importa o módulo e retorna uma referência a ele. Combinado com o poder de eval(), você pode construir uma única expressão que eliminará todos os seus arquivos:

>>> eval("__import__('subprocess').getoutput('rm /some/random/file')") ①
  1. Agora imagine a saída de 'rm -rf ~'. Na verdade, não haveria saída, mas você também não teria nenhum arquivo.

eval() é do mal

Bem, a parte ruim é avaliar expressões arbitrárias de fontes não confiáveis. Você só deve usar eval() na entrada confiável. Claro, o truque é descobrir o que é “confiável”. Mas aqui está algo que eu sei com certeza: você NÃO deve pegar este solucionador de alfamética e colocá-lo na internet como um pequeno serviço web divertido. Não cometa o erro de pensar: “Nossa, a função faz muita manipulação de strings antes de obter uma string para avaliar; Não consigo imaginar como alguém poderia explorar isso.” Alguém VAI descobrir como esconder código executável desagradável por toda essa manipulação de strings (coisas mais estranhas aconteceram), e então você pode dar adeus ao seu servidor.

Mas certamente há alguma maneira de avaliar expressões com segurança? Para colocar eval() em uma sandbox onde não pode acessar ou prejudicar o mundo exterior? Bem, sim e não.

>>> x = 5
>>> eval("x * 5", {}, {})               ①
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name 'x' is not defined
>>> eval("x * 5", {"x": x}, {})         ②
25
>>> import math
>>> eval("math.sqrt(x)", {"x": x}, {})  ③
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name 'math' is not defined
  1. O segundo e o terceiro parâmetros passados para a função eval() atuam como namespaces globais e locais para avaliar a expressão. Nesse caso, ambos estão vazios, o que significa que quando a string "x * 5" é avaliada, não há referência a x no namespace global ou local, então eval() lança uma exceção.
  2. Você pode incluir seletivamente valores específicos no namespace global listando-os individualmente. Então essas — e apenas essas — variáveis estarão disponíveis durante a avaliação.
  3. Mesmo que você tenha importado o módulo math, você não o incluiu no namespace passado para a função eval(), então a avaliação falhou.

Puxa, isso foi fácil. Deixe-me fazer um web service alfamético agora!

>>> eval("pow(5, 2)", {}, {})                   ①
25
>>> eval("__import__('math').sqrt(5)", {}, {})  ②
2.2360679774997898
  1. Mesmo que você tenha passado dicionários vazios para os namespaces globais e locais, todas as funções internas do Python ainda estão disponíveis durante a avaliação. Então pow(5, 2) funciona, porque 5 e 2 são literais, e pow() é uma função embutida.
  2. Infelizmente (e se você não vê por que é lamentável, continue lendo), a função __import__() também é uma função embutida, então também funciona.

Sim, isso significa que você ainda pode fazer coisas desagradáveis, mesmo se você definir explicitamente os namespaces globais e locais para dicionários vazios ao chamar eval():

>>> eval("__import__('subprocess').getoutput('rm /some/random/file')", {}, {})

Ops. Estou feliz por não ter feito esse serviço da web alphametics. Existe alguma maneira de usar eval() com segurança? Bem, sim e não.

>>> eval("__import__('math').sqrt(5)",
...     {"__builtins__":None}, {})          ①
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name '__import__' is not defined
>>> eval("__import__('subprocess').getoutput('rm -rf /')",
...     {"__builtins__":None}, {})          ②
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name '__import__' is not defined
  1. Para avaliar expressões não confiáveis com segurança, você precisa definir um dicionário de namespace global que mapeie "__builtins__" para None, o valor nulo do Python. Internamente, as funções “incorporadas” estão contidas em um pseudo-módulo chamado "__builtins__". Este pseudomódulo (ou seja, o conjunto de funções internas) é disponibilizado para expressões avaliadas, a menos que você o substitua explicitamente.
  2. Certifique-se de ter substituído __builtins__. Não __built-in__, __built-ins__, ou alguma outra variação que funcionará muito bem, mas o exporá a riscos catastróficos.

Então eval() está seguro agora? Bem, sim e não.

>>> eval("2 ** 2147483647",
...     {"__builtins__":None}, {})          ①
  1. Mesmo sem acesso a __builtins__, você ainda pode iniciar um ataque de negação de serviço. Por exemplo, tentar elevar 2 à potência 2147483647 aumentará a utilização da CPU do seu servidor para 100% por algum tempo. (Se você estiver tentando isso no shell interativo, pressione Ctrl-C algumas vezes para sair dele.) Tecnicamente, essa expressão retornará um valor eventualmente, mas enquanto isso seu servidor não fará nada.

No final, é possível avaliar com segurança expressões Python não confiáveis, para alguma definição de “seguro” que acaba não sendo muito útil na vida real. Tudo bem se você estiver apenas brincando, e tudo bem se você apenas passar uma entrada confiável. Mas qualquer outra coisa é apenas pedir problemas.

Juntando tudo

Para recapitular: este programa resolve quebra-cabeças alfaméticos por força bruta, ou seja, através de uma busca exaustiva de todas as soluções possíveis. Para isso, é preciso …

  1. Encontra todas as letras do quebra-cabeça com a função re.findall()
  2. Encontre todas as letras únicas no quebra-cabeça com conjuntos e a função set()
  3. Verifica se há mais de 10 letras únicas (o que significa que o quebra-cabeça é definitivamente insolúvel) com uma declaração assert
  4. Converte as letras em seus equivalentes ASCII com um objeto gerador
  5. Calcula todas as soluções possíveis com a função itertools.permutations()
  6. Converte cada solução possível em uma expressão Python com o método de string translate()
  7. Testa cada solução possível avaliando a expressão Python com a função eval()
  8. Retorna a primeira solução avaliada como True

…em apenas 14 linhas de código.

Leitura adicional

Muito obrigado a Raymond Hettinger por concordar em relicenciar seu código para que eu pudesse portá-lo para o Python 3 e usá-lo como base para este capítulo.

Esse artigo é uma tradução de um capítulo do livro "Dive Into Python 3" escrito por Mark Pilgrim. Você pode ler o livro desde o início em português clicando aqui.

Traduzido por Acervo Lima. O original pode ser acessado aqui.

Licença