Introdução
Nesse meu primeiro artigo, gostaria de falar um pouco sobre como melhorar a performance de suas classes em Python, e para isso, explicarei o funcionamento de um atributo de classe chamado __slots__
, cuja finalidade, de acordo com Guido van Rossum, criador da linguagem, é atingir a melhor performance.
Por padrão, todas as vezes que nós criamos um objeto a partir de uma classe, os atributos desse objeto serão guardados em um atributo especial chamado __dict__
, que nada mais é do que um dicionário.
Este dicionário nos permite, inclusive, criar novos atributos, mesmo depois de o objeto já ter sido instanciado.
class Book:
"""
A class to represent a book
"""
def __init__(self, title, author, pub_date):
self.title = title
self.author = author
self.pub_date = pub_date
book = Book("The Silmarillion", "J. R. R. Tolkien", "September 15, 1977")
print(book.__dict__)
# {'title': 'The Silmarillion', 'author': 'J. R. R. Tolkien', 'pub_date': 'September 15, 1977'}
print(Book.__dict__)
# {'__module__': '__main__', '__doc__': '\n A class to represent a book\n ', '__init__': <function Book.__init__ at 0x7fd494f0bee0>, '__dict__': <attribute '__dict__' of 'Book' objects>, '__weakref__': <attribute '__weakref__' of 'Book' objects>}
book.pages = 365
print(book.__dict__)
# {'title': 'The Silmarillion', 'author': 'J. R. R. Tolkien', 'pub_date': 'September 15, 1977', 'pages': 365}
print(book.pages)
# 365
Isso significa que dicionários são ruins? NÃO, pelo contrário, dicinários são incrivelmente bons, como nos mostra Raymond Hettinger, em sua palestra “Modern Python Dictionaries A confluence of a dozen great ideas”, na PyCon 2017.
Você provavelmente deve estar se perguntando “Se dicionários são incrivelmente bons, qual é o problema então?“. O problema é que dicionários são custosos em relação ao consumo de memória. Eles são criados posicionando um conjunto de chave:valor
, sendo a chave um valor arbitrário hasheável, e quando precisamos realizar uma busca nessa estrutura de dados, utilizamos a hash para buscar o endereço de memória onde está armazenado o valor.
O pior cenário da complexidade de tempo de um algoritmo get/set em um dict
é o O(n), e na iteração, tanto o cenário médio quanto o pior cenário tem complexidade O(n):
Todo desenvolvedor com um pouquinho a mais de experiência se preocupa (ou deveria se preocupar) com a performance de suas aplicações.
Como o atributo de classe __slots__
pode ajudar a resolver esse problema?
Como dito anteriormente, os __slots__
nasceram com a finalidade de “corrigir” esse problema de performance, tendo como função fazer duas coisas: 1) Getting e Setting de atributos muito mais rápidos, em razão de proporcionar uma estrutura de dados mais otimizada, e; 2) redução considerável do uso de memória RAM.
Isso se dá porque quando definimos o atributo __slots__
em uma classe, o Python reserva direto o espaço para os atributos definidos na estrutura da própria classe, e não cria um novo dicionário para cada instância. Ou seja, ele bloqueia a criação automática do atributo __dict__
e do __weakref__
de cada instância dessa classe, exceto quando estes são explicitamente declarados dentro dos __slots__
.
Deste modo, como afirma Guido van Rossum, criador da linguagem Python, esses atributos, que deixaram de ser armazenatos na estrutura de dicionários, agora são armazenatos em slots de uma lista, além disso, cada atributo é um objeto descriptor que sabe exatamente como fazer o get/set nessa lista pelo seu índice. Por baixo dos panos, toda a implementação é feita em C, aproveitando-se de toda sua eficiência e performance.
A complexidade de tempo do algoritmo get/set nas listas é melhor do que nos dicionarios no pior dos cenários:
Vejamos o exemplo anterior, mas agora utilizando o atributo __slots__
:
class BookWithSlot:
"""
A class to represent a book with __slot__ attribute.
"""
# Aqui definimos o atributo __slots__ com os atributos da classe
__slots__ = ["title", "author", "pub_date"]
def __init__(self, title, author, pub_date):
self.title = title
self.author = author
self.pub_date = pub_date
book = BookWithSlot("The Silmarillion", "J. R. R. Tolkien",
"September 15, 1977")
# Por causa do atributo __slots__, a instância não possui o atributo __dict__
print(book.__dict__)
# Traceback (most recent call last):
# File "file.py", line 17, in <module>
# print(book.__dict__)
# AttributeError: 'BookWithSlot' object has no attribute '__dict__'
# Mas seus atributos são acessados normalmente
print(book.title, book.author, book.pub_date)
# The Silmarillion J. R. R. Tolkien September 15, 1977
# Porém, como era esperado, não conseguimos mais incrementar a instância, pois o atributo __dict__ não existe
book.pages = 365
# Traceback (most recent call last):
# File "file.py", line 32, in <module>
# book.pages = 365
# AttributeError: 'BookWithSlot' object has no attribute 'pages'
Testes
Vamos realizar alguns testes para verificar a velocidade e o consumo de memória de uma classe com comportamento padrão, ou seja, que guarda os atributos no __dict__
, com outra classe que utiliza dos benefícios dos __slots__
.
No primeiro teste, vamos comparar o consumo de memória:
from sys import getsizeof
# Aqui nós declaramos as classes com e sem __slots__
class Book:
"""
A class to represent a book
"""
def __init__(self, title, author, pub_date):
self.title = title
self.author = author
self.pub_date = pub_date
class BookWithSlot:
"""
A class to represent a book with __slot__ attribute.
"""
__slots__ = ["title", "author", "pub_date"]
def __init__(self, title, author, pub_date):
self.title = title
self.author = author
self.pub_date = pub_date
# Vamos criar um payload que será usado para instanciar ambas as classes
payload = {
"title": "The Silmarillion",
"author": "J. R. R. Tolkien",
"pub_date": "September 15, 1977",
}
# Vamos criar um total de 10.000 instancias de cada classe
MAX_INSTANCES = 10000
# Vamos definir a função para calcular o tamanho da instancia
def get_instance_size(instance):
"""
Se a instancia tiver __dict__
nós adicionaremos o tamanho do __dict__ ao tamanho da instancia
Desse modo podemos aferir corretamente o tamannho da instancia e do __dict__
Precisa ser feito dessa forma para pegar o tamanho correto:
https://stackoverflow.com/questions/449560/how-do-i-determine-the-size-of-an-object-in-python
"""
size_dict = 0
try:
size_dict = getsizeof(instance.__dict__)
except AttributeError:
pass
return size_dict + getsizeof(instance)
# Vamos criar uma lista com o tamanho de cada uma das instancias da classe com __slots__
instances_with_slots = [
get_instance_size(BookWithSlot(**payload)) for _ in range(MAX_INSTANCES)
]
# Somamos o valor dentro da lista e dividimos pela quantidade de instancias
size_with_slots = sum(instances_with_slots)/MAX_INSTANCES
print(f"O tamanho total das instancias com __slots__ é: {size_with_slots} MB")
# O tamanho total das instancias com __slots__ é: 56.0 MB
instances_without_slots = [
get_instance_size(Book(**payload)) for _ in range(MAX_INSTANCES)
]
size_without_slots = sum(instances_without_slots)/MAX_INSTANCES
print(
f"O tamanho total das instancias com __dict__ é: {size_without_slots} MB")
# O tamanho total das instancias com __dict__ é: 152.0 MB
# Aqui vamos calcular a porcentagem de redução do uso de memória
size_reduction = (size_with_slots - size_without_slots) / \
size_without_slots * 100
print(f"Redução do uso de memória: {size_reduction:.2f}% ")
# Redução do uso de memória: -63.16%
Podemos perceber que, com apenas uma linha de código, conseguimos reduzir o consumo de memória em 63% utilizando o atributo __slots__
com a mesma quantidade de instâncias de cada classe. É um resultado excelente!
Também podemos testar o tempo em que a instância das classes levam para acessar um atributo:
from timeit import timeit
# Aqui nós declaramos as classes com e sem __slots__
class Book:
"""
A class to represent a book
"""
def __init__(self, title, author, pub_date):
self.title = title
self.author = author
self.pub_date = pub_date
class BookWithSlot:
"""
A class to represent a book with __slot__ attribute.
"""
__slots__ = ["title", "author", "pub_date"]
def __init__(self, title, author, pub_date):
self.title = title
self.author = author
self.pub_date = pub_date
# Vamos criar um payload que será usado para instanciar ambas as classes
payload = {
"title": "The Silmarillion",
"author": "J. R. R. Tolkien",
"pub_date": "September 15, 1977",
}
# Definimos o máximo de instancias de ambas as classes
MAX_INSTANCES = 10000
def get_average_of(x):
return sum(x)/MAX_INSTANCES
# Vamos mensurar a velocidade de acesso ao atributo title da classe instanciada
time_with_slots = timeit('instance_with_slot.title',
setup='instance_with_slot=BookWithSlot(**payload)', globals=globals())
times_with_slots = [time_with_slots for _ in range(MAX_INSTANCES)]
time_without_slots = timeit('instance_without_slot.title',
setup='instance_without_slot=Book(**payload)', globals=globals())
times_without_slots = [time_without_slots for _ in range(MAX_INSTANCES)]
average_time_with_slots = get_average_of(times_with_slots)
average_time_without_slotes = get_average_of(times_without_slots)
print(
f'O tempo médio de acesso ao atributo title com __slot__ é: {average_time_with_slots}')
# O tempo médio de acesso ao atributo title com __slot__ é: 0.028985498007386923
print(
f'O tempo médio de acesso ao atributo title com __dict__ é: {average_time_without_slotes}')
# O tempo médio de acesso ao atributo title com __dict__ é: 0.04082313401158899
# O acesso aos atributos da classe com os __slots__ é em média 28,997371933% mais rápido
Vemos que para acessar os atributos, as instâncias com __slots__
são, em média, 28,9% mais rápidas que as instancias com __dict__
.
Posso usar __slots__
com dataclasses?
Caso você não conheça essa feature sensacional que veio junto com o lançamento do Python 3.7, recomendo fortemente a leitura deste artigo.
Em poucas palavras, as classes de dados, ou dataclasses do Python 3.7+, são uma forma de automatizar a geração de classes, com a excelente vantagem de usar as anotações do tipos do Python. Você precisa basicamente usar o decorator @dataclass
para decorar uma classe de dados, que o Python se encarrega de criar pra você automaticamente os métodos de classe __init__
, __repr__
, __str__
e __eq__
.
from dataclasses import dataclass
from datetime import date
payload = {
"title": "The Silmarillion",
"author": "J. R. R. Tolkien",
"pub_date": "September 15, 1977",
}
# Exemplo anterior, mas utilizando dataclass
@dataclass
class Book:
"""
A class to represent a book
"""
title: str
author: str
pub_date: date
book = Book(**payload)
print(book)
# Book(title='The Silmarillion', author='J. R. R. Tolkien', pub_date='September 15, 1977')
Apesar de existir essa pequena diferença na sintaxe, dataclasses ainda são classes Python, e consequentemente podemos utilizar dos benefícios do atributo __slots__
.
from dataclasses import dataclass
from datetime import date
payload = {
"title": "The Silmarillion",
"author": "J. R. R. Tolkien",
"pub_date": "September 15, 1977",
}
# Exemplo anterior, mas utilizando dataclass e __slots__
@dataclass
class BookWithSlots:
"""
A class to represent a book with __slots__ attribute.
"""
__slots__ = ["title", "author", "pub_date"]
title: str
author: str
pub_date: date
book = BookWithSlots(**payload)
print(book)
# BookWithSlots(title='The Silmarillion', author='J. R. R. Tolkien', pub_date='September 15, 1977')
Conclusão
Recomendo fortemente a leitura da documentação oficial, que apresenta todas as informações que você vai precisar para entender e implementar tudo que foi falado neste artigo, e também algumas recomendações e cuidados, por exemplo, quando você vai criar uma hierarquia de classes e usar __slots__
, sendo que cada subclasse terá que definir __slots__
também, senão o Python, por padrão, vai criar o atributo __dict__
para a classe.
Espero que este artigo ajude vocês a entender as vantagens de usar classes com __slots__
, aos invés de __dicts__
, para garantir uma melhor eficiência e performance em suas aplicações.
Se houver alguma dúvida, sugestão ou crítica, fique a vontade para entrar em contato comigo.