python

Типы данных наносят ответный удар

  • среда, 5 ноября 2014 г. в 02:10:43
http://habrahabr.ru/company/mailru/blog/242305/

Это вторая часть моих размышлений на тему «Python, каким бы я хотел его видеть», и в ней мы более подробно рассмотрим систему типов. Для этого нам снова придётся углубиться в особенности реализации языка Python и его интерпретатора CPython.

Если вы программист на языке Python, для вас типы данных всегда оставались за кадром. Они где-то там существуют сами по себе и как-то там взаимодействуют друг с другом, но чаще всего вы задумываетесь об их существовании только когда возникает ошибка. И тогда исключение говорит вам, что какой-то из типов данных ведёт себя не так, как вы от него ожидали.

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

Практически все API, которые я реализовывал на Python, не работали в других языках программирования. Даже такая простая вещь, как интерфейс для работы с командной строкой (библиотека click) просто не работает в других языках, и основная причина в том, что вам приходится беспрестанно бороться с типами данных.

Не так давно поднимался вопрос добавления статической типизации в Python, и я искренне надеюсь, что лёд, наконец, тронулся. Постараюсь объяснить, почему я против явной типизации, и почему надеюсь, что Python никогда не пойдёт по этому пути.

Что такое «cистема типов»?

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

Я не буду слишком углубляться в систему типов по двум причинам. Во-первых, я сам не до конца понимаю эту область, а во-вторых, на самом деле совсем необязательно понимать всё, чтобы «почувствовать» взаимосвязи между типами данных. Для меня важно учитывать их поведение потому, что это влияет на архитектуру интерфейсов, и я буду рассказывать о типизации не как теоретик, а как практик (на примере построения красивого API).

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

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

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

Самый простой составной тип данных, который есть в большинстве языков программирования — структуры. В Python их, как таковых, нет, однако во многих случаях библиотекам необходимо определять собственные структуры, например, модели ORM в Django и SQLAlchemy. Каждый столбец в базе данных представлен через дескриптор Python, который соответствует полю в структуре, и когда вы говорите, что primary key называется id, и это IntegerField(), вы определяете модель как составной тип данных.

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

Словосочетание «список целочисленных» всегда имеет больший смысл, чем просто список. Вы можете с этим поспорить, ведь всегда можно пройтись по списку и посмотреть тип каждого элемента, однако что делать с пустым списком? Когда в Python у вас есть пустой список, вы не можете определить тип его данных.

Такая же проблема возникает при использовании значения None. Допустим, у вас есть функция, которая принимает аргумент “Пользователь”. Если вы передаёте в неё параметр None, вы никогда не узнаете, что это должен был быть объект типа «Пользователь».

Какое же решение этой проблемы? Не иметь нулевых указателей и иметь массивы с явно указанными типами элементов. Всем известно, что в Haskell всё так и есть, однако есть другие, менее враждебные к разработчикам языки. Например, Rust — язык программирования, более близкий и понятный нам, поскольку он очень похож на C++. И в Rust есть очень мощная система типов.

Как же можно передать значение «пользователь не задан», если отсутствуют нулевые указатели? Например, в Rust для этого существуют опциональные типы. Так, выражение Option представляет из себя помеченное перечисление, которое оборачивает значение (конкретного пользователя в данном случае), и оно означает, что может быть передан либо какой-либо пользователь Some(user), либо None. Поскольку теперь переменная может либо иметь значение, либо не иметь его, весь код, работающий с этой переменной, должен уметь корректно обрабатывать случаи передачи значения None, иначе он просто не скомпилируется.

Серое будущее

Раньше существовало чёткое разделение между интерпретируемыми языками с динамической типизацией и компилируемыми языками со статической типизацией. Новые тренды меняют сложившиеся правила игры.

Первым признаком того, что мы ступаем на неизведанную территорию, стало появление языка C#. Это компилируемый язык со статической типизацией, и поначалу он был очень похож на Java. По мере развития языка C#, в его системе типов стали появляться новые возможности. Самым важным событием стало появление обобщённых типов, что позволило строго типизировать не обрабатываемые компилятором коллекции (списки и словари). Дальше — больше: создатели языка внедрили возможность отказываться от статической типизации переменных для целых блоков кода. Это очень удобно, особенно при работе с данными, предоставляемыми веб-сервисами (JSON, XML и т.д.), поскольку позволяет совершать потенциально небезопасные операции, ловить исключения от системы типов и сообщать пользователям о некорректных данных.

В наши дни система типов языка C# очень мощная и поддерживает обобщённые типы с ковариантными и контрвариантными спецификациями. Ещё она поддерживает работу с типами, допускающими нулевые указатели. Например, для того, чтобы определять значения по умолчанию для объектов, представленных как null, был добавлен оператор объединения со значением null ("??"). Хотя C# уже зашёл слишком далеко, чтобы избавиться от null, все узкие места находятся под контролем.

Другие компилируемые языки со статической типизацией также пробуют новые подходы. Так, в C++ всегда имела место быть статическая типизация, однако его разработчики начали эксперименты с выведением типов на многих уровнях. Дни итераторов вида MyType<X, Y>::const_iterator ушли в прошлое, и теперь практически во всех случаях можно использовать автотипы, а компилятор подставит нужный тип данных за вас.

В языке программирования Rust выведение типов тоже реализовано очень хорошо, и это позволяет вам писать программы со статической типизацией вообще не указывая типов переменных:
use std::collections::HashMap;

fn main() {
    let mut m = HashMap::new();
    m.insert("foo", vec!["some", "tags", "here"]);
    m.insert("bar", vec!["more", "here"]);

    for (key, values) in m.iter() {
        println!("{} = {}", key, values.connect("; "));
    }
}

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

Python и явная типизация

Некоторое время назад на одной из конференций кто-то убеждённо доказывал, что статическая типизация — это здорово, и языку Python это крайне необходимо. Я точно не помню, чем эта дискуссия закончилось, однако в результате появился проект mypy, который в сочетании с синтаксисом аннотаций был предложен как золотой стандарт типизации в Python 3.

На случай, если вы не видели эту рекомендацию, она предлагает следующее решение:
from typing import List

def print_all_usernames(users: List[User]) -> None:
    for user in users:
        print(user.username)

Я искренне убеждён, что это не самое лучшее решение. Причин много, но основная проблема в том, что система типов в Python, к сожалению, уже не так хороша. По сути язык имеет различную семантику в зависимости от того, как вы на него смотрите.

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

Семантика типов Python

Если вы читали предыдущую статью о системе слотов, вы должны помнить, что типы в Python ведут себя по-разному, в зависимости от уровня, на котором они реализованы (C или Python). Это очень специфическая особенность языка и такого вы больше нигде не увидите. В то же время, на раннем этапе развития многие языки программирования реализуют фундаментальные типы данных на уровне интерпретатора.

В Python попросту нет «фундаментальных» типов, однако есть целая группа типов данных, реализованных на C. И это не только примитивы и фундаментальные типы, это может быть всё, что угодно, безо всякой логики. Например, класс collections.OrderedDict написан на Python, а класс collections.defaultdict из того же модуля написан на C.

Это доставляет множество проблем интерпретатору PyPy, которому важно эмулировать оригинальные типы настолько хорошо, насколько это вообще возможно. Это нужно для того, чтобы получить хороший API в котором любые различия с CPython не будут заметны. Очень важно понимать, в чём основная разница между уровнем интерпретатора, написанном на языке C, и всем остальным языком.

Ещё один пример — модуль re в версиях Python до 2.7. В более поздних версиях он был полностью переписан, однако основная проблема по-прежнему актуальна: интерпретатор работает не так, как язык программирования.

В модуле re есть функция compile для компилирования регулярного выражения в паттерн. Эта функция берёт строку и возвращает объект паттерна. Выглядит это примерно так:
>>> re.compile('foobar')
<_sre.SRE_Pattern object at 0x1089926b8>

Мы видим, что объект паттерна задан в модуле _sre, который является внутренним модулем, и тем не менее он доступен нам:
>>> type(re.compile('foobar'))
<type '_sre.SRE_Pattern'>

К сожалению, это не так, потому что модуль _sre на самом деле не содержит этот объект:
>>> import _sre
>>> _sre.SRE_Pattern
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'module' object has no attribute 'SRE_Pattern'

Ну хорошо, это не первый и не единственный раз, когда тип обманывает нас о своём местоположении, да и в любом случае это внутренний тип. Двигаемся дальше. Мы знаем тип паттерна (_sre.SRE_Pattern), и это наследник класса object:
>>> isinstance(re.compile(''), object)
True

Также мы знаем, что все объекты имплементируют некоторые самые общие методы. Например, у экземпляров таких классов есть метод __repr__:
>>> re.compile('').__repr__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: __repr__

Что же происходит? Ответ достаточно неожиданный. По причинам, мне неизвестным, в Python до версии 2.7 объект паттерна SRE имел свой собственный слот tp_getattr. В этом слоте была реализована своя логика по поиску аттрибутов, которая предоставляла доступ к своим собственным аттрибутам и методам. Если вы изучите этот объект с помощью метода dir(), вы обратите внимание, что многие вещи просто отсутствуют:
>>> dir(re.compile(''))
['__copy__', '__deepcopy__', 'findall', 'finditer', 'match',
 'scanner', 'search', 'split', 'sub', 'subn']

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

Тип данных заявляет, что он наследуется от object. Это так в CPython, но в самом Python это не так. На уровне Python этот тип не связан с интерфейсом типа object. Каждый вызов, который проходит через интерпретатор будет работать, в отличие от вызовов, проходящих через язык Python. Так, например, type(x) будет работать, а x.__class__ — нет.

Что такое сабкласс

Приведённый выше пример показывает нам, что в Python может быть класс, который наследуется от другого класса, но при этом его поведение не будет соответствовать базовому классу. И это важная проблема, если мы говорим о статической типизации. Так, в Python 3 вы не можете реализовать интерфейс для типа dict до тех пор, пока не напишете его на C. Причина такого ограничения в том, что этот тип диктует видимым объектам поведение, которое попросту не может быть реализовано. Это невозможно.

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

Неопределённое поведение

Странное поведение объекта паттерна регулярных выражений было изменено в Python 2.7, но проблема осталась. Как было показано на примере словарей, язык ведёт себя по-разному, в зависимости от того, как написан код, и точную семантику системы типов понять до конца просто невозможно.

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

Давайте возьмём в качестве примера сортировку множеств (set). Множества в Python — очень полезный тип данных, однако при сравнении они ведут себя очень странно. В Python 2 у нас есть функция cmp(), которая в качестве аргументов принимает два объекта и возвращает числовое значение, показывающее, какой из переданных аргументов больше.

Вот что произойдёт, если вы попытаетесь сравнить два экземпляра объекта set:
>>> cmp(set(), set())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: cannot compare sets using cmp()

Почему так? Если честно, я без понятия. Возможно, причина заключается в том, как операторы сравнения работают с множествами, и это не работает в cmp(). И в то же время экземпляры объектов frozensets замечательно сравниваются:
>>> cmp(frozenset(), frozenset())
0

За исключением тех случаев, когда одно из этих множеств не пустое, — тогда мы снова получим исключение. Почему? Ответ прост: это оптимизация интерпретатора CPython, а не поведение языка Python. Пустой frozenset всегда имеет одно и то же значение (это неизменяемый тип и мы не можем добавлять в него элементы), поэтому это всегда один и тот же объект. Когда два объекта имеют один и тот же адрес в памяти, функция cmp() сразу же возвращает 0. Почему так происходит я не смог сходу разобраться, поскольку код функции сравнения в Python 2 слишком сложный и запутанный, однако у этой функции есть несколько путей, которые могут привести к такому результату.

Смысл не только в том, что это баг. Смысл в том, что в Python нет чёткого понимания принципов взаимодействия типов друг с другом. Вместо этого на все особенности поведения системы типов в Python всегда был один ответ: «так работает CPython».

Сложно переоценить объём работы, который был сделан в PyPy для реконструирования поведения CPython. Учитывая, что PyPy написан на Python, вырисовывается интересная проблема. Если бы язык программирования Python был описан так, как реализована текущая Python-часть языка, у PyPy было бы гораздо меньше проблем.

Поведение на уровне экземпляров

Теперь давайте представим себе, что у нас, гипотетически, есть такая версия Python, в которой все описанные проблемы исправлены. И даже в этом случае мы не сможем добавить в язык статические типы. Причина в том, что на уровне Python типы не играют значимую роль, гораздо более важно то, как объекты взаимодействуют друг с другом.

Например, объекты datetime, в общем случае, можно сравнивать с другими объектами. Но если вы хотите сравнить два объекта datetime друг с другом, то это можно сделать только в случае совместимости их таймзон. Так же результат многих операций может быть непредсказуемым до тех пор, пока вы внимательно не изучите объекты, участвующие в них. Результат конкатенации двух строк в Python 2 может быть как unicode, так и bytestring. Различные API кодирования или декодирования из системы кодеков могут возвращать разные объекты.

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

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

Багаж и семантика

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

На мой взгляд, с учётом всего вышесказанного, введение аннотации типов не имеет практически никакого смысла.

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

Поддержание стройной и хорошо документированной архитектуры языка позволяет избежать многих проблем. Архитекторам будущих языков программирования определённо следует избегать всех ошибок, которые были совершены разработчиками языков PHP, Python и Ruby, когда поведение языка в конце концов объясняется поведением интерпретатора.

Я считаю, что Python уже вряд ли поменяется в лучшую сторону. На избавление языка от всего этого тяжёлого наследия требуется слишком много времени и сил.

Перевёл Dreadatour, текст читал %username%.