[Перевод] Fastcore — недооцененная но полезная библиотека Python
Недавно я начал оттачивать владение языком программирования Python. Я хотел изучить продвинутые паттерны, идиомы и методы программирования. Начал я с чтения книг по продвинутому Python, но информация, похоже, не откладывалась в голове без применения навыков. Хотелось иметь возможность задавать вопросы эксперту, пока учусь, а такую возможность трудно найти! Тогда ко мне и пришла идея: что, если я найду проект с открытым и достаточно продвинутым кодом и напишу документацию и тесты? Я сделал ставку, что это заставит меня изучать все очень глубоко, а поддерживающие проект люди оценит мою работу и будут готовы ответить на мои вопросы.
Предыстория
Поиском такого проекта и написанием документации с тестами к нему я занимался целый месяц и такое обучение было самым эффективным из всех, что я пробовал. Я обнаружил, что написание документации заставило меня глубоко понять не только то, что делает код, но и то, почему код работает именно так, как он работает, а также исследовать крайние случаи во время написания тестов. Самое главное, что я мог задавать вопросы, когда застрял, а люди были готовы посвятить мне дополнительное время, зная, что их разъяснения служило тому, чтобы сделать код доступнее! Оказывается, выбранная мной библиотека, fastcore — одна из самых увлекательных в Python, с которыми я работал. Ее цели и задачи действительно уникальны.
fastcore — это основа многих проектов fast.ai. Самое главное: fastcore расширяет Python, стремясь к устранению шаблонного кода и добавлению полезной функциональности для общих задач. В этом посте я выделю некоторые из моих любимых инструментов fastcore вместо того, чтобы делиться тем, что я узнал о языке. Моя задача — вызвать интерес к этой библиотеке, и, надеюсь, мотивировать вас к ознакомлению с документацией после прочтения статьи.
Чем интересна fastcore?
- Ознакомление с идеями из других языков прямо в Python: Я постоянно слышу, что полезно изучать другие языки, чтобы стать лучшим программистом. Мне было трудно изучать другие языки с практической точки зрения, потому что я не мог применять их на работе. Fastcore расширяет Python, чтобы включить в него паттерны из разных языков: Julia, Ruby и Haskell. Теперь, когда я понимаю эти инструменты, у меня появилась мотивация изучать другие языки.
- Новый набор прагматичных инструментов: fastcore включает в себя утилиты, позволяющие писать более лаконичный выразительный код и, возможно, решать новые задачи.
- Изучение Python: fastcore расширяет Python, в этом процессе проявляются многие продвинутые понятия. Для мотивированных людей это прекрасный способ увидеть многое о внутренней работе языка.
Пройдемся по fastcore ураганом
Вот некоторые привлекшие мое внимание вещи, которые возможно сделать с помощью fastcore.
Делаем kwargs
прозрачными
Я немного поеживаюсь каждый раз, когда вижу функцию с аргументом kwargs
. Это потому, что kwargs означает обфускацию API. Мне нужно прочитать исходный код, чтобы понять, какие параметры допустимы. Посмотрим на пример ниже:
def baz(a, b=2, c =3, d=4): return a + b + c
def foo(c, a, **kwargs):
return c + baz(a, **kwargs)
inspect.signature(foo)
Без чтения исходного кода может быть трудно узнать, что foo
также принимает дополнительные параметрыb
и d
. Это можно исправить с помощью delegates
:
def baz(a, b=2, c =3, d=4): return a + b + c
@delegates(baz) # this decorator will pass down keyword arguments from baz
def foo(c, a, **kwargs):
return c + baz(a, **kwargs)
inspect.signature(foo)
Поведение этого декоратора настраивается. Например, вы можете передать аргументы и сохранить при этом kwargs
:
@delegates(baz, keep=True)
def foo(c, a, **kwargs):
return c + baz(a, **kwargs)
inspect.signature(foo)
Аргументы можно исключать. Например, ниже мы исключаем из делегирования аргумент d
:
def basefoo(a, b=2, c =3, d=4): pass
@delegates(basefoo, but= ['d']) # exclude `d`
def foo(c, a, **kwargs): pass
inspect.signature(foo)
Возможно делегирование между классами:
class BaseFoo:
def __init__(self, e, c=2): pass
@delegates()# since no argument was passsed here we delegate to the superclass
class Foo(BaseFoo):
def __init__(self, a, b=1, **kwargs): super().__init__(**kwargs)
inspect.signature(Foo)
Для получения дополнительной информации прочтите документацию о delegates
.
Избегаем шаблонного кода при установке атрибутов экземпляра
Вы когда-нибудь задумывались, можно ли избежать шаблонного кода, связанного с установкой атрибутов в __init__
?
class Test:
def __init__(self, a, b ,c):
self.a, self.b, self.c = a, b, c
Ой! Это было больно. Посмотрите на все эти повторяющиеся имена переменных. Неужели действительно нужно повторять всё это при определении класса? Уже нет! Посмотрите на store_attr:
class Test:
def __init__(self, a, b, c):
store_attr()
t = Test(5,4,3)
assert t.b == 4
Вы также можете исключить определенные атрибуты:
class Test:
def __init__(self, a, b, c):
store_attr(but=['c'])
t = Test(5,4,3)
assert t.b == 4
assert not hasattr(t, 'c')
Есть гораздо больше способов настройки и использования store_attr
, чем я показываю. Ознакомьтесь с документацией для получения более подробной информации.
P.S. Вы можете подумать, что классы данных тоже позволяют избежать такого шаблонного кода. Хотя в некоторых случаях это верно, store_attr
более гибок. 1
Например, store_attr
не полагается на наследование, а это значит, что вы не застрянете, используя множественное наследование, когда применяете его со своими собственными классами. Кроме того, в отличие от классов данных, store_attr
не требует python 3.7 или выше. Вы можете использовать store_attr
в любой момент в жизненном цикле объекта и в любом месте вашего класса, чтобы настроить поведение в том, как и когда хранятся переменные. Подробности здесь.
Избегаем бойлерплейта подклассов
Одна вещь, которую я ненавижу в python — связанный с подклассами шаблонный код __super__ ().__init__ ()
. Например:
class ParentClass:
def __init__(self): self.some_attr = 'hello'
class ChildClass(ParentClass):
def __init__(self):
super().__init__()
cc = ChildClass()
assert cc.some_attr == 'hello' # only accessible b/c you used super
Мы можем избежать такого кода, используя метакласс PrePostInitMeta. Как? Определив новый класс под названием NewParent
— обертку вокруг ParentClass
:
class NewParent(ParentClass, metaclass=PrePostInitMeta):
def __pre_init__(self, *args, **kwargs): super().__init__()
class ChildClass(NewParent):
def __init__(self):pass
sc = ChildClass()
assert sc.some_attr == 'hello'
Диспетчеризация типа
Диспетчеризация типа или множественная диспетчеризация позволяет изменить поведение функции в зависимости от типов получаемых входных данных. Это характерная особенность некоторых языков программирования, таких как Julia. Вот концептуальный пример того, как работает множественная диспетчеризация в Julia. В зависимости от типов входных данных x
и y
возвращаются разные значения:
collide_with(x::Asteroid, y::Asteroid) = ...
# deal with asteroid hitting asteroid
collide_with(x::Asteroid, y::Spaceship) = ...
# deal with asteroid hitting spaceship
collide_with(x::Spaceship, y::Asteroid) = ...
# deal with spaceship hitting asteroid
collide_with(x::Spaceship, y::Spaceship) = ...
# deal with spaceship hitting spaceship
Диспетчеризация типа может быть особенно полезна в Data Science, где возможно разрешить различные типы ввода (т.е. массивы numpy и фреймы данных Pandas) в обрабатывающей данные функции. Типовая диспетчеризация позволяет иметь общий API у выполняющих похожие задачи функций. К сожалению, Python не поддерживает такую функциональность из коробки. К счастью, у нас есть декоратор @typedispatch. Этот декоратор полагается на подсказки типа, чтобы маршрутизировать входные данные к правильной версии функции:
@typedispatch
def f(x:str, y:str): return f'{x}{y}'
@typedispatch
def f(x:np.ndarray): return x.sum()
@typedispatch
def f(x:int, y:int): return x+y
Ниже показывается диспетчеризации типа при работе для функции f
:
f('Hello ', 'World!')
'Hello World!'
f(2,3)
5
f(np.array([5,5,5,5]))
20
У этой функциональности есть ограничения (также как у других способов использования этой функции) и о них вы можете прочитать здесь. В процессе изучения типовой диспетчеризации я также нашел библиотеку Python под названием multipledispatch, написанную Mathhew Rocklin — создателем Dask.
После применения этой функции я теперь мотивирован изучать такие языки, как Julia, чтобы обнаружить, каких еще парадигм мне может не хватать.
Лучшая версия functools.partial
functools.partial
— отличная утилита, создающая функции из других функций и позволяющая устанавливать значения по умолчанию. Возьмем, к примеру, функцию, которая фильтрует список таким образом, чтобы он содержал значения больше либо равное val
:
test_input = [1,2,3,4,5,6]
def f(arr, val):
"Filter a list to remove any values that are less than val."
return [x for x in arr if x >= val]
f(test_input, 3)
[3, 4, 5, 6]
Из этой функции вы можете создать новую функцию с помощью partial
, которая устанавливает значение по умолчанию: 5
:
filter5 = partial(f, val=5)
filter5(test_input)
[5, 6]
Одна из проблем с partial
заключается в том, что она удаляет исходную строку документации и заменяет ее общей строкой документации:
filter5.__doc__
'partial(func, *args, **keywords) - new function with partial application\n of the given arguments and keywords.\n'
fastcore.utils.partialler исправляет это и обеспечивает сохранение строки документации таким образом, чтобы новый API был прозрачным:
filter5 = partialler(f, val=5)
filter5.__doc__
'Filter a list to remove any values that are less than val.'
Композиция функций
Распространенный в функциональных языках программирования метод — композиция функций, когда вы связываете несколько функций вместе, чтобы достичь определенного результата. Это особенно полезно в различных преобразований данных. Рассмотрим игрушечный пример, где у меня три функции: первая удаляет элементы списка меньше 5 (из предыдущего раздела), вторая добавляет 2 к каждому числу, третья суммирует все числа:
def add(arr, val): return [x + val for x in arr]
def arrsum(arr): return sum(arr)
# See the previous section on partialler
add2 = partialler(add, val=2)
transform = compose(filter5, add2, arrsum)
transform([1,2,3,4,5,6])
15
Почему это полезно? Вы можете подумать, что я могу сделать то же самое вот так:
arrsum(add2(filter5([1,2,3,4,5,6])))
Все верно! Однако, композиция дает удобный интерфейс на случай, когда вы захотите сделать что-то такое:
def fit(x, transforms:list):
"fit a model after performing transformations"
x = compose(*transforms)(x)
y = [np.mean(x)] * len(x) # its a dumb model. Don't judge me
return y
# filters out elements < 5, adds 2, then predicts the mean
fit(x=[1,2,3,4,5,6], transforms=[filter5, add2])
[7.5, 7.5]
Более подробную информацию о compose
читайте в документации.
__repr__
, но полезнее
В Python __repr__
помогает получить информацию об объекте для логирования и отладки. Ниже приведено то, что вы получите по умолчанию, когда определите новый класс. Примечание: мы используем store_attr
, который обсуждался выше.
class Test:
def __init__(self, a, b=2, c=3): store_attr() # `store_attr` was discussed previously
Test(1)
<__main__.Test at 0x7ffcd766cee0>
Мы можем использовать basic_repr, чтобы быстро получить более разумное значение по умолчанию:
class Test:
def __init__(self, a, b=2, c=3): store_attr()
__repr__ = basic_repr('a,b,c')
Test(2)
Test(a=2, b=2, c=3)
Обезьяньи патчи через декоратор
Это может быть удобно для обезьяньих патчей с помощью декоратора, что особенно полезно, когда вы хотите пропатчить импортируемую внешнюю библиотеку. Мы можем использовать декоратор @patch
из fastcore.foundation
вместе с подсказками типа примерно так:
class MyClass(int): pass
@patch
def func(self:MyClass, a): return self+a
mc = MyClass(3)
Теперь в MyClass
есть дополнительный метод под названием func
:
mc.func(10)
13
Я еще не убедил вас? Тогда покажу вам еще один пример такого патча в следующем разделе.
pathlib.Path
Увидев эти расширения в pathlib.path
, вы больше никогда не будете работать с vanilla pathlib! В pathlib добавлен ряд дополнительных методов, таких как:
Path.readlines
: то же, чтоwith open ('somefile', 'r') as f: f.readlines ()
Path.read
: то же, чтоwith open ('somefile', 'r') as f: f.read ()
Path.save
: сохраняет файл какpickle
Path.load
: загружает файлpickle
Path.ls
: показывает содержимое пути в виде списка.- и так далее.
Подробнее об этом здесь. Вот демонстрация ls
:
from fastcore.utils import *
from pathlib import Path
p = Path('.')
p.ls() # you don't get this with vanilla Pathlib.Path!!
(#7) [Path('2020-09-01-fastcore.ipynb'),Path('README.md'),Path('fastcore_imgs'),Path('2020-02-20-test.ipynb'),Path('.ipynb_checkpoints'),Path('2020-02-21-introducing-fastpages.ipynb'),Path('my_icons')]
Подождите! Что здесь происходит? Мы только что импортировали pathlib.Path
— почему мы получили новую функциональность? Потому, что импортировали модуль fastcore.utils
, который патчит pathlib.Path
с помощью упомянутого выше декоратора @patch
. Чтобы довести дело до конца и показать, чем полезен @patch
, я пойду дальше и прямо сейчас добавлю еще один метод в Path
:
@patch
def fun(self:Path): return "This is fun!"
p.fun()
'This is fun!'
Волшебно, правда? Вот почему я пишу об этом!
Еще более лаконичный способ написать лямбду
Self
с заглавной буквы S — это еще более лаконичный способ написания вызывающих методы объекта лямбд. Например, создадим лямбду для получения суммы массива Numpy:
arr=np.array([5,4,3,2,1])
f = lambda a: a.sum()
assert f(arr) == 15
Вы можете таким же образом использовать Self
:
f = Self.sum()
assert f(arr) == 15
Давайте создадим лямбду, которая будет делать группировку и возвращать максимальный элемент фрейма данных Pandas:
import pandas as pd
df=pd.DataFrame({'Some Column': ['a', 'a', 'b', 'b', ],
'Another Column': [5, 7, 50, 70]})
f = Self.groupby('Some Column').mean()
f(df)
Подробнее о Self
читайте в документации.
Функции блокнота
Они просты, но удобны и позволяют узнать, выполняется ли код в блокноте Jupyter, Colab или через оболочку IPython:
from fastcore.imports import in_notebook, in_colab, in_ipython
in_notebook(), in_colab(), in_ipython()
(True, False, True)
Это полезно, когда вы отображаете определенные типы визуализаций, индикаторов прогресса или анимации в вашем коде, которые, возможно, захотите изменить или переключить в зависимости от окружения.
Замена стандартного списка
Вы можете быть довольно счастливы со стандартным list
в Python. Это одна из тех ситуаций, когда вы не знаете, что вам нужен список лучше, пока кто-то не покажет его вам. L
— как раз такой список с большим количеством доработок.
Лучший способ описать L
— сделать вид, что у list
и numpy
родился милый ребенок. Определите список (посмотрите на приятный __repr__
, показывающий длину списка!)
L(1,2,3)
(#3) [1,2,3]
Перемешайте список:
p = L.range(20).shuffle()
p
(#20) [8,7,5,12,14,16,2,15,19,6...]
Индекс [прим.перев. — скорее позиция — position] в списке:
p[2,4,6]
(#3) [5,14,2]
L
имеет разумные умолчания, например, вот добавление элемента в список:
1 + L(2,3,4)
(#4) [1,2,3,4]
L
может гораздо больше. Читайте документацию, чтобы узнать больше.
Но подождите… Это еще не всё!
Есть еще кое-что, что я хотел бы показать вам, но это никак не вписываются в пост. Вот список некоторых любимых вещей, которые я не показывал в этом посте:
Утилиты
Раздел Utilites содержит множество шорткатов для выполнения общих задач или предоставляет дополнительный интерфейс к стандартному интерфейсу.
- mk_class: быстро добавляет кучу атрибутов в класс
- wrap_class: добавление новых методов в класс с помощью простого декоратора
- groupby: похоже на
groupby
из Scala - merge: слияние словарей
- fasttuple: кортеж на стероидах
- Infinite Lists: полезно для увеличения размеров массивов и тестирования
- chunked: упаковка и организация элементов
Многопроцессорная обработка
Раздел Многопроцессорная обработка расширяет соответствующую библиотеку Python, предлагая такие возможности:
- Шкала прогресса
- Возможность сделать паузу, чтобы смягчить состояние гонки с помощью внешних сервисов
- Пакетная обработка для каждого воркера, если у вас нет векторизованных операций для выполнения в блоках (chunk)
Прокачивать себя в Python стало проще, ведь специально для хабравчан мы сделали промокод HABR, дающий дополнительную скидку 10% к скидке указанной на баннере.