Типи даних наносять удар у відповідь

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

Якщо ви програміст на мові Python, для вас типи даних завжди залишалися за кадром. Вони десь там існують самі по собі і як там взаємодіють один з одним, але найчастіше ви замислюєтеся про їх існування лише коли виникає помилка. І тоді виняток говорить вам, що якийсь із типів даних веде себе не так, як ви від нього чекали.

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

Практично всі API, які я реалізовував на Python, не працювали в інших мовах програмування. Навіть така проста річ, як звичайний клікабельний інтерфейс просто не працює в інших мовах, і основна причина в тому, що вам доводиться безупинно боротися з типами даних.

Не так давно піднімалося питання додавання статичної типізації в Python, і я щиро сподіваюся, що крига нарешті скресла. Постараюся пояснити, чому я проти явної типізації, і чому сподіваюся, що Python ніколи не піде цим шляхом.

Що таке система типів»?Система типів — це набір правил, згідно яким типи взаємодіють один з одним. Є цілий розділ комп'ютерної науки, присвячений виключно типами даних, що саме по собі вражає, але навіть якщо ви не цікавитеся теорією, вам важко буде ігнорувати систему типів.

Я не буду надто заглиблюватися в систему типів з двох причин. По-перше, я сам не до кінця розумію цю область, а по-друге, насправді зовсім необов'язково розуміти всі, щоб «відчути» взаємозв'язку між типами даних. Для мене важливо враховувати їх поведінку тому, що це впливає на архітектуру інтерфейсів, і я буду розповідати про типізації не як теоретик, а як практик (на прикладі побудови красивого 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%.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.