Статьи
January 7, 2023

Всё, что нужно знать о непроизвольных боргах в Python

В Python, в общем-то, реализована передача аргументов по ссылке. Что это значит, и почему нам это важно знать?

Передача по ссылке

В Python любая переменная, указывающая на объект, на самом деле не содержит копии его значения.

Давайте посмотрим, что это значит.

my_pizza_toppings = your_pizza_toppings = []

my_pizza_toppings.append('Анчоусы')
my_pizza_toppings.append('Оливки')

your_pizza_toppings.append('Ананас')
your_pizza_toppings.append('Ветчина')

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

print(my_pizza_toppings)
print(your_pizza_toppings)

В обоих пиццах начинка будет: ['Анчоусы', 'Оливки', 'Ананас', 'Ветчина'], и вряд ли мы этого хотели.

Причина создания такой странной пиццы в том, что мы изначально создали один объект (в нашем случае список с помощью = []). И у нас есть две переменные (my_pizza_toppings и your_pizza_toppings), указывающие на этот список (один и тот же!). И любые его изменения мы увидим, обратившись к нему по любому из этих имён.

Как исправить эту проблему?

К счастью, проблему легко исправить изначально создав два независимых списка. Напишем такой код:

my_pizza_toppings = []
your_pizza_toppings = []

my_pizza_toppings.append('Анчоусы')
my_pizza_toppings.append('Оливки')

your_pizza_toppings.append('Ананас')
your_pizza_toppings.append('Ветчина')

print(my_pizza_toppings)
print(your_pizza_toppings)

Теперь у нас две разные пиццы:

['Анчоусы', 'Оливки']
['Ананас', 'Ветчина']

Почему это так важно?

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

Объектно-ориентированная пиццерия

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

class Pizza:
    toppings = []

    def __init__(self, ...):
        ...
    
    ...

    def add_topping(self, topping):
        ...
        self.toppings.append(topping)
        ...

Давайте посмотрим, что произойдёт, если мы вдвоём закажем пиццу:

my_pizza = Pizza()
my_pizza.add_topping('Анчоусы')
my_pizza.add_topping('Оливки')

your_pizza = Pizza()
your_pizza.add_topping('Ананас')
your_pizza.add_topping('Ветчина')

Интересно, что мы получим сейчас?

print(my_pizza.toppings)
print(your_pizza.toppings)

И снова эта отвратительная пицца: ['Анчоусы', 'Оливки', 'Ананас', 'Ветчина']!Почемуууу?

Обратим особое внимание, что на этот раз мы создали два экземпляра класса Pizza, и не создавали сами две переменные, которые ссылались бы на один и тот же объект. Но всё равно наша пицца будет испорчена.

Причина в том, что список ингредиентов мы создали на уровне нашего класса Pizza, это атрибут класса. Интерпретатор Python создаст этот список ровно один раз, именно при объявлении класса. В итоге мы получаем экземпляры класса, которые оперируют с одним и тем же атрибутом класса toppings. Это всё будет одна и та же переменная.

Как это исправить?

Мы могли бы переписать наш класс Pizza следующим образом:

class Pizza:

    def __init__(self, ...):
        self.toppings = []
    
    ...

    def add_topping(self, topping):
        ...
        self.toppings.append(topping)
        ...

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

А теперь про функции

Допустим, нам для какой-то цели нужна функция, которая добавляет что-то в список. А если список ещё не создан, то функция любезно создаст его. Рассмотрим этот код:

def add_topping(topping_name, toppings = []):
    toppings.append(topping_name)
    return toppings

Во-первых, давайте проверим, работает ли эта функция так, как ожидалось:

add_topping('Анчоусы')
# ['Анчоусы']

Выглядит аппетитно, так что давайте пойдём и снова закажем две пиццы.

my_pizza_toppings = add_topping('Анчоусы')
my_pizza_toppings = add_topping('Оливки', my_pizza_toppings)

your_pizza_toppings = add_topping('Ананас')
your_pizza_toppings = add_topping('Ветчина', your_pizza_toppings)

О, нет! my_pizza_toppings и your_pizza_toppings снова одинаковые:

['Анчоусы', 'Оливки', 'Ананас', 'Ветчина']

А здесь что произошло? Вроде всё ведь правильно делали?

Причина здесь заключается в самом определении функции. Так же, как это было в случае с атрибутом класса в нашем классе Pizza, аргумент по умолчанию (toppings = []) создается Python единожды прямо при объявлении самой функции. Таким образом, любой вызов этой функции с таким параметром по умолчанию, в итоге будет работать с одним и тем же списком, который создался при объявлении функции.

Как это исправить?

Мы можем изменить значение параметра toppings по умолчанию на None и сравнить значение с None внутри функции. Если значение равно None, то уже будем создавать нужный список.

def add_topping(topping_name, toppings=None):
    if toppings is None:
        toppings = []
    toppings.append(topping_name)
    return toppings

В отличие от создания пустого списка в определении функции, на этот раз новый пустой список создаётся только тогда, когда функции вызываются без этого необязательного параметра.

А при чём тут борги?

Иногда совместное использование (ссылка на один и тот же объект) одного и того же объекта в вашем коде — это именно то, что может быть нужно.

Например, можно создать класс, который де-факто ведет себя как синглтон (Singleton class). Такой паттерн создания классов и называется borg как отсылка к боргам в "Star Trek", где они связаны коллективным разумом.

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

PythonTalk в Telegram

Чат PythonTalk в Telegram

Предложить материал | Поддержать канал

Источник: Bas Codes