Новый Python: 7 возможностей, которые вам понравятся

Говорят, что новый Python 3.7 стал намного быстрее и удобнее. Мы решили убедиться в этом и детально разобрали 7 важных изменений.

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

Упрощенное создание классов

В Python 3.7 появился новый модуль dataclasses и декоратор @dataclass, облегчающий создание пользовательских классов. Он автоматически добавляет специальные методы вроде __init__, __repr__ и __eq__.

from dataclasses import dataclass, field

@dataclass(order=True)
class Country:
    name: str
    population: int
    area: float = field(repr=False, compare=False)
    coastline: float = 0

    def beach_per_person(self):
        """Meters of coastline per person"""
        return (self.coastline * 1000) / self.population

Вот он – пример отличного шаблона класса в лучших практиках кодинга. Только вспомните, как пришлось бы создавать Country раньше. Помимо beach_per_person нужно было описать методы инициализации, вывода и целых 6 методов для сравнения. Просто посмотрите на это:

[spoiler title='Класс Country в привычном виде']

class Country:

    def __init__(self, name, population, area, coastline=0):
        self.name = name
        self.population = population
        self.area = area
        self.coastline = coastline

    def __repr__(self):
        return (
            f"Country(name={self.name!r}, population={self.population!r},"
            f" coastline={self.coastline!r})"
        )

    def __eq__(self, other):
        if other.__class__ is self.__class__:
            return (
                (self.name, self.population, self.coastline)
                == (other.name, other.population, other.coastline)
            )
        return NotImplemented

    def __ne__(self, other):
        if other.__class__ is self.__class__:
            return (
                (self.name, self.population, self.coastline)
                != (other.name, other.population, other.coastline)
            )
        return NotImplemented

    def __lt__(self, other):
        if other.__class__ is self.__class__:
            return ((self.name, self.population, self.coastline) < (
                other.name, other.population, other.coastline
            ))
        return NotImplemented

    def __le__(self, other):
        if other.__class__ is self.__class__:
            return ((self.name, self.population, self.coastline) <= (
                other.name, other.population, other.coastline
            ))
        return NotImplemented

    def __gt__(self, other):
        if other.__class__ is self.__class__:
            return ((self.name, self.population, self.coastline) > (
                other.name, other.population, other.coastline
            ))
        return NotImplemented

    def __ge__(self, other):
        if other.__class__ is self.__class__:
            return ((self.name, self.population, self.coastline) >= (
                other.name, other.population, other.coastline
            ))
        return NotImplemented

    def beach_per_person(self):
        """Meters of coastline per person"""
        return (self.coastline * 1000) / self.population

[/spoiler]

Новый Python берет эту тяжелую работу на себя.

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

>>> norway = Country("Norway", 5320045, 323802, 58133)
>>> norway
Country(name='Norway', population=5320045, coastline=58133)

>>> norway.area
323802

>>> usa = Country("United States", 326625791, 9833517, 19924)
>>> nepal = Country("Nepal", 29384297, 147181)
>>> nepal
Country(name='Nepal', population=29384297, coastline=0)

>>> usa.beach_per_person()
0.06099946957342386

>>> norway.beach_per_person()
10.927163210085629

Поля name, population, area и coastline устанавливаются при инициализации класса. При этом длина береговой линии – это необязательный параметр. Закрытый со всех сторон Непал, например, его не использует.

По умолчанию, классы данных можно сравнивать, а если в декораторе установлено свойство order, равное True, то еще и сортировать.

>>> norway == norway
True

>>> nepal == usa
False

>>> sorted((norway, usa, nepal))
[Country(name='Nepal', population=29384297, coastline=0),
 Country(name='Norway', population=5320045, coastline=58133),
 Country(name='United States', population=326625791, coastline=19924)]

Сортировка идет поочередно по значениям полей, начиная с name. Существует возможность настроить с помощью функции field, какие конкретно поля должны в этом участвовать. Например, свойство area не учитывается ни в выводе, ни в сравнении.

Классы данных очень похожи на namedtuple. Также их создатели черпали вдохновение в проекте attrs. Больше информации о новом модуле вы можете найти в PEP 557.

Атрибуты модулей

Атрибуты в Python везде, на них основана такая базовая функциональность языка, как интроспекция, документирование кода и пространства имен.

Обратиться к атрибуту можно с помощью оператора точка thing.attribute. Для получения атрибутов, созданных в процессе выполнения программы, используется функция getattr:

import random

random_attr = random.choice(("gammavariate", "lognormvariate", "normalvariate"))
random_func = getattr(random, random_attr)

print(f"A {random_attr} random value: {random_func(1, 1)}")

Этот код выведет нечто подобное:

A gammavariate random value: 2.8017715125270618

Если вы вызываете thing.attribute для классов, то первым делом интерпретатор проверяет, определен ли такой атрибут у thing. Если нет, то он передает управление методу thing.__getattr__("attr"). Это упрощенное описание, узнать подробнее вы можете здесь. Таким образом, с помощью __getattr__ можно контролировать доступ к атрибутам объекта.

До появления версии 3.7 осуществить подобное с модулями было непросто. Однако новый Python добавил в них __getattr__ и __dir__ – специальный метод для настройки вызова dir.

PEP 562 демонстрирует несколько примеров использования этих функций. В частности, с их помощью можно реализовать оповещения об устаревании или медленной загрузке. А мы напишем несложную систему плагинов для динамического добавления функций в модуль. Вам потребуется общее представление о пакетах в Python. Чтобы освежить его, загляните сюда.

Создайте папку с именем plugins, а в ней – __init__.py:

from importlib import import_module
from importlib import resources

PLUGINS = dict()

def register_plugin(func):
    """Декоратор для регистрации плагинов"""
    name = func.__name__
    PLUGINS[name] = func
    return func

def __getattr__(name):
    """Возвращает плагин по его имени"""
    try:
        return PLUGINS[name]
    except KeyError:
        _import_plugins()
        if name in PLUGINS:
            return PLUGINS[name]
        else:
            raise AttributeError(
                f"module {__name__!r} has no attribute {name!r}"
            ) from None

def __dir__():
    """Список доступных плагинов"""
    _import_plugins()
    return list(PLUGINS.keys())

def _import_plugins():
    """Импортирует все ресурсы для регистрации плагинов"""
    for name in resources.contents(__name__):
        if name.endswith(".py"):
            import_module(f"{__name__}.{name[:-3]}")

Разместите код плагинов в файлах plugin_1.py и plugin_2.py:

from . import register_plugin

@register_plugin
def hello_1():
    print("Hello from Plugin 1")
from . import register_plugin

@register_plugin
def hello_2():
    print("Hello from Plugin 2")

@register_plugin
def goodbye():
    print("Plugin 2 says goodbye")

Теперь запустите программу:

>>> import plugins
>>> plugins.hello_1()
Hello from Plugin 1

>>> dir(plugins)
['goodbye', 'hello_1', 'hello_2']

>>> plugins.goodbye()
Plugin 2 says goodbye

Здесь происходит что-то удивительное. Чтобы вызвать plugins.hello_1, мы должны были явно импортировать его в __init__.py, разве не так? Теперь нет!

Новые возможности модулей

Мы просто определяем hello_1 в любом файле пакета, и он самостоятельно регистрируется благодаря декоратору.

Есть разница, правда? Эта простая структура позволяет добавлять функциональность без привязки к остальному коду и централизованного объявления доступности.

Итак, что же делает __getattr__ внутри __init__.py? При вызове plugins.hello_1 интерпретатор сначала проверяет, объявлена ли такая функция и, естественно, не находит ее. Тогда он обращается к методу __getattr__("hello_1"). У нас он выглядит вот так:

def __getattr__(name):
    """Возвращает плагин по имени"""
    try:
        return PLUGINS[name]        # 1) Пытается вернуть плагин
    except KeyError:
        _import_plugins()           # 2) Импортирует все плагины
        if name in PLUGINS:
            return PLUGINS[name]    # 3) Снова пытается вернуть плагин
        else:
            raise AttributeError(   # 4) Выбрасывает ошибку
                f"module {__name__!r} has no attribute {name!r}"
            ) from None

Что делает этот метод?

  1. Проверяет существование свойства с именем "hello_1" в словаре PLUGINS. Но на данный момент его там нет.
  2. Из-за неудачи переходит в секцию except и импортирует плагины.
  3. После этого у PLUGINS должно появиться нужное свойство, которое можно вернуть вызывающему коду.
  4. Если что-то пошло не так, и после импорта нужный плагин не обнаружился, функция выбросит ошибку AttributeError.

По-прежнему непонятно, каким образом плагины попадают в словарь, ведь _import_plugins просто загружает файлы, но не изменяет PLUGINS:

def _import_plugins():
    """Импортирует все ресурсы для регистрации плагинов"""
    for name in resources.contents(__name__):
        if name.endswith(".py"):
            import_module(f"{__name__}.{name[:-3]}")

Вспомните, что каждый плагин декорирован с помощью @register_plugin. При импорте он и записывает свое имя в в словарь. Загрузите один файл самостоятельно, чтобы убедиться в этом:

>>> import plugins
>>> plugins.PLUGINS
{}

>>> import plugins.plugin_1
>>> plugins.PLUGINS
{'hello_1': <function hello_1 at 0x7f29d4341598>}

Теперь вызовем dir и увидим, что импортируются оставшиеся плагины.

>>> dir(plugins)
['goodbye', 'hello_1', 'hello_2']

>>> plugins.PLUGINS
{'hello_1': <function hello_1 at 0x7f29d4341598>,
 'hello_2': <function hello_2 at 0x7f29d4341620>,
 'goodbye': <function goodbye at 0x7f29d43416a8>}

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

>>> import plugins
>>> dir(plugins)
['PLUGINS', '__builtins__', '__cached__', '__doc__',
 '__file__', '__getattr__', '__loader__', '__name__',
 '__package__', '__path__', '__spec__', '_import_plugins',
 'import_module', 'register_plugin', 'resources']

Конечно, это довольно полезно, но хотелось бы просто увидеть доступные плагины. Новый Python позволяет настроить и это. Специальная функция __dir__ изменяет результаты вызова dir. В примере она сначала проверяет, импортированы ли все плагины, а затем выводит список их имен.

def __dir__():
    """Список доступных плагинов"""
    _import_plugins()
    return list(PLUGINS.keys())

Здесь используется еще одно нововведение Python 3.7: модуль importlib.resources. С его помощью мы загрузили модули из папки plugins без обращения к __file__ или pkg_resources.

Наносекундная точность

Python 3.7 добавил несколько новых возможностей в модуль time, которые были описаны в PEP 564, например:

  • clock_gettime_ns() - получение времени указанных часов;
  • clock_settime_ns() - установка времени указанных часов;
  • monotonic_ns() - получение времени относительных часов без отката (например, из-за летнего времени);
  • perf_counter_ns() - получение значения счетчика выполнения, предназначенного для измерения коротких промежутков;
  • process_time_ns() - получение суммы системного времени для конкретного процесса (без учета спящего режима);
  • time_ns() - получение количества наносекунд с 01.01.1970.

Похоже, никакой новой функциональности тут нет. Каждый метод уже имеет свой аналог, лишь добавился суффикс _ns. Разница заключается в том, что эти новые функции возвращают значение с типом int в наносекундах вместо количества секунд с типом float.

Большинство приложений не заметит эту разницу. Однако в целом работа со временем становится понятнее и проще. Дело в том, что тип float по определению неточный, в отличие от int:

>>> 0.1 + 0.1 + 0.1
0.30000000000000004

>>> 0.1 + 0.1 + 0.1 == 0.3
False

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

Для типа float в Python по стандарту используется 53 значащих байта. Поэтому любой период времени больше 104 дней (9 квадриллионов наносекунд) нельзя точно представить в типе float. А вот int в Python не имеет ограничений, поэтому в нем можно хранить любые значения с наносекундной точностью.

Например, time.time() возвращает прошедшее с начала UNIX-эпохи время в секундах – огромное число. Новая _ns версия функции работает почти в 3 раза быстрее старой.

Модуль datetime работает только с микросекундами:

>>> from datetime import datetime, timedelta
>>> datetime(2018, 6, 27) + timedelta(seconds=1e-6)
datetime.datetime(2018, 6, 27, 0, 0, 0, 1)

>>> datetime(2018, 6, 27) + timedelta(seconds=1e-9)
datetime.datetime(2018, 6, 27, 0, 0)

Поэтому, если вы желаете наносекундной точности, обратите внимание на проект astropy и его пакет astropy.time, который доступен в Python 3.5+.

>>> from astropy.time import Time, TimeDelta
>>> Time("2018-06-27")
<Time object: scale='utc' format='iso' value=2018-06-27 00:00:00.000>

>>> t = Time("2018-06-27") + TimeDelta(1e-9, format="sec")
>>> (t - Time("2018-06-27")).sec
9.976020010071807e-10

Официальное упорядочивание словарей

Python 3.7 на официальном уровне зафиксировал соответствие порядка перебора элементов словарей порядку их добавления.

Разве это новость? – спросите вы. В Python 3.6 словари уже были упорядочены, что видно на примере:

# Python <= 3.5
>>> {"one": 1, "two": 2, "three": 3}  
{'three': 3, 'one': 1, 'two': 2}


# Python >= 3.6
>>> {"one": 1, "two": 2, "three": 3}  
{'one': 1, 'two': 2, 'three': 3}

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

Признание async и await

Синтаксис async/await появился еще в Python 3.5, однако только сейчас эти слова официально стали ключевыми.

Теперь нельзя объявлять функции и переменные с такими именами.

>>> async = 1
  File "<stdin>", line 1
    async = 1
          ^
SyntaxError: invalid syntax

>>> def await():
  File "<stdin>", line 1
    def await():
            ^
SyntaxError: invalid syntax

Это не было сделано раньше в целях обеспечения обратной совместимости, но наконец время пришло.

Переменные контекста

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

Создадим три контекста, каждый с собственным name:

import contextvars

name = contextvars.ContextVar("name")
contexts = list()

def greet():
    print(f"Hello {name.get()}")

# создание контекстов с разными значениями переменной name
for first_name in ["Steve", "Dina", "Harry"]:
    ctx = contextvars.copy_context()
    ctx.run(name.set, first_name)
    contexts.append(ctx)

# запуск функции в каждом контексте
for ctx in reversed(contexts):
    ctx.run(greet)

Этот скрипт поприветствует в обратном порядке Стива, Дину и Гарри:

$ python3.7 context_demo.py
Hello Harry
Hello Dina
Hello Steve

Новый уровень asyncio

Модуль asyncio предназначен для асинхронного выполнения кода. Он был добавлен в Python 3.4. Если вы до сих пор с ним не знакомы, обратите внимание на это руководство.

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

import asyncio

async def hello_world():
    print("Hello World!")

asyncio.run(hello_world())

В целом модуль стал работать быстрее и эффективнее.

Переход на новый Python

Конечно, вам уже не терпится попробовать все новые возможности языка. Установите Python 3.7 и смело экспериментируйте! Вы даже можете использовать разные версии интерпретатора параллельно с помощью pyenv или Anaconda.

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

Большинство обновок Python 3.7, например, dataclasses, имеют бэкпорты в более старых версиях языка или менее удобные аналоги. Но использование некоторых возможностей крепко привяжет ваш код к новому стандарту. Среди них наносекундный тайминг и атрибуты модулей.

Здесь вы найдете несколько рекомендаций по обновлению.

Перевод статьи Cool New Features in Python 3.7.

Еще больше о Python

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

admin
11 декабря 2018

ООП на Python: концепции, принципы и примеры реализации

Программирование на Python допускает различные методологии, но в его основе...
admin
28 июня 2018

3 самых важных сферы применения Python: возможности языка

Существует множество областей применения Python, но в некоторых он особенно...
admin
13 февраля 2017

Программирование на Python: от новичка до профессионала

Пошаговая инструкция для всех, кто хочет изучить программирование на Python...