Voltar

O atributo __slots__ nas classes do Python e o impacto na performance de suas aplicações.

Python Race

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):

The Big O

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:

The Big O on List

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.