Яворски Михал, Зиаде Тарек
Я22 Python. Лучшие практики и инструменты. — СПб.: Питер, 2021. — 560 с.: ил. —
(Серия «Библиотека программиста»).
ISBN 978-5-4461-1589-1
Python — это динамический язык программирования, используемый в самых разных предметных
областях. Хотя писать код на Python просто, гораздо сложнее сделать этот код удобочитаемым, пригодным для многократного использования и легким в поддержке. Третье издание «Python. Лучшие
практики и инструменты» даст вам инструменты для эффективного решения любой задачи разработки
и сопровождения софта.
Авторы начинают с рассказа о новых возможностях Python 3.7 и продвинутых аспектах синтаксиса
Python. Продолжают советами по реализации популярных парадигм, в том числе объектно-ориентированного, функционального и событийно-ориентированного программирования. Также авторы
рассказывают о наилучших практиках именования, о том, какими способами можно автоматизировать
развертывание программ на удаленных серверах. Вы узнаете, как создавать полезные расширения для
Python на C, C++, Cython и CFFI.
16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. № 436-ФЗ.)
ББК 32.973.2-018.1
УДК 004.43
Права на издание получены по соглашению с Packt Publishing. Все права защищены. Никакая часть данной
книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев
авторских прав.
Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может
гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные
ошибки, связанные с использованием книги. Издательство не несет ответственности за доступность материалов,
ссылки на которые вы можете найти в этой книге. На момент подготовки книги к изданию все ссылки на интернетресурсы были действующими.
Краткое содержание
О создателях книги ................................................................................................................. 14
Предисловие ............................................................................................................................ 15
От издательства ....................................................................................................................... 20
Часть I. Перед началом работы
Глава 1. Текущее состояние Python............................................................................................. 22
Глава 2. Современные среды разработки на Python.................................................................... 39
Часть II. Ремесло Python
Глава 3. Современные элементы синтаксиса — ниже уровня класса............................................ 66
Глава 4. Современные элементы синтаксиса — выше уровня класса......................................... 123
Глава 5. Элементы метапрограммирования............................................................................... 152
Глава 6. Как выбирать имена.................................................................................................... 173
Глава 7. Создаем пакеты........................................................................................................... 195
Глава 8. Развертывание кода.................................................................................................... 231
Глава 9. Расширения Python на других языках.......................................................................... 268
Часть III. Качество, а не количество
Глава 10. Управление кодом..................................................................................................... 308
Глава 11. Документирование проекта....................................................................................... 339
Глава 12. Разработка на основе тестирования.......................................................................... 366
Часть IV. Жажда скорости
Глава 13. Оптимизация — принципы и методы профилирования.............................................. 404
Глава 14. Эффективные методы оптимизации........................................................................... 434
Глава 15. Многозадачность....................................................................................................... 461
Часть V. Техническая архитектура
Глава 16. Событийно-ориентированное и сигнальное программирование................................. 504
Глава 17. Полезные паттерны проектирования......................................................................... 523
Приложение. reStructuredText Primer........................................................................................ 552
Оглавление
О создателях книги ................................................................................................................. 14
Об авторах............................................................................................................................. 14
О научном редакторе.............................................................................................................. 14
Предисловие ............................................................................................................................ 15
Для кого эта книга.................................................................................................................. 15
Что мы рассмотрим................................................................................................................. 16
Как получить максимум от этой книги..................................................................................... 17
Скачивание файлов с примерами кода.................................................................................... 18
Скачивание цветных изображений.......................................................................................... 18
Условные обозначения........................................................................................................... 18
От издательства ....................................................................................................................... 20
Часть I. Перед началом работы
Глава 1. Текущее состояние Python............................................................................................. 22
Технические требования......................................................................................................... 23
Где мы находимся и куда движемся........................................................................................ 23
Почему и как изменился язык Python...................................................................................... 23
Как не отставать от изменений в документации PEP............................................................... 24
Внедрение Python 3 на момент написания этой книги............................................................. 25
Основные различия между Python 3 и Python 2....................................................................... 26
Почему это должно нас волновать.................................................................................... 26
Основные синтаксические различия и распространенные ошибки..................................... 27
Популярные инструменты и методы поддержания кросс-версионной совместимости ....... 29
Не только CPython.................................................................................................................. 33
Почему это должно нас волновать.................................................................................... 33
Stackless Python................................................................................................................ 33
Jython............................................................................................................................... 34
IronPython......................................................................................................................... 35
PyPy.................................................................................................................................. 36
MicroPython....................................................................................................................... 36
Полезные ресурсы.................................................................................................................. 37
Резюме................................................................................................................................... 38
Глава 2. Современные среды разработки на Python.................................................................... 39
Технические требования......................................................................................................... 40
Установка дополнительных пакетов Python с использованием pip........................................... 40
Изоляция сред выполнения.................................................................................................... 42
venv — виртуальное окружение Python................................................................................... 43
Изоляция среды на уровне системы........................................................................................ 46
Виртуальные среды разработки, использующие Vagrant.................................................... 47
Виртуальные среды, использующие Docker....................................................................... 49
Оглавление 7
Популярные инструменты повышения производительности.................................................... 59
Пользовательские оболочки Python — ipython, bpython, ptpython и т. д............................ 60
Включение оболочек в собственные скрипты и программы............................................... 62
Интерактивные отладчики................................................................................................ 63
Резюме................................................................................................................................... 64
Часть II. Ремесло Python
Глава 3. Современные элементы синтаксиса — ниже уровня класса............................................ 66
Технические требования......................................................................................................... 67
Встроенные типы языка Python .............................................................................................. 67
Строки и байты................................................................................................................. 67
Контейнеры...................................................................................................................... 73
Дополнительные типы данных и контейнеры.......................................................................... 85
Специализированные контейнеры данных из модуля collections........................................ 85
Символическое перечисление с модулем enum................................................................. 86
Расширенный синтаксис.......................................................................................................... 88
Итераторы........................................................................................................................ 88
Генераторы и операторы yield........................................................................................... 91
Декораторы...................................................................................................................... 94
Менеджеры контекста и оператор with............................................................................ 105
Функционально-стилевые особенности Python...................................................................... 109
Что такое функциональное программирование............................................................... 110
Лямбда-функции............................................................................................................. 111
map(), filter() и reduce().................................................................................................. 112
Частичные объекты и функция partial()........................................................................... 115
Выражения генераторов................................................................................................. 116
Аннотации функций и переменных....................................................................................... 117
Общий синтаксис............................................................................................................ 117
Возможные способы применения.................................................................................... 118
Статическая проверка типа с помощью mypy.................................................................. 118
Иные элементы синтаксиса, о которых вы, возможно, не знаете........................................... 119
Оператор for… else…....................................................................................................... 119
Именованные аргументы................................................................................................. 120
Резюме................................................................................................................................. 122
Глава 4. Современные элементы синтаксиса — выше уровня класса......................................... 123
Технические требования....................................................................................................... 124
Протоколы в языке Python — методы и атрибуты с двойным подчеркиванием...................... 124
Сокращение шаблонного кода с помощью классов данных................................................... 126
Создание подклассов встроенных типов............................................................................... 128
ПРМ и доступ к методам из суперклассов.............................................................................. 131
Классы старого стиля и суперклассы в Python 2.............................................................. 133
Понимание ПРМ в Python................................................................................................ 134
Ловушки суперкласса...................................................................................................... 138
Практические рекомендации........................................................................................... 141
Паттерны доступа к расширенным атрибутам....................................................................... 141
Дескрипторы................................................................................................................... 142
Свойства......................................................................................................................... 147
Слоты............................................................................................................................. 150
Резюме................................................................................................................................. 151
8 Оглавление
Глава 5. Элементы метапрограммирования............................................................................... 152
Технические требования....................................................................................................... 152
Что такое метапрограммирование......................................................................................... 153
Декораторы как средство метапрограммирования........................................................... 153
Декораторы класса......................................................................................................... 154
Использование __new__() для переопределения процесса создания экземпляра............ 156
Метаклассы..................................................................................................................... 158
Генерация кода............................................................................................................... 165
Резюме................................................................................................................................. 172
Глава 6. Как выбирать имена.................................................................................................... 173
Технические требования....................................................................................................... 174
PEP 8 и практические рекомендации по именованию............................................................ 174
Почему и когда надо соблюдать PEP 8............................................................................ 174
За пределами PEP 8 — правила стиля внутри команды................................................... 175
Стили именования................................................................................................................ 175
Переменные.................................................................................................................... 176
Руководство по именованию................................................................................................. 184
Использование префиксов is/has в булевых элементах................................................... 184
Использование множественного числа в именах коллекций............................................ 185
Использование явных имен для словарей....................................................................... 185
Избегайте встроенных и избыточных имен...................................................................... 185
Избегайте уже существующих имен................................................................................ 186
Практические рекомендации по работе с аргументами......................................................... 187
Сборка аргументов по итеративному принципу............................................................... 187
Доверие к аргументам и тестам...................................................................................... 188
Осторожность при работе с магическими аргументами *args и **kwargs......................... 188
Имена классов...................................................................................................................... 190
Имена модулей и пакетов..................................................................................................... 191
Полезные инструменты......................................................................................................... 191
Pylint............................................................................................................................... 192
pycodestyle и flake8......................................................................................................... 193
Резюме................................................................................................................................. 194
Глава 7. Создаем пакеты........................................................................................................... 195
Технические требования....................................................................................................... 195
Создание пакета................................................................................................................... 196
Странности в нынешних инструментах создания пакетов в Python.................................. 196
Конфигурация проекта.................................................................................................... 198
Пользовательская команда setup.................................................................................... 207
Работа с пакетами в процессе разработки....................................................................... 208
Пакеты пространства имен................................................................................................... 209
Почему это полезно........................................................................................................ 210
Загрузка пакета.................................................................................................................... 214
PyPI — каталог пакетов Python ...................................................................................... 214
Пакеты с исходным кодом и пакеты сборок..................................................................... 216
Исполняемые файлы............................................................................................................. 220
Когда бывают полезны исполняемые файлы................................................................... 221
Популярные инструменты............................................................................................... 221
Безопасность кода Python в исполняемых пакетах.......................................................... 228
Резюме................................................................................................................................. 230
Оглавление 9
Глава 8. Развертывание кода.................................................................................................... 231
Технические требования....................................................................................................... 232
Двенадцатифакторное приложение...................................................................................... 232
Различные подходы к автоматизации развертывания........................................................... 234
Использование Fabric для автоматизации развертывания................................................ 235
Ваш собственный каталог пакетов или зеркало каталогов.................................................... 239
Зеркала PyPI .................................................................................................................. 240
Объединение дополнительных ресурсов с пакетом Python.............................................. 241
Общие соглашения и практики............................................................................................. 249
Иерархия файловой системы.......................................................................................... 249
Изоляция........................................................................................................................ 250
Использование инструментов мониторинга процессов.................................................... 250
Запуск кода приложения в пространстве пользователя................................................... 252
Использование обратного HTTP-прокси........................................................................... 253
Корректная перезагрузка процессов............................................................................... 254
Контрольно-проверочный код и мониторинг......................................................................... 256
Ошибки журнала — Sentry/Raven.................................................................................... 256
Метрики систем мониторинга и приложений .................................................................. 260
Работа с журнальными приложениями............................................................................ 262
Резюме................................................................................................................................. 267
Глава 9. Расширения Python на других языках.......................................................................... 268
Технические требования....................................................................................................... 269
Различия между языками C и C++........................................................................................ 269
Необходимость в использовании расширений....................................................................... 272
Повышение производительности критических фрагментов кода..................................... 272
Интеграция существующего кода, написанного на разных языках................................... 273
Интеграция сторонних динамических библиотек............................................................. 274
Создание пользовательских типов данных...................................................................... 274
Написание расширений......................................................................................................... 275
Расширения на чистом языке C....................................................................................... 276
Написание расширений на Cython................................................................................... 291
Проблемы с использованием расширений............................................................................. 295
Дополнительная сложность............................................................................................ 296
Отладка.......................................................................................................................... 297
Взаимодействие с динамическими библиотеками без расширений........................................ 297
Модуль ctypes................................................................................................................. 298
CFFI................................................................................................................................ 304
Резюме................................................................................................................................. 306
Часть III. Качество, а не количество
Глава 10. Управление кодом..................................................................................................... 308
Технические требования....................................................................................................... 308
Работа с системой управления версиями.............................................................................. 308
Централизованные системы............................................................................................ 309
Распределенные системы................................................................................................ 312
Распределенные стратегии............................................................................................. 313
Централизованность или распределенность.................................................................... 314
По возможности используйте Git..................................................................................... 315
Рабочий процесс GitFlow и GitHub Flow........................................................................... 316
10 Оглавление
Настройка процесса непрерывной разработки...................................................................... 320
Непрерывная интеграция................................................................................................ 321
Непрерывная доставка.................................................................................................... 325
Непрерывное развертывание.......................................................................................... 326
Популярные инструменты для непрерывной интеграции................................................. 326
Выбор правильного инструмента и распространенные ошибки....................................... 335
Резюме................................................................................................................................. 338
Глава 11. Документирование проекта....................................................................................... 339
Технические требования....................................................................................................... 339
Семь правил технической документации............................................................................... 340
Пишите в два этапа........................................................................................................ 340
Ориентируйтесь на читателя........................................................................................... 341
Упрощайте стиль............................................................................................................ 342
Ограничивайте объем информации................................................................................. 342
Используйте реалистичные примеры кода...................................................................... 343
Пишите по минимуму, но достаточно.............................................................................. 344
Используйте шаблоны..................................................................................................... 344
Документация как код.......................................................................................................... 345
Использование строк документации в Python.................................................................. 345
Популярные языки разметки и стилей для документации................................................ 347
Популярные генераторы документации для библиотек Python.............................................. 348
Sphinx............................................................................................................................. 349
MkDocs............................................................................................................................ 352
Сборка документации и непрерывная интеграция........................................................... 352
Документирование веб-API................................................................................................... 353
Документация как прототип API с API Blueprint .............................................................. 354
Самодокументирующиеся API со Swagger/OpenAPI.......................................................... 355
Создание хорошо организованной системы документации.................................................... 356
Создание портфеля документации.................................................................................. 356
Ваш собственный портфель документации...................................................................... 362
Создание шаблона документации......................................................................................... 363
Шаблон для автора......................................................................................................... 364
Шаблон для читателя..................................................................................................... 364
Резюме................................................................................................................................. 365
Глава 12. Разработка на основе тестирования.......................................................................... 366
Технические требования....................................................................................................... 366
Я не тестирую....................................................................................................................... 367
Три простых шага разработки на основе тестирования................................................... 367
О каких тестах речь........................................................................................................ 372
Стандартные инструменты тестирования в Python.......................................................... 375
Я тестирую........................................................................................................................... 380
Ловушки модуля unittest ................................................................................................ 380
Альтернативы модулю unittest........................................................................................ 381
Охват тестирования........................................................................................................ 388
Подделки и болванки...................................................................................................... 390
Совместимость среды тестирования и зависимостей....................................................... 396
Разработка на основе документации............................................................................... 400
Резюме................................................................................................................................. 402
Оглавление 11
Часть IV. Жажда скорости
Глава 13. Оптимизация — принципы и методы профилирования.............................................. 404
Технические требования....................................................................................................... 404
Три правила оптимизации.................................................................................................... 405
Сначала — функционал.................................................................................................. 405
Работа с точки зрения пользователя............................................................................... 406
Поддержание читабельности и удобства сопровождения................................................ 407
Стратегии оптимизации........................................................................................................ 408
Пробуем свалить вину на другого................................................................................... 408
Масштабирование оборудования.................................................................................... 409
Написание теста скорости............................................................................................... 410
Поиск узких мест.................................................................................................................. 410
Профилирование использования ЦП............................................................................... 411
Профилирование использования памяти......................................................................... 419
Профилирование использования сети............................................................................. 430
Резюме................................................................................................................................. 433
Глава 14. Эффективные методы оптимизации........................................................................... 434
Технические требования....................................................................................................... 435
Определение сложности....................................................................................................... 436
Цикломатическая сложность........................................................................................... 437
Нотация «О большое»..................................................................................................... 438
Уменьшение сложности через выбор подходящей структуры данных.................................... 440
Поиск в списке................................................................................................................ 440
Использование модуля collections......................................................................................... 442
Тип deque....................................................................................................................... 442
Тип defaultdict................................................................................................................. 444
Тип namedtuple............................................................................................................... 444
Использование архитектурных компромиссов....................................................................... 446
Использование эвристических алгоритмов или приближенных вычислений.................... 446
Применение очереди задач и отложенная обработка...................................................... 447
Использование вероятностной структуры данных........................................................... 450
Кэширование........................................................................................................................ 451
Детерминированное кэширование.................................................................................. 452
Недетерминированное кэширование............................................................................... 455
Сервисы кэширования..................................................................................................... 456
Резюме................................................................................................................................. 460
Глава 15. Многозадачность....................................................................................................... 461
Технические требования....................................................................................................... 461
Зачем нужна многозадачность.............................................................................................. 462
Многопоточность.................................................................................................................. 463
Что такое многопоточность............................................................................................. 464
Как Python работает с потоками...................................................................................... 465
Когда использовать многопоточность............................................................................. 466
Многопроцессорная обработка............................................................................................. 481
Встроенный модуль multiprocessing................................................................................. 483
Асинхронное программирование........................................................................................... 489
Кооперативная многозадачность и асинхронный ввод/вывод.......................................... 490
Ключевые слова async и await......................................................................................... 491
12 Оглавление
Модуль asyncio в старых версиях Python......................................................................... 495
Практический пример асинхронного программирования................................................. 495
Интеграция синхронного кода с помощью фьючерсов async........................................... 498
Резюме................................................................................................................................. 501
Часть V. Техническая архитектура
Глава 16. Событийно-ориентированное и сигнальное программирование................................. 504
Технические требования....................................................................................................... 505
Что такое событийно-ориентированное программирование.................................................. 505
Событийно-ориентированный != асинхронный................................................................ 506
Событийно-ориентированное программирование в GUI................................................... 507
Событийно-ориентированная связь................................................................................. 509
Различные стили событийно-ориентированного программирования...................................... 511
Стиль на основе обратных вызовов................................................................................. 511
Стиль на основе субъекта............................................................................................... 513
Тематический стиль........................................................................................................ 515
Событийно-ориентированные архитектуры........................................................................... 518
Очереди событий и сообщений....................................................................................... 519
Резюме................................................................................................................................. 521
Глава 17. Полезные паттерны проектирования......................................................................... 523
Технические требования....................................................................................................... 524
Порождающие паттерны....................................................................................................... 524
Синглтон......................................................................................................................... 524
Структурные паттерны.......................................................................................................... 527
Адаптер.......................................................................................................................... 528
Заместитель.................................................................................................................... 542
Фасад............................................................................................................................. 543
Поведенческие паттерны...................................................................................................... 544
Наблюдатель.................................................................................................................. 544
Посетитель..................................................................................................................... 546
Шаблонный метод........................................................................................................... 548
Резюме................................................................................................................................. 550
Приложение. reStructuredText Primer........................................................................................ 552
reStructuredText.................................................................................................................... 552
Структура раздела.......................................................................................................... 554
Списки............................................................................................................................ 555
Форматирование внутри строк........................................................................................ 556
Блок литералов............................................................................................................... 557
Ссылки............................................................................................................................ 558
Благодарю любимую жену Оливию за ее любовь,
вдохновение и бесконечное терпение,
моих верных друзей Петра, Дэниела и Павла
за их поддержку,
мою мать, открывшую предо мной удивительный
мир программирования.
Михал Яворски
О создателях книги
Об авторах
Михал Яворски — программист на Python с десятилетним опытом. Занимал
разные должности в различных компаниях: от обычного фулстек-разработчика,
затем архитектора программного обеспечения и, наконец, до вице-президента по
разработке в динамично развивающейся стартап-компании. В настоящее время
Михал — старший бэкенд-инженер в Showpad. Имеет большой опыт в разработке
высокопроизводительных распределенных сервисов. Кроме того, является активным участником многих проектов Python с открытым исходным кодом.
Тарек Зиаде — Python-разработчик. Живет в сельской местности недалеко
от города Дижон во Франции. Работает в Mozilla, в команде, отвечающей за
сервисы. Тарек основал французскую группу пользователей Python (называется
Afpy) и написал несколько книг о Python на французском и английском языках.
В свободное от хакинга и тусовок время занимается любимыми хобби: бегом или
игрой на трубе.
Вы можете посетить его личный блог (Fetchez le Python) и подписаться на него
в Twitter (tarek_ziade).
О научном редакторе
Коди Джексон — кандидат наук, основатель компании Socius Consulting, работающей в сфере IT и консалтинга по управлению бизнесом в Сан-Антонио,
а также соучредитель Top Men Technologies. В настоящее время работает в CACI
International ведущим инженером по моделированию ICS/SCADA. В IT-индустрии
с 1994 года, еще со времен службы в ВМФ в качестве ядерного химика и радиотехника. До CACI он работал в университете в ECPI в должности ассистента профессора по компьютерным информационным системам. Выучился программированию
на Python самостоятельно, написал книги Learning to Program Using Python и Secret
Recipes of the Python Ninja.
Предисловие
Python — динамический язык программирования, применимый в широком спектре
задач благодаря своей простой, но мощной сути. Писать на Python легко, но сделать код удобочитаемым, универсальным и простым в сопровождении — сложно.
В третьем издании данной книги вы ознакомитесь с практическими рекомендация
ми, полезными инструментами и стандартами, используемыми профессиональными разработчиками на Python, так что сумеете преодолеть данную проблему.
Мы начнем эту книгу с новых возможностей, добавленных в Python 3.7. Изучим синтаксис Python и рассмотрим, как применять самые современные концепции и механизмы объектно-ориентированного программирования. Помимо
этого, исследуем различные подходы к реализации метапрограммирования.
Данная книга расскажет о присваивании имен при написании пакетов, создании исполняемых файлов, а также о применении мощных инструментов, таких
как buildout и virtualenv, для развертывания кода на удаленных серверах. Вы
узнаете, как создавать полезные расширения Python на языках C, C++, Cython
и Pyrex. Кроме того, чтобы писать чистый код, вам будет полезно изучить инструменты управления кодом, написания ясной документации и разработки
через тестирование.
Изучив эту книгу, вы станете экспертом в написании эффективного и удобного
в сопровождении кода на Python.
Для кого эта книга
Книга написана для разработчиков на Python, желающих продвинуться в освоении
этого языка. Под разработчиками мы имеем в виду в основном программистов,
которые зарабатывают на жизнь программированием на Python. Дело в том, что
книга сосредоточена на средствах и методах, наиболее важных для создания производительного, надежного и удобного в сопровождении программного обеспечения
на Python.
Это не значит, что в книге нет ничего интересного для любителей. Она отлично
подойдет для тех, кто хочет выйти на новый уровень в изучении Python. Базовых
16 Предисловие
навыков языка будет достаточно, чтобы понять изложенный материал, хотя менее
опытным программистам придется приложить некоторые усилия. Книга также
будет хорошим введением в Python 3.7 для тех, кто слегка отстал от жизни и пользуется версией Python 2.7 или еще более ранней.
Наибольшую пользу данная книга принесет веб- и бэкенд-разработчикам, поскольку в ней представлены две темы, особенно важные именно для этих специалистов: надежное развертывание кода и параллелизм.
Что мы рассмотрим
В главе 1 описано текущее состояние Python и его сообщества. Мы увидим, как
меняется язык, из-за чего это происходит и почему данные факты очень важны
для тех, кто хочет называть себя профессионалом в Python. Мы также рассмотрим
наиболее известные и канонические способы работы с кодом Python, а именно
популярные инструменты обеспечения производительности и правила, которые
сегодня фактически являются стандартами.
В главе 2 представлены современные способы создания повторяемых и последовательных сред разработки для программистов на Python. Мы сосредоточимся
на двух популярных инструментах для изоляции среды: средах типа virtualenv
и контейнерах Docker.
В главе 3 даны практические рекомендации по написанию кода на Python (идио
мы языка), а также краткое описание отдельных элементов синтаксиса Python,
которые могут оказаться новыми для программистов, более привыкших к старым
версиям Python. Кроме того, мы изложим пару полезных идей о внутренних реализациях типа CPython и их вычислительной сложности в качестве обоснования
для рассмотренных идиом.
В главе 4 рассмотрены более сложные концепции и механизмы объектно-ориентированного программирования, доступные в Python.
В главе 5 представлен обзор общих подходов к метапрограммированию для
программистов на Python.
В главе 6 приведено руководство по наиболее общепринятому стилю написания
кода на Python (PEP 8), а также указано, когда и почему разработчики должны
соблюдать его. Вдобавок мы рассмотрим некоторые общие рекомендации по назначению имен.
В главе 7 описаны особенности создания пакетов на Python и даны рекомендации по созданию пакетов, распространяемых в виде открытого исходного кода
в каталоге пакетов Python (Python Package Index, PyPI). Мы также рассмотрим
тему, которую часто игнорируют, — исполняемые файлы.
В главе 8 представлены некоторые облегченные инструменты для развертывания кода Python на удаленных серверах. Развертывание — это одна из областей,
Предисловие 17
где Python предстает во всей красе в реализации бэкенда для веб-сервисов и приложений.
В главе 9 объясняется, почему иногда удобно добавлять в код расширения на C
и C++, и показывается, что при наличии подходящих инструментов сделать это
будет проще, чем кажется.
В главе 10 рассказывается, как правильно управлять кодовой базой и почему
следует использовать систему управления версиями. Мы опробуем на деле возможности такой системы (а именно, Git) в осуществлении непрерывных процессов,
таких как непрерывная интеграция и непрерывная доставка.
В главе 11 приводятся общие правила написания технической документации,
применимые к программному обеспечению, написанному на любом языке, а также
различные инструменты, которые будут особенно полезны при создании документации для вашего кода на Python.
В главе 12 представлены плюсы подхода «разработка через тестирование» и по
дробно рассказывается о том, как использовать популярные инструменты Python
для тестирования.
В главе 13 обсуждаются базовые правила оптимизации, которые должен знать
каждый разработчик. Мы также научимся выявлять узкие места в производительности приложений и использовать общие инструменты профилирования.
В главе 14 показывается, как применить эти знания так, чтобы ваше приложение
действительно работало быстрее или эффективнее с точки зрения используемых
ресурсов.
В главе 15 объясняется, как реализовать параллелизм на Python с помощью
различных подходов и готовых библиотек.
В главе 16 рассказывается, что такое событийно-ориентированное и сигнальное
программирование и как оно связано с асинхронным и различными моделями параллелизма. Мы представим разные подходы к событийному программированию,
доступные программистам на Python, а также полезные библиотеки, позволяющие
применять эти шаблоны.
В главе 17 описаны несколько полезных паттернов проектирования и примеры
их реализации на Python.
Приложение содержит краткое руководство по использованию языка разметки
reStructuredText.
Как получить максимум от этой книги
Данная книга написана для программистов, работающих в любой операционной
системе, где установлен Python 3.
Издание не подходит для начинающих, поэтому мы предполагаем, что вы установили Python в своей среде или вы знаете, как это сделать. Однако в книге учитывается
18 Предисловие
тот факт, что не все могут знать о последних функциях Python или официально
рекомендованных инструментах. Именно поэтому в первой главе приведен обзор
наиболее часто используемых утилит (например, виртуальных сред и pip), которые
в настоящее время профессиональные разработчики на Python считают стандартными инструментами.
Скачивание файлов с примерами кода
Вы можете скачать файлы примеров кода для этой книги на сайте github.com/
PacktPublishing/Expert-Python-Programming-Third-Edition.
Чтобы скачать файлы кода, выполните следующие действия.
1. Перейдите по указанной ссылке на сайт github.com.
2. Нажмите кнопку Clone or Download.
3. Щелкните кнопкой мыши на ссылке Download ZIP.
4. Скачайте архив с файлами примеров.
После скачивания файла распакуйте его с помощью последней версии одной
из следующих программ:
WinRAR/7-Zip для Windows;
Zipeg/iZip/UnRarX для Mac;
7-Zip/PeaZip для Linux.
Скачивание цветных изображений
Мы также выложили оригинальный файл PDF, в котором приведены цветные изображения снимков экрана/схем, используемых в этой книге. Вы можете скачать его
по ссылке www.packtpub.com/sites/default/files/downloads/9781789808896_ColorImages.pdf.
Условные обозначения
В этой книге используется ряд текстовых и символьных обозначений.
Код в тексте: такой формат обозначает кодовые слова в тексте, имена таблиц
базы данных, имена папок и файлов, расширения файлов, пути к файлам, URLадреса, пользовательский ввод и инструменты Twitter. Например: «Любая попытка
запустить код, в котором есть такие проблемы, заставит интерпретатор завершить
работу, выбросив исключение SyntaxError».
Предисловие 19
Блок кода выглядит следующим образом:
print("hello world")
print "goodbye python2"
Любой ввод или вывод из командной строки записывается так:
$ Python3 script.py
Новые термины и важные слова выделены курсивом.
Так помечаются предупреждения и важные примечания.
А так — советы и секреты.
От издательства
Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com (издательство «Питер», компьютерная редакция).
Мы будем рады узнать ваше мнение!
На веб-сайте издательства www.piter.com вы найдете подробную информацию
о наших книгах.
Часть I
Перед началом
работы
Эта часть призвана помочь пользователю подготовиться к современным реалиям разработки на Python. Мы рассмотрим, как язык
изменился за последние несколько лет и какими инструментами
разработки пользуются современные программисты на Python.
1
Текущее
состояние Python
Python удивителен.
На протяжении долгого времени одним из самых важных достоинств Python
была его совместимость. Независимо от того, какую операционную систему используете вы или ваши клиенты, если у нее есть интерпретатор Python, то ваше
написанное на Python ПО будет в ней работать. И что важнее всего — работать так,
как нужно. Однако сейчас этим никого не удивишь. Современные языки, например Ruby и Java, предоставляют аналогичные возможности для взаимодействия.
Но в наше время совместимость не самое важное качество языка программирования. С появлением облачных вычислений,веб-приложений и надежного программного обеспечения для создания виртуальных окружений вопросы совместимости
и независимости от операционной системы отошли на второй план. Однако попрежнему важны инструменты, позволяющие программистам эффективно писать
надежное и удобное в сопровождении ПО. К счастью, Python относится к тем
языкам, благодаря которым программисты могут работать наиболее эффективно,
и для развития компаний это лучший выбор.
Python так долго не теряет актуальности благодаря тому, что постоянно развивается. Эта книга ориентирована на последнюю версию Python 3.7, и все примеры
кода написаны именно в ней, если не сказано иное. Поскольку Python имеет очень
длинную историю и еще есть программисты, пишущие на Python 2, данная книга
начинается с обзора текущего статус-кво Python 3. В этой главе вы узнаете, как
и почему Python изменился и как писать программное обеспечение, совместимое
и со старыми, и с последними версиями Python.
В этой главе:
где мы находимся и куда движемся;
почему и как изменился язык Python;
как не отставать от изменений в документации PEP;
принятие Python 3 на момент написания этой книги;
основные различия между Python 3 и Python 2;
не только CPython;
полезные ресурсы.
Глава 1.
Текущее состояние Python 23
Технические требования
Для этой главы скачать последнюю версию Python можно по ссылке www.python.org/
downloads/.
Альтернативные реализации интерпретатора Python можно найти на следу
ющих сайтах:
Stackless Python: github.com/stackless-dev/stackless;
PyPy: pypy.org;
Jython: www.jython.org;
IronPython: ironpython.net;
MicroPython: micropython.org.
Файлы с примерами кода для этой главы можно найти по ссылке github.com/
PacktPublishing/Expert-Python-Programming-Third-Edition/tree/master/chapter1.
Где мы находимся и куда движемся
История Python началась где-то в конце 1980-х годов, но релиз версии 1.0 состоялся в 1994 году. То есть это не молодой язык. Мы бы могли пройтись по всей
хронологии версий Python, однако на самом деле нас интересует только одна дата:
3 декабря 2008 года — выход Python 3.0.
На момент написания этой книги прошло почти десять лет с появления первого
релиза Python 3. Кроме того, прошло семь лет после выпуска PEP 404 — официального документа, в котором был отменен выпуск Python 2.8 и официально закрыта
вся серия 2.x. С тех пор прошло много времени, однако сообщество Python все
еще делится на два лагеря: несмотря на то что язык развивается очень быстро, есть
большая группа пользователей, которые не хотят идти с ним в ногу.
Почему и как изменился язык Python
Ответ прост — Python изменяется, поскольку в этом есть необходимость. Конкуренты не спят. Каждые несколько месяцев из ниоткуда появляется новый язык,
претендующий на решение всех проблем своих предшественников. Разработчики
быстро утрачивают интерес к большинству подобных проектов, и их популярность
часто вызвана исключительно хайпом.
За этим кроется более серьезная проблема. Люди берутся за разработку новых
языков, поскольку считают, что существующие не решают их проблем. Было бы
глупо отрицать необходимость в новых решениях. Кроме того, все более широкое
использование Python показывает: язык можно и нужно улучшать.
24 Часть I
•
Перед началом работы
Множество улучшений в Python обусловлены потребностями конкретных сфер,
в которых он применяется. Наиболее значимая из них — веб-разработка. Так, постоянно растущий спрос на скорость и производительность в этой области привел
к тому, что работа с параллелизмом в Python значительно упростилась.
Некоторые изменения вызваны попросту солидным возрастом и зрелостью
проекта Python. На протяжении многих лет он обрастал множеством неорганизованных и избыточных модулей стандартных библиотек и даже плохими проектными решениями. То есть выпуск Python 3 был призван подчистить и освежить
язык. К сожалению, время показало: данный план имел и неприятные последствия.
В течение долгого времени Python 3 использовался многими разработчиками несерьезно. Будем надеяться, это изменится.
Как не отставать от изменений
в документации PEP
Сообщество Python придумало устоявшийся способ реагирования на изменения.
Хотя рискованные идеи языка Python в основном обсуждаются в рассылках (pythonideas@python.org), по-настоящему серьезные изменения сопровождаются выходом
документа под названием Python Enhancement Proposal (PEP).
Это формализованный документ, в котором подробно описывается предложение
об изменении Python. Он также является отправной точкой для обсуждения в сообществе. Вся цель, формат и рабочий процесс вокруг данных документов также
стандартизированы в документе PEP 1 (www.python.org/dev/peps/pep-0001).
PEP-документация очень важна для Python и, в зависимости от темы, выполняет разные функции:
информирования — приводит информацию, необходимую разработчикам ядра
Python, и графики выпуска версий Python;
стандартизации — содержит указания по стилю кода, документации или другие
руководящие принципы;
проектирования — описывает предлагаемые функции.
Список всех предлагаемых PEP приведен в постоянно обновляемом документе
PEP 0 (www.python.org/dev/peps/). Найти их легко, а ссылку на них нетрудно сформировать самостоятельно, поэтому в книге мы будем называть их лишь по номерам.
Документ PEP 0 — важный источник информации для тех, кому интересно,
в каком направления движется язык Python, но некогда отслеживать каждое обсу
ждение в рассылках Python. В PEP 0 показано, какие документы уже были приняты, но еще не реализованы, а какие находятся на рассмотрении.
Глава 1.
Текущее состояние Python 25
Документы PEP выполняют и другие функции. Очень часто люди задают вопросы а-ля:
«Почему функция А работает именно так?»;
«Почему в Python нет функции Б?».
В большинстве случаев в конкретных документах PEP уже имеется развернутый ответ. Существует много PEP-документов с описанием возможностей языка
Python, которые были предложены, но не приняты. Эти документы играют роль
своего рода исторической справки.
Внедрение Python 3 на момент написания
этой книги
Если в Python столько новых и интересных функций, то наверняка он хорошо
принят в сообществе? Сложно сказать. Некогда популярная страница «Стена суперсил Python 3» (python3wos.appspot.com), на которой отслеживалась совместимость
самых популярных пакетов и Python 3, изначально была названа «Стеной позора
Python 3». Этот сайт больше не поддерживается, однако на момент последнего
обновления от 22 апреля 2018 года видно, что 191 из 200 наиболее популярных
пакетов Python были совместимы с Python 3. Таким образом, можно убедиться,
что эту версию хорошо приняли в сообществе программистов Python с открытым
исходным кодом. Тем не менее это не значит, что все команды программистов
полностью перешли на Python 3. По крайней мере, коль скоро большинство популярных пакетов Python доступны в Python 3, отговорки наподобие «то, чем мы
пользуемся, еще не портировали» уже не актуальны.
Основная причина такой ситуации заключается в том, что портирование существующего приложения с Python 2 на Python 3 — всегда сложная задача. Есть инструменты, такие как 2to3, позволяющие выполнять автоматизированный перевод
кода, но не гарантирующие, что результат будет правильным на 100 %. Кроме того,
такой «переведенный» код может утратить производительность, если не прибегнуть к ручной регулировке. Перевод существующего сложного кода на Python 3
может повлечь огромные усилия и расходы, которые могут себе позволить не все
организации. К счастью, подобные расходы можно распределить во времени. Некоторые хорошие методологии проектирования архитектуры программного обеспечения, такие как сервис-ориентированная архитектура или микросервисы, дают
возможность постепенно достичь этой цели. Новые компоненты проекта (сервисы
или микросервисы) можно писать по новой технологии, а существующие — портировать по одному.
26 Часть I
•
Перед началом работы
В перспективе переход на Python 3 может иметь только положительные последствия для проекта. Согласно PEP 404 поддержка Python 2 закончилась в 2020 году.
До этого времени мы можем ожидать только обновления версии патча, решающего
проблемы безопасности, но ничего более. Кроме того, в будущем, возможно, настанет время, когда все крупные проекты, такие как Django, Flask и NumPy, отключат
совместимость с 2.x и полностью перейдут на Python 3. В Django уже сделали этот
шаг, и, начиная с версии 2.0.0, он больше не поддерживает Python 2.7.
Наше мнение по данному вопросу противоречиво. Мы думаем, что лучшим стимулом для отказа сообщества от Python 2 будет прекращение поддержки Python 2
при создании новых пакетов. Конечно, это ограничивает создание нового программного обеспечения, но, возможно, послужит единственно верным способом
изменить мышление тех, кто никак не отвыкнет от Python 2.x.
Мы рассмотрим основные различия между Python 3 и Python 2 в следующем
разделе.
Основные различия между Python 3 и Python 2
Как уже было сказано, в Python 3 нет обратной совместимости с Python 2 на уровне
синтаксиса. Однако не все так плохо. Кроме того, не каждый модуль Python, написанный под версию 2.x, перестает работать в Python 3. Можно писать полностью
кросс-совместимый код, который будет работать в обеих версиях без дополнительных инструментов или методов, но обычно это возможно только для простых
приложений.
Почему это должно нас волновать
Несмотря на наше мнение о совместимости Python 2, которое мы высказали ранее
в данной главе, нельзя просто взять и забыть об этой проблеме. Есть несколько
действительно полезных пакетов, но в ближайшее время они вряд ли будут портированы.
Кроме того, иногда ограничения исходят от организации, в которой мы работаем. Уже имеющийся код может быть настолько сложным, что портировать его
экономически нецелесообразно. Таким образом, даже если мы решили двигаться
дальше и с этого момента пользоваться исключительно Python 3, сразу полностью
отказаться от Python 2 все равно будет невозможно.
В наши дни трудно назвать себя профессиональным разработчиком, если
не вносить свой вклад в деятельность сообщества. Таким образом, помочь разработчикам, пишущим открытый исходный код, внедрить совместимость Python 3
Глава 1.
Текущее состояние Python 27
с существующими пакетами — отличный способ погасить моральный долг, возникший в результате использования последних. Конечно, это невозможно сделать,
не зная различий между Python 2 и Python 3. Кстати, это также отличное упражнение для новичков в Python 3.
Основные синтаксические различия
и распространенные ошибки
Документация Python — лучшее место, где можно почитать о различиях между
версиями Python. Тем не менее для удобства читателей здесь перечислены
наиболее важные различия. Это не отменяет того факта, что документация обязательна к прочтению тому, кто еще не знаком с Python 3 (docs.python.org/3.0/
whatsnew/3.0.html).
Важнейшие нововведения Python 3 можно разделить на три группы:
изменения синтаксиса, в которых одни элементы синтаксиса были удалены/
изменены, а другие — добавлены;
изменения в стандартной библиотеке;
изменения типов данных и коллекций.
Изменения синтаксиса
Изменения синтаксиса, затрудняющие запуск кода, обнаружить легче всего, ведь
код просто не сможет выполняться. Код Python 3, в котором используются новые
элементы синтаксиса, не будет работать на Python 2, и наоборот. Элементы, удаленные из официального синтаксиса, сделают код Python 2 явно несовместимым
с Python 3. Любая попытка запустить подобный код немедленно приведет к сбою
интерпретатора, вызывая исключение SyntaxError . Ниже представлен пример
«сломанного» скрипта из двух команд, ни одна из которых не будет выполнена
из-за ошибки синтаксиса:
print("hello world")
print "goodbye python2"
Результат запуска скрипта на Python 3 выглядит следующим образом:
$ python3 script.py
File "script.py", line 2
print "goodbye python2"
^
SyntaxError: Missing parentheses in call to 'print'
28 Часть I
•
Перед началом работы
Если говорить о новых элементах синтаксиса Python 3, то на перечисление
всех различий уйдет много времени, и в каждой версии Python 3.x могут снова
появиться новые элементы синтаксиса, которые будут так же несовместимы с более ранними версиями Python (даже если это уже Python 3.x). Наиболее важные
из них рассмотрены в главах 2 и 3, так что нет необходимости перечислять их все
здесь.
Список вещей, которые работали в Python 2 и вызывали синтаксические или
функциональные ошибки в Python 3, гораздо короче. Ниже представлены наиболее
важные несовместимые изменения:
print уже не оператор, а функция, поэтому скобки обязательны;
указание исключений изменилось с except exc, var на except exc as var;
оператор сравнения был заменен на !=;
from module import * (docs.python.org/3.0/reference/simple_stmts.html#import) уже
допускается не только на уровне модуля и больше не допускается внутри
функций;
from .[module] import name — теперь единственный общепринятый синтаксис
для относительного импорта. Весь импорт, не начинающийся с точки, интерпретируется как абсолютный;
функция sorted() и метод списков sort() больше не принимают аргумент cmp,
нужно использовать аргумент key;
целочисленное деление на числа с плавающей точкой возвращает числа с плавающей точкой. Отсечение дробной части достигается за счет оператора //, например 1 // 2. С числами с плавающей точкой это также работает: 5.0 // 2.0 == 2.0.
Изменения в стандартной библиотеке
Критические изменения в стандартной библиотеке обнаружить чуть сложнее,
чем изменения синтаксиса. В каждой последующей версии Python добавляются,
улучшаются или полностью удаляются стандартные модули. Данный процесс
был распространен и в старых релизах Python (1.x и 2.x), так что в Python 3 это
не является чем-то из ряда вон. В большинстве случаев, в зависимости от модуля,
который был удален или реорганизован (например, urlparse перемещен в urllib.
parse), он будет вызывать исключения на время импорта только после интерпретации. Поэтому такие проблемы легко выявить. Чтобы быть уверенными, что будут
обнаружены все подобные моменты, необходимо тестировать весь код. В некоторых
случаях (например, при использовании лениво загруженных модулей) проблемы,
обычно заметные во время импорта, не будут проявляться, пока какая-либо функция не обратится к «проблемному» модулю. Именно поэтому важно убедиться, что
во время теста выполняется каждая строчка кода.
Глава 1.
Текущее состояние Python 29
Лениво загруженные модули
Лениво загруженный модуль — это модуль, который не был загружен
во время импорта. В Python операторы import могут быть включены
в функции, поэтому импорт будет происходить при вызове функции, а не
во время основного импорта. Иногда такая загрузка модулей может быть
разумным решением, но в большинстве случаев это обходной путь для
плохо разработанной конструкции модуля (например, чтобы избежать
циклического импорта). Такой код считается «с душком», и подобных
действий вообще следует избегать. Уважительной причины для ленивой
загрузки модулей стандартной библиотеки нет. В хорошо структурированном коде весь импорт должен быть сгруппирован в верхней части модуля.
Изменения типов данных, коллекций, строковых литералов
Разница в том, как Python представляет типы данных и коллекции, особенно заметна и создает больше всего проблем, когда разработчик пытается сохранить совместимость или просто портирует существующий код на Python 3. В то время как
несовместимый синтаксис или изменения стандартной библиотеки легко найти
и часто легко исправить, изменения в коллекциях и типах бывают неочевидны или
требуют большого объема монотонной работы. Перечень таких изменений будет
весьма длинным, поэтому официальная документация — самый лучший справочник.
Тем не менее здесь мы поговорим о том, как в Python 3 рассматриваются
строковые литералы, поскольку это одно из самых спорных изменений Python 3,
несмотря на то что это очень хороший ход, прояснивший многое.
Все строковые литералы теперь имеют кодировку Unicode, а у литералов
bytestring должен быть префикс b или B . В Python 3.0 и 3.1 старый префикс
Unicode U (например, u"foo") не принимается и вызывает синтаксическую ошибку.
Отказ от него был основной причиной большинства споров. Стало очень трудно
создать код, совместимый с различными ответвлениями Python, — в версии 2.x
Python ссылался на эти префиксы при создании литералов Unicode. Данный префикс был возвращен обратно в Python 3.3, чтобы облегчить процесс интеграции,
хотя в настоящее время в этом нет какого-либо синтаксического смысла.
Популярные инструменты и методы поддержания
кросс-версионной совместимости
Поддержание совместимости между версиями Python — трудная задача. Она может добавить много дополнительной работы, в зависимости от размера проекта,
но это тем не менее можно и нужно делать. Для пакетов, которые многократно
используются во многих средах, это абсолютно необходимо. Пакеты с открытым
исходным кодом без четко определенной и проверенной совместимости вряд ли
30 Часть I
•
Перед началом работы
станут популярны, да и сторонний код, применяемый в пределах компании, тоже
будет полезно протестировать в различных средах.
Следует отметить, что, хоть эта глава сосредоточена в основном на совместимости между различными версиями Python, данные подходы применяются для
поддержания совместимости с внешними зависимостями, такими как различные
версии пакетов, бинарные библиотеки, системы или внешние сервисы.
Весь процесс можно разделить на три основных направления, расположенных
в порядке их важности:
определение и документирование целевой оценки совместимости и управления
совместимостью;
тестирование во всех средах и версиях, совместимость с которым была заявлена;
реализация совместимости кода.
Заявление о том, что считается совместимым, — наиболее важная часть всего
процесса, поскольку дает пользователям и разработчикам возможность иметь
ожидания и делать предположения о работе кода и о том, как он может измениться
в будущем. Наш код можно задействовать в качестве зависимости в различных
проектах, в которых тоже будет внедрено управление совместимостью, поэтому
способность понимать его поведение очень важна.
В этой книге мы всегда пытаемся предоставить несколько возможностей выбора и не давать абсолютных рекомендаций по конкретным вариантам, но здесь
будет одно из немногих исключений. Лучший способ определить, каким образом
совместимость может измениться в будущем, — использовать правильный подход
к нумерации версий Semantic Versioning (semver) (semver.org). Это широко принятый
стандарт маркировки изменений в коде версии с помощью всего лишь трех цифр.
В нем также содержатся несколько советов о том, как работать с политикой устаревания. Вот выдержка из него (под лицензией Creative Commons — CC BY 3.0).
Допустим, номер версии приложения выглядит как MAJOR.MINOR.PATCH.
Тогда прибавляем единицу к номеру:
1) MAJOR-версии, если вносятся изменения, делающие текущий код несовместимым с предыдущей версией;
2) MINOR-версии, если изменения вносятся вместе с обратной совместимостью;
3) PATCH-версии, если вы исправляете ошибки обратной совместимости.
Дополнительные отметки предварительных версий и метаданных могут быть
дополнением к формату MAJOR.MINOR.PATCH.
Когда дело доходит до проверки совместимости кода с каждой заявленной
версией и в любой среде (в нашем случае — версии Python), он должен быть проверен в каждой комбинации. Конечно, это почти невозможно, если у проекта много
зависимостей, поскольку количество комбинаций быстро растет с каждой новой
Глава 1.
Текущее состояние Python 31
версией зависимости. Таким образом, как правило, ищут некий компромисс, чтобы
на тестирование совместимости не уходило много времени. Подборка инструментов, призванная облегчить тестирование в так называемых матрицах, представлена
в главе 12, в которой мы поговорим о процедуре тестирования в целом.
Преимущество использования проектов, которые следуют практике
semver, заключается в том, что, как правило, тестировать нужно только крупные релизы, поскольку мелкие и патч-релизы гарантированно
не имеют изменений без обратной совместимости. Конечно, это справедливо, только когда проект неукоснительно следует данной практике.
К сожалению, ошибки случаются с каждым и несовместимые изменения
возникают во многих проектах, даже в мелких патчах. Тем не менее
нарушение объявленной semver строгой совместимости в мелких изменениях и патчах считается ошибкой, и ее нужно исправлять.
Реализация слоя совместимости — последний и наименее важный шаг процесса,
если границы данной совместимости четко определены и тщательно протестированы. Тем не менее есть ряд инструментов и методов, которые должен знать каждый
программист, занимающийся этим.
Основным является модуль __future__. Он портирует некоторые новые возможности в старые версии и принимает форму оператора импорта:
from __future__ import
Функции, предоставляемые оператором future, — это синтаксические элементы,
которые не так уж легко обрабатывать различными способами. Данный оператор
влияет только на тот модуль, где был использован. Ниже представлен пример интерактивной сессии Python 2.7, которая переносит литералы Unicode с Python 3.0:
Python 2.7.10 (default, May 23 2015, 09:40:32) [MSC v.1500 32 bit
(Intel)] on win32
Type "help", "copyright", "credits" or "license" for more
information.
>>> type("foo") # Старые литералы
>>> from __future__ import unicode_literals
>>> type("foo") # Теперь Unicode
Вот список всех доступных вариантов оператора __future__, которые должны
знать разработчики, занимающиеся сопровождением:
division — добавляет оператор деления Python 3 (PEP 238);
absolute_import — заставляет каждую форму оператора import интерпретиро-
ваться «без точки», то есть как абсолютный импорт (PEP 328);
32 Часть I
•
Перед началом работы
print_function — заменяет оператор print функцией, то есть использование
скобок становится обязательным (PEP 3112);
unicode_literals — заставляет каждый строковый литерал интерпретироваться
как литералы Unicode (PEP 3112).
Список всех доступных вариантов оператора __future__ невелик и охватывает
лишь несколько свойств синтаксиса. Другие вещи, подвергшиеся изменению,
например синтаксис функции metaclass (о ней мы поговорим в главе 5), поддерживать намного сложнее. Этот оператор также не поможет надежной работе
с реорганизациями стандартных библиотек. К счастью, существуют инструменты, которые позволяют получить последовательный фрагмент готового к использованию совместимого кода. Наиболее известный — это Six (pypi.python.org/
pypi/six/), который обеспечивает сопровождение как одиночный модуль. Другой
перспективный, но чуть менее популярный инструмент — модуль future (pythonfuture.org/).
Иногда разработчики могут не захотеть включать дополнительные зависимости в небольшие пакеты. Часто используется дополнительный модуль,
обычно именуемый compat.py, который собирает весь код совместимости. Ниже
представлен пример таких модулей из проекта python-gmaps (github.com/swistakm/
python-gmaps):
# -*- coding: utf-8 -*"""Этот модуль обеспечивает совместимость
кода между разными версиями Python
"""
import sys
if sys.version_info < (3, 0, 0):
import urlparse # noqa
def is_string(s):
"""Возвращает True, если значение является строкой"""
return isinstance(s, basestring)
else:
# Примечание: urlparse перемещен в urllib.parse в Python 3
from urllib import parse as urlparse # noqa
def is_string(s):
"""Возвращает True, если значение является строкой"""
return isinstance(s, str)
Такие модули compat.py популярны даже в тех проектах, сопровождение которых
зависит от Six (https://pypi.python.org/pypi/six/), поскольку это очень удобный способ хранения кода, позволяющий получить совместимость с различными версиями пакетов.
В следующем разделе мы рассмотрим, что такое CPython.
Глава 1.
Текущее состояние Python 33
Не только CPython
Эталонная реализация интерпретатора Python называется CPython и, как следует
из названия, полностью написана на языке C. Это всегда был C и, вероятно, будет
еще очень долго. Данную реализацию выбирает большинство программистов на
Python, поскольку она всегда идет в ногу со спецификациями языка и является
интерпретатором, на котором протестировано большинство библиотек. Но, кроме C, интерпретатор Python был написан на нескольких других языках. Кроме того,
существуют модифицированные версии интерпретатора CPython, доступные под
разными названиями и адаптированные для некоторых нишевых приложений.
Большинство из них сильно отстают от CPython, но позволяют использовать и продвигать язык в узкоспециализированных задачах.
В этом разделе мы обсудим некоторые из наиболее известных и интересных
альтернативных реализаций Python.
Почему это должно нас волновать
Существует много реализаций Python. На «Вики»-странице Python по этой теме
(wiki.python.org/moin/PythonImplementations) представлены десятки различных вариантов языка, диалектов или реализаций интерпретатора Python, созданных
не на C. Одни из них реализуют лишь часть синтаксиса основного языка, функций
и встроенных расширений, но есть почти полностью совместимые с CPython. Надо
понимать, что, хотя некоторые из них — просто игрушка или эксперимент, большинство из них созданы для решения реальных проблем, которые было сложно
или невозможно решить с помощью CPython.
Примеры таких проблем:
запуск кода Python на встраиваемых системах;
интеграция с кодом, написанным для фреймворков вроде Java или .NET или
на разных языках;
запуск кода Python в браузерах.
В следующих подразделах кратко описаны субъективно наиболее популярные
и современные варианты, в настоящее время доступные для программистов на
Python.
Stackless Python
Stackless Python преподносит себя как улучшенную версию Python. Он носит такое
имя, поскольку позволяет избежать зависимости от стека вызова C и имеет свой
собственный стек. Это, по сути, модифицированный код CPython, в котором также
34 Часть I
•
Перед началом работы
добавлены новые функции, отсутствовавшие в ядре Python на момент создания
Stackless. Наиболее важными из них являются микропотоки, управляемые интерпретатором, как дешевая и облегченная альтернатива обычным потокам, которые
должны зависеть от ядра системы и планирования задач.
Последние доступные версии 2.7.15 и 3.6.6 реализуют Python 2.7 и 3.6 соответственно. Все дополнительные функции в версии Stackless показаны в качестве
основы в фреймворке через встроенный модуль stackless.
Stackless — не самая популярная альтернатива реализации Python, но о ней
стоит знать, поскольку некоторые из реализованных в ней идей сильно повлияли
на сообщество. Функциональность переключения ядра была извлечена из Stackless
и опубликована в качестве самостоятельного пакета под названием greenlet, в настоящее время лежащего в основе многих полезных библиотек и фреймворков.
Кроме того, большинство из его функций были вновь реализованы в PyPy — еще
одной реализации Python, о которой мы поговорим позже. Официальную онлайндокументацию по Stackless Python можно найти по адресу stackless.readthedocs.io,
а «Вики»-проект — на github.com/stackless-dev/stackless.
Jython
Jython — это реализация на Java. Код компилируется в байт-код Java и позволяет
разработчикам легко задействовать классы Java в модулях Python. Jython дает возможность использовать Python как скриптовый язык верхнего уровня для сложных
прикладных систем, например J2EE. Он также открывает Java-приложениям путь
в мир Python. Создание Apache Jackrabbit (хранилище документов API на основе
JCR, jackrabbit.apache.org) является хорошим примером того, что можно сделать
с помощью Jython.
Основные отличия Jython от CPython:
сбор мусора на Java вместо подсчета ссылок;
отсутствие глобальной блокировки интерпретатора (global interpreter lock, GIL)
позволяет более эффективно использовать несколько ядер в многопоточных
приложениях.
Основной недостаток данной реализации языка — отсутствие поддержки расширений Python на С, поэтому написанные на С расширения не будут работать
на Jython.
Последняя доступная версия Jython — Jython 2,7, и она соответствует версии
языка 2.7. По заявлению разработчиков, в ней реализовано почти все ядро стандартной библиотеки Python и используются те же регрессионные тесты. К сожалению, Jython 3.x так и не был выпущен, и проект можно смело считать мертвым.
Глава 1.
Текущее состояние Python 35
Тем не менее Jython заслуживает хотя бы внимания, поскольку в свое время был
уникальным явлением, которое значительно повлияло на другие реализации
Python.
Официальная страница проекта: www.jython.org.
IronPython
IronPython — это объединение Python и .NET Framework. Проект поддерживается корпорацией Microsoft, где работают ведущие разработчики IronPython.
Это довольно крутая реклама для продвижения языка. За исключением Java,
.NET — одно из крупнейших сообществ разработчиков в Microsoft. Стоит также
отметить, что Microsoft предоставляет набор бесплатных инструментов разработки, которые превращают Visual Studio в полноценную IDE для Python.
Он распространяется в виде плагинов Visual Studio под названием Python Tools
for Visual Studio (PVTS), доступных с открытым исходным кодом на GitHub
(microsoft.github.io/PTVS).
Последний стабильный релиз — версия 2.7.8, и она совместима с Python 2.7.
В отличие от Jython, здесь мы можем наблюдать активное развитие обеих веток — и 2.x, и 3.x, хотя поддержка Python 3 до сих пор официально не выпущена.
Несмотря на то что .NET работает в основном на Microsoft Windows, IronPython
можно также запустить на macOS и Linux. Это реализовано с помощью Mono,
кросс-платформенной реализации .NET с открытым исходным кодом.
Основные отличия и преимущества CPython, по сравнению с IronPython, заключаются в следующем:
как и в Jython, отсутствие глобальной блокировки интерпретатора (GIL) позво-
ляет более полно использовать несколько ядер в многопоточных приложениях;
код, написанный на C# и других языках .NET, легко интегрируется в IronPython
и наоборот;
он может работать во всех основных браузерах при наличии Silverlight (хотя
Microsoft обещает прекратить поддержку Silverlight в 2021 году).
Есть у IronPython и отрицательные стороны — он очень похож на Jython, поскольку не поддерживает API расширений Python/C. Это важно для разработчиков, которые хотели бы использовать пакеты вроде NumPy, основанные на C. Сообщество
несколько раз пыталось внедрить поддержку API Python/C в IronPython или по
крайней мере совместимость с пакетом NumPy, но, к сожалению, ни один проект
не стал успешным.
Узнать больше о IronPython можно на официальной странице проекта iron
python.net.
36 Часть I
•
Перед началом работы
PyPy
PyPy — вероятно, самая интересная альтернативная реализация Python, поскольку в ней Python переписан на Python. Интерпретатор PyPy написан на Python.
В CPython есть код на C, который делает всю работу. Но в PyPy этот код написан
на чистом Python.
Это значит, что вы можете изменить поведение интерпретатора во время выполнения, а также реализовать паттерны проектирования, которые в CPython
реализовать сложно.
PyPy в настоящее время полностью совместим с Python 2.7.13, в то время как
последняя версия PyPy 3 совместима с Python версии 3.5.3.
В прошлом PyPy был интересен больше из теоретических соображений и только
тем, кто увлечен особенностями языка. Обычно он не использовался в продакшене,
но со временем это изменилось. В настоящее время многие тесты показывают, что,
как ни удивительно, PyPy часто работает намного быстрее, чем реализация CPython.
У этого проекта есть собственный бенчмаркинг, в котором отслеживается эффективность всех версий, измеренная с помощью десятков различных критериев (см.
speed.pypy.org). Это говорит о том, что PyPy с JIT работает, как правило, в несколько
раз быстрее, чем CPython. Эти и другие особенности PyPy побуждают все больше
и больше разработчиков использовать PyPy в их production-среде.
Основные отличия PyPy, по сравнению с реализацией CPython, заключаются
в следующем:
используется сбор мусора вместо подсчета ссылок;
имеется встроенный компилятор JIT, который дает серьезные улучшения в про-
изводительности;
используется Stackless на уровне приложения, заимствованный из Stackless Python.
Как и почти любой другой альтернативной реализации Python, PyPy не хватает
полноценной официальной поддержки расширений Python на языке C. Тем не менее
в ней есть хоть какая-то поддержка расширений C через подсистему CPyExt, хотя
у той пока нет нормальной документации. Кроме того, сообщество постоянно пытается портировать NumPy на PyPy, поскольку это наиболее востребованная функция.
Официальную страницу проекта PyPy можно найти на сайте pypy.org.
MicroPython
MicroPython — одна из самых молодых альтернативных реализаций в данном
перечне, так как ее первая официальная версия была выпущена 3 мая 2014 года.
Кроме того, это одна из самых интересных реализаций. MicroPython — интерпре-
Глава 1.
Текущее состояние Python 37
татор Python, который был оптимизирован для использования на микроконтроллерах, то есть в стесненных условиях. Небольшой размер и кое-какие оптимизации позволяют ему работать всего в 256 килобайтах кода и всего в 16 килобайтах
оперативной памяти.
Протестировать этот интерпретатор можно на контроллерах BBC — это разрядные устройства и пайборды, ориентированные на обучение программированию
и основам электроники.
Интерпретатор MicroPython написан на C99 (это стандарт языка C) и может
быть построен для многих аппаратных архитектур, включая x86, x86-64, ARM,
ARM Thumb и Xtensa. Он основан на Python 3, однако ввиду многих различий
синтаксиса нельзя достоверно сказать о полной совместимости с любой версией
Python 3.x. Это скорее диалект Python 3 с функцией print(), ключевыми словами
async/await и многими другими функциями Python 3. Не стоит ожидать, что ваши
любимые библиотеки Python 3 будут работать должным образом без дополнительных настроек.
Узнать больше о MicroPython можно на официальной странице проекта
micropython.org.
Полезные ресурсы
Лучший способ знать все о состоянии Python — быть в курсе всего нового и читать
тематические ресурсы. В Интернете их множество. Наиболее важные и очевидные
из них уже упоминались ранее, но для порядка повторим:
документация Python;
каталог пакетов Python (Python Package Index, PyPI);
PEP 0 — индекс Python Enhancement Proposals (PEP).
Другие ресурсы, такие как книги и учебные пособия, тоже полезны, но быстро
теряют актуальность. Не устаревают ресурсы, активно обновляемые сообществом.
Те немногие, которые стоит рекомендовать, представлены ниже.
Awesome Python (github.com/vinta/awesome-python) включает список популярных
пакетов и структур.
r/Python (www.reddit.com/r/Python/) — сабреддит Python, на котором можно най-
ти новости и интересные посты о Python, каждый день размещаемые многими
членами сообщества Python.
Python Weekly (www.pythonweekly.com) — популярная информационная рассыл-
ка, в которой каждую неделю появляются десятки новых интересных пакетов
и ресурсов Python.
38 Часть I
•
Перед началом работы
Pycoder’s Weekly (pycoders.com) — еще одна популярная еженедельная информа-
ционная рассылка с дайджестом новых пакетов и интересных статей. Контент
там часто пересекается с Python Weekly, но иногда можно найти что-то уникальное, еще не опубликованное в другом месте.
На этих сайтах можно найти множество дополнительных материалов.
Резюме
Данная глава была посвящена текущему состоянию Python и изменениям, которые
происходили на протяжении всей истории этого языка. Мы начали с обсуждения
того, как и почему Python изменяется, и описали основные результаты этого процесса, особенно различия между версиями Python 2 и 3. Мы научились работать
с данными изменениями и узнали о некоторых полезных методах, позволяющих
писать код, совместимый с различными версиями языка и его библиотек.
Затем мы по-другому взглянули на идею изменений в языке программирования.
Рассмотрели несколько популярных альтернативных реализаций Python и поговорили об их основных отличиях от реализации CPython по умолчанию.
В следующей главе мы опишем современные способы создания повторяемых
и последовательных сред разработки для программистов Python и обсудим два
популярных инструмента для изоляции окружающей среды: virtualenv и контейнеры Docker.
2
Современные среды
разработки на Python
Глубокое понимание выбранного языка программирования — основа профессионализма. Это касается любой технологии. Действительно, трудно создавать хорошее
программное обеспечение, не умея работать с инструментами и методами, общепринятыми в сообществе. В Python нет ни одной функции, которой не было бы
в каком-либо другом языке. Если сравнивать синтаксис, выразительность или производительность, то всегда найдется решение, которое окажется лучше в том или
ином смысле. Но вот чем Python действительно выделяется, так это экосистемой,
возникшей вокруг языка. Сообщество Python долгие годы оттачивало стандартные
методы и библиотеки, которые помогают создавать более надежное программное
обеспечение в кратчайшие сроки.
Наиболее очевидная и важная часть экосистемы — огромная коллекция бесплатных пакетов и пакетов с открытым исходным кодом, которые решают множество
проблем. Написание нового программного обеспечения — всегда дорогостоящий
и трудоемкий процесс. Возможность использовать уже готовый код значительно
сокращает время разработки. Для некоторых компаний это единственный способ
сделать проекты экономически выгодными.
Разработчики на Python вложили много усилий в создание инструментов
и стандартов для работы с пакетами с открытым исходным кодом, написанными
другими разработчиками, начиная с виртуальных окружений, усовершенствованных интерактивных оболочек и отладчиков и заканчивая программами, которые
позволяют найти и проанализировать огромную коллекцию пакетов, имеющихся
в каталоге пакетов Python (Python Package Index, PyPI).
В этой главе мы обсудим:
установку дополнительных пакетов Python с использованием pip;
изоляцию сред исполнения;
venv — виртуальное окружение Python;
изоляцию среды на уровне системы;
популярные инструменты повышения производительности.
40 Часть I
•
Перед началом работы
Технические требования
Скачать бесплатные инструменты виртуализации, о которых мы будем говорить
в этой главе, можно со следующих сайтов:
Vagrant: www.vagrantup.com;
Docker: www.docker.com.
Ниже приведены пакеты Python, которые упоминаются в этой главе, их вы
можете скачать с PyPI:
virtualenv;
ipython;
ipdb;
ptpython;
ptbdb;
bpython;
bpdb.
Установить эти пакеты можно с помощью следующей команды:
python3 -m pip install
Файлы с примерами кода для этой главы можно найти по адресу github.com/
PacktPublishing/Expert-Python-Programming-Third-Edition/tree/master/chapter2.
Установка дополнительных пакетов Python
с использованием pip
Сегодня многие операционные системы поставляются с Python в качестве стандартного компонента. Большинство дистрибутивов Linux и UNIX на основе
FreeBSD, NetBSD, OpenBSD или macOS поставляются с Python либо сразу «из
коробки», либо из репозитория. Многие из них даже используют его в своих
основных компонентах — на Python работают инсталляторы Ubuntu (Ubiquity),
Red Hat Linux (Anaconda) и Fedora (опять же Anaconda). К сожалению, обычно
предустановленной версией является Python 2.7, которая уже устарела.
Из-за популярности Python в качестве компонента операционной системы многие пакеты PyPI доступны и в виде нативных пакетов, управляемых такими инструментами, как apt-get (Debian, Ubuntu), rpm (Red Hat Linux) или emerge (Gentoo).
Следует помнить, однако, что список доступных библиотек весьма ограничен и они
в основном устарели по сравнению с PyPI. Поэтому для получения новых пакетов
Глава 2.
Современные среды разработки на Python 41
в последней версии всегда нужно использовать pip, как было рекомендовано Python
Packaging Authority (PyPA). Несмотря на то что это независимый пакет, начиная
с CPython 2.7.9 и 3.4, он по умолчанию идет в комплекте с каждой новой версией.
Установить новый пакет очень просто:
pip install
Среди прочего pip позволяет устанавливать конкретные версии пакетов (с помощью команды pip install package-name==version) и обновлять их до последней
доступной версии (используя переключатель --upgrade). Полное описание применения большинства инструментов командной строки, представленных в книге, можно
легко получить, запустив команду с переключателем -h или --help. Ниже приведен
пример сеанса, который демонстрирует наиболее часто используемые опции:
$ pip show pip
Name: pip
Version: 18.0
Summary: The PyPA recommended tool for installing Python packages.
Home-page: https://pip.pypa.io/
Author: The pip developers
Author-email: pypa-dev@groups.google.com
License: MIT
Location: /Users/swistakm/.envs/epp-3rd-ed/lib/python3.7/site-packages
Requires:
Required-by:
$ pip install 'pip>=18.0'
Requirement already satisfied: pip>=18.0 in (...)/lib/python3.7/sitepackages
(18.0)
$ pip install --upgrade pip
Requirement already up-to-date: pip in (...)/lib/python3.7/site-packages
(18.0)
Не всегда pip бывает доступен по умолчанию. В Python 3.4 и далее (а также
в Python 2.7.9) его можно загрузить с помощью модуля ensurepip:
$ python -m ensurepip
Looking in links:
/var/folders/z6/3m2r6jgd04q0m7yq29c6lbzh0000gn/T/tmp784u9bct
Requirement already satisfied: setuptools in /Users/swistakm/.envs/epp-3rd
ed/lib/python3.7/site-packages (40.4.3)
Collecting pip
Installing collected packages: pip
Successfully installed pip-10.0.1
Самая актуальная информация о том, как установить pip в более старых версиях Python, доступна на странице документации проекта по ссылке pip.pypa.io/en/
stable/installing/.
42 Часть I
•
Перед началом работы
Изоляция сред выполнения
Можно использовать pip для установки систем пакетов. В UNIX-системах и в Linux
для этого нужны права суперпользователя, так что фактический вызов будет выглядеть следующим образом:
sudo pip install
Обратите внимание: в ОС Windows это не требуется, поскольку в ней по умолчанию нет интерпретатора Python и Python на Windows, как правило, устанавливается пользователем вручную без привилегий суперпользователя.
Не рекомендуется выполнять установку общесистемных пакетов непосредственно из PyPI. Данное утверждение на первый взгляд может противоречить предыдущему о том, что PyPA рекомендует использовать pip, но тому есть серьезные
причины. Как объяснялось ранее, Python часто является составной частью многих
пакетов, доступных в репозиториях ОС, и на нем может работать много сервисов.
В распределительных системах немало усилий тратится на выбор правильных
версий пакетов для обеспечения совместимости. Очень часто в пакеты Python, доступные в репозиториях, включены также пользовательские патчи или намеренно
старые версии, чтобы обеспечить совместимость с некоторыми другими компонентами системы. Принудительное обновление такого пакета с помощью pip до версии,
которая нарушает обратную совместимость, может привести к критическим багам
в ряде важных системных сервисов.
Делать подобные вещи даже на локальном компьютере в целях разработки
не рекомендуется. Безрассудно использовать pip таким образом — почти всегда
риск, в конечном итоге создающий проблемы, которые очень трудно отлаживать.
Это не значит, что установка пакетов из PyPI строго запрещена, но делать это
нужно сознательно и с пониманием риска.
К счастью, существует простое решение данной проблемы: изоляция среды.
Есть различныеинструменты, позволяющие изолировать среду выполнения Python
на разных уровнях абстракции системы. Основная идея заключается в том, чтобы
изолировать зависимости проекта от пакетов, которые нужны системным сервисам.
Преимущества такого подхода заключаются в следующем.
Это позволяет решить дилемму «Проекту X нужна версия 1.x, но проекту Y
необходима 4.x». Программист может работать над несколькими проектами
с различными зависимостями без риска их влияния друг на друга.
Проекты больше не ограничиваются версиями пакетов, которые установлены
в распределительных системах хранилищ разработчика.
Нет рисков поломки других системных сервисов, которые зависят от опреде-
ленных версий пакетов, так как новые версии пакетов доступны только в изолированной среде.
Глава 2.
Современные среды разработки на Python 43
Список пакетов-зависимостей можно заморозить и легко воспроизвести на
другом компьютере.
Если вы работаете параллельно над несколькими проектами, то быстро обнаружите, что невозможно сохранить их зависимости, не прибегая к какой-либо
изоляции.
Сравнение изоляции на уровне приложений с изоляцией на уровне системы.
Самый простой и облегченный подход к изоляции — использование виртуальных
окружений на уровне приложений. Они выполняют изоляцию интерпретатора
Python и пакетов, доступных внутри него. Такие окружения весьма просты в установке, и очень часто их хватает для обеспечения надлежащей изоляции в процессе
разработки небольших проектов и пакетов.
К сожалению, их не всегда хватает, когда нужно обеспечить достаточную согласованность и воспроизводимость. Несмотря на то что программное обеспечение,
написанное на Python, как правило, считается очень компактным, все равно есть
шанс столкнуться с проблемами, которые возникают в специфических системах
или даже конкретных распределениях таких систем (например, Ubuntu по сравнению с Gentoo). Это очень распространено в крупных и сложных проектах, особенно
если они зависят от скомпилированных расширений Python или внутренних компонентов хостинга операционной системы.
В подобных случаях изоляция на уровне системы отлично дополнит ваш рабочий процесс. При таком подходе делается попытка повторить и изолировать
полные операционные системы со всеми их библиотеками и важнейшими системными компонентами либо с классическими инструментами виртуализации системы (например, VMWare, Parallels и VirtualBox) или контейнерными системами
(например, Docker и Rocket). Некоторые из доступных решений, позволяющие
выполнить такую изоляцию, рассматриваются далее в этой главе.
venv — виртуальное окружение Python
Есть несколько способов изолировать среду выполнения Python. Самый простой
и очевидный, хоть и трудный в сопровождении, — вручную изменить значения переменных среды PATH и PYTHONPATH и/или переместить бинарные исходники Python
в другое, кастомизированное (настроеное специально для этого) место, где мы бы могли хранить зависимости проекта таким образом, что это изменило бы то, как Python
распознает доступные пакеты. К счастью, есть инструменты, которые могут помочь
в поддержании виртуальных окружений и установленных для них пакетов. В основном это virtualenv и venv. Они делают, по сути, то же самое, что мы будем делать вручную. Текущая стратегия зависит от конкретной реализации инструмента, но они, как
правило, более удобны в использовании и могут дать дополнительные преимущества.
44 Часть I
•
Перед началом работы
Создать новое виртуальное окружение можно с помощью следующей команды:
python3.7 -m venv ENV
Нужно заменить ENV на желаемое имя для нового окружения. Это создаст новый
каталог ENV в текущем рабочем каталоге. Внутри появится несколько новых каталогов:
bin/ — здесь хранятся новый исполняемый файл Python и скрипты/исполняе-
мые файлы других пакетов;
lib/ и include/ — эти каталоги содержат вспомогательные файлы библиотек
для нового Python в виртуальном окружении. Новые пакеты будут установлены
в ENV/Lib/pythonX.Y/site-packages/.
Созданное новое окружение нужно активировать в текущем сеансе оболочки
с помощью команды UNIX:
source ENV/bin/activate
Таким образом изменяется состояние текущих сессий оболочки благодаря воздействию на переменные окружения. Чтобы пользователь понимал, что он активировал виртуальное окружение, в подсказке появится приписка (ENV). Приведем
пример сеанса, который создает и активирует новое окружение:
$ python -m venv example
$ source example/bin/activate
(example) $ which python
/home/swistakm/example/bin/python
(example) $ deactivate
$ which python
/usr/local/bin/python
Важно отметить, что venv полностью зависит от состояния, которое хранится
в файловой системе. Она не дает каких-либо дополнительных возможностей и не
позволяет отслеживать, какие пакеты должны быть установлены. Виртуальные
окружения также не портируемы, и их нельзя перенести на другую машину. То есть
новое виртуальное окружение создается заново для каждого нового развертывания
приложения. Из-за этого пользователи venv часто хранят зависимости проекта
в файле requirements.txt (общепринятое имя), как показано в следующем коде:
# Строки после решетки (#) рассматриваются как комментарии
# Строгие имена версий лучше для воспроизводимости
eventlet==0.17.4
graceful==0.1.1
# Для проектов, которые хорошо протестированы с различными
# версиями зависимостей, принимаются относительные спецификаторы
falcon>=0.3.0, a3aec6c4b7c4
Step 2/5 : WORKDIR /app/
---> Running in 648a5bb2d9ab
Removing intermediate container 648a5bb2d9ab
---> a2489d084377
Step 3/5 : COPY static/ static/
---> 958a04fa5fa8
Step 4/5 : ENTRYPOINT ["python3.7", "-m", "http.server", "--bind", "80"]
---> Running in ec9f2a63c472
Removing intermediate container ec9f2a63c472
---> 991f46cf010a
Step 5/5 : CMD ["--directory", "static/"]
---> Running in 60322d5a9e9e
Removing intermediate container 60322d5a9e9e
---> 40c606a39f7a
Successfully built 40c606a39f7a
Successfully tagged webserver:latest
После создания вы можете просмотреть список доступных образов с помощью
команды:
$ docker images
REPOSITORY
TAG
webserver
latest
python
3.7-slim
IMAGE ID
40c606a39f7a
a3aec6c4b7c4
CREATED
2 minutes ago
2 weeks ago
SIZE
143MB
143MB
Поразительный размер образа контейнера
Вес простого образа Python в 143 Мбайт — это многовато, однако на
самом деле беспокоиться не о чем. Для краткости мы задействовали
базовый образ, который прост в применении. Есть и другие образы,
размер которых был специально ужат, но они, как правило, для более
опытных пользователей Docker. Кроме того, благодаря слоистой структуре образов Docker если вы применяете много контейнеров, то базовые
слои можно кэшировать и использовать повторно, так что в конечном
итоге вы не будете думать о размере.
54 Часть I
•
Перед началом работы
Когда образ будет собран и помечен, вы сможете запустить контейнер с по
мощью команды docker run . Наш контейнер является примером веб-сервиса,
поэтому мы должны дополнительно сказать Docker, что хотим открыть порты
контейнера, связав их локально:
docker run -it --rm -p 80:80 webserver
Вот объяснение некоторых аргументов предыдущей команды:
-it — это на самом деле две сопряженные опции: -i и -t. -i (от interactive) держит STDIN открытым, даже если процесс контейнера будет отсоединен, и -t
(например, tty) выделяет псевдо-TTY для контейнера. Короче говоря, эти две
опции позволяют увидеть живые логи от http.server и убедиться, что преры-
вание клавиатуры приведет к выходу из процесса. Он станет вести себя так же,
как если мы запустим Python прямо из командной строки;
--rm — дает Docker указание автоматически удалять контейнер при выходе;
-p 80:80 — дает Docker указание открыть порт 80, привязывая его к интерфейсу
хоста.
Настройка сложных сред
Хотя использовать Docker довольно легко в простых проектах, все может усложниться, как только вы начнете применять его сразу в нескольких проектах. Бывает
очень легко забыть о конкретных параметрах командной строки или о том, на
каких образах надо открывать те или иные порты. Все становится очень сложно
при наличии сервиса, который должен общаться с другими сервисами. Одиночные
контейнеры должны содержать только один запущенный процесс.
Это значит, что вам не нужно будет устанавливать дополнительные инструменты мониторинга процессов, такие как Supervisor или Circus, а вместо этого
следует создать несколько контейнеров, взаимодействующих друг с другом.
Каждый сервис может использовать свой образ, обеспечивать различные варианты
конфигурации и открывать порты, которые могут перекрывать или не перекрывать
друг друга.
Лучший инструмент, который можно использовать для простых и сложных
случаев, — это Compose. Он обычно распространяется с Docker, но в некоторых
дистрибутивах Linux (например, Ubuntu) его может и не быть по умолчанию и его
придется установить как отдельный пакет из репозитория пакетов. Compose — это
мощная утилита командной строки под именем docker-compose, которая позволяет
описывать мультиконтейнеры приложения с помощью синтаксиса YAML.
Compose ожидает, что в каталоге проекта находится специально названный
файл docker-compose.yml. Пример такого файла для нашего предыдущего проекта
может выглядеть следующим образом:
Глава 2.
Современные среды разработки на Python 55
version: '3'
services:
webserver:
# Инструктирует Compose собирать образ
# из локального каталога (.)
build: .
# Эквивалентно опции "-p" команды docker build
ports:
- "80:80"
# Эквивалентно опции "-t" команды docker build
tty: true
Если создать в проекте файл docker-compose.yml, то всю вашу прикладную
среду можно запустить и остановить двумя простыми командами:
docker-compose up;
docker-compose down.
Полезные рецепты Docker для Python
Docker и контейнеры — столь обширная тема, что невозможно обсудить ее всю
в одной главе этой книги. Compose позволит легко начать работать с Docker,
не имея особого понимания, как он функционирует внутри. Если вы новичок
в Docker, то вам стоит немного притормозить, взять документацию и вдумчиво
почитать ее, чтобы эффективно использовать Docker и преодолеть некоторые из
неизбежных сложностей.
Ниже приведены несколько простых советов и рецептов, позволяющих решить
большинство стандартных проблем, с которыми вы, вероятно, рано или поздно
столкнетесь.
Уменьшение размера контейнеров
Общая проблема новых пользователей Docker — размер образов контейнеров.
Действительно, контейнеры занимают много пространства по сравнению с простыми пакетами Python, но совсем немного по сравнению с размером образов для
виртуальных машин. По-прежнему очень часто разработчики размещают несколько
сервисов на одной виртуальной машине, но при использовании контейнеров у вас
обязательно должен быть отдельный образ для каждого сервиса. Это значит, что
при большом количестве сервисов расходы могут стать заметными.
Ограничить размер образов можно двумя дополнительными методами.
Использовать базовый образ, который разработан специально для этой цели.
Alpine Linux — пример компактного варианта Linux, специально приспособленного для создания очень маленьких и облегченных образов Docker. Базовый
56 Часть I
•
Перед началом работы
образ весит всего 5 Мбайт и включает элегантный менеджер пакетов, который
позволяет сохранить компактность образа.
Принять во внимание характеристики наложения файловой системы Docker.
Образы Docker состоят из слоев, каждый из которых включает разницу в корневой файловой системе между собой и предыдущим слоем. Когда слой создан,
размер образа уже не может быть уменьшен. Это значит, что если вам нужен
системный пакет в качестве зависимости сборки и его можно позже удалить из
образа, то вместо использования нескольких команд RUN будет лучше все делать
в одной команде RUN с командами оболочки.
Эти два метода можно проиллюстрировать следующим Dockerfile:
# Здесь мы используем alpine для иллюстрации
# управления пакетами, так как ему не хватает Python
# по умолчанию. Для проектов Python в целом
# лучше выбрать python:3.7-alpine.
FROM alpine:3.7
# Добавить пакет python3, так как у образа alpine его нет по умолчанию.
RUN apk add python3
# Запуск нескольких команд в одной команде RUN.
# Пространство может быть использовано после "apk del py3-pip", потому что
# слой изображения создается только после выполнения всей инструкции.
RUN apk add py3-pip && \
pip3 install django && \
apk del py3-pip
# (...)
Обращение к сервисам внутри среды Compose
Сложные приложения часто состоят из нескольких сервисов, которые взаимодействуют друг с другом. Compose позволяет легко определить такие приложения.
Ниже приведен пример файла docker-compose.yml, определяющего приложение
как комбинацию двух сервисов:
version: '3'
services:
webserver:
build: .
ports:
- "80:80"
tty: true
database:
image: postgres
restart: always
Глава 2.
Современные среды разработки на Python 57
Эта конфигурация определяет два сервиса:
webserver — это основной контейнер сервиса приложения, образы которого
взяты из локального Dockerfile;
database — это контейнер базы данных PostgreSQL с официального образа
Docker postgress.
Мы предполагаем, что сервис webserver хочет общаться с сервисом database
по сети. Для настройки таких связей необходимо знать IP-адрес сервиса или имя
хоста, чтобы их можно было использовать в качестве конфигурации приложения.
К счастью, Compose — инструмент, который был разработан именно для таких
сценариев, поэтому нам будет намного проще.
Всякий раз, когда вы запускаете среду с помощью команды docker-compose up,
Compose создаст выделенную сеть Docker по умолчанию и будет регистрировать
все сервисы в данной сети, используя их имена в качестве их имен хостов. Это значит, что сервис webserver может использовать database:5432 для связи с базой
данных (5432 — порт PostgreSQL по умолчанию), а также любые другие сервисы,
чтобы Compose имел возможность доступа к конечной точке HTTP сервиса вебсервера http://webserver:80.
Несмотря на то что имена хостов в Compose легко предсказуемы, нежелательно
жестко прописывать любые адреса в приложении или его конфигурации. Лучше
всего задавать их через переменные среды, которые приложение может считать
при запуске. В следующем примере показано, как определить произвольные переменные среды для каждого сервиса в файле docker-compose.yml:
version: '3'
services:
webserver:
build: .
ports:
- "80:80"
tty: true
environment:
- DATABASE_HOSTNAME=database
- DATABASE_PORT=5432
database:
image: postgres
restart: always
Обмен данными между несколькими средами Compose
Если вы создаете систему, состоящую из нескольких независимых сервисов и/или
приложений, то наверняка заходите сохранить свой код в нескольких независимых кодовых хранилищах (проектах). Файлы docker-compose.yml для каждого приложения
58 Часть I
•
Перед началом работы
Compose, как правило, хранятся в одном и том же хранилище кода, где и код приложения. Сеть по умолчанию, которую Compose создает для одного приложения,
изолирована от сетей других приложений. Итак, что вы можете сделать, если внезапно захотите, чтобы ваши независимые приложения общались друг с другом?
К счастью, такое намерение тоже легко реализуется с помощью Compose. Синтаксис файла docker-compose.yml позволяет определить именованную внешнюю сеть
Docker как сеть по умолчанию для всех сервисов, определенных в этой конфигурации. Ниже приведен пример конфигурации, которая определяет внешнюю сеть
с именем my-interservice-network:
version: '3'
networks:
default:
external:
name: my-interservice-network
services:
webserver:
build: .
ports:
- "80:80"
tty: true
environment:
- DATABASE_HOSTNAME=database
- DATABASE_PORT=5432
database:
image: postgres
restart: always
Такие внешние сети не управляются Compose, поэтому вам придется создать ее
вручную с помощью команды docker network create следующим образом:
docker network create my-interservice-network
Сделав это, вы сможете использовать созданную внешнюю сеть в других файлах
docker-compose.yml для всех приложений, которые должны иметь свои сервисы,
зарегистрированные в той же сети. Ниже приведен пример конфигурации для
других приложений, которые смогут взаимодействовать с сервисами database
и webserver через my-interservice-network, даже если они не определены в том же
файле docker-compose.yml:
version: '3'
networks:
default:
external:
name: my-interservice-network
В следующем разделе рассмотрим популярные инструменты повышения производительности.
Популярные инструменты повышения
производительности
«Инструмент повышения производительности» — немного расплывчатый термин.
С одной стороны, почти каждый пакет с открытым исходным кодом, появившийся
в Интернете, является своего рода усилителем производительности, так как предоставляет готовые к использованию решения для некоторых проблем, и поэтому
не нужно тратить время на изобретение велосипеда (в идеале). С другой стороны,
можно сказать, что весь Python заточен на производительность, и это тоже, несомненно, верно. Почти все в данном языке и его сообществе словно предназначено
для того, чтобы сделать разработку программного обеспечения как можно более
продуктивной.
Поскольку написание кода — веселый процесс, многие программисты в свободное время пытаются создать инструменты, которые сделают его еще проще и еще
веселее. Данный факт будет использоваться здесь в качестве основы для очень
субъективного и ненаучного определения инструмента повышения производительности. Мы будем так называть ту часть программного обеспечения, которая делает
разработку проще и веселее.
Инструменты повышения производительности сосредоточены главным образом на определенных элементах процесса разработки, таких как тестирование,
отладка и управление пакетами, и не являются основными частями продуктов,
которые разрабатываются. В ряде случаев эти инструменты могут даже не упоминаться нигде в кодовой базе проекта, несмотря на их ежедневное использование.
Ранее в этой главе мы уже обсуждали наиболее важные инструменты повышения производительности, pip и venv. В них есть пакеты для решения конкретных
проблем, например профилирования и тестирования, и им посвящены отдельные
60 Часть I
•
Перед началом работы
главы в данной книге. Текущий раздел рассказывает о других инструментах, которые действительно стоит упомянуть, но отдельная глава в этой книге под них
не отведена.
Пользовательские оболочки Python — ipython,
bpython, ptpython и т. д.
Python-программисты тратят много времени на интерактивные сессии интерпретатора. Это допустимо для тестирования небольших фрагментов кода, доступа к документации или даже отладки кода во время выполнения. Обычно интерактивная
сессия Python довольно проста и не предоставляет большое количество функций,
таких как автозаполнение или помощники по самоанализу кода. К счастью, оболочка Python по умолчанию легко поддается расширению и настройке.
Если вы часто работаете в интерактивной оболочке, то можете легко изменить
ее поведение. Python при запуске считывает переменную среды PYTHONSTARTUP
и ищет путь к пользовательским сценариям инициализаций. Некоторые дистрибутивы ОС, где Python является встроенным компонентом (например, Linux,
macOS), могут быть предварительно настроены на выполнение загрузочного
скрипта по умолчанию. Они обычно находятся в домашнем каталоге пользователя
под названием .pythonstartup. Эти сценарии часто задействуют модуль readline
(основанный на библиотеке Readline GNU) совместно с rlcompleter для того, чтобы обеспечить интерактивное автозаполнение и историю команд.
Если у вас нет скрипта запуска по умолчанию, то вы можете легко создать
собственный. Базовый скрипт для истории команд и автозаполнения выглядит
просто:
# Файл запуска python
import atexit
import os
try:
import readline
except ImportError:
print("Completion unavailable: readline module not available")
else:
import rlcompleter
# Автозаполнение
readline.parse_and_bind('tab: complete')
# Путь к файлу истории в домашнем каталоге пользователя
# Можно использовать собственный путь
history_file = os.path.join(os.environ['HOME'],
'.python_shell_history')
Создайте этот файл в вашем домашнем каталоге и назовите его .pythonstartup.
Затем добавьте переменную PYTHONSTARTUP в вашу среду, используя путь к этому
файлу.
Настройка переменной среды PYTHONSTARTUP
Если вы работаете под Linux или macOS, то самый простой способ — создать
скрипт запуска в домашней папке. Затем нужно связать его с переменной среды
PYTHONSTARTUP, которая задается в скрипте запуска оболочки. Например, в оболочках Bash и Korn используется файл .profile, в который вы можете вставить
такую строку:
export PYTHONSTARTUP=~/.pythonstartup
Если вы работаете под Windows, то легко установить новую переменную среды с правами администратора в настройках системы, а затем сохранить скрипт
в общем месте, а не задействовать заданное пользователем местоположение.
Писать скрипт для PYTHONSTARTUP — хорошее упражнение, но создавать хорошие пользовательские оболочки в одиночку бывает сложно и немногие разработчики находят на это время. К счастью, существует несколько реализаций
пользовательской оболочки Python, которые позволяют чрезвычайно упростить
интерактивные сессии в Python.
IPython
Оболочка IPython (ipython.readthedocs.io/en/stable/overview.html) имеет встроенную
расширенную командную оболочку Python. Среди функций, которые она предоставляет, наиболее интересны следующие:
динамический анализ объектов;
доступ к оболочке системы через командную строку;
прямая поддержка профилирования;
работа с отладочными средствами.
Теперь IPython является частью более крупного проекта под названием Jupyter,
предоставляющего интерактивные заметки/ноутбуки с кодом, который можно
в них же выполнять и который можно записать на разных языках.
62 Часть I
•
Перед началом работы
bpython
Оболочка bpython (bpython-interpreter.org) позиционирует себя как крутой интерфейс для интерпретатора Python. Вот некоторые из ее функций, перечисленных
на странице проекта:
подсветка синтаксиса;
построчное автозаполнение кода с отображением вариантов при вводе;
список передаваемых параметров для любой функции Python;
автоотступы;
поддержка Python 3.
ptpython
Оболочка ptpython (github.com/jonathanslenders/ptpython/) — иной подход к современным оболочкам Python. Интересно в данном проекте то, что реализация основных функций доступна в виде отдельного пакета, называемого prompt_toolkit
(от того же автора). Это позволяет легко создавать различные эстетически приятные интерактивные интерфейсы командной строки.
Эту оболочку часто по функциональности сравнивают с bpython, но основное отличие состоит в том, что она позволяет работать в режиме совместимости
с IPython и в ее синтаксисе есть дополнительные функции, такие как %pdb, %cpaste
и %profile.
Включение оболочек в собственные скрипты
и программы
Иногда возникает необходимость встроить цикл read-eval-print (REPL), похожий
на интерактивную сессию Python, в ваше ПО. Это облегчает экспериментирование
с кодом и его проверку «изнутри». Самый простой модуль, который позволяет эмулировать интерактивный интерпретатор Python, входит в стандартную библиотеку
и называется code.
Скрипт, который запускает интерактивную сессию, состоит из одного импорта
и вызова функции:
import code
code.interact()
Вы можете легко внести в него незначительные изменения, например сменить
значение ввода или добавить какие-нибудь сообщения, но для вещей покруче потребуется намного больше работы. Если вы хотите получить больше возможностей,
например подсветку кода, завершение выполнения или прямой доступ к системной
оболочке, то всегда лучше использовать что-то созданное кем-то ранее. К счастью,
Глава 2.
Современные среды разработки на Python 63
все интерактивные оболочки, упомянутые выше, можно встроить в вашу программу
так же легко, как модуль code.
Ниже приведены примеры того, как можно сослаться на все ранее упомянутые
оболочки внутри вашего кода:
# Пример для IPython
import IPython
IPython.embed()
# Пример для bpython
import bpython
bpython.embed()
# Пример для ptpython
from ptpython.repl import embed
embed(globals(), locals())
Интерактивные отладчики
Отладка кода — неотъемлемая часть процесса разработки ПО. Многие программисты проводят большую часть своей жизни, используя только логи и операторы
print в качестве основного средства отладки, но большинство профессиональных
разработчиков предпочитают задействовать какой-нибудь отладчик.
Python поставляется с уже встроенным интерактивным отладчиком под названием pdb (см. docs.python.org/3/library/pdb.html). Его можно вызвать из командной
строки в скрипте, чтобы Python запустил постмортем-отладку, если программа
завершается аварийно:
python -m pdb script.py
Постмортем-отладка хоть и полезна, но не универсальна. Она полезна, только
когда приложение завершается с исключением, если происходит ошибка. Часто
некорректный код просто ведет себя неправильно, но не завершается ошибкой.
В таких случаях можно установить пользовательские точки останова (брейкпойнты) в конкретных строках кода с помощью вот такой строки:
import pdb; pdb.set_trace()
Это заставит интерпретатор Python начать сеанс отладки во время выполнения
с этой строки.
Отладчик pdb очень полезен для отслеживания проблем и на первый взгляд
может показаться знакомым тем, кто работал с GNU Debugger (GDB). Поскольку
Python — динамический язык, сессия отладки pdb часто бывает похожа на обычную
сессию интерпретатора. Это значит, что разработчик не ограничивается отслеживанием выполнения кода, а может вызвать любой код и даже выполнить импорт
модуля.
64 Часть I
•
Перед началом работы
К сожалению, первый опыт работы с pdb может быть немного шокирующим изза наличия коротких команд отладчика, таких как h, b, s, n, j и r. Если сомневаетесь,
используйте команду help pdb, которую можно вводить во время сеанса отладчика, — она позволит получить немало дополнительной информации.
Сессия отладки в pdb выглядит очень просто и не дает дополнительных
функций, таких как автозаполнение или подсветка кода. К счастью, в PyPI есть
несколько пакетов, которые предоставляют такие функции из альтернативных
оболочек Python, уже упоминавшихся выше. Наиболее известные примеры:
ipdb — это отдельный пакет на основе ipython;
ptpdb — отдельный пакет на основе ptpython;
bpdb — идет в комплекте с bpython.
Резюме
Эта глава была целиком посвящена средам разработки для Python-программистов.
Мы обсудили важность изоляции окружающей среды для проектов Python.
Вы узнали о двух различных уровнях изоляции среды (уровень приложения и уровень системы), а также о нескольких инструментах, которые позволяют создавать
их воспроизводимыми и целостными. В конце главы мы привели обзор нескольких
инструментов, позволяющих улучшить экспериментирование с Python и отладку
программ.
Теперь, когда в вашем арсенале есть все эти инструменты, вы готовы изучить
следующие несколько глав, в которых мы обсудим особенности современного
синтаксиса Python.
В следующей главе мы рассмотрим практические рекомендации по написанию
кода на Python (идиомы языка) и приведем краткое описание отдельных элементов синтаксиса Python, которые могут быть новыми для «середнячков» Python, но
знакомы тем, кто работал с более старыми версиями языка.
Кроме того, мы рассмотрим внутренние реализации CPython и их вычислительные сложности в качестве обоснования для этих идиом.
Часть II
Ремесло Python
В этой части представлен обзор текущего развития Python с точки
зрения разработчика — того, кто зарабатывает на жизнь программированием и должен знать свои инструменты и язык вдоль и поперек
и даже изнутри. Вы узнаете о новейших элементах синтаксиса
Python и о том, как надежно и последовательно создавать качественное программное обеспечение.
3
Современные элементы
синтаксиса — ниже уровня
класса
Язык Python за последние несколько лет серьезно эволюционировал. С выхода
самой ранней версии и до текущего момента (версия 3.7) было введено много
усовершенствований, которые позволили сделать его более чистым и простым.
Основы Python не изменились, но предоставляемые им инструменты стали гораздо
более эргономичными.
По мере развития Python ваше ПО тоже должно эволюционировать. Если вы
уделите достаточно внимания тому, как пишется программа, то это очень поможет
ее эволюции. Многие программы в конечном итоге пришлось переписать с нуля
из-за деревянного синтаксиса, неясного API или нетрадиционных стандартов.
Использование новых возможностей языка программирования, которые позволяют
сделать код более выразительным и читабельным, повышает сопровождаемость
программного обеспечения и тем самым продлевает срок его службы.
В этой главе мы рассмотрим наиболее важные элементы современного синтаксиса Python, а также советы по их использованию. Мы также обсудим детали,
связанные с реализацией встроенных типов Python, которые по-разному влияют на
производительность кода, но при этом не станем чрезмерно углубляться в методы
оптимизации. Советы по повышению производительности кода, ускорению работы
или оптимизации использования памяти будут представлены позже, в главах 13 и 14.
В этой главе:
встроенные типы языка Python;
дополнительные типы данных и контейнеры;
расширенный синтаксис;
функционально-стилевые особенности Python;
аннотации функций и переменных;
другие элементы синтаксиса, о которых вы, возможно, не знаете.
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 67
Технические требования
Файлы с примерами кода для этой главы можно найти по ссылке github.com/
packtpublishing/expert-python-programming-third-edition/tree/master/chapter3.
Встроенные типы языка Python
В Python предусмотрен большой набор типов данных, как числовых, так и типовколлекций. В синтаксисе числовых типов нет ничего особенного. Конечно, существуют некоторые различия в определении литералов каждого типа и несколько
не очень хорошо известных деталей касательно операторов, но в целом синтаксис
числовых типов в Python мало чем может вас удивить. Однако все меняется, когда
речь заходит о коллекциях и строках. Несмотря на правило «каждому действию —
один способ», разработчик на Python часто располагает немалым количеством
вариантов. Некоторые шаблоны кода, кажущиеся новичкам интуитивно понятными и простыми, опытные программисты нередко считают «неканоническими»,
поскольку те либо неэффективны, либо слишком многословны.
«Пайтоноподобные» паттерны для решения часто встречающихся задач (многие
программисты называют их идиомами) часто могут показаться лишь эстетическим
украшением. Но это в корне неверно. Большинство идиом порождены тем, как
Python реализован внутри и как работают его встроенные конструкции и модули.
Зная больше о таких деталях, вы можете более глубоко и правильно понимать
принципы работы языка. К сожалению, в сообществе существуют некие мифы
и стереотипы о том, как работает Python. Только самостоятельно углубившись
в изучение языка, вы сможете понять, что из них правда, а что — ложь.
Посмотрим на строки и байты.
Строки и байты
Тема строк может привнести некоторую путаницу для программистов, которые
раньше работали только в Python 2. В Python 3 существует лишь один тип данных,
способный хранить текстовую информацию, — str, то есть просто строка. Это неизменяемая последовательность, хранящая кодовые точки Unicode. В этом состоит
основное отличие от Python 2, где тип str представлял собой строки байтов, которые
в настоящее время обрабатываются объектами byte (но не точно таким же образом).
Строки в Python являются последовательностями. Одного этого факта должно
быть достаточно, чтобы включить их обсуждение в раздел, посвященный другим
типам контейнеров. Но строки отличаются от других типов контейнеров одной
важной деталью. Они имеют весьма специфические ограничения на тип данных,
который могут хранить, — а именно, текст Unicode.
68 Часть II •
Ремесло Python
Тип byte (и его изменяемая альтернатива bytearray) отличается от str тем,
что принимает только байты в качестве значения последовательности, а байты
в Python являются целыми числами в диапазоне 0 ≤ х < 256. Поначалу это может
показаться сложным, поскольку при выводе на печать байты могут быть очень
похожи на строки:
>>> print(bytes([102, 111, 111]))
b'foo'
Типы byte и bytearray позволяют работать с сырыми двоичными данными,
которые не всегда могут быть текстовыми (например, аудио- и видеофайлы, изображения и сетевые пакеты). Истинная природа этих типов вскрывается, когда они
превращаются в другие типы последовательностей, такие как списки или кортежи:
>>> list(b'foo bar')
[102, 111, 111, 32, 98, 97, 114]
>>> tuple(b'foo bar')
(102, 111, 111, 32, 98, 97, 114)
В Python 3 велось немало споров о нарушении обратной совместимости
для строковых литералов и о том, как язык обрабатывает Unicode. Начиная
с Python 3.0, каждый строковый литерал без префикса обрабатывается как
Unicode. Литералы, заключенные в одинарные кавычки ('), двойные кавычки (")
или группы из трех кавычек (одинарных или двойных) без префикса, представляют
тип данных str:
>>> type("some string")
В Python 2 литералы Unicode требуют префикса (например, u"строка"). Этот
префикс по-прежнему разрешен для сохранения обратной совместимости (начиная
с Python 3.3), но не имеет никакого синтаксического значения в Python 3.
Байтовые литералы уже были представлены в некоторых предыдущих примерах, но их синтаксис будет показан для сохранения целостности повествования.
Байтовые литералы заключены в одиночные, двойные или тройные кавычки, но
им должен предшествовать префикс b или B:
>>> type(b"some bytes")
Обратите внимание: в Python нет синтаксиса для литералов bytearray. Если
вы хотите создать значение bytearray, то вам нужно использовать литерал bytes
и конструктор типа bytearray():
>>> bytearray(b'some bytes')
bytearray(b'some bytes')
Важно помнить, что в строках Unicode содержится абстрактный текст, который
не зависит от представления байтов. Это делает их непригодными для сохранения
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 69
на диске или отправки по сети без перекодирования в двоичные данные. Есть два
способа кодирования строки объектов в последовательность байтов.
С помощью метода str.encode(encoding, errors), который кодирует строку,
используя имеющийся кодировщик. Кодировщик задается через аргумент
encoding, по умолчанию равный UTF-8. Второй аргумент задает схему обработки ошибок. Может принимать значения 'strict' (по умолчанию), 'ignore',
'replace', 'xmlcharrefreplace' или любой другой зарегистрированный обработчик (см. документацию модуля codecs).
С помощью конструктора bytes(source, encoding, errors), который создает новую последовательность байтов. Когда source имеет тип str, аргумент encoding
является обязательным и не имеет значения по умолчанию. Аргументы encoding
и errors такие же, как и для метода str.encode().
Двоичные данные, представленные типом bytes, могут быть преобразованы
в строку аналогичным образом.
С помощью метода bytes.decode(encoding, errors), который декодирует байты
с использованием имеющегося кодировщика. Аргументы этого метода имеют
тот же смысл и значение по умолчанию, что и у str.encode().
С помощью конструктора str(source, encoding, errors), который создает новый экземпляр строки. Как и в конструкторе bytes(), кодирующий аргумент
encoding является обязательным и не имеет значения по умолчанию, если в качестве source используется последовательность байтов.
Байты или строка байтов: путаница в именах
Из-за изменений, внесенных в Python 3, некоторые программисты считают экземпляры bytes байтовыми строками. В основном это связано
с историческими причинами: bytes в Python 3 является типом, наиболее
близким к типу str из Python 2 (но это не одно и то же). Тем не менее
экземпляр bytes представляет собой последовательность байтов и не
обязательно несет в себе текстовые данные. Чтобы избежать путаницы,
рекомендуется всегда ссылаться на них как на байты или последовательность байтов, несмотря на их сходство со строками. Понятие строк
в Python 3 зарезервировано для текстовых данных, и это всегда тип str.
Рассмотрим подробности реализации строк и байтов.
Детали реализации
Строки в Python являются неизменяемыми. Это верно и для байтовых последовательностей. Данный факт очень важен, поскольку имеет как преимущества, так
и недостатки. Вдобавок он влияет и на то, как именно эффективно обрабатывать
70 Часть II •
Ремесло Python
строки в Python. Благодаря своей неизменности строки могут быть использованы
в качестве ключей в словарях или в качестве элемента множества, поскольку после
инициализации они не меняют значения. С другой стороны, всякий раз, когда вам
требуется измененная строка (даже с крошечной модификацией), придется создавать новый экземпляр. К счастью, у bytearray, изменяемого варианта bytes, такой
проблемы нет. Массивы байтов могут быть изменены «на месте» (без создания
новых объектов) через присвоение элементов, а могут изменяться динамически,
так же как списки — с помощью склеивания, вставок и т. д.
Поговорим подробнее о конкатенации.
Конкатенация строк
Неизменяемость строк в Python создает некоторые проблемы, когда нужно объединять несколько экземпляров строк. Ранее мы уже отмечали, что конкатенация
неизменяемых последовательностей приводит к созданию нового объекта-последовательности. Представим, что новая строка строится путем многократной
конкатенации нескольких строк, как показано ниже:
substrings = ["These ", "are ", "strings ", "to ", "concatenate."]
s = ""
for substring in substrings:
s += substring
Это ведет к квадратичным затратам времени выполнения в зависимости от общей длины строки. Другими словами, крайне неэффективно. Для обработки таких
ситуаций предусмотрен метод str.join(). Он принимает в качестве аргумента
итерируемые величины или строки и возвращает объединенные строки. Вызов
метода join() на строках можно выполнить двумя способами:
# Используем пустой литерал
s = "".join(substrings)
# Используем «неограниченный» вызов метода
str.join("", substrings)
Первая форма вызова join() является наиболее распространенной идиомой.
Строка, которая вызывает этот метод, будет использоваться в качестве разделителя
между подстроками. Рассмотрим следующий пример:
>>> ','.join(['some', 'comma', 'separated', 'values'])
'some,comma,separated,values'
Стоит помнить: преимущества по быстродействию (особенно для больших
списков) недостаточно, чтобы метод join() стал панацеей в любой ситуации,
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 71
когда нужно объединить две строки. Несмотря на широкое признание, эта идиома
не улучшает читабельность кода. А читабельность очень важна! Кроме того, бывают ситуации, когда метод join() не будет работать так же хорошо, как обычная
конкатенация с оператором +. Вот несколько примеров.
Если подстрок очень мало и они не содержатся в итерируемой переменной
(существующий список или кортеж строк), то в некоторых случаях затраты
на создание новой последовательности только для выполнения конкатенации
могут свести на нет преимущество join().
При конкатенации коротких литералов благодаря некоторой оптимизации ин-
терпретатора, например сворачиванию в константы в CPython (см. следующий
подпункт), часть сложных литералов (не только строки), таких как 'a' + 'b' +
+ 'c', может быть переведена в более короткую форму во время компиляции
(здесь 'abc'). Конечно, это разрешено только для относительно коротких констант (литералов).
В конечном счете если количество строк для конкатенации известно заранее,
то лучшая читабельность обеспечивается надлежащим форматированием строки
с помощью метода str.format(), оператора %, или форматирования f-строк. В разделах кода, где производительность не столь важна или выигрыш от оптимизации
конкатенации очень мал, форматирование строк — лучшая альтернатива конкатенации.
Сворачивание, локальный оптимизатор и оптимизатор AST. В CPython существуют различные методы оптимизации кода. Первая оптимизация выполняется,
как только исходный код преобразуется в форму абстрактного синтаксического
дерева перед компиляцией в байт-код. CPython может распознавать определенные
закономерности в абстрактном синтаксическом дереве и вносить в него прямые
изменения. Другой вид оптимизации — локальная. В ней реализуется ряд общих
оптимизаций непосредственно в байт-коде Python. Мы уже упоминали ранее, что
сворачивание в константы — одно из таких свойств. Оно позволяет интерпретатору
преобразовывать сложные буквенные выражения (такие как "one" + " " + "thing", "
" * 79 или 60 * 1000) в один литерал, который не требует дополнительных операций
(конкатенации или умножения) во время выполнения.
До Python 3.5 все сворачивание в константы выполнялось в CPython только
локальным оптимизатором. В случае со строками полученные константы были
ограничены по длине с помощью закодированного значения. В Python 3.5 это значение было равно 20. В Python 3.7 большинство оптимизаций сворачивания обрабатывается на уровне абстрактного синтаксического дерева. Но это скорее забавные
факты, а не полезные сведения. Информацию о других интересных оптимизациях,
72 Часть II •
Ремесло Python
выполняемых AST и локальным оптимизатором, можно найти в файлах исходного
кода Python/ast_opt.c и Python/peephole.c.
Рассмотрим форматирование f-строками.
Форматирование f-строками
F-строки — одна из самых любимых новых функций Python, которая появилась
в Python 3.6. Это также одна из самых противоречивых особенностей данной версии. F-строки, или форматированные строковые литералы, введенные в документе
PEP 498, — новый инструмент форматирования строк в Python. До Python 3.6
существовало два основных способа форматирования строк:
с помощью %, например "Some string with included % value" % "other";
с помощью метода str.format(), например "Some string with included {other}
value".format(other="other").
Форматированные строковые литералы обозначаются префиксом f , и их
синтаксис наиболее близок к методу str.format(), поскольку они используют
подобную разметку для обозначения замены полей в тексте, который должен
быть отформатирован. В методе str.format() замена текста относится к аргументам и именованным аргументам, передаваемым в метод форматирования. Вы
можете использовать как анонимные замены, которые будут превращаться в последовательные индексы аргументов, так и явные индексы аргументов или имена
ключевых слов.
Это значит, что одна и та же строка может быть отформатирована по-разному:
>>> from sys import version_info
>>> "This is Python {}.{}".format(*version_info)
'This is Python 3.7'
>>> "This is Python {0}.{1}".format(*version_info)
'This is Python 3.7'
>>> "This is Python {major}.{minor}".format(major=version_info.major,
minor=version_info.minor)
'This is Python 3.7'
Особенными f-строки делает тот факт, что заменяемые поля могут бытьлюбым
выражением Python, которое вычисляется во время выполнения. Внутри строк
у вас есть доступ к любой переменной, доступной в том же пространстве имен, что
и форматированный литерал. С помощью f-строк предшествующие примеры можно
записать следующим образом:
>>> from sys import version_info
>>> f"This is Python {version_info.major}.{version_info.minor}"
'This is Python 3.7'
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 73
Возможность использовать выражения в заменяемых полях позволяет упростить
форматирование кода. Можно применять те же спецификаторы форматирования
(заполнение пробелов, выравнивание, разметку и т. д.), как и в методе str.format().
Синтаксис выглядит следующим образом:
f"{replacement_field_expression:format_specifier}"
Ниже приведен простой пример кода, который печатает первые десять степеней числа 10, используя f-строку, и выравнивает результаты, используя строковое
форматирование с заполнением пробелами:
>>> for x in range(10):
...
print(f"10^{x} == {10**x:10d}")
...
10^0 ==
1
10^1 ==
10
10^2 ==
100
10^3 ==
1000
10^4 ==
10000
10^5 ==
100000
10^6 ==
1000000
10^7 ==
10000000
10^8 == 100000000
10^9 == 1000000000
Полная спецификация форматирования строк в Python — это почти еще один
язык программирования внутри Python. Лучшим справочником по форматированию
будет официальная документация, которую можно найти по адресу docs.python.org/3/
library/string.html. Еще один полезный интернет-ресурс по данной теме: pyformat.info.
Он содержит наиболее важные элементы этой спецификации, сопровожденные
примерами.
В следующем подразделе мы рассмотрим коллекции языка.
Контейнеры
В Python предусмотрен неплохой выбор встроенных контейнеров данных, позволяющих эффективно решать многие проблемы, если подойти к выбору с умом. Типы,
которые вы уже должны знать, имеют специальные литералы:
списки;
кортежи;
словари;
множества.
Разумеется, Python не ограничивается этими четырьмя контейнерами. Ассортимент можно серьезно расширить с помощью стандартной библиотеки. Часто решения
74 Часть II •
Ремесло Python
некоторых проблем сводятся к правильному выбору структуры данных для их
хранения. Эта часть книги призвана облегчить принятие подобных решений и помочь лучше понять возможные варианты.
Списки и кортежи
Два основных типа коллекций в Python — списки и кортежи, и оба они представляют
собой последовательность объектов. Основное различие между ними должно быть
очевидно для любого, кто изучает Python чуть больше пары часов: списки являются
динамическими и их размер может изменяться, в то время как кортежи неизменяемы.
Списки и кортежи в Python претерпели немало оптимизаций, которые позволяют ускорить выделение/очистку памяти для небольших объектов. Кроме
того, строки и кортежи рекомендуются для типов данных структур, где позиция
элемента — информация, полезная сама по себе. Например, кортежи отлично подходят для хранения пар координат (х, у). Детали реализации кортежей интереса
не представляют. Важно в рамках данной главы только то, что tuple является неизменяемым и, следовательно, хешируемым. Подробное объяснение будет приведено
в подразделе, посвященном словарям. Динамический аналог кортежей, а именно
списки, для нас интереснее. Ниже мы обсудим их функционирование и то, как
эффективно работать с ними.
Детали реализации. Многие программисты часто путают тип list Python со
связанными списками, которые обычно встречаются в стандартных библиотеках
других языков, таких как C, C++ или Java. На самом деле списки CPython — вообще не списки. В CPython списки реализованы в виде массивов переменной
длины. Это работает и для других реализаций, таких как Jython и IronPython,
хотя подобные детали не всегда бывают задокументированы. Причины такой
путаницы понятны: этот тип данных называется списком и имеет интерфейс,
типичный для любой имплементации структуры данных «связный список».
Почему это важно и что это значит? Списки — одна из наиболее популярных
структур данных, и то, как они используются, в значительной степени влияет на
производительность приложения. CPython — наиболее популярная и используемая
реализация, поэтому невероятно важно знать, как она устроена.
Списки в Python представляют собой непрерывные массивы ссылок на другие
объекты. Указатель на данный массив и значение длины хранятся в головной
структуре списка. Это значит, что каждый раз, когда в список добавляется или
из списка удаляется элемент, массив ссылок переопределяется (с точки зрения
памяти). К счастью, в Python эти массивы создаются с экспоненциальным избыточным выделением, вследствие чего не каждая операция требует фактического
изменения размера базового массива. Поэтому затраты на выполнение мелких
изменений на самом деле не столь велики. К сожалению, другие операции, ко-
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 75
торые считаются быстрыми в обычных связанных списках, в Python имеют относительно высокую вычислительную сложность:
вставка элемента в произвольном месте с использованием метода list.insert
имеет сложность O(n);
удаление элемента с помощью list.delete или с помощью оператора har имеет
сложность O(n).
Извлечение или установка элемента по индексу — это операция, сложность
которой не зависит от размера списка и всегда равна O(1).
Пусть n — длина списка. Вычислительная сложность для большинства операций
со списками приведена в табл. 3.1.
Таблица 3.1
Операция
Сложность
Копия
O(n)
Присоединение
O(1)
Вставка
O(n)
Извлечение значения элемента
O(1)
Установка значения элемента
O(1)
Удаление элемента
O(n)
Итерация
O(n)
Извлечение среза длины k
O(k)
Удаление среза
O(n)
Установка среза длины k
O(k+n)
Расширение
O(n)
Умножение на k
O(nk)
Проверка существования (элемента в списке)
O(n)
min()/max()
O(n)
Возврат длины
O(1)
Если необходим реальный связанный или дважды связанный список, то в Python
есть тип deque во встроенном модуле collections. Эта структура данных позволяет
добавлять и удалять элементы с каждой стороны со сложностью O(1). Это обобщение стеков и очередей, которое должно нормально работать в задачах, где требуется
дважды связанный список.
76 Часть II •
Ремесло Python
Списковое включение. Как вы, наверное, знаете, написание подобного кода
может быть утомительным:
>>>
>>>
...
...
...
>>>
[0,
evens = []
for i in range(10):
if i % 2 == 0:
evens.append(i)
evens
2, 4, 6, 8]
Это может работать на C, однако на Python замедляет работу по следующим
причинам:
заставляет интерпретатор работать каждый цикл, чтобы определить, какую
часть последовательности нужно изменить;
заставляет вводить отдельный счетчик, который отслеживает, какой элемент
обрабатывается;
нужно выполнять дополнительный просмотр на каждой итерации, поскольку
append() является методом списков.
Списковое включение лучше всего подходит для такого рода ситуаций. Оно позволяет определить список с помощью одной строки кода:
>>> [i for i in range(10) if i % 2 == 0]
[0, 2, 4, 6, 8]
Запись такого вида намного короче и включает в себя меньше элементов. Для
большой программы это значит меньше ошибок и код, который легче читать. Именно поэтому многие опытные программисты на Python будут считать такие формы
более удобочитаемыми.
Списковое включение и изменение размера массива
Среди некоторых Python-программистов бытует такой миф: списковое
включение позволяет обойти тот факт, что внутренний массив, представляющий объект списка, меняет свой размер после каждого изменения.
Некоторые говорят, что памяти для массива выделяется ровно столько,
сколько нужно. К сожалению, это не так.
Интерпретатор, оценивая включение, не может знать, насколько велик
будет окончательный контейнер, и не может заранее выделить нужный
объем памяти. Поэтому внутренний массив определяется по той же
схеме, которая была бы при использовании цикла for. Тем не менее
во многих случаях создать список с помощью включения будет чище
и быстрее, чем с применением обычных циклов.
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 77
Другие идиомы. Другой типичный пример идиом Python — использование
встроенной функции enumerate(). Она предоставляет удобный способ получить
индекс, когда последовательность итерируется внутри цикла. Рассмотрим следующий фрагмент кода в качестве примера отслеживания индекса элемента без
функции enumerate():
>>> i =
>>> for
...
...
...
0 one
1 two
2 three
0
element in ['one', 'two', 'three']:
print(i, element)
i += 1
Этот фрагмент можно заменить следующим кодом, который будет короче и, безусловно, чище:
>>> for i, element in enumerate(['one', 'two', 'three']):
...
print(i, element)
...
0 one
1 two
2 three
Если необходимо объединить элементы нескольких списков (или любых других
итерируемых типов) «один за одним», то можно использовать встроенную функцию zip(). Ниже приведен стандартный код для равномерного прохода по двум
итерируемым объектам одного размера:
>>>
...
...
(1,
(2,
(3,
for items in zip([1, 2, 3], [4, 5, 6]):
print(items)
4)
5)
6)
Обратите внимание, что результаты функции zip() можно отменить путем вызова другой функции zip():
>>> for items in zip(*zip([1, 2, 3], [4, 5, 6])):
...
print(items)
...
(1, 2, 3)
(4, 5, 6)
О функции zip() важно помнить следующее: она ожидает, что вводимые итерируемые объекты будут одинакового размера. Если вы введете аргументы разной
78 Часть II •
Ремесло Python
длины, то вывод будет сформирован для короткого аргумента, как показано в следующем примере:
>>> for items in zip([1, 2, 3, 4], [1, 2]):
...
print(items)
...
(1, 1)
(2, 2)
Еще один популярный элемент синтаксиса — последовательная распаковка.
Она не ограничивается списками и кортежами и будет работать с любым типом
последовательности (даже со строками и последовательностями байтов). Она позволяет распаковывать последовательность элементов в другой набор переменных,
до тех пор пока с левой стороны от оператора присваивания есть столько же переменных, сколько элементов в последовательности. Если вы внимательно читали
фрагменты кода, то, возможно, уже отметили эту идиому, когда мы обсуждали
функцию enumerate().
Ниже приведен специальный пример этого синтаксического элемента:
>>> first, second, third = "foo", "bar", 100
>>> first
'foo'
>>> second
'bar'
>>> third
100
Кроме того, распаковка позволяет хранить несколько элементов в одной
переменной с помощью выражений со звездочкой, если такое выражение может
быть однозначно истолковано. Распаковка также может выполняться с вложенными последовательностями. Это может быть полезно, особенно при переборе
некоторых сложных структур данных, составленных из нескольких последовательностей. Ниже приведены примеры более сложной распаковки последовательностей:
>>>
>>>
>>>
0
>>>
1
>>>
[2,
>>>
>>>
>>>
0
>>>
# Захват конца последовательности
first, second, *rest = 0, 1, 2, 3
first
second
rest
3]
# Захват середины последовательности
first, *inner, last = 0, 1, 2, 3
first
inner
Глава 3.
[1,
>>>
3
>>>
>>>
>>>
(1,
Современные элементы синтаксиса — ниже уровня класса 79
2]
last
# Распаковка иерархии
(a, b), (c, d) = (1, 2), (3, 4)
a, b, c, d
2, 3, 4)
Словари
Словари — одна из наиболее универсальных структур данных в Python. Тип dict
позволяет сопоставить набор уникальных ключей со значениями следующим образом:
{
1: ' one',
2: ' two',
3: ' three',
}
По идее, вы уже должны знать словарные литералы — в них нет ничего сложного. Python позволяет программистам также создать новый словарь, используя
выражения генерации списков. Ниже приведен простой пример кода, который
возводит числа в диапазоне от 0 до 99 в их квадраты:
squares = {number: number**2 for number in range(100)}
Важно то, что вся мощь генерации списков доступна и в словарях. Поэтому
они часто бывают более эффективны и делают код короче и чище. Для более
сложного кода, в котором для создания словаря требуется много операторов if
или вызовов функций, подойдет простой цикл for, особенно если это улучшает
читабельность.
Программистам, которым Python 3 в новинку, следует знать важную информацию об итерировании словарных элементов. Методы словарей keys(), values()
и items() больше не возвращают списков. Кроме того, их аналоги, iterkeys(),
itervalues() и iteritems() , возвращающие итераторы, в Python 3 вообще отсутствуют. Теперь методы keys(), values() и items() возвращают специальные
объекты-представления:
keys() — возвращает объект dict_keys, в котором перечислены все ключи сло-
варя;
values() — возвращает объект dict_values, в котором перечислены все значения
словаря;
items() — возвращает объект dict_items, в котором перечислены пары «ключ —
значение» в виде кортежей.
80 Часть II •
Ремесло Python
Объект-представление позволяет просматривать контент словаря динамическим образом, и каждый раз, когда в словарь вносятся изменения, они появляются
и в данном объекте:
>>> person = {'name': 'John', 'last_name': 'Doe'}
>>> items = person.items()
>>> person['age'] = 42
>>> items
dict_items([('name', 'John'), ('last_name', 'Doe'), ('age', 42)])
Объекты-представления ведут себя как в старые времена вели себя списки,
возвращаемые методом iter(). Эти объекты не хранят все значения в памяти (как,
например, списки), но позволяют узнать их длину (с помощью функции len())
и проверять наличие (ключевое слово in). И еще они, конечно, итерируемые.
Еще одна важная особенность объектов-представлений заключается в том, что
результат методов keys() и values() дает одинаковый порядок ключей и значений.
В Python 2 нельзя изменять содержимое словаря между вызовами этих методов,
если вы хотите получить одинаковый порядок извлекаемых ключей и значений.
Теперь объекты dict_keys и dict_values динамические, так что даже если содержание словаря изменяется между вызовами методов, то порядок итерации будет
подстроен соответствующим образом.
Подробности реализации. В CPython в качестве базовой структуры данных для
словарей используются хеш-таблицы с псевдослучайным зондированием. Это выглядит как излишние дебри реализации, но в ближайшем будущем здесь вряд ли
что-то изменится, и это довольно интересный факт для программиста на Python.
Из-за данной особенности реализации в качестве ключей в словарях могут использоваться только хешируемые (hashable) объекты. Таковым является объект,
имеющий значение хеш-функции, которое не меняется в течение срока его существования, и его можно сравнивать с другими объектами. Каждый встроенный
неизменяемый тип Python — хешируемый. Изменяемые типы, такие как списки,
словари и множества, не являются таковыми и поэтому не могут быть использованы в качестве ключей словаря. Протокол, определяющий хешируемость типа,
состоит из двух методов:
__hash__ — возвращает хеш-значение (целочисленное), которое необходимо для
внутренней реализации типа dict. Для объектов — экземпляров пользовательских классов является производным от id();
__eq__ — проверяет два объекта на предмет одинаковости их значений. Все
объекты, которые являются экземплярами пользовательских классов, по умолчанию не равны, если не сравниваются сами с собой.
Два проверяемых на равенство объекта должны иметь одинаковое значение
хеш-функции. При этом обратное утверждение не обязательно верно. То есть
возможны конфликты хеша: два объекта с одинаковым хешем не всегда оказы-
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 81
ваются одинаковыми. Это допускается, и любая реализация Python должна позволять исправлять такие конфликты. В CPython возможно открытое решение
этой проблемы. Вероятность конфликта сильно влияет на производительность
словаря, и если она высока, то словарь не получает бонусов к производительности
от внутренней оптимизации.
Хотя три основные операции, такие как добавление, получение и удаление элемента, имеют среднюю сложность O(1), их амортизированная сложность в худшем
случае будет намного выше. Она сводится к О(n), где n — текущий размер словаря.
Кроме того, если в качестве ключей словаря служат пользовательские объекты
класса и они хешируются неправильно (с высоким риском коллизий), то это окажет
огромное негативное влияние на производительность словаря. Временные сложности CPython для словарей приведены в табл. 3.2.
Таблица 3.2
Операция
Средняя сложность
Амортизированная сложность в худшем
случае
Получение элемента
O(1)
O(n)
Задание элемента
O(1)
O(n)
Удаление
O(1)
O(n)
Копирование
O(n)
O(n)
Перебор
O(n)
O(n)
Важно также знать, что число n в худшем случае сложности для копирования
и перебора словаря — это максимальный размер, которого словарь когда-либо достигал, а не текущий размер. Иными словами, перебор словаря, когда-то огромного,
а затем значительно сокращенного, будет выполняться невероятно долго. В отдельных случаях даже разумнее создать новый объект словаря, который будет гораздо
меньше и обрабатываться будет быстрее.
Слабые стороны и альтернативные решения. В течение длительного времени
одна из самых распространенных ошибок в словарях заключалась в том, что в них
сохранялся порядок элементов, в которых добавлялись новые ключи. В Python 3.6
ситуация немного изменилась, а в Python 3.7 проблема была решена на уровне
спецификации языка.
Но прежде, чем углубляться в Python 3.6 и более поздние версии, нам нужно
слегка уйти от темы и исследовать проблему так, как если бы мы все еще застряли
в прошлом, когда Python 3.6 еще не существовало. Раньше была возможна ситуация, когда последовательные ключи словаря имели последовательные хеши. В течение очень долгого времени это была единственная ситуация, в которой элементы
словаря перебирались в том же порядке, в каком добавлялись в словарь. Самый
82 Часть II •
Ремесло Python
простой способ представить это — с помощью целых чисел, поскольку их хеши
совпадают с их значениями:
>>> {number: None for number in range(5)}.keys()
dict_keys([0, 1, 2, 3, 4])
Использование типов данных с другими правилами хеширования может показать, что порядок не сохраняется. Ниже представлен пример, выполненный
в CPython 3.5:
>>> {str(number): None for number in range(5)}.keys()
dict_keys(['1', '2', '4', '0', '3'])
>>> {str(number): None for number in reversed(range(5))}.keys()
dict_keys(['2', '3', '1', '4', '0'])
Как было показано в предыдущем коде, в CPython 3.5 (а также в более ранних
версиях) полученный порядок зависит и от хеширования объекта, и от порядка,
в котором добавлялись элементы. На это не стоит полагаться, поскольку данная
ситуация может меняться в различных реализациях Python.
А что насчет Python 3.6 и более поздних версий? Начиная с Python 3.6, интерпретатор CPython перешел на новое компактное представление словарей, которое
занимает меньше памяти, а также как побочный эффект этой новой реализации
сохраняет порядок. И если в Python 3.6 сохранение порядка было лишь побочным
эффектом реализации, то в Python 3.7 эта функция официально объявлена в специ
фикации языка Python. Таким образом, начиная с Python 3.7, наконец можно полагаться на порядок вставки элементов словарей.
Параллельно с реализацией словарей CPython в Python 3.6 появилось еще одно
изменение в синтаксисе, связанное с порядком элементов в словарях. Как определено в документе PEP 468 (см. https://www.python.org/dev/peps/pep-0468/), порядок
именованных аргументов, полученных с помощью синтаксиса **kwargs, должен
быть таким же, как в вызове функции. Данное поведение хорошо видно на следующем примере:
>>> def fun(**kwargs):
...
print(kwargs)
...
>>> fun(a=1, b=2, c=3)
{'a': 1, 'b': 2, 'c': 3}
>>> fun(c=1, b=2, a=3)
{'c': 1, 'b': 2, 'a': 3}
Однако эти изменения могут эффективно использоваться только в новейших
версиях Python. Что нужно делать, если у вас есть библиотека, которая должна
работать еще и на старых версиях Python, и в некоторых частях кода требуется сохранение порядка в словарях? Самый лучший вариант — применить тип, который
явно сохраняет порядок элементов.
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 83
К счастью, в стандартной библиотеке Python в модуле collections есть упорядоченный словарь OrderedDict. Конструктор этого типа принимает в качестве
аргумента инициализации итерируемый тип. Каждый элемент этого аргумента
должен быть парой «ключ — значение», как показано в следующем примере:
>>> from collections import OrderedDict
>>> OrderedDict((str(number), None) for number in range(5)).keys()
odict_keys(['0', '1', '2', '3', '4'])
У этого типа также есть дополнительные функции, например извлечение элементов с обоих концов с использованием метода popitem() или перемещение указанного
элемента на один из концов с помощью метода move_to_end(). Полный справочник
по данной коллекции можно найти в документации по Python (см. docs.python.org/3/
library/collections.html). Даже если вы планируете работать только в Python версии 3.7
или более новых версиях, что гарантирует сохранение порядка вставки элементов,
тип OrderedDict все равно будет полезен. Он позволяет явным образом показать,
что вам нужно именно сохранение порядка. Если вы определяете OrderedDict
вместо простого dict, то становится очевидным, что в данном конкретном случае
порядок вставки элементов имеет важное значение.
Последнее интересное замечание: в очень старых кодовых базах можно найти
dict как примитивную реализацию множества, которая обеспечивает уникальность
элементов. Несмотря на то что это работает правильно, стоит избегать подобных
костылей, если вы не работаете в Python старше 2.3. Такое использование словарей
расточительно с точки зрения затрат ресурсов. В Python есть встроенный тип set,
предназначенный именно для этой цели. На самом деле он очень похожим образом
реализуется в словари CPython, но в нем есть некоторые дополнительные функции,
а также кое-какие оптимизации.
Множества
Множества — очень надежная структура данных, в основном полезная в ситуациях, когда порядок элементов не так важен, как их уникальность. Кроме того, они
важны, когда необходимо продуктивно проверить эффективность, если элемент содержится в коллекции. Множества в Python являются обобщением математических
множеств и представлены в виде встроенных типов в двух вариантах:
set() — это изменяемое неупорядоченное конечное множество уникальных
жество уникальных неизменяемых (хешируемых) объектов.
Неизменяемость объектов frozenset() позволяет использовать их в качестве
ключей словаря, а также других объектов set() и frozenset(). Изменяемый объ-
84 Часть II •
Ремесло Python
ект set() нельзя использовать подобным образом. Попытка сделать это приведет
к выбрасыванию исключения TypeError, как в следующем примере:
>>> set([set([1,2,3]), set([2,3,4])])
Traceback (most recent call last):
File "", line 1, in
TypeError: unhashable type: 'set'
С другой стороны, инициализация множеств в следующем примере будет совершенно правильна и не приведет к выбрасыванию исключений:
>>> set([frozenset([1,2,3]), frozenset([2,3,4])])
{frozenset({1, 2, 3}), frozenset({2, 3, 4})}
>>> frozenset([frozenset([1,2,3]), frozenset([2,3,4])])
frozenset({frozenset({1, 2, 3}), frozenset({2, 3, 4})})
Изменяемые множества можно создавать тремя способами с помощью:
функции set(), которая принимает в качестве аргумента итерируемый объект,
например set([0, 1, 2]);
генерации {element for element in range(3)};
литералов: {1, 2, 3}.
Обратите внимание: использование литералов и генерации множеств требует
особой осторожности, так как методика очень похожа на работу со словарями.
Кроме того, в Python не предусмотрено литералов для пустого множества, поскольку пустые фигурные скобки {} зарезервированы для пустых литералов словаря.
Детали реализации. Множества в CPython очень похожи на словари. По сути
дела, они реализованы как словари с фиктивными значениями, где только ключи
являются фактическими элементами коллекции. У множеств также не хватает
значений в отображении, которые можно было бы оптимизировать.
Благодаря этому множества позволяют очень быстро добавлять, удалять и проверять существование элемента со средней сложностью O(1). Тем не менее, поскольку реализация множеств в CPython опирается на аналогичную структуру
хеш-таблицы, в худшем случае сложность для этих операций все еще будет О(n),
где n — текущий размер множества.
Другие детали реализации тоже сохраняются. Элемент, включаемый во множество, должен быть хешируемым, и если экземпляры пользовательских классов
хешируются неправильно, то это негативно скажется на производительности.
Несмотря на концептуальное сходство со словарями, во множествах в Python 3.7
не сохраняется порядок элементов (ни в спецификации, ни в подробностях реализации CPython).
Рассмотрим дополнительные типы данных и контейнеры.
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 85
Дополнительные типы данных и контейнеры
В разделе «Встроенные типы языка Python» мы говорили в основном о типах
данных, имеющих специальные литералы в синтаксисе Python. Это были типы,
которые реализуются на уровне интерпретатора. Однако в стандартной библиотеке
Python есть много дополнительных типов данных, которые удобно использовать
там, где основные встроенные типы недорабатывают или где данные по своей природе требуют специализированной обработки (например, данные времени и даты).
Наиболее распространенными являются контейнеры данных, которые находятся
в collections, и мы уже кратко упомянули два из них: deque и OrderedDict. Однако
диапазон структур данных, доступных для Python-программистов, невероятно огромен, и почти в каждом модуле в стандартной библиотеке языка определены специализированные типы для обработки данных при возникновении различных проблем.
В этом разделе мы остановимся только на типах данных с самым широким потенциалом использования.
Специализированные контейнеры данных
из модуля collections
У каждой структуры данных свои недостатки. Не существует такой коллекции,
которая может решить любую проблему, и четырех основных типов коллекций
(кортеж, список, множество и словарь) все-таки маловато. Это самые основные
и важные коллекции, имеющие специальный синтаксис литералов. К счастью,
стандартная библиотека Python дает гораздо больше возможностей с помощью
встроенного модуля collections. Ниже приведены наиболее важные универсальные контейнеры данных, предоставляемые этим модулем:
namedtuple() — функция для создания подклассов кортежей, чьи индексы до-
ступны как именованные атрибуты;
deque — двухсторонняя очередь, обобщение стеков и очередей с быстрым добав-
лением и извлечением на обоих концах;
ChainMap — похожий на словарь класс для создания единого представления не-
скольких отображений;
Counter — подкласс словаря для подсчета хешируемых объектов;
OrderedDict — подкласс словаря, в котором сохраняется порядок добавления
элементов;
defaultdict — подкласс словаря, который заполняет недостающие значения
с помощью определенной пользователем функции.
86 Часть II •
Ремесло Python
Подробная информация о выборе коллекций из модуля collections и несколько советов о том, как их стоит использовать, приведены в главе 14.
Символическое перечисление с модулем enum
Одним из особо удобных типов в стандартной библиотеке Python является класс
Enum из модуля enum. Это базовый класс, позволяющий определять символические
перечисления, близкие по концепции к перечисляемым типам, имеющимся во
многих других языках программирования (C, C++, C#, Java и др.), которые часто
обозначаются ключевым словом enum.
Чтобы определить перечисление в Python, нужно будет создать подкласс класса
Enum и определить все элементы перечисления в качестве атрибутов класса. Ниже
приведен пример простого перечисления Python:
from enum import Enum
class Weekday(Enum):
MONDAY = 0
TUESDAY = 1
WEDNESDAY = 2
THURSDAY = 3
FRIDAY = 4
SATURDAY = 5
SUNDAY = 6
В документации Python определена следующая номенклатура для enum:
enumeration или enum — подкласс базового класса Enum. Здесь это был бы Weekday;
member — атрибут, который можно определить в подклассе Enum . Здесь это
Weekday.MONDAY, Weekday.TUESDAY и т. д.;
name — имя атрибута подкласса Enum, который определяет элемент. Здесь это
MONDAY для Weekday.MONDAY, TUESDAY для Weekday.TUESDAY и т. д.;
value — значение, присвоенное атрибуту подкласса Enum, который определяет
элемент. Здесь значение Weekday.MONDAY было бы 1, для Weekday.TUESDAY — 2 и т. д.
Можно применить любой тип в качестве значения члена перечисления. Если
член не имеет значения в коде, то можно даже использовать тип auto(), который
будет заменен на автоматически сгенерированное значение. Вот предыдущий пример, переписанный с использованием auto:
from enum import Enum, auto
class Weekday(Enum):
MONDAY = auto()
TUESDAY = auto()
WEDNESDAY = auto()
THURSDAY = auto()
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 87
FRIDAY = auto()
SATURDAY = auto()
SUNDAY = auto()
Перечисления в Python полезны в любом месте, где переменная может принимать конечное количество значений. Например, их можно применять для определения состояний объектов, как показано в следующем примере:
from enum import Enum, auto
class OrderStatus(Enum):
PENDING = auto()
PROCESSING = auto()
PROCESSED = auto()
class Order:
def __init__(self):
self.status = OrderStatus.PENDING
def process(self):
if self.status == OrderStatus.PROCESSED:
raise RuntimeError(
"Can't process order that has "
"been already processed"
)
self.status = OrderStatus.PROCESSING
...
self.status = OrderStatus.PROCESSED
Еще один случай использования перечислений — хранение выборки неуникальных вариантов. Это часто реализуется с использованием битовых флагов
и битовых масок в языках, в которых распространены битовые манипуляции
с числами, как в C. В Python это реализуется более выразительным и удобным
способом с помощью FlagEnum:
from enum import Flag, auto
class Side(Flag):
GUACAMOLE = auto()
TORTILLA = auto()
FRIES = auto()
BEER = auto()
POTATO_SALAD = auto()
Вы можете комбинировать эти флаги, используя битовые операции (операторы | и &) и тест на существование флага с помощью слова in. Вот некоторые примеры перечисления Side:
>>>
>>>
>>>
>>>
True
>>> Side.TORTILLA in bavarian_sides
False
>>> common_sides
Символические перечисления имеют некоторое сходство со словарями и именованными кортежами, поскольку все они сопоставляют имена/ключи со значениями. Основное отличие заключается в том, что определение Enum неизменяемо
и глобально. Его следует использовать всякий раз при наличии замкнутого
множества возможных значений, которое не может изменяться динамически
во время выполнения программы, и особенно если это множество определяется
только один раз и на глобальном уровне. Словари и именованные кортежи являются контейнерами данных. Вы можете создать столько их экземпляров, сколько
захотите.
В следующем разделе мы поговорим о расширенных элементах синтаксиса.
Расширенный синтаксис
Сложно объективно сказать, какой конкретный элемент синтаксиса является расширенным. Для целей настоящей главы мы будем считать таковыми те элементы,
которые непосредственно не связаны с какими-либо конкретными встроенными
типами данных и которые относительно трудно понять вначале. Наиболее распространенными функциями Python, сложными для понимания, являются:
итераторы;
генераторы;
менеджеры контекста;
декораторы.
Итераторы
Итератор — не что иное, как объект-контейнер, который реализует протокол итератора. Этот протокол состоит из двух методов:
__next__ — возвращает следующий элемент контейнера;
__iter__ — возвращает сам итератор.
Итераторы можно создать из последовательности с помощью встроенной функции iter. Рассмотрим пример:
>>> i = iter('abc')
>>> next(i)
'a'
>>> next(i)
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 89
'b'
>>> next(i)
'c'
>>> next(i)
Traceback (most recent call last):
File "", line 1, in
StopIteration
Когда последовательность закончится, выбрасывается исключение StopIteration.
Это делает итераторы совместимыми с циклами, так как данное исключение может
служить сигналом конца итерации. При создании пользовательского итератора
необходимо предоставить объекты с реализацией __next__, которая перебирает
состояние объекта, и методом __iter__, возвращающим итерируемый объект.
Оба метода часто реализуются внутри одного и того же класса. Ниже приведен
пример класса CountDown, который позволяет перебирать числа в сторону 0:
class CountDown:
def __init__(self, step):
self.step = step
def __next__(self):
"""Возвращает следующий элемент"""
if self.step >>
>>>
...
...
...
...
3
2
1
0
end
>>>
...
...
...
...
end
count_down = CountDown(4)
for element in count_down:
print(element)
else:
print("end")
for element in count_down:
print(element)
else:
print("end")
90 Часть II •
Ремесло Python
При желании сделать итератор доступным для повторного использования всегда
можно разделить его реализацию на два класса для того, чтобы отделить состояние
итерации и фактические объекты итератора, как показано в следующем примере:
class CounterState:
def __init__(self, step):
self.step = step
def __next__(self):
"""Изменение счетчика до нуля с шагом 1"""
if self.step >>
>>>
...
...
...
...
3
2
1
0
end
>>>
...
...
...
...
3
2
1
0
end
count_down = CountDown(4)
for element in count_down:
print(element)
else:
print("end")
for element in count_down:
print(element)
else:
print("end")
Итераторы сами по себе являются идеей низкого уровня, и программа может
жить без них. Однако они лежат в основе гораздо более интересных элементов:
генераторов.
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 91
Генераторы и операторы yield
Генераторы предоставляют элегантный способ написать простой и эффективный
код для функций, которые возвращают последовательность элементов. Оператор
yield позволяет приостановить выполнение функции и возвращает промежуточный результат. При этом контекст выполнения сохраняется и может быть возобновлен позже, если это необходимо.
Например, функцию, которая возвращает числа последовательности Фибоначчи, можно записать с помощью синтаксиса генератора. Следующий код — это
пример, взятый из документа PEP 255:
def fibonacci():
a, b = 0, 1
while True:
yield b
a, b = b, a + b
Можно извлекать новые значения генераторов, как если бы они были итераторами, с помощью функции next() и циклов:
>>>
>>>
1
>>>
1
>>>
2
>>>
[3,
fib = fibonacci()
next(fib)
next(fib)
next(fib)
[next(fib) for i in range(10)]
5, 8, 13, 21, 34, 55, 89, 144, 233]
Наша функция fibonacci() возвращает объект типа generator — специальный
итератор, который умеет сохранять контекст выполнения. Его можно вызывать
бесконечно, каждый раз получая очередной элемент последовательности. Синтаксис получается кратким, а бесконечность алгоритма не нарушает читабельность
кода. В данном коде не должно быть способа сделать функцию останавливаемой.
На самом деле подход работает аналогично тому, как работает функция генерации
последовательности в псевдокоде.
Часто затраты на обработку одного элемента оказываются меньше затрат на
хранение целых последовательностей. То есть такой метод делает программу
более эффективной. Например, последовательность Фибоначчи является бесконечной, но при этом генератору не требуется бесконечное количество памяти,
чтобы хранить все эти значения «одно за одним», и теоретически он может работать
до бесконечности. Общий случай использования таких генераторов — потоковая
передача буферов данных с генераторами (например, из файлов). Передачу можно
приостановить, возобновить или полностью прервать на любом этапе обработки
Здесь видно, что метод open.readline перебирает строки файла, а generate_
tokens обрабатывает их, выполняя дополнительные действия. Генераторы могут
также снизить сложность кода и повысить эффективность некоторых алгоритмов
преобразования данных при условии, что процесс трансформации можно разделить
на отдельные этапы обработки. Если каждый шаг обработки считать итератором,
а затем объединить их в функцию верхнего уровня, то это позволит избежать использования больших, некрасивых и нечитаемых функций. Кроме того, это может
дать живую обратную связь на протяжении всей цепочки обработки.
В следующем примере каждая функция выполняет некоторое преобразование
последовательности. Затем они применяются поочередно. Каждый вызов обрабатывает один элемент и возвращает его результат:
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 93
def capitalize(values):
for value in values:
yield value.upper()
def hyphenate(values):
for value in values:
yield f"-{value}-"
def leetspeak(values):
for value in values:
if value in {'t', 'T'}:
yield '7'
elif value in {'e', 'E'}:
yield '3'
else:
yield value
def join(values):
return "".join(values)
Разделив ваш конвейер обработки данных на несколько самостоятельных шагов,
вы сможете объединить их разными способами:
>>> join(capitalize("This will be uppercase text"))
'THIS WILL BE UPPERCASE TEXT'
>>> join(leetspeak("This isn't a leetspeak"))
"7his isn'7 a l337sp3ak"
>>> join(hyphenate("Will be hyphenated by words".split()))
'-Will--be--hyphenated--by--words-'
>>> join(hyphenate("Will be hyphenated by character"))
'-W--i--l--l-- --b--e-- --h--y--p--h--e--n--a--t--e--d-- --b--y---c--h--a--r--a--c--t--e--r-'
Простым должен быть код, а не данные
Лучше иметь много простых функций, которые обрабатывают последовательности значений, чем сложную функцию, вычисляющую одно
значение за раз.
Еще одна важная особенность Python, касающаяся генераторов, — это возможность взаимодействовать с кодом, вызываемым функцией next(). Оператор yield
становится выражением, а некоторые значения могут быть переданы через декоратор с помощью метода генератора send():
def psychologist():
print('Please tell me your problems')
while True:
94 Часть II •
Ремесло Python
answer = (yield)
if answer is not None:
if answer.endswith('?'):
print("Don't ask yourself too much questions")
elif 'good' in answer:
print("Ahh that's good, go on")
elif 'bad' in answer:
print("Don't be so negative")
Ниже представлен пример сеанса работы с нашей функцией psychologist():
>>> free = psychologist()
>>> next(free)
Please tell me your problems
>>> free.send('I feel bad')
Don't be so negative
>>> free.send("Why I shouldn't ?")
Don't ask yourself too much questions
>>> free.send("ok then i should find what is good for me")
Ahh that's good, go on
Метод send() действует аналогично функции next(), но оператор yield возвращает значение, переданное ему внутри определения функции. Следовательно,
функция может изменять свое поведение в зависимости от клиентского кода.
Чтобы завершить картину, используем два других метода: throw() и close() .
Они позволяют вводить в генератор исключения:
throw() — позволяет клиентскому коду выбрасывать любые исключения;
close() — работает так же, но выбрасывает специфическое исключение, Genera
torExit. В этом случае функция генератора должна вызвать GeneratorExit или
StopIteration.
Генераторы лежат в основе других концепций в Python, таких как сопрограммы и асинхронный параллелизм, которые будут рассмотрены
в главе 15.
Декораторы
Декораторы были добавлены в Python для улучшения читаемости функций и методов. Декоратор — это просто любая функция, которая принимает на вход функцию
и возвращает ее расширенный вариант. Раньше их использовали, чтобы можно
было определить методы как методы класса или статические методы в заголовке
определения. Без синтаксиса декораторов пришлось бы использовать разреженное
и повторяющееся определение:
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 95
class WithoutDecorators:
def some_static_method():
print("this is static method")
some_static_method = staticmethod(some_static_method)
def some_class_method(cls):
print("this is class method")
some_class_method = classmethod(some_class_method)
Выделенный синтаксис декоратора короче и легче для понимания:
class WithDecorators:
@staticmethod
def some_static_method():
print("this is static method")
@classmethod
def some_class_method(cls):
print("this is class method")
Рассмотрим общий синтаксис и возможные реализации декораторов.
Общий синтаксис и возможные реализации
Декоратор — это, как правило, именованный вызываемый объект (лямбда-выражения не допускаются), который принимает один аргумент при вызове (это будет
декорированная функция) и возвращает другой вызываемый объект. Вызываемый
объект здесь преднамеренно используется вместо функции. Декораторы часто
обсуждаются в рамках методов и функций, однако не ограничены ими. На самом
деле все вызываемые объекты (любой объект, который реализует метод __call__,
считается вызываемым) могут быть использованы в качестве декоратора, и часто
возвращаемые ими объекты являются не простыми функциями, а экземплярами
более сложных классов, которые реализуют собственные методы __call__.
Синтаксис декоратора — это просто синтаксический сахар. Рассмотрим следующий вариант использования декоратора:
@some_decorator
def decorated_function():
pass
Этот вариант всегда можно заменить явным вызовом декоратора и переназначением функции:
def decorated_function():
pass
decorated_function = some_decorator(decorated_function)
Однако последний вариант не слишком читабельный и понятный, если в одной
функции используется несколько декораторов.
96 Часть II •
Ремесло Python
Декоратор не обязательно возвращает вызываемый объект!
На самом деле в качестве декоратора может быть использована любая
функция, поскольку Python не устанавливает возвращаемый тип для
декораторов. Таким образом, задействовать некую функцию в качестве
декоратора, который принимает один аргумент, но не возвращает вызываемый объект, например строку, вполне допустимо с точки зрения
синтаксиса. Однако это не сработает, если пользователь попытается
вызвать объект, который был задекорирован таким образом. Эта часть
синтаксиса декоратора создает поле для интересных экспериментов.
Как функция. Существует много способов написанияпользовательских декораторов, но самый простой — написать функцию, которая возвращает вложенную
функцию, обертывающую вызов исходной функции.
Обобщенный шаблон выглядит следующим образом:
def mydecorator(function):
def wrapped(*args, **kwargs):
# Действия, выполняемые перед
# вызовом оригинальной функции
result = function(*args, **kwargs)
# Действия после выполнения функции
# и результат
return result
# Возвращает задекорированную функцию
return wrapped
Как класс. Декораторы почти всегда могут быть реализованы через функции,
но бывают ситуации, когда лучше задействовать пользовательский класс. Это часто верно, когда декоратор нуждается в сложной параметризации или зависит от
конкретного состояния.
Общий шаблон непараметризованного декоратора, определенного как класс,
выглядит следующим образом:
class DecoratorAsClass:
def __init__(self, function):
self.function = function
def __call__(self, *args, **kwargs):
# Действия, выполняемые перед
# вызовом оригинальной функции
result = self.function(*args, **kwargs)
# Действия после выполнения функции
# и результат
return result
Параметризация декораторов. В реальных сценариях использования часто
возникает необходимость в применении декораторов, которые могут быть параметризованы. Когда в качестве декоратора служит функция, решение простое — нужен
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 97
второй уровень упаковки. Вот простой пример декоратора, который повторяет выполнение декорированной функции заданное количество раз при каждом вызове:
def repeat(number=3):
"""Повтор декорированной функции заданное количество раз.
В результате возвращается последнее значение оригинальной функции.
: number — количество повторов, по умолчанию равно 3.
"""
def actual_decorator(function):
def wrapper(*args, **kwargs):
result = None
for _ in range(number):
result = function(*args, **kwargs)
return result
return wrapper
return actual_decorator
Определенный таким образом декоратор может принимать параметры:
>>> @repeat(2)
... def print_my_call():
...
print("print_my_call() called!")
...
>>> print_my_call()
print_my_call() called!
print_my_call() called!
Обратите внимание: даже если у параметризованных декораторов есть значения
аргументов по умолчанию, круглые скобки после имени обязательны. Правильный
способ использования предыдущего декоратора с аргументами по умолчанию выглядит следующим образом:
>>> @repeat()
... def print_my_call():
...
print("print_my_call() called!")
...
>>> print_my_call()
print_my_call() called!
print_my_call() called!
print_my_call() called!
Отсутствие круглых скобок приведет к ошибке при вызове декорированной
функции:
>>> @repeat
... def print_my_call():
...
print("print_my_call() called!")
...
>>> print_my_call()
Traceback (most recent call last):
File "", line 1, in
TypeError: actual_decorator() missing 1 required positional
argument: 'function'
98 Часть II •
Ремесло Python
Декораторы с сохранением самоанализа. Частая ошибка при использовании
декораторов — отсутствие сохранения метаданных функции (обычно это строка
документации и оригинальное название). Во всех предыдущих примерах данная
проблема не решена. В них создается новая функция и возвращается новый объект, а оригинальный при этом никак не упоминается. Это затрудняет отладку
задекорированных функций и нарушает работу большинства инструментов автодокументирования, поскольку оригинальные строки документации и сигнатуры
функций исчезают.
Рассмотрим этот момент подробнее. Предположим, что у нас есть некий фиктивный декоратор, который ничего не делает, и какие-то другие функции, задекорированные им:
def dummy_decorator(function):
def wrapped(*args, **kwargs):
"""Внутренняя документация обернутой функции"""
return function(*args, **kwargs)
return wrapped
@dummy_decorator
def function_with_important_docstring():
"""Это важная строка документации, ее терять не надо"""
Если мы исследуем function_with_important_docstring() в интерактивной
сессии Python, то увидим, что первоначальное название и строка документации
были потеряны:
>>> function_with_important_docstring.__name__
'wrapped'
>>> function_with_important_docstring.__doc__
'Internal wrapped function documentation.'
Правильное решение этой проблемы заключается в использовании декоратора
wraps(), предоставляемого модулем functools:
from functools import wraps
def preserving_decorator(function):
@wraps(function)
def wrapped(*args, **kwargs):
"""Внутренняя документация обернутой функции"""
return function(*args, **kwargs)
return wrapped
@preserving_decorator
def function_with_important_docstring():
"""Это важная строка документации, ее терять не надо"""
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 99
Если декоратор определен именно таким образом, то все важные метаданные
функции сохраняются:
>>> function_with_important_docstring.__name__
'function_with_important_docstring.'
>>> function_with_important_docstring.__doc__
'This is important docstring we do not want to lose.'
В следующем пункте рассмотрим использование декораторов.
Использование декораторов и полезные примеры
Поскольку интерпретатор загружает декораторы во время первого чтения модуля,
их использование следует ограничить применимыми обертками. Если декоратор
привязан к классу метода или сигнатуре функции, которую он декорирует, то должен быть превращен в обычный вызываемый объект, чтобы избежать сложностей.
Часто бывает полезно группировать декораторы в специальных модулях, которые
отражают их область применения, с целью упростить дальнейшую поддержку.
Общие шаблоны декораторов:
проверка аргументов;
кэширование;
прокси;
провайдер контекста.
Проверка аргументов. Проверка аргументов, которые принимает или возвращает функция, бывает полезна, когда функция выполняется в определенном
контексте. Например, если функция будет вызываться через XML-RPC, то Python
не сможет генерировать полную сигнатуру, как это делается в статически типизированных языках. Данная функция необходима для обеспечения возможности
самоанализа, когда клиент XML-RPC запрашивает сигнатуры функций.
Протокол XML-RPC
Протокол XML-RPC представляет собой облегченный протокол удаленного вызова процедуры, в котором для кодирования вызовов используется
XML через HTTP. Он часто применяется вместо SOAP для простого клиент-серверного обмена. В отличие от SOAP, предоставляющего страницу,
на которой перечислены все вызываемые функции (WSDL), у XML-RPC
нет каталога доступных функций. Было предложено расширение протокола, позволяющее выполнять открытие сервера API, и модуль xmlrpc
реализует его (см. docs.python.org/3/library/xmlrpc.server.html).
100 Часть II
•
Ремесло Python
Пользовательский декоратор может создавать такой тип сигнатуры. Кроме того,
он может проверять входные и выходные данные на соответствие определенным
параметрам сигнатуры:
rpc_info = {}
def xmlrpc(in_=(), out=(type(None),)):
def _xmlrpc(function):
# Объявление сигнатуры
func_name = function.__name__
rpc_info[func_name] = (in_, out)
def _check_types(elements, types):
"""Подфункция проверки типов"""
if len(elements) != len(types):
raise TypeError('argument count is wrong')
typed = enumerate(zip(elements, types))
for index, couple in typed:
arg, of_the_right_type = couple
if isinstance(arg, of_the_right_type):
continue
raise TypeError(
'arg #%d should be %s' % (index,
of_the_right_type))
# Обернутая функция
def __xmlrpc(*args): # Ключевые слова не допускаются
# Проверка входов
checkable_args = args[1:] # Удаление self
_check_types(checkable_args, in_)
# Запуск функции
res = function(*args)
# Проверка входов
if not type(res) in (tuple, list):
checkable_res = (res,)
else:
checkable_res = res
_check_types(checkable_res, out)
# Проверка типа и функции успешна
return res
return __xmlrpc
return _xmlrpc
Декоратор регистрирует функцию в глобальном словаре и хранит список типов
аргументов и возвращаемых значений. Обратите внимание: этот пример был весьма
упрощен, чтобы показать идею проверки аргументов в декораторе.
Пример использования выглядит следующим образом:
class RPCView:
@xmlrpc((int, int)) # два int -> None
def accept_integers(self, int1, int2):
print('received %d and %d' % (int1, int2))
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 101
В момент считывания конструктор класса заполняет словарь rpc_infos и может быть использован в конкретной среде, в которой проверяются типы аргументов:
>>> rpc_info
{'meth2': ((,), (,)), 'meth1': ((, ), (,))}
>>> my = RPCView()
>>> my.accept_integers(1, 2)
received 1 and 2
>>> my.accept_phrase(2)
Traceback (most recent call last):
File "", line 1, in
File "", line 26, in __xmlrpc
File "", line 20, in _check_types
TypeError: arg #0 должен быть
Кэширование. Кэширование в декораторе очень похоже на проверку аргументов, но фокусируется на тех функциях, внутреннее состояние которых
не влияет на выходные данные. Каждый набор аргументов может быть связан
с уникальным результатом. Подобный стиль характерен для функционального
программирования и может использоваться, когда множество входных значений
конечно.
Таким образом, кэширование в декораторе позволяет держать выходные данные
вместе с аргументами, которые были необходимы для вычисления, и возвращать
их непосредственно при последующих вызовах.
Подобное поведение называется запоминанием, и его довольно просто реализовать в качестве декоратора:
"""В этом модуле предусмотрены аргументы меморизации,
способные хранить кэшированные результаты
декорированной функции за заданный период времени.
"""
import time
import hashlib
import pickle
cache = {}
def is_obsolete(entry, duration):
"""Проверка актуальности записи в кэше"""
return time.time() - entry['time']> duration
def compute_key(function, args, kw):
"""Вычисление ключа кэширования для значения"""
102 Часть II
•
Ремесло Python
key = pickle.dumps((function.__name__, args, kw))
return hashlib.sha1(key).hexdigest()
def memoize(duration=10):
"""Декоратор меморизации по ключевому слову
позволяет запомнить аргументы функции
за заданное время
"""
def _memoize(function):
def __memoize(*args, **kw):
key = compute_key(function, args, kw)
# Помещено ли это в кэш?
if (
key in cache and
not is_obsolete(cache[key], duration)
):
# Если да и актуально,
# то возвращает кэшированное значение
print('we got a winner')
return cache[key]['value']
# Вычисление результата,
# если кэш не был найден
result = function(*args, **kw)
# Сохранение результата на потом
cache[key] = {
'value': result,
'time': time.time()
}
return result
return __memoize
return _memoize
Хеш-ключ SHA построен с использованием упорядоченных значений аргументов, и результат сохраняется в глобальном словаре. Хеш вычисляется с помощью ярлыка, замораживающего состояние всех объектов, которые передаются
в качестве аргументов, гарантируя, что все аргументы подходят. Если в качестве
аргумента используется поток или сокет, то возникает ошибка PicklingError
(см. docs.python.org/3/library/pickle.html). Параметр длительности применяется для
обнуления кэшированных значений, когда с момента последнего вызова функции
прошло слишком много времени.
Ниже представлен пример использования запоминания в декораторе (при
условии, что предыдущий фрагмент кода хранится в модуле memoize):
>>> from memoize import memoize
>>> @memoize()
... def very_very_very_complex_stuff(a, b):
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 103
...
# Если компьютер не справляется,
...
# лучше остановить выполнение
...
return a + b
...
>>> very_very_very_complex_stuff(2, 2)
4
>>> very_very_very_complex_stuff(2, 2)
we got a winner
4
>>> @memoize(1) # Отключает кэш через 1 секунду
... def very_very_very_complex_stuff(a, b):
...
return a + b
...
>>> very_very_very_complex_stuff(2, 2)
4
>>> very_very_very_complex_stuff(2, 2)
we got a winner
4
>>> cache
{'c2727f43c6e39b3694649ee0883234cf': {'value': 4, 'time':
1199734132.7102251)}
>>> time.sleep(2)
>>> very_very_very_complex_stuff(2, 2)
4
Кэширование ресурсозатратных функций может значительно повысить общую производительность программы, но применять его следует с осторожностью.
Кэшируемое значение также может быть привязано к функции вместо использования централизованного словаря для более эффективного управления данными
кэша. Но в любом случае более эффективный декоратор будет задействовать
специализированную библиотеку кэша и/или специализированный сервис кэширования с передовым алгоритмом кэширования. Memcached — хорошо известный
пример такого сервиса кэширования и может легко использоваться с Python.
В главе 14 приведены подробная информация и примеры для различных
методов кэширования.
Прокси. Прокси-декораторы применяются для маркировки и регистрации
функций с глобальным механизмом. Например, уровень безопасности, ограничивающий доступ к коду в зависимости от текущего пользователя, может быть реализован с помощью централизованной проверки с соответствующим разрешением,
которое запрашивает вызываемый объект:
class User(object):
def __init__(self, roles):
self.roles = roles
104 Часть II
•
Ремесло Python
class Unauthorized(Exception):
pass
def protect(role):
def _protect(function):
def __protect(*args, **kw):
user = globals().get('user')
if user is None or role not in user.roles:
raise Unauthorized("I won't tell you")
return function(*args, **kw)
return __protect
return _protect
Эта модель часто применяется в веб-фреймворках Python для определения безопасности при публикации ресурсов. Например, в Django есть декоратор, который
защищает доступ к веб-ресурсам.
Ниже представлен пример случая, когда имя текущего пользователя хранится
в глобальной переменной. Декоратор проверяет известные ему роли при вызове
метода (предыдущий фрагмент кода хранится в модуле пользователей):
>>> from users import User, protect
>>> tarek = User(('admin', 'user'))
>>> bill = User(('user',))
>>> class RecipeVault(object):
...
@protect('admin')
...
def get_waffle_recipe(self):
...
print('use tons of butter!')
...
>>> my_vault = RecipeVault()
>>> user = tarek
>>> my_vault.get_waffle_recipe()
use tons of butter!
>>> user = bill
>>> my_vault.get_waffle_recipe()
Traceback (most recent call last):
File "", line 1, in
File "", line 7, in wrap
__main__.Unauthorized: I won't tell you
Провайдер контекста. Декоратор — провайдер контекста. Он гарантирует, что
функция будет работать в правильном контексте, либо выполняет код до и/или
после запуска декорированной функции. Проще говоря, декоратор задает новую
конкретную среду выполнения. Например, если элемент данных используется
в нескольких потоках, то необходимо использовать блокировку, чтобы обеспечить
ему защиту от множественного доступа. В декораторе эта блокировка может быть
реализована следующим образом:
from threading import RLock
lock = RLock()
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 105
Контекстные декораторы часто заменяются менеджерами контекста (с оператором with), о которых мы также поговорим в этой главе.
Менеджеры контекста и оператор with
Оператор try..finally позволяет попытаться выполнить некий код, даже если
возникает ошибка. Существует много случаев его использования, например:
закрытие файла;
снятие блокировки;
создание временного кода;
запуск защищенного кода в особой среде.
Оператор with выделяет эти случаи, тем самым предоставляя простой способ обернуть блок кода методами, определенными внутри менеджера контекста.
Это позволяет вызывать некий код до и после выполнения блока, даже если этот
блок выбрасывает исключение. Например, при работе с файлом часто делается так:
>>> hosts = open('/etc/hosts')
>>> try:
...
for line in hosts:
...
if line.startswith('#'):
...
continue
...
print(line.strip())
... finally:
...
hosts.close()
...
127.0.0.1
localhost
255.255.255.255 broadcasthost
::1
localhost
Данный пример является специфичным для Linux, поскольку в нем считывается файл host, расположенный в папке /etc/, но с тем же успехом
это мог бы быть любой текстовый файл.
106 Часть II
•
Ремесло Python
С помощью оператора with код можно переписать лаконичнее и чище:
>>> with open('/etc/hosts') as hosts:
...
for line in hosts:
...
if line.startswith('#'):
...
continue
...
print(line.strip())
...
127.0.0.1
localhost
255.255.255.255 broadcasthost
::1
localhost
В предыдущем примере в качестве менеджера контекста используется функция open(), которая гарантирует, что файл будет закрыт после выполнения цикла
for, даже если в процессе выбрасывается исключение.
Ниже приведены общие элементы из стандартной библиотеки Python, совместимые с этим оператором, — классы из модуля threading:
threading.Lock;
threading.RLock;
threading.Condition;
threading.Semaphore;
threading.BoundedSemaphore.
Общий синтаксис и возможные реализации
Простейший синтаксис оператора with выглядит следующим образом:
with context_manager:
# Блок кода
...
Кроме того, если менеджер контекста определяет переменные контекста, то они
могут храниться локально с помощью оператора as:
with context_manager as context:
# Блок кода
...
Обратите внимание: можно использовать несколько менеджеров контекста
одновременно:
with A() as a, B() as b:
...
Это эквивалентно вложенности, показанной ниже:
with A() as a:
with B() as b:
...
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 107
В качестве класса. Любой объект, который реализует протокол менеджера
контекста, можно использовать в качестве менеджера контекста. Этот протокол
состоит из двух специальных методов:
__enter__(self) — позволяет определить, что должно произойти перед вы-
полнением кода, который был обернут менеджером контекста, и возвращает
переменную контекста;
__exit__(self, exc_type, exc_value, traceback) — позволяет провести кое-какую
чистку после выполнения кода, обернутого менеджером контекста, и захватывает все исключения, выброшенные в процессе.
Вкратце выполнение оператора with можно представить таким образом.
1. Вызывается метод __enter__. Любое возвращаемое значение привязано к целевому объекту, указанному в операторе as.
2. Выполняется внутренний блок кода.
3. Вызывается метод __exit__.
Метод __exit__ принимает три аргумента, которые заполняются при возникновении ошибки в блоке кода. Если ошибка не возникнет, то все три аргумента будут
иметь значение None. При возникновении ошибки метод __exit__() не должен
повторно вызывать ее, поскольку за это отвечает вызывающий объект. Так можно предотвратить выброс исключения, но будет возвращено True. Это открывает
новые случаи применения, например, декоратора contextmanager, который мы
увидим ниже. Но в большинстве ситуаций этот метод выполняет чистку так же,
как оператор finally. Обычно он ничего не возвращает, независимо от того, что
происходит в блоке.
Ниже приведен пример фиктивного менеджера контекста, реализующего данный протокол, чтобы показать, как это работает:
class ContextIllustration:
def __enter__(self):
print('entering context')
def __exit__(self, exc_type, exc_value, traceback):
print('leaving context')
if exc_type is None:
print('with no error')
else:
print(f'with an error ({exc_value})')
При запуске без выброшенных исключений вывод выглядит следующим образом
(предыдущий фрагмент хранится в модуле context_illustration):
>>> from context_illustration import ContextIllustration
>>> with ContextIllustration():
108 Часть II
•
Ремесло Python
...
print("inside")
...
entering context
inside
leaving context
with no error
Когда выбрасывается исключение, вывод выглядит так:
>>> from context_illustration import ContextIllustration
>>> with ContextIllustration():
...
raise RuntimeError("raised within 'with'")
...
entering context
leaving context
with an error (raised within 'with')
Traceback (most recent call last):
File "", line 2, in
RuntimeError: raised within 'with'
В качестве функции — модуль contextlib. Использование классов кажется
наиболее гибким способом реализации любого протокола языка Python, но может
быть слишком шаблонным в простых случаях. В стандартную библиотеку был
добавлен модуль, в котором есть помощники, упрощающие создание менеджеров
контекста. Самая полезная часть — декоратор contextmanager. Это позволяет задействовать процедуры __enter__ и __exit__ внутри одной функции, разделенной
оператором yield (обратите внимание, что это превращает функцию в генератор).
Если переписать предыдущий пример с этим декоратором, то код будет выглядеть
следующим образом:
from contextlib import contextmanager
@contextmanager
def context_illustration():
print('entering context')
try:
yield
except Exception as e:
print('leaving context')
print(f'with an error ({e})')
# Исключение будет выброшено заново
raise
else:
print('leaving context')
print('with no error')
При выбрасывании исключения функция должна вновь вызвать его, чтобы
передать далее. Обратите внимание: у модуля context_illustration могут быть
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 109
аргументы, если необходимо. Этот маленький помощник упрощает нормальный
класс на основе API менеджера контекста так же, как генераторы с итераторами
на основе класса.
У данного модуля есть еще четыре помощника:
closing(element) — возвращает менеджер контекста, который вызывает метод
элемента close() на выходе. Это полезно для классов, работающих с потоками
и файлами;
supress(*exceptions) — подавляет любое из указанных исключений, если они
происходят в теле оператора with;
redirect_stdout(new_target) и redirect_stderr(new_target) — перенаправляют
вывод sys.stdout или sys.stderr любого кода внутри блока к другому файлу
или файл-подобному объекту.
Рассмотрим функционально-стилевые особенности Python.
Функционально-стилевые особенности Python
Парадигма программирования — это очень важное понятие, позволяющее классифицировать различные языки программирования. Парадигма определяет
конкретный способ мышления о моделях исполнения языка (определение того,
как все работает) или о структуре и организации кода. Существует много парадигм программирования, но, как правило, они сгруппированы в две основные
категории:
императивные парадигмы, в которых программист в основном занимается со-
стоянием программы, а сама она является определением того, как компьютер
должен управлять ее состоянием для генерации ожидаемого результата;
декларативные парадигмы, в которых программист занимается формальным
определением задачи или свойств желаемого результата, но не думает о том,
как вычислять этот результат.
Благодаря модели исполнения и вездесущим классам и объектам наиболее
естественные для Python парадигмы — это объектно-ориентированное программирование и структурное программирование. Это также две наиболее распространенные императивные парадигмы программирования среди всех современных
языков программирования. Однако Python считается языком многопрофильной
парадигмы и содержит функции, которые типичны и для императивных, и для
декларативных языков.
Одной из прекраснейших особенностей программирования на Python является
то, что вы всегда можете посмотреть на программу с разных сторон. Всегда суще-
110 Часть II
•
Ремесло Python
ствуют различные способы решения проблемы, а иногда наилучшим подходом
оказывается не тот, который является наиболее очевидным. В ряде случаев такой
подход требует использования декларативного программирования. К счастью,
Python с его богатым синтаксисом и большой стандартной библиотекой имеет все
инструменты для функционального программирования, а оно — одна из основных
парадигм декларативного.
Функциональное программирование мы обсудим в следующем подразделе.
Что такое функциональное программирование
Функциональное программирование — парадигма, в которой программа — это
в основном вычисление функций (в математическом смысле), а состояние программы не изменяется с помощью определенных действий. Чисто функциональные
программы не предусматривают изменения состояния (побочных эффектов) и изменяемых данных. В Python функциональное программирование реализуется за
счет использования сложных выражений и деклараций функций.
Один из лучших способов глубже понять общую концепцию функционального
программирования — ознакомиться с его основными терминами.
Побочные эффекты. Функция имеет «побочный эффект», если изменяет со-
стояние программы за пределами своей локальной среды. Иными словами,
побочный эффект — это любые наблюдаемые изменения за пределами области
видимости функции, которые возникают в результате вызова функции. Примером таких побочных эффектов может быть изменение значения глобальной
переменной, изменение атрибута или объекта, доступного за пределами области
видимости функции, или сохранение данных в каком-либо внешнем сервисе.
Побочные эффекты находятся в центре концепции объектно-ориентированного
программирования, где экземпляры класса — это объекты, используемые для
инкапсуляции состояния приложения, а методы — это функции, привязанные
к этим объектам и используемые для изменения их состояния.
Ссылочная прозрачность. Ссылочно-прозрачная функция или выражение могут
быть заменены значениями, которые соответствуют входным данным, и при
этом поведение программы не изменится. Таким образом, отсутствие побочных
эффектов — необходимое условие для ссылочной прозрачности, но не каждая
функция, не имеющая побочных эффектов, является ссылочно-прозрачной.
Например, встроенная функция Python pow(х, у) ссылочно-прозрачна, поскольку не имеет побочных эффектов, и любую пару аргументов x и y можно заменить
значением хy. С другой стороны, метод datetime.now() типа datetime не имеет
наблюдаемых побочных эффектов, но при каждом вызове возвращает разные
значения. Таким образом, он является ссылочно-непрозрачным.
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 111
Чистые функции. Чистой является функция, не имеющая каких-либо побочных
эффектов и всегда возвращающая одинаковое значение для одного и того же
набора входных параметров. Другими словами, это функция, которая является
ссылочно-прозрачной. Любая математическая функция по определению чистая.
Функции первого класса. Язык содержит такие функции, если функции на этом
языке можно рассматривать как любое другое значение или сущность. Функции
первого класса могут быть переданы в качестве аргументов другим функциям,
являться возвращаемыми значениями и присваиваться переменным. Иными
словами, язык, в котором есть функции первого класса, — это язык, относящийся к функциям как к сущностям первого класса. Функции в Python являются
функциями первого класса.
Используя эти понятия, мы могли бы описать чисто функциональный язык
как язык, содержащий чистые функции верхнего уровня, в которых отсутствуют
неясные ссылки и побочные эффекты. Python, конечно же, не является чисто
функциональным языком программирования, и было бы очень трудно представить
полезную программу на Python, в которой задействованы только чистые функции
без каких-либо побочных эффектов. В Python есть немало функций, в течение
долгих лет доступные только в чисто функциональных языках, поэтому на Python
можно писать большие фрагменты кода в чисто функциональном стиле, хоть язык
в целом не является таковым.
В следующем подразделе рассмотрим лямбда-функции.
Лямбда-функции
Лямбда-функции — очень популярная концепция программирования, особенно
в функциональном программировании. В других языках программирования лямбда-функции иногда называются анонимными функциями, лямбда-выражениями
или функциональными литералами. Лямбда-функции — это анонимные функции,
которые не привязываются к какому-либо идентификатору (переменной).
Лямбда-функция в Python может быть определена только с помощью выражений. Синтаксис такой функции выглядит следующим образом:
lambda :
Лучший способ представить синтаксис лямбда-функций — это сравнить «обычную» и анонимную функции. Ниже показана простая функция, которая возвращает
площадь круга заданного радиуса:
import math
def circle_area(radius):
return math.pi * radius ** 2
112 Часть II
•
Ремесло Python
В виде лямбда-функции это будет иметь следующий вид:
lambda radius: math.pi * radius ** 2
Лямбды в функциях анонимны, но это не значит, что их нельзя применить
к любому идентификатору. Функции в Python — объекты первого класса, поэтому всякий раз, используя имя функции, вы на самом деле задействуете переменную, которая является ссылкой на объект функции. Как и любые другие
функции, лямбда-функции — «граждане первого класса», поэтому их тоже можно
отнести к новой переменной. После назначения переменной они будут неотличимы от обычных функций, за исключением некоторых атрибутов метаданных.
Следующие фрагменты из интерактивных сессий интерпретатора демонстрируют это:
>>> import math
>>> def circle_area(radius):
...
return math.pi * radius ** 2
...
>>> circle_area(42)
5541.769440932395
>>> circle_area
В следующем подразделе рассмотрим функции map(), filter() и reduce().
map(), filter() и reduce()
Функции map(), filter() и reduce() — три встроенные функции, которые наиболее
часто используются в сочетании с лямбда-функциями. Они широко применяются
в функциональном программировании на Python, так как позволяют выполнять
преобразования данных любой сложности, избегая побочных эффектов. В Python 2
все три функции были доступны по умолчанию и не требовали дополнительного
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 113
импорта. В Python 3 функция reduce() была перенесена в модуль functools, и его
нужно импортировать.
Функция map(fun, iterable, ...) применяет аргумент функции к каждому
элементу объекта iterable. Вы можете передать больше таких объектов функции
map(). Если вы сделаете это, то функция map() будет получать элементы из всех
итераторов одновременно. Функция func будет получать столько элементов, сколько итерируемых объектов доступно на каждом шаге. Если итерируемые объекты
разных размеров, то функция map() остановится, пока не закончится кратчайший
из них. Стоит помнить: функция map() не вычисляет весь результат сразу, но возвращает итератор так, что каждый полученный элемент можно вычислить, только
когда это необходимо.
Ниже приведен пример использования функции map() для вычисления квадратов первых десяти положительных целых чисел, включая 0:
>>> map(lambda x: x**2, range(10))
Функция filter(function, iterable) работает аналогично функции map(), оценивая входные элементы «один за одним». В отличие от map() функция filter()
не преобразует входные элементы в новые значения, но позволяет отфильтровать те
входные значения, которые соответствуют предикату, определяемому аргументом
function. Ниже приведены примеры использования функции filter():
>>> evens = filter(lambda number: number % 2 == 0, range(10))
>>> odds = filter(lambda number: number % 2 == 1, range(10))
>>> print(f"Even numbers in range from 0 to 9 are: {list(evens)}")
Even numbers in range from 0 to 9 are: [0, 2, 4, 6, 8]
>>> print(f"Odd numbers in range from 0 to 9 are: {list(odds)}")
Odd numbers in range from 0 to 9 are: [1, 3, 5, 7, 9]
>>> animals = ["giraffe", "snake", "lion", "squirrel"]
>>> animals_with_s = filter(lambda animal: 's' in animal, animals)
>>> print(f"Animals with letter 's' are: {list(animals_with_s)}")
Animals with letter 's' are: ['snake', 'squirrel']
114 Часть II
•
Ремесло Python
Функция reduce(function, iterable) работает полностью противоположно
функции map(). Вместо того чтобы брать элементы iterable и применять к каждому
из них function, возвращая список результатов, она кумулятивно выполняет операции, указанные в function, ко всем элементам iterable. Рассмотрим пример вызова
функции reduce() для вычисления суммы элементов в разных итерируемых объектах:
>>> from functools import
>>> reduce(lambda a, b: a
4
>>> reduce(lambda a, b: a
6
>>> reduce(lambda a, b: a
4950
Один интересный аспект функций map() и filter() таков: они могут работать
с бесконечными последовательностями. Понятно, что программа в данном случае
будет работать вечно. Однако возвращаемые значения map() и filter() являются
итераторами, и мы уже узнали в этой главе, что можем получить новые значения
итераторов с помощью функции next(). Функция range(), которую мы использовали в предыдущих примерах, к сожалению, требует конечного входного значения, но модуль itertools предоставляет полезную функцию count(), которая
позволяет считать от определенного числа в любом направлении до бесконечности.
Следующий пример показывает, как можно использовать все эти функции, чтобы
декларативно сформировать бесконечную последовательность:
>>>
>>>
...
...
...
...
...
...
...
...
...
...
>>>
9
>>>
81
>>>
225
>>>
441
from itertools import count
sequence = filter(
# Нам нужны только числа, кратные 3,
# но не кратные 2
lambda square: square % 3 == 0 and square % 2 == 1,
map(
# Все числа должны быть квадратами чисел
lambda number: number ** 2,
# Считаем до бесконечности
count()
)
)
next(sequence)
next(sequence)
next(sequence)
next(sequence)
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 115
В отличие от функций map() и filter() функции reduce() приходится вычислять все элементы ввода, чтобы вернуть значение, поскольку она не дает промежуточных результатов. То есть она не может быть использована на бесконечных
последовательностях.
Рассмотрим частичные объекты и функцию partial().
Частичные объекты и функция partial()
Частичные объекты слабо связаны с концепцией частичных функций из математики. Частичной называется обобщенная математическая функция, в которой
не приходится отображать все возможные значения входного сигнала (домен)
на результаты. В Python частичные объекты могут служить для создания среза
входных значений данной функции путем фиксации значений некоторых ее аргументов.
В предыдущих разделах мы использовали выражение x ** 2, чтобы получить
квадрат значения х. В Python есть встроенная функция под названием pow(х, у),
которая позволяет вычислить любую степень любого числа. То есть lambda х: х **
2 является частичной функцией для pow(х, y), так как мы ограничиваем диапазон
значений y одним значением 2. Функция partial() из модуля functools предоставляет альтернативный способ определения частичных функций, не прибегая
к лямбда-функциям, которые иногда бывают громоздкими.
Допустим, теперь мы хотим создать несколько иную частичную функцию из
pow(). Недавно мы генерировали квадраты последовательных чисел. Теперь сузим
область других входных аргументов и предположим, что хотим сгенерировать ряд
степеней двойки: 1, 2, 4, 8, 16 и т. д.
Сигнатура конструктора частичного объекта выглядит так: partial(func, *args,
**keywords). Частичный объект станет вести себя подобно func, но его входные
аргументы будут предварительно заполнены из *args (начиная с самого левого)
и **keywords. Функция pow(х, у) не поддерживает именованные аргументы, поэтому предварительно заполнить крайний левый аргумент х можно следующим
образом:
>>> from functools import partial
>>> powers_of_2 = partial(pow, 2)
>>> powers_of_2(2)
4
>>> powers_of_2(5)
32
>>> powers_of_2(10)
1024
116 Часть II
•
Ремесло Python
Обратите внимание: не обязательно назначать функцию partial всякий раз,
когда вы хотите зафиксировать аргумент. Для одноразовых функций этот метод
так же хорош, как и лямбда-функции. Следующий пример показывает, как различные функции, которые были представлены в этой главе, можно использовать
для создания простого генератора бесконечных степеней двойки без явного определения функции:
from functools import partial
from itertools import count
infinite_powers_of_2 = map(partial(pow, 2), count())
Модуль itertools — это кладезь помощников и утилит для работы с итераторами любого типа. В нем есть различные функции, которые среди
прочего позволяют зацикливать контейнеры, группировать их содержимое, разделять итерируемые объекты на части и объединять их и т. д.,
и все функции в данном модуле возвращают итераторы. Если вам интересно функциональное программирование на Python, то вы должны
обязательно познакомиться с этим модулем.
В следующем подразделе рассмотрим выражения генераторов.
Выражения генераторов
Это один элемент, который позволяет писать код в более функциональном стиле.
Его синтаксис похож на тот, что используется со словарями, множествами и списочными литералами. Выражение генератора обозначается круглыми скобками,
как показано в следующем примере:
(item for item in iterable_expression)
Выражения генератора можно использовать в качестве входных аргументов
в любой функции, которая принимает итераторы. Данные выражения также позволяют использовать операторы if для фильтрации определенных элементов.
Это значит, что вы сможете заменить сложночитаемые map() и filter() более читаемыми и компактными выражениями генератора. Сравним один из предыдущих
примеров с выражением генератора, которое делает то же самое:
sequence = filter(
lambda square: square % 3 == 0 and square % 2 == 1,
map(
lambda number: number ** 2,
count()
)
)
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 117
sequence = (
square for square
in (number ** 2 for number in count())
if square % 3 == 0 and square % 2 == 1
)
Теперь поговорим об аннотациях функций и переменных.
Аннотации функций и переменных
Аннотации функций — одна из самых уникальных особенностей Python 3. Официальная документация гласит: «Аннотации — это необязательные метаданные,
информация о типах используемых функций, определяемых пользователем».
Но указание типа — еще не все. В Python и его стандартной библиотеке не существует ни одного элемента, у которого есть такие аннотации. Именно поэтому
данная особенность уникальна — она не имеет никакого синтаксического значения.
Аннотации могут быть определены для функции и вызваны во время ее выполнения. Что с ними делать — остается на усмотрение разработчика.
В следующих разделах рассмотрим общий синтаксис аннотаций и возможные
способы их использования.
Общий синтаксис
Слегка модифицированный пример из документации Python показывает, как
определить и получить аннотации функций:
>>> def f(ham: str, eggs: str = 'eggs') -> str:
...
pass
...
>>> print(f.__annotations__)
{'return': , 'eggs': , 'ham': }
Как видим, аннотации параметров определяются выражением, оценивающим
значение аннотации с двоеточием перед ним. Возвращаемые аннотации определяются выражением между двоеточием, обозначающим конец оператора def, и литералом ->, за которым следует список параметров.
Определенные аннотации доступны в атрибуте __annotations__ объекта в виде
словаря и могут быть извлечены во время выполнения приложения.
Тот факт, что любое выражение можно использовать в качестве аннотации и оно
расположено в непосредственной близости аргументов по умолчанию, позволяет
создавать некоторые запутанные определения функций, а именно:
>>> def square(number: 0 (\
...
+9000): return number**2
>>> square(10)
100
118 Часть II
•
Ремесло Python
Однако такое использование аннотаций не имеет никакой другой цели, кроме
запутывания, а написать трудночитаемый и труднообслуживаемый код легко
и без них.
Возможные способы применения
Несмотря на большой потенциал аннотаций, широкого применения они не нашли.
В статье о новых функциях Python 3 (см. https://docs.python.org/3/whatsnew/3.0.html)
говорится следующее: «Цель состоит в том, чтобы поощрить экспериментирование
с помощью метаклассов, декораторов или фреймворков».
С другой стороны, в документе PEP 3107, в котором официально были предложены аннотации функций, перечислен следующий набор возможных вариантов
использования.
Предоставление информации о вводе:
yy проверка типов;
yy отображение типов входных и выходных данных для функций;
yy перегрузка/генерация функций;
yy работа с иностранным языком;
yy адаптация;
yy использование предикатов логических функций;
yy отображение запроса базы данных;
yy определение параметров RPC.
Иная информация:
yy документация параметров и возвращаемых значений.
Хотя аннотации появились вместе с Python 3, на данный момент все еще очень
трудно найти какой-либо популярный и активно поддерживаемый пакет, в котором они бы использовались для чего-то еще, кроме проверки типов. Помимо
статической проверки типов, аннотации функций по-прежнему годятся только
для экспериментов — изначальной цели их добавления в первоначальную версию
Python 3.
Статическая проверка типа с помощью mypy
Статическая проверка типа — это метод, позволяющий быстро найти возможные
ошибки и дефекты в коде до его выполнения. Это нормальная черта компилируемых языков со статической типизацией. Python, конечно, не хватает таких
встроенных функций, но есть сторонние пакеты, которые позволяют проводить
в Python статический анализ типов, чтобы улучшить качество кода. Аннотации
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 119
функций и переменных в настоящее время лучше всего использовать в качестве
подсказок для статической проверки типов. Ведущий пакет статической проверки
на Python — это mypy. Он анализирует функции и аннотации, которые могут быть
определены с помощью иерархии подсказок от типов модулей (см. PEP 484).
Лучшее в mypy то, что подсказки типов использовать необязательно. При
наличии большой кодовой базы вам не придется аннотировать весь код, чтобы
добиться пользы от проверки статического типа. Вы можете просто начать постепенно вводить аннотации в наиболее используемые фрагменты кода и со
временем получить нужный результат. Кроме того, mypy поддерживается разработчиками на Python в виде typeshed-проекта. Typeshed (см. github.com/python/
typeshed) — это набор библиотек заглушек со статическими определениями типов
как для стандартной библиотеки, так и для многих популярных сторонних проектов.
Более подробную информацию о mypy и его консоли можно найти на официальной странице проекта в mypy-lang.org.
Иные элементы синтаксиса, о которых вы,
возможно, не знаете
В Python есть непопулярные и редко задействуемые элементы. Это связано с тем,
что они не слишком полезны. Как следствие, многие программисты на Python
(даже с многолетним опытом) просто не знают об их существовании. В числе наиболее ярких примеров можно назвать:
оператор for… else…;
именованные аргументы.
Оператор for… else…
Использование оператора else после цикла for позволяет выполнить блок кода,
если цикл закончился естественным образом (без оператора break):
>>> for number in
...
break
... else:
...
print("no
...
>>> for number in
...
pass
... else:
...
print("no
...
no break
range(1):
break")
range(1):
break")
120 Часть II
•
Ремесло Python
Это позволяет избавиться от некоторых сигнальных переменных, в которых хранится информация о том, произошел ли экстренный выход из цикла. Код становится
чище, но это может сбить с толку программистов, незнакомых с таким синтаксисом.
Некоторые говорят, что подобное использование else противоречит здравому смыслу, но вот простой совет, который поможет вам вспомнить, как это работает, — else
срабатывает после for, если не было break.
Именованные аргументы
Конструкция for… else… скорее курьезная, и мало кто из разработчиков стремится
использовать ее, но есть и еще более экзотическая часть синтаксиса Python, которую стоило бы применять чаще. Это именованные аргументы.
Именованные аргументы появились в Python довольно давно, но их можно
было использовать только в ряде встроенных функций или расширений, построенных с помощью API Python/C. Только начиная с Python 3.0,именованные
аргументы стали официальным элементом синтаксиса языка, который можно использовать в любой сигнатуре функции. В сигнатурах функций каждый именованный аргумент определяется после одного символа *. То есть вы не можете передать
значение в качестве позиционного аргумента.
Чтобы лучше понять, какую проблему позволяют решить такие аргументы, рассмотрим следующий набор функциональных заглушек, которые были определены
без этой функции:
def process_order(order, client, suppress_notifications=False):
...
def open_order(order, client):
...
def archive_order(order, client):
...
Этот API довольно последователен. Четко видно, что каждая функция имеет
по два аргумента, вероятно весьма важные для любой программы, в которой выполняется работа с заказами. Кроме того, в функции process_order() появился
аргумент suppress_notifications. У него есть значение по умолчанию, то есть
это, скорее всего, флаг, который можно включать и выключать. Мы не знаем, что
делает данная программа, но можем предположить, как использовать эти функции.
Самый простой пример выглядит следующим образом:
order = ...
client = ...
open_order(order, client)
process_order(order, client)
archive_order(order, client)
Глава 3.
Современные элементы синтаксиса — ниже уровня класса 121
Все кажется ясным и простым. Тем не менее любопытный разработчик API увидит в таком интерфейсе нечто странное. При необходимости подавить уведомления
в функции process_order() пользователь API может сделать это двумя способами:
process_order(order, client, suppress_notifications=True)
process_order(order, client, True)
Первый вариант лучше, так как семантика функции будет ясной и понятной.
Здесь два крайних слева аргумента (order и client) лучше представить в виде
позиционных аргументов, поскольку они связаны с содержательными именами
переменных и их положение обычное для API. Значение аргумента suppress_
notifications будет полностью утрачено, если мы представим его в виде простого
True.
Что еще более тревожно, так это то, что такие нестрогие ограничения на использование API ставят разработчика в довольно неудобное положение, и он должен
быть предельно осторожным при расширении существующих интерфейсов. Представим, что потребовалась возможность отмены платежа по запросу. Это можно
сделать, добавив новый аргумент с именем suppress_payment. Изменение сигнатуры
будет довольно простым:
def process_order(
order, client,
suppress_notifications=False,
suppress_payment=False,
):
...
Для нас все ясно — suppress_notifications и suppress_payment должны быть
поданы функции в качестве именованных, а не позиционных аргументов. А вот
пользователям это может быть неясно. Очень скоро мы начнем видеть примерно
такое:
process_order(order,
process_order(order,
process_order(order,
process_order(order,
process_order(order,
process_order(order,
Такой паттерн опасен еще по одной причине. Представьте, что кто-то хуже
знает общую структуру API. Этот человек добавил новый аргумент, но не в конце
списка аргументов, а перед другими аргументами, которые должны были быть использованы в качестве ключевых слов. Такая ошибка нарушит вообще все вызовы
функций, поскольку аргументы поменяют позицию.
В больших проектах чрезвычайно трудно уберечь код от таких ошибок. Без
достаточной защиты каждый неправильно употребленный вызов функций станет
создавать проблемы в большом количестве. Лучший способ защитить свои функции
122 Часть II
•
Ремесло Python
от такой «порчи» — явно указывать, какие аргументы следует использовать в качестве ключевых слов. Для нашего примера это будет выглядеть следующим образом:
def process_order(
order, client,
*,
suppress_notifications=False,
suppress_payment=False,
):
...
Резюме
В этой главе мы рассмотрели различные практические рекомендации по синтаксису, которые не имеют прямого отношения к классам Python и объектно-ориентированному программированию. Мы начали с анализа синтаксиса основных
встроенных типов, а также технических деталей их реализации на интерпретаторе
CPython.
Систематизировав наши базовые знания о встроенных типах Python, мы наконец перешли к действительно серьезным элементам Python: итераторам, генераторам, декораторам и менеджерам контекста. Конечно, мы не могли полностью
обойтись без классов, так как все в Python является объектом и даже элементы
синтаксиса, которые не являются объектно-ориентированными, в сути своей
определены на уровне класса. Чтобы оправдать выбор названия этой главы, мы
рассмотрели еще один важный аспект программирования на Python — функции
языка, позволяющие программировать в функциональном стиле.
Чтобы закончить главу на более легкой ноте, мы рассмотрели менее известные,
но от этого не менее важные и полезные особенности языка Python.
В следующей главе мы будем применять все изученное до сих пор, чтобы лучше
понять объектно-ориентированные возможности Python. Мы подробнее рассмотрим понятие протоколов языка и порядок разрешения методов. Мы увидим, что
каждая парадигма в Python имеет свое место, и поймем, как объектно-ориентированные элементы языка делают его более гибким.
4
Современные элементы
синтаксиса — выше
уровня класса
В этой главе мы сосредоточимся на современных элементах синтаксиса Python
и подробнее поговорим о классах и объектно-ориентированном программировании.
Однако мы не будем касаться темы объектно-ориентированных паттернов проектирования, так как им посвящена глава 17. В данной главе мы проведем обзор
самых передовых элементов синтаксиса Python, которые позволят улучшить код
ваших классов.
Модель классов Python, известная нам, сильно эволюционировала в процессе
истории Python 2. Долгое время мы жили в мире, в котором две реализации парадигмы объектно-ориентированного программирования сосуществовали на одном
языке. Эти две модели были названы старым и новым стилем класса. Python 3 положил конец этой дихотомии, так что разработчикам доступен только новый стиль.
Но по-прежнему важно знать, как обе модели работали в Python 2, поскольку это
поможет в случае, если потребуется портировать старый код и написать обратно
совместимые приложения. Знание того, как изменилась объектная модель, также
поможет понять, почему сейчас она такая, какая есть. Именно по этой причине
в следующей главе мы частенько будем говорить о Python 2, несмотря на то что
книга посвящена последним версиям Python 3.
В этой главе:
протоколы языка Python;
сокращение шаблонного кода с помощью классов данных;
создание подклассов встроенных типов;
доступ к методам из суперклассов;
слоты.
124 Часть II
•
Ремесло Python
Технические требования
Файлы с примерами кода для этой главы можно найти по ссылке github.com/
PacktPublishing/Expert-Python-Programming-Third-Edition/tree/master/chapter4.
Протоколы в языке Python — методы и атрибуты
с двойным подчеркиванием
Модель данных Python определяет много специально именованных методов, которые могут быть переопределены в пользовательских классах и тем самым расширять их синтаксис. Вы можете узнать эти методы по обрамляющему их названия
двойному подчеркиванию — таково соглашение по наименованию для данных методов. Из-за этого они иногда называются dunder (сокращение от double underline —
«двойное подчеркивание»).
Наиболее распространенный и очевидный пример — метод __init__(), который используется для инициализации экземпляра класса:
class CustomUserClass:
def __init__(self, initiatization_argument):
...
Эти методы, определенные отдельно или в комбинации, представляют собой так
называемые языковые протоколы. Если объект реализует конкретные протоколы
языка, то становится совместимым с конкретными частями синтаксиса Python.
В табл. 4.1 приведены наиболее важные протоколы языка Python.
Таблица 4.1
Протокол
Методы
Описание
Протокол вызываемых
объектов
__call__()
Объекты можно вызывать с помощью скобок:
Протоколы
дескрипторов
__set__(), __get__()
и __del__()
Позволяют манипулировать паттерном
атрибутов доступа в классах (см. подраздел
«Дескрипторы» на с. 142)
Протокол контейнеров
__contains__()
Позволяет проверить, содержит ли объект
некое значение через ключевое слово in:
instance()
value in instance
Протокол итерируемых __iter__()
объектов
Позволяет объектам быть итерируемыми
и использоваться для цикла for:
for value in instance:
...
Глава 4. Современные элементы синтаксиса — выше уровня класса 125
Протокол
Методы
Описание
Протоколы
последовательности
__len__(),
Позволяют организовать индексацию
объектов через синтаксис квадратных
скобок и определять их длину с помощью
встроенной функции:
__getitem__()
item = instance[index]
length = len(instance)
Это наиболее важные протоколы языка с точки зрения данной главы. Полный
список, конечно, гораздо длиннее. Например, в Python есть более 50 таких методов, которые позволяют эмулировать числовые значения. Каждый из этих методов
коррелирует с конкретным математическим оператором и поэтому может рассматриваться как отдельный протокол языка. Полный список всех методов с двойным
подчеркиванием можно найти в официальной документации модели данных Python
(см. docs.python.org/3/reference/datamodel.html).
Языковые протоколы — основа концепции интерфейсов в Python. Одна из реа
лизаций интерфейсов Python — это абстрактные базовые классы, которые позволяют задать произвольный набор атрибутов и методов в определении интерфейса.
Такие определения интерфейсов в виде абстрактных классов могут впоследствии
служить для проверки совместимости данного объекта с конкретным интерфейсом. Модуль collections.abc из стандартной библиотеки Python включает набор
абстрактных базовых классов, которые относятся к наиболее распространенному
протоколу языка Python. Более подробную информацию об интерфейсах и абстрактных базовых классах см. в пункте «Интерфейсы» на с. 530.
То же соглашение об именах применяется для определенных атрибутов пользовательских функций и хранения различных метаданных об объектах Python.
Рассмотрим эти атрибуты:
__doc__ — перезаписываемый атрибут, который содержит документацию функции. По умолчанию заполняется функцией docstring;
__name__ — перезаписываемый атрибут, содержащий имя функции;
__qualname__ — перезаписываемый атрибут, который содержит полное имя
функции, то есть полный путь к объекту (с именами классов) в глобальной области видимости модуля, в котором определен объект;
__module__ — перезаписываемый атрибут, содержащий имя модуля, к которому
принадлежит функция;
__defaults__ — перезаписываемый атрибут, который содержит значения аргу-
ментов по умолчанию, если у функции есть таковые;
__code__ — перезаписываемый атрибут, содержащий код объекта компиляции
функции;
126 Часть II
•
Ремесло Python
__globals__ — атрибут только для чтения, который содержит ссылку на словарь
глобальных переменных сферы действия этой функции. Сфера действия — пространство имен модуля, где определена эта функция;
ции. Функции в Python являются объектами первого класса, поэтому могут
иметь любые произвольные аргументы, так же как и любой другой объект;
__closure__ — атрибут только для чтения, который содержит кортеж клеток со
свободными переменными функции. Позволяет создавать параметризованные
функции декораторов;
__annotations__ — перезаписываемый атрибут, который содержит аргумент
функции и возвращает аннотации;
__kwdefaults__ — перезаписываемый атрибут, содержащий значение аргументов
по умолчанию для именованных аргументов, если у функции они есть.
Далее рассмотрим, как сократить шаблонный код с помощью классов данных.
Сокращение шаблонного кода
с помощью классов данных
Прежде чем углубиться в обсуждение классов Python, сделаем небольшое отступление. Мы обсудим относительно новые дополнения языка Python — а именно,
классы данных. Модуль dataclasses, введенный в Python 3.7, включает в себя
декоратор и функцию, которая позволяет легко добавлять сгенерированные специальные методы в пользовательские классы.
Рассмотрим следующий пример. Мы разрабатываем программу, выполняющую
некие геометрические вычисления, и нам нужен класс, который позволяет хранить
информацию о двумерных векторах. Мы будем выводить данные векторов на экран
и выполнять простые математические операции, такие как сложение, вычитание
и проверка равенства. Нам уже известно, что для этой цели можно использовать
специальные методы. Мы можем реализовать наш класс Vector следующим образом:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
"""Два вектора с оператором +"""
return Vector(
self.x + other.x,
self.y + other.y,
)
Глава 4. Современные элементы синтаксиса — выше уровня класса 127
def __sub__(self, other):
"""Вычитание векторов оператором -"""
return Vector(
self.x - other.x,
self.y - other.y,
)
def __repr__(self):
"""Возвращает текстовое представление вектора"""
return f""
def __eq__(self, other):
"""Сравнение векторов на равенство"""
return self.x == other.x and self.y == other.y
Ниже приведен пример интерактивной сессии, где показано поведение программы при использовании обычных операторов:
>>> Vector(2,
>> Vector(5,
>> Vector(5,
>> Vector(1,
False
>>> Vector(2,
True
Данная реализация вектора довольно проста, но в ней много повторяющегося
кода, от которого можно было бы избавиться. Если в вашей программе используется
много подобных простых классов, которые не требуют сложной инициализации,
то понадобится много кода только для методов __init__(), __repr__() и __eq__().
С помощью модуля dataclasses мы можем сделать код класса Vector намного
короче:
from dataclasses import dataclass
@dataclass
class Vector:
x: int
y: int
def __add__(self, other):
"""Два вектора с оператором +"""
return Vector(
self.x + other.x,
self.y + other.y,
)
def __sub__(self, other):
"""Вычитание векторов оператором -"""
Декоратор класса dataclass считывает аннотации атрибута класса Vector
и автоматически создает методы __init__(), __repr__() и __eq__(). Проверка на
равенство по умолчанию предполагает равенство двух экземпляров, если все соответствующие атрибуты равны друг другу.
Но это не все. Классы данных предлагают множество полезных функций. Они легко совместимы с другими протоколами Python. Предположим, мы хотим, чтобы наши
экземпляры класса Vector были неизменяемыми. В таком случае они могут быть использованы в качестве ключей словаря или входить во множество. Вы можете сделать
это, просто добавив в декоратор аргумент frozen=True, как в примере ниже:
@dataclass(frozen=True)
class FrozenVector:
x: int
y: int
Такой замороженный класс Vector становится совершенно неизменяемым, и вы
не сможете изменить ни один из его атрибутов. Но складывать и вычитать векторы
все еще можно, как и показано в примере, поскольку эти операции просто создают
новый объект Vector.
В завершение разговора о классах данных в этой главе отметим, что вы можете
задать значения для определенных атрибутов по умолчанию с помощью конструктора field(). Можно использовать и статические значения, и конструкторы других
объектов. Рассмотрим следующий пример:
>>> @dataclass
... class DataClassWithDefaults:
...
static_default: str = field(default="this is static default value")
...
factory_default: list = field(default_factory=list)
...
>>> DataClassWithDefaults()
DataClassWithDefaults(static_default='this is static default value',
factory_default=[])
В следующем разделе мы поговорим о подклассах встроенных типов.
Создание подклассов встроенных типов
Создать подклассы встроенных типов в Python довольно просто. Встроенный тип
object — общий предок для всех встроенных типов, а также всех пользовательских
классов, не имеющих явно указанного родительского класса. Благодаря этому каждый раз, когда вам нужно реализовать класс, который ведет себя почти как один из
встроенных типов, лучше всего сделать его подтипом.
Глава 4. Современные элементы синтаксиса — выше уровня класса 129
Теперь рассмотрим код класса под названием distinctdict, где используется
именно такой метод. Это будет подкласс обычного типа dict. Этот новый класс
будет вести себя в основном так же, как обычный тип Python dict. Но вместо того,
чтобы допускать наличие нескольких ключей с одним значением, при добавлении
значения он вызывает подкласс ValueError со справочным сообщением.
Как уже было сказано, встроенный тип dict является объектом подкласса:
>>> isinstance(dict(), object)
True
>>> issubclass(dict, object)
True
Это значит, что мы могли бы легко определить собственный словарь в виде
подкласса:
class distinctdict(dict):
...
Описанный ранее подход будет работать как надо, поскольку подклассы из типов dict, list и str были разрешены, начиная с версии Python 2.2. Но, как правило,
лучше всего создавать подкласс с помощью модулей collections:
collections.UserDict;
collections.UserList;
collections.UserString.
С этими классами, как правило, легче работать, поскольку обычные объекты
dict, list и str сохраняются в виде атрибутов данных этих классов.
Ниже приведен пример реализации типа distinctdict , который отменяет
часть свойств словаря, чтобы он теперь мог содержать только уникальные значения:
from collections import UserDict
class DistinctError(ValueError):
"""Выдается, когда в distinctdict добавляется дубликат"""
class distinctdict(UserDict):
"""Словарь, в который нельзя добавлять дублирующиеся значения"""
def __setitem__(self, key, value):
if value in self.values():
if (
(key in self and self[key] != value) or
key not in self
):
raise DistinctError(
"This value already exists for different key"
)
super().__setitem__(key, value)
130 Часть II
•
Ремесло Python
Ниже приведен пример использования класса distinctdict в интерактивной
сессии:
>>> my = distinctdict()
>>> my['key'] = 'value'
>>> my['other_key'] = 'value'
Traceback (most recent call last):
File "", line 1, in
File "", line 10, in __setitem__
DistinctError: This value already exists for different key
>>> my['other_key'] = 'value2'
>>> my
{'key': 'value', 'other_key': 'value2'}
>>> my.data
{'key': 'value', 'other_key': 'value2'}
Если вы посмотрите на имеющийся у вас код, то найдете много классов, которые
частично реализуют протоколы или функции встроенных типов. Данные классы
стали бы работать быстрее и чище, будь они реализованы как подтипы этих типов.
Тип list, например, управляет последовательностями любого типа, и вы можете
использовать его всякий раз, когда ваш класс обрабатывает последовательности
или коллекции.
Ниже приведен простой пример класса Folder, который является подклассом
list для вывода содержимого каталогов в виде древовидной структуры и управления им:
from collections import UserList
class Folder(UserList):
def __init__(self, name):
self.name = name
def dir(self, nesting=0):
offset = " " * nesting
print('%s%s/' % (offset, self.name))
for element in self:
if hasattr(element, 'dir'):
element.dir(nesting + 1)
else:
print("%s %s" % (offset, element))
Обратите внимание: на самом деле мы создали подкласс класса UserList из
модуля collections, а не чистого list. Вы можете создавать подклассы чистых
встроенных типов, например строк, словарей и множеств, но желательно использовать их двойники из модуля collections, поскольку подклассы в этом случае
создавать немного легче.
Глава 4. Современные элементы синтаксиса — выше уровня класса 131
Ниже приведен пример использования нашего класса Folder в интерактивном
режиме:
>>> tree = Folder('project')
>>> tree.append('README.md')
>>> tree.dir()
project/
README.md
>>> src = Folder('src')
>>> src.append('script.py')
>>> tree.append(src)
>>> tree.dir()
project/
README.md
src/
script.py
>>> tree.remove(src)
>>> tree.dir()
project/
README.md
Встроенных типов достаточно для большинства задач
Собираясь создать новый класс, который действует как последовательность или отображение, подумайте о его особенностях и просмотрите
существующие встроенные типы. Модуль collections расширяет основные
списки встроенных типов с помощью множества полезных контейнеров.
Вы часто будете использовать один из них, что избавит вас от необходимости создавать собственные подклассы.
В следующем разделе поговорим о порядке разрешения методов (ПРМ).
ПРМ и доступ к методам из суперклассов
Класс super — это встроенный класс, который можно использовать для доступа
к атрибуту, принадлежащему родительскому объекту.
В официальной документации super — встроенная функция, однако на
самом деле это встроенный класс, даже если используется как функция:
>>> super
>>> isinstance(super, type)
Его сложновато использовать, если вы привыкли обращаться к атрибуту или
методу класса через вызов родительского класса и передачу self в качестве первого
132 Часть II
•
Ремесло Python
аргумента. Это старый паттерн, но его все еще можно найти в некоторых кодовых
базах (особенно в устаревших проектах). Смотрите следующий код:
class Mama: # Старый способ
def says(self):
print('do your homework')
class Sister(Mama):
def says(self):
Mama.says(self)
print('and clean your bedroom')
Обратите внимание на строку Mama.says(self). Здесь явно используется родительский класс. Это значит, что будет вызван метод says(), принадлежащий Mama.
Но экземпляр, на котором он будет вызываться, указывается в аргументе self,
который в данном случае является экземпляром Sister.
А если использовать super, то код будет выглядеть следующим образом:
class Sister(Mama):
def says(self):
super(Sister, self).says()
print('and clean your bedroom')
Кроме того, можно также использовать более короткую форму вызова super():
class Sister(Mama):
def says(self):
super().says()
print('and clean your bedroom')
Внутри методов допускается сокращенная форма super (без каких-либо аргументов), но использование этого класса не ограничивается телом методов.
Его можно задействовать в любой части кода, где требуется явный вызов метода
суперкласса. Однако если super не используется внутри тела метода, то все его
аргументы являются обязательными:
>>> anita = Sister()
>>> super(anita.__class__, anita).says()
do your homework
Последнее и самое важное, что следует отметить, — второй аргумент функции
класса super является необязательным. Когда указан только первый аргумент, super
возвращает неограниченный тип. Это особенно полезно при работе с classmethod:
class Pizza:
def __init__(self, toppings):
self.toppings = toppings
def __repr__(self):
return "Pizza with " + " and ".join(self.toppings)
Глава 4. Современные элементы синтаксиса — выше уровня класса 133
@classmethod
def recommend(cls):
"""Здесь не помешала бы пицца, причем с начинкой"""
return cls(['spam', 'ham', 'eggs'])
class VikingPizza(Pizza):
@classmethod
def recommend(cls):
"""Используем ту же рекомендацию, что и для super,
но добавляем немного «воды»"""
recommended = super(VikingPizza).recommend()
recommended.toppings += ['spam'] * 5
return recommended
Обратите внимание: вариация super() без аргументов также допускается для
методов, обработанных декоратором classmethod. Если super() вызывается без
аргументов в таких методах, то считается, что определен только первый аргумент.
Показанные случаи использования просты и понятны, но, когда вы сталкиваетесь со сложными схемами наследования, применять super становится трудно.
Прежде чем объяснять эту проблему, нужно понять, когда следует избегать super
и как в Python работает порядок разрешения методов (ПРМ).
Обсудим классы старого стиля и суперклассы в Python 2.
Классы старого стиля
и суперклассы в Python 2
super() в Python 2 работает почти точно так же, как и в Python 3. Единственное
отличие заключается в том, что название короче, форма без аргументов недоступна, поэтому всегда должен использоваться по меньшей мере один из ожидаемых
аргументов.
Еще один важный нюанс для программистов, которым приходится писать совместимый между версиями код, заключается в том, что super в Python 2 работает
только для новых классов. Более ранние версии Python не имеют общего предка
object для всех классов. Старое поведение сохранено во всех версиях Python 2.x
для обратной совместимости, поэтому в данных версиях, если определение класса
не имеет явно указанного предка, он интерпретируется как класс старого стиля
и super в нем использоваться не может:
class OldStyle1:
pass
class OldStyle2(OldStyle1):
pass
134 Часть II
•
Ремесло Python
Классы нового стиля в Python 2 должны явно наследовать от типа object или
другого класса нового стиля:
class NewStyleClass(object):
pass
class NewStyleClassToo(NewStyleClass):
pass
Python 3 больше не поддерживает концепцию классов старого стиля, поэтому
любой класс, который явно не наследует от любого другого класса, неявно наследует от object. Это значит, что явное указание наследования от object может
показаться излишним. Обычно принято не включать в программу избыточный
код, но удаление такой избыточности на самом деле подходит только для проектов, которые не планируется запускать на Python 2. Код, в котором должна быть
совместимость, должен включать в себя object как предка базовых классов, даже
если это является излишним в Python 3. В противном случае такие классы будут
интерпретироваться по старому стилю, и это в конечном итоге приведет к проблемам, которые очень трудно диагностировать.
Разберемся с ПРМ в Python в следующем подразделе.
Понимание ПРМ в Python
ПРМ в Python основан на C3, созданном для языка программирования Dylan
(opendylan.org). Справочный документ, написанный Микеле Симионато, можно
найти по адресу www.python.org/download/releases/2.3/mro. В документе показано,
как C3 строит линеаризацию класса или приоритет, который представляет собой
упорядоченный список предков. Этот список используется для поиска атрибута.
Алгоритм С3 более подробно описан ниже.
Изменение ПРМ позволяет устранить проблему, появившуюся после создания
общего базового типа (то есть типа object). До перехода на C3 если класс имел два
предка (рис. 4.1), то порядок разрешения методов было довольно легко вычислить
и отследить только в простых случаях, когда не использовалось несколько моделей
наследования.
Ниже приведен пример кода, который в соответствии с Python 2 не будет использовать C3:
class Base1:
pass
class Base2:
def method(self):
print('Base2')
class MyClass(Base1, Base2):
pass
Глава 4. Современные элементы синтаксиса — выше уровня класса 135
>>> MyClass().method()
Base2
Когда вызывается MyClass().method(), интерпретатор ищет метод в MyClass,
затем в Base1 и в конечном итоге находит его в Base2.
Рис. 4.1. Классическая иерархия
Когда мы вводим класс CommonBase на вершине нашей иерархии классов (Base1
и Base2 унаследуют от него; рис. 4.2), все усложнится. В результате вместо того,
чтобы следовать простому порядку разрешения, работающему по правилу «слева направо от нижних к верхним», мы вернемся к вершине через класс Base1, прежде чем
заглянуть в Base2. Этот алгоритм дает результат, противоречащий интуитивному.
Выполняемый метод — не обязательно ближайший метод в дереве наследования.
Рис. 4.2. Иерархия классов типа Diamond
136 Часть II
•
Ремесло Python
Этот алгоритм по-прежнему доступен в Python 2 для классов старого стиля.
Ниже представлен пример старого разрешения метода в Python 2 с помощью таких
классов:
class CommonBase:
def method(self):
print('CommonBase')
class Base1(CommonBase):
pass
class Base2(CommonBase):
def method(self):
print('Base2')
class MyClass(Base1, Base2):
pass
Приведенный ниже текст интерактивной сессии показывает, что Base2.method()
не будет вызываться, несмотря на то что класс Base2 находится ближе в иерархии
классов к MyClass, чем CommonBase:
>>> MyClass().method()
CommonBase
Такой сценарий наследования используется крайне редко, поэтому данная проблема скорее теоретическая, чем практическая. Стандартная библиотека не структурирует иерархию наследования подобным образом, и многие разработчики
считают, что так делать не стоит. Но с введением object как родителя всех типов
проблема множественного наследования всплывает на стороне языка C, что приводит к конфликтам при выполнении подтипов. Следует также отметить: каждый
класс в Python 3 теперь имеет общего предка. Поскольку заставить его работать
должным образом с существующим ПРМ слишком сложно, проще было ввести
новый ПРМ.
Итак, тот же пример в Python 3 дает другой результат:
class CommonBase:
def method(self):
print('CommonBase')
class Base1(CommonBase):
pass
class Base2(CommonBase):
def method(self):
print('Base2')
Глава 4. Современные элементы синтаксиса — выше уровня класса 137
class MyClass(Base1, Base2):
pass
А этот пример показывает, что при использовании сериализации С3 будет применяться метод ближайшего предка:
>>> MyClass().method()
Base2
Обратите внимание: такое поведение не может быть воспроизведено
в Python 2 без явного наследования CommonBase от object. Как следствие,
бывает полезно напрямую указывать такое наследование в Python 3,
даже если это является избыточным, — о чем мы и говорили в предыдущем подразделе.
ПРМ в Python основан на рекурсивном вызове через базовые классы. Мы говорили о работе Мишеля Симионато в начале этого подраздела — так вот, символическое обозначение C3 для нашего примера будет выглядеть следующим
образом:
L[MyClass(Base1, Base2)] = MyClass + merge(L[Base1], L[Base2], Base1, Base2)
Здесь L[MyClass] — это линеаризация MyClass, а merge — специфический алгоритм, который объединяет несколько результатов линеаризации.
Таким образом, как говорит в своей статье Симионато, «линеаризация C является суммой C и слияния линеаризации родителей и списка родителей».
Алгоритм merge отвечает за удаление дубликатов и сохранение правильного
порядка. Вот как он описан в документе (с адаптацией под наш пример): «Берем
первый элемент первого списка, то есть L[Base1][0]; если он не находится в хвосте
каких-либо других списков, то добавляем его к линеаризации MyClass и удаляем из
списков в merge, в противном случае смотрим на начало следующего списка и берем
его, если подходит. Затем повторяем эту операцию, пока все классы не будут удалены или окажется невозможно найти хорошие первые элементы. В таком случае
нельзя построить слияние, и Python 2.3 откажется от создания класса MyClass
и выбросит исключение».
Элемент head — первый в списке, а tail содержит остальные элементы. Так,
например, в (BASE1, Base2, ..., BaseN) элемент Base1 — это head, а (Base2, ...,
BaseN) — хвост (tail).
Иными словами, С3 выполняет рекурсивный поиск на глубину каждого из родителей, чтобы получить последовательность списков. Затем он вычисляет правило
«слева направо», чтобы объединить все списки с неоднозначностью иерархии, если
класс входит в несколько списков.
138 Часть II
•
Ремесло Python
Итак, результат выглядит следующим образом:
def L(klass):
return [k.__name__ for k in klass.__mro__]
>>> L(MyClass)
['MyClass', 'Base1', 'Base2', 'CommonBase', 'object']
Атрибут класса __mro__ (который доступен только для чтения) хранит
результат вычисления линеаризации. Расчет делается в тот момент,
когда загружается определение класса.
Кроме того, можно вызвать MyClass.mro(), чтобы вычислить и получить
результат. Это еще одна причина, почему классы в Python 2 нужно рассматривать как отдельный кейс. У классов старого стиля в Python 2 есть
определенный порядок, в котором разрешаются методы, но в них нет
атрибута __mro__. Таким образом, несмотря на порядок разрешения,
неправильно говорить, что у них есть ПРМ. В большинстве случаев, когда
кто-то говорит о ПРМ в Python, имеется в виду алгоритм С3, описанный
в данном разделе.
Теперь обсудим ряд проблем, с которыми сталкиваются программисты.
Ловушки суперкласса
Теперь вернемся к вызову super(). Если вы работаете с иерархией множественного
наследования, то здесь могут быть проблемы. В основном они связаны с инициализацией классов. В Python методы инициализации базовых классов (то есть методы
__init__) не вызываются неявно в классах предка, если классы предка переопределяют __init__. В таких случаях необходимо вызывать методы суперкласса явным
образом, и иногда это может привести к проблемам инициализации.
В этом подразделе мы рассмотрим несколько примеров таких проблемных
ситуаций.
Смешивание вызова суперкласса и явного вызова
В следующем примере, взятом с сайта Джеймса Найта (fuhm.net/super-harmful), есть
класс С, который вызывает методы инициализации своих родительских классов
с помощью super().
class A:
def __init__(self):
print("A", end=" ")
super().__init__()
Глава 4. Современные элементы синтаксиса — выше уровня класса 139
class B:
def __init__(self):
print("B", end=" ")
super().__init__()
class C(A, B):
def __init__(self):
print("C", end=" ")
A.__init__(self)
B.__init__(self)
Результат:
>>> print("MRO:", [x.__name__ for x in C.__mro__])
MRO: ['C', 'A', 'B', 'object']
>>> C()
C A B B
Как видно, инициализация класса С вызывает B.__init()__ дважды. Чтобы
избежать подобных проблем, super надо использовать в иерархии целого класса.
Проблема заключается в том, что иногда часть такой сложной иерархии может
быть расположена в стороннем коде, в результате чего она будет недоступна для
понимания. О других ловушках, связанных с неясностью иерархии и правил наследования, можно почитать на странице Джеймса.
К сожалению, вы не можете быть уверены в том, что внешние пакеты используют в коде super(). Всякий раз, когда нужно создать подкласс какого-то
стороннего класса, стоит заглянуть в его код и код других классов в ПРМ. Это
может быть утомительно, но в качестве бонуса вы получите некую информацию
о качестве кода в пакете и будете его лучше понимать, да и вообще узнаете чтото новое.
Гетерогенные аргументы
Еще одна проблема с super возникает в случае, если методы классов в пределах
иерархии классов используют несовместимые наборы аргументов. Как класс может
вызывать базовый класс __init__(), если у него не такая же сигнатура? Это приводит к следующей задаче:
class CommonBase:
def __init__(self):
print('CommonBase')
super().__init__()
class Base1(CommonBase):
def __init__(self):
print('Base1')
super().__init__()
140 Часть II
•
Ремесло Python
class Base2(CommonBase):
def __init__(self, arg):
print('base2')
super().__init__()
class MyClass(Base1 , Base2):
def __init__(self, arg):
print('my base')
super().__init__(arg)
Попытка создать экземпляр MyClass вызовет TypeError из-за несоответствия
сигнатур родительских классов __init__:
>>> MyClass(10)
my base
Traceback (most recent call last):
File "", line 1, in
File "", line 4, in __init__
TypeError: __init__() takes 1 positional argument but 2 were given
Одним из решений было бы применить упаковку аргументов и именованных
аргументов с помощью *args и **kwargs, так что все конструкторы будут передавать
все параметры, даже если не используют их:
class CommonBase:
def __init__(self, *args, **kwargs):
print('CommonBase')
super().__init__()
class Base1(CommonBase):
def __init__(self, *args, **kwargs):
print('Base1')
super().__init__(*args, **kwargs)
class Base2(CommonBase):
def __init__(self, *args, **kwargs):
print('base2')
super().__init__(*args, **kwargs)
class MyClass(Base1 , Base2):
def __init__(self, arg):
print('my base')
super().__init__(arg)
При таком подходе сигнатуры родительского класса всегда будут совпадать:
>>> _ = MyClass(10)
my base
Base1
base2
CommonBase
Глава 4. Современные элементы синтаксиса — выше уровня класса 141
Но это ужасный костыль, поскольку в таком случае все конструкторы будут
принимать вообще любые параметры. То есть любые баги тоже пройдут. Другое
решение — использовать явный вызов __init__() в MyClass, но это привело бы
к первой ловушке, о которой мы говорили.
В следующем подразделе мы обсудим практические рекомендации.
Практические рекомендации
Чтобы избежать всех вышеуказанных проблем и до тех пор, пока Python не эволюционировал в этой области, придется принять во внимание следующие моменты.
Следует избегать множественного наследования — вместо него можно задей-
ствовать паттерны проектирования, представленные в главе 17.
Использовать super нужно последовательно — в иерархии классов super следует
применять либо везде, либо нигде. Смешивание super и классических классов
ведет к путанице. Программисты, как правило, избегают super, чтобы сделать
код более ясным.
Применять явное наследование от объекта в Python 3, если нужна совмести-
мость с Python 2 — классы без какого-либо предка в Python 2 считаются классами старого стиля. Смешения классов старого стиля с новыми классами
в Python 2 следует избегать.
Нужно просмотреть иерархию классов перед вызовом метода родительского
класса — чтобы избежать каких-либо проблем, каждый раз при вызове метода
класса родителя следует обязательно изучить ПРМ (с помощью __mro__).
Посмотрим на паттерны доступа для расширенных атрибутов.
Паттерны доступа к расширенным атрибутам
Изучая Python, многие программисты C++ и Java удивляются отсутствию ключевого слова private. Наиболее близкая к нему концепция — это искажение (декорирование) имени (name mangling). Каждый раз, когда атрибут получает префикс __,
он динамически переименовывается интерпретатором:
class MyClass:
__secret_value = 1
Доступ к атрибуту __secret_value по его изначальному имени приведет к выбрасыванию исключения AttributeError:
>>> instance_of = MyClass()
>>> instance_of.__secret_value
Это сделано специально для того, чтобы избежать конфликта имен по наследованию, так как атрибут переименовывается именем класса в качестве префикса.
Это не точный аналог private, поскольку атрибут может быть доступен через составленное имя. Данное свойство можно применить для защиты доступа некоторых
атрибутов, однако на практике __ не используется никогда. Если атрибут не является публичным, то принято использовать префикс _. Он не вызывает алгоритм
декорирования имени, но документирует атрибут как приватный элемент класса
и является преобладающим стилем.
В Python есть и другие механизмы, позволяющие отделить публичную часть
класса от приватной. Дескрипторы и свойства дают возможность аккуратно оформить такое разделение.
Дескрипторы
Дескриптор позволяет настроить действие, которое происходит, когда вы ссылаетесь
на атрибут объекта.
Дескрипторы лежат в основе организации сложного доступа к атрибутам
в Python. Они используются для реализации свойств, методов, методов класса,
статических методов и надтипов. Это классы, которые определяют, каким образом
будет получен доступ к атрибутам другого класса. Иными словами, класс может
делегировать управление атрибута другому классу.
Классы дескрипторов основаны на трех специальных методах, которые формируют протокол дескриптора:
__set__(self, obj, value) — вызывается всякий раз, когда задается атрибут.
В следующих примерах мы будем называть его «сеттер»;
__get__(self, obj, owner=None) — вызывается всякий раз, когда считывается
атрибут (далее геттер);
__delete__(self, object) — вызывается, когда del вызывается атрибутом.
Глава 4. Современные элементы синтаксиса — выше уровня класса 143
Дескриптор, который реализует __get__ и __set__, называется дескриптором
данных. Если он просто реализует __get__, то называется дескриптором без данных.
Методы этого протокола фактически вызываются методом __getattribute__()
(не путать с __getattr__(), который имеет другое назначение) при каждом поиске атрибута. Всякий раз, когда такой поиск выполняется с помощью точки или
прямого вызова функции, неявно вызывается метод __getattribute__(), который
ищет атрибут в следующем порядке.
1. Проверяет, является ли атрибут дескриптором данных на объекте класса экземпляра.
2. Если нет, то смотрит, найдется ли атрибут в __dict__ объекта экземпляра.
3. Наконец, проверяет, является ли атрибут дескриптором без данных на объекте
класса экземпляра.
Иными словами, дескрипторы данных имеют приоритет над __dict__, который,
в свою очередь, имеет приоритет над дескрипторами без данных.
Для ясности приведем пример из официальной документации Python, в котором
показано, как дескрипторы работают в реальном коде:
class RevealAccess(object):
"""Дескриптор данных, который задает и возвращает значения
и выводит сообщения о попытках доступа
"""
def __init__(self, initval=None, name='var'):
self.val = initval
self.name = name
def __get__(self, obj, objtype):
print('Retrieving', self.name)
return self.val
def __set__(self, obj, val):
print('Updating', self.name)
self.val = val
class MyClass(object):
x = RevealAccess(10, 'var "x"')
y = 5
Вот пример его использования в интерактивном режиме:
>>> m = MyClass()
>>> m.x
Retrieving var "x"
144 Часть II
•
Ремесло Python
10
>>> m.x = 20
Updating var "x"
>>> m.x
Retrieving var "x"
20
>>> m.y
5
Пример ясно показывает, что если класс имеет дескриптор данных для этого
атрибута, то вызывается метод __get__(), чтобы вернуть значение каждый раз,
когда извлекается атрибут экземпляра, а __set__() вызывается всякий раз, когда
такому атрибуту присваивается значение. Использование метода __del__ в предыдущем примере не показано, но должно быть очевидно: он вызывается всякий раз,
когда атрибут экземпляра удаляется с помощью оператора del instance.attribute
или delattr(instance, 'attribute').
Разница между дескрипторами с данными и без имеет большое значение по
причинам, которые мы упомянули в начале подраздела. В Python используется
протокол дескриптора для связывания функций класса с экземплярами через
методы. Они также применяются в декораторах classmethod и staticmethod .
Это происходит потому, что функциональные объекты по сути также являются
дескрипторами без данных:
>>> def function(): pass
>>> hasattr(function, '__get__')
True
>>> hasattr(function, '__set__')
False
Это верно и для функций, созданных с помощью лямбда-выражений:
>>> hasattr(lambda: None, '__get__')
True
>>> hasattr(lambda: None, '__set__')
False
Таким образом, если __dict__ не будет иметь приоритет над дескрипторами
без данных, мы не сможем динамически переопределить конкретные методы уже
созданных экземпляров во время выполнения. К счастью, благодаря тому, как
дескрипторы работают в Python, это возможно; поэтому разработчики могут выбирать, в каких экземплярах что работает, не используя подклассы.
Пример из реальной жизни: ленивое вычисление атрибутов. Один из примеров
использования дескрипторов — задержка инициализации атрибута класса в момент
доступа к нему из экземпляра. Это может быть полезно, если инициализация таких
атрибутов зависит от глобального контекста приложения. Другой случай — когда
такая инициализация слишком затратна, и неизвестно, будет ли атрибут вообще
использоваться после импорта класса. Такой дескриптор можно реализовать следующим образом:
Глава 4. Современные элементы синтаксиса — выше уровня класса 145
class InitOnAccess:
def __init__(self, klass, *args, **kwargs):
self.klass = klass
self.args = args
self.kwargs = kwargs
self._initialized = None
def __get__(self, instance, owner):
if self._initialized is None:
print('initialized!')
self._initialized = self.klass(*self.args, **self.kwargs)
else:
print('cached!')
return self._initialized
Ниже представлен пример использования:
>>> class MyClass:
...
lazily_initialized = InitOnAccess(list, "argument")
...
>>> m = MyClass()
>>> m.lazily_initialized
initialized!
['a', 'r', 'g', 'u', 'm', 'e', 'n', 't']
>>> m.lazily_initialized
cached!
['a', 'r', 'g', 'u', 'm', 'e', 'n', 't']
Официальная библиотека OpenGL Python на PyPI под названием PyOpenGL
использует такую технику, чтобы реализовать объект lazy_property, который является одновременно декоратором и дескриптором данных:
class lazy_property(object):
def __init__(self, function):
self.fget = function
def __get__(self, obj, cls):
value = self.fget(obj)
setattr(obj, self.fget.__name__, value)
return value
Такаяреализация аналогична использованию декоратора property (о нем поговорим позже), но функция, которая оборачивается декоратором, выполняется
только один раз, а затем атрибут класса заменяется значением, возвращенным
этим свойством функции. Данный метод часто бывает полезен, когда необходимо
одновременно выполнить два требования:
экземпляр объекта должен быть сохранен как атрибут класса, который распре-
деляется между его экземплярами (для экономии ресурсов);
этот объект не может быть инициализирован в момент импорта, поскольку
процесс его создания зависит от некоего глобального состояния приложения/
контекста.
146 Часть II
•
Ремесло Python
В случае приложений, написанных с использованием OpenGL, вы будете часто
сталкиваться с такой ситуацией. Например, создание шейдеров в OpenGL обходится дорого, поскольку требует компиляции кода, написанного на OpenGL Shading
Language (GLSL). Разумно создавать их только один раз и в то же время держать их
описание в непосредственной близости от классов, которым они нужны. С другой
стороны, шейдерные компиляции не могут быть выполнены без инициализации
контекста OpenGL, так что их трудно определить и собрать в глобальном пространстве имен модуля на момент импорта.
В следующем примере показано возможное использование модифицированной
версии декоратора lazy_property PyOpenGL (здесь lazy_class_attribute) в некоем абстрактном приложении OpenGL. Изменения оригинального декоратора
lazy_property требуются для того, чтобы разрешить совместное использование
атрибута различными экземплярами класса:
import OpenGL.GL as gl
from OpenGL.GL import shaders
class lazy_class_attribute(object):
def __init__(self, function):
self.fget = function
def __get__(self, obj, cls):
value = self.fget(obj or cls)
# Примечание: хранение объекта не-экземпляра класса
#
независимо от уровня доступа
setattr(cls, self.fget.__name__, value)
return value
class ObjectUsingShaderProgram(object):
# Банальная реализация шейдера-вершины
VERTEX_CODE = """
#version 330 core
layout(location = 0) in vec4 vertexPosition;
void main(){
gl_Position = vertexPosition;
}
"""
# Шейдер грани, который закрашивает все белым
FRAGMENT_CODE = """
#version 330 core
out lowp vec4 out_color;
void main(){
out_color = vec4(1, 1, 1, 1);
}
"""
@lazy_class_attribute
def shader_program(self):
Глава 4. Современные элементы синтаксиса — выше уровня класса 147
print("compiling!")
return shaders.compileProgram(
shaders.compileShader(
self.VERTEX_CODE, gl.GL_VERTEX_SHADER
),
shaders.compileShader(
self.FRAGMENT_CODE, gl.GL_FRAGMENT_SHADER
)
)
Как и все расширенные функции синтаксиса Python, эту также следует использовать с осторожностью и хорошо документировать в коде. Неопытным разработчикам измененное поведение класса может преподнести сюрпризы, поскольку
дескрипторы влияют на поведение класса. Поэтому очень важно убедиться, что
все члены вашей команды знакомы с дескрипторами и понимают эту концепцию,
если она играет важную роль в кодовой базе проекта.
Свойства
Свойства предоставляют встроенный тип дескриптора, который знает, как связать
атрибут с набором методов. Свойство принимает четыре необязательных аргумента:
fget, fset, fdel и doc. Последний из них может быть предусмотрен для определения
строки документации, связанной с атрибутом, как если бы это был метод. Ниже
приведен пример класса Rectangle, которым можно управлять либо путем прямого
доступа к атрибутам, хранящим две угловые точки, либо с помощью свойств width
и height:
class Rectangle:
def __init__(self, x1, y1, x2, y2):
self.x1, self.y1 = x1, y1
self.x2, self.y2 = x2, y2
def _width_get(self):
return self.x2 - self.x1
def _width_set(self, value):
self.x2 = self.x1 + value
def _height_get(self):
return self.y2 - self.y1
def _height_set(self, value):
self.y2 = self.y1 + value
width = property(
_width_get, _width_set,
В следующем фрагменте кода приведен пример таких свойств, определенных
в интерактивной сессии:
>>> rectangle = Rectangle(10, 10, 25, 34)
>>> rectangle.width, rectangle.height
(15, 24)
>>> rectangle.width = 100
>>> rectangle
Rectangle(10, 10, 110, 34)
>>> rectangle.height = 100
>>> rectangle
Rectangle(10, 10, 110, 110)
>>> help(Rectangle)
Help on class Rectangle in module chapter3:
class Rectangle(builtins.object)
| Methods defined here:
|
| __init__(self, x1, y1, x2, y2)
|
Initialize self. See help(type(self)) for accurate signature.
|
| __repr__(self)
|
Return repr(self).
|
| -------------------------------------------------------| Data descriptors defined here:
| (...)
|
| height
|
rectangle height measured from top
|
| width
|
rectangle width measured from left
Эти свойства облегчают написание дескрипторов, но с ними следует аккуратно обращаться при использовании наследования по классам. Атрибут создается
динамически с помощью методов текущего класса и не будет применять методы,
которые переопределены в производных классах.
Глава 4. Современные элементы синтаксиса — выше уровня класса 149
Приведенный в следующем примере код не сможет переопределить реализацию
метода fget из свойства width родительского класса (Rectangle):
>>> class MetricRectangle(Rectangle):
...
def _width_get(self):
...
return "{} meters".format(self.x2 - self.x1)
...
>>> Rectangle(0, 0, 100, 100).width
100
Чтобы решить эту проблему, все свойство следует перезаписать в производном
классе:
>>> class MetricRectangle(Rectangle):
...
def _width_get(self):
...
return "{} meters".format(self.x2 - self.x1)
...
width = property(_width_get, Rectangle.width.fset)
...
>>> MetricRectangle(0, 0, 100, 100).width
'100 meters'
К сожалению, код имеет кое-какие проблемы с сопровождением. Может возникнуть путаница, если разработчик решит изменить родительский класс, но забудет
обновить вызов свойства. Именно поэтому не рекомендуется переопределять только
части поведения свойств. Вместо того чтобы полагаться на реализацию родительского класса, рекомендуется переписать все методы свойств в производных классах,
если нужно изменить способ их работы. Обычно других вариантов нет, поскольку
изменение свойств поведения setter влечет за собой изменение в поведении getter.
Лучшим вариантом создания свойств будет использование property в качестве
декоратора. Это позволит сократить количество сигнатур методов внутри класса
и сделать код более читабельным и удобным в сопровождении:
class Rectangle:
def __init__(self, x1, y1, x2, y2):
self.x1, self.y1 = x1, y1
self.x2, self.y2 = x2, y2
@property
def width(self):
"""Ширина прямоугольника измеряется слева направо"""
return self.x2 - self.x1
@width.setter
def width(self, value):
self.x2 = self.x1 + value
@property
def height(self):
150 Часть II
•
Ремесло Python
"""Высота измеряется сверху вниз"""
return self.y2 - self.y1
@height.setter
def height(self, value):
self.y2 = self.y1 + value
Слоты
Интересная функция, которая очень редко используются разработчиками, — это
слоты. Они позволяют установить статический список атрибутов для класса с помощью атрибута __slots__ и пропустить создание словаря __dict__ в каждом
экземпляре класса. Они были созданы для экономии места в памяти для классов
с малочисленными атрибутами, так как __dict__ создается не в каждом экземпляре.
Кроме того, они могут помочь в создании классов, чьи сигнатуры нужно заморозить. Например, если вам необходимо ограничить динамические свойства языка
для конкретного класса, то слоты могут помочь:
>>> class Frozen:
...
__slots__ = ['ice', 'cream']
...
>>> '__dict__' in dir(Frozen)
False
>>> 'ice' in dir(Frozen)
True
>>> frozen = Frozen()
>>> frozen.ice = True
>>> frozen.cream = None
>>> frozen.icy = True
Traceback (most recent call last): File "", line 1, in
AttributeError: 'Frozen' object has no attribute 'icy'
Эту возможность нужно использовать с осторожностью. Когда набор доступных атрибутов ограничен слотами, намного сложнее добавить что-то к объекту
динамически. Некоторые известные приемы, например обезьяний патч (monkey
patching), не будут работать с экземплярами классов, которые имеют определенные
слоты. К счастью, новые атрибуты можно добавить к производным классам, если
они не имеют собственных определенных слотов:
>>> class Unfrozen(Frozen):
...
pass
...
>>> unfrozen = Unfrozen()
>>> unfrozen.icy = False
>>> unfrozen.icy
False
Глава 4. Современные элементы синтаксиса — выше уровня класса 151
Резюме
В этой главе мы рассмотрели современные элементы синтаксиса Python, относящиеся к моделям класса и объектно-ориентированному программированию.
Мы начали с объяснения концепции протокола языка, обсуждали подклассы
встроенных типов и способы вызова методов из суперклассов. Далее мы пере
шли к более сложным понятиям объектно-ориентированного программирования
на Python. Речь шла о полезных функциях синтаксиса, работающих с доступом
к атрибутам экземпляра: дескрипторах и свойствах. Мы показали, как их можно
использовать для создания более чистого и удобного в сопровождении кода.
В следующей главе мы рассмотрим обширную тему метапрограммирования
в Python. Мы будем повторно использовать некоторые уже известные особенности
синтаксиса, чтобы показать различные методы метапрограммирования.
5
Элементы
метапрограммирования
Метапрограммирование — один из самых сложных и мощных подходов к программированию в Python. Его инструменты и методы эволюционировали вместе
с Python, и прежде, чем углубляться в данную тему, важно хорошо изучить все
элементы современного синтаксиса Python. Мы обсуждали их в двух предыдущих главах. Если вы изучили их внимательно, то должны знать достаточно, чтобы
полностью понять содержание этой главы.
В данной главе мы объясним, что такое метапрограммирование в Python, и представим несколько практических подходов к нему.
В этой главе:
что такое метапрограммирование;
декораторы;
метаклассы;
генерация кода.
Технические требования
Ниже приведены пакеты Python, упомянутые в этой главе, которые можно скачать
с PyPI:
macropy;
falcon;
hy.
Установить эти пакеты можно с помощью следующей команды:
python3 -m pip install
Файлы кода для этой главы можно найти по адресу github.com/PacktPublishing/
Expert-Python-Programming-Third-Edition/tree/master/chapter5.
Глава 5.
Элементы метапрограммирования 153
Что такое метапрограммирование
Вероятно, есть какое-то хорошее академическое определение метапрограммирования, которое можно было бы процитировать здесь, но данная книга все же скорее
о мастерстве программирования, чем о теории компьютерных наук. Именно поэтому мы будем использовать следующее простое определение: «Метапрограммирование — это методика написания компьютерных программ, которые могут
обрабатываться как данные, что позволяет им просматривать, создавать и/или
изменять себя во время работы».
Используя это определение, выделим два основных подхода к метапрограммированию в Python.
Первый подход концентрируется на способности языка к самоанализу собственных элементов — функций, классов или типов, а также способности создавать или изменять их динамически. В Python и впрямь немало инструментов для
работы в этой области. Данная особенность языка используется в IDE (например,
PyCharm), предоставляя возможности анализа кода и предложения имен в режиме реального времени. Самые простые инструменты метапрограммирования
в Python, в которых применяется самоанализ языка, — это декораторы, позволя
ющие добавлять дополнительные функциональные возможности к существу
ющим функциям, методам и классам. Кроме того, существуют специальные
методы классов, которые позволяют вмешиваться в процедуру создания экземпляра класса. Самые мощные в этом смысле — метаклассы, благодаря которым
программисты могут полностью переделать реализацию объектно-ориентированного программирования в Python.
Второй подход позволяет программистам работать непосредственно с кодом либо
в сыром виде (обычный текст), либо в более доступной форме абстрактного синтаксического дерева (abstract syntax tree, AST). Данный подход, разумеется, более сложный
и трудный в реализации, зато дает возможность делать действительно экстраординарные вещи, такие как расширение синтаксиса языка Python или даже создание
собственного предметно-ориентированного языка (domain-specific language, DSL).
В следующем подразделе мы подробнее поговорим о декораторах.
Декораторы как средство метапрограммирования
Мы говорили о синтаксисе декораторов в главе 3. Это синтаксический сахар, который работает по следующей простой схеме:
def decorated_function():
pass
decorated_function = some_decorator(decorated_function)
Эта многословная форма декорирования функции ясно показывает, что делает
декоратор. Он принимает объект функции и изменяет его во время выполнения.
154 Часть II
•
Ремесло Python
В результате новая функция (или что-нибудь еще) создается на основе предыдущей функции объекта с тем же именем. Это декорирование может быть сложной
операцией, выполняющей некий самоанализ кода или декорированную функцию,
чтобы дать разные результаты в зависимости от того, как была реализована оригинальная функция. Все это значит, что декоратор можно рассматривать в качестве
инструмента метапрограммирования.
И это хорошая новость. Основные принципы декораторов относительно просты
для понимания и в большинстве случаев позволяют сделать код более коротким,
читабельным и удобным в сопровождении. Остальные инструменты метапрограммирования в Python труднее и понять, и использовать. Кроме того, код от них
только усложняется.
Далее рассмотрим декораторы класса.
Декораторы класса
Одной из менее известных особенностей синтаксиса Python являются декораторы
класса. По синтаксису и реализации они аналогичны декораторам функций, как мы
уже упоминали в главе 3. Единственное отличие состоит в том, что они возвращают
класс, а не функцию. Ниже приведен пример декоратора класса, который изменяет
метод __repr__(), чтобы тот возвращал печатаемое представление объекта, сокращенное до произвольного количества символов:
def short_repr(cls):
cls.__repr__ = lambda self: super(cls, self).__repr__()[:8]
return cls
@short_repr
class ClassWithRelativelyLongName:
pass
Вывод будет выглядеть так:
>>> ClassWithRelativelyLongName()
>> ClassWithLittleBitLongerLongName().__class__
>>> ClassWithLittleBitLongerLongName().__doc__
'Subclass that provides decorated behavior'
К сожалению, исправить это не так просто, как мы объяснили в главе 3. В декораторах класса нельзя просто брать и использовать дополнительный декоратор
wraps, чтобы сохранить первоначальный тип класса и метаданные. Поэтому использование декораторов класса в некоторых случаях будет ограничено. Они могут,
например, испортить результаты работы средств автоматизированной генерации
документации.
Тем не менее, несмотря на этот недостаток, декораторы класса — простая и облегченная альтернатива популярному паттерну «Примесь». Таковым в Python
называют класс, который не создает экземпляров, но вместо этого используется
156 Часть II
•
Ремесло Python
для создания многоразового API или функциональности других существующих
классов. Такие классы почти всегда добавляются с помощью множественного наследования. Как правило, это принимает следующий вид:
class SomeConcreteClass(MixinClass, SomeBaseClass):
pass
Примеси (миксины, mixin) являются полезным паттерном проектирования,
который применяется во многих библиотеках и фреймворках. Например, они
широко используются в Django. Несмотря на пользу и популярность, примеси
могут стать проблемой, будучи плохо разработанными, поскольку в большинстве
случаев требуют от программиста использования множественного наследования.
Как мы уже отмечали, множественное наследование в Python реализуется относительно хорошо благодаря ПРМ. Но все равно по возможности старайтесь
избегать множественного наследования, так как оно усложняет понимание кода.
Декораторы класса могут быть хорошей заменой примесей.
Посмотрим на использование __new__() для переопределения процесса создания экземпляра.
Использование __new__() для переопределения
процесса создания экземпляра
Специальный метод __new __() — это статический метод, который отвечает за
создание экземпляров класса. Его нет необходимости объявлять статическим с помощью декоратора staticmethod. Этот метод __new__(cls, [, ...]) вызывается
до __init__(). Как правило, реализация переопределенного метода __new__() вызывает его версию суперкласса, используя super(). Метод __new__() с соответствующими аргументами модифицирует экземпляр перед его возвращением.
Ниже приведен пример класса с переопределенной реализацией метода __new__()
для подсчета количества экземпляров класса:
class InstanceCountingClass:
instances_created = 0
def __new__(cls, *args, **kwargs):
print('__new__() called with:', cls, args, kwargs)
instance = super().__new__(cls)
instance.number = cls.instances_created
cls.instances_created += 1
return instance
def __init__(self, attribute):
print('__init__() called with:', self, attribute)
self.attribute = attribute
Глава 5.
Элементы метапрограммирования 157
Ниже представлен журнал интерактивной сессии, которая показывает, как работает наша реализация InstanceCountingClass:
>>> from instance_counting import InstanceCountingClass
>>> instance1 = InstanceCountingClass('abc')
__new__() called with: ('abc',) {}
__init__() called with: abc
>>> instance2 = InstanceCountingClass('xyz')
__new__() called with: ('xyz',) {}
__init__() called with: xyz
>>> instance1.number, instance1.instances_created
(0, 2)
>>> instance2.number, instance2.instances_created
(1, 2)
Метод __new__() при нормальной работе должен возвращать экземпляр указанного класса, но может возвращать экземпляры и других классов. В таком случае
вызов метода __init__() пропускается. Это полезно, когда есть необходимость изменить создание/инициализацию экземпляров неизменяемых классов (например,
некоторых встроенных типов Python), как показано в следующем коде:
class NonZero(int):
def __new__(cls, value):
return super().__new__(cls, value) if value != 0 else None
def __init__(self, skipped_value):
# Имплементация __init_ в данном случае может быть пропущена,
# однако она оставлена, чтобы вы увидели, как не стоит вызывать этот метод
print("__init__() called")
super().__init__()
Рассмотрим это в следующей интерактивной сессии:
>>> type(NonZero(-12))
__init__() called
>>> type(NonZero(0))
>>> NonZero(-3.123)
__init__() called
-3
Когда же использовать __new__()? Ответ прост: только если одного __init__()
недостаточно. Один из таких случаев мы уже упомянули — наследование от неизменяемых встроенных типов Python, например int, str, float, frozenset и т. д.
Это связано с тем, что невозможно изменить такой экземпляр неизменяемого объекта в методе __init__() сразу после его создания.
158 Часть II
•
Ремесло Python
Отдельные программисты считают, что метод __new__() пригоден для инициализации важного объекта, которая может быть пропущена, если пользователь
забудет указать super.__init__() в переопределенном методе инициализации.
Это звучит разумно, однако есть существенный недостаток. При таком подходе
программист может явно пропустить предыдущие шаги по инициализации, если
нужное поведение уже имеет место быть. Кроме того, это нарушает негласное правило всех инициализаций, выполненных в __init__().
Поскольку __new__() не ограничен возвращением экземпляра именно того же
класса, им легко злоупотребить. Безответственное использование данного метода
может сильно повредить читабельность кода, поэтому его всегда следует применять
с осторожностью и прикреплять обширную документацию. Как правило, лучше
поискать другие имеющиеся решения данной задачи, в которых создание объекта
не будет модифицировано так, что приведет к поломке. Даже переопределенную
инициализацию неизменяемых типов можно заменить более предсказуемыми
и хорошо зарекомендовавшими себя паттернами проектирования, один из которых
мы рассмотрим в главе 17.
Существует по крайней мере один инструмент программирования на Python,
в котором обширное использование __new__() вполне оправданно. Это метаклассы,
и о них мы поговорим в следующем подразделе.
Метаклассы
Метаклассы — это особенность Python, которую многие разработчики считают
одной из самых трудных для понимания и поэтому избегают ее. На самом деле все
не так сложно, как кажется, стоит усвоить несколько основных понятий. Наградой
будет знание того, как использовать метаклассы, и вы сможете делать то, что без
них невозможно.
Метакласс — это тип (класс), определяющий другие типы (классы). Самое главное, что нужно знать для их понимания, — классы, которые определяют экземпляры
объектов, тоже являются объектами. И поэтому у них есть соответствующий класс.
Основной тип каждого класса по умолчанию просто встроенный класс type. Простая схема (рис. 5.1) помогает это понять.
Рис. 5.1. Типизация класса
В Python можно заменить метакласс для объекта класса собственным типом.
Как правило, новый метакласс — это все еще подкласс класса type (рис. 5.2), по-
Глава 5.
Элементы метапрограммирования 159
скольку в противном случае полученные классы будут несовместимы с другими
классами с точки зрения наследования.
Общий синтаксис
Вызов встроенного класса type() может использоваться в качестве динамического
эквивалента объявления класса. Ниже приведен пример определения класса с вызовом type():
def method(self):
return 1
MyClass = type('MyClass', (object,), {'method': method})
Это эквивалентно явному определению класса с ключевым словом class:
class MyClass:
def method(self):
return 1
Каждый класс, который явно создается таким образом, имеет метакласс type.
Такое поведение по умолчанию можно изменить, добавив именованный аргумент
metaclass:
class ClassWithAMetaclass(metaclass=type):
pass
Значение, предоставляемое в качестве аргумента metaclass, — это, как правило,
еще один объект класса, но может быть любым другим вызываемым объектом,
160 Часть II
•
Ремесло Python
который принимает те же аргументы, что и класс type, и возвращает другой объект
класса. Сигнатура вызова такова: type(name, bases, namespace). Значение аргументов выглядит следующим образом:
name — имя класса, которое будет храниться в атрибуте __name__;
bases — список родительских классов, которые станут атрибутом __base__ и бу-
дут использоваться для построения ПРМ вновь созданного класса;
namespace — пространство имен (отображение) с определениями для тела класса, который станет атрибутом __dict__.
Метаклассы — это своего рода метод __new__(), но на более высоком уровне
определения класса.
Несмотря на то что вместо метаклассов можно добавить функции, которые явно
вызывают type(), обычно для этого используется другой класс, наследующий от
type. Общий шаблон для метакласса выглядит следующим образом:
class Metaclass(type):
def __new__(mcs, name, bases, namespace):
return super().__new__(mcs, name, bases, namespace)
@classmethod
def __prepare__(mcs, name, bases, **kwargs):
return super().__prepare__(name, bases, **kwargs)
def __init__(cls, name, bases, namespace, **kwargs):
super().__init__(name, bases, namespace)
def __call__(cls, *args, **kwargs):
return super().__call__(*args, **kwargs)
Аргументы name, bases, namespace имеют такое же значение, как и в type(), но
все эти четыре метода могут иметь различные цели.
Метод __new__(mcs, name, bases, namespace) отвечает за фактическое создание
объекта класса, как и у обычных классов. Первый аргумент является объектом
метакласса. В предыдущем примере это был бы просто Metaclass. Обратите
внимание, что mcs — общепринятое имя для данного аргумента.
Метод __prepare__(mcs, name, bases, **kwargs) создает пустой объект пространства имен. По умолчанию возвращает пустой dict , но может возвра-
щать и любой другой тип отображения. Обратите внимание: он не принимает
namespace в качестве аргумента, поскольку до вызова пространство имен еще
не существует. Пример использования этого метода будет объяснен позже
в пункте на с. 162.
Метод __init__(cls, name, bases, namespace, **kwargs) не особо популярен
в реализации метакласса, но имеет тот же смысл, что и в обычных классах.
Глава 5.
Элементы метапрограммирования 161
Он может выполнять дополнительную инициализацию объекта класса, как
только тот будет создан с помощью __new__(). Первый позиционный аргумент
теперь называется cls и обозначает уже созданный объект класса (экземпляр
метакласса), а не объект метакласса. В момент вызова __init__() класс уже
был создан, и поэтому данный метод не так полезен, как __new__(). Реализация
такого метода очень похожа на использование декораторов класса, но основное
отличие состоит в том, что __init__() будет вызываться для каждого подкласса,
а вот декораторы класса для подклассов не вызываются.
Метод __call__(cls, *arg, **kwargs) вызывается, когда вызывается экземпляр
метакласса. Последний является объектом класса (см. рис. 5.1), он вызывается
при создании новых экземпляров класса. Метод позволяет переопределить
способ создания и инициализации экземпляров класса.
Каждый из указанных выше методов может принимать дополнительные именованные аргументы, указанные в **kwargs. Эти аргументы могут быть переданы
объекту метакласса с помощью дополнительных именованных аргументов в определении класса:
class Klass(metaclass=Metaclass, extra="value"):
pass
Столько новой информации без соответствующих примеров — это многовато,
так что посмотрим, как метаклассы, классы и экземпляры создаются с помощью
вызовов print():
class RevealingMeta(type):
def __new__(mcs, name, bases, namespace, **kwargs):
print(mcs, "__new__ called")
return super().__new__(mcs, name, bases, namespace)
@classmethod
def __prepare__(mcs, name, bases, **kwargs):
print(mcs, "__prepare__ called")
return super().__prepare__(name, bases, **kwargs)
def __init__(cls, name, bases, namespace, **kwargs):
print(cls, "__init__ called")
super().__init__(name, bases, namespace)
def __call__(cls, *args, **kwargs):
print(cls, "__call__ called")
return super().__call__(*args, **kwargs)
Если мы используем RevealingMeta как метакласс и создаем новое определение
класса, то результат интерактивной сессии Python будет следующим:
>>> class RevealingClass(metaclass=RevealingMeta):
...
def __new__(cls):
162 Часть II
•
Ремесло Python
...
print(cls, "__new__ called")
...
return super().__new__(cls)
...
def __init__(self):
...
print(self, "__init__ called")
...
super().__init__()
...
__prepare__ called
__new__ called
__init__ called
>>> instance = RevealingClass()
__call__ called __new__
called __init__ called
Рассмотрим новый синтаксис Python 3 для работы с метаклассами.
Новый синтаксис метаклассов Python 3
Метаклассы уже не новинка, они были доступны в Python, начиная с версии 2.2.
А вот их синтаксис существенно изменился, причем совместимость из-за этих
изменений нарушилась в обе стороны. Новый синтаксис выглядит следующим
образом:
class ClassWithAMetaclass(metaclass=type):
pass
В Python 2 это выглядело так:
class ClassWithAMetaclass(object):
__metaclass__ = type
Операторы классов в Python 2 не принимают именованные аргументы, поэтому
в синтаксисе Python 3 для определения метаклассов в процессе импорта будет выброшено исключение SyntaxError. Метаклассы позволяют писать код, который будет работать на обеих версиях Python, но это требует дополнительных трудозатрат.
К счастью, пакеты совместимости, например six, предоставляют готовые решения
данной проблемы наподобие тех, что показаны в следующем коде:
from six import with_metaclass
class Meta(type):
pass
class Base(object):
pass
class MyClass(with_metaclass(Meta, Base)):
pass
Другим важным отличием является отсутствие в метаклассах Python 2 хука
__prepare__(). Реализация такой функции не выбрасывает никаких исключений
в Python 2, но в ней нет смысла, поскольку ее не будут использовать для создания
Глава 5.
Элементы метапрограммирования 163
чистого объекта пространства имен. Именно поэтому в пакетах совместимости
с Python 2 используются более сложные трюки, результатом которых будет то
же, что дает __prepare__(). Например, в Django REST Framework версии 3.4.7
(www.django-rest-framework.org), чтобы сохранить порядок, в котором атрибуты добавляются к классу, применяется следующий подход:
class SerializerMetaclass(type):
@classmethod
def _get_declared_fields(cls, bases, attrs):
fields = [(field_name, attrs.pop(field_name))
for field_name, obj in list(attrs.items())
if isinstance(obj, Field)]
fields.sort(key=lambda x: x[1]._creation_counter)
# If this class is subclassing another Serializer, add
# that Serializer's fields.
# Note that we loop over the bases in *reverse*.
# This is necessary in order to maintain the
# correct order of fields.
for base in reversed(bases):
if hasattr(base, '_declared_fields'):
fields = list(base._declared_fields.items()) + fields
return OrderedDict(fields)
def __new__(cls, name, bases, attrs):
attrs['_declared_fields'] = cls._get_declared_fields(
bases, attrs
)
return super(SerializerMetaclass, cls).__new__(
cls, name, bases, attrs
)
Это позволяет обойти проблему того, что тип пространства имен по умолчанию (dict) не гарантирует сохранение порядка кортежей «ключ — значение»
в версиях Python старше 3.7 (мы говорили об этом в пункте «Словари» на с. 79).
Атрибут _creation_counter ожидается в каждом экземпляре класса Field. Атрибут
Field.creation_counter создается так же, как и InstanceCountingClass.instance_
number, о котором мы говорили выше, в подразделе «Использование __new__() для
переопределения процесса создания экземпляра». Это довольно сложное решение,
которое нарушает единый принцип ответственности, так как его реализация разделена на два различных класса для отслеживания порядка атрибутов. В Python 3
все гораздо проще, поскольку __prepare__() может возвращать другие типы отображения, например OrderedDict, как показано в следующем коде:
from collections import OrderedDict
class OrderedMeta(type):
@classmethod
164 Часть II
•
Ремесло Python
def __prepare__(cls, name, bases, **kwargs):
return OrderedDict()
def __new__(mcs, name, bases, namespace):
namespace['order_of_attributes'] = list(namespace.keys())
return super().__new__(mcs, name, bases, namespace)
class ClassWithOrder(metaclass=OrderedMeta):
first = 8
second = 2
Если вы проверите ClassWithOrder в интерактивной сессии, то увидите следующий вывод:
>>> ClassWithOrder.order_of_attributes
['__module__', '__qualname__', 'first', 'second']
>>> ClassWithOrder.__dict__.keys()
dict_keys(['__dict__', 'first', '__weakref__', 'second',
'order_of_attributes', '__module__', '__doc__'])
В следующем пункте мы поговорим об использовании метаклассов.
Использование метаклассов
Освоив работу с метаклассами, вы получите мощный инструмент, который, впрочем, всегда будет усложнять ваш код. Кроме того, они плохо объединяются, и вы
быстро столкнетесь с проблемами, когда попробуете смешать несколько метаклассов через наследование.
Для простых задач вроде изменения атрибутов чтения/записи или добавления
новых атрибутов можно отказаться от метаклассов в пользу более простых решений, таких как свойства, дескрипторы или декораторы класса.
Но бывают ситуации, когда без метаклассов не обойтись. Например, трудно
представить себе реализацию ORM в Django без широкого использования метаклассов. Это возможно, но маловероятно, что результат вообще будет пригоден
для использования. А вот в фреймворках метаклассы действительно работают
прекрасно. Как правило, в них много сложного для понимания внутреннего кода,
но в конечном счете это позволит другим программистам писать более концентрированный и читабельный код, работающий на более высоком уровне абстракции.
Рассмотрим кое-какие ограничения, связанные с применением метаклассов.
Ловушки метаклассов
Как и некоторые другие расширенные функции Python, метаклассы очень эластичны и с ними легко переборщить. Синтаксис вызова класса достаточно строгий,
но Python не задает тип возвращаемого параметра. Он может быть каким угодно,
пока класс принимает заданные при вызове аргументы и имеет нужные атрибуты,
когда это необходимо.
Глава 5.
Элементы метапрограммирования 165
Одним из таких объектов, который может быть чем угодно и где угодно, является экземпляр класса Mock, предоставляемый в модуле unittest.mock. Mock — это
не метакласс, он не наследует от class. Он также не возвращает объект класса
при создании экземпляра. Тем не менее он может быть включен в качестве именованного аргумента метакласса при его определении, и это не вызывает никаких
синтаксических ошибок. Использование Mock как метакласса — это полная ерунда,
но рассмотрим следующий пример:
>>> from unittest.mock import Mock
>>> class Nonsense(metaclass=Mock): # pointless, but illustrative
...
pass
...
>>> Nonsense
Нетрудно предвидеть, что любая попытка создать экземпляр придуманного
нами псевдокласса Nonsense потерпит неудачу. Любопытно, что результат получится таким:
>>> Nonsense()
Traceback (most recent call last):
File "", line 1, in
File "/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/
mock.py", line 917, in __call__
return _mock_self._mock_call(*args, **kwargs)
File "/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/unittest/
mock.py", line 976, in _mock_call
result = next(effect)
StopIteration
Дает ли исключение StopIteration хоть малый шанс понять, что проблема возникла именно в определении класса на уровне метакласса? Очевидно, нет. Этот
пример показывает, как трудно отлаживать код метакласса, если вы не знаете, где
искать ошибки.
В следующем подразделе поговорим о генерации кода.
Генерация кода
Как мы уже упоминали, динамическая генерация кода — это наиболее сложный
подход к метапрограммированию. В Python есть инструменты, которые позволяют
создавать и выполнять код или даже вносить изменения в уже скомпилированные
части кода.
По проектам наподобие Hy (о нем позже поговорим отдельно) видно, что с помощью методов генерации кода Python можно переопределять целые языки.
То есть возможности этого инструментария практически безграничны. Мы знаем,
166 Часть II
•
Ремесло Python
насколько сложна данная тема и сколько в ней скрытых подвохов, поэтому даже
не будем пытаться приводить примеры кода или давать вам рекомендации о том,
как с этим работать.
Но вам будет полезно знать, что это в принципе возможно, если вы планируете
самостоятельно заняться данной работой позже. Считайте этот подраздел указанием к действию для дальнейшего обучения.
Посмотрим, как использовать функции exec, eval и compile.
exec, eval и compile
В Python есть три встроенные функции, позволяющие вручную выполнить, вычислить и скомпилировать произвольный код Python.
exec(object, global, locals) — позволяет динамически выполнять код
Python. Элемент object должен быть строкой или объектом кода (см. функцию
compile()), представляющим один оператор или последовательность нескольких. Аргументы global и local — это глобальные и локальные пространства
имен для исполняемого кода, которые не являются обязательными. Если они
не указаны, то код выполняется в текущем пространстве. Если указаны, то
global должен быть словарем, а local может быть любым объектом отображения, он всегда возвращает None.
eval(expression, global, locals) — используется для вычисления данного
выражения и возвращает его значение. Похоже на exec(), но expression — это
всего одно выражение Python, а не последовательность операторов. Возвращает
значение вычисленного выражения.
compile(source, filename, mode) — компилирует источник в объект кода или
AST. Исходный код предоставляется в качестве строкового значения в аргументе source. filename — это файл, из которого читается код. Если связанного файла
нет (например, потому что он был создан динамически), обычно используется
значение . Режим — exec (последовательность операторов), eval (одно
выражение) или single (один интерактивный оператор, например, в интерактивной сессии Python).
Начать работу с функциями exec() и eval() легче всего с динамической генерации кода, поскольку функции работают со строками. Если вы уже знаете, как
программировать на Python, то знаете и то, как правильно сформировать рабочий
исходный код из программы.
Наиболее полезной в контексте метапрограммирования функцией будет, очевидно, exec(), поскольку она позволяет выполнять любую последовательность
операторов Python. Здесь вас должно тревожить слово «любую». Даже функция
eval(), которая в руках умелого программиста позволяет вычислять выражения
(при подаче на вход пользовательского ввода), может привести к проблемам с без-
Глава 5.
Элементы метапрограммирования 167
опасностью. И сбоя интерпретатора Python здесь стоит бояться меньше всего.
Вводя уязвимость в удаленное выполнение в виде этих функций, вы рискуете своей
репутацией и карьерой.
Даже если вы доверяете входным данным, список мелких проблем с exec()
и eval() все еще слишком велик, а результаты работы вашей программы будут весьма неожиданными. Армин Ронакер написал хорошую статью Be careful with exec and
eval in Python, в которой перечислены наиболее важные из них (см. lucumr.pocoo.org/
2011/2/1/exec-in-python/).
Несмотря на все эти пугающие предупреждения, существуют естественные ситуации, когда использование exec() и eval() действительно оправданно. Тем не менее, в случае даже малейших сомнений лучше всего отказаться от этих функций
и попытаться найти другое решение.
eval() и ненадежный ввод
Сигнатура функции eval() наводит на мысль о том, что если вы дадите ей пустые globals и locals и обернете ее оператором try…except,
то все будет достаточно безопасно. Но это огромная ошибка. Нед
Батчелер написал очень хорошую статью, в которой показывает, как
вызвать ошибку сегментации интерпретатора с помощью вызова eval()
(см. nedbatchelder.com/blog/201206/eval_really_is_dangerous.html). Это
доказательство того, что exec() и eval() никогда не должны использоваться с ненадежными входными данными.
В следующем пункте рассмотрим абстрактное синтаксическое дерево.
Абстрактное синтаксическое дерево
Синтаксис Python преобразуется в AST до компиляции в байт-код. Это представление абстрактной синтаксической структуры исходного кода в виде дерева.
Обработка грамматики в Python реализуется через встроенный модуль ast. Сырые
AST кода Python создаются с помощью функции compile() с флагом ast.PyCF_
ONLY_AST или с использованием помощника ast.parse(). Трансляция в обратном
направлении — сложная задача, и в стандартной библиотеке нет функций, которые позволяют ее реализовать. Но в некоторых проектах, например в PyPy, такие
функции есть.
Модуль ast предоставляет некоторые вспомогательные функции, позволяющие
работать с AST, например:
>>> tree = ast.parse('def hello_world(): print("hello world!")')
>>> tree
Выход ast.dump() в предыдущем примере был переформатирован с целью
улучшить читабельность и лучше показать древовидную структуру AST. Важно
знать, что AST можно изменить до передачи в функцию compile(). Это дает много
новых возможностей. Так, новые узлы синтаксиса могут быть использованы для,
например, измерения области охвата теста. Кроме того, можно изменить имеющееся дерево кода, чтобы добавить новую семантику к существующему синтаксису.
Такой метод используется в рамках проекта MacroPy (github.com/lihaoyi/macropy) для
добавления в Python синтаксических макросов с помощью уже существующего
синтаксиса (рис. 5.3).
Рис. 5.3. Как MacroPy добавляет синтаксические макросы
в модуль Python при импорте
Глава 5.
Элементы метапрограммирования 169
AST также можно создавать чисто искусственным образом, и нет необходимости
парсить какой-либо источник на всех. Это дает Python-программистам возможность создавать байт-код Python для пользовательских предметно-ориентированных языков или даже полностью реализовать другие языки программирования
поверх Python.
Хуки импорта. Воспользоваться способностью MacroPy изменять оригинальную AST позволит оператор import macropy.activate , если каким-то образом
сможет переопределить импорт Python. К счастью, в Python есть способ перехвата
импорта с помощью следующих двух видов хуков.
Метахуки вызываются до обработки import. Использование метахуков позволяет изменить способ обработки sys.path даже для замороженных и встро-
енных модулей. Чтобы добавить новый метахук, нужно добавить в список
sys.meta_path новый объект meta path finder.
Хуки пути импорта вызываются как часть обработки sys.path. Они исполь-
зуются, если встречается элемент пути, связанный с данным хуком. Хуки пути
импорта добавляются путем расширения списка sys.path_hooks новым объектом path finder.
Подробности реализации path finders детально описаны в официальной документации Python (см. docs.python.org/3/reference/import.html). Она должна быть
вашим основным ресурсом, если вы хотите взаимодействовать с импортом на
данном уровне. Это объясняется тем, что механизм импорта в Python довольно
сложен и любая попытка обобщить его неизбежно терпит неудачу. Здесь мы лишь
отметили, что такие вещи в принципе возможны.
Ниже рассмотрим проекты, в которых используются паттерны генерации кода.
Проекты, в которых используются паттерны генерации кода
Трудно найти действительно полезную реализацию библиотеки, которая основана
на паттернах генерации кода и при этом будет реально рабочей, а не экспериментом
фанатов. Причины этой ситуации достаточно очевидны:
обоснованный страх перед функциями ехес() и eval(), поскольку их непра-
вильное использование может привести к катастрофе;
успешную генерацию кода очень трудно разрабатывать и поддерживать, так
как это требует глубокого понимания языка и серьезных навыков программирования в целом.
Несмотря на эти трудности, есть ряд проектов, в которых данный подход успешно используется либо для повышения производительности, либо для достижения
того, что было бы невозможно получить с помощью других средств.
170 Часть II
•
Ремесло Python
Маршрутизация на Falcon. Falcon (falconframework.org) — это минималистский
веб-фреймворк Python WSGI для создания быстрых и облегченных API. В нем
используется архитектурный стиль REST, который сегодня весьма популярен во
всем Интернете. Это хорошая альтернатива другим достаточно тяжелым движкам наподобие Django или Pyramid. Он также составляет сильную конкуренцию
другим микрофреймворкам, стремящимся к простоте, таким как Flask, Bottle или
web2py.
Одна из особенностей этого фреймворка — очень простой механизм маршрутизации. Она будет проще, чем маршрутизация на urlconf Django, хотя и менее
функциональна. Однако в большинстве случаев и этого функционала достаточно
для любого API, построенного на архитектуре REST. Самое интересное в маршрутизации Falcon — это внутреннее устройство маршрутизатора. Он реализован
с помощью кода, сгенерированного из списка маршрутов, и код меняется каждый
раз, когда регистрируется новый маршрут. Именно этот трюк позволяет ускорить
маршрутизацию.
Рассмотрим короткий пример API, взятый из веб-документации Falcon:
# sample.py
import falcon
import json
class QuoteResource:
def on_get(self, req, resp):
"""Обработка GET-запросов"""
quote = {
'quote': 'I\'ve always been more interested in '
'the future than in the past.',
'author': 'Grace Hopper'
}
resp.body = json.dumps(quote)
api = falcon.API()
api.add_route('/quote', QuoteResource())
Выделенный вызов метода api.add_route() динамически обновляет все сгенерированное дерево кода по запросу маршрутизатора Falcon. Он также собирает
его с помощью функции compile() и генерирует новую функцию поиска маршрута посредством eval(). Внимательнее посмотрим на атрибут __code__ функции
api._router._find():
>>> api._router._find.__code__
Результат показывает, что код этой функции был сгенерирован из строки, а не
из реального исходного кода (файла ""). Кроме того, видно, что факти-
Глава 5.
Элементы метапрограммирования 171
ческий объект кода изменяется с каждым вызовом метода api.add_route() (адрес
объекта в памяти изменяется).
Hy. Hy (docs.hylang.org) — это диалект Lisp, полностью написанный на Python.
Многие подобные проекты, которые реализуют другой код в Python, обычно
стремятся к простой форме кода, хранимого в виде файлоподобного объекта или
строки и интерпретируемого как последовательность явных вызовов на Python.
В отличие от других, Hy — это язык, который полностью работает в среде выполнения Python, как и собственно Python. Код, написанный на Hy, может использовать
существующие встроенные модули и внешние пакеты, и, наоборот, код, написанный
наHy, можно импортировать обратно в Python.
Чтобы вставить Lisp в Python, Hy переводит код Lisp непосредственно в Python
AST. Совместимость на этапе импорта достигается с помощью хука импорта, который регистрируется в тот момент, когда модуль Hy импортируется в Python.
Любой модуль с расширением .hy рассматривается как модуль Hy и может быть
импортирован как обычный модуль Python. Ниже показан стандартный Hello World
на этом диалекте Lisp:
;; hyllo.hy
(defn hello [] (print "hello world!"))
Этот код можно импортировать и выполнить с помощью следующего кода
Python:
>>> import hy
>>> import hyllo
>>> hyllo.hello()
hello world!
Если копнуть глубже и попытаться разобрать hyllo.hello с помощью встроенного модуля dis, то мы заметим, что байт-код функции Hy не слишком отличается
от своего чистого аналога Python, как показано в следующем коде:
>>> import dis
>>> dis.dis(hyllo.hello)
2
0 LOAD_GLOBAL
0 (print)
3 LOAD_CONST
1 ('hello world!')
6 CALL_FUNCTION
1 (1 positional, 0 keyword pair)
9 RETURN_VALUE
>>> def hello(): print("hello world!")
...
>>> dis.dis(hello)
1
0 LOAD_GLOBAL
0 (print)
3 LOAD_CONST
1 ('hello world!')
6 CALL_FUNCTION
1 (1 positional, 0 keyword pair)
9 POP_TOP
10 LOAD_CONST
0 (None)
13 RETURN_VALUE
172 Часть II
•
Ремесло Python
Резюме
В этой главе мы рассмотрели обширную тему метапрограммирования в Python.
Мы подробно обсудили особенности синтаксиса, с помощью которых можно реализовать различные паттерны метапрограммирования. В основном это декораторы
и метаклассы.
Кроме того, мы рассмотрели еще один важный аспект метапрограммирования —
динамическое генерирование кода. Эта тема слишком обширна, чтобы ее можно
было тщательно обсудить на страницах данной книги, поэтому мы затронули ее
лишь слегка. Тем не менее предоставленная информация может стать для вас хорошей отправной точкой для дальнейшего изучения этой области.
В следующей главе мы немного отдохнем от сложных вещей и поговорим об
общепринятых методах присвоения имен.
6
Как выбирать имена
Большая часть стандартной библиотеки была построена с учетом требований
к юзабилити. В этом смысле Python можно сравнить с псевдокодом, который возникает в вашей голове, когда вы работаете над программой. Почти весь код можно
прочитать вслух. Например, следующий фрагмент будет понятен даже тем, кто
далек от программирования:
my_list = []
if 'd' not in my_list:
my_list.append('d')
Код на Python близок к естественному языку, и это — одна из причин того, почему Python настолько прост в освоении и использовании. Когда вы пишете программу, поток ваших мыслей быстро превращается в строки кода.
В данной главе мы сосредоточимся на практических рекомендациях по написанию кода, который легко понять и применять, а именно:
использование соглашения об именовании, описанного в PEP 8.
выдача рекомендаций по присвоению имен;
краткий обзор популярных инструментов, которые позволяют проверить ваш
код на соответствие требованиям стиля.
В этой главе:
PEP 8 и практические рекомендации по именованию;
стили именования;
руководство по именованию;
рекомендации для аргументов;
имена классов;
имена модулей и пакетов;
полезные инструменты.
174 Часть II
•
Ремесло Python
Технические требования
Ниже приведены пакеты Python, упомянутые в этой главе, которые можно скачать
с PyPI:
pylint;
pycodestyle;
flake8.
Установить эти пакеты можно с помощью следующей команды:
python3 -m pip install
Файлы кода для этой главы можно найти по ссылке github.com/PacktPublishing/
Expert-Python-Programming-Third-Edition/tree/master/chapter6.
PEP 8 и практические рекомендации
по именованию
В документе РЕР 8 (www.python.org/dev/peps/pep-0008) приведено руководство по
стилю написания кода на Python. Помимо базовых правил, например, касающихся
отступов, максимальной длины строки, и других правил размещения кода, в PEP 8
есть раздел, посвященный соглашениям об именовании, которым следует большинство кодовых баз.
Текущий раздел нашей книги содержит лишь краткий обзор PEP 8 и удобный
путеводитель по именованию для каждого типа синтаксиса Python. И тем не менее
всем Python-программистам нужно в обязательном порядке прочитать документ
PEP 8.
Почему и когда надо соблюдать PEP 8
Если вы создаете новый программный пакет, предназначенный для работы с открытым исходным кодом, обязательно нужно следовать PEP 8, поскольку это широко распространенный стандарт, который используется в большинстве проектов
с открытым исходным кодом, написанных на Python. Если вы хотите как-либо
сотрудничать с другими программистами, то должны обязательно придерживаться
PEP 8, даже если ваше мнение относительно оформления кода расходится с тем,
что приведено в данном документе. Следование изложенным в нем правилам позволяет другим разработчикам быстро и без проблем разобраться в вашем проекте.
Код будет более читабельным для новичков благодаря согласованности его стиля
с большинством других пакетов Python с открытым исходным кодом.
Глава 6.
Как выбирать имена 175
Кроме того, сразу начиная полностью соблюдать требования PEP 8, вы сэкономите время и избавитесь от некоторых проблем в будущем. Если вы захотите
выпустить свой код в массы, то рано или поздно коллеги-программисты все равно
предложат вам перейти на PEP 8. Споры о том, действительно ли это необходимо
делать для конкретного проекта, — источник длинных и безрезультатных споров.
И, как ни печально, в конечном итоге вам придется уступить, иначе с вами просто
не захотят сотрудничать.
Рестайлинг базового кода большого проекта может потребовать огромного
количества времени и усилий. В некоторых случаях придется редактировать почти
каждую строку кода. Большинство изменений можно автоматизировать (отступы,
разрывы строк, замыкающие пробелы). Тем не менее такой капитальный ремонт
кода, как правило, приводит к куче багов и конфликтов в каждом рабочем процессе
контроля версий. Кроме того, будет очень трудно охватить столько изменений сразу.
Именно поэтому во многих проектах с открытым исходным кодом есть правило, согласно которому изменение стиля кода выпускается отдельным патчем, чтобы оно
не влияло на функционал и не вызывало багов.
За пределами PEP 8 — правила стиля внутри команды
Несмотря на внушительный набор указаний по стилю, PEP 8 все-таки дает разработчикам немного свободы. Особенно это касается вложенных литералов данных
и многопоточных вызовов функций, требующих длинных списков аргументов.
Некоторые команды могут решить, что им нужны дополнительные правила оформления, и даже выпускают свой документ, который регламентирует это и доступен
для каждого ее участника.
Кроме того, в ряде ситуаций может быть нереально или экономически недостижимо полностью перевести на PEP 8 старые проекты, в которых не были определены четкие правила. Таким проектам не помешает какое-нибудь более строгое
оформление кода, пусть это и не будет именно набор правил PEP 8. Помните: согласованность в рамках проекта даже важнее, чем соблюдение PEP 8. Если у каждого
программиста есть понятные и четко описанные правила, то будет намного легче
поддерживать согласованность в рамках проекта и организации.
В следующем разделе рассмотрим различные стили именования.
Стили именования
Стили именования, используемые в Python:
ВерблюжийРегистр;
смешанныйСтиль;
176 Часть II
•
Ремесло Python
ВЕРХНИЙРЕГИСТР и ВЕРХНИЙ_РЕГИСТР_С_ПОДЧЕРКИВАНИЯМИ;
нижнийрегистр и нижний_регистр_с_подчеркиваниями;
подчеркивание _до и после_, либо __двойное__.
Строчные и прописные элементы часто представляют собой одно слово, а иногда
и несколько сочлененных слов. Будучи подчеркнутыми, они обычно являются сокращенными фразами. Лучше использовать одно слово. Подчеркивание до и после
служит для обозначения приватности и специальных элементов.
Эти стили применяются к следующим объектам:
переменные;
функции и методы;
свойства;
классы;
модули;
пакеты.
Переменные
В Python существует два вида переменных:
константы содержат значения, которые не должны изменяться во время вы-
полнения программы;
публичные и приватные переменные содержат значения, которые могут изменяться во время выполнения программы.
Константы
Для неизменяемых глобальных переменных используется верхний регистр с подчеркиванием. Такой стиль сообщает разработчику, что данная переменная —
константа.
В Python нет констант, как, например, в C++, где можно использовать
const. В Python вы можете изменить значение любой переменной.
Поэтому в Python для обозначения констант используется другой стиль
именования.
Например, модуль doctest предоставляет список флагов опций и директив (docs.python.org/lib/doctest-options.html) — это небольшие предложения, четко
определяющие, для чего предназначен каждый вариант, допустим:
from doctest import IGNORE_EXCEPTION_DETAIL
from doctest import REPORT_ONLY_FIRST_FAILURE
Глава 6.
Как выбирать имена 177
Эти имена переменных кажутся длинноватыми, но важно четко описать их.
Они используются в основном в разделе инициализации, а не в теле самого кода,
так что их многословность не слишком раздражает.
Сокращенные имена обычно только вносят путаницу. Не бойтесь использовать полные слова, если аббревиатура кажется неясной.
Кроме того, имена некоторых констант определяются их базовой технологией.
Так, в модуле os есть константы, определенные на стороне C, например серия EX_XXX,
которая определяет номера выходных кодов UNIX. То же кодовое имя можно найти, как в следующем примере, в файлах заголовков sysexits.h:
import os
import sys
sys.exit(os.EX_SOFTWARE)
Еще один хороший прием использования констант: собирать их все в верхней
части модуля, в котором они применяются. Кроме того, их часто объединяют под
новыми переменными, если они представляют собой флаги или перечисления,
которые позволяют выполнить такие операции, как в примере ниже:
import doctest
TEST_OPTIONS = (doctest.ELLIPSIS |
doctest.NORMALIZE_WHITESPACE |
doctest.REPORT_ONLY_FIRST_FAILURE)
Далее обсудим именование и использование констант.
Именование и использование
Константы служат для определения набора значений, которые использует программа, например имени файла конфигурации по умолчанию.
Хорошим приемом будет собрать все константы в одном файле в пакете. Так работает Django, например. Модуль с именем settings.py содержит все константы:
# config.py
SQL_USER = 'tarek'
SQL_PASSWORD = 'secret'
SQL_URI = 'postgres://%s:%s@localhost/db' % (
SQL_USER, SQL_PASSWORD
)
MAX_THREADS = 4
Другой подход заключается в использовании файла конфигурации, который
можно проанализировать с помощью модуля ConfigParser или другого инструмента парсинга конфигурации. Некоторые, впрочем, считают излишеством применять
другой формат файла в языках, подобных Python, где исходный файл редактируется столь же легко, как текстовый.
178 Часть II
•
Ремесло Python
Переменные-флаги обычно объединяются с логическими операциями, как это
делается в модулях doctest и re. Паттерн, взятый из doctest, довольно прост, и это
видно по следующему коду:
OPTIONS = {}
def register_option(name):
return OPTIONS.setdefault(name, 1 >> # Попробуем их
>>> SET = BLUE | RED
>>> has_option(SET, BLUE)
True
>>> has_option(SET, WHITE)
False
При определении нового набора констант следует избегать использования
общего префикса, если в модуле нет нескольких независимых наборов опций. Само
название модуля является общим префиксом.
Еще одно хорошее решение для констант-опций — задействовать класс Enum от
встроенного модуля enum и просто применять множества вместо бинарных операторов. Подробности использования и синтаксис модуля enum см. в главе 3.
Использование двоичных побитовых операций для объединения опций —
обычное дело в Python. Оператор OR (|) позволит объединить несколько
опций в одну, а AND (&) — проверить, присутствует ли опция в целом
числе (см. функцию has_option()).
В следующем пункте поговорим о публичных и приватных переменных.
Публичные и приватные переменные
Для глобальных переменных, которые являются изменяемыми и свободно доступны через импорт, следует применять строчные буквы с подчеркиванием в ситуациях, когда не требуется защита. Если переменная не должна использоваться
Глава 6.
Как выбирать имена 179
и модифицироваться за пределами порождающего модуля, то мы считаем ее приватной для него. В этом случае начальное подчеркивание помечает переменную как
приватный элемент пакета, что и показано в следующем коде:
_observers = []
def add_observer(observer):
_observers.append(observer)
def get_observers():
"""Убеждаемся, что _observers нельзя изменить"""
return tuple(_observers)
Переменные в функциях и методах следуют тем же правилам, что и публичные
переменные, и никогда не помечаются как приватные, поскольку являются локальными в контексте функции.
Для переменных класса или экземпляра нужно использовать маркер приватности (начальное подчеркивание), если включение переменной в состав публичной
сигнатуры не несет никакой полезной информации или является излишним. Иными словами, если переменная служит только для внутренних целей метода, который
предоставляет публичную функцию, то лучше сделать ее приватной.
Например, атрибуты свойств — это приватные переменные, как показано в следующем коде:
class Citizen(object):
def __init__(self, first_name, last_name):
self._first_name = first_name
self._last_name = last_name
@property
def full_name(self):
return f"{self._first_name} {self._last_name}"
Другой пример — переменная, сохраняющая некое внутреннее состояние, которое не должно быть раскрыто другим классам. Это значение не является полезным
для остального кода, но участвует в поведении класса:
class UnforgivingElephant(object):
def __init__(self, name):
self.name = name
self._people_to_stomp_on = []
def get_slapped_by(self, name):
self._people_to_stomp_on.append(name)
print('Ouch!')
def revenge(self):
print('10 years later...')
for person in self._people_to_stomp_on:
print('%s stomps on %s' % (self.name, person))
180 Часть II
•
Ремесло Python
Вот что вы увидите в интерактивной сессии:
>>> joe = UnforgivingElephant('Joe')
>>> joe.get_slapped_by('Tarek')
Ouch!
>>> joe.get_slapped_by('Bill')
Ouch!
>>> joe.revenge()
10 years later...
Joe stomps on Tarek
Joe stomps on Bill
Рассмотрим именование функций и методов.
Функции и методы
Функции и методы пишутся в нижнем регистре с подчеркиванием. Но данное правило не всегда выполняется в старых модулях стандартной библиотеки. В стандартной библиотеке Python 3 было сделано немало изменений, поэтому у большинства
функций и методов регистр букв переделан на «правильный». Тем не менее в ряде
модулей, таких как threading, еще присутствуют функции со старыми именами,
в которых используется смешанный регистр (например, currentThread). Подобные
функции были оставлены без изменений, чтобы обеспечить обратную совместимость, но если вам не требуется запускать ваш код в более старых версиях Python,
то следует избегать использования этих старых имен.
Именно такое написание было распространено до того, как нижний регистр
стал стандартом, и в некоторых фреймворках, например Zope и Twisted, названия
методов до сих пор пишутся в смешанном регистре. Сообщество программистов,
работающих с этими фреймворками, по-прежнему достаточно велико. Таким образом, выбор между данным написанием и строчными буквами с подчеркиванием,
безусловно, зависит от набора библиотек, которые вы используете.
Разработчикам Zope трудновато соблюдать общие правила именования, поскольку довольно сложно создать приложение, в котором сочетаются чистый
Python и импортированные модули Zope. В ряде классов Zope правила именования
смешиваются, поскольку базовый код по-прежнему развивается, и разработчики
Zope пытаются перейти к использованию общепринятых соглашений.
Хорошим приемом в таких библиотечных средах является использование смешанного регистра только для элементов, которые применяются в фреймворках,
и сохранение остальной части кода в стиле PEP 8.
Отметим, что разработчики проекта Twisted используют совершенно иной подход к этой проблеме. Проект Twisted, так же как Zope, возник еще до документа
PEP 8. В те времена еще не было никаких официальных руководящих принципов
по стилю кода Python, и в данном проекте были собственные правила. Стилистические правила, касающиеся отступов, строк документации, длины строк и т. д., легко
можно адаптировать. А вот обновление всего кода в соответствии с соглашениями
Глава 6.
Как выбирать имена 181
об именованиях PEP 8 приведет к полному нарушению обратной совместимости.
Допускать подобное для такого крупного проекта, как Twisted, нельзя. Поэтому
в Twisted PEP 8 был принят настолько, насколько это возможно, а смешанный
регистр остался для переменных, функций и методов в рамках собственного стандарта проекта. Но это полностью отвечает PEP 8, поскольку соответствие внутри
проекта важнее, чем соответствие указаниям РЕР 8.
Споры о приватности
Для приватных методов и функций обычно используется одно начальное подчеркивание. Это просто соглашение об именовании, и оно не имеет синтаксического
смысла. Но это не значит, что начальные подчеркивания вообще его не имеют. Когда
в названии метода есть два начальных подчеркивания, он переименовывается интерпретатором динамически, чтобы предотвратить конфликт имен с методом из любого
подкласса. Эта функция Python называется искажением (декорированием) имен.
Некоторые разработчики часто используют двойное подчеркивание для именования приватных атрибутов, чтобы избежать конфликтов имен в подклассах,
например:
class Base(object):
def __secret(self):
print("don't tell")
def public(self):
self.__secret()
class Derived(Base):
def __secret(self):
print("never ever")
Вывод кода будет следующим:
>>> Base.__secret
Traceback (most recent call last):
File "", line 1, in
AttributeError: type object 'Base' has no attribute '__secret'
>>> dir(Base)
['_Base__secret', ..., 'public']
>>> Base().public()
don't tell
>>> Derived().public()
don't tell
Причиной введения декорирования имен в Python было не желание создать
примитивную изоляцию, как у слова private в C++, а желание убедиться, что
в базовых классах будут неявно устранены конфликты в подклассах, особенно
если они предназначены для использования в различных контекстах наследования
(например, в примесях). Применение такого декорирования для каждого атрибута,
182 Часть II
•
Ремесло Python
который не является публичным, ведет к путанице в коде и делает его чрезвычайно
трудным для расширения. А это не в стиле Python.
Дополнительную информацию по этой теме можно почитать в рассылке Python,
которая выходила много лет назад, когда люди рассуждали о пользе декорирования имен и его судьбе в языке: mail.python.org/pipermail/python-dev/2005-December/
058555.html.
Рассмотрим стили именования специальных методов.
Специальные методы
Имена специальных методов (docs.python.org/3/reference/datamodel.html#specialmethodnames) начинаются и заканчиваются двойным подчеркиванием и образуют так называемые протоколы языка (см. главу 4). Некоторые разработчики привыкли называть их
методами dunder из-за двойного подчеркивания. Они используются для перегрузки
операторов, определения контейнеров и т. д. В целях читабельности их нужно собирать в начале определения класса, как показано в следующем коде:
class WeirdInt(int):
def __add__(self, other):
return int.__add__(self, other) + 1
def __repr__(self):
return '' % self
# Публичный API
def do_this(self):
print('this')
def do_that(self):
print('that')
Данное соглашение не должен применять ни один пользовательский метод, если
ему явно не требуется реализовать один из протоколов объектов Python. Так что
не придумывайте собственные методы наподобие этого:
class BadHabits:
def __my_method__(self):
print('ok')
Далее поговорим о правилах именования аргументов.
Аргументы
Аргументы именуются в нижнем регистре, с подчеркиванием, если это необходимо.
К ним применяются те же правила именования, что и к переменным, поскольку
аргументы — это всего лишь локальные переменные, которые получают свое значение при вызове функций. В следующем примере text и separator являются
аргументами функции one_line():
Глава 6.
Как выбирать имена 183
def one_line(text, separator=" "):
"""Многострочный текст объединяется в одну строку"""
return separator.join(text.split())
Перейдем к стилю именования свойств.
Свойства
Имена свойств пишутся в нижнем регистре или в нижнем регистре с подчеркиванием. В основном они представляют состояние объекта (это может быть
существительное, или прилагательное, или небольшая фраза, когда это необходимо). В следующем примере кода класс Contatiner представляет собой простую
структуру данных, которая может возвращать копии своего содержимого через
свойства unique_items и ordered_items:
class Container:
_contents = []
def append(self, item):
self._contents.append(item)
@property
def unique_items(self):
return set(self._contents)
@property
def ordered_items(self):
return list(self._contents)
Рассмотрим стили именования классов.
Классы
Имена классов всегда пишутся в верблюжьем регистре и могут иметь подчеркивание, когда являются приватными внутри модуля.
В объектно-ориентированном программировании классы используются для
инкапсуляции состояния приложения. Атрибуты объектов представляют своего
рода записи этих состояний. Методы применяются для изменения состояния,
их преобразования в понятные по смыслу значения или для побочных действий.
Именно поэтому имена классов — это, как правило, существительные или фразы, а логика их использования формируется через имена методов — глагольные
конструкции. Следующий пример кода содержит определение класса Document
с методом save():
class Document():
file_name: str
contents: str
...
184 Часть II
•
Ремесло Python
def save(self):
with open(self.file_name, 'w') as file:
file.write(self.contents)
Экземпляры классов часто задействуют те же именные конструкции, что и документ, но пишутся в нижнем регистре. Таким образом, использование класса
Document может выглядеть следующим образом:
new_document = Document()
new_document.save()
Рассмотрим стили именования для модулей и пакетов.
Модули и пакеты
Все модули, кроме специального модуля __init__, именуются в нижнем регистре.
Ниже приведены некоторые примеры из стандартной библиотеки:
os;
sys;
shutil.
В стандартной библиотеке Python для разделения слов в именах модулей подчеркивания не используются, чего не скажешь о многих других проектах. Когда
модуль является приватным в рамках пакета, добавляется начальное подчеркивание. Модули Compiled C или C++ обычно именуются с подчеркиванием и импортируются в чистые модули Python. Имена пакетов подчиняются тем же правилам,
поскольку пакеты работают скорее как структурированные модули.
В следующем разделе поговорим о других правилах именования.
Руководство по именованию
Общий набор правил именования может быть применен к переменным, методам,
функциям и свойствам. Имена классов и модулей играют весьма важную роль в построении пространства имен и оказывают сильное влияние на читабельность кода.
В этом разделе приведено мини-руководство, которое поможет вам определить
значимые и читабельные имена для элементов кода.
Использование префиксов is/has в булевых элементах
Когда элемент содержит булево значение, логично добавить в имя префикс is
и/или has, чтобы сделать переменную более читабельной. В следующем примере
идентификаторы is_connected и has_cache содержат логические состояния экземпляров класса DB:
Глава 6.
Как выбирать имена 185
class DB:
is_connected = False
has_cache = False
Использование множественного числа
в именах коллекций
Когда элемент содержит последовательность, бывает удобно использовать форму
множественного числа. То же самое можно делать и для различных переменных
отображения и свойств. В следующем примере connected_users и tables — это
атрибуты класса, которые содержат несколько значений:
class DB:
connected_users = ['Tarek']
tables = {'Customer':['id', 'first_name', 'last_name']}
Использование явных имен для словарей
Когда переменная содержит отображение, по возможности нужно использовать
явное имя. Например, если dict содержит адрес человека, то его следует назвать persons_addresses:
persons_addresses = {'Bill': '6565 Monty Road',
'Pamela': '45 Python street'}
Избегайте встроенных и избыточных имен
Обычно следует избегать использования в именах слов list, dict и set, причем
даже для локальных переменных. Python теперь предлагает аннотации функций
и переменных, а также иерархию типов, что позволяет явно указывать ожидаемый
тип для данной переменной, вследствие чего больше нет необходимости описывать
типы объектов в их именах. Это делает код трудным для чтения, понимания и использования. Кроме того, следует избегать применения встроенных имен, чтобы
не возникало переопределение в текущем пространстве имен. Общих глаголов тоже
следует избегать, если они не имеют значения в пространстве имен.
Лучше использовать термины, связанные с вашей задачей:
def compute(data): # Так примитивно
for element in data:
yield element ** 2
def squares(numbers): # Лучше
for number in numbers:
yield number ** 2
186 Часть II
•
Ремесло Python
Ниже приведен список префиксов и суффиксов, которых, несмотря на их широкое распространение в программировании, следует избегать в именах функций
и классов:
Manager;
Object;
Do, handle или perform.
Причина в том, что эти слова неконкретные, двусмысленные и не придают
никакого значения реальному имени. Джефф Этвуд, соучредитель Discourse
и Stack Overflow, написал очень хорошую статью на данную тему в своем блоге
blog.codinghorror.com/ishall-call-it-somethingmanager/.
Существует также список имен пакетов, которых следует избегать. Имена,
не связанные с содержимым пакета, могут навредить проекту в долгосрочной
перспективе. Такие имена, как misc, tools, utils, common или core, приводят к появлению больших кусков кода очень низкого качества, объем которых потом
экспоненциально растет. Чаще всего наличие такого модуля говорит о лени разработчика. Любители таких имен модулей с тем же успехом могли бы называть
их trash или dumpster, поскольку именно так их и будут воспринимать товарищи
по команде.
В большинстве случаев почти всегда лучше иметь несколько небольших модулей, пусть даже с малым количеством контента, но с именами, хорошо отража
ющими содержимое. Честно говоря, в таких именах, как utils и common, нет ничего
плохого и их можно использовать без ущерба проекту. Но реальность показывает:
часто они лишь порождают примеры того, как не надо делать, которые размножаются очень быстро. Лучше всего просто избегать таких рискованных организационных моделей и пресекать их в зародыше.
Избегайте уже существующих имен
Не принято использовать имена, которые дублируют уже существующие в том же
контексте. Это сильно усложняет чтение и отладку кода. Всегда лучше определить
заранее уже существующие имена, даже если они являются локальными по отношению к контексту. Если вам все же необходимо повторно использовать существующие имена или ключевые слова, то используйте подчеркивание в конце, чтобы
избежать конфликтов, например:
def xapian_query(terms, or_=True):
"""если or_ истинно, элементы terms объединяются
с помощью оператора OR"""
...
Глава 6.
Как выбирать имена 187
Обратите внимание: ключевое слово class часто заменяется на klass или cls:
def factory(klass, *args, **kwargs):
return klass(*args, **kwargs)
Рассмотрим некоторые рекомендации по работе с аргументами.
Практические рекомендации
по работе с аргументами
Сигнатуры функций и методов — это столпы, на которых зиждется целостность
кода. Они определяют его использование и составляют его API. Помимо правил
именования, представленных выше, следует уделить особое внимание аргументам.
Это можно сделать с помощью трех простых правил, таких как:
сборка аргументов по итеративному принципу;
доверие аргументам и тестам;
осторожное использование *args и **kwargs.
Сборка аргументов по итеративному принципу
Наличие постоянного и четко определенного списка аргументов для каждой функции делает ваш код более надежным. Но это не получится воплотить в первой
версии, так что аргументы должны быть построены по итеративному принципу.
Они должны отражать конкретные случаи использования элемента, для которого
он был создан, и развиваться соответственно.
Рассмотрим следующий пример из первых версий класса Service:
class Service: # Версия 1
def _query(self, query, type):
print('done')
def execute(self, query):
self._query(query, 'EXECUTE')
Если вы хотите расширить сигнатуру метода execute() новыми аргументами
так, чтобы сохранить обратную совместимость, то должны указать значения по
умолчанию для этих аргументов следующим образом:
class Service(object): # Версия 2
def _query(self, query, type, logger):
logger('done')
def execute(self, query, logger=logging.info):
self._query(query, 'EXECUTE', logger)
188 Часть II
•
Ремесло Python
В следующем примере из интерактивной сессии показаны два стиля вызова
метода execute() обновленного класса Service:
>>> Service().execute('my query')
# Устаревший вызов
>>> Service().execute('my query', logging.warning)
WARNING:root:done
Доверие к аргументам и тестам
Учитывая природу динамического ввода Python, некоторые разработчики используют утверждение в верхней части функций и методов с целью убедиться, что
в аргументах заложено правильное содержание, например:
def divide(dividend, divisor):
assert isinstance(dividend, (int, float))
assert isinstance(divisor, (int, float))
return dividend / divisor
Это часто делают разработчики, которые привыкли к статической типизации
и чувствуют, что в Python чего-то не хватает.
Данный способ проверки аргументов является частью стиля контрактного
программирования DBC, в котором предварительные условия проверяются перед
непосредственным запуском кода.
Две основные проблемы этого подхода заключаются в следующем:
код DBC объясняет, как его следует использовать, что делает его менее читаемым;
это может замедлить его, так как оператор условия есть в каждом вызове.
Последнего можно избежать с помощью опции -O интерпретатора Python.
В этом случае все утверждения удаляются из кода до создания байт-кода и проверка теряется.
В любом случае утверждения надо делать осторожно и их нельзя использовать
для насильного превращения Python в статически типизированный язык. Единственный случай использования такого метода — для защиты кода от бессмысленного вызова. Если вам нужна статическая типизация в Python, то обязательно
попробуйте MyPy или аналогичную статическую проверку, которая не влияет на
время выполнения кода и позволяет обеспечить определение типов в более удобном
для восприятия виде с помощью аннотаций функций и переменных.
Осторожность при работе с магическими
аргументами *args и **kwargs
Аргументы *args и **kwargs могут нарушить устойчивость функции или метода
к различным ошибкам. Сигнатура становится расплывчатой, а код выполняет лишний парсинг аргументов, хотя и не должен, например:
Глава 6.
Как выбирать имена 189
def fuzzy_thing(**kwargs):
if 'do_this' in kwargs:
print('ok i did this')
if 'do_that' in kwargs:
print('that is done')
print('ok')
>>> fuzzy_thing(do_this=1)
ok i did this
ok
>>> fuzzy_thing(do_that=1)
that is done
ok
>>> fuzzy_thing(what_about_that=1)
ok
Если список аргументов становится длинным и сложным, то появляется соблазн
добавить магические аргументы. Однако это говорит лишь о недостатках функции
или метода, который придется делить на части или реорганизовывать.
Если *args используется для работы с последовательностью элементов, которые
обрабатываются так же, как функции, то лучше будет задать в качестве аргумента
уникальный контейнер, например iterator:
def sum(*args): # Сойдет
total = 0
for arg in args:
total += arg
return total
def sum(sequence): # Лучше!
total = 0
for arg in sequence:
total += arg
return total
Для **kwargs применяется то же самое правило. Лучше зафиксировать именованные аргументы, чтобы сигнатура стала более осмысленной:
def make_sentence(**kwargs):
noun = kwargs.get('noun', 'Bill')
verb = kwargs.get('verb', 'is')
adjective = kwargs.get('adjective', 'happy')
return f'{noun} {verb} {adjective}'
def make_sentence(noun='Bill', verb='is', adjective='happy'):
return f'{noun} {verb} {adjective}'
Еще один интересный подход заключается в создании контейнера класса, который группирует несколько взаимосвязанных аргументов, чтобы создать контекст
190 Часть II
•
Ремесло Python
выполнения. Эта структура отличается от *args или **kwargs тем, что позволяет
работать со значениями и ее части могут развиваться независимо друг от друга.
Код, в котором используются такие аргументы, не будет работать с его внутренними элементами.
Например, веб-запрос, который передается в функцию, часто является экземпляром класса. Этот класс отвечает за хранение данных, передаваемых вебсервером, как показано в следующем коде:
def log_request(request): # версия 1
print(request.get('HTTP_REFERER', 'No referer'))
def log_request(request): # версия 2
print(request.get('HTTP_REFERER', 'No referer'))
print(request.get('HTTP_HOST', 'No host'))
Иногда без магических аргументов обойтись нельзя, особенно в метапрограммировании. Например, они незаменимы при создании декораторов, которые работают
в функциях с любым видом сигнатуры.
В следующем разделе поговорим об именах классов.
Имена классов
Имя класса должно быть кратким, точным и описательным. Обычно используют суффикс, в котором заложена информация о типе или природе класса, например:
SQLEngine;
Mimetypes;
StringWidget;
TestCase.
Для базовых или абстрактных классов можно использовать префикс Base или
Abstract:
BaseCookie;
AbstractFormatter.
Самое главное — не путаться в атрибутах класса. К примеру, нужно избегать
избыточности между именами класса и его атрибутов, как показано ниже:
>>> SMTP.smtp_send()
>>> SMTP.send()
# Избыточная информация в пространстве имен
# Более читабельный вариант
В следующем разделе поговорим об именах модулей и пакетов.
Глава 6.
Как выбирать имена 191
Имена модулей и пакетов
Имена модулей и пакетов должны нести информацию об их назначении и содержании.
Это короткие имена в нижнем регистре и, как правило, без подчеркивания, например:
sqlite;
postgres;
sha1.
Если модуль реализует протокол, то часто используется суффикс lib, как в следующем примере:
import smtplib
import urllib
import telnetlib
При выборе имени для модуля всегда нужно учитывать его содержание и устранять избыточность в пространстве имен, например:
from widgets.stringwidgets import TextWidget # Плохо
from widgets.strings import TextWidget
# Лучше
Когда модуль становится слишком сложным и обрастает классами, неплохо бы
создать пакет и разделить элементы модуля на дополнительные модули.
Модуль __init__ можно задействовать для возврата некоторых общих API на
верхний уровень пакета. Такой подход позволяет разделить код на более мелкие
компоненты, причем не во вред удобству использования.
Рассмотрим некоторые полезные инструменты, применяемые при работе с соглашениями по именованию и стилям.
Полезные инструменты
Общие соглашения и приемы, используемые в программном проекте, всегда должны быть задокументированы. Однако наличия документации по руководящим
принципам не всегда достаточно, чтобы обеспечить их соблюдение. К счастью,
можно применять автоматизированные инструменты, позволяющие проверить
источники кода и то, соответствует ли он требованиям конкретных соглашений об
именовании и руководящим принципам стиля.
Ниже приведено несколько популярных инструментов:
pylint — очень гибкий анализатор исходного кода;
pycodestyle и flake8 — инструменты для проверки и обертки кода, которые
к тому же добавляют в код некоторые полезные функции, такие как статический анализ и измерение сложности.
192 Часть II
•
Ремесло Python
Pylint
Помимо некоторых показателей обеспечения качества, Pylint позволяет проверить,
соответствует ли данный исходный код соглашению об именовании. Его настройки
по умолчанию соответствуют PEP 8, а скрипт Pylint обеспечивает вывод отчета
оболочки.
Чтобы установить Pylint, вы можете использовать pip следующим образом:
$ pip install pylint
После этого команда будет доступна и вы сможете работать с одним или несколькими модулями с помощью символов. Попробуем Pylint на скрипте boot
strap.py из Buildout, как показано ниже:
$ wget -O bootstrap.py https://bootstrap.pypa.io/bootstrap-buildout.py -q
$ pylint bootstrap.py
No config file found, using default configuration
************* Module bootstrap
C: 76, 0: Unnecessary parens after 'print' keyword (superfluous-parens)
C: 31, 0: Invalid constant name "tmpeggs" (invalid-name)
C: 33, 0: Invalid constant name "usage" (invalid-name)
C: 45, 0: Invalid constant name "parser" (invalid-name)
C: 74, 0: Invalid constant name "options" (invalid-name)
C: 74, 9: Invalid constant name "args" (invalid-name)
C: 84, 4: Import "from urllib.request import urlopen" should be placed at
the top of the module (wrong-import-position)
...
Global evaluation
----------------Your code has been rated at 6.12/10
Реальный вывод Pylint будет немного длиннее, а здесь он был усечен для крат
кости.
Помните, что Pylint часто выдает ложноположительные предупреждения,
которые снижают общую оценку качества. Например, оператор импорта, не используемый в коде самого модуля, прекрасно будет работать в некоторых случаях
(например, при сборке модулей __init__ верхнего модуля в пакете). Вывод Pylint —
это скорее подсказка, а не что-то стопроцентно верное.
Выполнение вызовов библиотек, в именах методов которых используется
смешанный регистр, также может привести к снижению оценки. В любом случае
глобальная оценка кода не так уж важна. Pylint — это просто инструмент, который
указывает, что можно улучшить.
Всегда рекомендуется подстраивать Pylint под себя. Для этого нужно создать
файл конфигурации .pylinrc в корневом каталоге вашего проекта. Вы можете
сделать это с помощью опции -generate-rcfile команды pylint:
$ pylint --generate-rcfile > .pylintrc
Глава 6.
Как выбирать имена 193
Этот файл конфигурации самодокументируется (то есть все опции описаны
в комментариях) и уже должен содержать все доступные опции конфигурации
Pylint.
Помимо проверки на соответствие некоторым обязательным стандартам кодирования, Pylint также может дать дополнительную информацию об общем качестве
кода, например:
метрики дублирования кода;
неиспользованные переменные и импорт;
отсутствующие строки документации в функциях, методах или классах;
слишком длинные сигнатуры функций.
Список проверок, доступных по умолчанию, довольно велик. Важно понимать,
что часть этих правил весьма условна и их не всегда легко применить к каждой
кодовой базе. Помните, что последовательность всегда ценнее, чем соблюдение
некоторых произвольных правил. К счастью, Pylint довольно гибок, так что если
в вашей команде используются некие соглашения по именованию и кодированию,
которые отличаются от общепринятых, то вы легко можете настроить Pylint для
проверки согласованности именно с вашими соглашениями.
pycodestyle и flake8
Инструмент pycodestyle (ранее назывался pep8) был создан для выполнения проверки стиля по соглашениям, определенным в PEP 8. Это его основное отличие
от Pylint, у которого есть еще много других возможностей. Это лучший вариант
для программистов, желающих иметь автоматизированный инструмент проверки
стиля кода только для стандарта PEP 8, без каких-либо дополнительных настроек,
как в случае с Pylint.
Устанавливается pycodestyle через pip следующим образом:
$ pip install pycodestyle
При запуске скрипта bootstrap.py из Buildout вы получите следующий краткий
перечень нарушений стиля:
$ wget -O bootstrap.py https://bootstrap.pypa.io/bootstrap-buildout.py -q
$ pycodestyle bootstrap.py
bootstrap.py:118:1: E402 module level import not at top of file
bootstrap.py:119:1: E402 module level import not at top of file
bootstrap.py:190:1: E402 module level import not at top of file
bootstrap.py:200:1: E402 module level import not at top of file
Основное отличие этого вывода от Pylint заключается в его длине. Инструмент
pycodestyle сконцентрирован только на стиле и поэтому не выдает других преду
преждений, таких как неиспользуемые переменные, слишком длинные имена
194 Часть II
•
Ремесло Python
функций или отсутствующие строки документации. Кроме того, он не дает коду
оценку. В этом есть смысл, поскольку нет такого понятия, как «код частично написан по правилам». Любое, даже малейшее, нарушение руководящих принципов
стиля делает код несоответствующим PEP 8.
Код pycodestyle проще, чем у Pylint, и его вывод легче анализировать, так что
он может подойти, если вы хотите включить проверку стиля кода в непрерывный
процесс интеграции. В случае нехватки каких-нибудь функций статического анализа есть пакет flake8, который является оболочкой pycodestyle, а также нескольких
других легко расширяемых инструментов, имеющих более широкий набор функций. К ним относятся следующие:
измерение сложности Мак-Кейба;
статический анализ с помощью pyflakes;
отключение целых файлов или отдельных строк с помощью комментариев.
Резюме
В этой главе мы рассмотрели наиболее распространенные и широко принятые
соглашения о написании кода. Мы начали с официального руководства по стилю
Python (PEP 8). Затем мы дополнили его кое-какими предложениями по именованию, которые сделают ваш будущий код более явным. Мы также рассмотрели
ряд полезных инструментов, необходимых для поддержания согласованности
и качества кода.
Теперь вы готовы перейти к первой практической теме книги: написанию
и распространению собственных пакетов. В следующей главе вы узнаете, как
опубликовать собственный пакет в репозитории PyPI, а также как использовать
возможности экосистем упаковки в вашей частной организации.
7
Создаем пакеты
Эта глава посвящена процессу написания и выпуска пакетов на Python. Мы узнаем, как побыстрее установить все, что нужно, прежде чем начать реальную работу.
Мы также увидим, как стандартизировать методику написания пакетов и упростить
разработку через тестирование. Наконец, мы поговорим о том, как облегчить процесс выпуска.
Глава разделена на следующие четыре части.
Общая схема для всех пакетов, описывающая сходство между всеми пакетами
Python, а также то, какую роль играют дистрибутивы и инструменты установки
в процессе упаковки.
Что такое пакеты пространства имен и чем они полезны.
Как зарегистрировать и загрузить пакеты в каталог пакетов Python (Python
Package Index, PyPI), правила безопасности и распространенные ловушки.
Исполняемые файлы как альтернативный способ упаковки и распространения
приложений, написанных на Python.
В этой главе:
создание пакета;
пакеты пространства имен;
загрузка пакета;
исполняемые файлы.
Технические требования
Ниже перечислены упомянутые в этой главе пакеты, которые можно скачать
с PyPI:
twine;
wheel;
cx_Freeze;
196 Часть II
•
Ремесло Python
py2exe;
pyinstaller.
Установить эти пакеты можно с помощью следующей команды:
python3 -m pip install
Файлы кода для этой главы можно найти по ссылке github.com/packtpublishing/
expert-python-programming-third-edition/tree/master/chapter7.
Создание пакета
Сам процесс упаковки в Python может поначалу показаться странным. В основном
это связано с путаницей в инструментах, подходящих для создания пакетов Python.
Однако, создав свой первый пакет, вы увидите, что все не так сложно, как выглядит. Кроме того, вам поможет знание передовых инструментов.
Вам стоит уметь создавать пакеты, даже если вы не собираетесь распространять
код. Умение создавать собственные пакеты позволит лучше понимать всю эту
систему и, следовательно, разбираться со сторонним кодом, который есть на PyPI
и который вы, вероятно, уже используете.
Кроме того, наличие проекта сзакрытым исходным кодом или его компонентов в виде пакетов может помочь в развертывании вашего кода в различных
средах. Преимущества использования packaging-экосистемы Python в процессе
развертывания кода будут более подробно описаны в следующей главе. Здесь
мы сосредоточимся на правильных инструментах и методах создания таких дистрибутивов.
В следующем разделе мы обсудим, что такого странного в нынешних инструментах создания пакетов в Python.
Странности в нынешних инструментах
создания пакетов в Python
Создание пакетов Python переживало долгие странные времена, и потребовалось
много лет, чтобы навести в этой теме порядок. Все началось с пакета distutils,
введенного в 1998 году, который впоследствии был дополнен setuptools в 2003-м.
Эти два проекта породили великое множество ответвлений, альтернативных проектов и полных переписываний, целью которых было раз и навсегда привести
в порядок экосистему упаковки в Python. К сожалению, большинство из этих попыток оказались неудачными. Эффект был совершенно противоположный. Каждый новый проект, пытавшийся заменить собой setuptools или distutils, только
вносил еще больше путаницы. Одни проекты выродились и вернулись к корням
Глава 7.
Создаем пакеты 197
(например, distribute — ответвление setuptools), а другие остались заброшенными (например, distutils2).
К счастью, дела постепенно налаживаются. Организация под названием
Python Packaging Authority (PyPA) была создана с целью вернуть порядок и организацию в экосистему упаковки. Руководство пользователя по упаковке Python
(packaging.python.org), выпускаемое PyPA, является авторитетным источником
информации о новейших средствах упаковки и практических рекомендациях.
В рамках данной главы этот сайт можно считать лучшим источником информации
об упаковке. Руководство также содержит подробную историю изменений и информацию о новых проектах, связанных с упаковкой. Даже если вы уже знаете
кое-что об упаковке, его стоит прочесть с целью убедиться, что вы используете
правильные инструменты.
Держитесь подальше от других популярных интернет-ресурсов, таких как
Hitchhiker’s Guide to Packaging. Это старый, неподдерживаемый и неактуальный
ресурс. Он может быть интересен только любителям истории, а PyPA, по сути, ответвление данного ресурса.
Посмотрим, как PyPA влияет на упаковку Python.
Нынешняя ситуация с созданием пакетов Python благодаря PyPA
Помимо авторитетного руководства по упаковке, PyPA также поддерживает другие
подобные проекты и процесс стандартизации новых официальных аспектов упаковки Python. Все проекты PyPA можно найти в одной организации на GitHub:
github.com/pypa.
Некоторые из проектов уже были упомянуты в книге. Ниже приведены наиболее видные из них:
pip;
virtualenv;
twine;
warehouse.
Обратите внимание: большинство из этих проектов были запущены еще до PyPA
и вошли в него, уже будучи зрелыми и распространенными решениями.
Благодаря PyPA произошел прогрессивный отказ от всякой ерунды в пользу хороших решений. Кроме того, благодаря приверженности сообщества PyPA старая
реализация PyPI была наконец полностью переписана в виде проекта Warehouse.
Теперь у PyPI появился модернизированный пользовательский интерфейс и произошло долгожданное улучшение юзабилити и возможностей.
В следующем подразделе рассмотрим некоторые из инструментов, рекомендуемых для работы с пакетами.
198 Часть II
•
Ремесло Python
Рекомендации по инструментам
Руководство по упаковке Python содержит несколько рекомендаций по инструментам для работы с пакетами. В целом их можно разделить на следующие две
группы:
инструменты для установки пакетов;
инструменты для создания и распространения пакетов.
Инструменты из первой группы, рекомендованные PyPA, уже упоминались
нами в главе 2, однако вспомним их еще раз в порядке логики повествования:
использование pip для установки пакетов из PyPI;
использование virtualenv или venv для изоляции среды выполнения Python
на уровне приложения.
Рекомендации руководства по упаковке Python, касающиеся инструментов для
создания и распространения пакетов, заключаются в следующем:
используйте setuptools для определения проектов и распространения исходного
кода;
используйте подходящие средства для распространения сборок;
используйте twine для загрузки пакета на PyPI и его последующего распро-
странения.
Рассмотрим, как настроить ваш проект.
Конфигурация проекта
Очевидно, что самый простой способ организовать код в большом приложении —
разделить его на несколько пакетов. Это упрощает понимание кода, его сопровождение и редактирование, а также максимизирует повторное использование кода.
Отдельные пакеты выступают в качестве компонентов, которые можно задействовать в различных программах.
setup.py
В корневом каталоге распространяемого пакета есть скрипт setup.py. Там определены все метаданные, как описано в модуле distutils. Метаданные пакета выражаются в виде аргументов при вызове стандартной функции setup(). Несмотря на то что
distutils — это модуль стандартной библиотеки, предназначенный для упаковки,
все же вместо него рекомендуется использовать setuptools. В данный пакет внесены
несколько усовершенствований по сравнению со стандартным модулем distutils.
Глава 7.
Создаем пакеты 199
Минимальное содержание этого файла выглядит следующим образом:
from setuptools import setup
setup(
name='mypackage',
)
Элемент name — это полное имя пакета. Кроме того, скрипт предоставляет несколько команд, которые можно вывести с помощью опции --help-commands, как
показано в следующем коде:
$ python3 setup.py --help-commands
Standard commands:
build
build everything needed to install
clean
clean up temporary files from 'build' command
install
install everything from build directory
sdist
create a source distribution (tarball, zip file, etc.)
register
register the distribution with the Python package index
bdist
create a built (binary) distribution
check
perform some checks on the package
upload
upload binary package to PyPI
Extra commands:
bdist_wheel
alias
develop
usage:
or:
or:
or:
setup.py
setup.py
setup.py
setup.py
create a wheel distribution
define a shortcut to invoke one or more commands
install package in 'development mode'
[global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts] ...]
--help [cmd1 cmd2 ...]
--help-commands
cmd --help
На самом деле список команд больше и может варьироваться в зависимости от
имеющихся расширений setuptools. Мы показали только самые важные и актуальные для данной главы. Стандартные команды — это встроенные команды, из
distutils, а дополнительные пришли из сторонних пакетов, таких как setuptools,
или из любого другого пакета, который определяет и регистрирует новую команду.
Здесь примером такой дополнительной команды является bdist_wheel из пакета
wheel.
setup.cfg
Файл setup.cfg содержит параметры по умолчанию для команд скрипта setup.py.
Он чрезвычайно полезен, когда процесс создания и распространения пакета услож
няется и требует множества необязательных аргументов, которые передаются
командам скрипта setup.py. В файле setup.cfg можно хранить такие параметры по
200 Часть II
•
Ремесло Python
умолчанию вместе с исходным кодом для каждого проекта. Это позволит сделать
процедуру распространения независимой от проекта, а также обеспечит прозрачность процесса сборки и распространения пакета среди пользователей и других
членов команды.
Синтаксис файла setup.cfg аналогичен встроенному модулю configparser,
поэтому он похож на популярные файлы Microsoft Windows INI. Ниже приведен
пример файла конфигурации setup.cfg , в котором определены параметры по
умолчанию global, sdlist и bdist_wheel:
[global]
quiet=1
[sdist]
formats=zip,tar
[bdist_wheel]
universal=1
Пример конфигурации гарантирует, что исходный код (раздел sdist ) все
гда будет создаваться в двух форматах (ZIP и TAR), а встроенные сборки wheel
(bdist_wheel) создаются как универсальные диски, не зависящие от версии Python.
Кроме того, большая часть выходных данных каждой команды будет подавляться
глобальным переключателем --quiet. Обратите внимание: эта опция включена
только в целях демонстрации и подавлять вывод для каждой команды по умолчанию с помощью данной опции — плохое решение.
MANIFEST.in
При создании сборки с помощью команды sdist модуль distutils просматривает
каталог пакета и ищет файлы, которые следует добавить в архив. По умолчанию
distutils содержит:
все исходные файлы Python, подключенные в аргументах py_modules, packages
и scripts;
все исходные файлы C, перечисленные в аргументе ext_modules;
файлы, соответствующие стандартной маске test/test*.py;
файлы с именами README, README.txt, setup.py и setup.cfg.
Кроме того, если ваш пакет управляется системой контроля версий, например
Subversion, Mercurial или Git, то вы можете автоматически включать все версии
контролируемых файлов с помощью дополнительных расширений setuptools,
таких как setuptools-svn, setuptools-hg, и setuptools-git. С помощью других
расширений возможна интеграция с другими системами управления версиями.
Независимо от того, встроенная это стратегия сбора по умолчанию или опреде-
Глава 7.
Создаем пакеты 201
ляется пользовательским расширением, sdist создаст файл MANIFEST, в котором
перечислены все файлы, и включит их в финальный архив.
Предположим, что вы не используете никаких дополнительных расширений
и нужно включить в дистрибутив пакета файлы, которые не были учтены по умолчанию. Вы можете определить шаблон MANIFEST.in в корневом каталоге вашего
пакета (тот же каталог, что и у файла setup.py). Этот шаблон направляет sdist
команду о том, какие файлы включить.
Шаблон MANIFEST.in определяет одно включение или исключение правила
в каждой строке:
include HISTORY.txt
include README.txt
include CHANGES.txt
include CONTRIBUTORS.txt
include LICENSE
recursive-include *.txt *.py
Полный список команд MANIFEST.in можно найти в официальной документации
distutils.
Наиболее важные метаданные
Помимо имени и версии сборки пакета, можно выделить другие наиболее важные
аргументы, которые принимает функция setup():
description: — несколько предложений, описывающих пакет;
long_description — включает в себя полное описание в виде reStructuredText
(по умолчанию) или на других поддерживаемых языках разметки;
long_description_content_type — определяет тип MIME длинного описания;
служит для указания репозитория пакетов, какой язык разметки используется
для описания пакета;
keywords — список ключевых слов, которые определяют пакет и дают лучшую
индексацию в репозитории пакетов;
author — имя автора пакета или выпустившей его организации;
author_email — адрес электронной почты автора;
url — URL проекта;
license — имя лицензии (GPL, LGPL и т. д.), под которой распространяется
пакет;
packages — список всех имен пакетов в сборке; в setuptools есть небольшая
функция под названием find_packages, позволяющая автоматически находить
имена пакетов, которые надо включить;
namespace_packages — список пакетов пространства имен в пределах сборки.
202 Часть II
•
Ремесло Python
Классификаторы коллекций
PyPI и distutils — это решения для категоризации приложений с множеством
классификаторов под классификаторы коллекций. Все классификаторы коллекций
образуют древовидную структуру. Каждая строка классификатора определяет
список вложенных пространств имен, в котором каждое из них разделено подстрокой ::. Их перечень приведен в определении пакета в аргументе classifiers
функции setup().
Ниже приведен пример списка классификаторов, взятых из проекта solrq, доступного на PyPI:
from setuptools import setup
setup(
name="solrq",
# (...)
classifiers=[
'Development Status :: 4 - Beta',
'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.2',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: Implementation :: PyPy',
'Topic :: Internet :: WWW/HTTP :: Indexing/Search',
],
)
Применять такие классификаторы в определении пакета совершенно не обязательно, но это бывает полезно в дополнение к основным метаданным, доступным
в интерфейсе setup(). Среди прочего классификаторы коллекций предоставляют
информацию о поддерживаемых версиях Python, поддерживаемых операционных
системах, стадиях разработки проекта или лицензиях кода. Многие пользователи
PyPI ищут доступные пакеты по категориям, и правильная классификация в этом
случае помогает пакетам достичь их цели.
Классификаторы коллекций играют важную роль во всей экосистеме создания
пакетов, и их никогда не следует игнорировать. Нет ни одной организации, которая
проверяла бы классификацию пакетов, так что именно вы должны задать классификаторы для ваших пакетов, чтобы не порождать в репозитории хаос.
Глава 7.
Создаем пакеты 203
На момент написания этой книги на PyPI есть 667 классификаторов, разбитых
на следующие девять основных категорий:
состояние разработки;
среда;
фреймворк;
целевая аудитория;
лицензия;
естественный язык;
операционная система;
язык программирования;
тема.
Данный список постоянно растет, и время от времени добавляются новые классификаторы. Поэтому их количество может оказаться другим в момент, когда вы
читаете эти строки. Полный список доступных в настоящее время классификаторов
коллекций можно посмотреть по ссылке pypi.org/classifiers.
Общие шаблоны
Создание сборки пакета — довольно утомительная задача для неопытных разработчиков. Большую часть метаданных, которые подаются setuptools или
distuitls в функциях setup(), можно настроить вручную, игнорируя тот факт,
что эти метаданные могут быть доступны и в других частях проекта. Ниже показан пример:
from setuptools import setup
setup(
name="myproject",
version="0.0.1",
description="mypackage project short description",
long_description="""
Longer description of mypackage project
possibly with some documentation and/or
usage examples
""",
install_requires=[
'dependency1',
'dependency2',
'etc',
]
)
204 Часть II
•
Ремесло Python
Некоторые элементы метаданных время от времени встречаются в разных
местах в типичном проекте Python. Например, содержание длинного описания
обычно включается в файл README , а спецификатор версии обычно находится
в модуле __init__ пакета. Жесткая привязка таких метаданных к функции setup()
будет избыточной и приведет к лишним ошибкам и несоответствиям в дальнейшем.
Модули setuptools и distutils не могут автоматически получать информацию
о метаданных из кода проекта, так что это нужно делать самостоятельно. Сообществом Python приняты некоторые общие шаблоны решения наиболее популярных
проблем, таких как управление зависимостями, включение версий/README и т. д.
Следует знать по крайней мере некоторые из них: они настолько популярны, что
их можно брать за эталон.
Автоматическое включение строки версии из пакета. В документе PEP 440
определен стандарт спецификации версии и зависимостей. Это длинный документ,
в котором описывается схема спецификации версий и определяется, как в Python
должно работать сопоставление и сравнение версий в инструментах упаковки.
Если вы используете или планируете применять сложную систему нумерации
версий проекта, то вам следует внимательно изучить этот документ. В случае использования простой схемы из одного, двух, трех или более чисел, разделенных
точками, копаться в PEP 440 вам не придется. Если вы не знаете, как выбрать правильную схему управления версиями, то я настоятельно рекомендую применить
семантическую схему, которую мы уже кратко упоминали в главе 1.
Другая проблема, связанная с управлением версиями кода, заключается в том,
где именно надо подключать спецификатор версии внутри пакета или модуля.
Документ PEP 396 решает именно эту проблему. Данный документ является информационным и имеет отложенный статус и поэтому не входит в перечень официальных стандартов Python. Однако в нем описано то, что де-факто в настоящее
время считается стандартом. Согласно PEP 396, если у пакета или модуля есть
конкретная определенная версия, то спецификатор версии включается как атрибут
__version__ в файл __init__.py или в файл сборки модуля. Еще один общепринятый стандарт — это включать атрибут VERSION, содержащий кортеж частей специ
фикатора версии. Такой способ помогает пользователям писать совместимый код,
поскольку кортежи версий удобно сравнивать, если схема управления версиями
достаточно проста.
Множество пакетов, выложенных на PyPI, соответствуют обоим соглашениям.
В их файлах __init__.py указаны атрибуты версий, которые выглядят следующим
образом:
# Версия в виде кортежа для простоты сравнения
VERSION = (0, 1, 1)
# Строка, созданная из кортежа во избежание ошибок
__version__ = ".".join([str(x) for x in VERSION])
Глава 7.
Создаем пакеты 205
Другое предложение PEP 396 состоит в том, чтобы аргумент версии в функции
setup() из скрипта setup.py извлекался из __version__ или наоборот. В руковод-
стве по упаковке Python есть несколько шаблонов для генерации версии проекта
из одного источника, и каждый из них имеет свои преимущества и недостатки.
Мой любимый шаблон довольно длинный и не входит в руководство PyPA, но его
сложность ограничена лишь скриптом setup.py. Этот шаблон предполагает, что
спецификатор версии указан в атрибуте VERSION модуля __init__ и извлекает эти
данные для включения в вызов setup(). Ниже приведен отрывок из скрипта setup.py
воображаемого пакета, иллюстрирующий этот подход:
from setuptools import setup
import os
def get_version(version_tuple):
# Дополнительная обработка тегов, которая может быть
# проще в зависимости от схемы версий
if not isinstance(version_tuple[-1], int):
return '.'.join(
map(str, version_tuple[:-1])
) + version_tuple[-1]
return '.'.join(map(str, version_tuple))
# Путь к модулю __init__ в проекте
init = os.path.join(
os.path.dirname(__file__), 'src', 'some_package',
'__init__.py'
)
version_line = list(
filter(lambda l: l.startswith('VERSION'), open(init))
)[0]
# VERSION — это кортеж, поэтому нужно оценить version_line.
# Мы могли бы просто импортировать его из пакета, но не факт,
# что пакет импортируется до того, как установка будет завершена.
PKG_VERSION = get_version(eval(version_line.split('=')[-1]))
setup(
name='some-package',
version=PKG_VERSION,
# ...
)
Файл README. Каталог пакетов Python позволяет отображать файл README
проекта или значение переменной long_description на странице пакета на портале PyPI. Портал может интерпретировать разметку, используемую в содержании
long_description , и выводит ее в виде HTML на странице пакета. Тип языка
206 Часть II
•
Ремесло Python
разметки задается аргументом long_description_content_type функции setup().
На данный момент доступны следующие три варианта разметки:
обычный текст — long_description_content_type='text/plain';
ReStructuredText — long_description_content_type='text/x-rst';
Markdown — long_description_content_type='text/markdown'.
Markdown и ReStructuredText — наиболее популярный выбор разработчиков
на Python, но кто-то по тем или иным причинам по-прежнему может использовать
другие языки разметки. Если вы хотите задействовать какой-то другой язык разметки для README вашего проекта, то можете указать его в качестве описания проекта на странице PyPI в читабельном виде. Вся соль здесь в применении пакета
pypandoc, который позволяет превратить другой язык разметки в ReStructuredText
(или Markdown) при загрузке пакета в каталог пакетов Python. Нужно также преду
смотреть запасной вариант для простого отображения файла README , поэтому
установка не завершится неудачей, если у пользователя не установлен pypandoc.
Ниже приведен код скрипта setup.py, который считывает содержимое файла README,
записанного на языке разметки AsciiDoc и переводит его в ReStructuredText перед
включением аргумента long_description.
from setuptools import setup
try:
from pypandoc import convert
def read_md(file_path):
return convert(file_path, to='rst', format='asciidoc')
except ImportError:
convert = None
print(
"warning: pypandoc module not found, "
"could not convert Asciidoc to RST"
)
def read_md(file_path):
with open(file_path, 'r') as f:
return f.read()
README = os.path.join(os.path.dirname(__file__), 'README')
setup(
name='some-package',
long_description=read_md(README),
long_description_content_type='text/x-rst',
# ...
)
Управление зависимостями. Многим проектам для нормальной работы требуется установка внешних пакетов. Когда список зависимостей становится длинным,
Глава 7.
Создаем пакеты 207
встает вопрос управления им. Ответ в большинстве случаев прост: не надо перебарщивать со сложностью. Просто явно укажите список зависимостей в скрипте
setup.py следующим образом:
from setuptools import setup
setup(
name='some-package',
install_requires=['falcon', 'requests', 'delorean']
# ...
)
Некоторые разработчики на Python любят использовать файлы requirements.txt
для отслеживания списков зависимостей своих пакетов. Иногда это обоснованно,
но в большинстве случаев является пережитком времен, когда код проекта нельзя
было нормально упаковать. Во всяком случае, даже такие известные проекты, как
Celery, по-прежнему придерживаются этого стиля. Поэтому, если вы не готовы изменить свои привычки или по какой-то причине вынуждены использовать такой
файл, то по крайней мере делайте это правильно. Вот одна из популярных идиом
для чтения списка зависимостей из файла requirements.txt:
from setuptools import setup
import os
def strip_comments(l):
return l.split('#', 1)[0].strip()
def reqs(*f):
return list(filter(None, [strip_comments(l) for l in open(
os.path.join(os.getcwd(), *f)).readlines()]))
setup(
name='some-package',
install_requires=reqs('requirements.txt')
# ...
)
В следующем подразделе вы узнаете, как добавлять пользовательские команды
в скрипт настройки.
Пользовательская команда setup
Модуль distutils позволяет создавать новые команды. Новая команда регистрируется с точкой входа, указанной в setuptools, что дает простой способ определения
пакетов в виде плагинов.
Точка входа является именованной ссылкой на класс или функцию, которая
доступна через несколько API в setuptools . Любое приложение может просмотреть все зарегистрированные пакеты и использовать связанный код в виде
плагина.
208 Часть II
•
Ремесло Python
Чтобы привязать новую команду, можно применить метаданные entry_points
в вызове setup следующим образом:
setup(
name="my.command",
entry_points="""
[distutils.commands]
my_command = my.command.module.Class
"""
)
Все именованные ссылки собираются в именованных разделах. После загрузки модуль distutils сканирует ссылки, которые были зарегистрированы
в distutils.commands.
Этот механизм используется многими приложениями Python, которые обеспечивают расширяемость.
Посмотрим, как работать с пакетами на стадии разработки.
Работа с пакетами в процессе разработки
Работа с setuptools в основном связана с созданием и распространением пакетов.
Тем не менее setuptools также нужно использовать для установки пакетов непосредственно из источника, и причина этому проста. Желательно проверять, правильно ли
работает код упаковки, прежде чем отправлять пакет в PyPI. Самый простой способ
проверить пакет — это установить его. Если вы отправляете в репозиторий повре
жденный пакет, то для его повторной загрузки вам следует увеличить номер версии.
Тестирование на предмет правильной упаковки перед окончательной сборкой
спасает вас от ненужных увеличений номера версии и, очевидно, пустой траты
времени. Кроме того, установка непосредственно из исходного кода с помощью
setuptools может иметь значение при одновременной работе с несколькими смежными пакетами.
Установка setup.py
Команда install устанавливает пакет в текущей среде Python. Она будет пытаться
собрать пакет, если более ранняя сборка не выполнена, а затем вводит результат
в каталог файловой системы, где Python ищет установленные пакеты. При наличии
архива со сборкой какого-либо пакета вы можете распаковать его во временную
папку, а затем установить его с помощью этой команды. Она также установит зависимости, которые определены в аргументе install_requires. Зависимости будут
установлены из каталога пакетов Python.
Вместо голого скрипта setup.py для установки пакета можно использовать
pip. Так как этот инструмент рекомендован PyPA, его стоит применять даже при
Глава 7.
Создаем пакеты 209
установке пакета в локальной среде в процессе разработки. Чтобы установить пакет
из локального источника, выполните следующую команду:
pip install
Удаление пакета
Удивительно, но в setuptools и distutils нет команды uninstall. К счастью, любой
пакет Python можно удалить с помощью pip:
pip uninstall
Удаление — опасная операция для общесистемных пакетов. Это еще одна причина, почему для разработки важно использовать виртуальное окружение.
setup.py или pip -e
Пакеты, установленные командой setup.py install, копируются в каталог sitepackages текущей среды Python. Это значит, что всякий раз, когда вы вносите
изменения в исходный код пакета, его придется переустановить. Об этом часто
забывают, вследствие чего возникают проблемы. Поэтому в setuptools есть дополнительная команда develop, которая позволяет устанавливать пакеты в режиме
разработки. Эта команда создает специальную ссылку, которая проецирует исходный код в каталоге развертывания (site-packages) вместо копирования туда
пакета. Исходный код пакетов можно редактировать, не прибегая к необходимости
переустановки, и они доступны в sys.path, как если бы были установлены в обычном режиме.
pip тоже позволяет устанавливать пакеты в таком режиме. Этот вариант установки называется редактируемым режимом и включается параметром -e в команде
install следующим образом:
pip install -e
После установки пакета в среду в режиме редактирования вы можете спокойно
редактировать установленный пакет на месте, все изменения будут видны сразу,
и необходимость повторной установки пакета не будет возникать.
В следующем разделе рассмотрим пакеты пространства имен.
Пакеты пространства имен
В «Дзене Пайтона», который можно прочитать после import this в сессии интерпретатора, сказано следующее: «Пространства имен — отличная идея, давайте
использовать их почаще!»
210 Часть II
•
Ремесло Python
Это можно интерпретировать по меньшей мере двумя способами. Первый: пространство имен в контексте языка. Сами того не зная, мы используем следующие
пространства имен:
глобальное пространство имен модуля;
локальное пространство имен функции или вызова метода;
пространство имен класса.
Еще один вид пространства имен существует на уровне упаковки. Это пакеты
пространства имен. Эту функцию упаковки Python часто упускают из виду, однако
она весьма полезна для структурирования экосистемы пакетов в пределах организации или в очень крупном проекте.
Почему это полезно
Пространство имен можно расценивать как способ группировки связанных пакетов, где каждый из этих пакетов устанавливается независимо друг от друга.
Пакеты пространства имен особенно полезны, если у вас есть обособленно разработанные, упакованные и пронумерованные компоненты, но вам нужно иметь
доступ к ним из одного пространства имен. Это также помогает четко определить,
к какой организации или проекту относится каждый пакет. Например, для вымышленной компании Acme общее пространство имен может называться acme. В такой
организации можно было бы создать общий пакет пространства имен acme, который выступает в качестве контейнера для других пакетов из данной организации.
Например, если кто-то из Acme захочет внести изменения в пространство имен, например библиотеку SQL-запросов, то может создать новый пакет acme.sql, который
регистрирует себя в пространстве имен acme.
Важно понимать, чем отличаются обычные пакеты и пакеты пространства имен
и какие задачи они решают. В нормальной ситуации (без пакетов пространства
имен) вы можете создать пакет под названием acme с подпакетом/подмодулем sql
со следующей структурой файла:
$ tree acme/
acme/
├── acme
│
├── __init__.py
│
└── sql
│
└── __init__.py
└── setup.py
2 directories, 3 files
Всякий раз, когда вы захотите добавить новый подпакет, его нужно будет включить в исходное дерево acme следующим образом:
Такой подход делает независимую разработку acme.sql и acme.templating
практически невозможной. В скрипте setup.py нужно указать все зависимости
для каждого подпакета. В связи с этим будет невозможно (или по крайней мере
очень сложно) настроить опциональную установку отдельных компонентов acme.
Кроме того, при наличии большого количества подпакетов практически нереально
избежать конфликтов зависимостей.
Пакеты пространства имен позволяют хранить независимое исходное дерево
для каждого из этих подпакетов:
$ tree acme.sql/
acme.sql/
├── acme
│
└── sql
│
└── __init__.py
└── setup.py
2 directories, 2 files
$ tree acme.templating/
acme.templating/
├── acme
│
└── templating
│
└── __init__.py
└── setup.py
2 directories, 2 files
Их можно также зарегистрировать в PyPI или другом каталоге пакетов независимо друг от друга. Пользователи могут выбрать, какие подпакеты хотят установить из пространства имен acme, однако никогда не устанавливают весь пакет acme
(его может даже не существовать):
$ pip install acme.sql acme.templating
Обратите внимание: независимости деревьев исходного кода недостаточно,
чтобы создавать пакеты пространства имен в Python. Потребуется принять дополнительные меры, чтобы ваши пакеты не перезаписывали друг друга. Кроме того,
эти самые меры могут отличаться в зависимости от версии языка Python, с которой
вы работаете. Подробнее об этом поговорим в следующих двух пунктах.
212 Часть II
•
Ремесло Python
PEP 420 — неявные пакеты пространства имен
Если вы планируете работать только с Python 3, то у меня для вас хорошая новость.
В документе PEP 420 задан новый способ определения пространства имен пакетов.
Этот документ является частью стандартов и стал официальной частью языка,
начиная с версии 3.3. Если коротко, то каждый каталог, в котором содержатся пакеты или модули Python (включая пакеты пространства имен), считается пакетом
пространства имен при условии, что в нем нет файла __init__. Ниже приведены
примеры файловых структур, представленных в предыдущем подразделе:
$ tree acme.sql/
acme.sql/
├── acme
│
└── sql
│
└── __init__.py
└── setup.py
2 directories, 2 files
$ tree acme.templating/
acme.templating/
├── acme
│
└── templating
│
└── __init__.py
└── setup.py
2 directories, 2 files
Такой структуры достаточно, чтобы определить пакет пространства имен acme
в Python 3.3 и более поздней версии. Минимальный файл setup.py для пакета
acme.templating будет выглядеть следующим образом:
from setuptools import setup
setup(
name='acme.templating',
packages=['acme.templating'],
)
К сожалению, функция setuptools.find_packages() на момент написания данной книги не поддерживает PEP 420. Но в будущем это может измениться. Кроме
того, необходимость явно определять список пакетов — не такая большая цена за
легкую интеграцию пакетов пространства имен.
Пакеты пространства имен в предыдущих версиях Python
Вы не можете задействовать неявные пакеты пространства имен (по PEP 420)
в версиях Python старше 3.3. Однако концепция пакетов пространства имен очень
стара и давно и широко применяется в таких зрелых проектах, как Zope. Это значит,
что вы можете использовать пакеты пространства имен в старых версиях Python.
Глава 7.
Создаем пакеты 213
Существует несколько способов, позволяющих определить, что пакет должен рассматриваться как пространство имен.
Самый простой — создать файловую структуру для каждого компонента, напоминающую обычный макет пакета без неявных пакетов пространства имен. Вся работа
выполняется в setuptools. Так, например, макет для acme.sql и acme.templating
может выглядеть следующим образом:
$ tree acme.sql/
acme.sql/
├── acme
│
├── __init__.py
│
└── sql
│
└── __init__.py
└── setup.py
2 directories, 3 files
$ tree acme.templating/
acme.templating/
├── acme
│
├── __init__.py
│
└── templating
│
└── __init__.py
└── setup.py
2 directories, 3 files
Обратите внимание: и у acme.sql, и у acme.templating есть дополнительный
файл исходного кода acme/__init__.py. Этот файл должен быть пустым. Пакет
пространства имен acme будет создан, если мы передадим его имя в качестве значения именованного аргумента namespace_packages функции setuptools.setup()
следующим образом:
from setuptools import setup
setup(
name='acme.templating',
packages=['acme.templating'],
namespace_packages=['acme'],
)
Но проще не значит лучше. Для регистрирации нового пространства имен
модуль setuptools будет вызывать функцию pkg_resources.declare_namespace()
в вашем файле __init__.py. Это сработает, даже если данный файл пуст. Во всяком
случае, в официальной документации говорится, что именно вы должны объявить
пространство имен в вашем файле __init__.py и это неявное поведение setuptools
в будущем может быть отброшено. Чтобы избавиться от проблем в дальнейшем,
вам нужно добавить следующую строку в файл __init__.py:
__import__('pkg_resources').declare_namespace(__name__)
214 Часть II
•
Ремесло Python
Эта строка позволит обезопасить ваш пакет пространства имен от возможных
будущих изменений в пакете пространства имен в модуле setuptools.
В следующем разделе рассмотрим, как загрузить пакет.
Загрузка пакета
Пакеты будут бесполезны, если их нельзя хранить, загружать и скачивать. PyPI —
основное хранилище пакетов с открытым исходным кодом в сообществе Python.
Любой пользователь может свободно загружать новые пакеты, и для этого достаточно всего лишь зарегистрироваться на сайте PyPI: pypi.python.org/pypi.
Разумеется, вы можете задействовать и другие хранилища, репозитории и инструменты. Это особенно полезно для распространения пакетов с закрытым исходным кодом внутри организации или для целей развертывания. Подробнее о таком
использовании пакета рассказывается в следующей главе, а также приводятся
инструкции по созданию собственного индекса пакетов. Здесь мы сосредоточимся
на загрузке пакетов с открытым исходным кодом PyPI и скажем пару слов о том,
как указать альтернативные репозитории.
PyPI — каталог пакетов Python
Как уже упоминалось, PyPI — официальный репозиторий пакетов с открытым
исходным кодом. Для скачивания не требуется никакая учетная запись или разрешение. Единственное, что вам нужно, — это менеджер пакетов, с помощью которого можно скачивать новые дистрибутивы из PyPI. Предпочтительный вариант
менеджера — pip.
В следующем разделе посмотрим, как загрузить пакет.
Скачивание пакета из PyPI или другого индекса
Любой желающий может зарегистрироваться и загрузить пакеты PyPI при условии наличия учетной записи на сайте. Пакеты привязываются к пользователю,
поэтому только зарегистрировавший пакет пользователь по умолчанию является
его администратором и может загружать новые дистрибутивы. Это может стать
проблемой в более крупных проектах, поэтому предусмотрена возможность давать
право загружать новые дистрибутивы и другим пользователям.
Самый простой способ загрузить пакет — это использовать команду upload из
скрипта setup.py:
$ python setup.py upload
Здесь — это список команд, который создает дистрибутив для загрузки. В хранилище будут загружены только сборки, созданные в одном
Глава 7.
Создаем пакеты 215
и том же выполнении setup.py. Таким образом, если вы одновременно загружаете
исходный код, сборку и пакет wheel, то вам необходимо выполнить следующую
команду:
$ python setup.py sdist bdist bdist_wheel upload
При загрузке с использованием setup.py вы не можете повторно использовать
дистрибутивы, уже собранные в предыдущих вызовах команды, поэтому вам
нужно заново выполнять сборку при каждой загрузке. Это может быть неудобно
для больших и сложных проектов, в которых на создание сборки может уйти довольно много времени. Еще одна проблема применения setup.py заключается
в том, что такой способ в некоторых версиях Python позволяет использовать
простой текстовый HTTP или непроверенные соединения HTTPS. Поэтому
в качестве безопасной замены для команды setup.py рекомендуют задействовать
Twine.
Twine — это утилита для взаимодействия с PyPI, предназначенная для одной
цели — безопасной загрузки пакетов в репозиторий. Утилита поддерживает любой формат пакетов и всегда гарантирует безопасность соединения. Она также
позволяет загружать уже созданные файлы, поэтому вы можете проверить сборку
перед выпуском. В следующем примере использования twine для создания сборки
требуется вызов скрипта setup.py:
$ python setup.py sdist bdist_wheel
$ twine upload dist/*
Далее поговорим о том, что такое .pypirc.
.pypirc
Файл .pypirc — это файл конфигурации, в котором хранится информация о репозиториях пакетов Python. Он должен быть размещен в вашем домашнем каталоге.
Формат данного файла выглядит следующим образом:
[distutils]
index-servers =
pypi
other
[pypi]
repository:
username:
password:
[other]
repository: https://example.com/pypi
username:
password:
216 Часть II
•
Ремесло Python
В разделе distutils должна быть переменная index-servers, в которой перечислены секции, описывающие все доступные репозитории и учетные данные для
них. В каждой секции для каждого хранилища есть следующие три переменные:
repository — URL репозитория пакетов (по умолчанию pypi.org);
username — имя пользователя для авторизации в данном репозитории;
password — пароль пользователя для авторизации в данном репозитории (в виде
обычного текста).
Обратите внимание: хранение пароля от репозитория в виде простого текста —
рискованное решение с точки зрения безопасности. Вы всегда можете оставить
это поле пустым, и тогда у вас при необходимости будет появляться предложение
ввести пароль.
Файл .pypirc должен поддерживаться каждым инструментом упаковки, созданным в Python. Это не всегда выполняется для всех утилит работы с пакетами, однако данный файл поддерживается наиболее важными из них: pip, twine,
distutils и setuptools.
Сравним пакеты исходного кода и сборки.
Пакеты с исходным кодом и пакеты сборок
В целом можно выделить два типа создания пакетов Python:
исходный код;
дистрибутивы (бинарные файлы).
Пакеты исходного кода проще и не зависят от платформы использования.
Для чистых пакетов Python это не проблема. В таком пакете есть только исходный
код Python, что само по себе означает высокую портируемость.
Все немного усложняется, если к вашему пакету подключаются расширения,
например, в пакетах на C. Исходный код отлично подходит при условии, что
у пользователя пакета есть надлежащий набор инструментов разработки в своей
среде. В основном это компилятор и соответствующие заголовочные файлы C.
Для таких случаев лучше подходит именно формат сборки, так как в нее входят
уже собранные расширения для конкретных платформ.
Рассмотрим команду sdist.
sdist
Команда sdist — самая простая из доступных команд. Она создает дерево релиза,
в которое копируется все, что нужно для запуска пакета. Оно архивируется в один
или несколько файлов (обычно создается один архив). В целом архив — это просто
копия дерева исходного кода.
Глава 7.
Создаем пакеты 217
Эта команда — самый простой способ распространения пакета, независимого
от целевой системы. Она создает каталог dist/ для хранения архивов, подлежащих распространению. Прежде чем создавать первый дистрибутив, следует вызвать функцию setup() с номером версии. Если вы этого не сделаете, то модуль
setuptools будет принимать значение по умолчанию version = '0.0.0':
from setuptools import setup
setup(name='acme.sql', version='0.1.1')
При каждом выпуске пакета номер версии нужно увеличивать, чтобы система
знала, что пакет изменился.
Рассмотрим следующую команду sdist для пакета acme.sql версии 0.1.1:
$ python setup.py sdist
running sdist
...
creating dist
tar -cf dist/acme.sql-0.1.1.tar acme.sql-0.1.1
gzip -f9 dist/acme.sql-0.1.1.tar
removing 'acme.sql-0.1.1' (and everything under it)
$ ls dist/
acme.sql-0.1.1.tar.gz
В Windows тип архива по умолчанию будет ZIP.
Версия используется для того, чтобы задать имя архива, который в дальнейшем можно будет скачивать/распространять и устанавливать на любую систему
с Python. В дистрибутиве sdist, если пакет содержит библиотеки или расширения C, за их компиляцию будет отвечать система, в которой выполняется установка. Это очень характерно для систем на основе macOS и Linux, поскольку в них
обычно есть компилятор. В Windows такая ситуация встречается реже. Именно
поэтому у пакета должен быть грамотно продуманный дистрибутив, если пакет
предназначен для запуска на нескольких платформах.
Далее рассмотрим команды bdist и wheels.
bdist и wheels
Распространить скомпилированный дистрибутив в модуле distutils помогает
команда build. Она компилирует пакет в следующие четыре этапа:
build_py — создает чистые модули Python с помощью байтовой компиляции
и копирует их в папку сборки;
218 Часть II
•
Ремесло Python
build_clib — создает библиотеки C, если таковые входят в пакет, используя
компилятор Python, и создает статическую библиотеку в папке сборки;
build_ext — создает расширения C и помещает результат в папку сборки, например, build_clib;
build_scripts — создает модули, которые помечаются как скрипты. Кроме того,
изменяет путь интерпретатора после установки первой строки (с использованием префикса !#) и устанавливает режим файла так, чтобы он был исполняемым.
Каждый из этих этапов — это команда, которую можно вызвать независимо от
других. Результатом процесса компиляции является папка build, в которой лежит
все необходимое для установки пакета. В пакете distutils пока еще нет возможности кросс-компиляции. Это значит, что результат выполнения команды всегда
зависит от системы, для которой делается сборка.
Если нужно создать расширение C, то в процессе сборки можно будет использовать компилятор по умолчанию и заголовочный файл Python (Python.h). Этот
включаемый файл доступен с момента, когда Python только-только появился.
Для упакованного дистрибутива вам, вероятно, потребуется дополнительный пакет,
зависящий от вашей системы. По крайней мере, в популярных дистрибутивах Linux
его часто называют python-dev. Он содержит все необходимые файлы заголовков
для создания расширений Python.
Компилятор C, используемый в процессе сборки, — это компилятор по умолчанию для вашей операционной системы. Для систем на основе Linux или macOS это
gcc или clang соответственно. Для Windows можно задействовать Microsoft Visual
C++ (есть бесплатная версия в формате командной строки). Кроме того, можно
взять проект с открытым исходным кодом MinGW. Это настраивается в distutils.
Команда build используется командой bdist для сборки бинарного дистрибутива. Он вызывает build и все зависимые команды, а затем создает архив таким же
образом, как и sdist.
Создадим бинарный дистрибутив для acme.sql на macOS следующим образом:
$ python setup.py bdist
running bdist
running bdist_dumb
running build
...
running install_scripts
tar -cf dist/acme.sql-0.1.1.macOSx-10.3-fat.tar .
gzip -f9 acme.sql-0.1.1.macOSx-10.3-fat.tar
removing 'build/bdist.macOSx-10.3-fat/dumb' (and everything under it)
$ ls dist/
acme.sql-0.1.1.macOSx-10.3-fat.tar.gz acme.sql-0.1.1.tar.gz
Обратите внимание: имя вновь созданного архива содержит имя системы и дистрибутива, на котором она была построена (macOS 10.3).
Глава 7.
Создаем пакеты 219
Та же команда, будучи выполненной на Windows, создаст другой архив:
C:\acme.sql> python.exe setup.py bdist
...
C:\acme.sql> dir dist
25/02/2008 08:18
Если, помимо исходного дистрибутива, пакет содержит код C, то важно выпустить
как можно больше бинарных дистрибутивов. По крайней мере, бинарный дистрибутив на Windows важен для тех, у кого нет установленного компилятора С.
Бинарный релиз содержит дерево, которое можно скопировать непосредственно в дерево Python. В немсодержится папка, копируемая в папку site-packages
Python. Кроме того, он может включать кэшированные файлы байт-кода (*.pyc
в Python 2 и __pycache__/*.pyc в Python 3).
Другой вид дистрибутивов — это wheels, которые предоставляются пакетом
wheel. Будучи установленным (например, с помощью pip), пакет wheel добавляет
к distutils новую команду bdist_wheel. Она позволяет создавать специфичные
для платформы дистрибутивы (пока что только для Windows, macOS и Linux),
которые работают лучше, чем обычные дистрибутивы bdist. Их цель — заменить
более старый формат дистрибутива, введенный в setuptools, — eggs, в настоящее
время уже неактуальный, и в этой книге мы о нем говорить не будем. Перечень
преимуществ использования wheels довольно длинный. Вот те из них, которые
упомянуты на странице Python Wheels (pythonwheels.com):
более быстрая установка пакетов чистого Python и нативных расширений C;
позволяет избежать выполнения произвольного кода для установки (файлов
setup.py);
установка расширения C не требует компилятора на Windows, macOS или
Linux;
обеспечивает лучшее кэширование для тестирования и непрерывной интеграции;
создает файлы .pyc как часть установки, чтобы обеспечить их соответствие ис-
пользуемому интерпретатору Python;
более последовательная установка на разных платформах и машинах.
В соответствии с рекомендациями PyPA формат wheels следует использовать по
умолчанию. В течение очень долгого времени бинарные wheels не поддерживались
на Linux, но, к счастью, это уже в прошлом. Бинарные wheels на Linux называются
manylinux wheels. Процесс их создания, к сожалению, не так прост, как на Windows
и macOS. Для таких wheels в PyPA поддерживаются специальные образы Docker,
220 Часть II
•
Ремесло Python
которые служат в качестве готовой среды сборки. Код этих образов и дополнительная
информация есть в официальном репозитории на GitHub: github.com/pypa/manylinux.
В следующем разделе рассмотрим исполняемые файлы.
Исполняемые файлы
Создание отдельных исполняемых файлов — тема, которую часто упускают в материалах, посвященных упаковке кода Python. Главным образом это связано с тем,
что в стандартной библиотеке Python мало подходящих инструментов, позволя
ющих программистам создавать простые исполняемые файлы, которые пользователи
могли бы запустить, не прибегая к установке интерпретатора Python.
Компилируемые языки имеют большое преимущество над Python: они позволяют создать исполняемое приложение для данной архитектуры системы, которое
пользователи могут запустить, не имея каких-либо знаний о базовой технологии.
Для запуска кода Python, распространяемого в виде пакета, требуется наличие интерпретатора Python. Это создает большие неудобства для пользователей, которые
не имеют достаточных технических навыков.
Удобные для разработчиков операционные системы, такие как macOS или
большинство дистрибутивов Linux, поставляются с предустановленным интерпретатором Python. Для пользователей приложения Python вполне можно распространять в качестве исходного пакета, зависящего от конкретной директивы
интерпретатора в главном файле скрипта, который в народе называется shebang.
У большинства приложений Python он имеет следующий вид:
#!/usr/bin/env python
Такая директива, указанная в первой строке скрипта, говорит о том, как его следует интерпретировать в версии Python по умолчанию для данной среды. Директива может быть написана в более подробной форме, которая требует определенной
версии Python, например python3.4, python3, python2 и т. д. Обратите внимание: это
будет работать в самых популярных системах POSIX, но портируемость при этом
полностью теряется. Данное решение основано на существовании определенных
версий Python, а также доступности переменной env в /usr/bin/env. Оба эти предположения не всегда срабатывают на некоторых операционных системах. Кроме
того, shebang вообще не работает на Windows. Вдобавок установка и настройка
Python-окружения на Windows бывает сложной даже для опытных разработчиков,
и не стоит ожидать, что неопытные пользователи смогут сделать это сами.
Стоит понять и принять, что обычный пользователь привык к простоте в работе
на компьютере. Пользователи обычно ожидают, что приложения можно запускать
с рабочего стола, просто дважды щелкнув на их ярлыке. Не каждое рабочее окружение сможет «понять»/поддержать написанных на Python приложений, если оно
распространяется в виде исходного кода.
Глава 7.
Создаем пакеты 221
Поэтому будет лучше, если мы сможем создать бинарный дистрибутив, который
станет работать так же, как и любой другой скомпилированный исполняемый файл.
К счастью, можно создать исполняемый файл, в состав которого входит и интерпретатор Python, и наш проект. Это позволяет пользователям открывать наше
приложение, не заботясь о Python или любой другой зависимости.
Посмотрим, когда бывают полезны исполняемые файлы.
Когда бывают полезны исполняемые файлы
Исполняемые файлы удобны в ситуациях, когда простота для пользователя важнее,
чем возможность копаться в коде. Обратите внимание: распространение приложения
в виде исполняемого файла усложняет чтение или изменение кода приложения,
однако не делает это полностью невозможным. Такое распространение — способ
не защитить код приложения, а упростить взаимодействие с приложением.
Исполняемые файлы должны быть предпочтительным способом распространения приложений для технически не подкованных конечных пользователей, а также
единственным разумным способом распространения любого приложения Python
для Windows.
Исполняемые файлы, как правило, хороши в таких случаях, как:
приложения, зависящие от конкретной версии Python, которой может и не быть
в целевой операционной системе;
приложения, использующие модифицированный скомпилированный код
CPython;
приложения с графическим интерфейсом;
проекты, в которых много бинарных расширений, написанных на разных язы-
ках;
игры.
В следующем подразделе рассмотрим некоторые из популярных инструментов.
Популярные инструменты
В Python нет встроенной поддержки создания исполняемых файлов. К счастью,
сообщество создало несколько проектов, решающих эту проблему с различной
степенью успеха. Наиболее известны следующие четыре:
PyInstaller;
cx_Freeze;
py2exe;
py2app.
222 Часть II
•
Ремесло Python
Они немного отличаются друг от друга в эксплуатации, и у каждого из них есть
свои недостатки. Прежде чем выбрать свой инструмент, вы должны решить, какая
у вас целевая платформа, поскольку каждый инструмент упаковки поддерживает
определенный набор операционных систем.
Такое решение лучше всего принимать в самом начале жизненного цикла проекта. Ни один из этих инструментов не требует глубокого взаимодействия с вашим
кодом, но если вы начнете создавать автономные пакеты своевременно, то сможете
автоматизировать весь процесс и сэкономить время и средства на будущую интеграцию. Оставив решение на потом, вы можете оказаться в ситуации, когда проект
усложнится настолько, что ни один инструмент вашу задачу не решит. Создание
отдельного исполняемого файла в этом случае будет проблематичным и займет
много времени.
В следующем пункте рассмотрим PyInstaller.
PyInstaller
PyInstaller (www.pyinstaller.org) на сегодняшний день является самой продвинутой
программой для превращения пакетов Python в исполняемые файлы. В ней предусмотрена наиболее широкая мультиплатформенная совместимость среди всех
прочих решений, и мы рекомендуем использовать именно ее. PyInstaller поддерживает следующие платформы:
Windows (32 и 64 бита);
Linux (32 и 64 бита);
macOS (32 и 64 бита);
FreeBSD, Solaris и AIX.
Поддерживаемые версии Python — Python 2.7, 3.3–3.5. Программа доступна на
PyPI, поэтому ее можно установить в рабочей среде с помощью pip. При возникновении проблем с установкой вы всегда можете скачать инсталлятор со страницы
проекта.
К сожалению, кросс-платформенная сборка (кросс-компиляция) не поддерживается, поэтому если вы хотите создать исполняемый файл для конкретной
платформы, то выполнять сборку вам необходимо на данной платформе. Сегодня
это не такая большая проблема, поскольку появилось много разных инструментов
виртуализации. При отсутствии на вашем компьютере нужной системы вы всегда
можете использовать Vagrant, который позволяет запустить желаемую операционную систему на виртуальной машине.
Применять PyInstaller для простых приложений очень легко. Предположим,
что наше приложение содержится в скрипте с названием myscript.py. Это будет
стандартный hello world. Мы хотим создать исполняемый файл для пользователей
Глава 7.
Создаем пакеты 223
Windows, и файлы исходного кода находятся в папке D://dev/app. Наше приложение
можно собрать следующей короткой командой:
$ pyinstaller myscript.py
2121 INFO: PyInstaller: 3.1
2121 INFO: Python: 2.7.10
2121 INFO: Platform: Windows-7-6.1.7601-SP1
2121 INFO: wrote D:\dev\app\myscript.spec
2137 INFO: UPX is not available.
2138 INFO: Extending PYTHONPATH with paths ['D:\\dev\\app', 'D:\\dev\\app']
2138 INFO: checking Analysis
2138 INFO: Building Analysis because out00-Analysis.toc is non existent
2138 INFO: Initializing module dependency graph...
2154 INFO: Initializing module graph hooks...
2325 INFO: running Analysis out00-Analysis.toc
(...)
25884 INFO: Updating resource type 24 name 2 language 1033
Стандартный вывод PyInstaller достаточно велик, даже для простых приложений,
так что в предыдущем примере мы его сократили. Если работать на Windows, то
полученная структура каталогов и файлов будет выглядеть следующим образом:
$ tree /0066
│
myscript.py
│
myscript.spec
│
├───build
│ └───myscript
│
myscript.exe
│
myscript.exe.manifest
│
out00-Analysis.toc
│
out00-COLLECT.toc
│
out00-EXE.toc
│
out00-PKG.pkg
│
out00-PKG.toc
│
out00-PYZ.pyz
│
out00-PYZ.toc
│
warnmyscript.txt
│
└───dist
└───myscript
bz2.pyd
Microsoft.VC90.CRT.manifest
msvcm90.dll
msvcp90.dll
msvcr90.dll
myscript.exe
myscript.exe.manifest
python27.dll
select.pyd
unicodedata.pyd
_hashlib.pyd
224 Часть II
•
Ремесло Python
В каталоге dist/myscript находится собранное приложение, которое теперь
можно распространять среди пользователей. Обратите внимание: распространять
нужно весь каталог. В нем находятся все дополнительные файлы, необходимые
для запуска нашего приложения (DLL, скомпилированные библиотеки расширения и т. д.). Более компактный дистрибутив можно получить с помощью переключателя --onefile команды pyinstaller следующим образом:
$ pyinstaller --onefile myscript.py
(...)
$ tree /f
├───build
│ └───myscript
│
myscript.exe.manifest
│
out00-Analysis.toc
│
out00-EXE.toc
│
out00-PKG.pkg
│
out00-PKG.toc
│
out00-PYZ.pyz
│
out00-PYZ.toc
│
warnmyscript.txt
│
└───dist
myscript.exe
При сборке с опцией --onefile распространять нужно только один исполня
емый файл из каталога dist (здесь myscript.exe). Для небольших приложений это,
вероятно, предпочтительный вариант.
Одним из побочных эффектов выполнения команды pyinstaller является
создание файла *.spec . Это автогенерируемый модуль Python, содержащий
спецификацию того, как создавать исполняемые файлы из исходного кода.
Ниже показан пример файла спецификации, созданного автоматически для кода
myscript.py:
# -*- mode: python -*block_cipher = None
a = Analysis(['myscript.py'],
pathex=['D:\\dev\\app'],
binaries=None,
datas=None,
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
Файл .spec содержит все аргументы pyinstaller, указанные ранее. Это очень
полезно, если в вашей сборке было задано много настроек. Далее вы сможете
применять его в качестве аргумента команды pyinstaller вместо вашего скрипта
Python следующим образом:
$ pyinstaller.exe myscript.spec
Обратите внимание: это реальный модуль Python, так что вы можете расширить
его и выполнять более сложные настройки в процессе сборки. Настройка файла
.spec особенно полезна, когда у вас много целевых платформ. Кроме того, некоторые опции pyinstaller недоступны через интерфейс командной строки и могут
применяться только при изменении файла .spec.
PyInstaller — это серьезный инструмент, к тому же простой в использовании
для большинства программ. В любом случае, если вы хотите применять его для
распространения приложений, рекомендуется внимательно ознакомиться с документацией.
В следующем пункте рассмотрим cx_Freeze.
cx_Freeze
Инструмент cx_Freeze (cx-freeze.sourceforge.net) также служит для создания исполняемых файлов. Это более простое решение, чем PyInstaller, однако поддерживает
следующие три основные платформы:
Windows;
Linux;
macOS.
Как и PyInstaller, этот инструмент не позволяет выполнять кросс-платфор
менную сборку, поэтому вам придется создавать исполняемые файлы в той же
226 Часть II
•
Ремесло Python
операционной системе, которая является целевой. Основной недостаток cx_Freeze —
он не позволяет создавать однофайловые реализации. Приложения, созданные с помощью cx_Freeze, должны поставляться с соответствующими DLLфайлами и библиотеками. Предположим, у нас есть такое же приложение, какое
мы показали в пункте PyInstaller. Тогда пример использования тоже будет
простым:
$ cxfreeze myscript.py
copying C:\Python27\lib\site-packages\cx_Freeze\bases\Console.exe ->
D:\dev\app\dist\myscript.exe
copying C:\Windows\system32\python27.dll ->
D:\dev\app\dist\python27.dll
writing zip file D:\dev\app\dist\myscript.exe
(...)
copying C:\Python27\DLLs\bz2.pyd -> D:\dev\app\dist\bz2.pyd
copying C:\Python27\DLLs\unicodedata.pyd -> D:\dev\app\dist\unicodedata.pyd
Resulting structure of files is as follows:
$ tree /f
│ myscript.py
│
└───dist
bz2.pyd
myscript.exe
python27.dll
unicodedata.pyd
Вместо создания собственного формата для сборки (как это делает PyInstaller)
cx_Freeze расширяет пакет distutils . Это значит, что вы можете настроить
сборку вашего исполняемого файла с помощью скрипта setup.py . Это делает
cx_Freeze очень удобным инструментом, если вы уже распространяете пакет,
используя setuptools или distutils, поскольку дополнительная интеграция требует лишь небольших изменений в setup.py. Вот пример такого скрипта setup.py
с применением cx_Freeze.setup() для создания отдельных исполняемых файлов
в Windows:
import sys
from cx_Freeze import setup, Executable
# Зависимости обнаруживаются автоматически, но могут требовать донастройки
build_exe_options = {"packages": ["os"], "excludes": ["tkinter"]}
setup(
name="myscript",
version="0.0.1",
description="My Hello World application!",
С таким файлом новый исполняемый файл можно создать, добавив новую команду build_exe в скрипт setup.py следующим образом:
$ python setup.py build_exe
Применять cx_Freeze немного проще, чем PyInstaller, и интеграция с пакетом
distutils — это очень полезная функция. К сожалению, у неопытных разработчи-
ков могут возникнуть кое-какие трудности по следующим причинам:
установка с помощью pip под Windows может быть проблематична;
официальная документация местами довольно скудна.
В следующем пункте рассмотрим py2exe и py2app.
py2exe и py2app
py2exe (www.py2exe.org/) и py2app (py2app.readthedocs.io/en/latest) — две взаимодополняющие программы, которые интегрируются с упаковкой Python через
distutils или setuptools для создания исполняемых файлов. Мы говорим о них
обеих, поскольку они очень похожи в использовании и имеют общие недостатки.
Основным из них является то, что обе программы ориентированы только на одну
платформу:
py2exe позволяет создавать исполняемые файлы для Windows;
py2app позволяет создавать приложения для macOS.
Поскольку в использовании они очень похожи и для работы требуется только
модификация скрипта setup.py, эти пакеты дополняют друг друга. В документации
проекта py2app приведен следующий пример скрипта setup.py, который позволяет
создавать исполняемые файлы с помощью правильного инструмента (py2exe или
py2app), в зависимости от применяемой платформы:
import sys
from setuptools import setup
mainscript = 'MyApplication.py'
if sys.platform == 'darwin':
extra_options = dict(
setup_requires=['py2app'],
228 Часть II
•
Ремесло Python
app=[mainscript],
# Кросс-платформенные приложения обычно ожидают, что sys.argv
# будет использоваться для открытия файлов
options=dict(py2app=dict(argv_emulation=True)),
)
elif sys.platform == 'win32':
extra_options = dict(
setup_requires=['py2exe'],
app=[mainscript],
)
else:
extra_options = dict(
# Обычно Unix-подобные платформы используют команду setup.py install
# и устанавливают основной скрипт как таковой
scripts=[mainscript],
)
setup(
name="MyApplication",
**extra_options
)
Используя такой скрипт, вы можете создать исполняемый файл Windows с помощью команды python setup.py py2exe, а для macOS — с помощью python setup.py
py2app. Кросс-компиляция, разумеется, невозможна.
Несмотря на очевидные недостатки py2app и py2exe и меньшую гибкость по
сравнению с PyInstaller или cx_Freeze, познакомиться с этими инструментами
полезно. В некоторых случаях PyInstaller или cx_Freeze неправильно создают
исполняемый файл. В таких ситуациях всегда стоит проверить, могут ли другие
решения работать с вашим кодом.
Безопасность кода Python в исполняемых пакетах
Важно знать, что исполняемые файлы никак не защищают код приложения. Декомпилировать встроенный код из таких исполняемых файлов довольно сложно,
но все-таки реально. Еще более важно то, что результаты подобной декомпиляции
(выполненной надлежащими инструментами) могут выглядеть поразительно похожими на исходный код.
Данный факт делает исполняемые файлы Python неподходящим решением
для проектов с закрытым кодом, в которых утечка кода приложения может нанести вред организации. Таким образом, если весь ваш бизнес зависит от кода
этого приложения, то вам нужен другой способ распространения приложения.
Возможно, распространение программного обеспечения в качестве сервиса будет
более удачным выбором.
Глава 7.
Создаем пакеты 229
Усложнение декомпиляции. Как уже говорилось, нет надежного способа защитить приложение от декомпиляции с помощью доступных на данный момент
инструментов. Тем не менее есть несколько способов, позволяющих усложнить
этот процесс. Однако «труднее» не означает «менее вероятно». Для некоторых из
нас самые сложные проблемы заманчивее всего. И в конечном итоге цена решения
данной задачи — код, который вы пытались защитить.
Обычно процесс декомпиляции состоит из следующих этапов.
1. Извлечение двоичного представления байт-кода проекта из исполняемого
файла.
2. Отображение двоичного представления в байт-код конкретной версии Python.
3. Перевод байт-кода в AST.
4. Воссоздание кода непосредственно из AST.
Предоставлять точные решения для сдерживания разработчиков от обратного
проектирования исполняемых файлов было бы бессмысленно по понятным причинам. Ниже представлены несколько идей о том, как усложнить процесс декомпиляции или обесценить результаты.
Удаление любых метаданных кода, доступных во время выполнения (строки
документации), чтобы конечные результаты были немного менее читабельными.
Изменение значений байт-кода, используемых интерпретатором CPython. В ре-
зультате преобразование из двоичного кода в байт-код, а затем в AST потребует
больших усилий.
Использование версии исходного кода CPython, модифицированной таким
сложным образом, что даже при наличии исходного кода приложения вы ничего
не сможете сделать без декомпиляции модифицированного CPython.
Применение скриптов обфускации перед сборкой в исполняемый файл. Такие
скрипты делают исходный код менее ценным после декомпиляции.
Такие решения значительно усложняют процесс разработки. Некоторые из
них требуют очень глубокого понимания времени выполнения Python, и в каждом есть ловушки и недостатки. В целом все это послужит лишь отсрочкой неизбежного. Когда ваш трюк раскусят, все ваши затраты времени и ресурсов будут
напрасны.
Единственный надежный способ не допустить утечки вашего кода за пределы
вашего приложения — это не отправлять приложение пользователю в какой-либо
форме. А это возможно, только когда работа вашей организации в целом герметична.
230 Часть II
•
Ремесло Python
Резюме
В данной главе мы обсудили детали экосистемы упаковки Python. Теперь вы
должны знать, какие инструменты соответствуют вашей задаче упаковки, а также
какие типы дистрибутивов нужны проекту. Вы также должны знать популярные
методы решения общих проблем и способы предоставления полезных метаданных
вашему проекту.
Мы также обсудили тему исполняемых файлов, которые очень полезны при
распространении приложений для ПК.
В следующей главе мы будем опираться на то, что узнали в этой, и покажем,
как эффективно справляться с развертываниями кода надежным и автоматизированным способом.
8
Развертывание кода
Даже совершенный код (при условии, что он вообще существует) бесполезен, если
неработоспособен. Итак, чтобы служить какой-либо цели, код должен быть установлен на целевой машине (компьютере) и выполнен. Процесс создания определенной версии приложения или сервиса, доступного для конечных пользователей,
называется развертыванием.
В случае с приложениями для ПК все кажется простым: вы должны предоставить
скачиваемый пакет с дополнительным установщиком, если необходимо. Задача пользователя — скачать и установить пакет в своей среде. Ваша ответственность — сделать
процесс максимально простым и удобным для пользователя. Правильная упаковка —
непростая задача, но мы уже описали некоторые инструменты в предыдущей главе.
Удивительно, но все становится гораздо сложнее, когда ваш код не является
автономным продуктом. Если ваше приложение — продаваемый пользователям
сервис, то это вы должны запустить его в своей инфраструктуре. Данный сценарий
типичен для веб-приложения или любого другого сервиса. В таком случае код разворачивается для работы на удаленных машинах, физический доступ к которым
есть у разработчиков. Это особенно актуально, если вы уже являетесь пользователем облачных сервисов, таких как Amazon Web Services (AWS) или Heroku.
В этой главе мы сосредоточимся на развертывании кода на удаленных хостах
из-за очень высокой популярности Python в области построения различных
веб-сервисов и продуктов. Несмотря на высокую портируемость данного языка,
у него нет какого-то конкретного качества, которое делает его код легким для развертывания. Важнее всего то, как создается ваше приложение и какие процессы
используются для развертывания в целевых средах. Таким образом, в этой главе:
каковы основные проблемы развертывания кода в удаленных средах;
как создавать на Python легкие в развертывании приложения;
как перезагружать веб-сервисы без простоев;
как использовать экосистему Python с использованием пакетов при разверты-
вании кода;
как правильно управлять кодом, работающим удаленно.
232 Часть II
•
Ремесло Python
Технические требования
Скачать различные инструменты мониторинга и обработки журналов, упомянутые
в этой главе, можно со следующих сайтов:
Munin: munin-monitoring.org;
Logstash, Elasticsearch и Kibana: www.elastic.co;
Fluentd: www.fluentd.org.
Ниже приведены упомянутые в этой главе пакеты Python, которые можно
скачать с PyPI:
fabric;
devpi;
circus;
uwsgi;
gunicorn;
sentry_sdk;
statsd.
Установить эти пакеты можно с помощью следующей команды:
python3 -m pip install
Файлы кода для этой главы можно найти по ссылке github.com/packtpublishing/
expert-python-programming-third-edition/tree/master/chapter8.
Двенадцатифакторное приложение
Главное правило безболезненного развертывания — создание приложения таким,
чтобы процесс был максимально простым и оптимизированным. Речь идет об
устранении препятствий и поощрении устоявшихся приемов. Соблюдение общепринятых методов особенно важно в организациях, где за разработку отвечают одни
люди (команда разработки, или Dev), а за развертывание и сопровождение среды
выполнения — другие (команда эксплуатации, или Ops).
Все задачи, связанные с сопровождением сервера, мониторингом, развертыванием, настройкой и т. д., часто объединяют в одно понятие «эксплуатация». Даже
в организациях, не имеющих отдельных команд для решения таких задач, лишь
некоторые из разработчиков имеют право заниматься задачами развертывания и сопровождения удаленных серверов. Общее название такой должности — DevOps.
Кроме того, часто бывает так, что каждый член команды разработчиков отвечает
за эксплуатацию, поэтому всех участников такой команды можно назвать DevOps.
Глава 8. Развертывание кода 233
Независимо от структуры вашей организация и обязанностей каждого разработчика все должны знать, как устроена эксплуатационная деятельность и как код
разворачивается на удаленном сервере, поскольку в конечном счете среда исполнения и ее конфигурация являются скрытой частью создаваемого вами продукта.
Следующие общепринятые приемы и соглашения имеют значение в основном
по двум причинам.
В каждой компании существует текучка кадров. Используя лучшие подходы,
вы облегчите вхождение в работу новых членов команды. Конечно, нельзя быть
уверенными на сто процентов, что новые сотрудники уже знакомы с общими методами конфигурирования системы и запуска приложений надежным способом,
но по крайней мере вы можете сделать более вероятной их быструю адаптацию.
В организациях, где ответственность за развертывание несут определенные
люди, это позволяет уменьшить разногласия между членами команд эксплуатации и разработки.
Пример практики, которая поощряет создание легко развертываемых приложений, — манифест под названием «Двенадцатифакторное приложение». Это общая
языковая методология создания приложений в виде сервисов. Одна из целей этой
практики — упростить развертывание приложений, а также выдвинуть на первый
план другие темы, такие как удобство сопровождения и масштабируемость.
Из названия ясно, что «Двенадцатифакторное приложение» состоит из 12 правил:
кодовая база — одна кодовая база отслеживается и системой контроля версий,
и при развертывании;
зависимости — явное объявление и изоляция зависимостей;
конфигурация — хранение конфигурации в среде разработки;
резервирование — работа со службами резервирования как с прикрепленными
ресурсами;
сборка, релиз, запуск — строгое разделение этапов сборки и запуска;
процессы — выполнение приложения в качестве одного процесса или более без
состояния;
привязка портов — экспорт через привязку портов;
параллелизм — масштабирование через модель процесса;
простота — максимальная устойчивость, быстрый запуск и корректная остановка;
паритет разработки и производства — старайтесь держать develop-, stage-
и production-ветки максимально похожими;
журналы — журналы обрабатываются как потоки событий;
администрирование — выполнение задач администрирования/управления
в одинарных процессах.
234 Часть II
•
Ремесло Python
Останавливаться на каждом из этих правил мы не будем, а вместо этого приведем ссылку на страницу «Двенадцатифакторное приложение» (12factor.net), где
дано обширное обоснование каждого фактора с примерами инструментов для различных фреймворков и сред.
В этой главе мы будем пытаться соблюдать эти правила, поэтому некоторые из
них обсудим более подробно по мере необходимости. Показанные методы и примеры иногда могут слегка отличаться от этих 12 факторов, так как они не являются
истиной в последней инстанции. Эти правила хороши до тех пор, пока выполняют
свою задачу. Но самое важное — это рабочее приложение (продукт), а не совместимость с некоторой методологией.
В следующем разделе рассмотрим различные подходы к автоматизации развертывания.
Различные подходы к автоматизации
развертывания
С появлением контейнеризации приложений (Docker и подобных технологий),
современных инструментов поставки ПО (например, Puppet, Chef, Ansible и Salt)
и систем управления инфраструктурой (например, Terraform и SaltStack) команды разработки и эксплуатации получили много инструментов для организации
и управления кодом и настройки удаленных систем. Каждое решение имеет свои
плюсы и минусы, поэтому средства автоматизации нужно выбирать разумно и с учетом особенностей выстроенных процессов и методологий разработки.
Быстроработающие команды, которые используют микросервисную архитектуру и часто развертывают код (возможно, даже одновременно в параллельных версиях), наверняка любят контейнерные системы, такие как Kubernetes, или используют выделенные сервисы, предоставляемые их облачным провайдером (например,
AWS). Команды, по старинке пишущие монолитные приложения и запускающие
их на своих собственных серверах, вероятно, захотят использовать автоматизацию
низкого уровня и системы поставки ПО. На самом деле четких правил здесь нет,
и всегда найдутся команды любого размера, в которых используется тот или иной
подход к резервированию ПО, развертыванию кода и оркестровке приложений.
Ограничениями здесь выступают лишь ресурсы и знания.
Именно поэтому очень трудно кратко перечислить универсальный набор
инструментов и решений, который подошел бы под потребности и возможности
каждого разработчика и каждой команды. Поэтому в данной главе мы поговорим
о простом подходе к автоматизации с помощью Fabric. Можно было бы сказать,
что такой подход устарел, и это, наверное, правда. Наиболее современными сейчас
считаются контейнерные системы оркестровки в стиле Kubernetes, позволя-
Глава 8. Развертывание кода 235
ющие использовать контейнеры Docker для создания быстрых, удобных в сопровождении, масштабируемых и воспроизводимых программ. Но эти системы
характеризуются довольно крутой кривой обучения, и в одной главе о них рассказать не получится. А вот Fabric, с другой стороны, очень прост для понимания
и является действительно отличным инструментом для начинающих в вопросах
автоматизации.
В следующем подразделе рассмотрим использование Fabric в области автоматизации развертывания.
Использование Fabric для автоматизации
развертывания
В очень маленьких проектах можно разворачивать код вручную, то есть вручную
вводить последовательность команд через удаленные оболочки, каждый раз внедряя новую версию кода и выполняя его в такой оболочке. Однако даже для проекта средних размеров подобный подход чреват ошибками, утомителен и будет
пустой тратой большей части вашего самого дорогого ресурса — времени.
Для этого и нужна автоматизация. Простое правило: если вам необходимо выполнить некую задачу вручную по меньшей мере дважды, то ее нужно автоматизировать, чтобы не выполнять в третий раз.
Существуют различные инструменты, которые позволяют автоматизировать
те или иные задачи.
Средства удаленного выполнения, такие как Fabric, используются для авто-
матизированного выполнения кода на нескольких удаленных хостах по требованию.
Средства управления конфигурацией, такие как Chef, Puppet, Cfengine, Salt
и Ansible, предназначены для автоматизированной конфигурации удаленных
хостов (сред выполнения). Их можно задействовать для создания служб резервирования (базы данных, кэши и т. д.), системных разрешений, пользователей и т. д. Большинство из них применимы и в качестве инструмента для
удаленного выполнения (как, например, Fabric), но, в зависимости от их архитектуры, это может быть более или менее удобно.
Решения для управления конфигурациями — сложная тема, которая заслуживает отдельной книги. Правда в том, что у простейших фреймворков удаленного
выполнения низкий порог вхождения и для небольших проектов они более предпочтительны. На самом деле каждый инструмент управления конфигурацией,
позволяющий декларативно определить конфигурацию ваших машин, где-то
глубоко внутри содержит слой удаленного выполнения.
236 Часть II
•
Ремесло Python
Кроме того, отдельные инструменты управления конфигурацией не очень
хорошо подходят для реального автоматизированного развертывания кода. Один
из таких примеров — инструмент Puppet, в котором неудобно выполнять явный
запуск каких-либо команд оболочки. Именно поэтому многие предпочитают использовать оба решения, дополняющие друг друга: управление конфигурацией для
настройки среды на системном уровне и удаленное выполнение по требованию.
Fabric (www.fabfile.org) до сих пор наиболее популярное решение, которое разработчики на Python задействуют в целях автоматизации удаленного выполнения.
Это библиотека и инструмент командной строки Python для оптимизации использования SSH в рамках задач управления развертыванием или приложениями.
Остановимся на нем, поскольку для старта это самое оно. Имейте в виду, что в зависимости от ваших потребностей оно может быть не лучшим решением проблем.
Во всяком случае, это отличный пример инструмента, позволяющего автоматизировать некоторые ваши действия.
Конечно, можно автоматизировать всю работу, применяя только скрипты Bash,
но это очень утомительно и порождает ошибки. В Python есть более удобные способы обработки строк, поощряющие модульность кода. Fabric — лишь инструмент
для объединения выполняемых команд через SSH. То есть вам все равно нужно
будет знать, как использовать интерфейс командной строки и его утилиты в удаленной среде.
Итак, если вы хотите строго следовать методологии «Двенадцатифакторного
приложения», то не должны поддерживать код в исходном дереве развернутого
приложения.
Сложные проекты очень часто строятся из различных компонентов, поддерживаемых из отдельных кодовых баз, так что это еще одна причина, почему хорошо бы
иметь один отдельный репозиторий для всех конфигураций компонентов проекта
и скриптов Fabric. Это делает развертывание различных сервисов более последовательным и поощряет повторное использование кода.
Чтобы начать работать с Fabric, вам необходимо установить пакет fabric (используя pip) и создать скрипт под названием fabfile.py. Этот скрипт, как правило,
находится в корневом каталоге вашего проекта. Обратите внимание: fabfile.py
можно считать частью вашей конфигурации проекта.
Но прежде, чем мы создадим наш fabfile, определим некие начальные утилиты,
призванные помочь настроить проект удаленно. Вот модуль, который мы будем
называть fabutils:
import os
# Предположим, у нас есть private-репозиторий,
# созданный с devpi
PYPI_URL = 'http://devpi.webxample.example.com'
Это обязательное расположение для хранения релизов.
Каждый релиз расположен в отдельном каталоге виртуального окружения,
названном как версия проекта. Есть также символический
указатель «текущая», указывающий на самую новую версию.
Данный указатель — реальный путь, используемый для настройки:
.
├── 0.0.1
├── 0.0.2
├── 0.0.3
├── 0.1.0
└── current -> 0.1.0/
REMOTE_PROJECT_LOCATION = "/var/projects/webxample"
def prepare_release(c):
"""Подготавливаем новый выпуск, создав исходный код и загрузив его
в приватный репозиторий пакетов
"""
c.local(f'python setup.py build sdist')
c.local(f'twine upload --repository-url {PYPI_URL}')
def get_version(c):
"""Получаем текущую версию проекта из setuptools"""
return c.local('python setup.py --version').stdout.strip()
def switch_versions(c, version):
"""Переключаемся между версиями, атомарно заменяя символические ссылки"""
new_version_path = os.path.join(REMOTE_PROJECT_LOCATION, version)
temporary = os.path.join(REMOTE_PROJECT_LOCATION, 'next')
desired = os.path.join(REMOTE_PROJECT_LOCATION, 'current')
# Принудительная символическая ссылка (-f), поскольку, возможно, она уже есть
c.run(f"ln -fsT {new_version_path} {temporary}")
# mv -T обеспечивает атомарность этой операции
c.run(f"mv -Tf {temporary} {desired}" )
Пример окончательного fabfile, который определяет простую процедуру развертывания, будет выглядеть следующим образом:
from fabric import task
from .fabutils import *
@task
def uptime(c):
"""
Запускаем команду uptime на удаленном хосте — для тестирования подключения
"""
c.run("uptime")
238 Часть II
•
Ремесло Python
@task
def deploy(c):
"""Развертывание приложения с учетом упаковки"""
version = get_version(c)
pip_path = os.path.join(
REMOTE_PROJECT_LOCATION, version, 'bin', 'pip'
)
if not c.run(f"test -d {REMOTE_PROJECT_LOCATION}", warn=True):
# Проект может не существовать при первом развертывании на новом хосте
c.run(f"mkdir -p {REMOTE_PROJECT_LOCATION}")
with c.cd(REMOTE_PROJECT_LOCATION):
# Создать новое виртуальное окружение, используя venv
c.run(f'python3 -m venv {version}')
c.run(f"{pip_path} install webxample=={version} --index-url{PYPI_URL}")
switch_versions(c, version)
# Предположим, что Circus — наш инструмент наблюдения
c.run('circusctl restart webxample')
Каждая функция, декорированная с помощью @task, теперь рассматривается
как доступная подкоманда утилиты fab с пакетом fabric. Вы можете перечислить
все имеющиеся подкоманды с помощью переключателя -l или --list. Код показан
в следующем фрагменте:
$ fab --list
Available commands:
deploy Deploy application with packaging in mind
uptime Run uptime command on remote host — for testing connection.
Теперь вы можете развернуть приложение в среде данного типа одной командой
оболочки:
$ fab -H myhost.example.com deploy
Обратите внимание: предыдущий fabfile служит только для иллюстративных
целей. В своем коде вы можете захотеть внедрить обработку ошибок и попробовать
перезагрузить приложение, не прибегая к необходимости перезагрузки веб-сервиса.
Кроме того, некоторые из представленных здесь методов могут быть неочевидны
прямо сейчас, но будут объяснены позже в этой главе. К ним относятся следующие:
развертывание приложения с кодом из private-репозитория;
использование Circus для наблюдения на удаленном хосте.
В следующем разделе мы рассмотрим зеркальное отображение каталогов
в Python.
Глава 8. Развертывание кода 239
Ваш собственный каталог пакетов
или зеркало каталогов
Есть три основные причины, по которым вам может понадобиться запустить свой
собственный каталог пакетов Python.
Официальный PyPI может оказаться недоступен. Он находится в ведении
Python Software Foundation и работает благодаря многочисленным пожертвованиям. Это значит, что сайт может оказаться недоступен в самое неподходящее
время. Вы вряд ли захотите останавливать развертывание или упаковку в середине процесса из-за сбоя на PyPI.
Полезно иметь многократно применяемые написанные на Python компоненты
в упакованном виде даже для закрытого кода, который никогда не будет опубликован. Это упрощает кодовую базу, поскольку пакеты, используемые для
различных проектов во всей компании, не нужно распространять. Вы можете
просто установить их из репозитория. Это упрощает сопровождение кода и позволяет сократить затраты на разработку для всей компании, особенно если
в ней много команд, работающих над различными проектами.
Хороший прием — упаковка проекта с помощью setuptools. Тогда развернуть новую версию приложения будет проще некуда: pip install --update my-application.
Поставка кода
Поставка кода — практика включения кода из внешнего пакета в исходный код (репозиторий) других проектов. Это обычно делается в случаях,
когда код проекта зависит от конкретной версии какого-либо внешнего
пакета, который также может потребоваться в других пакетах (и в совершенно иной версии).
Например, в популярном пакете requests используется некая версия urllib3
в исходном дереве, поскольку он очень тесно связан с ней и вряд ли будет
работать с любой другой версией urllib3. Пример модуля, который особенно
часто используется другими, — модуль six. Его можно найти в коде многочисленных популярных проектов, таких как «Джанго» (django.utils.six),
Boto (boto.vendored.six) или Matplotlib (matplotlib.externals.six).
Хотя поставка практикуется даже в некоторых крупных и успешных
проектах с открытым исходным кодом, ее следует по возможности
избегать. Такое использование оправданно лишь при определенных
обстоятельствах и не должно рассматриваться в качестве заменителя
управления пакетом зависимостей.
В следующем подразделе мы обсудим зеркала PyPI.
240 Часть II
•
Ремесло Python
Зеркала PyPI
Проблему неполадок на PyPI можно сгладить, если скачивать пакеты через одно из
зеркал. На самом деле официальный каталог пакетов Python уже поставляется через сеть доставки контента (Content Delivery Network, CDN), которая и является,
по сути, зеркалом. Это не отменяет того факта, что время от времени все работает
не очень хорошо. Использование неофициальных зеркал — тоже не решение, поскольку в данном случае могут возникнуть некоторые проблемы безопасности.
Лучше всего иметь собственное зеркало PyPI, на котором будут все нужные
вам пакеты. Использовать такое зеркало будете только вы, благодаря чему гораздо легче обеспечить доступность. Еще одно преимущество — если ваш сервер
обвалится, то вы сможете заняться его починкой, не полагаясь на кого-то другого.
Инструмент создания зеркал, сопровождаемый и рекомендованный PyPA, — это
Bandersnatch (pypi.python.org/pypi/bandersnatch). Он отражает все содержимое пакетов
Python и может указываться в опции index-url для секции репозитория в файле .pypirc (как описано в предыдущей главе). Данное зеркало не поддерживает
загрузку со стороны клиента и не имеет веб-части PyPI. Но будьте осторожны!
Для полноценного зеркала нужны сотни гигабайт дискового пространства, и его
размер будет продолжать расти в течение долгого времени.
Но зачем останавливаться на простом зеркале при наличии гораздо лучшей
альтернативы? Весьма маловероятно, что вам потребуется зеркало всего каталога.
Даже если в вашем проекте сотни зависимостей, это будет лишь незначительная
часть всех имеющихся пакетов. Кроме того, огромный недостаток такого простого
зеркала — невозможность загрузить туда свой собственный пакет. Может показаться, что добавленная стоимость использования Bandersnatch очень мала для такой
высокой цены. И в большинстве случаев это верно. Если зеркало требуется всего
лишь для одного или нескольких проектов, то гораздо лучше будет задействовать
Devpi (doc.devpi.net). Это PyPI-совместимая реализация каталога пакетов, которая
обеспечивает:
частный каталог для загрузки непубличных пакетов;
создание каталогов зеркал.
Главное преимущество Devpi по сравнению Bandersnatch — подход к созданию зеркала. Он позволяет сделать полноценное зеркало других каталогов, как
и Bandersnatch, но главное не это. Вместо того чтобы делать огромную резервную
копию всего репозитория, можно делать зеркала только для пакетов, которые уже
запрашивались клиентами. Таким образом, всякий раз, когда инструмент для установки запрашивает пакет (pip, setuptools и easy_install), при отсутствии того на
локальном зеркале сервер Devpi попытается скачать его с зеркала (обычно PyPI).
После того как пакет скачан, Devpi будет периодически проверять его обновления,
тем самым поддерживая актуальность зеркала.
Глава 8. Развертывание кода 241
В таком подходе есть небольшой риск сбоя в случае, если вы запрашиваете новый пакет, которого в зеркале еще нет, а вышестоящий репозиторий внезапно «падает». Но подобная ситуация встречается нечасто, и чаще всего вы будете использовать уже «отзеркаленные» пакеты. В остальном применение зеркал в конечном
итоге гарантирует целостность, и новые версии будут скачиваться автоматически.
Это кажется очень разумным компромиссом.
Теперь посмотрим, как правильно объединять и создаватьдополнительные
«непитоновские» ресурсы в приложении Python.
Объединение дополнительных ресурсов с пакетом Python
У современных веб-приложений много зависимостей, и их часто бывает сложно
правильно установить на удаленном хосте. Например, типичный процесс самонастройки для новой версии приложения на удаленном хосте состоит из следующих
этапов.
1. Создание нового виртуального окружения для изоляции.
2. Перемещение кода проекта в среду выполнения.
3. Установка последней версии требований проекта (как правило, из файла
requirements.txt).
4. Синхронизация или перенос схемы базы данных.
5. Сборка статических файлов из кода проекта и внешних пакетов в нужном месте.
6. Компиляция файлов локализации для приложений, доступных на разных языках.
Более сложные сайты могут иметь еще больше задач, и трудности связаны
в основном с фронтенд-кодом, который не зависит от ранее определенных задач,
как в следующем примере.
1. Генерация файлов CSS с помощью препроцессора, например SASS или LESS.
2. Выполнение минификации, обфускации и/или конкатенации статических
файлов (JavaScript и CSS-файлы).
3. Компиляция кода на языках семейства JavaScript (CoffeeScript, TypeScript
и т. д.) в нативный JS.
4. Препроцессинг шаблонов файлов ответов (минификация, встраивание стиля и т. д.).
Сегодня для приложений, требующих много дополнительных средств, большинство разработчиков, вероятно, используют образы Docker. В Docker-файлах
можно легко определить все шаги, необходимые для объединения всех ассетов
с образом вашего приложения. Но если вы не используете Docker, то можно автоматизировать все это с помощью других инструментов, таких как Make, Bash,
242 Часть II
•
Ремесло Python
Fabric или Ansible. Однако нежелательно выполнять все шаги непосредственно на
удаленных хостах, на которых устанавливается приложение, и вот почему.
Некоторые из популярных инструментов для обработки статических ассетов заби-
рают на себя много ресурсов процессора или памяти. Их запуск в production-среде
может привести к дестабилизации выполнения вашего приложения.
Для этих инструментов очень часто требуются дополнительные зависимости,
которые для нормальной работы ваших проектов не нужны. Обычно это дополнительные среды выполнения, такие как JVM, Node или Ruby. Данное обстоятельство повышает сложность управления конфигурацией и увеличивает
общие затраты на техническое обслуживание.
При развертывании приложения на нескольких серверах (десятках, сотнях
или тысячах) многократно выполняется одна и та же работа, которую можно
было бы сделать один раз. Если у вас есть собственная инфраструктура, то это
не выльется в особые затраты, особенно при развертывании в периоды низкого
трафика. Но в случае использования облачных сервисов, которые берут с вас
дополнительную плату за скачки нагрузки или процессорное время, дополнительные расходы могут оказаться существенными.
Большинство из указанных шагов занимает много времени. Вы устанавливаете
код на удаленный сервер, поэтому точно не хотите, чтобы соединение сопровождалось какими-либо проблемами. Чем быстрее идет развертывание, тем
меньше шанс прервать процесс.
Очевидно, что результаты этих шагов нельзя включить в репозиторий кода
приложения. Есть вещи, которые все равно следует делать при выпуске каждой
версии, и тут ничего не поделать. Именно здесь, очевидно, требуется правильная
автоматизация, которая будет работать в нужном месте в нужное время.
Большинство действий, таких как статический сбор и предварительная обработка ассетов/кода, можно выполнять локально или в специальной среде, и фактический код, который будет развернут на удаленном сервере, потребуется лишь
немножко обработать на месте. Ниже приведены наиболее существенные из таких
этапов развертывания в процессе сборки дистрибутива или установки пакета.
1. Установка зависимостей Python и передача статических ассетов (файлов CSS
и JavaScript) в нужное место может выполняться как часть команды install
скрипта setup.py.
2. Предварительная обработка кода (обработка надмножеств JavaScript, минификация/затемнение/конкатенация ассетов и запуск SASS или LESS) и такие
действия, как локализованная компиляция текста (например, compilemessages
в Django), может быть частью команды sdist/bdist скрипта setup.py.
Включение предобработанного кода не из Python легко реализуется с помощью
правильного файла MANIFEST.in. Зависимости, конечно, лучше всего указывать
в аргументе install_requires функции setup() из пакета setuptools.
Глава 8. Развертывание кода 243
Для упаковки всего приложения, конечно, потребуется поработать, например
указать свои собственные команды setuptools или переписывать существующие,
но это дает вам много преимуществ и позволяет значительно ускорить развертывание проекта и сделать его более надежным.
В качестве примера воспользуемся проектом на основе Django (версии Django 1.9).
Мы выбрали эту структуру, поскольку она кажется нам наиболее популярным
проектом Python такого типа, и с ней вы можете быть уже знакомы. Типичная
структура файлов в подобном проекте может выглядеть следующим образом:
$ tree . -I __pycache__ --dirsfirst
.
├── webxample
│
├── conf
│
│
├── __init__.py
│
│
├── settings.py
│
│
├── urls.py
│
│
└── wsgi.py
│
├── locale
│
│
├── de
│
│
│
└── LC_MESSAGES
│
│
│
└── django.po
│
│
├── en
│
│
│
└── LC_MESSAGES
│
│
│
└── django.po
│
│
└── pl
│
│
└── LC_MESSAGES
│
│
└── django.po
│
├── myapp
│
│
├── migrations
│
│
│
└── __init__.py
│
│
├── static
│
│
│
├── js
│
│
│
│
└── myapp.js
│
│
│
└── sass
│
│
│
└── myapp.scss
│
│
├── templates
│
│
│
├── index.html
│
│
│
└── some_view.html
│
│
├── __init__.py
│
│
├── admin.py
│
│
├── apps.py
│
│
├── models.py
│
│
├── tests.py
│
│
└── views.py
│
├── __init__.py
│
└── manage.py
├── MANIFEST.in
├── README.md
└── setup.py
15 directories, 23 files
244 Часть II
•
Ремесло Python
Обратите внимание: данная структура немного отличается от обычной структуры Django-проекта. По умолчанию пакет, который содержит приложение WSGI,
модуль настройки и конфигурацию URL, имеет то же имя, что и проект. Поскольку
мы решили использовать подход с использованием пакетов, это все называлось бы
webxample. Но тут возможна путаница, так что лучше переименовать его в conf. Не
углубляясь в детали реализации, просто сделаем несколько простых предположений:
в нашем примере у приложения есть несколько внешних зависимостей. Здесь
будет два популярных пакета Django — djangorestframework и django-allauth,
плюс один пакет не из Django — gunicorn;
djangorestframework и django-allauth предоставляются в INSTALLED_APPS в модуле webexample.webexample.settings;
приложение локализовано на трех языках (немецком, английском и польском),
но мы не хотим хранить скомпилированные сообщения gettext в репозитории;
мы устали от оригинального синтаксиса CSS, поэтому решили использовать
более эффективный язык SCSS, который превратим в CSS с помощью SASS.
Зная структуру проекта, мы можем написать скрипт setup.py таким образом,
чтобы setuptools выполнял следующие действия:
компиляцию файлов SCSS в webxample/myapp/статический/scss;
компиляцию сообщений gettext под webexample/locale из формата .po в .mo;
установку требований;
выполнение нового скрипта, который обеспечивает точку входа в пакет, так что
возьмем пользовательскую команду вместо скрипта manage.py.
Здесь нам немного повезло: в Python для привязки libsass (портированного на
С/C++ движка SASS) есть некая интеграция с setuptools и distutils. Немного
поменяв настройки, мы можем добавить пользовательскую команду setup.py для
запуска компиляции SASS. Это показано в следующем коде:
from setuptools import setup
setup(
name='webxample',
setup_requires=['libsass == 0.6.0'],
sass_manifests={
'webxample.myapp': ('static/sass', 'static/css')
},
)
Таким образом, вместо выполнения команды sass вручную или подпроцесса
в скрипте setup.py мы можем ввести команду python setup.py build_scss и превратить файлы SCSS в CSS. Но и этого мало. Описанные действия уже облегчили нам
Глава 8. Развертывание кода 245
жизнь, но мы хотим, чтобы все распространение было полностью автоматизировано
и новые версии выпускались в одно касание. Для достижения данной цели нужно
будет переписать некоторые из существующих команд setuptools.
Пример файла setup.py, выполняющего некоторые из этапов подготовки проекта через упаковку, может выглядеть следующим образом:
import os
from
from
from
from
try:
from django.core.management.commands.compilemessages \
import Command as CompileCommand
except ImportError:
# Примечание: во время установки django может быть недоступен
CompileCommand = None
# Требуется эта среда
os.environ.setdefault(
"DJANGO_SETTINGS_MODULE", "webxample.conf.settings"
)
class build_messages(Command):
"""Пользовательская команда для создания сообщений в Django
"""
description = """сборка сообщений gettext"""
user_options = []
def initialize_options(self):
pass
def finalize_options(self):
pass
def run(self):
if CompileCommand:
CompileCommand().handle(
verbosity=2, locales=[], exclude=[]
)
else:
raise RuntimeError("could not build translations")
class build(_build):
"""Переопределенная команда build с парой новых действий
"""
sub_commands = [
При такой реализации мы можем собрать все ассеты и создать дистрибутив исходного пакета для проекта webxample, используя одну команду консоли:
$ python setup.py build sdist
При наличии собственного каталога пакетов (созданный с помощью Devpi) вы
можете добавить подкоманду install или использовать twine, и этот пакет будет
доступен для установки через pip. Если мы посмотрим на структуру исходного
дистрибутива, созданного в скрипте setup.py, то увидим, что он содержит скомпилированные сообщения gettext и таблицы стилей CSS, сгенерированные из
файлов SCSS:
$ tar -xvzf dist/webxample-0.0.0.tar.gz 2> /dev/null
$ tree webxample-0.0.0/ -I __pycache__ --dirsfirst
webxample-0.0.0/
├── webxample
│
├── conf
│
│
├── __init__.py
Для этого пришлось внести небольшое изменение в скрипт manage.py для совместимости с entry_points в setup(), так что основная часть своего кода обернута
вызовом функции main(). Это показано в следующем коде:
#!/usr/bin/env python3
import os
import sys
def main():
os.environ.setdefault(
"DJANGO_SETTINGS_MODULE", "webxample.conf.settings"
)
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
К сожалению, во многих фреймворках (включая Django) не заложена идея
именно такой упаковки ваших проектов. То есть в зависимости от дальнейшего
развития вашего приложения для его превращения в пакет может потребоваться
много изменений. В Django это часто ведет к переписыванию неявных импортов
и обновлению переменных конфигурации в файле настройки.
Еще одна проблема — последовательность релизов, созданных с помощью упаковки Python. Если разные члены команды имеют право создавать дистрибутивы
приложений, то важно, чтобы этот процесс происходил в одной и той же воспроизводимой среде. При многочисленном выполнении предварительной обработки
ассетов вероятна ситуация, когда пакет, созданный в двух различных средах, будет
выглядеть по-разному, даже если создается из одного и того же кода. Это может
быть связано с различными версиями инструментов, используемых в процессе
сборки. Лучше сделать так, чтобы дистрибутивы создавались в некоторой непрерывной системе интеграции/доставки, такой как Jenkins, BuildBot, Travis CI и т. д.
Еще одно преимущество состоит в том, что в этом случае вы будете уверены: пакет
пройдет все необходимые испытания.
Имейте в виду: несмотря на то что распространение кода в виде пакетов Python
с использованием setuptools может показаться элегантным, все не так просто.
Потенциал есть возможность значительно упростить развертывание, и поэтому,
Глава 8. Развертывание кода 249
безусловно, такой метод стоит попробовать, но ценой ему будет повышение сложности. Если предварительная обработка вашего приложения становится слишком
сложной, то вы обязательно должны подумать о создании образа Docker и развертывании приложения в контейнере.
Развертывание с помощью Docker требует дополнительной настройки и оркестровки, но в долгосрочной перспективе сэкономит вам много времени и ресурсов,
которые в противном случае потребовались бы на содержание сред сборки и сложную предварительную обработку.
В следующем разделе мы рассмотрим общие соглашения и практики в отношении развертывания приложений Python.
Общие соглашения и практики
Существует ряд общих соглашений и практик развертывания, которые может знать
не каждый разработчик, но они будут очевидны для тех, кто занимается эксплуатационной деятельностью. Как объяснялось во введении к данной главе, вам важно
знать по крайней мере некоторые из них, даже если вы не занимаетесь разверткой
кода и эксплуатацией, поскольку это позволит вам принимать более конструктивные решения в процессе разработки.
В следующем подразделе рассмотрим иерархию файловой системы.
Иерархия файловой системы
Наиболее очевидные соглашения, которые вы можете вспомнить, вероятно, касаются иерархии файловой системы и именования пользователей. Но здесь мы об этом
говорить не будем. Существует, конечно, стандарт иерархий Filesystem Hierarchy
Standard (FHS), определяющий структуру каталогов и их содержимого в Unix
и Unix-подобных операционных системах, однако на самом деле будет трудно
найти реальную ОС, которая полностью совместима с FHS. Если системные дизайнеры и программисты не соблюдают эти стандарты, то трудно ожидать того же
от администраторов. Мы на своем опыте видели развертку кода приложения почти
везде, где это возможно, в том числе в нестандартных пользовательских каталогах
на уровне корневой файловой системы. Почти всегда люди, принимавшие такие
решения, приводили очень сильные аргументы:
выбирайте с умом и избегайте сюрпризов;
будьте последовательны по всей инфраструктуре вашего проекта;
постарайтесь быть последовательными в организации, в которой вы работаете.
А вот что реально полезно, так это документирование соглашений для вашего
проекта. Только не забудьте убедиться, что данная документация доступна каждому
250 Часть II
•
Ремесло Python
заинтересованному члену команды и все знают о существовании такого документа
в принципе.
В следующем подразделе мы поговорим об изоляции.
Изоляция
О необходимости изоляции и о рекомендуемых инструментах мы уже поговорили
в главе 2. Причины для введения изоляции таковы: лучшая воспроизводимость
среды и решение неизбежных проблем конфликтов зависимостей. Что касается развертывания, осталось добавить одну важную вещь. Вы всегда должны изолировать
зависимости проекта для каждой версии приложения. На практике это означает,
что всякий раз, когда вы разворачиваете новую версию приложения, необходимо
создать для нее новую изолированную среду (с помощью virtualenv или venv).
Старые среды тоже следует на некоторое время оставить, чтобы в случае возникновения проблем вы могли легко выполнить откат к одной из старых версий
приложения.
Создание свежей среды для каждой версии помогает управлять чистотой состояния и соответствием перечню предоставляемых зависимостей. Под свежей
средой мы имеем в виду создание нового дерева каталогов в файловой системе
вместо обновления уже существующих файлов. К сожалению, это может усложнить
процедуру корректного перезапуска сервиса, поскольку обновление файлов «на
месте» — гораздо более элегантный метод.
В следующем подразделе мы рассмотрим, как применять инструменты мониторинга процессов.
Использование инструментов мониторинга процессов
Приложения на удаленных серверах обычно работают в непрерывном режиме.
Если это веб-приложение, то его HTTP-сервер будет ожидать новых соединений
и запросов и завершать работу только в случае возникновения какой-то неисправимой ошибки.
Конечно, не представляется возможным запустить сервис вручную в оболочке
и поддерживать бесконечное соединение по SSH. Использовать nohup, screen или
tmux для полудемонизации процесса — тоже не вариант. Такой подход ведет к провалу.
Вам необходим некий надзорный инструмент, который позволит запускать процесс приложения и управлять им. Прежде чем выбрать правильный инструмент,
вы должны убедиться, что он делает следующее:
перезапускает сервис, если тот завершает работу;
правильно отслеживает его состояние;
Глава 8. Развертывание кода 251
захватывает потоки stdout/stderr для записи в журнал;
запускает процесс с разрешениями, зависящими от конкретного пользователя/
группы;
настраивает системные переменные среды.
Большинство дистрибутивов Unix и Linux имеют встроенные инструменты/
подсистемы для наблюдения за процессами, например скрипты initd, upstart
и runit. К сожалению, в большинстве случаев они не очень хорошо подходят для
запуска программного кода на уровне пользователя и сложны в сопровождении.
В частности, написать надежный скрипт init.d довольно трудно, поскольку для
этого требуется много скриптов Bash, с которыми тяжело работать. В некоторых
дистрибутивах Linux, таких как Gentoo, подход к скриптам init.d был изменен,
вследствие чего писать их намного проще. Во всяком случае, зацикливаться на одной ОС исключительно из-за удобного инструмента мониторинга — плохая идея.
Существует два популярных в сообществе Python инструмента для управления
процессами приложений — Supervisor (supervisord.org) и Circus (circus.readthedocs.org/
en/latest/). Оба инструмента очень похожи в настройке и использовании. Circus немного моложе, чем Supervisor, поскольку был создан с целью устранить некоторые
его недостатки. Оба инструмента конфигурируются через простой INI-подобный
формат. Они не ограничены запуском процесса Python и могут быть настроены
на управление любым приложением. Трудно сказать, какой из них лучше, поскольку их функциональность невероятно схожа. Однако Supervisor не работает
на Python 3, поэтому не подходит нам автоматически. Python 3 все же можно запустить под Supervisor, но мы тем не менее сосредоточимся на Circus.
Предположим, что мы хотим запустить приложение webxample (представленное ранее в этой главе), используя веб-сервер gunicorn под управлением Circus.
В продакшене мы бы, вероятно, запустили Circus под подходящим инструментом
управления процессом на системном уровне (initd, upstart, и runit), особенно
если он был установлен из репозитория системных пакетов. Для простоты мы
будем запускать его локально внутри виртуального окружения. Минимальный
файл конфигурации (названный circus.ini), который позволяет нам запускать
наше приложение в Circus, выглядит следующим образом:
[watcher:webxample]
cmd = /path/to/venv/dir/bin/gunicorn webxample.conf.wsgi:application
numprocesses = 1
Теперь процесс circus может работать с этим файлом конфигурации в качестве
аргумента выполнения:
$ circusd circus.ini
2016-02-15 08:34:34 circus[1776] [INFO] Starting master on pid 1776
2016-02-15 08:34:34 circus[1776] [INFO] Arbiter now waiting for commands
Теперь вы можете использовать команду circusctl, чтобы запустить интер
активную сессию и управлять всеми процессами с помощью простых команд.
Вот пример такой сессии:
$ circusctl
circusctl 0.13.0
webxample: active
(circusctl) stop webxample
ok
(circusctl) status
webxample: stopped
(circusctl) start webxample
ok
(circusctl) status
webxample: active
Конечно, у обоих упомянутых инструментов намного больше возможностей.
Все они перечислены в их документации, поэтому, прежде чем сделать свой выбор,
вы должны внимательно изучить ее.
В следующем подразделе рассматривается важность запуска кода приложения
в пространстве пользователя.
Запуск кода приложения
в пространстве пользователя
Код приложения всегда должен работать в пространстве пользователя. Это
значит, что он не должен выполняться с правами суперпользователя. Если вы
разрабатываете приложение с соблюдением правил «Двенадцатифакторного
приложения», то можно запустить приложение под пользователем, не имеющим
почти никаких привилегий. Общепринятое имя для пользователя, который
не владеет файлами и не находится ни в одной из привилегированных групп, —
nobody. Однако мы рекомендуем создавать отдельного пользователя для каждого
демона. Это обусловлено системой безопасности и делается с целью ограничить
ущерб, который может причинить злоумышленник, если получает контроль над
процессом. В Linux процессы одного и того же пользователя могут взаимодействовать, поэтому важно, чтобы различные приложения разделялись на уровне
пользователя.
В следующем разделе показано, как задействовать обратный HTTP-прокси.
Глава 8. Развертывание кода 253
Использование обратного HTTP-прокси
Несколько WSGI-совместимых серверов могут легко обслуживать HTTP-трафик
самостоятельно, не прибегая к необходимости иметь какой-либо другой веб-сервер
на верхнем уровне. Все еще часто многие прячут их за обратным прокси-сервером
наподобие NGINX или Apache. Обратный прокси-сервер создает дополнительный
слой HTTP-сервера, который перенаправляет запросы и ответы между клиентами
и приложением и появляется на сервере Python так, словно это запрашивающий
клиент. Обратные прокси-серверы можно использовать по следующим различным
причинам.
Разрыв TLS/SSL, как правило, лучше обрабатывается веб-серверами верх-
него уровня, например NGINX и Apache. Это позволяет приложению Python
говорить только на языке HTTP-протокола (вместо HTTPS), вследствие чего
вопросы сложности и конфигурации защищенных каналов связи ложатся на
обратный прокси-сервер.
Непривилегированные пользователи не могут связывать порты нижнего уров-
ня (в диапазоне 0–1000), но протокол HTTP идет к пользователям на порт 80,
а HTTPS — на порт 443. Для этого необходимо запустить процесс с привилегия
ми суперпользователя. Как правило, более безопасно сделать так, чтобы ваше
приложение работало на порте верхнего уровня или на сокете домена Unix и использовало его в качестве восходящего потока для обратного прокси-сервера,
который выполняется под более привилегированным пользователем.
Обычно NGINX работает со статическими ассетами (изображения, JS, CSS
и другие мультимедиа) эффективнее, чем код Python. Если настроить его
в качестве обратного прокси-сервера, то потребуется всего несколько строк
конфигурации для работы со статическими файлами.
Когда один хост обслуживает несколько приложений от различных доменов,
для создания виртуальных хостов для различных доменов, обслуживаемых на
одном порте, нужно использовать Apache или NGINX.
Обратные прокси-серверы могут повысить производительность за счет добавле-
ния дополнительных слоев кэширования или быть сконфигурированными как
простые балансировщики нагрузки. Кроме того, на обратных прокси-серверах
может применяться сжатие (например, GZIP) для ответов с целью ограничить
количество требуемой пропускной способности сети.
Некоторые из веб-серверов, такие как NGINX, на самом деле даже рекомендуется запускать за прокси-сервером. Например, gunicorn — это очень надежный
WSGI-сервер, показывающий великолепные результаты, если его клиенты функционируют достаточно быстро. С другой стороны, он плохо работает с медленными
клиентами и легко восприимчив к атакам, основанным на медленном соединении
254 Часть II
•
Ремесло Python
с клиентом. Использование прокси-сервера для буферизации медленных клиентов — лучший способ решить данную проблему.
Помните: имея соответствующую инфраструктуру, можно почти полностью избавиться от обратного прокси. Сегодня такие проблемы, как разрыв SSL и сжатие,
можно легко решить с помощью служб балансировки нагрузки сервисов, например,
AWS Load Balancer. Статические и мультимедийные ассеты лучше подавать через
сеть доставки контента (Content Delivery Networks, CDN), которые также могут
быть использованы для кэширования других откликов вашего сервиса.
Упомянутое выше требование передавать HTTP/HTTPS-трафик на порты 80/433 (которые не могут быть связаны непривилегированными пользователями) тоже больше не проблема, если точки входа ваших клиентов общаются
с вашими балансировщиками нагрузки и CDN. Тем не менее даже наличие такой
архитектуры не обязательно означает, что ваша система не работает с обратными
прокси-серверами. Например, многие балансировщики нагрузки поддерживают
протокол прокси. Это значит, балансировщик нагрузки может появиться в приложении, притворившись запрашивающим клиентом. В подобных случаях балансировщик нагрузки действует как обратный прокси-сервер.
В следующем подразделе поговорим о перезагрузке процессов.
Корректная перезагрузка процессов
Девятое правило методологии «Двенадцатифакторного приложения» касается
одноразовости процессов и говорит о том, что вы должны максимизировать надежность через быстрый запуск и корректную остановку. С быстрым запуском все
понятно, а вот о корректной остановке нужно поговорить отдельно.
Если в веб-приложении процесс сервера завершается ненадлежащим образом,
то просто возьмет и закроется, не закончив обработку запросов и выдачу правильных ответов подключенным клиентам. В лучшем случае при использовании
какого-либо обратного прокси-сервера он сможет ответить подключенным клиентам с некоторой реакцией на ошибку (например, 502 Bad Gateway), даже если это
не самый удачный способ уведомить пользователей о перезагрузке приложения
и развертывании новой версии.
В соответствии с «Двенадцатифакторным приложением» процесс веб-сервера
должен корректно завершаться при получении сигнала Unix SIGTERM. Это значит,
что сервер должен прекратить создание новых подключений, завершить обработку всех ожидающих запросов, а затем выйти с неким кодом, как только завершит
текущие задачи.
Очевидно, при запуске процедуры отключения сервер больше не может обрабатывать новые запросы. Это означает нарушение в работе сервиса. Таким образом,
вам нужно сделать еще кое-что: создать новых исполнителей, которые смогли бы
Глава 8. Развертывание кода 255
принимать новые соединения, пока старые отключаются. Существуют WSGIсовместимые реализации веб-сервера Python, позволяющие должным образом
перезагрузить сервис, не прибегая к каким-либо простоям.
Самые популярные веб-серверы Python — это Gunicorn и uWSGI, имеющие
такие функции:
мастер-процесс Gunicorn при получении сигнала SIGHUP (kill -HUP ) создает новых исполнителей (с новым кодом и конфигурацией) и кор
ректно завершает работу на старых;
uWSGI имеет по меньшей мере три независимых схемы корректной переза-
грузки. Все они сложные, и в двух словах их не описать, но полная информация
о них есть в официальной документации.
На сегодняшний день корректная перезагрузка — стандарт развертывания
веб-приложений. Подход Gunicorn выглядит самым простым в использовании,
но в то же время обладает наименьшей гибкостью. В противовес этому корректная перезагрузка в uWSGI позволяет значительно лучше управлять процессом
перезагрузки, но более сложна для автоматизации и настройки. Кроме того, применяемый подход к перезагрузке в автоматизированном развертывании зависит
и от того, какие инструменты контроля используются и как настроены. Например,
в Gunicorn все просто:
kill -HUP
Но если вы хотите правильно изолировать дистрибутивы проектов путем создания отдельных виртуальных окружений для каждой версии и настроить контроль
за процессом с помощью символических ссылок (как это представлено в примере
fabfile), то вскоре заметите, что данная функция Gunicorn работает не вполне
ожидаемым образом. При более сложном развертывании до сих пор не существует
решения на уровне систем, работающего сразу «из коробки». Вам всегда придется
залезать внутрь, а для этого иногда требуется иметь немало знаний о низкоуровневых моментах реализации системы.
В таких сложных скриптах, как правило, лучше решать проблему на более высоком уровне абстракции. Если вы наконец решили запускать приложения в качестве
контейнеров и распространяете новые версии в виде образов контейнеров (что
настоятельно рекомендуется), то можете передать ответственность за корректную
перезагрузку вашей системе оркестровки контейнера (например, Kubernetes),
которая, как правило, позволяет обрабатывать различные стратегии перезагрузки
«из коробки».
Даже не имея современных систем оркестровки, вы можете реализовать перезагрузку на уровне инфраструктуры. Например, AWS Elastic Load Balancer позволяет
корректно перенаправлять трафик от старых экземпляров приложения (например,
256 Часть II
•
Ремесло Python
хостов EC2) на новые. Когда старые экземпляры приложения перестают получать
трафик и обрабатывать запросы, их можно просто отключить без видимых перебоев сервиса. У других облачных провайдеров тоже, как правило, есть аналогичные
сервисы в портфеле услуг.
В следующем разделе поговорим о контрольно-проверочном коде и мониторинге.
Контрольно-проверочный код и мониторинг
Наша работа не заканчивается на написании приложений и их развертывании в целевой среде выполнения. Можно написать приложение, которому после развертывания не нужно будет дальнейшее сопровождение, хотя это весьма маловероятно.
Вместо этого мы должны убедиться, что приложение правильно контролируется
на предмет наличия ошибок и производительности.
Чтобы быть уверенными в должной и ожидаемой работе продукта, вам следует
правильно обрабатывать журналы приложений и контролировать необходимые
показатели. В эту задачу входит следующее:
мониторинг журналов доступа веб-приложений для различных кодов состоя
ния HTTP;
сбор журналов процессов, которые могут содержать информацию об ошибках
во время выполнения и различные предупреждения;
использование системных ресурсов (загрузка процессора, задействование па-
мяти, сетевой трафик, производительность ввода/вывода, использование диска и т. д.) на удаленных хостах, на которых запускается приложение;
мониторинг производительности на уровне приложений и показателей эф-
фективности бизнеса (привлечение клиентов, доход, коэффициент конверсии и т. д.).
К счастью, для выполнения этой задачи существует много инструментов, и большинство из них очень легко интегрировать.
В следующем подразделе поговорим об ошибках журнала и Sentry/Raven.
Ошибки журнала — Sentry/Raven
Истина ужасна. Независимо от того, насколько хорошо будет протестировано ваше
приложение, в какой-то момент ваш код все равно даст сбой. Это может быть что
угодно: неожиданное исключение, истощение ресурсов, сбой некоего основного
сервиса или сети либо просто проблема во внешней библиотеке. Некоторые из
возможных проблем (например, истощение ресурсов) можно предсказать и предот-
Глава 8. Развертывание кода 257
вратить заранее при наличии надлежащего контроля. Но, к сожалению, проблемы
все равно возникают всегда, независимо от ваших усилий.
Но зато вы можете подготовиться к таким сценариям и убедиться, что ошибка
не останется незамеченной. В большинстве случаев любые неожиданные результаты приводят к выбрасыванию исключения, которое попадает в журнал. Это
может быть stdout, stderr, файл журнала или любой другой настроенный вывод.
В зависимости от реализации, это может привести или не привести к закрытию
приложения с неким кодом выхода.
Можно, конечно, в целях обнаружения и мониторинга ошибок приложения
положиться исключительно на файлы журналов, хранящиеся в файловой системе. К сожалению, поиск ошибки в простом текстовом виде мучительно труден
и не масштабируется за пределы чего-либо более сложного, чем выполнение кода
в процессе разработки. Все равно вам придется использовать специальные сервисы,
предназначенные для сбора и анализа журналов. Правильная обработка журналов очень важна и по другим причинам (об этом чуть позже), однако не слишком
хорошо работает для отслеживания и отладки ошибок. Причина проста. Наиболее
распространенная форма журналов ошибок — просто стек вызовов Python. Если вы
останавливаетесь лишь на этом, то вскоре поймете, что таких действий недостаточно
для обнаружения причины ваших проблем. Это особенно верно, когда ошибки возникают в неизвестных ситуациях или в определенных условиях нагрузки.
Что вам действительно нужно — это как можно больше контекстной информации о возникновении ошибки. Кроме того, очень полезно иметь полную историю
ошибок, которые возникали в production-среде, и удобный способ их поиска.
Один из наиболее распространенных инструментов, который дает такие возможности, — Sentry (getsentry.com). Это проверенный сервис для отслеживания
исключений и сбора отчетов об ошибках. Он доступен в виде открытого исходного
кода, написан на Python и изначально возник как инструмент для бэкенд-вебразработчиков. Сейчас он перерос изначальные амбиции и имеет поддержку многих
других языков, в том числе PHP, Ruby и JavaScript, но по-прежнему остается самым
популярным инструментом выбора для многих веб-разработчиков на Python.
Исключение стека вызовов в веб-приложениях
Обычно веб-приложения не завершаются в случае необработанных исключений, поскольку HTTP-серверы обязаны вернуть ответ об ошибке
с кодом состояния из группы 5XX, если произошла ошибка сервера.
В большинстве веб-фреймворков Python такие вещи реализованы по
умолчанию. В таких случаях исключение, по сути, обрабатывается либо
на внутреннем уровне веб-фреймворка, либо на промежуточном WSGIсервере. Во всяком случае, это, как правило, приводит к выбрасыванию
исключения стека вызовов (обычно в стандартном выводе).
258 Часть II
•
Ремесло Python
Sentry доступен в качестве платного «ПО как услуга», но с открытым исходным
кодом, вследствие чего его можно бесплатно разместить в вашей собственной инфраструктуре. Библиотека, которая обеспечивает интеграцию с Sentry, — sentrysdk (доступна на PyPI). Если вы еще не работали с ней и хотите попробовать, но
у вас нет доступа к собственному серверу Sentry, то можете легко подписаться
на бесплатную пробную версию на сайте Sentry. Имея доступ к серверу Sentry
и созданный новый проект, вы получите строку с именем Data Source Name (DSN).
Эта строка — минимальная настройка конфигурации, необходимая для интеграции
приложения с Sentry. Она содержит протокол, учетные данные, местоположение
сервера и ваш идентификатор организации/проекта в следующем виде:
'{PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}/{PATH}{PROJECT_ID}'
Получив DSN, вы можете легко выполнить интеграцию, как показано в следующем коде:
import sentry_sdk
sentry_sdk.init(
dsn='https://:@app.getsentry.com/'
)
try:
1 / 0
except Exception as e:
sentry_sdk.capture_exception(e)
Sentry и Raven
Старая библиотека для интеграции Sentry — это Raven. Она по-прежнему
поддерживается и доступна на PyPI, но ее век уже подходит к концу,
вследствие чего лучше всего начать интеграцию Sentry с помощью пакета python-sdk. Однако вполне возможно, что некоторые фреймворки или
расширения Raven не были портируемы на новый SDK, поэтому в таких
ситуациях интеграция с помощью Raven пока более предпочтительна.
Sentry SDK имеет множество интеграций с большинством популярных структур, таких как Python Django, Flask, Celery или Pyramid, упрощающих интеграцию.
Эти интеграции автоматически дают дополнительный контекст, специфичный
для данной структуры. Если у веб-фреймворка нет поддержки, то пакет sentrysdk содержит общее промежуточное ПО WSGI, которое делает его совместимым
с любыми WSGI-серверами, как показано в следующем коде:
from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware
sentry_sdk.init(
Глава 8. Развертывание кода 259
dsn='https://:@app.getsentry.com/'
)
# ...
# Примечание: application — объект приложения WSGI, определенный ранее
application = SentryWsgiMiddleware(application)
Другая примечательная интеграция — возможность отслеживать сообщения,
регистрируемые через встроенный модуль Python logging. Для включения такой
поддержки требуется всего несколько дополнительных строк кода:
import logging
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration
sentry_logging = LoggingIntegration(
level=logging.INFO,
event_level=logging.ERROR,
)
sentry_sdk.init(
dsn='https://:@app.getsentry.com/',
integrations=[sentry_logging],
)
Захват сообщений журнала может иметь свои сложности, вследствие чего вам
нужно изучить официальную документацию по данной теме. Это должно избавить
вас от неприятных сюрпризов.
Наконец, скажем о запуске собственного Sentry в качестве способа сэкономить
деньги. Помните: бесплатный сыр только в мышеловке. Вы все равно будете нести
дополнительные расходы на инфраструктуру, а Sentry станет просто еще одним
сервисом, требующим сопровождения. Сопровождение = дополнительная работа =
= затраты! По мере развития вашего приложения количество исключений растет, следовательно, вы будете вынуждены масштабировать Sentry вместе со своим
продуктом. К счастью, это очень надежный проект, но вам от него никакой пользы
в случае слишком высокой нагрузки. Кроме того, сложно держать Sentry в готовности к сценарию отказа, в котором могут возникать тысячи отчетов о сбоях в секунду. Таким образом, вы должны решить, какой вариант для вас действительно
дешевле и имеете ли вы достаточно ресурсов, чтобы сделать все это самостоятельно. Подобной дилеммы, конечно, не возникает, если политика безопасности вашей
организации не подразумевает отправку каких-либо данных третьим лицам. В этом
случае вы можете просто разместить Sentry в вашей собственной инфраструктуре.
Расходы, конечно, есть, но они того стоят.
Далее мы рассмотрим метрики систем мониторинга и приложений.
260 Часть II
•
Ремесло Python
Метрики систем мониторинга и приложений
Когда дело доходит до мониторинга производительности, вашему вниманию предлагается огромный выбор инструментов. Если у вас большие ожидания, то вполне
возможно, что вам придется использовать сразу несколько из них.
Munin (munin-monitoring.org) — один из самых популярных вариантов, применяемых многими организациями, независимо от стека технологий. Это отличный
инструмент для анализа тенденций в ресурсах, который предоставляет много
полезной информации даже при установке по умолчанию, без дополнительных
настроек. Его установка состоит из следующих двух основных компонентов:
мастера Munin, собирающего метрику от других узлов и выводящего графики;
узла Munin, который устанавливается на управляемом хосте и собирает локаль-
ные метрики и посылает их мастеру Munin.
Большинство плагинов написаны на Perl. Есть также реализация узла на других
языках: munin-node-c на C (github.com/munin-monitoring/munin-c) и munin-node-python
на Python (github.com/agroszer/munin-node-python). Munin поставляется с огромным
количеством плагинов, доступных в репозитории contrib. Это значит, что в нем
«из коробки» поддерживается большинство популярных баз данных и системных
сервисов. Есть даже плагин для мониторинга популярных веб-серверов Python,
таких как uWSGI или Gunicorn.
Основной недостаток Munin заключается в том, что он выводит графики как
статические изображения, а конфигурация графика возложена на настройку плагина. Вы не сможете создавать гибкие панели мониторинга и сравнивать значения
метрики из разных источников на одном графике. Но это цена простой установки
и универсальности. Писать собственные плагины достаточно легко. Существует
пакет munin-python (python-munin.readthedocs.org/en/latest/), который позволяет писать
плагины Munin на Python.
К сожалению, архитектура Munin, предполагающая, что всегда существует отдельный демон-процесс мониторинга на каждом хосте, который отвечает за сбор
метрик, не слишком хорошо подходит для мониторинга показателей производительности пользовательских приложений. Вам будет очень легко написать свой
собственный плагин Munin, но при условии, что процесс контроля уже позволяет
вам получать статистические данные о производительности.
Если вы хотите собрать метрики на уровне приложений, то вам может понадобиться их сбор и хранение в каком-то временном хранилище до представления
в пользовательском плагине Munin. Это делает создание пользовательских метрик
более сложным, вследствие чего стоит рассмотреть другие решения.
Другое популярное решение, с помощью которого легко собирать пользовательские метрики, — StatsD (github.com/etsy/statsd). Это сетевой демон, написанный на
Глава 8. Развертывание кода 261
Node.js, который собирает различные статистические данные со счетчиков, таймеров и датчиков. Его очень легко интегрировать благодаря простому протоколу,
основанному на UDP. Кроме того, существует простой в применении пакет Python
statsd для отправки метрик в демон StatsD:
>>>
>>>
>>>
>>>
import statsd
c = statsd.StatsClient('localhost', 8125)
c.incr('foo') # Увеличиваем счетчик 'foo'.
c.timing('stats.timed', 320) # Record a 320ms 'stats.timed'.
Поскольку UDP — протокол без установления соединения, он слабо влияет на
производительность кода приложения и поэтому очень подходит для отслеживания
и измеренияпользовательских событий внутри кода приложения.
К сожалению, StatsD занимается только сбором метрик и не позволяет создавать отчетность. Поэтому вам понадобятся другие процессы, способные обрабатывать данные из StatsD и строить графики. Самый популярный выбор — Graphite
(graphite.readthedocs.org). Он делает следующее:
хранит данные времени в числовом формате;
отрисовывает графики под эти данные.
Graphite позволяет сохранять пресеты с высокими возможностями к настройке. Вы также можете сгруппировать множество графиков в тематические панели.
Графики, как и Munin, визуализируются как статические изображения, но есть
также JSON API, которая позволит другим оболочкам считывать данные графика
и выводить их другими средствами.
Один из самых крутых плагинов приборной панели, интегрированных с Gra
phite, — это Grafana (grafana.org). Его действительно стоит попробовать, поскольку
он гораздо удобнее в использовании, чем простые панели Graphite. Графики, представляемые в Grafana, полностью интерактивны и проще в управлении.
Проект Graphite, к сожалению, немного сложноват. Это модульный сервис,
который состоит из следующих трех отдельных компонентов:
Carbon — демон, написанный с помощью Twisted, ожидающий данные времен-
ных рядов;
whisper — простая библиотека баз данных для хранения данных временных
рядов;
graphite WebApp — веб-приложение Django, которое выводит графики как
статические изображения (с помощью библиотеки Cairo) или в виде данных
в формате JSON.
При использовании совместно с проектом StatsD демон statsd отправляет свои
данные на демон carbon. В результате полноценное решение превращается в довольно большую стопку приложений, каждое из которых написано по отдельной
262 Часть II
•
Ремесло Python
технологии. Кроме того, в нем нет настраиваемых графиков, плагинов и инструментальных панелей, следовательно, вам предстоит настроить все самостоятельно.
Вам придется сделать много работы в начале и очень легко пропустить что-то важное. Поэтому хорошей идеей может быть использование Munin в качестве резервного
мониторинга, даже если в качестве основного средства применяется Graphite.
Другое хорошее решение мониторинга для сбора метрики — Prometheus.
Он имеет архитектуру, совершенно отличную от Munin и StatsD. Вместо того
чтобы полагаться на контролируемые приложения или демоны в целях выдачи
метрики в настраиваемых интервалах, Prometheus вытягивает метрику непосредственно из источника с помощью протокола HTTP. Для этого требуется
контролируемый сервис для хранения (а иногда и обработки) метрик и их выдачи
на конечные точки.
К счастью, Prometheus поставляется с кучей библиотек для разных языков
и фреймворков, позволяющих сделать данный вид интеграции максимально простым. Существуют также различные экспортеры, которые действуют как мосты
между другими системами мониторинга Prometheus. Поэтому если вы уже используете другие решения для мониторинга, то вам будет легко перейти на Prometheus.
Он также прекрасно интегрируется с Graphana.
В следующем подразделе мы увидим, как работать с журнальными приложениями.
Работа с журнальными приложениями
Хотя такие решения, как Sentry, обычно намного более эффективны, чем простой
вывод в текстовый файл, журналы — наше все. Вывод информации в стандартный
вывод или файл — одна из самых простых вещей, на которые способно приложение, и это свойство никогда не следует недооценивать. Существует риск того, что
сообщения, отправляемые к Sentry от Raven, не дойдут до получателя. Вероятны
ошибки сети, у Sentry может закончиться память, или он не сможет обрабатывать
поступающий трафик. Кроме того, ваше приложение может сломаться до отправки
сообщения (с ошибкой сегментации, например) — и все это лишь некоторые из
потенциальных сценариев.
Менее вероятно, что ваше приложение не сможет зарегистрировать сообщения, записываемые и сохраняемые в файловой системе. Конечно, все возможно, но
скажем начистоту: если до такого дойдет, то это значит, что у вас начались более
серьезные проблемы, чем утеря пары-тройки сообщений журнала.
Помните, что журналы нужны не только для ошибок. Многие разработчики
привыкли считать журналы лишь источником полезных для отладки данных или
данных для анализа проблемы.
Гораздо меньшее количество разработчиков пробует задействовать журналы
в качестве источника данных для генерации метрик приложений или выполнения
Глава 8. Развертывание кода 263
статистического анализа. Но и на этом их польза не заканчивается. Журналы даже
могут быть основой выпускаемого продукта. Отличный пример такого продукта
приведен в статье Amazon, в которой показана архитектура для обслуживания торгов в режиме реального времени, где все сосредоточено вокруг сбора и обработки информации в журнале: aws.amazon.com/blogs/aws/real-time-ad-impression-bids-using-dynamodb.
Рассмотрим основные практики работы с журналами низкого уровня.
Низкоуровневые методы работы с журналами
Правила «Двенадцатифакторного приложения» гласят: журналы должны рассматриваться как потоки событий. Таким образом, файл журнала — это не журнал как
таковой, а формат вывода. Это значит, что в журнале описаны упорядоченные по
времени события. В сыром виде они имеют вид обычного текста, где одна строка
соответствует одному событию, хотя в некоторых случаях событие может захватывать несколько строк (это характерно для ошибок времени выполнения).
В соответствии с методологией «Двенадцатифакторного приложения» приложение никогда не должно знать о формате, в котором хранятся журналы. Это значит, запись в файл или ротацию журналов никогда не следует выполнять в коде приложения.
Это является обязанностью среды, в которой выполняется приложение. Данный
факт немного сбивает с толку, поскольку во многих фреймворках есть функции
и классы для управления файлами журналов, их ротации, сжатия и сохранения.
Возникает искушение использовать их, поскольку тогда все можно разместить
в коде приложения базы, однако на самом деле такого подхода следует избегать.
Лучшие методы для работы с журналами заключаются в следующем:
приложение должно записывать небуферизованные журналы в стандартный
вывод (stdout);
среда выполнения отвечает за сбор и маршрутизацию журналов до конечной
точки.
Основная часть этой среды выполнения — как правило, своего рода инструмент
для контролирования состояния процессов. Популярные решения Python, такие
как Supervisor или Circus, позволяют работать с журналами и маршрутизацией.
Если журналы нужно хранить в локальной файловой системе, то лишь они должны
создавать сами файлы журналов.
И Supervisor, и Circus также способны выполнять ротацию журналов и сохранение для необходимых вам процессов, но следует определить, нужен ли вообще
вам этот путь. Успех работы заключается в простоте и последовательности. Вполне
возможно, что вам потребуется обрабатывать не только журналы вашего собственного приложения. Если вы используете Apache или NGINX в качестве обратного
прокси-сервера, то их журналы вам тоже понадобятся.
Вы также можете хранить и обрабатывать журналы кэша и баз данных. Если вы
работаете в каком-то популярном дистрибутиве Linux, то велика вероятность того,
264 Часть II
•
Ремесло Python
что у каждого из этих сервисов будут свои собственные файлы журналов, обрабатываемые с помощью популярной утилиты под названием logrotate. Мы рекомендуем забыть о возможностях Supervisor и Circus ради достижения согласованности
с другими сервисами. Утилита logrotate более гибкая в настройке и к тому же
поддерживает сжатие.
Logrotate и Supervisor/Circus
Есть важная вещь, которую следует знать при использовании logrotate
с Supervisor или Circus. Ротация журналов будет происходить всегда,
а у Supervisor есть открытый дескриптор ротации журналов. Если вы
не станете принимать надлежащие контрмеры, то новые события будут
записываться в дескриптор, который уже был удален logrotate. В результате в файловой системе не останется вообще ничего. Решить эту
проблему достаточно просто. Нужно настроить logrotate на файлы журналов процессов, управляемых Supervisor или Circus, с помощью опции
copytruncate. Вместо перемещения файла журнала после ротации он
будет копироваться, а исходный файл станет очищаться. Такой подход
не отменяет существующие дескрипторы файлов и процессов, и запись
в журналы будет идти своим ходом. Supervisor может также принимать
сигнал SIGUSR2, который заново открывает все дескрипторы файлов.
Он может быть включен в скрипт postrotate в настройке logrotate. Этот
подход более экономичен с точки зрения операций ввода/вывода, но
в то же время менее надежен и более труден в сопровождении.
Инструменты для обработки журналов описаны ниже.
Инструменты для обработки журналов
Если у вас нет опыта взаимодействия с большими объемами журналов, то вы рано
или поздно получите его, работая с продуктом, обрабатывающим существенную
нагрузку. Вы вскоре заметите, что простого подхода, основанного на хранении журналов в файлах и в каком-либо постоянном хранилище для последующего извлечения, будет недостаточно. Отсутствие соответствующих инструментов превратит
эту работу в сложную и топорную. Простые утилиты, такие как logrotate, помогут
вам освободить жесткий диск от постоянно увеличивающегося количества новых
событий, хотя разделение и сжатие файлов журналов помогает только в работе
с архивами данных, однако не упрощает их извлечение или анализ.
При работе с системами, распределенными по нескольким узлам, хорошо иметь
одну центральную точку, из которой все журналы можно извлечь и затем проанализировать. Для этого требуется процедура обработки журнала, выходящая далеко
за рамки простого сжатия и резервного копирования. К счастью, данная задача
хорошо известна и есть много инструментов, позволяющих ее решить.
Глава 8. Развертывание кода 265
Один из популярных вариантов среди разработчиков — Logstash. Это демон
сбора журнала, который может наблюдать за активными файлами журналов, анализировать записи и отправлять их в бэкенд-сервис в структурированной форме.
В качестве сервиса почти всегда используется Elasticsearch, поисковая система,
построенная вокруг Lucene. Помимо возможностей поиска текста, она содержит
уникальный фреймворк агрегации данных, который очень хорошо подходит для
целей анализа журнала. Другое дополнение к этой паре инструментов — Kibana.
Это универсальная платформа для мониторинга, анализа и визуализации для
Elasticsearch. Таким образом, эти три инструмента дополняют друг друга и потому почти всегда используются совместно, как единый стек для обработки
журналов.
Интеграция существующих сервисов с Logstash очень проста, поскольку он
может отслеживать изменения файла журнала на предмет событий при минимальных изменениях в конфигурации журнала. Он анализирует журналы в текстовом
виде и имеет встроенную поддержку ряда популярных форматов журналов, таких
как журналы доступа Apache/NGINX. Logstash может быть дополнена Beats. Это
поставщик журнала, совместимый с входными протоколами Logstash, который
может собирать не только необработанные данные из журналов файлов (filebeat),
но и метрики различных систем (metricbeat) и даже действия пользователей аудита
на хостах (auditbeat).
Другое решение, позволяющее устранить недостатки Logstash, — Fluentd.
Это еще один демон для сбора журналов, который можно взаимозаменяемо применять с Logstash в упомянутом стеке мониторинга журнала. Он также позволяет
слушать и анализировать события журнала непосредственно в файлах журналов,
поэтому интеграцию настроить несложно. В отличие от Logstash он очень хорошо
обрабатывает случаи перезагрузки и не нуждается в сигналах о ротации файлов
журналов. Наибольшее преимущество достигается путем использования одного из
его альтернативных вариантов сбора журнала, для которых потребуются кое-какие
существенные изменения в настройке ведения журналов.
Fluentd действительно обрабатывает журналы в виде потоков событий (в соответствии с рекомендациями «Двенадцатифакторного приложения»). Интеграция
на основе файлов по-прежнему возможна, но это единственный вид обратной совместимости для старых приложений, в котором журналы рассматриваются в виде
файлов. Каждая запись в журнале — событие, и оно должно быть структурировано.
Fluentd позволяет разбирать текстовые журналы и включает несколько вариантов
плагинов, в том числе следующие:
общие форматы (Apache, NGINX, и syslog);
произвольные форматы, которые задаются с помощью регулярных выражений
или обрабатываются пользовательским плагином;
общие форматы для структурированных сообщений, таких как JSON.
266 Часть II
•
Ремесло Python
Лучший формат событий для Fluentd — это JSON, поскольку позволяет достичь наименьшего количества затрат. Сообщения в формате JSON также могут
быть переданы практически без каких-либо изменений в бэкенд-сервисе, таком как
Elasticsearch, или в базе данных.
Другая очень полезная особенность Fluentd — способность пропускать потоки
событий способами, отличными от тех, которыми записываются журналы на диск.
Ниже приведены наиболее примечательные встроенные входные плагины и то,
какие действия возможно выполнить с их помощью:
in_udp — каждый журнал событий передается в виде пакетов UDP;
in_tcp — события посылаются через соединение TCP;
in_unix — события посылаются через сокет домена Unix (именованный сокет);
in_http — события передаются в виде запросов HTTP POST;
in_exec — процесс Fluentd периодически выполняет внешнюю команду, вы-
таскивая события в формате JSON или MessagePack;
in_tail — процесс Fluentd прослушивает события в текстовом файле.
Альтернативные способы передачи журнала событий могут быть особенно
полезны в ситуациях, когда приходится работать с медленным вводом/выводом
памяти компьютера. Это часто применяется в сервисах облачных вычислений,
у которых на диске-хранилище по умолчанию очень мало операций ввода-вывода
в секунду (input output operations per second, IOPS), а получить большую производительность выходит дорого.
Если приложение выдает большое количество сообщений журнала, то вы можете легко достичь предела возможностей ввода-вывода, даже если размер данных
не очень велик. Наличие альтернативного канала передачи позволит вам более
эффективно использовать оборудование, поскольку работа по буферизации выполняется одним процессом сбора журнала. Настроив буферизацию сообщений
в память вместо диска, вы можете полностью избавиться от диска как такового,
хотя это может значительно снизить качество собранных журналов.
Использование различных каналов передачи, на первый взгляд, слегка противоречит 11-му правилу методологии «Двенадцатифакторного приложения». Обработка журналов как потоков событий говорит о том, что приложение всегда должно
делать записи через один стандартный поток вывода (stdout). Вы по-прежнему
можете использовать альтернативные способы передачи, не нарушая данное правило. Запись в стандартный вывод не обязательно означает, что этот поток должен
быть записан именно в файл.
Вы можете оставить ведение журнала в таком виде и обернуть его внешним
процессом, который будет захватывать данный поток и передавать его непосредственно в Logstash или Fluentd, не задействуя файловую систему. Это сложная
Глава 8. Развертывание кода 267
модель, которая может подойти не для каждого проекта. Она имеет очевидный
недостаток в виде повышенной сложности, так что вы должны решить для себя,
стоит ли оно того.
Резюме
Развертывание кода — сложная тема, и вы наверняка уже поняли это после прочтения текущей главы. Несмотря на то что мы ограничили сферу нашей деятельности исключительно веб-приложениями, затронута лишь верхушка айсберга. Мы
использовали методику «Двенадцатифакторного приложения» как основу для демонстрации возможных решений различных проблем, связанных с развертыванием
кода. Мы подробно обсудили только часть из них: работу с журналами, управление
зависимостями и разделение этапов сборки и выполнения.
После прочтения данной главы вы наверняка начали понимать, как выполнить
автоматизацию процесса развертывания с учетом передового опыта в этой области, а также иметь возможность добавить надлежащий инструментарий и средства
мониторинга для кода, который выполняется на удаленных хостах.
В следующей главе мы изучим, почему писать расширения на C и C++ для
Python иногда весьма полезно, и покажем, что это не так сложно, как кажется, если
выбрать для работы подходящие инструменты.
9
Расширения Python
на других языках
Занимаясь написанием приложений на Python, вы не обязаны ограничиваться
одним лишь языком Python. Существуют такие инструменты, как Hy (о них мы
кратко говорили в главе 5), позволяющие писать модули, пакеты и даже целые
приложения на другом языке (диалекте Lisp), который станет работать на виртуальной машине Python. Это дает вам возможность выразить логику программы
с совершенно другим синтаксисом, однако во время компиляции в байт-код это
будет один и тот же язык, имеющий те же ограничения, что и у обычного кода на
Python. Перечислим некоторые из таких ограничений:
многопоточность значительно снижается из-за глобальной фиксации интер-
претатора (global interpreter lock, GIL) в CPython и зависит от выбранной
реализации Python;
Python — некомпилируемый язык, поэтому во время компиляции нет оптимизации;
Python не имеет статической типизации и возможных связанных с ней оптимизаций.
Преодолеть такие ограничения можно с помощью расширений Python, которые
полностью написаны на другом языке, но пропускают свой интерфейс через API
для расширения Python.
В текущей главе мы рассмотрим основные причины для написания собственных
расширений на других языках и познакомимся с популярными инструментами,
позволяющими их создавать.
В этой главе:
различия между языками C и C++;
создание простого расширения на C с использованием Python/C API;
создание простого расширения на C c помощью Cython;
понимание основных проблем и задач, связанных с использованием расширений;
взаимодействие с компилируемыми динамическими библиотеками без создания
выделенных расширений на чистом Python.
Глава 9.
Расширения Python на других языках 269
Технические требования
Для компиляции расширений Python, о которых мы поговорим в этой главе, нам
понадобятся компиляторы C и C++. Ниже приведены подходящие компиляторы,
которые можно бесплатно скачать для нужной операционной системы:
Visual Studio 2019 (Windows): visualstudio.microsoft.com;
GCC (Linux и большинство систем POSIX): gcc.gnu.org;
Clang (Linux и большинство систем POSIX): clang.llvm.org.
В Linux компиляторы GCC и Clang, как правило, можно скачать через систему
управления пакетами для данного конкретного дистрибутива. В macOS компилятор
является частью Xcode IDE (доступна через App Store).
Пакеты Python, упомянутые в этой главе, можно скачать с PyPI:
Cython;
cffi.
Установить эти пакеты можно с помощью следующей команды:
python3 -m pip install
Файлы кода для этой главы доступны по ссылке github.com/PacktPublishing/ExpertPython-Programming-Third-Edition/tree/master/chapter9.
Различия между языками C и C++
Когда речь идет об интеграции других языков с Python, это почти всегда C и C++.
Даже такие инструменты, как Cython или Pyrex, которые определяют суперсеты
языка Python только в целях создания расширений Python, на самом деле являются
компиляторами «код — код», генерирующими код C из расширенного синтаксиса
Python.
Фактически вы можете использовать динамические/разделяемые библиотеки
Python, написанные на любом языке, если он поддерживает компиляцию в виде
динамических/разделяемых библиотек. Таким образом, возможности межъязыковой интеграции выходят далеко за пределы C и C++. Это связано с тем, что
библиотеки задействуются повсеместно и могут быть применены в любом языке,
который поддерживает их загрузку. Итак, даже если вы пишете библиотеку на
совершенно другом языке (скажем, Delphi или Prolog), вы можете использовать
ее в Python. Тем не менее называть такую библиотеку расширением Python, если
в ней не применяется Python/C API, не поворачивается язык.
К сожалению, писать собственные расширения лишь на C или C++, используя
голый Python/C API, довольно сложно. Дело не только в том, что это требует хорошего понимания одного из двух языков, изначально трудных в освоении, но и в том,
270 Часть II
•
Ремесло Python
что здесь потребуется большой объем шаблонной работы. Вам придется писать
много повторяющегося кода, нужного исключительно для создания интерфейса,
который склеит ваш код C или C++ с интерпретатором Python и его типами данных. Однако вам будет полезно знать, как построены чистые расширения C, ввиду
следующих причин:
вы лучше поймете, как в целом работает Python;
однажды вам может понадобиться выполнить отладку или сопровождение на-
тивного расширения C/C++;
это помогает понять, как работают инструменты высокого уровня для создания
расширений.
В следующем подразделе поговорим о загрузке расширений на C или C++.
Загрузка расширений на C или C++. Интерпретатор Python способен загружать расширения из динамических/общих библиотек, таких как модули Python,
если у них предусмотрен подходящий интерфейс с использованием Python/C API.
Данный API должен быть включен в исходный код расширения с помощью файла заголовка Python.h, который распространяется вместе с исходниками Python.
Во многих дистрибутивах Linux этот файл заголовка содержится в отдельном
пакете (например, python-dev в Debian/Ubuntu), а под Windows распространяется
по умолчанию с интерпретатором. В системах POSIX (например, Linux и macOS)
его можно найти в каталоге include/ в месте установки Python, в операционной
системе Windows — в каталоге Include/ в месте установки Python.
Python/C API традиционно изменяется с каждой новой версией Python.
Как правило, эти изменения представляют собой добавление в API новых функций, совместимых с исходниками. Однако в большинстве случаев бинарная совместимость не сохраняется из-за изменений в бинарном интерфейсе приложения
(application binary interface, ABI). Это значит, что расширения нужно создавать
отдельно для каждой версии Python. Кроме того, различные операционные системы имеют несовместимые ABI, вследствие чего практически невозможно создать
бинарный код для каждой возможной среды. Поэтому большинство расширений
Python распространяется в виде исходного кода.
Начиная с версии Python 3.2, было определенно подмножество Python/C API
в целях стабильности ABI. И теперь вы можете создавать расширения с использованием этого ограниченного API (со стабильным ABI), следовательно, можно
собирать расширения для данной операционной системы только один раз, после
чего работать с любой версией Python от 3.2 и выше, не прибегая к перекомпиляции. Стоит заметить, что это ограничивает количество функций API и не решает
проблем старых версий Python. Кроме того, не позволяет создать единый бинарный
код, который будет работать на различных операционных системах. То есть мы
получаем некий компромисс, но цена стабильного ABI кажется немного высокой
для такой незначительной выгоды.
Глава 9.
Расширения Python на других языках 271
Важно знать: Python/C API работает только с реализацией CPython. Были
предприняты некоторые усилия с целью организовать поддержку расширений
в альтернативных реализациях, таких как PyPI, Jython или IronPython, но нам
кажется, что в данный момент для них не существует стабильного и полноценного
решения. Единственная альтернативная реализация Python, позволяющая легко
работать с расширениями, — Stackless Python, поскольку это всего лишь модифицированная версия CPython.
Расширения C для Python должны компилироваться в общие/динамические
библиотеки до импорта, поскольку отсутствует встроенный способ импортировать
код C/C++ в Python непосредственно из исходного кода. К счастью, в distutils
и setuptools есть помощники для определения скомпилированных расширений
в виде модулей, поэтому компиляция и распространение могут быть выполнены
с помощью скрипта setup.py, как если бы они были обычными пакетами Python.
Ниже приведен пример скрипта setup.py из официальной документации, в котором
выполняется создание простого пакета с расширением на C:
from distutils.core import setup, Extension
module1 = Extension(
'demo',
sources=['demo.c']
)
setup(
name='PackageName',
version='1.0',
description='This is a demo package',
ext_modules=[module1]
)
После написания такого кода нужно сделать еще один шаг:
python setup.py build
Это действие позволяет скомпилировать все ваши расширения, определенные
в качестве аргумента ext_modules, в соответствии со всеми дополнительными настройками компилятора, предоставленными конструктором Extension(). При этом
будет использоваться компилятор, определенный по умолчанию для вашей среды.
Данный компиляционный шаг не требуется, если пакет распространяется в виде
исходного кода. В таком случае вы должны быть уверены в том, что целевая среда
имеет все необходимое для компиляции, то есть компилятор, файлы заголовков
и дополнительные библиотеки, которые будут связаны с бинарным файлом (если
тот нужен расширению). Подробнее об упаковке расширений Python мы поговорим
позже, в разделе «Проблемы с использованием расширений» данной главы.
А в следующем разделе мы обсудим, почему мы должны использовать расширения.
272 Часть II
•
Ремесло Python
Необходимость в использовании
расширений
Сложно сказать, когда имеет смысл писать расширения на C/C++. Можно предположить такое правило: пишите, если у вас нет другого выбора. Но это очень субъективное мнение, которое оставляет много места для интерпретации выполнимого
в Python. Довольно трудно найти нечто, чего нельзя было бы сделать с помощью
чистого кода Python. Тем не менее есть определенные задачи, в которых расширения могут быть особенно полезны и дают следующие преимущества:
игнорирование GIL в потоковой модели CPython;
повышение производительности в критических секциях кода;
интеграцию сторонних динамических библиотек;
интеграцию исходного кода, написанного на других языках;
создание пользовательских типов данных.
Конечно, для каждой подобной проблемы, как правило, есть жизнеспособное
решение на Python. Например, основные ограничения интерпретатора CPython,
такие как GIL, довольно легко преодолеваются с помощью другого подхода, скажем,
многопроцессорной обработки вместо потоковой модели. Сторонние библиотеки
можно интегрировать с модулем ctypes. Любой тип данных можно реализовать
на Python. Однако нативный подход Python не всегда оказывается оптимальным. Интеграция внешней библиотеки с чистым Python может быть громоздкой
и сложной в сопровождении. Реализация пользовательских типов данных может
быть неоптимальной и не иметь доступа к управлению памятью низкого уровня.
Таким образом, окончательное решение нужно принимать очень осторожно, учитывая множество факторов. Лучше всего начать с чистой реализации Python и прибегать к использованию расширений, только когда нативный подход оказывается
недостаточно эффективным.
В следующем подразделе мы попробуем улучшить производительность критических фрагментов кода.
Повышение производительности критических
фрагментов кода
Будем честны. Python стал любимцем разработчиков не из-за производительности.
Он выполняется не слишком быстро, зато позволяет ускорить разработку программы. Тем не менее, независимо от нашей эффективности как программистов, иногда находятся проблемы, которые не получается эффективно решить с помощью
чистого Python.
Глава 9.
Расширения Python на других языках 273
В большинстве случаев решение проблем с производительностью сводится
к выбору правильных алгоритмов и структур данных, а не к ограничению постоянных затрат языка. И как правило, не стоит использовать расширения с целью
сэкономить пару тактов процессора, если код уже написан плохо или задействует
неэффективные алгоритмы. Часто бывает так, что удается повысить производительность до приемлемого уровня, не прибегая к увеличению сложности вашего
проекта, которая вытекает из добавления еще одного языка в стек технологий.
Если есть возможность использовать в проекте только один язык программирования — это приоритетная цель. Но также весьма вероятно, что даже самые передовые алгоритмы и наиболее подходящие структуры данных не помогут преодолеть
технологические ограничения чистого Python.
Пример ситуации, в которой возникают четко определенные ограничения на
производительность приложения, — бизнес ставок в реальном времени (real-time
bidding, RTB). Вкратце, RTB — это покупка и продажа инструментов для рекламы
(мест для нее) примерно таким же образом, как на аукционе или фондовой бирже.
Вся торговля обычно проходит через некоторые сервисы биржи рекламы, отправляющей информацию о доступных ресурсах на платформу спроса (demand-side
platform, DSP), заинтересованной в покупке места для рекламы. И вот здесь начинается самое интересное. На большинстве бирж для связи с потенциальными покупателями используется протокол OpenRTB (основанный на HTTP). DSP — сайт,
отвечающий за обслуживание ответов на HTTP-запросы OpenRTB. Рекламные
биржи всегда ставят очень жесткие ограничения на то, сколько времени может
выполняться весь процесс. Оно может иметь довольно малые значения — 50 мс от
получения первого пакета TCP до записи последнего байта на сервер DSP. Чтобы
ускорить работу, DSP-платформа может обрабатывать десятки тысяч запросов
в секунду. Возможность сократить время отклика на несколько миллисекунд часто
определяет прибыльность сервиса. Это значит, что перенос даже тривиального кода
на C может оказаться в такой ситуации разумным, но только если действительно
есть узкое место, в котором не получается добиться улучшений алгоритмически.
Как однажды сказал Гвидо, «если вы жаждете скорости... — цикл, написанный на C,
будет непобедим».
Об интеграции существующего кода, написанного на разных языках, поговорим
в следующем подразделе.
Интеграция существующего кода,
написанного на разных языках
Компьютерные науки довольно молоды по сравнению с другими областями
техники, однако многие программисты уже написали большое количество полезных библиотек для решения часто возникающих задач на разных языках
274 Часть II
•
Ремесло Python
программирования. Было бы расточительно забывать обо всем этом наследии
всякий раз в момент появления нового языка программирования, но в то же время
невозможно надежно портировать все программы, когда-либо написанные на всех
языках.
C и C++ — наиболее важные языки, предоставляющие много библиотек и реа
лизаций, которые можно было бы интегрировать в код приложения, не прибегая
к полному портированию на Python. К счастью, CPython уже написан на С, поэтому наиболее естественным способом интегрировать такой код будет применение
пользовательских расширений.
В следующем подразделе мы объясним, как интегрировать сторонние динамические библиотеки.
Интеграция сторонних динамических библиотек
Интеграция кода, написанного с помощью отличных от Python технологий, не заканчивается на C/C++. Множество библиотек, особенно программное обеспечение
сторонних производителей с закрытым кодом, распространяется в виде скомпилированных двоичных файлов. На C загружать такие общие/динамические библиотеки и вызывать их функции весьма легко. Это значит, что вы можете использовать
любую библиотеку C, если она обернута расширением с помощью Python/C API.
Это, конечно, не единственное решение, и существуют инструменты, такие
как ctypes или CFFI, которые позволяют взаимодействовать с динамическими
библиотеками с помощью чистого Python, не прибегая к необходимости писать
расширения на C. Очень часто выбор Python/C API — все еще лучший вариант,
поскольку в этом случае вы получаете лучшее разделение между интеграционным
слоем (написанным на C) и остальной частью вашего приложения.
В следующем подразделе расскажем, как создавать пользовательские типы
данных.
Создание пользовательских типов данных
В Python есть весьма универсальный выбор встроенных типов данных. Некоторые
из них реализованы по последнему писку моды (по крайней мере в CPython) и специально созданы для использования на языке Python. Количество базовых типов
и коллекций, доступных «из коробки», впечатляюще выглядит для новичков, но
все же не охватывает все возможные потребности.
Вы можете создавать на Python пользовательские структуры данных, основывая их полностью на некоторых встроенных типах или создавая их с нуля как
совершенно новые классы. К сожалению, в приложениях, чья работа существенно
зависит от таких пользовательских структур данных, производительность подобной
Глава 9.
Расширения Python на других языках 275
структуры может быть неоптимальной. Вся мощь сложных коллекций наподобие
dict или set исходит от лежащей в их основе реализации C. Почему бы не сделать
то же самое и не реализовать некоторые из ваших пользовательских структур
данных на C?
В следующем разделе мы обсудим, как писать расширения.
Написание расширений
Как уже было сказано, написание расширений — непростая задача, но в результате
напряженной работы появится множество преимуществ. Самый простой подход
к созданию расширений — использование таких инструментов, как Cython или
Pyrex. Эти проекты увеличат вашу продуктивность, а также сделают код более
легким для разработки, чтения и сопровождения.
В любом случае если вы новичок в данной области, то лучше всего начать ваше
приключение в мире расширений с написания такого расширения на чистом C
и Python/C API. Это позволит вам лучше понять, как работают расширения,
а также поможет оценить преимущества альтернативных решений. Для простоты
возьмем в качестве примера простую алгоритмическую задачу и попытаемся реализовать ее с помощью двух различных подходов, таких как:
написание расширения на чистом C;
использование Cython.
Задача: найти n-е число последовательности Фибоначчи. Очень маловероятно,
что вы захотите писать для такой задачи компилируемые решения, но мы возьмем
ее в качестве примера написания функции на C для Python/C API. Наша цель —
ясность и простота, поэтому мы не будем пытаться создать наиболее эффективное
решение.
Прежде чем создать наше первое расширение, определим эталонную реализацию функции Фибоначчи, которая позволит сравнить различные решения. Она написана на чистом Python и выглядит следующим образом:
"""Модуль Python, который обеспечивает функцию последовательности Фибоначчи"""
def fibonacci(n):
"""Возвращает номер n-го элемента Фибоначчи, вычисляемого рекурсивно"""
if n < 2:
return 1
else:
return fibonacci(n - 1) + fibonacci(n - 2)
Следует отметить, что это одна из самых простых реализаций функции fibonacci(),
и даже на Python можно было бы ее улучшить. Мы не будем улучшать нашу
276 Часть II
•
Ремесло Python
реализацию (используя шаблоны запоминания, например), поскольку цель нашего
примера не в том. Аналогично мы не будем оптимизировать наш код позже, при
обсуждении реализации на C или Cython, даже несмотря на то, что скомпилированный код дает гораздо больше возможностей сделать это.
В следующем подразделе посмотрим на расширения на чистом C.
Расширения на чистом языке C
Прежде чем полностью погрузиться в примеры кода расширений Python, написанных на C, нужно отметить важнейший момент. Если вы хотите расширить Python
языком C, то должны хорошо знать оба языка. Особенно это касается C. Отсутствие опыта работы с ним может привести к катастрофе, поскольку ошибиться
в нем легко.
Если вы решили, что вам точно нужно писать расширения C для Python, то
мы предполагаем, что вы уже знаете язык C на уровне, который позволит вам
полностью понять показанные здесь примеры. Разъясняться будут только детали
Python/C API. Наша книга о Python, а не каком-либо другом языке. Если вы вообще не знаете C, то вам не стоит пытаться писать свои собственные расширения,
пока не получите достаточно опыта и навыков. Оставьте эту задачу другим и используйте Cython или Pyrex, поскольку они намного безопаснее с точки зрения
новичка. Все дело в том, что Python/C API, несмотря на тщательную проработку,
не слишком хорошо подходит для начала работы с C.
Как предлагалось ранее, мы попытаемся портировать функцию fibonacci()
на C и вставить ее в код Python в качестве расширения. Начнем с базовой реализации, которая будет аналогична предыдущему примеру на Python. Голые функции
без использования Python/C API могут выглядеть грубо:
long long fibonacci(unsigned int n) {
if (n < 2) {
return 1;
} else {
return fibonacci(n - 2) + fibonacci(n - 1);
}
}
А вот пример полного и абсолютно функционального расширения, которое
передает эту единственную функцию в скомпилированный модуль:
#include
long long fibonacci(unsigned int n) {
if (n < 2) {
return 1;
} else {
return fibonacci(n - 2) + fibonacci(n - 1);
}
}
Представленный пример может вас огорчить, поскольку нам пришлось добавить
в четыре раза больше кода просто для того, чтобы функция fibonacci(), написанная
на С, стала доступна из Python. Позже мы обсудим каждую строку данного кода,
так что не волнуйтесь. Но прежде чем сделаем это, мы посмотрим, как этот код
можно упаковать и выполнить на Python.
Ниже приведена минимальная конфигурация setuptools для нашего модуля,
которая должна использовать класс setuptools.Extension, чтобы сообщить интерпретатору, как компилируется наше расширение:
from setuptools import setup, Extension
setup(
name='fibonacci',
ext_modules=[
Extension('fibonacci', ['fibonacci.c']),
]
)
278 Часть II
•
Ремесло Python
Процесс сборки расширения инициализируется с помощью команды setup.py
build, но также будет автоматически выполняться после установки пакета. Эти же
файлы кода можно найти в каталоге chapter9/fibonacci_c в приложении к этой
книге. Ниже показаны результат установки в режиме разработки и простая интер
активная сессия, где проверяется и выполняется наша скомпилированная функция
fibonacci():
$ ls -1ap
fibonacci.c
setup.py
$ python3 -m pip install -e .
Obtaining file:///Users/swistakm/dev/Expert-Python-ProgrammingThird_edition/chapter9
Installing collected packages: fibonacci
Running setup.py develop for fibonacci
Successfully installed Fibonacci
$ ls -1ap
build/
fibonacci.c
fibonacci.cpython-35m-darwin.so
fibonacci.egg-info/
setup.py
$ python3
Python 3.7.2 (default, Feb 12 2019, 00:16:38)
[Clang 10.0.0 (clang-1000.11.45.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import fibonacci
>>> help(fibonacci.fibonacci)
Help on built-in function fibonacci in fibonacci:
fibonacci.fibonacci = fibonacci(...)
fibonacci(n): Return nth Fibonacci sequence number computed recursively
>>> [fibonacci.fibonacci(n) for n in range(10)]
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
В следующем пункте поговорим о Python/C API.
Подробнее о Python/C API
Поскольку мы знаем, как правильно упаковать, скомпилировать и установить
пользовательские расширения C, и уверены, что все будет работать ожидаемым
образом, настало время обсудить наш код более подробно.
Модуль extensions начинается со следующей директивы препроцессора C,
которая включает файл заголовка Python.h:
#include
Глава 9.
Расширения Python на других языках 279
Эта команда подтягивает Python/C API и все остальное, необходимое для написания расширения. В более реальных ситуациях вашему коду потребуется гораздо
больше директив препроцессора, чтобы извлечь пользу из функций стандартной
библиотеки C или интегрировать другие файлы кода. Наш пример был прост, поэтому больше не потребовалось никаких директив.
Далее ядро нашего модуля выглядит следующим образом:
long long fibonacci(unsigned int n) {
if (n < 2) {
return 1;
} else {
return fibonacci(n - 2) + fibonacci(n - 1);
}
}
Функция fibonacci() — единственная часть нашего кода, делающая что-то полезное. Это реализация на чистом C, которую Python по умолчанию не понимает.
Остальная часть нашего примера позволяет создать интерфейс, пропускающий
функцию через API Python/C.
Первый этап воздействия данного кода на Python — создание функции C, которая совместима с интерпретатором CPython. В Python все является объектом.
Это значит, что функции C, вызываемые в Python, также должны вернуть реальные объекты Python. В Python/C API есть тип PyObject, и каждый вызываемый
объект должен вернуть указатель на него. Сигнатура нашей функции выглядит
следующим образом:
static PyObject* fibonacci_py(PyObject* self, PyObject* args)
Обратите внимание: в этой сигнатуре не указан точный список аргументов, но
только PyObject* args будет включать указатель на структуру, которая содержит
кортеж предоставленных значений. Фактическая проверка списка аргументов
должна выполняться внутри тела функции, и это именно то, что делает функция
fibonacci_py(). Она анализирует список аргументов args при условии, что все
они имеют тип unsigned int, и использует данное значение в качестве аргумента
в функции fibonacci(), чтобы получить элемент последовательности Фибоначчи,
как показано в следующем коде:
static PyObject* fibonacci_py(PyObject* self, PyObject* args) {
PyObject *result = NULL;
long n;
if (PyArg_ParseTuple(args, "l", &n)) {
result = Py_BuildValue("L", fibonacci((unsigned int)n));
}
return result;
}
280 Часть II
•
Ремесло Python
В предыдущем примере есть серьезная ошибка, которую опытный разработчик легко заметит. Попробуйте найти его в качестве упражнения
по работе с расширениями C. Пока оставим все как есть (для краткости).
Мы постараемся все исправить позже, когда будем обсуждать борьбу
с ошибками в пункте «Обработка исключений».
Строка "l" в вызове PyArg_ParseTuple(args, "l", &n) означает, что мы ожидаем в args одного значения типа long. В случае неудачи функция возвращает NULL
и хранит информацию об исключении в потоке в состоянии интерпретатора. Более
подробно об обработке поговорим в соответствующем пункте книги.
На самом деле сигнатура функции парсинга выглядит как int PyArg_Par
seTuple(PyObject *args, const char *format, ...), и после строки format идет
список аргументов неизвестной длины, которые представляют собой разобранное
выходное значение (в виде указателей). Это аналогично тому, как работает функция scanf() из стандартной библиотеки C. Если наше предположение неверно
и пользователь введет список несовместимых аргументов, то PyArg_ParseTuple()
выбросит соответствующее исключение. Этот способ кодирования сигнатур функций оказывается очень удобным, если привыкнуть к нему, но все равно является
громоздким по сравнению с простым кодом Python.Такие сигнатуры Python,
неявно определенные вызовом PyArg_ParseTuple(), сложно проверить внутри
интерпретатора Python. Следует помнить об этом факте при использовании кода,
предоставленного в виде расширений.
Как уже говорилось, Python ожидает, что из вызываемых объектов должны
возвращаться некие объекты. То есть мы не можем вернуть сырое значение long,
полученное из функции fibonacci(), в качестве результата fibonacci_py(). Такой
код вообще не скомпилируется, поскольку не предусмотрена автоматическая
переделка типов С в объекты Python. Вместо этого надо использовать функцию
Py_BuildValue(*format, ...) — аналог PyArg_ParseTuple(), принимающий на вход
подобный набор строк. Это не выход функции, а вход, вследствие чего это должны
быть фактические значения, а не указатели.
Когда функция fibonacci_py() определена, большая часть тяжелой работы позади. Последним шагом будет выполнение инициализации модуля и добавление
в нашу функцию метаданных, которые слегка упростят ее применение для пользователей. Это шаблонная часть кода нашего расширения и в ряде простых примеров
наподобие нашего может занять больше места, чем сама функция, реализуемая
нами. В большинстве случаев эта часть состоит из нескольких статических структур
и одной функции инициализации, которые будут выполняться интерпретатором
во время импорта модуля.
Сначала мы создаем строку static, которая будет являться строкой документации Python для функции fibonacci_py(), следующим образом:
Обратите внимание: ее можно встроить где-то в конце fibonacci_module_
methods, однако мы рекомендуем хранить строки документации отдельно, но в непосредственной близости от фактического определения функции, на которую они
ссылаются.
Следующая часть нашего определения — это массив структур PyMethodDef,
определяющие методы (функции), которые будут доступны в нашем модуле.
Эта структура содержит четыре следующих поля:
char* ml_name — имя метода;
PyCFunction ml_meth — указатель на реализацию функции на C;
int ml_flags — включает в себя флаги, указывающие на соглашение о вызовах
или на связывающее соглашение. Последнее применимо только к определениям
методов класса;
char* ml_doc — указатель на содержание строки документации метода/функции.
Такой массив всегда должен заканчиваться контрольным значением {NULL,
NULL, 0, NULL}. Оно указывает на конец структуры. В нашем простом случае мы
создали массив static fibonacci_module_methods PyMethodDef[], который содержит
только два элемента (включая контрольное значение), следующим образом:
static PyMethodDef fibonacci_module_methods[] = {
{"fibonacci", (PyCFunction)fibonacci_py,
METH_VARARGS, fibonacci_docs},
{NULL, NULL, 0, NULL}
};
Покажем, как первый элемент отображается на структуру PyMethodDef:
ml_name = "fibonacci" — C-функция fibonacci_py() станет Python-функцией
с именем fibonacci;
ml_meth = (PyCFunction)fibonacci_py — переделка PyCFunction требуется
Python/C API и диктуется соглашением, определенным далее в ml_flags;
ml_flags = METH_VARARGS — флаг METH_VARARGS указывает на то, что соглашение
о вызове нашей функции принимает список переменных аргументов и не имеет
именованных аргументов;
ml_doc = fibonacci_docs — функция Python будет документирована контентом
строки fibonacci_docs.
Когда массив определений функций будет завершен, мы сможем создать еще
одну структуру, которая содержит определение всего модуля. Она описывается
282 Часть II
•
Ремесло Python
с помощью типа PyModuleDef и содержит несколько полей. Некоторые из них
полезны только для более сложных сценариев, требующих точного контроля
над процессом инициализации модуля. Здесь нас интересуют лишь первые пять
из них:
PyModuleDef_Base m_base — всегда должно быть инициализировано через
PyModuleDef_HEAD_INIT;
char* m_name — имя вновь созданного модуля; в нашем случае fibonacci;
char* m_doc — указатель на содержимое строки документации модуля. Обычно
у нас в одном исходном файле C определен всего один модуль, так что вполне
нормально встраивать нашу строку документации по всей структуре;
Py_ssize_t m_size — размер памяти, выделенной для поддержания состояния
модуля. Используется, только когда требуется поддержка нескольких субинтерпретаторов или многофазная инициализация. В большинстве случаев не требуется, и размер памяти имеет значение -1;
PyMethodDef * m_methods — указатель на массив, содержащий функции модульного уровня, описанные значениями PyMethodDef. Может иметь значение NULL,
если в модуле нет каких-либо функций. В нашем случае это fibonacci_module_
methods.
Остальные поля подробно описаны в официальной документации Python
(docs.python.org/3/c-api/module.html), но для нашего примера они не требуются.
Они должны иметь значения NULL, если не требуются, и будут неявно инициализированы этим значением при отсутствии заданных иных значений. Как следствие, наше описание модуля в переменной fibonacci_module_definition может
иметь простой вид:
static struct PyModuleDef fibonacci_module_definition = {
PyModuleDef_HEAD_INIT,
"fibonacci",
"Extension module that provides fibonacci sequence function",
-1,
fibonacci_module_methods
};
Вишенка на торте нашей работы — функция инициализации модуля. Она должна
именоваться с учетом весьма конкретных соответствующих правил, чтобы интерпретатор Python мог легко выбрать ее при загрузке динамической/общей библио
теки. Она должна называться PyInit_ , где — имя вашего модуля.
Это точно та же строка, которая была использована как поле m_base в определении
PyModuleDef и первый аргумент вызова setuptools.Extension(). Если вам не требуется сложный процесс инициализации модуля, то он принимает очень простую
форму, как и в нашем примере:
Макрос PyMODINIT_FUNC — это макрос препроцессора, который будет объявлять
тип возвращаемого значения данной функции инициализации как PyObject* и добавлять специальные декларации для связи, если того требует платформа.
В следующем пункте мы увидим, как можно вызывать и привязывать соглашения.
Вызов и привязка соглашений
Как мы говорили в предыдущем фрагменте текста, поле ml_flags структуры
PyMethodDef содержит флаги для вызова и привязки соглашений. Описание флагов
соглашения о вызове приведено ниже.
METH_VARARGS — типичное соглашение для функции или метода Python, который принимает в качестве параметров только аргументы. Тип в поле ml_meth
для такой функции должен быть PyCFunction. Функция будет снабжена двумя
аргументами типа PyObject*. Первый — это объект self (для методов) или
module (для функций модуля). Типичная сигнатура для функции C с таким соглашением — PyObject* function(PyObject* self, PyObject* args).
METH_KEYWORDS — соглашение для функции Python, которая при вызове принимает именованные аргументы. Ее соответствующий тип С — PyCFunc
tionWithKeywords. Функция C должна принимать три аргумента типа PyObject*:
self , args и словарь именованных аргументов. В сочетании с METH_VARARGS
первые два аргумента имеют такое же значение, как и для предыдущего вызова,
а в противном случае args будет иметь значение NULL. Типичная сигнатура функции C — PyObject* function(PyObject* self, PyObject* args, PyObject* keywds).
METH_NOARGS — соглашение для функций Python, которые не принимают другие
аргументы. Функция C должна быть типа PyCFunction, поэтому сигнатура такая
же, как и для соглашения METH_VARARGS (аргументы self и args). Единственное
отличие состоит в том, что args всегда имеет значение NULL, поэтому нет необходимости вызывать PyArg_ParseTuple(). Данный флаг не объединяется с любым
другим флагом.
METH_O — сокращение для функций и методов, принимающих в качестве аргументов одиночные объекты. Тип функции C снова PyCFunction, поэтому она
принимает два аргумента PyObject*: self и args. Ее отличие от METH_VARARGS
заключается в отсутствии необходимости вызывать PyArg_ParseTuple(), поскольку PyObject* в args уже является единственным аргументом, представленным в вызове Python к этой функции. Данный флаг не объединяется с любым
другим флагом.
284 Часть II
•
Ремесло Python
Функция, которая принимает ключевые слова, описывается либо с помощью
METH_KEYWORDS, либо побитовой комбинацией флагов вызовов в виде METH_VARARGS
| METH_KEYWORDS . Если да, то можно разбирать аргументы с помощью PyArg_
ParseTupleAndKeywords() вместо PyArg_ParseTuple() или PyArg_UnpackTuple().
Ниже приведен пример модуля с единственной функцией, возвращающей None
Парсинг аргументов в Python/C API очень эластичен и был подробно описан
в официальной документации (docs.python.org/3.7/c-api/arg.html). Аргумент формата
в PyArg_ParseTuple() и PyArg_ParseTupleAndKeywords() позволяет осуществлять
точный контроль над количеством аргументов и типами. Любое продвинутое со-
Глава 9.
Расширения Python на других языках 285
глашение о вызовах, известное из Python, может быть закодировано в C с помощью
API, включая следующие:
функции со значениями по умолчанию для аргументов;
функции с аргументами — только ключевыми словами;
функции с переменным количеством аргументов.
Флаги привязки соглашений METH_CLASS, METH_STATIC и METH_COEXIST зарезервированы для методов и не могут использоваться для описания функций модуля.
Первые два пункта вполне очевидны. Они являются двойниками декораторов
classmethod и staticmethod и изменяют значение аргумента self, переданного
в функцию C.
Флаг METH_COEXIST позволяет загружать метод в месте существующего определения. Это редко бывает полезно. В основном данный флаг используется в случаях,
когда нужно реализовать метод C, который будет генерироваться автоматически
из других особенностей уже определенного типа. В документации Python приведен пример обертки __contains__(), который будет генерироваться, если в типе
определен sq_contains. К сожалению, определение собственных классов и типов
с помощью Python/C API выходит за рамки этой вводной главы.
Далее рассмотрим обработку исключений.
Обработка исключений
C, в отличие от Python или даже C++, не имеет синтаксиса для выбрасывания
и перехвата исключений. Вся обработка ошибок обычно выполняется с помощью
возвращаемых функций и необязательного глобального состояния для хранения
информации, которая могла бы объяснить причину последнего сбоя.
Обработка исключений в Python/C API построена в соответствии с этим простым принципом. Существует глобальный индикатор последней произошедшей
ошибки. Он задается для того, чтобы описать причину проблемы. Существует также стандартный способ проинформировать вызывающий объект данной функции
о том, что это состояние было изменено во время вызова, например:
если функция должна возвращать указатель, то возвращает NULL;
если функция должна возвращать тип int, то возвращает -1.
Единственные исключения из предыдущих правил в Python/C API — это флаги
PyArg_*(), которые возвращают 1 в случае успеха и 0 в случае сбоя.
Чтобы посмотреть, как это работает на практике, вспомним нашу функцию
fibonacci_py(), упоминавшуюся ранее:
static PyObject* fibonacci_py(PyObject* self, PyObject* args) {
PyObject *result = NULL;
long n;
286 Часть II
•
Ремесло Python
if (PyArg_ParseTuple(args, "l", &n)) {
result = Py_BuildValue("L", fibonacci((unsigned int) n));
}
return result;
}
Строки, которые тем или иным образом участвуют в обработке ошибок, выделены жирным. Обработка ошибок начинается в самом начале нашей функции
с инициализации переменной result. Данная переменная должна хранить возвращаемое значение нашей функции. Она инициализируется как NULL, а мы уже знаем,
что это — индикатор ошибки. Обычно расширения так и используются — с предположением о том, что ошибки для нашего кода — это нормально.
Затем у нас будет вызов PyArg_ParseTuple(), который установит информацию
об ошибке на случай исключения и возврата 0. Это часть оператора if, и в таком
случае мы больше ничего не делаем и возвращаем NULL. Тот, кто вызывает нашу
функцию, получит уведомление об ошибке.
Py_BuildValue() также может выбрасывать исключения. Предполагается вернуть PyObject* (указатель), поэтому в случае отказа он дает NULL. Мы можем просто хранить его в качестве нашей переменной результата и передавать дальше как
возвращаемое значение.
Однако наша работа не заканчивается на обработке исключений, поднятых
вызовом Python/C API. Вполне вероятно, что вам нужно будет сообщить пользователю о том, какая именно ошибка произошла. В Python/C API есть несколько
функций, которые помогут вам выбросить исключение, но наиболее распространена PyErr_SetString(). Она устанавливает индикатор ошибки с заданным типом
исключения и с дополнительной строкой, представленной в качестве объяснения
причины ошибки.
Полная сигнатура этой функции выглядит следующим образом:
void PyErr_SetString(PyObject* type, const char* message)
Мы уже говорили о том, что в реализации нашей функции fibonacci_py() есть
серьезная ошибка. Сейчас самое время поговорить о ней и попытаться исправить
ее. К счастью, у нас уже есть необходимые для этого инструменты. Проблема заключается в небезопасном преобразовании long в unsigned int в следующих строках:
if (PyArg_ParseTuple(args, "l", &n)) {
result = Py_BuildValue("L", fibonacci((unsigned int) n));
}
Благодаря вызову PyArg_ParseTuple() первый и единственный аргумент будет
интерпретироваться как тип long (спецификатор "l") и хранится в локальной
переменной n. Затем он превращается в unsigned int, поэтому может возникнуть
проблема, если пользователь вызывает функцию fibonacci() из Python с отрицательным значением. Например, -1 в виде 32-разрядного целого числа со знаком бу-
Глава 9.
Расширения Python на других языках 287
дет интерпретироваться как 4294967295 при превращении в беззнаковое 32-битное
целое число. Такое значение приведет к очень глубокой рекурсии, переполнению
стека и ошибкам сегментации. Обратите внимание: то же самое может произойти, если пользователь задаст сколь угодно большой положительный аргумент.
Мы не можем исправить это без полной реконструкции функции fibonacci(),
но можем по крайней мере попытаться убедиться в том, что входной аргумент
функции удовлетворяет некоторым предварительным условиям. Здесь мы убеждаемся, что значение аргумента n больше или равно 0, и выбрасываем исключение
ValueError, если это не так:
static PyObject* fibonacci_py(PyObject* self, PyObject* args) {
PyObject *result = NULL;
long n;
long long fib;
if (PyArg_ParseTuple(args, "l", &n)) {
if (n> from fibonacci import fibonacci
>>> fibonacci(5)
5
>>> fibonacci(-1)
Traceback (most recent call last):
File "", line 1, in
File "fibonacci.pyx", line 21, in fibonacci.fibonacci (fibonacci.c:704)
OverflowError: can't convert negative value to unsigned int
>>> fibonacci(10 ** 10)
Traceback (most recent call last):
File "", line 1, in
File "fibonacci.pyx", line 21, in fibonacci.fibonacci (fibonacci.c:704)
OverflowError: value too large to convert to unsigned int
Мы уже знаем, что Cython компилирует только из кода в код, и сгенерированный код задействует тот же API Python/C, который мы будем использовать при
написании расширений C вручную. Следует отметить: fibonacci() — рекурсивная
функция, поэтому очень часто вызывает сама себя. Это будет означать, что, хоть
мы и объявили тип static входного аргумента, при рекурсивном вызове она будет
относиться к себе так же, как и к любой другой функции Python. Таким образом,
числа n-1 и n-2 будут упакованы обратно в объект Python и затем переданы на
скрытый слой внутренней реализации fibonacci(), который станет возвращать тип
unsigned int. Так будет происходить снова и снова, пока мы не достигнем предела
глубины рекурсии. Это не обязательно станет проблемой, но требует гораздо большего объема обработки аргументов, чем нужно на самом деле.
Мы можем снизить затраты на вызовы функций Python и обработку аргументов,
передавая больше работы чистой функции C, которая ничего не знает о структурах
Python. Мы делали это ранее при создании расширений C на чистом C и можем
повторить в Cython. Можно использовать ключевое слово cdef, чтобы объявить
функции в стиле С, принимающие и возвращающие только типы C:
cdef long long fibonacci_cc(unsigned int n):
if n < 2:
return n
else:
return fibonacci_cc(n - 1) + fibonacci_cc(n - 2)
Глава 9.
Расширения Python на других языках 295
def fibonacci(unsigned int n):
"""Возвращаем n-й элемент из последовательности чисел Фибоначчи,
вычисленный рекурсивно"""
return fibonacci_cc(n)
Можно пойти еще дальше. Пример на чистом C показывает, как выпустить GIL
во время вызова нашей чистой функции C, вследствие чего это расширение слегка
улучшилось для многопоточных приложений. В предыдущих примерах мы использовали макросы препроцессора Py_BEGIN_ALLOW_THREADS и Py_BEGIN_ALLOW_THREADS
из заголовков API Python/C, чтобы отметить раздел кода без вызовов Python.
Синтаксис Cython намного короче, и его легче запомнить. GIL можно выпустить
вокруг секции кода, задействуя простой оператор with nogil следующим образом:
def fibonacci(unsigned int n):
"""Возвращаем n-й элемент из последовательности чисел Фибоначчи,
вычисленный рекурсивно"""
with nogil:
result = fibonacci_cc(n)
return fibonacci_cc(n)
Можно также пометить всю функцию C как безопасную для вызова без GIL
следующим образом:
cdef long long fibonacci_cc(unsigned int n) nogil:
if n < 2:
return n
else:
return fibonacci_cc(n - 1) + fibonacci_cc(n - 2)
Важно знать, что такие функции не могут принимать в качестве аргументов
или возвращать объекты Python. Всякий раз, когда функция помечается как nogil,
необходимо выполнить любой вызов Python/C API, и он должен вернуть GIL с помощью оператора with gil.
В следующем разделе поговорим о проблемах с использованием расширений.
Проблемы с использованием расширений
Честно говоря, мы начали работу с Python только потому, что устали от сложности
написания программ на C и C++. На самом деле довольно часто программисты
начинают изучать Python, придя к пониманию того, что другие языки не позволяют получить то, что нужно их пользователям. Программирование на Python, по
сравнению с C, C++ или Java, — легкая прогулка по вечернему пляжу. Все кажется
простым и хорошо продуманным. Кажется, словно здесь все идеально и другие
языки программирования вообще больше не нужны.
296 Часть II
•
Ремесло Python
Но, конечно, думать так ошибочно. Да, Python — удивительный язык с большим
количеством интересных функций и используется во многих областях. Но это вовсе
не означает, что он идеален и не имеет никаких недостатков. Его легко понять и на
нем легко писать, но за легкость приходится платить. Он не настолько медленный,
как многие думают, но никогда не будет быстрым, как C. Он весьма портативен, но
его интерпретатор не так распространен в разных архитектурах и компиляторах, как
у других языков. И этот список можно продолжать очень долго.
Чтобы исправить данную проблему, были написаны расширения, тем самым
произведен перенос некоторых преимуществ старого доброго C обратно в Python.
И в большинстве случаев это работает хорошо. Но вопрос вот в чем — мы действительно хотим использовать Python, чтобы в конечном итоге писать расширения
на C? Ответ: нет. Это лишь неудобная необходимость в ситуациях, когда у нас
не хватает возможностей.
О других трудностях поговорим в следующем подразделе.
Дополнительная сложность
Не секрет, что разработка приложений на разных языках — непростая задача.
Python и C — совершенно разные технологии, и найти в них общие черты очень
трудно. Кроме того, не существует приложений, в которых не было бы ошибок.
Если расширения стали обычным явлением в вашей кодовой базе, то отладка превращается в сущий ад. Дело не только в том, что отладка кода C требует совершенно
иного рабочего процесса и инструментария, но и в предстоящей необходимости
часто переключать контекст между двумя различными языками.
Мы все люди, и наши когнитивные способности ограниченны. Разумеется,
есть люди, которые могут обрабатывать несколько уровней абстракции одинаково
эффективно, но это редкость. Независимо от вашего опыта и навыков, за сопровождение таких гибридных решений всегда придется чем-то платить. Платой могут
быть дополнительные трудозатраты и время, необходимое для переключения
между C и Python, или дополнительный стресс, который в конечном счете приведет
к снижению вашей эффективности.
Согласно индексу TIOBE, C — все еще один из самых популярных языков программирования. Несмотря на это, весьма часто программисты на Python знают его
очень мало или вообще не знают. Лично я считаю, что C должен стать общепринятым языком программирования, но мое мнение вряд ли что-то изменит в данном
вопросе. Python столь соблазнителен и легок в изучении, что многие программисты
забывают весь свой прошлый опыт и с радостью полностью переходят на новую технологию. Но программирование — не езда на велосипеде. Это умение очень быстро
забывается, если его забросить и не оттачивать. Даже программисты на C с большим
Глава 9.
Расширения Python на других языках 297
опытом рискуют утратить свои знания, слишком сильно погрузившись в Python.
Все это ведет к простому выводу о том, что труднее найти людей, которые будут
в состоянии понять и дополнить ваш код. Для пакетов с открытым исходным кодом
речь идет о меньшем количестве помощников. Для закрытого кода это означает,
что не все ваши товарищи по команде смогут корректно работать с вашим кодом.
В следующем подразделе поговорим об отладке.
Отладка
Когда дело доходит до ошибок, расширение ломается красиво и с треском. Статическая типизация дает много преимуществ по сравнению с Python и позволяет
обнаруживать на этапе компиляции такие проблемы, которые будет трудно заметить
в Python. Это может произойти даже без тщательного тестирования и полноценных испытаний. С другой стороны, управление памятью в таком случае организуется вручную, а плохое управление памятью — основная причина большинства
ошибок в C. В лучшем случае такие ошибки приведут к паре утечек памяти, которые приведут к поглощению всех ресурсов среды. Но решить данную проблему
сложно. Действительно трудно искать утечки памяти без использования надлежащих внешних инструментов, таких как Valgrind. В большинстве случаев проблемы управления памятью в коде расширения приведут к ошибкам сегментации,
которые в Python не исправляются и заставляют интерпретатор останавливать
работу, не выбрасывая исключение. То есть вам все равно придется вооружиться
дополнительными инструментами, которые большинству программистов Python
обычно не требуются. Это усложняет вашу среду разработки и рабочий процесс.
В следующем разделе рассматривается взаимодействие с динамическими библиотеками без использования расширений.
Взаимодействие с динамическими библиотеками
без расширений
Благодаря ctypes (модулю в стандартной библиотеке) или cffi (внешнему пакету) вы можете интегрировать все скомпилированные динамические/разделяемые
библиотеки в Python, независимо от того, на каком языке они были написаны.
Вы можете сделать это на чистом Python без какой-либо компиляции, что является
интересной альтернативой написанию собственных расширений в C.
Это не значит, что вам не нужно ничего знать о C. Оба решения требуют от вас
понимания C и принципов работы динамических библиотек. С другой стороны,
они снимают бремя борьбы с подсчетом ссылок на Python и значительно снижают
298 Часть II
•
Ремесло Python
риск болезненных ошибок. Кроме того, взаимодействие с кодом C через ctypes
или cffi более компактно, чем написание и компиляция модулей расширения C.
В следующем подразделе посмотрим на модуль ctypes.
Модуль ctypes
Модуль ctypes — самый популярный модуль для вызова функций из динамических или разделяемых библиотек без необходимости написания пользовательских
расширений на C. Причина тому очевидна. Он является частью стандартной библиотеки, поэтому всегда доступен и внешних зависимостей не требуется. Это библиотека Foreign Function Interface (FFI), которая предоставляет API-интерфейсы
для создания C-совместимых типов данных.
Далее рассмотрим загрузку библиотек.
Загрузка библиотек
Существует четыре типа загрузчиков динамических библиотек в ctypes и две
конвенции, регулирующие их использование. Классы, которые представляют
собой динамические и разделяемые библиотеки, — ctypes.CDLL, ctypes.PyDLL,
ctypes.OleDLL и ctypes.WinDLL. Последние две доступны только в Windows, поэтому здесь мы не будем обсуждать их подробно. Различия между CDLL и PyDLL
заключаются в следующем:
класс ctypes.CDLL представляет собой подгружаемые общие библиотеки. Функ-
ции в этих библиотеках используют стандартное соглашение о вызовах и возвращают int. Во время разговора выпускается GIL;
класс ctypes.PyDLL работает как CDLL, но GIL во время вызова не выпускается.
После выполнения проверяется флаг ошибки Python и выбрасывается исключение, если он установлен. Это полезно только в случае вызова загруженной
библиотекой функции непосредственно из Python/C API или использования
функций обратного вызова, которые могут быть кодом Python.
Чтобы загрузить библиотеку, вы можете создать экземпляр одного из предыдущих
классов с соответствующими аргументами или вызвать функцию LoadLibrary()
из подмодуля, связанного с конкретным классом:
ctypes.cdll.LoadLibrary() для ctypes.CDLL;
ctypes.pydll.LoadLibrary() для ctypes.PyDLL;
ctypes.windll.LoadLibrary() для ctypes.WinDLL;
ctypes.oledll.LoadLibrary() для ctypes.OleDLL.
Основная проблема при загрузке общих библиотек заключается в их портативном поиске. В различных системах используются разные суффиксы для раз-
Глава 9.
Расширения Python на других языках 299
деляемых библиотек (.dll на Windows, .dylib на macOS, .so на Linux) и поиск
выполняется в разных местах. Хуже всего здесь Windows, в которой нет предопределенной схемы именования для библиотек. Как следствие, мы не будем обсуждать
подробности загрузки библиотек с ctypes в данной системе и сконцентрируемся
в основном на Linux и macOS, в которых эта проблема решается последовательно
и похожим образом. Если вы заинтересованы в платформе Windows, то обратитесь
к официальной документации ctypes, в которой много информации о поддержке
данной системы (docs.python.org/3.5/library/ctypes.html).
Оба соглашения загрузки библиотек (функция LoadLibrary() и конкретные
классы типа библиотеки) требуют использования полного имени библиотеки.
Это означает, что все префиксы и суффиксы предопределенных библиотек должны
быть включены. Например, для загрузки стандартной библиотеки C на Linux вам
нужно написать следующее:
>>> import ctypes
>>> ctypes.cdll.LoadLibrary('libc.so.6')
Для macOS это выглядело бы так:
>>> import ctypes
>>> ctypes.cdll.LoadLibrary('libc.dylib')
К счастью, в подмодуле ctypes.util есть функция find_library(), которая позволяет загрузить библиотеку, используя ее имя без префиксов или суффиксов,
и будет работать в любой системе, имеющей определенную схему именования
разделяемых библиотек:
>>> import ctypes
>>> from ctypes.util import find_library
>>> ctypes.cdll.LoadLibrary(find_library('c'))
>>> ctypes.cdll.LoadLibrary(find_library('bz2'))
>>> ctypes.cdll.LoadLibrary(find_library('AGL'))
Так что, если вы пишете пакет ctypes, который должен работать как в macOS,
так и в Linux, то всегда используйте ctypes.util.find_library().
Вызов функции С с помощью ctypes описывается ниже.
Вызов функции С помощью ctypes
Когда динамическая/общая библиотека успешно загружена в объект Python, ее
обычно сохраняют в качестве переменной уровня модуля с тем же именем, что
и у загруженной библиотеки. Эти функции доступны в качестве атрибутов объекта,
300 Часть II
•
Ремесло Python
и вызываются они так же, как функции Python из любого другого импортируемого
модуля:
>>> import ctypes
>>> from ctypes.util import find_library
>>> libc = ctypes.cdll.LoadLibrary(find_library('c'))
>>> libc.printf(b"Hello world!\n")
Hello world!
13
К сожалению, все встроенные типы Python, кроме целых чисел, строк и байтов,
несовместимы с типами данных C и вследствие этого должны быть обернуты в соответствующие классы, имеющиеся в модуле ctypes. В табл. 9.1 приведен полный
список совместимых типов данных из документации ctypes.
Таблица 9.1. Полный список совместимых типов данных из документации ctypes
Тип ctypes
Тип C
Тип Python
c_bool
_Bool
BOOL
c_char
char
Односимвольный объект bytes
c_wchar
wchar_t
Односимвольная строка
c_byte
char
int
c_ubyte
unsigned char
int
c_short
short
int
c_ushort
unsigned int
int
c_int
int
int
c_uint
unsigned int
int
c_long
long
int
c_ulong
unsigned long
int
c_longlong
__int64 or long long
int
c_ulonglong
unsigned __int64 or unsigned long long
int
c_size_t
size_t
int
c_ssize_t
ssize_t или Py_ssize_t
int
c_float
float
float
c_double
double
float
c_longdouble
long double
float
c_char_p
char* (NUL terminated)
Объект Bytes или None
c_wchar_p
wchar_t* (NUL terminated)
string или None
c_void_p
void*
int или None
Глава 9.
Расширения Python на других языках 301
Как вы можете видеть, в таблице нет специальных типов, которые превращали бы коллекции Python в массивы C. Рекомендуемый способ создания типов для
массивов C — простое использование оператора умножения с требуемым типом
ctypes следующим образом:
>>> import ctypes
>>> IntArray5 = ctypes.c_int * 5
>>> c_int_array = IntArray5(1, 2, 3, 4, 5)
>>> FloatArray2 = ctypes.c_float * 2
>>> c_float_array = FloatArray2(0, 3.14)
>>> c_float_array[1]
3.140000104904175
Такой же синтаксис работает для каждого основного типа ctypes.
В следующем пункте посмотрим на то, как функции Python передаются в виде
обратных вызовов C.
Передача функций Python в виде обратных вызовов C
Очень популярный паттерн проектирования заключается в том, чтобы делегировать часть работы по реализации функции в пользовательские обратные вызовы.
Наиболее известная функция из стандартной библиотеки C, принимающая такие
обратные вызовы функции, — это qsort(), которая обеспечивает общую реализацию алгоритма быстрой сортировки. Весьма маловероятно, что вы захотите
использовать данный алгоритм вместо стандартного алгоритма TimSort, реализованного в интерпретаторе CPython, который больше подходит для сортировки коллекций Python. Однако qsort() кажется каноническим примером эффективного
алгоритма сортировки и API C, который использует механизм обратного вызова,
описанный во многих книгах по программированию. Поэтому мы будем стараться
использовать его в качестве примера прохождения функции Python в виде обратного вызова C.
Обычный тип функции Python несовместим с типом функции обратного вызова, требуемым спецификацией qsort(). Вот сигнатура qsort() со страницы
BSD man, которая также содержит тип принимаемого обратного вызова (аргумент
compar):
void qsort(void *base, size_t nel, size_t width,
int (*compar)(const void *, const void *));
Поэтому для выполнения qsort() из libc вам потребуется:
base — массив, который сортируется как указатель void*;
nel — число элементов как size_t;
width — размер одного элемента в массиве size_t;
302 Часть II
•
Ремесло Python
compar — указатель на функцию, которая должна возвращать int и принимает
два указателя void*. Он указывает на функцию, которая сравнивает размер двух
упорядоченных элементов.
Из пункта «Вызов функции С с помощью ctypes» мы уже знаем, как построить
массив C из другого типа ctypes, используя оператор умножения. nel должен быть
size_t и отображает int на Python, поэтому не требует никакой дополнительной
упаковки и может быть передан как len(itarable). Значение width может быть
получено с помощью функции ctypes.sizeof(), как только мы узнаем тип нашего массива base. Последнее, что нам нужно знать, — это как создать указатель на
функцию Python, совместимую с аргументом compar.
Модуль ctypes содержит функцию CFUNCTYPE(), которая позволяет обернуть
функцию Python и представить ее в виде вызываемого указателя функции С.
Первый аргумент — это возвращаемый тип C, который должна возвращать обернутая функция.
За ним следует список переменных типов C, который функция принимает в качестве аргументов. Тип функции, совместимый с аргументом compar qsort(), будет
выглядеть следующим образом:
CMPFUNC = ctypes.CFUNCTYPE(
# Возвращаемый тип
ctypes.c_int,
# Тип первого аргумента
ctypes.POINTER(ctypes.c_int),
# Тип второго аргумента
ctypes.POINTER(ctypes.c_int),
)
CFUNCTYPE() задействует соглашение о вызове cdecl, поэтому она совместима только с CDLL и PyDLL. Динамические библиотеки в операционной
системе Windows, которые загружаются с WinDLL или OleDLL, применяют
соглашение sdtcall. Это означает, что для обертки функции Python как
указателя C используется другой механизм. В ctypes это WINFUNCTYPE().
Чтобы обернуть все это, предположим, что хотим отсортировать случайный
список целых чисел с помощью функции QSort() из стандартной библиотеки C.
Вот пример скрипта, который показывает, как сделать это, используя наши новые
знания о ctypes:
from random import shuffle
import ctypes
from ctypes.util import find_library
libc = ctypes.cdll.LoadLibrary(find_library('c'))
Глава 9.
Расширения Python на других языках 303
CMPFUNC = ctypes.CFUNCTYPE(
# Возвращаемый тип
ctypes.c_int,
# Тип первого аргумента
ctypes.POINTER(ctypes.c_int),
# Тип второго аргумента
ctypes.POINTER(ctypes.c_int),
)
def ctypes_int_compare(a, b):
# Аргументы — указатели, поэтому нужен индекс [0]
print(" %s cmp %s" % (a[0], b[0]))
# По спецификации qsort должно возвращаться:
# * меньше нуля, если a < b,
# * ноль, если a == b,
# * больше нуля, если a > b.
return a[0] - b[0]
def main():
numbers = list(range(5))
shuffle(numbers)
print("shuffled: ", numbers)
# Создается новый тип — массив длиной,
# равной длине списка чисел
NumbersArray = ctypes.c_int * len(numbers)
# Создается новый массив С с помощью нового типа
c_array = NumbersArray(*numbers)
libc.qsort(
# Указатель на отсортированный массив
c_array,
# Длина массива
len(c_array),
# Размер элемента массива
ctypes.sizeof(ctypes.c_int),
# Обратный вызов (указатель на функцию сравнения C)
CMPFUNC(ctypes_int_compare)
)
print("sorted: ", list(c_array))
if __name__ == "__main__":
main()
Функция сравнения, которая предоставляется в виде обратного вызова, имеет
дополнительный оператор print, поэтому видно, как она выполняется в процессе
сортировки:
$ python ctypes_qsort.py
shuffled: [4, 3, 0, 1, 2]
Конечно, использование qsort в Python имеет мало смысла, поскольку в Python
есть свой собственный специализированный алгоритм сортировки. Но зато передача функции Python в виде обратного вызова C — очень полезный метод для
интеграции сторонних библиотек.
В следующем подразделе поговорим о CFFI.
CFFI
CFFI — это FFI для Python и интересная альтернатива ctypes. Она не является
частью стандартной библиотеки, но зато доступна на PyPI под именем cffi. Она
отличается от ctypes, поскольку тут делается больший акцент на повторном использовании простых деклараций C вместо предоставления обширного Python
API в одном модуле. Этот путь сложнее и имеет особенность, позволяющую автоматически компилировать некоторые части интеграционного слоя в расширения
с помощью компилятора С. Таким образом, CFFI можно использовать в качестве
гибридного решения, заполняющего пробел между простыми расширениями C
и ctypes.
Поскольку это очень большой проект, описать его в двух словах невозможно.
С другой стороны, было бы стыдно не уделить ему внимания. Мы уже обсудили
один пример интегрирования функции qsort() из стандартной библиотеки
с использованием ctypes . Таким образом, лучший способ показать основные
различия между этими двумя решениями — определить тот же пример с по
мощью cffi . Мы надеемся, следующий блок поможет вам. «Просто “Питон”
вместо тысячи слов»:
from random import shuffle
from cffi import FFI
ffi = FFI()
ffi.cdef("""
void qsort(void *base, size_t nel, size_t width,
int (*compar)(const void *, const void *));
Глава 9.
Расширения Python на других языках 305
""")
C = ffi.dlopen(None)
@ffi.callback("int(void*, void*)")
def cffi_int_compare(a, b):
# Сигнатура обратного вызова требует
# точного совпадения типов.
# Магии здесь меньше, чем в ctypes,
# но зато больше точности и требуется
# явное преобразование.
int_a = ffi.cast('int*', a)[0]
int_b = ffi.cast('int*', b)[0]
print(" %s cmp %s" % (int_a, int_b))
# По спецификации qsort должно возвращаться:
# * меньше нуля, если a < b,
# * ноль, если a == b,
# * больше нуля, если a > b
return int_a - int_b
def main():
numbers = list(range(5))
shuffle(numbers)
print("shuffled: ", numbers)
c_array = ffi.new("int[]", numbers)
C.qsort(
# Указатель на отсортированный массив
c_array,
# Длина массива
len(c_array),
# Размер элемента массива
ffi.sizeof('int'),
# Обратный вызов (указатель на функцию сравнения C)
cffi_int_compare,
)
print("sorted: ", list(c_array))
if __name__ == "__main__":
main()
Результат будет аналогичен тому, который был приведен выше, при обсуждении примера обратных вызовов C в ctypes. Использование CFFI для интеграции
qsort в Python не более осмысленно, чем применение ctypes для той же цели.
Во всяком случае, предыдущий пример должен показать основные различия
между ctypes и CFFI относительно обработки типов данных и функций обратного вызова.
306 Часть II
•
Ремесло Python
Резюме
В данной главе описан один из самых сложных вопросов, поднимаемых в нашей
книге. Мы обсудили причины создания расширений Python и привели примеры
соответствующих инструментов. Мы начали писать расширения на чистом C, которые зависят только от API Python/C, а затем переписали их на Cython, чтобы
показать, как правильный выбор инструмента упрощает работу.
Все же существуют причины делать нечто, набивая шишки, используя только
компилятор чистого С и заголовки Python.h. Во всяком случае, лучше всего применять такие инструменты, как Cython или Pyrex (о нем мы здесь не говорили),
поскольку это сделает ваш код более читабельным и удобным в сопровождении.
Это также избавит вас от большинства проблем, связанных с неосторожным подсчетом ссылок и распределением памяти.
Наше обсуждение расширений завершилось разговором о ctypes и CFFI, которые представляют собой альтернативные способы решения проблем интеграции
общих библиотек. Поскольку они не требуют написания пользовательских расширений для вызова функций из скомпилированных бинарных файлов, именно
эти инструменты лучше всего подходят для интеграции с закрытым кодом динамических/разделяемых библиотек, особенно если вам не нужно применять пользовательский код C.
В следующей главе мы слегка отдохнем от передовых методов программирования и углубимся в не менее важные темы — управление кодом и системы управления версиями.
Часть III
Качество,
а не количество
В этой части рассматриваются различные процессы разработки,
которые позволяют улучшить качество программного обеспечения
и оптимизацию разработки в целом. Вы узнаете, как именно работать с кодом в системах управления версиями, документировать
код и убедиться, что тестирование будет выполнено должным
образом.
10
Управление кодом
Довольно трудно работать над программным проектом, если им занимается несколько человек. Когда состав команды увеличивается, работа словно замедляется
и усложняется. Это происходит по многим причинам. В данной главе мы рассмотрим некоторые из них, а также поговорим о методах работы, направленных на
улучшение совместной разработки кода.
Любая кодовая база со временем эволюционирует, и очень важно отслеживать
все изменения, особенно если над ней трудятся много разработчиков. Для этого
нужна система управления версиями (система контроля версий).
Часто бывает, что несколько людей одновременно и параллельно дополняют
кодовую базу в ходе работы. Было бы легче, если бы они имели разные роли и части в проекте. Но так бывает редко. Подобное отсутствие глобальной картины порождает много путаницы в отношении того, что происходит и кто чем занимается.
Это неизбежно, ввиду чего нужно использовать инструменты для непрерывного
улучшения видимости и смягчения проблем. Это делается путем создания ряда
инструментов для непрерывной разработки, таких как непрерывная интеграция
или непрерывная доставка.
В этой главе:
работа с системой управления версиями;
настройка процесса непрерывной разработки.
Технические требования
Скачать последнюю версию для этой главы можно по ссылке git-scm.com.
Работа с системой управления версиями
Системы управления версиями (version control systems, VCS) предоставляют возможность общего доступа, синхронизации и резервного копирования файлов лю-
Глава 10.
Управление кодом 309
бого типа, но в основном работают с текстовыми файлами, содержащими исходный
код. Эти системы подразделяются на следующие два семейства:
централизованные системы;
распределенные системы.
В следующих подразделах рассмотрим эти семейства.
Централизованные системы
Централизованная система контроля версий — это один сервер с файлами, который
позволяет пользователям вносить свои и видеть чужие изменения, внесенные в эти
файлы. Принцип довольно прост — каждый может скопировать нужные файлы
на свой компьютер и работать над ними. После этого каждый пользователь может
внести на сервер изменения. Они будут применены, после чего генерируется номер
версии. Другие пользователи смогут получить измененные файлы путем синхронизации их копий проектов через механизм обновления.
Как показано на схеме ниже (рис. 10.1), репозиторий обновляется с каждой посылкой, а система архивирует все изменения в базу данных, позволяя откатить любые изменения, а также предоставить информацию о том, что и кем было сделано.
Рис. 10.1. Схема репозитория 1
Каждый пользователь в такой централизованной конфигурации должен синхронизировать свой локальный репозиторий с основным, чтобы своевременно
310 Часть III
•
Качество, а не количество
получить изменения других пользователей. Это означает потенциальное возникновение конфликтов, когда измененный вами файл был изменен кем-то еще.
Тогда подключается механизм разрешения конфликтов, в данном случае в системе
пользователя, как показано на следующей схеме (рис. 10.2).
Рис. 10.2. Схема репозитория 2
Следующие шаги помогут вам лучше понять этот процесс:
Джо вносит изменения;
Памела тоже пытается изменить тот же файл;
сервер говорит, что ее копия файла уже устарела;
Памела обновляет свою локальную копию. Программное обеспечение контроля
версий бесшовно объединяет (если получится) эти две версии;
Памела отсылает новую версию, в которой есть последние изменения, сделан-
ные и Джо, и ею самой.
Такой процесс прекрасно функционирует в проектах, над которыми трудятся
несколько разработчиков и где используется небольшое количество файлов, но для
более крупных проектов дело становится труднее. Например, сложные изменения
часто затрагивают много файлов, а это отнимает много времени, и хранить у себя
локальную копию всего проекта становится просто невозможно. Ниже приведены
некоторые проблемы описанного выше подхода:
Глава 10.
Управление кодом 311
пользователь может долго работать только в своей локальной копии безнад-
лежащего резервного копирования;
тяжело делиться с другими своей работой, пока она не отправлена в систему,
а делать это без проверки означает поставить под угрозу нормальную деятельность хранилища.
В централизованной VCS можно решить эту проблему с помощью ответвлений
и слияний. От основного потока изменений могут отделяться ветви, которые затем
снова сливаются в основной поток.
На следующей схеме (рис. 10.3) Джо начинает новую ветвь от версии 2, поскольку планирует поработать над новой функцией. Изменения подсчитываются
в основном потоке и в отделенной ветви. Дойдя до версии 7, Джо закончил работу
и внес свои изменения в ствол (основную ветвь). В этом случае часто требуется
разрешение конфликтов.
Рис. 10.3. Схема репозитория 3
Однако, несмотря на все преимущества, централизованная система VCS имеет
следующие недостатки.
312 Часть III
•
Качество, а не количество
Ветвление и слияние довольно трудно организовать. Это сложная система,
которая может превратиться в кошмар.
Поскольку система централизована, невозможно зафиксировать изменения
в автономном режиме. В какой-то момент накопится большой и сложный пакет
изменений.
И наконец, подобная система не очень хорошо работает для таких проектов,
как Linux, где у компаний есть собственный филиал программного обеспечения и нет центрального хранилища, в котором у каждого была бы учетная
запись.
Что касается последнего, некоторые инструменты, такие как SVK, позволяют
функционировать в автономном режиме, но более фундаментальная проблема заключается в самом принципе работы централизованной VCS.
Несмотря на эти ловушки, централизованные VCS по-прежнему весьма
популярны у многих компаний, в основном за счет высокой инертности корпоративных сред. В качестве примеров централизованных VCS, используемых
многими организациями, можно привести Subversion (SVN) и System Concurrent
Version (CVS). Очевидные проблемы централизованной архитектуры систем
управления версиями привели к тому, что большинство сообществ, работающих
с открытым кодом, перешли на более надежную распределенную архитектуру
VCS (DVCS).
Распределенные системы
Распределенные VCS призваны скомпенсировать недостатки централизованных
VCS. Они располагаются не на главном сервере, с которым работают люди, а на
равноправных (peer-to-peer) узлах. Каждый пользователь применяет собственный
независимый репозиторий для проекта и синхронизирует его с другими репозиториями, как показано на следующей схеме (рис. 10.4).
На этой схеме показан пример использования такой системы.
Билл выгружает файлы из хранилища HAL.
Билл вносит в эти файлы некоторые изменения.
Амина выгружает файлы из хранилища Билла.
Амина тоже вносит изменения.
Амина загружает изменения в HAL.
Кенни выгружает файлы из HAL.
Кенни вносит изменения.
Кенни регулярно отправляет изменения в HAL.
Глава 10.
Управление кодом 313
Рис. 10.4. Распределенная система
Главным здесь является принцип, по которому люди выгружают файлы в другие репозитории и загружают из них, и он меняется в зависимости от того, как
организованы работа людей и управление проектом. Поскольку никакого «главного» репозитория больше нет, менеджеру проекта необходимо определить стратегию
этой выгрузки и загрузки и внесения изменений.
Кроме того, работающие с несколькими репозиториями люди вынуждены больше думать. Во многих распределенных системах управления версиями номера версий определяются локально для каждого хранилища, и не существует глобальных
номеров, с которыми можно сверяться. Таким образом, нужно задействовать специальные теги, чтобы работа стала яснее. Теги — текстовые метки, которые могут быть
присоединены к версии. Наконец, пользователи сами отвечают за резервное копирование собственных репозиториев, в то время как в централизованной инфраструктуре разработкой стратегий резервного копирования занимается администратор.
В следующем подразделе поговорим о распределенных стратегиях.
Распределенные стратегии
Даже при использовании распределенной системы желательно иметь центральный
сервер, если вы работаете с другими людьми. Но его назначение будет совершенно
не тем, что у централизованных VCS. Этот сервер представляет собой хаб, который
314 Часть III
•
Качество, а не количество
позволяет всем разработчикам одновременно использовать свои изменения в одном месте, а не заниматься загрузкой и выгрузкой друг у друга. Такой единый
центральный репозиторий (часто называемый также вышестоящим) служит в качестве резервного для всех изменений, выполняемых в отдельных репозиториях
всех членов команды.
Для объединения доступа к коду с центральным репозиторием в DVCS используются различные подходы. Самый простой из них заключается в создании
сервера, который выполняет функцию обычного централизованного сервера,
и каждый участник проекта может вносить свои изменения в общий поток. Но такой подход несколько простоват и не позволяет получить максимум преимуществ
распределенной системы, поскольку работа в конечном итоге будет такой же, как
и с централизованной системой.
Другой подход заключается в создании на сервере нескольких репозиториев
с различными уровнями доступа:
нестабильный репозиторий — сюда каждый может вносить изменения;
стабильный репозиторий — открыт только для чтения для всех участников, за
исключением менеджеров. Они могут выгружать изменения из нестабильного
репозитория и решать, что нужно объединять;
релизный репозиторий — для релизов, только для чтения.
Такой подход позволяет участникам вносить свой вклад, а менеджерам — просматривать изменения, прежде чем вносить их в стабильный репозиторий. Однако,
в зависимости от используемых инструментов, подобный подход может оказаться
затратным. Во многих распределенных системах контроля версий эта проблема
также решается надлежащей стратегией ветвления.
Сравним централизованные и распределенные системы управления версиями.
Централизованность или распределенность
Забудьте о централизованных системах управления версиями. Поговорим начистоту: централизованные системы управления версиями — пережиток прошлого.
Сегодня, когда большинство из нас может работать полный рабочий день удаленно,
неразумно ограничиваться всеми недостатками централизованных VCS. Например,
при использовании CVS или SVN вы не можете отслеживать изменения в автономном режиме. Это глупо.
А как тогда поступить, если у вас временно пропало подключение к Интернету
или «упал» сам репозиторий? И что теперь, просто забыть о рабочем процессе
и не вносить изменения до тех пор, пока ситуация не изменится, а затем просто
отправить большой объем неструктурированных обновлений? Нет!
Глава 10.
Управление кодом 315
Кроме того, большинство централизованных систем контроля версий не поддерживают эффективных схем ветвления версий — а это очень полезный метод,
который позволяет снизить количество конфликтов при слиянии версий в проектах, где разработчики трудятся над одними и теми же частями проекта. Ветвление
в SVN организовано настолько глупо, что большинство разработчиков старается
любой ценой избегать его. Вместо этого в большинстве централизованных VCS есть
механизм блокировки файлов, который несет больше вреда, чем пользы.
Печальная правда о каждом инструменте управления версиями (да и о программном обеспечении в целом) заключается в том, что если в ней есть какаянибудь рискованная функция, то кто-то в вашей команде обязательно начнет использовать ее каждый день. Блокировка — как раз такая функция, которая в обмен
на снижение количества конфликтов слияния резко снизит производительность
всей вашей команды. Выбрав систему управления версиями, не позволяющую вести подобные рабочие процессы, вы создадите среду, которую ваши разработчики,
вероятно, будут использовать эффективно.
В следующем подразделе поговорим о распределенной системе управления
версиями Git.
По возможности используйте Git
Git — самая популярная распределенная система управления версиями. Она была
создана Линусом Торвальдсом с целью сохранения версий ядра Linux, когда разработчикам ядра потребовалось отказаться от патентованного программного обеспечения BitKeeper, которое они использовали ранее.
Если вы еще не пробовали никаких систем контроля версий, то стоит начать
с Git. В случае использования каких-либо других инструментов для управления
версиями с Git все равно стоит познакомиться, даже при условии, что ваша организация не желает переключаться на Git в ближайшем будущем. Иначе вы рискуете
стать живым ископаемым.
Однако мы не говорим, что Git — идеальная и лучшая в мире DVCS. Безусловно, она имеет свои недостатки. Прежде всего, это довольно сложный в использовании инструмент, особенно для новичков. Крутая кривая обучения Git уже стала
источником множества шуток в Интернете. Наверняка есть системы управления
версиями, которые для определенных проектов работали бы лучше, а полный
список конкурентов Git с открытым исходным кодом будет довольно длинным.
Во всяком случае, Git в настоящее время — самая популярная DVCS, поэтому сетевой эффект явно работает в ее пользу.
Если говорить кратко, то сетевой эффект означает, что совокупная выгода от использования популярных инструментов больше, чем от других, даже при наличии
определенно лучшей альтернативы. Это связано именно с высокой популярностью
316 Часть III
•
Качество, а не количество
инструмента (так в свое время VHS уничтожил Betamax). Весьма вероятно, что
кто-то в вашей организации, а также новые сотрудники будут иметь некий опыт
работы с Git, вследствие чего стоимость интеграции именно этой системы DVCS
будет ниже, чем в случае с менее популярной системой.
Ну и последний аргумент — всегда хорошо узнавать что-то еще и знакомство
с другими DVCS вам точно не повредит. Самые популярные конкуренты Git с открытым исходным кодом — это Mercurial, Bazaar и Fossil. Первая система особенно
хороша, поскольку написана на Python и была официальной системой управления
версиями в исходниках CPython. Существуют некоторые признаки того, что в ближайшем будущем они могут перейти на другую систему, поэтому разработчики
CPython, возможно, переходят на Git в ту самую секунду, пока вы читаете данную
книгу. Но на самом деле это не имеет значения. Обе системы достаточно круты.
Если бы не было Git или она была менее популярна, то мы бы определенно рекомендовали Mercurial. В ее устройстве есть своя особая красота. Она, безусловно,
не так эффективна, как Git, но ее намного проще освоить начинающим.
Теперь поговорим о работе с GitFlow и GitHub.
Рабочий процесс GitFlow и GitHub Flow
Очень популярная и стандартизированная методика работы с Git называется просто
GitFlow. Ниже приведено краткое описание основных правил рабочего процесса.
Существует основная рабочая ветвь, как правило называемая develop, где про-
исходит вся разработка последней версии приложения.
Новые функции проекта реализуются в отдельных ветвях, называемых функциональными, которые всегда начинаются от ветви develop. Когда работа по
функции будет завершена и код надлежащим образом проверен, новая ветвь
объединяется с develop.
Когда код в develop стабилизируется (без известных ошибок) и возникает по-
требность в новой версии приложения, создается новая релиз-ветвь. Для этой
ветви обычно делаются дополнительные тесты (обширные испытания по оценке
качества, интеграционные тесты и т. д.), так что, безусловно, вы найдете новые
ошибки. Если потребуются дополнительные изменения (например, исправление
ошибок), то они в конечном счете должны быть перенесены и в develop.
Когда код в релиз-ветви готов к выпуску, она объединяется с ветвью master
и последняя посылка в master помечается соответствующим тегом версии.
Никакие другие ветви, кроме функций, не могут быть объединены с master.
Исключение составляют лишь хотфиксы, которые необходимо развернуть или
выпустить немедленно.
Хотфиксы, которые требуют срочного выпуска, всегда реализуются на отдельных ветвях, отходящих от master. Когда исправление сделано, оно объединяется
Глава 10.
Управление кодом 317
с ветвями develop и master. Слияние ветви хотфиксов выполняется так, как
если бы это была обычная релиз-ветвь, поэтому она должна быть корректно
промаркирована и получить тег версии.
Визуальная схема рабочего процесса GitFlow представлена ниже (рис. 10.5).
Тем, кто никогда не работал таким образом или никогда не использовал распределенную систему управления версиями, она может показаться сложноватой.
Во всяком случае, вам стоит попробовать ее в вашей организации, если у вас нет
какого-либо формализованного процесса. Такая система имеет множество преимуществ и позволяет решать серьезные проблемы. Это особенно полезно для команд,
в которых несколько программистов работают над несколькими отдельными функциями и когда требуется постоянная поддержка нескольких релизов.
Рис. 10.5. Рабочий процесс GitFlow
318 Часть III
•
Качество, а не количество
Данная методика также удобна, если вы хотите осуществить непрерывную доставку с помощью непрерывных процессов развертывания, поскольку становится
ясно, какая версия кода представляет собой доставляемый релиз вашего приложения или сервиса. Кроме того, это отличный инструмент для проектов с открытым
исходным кодом, поскольку обеспечивает большую прозрачность для пользователей и активных участников.
Итак, если это краткое описание GitFlow вас не напугало и хоть что-то понятно,
то следует копнуть глубже в онлайн-ресурсах по данной теме. Трудно сказать, кто
автор описанного выше рабочего процесса, но большинство интернет-источников
указывает на Винсента Дрессена. Таким образом, почитать о GitFlow лучше всего
в его статье под названием A successful Git branching model (nvie.com/posts/a-successfulgit-branching-model/).
Как и любая другая популярная методика, GitFlow получила много критики
в Интернете от программистов, которым не понравилась. Больше всего комментариев получило правило (строго техническое) о том, что при каждом слиянии
должна создаваться новая искусственная посылка, содержащая результат с