Kodomo

Пользователь

Последние мелочи и утки

И наступил последний содержательный рассказ в этом семестре.

Я посвящу его тому, чтобы подобрать несколько важных, но не критичных мелочей. (А вовсе не тому, чтобы пересказать заново весь питон со словами "смотрите, как всё просто" – что я пытался сделать на одном из занятий).

set, frozenset

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

Первый из них – set. Множество.

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

   1 >>> x = set([1, 2, 3])
   2 >>> x
   3 set([1, 2, 3])
   4 >>> x.add(5)
   5 >>> x
   6 set([1, 2, 3, 5])
   7 >>> y = set([3, 4])
   8 >>> x & y
   9 set([3])
  10 >>> x | y
  11 set([1, 2, 3, 4, 5])
  12 >>> x - y
  13 set([1, 2, 5])
  14 >>> y - x
  15 set([4])
  16 >>> 3 in x
  17 True
  18 >>> 4 in x
  19 False
  20 >>> x < y
  21 False
  22 >>> x < set(range(10))
  23 True

Операциями & и | обозначаются пересечение (множество из элементов, которые входят и в первое множество, и во второе) и объединение (множество из элементов, которые входят в первое или во второе множество) соответственно.

Единственный способ получать элементы множества – циклом. Ну или, если хотите, можете делать из множества список или тупль, сортировать или делать с ним, что хотите (например: x0 = sorted(tuple(x))[0]).

Важная оговорка: элементами множества могут быть только немутируемые (неизменяемые) объекты. Т.е. числа, строки, True, False, None, тупли (и, если вы не приложите усилий к тому, чтобы это было не так, объекты, созданные из классов). Списки, словари и множества элементами множества быть не могут.

У set есть тип-близнец frozenset, который умеет делать всё то же самое, но при этом его объекты нельзя изменять. (Угадайте, для чего это нужно).2

Эти два типа стоит использовать всякий раз, когда у вас по сути в программе множества (например, множество всех имён аминокислот) – не столько ради скорости (помните первое правило оптимизации!), сколько ради прояснения своих намерений читателю программы.

Если вам кажется, что я чего-то не договорил (а я стремился лишь дать вам первый вкус этих типов, а не повторять документацию), RTFM.

complex

Ещё в питоне встроены в ядро языка комплексные числа. Зачем – ума не приложу, не пользовался ими ни разу. Хотя, может быть, тем, кто больше имеет дела с математикой в питоне, они полезны.

Главная заковыка комплексных чисел такая: мнимая единица пишется буквой j сразу за значением.

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

Примеры:

   1 >>> x = 1 + 1j
   2 >>> x * 1j
   3 (-1+1j)
   4 >>> x + 1j
   5 (1+2j)
   6 >>> x / 1j
   7 (1-1j)
   8 >>> x ** 1j
   9 (0.42882900629436788+0.15487175246424681j)
  10 >>> 1 ** 1j
  11 (1+0j)
  12 >>> 1j ** 1j
  13 (0.20787957635076193+0j)
  14 >>> x.imag
  15 1.0
  16 >>> x.real
  17 1.0

С вопросами, опять же, обращаться к TFM.

Вот, собственно, про встроенные типы и всё, что про них есть интересного.

global

Посмотрим на такую вот простенькую программу:

   1 x = 1
   2 
   3 def f(a):
   4     x = a
   5 
   6 def g():
   7     print(x)
   8 
   9 f(2)
  10 g()

Вопрос такой: что эта программа напечатает и как она будет исполняться?

Ответ.

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

К тому моменту, как интерпретатор дойдёт до 8-й строки, стек будет выглядеть так:

http://kodomo.fbb.msu.ru/~dendik/images/python/2009-12-10-globals.png

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

Исполняем 9-ю строку. Входим в функцию f, создаём для её исполнения свой фрейм стека, получаем значения аргументов: присваиваем в локальную переменную a в фрейме значение 2. Выполняем следующее присваивание: создаём новую переменную в локальном фрейме: x со значением 2. Картинка:

http://kodomo.fbb.msu.ru/~dendik/images/python/2009-12-10-f.png

Главное: глобальный x никуда не изменился.

Исполняем 10-ю строку. Входим в функцию g, создаём пустой фрейм стека. В этом фрейме (впрочем, аналогичное было и в фрейме для функции f) написано, что возник этот фрейм путём вызова функции g, а в функции g у нас есть информация о том, где же хранятся глобальные переменные. И вот когда мы говорим print(x), пытаемся прочитать x и не находим его в локальных переменных, мы и идём искать его в глобальных переменных именно по этой цепочке:

http://kodomo.fbb.msu.ru/~dendik/images/python/2009-12-10-g.png

Мы можем добиться того, чтобы питон изменил значение именно глобальной переменной. Для этого нам потребуется ключевое слово global. С его помощью мы объявляем переменную как глобальную – это значит, что вся работа с этой переменной в этой функции будет обозначать изменения глобального фрейма:

   1 x = 1
   2 
   3 def f(a):
   4     global x
   5     x = a
   6 
   7 def g():
   8     print(x)
   9 
  10 f(2)
  11 g()

Теперь картинка в конце исполнения тела функции f будет такая:

http://kodomo.fbb.msu.ru/~dendik/images/python/2009-12-10-f-global.png

Я должен сразу предупредить, что хотя возможность записи в глобальные переменные из функции в питоне есть, пользоваться ей следует только тогда, когда вы пишете маленькие программы для себя, чтобы использовать и выбросить. Потому, что использование глобальных переменных в функции лишает функцию некоторой степени универсальности3. Если у вас очень часто возникает набор переменных, с которыми вам хочется работать (настолько, что вы хотите не передавать их в функцию каждый раз, а сложить их в глобальные переменные), задумайтесь: а не повод ли это завести класс?

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

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

Сделано это ради того, чтобы избегать путаницы.

Ради справки. Присваивания бывают:

assert

Есть в питоне ещё одна очень полезная конструкция, которой ни в коем случае не стоит злоупотреблять.

assert в переводе с английского означает "удостовериться".

Конструкция assert в питоне4 делает две вещи: проверяет условие, и если условие не выполнилось, выбрасывает исключение.

assert x == 2 является синонимом: if not (x == 2): raise Exception("Assertion error")5

assert x == 2, "Only pairwise alignments are allowed" – это синоним: if not (x == 2): raise Exception("Only pairwise alignments are allowed").

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

Про уток

И эта тема плавно перетекает в следующую: что в программе стоит проверять, а что стоит брать на веру?

Что для вас такое утка? Это anas platyrhynchos или просто семейство anas?

Поэт Джеймс Уиткомб Райлей сказал: "когда я вижу птицу, которая ходит как утка и плавает как утка и крякает как утка, я называю такую птицу уткой".6

Это изречение прижилось в программировании под названием утиной типизации.

Понимать всё это нужно так: если мы не делаем лишних проверок (но тщательно тестируем), наши программы становятся более могучими.

Вспомним пример с деревьями. У нас была задача посчитать сумму листьев в дереве. Мы писали так:

   1 def sum_tree(tree):
   2     if isinstance(tree, list):
   3         result = 0
   4         for node in tree:
   5             result += sum_tree(node)
   6     else:
   7         result = tree
   8     return result

Что будет, если мы спросим у питона:

   1 >>> sum_tree([1, (2, 3)])

Будет ошибка. Мы попытаемся сложить 1 с (2, 3), а питон на это пойтить не могёт. (И правильно делает. Мало ли, что мы имели в виду?)

Но ведь нам же ясно, что мы всего лишь собирали дерево из разных частей! Не пересобирать же дерево заново из списков только ради того, чтобы его просуммировать! (Это же в сущности, ещё ровно столько же работы выполнить нужно – если мы дерево получили откуда-то из левого места, а не сами руками вбиваем с клавиатуры).

Как нам избавиться от проверки, что tree у нас – это dendrus primus?

Давайте подумаем. Всё, что нам на самом деле хотелось бы требовать от внутренних вершин дерева – это что по ним можно ходить циклом. Как это проверять? А очень просто: мы попытаемся пойти по ним циклом, а если не получится, питон нам выбросит исключение!

Вооружившись этой идеей, получаем:

   1 def sum_tree(tree):
   2     try:
   3         result = 0
   4         for node in tree:
   5             result += sum_tree(node)
   6     except Exception:
   7         result = tree
   8     return result

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

Именно эту версию я считаю идеальным питонским примером для этой задачи.

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

Пример:

   1 >>> sum_tree(["1", ["2", "3"]])

Что бы мы ожидали от этого примера?

Мне бы казалось логичным ожидать "123".

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

Я бы попытался бороться с этим так:

   1 def sum_tree(tree):
   2     try:
   3         result = 0
   4         for node in tree:
   5             assert node != tree
   6             result += sum_tree(node)
   7     except Exception:
   8         result = tree
   9     return result

Мы таким образом перестанем уходить в бесконечную рекурсию со строками (когда tree оказывается строкой длины один, не срабатывает условие node != tree, питон выбрасывает исключение, и мы переходим в ветку, которая считает, что tree – это лист дерева), теперь наша программа считает каждую букву каждой строки во входных данных отдельным листом. Беда только в том, что мы эти листья пытаемся складывать с нулём, что не очень осмысленно.

Тут можно поступать по-разному, в зависимости от того, чего мы хотим (но напоминаю: мы уже ковыряемся в деталях, о которых в изначальной постановке задачи ничего не было; это как в физике когда прибор на приборе со шкалой в 1 мы пишем ответ 3.0125 +- 0.5 – последние четыре цифры после запятой уже не содержательны).

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

   1 def sum_tree(tree):
   2     try:
   3         result = list(tree)[0]
   4         for node in list(tree)[1:]:
   5             assert node != tree
   6             result += sum_tree(node)
   7     except Exception:
   8         result = tree
   9     return result

Тут мы ещё получаем как неприятный побочный эффект то, что нашей функции нельзя скормить дерево с пустой веткой: [1, [[[]]], 2] – одним из листьев дерева питон сочтёт [] (так как try-блок обработать эту ситуацию не сможет), дальше у нас случится беда при попытке сложить этот лист с 1 из прошлого шага, и в итоге мы получим всё наше дерево целиком в неизменном виде в качестве его суммы.7

Другой подход был бы в том, что мы строго требовали бы, чтобы всё, что мы пытаемся суммировать, было бы примерно числами:

   1 def sum_tree(tree):
   2     try:
   3         result = 0
   4         for node in tree:
   5             assert node != tree
   6             result += sum_tree(node)
   7     except Exception:
   8         result = int(tree)
   9     return result

Тогда:

   1 >>> sum_tree([1, "23", [[4]]])
   2 10

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

Ещё более яркий пример – работа с файлами.

Сравните:

   1 def parse_csv_1(filename):
   2     file = open(filename)
   3     for line in file:
   4         print line.strip().split(",")
   5 
   6 def parse_csv_2(file):
   7     for line in file:
   8         print line.strip().split(",")

Первой функции мы на вход можем скормить только имя файла, а второй: открытый файл (прямо open(filename), не стесняясь), стандартный поток ввода, список строк, множество строк, словарь с ключами-строками, одну строку (у которой каждый символ будет считаться новой строкой), список штук, для которых определён метод strip(), который возвращает строку (например, мы сами руками такой объект написали), объект StringIO (вот заодно и почитайте, что это такое), или какой-нибудь ещё из большой кучи объектов, поддерживающих поведение как файл (см. напр., модули urllib, gzip, bz2).

Какой из этих подходов лучше?8

Статья про утиную типизацию в википедии завершается словами, которыми и я сочту правильным заверштить эту лекцию и этот семестр:

  1. В питоне очень трудно сказать, что "всё, я рассказал обо всех встроенных типах". Для этого пришлось бы рассказывать о дикой куче совсем неинтересных типов, например: NoneType, к которому принадлежит ровно один объект и новых объектов к которому добавлять не получится, instance, к которому принадлежат все объекты, сделанные из классов, type и classobj, к которым принадлежат объкты описаний классов и много других, о которых нужно знать создателю питона, но можно не заботиться программисту на питоне. (1)

  2. Нежто кому-то стало интересно, что я тут напишу? См. абзац выше! (2)

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

  4. И во многих других языках, где она есть. Эту конструкцию мало кто использует, но при этом во многих языках она есть, и почти везде, где она есть, её синтаксис и семантика чуть ли не дословно совпадают с питонскими. Впрочем, конструкция эта намного старее питона. (4)

  5. Это не правда. На самом деле, там другой тип исключений. Но я вам ничего про типы исключений не рассказывал, так как и сам этим почти никогда не пользуюсь. Разные типы исключений бывают нужны разве что в очень-очень больших программах. (5)

  6. Цитата с википедии: "when I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck." (6)

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

  8. Подумайте. Действительно подумайте. Я хочу сказать, что универсального ответа нет. (8)