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)
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
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.
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.
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.
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
.
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 |
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]).
link:code/12-seq-hacking/vector_v1.py[role=include]
-
O atributo de instância "protegido"
self._components
vai manter umarray
com os componentes doVector
. -
Para permitir iteração, devolvemos um itereador sobre self._components.[1]
-
Usa
reprlib.repr()
para obter um representação de tamanho limitado deself._components
(por exemplo,array('d', [0.0, 1.0, 2.0, 3.0, 4.0, …])
). -
Remove o prefixo
array('d',
e o)
final, antes de inserir a string em uma chamada ao construtor deVector
. -
Cria um objeto
bytes
diretamente deself._components
. -
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))
. -
A única mudança necessária no
frombytes
anterior é na última linha: passamos amemoryview
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 |
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.
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.
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 |
Vamos agora implementar o protocolo sequência em Vector
, primeiro sem suporte adequado ao fatiamento, que acrescentaremos mais tarde.
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__(...)
.
Uma demonstração vale mais que mil palavras, então dê uma olhada no Exemplo 4.
__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))
-
Para essa demonstração, o método
__getitem__
simplesmente devolve o que for passado a ele. -
Um único índice, nada de novo.
-
A notação
1:4
se tornaslice(1, 4, None)
. -
slice(1, 4, 2)
significa comece em 1, pare em 4, ande de 2 em 2. -
Surpresa: a presença de vírgulas dentro do
[]
significa que__getitem__
recebe uma tupla. -
A tupla pode inclusive conter vários objetos
slice
.
Vamos agora olhar mais de perto a própria classe slice
, no Exemplo 5.
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']
-
slice
é um tipo embutido (que já vimos antes na [slice_objects]). -
Inspecionando uma
slice
descobrimos os atributos de dadosstart
,stop
, estep
, e um métodoindices
.
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 índicesstart
(início) estop
(fim), e a extensão dostride
(passo) da fatia estendida descrita porS
. Í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)
-
'ABCDE'[:10:2]
é o mesmo que'ABCDE'[0:5: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__
.
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).
__len__
e __getitem__
adicionados à classe Vector
, de vector_v1.py (no Exemplo 2)link:code/12-seq-hacking/vector_v2.py[role=include]
-
Se o argumento
key
é umaslice
… -
…obtém a classe da instância (isto é,
Vector
) e… -
…invoca a classe para criar outra instância de
Vector
a partir de uma fatia do array_components
. -
Se podemos obter um
index
dekey
… -
…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 |
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 .
Vector.__getitem__
aperfeiçoado, do Exemplo 6link:code/12-seq-hacking/vector_v2.py[role=include]
-
Um índice inteiro recupera apenas o valor de um componente, um
float
. -
Uma fatia como índice cria um novo
Vector
. -
Um fatia de
len == 1
também cria umVector
. -
Vector
não suporta indexação multidimensional, então tuplas de índices ou de fatias geram um erro.
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.
__getattr__
acrescentado à classe Vector
link:code/12-seq-hacking/vector_v3.py[role=include]
-
Define
__match_args__
para permitir pattern matching posicional sobre os atributos dinâmicos suportados por__getattr__
.[3] -
Obtém a classe de
Vector
, para uso posterior. -
Tenta obter a posição de
name
em__match_args__
. -
.index(name)
gera umValueError
quandoname
não é encontrado; definepos
como-1
. (Eu preferiria usar algo comostr.find
aqui, mastuple
não implementa esse método.) -
Se
pos
está dentro da faixa de componentes disponíveis, devolve aquele componente. -
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.
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)
-
Acessa o elemento
v[0]
comov.x
. -
Atribui um novo valor a
v.x
. Isso deveria gera uma exceção. -
Ler
v.x
obtém o novo valor,10
. -
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.
__setattr__
na classe Vector
link:code/12-seq-hacking/vector_v3.py[role=include]
-
Tratamento especial para nomes de atributos com uma única letra.
-
Se
name
está em__match_args__
, configura mensagens de erro específicas. -
Se
name
é uma letra minúscula, configura a mensagem de erro sobre todos os nomes de uma única letra. -
Caso contrário, configura uma mensagem de erro vazia.
-
Se existir uma mensagem de erro não-vazia, gera um
AttributeError
. -
Caso default: chama
__setattr__
na superclasse para obter o comportamento padrão.
Tip
|
A função |
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 |
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.
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
.
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 lst
—fn(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
.
>>> 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
-
xor agregado com um loop
for
e uma variável de acumulação. -
functools.reduce
usando uma função anônima. -
functools.reduce
substituindo alambda
personalizada poroperator.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.
__hash__
adicionados à classe Vector
de vector_v3.pyfrom 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...
-
Importa
functools
para usarreduce
. -
Importa
operator
para usarxor
. -
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. -
Cria uma expressão geradora para computar sob demanda o hash de cada componente.
-
Alimenta
reduce
comhashes
e a funçãoxor
, para computar o código hash agregado; o terceiro argumento,0
, é o inicializador (veja o próximo aviso).
Warning
|
Ao usar |
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).
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 |
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.
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)
-
Se as
len
dos objetos são diferentes, eles não são iguais. -
zip
produz um gerador de tuplas criadas a partir dos itens em cada argumento iterável. Veja a caixa O fantástico zip, sezip
for novidade para você. Em , a comparação comlen
é necessária porquezip
para de produzir valores sem qualquer aviso quando uma das fontes de entrada se exaure. -
Sai assim que dois componentes sejam diferentes, devolvendo
False
. -
Caso contrário, os objetos são iguais.
Tip
|
O nome da função |
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
.
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.
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.
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)]
-
zip
devolve um gerador que produz tuplas sob demanda. -
Cria uma
list
apenas para exibição; nós normalmente iteramos sobre o gerador. -
zip
para sem aviso quando um dos iteráveis é exaurido. -
A função
itertools.zip_longest
se comporta de forma diferente: ela usa umfillvalue
opcional (por defaultNone
) 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 |
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
.
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 ( |
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
Vector
; as notas explicativas enfatizam os acréscimos necessários para suportar __format__
link:code/12-seq-hacking/vector_v5.py[role=include]
-
Importa
itertools
para usar a funçãochain
em__format__
. -
Computa uma das coordendas angulares, usando fórmulas adaptadas do artigo n-sphere (EN: ver Nota 6) na Wikipedia.
-
Cria uma expressão geradora para computar sob demanda todas as coordenadas angulares.
-
Produz uma genexp usando
itertools.chain
, para iterar de forma contínua sobre a magnitude e as coordenadas angulares. -
Configura uma coordenada esférica para exibição, com os delimitadores de ângulo (
<
e>
). -
Configura uma coordenda cartesiana para exibição, com parênteses.
-
Cria uma expressão geradoras para formatar sob demanda cada item de coordenada.
-
Insere componentes formatados, separados por vírgulas, dentro de delimitadores ou parênteses.
Note
|
Estamos fazendo uso intensivo de expressões geradoras em |
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.
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.
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.
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.
__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__
.
sum
, any
, e all
cobrem a maioria dos casos de uso comuns de reduce
. Veja a discussão na [map_filter_reduce].
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).