Сообщить об ошибке.

Утиная типизация 'Duck Typing' в Python

Понимание утиной типизации в Python

Утиная типизация заключается в том, что вместо проверки типа чего-либо в Python мы склонны проверять, какое поведение оно поддерживает, зачастую пытаясь использовать это поведение и перехватывая исключение, если оно не работает.

Например, мы можем проверить, является ли что-то целым, пытаясь преобразовать его в целое число:

try:
    x = int(input("Введите целое число: "))
except ValueError:
    print("Это не целое число. Попробуй еще раз.")
else:
    print(f"Отлично: {x}")

Python программисты говорят "если это похоже на утку и крякает как утка, то это утка". Не нужно проверять ДНК утки, чтобы понять утка ли это, нужно просто посмотреть на ее поведение.

Утиная типизация "DuckTyping" настолько глубоко заложена и распространена в Python, что она действительно повсюду, как вода для рыбы: мы даже не думаем об этом. В Python очень часто проще предположить поведение объектов, вместо проверки их типов.

В Python программисты постоянно используют такие слова, как последовательность sequence, итерируемый iterable, вызываемый callable, отображение mapping, чтобы описать поведение объекта, а не описание его типа. Тип означает класс объекта, который можно получить, используя встроенную функцию type().

Слова, ориентированные на поведение, важны: нас не волнует, что такое объект, нам важно, что он может сделать.

Содержание:

Sequence: это как список list?

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

Строки, кортежи и списки являются последовательностями:

>>> s = "hello"
>>> t = (1, 2, 3)
>>> l = ['a', 'b', 'c']
>>> s[0]
# 'h'
>>> t[0]
# 1
>>> l[0]
# 'a'

Строки и кортежи являются неизменяемыми последовательностями, т.е. их нельзя изменить, а списки являются изменяемыми последовательностями.

Последовательности sequence обычно имеют еще несколько вариантов поведения, которые представлены в разделе "Общие операции с последовательностями"

Iterable: можем ли мы использовать их в циклах?

Итерации iterable являются более общим понятием, чем последовательности. Все, что вы можете зациклить с помощью цикла for .. in, является итеративным.

Списки, строки, кортежи, множества, словари, файлы, генераторы, объекты диапазона, объекты zip и многое другое в Python являются итерируемыми iterable.

Callables: это функция?

Если вы можете поставить круглые скобки после чего-то в Python, то это можно назвать вызываемым объектом. Функции и классы являются вызываемыми объектами. Все, что связано с методом __call__, также может быть вызвано.

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

Mapping: это словарь?

Программисты Python используют слово mapping - отображение для обозначения словарных объектов.

Вы можете спросить, что такое словарный объект? Это зависит от того, что вы подразумеваете под этим вопросом.

Если имеется в виду, можно ли назначить ему пары ключ/значение, используя синтаксис d[...], то все, что для этого нужно, это использовать магические методы __getitem__/__setitem__/__delitem__:

>>> class A:
...     def __getitem__(self, key):
...         return self.__dict__[key]
...     def __setitem__(self, key, value):
...         self.__dict__[key] = value
...     def __delitem__(self, key):
...         del self.__dict__[key]
...
>>> a = A()
>>> a['a'] = 4
>>> a['a']
# 4

Если имеется в виду, работает ли он с синтаксисом **, то понадобится метод keys и метод __getitem__:

>>> class A:
...     def keys(self):
...         return ['a', 'b', 'c']
...     def __getitem__(self, key):
...         return 4
...
>>> {**A()}
# {'a': 4, 'b': 4, 'c': 4}

File-like object - файлоподобные объекты.

Можно получить файловые объекты в Python с помощью встроенной функции open(), которая откроет файл и вернет файловый объект для работы с этим файлом.

Является ли sys.stdout файлом? У него есть метод записи write(), как у файлов, а также методы writable() и readable(), которые возвращают True и False, говорящие о том, могут ли он делать это с файлами.

Как насчет io.StringiO? Объекты StringIO в основном являются файлами в памяти. Они реализуют все методы, которые должны иметь файлы, но они просто хранят свое содержимое внутри текущего процесса Python, они ничего не записывают на диск. Таким образом, они 'Крякают как файл'.

Функция gzip.open в модуле gzip также возвращает файловые объекты. Эти объекты имеют все методы, которые есть у файлов, за исключением сжатия или распаковки при чтении/записи данных в сжатые файлы.

Файлы являются отличным примером "DuckTyping" в Python. Если можно создать объект, который действует как файл, часто путем наследования от одного из абстрактных классов в модуле io), то с точки зрения языка Python - этот объект является файлом.

Context - контекстные менеджеры.

Менеджер контекста - это любой объект, который работает с блоком Python например так:

with context_manager:
    # здесь что-нибудь делают
    pass

При вводе блока with для объекта менеджера контекста будет вызван метод __enter__, а при выходе из блока будет вызван метод __exit__.

Файловые объекты являются отличным примером этого:

>>> with open('my_file.txt') as fp:
...     print(fp.closed)
...
# False
>>> print(fp.closed)
# True

После вызова функции open() в блоке with можно использовать объект файла fp, это означает, что он должен иметь методы __enter__ и __exit__:

>>> f = open('my_file.txt')
>>> f.__enter__()
>>> f.closed
# False
>>> f.__exit__()
>>> f.closed
# True

Python практикует вводить утиную типизацию с помощью блоков. Интерпретатор Python не проверяет тип объектов, используемых в блоке with: он только проверяет, реализуют ли они методы __enter__ и __exit__. В блоке with будет работать любой класс с методами __enter__ и __exit__.

Подробнее о менеджерах контекста смотрите на странице "Контекстный менеджер with в Python".

Dunder method - методы утиной типизации.

Какое поведение поддерживает этот объект? Это крякает, как утка? Это идет как утка?

Dunder method - магические методы или методы двойного подчеркивания - это способ, которым создатели классов настраивают экземпляры класса для поддержки определенных режимов поведения, поддерживаемых Python.

Например, метод __add__, он же "dunder add" будет вызываться, когда что то добавляется с помощью оператора '+':

>>> class Thing:
...     def __init__(self, value):
...         self.value = value
...     def __add__(self, other):
...         return Thing(self.value + other.value)
...
>>> thing = Thing(4) + Thing(5)
>>> thing.value
# 9

Другие примеры.

Идея утиной типизации 'Duck Typing' в языке программирования Python повсеместна.

Встроенная функция sum() принимает любые повторяющиеся объекты, которые она может сложить вместе. То есть она работает со всем, что поддерживает знак '+', даже со списками и кортежами:

>>> sum([(1, 2), (3, 4)], ())
# (1, 2, 3, 4)

Метод объединения строк str.join также работает с любыми повторяемыми строками, а не только со списками строк:

>>> words = ["words", "in", "a", "list"]
>>> numbers = [1, 2, 3, 4]
>>> generator_of_strings = (str(n) for n in numbers)
>>> " ".join(words)
# 'words in a list'
>>> ", ".join(generator_of_strings)
# '1, 2, 3, 4'

Встроенные функции zip и enumerate принимают любые iterable - итерируемые объекты. Не только list или sequence, любые iterable!

>>> list(zip([1, 2, 3], (4, 5, 6), range(3)))
# [(1, 4, 0), (2, 5, 1), (3, 6, 2)]

Функция csv.reader работает со всеми объектами, похожими на файлы, но он также работает с любыми повторяемыми объектами, которые будут возвращать строки строк с разделителями. Так что функция csv.reader даже примет список строк:

>>> rows = ['a,b,c', '1,2,3']
>>> import csv
>>> list(csv.reader(rows))
# [['a', 'b', 'c'], ['1', '2', '3']]

Что в Python не поддерживает "Утиную типизацию"?

Обработка исключений в Python основана на строгой проверке типов. Если необходимо, чтобы наше исключение было ValueError, мы должны наследовать от типа ValueError:

>>> class MyError(ValueError):
...     pass
...
>>> try:
...     raise MyError("Example error being raised")
... except ValueError:
...     print("A value error was caught!")
...
# A value error was caught!

Магические методы также часто основаны на строгой проверке типов. Обычно такие методы, как __add__, возвращают NotImplemented, если ему дан тип объекта, с которым он не знает как работать. Это поведение сигнализирует Python, что ему следует попробовать другие способы добавления этого объекта, например, вызов __radd__.

class Thing:
    def __init__(self, value):
        self.value = value
    def __add__(self, other):
        if not isinstance(other, Thing):
            return NotImplemented
        return Thing(self.value + other.value)

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

>>> Thing(4) + 5
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
# TypeError: unsupported operand type(s) for +: 'Thing' and 'int'

Многие функции в Python также требуют строки, которые определяется как "объект, который наследуется от класса str". Например, метод присоединения строк str.join принимает итерируемые строки, а не итерации объекта любого типа:

>>> class MyString:
...     def __init__(self, value):
...         self.value = str(value)
...
>>> ", ".join([MyString(4), MyString(5)])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: sequence item 0: expected str instance, MyString found

Если класс MyString наследует от str, этот код будет работать:

>>> class MyString(str):
...     pass
...
>>> ", ".join([MyString(4), MyString(5)])
'4, 5'

Если вы видите в коде использование функций isinstance или issubclass, то кто-то не практикует "Утиную типизацию". Это не обязательно плохо, но это редко. Программисты на Python, как правило, практикуют "Duck Typing" в большинстве кода на Python и редко используют строгую проверку типов.

Если утиная типизация повсюду, какой смысл знать об этом?

Этот вопрос касается мышления программиста. Если вы уже используете Duck Typing, не зная этого термина, это здорово. Если нет, то задавайте себе эти вопросы при написании кода Python:

  • Может ли функция, которую я пишу, принять более общий вид объекта (менее специализированный), чем тот, который ожидается? Например, могу ли функция принять итерацию вместо предполагаемой последовательности?
  • Должен ли тип возвращаемых данных функции быть типом, который используется или другой тип будет таким же или даже лучше?
  • Какие типы входных данных ожидает функция? Должен ли это быть список/файл/и т. д. Или это может быть что-то более удобное, что может потребовать меньше преобразований?
  • Нужна ли проверка типов при вызове функции? Или лучше проверить как ведет себя функция?