Введение
В этом лабораторном занятии вы узнаете о контейнерах Python и управлении памятью. Вы изучите, как Python управляет памятью для встроенных структур данных, и узнаете, как создать собственный класс контейнера, экономящий память.
Цели этого лабораторного занятия: изучить поведение распределения памяти для списков и словарей Python, создать собственный класс контейнера для оптимизации использования памяти и понять преимущества столбцового хранения данных.
Понимание распределения памяти для списков
В Python списки являются очень полезной структурой данных, особенно когда вам нужно добавлять элементы в них. Списки Python разработаны так, чтобы операции добавления элементов были эффективными. Вместо того чтобы выделять ровно столько памяти, сколько нужно, Python выделяет дополнительную память заранее, ожидая будущих добавлений. Эта стратегия минимизирует количество перераспределений памяти, необходимых при расширении списка.
Попробуем лучше понять этот концепт, используя функцию sys.getsizeof(). Эта функция возвращает размер объекта в байтах, что позволяет нам увидеть, сколько памяти использует список на разных этапах.
- Сначала вам нужно открыть интерактивную оболочку Python в терминале. Это как игровая площадка, где вы можете сразу запускать код Python. Чтобы открыть ее, введите следующую команду в терминале и нажмите Enter:
python3
- Как только вы находитесь в интерактивной оболочке Python, вам нужно импортировать модуль
sys. Модули в Python похожи на ящики с инструментами, которые содержат полезные функции. В модулеsysесть функцияgetsizeof(), которая нам нужна. После импорта модуля создайте пустой список с именемitems. Вот код для этого:
import sys
items = []
- Теперь проверим начальный размер пустого списка. Мы будем использовать функцию
sys.getsizeof()с спискомitemsв качестве аргумента. Введите следующий код в интерактивной оболочке Python и нажмите Enter:
sys.getsizeof(items)
Вы должны увидеть значение, например, 64 байта. Это значение представляет накладные расходы для пустого списка. Накладные расходы - это базовое количество памяти, которое Python использует для управления списком, даже когда в нем нет элементов.
- Далее мы начнем добавлять элементы в список по одному и будем наблюдать, как меняется распределение памяти. Мы будем использовать метод
append()для добавления элемента в список и затем снова проверим размер. Вот код:
items.append(1)
sys.getsizeof(items)
Вы должны увидеть большее значение, примерно 96 байт. Это увеличение размера показывает, что Python выделил больше памяти, чтобы вместить новый элемент.
- Продолжим добавлять больше элементов в список и проверять размер после каждого добавления. Вот код для этого:
items.append(2)
sys.getsizeof(items) ## Размер остается прежним
items.append(3)
sys.getsizeof(items) ## Размер по-прежнему не меняется
items.append(4)
sys.getsizeof(items) ## Размер по-прежнему не меняется
items.append(5)
sys.getsizeof(items) ## Размер резко увеличивается
Вы заметите, что размер не увеличивается при каждой операции добавления элемента. Вместо этого он периодически увеличивается. Это показывает, что Python выделяет память блоками, а не отдельно для каждого нового элемента.
Это поведение предусмотрено дизайном. Python изначально выделяет больше памяти, чем нужно, чтобы избежать частых перераспределений при расширении списка. Каждый раз, когда список превышает свою текущую емкость, Python выделяет больший блок памяти.
Помните, что список хранит ссылки на объекты, а не сами объекты. На 64-битной системе каждая ссылка обычно требует 8 байт памяти. Это важно понимать, так как это влияет на то, сколько памяти на самом деле использует список, когда он содержит разные типы объектов.
Распределение памяти для словарей
В Python, как и списки, словари являются фундаментальной структурой данных. Одним из важных аспектов, которые нужно понять о них, является то, как они распределяют память. Распределение памяти означает, как Python отводит место в памяти компьютера для хранения данных в вашем словаре. Как и списки, словари Python также выделяют память блоками. Исследуем, как работает распределение памяти для словарей.
- Сначала нам нужно создать словарь, с которым будем работать. В той же оболочке Python (или откройте новую, если вы ее закрыли), мы создадим словарь, представляющий запись данных. Словарь в Python представляет собой коллекцию пар ключ - значение, где каждый ключ уникален и используется для доступа к соответствующему значению.
import sys ## Импортируйте sys, если вы начали новую сессию
row = {'route': '22', 'date': '01/01/2001', 'daytype': 'U', 'rides': 7354}
Здесь мы импортировали модуль sys, который предоставляет доступ к некоторым переменным, используемым или поддерживаемым интерпретатором Python, а также к функциям, которые сильно взаимодействуют с интерпретатором. Затем мы создали словарь с именем row с четырьмя парами ключ - значение.
- Теперь, когда у нас есть наш словарь, мы хотим проверить его начальный размер. Размер словаря означает количество памяти, которое он занимает в компьютере.
sys.getsizeof(row)
Функция sys.getsizeof() возвращает размер объекта в байтах. Когда вы запустите этот код, вы должны увидеть значение около 240 байт. Это дает вам представление о том, сколько памяти занимает словарь изначально.
- Далее мы добавим новые пары ключ - значение в словарь и посмотрим, как изменится распределение памяти. Добавление элементов в словарь - это обычная операция, и понимание того, как это влияет на память, очень важно.
row['a'] = 1
sys.getsizeof(row) ## Размер может остаться прежним
row['b'] = 2
sys.getsizeof(row) ## Размер может увеличиться
Когда вы добавляете первую пару ключ - значение ('a': 1), размер словаря может остаться прежним. Это происходит потому, что Python уже выделил определенный блок памяти, и в этом блоке может быть достаточно места для нового элемента. Однако, когда вы добавляете вторую пару ключ - значение ('b': 2), размер может увеличиться. Вы заметите, что после добавления определенного количества элементов размер словаря внезапно увеличивается. Это происходит потому, что словари, как и списки, выделяют память блоками для оптимизации производительности. Выделение памяти блоками уменьшает количество раз, когда Python должен запрашивать больше памяти у системы, что ускоряет процесс добавления новых элементов.
- Попробуем удалить элемент из словаря, чтобы увидеть, уменьшится ли использование памяти. Удаление элементов из словаря также является обычной операцией, и интересно посмотреть, как это влияет на память.
del row['b']
sys.getsizeof(row)
Интересно, что удаление элемента обычно не уменьшает распределение памяти. Это происходит потому, что Python сохраняет выделенную память, чтобы избежать перераспределения, если элементы будут добавлены снова. Перераспределение памяти - это относительно затратная операция с точки зрения производительности, поэтому Python пытается избегать ее как можно чаще.
Вопросы эффективности использования памяти:
При работе с большими наборами данных, когда вам нужно создать много записей, использование словарей для каждой записи может не быть самым эффективным с точки зрения памяти подходом. Словари очень гибкие и просты в использовании, но они могут потреблять значительное количество памяти, особенно при работе с большим количеством записей. Вот некоторые альтернативы, которые потребляют меньше памяти:
- Кортежи (tuples): Простые неизменяемые последовательности. Кортеж - это коллекция значений, которые не могут быть изменены после создания. Он использует меньше памяти, чем словарь, потому что не нужно хранить ключи и управлять связанным отображением ключ - значение.
- Именнованные кортежи (named tuples): Кортежи с именами полей. Именнованные кортежи похожи на обычные кортежи, но они позволяют вам получать доступ к значениям по имени, что может сделать код более читаемым. Они также используют меньше памяти, чем словари.
- Классы с
__slots__: Классы, которые явно определяют атрибуты, чтобы избежать использования словаря для переменных экземпляра. Когда вы используете__slots__в классе, Python не создает словарь для хранения переменных экземпляра, что уменьшает использование памяти.
Эти альтернативы могут значительно уменьшить использование памяти при обработке большого количества записей.
Оптимизация памяти с использованием столбцовой организации данных
В традиционной хранении данных мы часто храним каждую запись в виде отдельного словаря, что называется строковой (row-oriented) организацией данных. Однако этот метод может потреблять значительное количество памяти. Альтернативный способ - хранить данные по столбцам. В столбцовой (column-oriented) организации данных мы создаем отдельные списки для каждого атрибута, и каждый список содержит все значения для этого конкретного атрибута. Это может помочь нам сэкономить память.
- Сначала вам нужно создать новый файл Python в директории проекта. Этот файл будет содержать код для чтения данных в столбцовой организации. Назовите файл
readrides.py. Вы можете использовать следующие команды в терминале для этого:
cd ~/project
touch readrides.py
Команда cd ~/project изменяет текущую директорию на директорию проекта, а команда touch readrides.py создает новый пустой файл с именем readrides.py.
- Затем откройте файл
readrides.pyв редакторе WebIDE. Затем добавьте следующий код Python в файл. Этот код определяет функциюread_rides_as_columns, которая читает данные о поездках на автобусах из CSV-файла и хранит их в четырех отдельных списках, каждый из которых представляет столбец данных.
## 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)
В этом коде мы сначала импортируем необходимые модули csv, sys и tracemalloc. Модуль csv используется для чтения CSV-файлов, sys может быть использован для операций, связанных с системой (хотя в этой функции не используется), а tracemalloc используется для профилирования памяти. Внутри функции мы инициализируем четыре пустых списка для хранения разных столбцов данных. Затем мы открываем файл, пропускаем строку заголовков и проходим по каждой строке в файле, добавляя соответствующие значения в соответствующие списки. В конце мы возвращаем словарь, содержащий эти четыре списка.
- Теперь давайте проанализируем, почему столбцовая организация данных может сэкономить память. Мы сделаем это в оболочке Python. Запустите следующий код:
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)")
В этом коде мы сначала импортируем модуль readrides, который мы только что создали, и модуль tracemalloc. Затем мы оцениваем использование памяти для строковой организации данных. Мы предполагаем, что каждый словарь имеет накладные расходы в 240 байт, и мы умножаем это на количество строк в исходном файле, чтобы получить общее использование памяти для строковой организации данных. Для столбцовой организации данных мы предполагаем, что на 64-битной системе каждый указатель занимает 8 байт. Поскольку у нас есть 4 столбца и один указатель на запись, мы вычисляем общее использование памяти для столбцовой организации данных. В конце мы вычисляем экономию памяти, вычитая использование памяти для столбцовой организации данных из использования памяти для строковой организации данных.
Этот расчет показывает, что столбцовая организация данных должна сэкономить около 120 МБ памяти по сравнению со строковой организацией данных с использованием словарей.
- Давайте проверим это, измерив фактическое использование памяти с помощью модуля
tracemalloc. Запустите следующий код:
## 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()
В этом коде мы сначала начинаем отслеживать память с помощью tracemalloc.start(). Затем мы вызываем функцию read_rides_as_columns для чтения данных из файла ctabus.csv. После этого мы используем tracemalloc.get_traced_memory() для получения текущего и пикового использования памяти. В конце мы останавливаем отслеживание памяти с помощью tracemalloc.stop().
Вывод показывает фактическое использование памяти вашей структуры данных с столбцовой организацией. Это должно быть значительно меньше, чем наше теоретическое предположение для строковой организации данных.
Значительная экономия памяти достигается за счет устранения накладных расходов тысяч объектов - словарей. Каждый словарь в Python имеет фиксированные накладные расходы, независимо от того, сколько элементов он содержит. Используя столбцовое хранение, нам нужно только несколько списков вместо тысяч словарей.
Создание пользовательского контейнерного класса
В обработке данных столбцовая организация данных отлично подходит для экономии памяти. Однако это может вызвать проблемы, если существующий код предполагает, что данные представлены в виде списка словарей. Чтобы решить эту проблему, мы создадим пользовательский контейнерный класс. Этот класс предоставит интерфейс строковой организации данных, то есть для кода он будет выглядеть и вести себя как список словарей. Но внутренне он будет хранить данные в столбцовом формате, что поможет нам сэкономить память.
- Сначала откройте файл
readrides.pyв редакторе WebIDE. Мы добавим новый класс в этот файл. Этот класс станет основой нашего пользовательского контейнера.
## 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'])
В этом коде мы определяем класс с именем RideData, который наследуется от Sequence. Метод __init__ инициализирует четыре пустых списка, каждый из которых представляет столбец данных. Метод __len__ возвращает длину контейнера, которая равна длине списка routes. Метод __getitem__ позволяет получить доступ к конкретной записи по индексу, возвращая ее в виде словаря. Метод append добавляет новую запись в контейнер, добавляя значения в каждый столбцовый список.
- Теперь нам нужна функция для чтения данных о поездках на автобусах в наш пользовательский контейнер. Добавьте следующую функцию в файл
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
Эта функция создает экземпляр класса RideData и заполняет его данными из CSV-файла. Она читает каждую строку из файла, извлекает соответствующую информацию, создает словарь для каждой записи и затем добавляет его в контейнер RideData. Главное, что она сохраняет тот же интерфейс, что и список словарей, но внутренне хранит данные по столбцам.
- Давайте протестируем наш пользовательский контейнер в оболочке Python. Это поможет нам убедиться, что он работает как ожидается.
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
Наш пользовательский контейнер успешно реализует интерфейс Sequence, что означает, что он ведет себя как список. Вы можете использовать функцию len() для получения количества записей в контейнере, и вы можете использовать индексацию для доступа к отдельным записям. Каждая запись выглядит как словарь, даже если данные хранятся по столбцам внутренне. Это замечательно, потому что существующий код, который предполагает список словарей, будет продолжать работать с нашим пользовательским контейнером без каких - либо изменений.
- Наконец, давайте измерим использование памяти нашего пользовательского контейнера. Это покажет, сколько памяти мы экономим по сравнению со списком словарей.
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()
Когда вы запустите этот код, вы должны увидеть, что использование памяти похоже на столбцовую организацию данных, которое намного меньше, чем использование памяти списком словарей. Это демонстрирует преимущество нашего пользовательского контейнера с точки зрения эффективности использования памяти.
Улучшение пользовательского контейнера для срезов
Наш пользовательский контейнер отлично подходит для доступа к отдельным записям. Однако возникает проблема при использовании срезов. Когда вы пытаетесь взять срез из нашего контейнера, результат не соответствует обычным ожиданиям.
Понятно, почему это происходит. В Python взятие среза - это распространенная операция, используемая для извлечения части последовательности. Но для нашего пользовательского контейнера Python не знает, как создать новый объект RideData только с данными среза. Вместо этого он создает список, содержащий результаты вызова __getitem__ для каждого индекса в срезе.
- Давайте протестируем взятие срезов в оболочке 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
В этом коде мы сначала импортируем модуль readrides. Затем мы читаем данные из файла ctabus.csv в переменную rows. Когда мы пытаемся взять срез из первых 10 записей и проверить тип результата, мы обнаруживаем, что это список, а не объект RideData. Вывод результата может показать что - то неожиданное, например, список чисел вместо словарей.
- Давайте модифицируем наш класс
RideData, чтобы он корректно обрабатывал срезы. Откройте файлreadrides.pyи обновите метод__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]}
В этом обновленном методе __getitem__ мы сначала проверяем, является ли index срезом. Если это так, мы создаем новый объект RideData с именем result. Затем мы заполняем этот новый объект срезами исходных столбцов данных (routes, dates, daytypes и numrides). Это гарантирует, что при взятии среза из нашего пользовательского контейнера мы получаем другой объект RideData, а не список. Если index не является срезом (то есть это одиночный индекс), мы возвращаем словарь, содержащий соответствующую запись.
- Давайте протестируем улучшенную возможность взятия срезов:
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]
После обновления метода __getitem__ мы можем снова протестировать взятие срезов. Когда мы берем срез из первых 10 записей, тип результата теперь должен быть readrides.RideData. Длина среза должна быть равна 10, и доступ к отдельным элементам среза должен давать те же результаты, что и доступ к соответствующим элементам исходного контейнера.
- Вы также можете протестировать разные шаблоны срезов:
## 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
Здесь мы тестируем разные шаблоны срезов. Первый срез rows[0:20:2] извлекает каждую вторую запись из первых 20 записей, и длина полученного среза должна быть равна 10. Второй срез rows[-10:] извлекает последние 10 записей, и его длина также должна быть равна 10.
Правильно реализовав взятие срезов, наш пользовательский контейнер теперь еще больше похож на стандартный список Python, сохраняя при этом эффективность использования памяти столбцовой организации данных.
Подход создания пользовательских контейнерных классов, которые имитируют встроенные контейнеры Python, но имеют разную внутреннюю структуру, является мощной техникой для оптимизации использования памяти без изменения интерфейса, который ваш код предоставляет пользователям.
Резюме
В этом практическом занятии (лабораторной работе) вы научились нескольким важным навыкам. Во - первых, вы изучили поведение распределения памяти в списках и словарях Python и узнали, как оптимизировать использование памяти, переходя от строковой к столбцовой организации данных. Во - вторых, вы создали пользовательский контейнерный класс, который сохраняет исходный интерфейс, но использует меньше памяти, и улучшили его для правильной обработки операций взятия срезов.
Эти техники очень ценны при работе с большими наборами данных, так как они могут значительно уменьшить использование памяти без изменения интерфейса вашего кода. Возможность создавать пользовательские контейнерные классы, которые имитируют встроенные контейнеры Python, но имеют разную внутреннюю структуру, является мощным инструментом оптимизации для приложений на Python. Вы можете применить эти концепции в других проектах, где память является критическим фактором, особенно в тех, которые связаны с большими, регулярно структурированными наборами данных.