Разница в Python между изменением и повторным назначением списка (_list = и _list [:] =) - PullRequest
7 голосов
/ 25 мая 2019

Поэтому я часто пишу код, следуя шаблону, подобному следующему:

_list = list(range(10)) # Or whatever
_list = [some_function(x) for x in _list]
_list = [some_other_function(x) for x in _list]

и т. Д.

Я видел теперь по другому вопросу комментарий, объясняющий, как этот подход каждый раз создает новый списоки лучше изменить существующий список следующим образом:

_list[:] = [some_function(x) for x in _list]

Впервые я увидел эту явную рекомендацию, и мне интересно, каковы последствия:

1) Мутация экономит память?Предположительно, ссылки на «старый» список упадут до нуля после переназначения, а «старый» список не учитывается, но есть ли задержка, прежде чем это произойдет, когда я потенциально использую больше памяти, чем мне нужно, когда я используюпереназначение вместо изменения списка?

2) Есть ли вычислительные затраты на использование мутации?Я подозреваю, что изменить что-то на месте дороже, чем создать новый список и просто удалить старый?

В целях безопасности я написал скрипт для проверки этого:

def some_function(number: int):
    return number*10

def main():
    _list1 = list(range(10))
    _list2 = list(range(10))

    a = _list1
    b = _list2 

    _list1 = [some_function(x) for x in _list1]
    _list2[:] = [some_function(x) for x in _list2]

    print(f"list a: {a}")
    print(f"list b: {b}")


if __name__=="__main__":
    main()

Какие выходные данные:

list a: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
list b: [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]

Таким образом, мутация, похоже, имеет недостаток, заключающийся в большей вероятности возникновения побочных эффектов.Хотя это может быть желательно.Есть ли PEP, которые обсуждают этот аспект безопасности, или другие руководства по лучшей практике?

Спасибо.

РЕДАКТИРОВАТЬ: конфликтующие ответы: так что больше тестов на память Итак, у меня естьдо сих пор получил два противоречивых ответа.В комментариях Джейсон Харпер написал, что правая часть уравнения не знает о левой части, и, следовательно, использование памяти не может зависеть от того, что появляется слева.Однако в своих ответах Масуд написал, что «когда используется [переназначение], создаются два новых и старых _списка с двумя разными идентификаторами и значениями. После этого старый _list собирается сборщиком мусора. Но когда контейнер видоизменяется, каждое отдельное значениеизвлекается, изменяется в ЦП и обновляется по одному. Таким образом, список не дублируется. "Похоже, это указывает на то, что для переназначения требуется много памяти.

Я решил попробовать использовать memory-profiler , чтобы копать глубже.Вот тестовый скрипт:

from memory_profiler import profile


def normalise_number(number: int):
    return number%1000


def change_to_string(number: int):
    return "Number as a string: " + str(number) + "something" * number


def average_word_length(string: str):
    return len(string)/len(string.split())


@profile(precision=8)
def mutate_list(_list):
    _list[:] = [normalise_number(x) for x in _list]
    _list[:] = [change_to_string(x) for x in _list]
    _list[:] = [average_word_length(x) for x in _list]


@profile(precision=8)
def replace_list(_list):
    _list = [normalise_number(x) for x in _list]
    _list = [change_to_string(x) for x in _list]
    _list = [average_word_length(x) for x in _list]
    return _list


def main():
    _list1 = list(range(1000))
    mutate_list(_list1)

    _list2 = list(range(1000))
    _list2 = replace_list(_list2)

if __name__ == "__main__":
    main()

Обратите внимание, что я знаю, что, например, эта функция поиска средней длины слова не особенно хорошо написана.Просто для тестирования.

Вот результаты:

Line #    Mem usage    Increment   Line Contents
================================================
    16  32.17968750 MiB  32.17968750 MiB   @profile(precision=8)
    17                             def mutate_list(_list):
    18  32.17968750 MiB   0.00000000 MiB       _list[:] = [normalise_number(x) for x in _list]
    19  39.01953125 MiB   0.25781250 MiB       _list[:] = [change_to_string(x) for x in _list]
    20  39.01953125 MiB   0.00000000 MiB       _list[:] = [average_word_length(x) for x in _list]


Filename: temp2.py

Line #    Mem usage    Increment   Line Contents
================================================
    23  32.42187500 MiB  32.42187500 MiB   @profile(precision=8)
    24                             def replace_list(_list):
    25  32.42187500 MiB   0.00000000 MiB       _list = [normalise_number(x) for x in _list]
    26  39.11328125 MiB   0.25781250 MiB       _list = [change_to_string(x) for x in _list]
    27  39.11328125 MiB   0.00000000 MiB       _list = [average_word_length(x) for x in _list]
    28  32.46484375 MiB   0.00000000 MiB       return _list

Я обнаружил, что даже если я увеличу размер списка до 100000, переназначение последовательно использует больше памяти, но, кактолько на 1% больше.Это заставляет меня думать, что стоимость дополнительной памяти, вероятно, где-то просто дополнительный указатель, а не стоимость всего списка.

Чтобы дополнительно проверить гипотезу, я выполнил профилирование на основе времени с интервалами 0,00001 секунды и построил графикРезультаты.Я хотел посмотреть, был ли, возможно, кратковременный всплеск использования памяти, который мгновенно исчез из-за сбора мусора (подсчет ссылок).Но, увы, я не нашел такого всплеска.

Кто-нибудь может объяснить эти результаты?Что именно происходит здесь под капотом, что вызывает небольшое, но последовательное увеличение использования памяти?

Ответы [ 3 ]

2 голосов
/ 02 июня 2019

Трудно ответить на этот вопрос канонически, потому что фактические детали зависят от реализации или даже от типа.

Например, в CPython , когда объект достигает нулевого числа ссылок, он удаляется и память освобождается немедленно. Однако у некоторых типов есть дополнительный «пул», который ссылается на экземпляры без вашего ведома. Например, CPython имеет «пул» неиспользованных list экземпляров. Когда в коде Python отбрасывается последняя ссылка list, ее можно добавить в этот «свободный список» вместо освобождения памяти (нужно будет вызвать что-то PyList_ClearFreeList чтобы восстановить эту память).

Но список - это не только память, необходимая для списка, список содержит объектов. Даже когда память списка освобождается, объекты, которые были в списке, могут остаться, например, есть ссылка на этот объект где-то еще, или у этого типа также есть «свободный список».

Если вы посмотрите на другие реализации, такие как PyPy , то даже при отсутствии «пула» объект не удаляется сразу, когда никто больше не ссылается на него, он только удаляется «в конце концов» .

Так как это относится к вашим примерам, вы можете задаться вопросом.

Давайте посмотрим на ваши примеры:

_list = [some_function(x) for x in _list]

Перед запуском этой строки для переменной _list назначен один экземпляр списка. Затем вы создаете новый список , используя список-понимание, и присваиваете ему имя _list. Незадолго до этого назначения в памяти есть два списка. Старый список и список, созданный пониманием. После назначения есть один список, на который ссылается имя _list (новый список), и один список с числом ссылок, которое было уменьшено на 1. В случае, если на старый список больше нигде нет ссылок, и, таким образом, он достиг счетчика ссылок из 0, он может быть возвращен в пул, может быть удален или может быть удален в конечном итоге. То же самое для содержимого старого списка.

А как насчет другого примера:

_list[:] = [some_function(x) for x in _list]

Перед запуском этой строки снова присваивается один список с именем _list. Когда строка выполняется, она также создает новый список через понимание списка. Но вместо присвоения новому списку имени _list он заменит содержимое старого списка содержимым нового списка. Однако, пока он очищает старый список, он будет иметь два списка, которые хранятся в памяти. После этого назначения старый список по-прежнему доступен через имя _list, но на список, созданный с помощью списка-списков, больше нет ссылок, он достигает счетчика ссылок 0 и от того, что с ним происходит, зависит. Он может быть помещен в «пул» свободных списков, он может быть удален немедленно, он также может быть удален в какой-то неизвестный момент в будущем. То же самое для оригинального содержимого старого списка, который был очищен.

Так в чем же разница:

На самом деле нет большой разницы. В обоих случаях Python должен хранить два списка полностью в памяти. Однако первый подход освобождает ссылку на старый список быстрее, чем второй подход освобождает ссылку на промежуточный список в памяти просто потому, что он должен поддерживаться во время копирования содержимого.

Однако более быстрое освобождение ссылки не гарантирует, что она фактически приведет к «меньшему объему памяти», поскольку она может быть возвращена в пул или реализация освободит память только в какой-то (неизвестной) точке в будущем.

Менее дорогая альтернатива памяти

Вместо того, чтобы создавать и отбрасывать списки, вы можете связывать итераторы / генераторы в цепочку и использовать их, когда вам нужно их итерировать (или вам нужен фактический список).

Так что вместо того, чтобы делать:

_list = list(range(10)) # Or whatever
_list = [some_function(x) for x in _list]
_list = [some_other_function(x) for x in _list]

Вы можете сделать:

def generate_values(it):
    for x in it:
        x = some_function(x)
        x = some_other_function(x)
        yield x

А потом просто потребьте это:

for item in generate_values(range(10)):
    print(item)

Или использовать его со списком:

list(generate_values(range(10)))

Они не будут (за исключением случаев, когда вы передаете его list) вообще создавать какие-либо списки.Генератор - это конечный автомат, который обрабатывает элементы по одному за раз.

2 голосов
/ 26 мая 2019

Согласно документации CPython :

Некоторые объекты содержат ссылки на другие объекты; они называются контейнерами. Примерами контейнеров являются кортежи, списки и словари. Ссылки являются частью значения контейнера. В большинстве случаев, когда мы говорим о значении контейнера, мы подразумеваем значения, а не идентичности содержащихся объектов; однако, когда мы говорим об изменчивости контейнера, подразумеваются только идентификаторы непосредственно содержащихся объектов.

Так, когда список видоизменяется, ссылки, содержащиеся в списке, видоизменяются, в то время как идентичность объекта остается неизменной. Интересно, что хотя изменяемые объекты с одинаковыми значениями не могут иметь одинаковую идентичность, идентичные неизменяемые объекты могут иметь одинаковую идентичность (поскольку они неизменны!).

a = [1, 'hello world!']
b = [1, 'hello world!']
print([hex(id(_)) for _ in a])
print([hex(id(_)) for _ in b])
print(a is b)

#on my machine, I got:
#['0x55e210833380', '0x7faa5a3c0c70']
#['0x55e210833380', '0x7faa5a3c0c70']
#False

когда код:

_list = [some_function(x) for x in _list]

используется, создаются два новых и старых списка _ с двумя разными идентификаторами и значениями. После этого старый _list является сборщиком мусора. Но когда контейнер видоизменяется, каждое отдельное значение извлекается, изменяется в ЦП и обновляется по одному. Таким образом, список не дублируется.

Что касается эффективности обработки, ее легко сопоставить:

import time

my_list = [_ for _ in range(1000000)]

start = time.time()
my_list[:] = [_ for _ in my_list]
print(time.time()-start)  # on my machine 0.0968618392944336 s


start = time.time()
my_list = [_ for _ in my_list]
print(time.time()-start)  # on my machine 0.05194497108459473 s

обновление: Можно считать, что список состоит из двух частей: ссылок на (идентификаторы) других объектов и значений ссылок. Я использовал код, чтобы продемонстрировать процент памяти, занимаемой объектом списка, непосредственно занимающим общее потребление памяти (объект списка + упомянутые объекты):

import sys
my_list = [str(_) for _ in range(10000)]

values_mem = 0
for item in my_list:
    values_mem+= sys.getsizeof(item)

list_mem = sys.getsizeof(my_list)

list_to_total = 100 * list_mem/(list_mem+values_mem)
print(list_to_total) #result ~ 14%
1 голос
/ 02 июня 2019

TLDR: Вы не можете изменить список на месте в Python, не выполняя какой-либо цикл самостоятельно или не используя внешнюю библиотеку, но в любом случае, вероятно, не стоит пытаться по соображениям экономии памяти (преждевременная оптимизация).Возможно, стоит попробовать использовать функцию Python map и iterables , которые вообще не сохраняют результаты, а вычисляют их по требованию.


Существует несколькоспособы применения модифицирующей функции по списку (т.е. выполнение map ) в Python, каждый из которых имеет различные значения для производительности и побочных эффектов:


Новый список

Это то, что фактически делают обе опции в вопросе.

[some_function(x) for x in _list]

Это создает новый список со значениями, заполняемыми по порядку, путем запуска some_function для соответствующего значения в _list.Затем он может быть назначен в качестве замены для старого списка (_list = ...) или его значения заменяют старые значения, сохраняя при этом ссылку на объект (_list[:] = ...).Первое назначение происходит в постоянном времени и памяти (в конце концов, это просто замена ссылки), где второе должно выполнять итерацию по списку для выполнения назначения, которое является линейным по времени.Тем не менее, время и память, необходимые для создания списка, в первую очередь линейны, поэтому _list = ... строго быстрее, чем _list[:] = ..., но все равно линейно по времени и памяти, поэтому на самом деле это не имеет значения.

С функциональной точки зрения два варианта этого варианта имеют потенциально опасные последствия из-за побочных эффектов._list = ... оставляет старый список висящим вокруг, что не опасно, но означает, что память не может быть освобождена.Любой другой код, ссылающийся на _list, сразу же после изменения получит новый список, что, вероятно, снова хорошо, но может вызвать незначительные ошибки, если вы не обращаете на это внимания.list[:] = ... изменяет существующий список, так что любой, у кого есть ссылка на него, будет менять значения у себя под ногами.Имейте в виду, что если список когда-либо возвращается из метода или выходит за пределы области действия, в которой вы работаете, вы можете не знать, кто еще его использует.

Суть в том, что оба эти методалинейны как по времени, так и по памяти, потому что они копируют список, и имеют побочные эффекты, которые необходимо учитывать.


Замена на месте

Другая возможность, на которую намекают вВопрос об изменении ценностей на месте.Это позволит сэкономить на памяти копию списка.К сожалению, в Python нет встроенной функции для этого, но это не сложно сделать вручную (как предлагается в различных ответах на этот вопрос ).

for i in range(len(_list)):
    _list[i] = some_function(_list[i])

в отношении сложностиэто все еще имеет линейную временную стоимость выполнения вызовов до some_function, но экономит дополнительную память для хранения двух списков.Если на него не ссылаются в другом месте, каждый элемент в старом списке можно собирать мусором, как только он будет заменен.

Функционально это, возможно, самый опасный вариант, поскольку список находится в несогласованном состоянии.во время звонков на some_function.Пока some_function не ссылается на список (который в любом случае был бы довольно ужасным дизайном), он должен быть таким же безопасным, как и разнообразные решения new list .Он также имеет те же опасности, что и решение _list[:] = ..., приведенное выше, поскольку исходный список изменяется.


Iterables

Функция Python 3 map действует на итерации, а не насписки.Списки являются итерируемыми, но итерируемые не всегда являются списками, и когда вы вызываете map(some_function, _list), он сразу не запускает some_function.Это происходит только тогда, когда вы пытаетесь потреблять итерируемого каким-либо образом.

list(map(some_other_function, map(some_function, _list)))

Приведенный выше код применяет some_function, затем some_other_function к элементам _list и помещает результаты в новый список, но, что важно, он вообще не сохраняет промежуточное значение.Если вам нужно только выполнить итерацию результатов или вычислить максимум из них или какую-либо другую функцию уменьшение , вам не нужно ничего сохранять по пути.

Этот подход подходитс функциональной парадигмой программирования , которая препятствует побочным эффектам (часто источником хитрых ошибок).Поскольку исходный список никогда не изменяется, даже если some_function ссылался на него, кроме того, который он рассматривал в то время (что, кстати, все еще не является хорошей практикой), текущее на него не повлияло бы.map .

В стандартной библиотеке Python есть множество функций для работы с итераторами и генераторами itertools.


Замечание по распараллеливанию

Очень заманчиво подумать о том, как можно распараллелить выполнение карты в списке, чтобы уменьшить линейную временную стоимость вызовов до some_function, поделив ее между несколькими процессорами.В принципе, все эти методы могут быть распараллелены, но Python делает это довольно сложным.Один из способов сделать это - использовать библиотеку multiprocessing, которая имеет функцию map. Этот ответ описывает, как его использовать.

...