Рубрики
Без рубрики

Typeclasses в Python

Typeclasses – это новая (но знакомая) идея о том, как вы можете организовать поведение вокруг своих типов. Tagged с Python, Rust, Elixir, Haskell.

Первоначально опубликовано в моем блоге : https://sobolevn.me/2021/06/typeclasses-in-python

Сегодня я собираюсь представить новую концепцию для разработчиков Python: Typeclasses. Это концепция нашего нового сухой питон Библиотека под названием классы .

Я скажу вам заранее, что это будет выглядеть очень знакомо для того, что вы уже знаете, и, возможно, даже использовать. Более того, мы повторно используем много существующего кода из стандартной библиотеки Python. Итак, вы можете назвать этот подход «родным» и «Pythonic». И это все еще будет интересно: я показываю примеры на 4 разных языках!

Но, прежде чем обсудить сами типа, давайте обсудим, какую проблему они решат.

Некоторые функции должны вести себя по -разному

Хорошо, это знакомая проблема для всех разработчиков. Как мы можем написать функцию, которая будет вести себя по -разному для разных типов?

Давайте создадим пример. Мы хотим Приветствую Разные типы по -разному (да, примеры «Привет, мир», вот и мы). Мы хотим приветствовать :

  • str экземпляры как Привет, {string_content}!
  • Myuser экземпляры как Привет еще раз, {имя пользователя}

Обратите внимание, что Приветствую В качестве простого примера на самом деле не имеет большого «бизнеса», но более сложные вещи, такие как to_json , from_json , to_sql В from_sql и to_binary Иметь большой смысл и можно найти практически в любом проекте. Но, ради простоты реализации, я собираюсь придерживаться нашего Приветствую пример.

Первый подход, который приходит в нашу уму, – это использовать iSinstance () Проверяет внутри самой функции. И это может работать в некоторых случаях! Единственным требованием является то, что мы должен Знайте все типы, с которыми мы будем работать заранее.

Вот как бы это выглядело:

@dataclass
class MyUser(object):
    name: str

def greet(instance: str | MyUser) -> str:
    if isinstance(instance, str):
        return 'Hello, "{0}"!'.format(instance)
    elif isinstance(instance, MyUser):
        return 'Hello again, {0}'.format(instance.name)
    raise NotImplementedError(
        'Cannot greet "{0}" type'.format(type(instance)),
    )

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

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

И это та часть, где все начинает становиться интересными.

Все языки программирования решают эту проблему по -разному. Давайте начнем с традиционного подхода Python к ООП.

ООП расширяемость и чрезмерные проблемы

Итак, как Python решает эту проблему?

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

Допустим, мы хотим приветствовать пользователя:

@dataclass
class MyUser(object):
    name: str

    def greet(self) -> str:
        return 'Hello again, {0}'.format(self.name)

Вы можете использовать этот метод напрямую, или вы можете создать помощника с набор Протокол :

from typing_extensions import Protocol

class CanGreet(Protocol):
    def greet(self) -> str:
        """
        It will match any object that has the ``greet`` method.

        Mypy will also check that ``greet`` must return ``str``.
        """

def greet(instance: CanGreet) -> str:
    return instance.greet()

И тогда мы можем его использовать:

print(greet(MyUser(name='example')))
# Hello again, example

Итак, это работает? Не совсем Анкет

Есть несколько проблем.

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

class Person(object):
    def become_friends(self, friend: 'Person') -> None:
         ...

    def is_friend_of(self, person: 'Person') -> bool:
        ...

    def get_pets(self) -> Sequence['Pet']:
        ...

Это делает это Человек (каламбур) заслуживает того, чтобы знать, что некоторые to_json Существует преобразование, которое может превратить эту бедную Человек в текстовые данные? А как насчет бинарного маринования? Конечно, эти детали не должны быть добавлены в абстракцию на уровне бизнеса, это называется Утекая абстракция Когда вы делаете иначе.

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

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

Второй , это работает только для пользовательских типов. Существующие типы трудно расширить Анкет Например, как бы вы добавили Приветствую Метод для str тип?

Вы можете создать str подтип с Приветствую Метод в нем:

class MyStr(str):
    def greet(self) -> str:
        return 'Hello, {0}!'.format(self)

Но это потребует изменения в нашем использовании:

print(greet(MyStr('world')))
# Hello, world!

print(greet('world'))
# fails with TypeError

Обезьяна

Некоторые могут предположить, что мы можем просто вставить необходимые методы непосредственно в объект/тип. Некоторые динамически напечатанные языки пошли по этому пути: JavaScript (В 2000 -х и начале 2010 -х годов, в основном популяризированной jQuery плагины) и Ruby ( все еще происходит прямо сейчас ). Вот как это выглядит:

String.prototype.greet = function (string) {
    return `Hello, ${string}!`
}

Это совершенно очевидно, что он не будет работать ни для чего сложного. Почему ?

  • Различные части вашей программы могут использовать обезьяны методов с одинаковым именем, но с различной функциональностью. И Ничто не сработает
  • Его трудно прочитать, потому что исходный источник не содержит исправленного метода, и место для исправления может быть глубоко скрыто в других файлах
  • Например, сложно печатать, mypy не поддерживает это вообще
  • Сообщество Python не привыкли к этому стилю, было бы довольно трудно убедить их написать свой код так (и это хорошо!)

Я надеюсь, что это ясно: мы не попадем в эту ловушку. Давайте рассмотрим другую альтернативу.

Дополнительные абстракции

Люди знакомы с такими вещами, как django-rest-framework может рекомендовать добавить Специальные абстракции к Приветствую различные виды:

import abc
from typing import Generic, TypeVar

_Wrapped = TypeVar('_Wrapped')

class BaseGreet(Generic[_Wrapped]):
    """Abstract class of all other """

    def __init__(self, wrapped: _Wrapped) -> None:
        self._wrapped = wrapped

    @abc.abstractmethod
    def greet(self) -> str:
        raise NotImplementedError

class StrGreet(BaseGreet[str]):
    """Wrapped instance of built-in type ``str``."""

    def greet(self) -> str:
        return 'Hello, {0}!'.format(self._wrapped)

# Our custom type:

@dataclass
class MyUser(object):
    name: str

class MyUserGreet(BaseGreet[MyUser]):
    def greet(self) -> str:
        return 'Hello again, {0}'.format(self._wrapped.name)

И мы можем использовать это так:

print(greet(MyStrGreet('world')))
# Hello, world!

print(greet(MyUserGreet(MyUser(name='example'))))
# Hello again, example

Но теперь у нас другая проблема: у нас есть разрыв между реальными типами и их обертками. Нет простого способа обернуть тип в его обертку. Как мы можем их соответствовать? Мы должны сделать это либо вручную, либо использовать какой -то реестр, такой как Dict [type, type [basegreet]] Анкет

И этого все еще недостаточно, будут ошибки времени выполнения! На практике это заканчивается как не является json-serializable Как многие из нас могли видеть это с DRF Сериализаторы при попытке сериализовать пользовательский незарегистрированный тип.

Typeclasses и подобные концепции

Давайте посмотрим, как функциональные языки (и Rust , люди все еще Будь то функционально или нет) обрабатывать эту проблему.

Некоторые общеизвестные знания:

  • У всех этих языков нет класс концепция, как мы его знаем в Python и, конечно, нет подклассов
  • Все языки ниже не имеют объект S, как мы делаем в Python, они не смешивают поведение и структуру (однако, elixir имеет Алан Кей Реальные объекты )
  • Вместо этого эти языки используют Ак-хок-полиморфизм Для того, чтобы функции ведут себя по -разному для разных типов через перегрузку
  • И, конечно, вам не нужно знать ни одного из языков ниже, чтобы понять, что происходит

Эликсир

Давайте начнем с одного из моих любимых. Эликсир имеет Протокол S Чтобы достичь того, что мы хотим:

@doc "Our custom protocol"
defprotocol Greet do
  # This is an abstract function,
  # that will behave differently for each type.
  def greet(data)
end

@doc "Enhancing built-in type"
defimpl Greet, for: BitString do
  def greet(string), do: "Hello, #{string}!"
end

@doc "Custom data type"
defmodule MyUser do
  defstruct [:name]
end

@doc "Enhancing our own type"
defimpl Greet, for: MyUser do
  def greet(user), do: "Hello again, #{user.name}"
end

Я почти уверен, что мои читатели смогли читать и понять Эликсир Даже если они не знакомы с этим языком. Это то, что я называю красотой!

Использование кода выше:

# Using our `Greet.greet` function with both our data types:
IO.puts(Greet.greet("world"))
# Hello, world!
IO.puts(Greet.greet(%MyUser{name: "example"}))
# Hello again, example

Вещь с Эликсир S Протокол S это то, что это в настоящее время невозможно Чтобы выразить, что какой -то тип поддерживает наш Greet.greet для Эликсир S Тип Проверка . Но это не имеет большого значения для Эликсир , который на 100% динамически напечатан.

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

  • Перечисляется Позволяет работать с коллекциями: подсчет элементов, Поиск участников, сокращение и нарезку
  • Нить. Chars что -то вроде __str__ В Python он преобразует структуры в читаемый по человеку формат

Ржавчина

Ржавчина имеет Черта S Анкет Концепция очень похожа на Протокол s в Эликсир :

// Our custom trait
trait Greet {
    fn greet(&self) -> String;
}

// Enhancing built-in type
impl Greet for String {
    fn greet(&self) -> String {
        return format!("Hello, {}!", &self);
    }
}

// Defining our own type
struct MyUser {
    name: String,
}

// Enhancing it
impl Greet for MyUser {
    fn greet(&self) -> String {
        return format!("Hello again, {}", self.name);
    }
}

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

// We can express that `greet` function only accepts types
// that implement `Greet` trait:
fn greet(instance: &dyn Greet) -> String {
    return instance.greet();
}

pub fn main() {
    // Using our `greet` function with both our data types:
    println!("{}", greet(&"world".to_string()));
    // Hello, world!
    println!("{}", greet(&MyUser { name: "example".to_string() }));
    // Hello again, example
}

Видеть? Идея настолько похожа, что использует почти тот же синтаксис, что и Эликсир Анкет

Примечательные примеры реальной жизни того, как Ржавчина использует его Черта S:

  • Копия и Клон – дублирующие объекты
  • Отладка чтобы показать лучше repr объекта снова как __str__ в Python

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

Хаскелл

Хаскелл имеет Typeclasses делать почти то же самое.

Итак, что такое Typeclass? TypeClass – это группа типов, все из которых удовлетворяют некоторому общему контракту. Это также форма специального полиморфизма, который в основном используется для перегрузки.

Мне немного жаль Хаскелл Синтаксис ниже, может быть не очень приятно и ясно читать, особенно для людей, которые не знакомы с этим блестящим языком, но у нас есть то, что имеем:

{-# LANGUAGE FlexibleInstances #-}

-- Our custom typeclass
class Greet instance where
  greet :: instance -> String

-- Enhancing built-in type with it
instance Greet String where
  greet str = "Hello, " ++ str ++ "!"

-- Defining our own type
data MyUser = MyUser { name :: String }

-- Enhancing it
instance Greet MyUser where
  greet user = "Hello again, " ++ (name user)

По сути, мы делаем то же самое, что и уже для Ржавчина и Эликсир :

  1. Мы определяем Приветствую TypeClass, который имеет одну функцию для реализации: приветствовать
  2. Затем мы определяем реализацию экземпляра для Строка Тип, который является встроенным (псевдоним для [Char] )
  3. Тогда мы определяем пользователь Myuser Тип с имя Поле Строка тип
  4. Реализация Приветствую Typeclass для Myuser это последнее, что мы делаем

Тогда мы можем использовать наш новый приветствовать Функция:

-- Here you can see that we can use `Greet` typeclass to annotate our types.
-- I have made this alias entirely for this annotation demo,
-- in real life we would just use `greet` directly:
greetAlias :: Greet instance => instance -> String
greetAlias = greet

main = do
  print $ greetAlias "world"
  -- Hello, world!
  print $ greetAlias MyUser { name="example" }
  -- Hello again, example

Некоторые реальные примеры Typeclasses:

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

Важно отметить, что Typeclases от Хаскелл и черты от Ржавчина немного разные , но мы не будем вдаваться в эти детали, чтобы сохранить эту статью довольно короткой.

Но как насчет Python?

сухой питотон/классы

В библиотеке Python Standard есть потрясающая функция под названием SingleDispatch Анкет

Это делает именно то, что нам нужно. Вы все еще помните, что мы находим способ изменить поведение функции на основе ввода?

Давайте посмотрим!

from functools import singledispatch

@singledispatch
def greet(instance) -> str:
    """Default case."""
    raise NotImplementedError

@greet.register
def _greet_str(instance: str) -> str:
    return 'Hello, {0}!'.format(instance)

# Custom type

@dataclass
class MyUser(object):
    name: str

@greet.register
def _greet_myuser(instance: MyUser) -> str:
    return 'Hello again, {0}'.format(instance.name)

Выглядит круто, кроме того, это в стандартной LIB, вам даже не нужно ничего устанавливать!

И мы можем использовать его как обычная функция:

print(greet('world'))
# Hello, world!
print(greet(MyUser(name='example')))
# Hello again, example

Итак, какой смысл в написании совершенно другой библиотеки, как мы делали с сухой питотон/классы ?

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

Лучший набор

С SingleDispatch Вы не можете быть уверены, что все будет работать, потому что оно не поддерживается mypy Анкет

Например, вы можете передать неподдерживаемые типы:

greet(1)  # mypy is ok with that :(
# runtime will raise `NotImplementedError`

В сухой питотон/классы Мы исправили это. Вы можете пройти только типы, которые поддерживаются:

from classes import typeclass

@typeclass
def greet(instance) -> str:
    ...

@greet.instance(str)
def _greet_str(instance: str) -> str:
    return 'Iterable!'

greet(1)
# Argument 1 to "greet" has incompatible type "int"; expected "str"

Или вы можете сломать @singledispatch Контракт на подпись:

@greet.register
def _greet_dict(instance: dict, key: str) -> int:
    return instance[key]  # still no mypy error

Но не с сухой питотон/классы :

@greet.instance(dict)
def _greet_dict(instance: dict, key: str) -> int:
    ...
# Instance callback is incompatible
# "def (instance: builtins.dict[Any, Any], key: builtins.str) -> builtins.int";
# expected
# "def (instance: builtins.dict[Any, Any]) -> builtins.str"

@singledispatch также не позволяет определять общие функции:

@singledispatch
def copy(instance: X) -> X:
    """Default case."""
    raise NotImplementedError

@copy.register
def _copy_int(instance: int) -> int:
    return instance
# Argument 1 to "register" of "_SingleDispatchCallable"
# has incompatible type "Callable[[int], int]";
# expected "Callable[..., X]"

reveal_type(copy(1))
# Revealed type is "X`-1"
# Should be: `int`

Что, опять же, возможно с сухой питотон/классы , мы полностью поддерживаем общие функции :

from typing import TypeVar
from classes import typeclass

X = TypeVar('X')

@typeclass
def copy(instance: X) -> X:
    ...

@copy.instance(int)
def _copy_int(instance: int) -> int:
    ...  # ok

reveal_type(copy(1))  # int

И ты не можешь ограничить @singledispatch Работать только с подтипами определенных типов, даже если вы хотите.

Протоколы не поддерживаются

Протоколы являются важной частью Python. К сожалению, они не поддерживаются @singledispatch :

@greet.register
def _greet_iterable(instance: Iterable) -> str:
    return 'Iterable!'
# TypeError: Invalid annotation for 'instance'.
# typing.Iterable is not a class

Протоколы Поддержка также решается с сухой питотон/классы :

from typing import Iterable
from classes import typeclass

@typeclass
def greet(instance) -> str:
    ...

@greet.instance(Iterable, is_protocol=True)
def _greet_str(instance: Iterable) -> str:
    return 'Iterable!'

print(greet([1, 2, 3]))
# Iterable!

Нет возможности аннотировать типы

Допустим, вы хотите написать функцию и аннотировать один из ее аргументов, что она должна поддерживать приветствовать функция Что-то вроде:

def greet_and_print(instance: '???') -> None:
    print(greet(instance))

Это невозможно с @singledispatch Анкет Но вы можете сделать это с сухой питотон/классы :

from classes import AssociatedType, Supports, typeclass

class Greet(AssociatedType):
    """Special type to represent that some instance can `greet`."""

@typeclass(Greet)
def greet(instance) -> str:
    """No implementation needed."""

@greet.instance(str)
def _greet_str(instance: str) -> str:
    return 'Hello, {0}!'.format(instance)

def greet_and_print(instance: Supports[Greet]) -> None:
    print(greet(instance))

greet_and_print('world')  # ok
greet_and_print(1)  # type error with mypy, exception in runtime
# Argument 1 to "greet_and_print" has incompatible type "int";
# expected "Supports[Greet]"

Вывод

Мы прошли долгий путь, от базового сложенного iSinstance () Условия – через ООП – в типовые клады.

Я показал, что эта родная и питоническая идея заслуживает более широкого признания и использования. И наши дополнительные функции в сухой питотон/классы Можно спасти вас от множества ошибок и помочь написать более выразительную и безопасную бизнес -логику.

В результате использования TypeClasses вы растете свои структуры от поведения, что позволит вам избавиться от бесполезных и сложных абстракций и написать код типов в типах. У вас будет ваше поведение рядом с структурами, а не внутри них. Это также решит проблему расширения ООП.

Объединить его с другими сухой питон Библиотеки для дополнительного эффекта!

Будущая работа

Что мы планируем на будущее?

Есть несколько ключевых аспектов для улучшения:

  1. Наш Поддерживает должен взять любое количество аргументов типа: Поддерживает [A, B, C] Анкет Этот тип будет представлять собой тип, который поддерживает все три типа Typeclass A , B и В в то же время
  2. Мы не Поддержка бетона дженерики только пока. Так, например, невозможно определить различные случаи для Список [int] и Список [str] . Это может потребовать добавления времени выполнения Typecheker к сухой питотон/классы
  3. Я планирую Чтобы сделать тесты частью этого приложения также! Мы отправим Гипотеза плагин Чтобы протестировать Typeclasses пользователей в одной строке кода

Быть в курсе!

Если вам нравится эта статья, вы можете:

  1. Пожертвовать в будущее сухой питон Развитие на GitHub
  2. Светь наш классы репо
  3. Подписаться в мой блог для большего контента!

Оригинал: “https://dev.to/wemake-services/typeclasses-in-python-3ma6”