Skip to content

Latest commit

 

History

History
919 lines (705 loc) · 65.1 KB

cap12.adoc

File metadata and controls

919 lines (705 loc) · 65.1 KB

Métodos especiais para sequências

Não queira saber se aquilo é-um pato: veja se ele grasna-como-um pato, anda-como-um pato, etc., etc., dependendo de qual subconjunto exato de comportamentos de pato você precisa para usar em seus jogos de linguagem. (comp.lang.python, Jul. 26, 2000)

— Alex Martelli

Neste capítulo, vamos criar uma classe Vector, para representar um vetor multidimensional—um avanço significativo sobre o Vector2D bidimensional do [pythonic_objects]. Vector vai se comportar como uma simples sequência imutável padrão do Python. Seus elementos serão números de ponto flutuante, e ao final do capítulo a classe suportará o seguinte:

  • O protocolo de sequência básico: __len__ e __getitem__

  • Representação segura de instâncias com muitos itens

  • Suporte adequado a fatiamento, produzindo novas instâncias de Vector

  • Hashing agregado, levando em consideração o valor de cada elemento contido na sequência

  • Um extensão personalizada da linguagem de formatação

Também vamos implementar, com __getattr__, o acesso dinâmico a atributos, como forma de substituir as propriedades apenas para leitura que usamos no Vector2d—apesar disso não ser típico de tipos sequência.

Nossa apresentação voltada para o código será interrompida por uma discussão conceitual sobre a ideia de protocolos como uma interface informal. Vamos discutir a relação entre protocolos e duck typing, e as implicações práticas disso na criação de seus próprios tipos

Novidades nesse capítulo

Não ocorreu qualquer grande modificação neste capítulo. Há uma breve discussão nova sobre o typing.Protocol em um quadro de dicas, no final da Protocolos e o duck typing.

Na Um __getitem__ que trata fatias, a implementação do __getitem__ no Exemplo 6 está mais concisa e robusta que o exemplo na primeira edição, graças ao duck typing e ao operator.index. Essa mudança foi replicada para as implementações seguintes de Vector aqui e no [operator_overloading].

Vamos começar.

Vector: Um tipo sequência definido pelo usuário

Nossa estratégia na implementação de Vector será usar composição, não herança. Vamos armazenar os componentes em um array de números de ponto flutuante, e implementar os métodos necessários para que nossa classe Vector se comporte como uma sequência plana imutável.

Mas antes de implementar os métodos de sequência, vamos desenvolver uma implementação básica de Vector compatível com nossa classe Vector2d, vista anteriormente—​exceto onde tal compatibilidade não fizer sentido.

Aplicações de vetores além de três dimensões

Quem precisa de vetores com 1.000 dimensões? Vetores N-dimensionais (com valores grandes de N) são bastante utilizados em recuperação de informação, onde documentos e consultas textuais são representados como vetores, com uma dimensão para cada palavra. Isso se chama Modelo de Espaço Vetorial (EN). Nesse modelo, a métrica fundamental de relevância é a similaridade de cosseno—o cosseno do ângulo entre o vetor representando a consulta e o vetor representando o documento. Conforme o ângulo diminui, o valor do cosseno aumenta, indicando a relevância do documento para aquela consulta: cosseno próximo de 0 significa pouca relevância; próximo de 1 indica alta relevância.

Dito isto, a classe Vector nesse capítulo é um exemplo didático. O objetivo é apenas demonstrar alguns métodos especiais do Python no contexto de um tipo sequência, sem grandes conceitos matemáticos.

A NumPy e a SciPy são as ferramentas que você precisa para fazer cálculos vetoriais em aplicações reais. O pacote gensim do PyPi, de Radim Řehůřek, implementa a modelagem de espaço vetorial para processamento de linguagem natural e recuperação de informação, usando a NumPy e a SciPy.

Vector versão #1: compatível com Vector2d

A primeira versão de Vector deve ser tão compatível quanto possível com nossa classe Vector2d desenvolvida anteriormente.

Entretanto, pela própria natureza das classes, o construtor de Vector não é compatível com o construtor de Vector2d. Poderíamos fazer Vector(3, 4) e Vector(3, 4, 5) funcionarem, recebendo argumentos arbitrários com *args em __init__. Mas a melhor prática para um construtor de sequências é receber os dados através de um argumento iterável, como fazem todos os tipos embutidos de sequências. O Exemplo 1 mostra algumas maneiras de instanciar objetos do nosso novo Vector.

Exemplo 1. Testes de Vector.__init__ e Vector.__repr__
>>> Vector([3.1, 4.2])
Vector([3.1, 4.2])
>>> Vector((3, 4, 5))
Vector([3.0, 4.0, 5.0])
>>> Vector(range(10))
Vector([0.0, 1.0, 2.0, 3.0, 4.0, ...])

Exceto pela nova assinatura do construtor, me assegurei que todos os testes realizados com Vector2d (por exemplo, Vector2d(3, 4)) são bem sucedidos e produzem os mesmos resultados com um Vector de dois componentes, como Vector([3, 4]).

Warning

Quando um Vector tem mais de seis componentes, a string produzida por repr() é abreviada com …​, como visto na última linha do Exemplo 1. Isso é fundamental para qualquer tipo de coleção que possa conter um número grande de itens, pois repr é usado na depuração—e você não quer que um único objeto grande ocupe milhares de linhas em seu console ou arquivo de log. Use o módulo reprlib para produzir representações de tamanho limitado, como no Exemplo 2. O módulo reprlib se chamava repr no Python 2.7.

O Exemplo 2 lista a implementação de nossa primeira versão de Vector (esse exemplo usa como base o código mostrado no #ex_vector2d_v0 e no #ex_vector2d_v1 do [pythonic_objects]).

Exemplo 2. vector_v1.py: derived from vector2d_v1.py
link:code/12-seq-hacking/vector_v1.py[role=include]
  1. O atributo de instância "protegido" self._components vai manter um array com os componentes do Vector.

  2. Para permitir iteração, devolvemos um itereador sobre self._components.[1]

  3. Usa reprlib.repr() para obter um representação de tamanho limitado de self._components (por exemplo, array('d', [0.0, 1.0, 2.0, 3.0, 4.0, …​])).

  4. Remove o prefixo array('d', e o ) final, antes de inserir a string em uma chamada ao construtor de Vector.

  5. Cria um objeto bytes diretamente de self._components.

  6. Desde o Python 3.8, math.hypot aceita pontos N-dimensionais. Já usei a seguinte expressão antes: math.sqrt(sum(x * x for x in self)).

  7. A única mudança necessária no frombytes anterior é na última linha: passamos a memoryview diretamente para o construtor, sem desempacotá-la com *, como fazíamos antes.

O modo como usei reprlib.repr pede alguma elaboração. Essa função produz representações seguras de estruturas grandes ou recursivas, limitando a tamanho da string devolvida e marcando o corte com '…​'. Eu queria que o repr de um Vector se parecesse com Vector([3.0, 4.0, 5.0]) e não com Vector(array('d', [3.0, 4.0, 5.0])), porque a existência de um array dentro de um Vector é um detalhe de implementação. Como essas chamadas ao construtor criam objetos Vector idênticos, preferi a sintaxe mais simples, usando um argumento list.

Ao escrever o __repr__, poderia ter produzido uma versão para exibição simplificada de components com essa expressão: reprlib.repr(list(self._components)). Isso, entretanto, geraria algum desperdício, pois eu estaria copiando cada item de self._components para uma list apenas para usar a list no repr. Em vez disso, decidi aplicar reprlib.repr diretamente no array self._components, e então remover os caracteres fora dos []. É isso o que faz a segunda linha do __repr__ no Exemplo 2.

Tip

Por seu papel na depuração, chamar repr() em um objeto não deveria nunca gerar uma exceção. Se alguma coisa der errado dentro de sua implementação de __repr__, você deve lidar com o problema e fazer o melhor possível para produzir uma saída aproveitável, que dê ao usuário uma chance de identificar o objeto receptor (self).

Observe que os métodos __str__, __eq__, e __bool__ são idênticos a suas versões em Vector2d, e apenas um caractere mudou em frombytes (um * foi removido na última linha). Isso é um dos benefícios de termos tornado o Vector2d original iterável.

Aliás, poderíamos ter criado Vector como uma subclasse de Vector2d, mas escolhi não fazer isso por duas razões. Em primeiro lugar, os construtores incompatíveis de fato tornam a relação de super/subclasse desaconselhável. Eu até poderia contornar isso como um tratamento engenhoso dos parâmetros em __init__, mas a segunda razão é mais importante: queria que Vector fosse um exemplo independente de uma classe que implementa o protocolo de sequência. É o que faremos a seguir, após uma discussão sobre o termo protocolo.

Protocolos e o duck typing

Já no [data_model], vimos que não é necessário herdar de qualquer classe em especial para criar um tipo sequência completamente funcional em Python; basta implementar os métodos que satisfazem o protocolo de sequência. Mas de que tipo de protocolo estamos falando?

No contexto da programação orientada a objetos, um protocolo é uma interface informal, definida apenas na documentação (e não no código). Por exemplo, o protocolo de sequência no Python implica apenas no métodos __len__ e __getitem__. Qualquer classe Spam, que implemente esses métodos com a assinatura e a semântica padrões, pode ser usada em qualquer lugar onde uma sequência for esperada. É irrelevante se Spam é uma subclasse dessa ou daquela outra classe; tudo o que importa é que ela fornece os métodos necessários. Vimos isso no [ex_pythonic_deck], reproduzido aqui no Exemplo 3.

Exemplo 3. Código do [ex_pythonic_deck], reproduzido aqui por conveniência
link:code/01-data-model/frenchdeck.py[role=include]

A classe FrenchDeck, no Exemplo 3, pode tirar proveito de muitas facilidades do Python por implementar o protocolo de sequência, mesmo que isso não esteja declarado em qualquer ponto do código. Um programador Python experiente vai olhar para ela e entender que aquilo é uma sequência, mesmo sendo apenas uma subclasse de object. Dizemos que ela é uma sequênca porque ela se comporta como uma sequência, e é isso que importa.

Isso ficou conhecido como duck typing (literalmente "tipagem pato"), após o post de Alex Martelli citado no início deste capítulo.

Como protocolos são informais e não obrigatórios, muitas vezes é possível resolver nosso problema implementando apenas parte de um protocolo, se sabemos o contexto específico em que a classe será utilizada. Por exemplo, apenas __getitem__ basta para suportar iteração; não há necessidade de fornecer um __len__.

Tip

Com a PEP 544—Protocols: Structural subtyping (static duck typing) (Protocolos:sub-tipagem estrutural (duck typing estático)) (EN), o Python 3.8 suporta classes protocolo: subclasses de typing.Protocol, que estudamos na [protocols_in_fn]. Esse novo uso da palavra protocolo no Python tem um significado relacionado, mas diferente. Quando preciso diferenciá-los, escrevo protocolo estático para me referir aos protocolos formalizados em classes protocolo (subclasses de typing.Protocol), e protocolos dinâmicos para o sentido tradicional. Uma diferença fundamental é que implementações de um protocolo estático precisam oferecer todos os métodos definidos na classe protocolo. A [two_kinds_protocols_sec] no [ifaces_prot_abc] traz maiores detalhes.

Vamos agora implementar o protocolo sequência em Vector, primeiro sem suporte adequado ao fatiamento, que acrescentaremos mais tarde.

Vector versão #2: Uma sequência fatiável

Como vimos no exemplo da classe FrenchDeck, suportar o protocolo de sequência é muito fácil se você puder delegar para um atributo sequência em seu objeto, como nosso array self._components. Esses __len__ e __getitem__ de uma linha são um bom começo:

class Vector:
    # many lines omitted
    # ...

    def __len__(self):
        return len(self._components)

    def __getitem__(self, index):
        return self._components[index]

Após tais acréscimos, agora todas as seguintes operações funcionam:

>>> v1 = Vector([3, 4, 5])
>>> len(v1)
3
>>> v1[0], v1[-1]
(3.0, 5.0)
>>> v7 = Vector(range(7))
>>> v7[1:4]
array('d', [1.0, 2.0, 3.0])

Como se vê, até o fatiamento é suportado—mas não muito bem. Seria melhor se uma fatia de um Vector fosse também uma instância de Vector, e não um array. A antiga classe FrenchDeck tem um problema similar: quando ela é fatiada, o resultado é uma list. No caso de Vector, muito da funcionalidade é perdida quando o fatiamento produz arrays simples.

Considere os tipos sequência embutidos: cada um deles, ao ser fatiado, produz uma nova instância de seu próprio tipo, e não de algum outro tipo.

Para fazer Vector produzir fatias como instâncias de Vector, não podemos simplesmente delegar o fatiamento para array. Precisamos analisar os argumentos recebidos em __getitem__ e fazer a coisa certa.

Vejamos agora como o Python transforma a sintaxe my_seq[1:3] em argumentos para my_seq.__getitem__(...).

Como funciona o fatiamento

Uma demonstração vale mais que mil palavras, então dê uma olhada no Exemplo 4.

Exemplo 4. Examinando o comportamento de __getitem__ e fatias
>>> class MySeq:
...     def __getitem__(self, index):
...         return index  # (1)
...
>>> s = MySeq()
>>> s[1]  # (2)
1
>>> s[1:4]  # (3)
slice(1, 4, None)
>>> s[1:4:2]  # (4)
slice(1, 4, 2)
>>> s[1:4:2, 9]  # (5)
(slice(1, 4, 2), 9)
>>> s[1:4:2, 7:9]  # (6)
(slice(1, 4, 2), slice(7, 9, None))
  1. Para essa demonstração, o método __getitem__ simplesmente devolve o que for passado a ele.

  2. Um único índice, nada de novo.

  3. A notação 1:4 se torna slice(1, 4, None).

  4. slice(1, 4, 2) significa comece em 1, pare em 4, ande de 2 em 2.

  5. Surpresa: a presença de vírgulas dentro do [] significa que __getitem__ recebe uma tupla.

  6. A tupla pode inclusive conter vários objetos slice.

Vamos agora olhar mais de perto a própria classe slice, no Exemplo 5.

Exemplo 5. Inspecionando os atributos da classe slice
>>> slice  # (1)
<class 'slice'>
>>> dir(slice) # (2)
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__',
 '__format__', '__ge__', '__getattribute__', '__gt__',
 '__hash__', '__init__', '__le__', '__lt__', '__ne__',
 '__new__', '__reduce__', '__reduce_ex__', '__repr__',
 '__setattr__', '__sizeof__', '__str__', '__subclasshook__',
 'indices', 'start', 'step', 'stop']
  1. slice é um tipo embutido (que já vimos antes na [slice_objects]).

  2. Inspecionando uma slice descobrimos os atributos de dados start, stop, e step, e um método indices.

No Exemplo 5, a chamada dir(slice) revela um atributo indices, um método pouco conhecido mas muito interessante. Eis o que diz help(slice.indices):

S.indices(len) → (start, stop, stride)

Supondo uma sequência de tamanho len, calcula os índices start (início) e stop (fim), e a extensão do stride (passo) da fatia estendida descrita por S. Índices fora dos limites são recortados, exatamente como acontece em uma fatia normal.

Em outras palavras, indices expõe a lógica complexa implementada nas sequências embutidas, para lidar graciosamente com índices inexistentes ou negativos e com fatias maiores que a sequência original. Esse método produz tuplas "normalizadas" com os inteiros não-negativos start, stop, e stride ajustados para uma sequência de um dado tamanho.

Aqui estão dois exemplos, considerando uma sequência de len == 5, por exemplo 'ABCDE':

>>> slice(None, 10, 2).indices(5)  # (1)
(0, 5, 2)
>>> slice(-3, None, None).indices(5)  # (2)
(2, 5, 1)
  1. 'ABCDE'[:10:2] é o mesmo que 'ABCDE'[0:5:2].

  2. 'ABCDE'[-3:] é o mesmo que 'ABCDE'[2:5:1].

No código de nosso Vector não vamos precisar do método slice.indices(), pois quando recebermos uma fatia como argumento vamos delegar seu tratamento para o array interno _components. Mas quando você não puder contar com os serviços de uma sequência subjacente, esse método ajuda evita a necessidade de implementar uma lógica sutil.

Agora que sabemos como tratar fatias, vamos ver a implementação aperfeiçoada de Vector.__getitem__.

Um __getitem__ que trata fatias

O Exemplo 6 lista os dois métodos necessários para fazer Vector se comportar como uma sequência: __len__ e __getitem__ (com o último implementado para tratar corretamente o fatiamento).

Exemplo 6. Parte de vector_v2.py: métodos __len__ e __getitem__ adicionados à classe Vector, de vector_v1.py (no Exemplo 2)
link:code/12-seq-hacking/vector_v2.py[role=include]
  1. Se o argumento key é uma slice…​

  2. …​obtém a classe da instância (isto é, Vector) e…​

  3. …​invoca a classe para criar outra instância de Vector a partir de uma fatia do array _components.

  4. Se podemos obter um index de key…​

  5. …​devolve o item específico de _components.

A função operator.index() chama o método especial __index__. A função e o método especial foram definidos na PEP 357—Allowing Any Object to be Used for Slicing (Permitir que Qualquer Objeto seja Usado para Fatiamento) (EN), proposta por Travis Oliphant, para permitir que qualquer um dos numerosos tipos de inteiros na NumPy fossem usados como argumentos de índices e fatias. A diferença principal entre operator.index() e int() é que o primeiro foi projetado para esse propósito específico. Por exemplo, int(3.14) devolve 3, mas operator.index(3.14) gera um TypeError, porque um float não deve ser usado como índice.

Note

O uso excessivo de isinstance pode ser um sinal de design orientado a objetos ruim, mas tratar fatias em __getitem__ é um caso de uso justificável. Na primeira edição, também usei um teste isinstance com key, para verificar se esse argumento era um inteiro. O uso de operator.index evita esse teste, e gera um Type​Error com uma mensagem muito informativa, se não for possível obter o index a partir de key. Observe a última mensagem de erro no Exemplo 7, abaixo.

Após a adição do código do Exemplo 6 à classe Vector class, temos o comportamento apropriado para fatiamento, como demonstra o Exemplo 7 .

Exemplo 7. Testes do Vector.__getitem__ aperfeiçoado, do Exemplo 6
link:code/12-seq-hacking/vector_v2.py[role=include]
  1. Um índice inteiro recupera apenas o valor de um componente, um float.

  2. Uma fatia como índice cria um novo Vector.

  3. Um fatia de len == 1 também cria um Vector.

  4. Vector não suporta indexação multidimensional, então tuplas de índices ou de fatias geram um erro.

Vector versão #3: acesso dinâmico a atributos

Ao evoluir Vector2d para Vector, perdemos a habilidade de acessar os componentes do vetor por nome (por exemplo, v.x, v.y). Agora estamos trabalhando com vetores que podem ter um número grande de componentes. Ainda assim, pode ser conveniente acessar os primeiros componentes usando letras como atalhos, algo como x, y, z em vez de v[0], v[1], and v[2].

Aqui está a sintaxe alternativa que queremos oferecer para a leitura dos quatro primeiros componentes de um vetor:

>>> v = Vector(range(10))
>>> v.x
0.0
>>> v.y, v.z, v.t
(1.0, 2.0, 3.0)

No Vector2d, oferecemos acesso somente para leitura a x e y através do decorador @property (veja o [ex_vector2d_v3]). Poderíamos incluir quatro propriedades no Vector, mas isso seria tedioso. O método especial __getattr__ nos fornece uma opção melhor.

O método __getattr__ é invocado pelo interpretador quando a busca por um atributo falha. Simplificando, dada a expressão my_obj.x, o Python verifica se a instância de my_obj tem um atributo chamado x; em caso negativo, a busca passa para a classe (my_obj.__class__) e depois sobe pelo diagrama de herança.[2] Se por fim o atributo x não for encontrado, o método __getattr__, definido na classe de my_obj, é chamado com self e o nome do atributo em formato de string (por exemplo, 'x').

O Exemplo 8 lista nosso método __getattr__. Ele basicamente verifica se o atributo desejado é uma das letras xyzt. Em caso positivo, devolve o componente correspondente do vetor.

Exemplo 8. Parte de vector_v3.py: método __getattr__ acrescentado à classe Vector
link:code/12-seq-hacking/vector_v3.py[role=include]
  1. Define __match_args__ para permitir pattern matching posicional sobre os atributos dinâmicos suportados por __getattr__.[3]

  2. Obtém a classe de Vector, para uso posterior.

  3. Tenta obter a posição de name em __match_args__.

  4. .index(name) gera um ValueError quando name não é encontrado; define pos como -1. (Eu preferiria usar algo como str.find aqui, mas tuple não implementa esse método.)

  5. Se pos está dentro da faixa de componentes disponíveis, devolve aquele componente.

  6. Se chegamos até aqui, gera um AttributeError com uma mensagem de erro padrão.

Não é difícil implementar __getattr__, mas neste caso não é o suficiente. Observe a interação bizarra no Exemplo 9.

Exemplo 9. Comportamento inapropriado: realizar uma atribuição a v.x não gera um erro, mas introduz uma inconsistência
>>> v = Vector(range(5))
>>> v
Vector([0.0, 1.0, 2.0, 3.0, 4.0])
>>> v.x  # (1)
0.0
>>> v.x = 10  # (2)
>>> v.x  # (3)
10
>>> v
Vector([0.0, 1.0, 2.0, 3.0, 4.0])  # (4)
  1. Acessa o elemento v[0] como v.x.

  2. Atribui um novo valor a v.x. Isso deveria gera uma exceção.

  3. Ler v.x obtém o novo valor, 10.

  4. Entretanto, os componentes do vetor não mudam.

Você consegue explicar o que está acontecendo? Em especial, por que v.x devolve 10 na segunda consulta (<3>), se aquele valor não está presente no array de componentes do vetor? Se você não souber responder de imediato, estude a explicação de __getattr__ que aparece logo antes do Exemplo 8. A razão é um pouco sutil, mas é um alicerce fundamental para entender grande parte do que veremos mais tarde no livro.

Após pensar um pouco sobre essa questão, siga em frente e leia a explicação para o que aconteceu.

A inconsistência no Exemplo 9 ocorre devido à forma como __getattr__ funciona: o Python só chama esse método como último recurso, quando o objeto não contém o atributo nomeado. Entretanto, após atribuirmos v.x = 10, o objeto v agora contém um atributo x, e então __getattr__ não será mais invocado para obter v.x: o interpretador vai apenas devolver o valor 10, que agora está vinculado a v.x. Por outro lado, nossa implementação de __getattr__ não leva em consideração qualquer atributo de instância diferente de self._components, de onde ele obtém os valores dos "atributos virtuais" listados em __match_args__.

Para evitar essa inconsistência, precisamos modificar a lógica de definição de atributos em nossa classe Vector.

Como você se lembra, nos nossos últimos exemplos de Vector2d no [pythonic_objects], tentar atribuir valores aos atributos de instância .x ou .y gerava um AttributeError. Em Vector, queremos produzir a mesma exceção em resposta a tentativas de atribuição a qualquer nome de atributo com um única letra, só para evitar confusão. Para fazer isso, implementaremos __setattr__, como listado no Exemplo 10.

Exemplo 10. Parte de vector_v3.py: o método __setattr__ na classe Vector
link:code/12-seq-hacking/vector_v3.py[role=include]
  1. Tratamento especial para nomes de atributos com uma única letra.

  2. Se name está em __match_args__, configura mensagens de erro específicas.

  3. Se name é uma letra minúscula, configura a mensagem de erro sobre todos os nomes de uma única letra.

  4. Caso contrário, configura uma mensagem de erro vazia.

  5. Se existir uma mensagem de erro não-vazia, gera um AttributeError.

  6. Caso default: chama __setattr__ na superclasse para obter o comportamento padrão.

Tip

A função super() fornece uma maneira de acessar dinamicamente métodos de superclasses, uma necessidade em uma linguagem dinâmica que suporta herança múltipla, como o Python. Ela é usada para delegar alguma tarefa de um método em uma subclasse para um método adequado em uma superclasse, como visto no Exemplo 10. Falaremos mais sobre super na [mro_section].

Ao escolher a menssagem de erro para mostrar com AttributeError, primeiro eu verifiquei o comportamento do tipo embutido complex, pois ele é imutável e tem um par de atributos de dados real and imag. Tentar mudar qualquer um dos dois em uma instância de complex gera um AttributeError com a mensagem "can’t set attribute" ("não é possível [re]-definir o atributo"). Por outro lado, a tentativa de modificar um atributo protegido por uma propriedade, como fizemos no [hashable_vector2d], produz a mensagem "read-only attribute" ("atributo apenas para leitura"). Eu me inspirei em ambas as frases para definir a string error em __setitem__, mas fui mais explícito sobre os atributos proibidos.

Observe que não estamos proibindo a modificação de todos os atributos, apenas daqueles com nomes compostos por uma única letra minúscula, para evitar conflitos com os atributos apenas para leitura suportados, x, y, z, e t.

Warning

Sabendo que declarar __slots__ no nível da classe impede a definição de novos atributos de instância, é tentador usar esse recurso em vez de implementar __setattr__ como fizemos. Entretanto, por todas as ressalvas discutidas na [problems_with_slots], usar __slots__ apenas para prevenir a criação de atributos de instância não é recomendado. __slots__ deve ser usado apenas para economizar memória, e apenas quando isso for um problema real.

Mesmo não suportando escrita nos componentes de Vector, aqui está uma lição importante deste exemplo: muitas vezes, quando você implementa __getattr__, é necessário também escrever o __setattr__, para evitar comportamentos inconsistentes em seus objetos.

Para permitir a modificação de componentes, poderíamos implementar __setitem__, para permitir v[0] = 1.1, e/ou __setattr__, para fazer v.x = 1.1 funcionar. Mas Vector permanecerá imutável, pois queremos torná-lo hashable, na próxima seção.

Vector versão #4: o hash e um == mais rápido

Vamos novamente implementar um método __hash__. Juntamente com o __eq__ existente, isso tornará as instâncias de Vector hashable.

O __hash__ do Vector2d (no [ex_vector2d_v3_hash]) computava o hash de uma tuple construída com os dois componentes, self.x and self.y. Nós agora podemos estar lidando com milhares de componentes, então criar uma tuple pode ser caro demais. Em vez disso, vou aplicar sucessivamente o operador ^ (xor) aos hashes de todos os componentes, assim: v[0] ^ v[1] ^ v[2]. É para isso que serve a função functools.reduce. Anteriormente afirmei que reduce não é mais tão popular quanto antes,[4] mas computar o hash de todos os componentes do vetor é um bom caso de uso para ela. A Figura 1 ilustra a ideia geral da função reduce.

Diagrama de reduce
Figura 1. Funções de redução—reduce, sum, any, all—produzem um único resultado agregado a partir de uma sequência ou de qualquer objeto iterável finito.

Até aqui vimos que functools.reduce() pode ser substituída por sum(). Vamos agora explicar exatamente como ela funciona. A ideia chave é reduzir uma série de valores a um valor único. O primeiro argumento de reduce() é uma função com dois argumentos, o segundo argumento é um iterável. Vamos dizer que temos uma função fn, que recebe dois argumentos, e uma lista lst. Quando chamamos reduce(fn, lst), fn será aplicada ao primeiro par de elementos de lstfn(lst[0], lst[1])—produzindo um primeiro resultado, r1. Então fn é aplicada a r1 e ao próximo elemento—fn(r1, lst[2])—produzindo um segundo resultado, r2. Agora fn(r2, lst[3]) é chamada para produzir r3 …​ e assim por diante, até o último elemento, quando finalmente um único elemento, rN, é produzido e devolvido.

Aqui está como reduce poderia ser usada para computar 5! (o fatorial de 5):

>>> 2 * 3 * 4 * 5  # the result we want: 5! == 120
120
>>> import functools
>>> functools.reduce(lambda a,b: a*b, range(1, 6))
120

Voltando a nosso problema de hash, o Exemplo 11 demonstra a ideia da computação de um xor agregado, fazendo isso de três formas diferente: com um loop for e com dois modos diferentes de usar reduce.

Exemplo 11. Três maneiras de calcular o xor acumulado de inteiros de 0 a 5
>>> n = 0
>>> for i in range(1, 6):  # (1)
...     n ^= i
...
>>> n
1
>>> import functools
>>> functools.reduce(lambda a, b: a^b, range(6))  # (2)
1
>>> import operator
>>> functools.reduce(operator.xor, range(6))  # (3)
1
  1. xor agregado com um loop for e uma variável de acumulação.

  2. functools.reduce usando uma função anônima.

  3. functools.reduce substituindo a lambda personalizada por operator.xor.

Das alternativas apresentadas no Exemplo 11, a última é minha favorita, e o loop for vem a seguir. Qual sua preferida?

Como visto na [operator_module_section], operator oferece a funcionalidade de todos os operadores infixos do Python em formato de função, diminuindo a necessidade do uso de lambda.

Para escrever Vector.__hash__ no meu estilo preferido precisamos importar os módulos functools e operator. Exemplo 12 apresenta as modificações relevantes.

Exemplo 12. Parte de vector_v4.py: duas importações e o método __hash__ adicionados à classe Vector de vector_v3.py
from array import array
import reprlib
import math
import functools  # (1)
import operator  # (2)


class Vector:
    typecode = 'd'

    # many lines omitted in book listing...

    def __eq__(self, other):  # (3)
        return tuple(self) == tuple(other)

    def __hash__(self):
        hashes = (hash(x) for x in self._components)  # (4)
        return functools.reduce(operator.xor, hashes, 0)  # (5)

    # more lines omitted...
  1. Importa functools para usar reduce.

  2. Importa operator para usar xor.

  3. Não há mudanças em __eq__; listei-o aqui porque é uma boa prática manter __eq__ e __hash__ próximos no código-fonte, pois eles precisam trabalhar juntos.

  4. Cria uma expressão geradora para computar sob demanda o hash de cada componente.

  5. Alimenta reduce com hashes e a função xor, para computar o código hash agregado; o terceiro argumento, 0, é o inicializador (veja o próximo aviso).

Warning

Ao usar reduce, é uma boa prática fornecer o terceiro argumento, reduce(function, iterable, initializer), para prevenir a seguinte exceção: TypeError: reduce() of empty sequence with no initial value ("TypeError: reduce() de uma sequência vazia sem valor inicial"— uma mensagem excelente: explica o problema e diz como resolvê-lo) . O initializer é o valor devolvido se a sequência for vazia e é usado como primeiro argumento no loop de redução, e portanto deve ser o elemento neutro da operação. Assim, o initializer para +, |, ^ deve ser 0, mas para * e & deve ser 1.

Da forma como está implementado, o método __hash__ no Exemplo 12 é um exemplo perfeito de uma computação de map-reduce (mapeia e reduz). Veja a (Figura 2).

Diagrama de map-reduce
Figura 2. Map-reduce: aplica uma função a cada item para gerar uma nova série (map), e então computa o agregado (reduce).

A etapa de mapeamento produz um hash para cada componente, e a etapa de redução agrega todos os hashes com o operador xor. Se usarmos map em vez de uma genexp, a etapa de mapeamento fica ainda mais visível:

    def __hash__(self):
        hashes = map(hash, self._components)
        return functools.reduce(operator.xor, hashes)
Tip

A solução com map seria menos eficiente no Python 2, onde a função map cria uma nova list com os resultados. Mas no Python 3, map é preguiçosa (lazy): ela cria um gerador que produz os resultados sob demanda, e assim economiza memória—exatamente como a expressão geradora que usamos no método __hash__ do Exemplo 8.

E enquanto estamos falando de funções de redução, podemos substituir nossa implementação apressada de __eq__ com uma outra, menos custosa em termos de processamento e uso de memória, pelo menos para vetores grandes. Como visto no [ex_vector2d_v0], temos esta implementação bastante concisa de __eq__:

    def __eq__(self, other):
        return tuple(self) == tuple(other)

Isso funciona com Vector2d e com Vector—e até considera Vector([1, 2]) igual a (1, 2), o que pode ser um problema, mas por ora vamos ignorar esta questão[5]. Mas para instâncias de Vector, que podem ter milhares de componentes, esse método é muito ineficiente. Ele cria duas tuplas copiando todo o conteúdo dos operandos, apenas para usar o __eq__ do tipo tuple. Para Vector2d (com apenas dois componentes), é um bom atalho. Mas não para grandes vetores multidimensionais. Uma forma melhor de comparar um Vector com outro Vector ou iterável seria o código do Exemplo 13.

Exemplo 13. A implementação de Vector.__eq__ usando zip em um loop for, para uma comparação mais eficiente
    def __eq__(self, other):
        if len(self) != len(other):  # (1)
            return False
        for a, b in zip(self, other):  # (2)
            if a != b:  # (3)
                return False
        return True  # (4)
  1. Se as len dos objetos são diferentes, eles não são iguais.

  2. zip produz um gerador de tuplas criadas a partir dos itens em cada argumento iterável. Veja a caixa O fantástico zip, se zip for novidade para você. Em 1, a comparação com len é necessária porque zip para de produzir valores sem qualquer aviso quando uma das fontes de entrada se exaure.

  3. Sai assim que dois componentes sejam diferentes, devolvendo False.

  4. Caso contrário, os objetos são iguais.

Tip

O nome da função zip vem de zíper, pois esse objeto físico funciona engatando pares de dentes tomados dos dois lados do zíper, uma boa analogia visual para o que faz zip(left, right). Nenhuma relação com arquivos comprimidos.

O Exemplo 13 é eficiente, mas a função all pode produzir a mesma computação de um agregado do loop for em apenas uma linha: se todas as comparações entre componentes correspoendentes nos operandos forem True, o resultado é True. Assim que uma comparação é False, all devolve False. O Exemplo 14 mostra um __eq__ usando all.

Exemplo 14. A implementação de Vector.__eq__ usando zip e all: mesma lógica do Exemplo 13
    def __eq__(self, other):
        return len(self) == len(other) and all(a == b for a, b in zip(self, other))

Observe que primeiro comparamos o len() dos operandos porque se os tamanhos são diferentes é desnecessário comparar os itens.

O Exemplo 14 é a implementação que escolhemos para __eq__ em vector_v4.py.

O fantástico zip

Ter um loop for que itera sobre itens sem perder tempo com variáveis de índice é muito bom e evita muitos bugs, mas exige algumas funções utilitárias especiais. Uma delas é a função embutida zip, que facilita a iteração em paralelo sobre dois ou mais iteráveis, devolvendo tuplas que você pode desempacotar em variáveis, uma para cada item nas entradas paralelas. Veja o Exemplo 15.

Exemplo 15. A função embutida zip trabalhando
>>> zip(range(3), 'ABC')  # (1)
<zip object at 0x10063ae48>
>>> list(zip(range(3), 'ABC'))  # (2)
[(0, 'A'), (1, 'B'), (2, 'C')]
>>> list(zip(range(3), 'ABC', [0.0, 1.1, 2.2, 3.3]))  # (3)
[(0, 'A', 0.0), (1, 'B', 1.1), (2, 'C', 2.2)]
>>> from itertools import zip_longest  # (4)
>>> list(zip_longest(range(3), 'ABC', [0.0, 1.1, 2.2, 3.3], fillvalue=-1))
[(0, 'A', 0.0), (1, 'B', 1.1), (2, 'C', 2.2), (-1, -1, 3.3)]
  1. zip devolve um gerador que produz tuplas sob demanda.

  2. Cria uma list apenas para exibição; nós normalmente iteramos sobre o gerador.

  3. zip para sem aviso quando um dos iteráveis é exaurido.

  4. A função itertools.zip_longest se comporta de forma diferente: ela usa um fillvalue opcional (por default None) para preencher os valores ausentes, e assim consegue gerar tuplas até que o último iterável seja exaurido.

Note
A nova opção de zip() no Python 3.10

Escrevi na primeira edição deste livro que zip encerrar silenciosamente ao final do iterável mais curto era surpreendente—e não era uma boa característica em uma API. Ignorar parte dos dados de entrada sem qualquer alerta pode levar a bugs sutis. Em vez disso, zip deveria gerar um ValueError se os iteráveis não forem todos do mesmo tamanho, como acontece quando se desempacota um iterável para uma tupla de variáveis de tamanho diferente—alinhado à política de falhar rápido do Python. A PEP 618—Add Optional Length-Checking To zip acrescentou um argumento opcional strict à função zip, para fazê-la de comportar dessa forma. Isso foi implementado no Python 3.10.

A função zip pode também ser usada para transpor uma matriz, representada como iteráveis aninhados. Por exemplo:

>>> a = [(1, 2, 3),
...      (4, 5, 6)]
>>> list(zip(*a))
[(1, 4), (2, 5), (3, 6)]
>>> b = [(1, 2),
...      (3, 4),
...      (5, 6)]
>>> list(zip(*b))
[(1, 3, 5), (2, 4, 6)]

Se você quiser entender zip, passe algum tempo descobrindo como esses exemplos funcionam.

A função embutida enumerate é outra função geradora usada com frequência em loops for, para evitar manipulação direta de variáveis índice. Quem não estiver familiarizado com enumerate deve estudar a seção dedicada a ela na documentação das "Funções embutidas". As funções embutidas zip e enumerate, bem como várias outras funções geradores na biblioteca padrão, são tratadas na [stdlib_generators].

Vamos encerrar esse capítulo trazendo de volta o método __format__ do Vector2d para o Vector.

Vector versão #5: Formatando

O método __format__ de Vector será parecido com o mesmo método em Vector2d, mas em vez de fornecer uma exibição personalizada em coordenadas polares, Vector usará coordenadas esféricas—também conhecidas como coordendas "hiperesféricas", pois agora suportamos n dimensões, e as esferas são "hiperesferas", em 4D e além[6]. Como consequência, mudaremos também o sufixo do formato personalizado de 'p' para 'h'.

Tip

Como vimos na [format_display_sec], ao estender a Minilinguagem de especificação de formato é melhor evitar a reutilização dos códigos de formato usados por tipos embutidos. Especialmente, nossa minilinguagens estendida também usa os códigos de formato dos números de ponto flutuante ('eEfFgGn%'), em seus significados originais, então devemos certamente evitar qualquer um daqueles. Inteiros usam 'bcdoxXn' e strings usam 's'. Escolhi 'p' para as coordenadas polares de Vector2d. O código 'h' para coordendas hiperesféricas é uma boa opção.

Por exemplo, dado um objeto Vector em um espaço 4D (len(v) == 4), o código 'h' irá produzir uma linha como <r, Φ₁, Φ₂, Φ₃>, onde r é a magnitude (abs(v)), e o restante dos números são os componentes angulares Φ₁, Φ₂, Φ₃.

Aqui estão algumas amostras do formato de coordenadas esféricas em 4D, retiradas dos doctests de vector_v5.py (veja o Exemplo 16):

>>> format(Vector([-1, -1, -1, -1]), 'h')
'<2.0, 2.0943951023931957, 2.186276035465284, 3.9269908169872414>'
>>> format(Vector([2, 2, 2, 2]), '.3eh')
'<4.000e+00, 1.047e+00, 9.553e-01, 7.854e-01>'
>>> format(Vector([0, 1, 0, 0]), '0.5fh')
'<1.00000, 1.57080, 0.00000, 0.00000>'

Antes de podermos implementar as pequenas mudanças necessárias em __format__, precisamos escrever um par de métodos de apoio: angle(n), para computar uma das coordenadas angulares (por exemplo, Φ₁), e angles(), para devolver um iterável com todas as coordenadas angulares. Não vou descrever a matemática aqui; se você tiver curiosidade, a página n-sphere” (EN: ver Nota 6) da Wikipedia contém as fórmulas que usei para calcular coordenadas esféricas a partir das coordendas cartesianas no array de componentes de Vector.

O Exemplo 16 é a listagem completa de vector_v5.py, consolidando tudo que implementamos desde a Vector versão #1: compatível com Vector2d, e acrescentando a formatação personalizada

Exemplo 16. vector_v5.py: doctests e todo o código da versão final da classe Vector; as notas explicativas enfatizam os acréscimos necessários para suportar __format__
link:code/12-seq-hacking/vector_v5.py[role=include]
  1. Importa itertools para usar a função chain em __format__.

  2. Computa uma das coordendas angulares, usando fórmulas adaptadas do artigo n-sphere (EN: ver Nota 6) na Wikipedia.

  3. Cria uma expressão geradora para computar sob demanda todas as coordenadas angulares.

  4. Produz uma genexp usando itertools.chain, para iterar de forma contínua sobre a magnitude e as coordenadas angulares.

  5. Configura uma coordenada esférica para exibição, com os delimitadores de ângulo (< e >).

  6. Configura uma coordenda cartesiana para exibição, com parênteses.

  7. Cria uma expressão geradoras para formatar sob demanda cada item de coordenada.

  8. Insere componentes formatados, separados por vírgulas, dentro de delimitadores ou parênteses.

Note

Estamos fazendo uso intensivo de expressões geradoras em __format__, angle, e angles, mas nosso foco aqui é fornecer um __format__ para levar Vector ao mesmo nível de implementação de Vector2d. Quando tratarmos de geradores, no [iterables2generators], vamos usar parte do código de Vector nos exemplos, e lá os recursos dos geradores serão explicados em detalhes.

Isso conclui nossa missão nesse capítulo. A classe Vector será aperfeiçoada com operadores infixos no [operator_overloading]. Nosso objetivo aqui foi explorar técnicas para programação de métodos especiais que são úteis em uma grande variedade de classes de coleções.

Resumo do capítulo

A classe Vector, o exemplo que desenvolvemos nesse capítulo, foi projetada para ser compatível com Vector2d, exceto pelo uso de uma assinatura de construtor diferente, aceitando um único argumento iterável, como fazem todos os tipos embutidos de sequências. O fato de Vector se comportar como uma sequência apenas por implementar __getitem__ e __len__ deu margem a uma discussão sobre protocolos, as interfaces informais usadas em linguagens com duck typing.

A seguir vimos como a sintaxe my_seq[a:b:c] funciona por baixo dos panos, criando um objeto slice(a, b, c) e entregando esse objeto a __getitem__. Armados com esse conhecimento, fizemos Vector responder corretamente ao fatiamento, devolvendo novas instâncias de Vector, como se espera de qualquer sequência pythônica.

O próximo passo foi fornecer acesso somente para leitura aos primeiros componentes de Vector, usando uma notação do tipo my_vec.x. Fizemos isso implementando __getattr__. Fazer isso abriu a possibilidade de incentivar o usuário a atribuir àqueles componentes especiais, usando a forma my_vec.x = 7, revelando um possível bug. Consertamos o problema implementando também __setattr__, para barrar a atribuição de valores a atributos cujos nomes tenham apenas uma letra. É comum, após escrever um __getattr__, ser necessário adicionar também __setattr__, para evitar comportamento inconsistente.

Implementar a função __hash__ nos deu um contexto perfeito para usar functools.reduce, pois precisávamos aplicar o operador xor (^) sucessivamente aos hashes de todos os componentes de Vector, para produzir um código de hash agregado referente a todo o Vector. Após aplicar reduce em __hash__, usamos a função embutida de redução all, para criar um método __eq__ mais eficiente.

O último aperfeiçoamento a Vector foi reimplementar o método __format__ de Vector2d, para suportar coordenadas esféricas como alternativa às coordenadas cartesianas default. Usamos bastante matemática e vários geradores para programar __format__ e suas funções auxiliares, mas esses são detalhes de implementação—e voltaremos aos geradores no [iterables2generators]. O objetivo daquela última seção foi suportar um formato personalizado, cumprindo assim a promessa de um Vector capaz de fazer tudo que um Vector2d faz e algo mais.

Como fizemos no [pythonic_objects], muitas vezes aqui olhamos como os objetos padrão do Python se comportam, para emulá-los e dar a Vector uma aparência "pythônica".

No [operator_overloading] vamos implemenar vários operadores infixos em Vector. A matemática será muito mais simples que aquela no método angle() daqui, mas explorar como os operadores infixos funcionam no Python é uma grande lição sobre design orientado a objetos. Mas antes de chegar à sobrecarga de operadores, vamos parar um pouco de trabalhar com uma única classe e olhar para a organização de múltiplas classes com interfaces e herança, os assuntos dos capítulos #ifaces_prot_abc e #inheritance.

Leitura complementar

A maioria dos métodos especiais tratados no exemplo de Vector também apareceram no exemplo do Vector2d, no [pythonic_objects], então as referências na [pythonic_further_reading] ali são todas relevantes aqui também.

A poderosa função de ordem superior reduce também é conhecida como fold (dobrar), accumulate (acumular), aggregate (agregar), compress (comprimir), e inject (injetar). Para mais informações, veja o artigo "Fold (higher-order function)" ("Dobrar (função de ordem superior)") (EN), que apresenta aplicações daquela função de ordem superior, com ênfase em programação funcional com estruturas de dados recursivas. O artigo também inclui uma tabela mostrando funções similares a fold em dezenas de linguagens de programação.

Em "What’s New in Python 2.5" (Novidades no Python 2.5) (EN) há uma pequena explicação sobre __index__, projetado para suportar métodos __getitem__, como vimos na Um __getitem__ que trata fatias. A PEP 357—Allowing Any Object to be Used for Slicing (Permitir que Qualquer Objeto seja Usado para Fatiamento) detalha a necessidade daquele método especial na perspectiva de um implementador de uma extensão em C—Travis Oliphant, o principal criador da NumPy. As muitas contribuições de Oliphant tornaram o Python uma importante linguagem para computação científica, que por sua vez posicionou a linguagem como a escolha preferencial para aplicações de aprendizagem de máquina.

Ponto de vista

Protocolos como interfaces informais

Protocolos não são uma invenção do Python. Os criadores do Smalltalk, que também cunharam a expressão "orientado a objetos", usavam "protocolo" como um sinônimo para aquilo que hoje chamamos de interfaces. Alguns ambientes de programação Smalltalk permitiam que os programadores marcassem um grupo de métodos como um protocolo, mas isso era meramente um artefato de documentação e navegação, e não era imposto pela linguagem. Por isso acredito que "interface informal" é uma explicação curta razoável para "protocolo" quando falo para uma audiência mais familiar com interfaces formais (e impostas pelo compilador).

Protocolos bem estabelecidos ou consagrados evoluem naturalmente em qualquer linguagem que usa tipagem dinâmica (isto é, quando a verificação de tipo acontece durante a execução), porque não há informação estática de tipo em assinaturas de métodos e em variáveis. Ruby é outra importante linguagem orientada a objetos que tem tipagem dinâmica e usa protocolos.

Na documentação do Python, muitas vezes podemos perceber que um protocolo está sendo discutido pelo uso de linguagem como "um objeto similar a um arquivo". Isso é uma forma abreviada de dizer "algo que se comporta como um arquivo, implementando as partes da interface de arquivo relevantes ao contexto".

Você poderia achar que implementar apenas parte de um protocolo é um desleixo, mas isso tem a vantagem de manter as coisas simples. A Seção 3.3 do capítulo "Modelo de Dados" na documentação do Python sugere que:

Ao implementar uma classe que emula qualquer tipo embutido, é importante que a emulação seja implementada apenas na medida em que faça sentido para o objeto que está sendo modelado. Por exemplo, algumas sequências podem funcionar bem com a recuperação de elementos individuais, mas extrair uma fatia pode não fazer sentido.

Quando não precisamos escrever métodos inúteis apenas para cumprir o contrato de uma interface excessivamente detalhista e para manter o compilador feliz, fica mais fácil seguir o princípio KISS.

Por outro lado, se você quiser usar um verificador de tipo para checar suas implementações de protocolos, então uma definição mais estrita de "protocolo" é necessária. É isso que typing.Protocol nos fornece.

Terei mais a dizer sobre protocolos e interfaces no [ifaces_prot_abc], onde esses conceitos são o assunto principal.

As origens do duck typing

Creio que a comunidade Ruby, mais que qualquer outra, ajudou a popularizar o termo "duck typing" (tipagem pato), ao pregar para as massas de usuários de Java. Mas a expressão já era usada nas discussões do Python muito antes do Ruby ou do Python se tornarem "populares". De acordo com a Wikipedia, um dos primeiros exemplos de uso da analogia do pato, no contexto da programação orientada a objetos, foi uma mensagem para Python-list (EN), escrita por Alex Martelli e datada de 26 de julho de 2000: "polymorphism (was Re: Type checking in python?)" (polimorfismo (era Re: Verificação de tipo em python?)). Foi dali que veio a citação no início desse capítulo. Se você tiver curiosidade sobre as origens literárias do termo "duck typing", e a aplicação desse conceito de orientação a objetos em muitas linguagens, veja a página "Duck typing" na Wikipedia.

Um __format__ seguro, com usabilidade aperfeiçoada

Ao implementar __format__, não tomei qualquer precaução a respeito de instâncias de Vector com um número muito grande de componentes, como fizemos no __repr__ usando reprlib. A justificativa é que repr() é usado para depuração e registro de logs, então precisa sempre gerar uma saída minimamente aproveitável, enquanto __format__ é usado para exibir resultados para usuários finais, que presumivelmente desejam ver o Vector inteiro. Se isso for considerado inconveniente, então seria legal implementar um nova extensão à Minilinguagem de especificação de formato.

O quê eu faria: por default, qualquer Vector formatado mostraria um número razoável mas limitado de componentes, digamos uns 30. Se existirem mais elementos que isso, o comportamento default seria similar ao de reprlib: cortar o excesso e colocar …​ em seu lugar. Entretanto, se o especificador de formato terminar com um código especial *, significando "all" (todos), então a limitação de tamanho seria desabilitada. Assim, um usuário ignorante do problema de exibição de vetores muito grandes não será acidentalmente penalizado. Mas se a limitação default se tornar incômoda, a presença das …​ iria incentivar o usuário a consultar a documentação e descobrir o código de formatação * .

A busca por uma soma pythônica

Não há uma resposta única para a "O que é pythônico?", da mesma forma que não há uma resposta única para "O que é belo?". Dizer, como eu mesmo muitas vezes faço, que significa usar "Python idiomático" não é 100% satisfatório, porque talvez o que é "idiomático" para você não seja para mim. Sei de uma coisa: "idiomático" não significa o uso dos recursos mais obscuros da linguagem.

Na Python-list (EN), há uma thread de abril de 2003 chamada "Pythonic Way to Sum n-th List Element?" (A forma pythônica de somar os "n" elementos de uma lista). Ela é relevante para nossa discussão de reduce acima nesse capítulo.

O autor original, Guy Middleton, pediu melhorias para essa solução, afirmando não gostar de usar lambda:[7]

>>> my_list = [[1, 2, 3], [40, 50, 60], [9, 8, 7]]
>>> import functools
>>> functools.reduce(lambda a, b: a+b, [sub[1] for sub in my_list])
60

Esse código usa muitos idiomas: lambda, reduce e uma compreensão de lista. Ele provavelmente ficaria em último lugar em um concurso de popularidade, pois ofende quem odeia lambda e também aqueles que desprezam as compreensões de lista—praticamente os dois lados de uma disputa.

Se você vai usar lambda, provavelmente não há razão para usar uma compreensão de lista—exceto para filtragem, que não é o caso aqui.

Aqui está uma solução minha que agradará os amantes de lambda:

>>> functools.reduce(lambda a, b: a + b[1], my_list, 0)
60

Não tomei parte na discussão original, e não usaria o trecho acima em código real, pois eu também não gosto muito de lambda. Mas eu queria mostrar um exemplo sem uma compreensão de lista.

A primeira resposta veio de Fernando Perez, criador do IPython, e realça como o NumPy suporta arrays n-dimensionais e fatiamento n-dimensional:

>>> import numpy as np
>>> my_array = np.array(my_list)
>>> np.sum(my_array[:, 1])
60

Acho a solução de Perez boa, mas Guy Middleton elegiou essa próxima solução, de Paul Rubin e Skip Montanaro:

>>> import operator
>>> functools.reduce(operator.add, [sub[1] for sub in my_list], 0)
60

Então Evan Simpson perguntou, "Há algo errado com isso?":

>>> total = 0
>>> for sub in my_list:
...     total += sub[1]
...
>>> total
60

Muitos concordaram que esse código era bastante pythônico. Alex Martelli chegou a dizer que provavelmente seria assim que Guido escreveria a solução.

Gosto do código de Evan Simpson, mas também gosto do comentário de David Eppstein sobre ele:

Se você quer a soma de uma lista de itens, deveria escrever isso para se parecer com "a soma de uma lista de itens", não para se parecer com "faça um loop sobre esses itens, mantenha uma outra variável t, execute uma sequência de adições". Por que outra razão temos linguagens de alto nível, senão para expressar nossas intenções em um nível mais alto e deixar a linguagem se preocupar com as operações de baixo nível necessárias para implementá-las?

E daí Alex Martelli voltou para sugerir:

A soma é necessária com tanta frequência que eu não me importaria de forma alguma se o Python a tornasse uma função embutida. Mas "reduce(operator.add, …​" não é mesmo uma boa maneira de expressar isso, na minha opinião (e vejam que, como um antigo APLista[8] e um apreciador da FP[9], eu deveria gostar daquilo, mas não gosto.).

Martelli então sugere uma função sum(), que ele mesmo programa e propõe para o Python. Ela se torna uma função embutida no Python 2.3, lançado apenas três meses após aquela conversa na lista. E a sintaxe preferida de Alex se torna a regra:

>>> sum([sub[1] for sub in my_list])
60

No final do ano seguinte (novembro de 2004), o Python 2.4 foi lançado e incluía expressões geradoras, fornecendo o que agora é, na minha opinião, a resposta mais pythônica para a pergunta original de Guy Middleton:

>>> sum(sub[1] for sub in my_list)
60

Isso não só é mais legível que reduce, também evita a armadilha da sequência vazia: sum([]) é 0, simples assim.

Na mesma conversa, Alex Martelli sugeriu que a função embutida reduce do Python 2 trazia mais problemas que soluções, porque encorajava idiomas de programação difíceis de explicar. Ele foi bastante convincente: a função foi rebaixada para o módulo functools no Python 3.

Ainda assim, functools.reduce tem seus usos. Ela resolveu o problema de nosso Vector.__hash__ de uma forma que eu chamaria de pythônica.


1. A função iter() é tratada no [iterables2generators], juntamente com o método __iter__.
2. A pesquisa de atributos é mais complicada que isso; veremos todos detalhes macabros desse processo no [metaprog_part]. Por ora, essa explicação simplificada nos serve.
3. Apesar de __match_args__ existir para suportar pattern matching desde Python 3.10, definir este atributo em versões anteriores da linguagem é inofensivo. Na primeira edição chamei este atributo de shortcut_names. Com o novo nome, ele cumpre dois papéis: suportar padrões posicionais em instruções case e manter os nomes dos atributos dinâmicos suportados por uma lógica especial em __getattr__ e __setattr__.
4. sum, any, e all cobrem a maioria dos casos de uso comuns de reduce. Veja a discussão na [map_filter_reduce].
5. Vamos considerar seriamente o caso de Vector([1, 2]) == (1, 2) na [op_overloading_101_sec].
6. O website Wolfram Mathworld tem um artigo sobre hypersphere (hiperesfera) (EN); na Wikipedia, "hypersphere" redireciona para a página “n-sphere” (EN)footnote:[NT: A Wikipedia tem uma página em português, "N-esfera". Entretanto, enquanto a versão em inglês traz uma extensa explicação matemática, dividida em 12 seções e inúmeras subseções, a versão em português se resume a um parágrafo curto. Preferimos então manter o link para a versão mais completa.
7. Adaptei o código apresentado aqui: em 2003, reduce era uma função embutida, mas no Python 3 precisamos importá-la; também substitui os nomes x e y por my_list e sub (para sub-lista).
8. NT: Aqui Martelli está se referindo à linguagem APL
9. NT:E aqui à linguagem FP