Crie um Contêiner Personalizado

Beginner

This tutorial is from open-source community. Access the source code

Introdução

Neste laboratório, você aprenderá sobre containers Python e gerenciamento de memória. Você explorará como o Python lida com a memória para estruturas de dados embutidas e descobrirá como criar uma classe de container personalizada eficiente em termos de memória.

Os objetivos deste laboratório são examinar o comportamento de alocação de memória de listas e dicionários Python, criar uma classe de container personalizada para otimizar o uso de memória e entender os benefícios do armazenamento de dados orientado a colunas (column-oriented data storage).

Este é um Lab Guiado, que fornece instruções passo a passo para ajudá-lo a aprender e praticar. Siga as instruções cuidadosamente para completar cada etapa e ganhar experiência prática. Dados históricos mostram que este é um laboratório de nível iniciante com uma taxa de conclusão de 93%. Recebeu uma taxa de avaliações positivas de 97% dos estudantes.

Compreendendo a Alocação de Memória de Listas

Em Python, as listas são uma estrutura de dados muito útil, especialmente quando você precisa adicionar elementos a elas. As listas Python são projetadas para serem eficientes para operações de adição (appending). Em vez de alocar exatamente a quantidade de memória necessária, o Python aloca memória extra em antecipação a adições futuras. Essa estratégia minimiza o número de realocações de memória necessárias quando a lista cresce.

Vamos entender melhor esse conceito usando a função sys.getsizeof(). Essa função retorna o tamanho de um objeto em bytes, o que nos ajuda a ver quanta memória uma lista está usando em diferentes estágios.

  1. Primeiro, você precisa abrir um shell interativo Python no seu terminal. Isso é como um playground onde você pode executar código Python imediatamente. Para abri-lo, digite o seguinte comando no seu terminal e pressione Enter:
python3
  1. Depois de entrar no shell interativo Python, você precisa importar o módulo sys. Módulos em Python são como caixas de ferramentas que contêm funções úteis. O módulo sys tem a função getsizeof() que precisamos. Após importar o módulo, crie uma lista vazia chamada items. Aqui está o código para fazer isso:
import sys
items = []
  1. Agora, vamos verificar o tamanho inicial da lista vazia. Usaremos a função sys.getsizeof() com a lista items como seu argumento. Digite o seguinte código no shell interativo Python e pressione Enter:
sys.getsizeof(items)

Você deve ver um valor como 64 bytes. Esse valor representa a sobrecarga (overhead) para uma lista vazia. A sobrecarga é a quantidade básica de memória que o Python usa para gerenciar a lista, mesmo quando ela não tem elementos.

  1. Em seguida, começaremos a adicionar itens à lista um por um e observar como a alocação de memória muda. Usaremos o método append() para adicionar um elemento à lista e, em seguida, verificar o tamanho novamente. Aqui está o código:
items.append(1)
sys.getsizeof(items)

Você deve ver um valor maior, em torno de 96 bytes. Esse aumento no tamanho mostra que o Python alocou mais memória para acomodar o novo elemento.

  1. Vamos continuar adicionando mais itens à lista e verificar o tamanho após cada adição. Aqui está o código para fazer isso:
items.append(2)
sys.getsizeof(items)  ## Size remains the same

items.append(3)
sys.getsizeof(items)  ## Size still unchanged

items.append(4)
sys.getsizeof(items)  ## Size still unchanged

items.append(5)
sys.getsizeof(items)  ## Size jumps to a larger value

Você notará que o tamanho não aumenta com cada operação de adição (append). Em vez disso, ele aumenta periodicamente. Isso demonstra que o Python está alocando memória em blocos (chunks) em vez de individualmente para cada novo item.

Esse comportamento é intencional. O Python aloca mais memória do que o necessário inicialmente para evitar realocações frequentes à medida que a lista cresce. Cada vez que a lista excede sua capacidade atual, o Python aloca um bloco de memória maior.

Lembre-se de que uma lista armazena referências a objetos, não os próprios objetos. Em um sistema de 64 bits, cada referência normalmente requer 8 bytes de memória. Isso é importante para entender porque afeta quanta memória uma lista realmente usa quando contém diferentes tipos de objetos.

Alocação de Memória de Dicionários

Em Python, assim como as listas, os dicionários são uma estrutura de dados fundamental. Um aspecto importante a entender sobre eles é como eles alocam memória. A alocação de memória se refere a como o Python reserva espaço na memória do computador para armazenar os dados em seu dicionário. Semelhante às listas, os dicionários Python também alocam memória em blocos (chunks). Vamos explorar como a alocação de memória funciona com dicionários.

  1. Primeiro, precisamos criar um dicionário para trabalhar. No mesmo shell Python (ou abra um novo se você o fechou), criaremos um dicionário representando um registro de dados. Um dicionário em Python é uma coleção de pares chave-valor (key-value), onde cada chave é única e é usada para acessar seu valor correspondente.
import sys  ## Import sys if you're starting a new session
row = {'route': '22', 'date': '01/01/2001', 'daytype': 'U', 'rides': 7354}

Aqui, importamos o módulo sys, que fornece acesso a algumas variáveis usadas ou mantidas pelo interpretador Python e a funções que interagem fortemente com o interpretador. Em seguida, criamos um dicionário chamado row com quatro pares chave-valor.

  1. Agora que temos nosso dicionário, queremos verificar seu tamanho inicial. O tamanho de um dicionário se refere à quantidade de memória que ele ocupa no computador.
sys.getsizeof(row)

A função sys.getsizeof() retorna o tamanho de um objeto em bytes. Quando você executa este código, você deve ver um valor em torno de 240 bytes. Isso lhe dá uma ideia de quanta memória o dicionário ocupa inicialmente.

  1. Em seguida, adicionaremos novos pares chave-valor ao dicionário e observaremos como a alocação de memória muda. Adicionar itens a um dicionário é uma operação comum, e entender como isso afeta a memória é crucial.
row['a'] = 1
sys.getsizeof(row)  ## Size might remain the same

row['b'] = 2
sys.getsizeof(row)  ## Size may increase

Quando você adiciona o primeiro par chave-valor ('a': 1), o tamanho do dicionário pode permanecer o mesmo. Isso ocorre porque o Python já alocou um certo bloco de memória, e pode haver espaço suficiente nesse bloco para acomodar o novo item. No entanto, quando você adiciona o segundo par chave-valor ('b': 2), o tamanho pode aumentar. Você notará que, após adicionar um certo número de itens, o tamanho do dicionário aumenta repentinamente. Isso ocorre porque os dicionários, como as listas, alocam memória em blocos para otimizar o desempenho. Alocar memória em blocos reduz o número de vezes que o Python precisa solicitar mais memória do sistema, o que acelera o processo de adição de novos itens.

  1. Vamos tentar remover um item do dicionário para ver se o uso de memória diminui. Remover itens de um dicionário também é uma operação comum, e é interessante ver como isso afeta a memória.
del row['b']
sys.getsizeof(row)

Curiosamente, remover um item normalmente não reduz a alocação de memória. Isso ocorre porque o Python mantém a memória alocada para evitar a realocação se os itens forem adicionados novamente. Realocar memória é uma operação relativamente cara em termos de desempenho, então o Python tenta evitá-la o máximo possível.

Considerações sobre Eficiência de Memória:

Ao trabalhar com grandes conjuntos de dados onde você precisa criar muitos registros, usar dicionários para cada registro pode não ser a abordagem mais eficiente em termos de memória. Os dicionários são muito flexíveis e fáceis de usar, mas podem consumir uma quantidade significativa de memória, especialmente ao lidar com um grande número de registros. Aqui estão algumas alternativas que consomem menos memória:

  • Tuplas (Tuples): Sequências imutáveis simples. Uma tupla é uma coleção de valores que não podem ser alterados após sua criação. Ela usa menos memória do que um dicionário porque não precisa armazenar chaves e gerenciar o mapeamento chave-valor associado.
  • Tuplas nomeadas (Named tuples): Tuplas com nomes de campo. As tuplas nomeadas são semelhantes às tuplas regulares, mas permitem que você acesse os valores por nome, o que pode tornar o código mais legível. Elas também usam menos memória do que os dicionários.
  • Classes com __slots__: Classes que definem explicitamente atributos para evitar o uso de um dicionário para variáveis de instância. Quando você usa __slots__ em uma classe, o Python não cria um dicionário para armazenar as variáveis de instância, o que reduz o uso de memória.

Essas alternativas podem reduzir significativamente o uso de memória ao lidar com muitos registros.

Otimizando a Memória com Dados Orientados a Colunas

No armazenamento de dados tradicional, frequentemente armazenamos cada registro como um dicionário separado, o que é chamado de abordagem orientada a linhas. No entanto, esse método pode consumir uma quantidade significativa de memória. Uma forma alternativa é armazenar dados em colunas. Na abordagem orientada a colunas, criamos listas separadas para cada atributo, e cada lista contém todos os valores para aquele atributo específico. Isso pode nos ajudar a economizar memória.

  1. Primeiro, você precisa criar um novo arquivo Python no diretório do seu projeto. Este arquivo conterá o código para ler dados de forma orientada a colunas. Nomeie o arquivo readrides.py. Você pode usar os seguintes comandos no terminal para fazer isso:
cd ~/project
touch readrides.py

O comando cd ~/project altera o diretório atual para o seu diretório de projeto, e o comando touch readrides.py cria um novo arquivo vazio chamado readrides.py.

  1. Em seguida, abra o arquivo readrides.py no editor WebIDE. Depois, adicione o seguinte código Python ao arquivo. Este código define uma função read_rides_as_columns que lê dados de viagens de ônibus de um arquivo CSV e os armazena em quatro listas separadas, cada uma representando uma coluna de dados.
## readrides.py
import csv
import sys
import tracemalloc

def read_rides_as_columns(filename):
    '''
    Read the bus ride data into 4 lists, representing columns
    '''
    routes = []
    dates = []
    daytypes = []
    numrides = []
    with open(filename) as f:
        rows = csv.reader(f)
        headings = next(rows)     ## Skip headers
        for row in rows:
            routes.append(row[0])
            dates.append(row[1])
            daytypes.append(row[2])
            numrides.append(int(row[3]))
    return dict(routes=routes, dates=dates, daytypes=daytypes, numrides=numrides)

Neste código, primeiro importamos os módulos necessários csv, sys e tracemalloc. O módulo csv é usado para ler arquivos CSV, sys pode ser usado para operações relacionadas ao sistema (embora não seja usado nesta função), e tracemalloc é usado para perfilamento de memória. Dentro da função, inicializamos quatro listas vazias para armazenar diferentes colunas de dados. Em seguida, abrimos o arquivo, ignoramos a linha de cabeçalho e iteramos por cada linha no arquivo, adicionando os valores correspondentes às listas apropriadas. Finalmente, retornamos um dicionário contendo essas quatro listas.

  1. Agora, vamos analisar por que a abordagem orientada a colunas pode economizar memória. Faremos isso no shell Python. Execute o seguinte código:
import readrides
import tracemalloc

## Estimate memory for row-oriented approach
nrows = 577563     ## Number of rows in original file
dict_overhead = 240  ## Approximate dictionary overhead in bytes
row_memory = nrows * dict_overhead
print(f"Estimated memory for row-oriented data: {row_memory} bytes ({row_memory/1024/1024:.2f} MB)")

## Estimate memory for column-oriented approach
pointer_size = 8   ## Size of a pointer in bytes on 64-bit systems
column_memory = nrows * 4 * pointer_size  ## 4 columns with one pointer per entry
print(f"Estimated memory for column-oriented data: {column_memory} bytes ({column_memory/1024/1024:.2f} MB)")

## Estimate savings
savings = row_memory - column_memory
print(f"Estimated memory savings: {savings} bytes ({savings/1024/1024:.2f} MB)")

Neste código, primeiro importamos o módulo readrides que acabamos de criar e o módulo tracemalloc. Em seguida, estimamos o uso de memória para a abordagem orientada a linhas. Assumimos que cada dicionário tem uma sobrecarga (overhead) de 240 bytes, e multiplicamos isso pelo número de linhas no arquivo original para obter o uso total de memória para os dados orientados a linhas. Para a abordagem orientada a colunas, assumimos que em um sistema de 64 bits, cada ponteiro (pointer) ocupa 8 bytes. Como temos 4 colunas e um ponteiro por entrada, calculamos o uso total de memória para os dados orientados a colunas. Finalmente, calculamos a economia de memória subtraindo o uso de memória orientado a colunas do uso de memória orientado a linhas.

Este cálculo mostra que a abordagem orientada a colunas deve economizar cerca de 120MB de memória em comparação com a abordagem orientada a linhas com dicionários.

  1. Vamos verificar isso medindo o uso real de memória com o módulo tracemalloc. Execute o seguinte código:
## Start tracking memory
tracemalloc.start()

## Read the data
columns = readrides.read_rides_as_columns('ctabus.csv')

## Get current and peak memory usage
current, peak = tracemalloc.get_traced_memory()
print(f"Current memory usage: {current/1024/1024:.2f} MB")
print(f"Peak memory usage: {peak/1024/1024:.2f} MB")

## Stop tracking memory
tracemalloc.stop()

Neste código, primeiro começamos a rastrear a memória usando tracemalloc.start(). Em seguida, chamamos a função read_rides_as_columns para ler os dados do arquivo ctabus.csv. Depois disso, usamos tracemalloc.get_traced_memory() para obter o uso atual e de pico de memória. Finalmente, paramos de rastrear a memória usando tracemalloc.stop().

A saída mostrará o uso real de memória da sua estrutura de dados orientada a colunas. Isso deve ser significativamente menor do que nossa estimativa teórica para a abordagem orientada a linhas.

A economia significativa de memória vem da eliminação da sobrecarga de milhares de objetos de dicionário. Cada dicionário em Python tem uma sobrecarga fixa, independentemente de quantos itens ele contém. Ao usar o armazenamento orientado a colunas, precisamos apenas de algumas listas em vez de milhares de dicionários.

Criando uma Classe Contêiner Personalizada

No processamento de dados, a abordagem orientada a colunas é ótima para economizar memória. No entanto, ela pode causar problemas quando seu código existente espera que os dados estejam na forma de uma lista de dicionários. Para resolver esse problema, criaremos uma classe contêiner personalizada. Essa classe apresentará uma interface orientada a linhas, o que significa que ela se parecerá e agirá como uma lista de dicionários para o seu código. Mas internamente, ela armazenará os dados em um formato orientado a colunas, ajudando-nos a economizar memória.

  1. Primeiro, abra o arquivo readrides.py no editor WebIDE. Vamos adicionar uma nova classe a este arquivo. Essa classe será a base do nosso contêiner personalizado.
## Add this to readrides.py
from collections.abc import Sequence

class RideData(Sequence):
    def __init__(self):
        ## Each value is a list with all of the values (a column)
        self.routes = []
        self.dates = []
        self.daytypes = []
        self.numrides = []

    def __len__(self):
        ## All lists assumed to have the same length
        return len(self.routes)

    def __getitem__(self, index):
        return {'route': self.routes[index],
                'date': self.dates[index],
                'daytype': self.daytypes[index],
                'rides': self.numrides[index]}

    def append(self, d):
        self.routes.append(d['route'])
        self.dates.append(d['date'])
        self.daytypes.append(d['daytype'])
        self.numrides.append(d['rides'])

Neste código, definimos uma classe chamada RideData que herda de Sequence. O método __init__ inicializa quatro listas vazias, cada uma representando uma coluna de dados. O método __len__ retorna o comprimento do contêiner, que é o mesmo que o comprimento da lista routes. O método __getitem__ nos permite acessar um registro específico por índice, retornando-o como um dicionário. O método append adiciona um novo registro ao contêiner, adicionando valores a cada lista de coluna.

  1. Agora, precisamos de uma função para ler os dados de viagens de ônibus em nosso contêiner personalizado. Adicione a seguinte função ao arquivo readrides.py.
## Add this to readrides.py
def read_rides_as_dicts(filename):
    '''
    Read the bus ride data as a list of dicts, but use our custom container
    '''
    records = RideData()
    with open(filename) as f:
        rows = csv.reader(f)
        headings = next(rows)     ## Skip headers
        for row in rows:
            route = row[0]
            date = row[1]
            daytype = row[2]
            rides = int(row[3])
            record = {
                'route': route,
                'date': date,
                'daytype': daytype,
                'rides': rides
            }
            records.append(record)
    return records

Esta função cria uma instância da classe RideData e a preenche com dados do arquivo CSV. Ela lê cada linha do arquivo, extrai as informações relevantes, cria um dicionário para cada registro e, em seguida, o adiciona ao contêiner RideData. O ponto-chave é que ela mantém a mesma interface que uma lista de dicionários, mas internamente armazena os dados em colunas.

  1. Vamos testar nosso contêiner personalizado no shell Python. Isso nos ajudará a verificar se ele funciona como esperado.
import readrides

## Read the data using our custom container
rows = readrides.read_rides_as_dicts('ctabus.csv')

## Check the type of the returned object
type(rows)  ## Should be readrides.RideData

## Check the length
len(rows)   ## Should be 577563

## Access individual records
rows[0]     ## Should return a dictionary for the first record
rows[1]     ## Should return a dictionary for the second record
rows[2]     ## Should return a dictionary for the third record

Nosso contêiner personalizado implementa com sucesso a interface Sequence, o que significa que ele se comporta como uma lista. Você pode usar a função len() para obter o número de registros no contêiner e pode usar a indexação para acessar registros individuais. Cada registro parece ser um dicionário, embora os dados sejam armazenados em colunas internamente. Isso é ótimo porque o código existente que espera uma lista de dicionários continuará a funcionar com nosso contêiner personalizado sem qualquer modificação.

  1. Finalmente, vamos medir o uso de memória do nosso contêiner personalizado. Isso nos mostrará quanta memória estamos economizando em comparação com uma lista de dicionários.
import tracemalloc

tracemalloc.start()
rows = readrides.read_rides_as_dicts('ctabus.csv')
current, peak = tracemalloc.get_traced_memory()
print(f"Current memory usage: {current/1024/1024:.2f} MB")
print(f"Peak memory usage: {peak/1024/1024:.2f} MB")
tracemalloc.stop()

Quando você executa este código, você deve ver que o uso de memória é semelhante à abordagem orientada a colunas, que é muito menor do que o que uma lista de dicionários usaria. Isso demonstra a vantagem do nosso contêiner personalizado em termos de eficiência de memória.

Aprimorando o Contêiner Personalizado para Fatiamento

Nosso contêiner personalizado é ótimo para acessar registros individuais. No entanto, há um problema quando se trata de fatiamento (slicing). Quando você tenta obter uma fatia do nosso contêiner, o resultado não é o que você normalmente esperaria.

Vamos entender por que isso acontece. Em Python, o fatiamento é uma operação comum usada para extrair uma parte de uma sequência. Mas para nosso contêiner personalizado, o Python não sabe como criar um novo objeto RideData com apenas os dados fatiados. Em vez disso, ele cria uma lista contendo os resultados da chamada __getitem__ para cada índice na fatia.

  1. Vamos testar o fatiamento no shell Python:
import readrides

rows = readrides.read_rides_as_dicts('ctabus.csv')
r = rows[0:10]  ## Take a slice of the first 10 records
type(r)  ## This will likely be a list, not a RideData object
print(r)  ## This might look like a list of numbers, not dictionaries

Neste código, primeiro importamos o módulo readrides. Em seguida, lemos os dados do arquivo ctabus.csv em uma variável rows. Quando tentamos obter uma fatia dos primeiros 10 registros e verificar o tipo do resultado, descobrimos que é uma lista em vez de um objeto RideData. Imprimir o resultado pode mostrar algo inesperado, como uma lista de números em vez de dicionários.

  1. Vamos modificar nossa classe RideData para lidar com o fatiamento corretamente. Abra readrides.py e atualize o método __getitem__:
def __getitem__(self, index):
    if isinstance(index, slice):
        ## Handle slice
        result = RideData()
        result.routes = self.routes[index]
        result.dates = self.dates[index]
        result.daytypes = self.daytypes[index]
        result.numrides = self.numrides[index]
        return result
    else:
        ## Handle single index
        return {'route': self.routes[index],
                'date': self.dates[index],
                'daytype': self.daytypes[index],
                'rides': self.numrides[index]}

Neste método __getitem__ atualizado, primeiro verificamos se o index é uma fatia. Se for, criamos um novo objeto RideData chamado result. Em seguida, preenchemos este novo objeto com fatias das colunas de dados originais (routes, dates, daytypes e numrides). Isso garante que, quando fatiamos nosso contêiner personalizado, obtemos outro objeto RideData em vez de uma lista. Se o index não for uma fatia (ou seja, é um único índice), retornamos um dicionário contendo o registro relevante.

  1. Vamos testar nossa capacidade de fatiamento aprimorada:
import readrides

rows = readrides.read_rides_as_dicts('ctabus.csv')
r = rows[0:10]  ## Take a slice of the first 10 records
type(r)  ## Should now be readrides.RideData
len(r)   ## Should be 10
r[0]     ## Should be the same as rows[0]
r[1]     ## Should be the same as rows[1]

Depois de atualizar o método __getitem__, podemos testar o fatiamento novamente. Quando obtemos uma fatia dos primeiros 10 registros, o tipo do resultado agora deve ser readrides.RideData. O comprimento da fatia deve ser 10, e acessar elementos individuais na fatia deve nos dar os mesmos resultados que acessar os elementos correspondentes no contêiner original.

  1. Você também pode testar com diferentes padrões de fatia:
## Get every other record from the first 20
r2 = rows[0:20:2]
len(r2)  ## Should be 10

## Get the last 10 records
r3 = rows[-10:]
len(r3)  ## Should be 10

Aqui, estamos testando diferentes padrões de fatia. A primeira fatia rows[0:20:2] obtém todos os outros registros dos primeiros 20 registros, e o comprimento da fatia resultante deve ser 10. A segunda fatia rows[-10:] obtém os últimos 10 registros, e seu comprimento também deve ser 10.

Ao implementar corretamente o fatiamento, nosso contêiner personalizado agora se comporta ainda mais como uma lista Python padrão, mantendo a eficiência de memória do armazenamento orientado a colunas.

Essa abordagem de criar classes de contêiner personalizadas que imitam contêineres Python embutidos, mas com diferentes representações internas, é uma técnica poderosa para otimizar o uso de memória sem alterar a interface que seu código apresenta aos usuários.

Resumo

Neste laboratório, você aprendeu várias habilidades importantes. Primeiro, você explorou o comportamento de alocação de memória em listas e dicionários Python e aprendeu a otimizar o uso de memória, mudando do armazenamento de dados orientado a linhas para o orientado a colunas. Segundo, você criou uma classe contêiner personalizada que mantém a interface original, usando menos memória, e a aprimorou para lidar com operações de fatiamento (slicing) corretamente.

Essas técnicas são altamente valiosas para trabalhar com grandes conjuntos de dados, pois podem reduzir significativamente o uso de memória sem alterar a interface do seu código. A capacidade de criar classes de contêiner personalizadas que imitam contêineres Python embutidos com diferentes representações internas é uma ferramenta de otimização poderosa para aplicações Python. Você pode aplicar esses conceitos a outros projetos críticos em termos de memória, especialmente aqueles que envolvem grandes conjuntos de dados estruturados regularmente.