Утиная типизация

Опытным программистам концепция утиной типизации наверняка знакома. Для новичков же это словосочетание может звучать довольно странно: какое отношение имеют утки к программированию? 

Эта концепция адаптирована из следующего абдуктивного умозаключения:

Если что-то выглядит как утка, плавает как утка и крякает как утка, это наверняка и есть утка. 

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

Если я в реальной жизни встречусь с птицей, похожей на утку, которая плавает и крякает, я подумаю, что это утка. Это не ядерная физика, простой жизненный опыт приводит к наиболее реалистичному выводу.

Но шутки в сторону, какое отношение утиная типизация имеет к программированию, особенно к Python — языку, интересующему нас в этой статье?

Динамическая и статическая типизация

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

>>> a = 2000
>>> type(a)
<class 'int'>
>>> a = 'Dynamic Typing'
>>> type(a)
<class 'str'>
>>> a = [1, 2, 3]
>>> type(a)
<class 'list'>

В этом фрагменте мы сначала присвоили целое число переменной a, присвоив тем самым ей тип int. Позже мы присвоили тип “строка” и “список” той же переменной, и её тип стал соответственно str, а затем list. Интерпретатор не ругался на изменение типов данных одной и той же переменной.

Однако многие языки, такие как Java или Swift, поддерживают статическую типизацию. При объявлении переменной в них необходимо указывать тип данных для этой переменной. Впоследствии, если мы захотим изменить тип данных, компилятор не позволит это сделать, поскольку это несовместимо с изначальным объявлением. Пример ниже демонстрирует, что произойдёт, когда я попытаюсь сделать то же самое на Swift:

Как видите, код не может быть скомпилирован, потому что не может присвоить строку или массив переменной a, которая изначально объявлена как целое число.

Теоретический пример

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

>>> class Duck:
...     def swim_quack(self):
...         print("I'm a duck, and I can swim and quack.")
... 
>>> class RoboticBird:
...     def swim_quack(self):
...         print("I'm a robotic bird, and I can swim and quack.")
... 
>>> class Fish:
...     def swim(self):
...         print("I'm a fish, and I can swim, but not quack.")
... 
>>> def duck_testing(animal):
...     animal.swim_quack()
... 
>>> duck_testing(Duck())
I'm a duck, and I can swim and quack.
>>> duck_testing(RoboticBird())
I'm a robotic bird, and I can swim and quack.
>>> duck_testing(Fish())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in duck_testing
AttributeError: 'Fish' object has no attribute 'swim_quack'

Из успешного вызова функции duck_testing во фрагменте выше видно, что экземпляр класса Duck точно умеет плавать и крякать. Это же верно и для класса RoboticBird, который так же реализует необходимую функцию swim_quack. Однако класс Fish не реализует функцию swim_quack, вследствие чего его экземпляр не проходит утиный тест.

Принимая во внимание эти наблюдения, стоит понимать основные обозначения утиной типизации. При использовании пользовательских типов для определённых целей, реализация связанных функций важнее, чем точные типы данных. В нашем примере, хоть роботизированная птица и не является настоящей уткой, её реализация функции swim_quack “превращает” её в утку — животное, которое плавает и крякает.

Практические примеры

Итераторы

В Python итерация позволяет перебирать список элементов для выполнения определённых операций. Одним из распространённых способов создания итераций является цикл, имеющий следующий общий формат:

for i in iterable:
    expression

Мы можем создавать пользовательские итераторы и использовать их в качестве итерируемых в цикле for. Чтобы выполнить “утиный тест” для итераторов, пользовательский класс должен реализовывать методы __iter__() и __next__(). Конкретный пример приведён ниже: 

>>> class Cubes:
...     def __init__(self, limit):
...         self.limit = limit
...         self.n = 1
...     def __iter__(self):
...         print("__iter__ is called")
...         return self
...     def __next__(self):
...         print("__next__ is called")
...         if self.n < self.limit:
...             result = self.n ** 3
...             self.n += 1
...             return result
...         else:
...             raise StopIteration
... 
>>> cubes_iterator = iter(Cubes(4))
__iter__ is called
>>> 
>>> for i in cubes_iterator:
...     print(i)
... 
__iter__ is called
__next__ is called
1
__next__ is called
8
__next__ is called
27
__next__ is called

Во фрагменте выше реализованы оба метода __iter__() и __next__(), которые собирают экземпляры пользовательских итераторов класса Cubes так, чтобы эти экземпляры можно было использовать в цикле for.

Вызываемые

Помимо встроенного типа данных dict, другим важным словарным типом является defaultdict, доступный в модуле collections. Этот словарный тип данных имеет следующий конструктор: defaultdict([default_factory[, ...]]). В частности, аргумент default_factory — это тип вызываемой, например, функции или лямбда-функции.

Мы можем реализовать defaultdict, передавая лямбда-функцию. Вот пример её использования:

>>> from collections import defaultdict
>>> letter_counts = defaultdict(lambda: 0)
>>> for i in 'abcddddeeeee':
...     letter_counts[i] += 1
... 
>>> letter_counts.items()
dict_items([('a', 1), ('b', 1), ('c', 1), ('d', 4), ('e', 5)])

Причём мы получим лучшую гибкость в использовании типа данных defaultdict, если создадим собственную фабрику по умолчанию. С философией “утиной типизации” пользовательский класс должен реализовать метод __call__(), который делает что-то вызываемым. Давайте посмотрим, как это работает:

>>> from collections import defaultdict
>>> class Duckling:
...     pass
... 
>>> class DucklingFactory:
...     def __call__(self):
...         return [Duckling()]
... 
>>> duckling_factory = DucklingFactory()
>>> ducklings = defaultdict(duckling_factory)
>>> for i in (1,2,3):
...     ducklings[i] = ducklings[i] * i
... 
>>> ducklings.items()
dict_items([(1, [<__main__.Duckling object at 0x10205c510>]), (2, [<__main__.Duckling object at 0x10205c550>, <__main__.Duckling object at 0x10205c550>]), (3, [<__main__.Duckling object at 0x10205c590>, <__main__.Duckling object at 0x10205c590>, <__main__.Duckling object at 0x10205c590>])])

Мы создаём класс DucklingFactory, чья функция __call__() возвращает список экземпляра Duckling. Используя эту фабричную функцию, мы имеем возможность создавать желаемое количество утят, умножая список по умолчанию. 

Сортировка с Len()

Другое возможное применение утиной типизации — реализация пользовательской функции len(), которую можно использовать в сортировке списка с помощью функции sort(). Примечание: некоторые называют функцию len() магической, потому что она реализуется скрытым вызовом функции __len__() (функции с двойным подчёркиванием в качестве префикса и суффикса называются магическими функциями).

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

>>> class NamedDuck:
...     def __init__(self, name):
...         self.name = name
...     def __str__(self):
...         return self.name
...     def __len__(self):
...         return len(str(self))
... 
>>> ducks = [NamedDuck('Amazing'), 'OK', NamedDuck('Lucky'), 'Cool']
>>> ducks.sort(key=len)
>>> [str(x) for x in ducks]
['OK', 'Cool', 'Lucky', 'Amazing']

В приведённом выше фрагменте пользовательский класс NamedDuck реализует функции __str__() и __len__(), которые позволяют нам использовать функции str() и len() для его экземпляров в строках 10 и 12 соответственно. Важно отметить, что хотя в списке смешаны именованные утки и строки, все элементы могут вызывать функции str() и len(), так что список можно отсортировать, используя len() в качестве ключа, и использовать для вывода отсортированного результата. 

Таким образом, более широкое следствие такого использования состоит в том, что в смешанном списке элементов различных типов данных при условии, что каждый тип реализует те же функции, элементы будут проходить утиный тест и применяться с соответствующими операциями. Вот пример общего случая использования функции len() на смешанном списке типов данных str, tuple и list:

>>> mixed_list = ['Amazing', (1, 2), ['hello']]
>>> mixed_list.sort(key=len)
>>> mixed_list
[['hello'], (1, 2), 'Amazing']

Заключение

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

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

Читайте также:


Перевод статьи Yong Cui, Ph.D.: Duck Typing in Python — 3 Practical Examples

Предыдущая статьяОткройте миру разрабатываемые вами API
Следующая статьяСоздаём конвейер автоматизированных сборок для проекта на Arduino. Часть 1/2