А вы можете решить эти три (обманчиво) простые задачи на Python?

Автор оригинала: Maria Fabiańska
  • Перевод
С самого начала своего пути как разработчика программного обеспечения я очень любил копаться во внутренностях языков программирования. Мне всегда было интересно, как устроена та или иная конструкция, как работает та или иная команда, что под капотом у синтаксического сахара и т.п. Недавно мне на глаза попалась интересная статья с примерами того, как не всегда очевидно работают mutable- и immutable-объекты в Python. На мой взгляд, ключевое — это то, как меняется поведение кода в зависимости от используемого типа данных, при сохранении идентичной семантики и используемых языковых конструкциях. Это отличный пример того, что думать надо не только при написании, но и при использовании. Предлагаю всем желающим ознакомиться с переводом.



Попробуйте решить эти три задачи, а потом сверьтесь с ответами в конце статьи.

Совет: у задач есть кое-что общее, поэтому освежите в памяти решение первой задачи, когда перейдёте ко второй или третьей, так вам будет проще.

Первая задача


Есть несколько переменных:

x = 1
y = 2
l = [x, y]
x += 5

a = [1]
b = [2]
s = [a, b]
a.append(5)

Что будет выведено на экран при печати l и s?

Вторая задача


Определим простую функцию:

def f(x, s=set()):
    s.add(x)
    print(s)

Что будет, если вызвать:

>>f(7)
>>f(6, {4, 5})
>>f(2)

Третья задача


Определим две простые функции:

def f():
    l = [1]
    def inner(x):
        l.append(x)
        return l
    return inner

def g():
    y = 1
    def inner(x):
        y += x
        return y
    return inner

Что мы получим после выполнения этих команд?

>>f_inner = f()
>>print(f_inner(2))

>>g_inner = g()
>>print(g_inner(2))

Насколько вы уверены в своих ответах? Давайте проверим вашу правоту.

Решение первой задачи


>>print(l)
[1, 2]

>>print(s)
[[1, 5], [2]]

Почему второй список реагирует на изменение своего первого элемента a.append(5), а первый список полностью игнорирует такое же изменение x+=5?

Решение второй задачи


Посмотрим, что произойдёт:

>>f(7)
{7}

>>f(6, {4, 5})
{4, 5, 6}

>>f(2)
{2, 7}

Погодите, разве последним результатом не должно быть {2}?

Решение третьей задачи


Результат будет таким:

>>f_inner = f()
>>print(f_inner(2))
[1, 2]

>>g_inner = g()
>>print(g_inner(2))
UnboundLocalError: local variable ‘y’ referenced before assignment

Почему g_inner(2) не выдала 3? Почему внутренняя функция f() помнит о внешней области видимости, а внутренняя функция g() не помнит? Они же практически идентичны!

Объяснение


Что если я скажу вам, что все эти примеры странного поведения связаны с различием между изменяемыми и неизменяемыми объектами в Python?

Изменяемые объекты, такие как списки, множества или словари, могут быть изменены на месте. Неизменяемые объекты, такие как числовые и строковые значения, кортежи, не могут быть изменены; их «изменение» приведёт к созданию новых объектов.

Объяснение первой задачи


x = 1
y = 2
l = [x, y]
x += 5

a = [1]
b = [2]
s = [a, b]
a.append(5)

>>print(l)
[1, 2]

>>print(s)
[[1, 5], [2]]

Поскольку x неизменяема, операция x+=5 не меняет исходный объект, а создаёт новый. Но первый элемент списка всё ещё ссылается на исходный объект, поэтому его значение не меняется.

Т.к. a изменяемый объект, то команда a.append(5) меняет исходный объект (а не создает новый), и список s «видит» изменения.

Объяснение второй задачи


def f(x, s=set()):
    s.add(x)
    print(s)

>>f(7)
{7}

>>f(6, {4, 5})
{4, 5, 6}

>>f(2)
{2, 7}

С первыми двумя результатами всё понятно: первое значение 7 добавляется к изначально пустому множеству и получается {7}; потом значение 6 добавляется к множеству {4, 5} и получается {4, 5, 6}.

А потом начинаются странности. Значение 2 добавляется не к пустому множеству, а к {7}. Почему? Исходное значение опционального параметра s вычисляется только один раз: при первом вызове s будет инициализировано как пустое множество. А поскольку оно изменяемое, после вызова f(7) оно будет будет изменено “на месте”. Второй вызов f(6, {4, 5}) не повлияет на параметр по умолчанию: его заменяет множество {4, 5}, то есть {4, 5} является другой переменной. Третий вызов f(2) использует ту же переменную s, что использовалась при первом вызове, но она не переинициализируется как пустое множество, а вместо этого берётся её предыдущее значение {7}.

Поэтому не следует использовать изменяемые аргументы в качестве аргументов по умолчанию. В этом случае функцию нужно изменить:

def f(x, s=None):
    if s is None:
        s = set()
    s.add(x)
    print(s)

Объяснение третьей задачи


def f():
   l = [1]
   def inner(x):
       l.append(x)
       return l
   return inner

def g():
   y = 1
   def inner(x):
       y += x
       return y
   return inner

>>f_inner = f()
>>print(f_inner(2))
[1, 2]

>>g_inner = g()
>>print(g_inner(2))
UnboundLocalError: local variable ‘y’ referenced before assignment

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

Почему так происходит? Когда мы исполняем l.append(x), меняется изменяемый объект, созданный при определении функции. Но переменная l всё ещё ссылается на старый адрес в памяти. Однако попытка изменить неизменяемую переменную во второй функции y += x приводит к тому, что y начинает ссылаться на другой адрес в памяти: исходная y будет забыта, что приведёт к ошибке UnboundLocalError.

Заключение


Разница между изменяемыми и неизменяемыми объектами в Python очень важна. Избегайте странного поведения, описанного в этой статье. В особенности:

  • Не используйте по умолчанию изменяемые аргументы.
  • Не пытайтесь менять неизменяемые переменные-замыкания во внутренних функциях.
ДомКлик
Место силы

Комментарии 32

    +1
    со значением по умолчанию в функции вообще внимательным нужно быть,

    def f(x, d=datetime.now()):
        print(x, d)


    скорее всего результат будет не тот которого ожидаешь
      +3

      Поэтому правильный вариант, очевидно:


      def f(x, val_getter=datetime.now):
          print(x, val_getter())

      Хотя можно и чуточку усложнить, дабы предупредить более "плоское" использование, но тогда логике посыпится в редких случаях, но они специфичны и довольно легко обходятся:


      def f(x, val_handler=datetime.now):
          if callable(val_handler):
              print(x, val_handler())
          else:
              print(x, val_handler)

      Вообще, статья какая-то игрушечная, я бы сказал — кликбейторская. Обладая простейшим пониманием как работают ссылки (и немного про область видимости в третьей "задаче") — "решения" наиочевиднейшие.

        +6
        вообще, если на вход ожидается datetime,
        def f(x, d=None):
            print(x, d or datetime.now())

        мне больше нравится вот такое решение
          0

          Это если ожидается datetime, здесь это никоим образом не продемонстрированно, а из контекста непонятно. Но и такой вариант жизнеспособен, конечно.

            +5
            Только такой вариант я и видел в качестве «рекомендованного».

            А вот этот вот setter — это на цирковой трюк похоже. Потому что запаковывать дату с лямбду можно, конечно — но очень уж напоминает вырезание гланд через задний проход.

              0

              Тут вопрос в использовании данных. Если функция быстрая — то всё хорошо, но если медленная/асинхронная, а то и вовсе — переодическая — то у нас проблемы. Подобные подходы в ORM используются, например.

      –1

      Мне кажется, что во второй задаче, вместо решения


      if s is None:
          s = set()

      будет более идиоматично использовать:


      s = s or set()

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

        0
        Согласен, такое решение более элегантно. Но тут код взят из оригинальной статьи =) Не стал менять =)
          0
          Нет, как раз таки хорошее решение. Если в функцию передать пустой set(), в который мы хотим добавить значений, то они не добавятся в него
            0
            Нет, ибо если функции передать пустой set (именованный), в надежде, что она его поменяет также, как не пустой, то «более идиоматичный» способ поведёт себя по-другому и оставит его неизменным.
              –2

              Да, вы правы но, если мы обсуждаем коня сферического в вакууме то:


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

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

                +1
                Если ваша цель изменять входные аргументы, то, естественно, предложенное решение не подходит.
                Ультрагениально: решение, призванное решить проблему изменения дефолтных параметров — отлично работает, если вы эти параметры не меняете. Класс. Высший пилотаж.

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

                Ну или грузовик — прекрасно ездящий если не пытаться на нём грузы перевозить…

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

                Совсем ненормально — объяснять, что код-то отличный, идеоматичный, красивый… а что он не работает как нужно — ну так то вообще дело десятое…
                  –1

                  Нравятся функции с неявным возвратом значений через изменение аргументов (побочные эффекты)? Ну тогда не используйте предложенную конструкцию. На SO достаточно веток, обсуждающих "правила безопасности" функций с побочными эффектами, но все реально.


                  Хотите создавать функции без побочных эффектов с явным возвратом значений ("Explicit is better than implicit." — Zen Of Python)? Можете использовать как одну, так и другую конструкции. Оба варианта будут полностью работоспособными. На SO есть ветки обсуждающие что "правильнее".


                  На суть статьи ни один из вариантов никак не влияет, хотя-бы потому, что "как нужно" не указано. Мы же не будем, устраивать полноценный code review для синтаксического, учебного примера?


                  Ну и не надо забывать, что вариант с s is None не является эквивалентом s or set(), надо четко представлять в чем разница и плюсы и минусы обеих конструкций. В жизни приходится применять оба подхода. Всегда лучше иметь выбор и знать о том, что логические операторы в Python могут использоваться подобным образом.


                  Эпитеты, пожалуйста, оставьте при себе.

                    +1
                    Хотите создавать функции без побочных эффектов с явным возвратом значений («Explicit is better than implicit.» — Zen Of Python)? Можете использовать как одну, так и другую конструкции. Оба варианта будут полностью работоспособными.
                    Ну что за детский сад, честное слово.

                    Ни одна из этих конструкций работать, очевидно, не будет, потому что ни в одной из них множество не копируется.

                    Если весь дальнейший код это множество, после этого, ещё и не меняет — то ни одна из этих конструкций не нужна. Если меняет — ни одна из них недостаточна, нужно либо copy(), либо, возможно, даже deepcopy() использовать.

                    На суть статьи ни один из вариантов никак не влияет, хотя-бы потому, что «как нужно» не указано.
                    Не указано. А ещё там не указано, что данная функция не должна отсылать содержимое /etc/passwd на сервер ботнета. И ешё не указано, что она не должна ломаться в пятницу, 13го. Обозначает ли это что решение, содержащее «по приколу» что-нибудь «развлекательно-познавательное», ну, скажем… if random.randrange(1000000) < 7: sys.exit() будет хорошим решением?

                    А чё? В условиях же не сказано.

                    Мы же не будем, устраивать полноценный code review для синтаксического, учебного примера?
                    А почему нет, собственно? Как вообще на учебных примерах человек может чему-то научиться если они по идиотски написаны?

                    Ну и не надо забывать, что вариант с s is None не является эквивалентом s or set(), надо четко представлять в чем разница и плюсы и минусы обеих конструкций.
                    Но вы же забыли. Или специально решили «подловить новичка на подлости» (что ещё хуже).

                    Может быть вы хотели написать что-нибудь типа s = (s or set()).copy()?

                    Да, это, возможно, будет работать — но это, извините, тоже ребус. Уж в этом-то случае проще написать s = set() в параметрах и s = s.copy() в качестве первой строки…

                    На SO есть ветки обсуждающие что «правильнее».
                    Отлично — покажите, пожалуйста, где, кто и когда изобретённую вами семантику «чтобы получить результат работы через переданное вами множество нужно в него, предварительно, засунуть фиктивный элемент» считает правильной. Особенно интересно увидеть как они предлагают боротьсь с тем, что этот элемент может оказаться и в результатах работы тоже… а главное — зачем такое может потребоваться…

                    Эпитеты, пожалуйста, оставьте при себе.
                    Знаете — когда тебе, на полном серьёзе, предлагают что-то из книжки «вредные советы»… это не так-то просто сделать…
            +1
            Если бы a был изменяемым объектом, то команда a.append(5) меняла бы исходный объект, и тогда список s «видел» бы изменение.
            Но в данном случае же a — изменяемый объект, и его изменение влияет на s, нет?
              0
              Вы правы, ошибся немного в переводе. Скорректировал
                +1

                "при первом вызове s будет инициализировано как пустое множество"


                Пустое множество создаётся в момент определения функции, а не её вызова.

              0
              a = [1]
              b = [2]
              s = [a, b]
              a.append(5)
              
              >>print(s)
              >>[[1, 5], [2]]


              Если бы a был изменяемым объектом, то команда a.append(5) меняла бы исходный объект, и тогда список s «видел» бы изменение.

              так вроде ж так и есть?
              почему бы?
                0

                Скучно. Нате поинтереснее


                def f():
                    a = 1
                    def g():
                        nonlocal a
                        print(a)
                        a+=1
                    return g
                
                g1 = f()
                g2= f()
                
                g1()
                g2()
                g1()
                
                  0
                  А что тут интересного? Ещё если бы nonlocal вёл себя как global — я бы удивился… а так — всё работает самым очевидным образом, я бы сказал…
                    0

                    Ок, ок! Ну так то хоть повеселее?


                    def f():
                        a = 1
                    
                        def g():
                            nonlocal a
                            print(a)
                            a += 1
                    
                        def update(x):
                            nonlocal a
                            a=x
                        g.update=update
                    
                        return g
                    
                    g1 = f()
                    g2 = f()
                    
                    g1()
                    g2()
                    g1()
                    
                    g1.update(100)
                    g1()
                      0
                      Если помнить, что в Python всё на свете это просто dictionary, то такие примеры распутываются легко. Но когда у вас там начинает уже фигурировать десяток объектов, то тут уже нужно на бумажке рисовать куда, чего и когда вы двигаете. Но ничего принципиально не меняется.

                      Ну кроме того, что если функции ничего не возвращают, то результат — всегда None… но я надеюсь пример был не об этом…
                  +2
                  По поводу 3-го случая объяснение в статье какое-то загадочное.
                  Однако попытка изменить неизменяемую переменную во второй функции y += x приводит к тому, что y начинает ссылаться на другой адрес в памяти: исходная y будет забыта, что приведёт к ошибке UnboundLocalError.

                  Здесь мы пытаемся использовать неинициализированную переменную. Потому как: y += x это y = y + x. Справа доступ к неинициализированной y.
                    +1
                    Потому как: y += x это y = y + x. Справа доступ к неинициализированной y.

                    Не совсем.


                    Во-первых, y += x это не y = y + x, а вовсе даже y = operator.iadd(y, x).


                    Во-вторых, а с чего она там не инициализирована-то? — мы же вроде как захватили ее из родительского скоупа, см. пример 1. Более того, если вместо y += x вы напишете z = y + x все снова будет прекрасно работать с кложурно захваченным y. y становится неинициализированным внутри скоупа функции когда парсер натыкается на y =. Тут происходит неявный хойстинг и внутри всего скоупа — бамс! — y не инициализирован. Смотрите:


                    >>> def g():
                    ...   y = 0
                    ...   def inner():
                    ...     print(y) # BOOM!
                    ...     y = 42
                    ...     return y
                    ...   return inner
                    ... 
                    >>> g()()
                    Traceback (most recent call last):
                      File "<stdin>", line 1, in <module>
                      File "<stdin>", line 4, in inner
                    UnboundLocalError: local variable 'y' referenced before assignment

                    Я, кстати, в жизни не написал ни строчки кода на питоне, но такие вещи хорошо бы понимать просто по факту принадлежности к CS, скоупинг так организован примерно во всех скриптовых языках.

                      +2
                      В контексте данной проблемы x += y и x = x + y одно и тоже (т.е. от деталей можно абстрагироваться). Если Питон видит assignment для переменной, то она автоматом помещается внутрь локального scope, если не указан nonlocal или global.

                      Понятие hoisting немного смежное, но к питону это не имеет отношения. При загрузке модуля (скрипта), пока мы не дойдем до объявления класса/функции (и не выполним этот код!) — обращаться к таким объектам нельзя. Переменные вообще невозможно объявить без инициализации.

                      Бывают специфические для языков штуки. Вот как оно в CS бывает.
                      0
                      Такие случаи или понятны интуитивно, или ты в них не уверен и проверяешь в интерпретаторе.
                      Зачем это на бумаге уметь выводить/помнить — хз.
                        +1
                        Почему так происходит? Когда мы исполняем l.append(x), меняется изменяемый объект, созданный при определении функции. Но переменная l всё ещё ссылается на старый адрес в памяти. Однако попытка изменить неизменяемую переменную во второй функции y += x приводит к тому, что y начинает ссылаться на другой адрес в памяти: исходная y будет забыта, что приведёт к ошибке UnboundLocalError.

                        Полнейшая чепуха.

                        >>> def f():
                        ...     l = [1]
                        ...     def inner(x):
                        ...         l += [x]
                        ...         return l
                        ...     return inner
                        ...
                        >>> f_inner = f()
                        >>> print(f_inner(2))
                        Traceback (most recent call last):
                          File "<stdin>", line 1, in <module>
                          File "<stdin>", line 4, in inner
                        UnboundLocalError: local variable 'l' referenced before assignment
                        

                        Если следовать логике автора, то эта функция должна работать точно так же, как и её собственная f(): тут точно такой же изменяемый объект-список.
                          0
                          Берём пример и смотрим на байткод.

                          import dis
                          
                          a = 10
                          b = 20
                          def foo():
                              print(b)
                              print(a)
                              a += 10
                          
                          dis.dis(foo)


                          6 0 LOAD_GLOBAL 0 (print)
                          2 LOAD_GLOBAL 1 (b)
                          4 CALL_FUNCTION 1
                          6 POP_TOP

                          7 8 LOAD_GLOBAL 0 (print)
                          10 LOAD_FAST 0 (a)
                          12 CALL_FUNCTION 1
                          14 POP_TOP

                          8 16 LOAD_FAST 0 (a)
                          18 LOAD_CONST 1 (10)
                          20 INPLACE_ADD
                          22 STORE_FAST 0 (a)
                          24 LOAD_CONST 0 (None)
                          26 RETURN_VALUE


                          У нас есть a и b. Для их чтения используются разные инструкции (LOAD_FAST, LOAD_GLOBAL).
                          При построении байткода если есть локальная переменная, все обращения к ней локальны (LOAD_FAST), вне зависимости от порядка их следования.
                          0

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

                            +1
                            Для людей знакомых например с с++, но не знакомых с питон, знание о передаче аргументов по ссылке или по значению — лишь немного путают в этой ситуации )

                            На самом деле в питоне все есть объекты. Вот сколько например у int методов:

                            i = 1
                            print(type(i))
                            print(dir(i))
                            

                            <class 'int'>
                            ['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', '__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', '__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__', '__getnewargs__', '__gt__', '__hash__', '__index__', '__init__', '__init_subclass__', '__int__', '__invert__', '__le__', '__lshift__', '__lt__', '__mod__', '__mul__', '__ne__', '__neg__', '__new__', '__or__', '__pos__', '__pow__', '__radd__', '__rand__', '__rdivmod__', '__reduce__', '__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__', '__rmod__', '__rmul__', '__ror__', '__round__', '__rpow__', '__rrshift__', '__rshift__', '__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__', 'as_integer_ratio', 'bit_length', 'conjugate', 'denominator', 'from_bytes', 'imag', 'numerator', 'real', 'to_bytes']

                            И все передается по ссылке, ниже id выводит адрес объекта в памяти:

                            
                            def foo(value):
                                print('2)', id(value))
                            
                            i = 1
                            print('1)', id(i))
                            foo(i)
                            
                            # 1) 93983712618240
                            # 2) 93983712618240


                            Здесь другая ситуация. При попытке сделать assignment для переменной, котороя ссылается на immutable объект, питон создает новый объект. Встроенный int это объект, но не изменяемый.

                            x = 1  # int is immutable
                            print(id(x))
                            x += 1
                            print(id(x))
                            
                            # 94029430744832
                            # 94029430744864
                            
                            x = (1, 2)  # tuple is immutable
                            print(id(x))
                            x += (3, 4)
                            print(id(x))
                            
                            # 140065572803008
                            # 140065572117216
                            
                            x = []  # list is mutable
                            print(id(x))
                            x += [1, 2]
                            print(id(x))
                            
                            # 140381673357056
                            # 140381673357056
                              0
                              Еще для наглядности.
                              Все операции типа +, += это вызов специальных методов. x += y это x.__iadd__(y). В таком методе мы можем поменять состояние объекта и вернуть себя же (self), или же создать и вернуть новый объект.

                              class Immutable:
                                  def __iadd__(self, x):
                                      return self._value + x  #  ну или Immutable(self._value + x)
                              
                              class Mutable:
                                  def __iadd__(self, x):
                                      self._value += x
                                      return self

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое