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

Последовательности и повторения

Последовательностями в Python называют упорядоченные коллекции элементов. К числу последовательностей относятся объекты таких встроенных типов данных, как строки (str), последовательности байтов (bytes), кортежи (tuple) и списки (list). Существует набор операций, которые обязаны поддерживаться последовательностями. Например, если объект является последовательностью, то он обязан быть итерируемым (поддерживать обход по своим элементам с помощью цикла for), а также обязан поддерживать обращения к своим элементам по целочисленным индексам. Помимо этого существует ряд операций, определение которых не является обязательным при определении последовательностей, но которые, тем не менее, определены для всех встроенных типов данных, объекты которых являются последовательностями. Одна из таких операций - операция повторения. В коде повторение последовательностей выглядит как умножение объектов на число справа:

arr = list(range(3))
arr_repeated = arr * 2  # повторение
# arr = [0, 1, 2]
# arr_repeated = [0, 1, 2, 0, 1, 2]

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

zeros = [0] * 10
# zeros = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

zeros[1] = 42
# zeros = [0, 42, 0, 0, 0, 0, 0, 0, 0, 0]

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

Представим, что мы занимаемся разработкой компьютерной версии игры Крестики Нолики. Для того, чтобы отслеживать состояние игры нам потребуется хранить игровое поле - двумерный массив размера 3 x 3. Изначально все клетки поля должны быть пустыми. Затем по мере продолжения игры игроки будут заполнять пустые клетки поля или символами "X", или символами "O". Поскольку мы знаем об операции повторения, инициализация игрового поля может выглядеть следующим образом:

# вспомогательная функция для визуализации игрового поля
def print_2d_grid(grid: list[list[str]]) -> None:
    for row in grid:
        print(row)
    
    print("")


tic_tac_toe = [[""] * 3] * 3
print_2d_grid(tic_tac_toe)
# Вывод:
# ['', '', '']
# ['', '', '']
# ['', '', '']

Все выглядит более чем логично. Сначала, повторяя список из пустого строкового литерала [""] три раза, мы определяем одну строку игрового поля: ["", "", ""]. Затем, повторяя три раза список, состоящий из одной строки поля, мы определяем все игровое поле. Однако при таком подходе к инициализации первый же ход игроков заставит нас столкнуться с серьезными проблемами:

tic_tac_toe[0][0] = "X"
print_2d_grid(tic_tac_toe)
# Вывод:
# ['X', '', '']
# ['X', '', '']
# ['X', '', '']

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

Для начала выясним, как именно происходит повторение последовательностей. Рассмотрим логику работы повторений на примере списков. Эта логика без труда экстраполируется на другие последовательности. Для понимания логики работы операций повторения, нам придется вспомнить способ хранения Python-списков в памяти компьютера. Сильно упрощая, в памяти компьютера списки Python выглядят как массивы адресов ячеек памяти компьютера. Фактические значения элементов списка хранятся как раз по этим адресам. Следующая схема иллюстрирует написанное.

Схема хранения Python-списков в памяти компьютера

Когда мы выполняем операцию повторения, мы создаем новый объект, в нашем случае новый список. Элементы нового списка - элементы исходного списка, повторенные N раз с сохранением их изначального порядка следования. Следующая картинки иллюстрирует результат повторения списка [1, 2, 3] два раза.

Результат повторения списка [1, 2, 3] два раза

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

Теперь вернемся к нашему примеру с созданием игрового поля. Рассмотрим следующую часть выражения, использованного для инициализации поля: [[""] * 3]. В результате выполнения этого выражения, мы получим следующий список: [["", "", ""]]. Вложенный список состоит из трех строковых литералов. Причем в памяти компьютера элементы вложенного списка являются одним и тем же адресом: адресом объекта "". "Внешний" список состоит всего из одного элемента - из списка строковых литералов. Его фактическое значение - массив, состоящий из одного элемента - адреса объекта ["", "", ""] в памяти  компьютера. Повторяя "внешний" список три раза, мы получаем список, элементы которого  ссылаются на один и тот же объект в памяти компьютера. Чтобы убедиться в правильности этого рассуждения, мы можем напечатать значение идентичности каждого элемента полученного списка с помощью встроенной функции id(). В CPython результат выполнения id() соответствует адресу объекта в памяти компьютера. Итак:

for row in tic_tac_toe:
    print(f"row ID: {id(row)};")
# Вывод (значение адреса может отличаться):
# row ID: 2481143860160;
# row ID: 2481143860160;
# row ID: 2481143860160;

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

Чтобы избежать ошибку, мы можем инициализировать наше поле для игры в Крестики Нолики следующим образом:

grid_size = 3
tic_tac_toe = [[""] * grid_size for _ in range(grid_size)]

tic_tac_toe[0][0] = "X"
print_2d_grid(tic_tac_toe)
# Вывод:
# ['X', '', '']
# ['', '', '']
# ['', '', '']

В этом случае мы использовали list comprehension или, как его называют в русскоязычной литературе, списковое включение. В отличие от предыдущего подхода, на каждой итерации спискового включения будет создан новый список в памяти компьютера. Элементы результирующего списка - адреса трех разных, независимых друг от друга объектов. Изменение элементов одной строки нового игрового поля не будет отражаться на остальных строках этого поля.

Глобальные переменные

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

num_global = 5


def test_global(num: int) -> int:
    print(num_global)
    return num_global + num


print(test_global(42))
# Вывод:
# 5
# 47

В теле функции мы печатаем значение глобальной переменной num_global в стандартный поток вывода, после чего считаем сумму значений локальной переменной num и глобальной переменной num_global и возвращаем вычисленное значение.

Давайте изменим код нашей функции следующим образом. Теперь при каждом вызове функции test_global мы будем выводить актуальное значение глобальной переменной num_global, а затем увеличивать это значение на значение аргумента num, после чего возвращать рассчитанное значение в качестве результата выполнения функции. Получим следующее определение:

num_global = 5


def test_global(num: int) -> int:
    print(num_global)
    num_global += num

    return num_global

Вызовем функцию test_global:

test_global(42)
# Вывод:
# ...
# UnboundLocalError: cannot access local variable 'num_global' where it is not associated with a value

Внезапно мы получили ошибку UnboundLocalError. Из описания ошибки ясно, что в момент вызова функции print() интерпретатор Python считает переменную num_global неопределенной. Но почему? Ведь мы определили эту переменную до определения функции test_global?

Все дело в том, как интерпретатор Python квалифицирует используемые имена. Каждый python-идентификатор, используемый в теле функции, с точки зрения интерпретатора считается или именем локальной переменной, или именем нелокальной переменной (freevar), или именем глобальной переменной. Как упоминалось выше, определения локальных переменных должны содержаться в теле функции. Определения нелокальных переменных должны находиться в функции, относительно которой данная функция считается вложенной. Глобальные переменные должны быть определены на уровне модуля.

Чтобы понять, что именно пошло не так в нашем случае, мы можем проверить, как интерпретатор квалифицировал нашу переменную. Это можно сделать, перечитав название ошибки UnboundLocalError. Однако, для большей уверенности все же выполним проверку вручную. Функции в Python имеют dunder-атрибут code, в котором хранится скомпилированный исполняемый код Python, или байт-код. Подробнее про этот объект можно прочитать в Language Reference. Нас с вами интересуют следующие факты: объект code имеет атрибут co_varnames - кортеж имен локальных переменных данной функции, co_freevars - кортеж с именами нелокальных переменных данной функции, и co_names - кортеж, в котором лежат остальные имена, использованные в теле функции. Обычно в co_names хранятся имена глобальных переменных и имена встроенных (builtins) объектов. Исходя из знания этих фактов, делаем вывод, что в нашем случае имя num_global должно лежать в атрибуте co_names. Проверяем:

print(test_global.__code__.co_names)
# Вывод:
# ('print',)

Промах. В co_names имени num_global не обнаружено. Но где же оно тогда лежит? Доверимся названию полученной ошибки и проверим co_varnames:

print(test_global.__code__.co_varnames)
# Вывод:
# ('num', 'num_global')

Итак, интерпретатор действительно квалифицировал num_global как локальную переменную функции test_global, несмотря на тот факт что определение этой переменной находится за пределами функции. Но почему?

Все дело в составном присваивании. Как только какая-либо переменная в теле функции встречается слева от оператора присваивания (=) или слева от оператора составного присваивания (в нашем случае +=), интерпретатор Python начинает считать ее локальной для данной функции. Поскольку в нашем примере мы использовали переменную num_global слева от оператора составного присваивания num_global += num, интерпретатор посчитал переменную num_global локальной. При этом само тело функции не содержит определения этой переменной, поэтому уже на моменте использования num_global в качестве аргумента функции print() мы получили ошибку.

Исправить ситуацию можно за счет использования ключевого слова global, которое явно сообщает интерпретатору о наших намерениях:

num_global = 5


def test_global(num: int) -> int:
    global num_global

    print(num_global)
    num_global += num

    return num_global

print(test_global(42))
# Вывод:
# 5
# 47

Теперь все работает, как и ожидалось изначально. А если мы проверим значение атрибута co_names, то увидим следующее значение:

print(test_global.__code__.co_names)
# Вывод:
# ('print', 'num_global')

Модификация аргументов функции

Реализуем функцию test_add, которая принимает на вход объект целочисленного типа данных и список. В теле функции происходит печать ID переданных объектов, после чего для каждого переданного объекта выполняется операция составного присваивания +=.

def test_add(num: int, lst: list) -> None:
    print(f"num ID: {id(num)}; lst ID: {id(lst)}")
    num += 1
    lst += [42]

Теперь определим список lst со значением [1, 2, 3] и объект целочисленного типа данных num со значением 5 и воспользуемся нашей функций. Вопрос, каковы будут значения переменных lst и num после вызова функции test_add?

lst = [1, 2, 3]
num = 5
print(f"{num = }, {lst = }")
print(f"num ID: {id(num)}; lst ID: {id(lst)}")

test_add(num, lst)
print(f"{num = }, {lst = }")
# Вывод (ID объектов могут отличаться):
# num = 5, lst = [1, 2, 3]
# num ID: 140713921602472; lst ID: 2481143863808
# num ID: 140713921602472; lst ID: 2481143863808
# num = 5, lst = [1, 2, 3, 42]

После вызова функции test_add значения num и lst будут равны 5 и [1, 2, 3, 42], соответственно. Почему? Фактически, в момент вызова функции будут созданы переменные lst и num, локальные для данной функции. Эти переменные будут указывать на те же объекты в памяти, что и переменные lst и num, определенные вне функции test_add. Чтобы убедиться в этом, достаточно сравнить ID объектов, напечатанные до выполнения функции и во время выполнения функции.

Далее в теле функции выполняются операции составного присваивания (+=). Во время выполнения операции составного присваивания с объектом числового типа данных создается новый объект числового типа данных. Это происходит, поскольку числовые типы данных неизменяемы. Мы не можем изменить значение существующего целочисленного объекта. Поэтому при выполнении операции составного присваивания будет создан новый объект числового типа данных, чье значение будет равно значению num + 1. Затем  этот новый объект будет связан с локальной переменной num функции test_add. Т.е. локальная переменная num перестанет ссылаться на объект целочисленного типа данных со значением 5 и начнет ссылаться на только что созданный объект с целочисленным значением 6. Никакого влияния на внешнюю переменную num со значением 5 это составное присваивание не окажет. При выполнении операции составного присваивания со списком, мы изменим его значение, поскольку списки - это объекты изменяемого типа данных. В этом случае никакого нового объекта создано не будет, все изменения будут применены к уже существующему объекту, ссылки на которой имеются и в теле функции, и за его пределами. Следующее изображение иллюстрирует написанное:

Ссылки и составное присваивание

После выхода из функции test_add все ссылки, созданные в теле функции, будут уничтожены, а все модификации изменяемых объектов, совершенные во время выполнения функции, останутся.

Значения по умолчанию

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

def test_defaults(num: int, lst: list[int] = []) -> list[int]:
    # обрабатываем num
    lst.append(num)
    return lst

Однако минимальная проверка корректности этого решения позволяет выявить ошибку:

print(test_defaults(42, [1, 2, 3]))
print(test_defaults(42))
print(test_defaults(42))
# Вывод:
# [1, 2, 3, 42]
# [42]
# [42, 42]

В результате использования функции test_defaults в первом и во втором случае мы получаем ровно те результаты, на которые рассчитывали: в первом случае число 42 было добавлено в конец переданного списка, а во втором случае число 42 было добавлено в конец пустого списка. Но в третьем примере что-то пошло не так и вместо списка [42] в результате выполнения функции мы получили список [42, 42]. Более того, если продолжать вызывать функцию test_defaults без указания списка, результат будет содержать все больше и больше значений.

Это происходит, потому что значения по умолчанию для аргументов функции вычисляются не во время каждого вызова, а во время определения функции. В момент определения функции test_defaults был создан пустой список и сохранен в специальный dunder-атрибут функции defaults. defaults - это кортеж, в котором хранятся значения по умолчанию для нестрого именованных аргументов функции. Значения по умолчанию для строго именованных аргументов функции хранятся в dunder-атрибуте kwdefaults, но сейчас не об этом. Если во время вызова функции test_defaults вызывающая сторона передает значение для аргумента lst, интерпретатор использует его. Если же во время вызова значение для lst не было передано, интерпретатор воспользуется значением lst, сохраненным в defaults. В том случае, когда значение по умолчанию - это объект неизменяемого типа данных, ничего страшного не произойдет, и любые манипуляции со значением по умолчанию никак не отразятся на содержимом defaults. Если же, как в нашем случае, значение аргумента по умолчанию - это объект изменяемого типа данных, все может закончиться очень и очень плохо: все изменения этого значения будут отражаться на дальнейшей работе функции. Более того, если мы будем использовать значение по умолчанию изменяемого типа данных в качестве возвращаемого значения функции, то из-за ссылочной модели памяти в Python, мы предоставим вызывающей стороне возможность изменять значение по умолчанию. Безусловно, все это не лучшим образом скажется на последующих вызовах функции.

Итак, если вам не требуется подобного поведения, то старайтесь всячески избегать использования объектов изменяемых типов данных в качестве значений по умолчанию для аргументов функций. Вместо них можно использовать сигнальные значения: в простейшем случае можно использовать None.

from typing import Optional


def test_defaults(num: int, lst: Optional[list[int]] = None) -> list[int]:
    # обрабатываем num
    if lst is None:
        lst = []

    lst.append(num)
    return lst


print(test_defaults(42, [1, 2, 3]))
print(test_defaults(42))
print(test_defaults(42))
# Вывод:
# [1, 2, 3, 42]
# [42]
# [42]

На этот раз все работает так, как мы изначально задумывали.

Глобальные переменные наносят ответный удар

В последнем примере мы не будем ничего представлять. Я просто покажу вам код, который я увидел почти год назад в одном из рабочих чатов. И при первом знакомстве этот код сломал мне голову.

from typing import Callable


functions: list[Callable[[int], int]] = []

for i in range(3):
    functions.append(lambda x: x + i)

В данном коде происходит следующее. Мы определяем список, в котором будут лежать функции, принимающие на вход целочисленные аргументы и возвращающие целые числа в качестве результата. В цикле в список сохраняются анонимные функции, они же лямбда-функции. Каждая лямбда-функция должна смещать переданное значение x на значение i, использованное в момент создания функции. Т.е. первая функция списка не смещает значение аргумента, вторая функция списка смещает значение аргумента на 1, последняя функция - на 2. Однако после выполнения каждой из определенных функций мы увидим одинаковые результаты:

for func in functions:
    print(func(0))
# Вывод:
# 2
# 2
# 2

В этот раз обойдемся без риторических вопросов. Чтобы понять причину такого поведения, нам нужно вспомнить, что анонимные функции - это, на самом деле, полноценные функции Python, просто определенные с использованием другого ключевого слова. Раз так, все рассуждения о локальных и глобальных переменных функции применимы и в этом случае. Переменная i является глобальной переменной для всех функций из нашего списка:

for func in functions:
    print(func.__code__.co_names)
# Вывод:
# ('i',)
# ('i',)
# ('i',)

Все три функции используют одну и ту же глобальную переменную для определения смещения. При этом значение этой переменной никак не фиксируется в определении функции. Фиксируется только имя переменной, поиск которого будет выполнен в глобальной области видимости в момент вызова функции. После завершения цикла for с определением всех анонимных функций, значение переменной i равно 2. В момент вызова каждой из определенных функций, интерпретатор понимает, что i - глобальная переменная, ищет значение переменной i в глобальной области видимости и использует его для вычислений. Но, очевидно, значение глобальной переменной i никак не изменяется в зависимости от обращающейся к нему функции. Во всех случаях i == 2.

Чтобы заставить этот код работать так, как нам нужно, придется реализовать фабрику. Такое решение позволит сделать переменную i нелокальной для каждой из определяемых функций. Каждая функция будет хранить в специальном dunder-атрибуте closure значение переменной i, использованное в момент определения этой функции.

from typing import Callable


def create_shifter(shift: int) -> Callable[[int], int]:
    return lambda x: x + shift


functions: list[Callable[[int], int]] = []

for i in range(3):
    functions.append(create_shifter(i))

for func in functions:
    print(func(0))
# Вывод:
# 0
# 1
# 2

Заключение

Благодарю вас за прочтение. Надеюсь, вам были интересны рассмотренные листинги кода и вы узнали для себя что-то новое. Если приведенные мною объяснения содержат ошибки или неточности, то обязательно дайте знать в комментариях. Также призываю вас подписаться на мой канал в telegram, в котором я пишу о Python и разработке.

Dockhost — облачная платформа для хостинга приложений на основе Docker-контейнеров (боты, сайты, базы данных и т.д.), которая позволяет запускать и масштабировать как простые проекты, так и сложные микросервисные приложения без необходимости настраивать и контролировать инфраструктуру.