Classes de dados são como crianças. São boas como um ponto de partida mas, para participarem como um objeto adulto, precisam assumir alguma responsabilidade.
Martin Fowler and Kent Beck em Refactoring, primeira edição, Capítulo 3, seção "Bad Smells in Code, Data Class" (Mau cheiro no código, classe de dados), página 87 (Addison-Wesley).
O Python oferece algumas formas de criar uma classe simples, apenas uma coleção de campos, com pouca ou nenhuma funcionalidade adicional.
Esse padrão é conhecido como "classe de dados"—e dataclasses
é um dos pacotes que suporta tal modelo.
Este capítulo trata de três diferentes fábricas de classes que podem ser utilizadas como atalhos para escrever classes de dados:
collections.namedtuple
-
A forma mais simples—disponível desde o Python 2.6.
typing.NamedTuple
-
Uma alternativa que requer dicas de tipo nos campos—desde o Python 3.5, com a sintaxe
class
adicionada no 3.6. @dataclasses.dataclass
-
Um decorador de classe que permite mais personalização que as alternativas anteriores, acrescentando várias opções e, potencialmente, mais complexidade—desde o Python 3.7.
Após falar sobre essas fábricas de classes, vamos discutir o motivo de classe de dados ser também o nome um code smell: um padrão de programação que pode ser um sintoma de um design orientado a objetos ruim.
Note
|
A classe Entretanto, |
Este capítulo é novo, aparece nessa segunda edição do Python Fluente. A Tuplas nomeadas clássicas era parte do capítulo 2 da primeira edição, mas o restante do capítulo é inteiramente inédito.
Vamos começar por uma visão geral, por alto, das três fábricas de classes.
Considere uma classe simples, representando um par de coordenadas geográficas, como aquela no Exemplo 1.
link:code/05-data-classes/class/coordinates.py[role=include]
A tarefa da classe Coordinate
é manter os atributos latitude e longitude.
Escrever o __init__
padrão fica cansativo muito rápido,
especialmente se sua classe tiver mais que alguns poucos atributos:
cada um deles é mencionado três vezes!
E aquele código repetitivo não nos dá sequer os recursos básicos que esperamos de um objeto Python:
>>> from coordinates import Coordinate
>>> moscow = Coordinate(55.76, 37.62)
>>> moscow
<coordinates.Coordinate object at 0x107142f10> (1)
>>> location = Coordinate(55.76, 37.62)
>>> location == moscow (2)
False
>>> (location.lat, location.lon) == (moscow.lat, moscow.lon) (3)
True
-
O
__repr__
herdado deobject
não é muito útil. -
O
==
não faz sentido; o método__eq__
herdado deobject
compara os IDs dos objetos. -
Comparar duas coordenadas exige a comparação explícita de cada atributo.
As fábricas de classes de dados tratadas nesse capítulo fornecem automaticamente os métodos __init__
, __repr__
, e __eq__
necessários, além alguns outros recursos úteis.
Note
|
Nenhuma das fábricas de classes discutidas aqui depende de herança para funcionar. Tanto |
Aqui está uma classe Coordinate
criada com uma namedtuple
—uma função fábrica que cria uma subclasse de tuple
com o nome e os campos especificados:
>>> from collections import namedtuple
>>> Coordinate = namedtuple('Coordinate', 'lat lon')
>>> issubclass(Coordinate, tuple)
True
>>> moscow = Coordinate(55.756, 37.617)
>>> moscow
Coordinate(lat=55.756, lon=37.617) (1)
>>> moscow == Coordinate(lat=55.756, lon=37.617) (2)
True
-
Um
__repr__
útil. -
Um
__eq__
que faz sentido.
A typing.NamedTuple
, mais recente, oferece a mesma funcionalidade e acrescenta anotações de tipo a cada campo:
>>> import typing
>>> Coordinate = typing.NamedTuple('Coordinate',
... [('lat', float), ('lon', float)])
>>> issubclass(Coordinate, tuple)
True
>>> typing.get_type_hints(Coordinate)
{'lat': <class 'float'>, 'lon': <class 'float'>}
Tip
|
Uma tupla nomeada e com dicas de tipo pode também ser construída passando os campos como argumentos nomeados, assim: Coordinate = typing.NamedTuple('Coordinate', lat=float, lon=float) Além de ser mais legível, essa forma permite fornecer o mapeamento de campos e tipos como |
Desde o Python 3.6, typing.NamedTuple
pode também ser usada em uma instrução class
,
com as anotações de tipo escritas como descrito na PEP 526—Syntax for Variable Annotations (Sintaxe para Anotações de Variáveis) (EN).
É muito mais legível, e torna fácil sobrepor métodos ou acrescentar métodos novos.
O Exemplo 2 é a mesma classe Coordinate
, com um par de atributos float
e um __str__
personalziado, para mostrar a coordenada no formato 55.8°N, 37.6°E.
link:code/05-data-classes/typing_namedtuple/coordinates.py[role=include]
Warning
|
Apesar de >>> issubclass(Coordinate, typing.NamedTuple)
False
>>> issubclass(Coordinate, tuple)
True |
No método __init__
gerado por typing.NamedTuple
, os campos aparecem como parâmetros e na mesma ordem em que aparecem na declaração class
.
Assim como typing.NamedTuple
, o decorador dataclass
suporta a sintaxe da PEP 526 (EN) para declarar atributos de instância.
O decorador lê as anotações das variáveis e gera métodos automaticamente para sua classe.
Como comparação, veja a classe Coordinate
equivante escrita com a ajuda do decorador dataclass
, como mostra o Exemplo 3.
link:code/05-data-classes/dataclass/coordinates.py[role=include]
Observe que o corpo das classes no Exemplo 2 e no Exemplo 3 são idênticos—a diferença está na própria declaração class
.
O decorador @dataclass
não depende de herança ou de uma metaclasse, então não deve interferir no uso desses mecanismos pelo usuário.[2]
A classe Coordinate
no Exemplo 3 é uma subclasse de object
.
As diferentes fábricas de classes de dados tem muito em comum, como resume a Tabela 1.
x
é uma instância de uma classe de dados daquele tipo
namedtuple | NamedTuple | dataclass | |
---|---|---|---|
instâncias mutáveis |
NÃO |
NÃO |
SIM |
sintaxe de declaração de classe |
NÃO |
SIM |
SIM |
criar um dict |
x._asdict() |
x._asdict() |
dataclasses.asdict(x) |
obter nomes dos campos |
x._fields |
x._fields |
[f.name for f in dataclasses.fields(x)] |
obter defaults |
x._field_defaults |
x._field_defaults |
[f.default for f in dataclasses.fields(x)] |
obter tipos dos campos |
N/A |
x.__annotations__ |
x.__annotations__ |
nova instância com modificações |
x._replace(…) |
x._replace(…) |
dataclasses.replace(x, …) |
nova classe durante a execução |
namedtuple(…) |
NamedTuple(…) |
dataclasses.make_dataclass(…) |
Warning
|
As classes criadas por |
Vamos agora detalhar aqueles recursos principais.
A diferença fundamental entre essas três fábricas de classes é que collections.namedtuple
e typing.NamedTuple
criam subclasses de tuple
, e portanto as instâncias são imutáveis. Por default, @dataclass
produz classes mutáveis. Mas o decorador aceita o argumento nomeado frozen
—que aparece no Exemplo 3. Quando frozen=True
, a classe vai gerar uma exceção se você tentar atribuir um valor a um campo após a instância ter sido inicializada.
Apenas typing.NamedTuple
e dataclass
suportam a sintaxe de declaração de class
regular, tornando mais fácil acrescentar métodos e docstrings à classe que está sendo criada.
As duas variantes de tuplas nomeadas fornecem um método de instância (._asdict
), para construir um objeto dict
a partir dos campos de uma instância de classe de dados.
O módulo dataclasses
fornece uma função para fazer o mesmo: dataclasses.asdict
.
Todas as três fábricas de classes permitem que você obtenha os nomes dos campos e os valores default (que podem ser configurados para cada campo).
Nas classes de tuplas nomeadas, aqueles metadados estão nos atributos de classe ._fields
e ._fields_defaults
.
Você pode obter os mesmos metadados em uma classe decorada com dataclass
usando a função fields
do módulo dataclasses
. Ele devolve uma tupla de objetos Field
com vários atributos, incluindo name
e default
.
Classes definidas com a ajuda de typing.NamedTuple
e @dataclass
contêm um mapeamento dos nomes dos campos para seus tipos, o atributo de classe __annotations__
.
Como já mencionado, use a função typing.get_type_hints
em vez de ler diretamente de
__annotations__
.
Dada uma instância de tupla nomeada x
, a chamada x._replace(**kwargs)
devolve uma nova instância com os valores de alguns atributos modificados, de acordo com os argumentos nomeados incluídos na chamada. A função de módulo dataclasses.replace(x, **kwargs)
faz o mesmo para uma instância de uma classe decorada com dataclass
.
Apesar da sintaxe de declaração de classe ser mais legível, ela é fixa no código. Um framework pode ter a necessidade de criar classes de dados durante a execução. Para tanto, podemos usar a sintaxe default de chamada de função de collections.namedtuple
, que também é suportada por typing.NamedTuple
. O módulo dataclasses
oferece a função make_dataclass
, com o mesmo propósito.
Após essa visão geral dos principais recursos das fábricas de classes de dados, vamos examinar cada uma delas mais de perto, começando pela mais simples.
A função collections.namedtuple
é uma fábrica que cria subclasses de tuple
, acrescidas de nomes de campos, um nome de classe, e um __repr__
informativo.
Classes criadas com namedtuple
podem ser usadas onde quer que uma tupla seja necessária. Na verdade, muitas funções da biblioteca padrão, que antes devolviam tuplas agora devolvem, por conveniência, tuplas nomeadas, sem afetar de forma alguma o código do usuário.
Tip
|
Cada instância de uma classe criada por |
O Exemplo 4 mostra como poderíamos definir uma tupla nomeada para manter informações sobre uma cidade.
>>> from collections import namedtuple
>>> City = namedtuple('City', 'name country population coordinates') (1)
>>> tokyo = City('Tokyo', 'JP', 36.933, (35.689722, 139.691667)) (2)
>>> tokyo
City(name='Tokyo', country='JP', population=36.933, coordinates=(35.689722,
139.691667))
>>> tokyo.population (3)
36.933
>>> tokyo.coordinates
(35.689722, 139.691667)
>>> tokyo[1]
'JP'
-
São necessários dois parâmetros para criar uma tupla nomeada: um nome de classe e uma lista de nomes de campos, que podem ser passados como um iterável de strings ou como uma única string com os nomes delimitados por espaços.
-
Na inicialização de uma instância, os valores dos campos devem ser passados como argumentos posicionais separados (uma
tuple
, por outro lado, é inicializada com um único iterável) -
É possível acessar os campos por nome ou por posição.
Como uma subclasse de tuple
, City
herda métodos úteis, tal como __eq__
e os métodos especiais para operadores de comparação—incluindo __lt__
, que permite ordenar listas de instâncias de City
.
Uma tupla nomeada oferece alguns atributos e métodos além daqueles herdados de tuple
.
O Exemplo 5 demonstra os mais úteis dentre eles: o atributo de classe _fields
, o método de classe _make(iterable)
, e o método de instância _asdict()
.
>>> City._fields (1)
('name', 'country', 'population', 'location')
>>> Coordinate = namedtuple('Coordinate', 'lat lon')
>>> delhi_data = ('Delhi NCR', 'IN', 21.935, Coordinate(28.613889, 77.208889))
>>> delhi = City._make(delhi_data) (2)
>>> delhi._asdict() (3)
{'name': 'Delhi NCR', 'country': 'IN', 'population': 21.935,
'location': Coordinate(lat=28.613889, lon=77.208889)}
>>> import json
>>> json.dumps(delhi._asdict()) (4)
'{"name": "Delhi NCR", "country": "IN", "population": 21.935,
"location": [28.613889, 77.208889]}'
-
._fields
é uma tupla com os nomes dos campos da classe. -
._make()
cria umaCity
a partir de um iterável;City(*delhi_data)
faria o mesmo. -
._asdict()
devolve umdict
criado a partir da instância de tupla nomeada. -
._asdict()
é útil para serializar os dados no formato JSON, por exemplo.
Warning
|
Até o Python 3.7, o método |
Desde o Python 3.7, a namedtuple
aceita o argumento nomeado defaults
, fornecendo um iterável de N valores default para cada um dos N campos mais à direita na definição da classe.
O Exemplo 6 mostra como definir uma tupla nomeada Coordinate
com um valor default para o campo reference
.
>>> Coordinate = namedtuple('Coordinate', 'lat lon reference', defaults=['WGS84'])
>>> Coordinate(0, 0)
Coordinate(lat=0, lon=0, reference='WGS84')
>>> Coordinate._field_defaults
{'reference': 'WGS84'}
Na Sintaxe de declaração de classe, mencionei que é mais fácil programar métodos com a sintaxe de classe suportada por typing.NamedTuple
and @dataclass
.
Você também pode acrescentar métodos a uma namedtuple
, mas é um remendo.
Pule a próxima caixinha se você não estiver interessada em gambiarras.
Lembre como criamos a classe Card
class no [ex_pythonic_deck] do [data_model]:
Card = collections.namedtuple('Card', ['rank', 'suit'])
Mas tarde no [data_model], escrevi uma função spades_high
, para ordenação.
Seria bom que aquela lógica estivesse encapsulada em um método de Card
,
mas acrescentar spades_high
a Card
sem usar uma declaração class
exige um remendo rápido: definir a função e então atribuí-la a um atributo de classe. O Exemplo 7 mostra como isso é feito:
Card
, a namedtuple
da [pythonic_card_deck]link:code/05-data-classes/frenchdeck.doctest[role=include]
-
Acrescenta um atributo de classe com valores para cada naipe.
-
A função
spades_high
vai se tornar um método; o primeiro argumento não precisa ser chamado deself
. Como um método, ela de qualquer forma terá acesso à instância que recebe a chamada. -
Anexa a função à classe
Card
como um método chamadooverall_rank
. -
Funciona!
Para uma melhor legibilidade e para ajudar na manutenção futura, é muito melhor programar métodos dentro de uma declaração class
.
Mas é bom saber que essa gambiarra é possível, pois às vezes pode ser útil.[3]
Isso foi apenas um pequeno desvio para demonstrar o poder de uma linguagem dinâmica.
Agora vamos ver a variante typing.NamedTuple
.
A classe Coordinate
com um campo default, do Exemplo 6, pode ser escrita usando typing.NamedTuple
, como se vê no Exemplo 8.
link:code/05-data-classes/typing_namedtuple/coordinates2.py[role=include]
-
Todo campo de instância precisa ter uma anotação de tipo.
-
O campo de instância
reference
é anotado com um tipo e um valor default.
As classes criadas por typing.NamedTuple
não tem qualquer método além daqueles que collections.namedtuple
também gera—e aquele herdados de tuple
.
A única diferença é a presença do atributo de classe __annotations__
—que o Python ignora completamente durante a execução do programa.
Dado que o principal recurso de typing.NamedTuple
são as anotações de tipo, vamos dar uma rápida olhada nisso antes de continuar nossa exploração das fábricas de classes de dados.
Dicas de tipo—também chamadas anotações de tipo—são formas de declarar o tipo esperado dos argumentos, dos valores devolvidos, das variáveis e dos atributos de funções.
A primeira coisa que você precisa saber sobre dicas de tipo é que elas não são impostas de forma alguma pelo compilador de bytecode ou pelo interpretador do Python.
Note
|
Essa é uma introdução muito breve sobre dicas de tipo, suficiente apenas para que a sintaxe e o propósito das anotações usadas nas declarações de |
Pense nas dicas de tipo do Python como "documentação que pode ser verificada por IDEs e verificadores de tipo".
Isso porque as dicas de tipo não tem qualquer impacto sobre o comportamento de programas em Python durante a execução. Veja o Exemplo 9.
>>> import typing
>>> class Coordinate(typing.NamedTuple):
... lat: float
... lon: float
...
>>> trash = Coordinate('Ni!', None)
>>> print(trash)
Coordinate(lat='Ni!', lon=None) # (1)
-
Eu avisei: não há verificação de tipo durante a execução!
Se você incluir o código do Exemplo 9 em um módulo do Python,
ela vai rodar e exibir uma Coordinate
sem sentido, e sem gerar qualquer erro ou aviso:
$ python3 nocheck_demo.py
Coordinate(lat='Ni!', lon=None)
O objetivo primário das dicas de tipo é ajudar os verificadores de tipo externos, como o Mypy ou o verificador de tipo embutido do PyCharm IDE. Essas são ferramentas de análise estática: elas verificam código-fonte Python "parado", não código em execução.
Para observar o efeito das dicas de tipo, é necessário executar umas dessas ferramentas sobre seu código—como um linter (analisador de código). Por exemplo, eis o quê o Mypy tem a dizer sobre o exemplo anterior:
$ mypy nocheck_demo.py
nocheck_demo.py:8: error: Argument 1 to "Coordinate" has
incompatible type "str"; expected "float"
nocheck_demo.py:8: error: Argument 2 to "Coordinate" has
incompatible type "None"; expected "float"
Como se vê, dada a definição de Coordinate
, o Mypy sabe que os dois argumentos para criar um instância devem ser do tipo float
,
mas atribuição a trash
usa uma str
e None
.[4]
Vamos falar agora sobre a sintaxe e o significado das dicas de tipo.
Tanto typing.NamedTuple
quanto @dataclass
usam a sintaxe de anotações de variáveis definida na PEP 526 (EN).
Vamos ver aqui uma pequena introdução àquela sintaxe, no contexto da definição de atributos em declarações class
.
A sintaxe básica da anotação de variáveis é :
var_name: some_type
A seção "Acceptable type hints" (_Dicas de tipo aceitáveis), na PEP 484, explica o que são tipo aceitáveis. Porém, no contexto da definição de uma classe de dados, os tipos mais úteis geralmente serão os seguintes:
-
Uma classe concreta, por exemplo
str
ouFrenchDeck
. -
Um tipo de coleção parametrizada, como
list[int]
,tuple[str, float]
, etc. -
typing.Optional
, por exemploOptional[str]
—para declarar um campo que pode ser umastr
ouNone
.
Você também pode inicializar uma variável com um valor. Em uma declaração de typing.NamedTuple
ou @dataclass
, aquele valor se tornará o default daquele atributo quando o argumento correspondente for omitido na chamada de inicialização:
var_name: some_type = a_value
Vimos, no tópico Nenhum efeito durante a execução, que dicas de tipo não tem qualquer efeito durante a execução de um programa.
Mas no momento da importação—quando um módulo é carregado—o Python as lê para construir o dicionário __annotations__
, que typing.NamedTuple
e @dataclass
então usam para aprimorar a classe.
Vamos começar essa exploração no Exemplo 10, com uma classe simples,
para mais tarde ver que recursos adicionais são acrescentados por typing.NamedTuple
e @dataclass
.
link:code/05-data-classes/meaning/demo_plain.py[role=include]
-
a
se torna um registro em__annotations__
, mas é então descartada: nenhum atributo chamadoa
é criado na classe. -
b
é salvo como uma anotação, e também se torna um atributo de classe com o valor1.1
. -
c
é só um bom e velho atributo de classe básico, sem uma anotação.
Podemos checar isso no console, primeiro lendo o __annotations__
da DemoPlainClass
,
e daí tentando obter os atributos chamados a
, b
, e c
:
>>> from demo_plain import DemoPlainClass
>>> DemoPlainClass.__annotations__
{'a': <class 'int'>, 'b': <class 'float'>}
>>> DemoPlainClass.a
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: type object 'DemoPlainClass' has no attribute 'a'
>>> DemoPlainClass.b
1.1
>>> DemoPlainClass.c
'spam'
Observe que o atributo especial __annotations__
é criado pelo interpretador
para registrar dicas de tipo que aparecem no código-fonte—mesmo em uma classe básica.
O a
sobrevive apenas como uma anotação, não se torna um atributo da classe, porque nenhum valor é atribuído a
ele.[5]
O b
e o c
são armazenados como atributos de classe porque são vinculados a valores.
Nenhum desses três atributos estará em uma nova instância de DemoPlainClass
.
Se você criar um objeto o = DemoPlainClass()
, o.a
vai gerar um AttributeError
,
enquanto o.b
e o.c
vão obter os atributos de classe com os valores
1.1
e 'spam'
—que é apenas o comportamento normal de um objeto Python.
Agora vamos examinar uma classe criada com typing.NamedTuple
(Exemplo 11),
usando os mesmos atributos e anotações da DemoPlainClass
do Exemplo 10.
typing.NamedTuple
link:code/05-data-classes/meaning/demo_nt.py[role=include]
-
a
se torna uma anotação e também um atributo de instância. -
b
é outra anotação, mas também se torna um atributo de instância com o valor default1.1
. -
c
é só um bom e velho atributo de classe comum; não será mencionado em nenhuma anotação.
Inspecionando a DemoNTClass
, temos o seguinte:
>>> from demo_nt import DemoNTClass
>>> DemoNTClass.__annotations__
{'a': <class 'int'>, 'b': <class 'float'>}
>>> DemoNTClass.a
<_collections._tuplegetter object at 0x101f0f940>
>>> DemoNTClass.b
<_collections._tuplegetter object at 0x101f0f8b0>
>>> DemoNTClass.c
'spam'
Aqui vemos as mesmas anotações para a
e b
que vimos no Exemplo 10.
Mas typing.NamedTuple
cria os atributos de classe a
e b
.
O atributo c é apenas um atributo de classe simples, com o valor 'spam'
.
Os atributos de classe a
e b
são descritores (descriptors)—um recurso avançado tratado no [attribute_descriptors].
Por ora, pense neles como similares a um getter de propriedades do objeto[6]:
métodos que não exigem o operador explícito de chamada ()
para obter um atributo de instância.
Na prática, isso significa que a
e b
vão funcionar como atributos de instância somente para leitura—o que faz sentido, se lembrarmos que instâncias de DemoNTClass
são apenas tuplas chiques, e tuplas são imutáveis.
A DemoNTClass
também recebe uma docstring personalizada:
>>> DemoNTClass.__doc__
'DemoNTClass(a, b)'
Vamos examinar uma instância de DemoNTClass
:
>>> nt = DemoNTClass(8)
>>> nt.a
8
>>> nt.b
1.1
>>> nt.c
'spam'
Para criar nt
, precisamos passar pelo menos o argumento a
para DemoNTClass
. O construtor também aceita um argumento b
, mas como este último tem um valor default (de 1.1
), ele é opcional.
Como esperado, o objeto nt
possui os atributos a
e b
; ele não tem um atributo c
, mas o Python obtém c
da classe, como de hábito.
Se você tentar atribuir valores para nt.a
, nt.b
, nt.c
, ou mesmo para nt.z
, vai gerar uma exceção AttributeError
, com mensagens de erro sutilmente distintas. Tente fazer isso, e reflita sobre as mensagens.
Vamos agora examinar o Exemplo 12.
@dataclass
link:code/05-data-classes/meaning/demo_dc.py[role=include]
-
a
se torna uma anotação, e também um atributo de instância controlado por um descritor. -
b
é outra anotação, e também se torna um atributo de instância com um descritor e um valor default de1.1
. -
c
é apenas um atributo de classe comum; nenhuma anotação se refere a ele.
Podemos então verificar o __annotations__
, o __doc__
, e os atributos a
, b
, c
no DemoDataClass
:
>>> from demo_dc import DemoDataClass
>>> DemoDataClass.__annotations__
{'a': <class 'int'>, 'b': <class 'float'>}
>>> DemoDataClass.__doc__
'DemoDataClass(a: int, b: float = 1.1)'
>>> DemoDataClass.a
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: type object 'DemoDataClass' has no attribute 'a'
>>> DemoDataClass.b
1.1
>>> DemoDataClass.c
'spam'
O __annotations__
e o __doc__
não guardam surpresas.
Entretanto, não há um atributo chamado a
em DemoDataClass
—diferente do que ocorre na DemoNTClass
do Exemplo 11,
que inclui um descritor para obter a
das instâncias da classe, como atributos somente para leitura (aquele misterioso <_collections.tuplegetter>
).
Isso ocorre porque o atributo a
só existirá nas instâncias de DemoDataClass
.
Será um atributo público, que poderemos obter e definir, a menos que a classe seja frozen.
Mas b
e c
existem como atributos de classe, com b
contendo o valor default para o atributo de instância b
, enquanto c
é apenas um atributo de classe que não será vinculado a instâncias.
Vejamos como se parece uma instância de DemoDataClass
:
>>> dc = DemoDataClass(9)
>>> dc.a
9
>>> dc.b
1.1
>>> dc.c
'spam'
Novamente, a
e b
são atributos de instância, e c
é um atributo de classe obtido através da instância.
Como mencionado, instâncias de DemoDataClass
são mutáveis—e nenhuma verificação de tipo é realizada durante a execução:
>>> dc.a = 10
>>> dc.b = 'oops'
Podemos fazer atribuições ainda mais ridículas:
>>> dc.c = 'whatever'
>>> dc.z = 'secret stash'
Agora a instância dc
tem um atributo c
—mas isso não muda o atributo de classe c
.
E podemos adicionar um novo atributo z
.
Isso é o comportamento normal do Python: instâncias regulares podem ter seus próprios atributos, que não aparecem na classe.[7]
Até agora, só vimos exemplos simples do uso de @dataclass
. Esse decorador aceita vários argumentos nomeados. Esta é sua assinatura:
@dataclass(*, init=True, repr=True, eq=True, order=False,
unsafe_hash=False, frozen=False)
O *
na primeira posição significa que os parâmetros restantes são todos parâmetros nomeados. A Tabela 2 os descreve.
@dataclass
Option | Meaning | Default | Notes |
---|---|---|---|
|
Gera o |
|
Ignorado se o
|
|
Gera o |
|
Ignorado se o
|
|
Gera o |
|
Ignorado se o
|
|
Gera |
|
Se |
|
Gera o |
|
Semântica complexa e várias restrições—veja a: documentação de dataclass. |
|
Cria instâncias "imutáveis" |
|
As instâncias estarão razoavelmente protegidas contra mudanças acidentais, mas não serão realmente imutáveis.[8] |
Os defaults são, de fato, as configurações mais úteis para os casos de uso mais comuns. As opções mais prováveis de serem modificadas de seus defaults são:
frozen=True
-
Protege as instâncias da classe de modificações acidentais.
order=True
-
Permite ordenar as instâncias da classe de dados.
Dada a natureza dinâmica de objetos Python, não é muito difícil para um programador curioso contornar a proteção oferecida por frozen=True
. Mas os truques necessários são fáceis de perceber em uma revisão do código.
Se tanto o argumento eq
quanto o frozen
forem True
, @dataclass
produz um método
__hash__
adequado, e daí as instâncias serão hashable.
O __hash__
gerado usará dados de todos os campos que não forem individualmente excluídos usando uma opção de campo, que veremos na Opções de campo.
Se frozen=False
(o default), @dataclass
definirá __hash__
como None
, sinalizando que as instâncias não são hashable, e portanto sobrepondo o __hash__
de qualquer superclasse.
A PEP 557—Data Classes (Classe de Dados) (EN) diz o seguinte sobre unsafe_hash
:
Apesar de não ser recomendado, você pode forçar Classes de Dados a criarem um método
__hash__
comunsafe_hash=True
. Pode ser esse o caso, se sua classe for logicamente imutável e mesmo assim possa ser modificada. Este é um caso de uso especializado e deve ser considerado com cuidado.
Deixo o unsafe_hash
por aqui.
Se você achar que precisa usar essa opção, leia a
documentação de dataclasses.dataclass
.
Outras personalizações da classe de dados gerada podem ser feitas no nível dos campos.
Já vimos a opção de campo mais básica: fornecer (ou não) um valor default junto com a dica de tipo.
Os campos de instância declarados se tornarão parâmetros no __init__
gerado.
O Python não permite parâmetros sem um default após parâmetros com defaults.
Então, após declarar um campo com um valor default, cada um dos campos seguintes deve também ter um default.
Valores default mutáveis são a fonte mais comum de bugs entre desenvolvedores Python iniciantes.
Em definições de função, um valor default mutável é facilmente corrompido, quando uma invocação da função modifica o default, mudando o comportamento nas invocações posteriores—um tópico que vamos explorar na [mutable_default_parameter_sec] (no [mutability_and_references]).
Atributos de classe são frequentemente usados como valores default de atributos para instâncias, inclusive em classes de dados.
E o @dataclass
usa os valores default nas dicas de tipo para gerar parâmetros com defaults no
__init__
.
Para prevenir bugs, o @dataclass
rejeita a definição de classe que aparece no Exemplo 13.
ValueError
link:code/05-data-classes/dataclass/club_wrong.py[role=include]
Se você carregar o módulo com aquela classe ClubMember
, o resultado será esse:
$ python3 club_wrong.py
Traceback (most recent call last):
File "club_wrong.py", line 4, in <module>
class ClubMember:
...several lines omitted...
ValueError: mutable default <class 'list'> for field guests is not allowed:
use default_factory
A mensagem do ValueError
explica o problema e sugere uma solução: usar a default_factory
. O Exemplo 14 mostra como corrigir a ClubMember
.
ClubMember
funcionalink:code/05-data-classes/dataclass/club.py[role=include]
No campo guests
do Exemplo 14, em vez de uma lista literal, o valor default é definido chamando a função dataclasses.field
com default_factory=list
.
O parâmetro default_factory
permite que você forneça uma função, classe ou qualquer outro invocável, que será chamado com zero argumentos, para gerar um valor default a cada vez que uma instância da classe de dados for criada. Dessa forma, cada instância de ClubMember
terá sua própria list
—ao invés de todas as instâncias compartilharem a mesma list
da classe, que raramente é o que queremos, e muitas vezes é um bug.
Warning
|
É bom que |
Se você estudar a documentação do módulo dataclasses
, verá um campo list
definido com uma sintaxe nova, como no Exemplo 15.
ClubMember
é mais precisalink:code/05-data-classes/dataclass/club_generic.py[role=include]
-
list[str]
significa "uma lista de str."
A nova sintaxe list[str]
é um tipo genérico parametrizado: desde o Python 3.9,
o tipo embutido list
aceita aquela notação com colchetes para especificar o tipo dos itens da lista.
Warning
|
Antes do Python 3.9, as coleções embutidas não suportavam a notação de tipagem genérica. Como uma solução temporária, há tipos correspondentes de coleções no módulo |
Vamos tratar dos tipos genéricos no [type_hints_in_def_ch]. Por ora, observe que o Exemplo 14 e o Exemplo 15 estão ambos corretos, e que o verificador de tipagem Mypy não reclama de nenhuma das duas definições de classe.
A diferença é que aquele guests: list
significa que guests
pode ser uma list
de objetos de qualquer natureza, enquanto guests: list[str]
diz que guests
deve ser uma list
na qual cada item é uma str
.
Isso permite que o verificador de tipos encontre (alguns) bugs em código que insira itens inválidos na lista, ou que leia itens dali.
A default_factory
é possivelmente a opção mais comum da função field
, mas há várias outras, listadas na Tabela 3.
field
Option | Meaning | Default |
---|---|---|
|
Valor default para o campo |
|
|
função com 0 parâmetros usada para produzir um valor default |
|
|
Incluir o campo nos parâmetros de |
|
|
Incluir o campo em |
|
|
Usar o campo nos métodos de comparação |
|
|
Incluir o campo no cálculo de |
None[10] |
|
Mapeamento com dados definidos pelo usuário; ignorado por |
|
A opção default
existe porque a chamada a field
toma o lugar do valor default na anotação do campo. Se você quisesse criar um campo athlete
com o valor default False
, e também omitir aquele campo do método __repr__
, escreveria o seguinte:
@dataclass
class ClubMember:
name: str
guests: list = field(default_factory=list)
athlete: bool = field(default=False, repr=False)
O método __init__
gerado por @dataclass
apenas recebe os argumentos passados e os atribui—ou seus valores default, se o argumento não estiver presente—aos atributos de instância, que são campos da instância. Mas pode ser necessário fazer mais que isso para inicializar a instância. Se for esse o caso, você pode fornecer um método __post_init__
.
Quando esse método existir, @dataclass
acrescentará código ao __init__
gerado para invocar
__post_init__
como o último passo da inicialização.
Casos de uso comuns para __post_init__
são validação e o cálculo de valores de campos baseado em outros campos. Vamos estudar um exemplo simples, que usa __post_init__
pelos dois motivos.
Primeiro, dê uma olhada no comportamento esperado de uma subclasse de ClubMember
, chamada HackerClubMember
, como descrito por doctests no Exemplo 16.
HackerClubMember
link:code/05-data-classes/dataclass/hackerclub.py[role=include]
Observe que precisamos fornecer handle
como um argumento nomeado, pois HackerClubMember
herda name
e guests
de ClubMember
, e acrescenta o campo handle
. A docstring gerada para HackerClubMember
mostra a ordem dos campos na chamada de inicialização:
>>> HackerClubMember.__doc__
"HackerClubMember(name: str, guests: list = <factory>, handle: str = '')"
Aqui <factory>
é um caminho mais curto para dizer que algum invocável vai produzir o valor default para guests
(no nosso caso, a fábrica é a classe list
).
O ponto é o seguinte: para fornecer um handle
mas não um guests
, precisamos passar handle
como um argumento nomeado.
A seção "Herança na documentação do módulo dataclasses
explica como a ordem dos campos é analisada quando existem vários níveis de herança.
Note
|
No [inheritance] vamos falar sobre o uso indevido da herança, especialmente quando as superclasses não são abstratas.
Criar uma hierarquia de classes de dados é, em geral, uma má ideia, mas nos serviu bem aqui para tornar o Exemplo 17 mais curto, e permitir que nos concentrássemos na declaração do campo |
O Exemplo 17 mostra a implementação.
HackerClubMember
link:code/05-data-classes/dataclass/hackerclub.py[role=include]
-
HackerClubMember
estendeClubMember
. -
all_handles
é um atributo de classe. -
handle
é um campo de instância do tipostr
, com uma string vazia como valor default; isso o torna opcional. -
Obtém a classe da instância.
-
Se
self.handle
é a string vazia, a define como a primeira parte dename
. -
Se
self.handle
está emcls.all_handles
, gera umValueError
. -
Insere o novo
handle
emcls.all_handles
.
O Exemplo 17 funciona como esperado, mas não é satisfatório pra um verificador estático de tipos. A seguir veremos a razão disso, e como resolver o problema.
Se verificarmos os tipos de Exemplo 17 com o Mypy, seremos repreendidos:
$ mypy hackerclub.py
hackerclub.py:37: error: Need type annotation for "all_handles"
(hint: "all_handles: Set[<type>] = ...")
Found 1 error in 1 file (checked 1 source file)
Infelizmente, a dica fornecida pelo Mypy (versão 0.910 quando essa seção foi revisada) não é muito útil no contexto do uso de @dataclass
.
Primeiro, ele sugere usar Set
, mas desde o Python 3.9 podemos usar set
—sem a necessidade de importar Set
de typing
.
E mais importante, se acrescentarmos uma dica de tipo como set[…]
a all_handles
, @dataclass
vai encontrar essa anotação e transformar all_handles
em um campo de instância.
Vimos isso acontecer na Inspecionando uma classe decorada com dataclass.
A forma de contornar esse problema definida na
PEP 526—Syntax for Variable Annotations (Sintaxe para Anotações de Variáveis) (EN)
é horrível.
Para criar uma variável de classe com uma dica de tipo, precisamos usar um pseudo-tipo chamado typing.ClassVar
, que aproveita a notação de tipos genéricos ([]
) para definir o tipo da variável e também para declará-la como um atributo de classe.
Para fazer felizes tanto o verificador de tipos quando o @dataclass
, deveríamos declarar o all_handles
do Exemplo 17 assim:
all_handles: ClassVar[set[str]] = set()
Aquela dica de tipo está dizendo o seguinte:
all_handles
é um atributo de classe do tipo set-de-str, com umset
vazio como valor default.
Para escrever aquela anotação precisamos também importar ClassVar
do módulo typing
.
O decorador @dataclass
não se importa com os tipos nas anotações, exceto em dois casos, e este é um deles: se o tipo for ClassVar
, um campo de instância não será gerado para aquele atributo.
O outro caso onde o tipo de um campo é relevante para @dataclass
é quando declaramos variáveis apenas de inicialização, nosso próximo tópico.
Algumas vezes pode ser necessário passar para __init__
argumentos que não são campos de instância.
Tais argumentos são chamados "argumentos apenas de inicialização" (init-only variables) pela documentação de dataclasses
.
Para declarar um argumento desses, o módulo dataclasses
oferece o pseudo-tipo InitVar
, que usa a mesma sintaxe de typing.ClassVar
.
O exemplo dados na documentação é uma classe de dados com um campo inicializado a partir de um banco de dados, e o objeto banco de dados precisa ser passado para o __init__
.
O Exemplo 18 mostra o código que ilustra a seção "Variáveis de inicialização apenas".
dataclasses
@dataclass
class C:
i: int
j: int | None = None
database: InitVar[DatabaseType | None] = None
def __post_init__(self, database):
if self.j is None and database is not None:
self.j = database.lookup('j')
c = C(10, database=my_database)
Veja como o atributo database
é declarado. InitVar
vai evitar que @dataclass
trate database
como um campo regular.
Ele não será definido como um atributo de instância, e a função dataclasses.fields
não vai listá-lo.
Entretanto, database
será um dos argumentos aceitos pelo __init__
gerado, e também será passado para o __post_init__
. Ao escrever aquele método é preciso adicionar o argumento correspondente à sua assinatura, como mostra o Exemplo 18.
Esse longo tratamento de @dataclass
cobriu os recursos mais importantes desse decorador—alguns deles apareceram em seções anteriores, como na Principais recursos, onde falamos em paralelo das três fábricas de classes de dados. A documentação de dataclasses
e a PEP 526—Syntax for Variable Annotations (Sintaxe para Anotações de Variáveis) (EN) têm todos os detalhes.
Na próxima seção apresento um exemplo mais completo com o @dataclass
.
Frequentemente as classes criadas com o @dataclass
vão ter mais campos que os exemplos muito curtos apresentados até aqui.
O Dublin Core (EN) oferece a fundação para um exemplo mais típico de @dataclass
.
O Dublin Core é um esquema de metadados que visa descrever objetos digitais, tais como, videos, sons, imagens, textos e sites na web. Aplicações de Dublin Core utilizam XML e o RDF (Resource Description Framework).[11]
O padrão define 15 campos opcionais; a classe Resource
, no Exemplo 19, usa 8 deles.
Resource
, uma classe baseada nos termos do Dublin Corelink:code/05-data-classes/dataclass/resource.py[role=include]
-
Esse
Enum
vai fornecer valores de um tipo seguro para o campoResource.type
. -
identifier
é o único campo obrigatório. -
title
é o primeiro campo com um default. Isso obriga todos os campos abaixo dele a fornecerem defaults. -
O valor de
date
pode ser uma instância dedatetime.date
ouNone
. -
O default do campo
type
éResourceType.BOOK
.
O Exemplo 20 mostra um doctest, para demonstrar como um registro Resource
aparece no código.
Resource
, uma classe baseada nos termos do Dublin Corelink:code/05-data-classes/dataclass/resource.py[role=include]
O __repr__
gerado pelo @dataclass
é razoável,
mas podemos torná-lo mais legível.
Esse é o formato que queremos para repr(book)
:
link:code/05-data-classes/dataclass/resource_repr.py[role=include]
O Exemplo 21 é o código para o __repr__
, produzindo o formato que aparece no trecho anterior.
Esse exemplo usa dataclass.fields
para obter os nomes dos campos da classe de dados.
dataclass/resource_repr.py
: código para o método __repr__
, implementado na classe Resource
do Exemplo 19link:code/05-data-classes/dataclass/resource_repr.py[role=include]
-
Dá início à lista
res
, para criar a string de saída com o nome da classe e o parênteses abrindo. -
Para cada campo
f
na classe… -
…obtém o atributo nomeado da instância.
-
Anexa uma linha indentada com o nome do campo e
repr(value)
—é isso que o!r
faz. -
Acrescenta um parênteses fechando.
-
Cria uma string de múltiplas linhas a partir de
res
, e devolve essa string.
Com esse exemplo, inspirado pelo espírito de Dublin, Ohio, concluímos nosso passeio pelas fábricas de classes de dados do Python.
Classes de dados são úteis, mas podem estar sendo usadas de forma excessiva em seu projeto. A próxima seção explica isso.
Independente de você implementar uma classe de dados escrevendo todo o código ou aproveitando as facilidades oferecidas por alguma das fábricas de classes descritas nesse capítulo, fique alerta: isso pode sinalizar um problema em seu design.
No Refactoring: Improving the Design of Existing Code (Refatorando: Melhorando o Design de Código Existente), 2nd ed. (Addison-Wesley), Martin Fowler e Kent Beck apresentam um catálogo de "cheiros no código"[12]—padrões no código que podem indicar a necessidade de refatoração. O verbete entitulado "Data Class" (Classe de Dados) começa assim:
Essas são classes que tem campos, métodos para obter e definir os campos, e nada mais. Tais classes são recipientes burros de dados, e muitas vezes são manipuladas de forma excessivamente detalhada por outras classes.
No site pessoal de Fowler, há um post muito esclarecedor chamado "Code Smell" (Cheiro no Código) (EN). Esse texto é muito relevante para nossa discussão, pois o autor usa a classe de dados como um exemplo de cheiro no código, e sugere alternativas para lidar com ele. Abaixo está a tradução integral daquele artigo.[13]
De Martin Fowler
Um cheiro no código é um indicação superficial que frequentemente corresponde a um problema mais profundo no sistema. O termo foi inventado por Kent Beck, enquanto ele me ajudava com meu livro, Refactoring.
A rápida definição acima contém um par de detalhes sutis. Primeiro, um cheiro é, por definição, algo rápido de detectar—é "cheiroso", como eu disse recentemente. Um método longo é um bom exemplo disso—basta olhar o código e ver mais de uma dúzia de linhas de Java para meu nariz se contrair.
O segundo detalhe é que cheiros nem sempre indicam um problema. Alguns métodos longos são bons. É preciso ir mais fundo para ver se há um problema subjacente ali. Cheiros não são inerentemente ruins por si só—eles frequentemente são o indicador de um problema, não o problema propriamente dito.
Os melhores cheiros são algo fácil de detectar e que, na maioria das vezes, leva a problemas realmente interessantes. Classes de dados (classes contendo só dados e nenhum comportamento [próprio]) são um bom exemplo. Você olha para elas e se pergunta que comportamento deveria fazer parte daquela classe. Então você começa a refatorar, para incluir ali aquele comportamento. Muitas vezes, algumas perguntas simples e essas refatorações iniciais são um passo vital para transformar um objeto anêmico em alguma coisa que realmente tenha classe.
Uma coisa boa sobre cheiros é sua facilidade de detecção por pessoas inexperientes, mesmo aquelas pessoas que não conhecem o suficiente para avaliar se há mesmo um problema ou , se existir, para corrigi-lo. Soube de um líder de uma equipe de desenvolvimento que elege um "cheiro da semana", e pede às pessoas que procurem aquele cheiro e o apresentem para colegas mais experientes. Fazer isso com um cheiro por vez é uma ótima maneira de ensinar gradualmente os membros da equipe a serem programadores melhores.
A principal ideia da programação orientada a objetos é manter o comportamento e os dados juntos, na mesma unidade de código: uma classe. Se uma classe é largamente utilizada mas não tem qualquer comportamento próprio significativo, é bem provável que o código que interage com as instâncias dessa classe esteja espalhado (ou mesmo duplicado) em métodos e funções ao longo de todo o sistema—uma receita para dores de cabeça na manutenção. Por isso, as refatorações de Fowler para lidar com uma classe de dados envolvem trazer responsabilidades de volta para a classe.
Levando o que foi dito acima em consideração, há alguns cenários comuns onde faz sentido ter um classe de dados com pouco ou nenhum comportamento.
Nesse cenário, a classe de dados é uma implementação simplista inicial de uma classe, para dar início a um novo projeto ou módulo. Com o tempo, a classe deve ganhar seus próprios métodos, deixando de depender de métodos de outras classes para operar sobre suas instâncias. O esboço é temporário; ao final do processo, sua classe pode se tornar totalmente independente da fábrica usada inicialmente para criá-la.
O Python também é muito usado para resolução rápida de problemas e para experimentaçào, e nesses casos é aceitável deixar o esboço pronto para uso.
Uma classe de dados pode ser útil para criar registros que serão exportados para o JSON ou algum outro formato de intercomunicação, ou para manter dados que acabaram de ser importados, cruzando alguma fronteira do sistema. Todas as fábricas de classes de dados do Python oferecem um método ou uma função para converter uma instância em um dict
simples, e você sempre pode invocar o construtor com um dict
, usado para passar argumentos nomeados expandidos com **
. Um dict
desses é muito similar a um registro JSON.
Nesse cenário, as instâncias da classe de dados devem ser tratadas como objetos imutáveis—mesmo que os campos sejam mutáveis, não deveriam ser modificados nessa forma intermediária. Mudá-los significa perder o principal benefício de manter os dados e o comportamento próximos. Quando o processo de importação/exportação exigir mudança nos valores, você deve implementar seus próprios métodos de fábrica, em vez de usar os métodos "as dict" existentes ou os construtores padrão.
Vamos agora mudar de assunto e aprender como escrever padrões que "casam" com instâncias de classes arbitrárias, não apenas com as sequências e mapeamentos que vimos nas seções [sequence_patterns_sec] e [pattern_matching_mappings_sec].
Padrões de classe são projetados para "casar" com instâncias de classes por tipo e—opcionalmente—por atributos. O sujeito de um padrão de classe pode ser uma instância de qualquer classe, não apenas instâncias de classes de dados.[14]
Há três variantes de padrões de classes: simples, nomeado e posicional. Vamos estudá-las nessa ordem.
Já vimos um exemplo de padrões de classe simples usados como sub-padrões na [sequence_patterns_sec]:
case [str(name), _, _, (float(lat), float(lon))]:
Aquele padrão "casa" com uma sequência de quatro itens, onde o primeiro item deve ser uma instância de str
e o último item deve ser um tupla de dois elementos, com duas instâncias de float
.
A sintaxe dos padrões de classe se parece com a invocação de um construtor.
Abaixo temos um padrão de classe que "casa" com valores float
sem vincular uma variável (o corpo do case
pode ser referir a x
diretamente, se necessário):
match x:
case float():
do_something_with(x)
Mas isso aqui possivelmente será um bug no seu código:
match x:
case float: # DANGER!!!
do_something_with(x)
No exemplo anterior, case float:
"casa" com qualquer sujeito, pois o Python entende float
como uma variável, que é então vinculada ao sujeito.
A sintaxe float(x)
do padrão simples é um caso especial que se aplica apenas a onze tipos embutidos "abençoados", listados no final da seção "Class Patterns" (Padrões de Classe) (EN) da
PEP 634—Structural Pattern Matching: Specification ((Pattern Matching Estrutural: Especificação):
bool bytearray bytes dict float frozenset int list set str tuple
Nessas classes, a variável que parece um argumento do construtor—por exemplo, o x
em float(x)
—é vinculada a toda a instância do sujeito ou à parte do sujeito que "casa" com um sub-padrão, como exemplificado por str(name)
no padrão de sequência que vimos antes:
case [str(name), _, _, (float(lat), float(lon))]:
Se a classe não de um daqueles onze tipos embutidos "abençoados", então essas variáveis parecidas com argumentos representam padrões a serem testados com atributos de uma instância daquela classe.
Para entender como usar padrões de classe nomeados,
observe a classe City
e suas cinco instâncias no Exemplo 22, abaixo.
City
e algumas instânciaslink:code/05-data-classes/match_cities.py[role=include]
Dadas essas definições, a seguinte função devolve uma lista de cidades asiáticas:
link:code/05-data-classes/match_cities.py[role=include]
O padrão City(continent='Asia')
encontra qualquer instância de City
onde o atributo continent
seja igual a 'Asia'
, independente do valor dos outros atributos.
Para coletar o valor do atributo country
, você poderia escrever:
link:code/05-data-classes/match_cities.py[role=include]
O padrão City(continent='Asia', country=cc)
encontra as mesmas cidades asiáticas, como antes, mas agora a variável cc
está vinculada ao atributo country
da instância.
Isso inclusive funciona se a variável do padrão também se chamar country
:
match city:
case City(continent='Asia', country=country):
results.append(country)
Padrões de classe nomeados são bastante legíveis, e funcionam com qualquer classe que possua atributos de instância públicos. Mas eles são um tanto prolixos.
Padrões de classe posicionais são mais convenientes em alguns casos, mas exigem suporte explícito da classe do sujeito, como veremos a seguir.
Dadas as definições do Exemplo 22, a seguinte função devolveria uma lista de cidades asiáticas, usando um padrão de classe posicional:
link:code/05-data-classes/match_cities.py[role=include]
O padrão City('Asia')
encontra qualquer instância de City
na qual o valor do primeiro atributo seja Asia
, independente do valor dos outros atributos.
Se você quiser obter o valor do atributo country
, poderia escrever:
link:code/05-data-classes/match_cities.py[role=include]
O padrão City('Asia', _, country)
encontra as mesmas cidades de antes, mas agora variável country
está vinculada ao terceiro atributo da instância.
Eu falei do "primeiro" ou do "terceiro" atributos, mas o quê isso realmente significa?
City
(ou qualquer classe) funciona com padrões posicionais graças a um atributo de classe especial chamado __match_args__
,
que as fábricas de classe vistas nesse capítulo criam automaticamente.
Esse é o valor de __match_args__
na classe City
:
>>> City.__match_args__
('continent', 'name', 'country')
Como se vê, __match_args__
declara os nomes dos atributos na ordem em que eles serão usados em padrões posicionais.
Na [positional_pattern_implement_sec] vamos escrever código para definir __match_args__
em uma classe que criaremos sem a ajuda de uma fábrica de classes.
Tip
|
Você pode combinar argumentos nomeados e posicionais em um padrão.
Alguns, mas não todos, os atributos de instância disponíveis para o match podem estar listados no
|
Hora de um resumo de capítulo.
O tópico principal desse capítulo foram as fábricas de classes de dados collections.namedtuple
, typing.NamedTuple
, e dataclasses.dataclass
.
Vimos como cada uma delas gera classes de dados a partir de descrições, fornecidas como argumentos a uma função fábrica ou, no caso das duas últimas, a partir de uma declaração class
com dicas de tipo.
Especificamente, ambas as variantes de tupla produzem subclasses de tuple
, acrescentando apenas a capacidade de acessar os campos por nome, e criando também um atributo de classe _fields
, que lista os nomes dos campos na forma de uma tupla de strings.
A seguir colocamos lado a lado os principais recursos de cada uma das três fábricas de classes, incluindo como extrair dados da instância como um dict
, como obter os nomes e valores default dos campos, e como criar uma nova instância a partir de uma instância existente.
Isso levou ao nosso primeiro contato com dicas de tipo, especialmente aquelas usadas para anotar atributos em uma declaração class
, usando a notação introduzida no Python 3.6 com a PEP 526—Syntax for Variable Annotations (Sintaxe para Anotações de Variáveis) (EN).
O aspecto provavelmente mais surpreeendente das dicas de tipo em geral é o fato delas não terem qualquer efeito durante a execução.
O Python continua sendo uma linguagem dinâmica.
Ferramentas externas, como o Mypy, são necessárias para aproveitar a informação de tipagem na detecção de erros via análise estática do código-fonte.
Após um resumo básico da sintaxe da PEP 526, estudamos os efeitos das anotações em uma classe simples e em classes criadas por typing.NamedTuple
e por @dataclass
.
A seguir falamos sobre os recursos mais usados dentre os oferecidos por @dataclass
, e sobre a opção default_factory
da função dataclasses.field
.
Também demos uma olhada nas dicas de pseudo-tipo especiais typing.ClassVar
e
dataclasses.InitVar
, importantes no contexto das classes de dados.
Esse tópico central foi concluído com um exemplo baseado no schema Dublin Core, ilustrando como usar dataclasses.fields
para iterar sobre os atributos de uma instância de Resource
em um
__repr__
personalizado.
Então alertamos contra os possíveis usos abusivos das classes de dados, frustrando um princípio básico da programação orientada a objetos: os dados e as funções que acessam os dados devem estar juntos na mesma classe. Classes sem uma lógica podem ser um sinal de uma lógica fora de lugar.
Na última seção, vimos como o pattern matching funciona com instâncias de qualquer classe como sujeitos—e não apenas das classes criadas com as fábricas apresentadas nesse capítulo.
A documentação padrão do Python para as fábricas de classes de dados vistas aqui é muito boa, e inclui muitos pequenos exemplos.
Em especial para @dataclass
, a maior parte da PEP 557—Data Classes (Classes de Dados) (EN) foi copiada para a documentação do módulo dataclasses
.
Entretanto, algumas seções informativas da PEP 557 não foram copiadas,
incluindo "Why not just use namedtuple?" (Por que simplesmente não usar namedtuple?),
"Why not just use typing.NamedTuple?" (Por que simplesmente não usar typing.NamedTuple?), e a seção "Rationale" (Justificativa), que termina com a seguinte Q&A:
Quando não é apropriado usar Classes de Dados?
Quando for exigida compatibilidade da API com tuplas de dicts. Quando for exigida validação de tipo além daquela oferecida pelas PEPs 484 e 526 , ou quando for exigida validação ou conversão de valores.
PEP 557 "Justificativa"
Em RealPython.com, Geir Arne Hjelle escreveu um "Ultimate guide to data classes in Python 3.7" (O guia definitivo das classes de dados no Python 3.7) (EN) muito completo.
Na PyCon US 2018, Raymond Hettinger apresentou "Dataclasses: The code generator to end all code generators" (video) (Dataclasses: O gerador de código para acabar com todos os geradores de código) (EN).
Para mais recursos e funcionalidade avançada, incluindo validação, o
projeto attrs (EN),
liderado por Hynek Schlawack, surgiu anos antes de dataclasses
e oferece mais facilidades, com a promessa de "trazer de volta a alegria de criar classes, liberando você do tedioso trabalho de implementar protocolos de objeto
(também conhecidos como métodos dunder)".
A influência do attrs sobre o @dataclass
é reconhecida por Eric V. Smith na PEP 557.
Isso provavelmente inclui a mais importante decisão de Smith sobre a API: o uso de um decorador de classe em vez de uma classe base ou de uma metaclasse para realizar a tarefa.
Glyph—fundador do projeto Twisted—escreveu uma excelente introdução à attrs em "The One Python Library Everyone Needs" (A Biblioteca Python que Todo Mundo Precisa Ter) (EN). A documentação da attrs inclui uma discussão aobre alternativas.
O autor de livros, instrutor e cientista maluco da computação Dave Beazley escreveu o
cluegen, um outro gerador de classes de dados.
Se você já assistiu alguma palestra do David, sabe que ele é um mestre na metaprogramação Python a partir de princípios básicos.
Então achei inspirador descobrir, no arquivo README.md do cluegen,
o caso de uso concreto que o motivou a criar uma alternativa ao @dataclass
do Python,
e sua filosofia de apresentar uma abordagem para resolver o problema,
ao invés de fornecer uma ferramenta: a ferramenta pode inicialmente ser mais rápida de usar ,
mas a abordagem é mais flexível e pode ir tão longe quanto você deseje.
Sobre a classe de dados como um cheiro no código, a melhor fonte que encontrei foi livro de Martin Fowler, Refactoring ("Refatorando"), 2ª ed. A versão mais recente não traz a citação da epígrafe deste capitulo, "Classes de dados são como crianças…", mas apesar disso é a melhor edição do livro mais famoso de Fowler, em especial para pythonistas, pois os exemplos são em JavaScript moderno, que é mais próximo do Python que do Java—a linguagem usada na primeira edição.
O site Refactoring Guru (Guru da Refatoração) (EN) também tem uma descrição do data class code smell (classe de dados como cheiro no código) (EN).
O verbete para "Guido" no "The Jargon File" (EN) é sobre Guido van Rossum. Entre outras coisa, ele diz:
Diz a lenda que o atributo mais importante de Guido, além do próprio Python, é a máquina do tempo de Guido, um aparelho que se diz que ele possui por causa da frequência irritante com que pedidos de usuários por novos recursos recebem como resposta "Eu implementei isso noite passada mesmo…"
Por um longo tempo, uma das peças ausentes da sintaxe do Python foi uma forma rápida e padronizada de declarar atributos de instância em uma classe.
Muitas linguagens orientadas a objetos incluem esse recurso.
Aqui está parte da definição da classe Point
em Smalltalk:
Object subclass: #Point instanceVariableNames: 'x y' classVariableNames: '' package: 'Kernel-BasicObjects'
A segunda linha lista os nomes dos atributos de instância x
e y
.
Se existissem atributos de classe, eles estariam na terceira linha.
O Python sempre teve uma forma fácil de declarar um atributo de classe, se ele tiver um valor inicial.
Mas atributos de instância são muito mais comuns, e os programadores Python tem sido obrigados a olhar dentro do método __init__
para encontrá-los, sempre temerosos que podem existir atributos de instância sendo criados em outro lugar na classe—ou mesmo por funções e métodos de outras classes.
Agora temos o @dataclass
, viva!
Mas ele traz seus próprios problemas
Primeiro, quando você usa @dataclass
, dicas de tipo não são opcionais. Pelos últimos sete anos, desde a PEP 484—Type Hints (Dicas de Tipo) (EN), nos prometeram que elas sempre seriam opcionais.
Agora temos um novo recurso importante na linguagem que exige dicas de tipo.
Se você não gosta de toda essa tendência de tipagem estática, pode querer usar a attrs
no lugar do @dataclass
.
Em segundo lugar, a sintaxe da PEP 526 (EN) para anotar atributos de instância e de classe inverte a convenção consagrada para declarações de classe:
tudo que era declarado no nível superior de um bloco class
era um atributo de classe (métodos também são atributos de classe).
Com a PEP 526 e o @dataclass
,
qualquer atributo declarado no nível superior com uma dica de tipo se torna um atributo de instância:
@dataclass
class Spam:
repeat: int # instance attribute
Aqui, repeat
também é um atributo de instância:
@dataclass
class Spam:
repeat: int = 99 # instance attribute
Mas se não houver dicas de tipo, subitamente estamos de volta os bons velhos tempos quando declarações no nível superior da classe pertencem apenas à classe:
@dataclass
class Spam:
repeat = 99 # class attribute!
Por fim, se você desejar anotar aquele atributo de classe com um tipo,
não pode usar tipos regulares, porque então ele se tornará um atributo de instância.
Você tem que recorrer a aquela anotação usando o pseudo-tipo ClassVar
:
@dataclass
class Spam:
repeat: ClassVar[int] = 99 # aargh!
Aqui estamos falando sobre uma exceçao da exceção da regra. Me parece algo muito pouco pythônico.
Não tomei parte nas discussões que levaram à PEP 526 ou à PEP 557—Data Classes (Classes de Dados), mas aqui está uma sintaxe alternativa que eu gostaria de ver:
@dataclass
class HackerClubMember:
.name: str # (1)
.guests: list = field(default_factory=list)
.handle: str = ''
all_handles = set() # (2)
-
Atributos de instância devem ser declarados com um prefixo
.
. -
Qualquer nome de atributo que não tenha um prefixo
.
é um atributo de classe (como sempre foram).
A gramática da linguagem teria que mudar para acomodar isso. Mas acho essa forma muito legível, e ela evita o problema da exceção-da-exceção.
Queria poder pegar a máquina do tempo de Gudo emprestada e voltar a 2017, para convencer os desenvolvedores principais a aceitarem essa ideia.
str
, list
, etc. Considero essa limitação do Python uma benção.
None
não é o singleton NoneType
, mas um apelido para o próprio NoneType
. Se pararmos para pensar, isso é estranho, mas agrada nossa intuição e torna as anotações de valores devolvidos por uma função mais fáceis de ler, no caso comum de funções que devolvem None
.
__init__
prejudica a otimização de uso de memória com o compartilhamento das chaves do __dict__
, mencionada na [consequences_dict_internals].
@dataclass
emula a imutabilidade criando um __setattr__
e um __delattr__
que geram um dataclass.FrozenInstanceError
—uma subclasse de AttributeError
—quando o usuário tenta definir ou apagar o valor de um campo.
dataclass._MISSING_TYPE
é um valor sentinela, indicando que a opção não foi fornecida. Ele existe para que se possa definir None
como um valor default efetivo, um caso de uso comum.