Pull to refresh

Python как предельный случай C++. Часть 1/2

Reading time10 min
Views17K
Original author: Brandon Rhodes
От переводчика

Брендон Роудс − весьма скромный человек, представляющий себя в твиттере как «Python-программиста, возвращающего долг сообществу в форме докладов или эссе». Число этих «докладов и эссе» впечатляет, равно как и число свободных проектов, контрибьютором которых Брендон являлся или является. А ещё Брэндон опубликовал две книги и пишет третью.


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


Как у нас принято, прошу сообщать в личку о допущенных мной ошибках и опечатках.


Что означает словосочетание «предельный случай» в названии моего доклада? Предельный случай возникает, когда вы перебираете последовательность опций, пока не дойдёте до крайнего значения. Например, n-сторонний многоугольник. Если n=3, то это треугольник, n=4 − четырёхугольник, n=5 − пятиугольник, и т. д. По мере приближения n к бесконечности стороны становятся всё меньше и всё многочисленнее, и очертание многоугольника становится похоже на окружность. Таким образом, окружность является предельным случаем для правильных многоугольников. Вот что происходит, когда некая идея доводится до предела.


Я хочу поговорить о Python как о предельном случае для C++. Если вы возьмёте все хорошие идеи из C++ и очистите их, доведя до логического завершения, я уверен, в результате вы придёте к Python так же естественно, как серия многоугольников приходит к окружности.


«Непрофильные активы»


Я заинтересовался Python в 90-х: это был такой период в моей жизни, когда я избавлялся от «непрофильных активов», как я это называю. Многие вещи начали меня утомлять. Прерывания, например. Помните, когда-то на многих компьютерных платах были такие контакты с перемычками? И вы выставляли эти перемычки по мануалам, чтобы видеокарта получала более приоритетное прерывание, чтобы ваша игра работала быстрее? Так вот, мне надоело распределять и освобождать память с помощью malloc() и free() примерно тогда же, когда я перестал настраивать производительность своего компьютера перемычками. Был 1997 год или около того.


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


Поэтому в конце 90-х я искал такой язык программирования, который позволит мне заняться предметной областью и моделированием задачи, а не беспокойством о том, в какой области памяти компьютера хранятся мои данные. Как мы можем упростить C++, не повторяя грехи известных скриптовых языков?


Например, я так и не смог использовать Perl, и знаете, почему? Этот знак доллара! Он сразу давал понять, что создатель Perl не понимал, как работают языки программирования. Вы используете доллар в Bash, чтобы отделить имена переменных от остального содержимого строки, потому что программа на Bash состоит из буквально воспринимаемых команд и их параметров. Но после того, как вы познакомитесь с настоящими языками программирования, в которых строки размещаются между парами маленьких символов, называемых кавычками, а не по всему тексту программы, вы начинаете воспринимать $ как визуальный мусор. Знак долллара бесполезен, он уродлив, он должен уйти! Если вы хотите спроектировать язык для серьёзного программирования, вы не должны использовать специальные символы для обозначения переменных.


Синтаксис


Как же быть с синтаксисом? Возьмём за основу C! Это неплохо работает. Пусть присваивание обозначается знаком равенства. Такое обозначение принято не во всех языках, но, так или иначе, многие к нему привыкли. Но давайте не будем делать присваивание выражением. Пользователями нашего языка будут не только профессиональные программисты, но и школьники, учёные или дата-саентисты (если вы не в курсе, какая из этих категорий пользователей пишет худший код, то я намекну − это не школьники). Не дадим пользователям возможность изменять состояние переменных в неожиданных местах, и сделаем присваивание оператором.


Что же тогда использовать для обозначения равенства, если знак равенства уже использован для присваивания? Конечно же, двойное присваивание, как это сделано в C! Многие уже привыкли к этому. Также мы позаимствуем из C обозначения всех арифметических и побитовых операций, потому что эти обозначения работают, и многие ими вполне довольны.


Разумеется, кое-что мы можем и улучшить. О чём вы думаете, когда видите в тексте программы знак процента? О строковой интерполяции, конечно! Хотя % − это прежде всего оператор взятия модуля, просто для строк он оставался не определён. А раз так, то почему бы не переиспользовать его?


Численные и строковые литералы, управляющие последовательности с обратными слэшами − всё это будет выглядеть, как в C.


Управление потоком исполнения? Те же if, else, while, break и continue. Конечно, мы добавим немного фана, кооптировав старый добрый for для итерирования по структурам данных и диапазонам значений. Позже это будет предложено в C++11, но в Python оператор for изначально инкапсулировал все операции по вычислению размеров, обходу ссылок, инкрементированию счётчика и т. д., иначе говоря, делал всё, что нужно, чтобы предоставить пользователю элемент структуры данных. Структуры какого типа? Это неважно, просто передайте её for, он разберётся.


Мы также позаимствуем у C++ исключения, но сделаем их настолько дешёвыми в плане потребления ресурсов, что их можно будет использовать не только для обработки ошибок, но и для управления потоком исполнения. Мы сделаем индексирование интереснее, добавив слайсинг − возможность индексировать не только отдельные элементы последовательных структур данных, но и их диапазоны.


Ах, да! Мы исправим изначальный недостаток дизайна C − добавим висящую запятую!


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


If (n = 0) then begin
    writeln('N is now zero');
    func := 1
end

Керниган и Ритчи поступили правильнее, когда определили точку с запятой в C как терминатор выражения, а не разделитель, создав ту замечательную симметрию, когда каждая строка в программе, включая последнюю, заканчивается одинаково, и их можно свободно менять местами. К сожалению, в дальнейшем чувство гармонии им изменило, и они сделали запятую разделителем в статических инициализаторах. Это выглядит нормально, когда выражение умещается в одной строке:


int a[] = {4, 5, 6};

но когда ваш инициализатор становится длиннее, и вы компонуете его вертикально, вы получаете ту же неудобную асимметрию, что и в Pascal:


int a[] = {
    4,
    5,
    6
};

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


Позже стандарты С99 и С++11 так же исправили первоначальное недоразумение, позволив добавлять запятую после последнего литерала в инициализаторе.


Пространства имён


Ещё мы должны реализовать в своём языке программирования такую вещь, как пространства имён или неймспейсы (namespaces). Это критичная часть языка, которая должна избавить нас от ошибок вроде конфликтов имён. Мы поступим проще, чем C++: вместо того, чтобы дать пользователю возможность произвольно именовать неймспейсы, мы создадим по одному неймспейсу на модуль (файл) и обозначим их именами файлов. Например, если вы создадите модуль foo.py, ему будет присвоен неймспейс foo.


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


Создадим каталог my_package, поместим туда файл my_module.py, а в файле объявим класс:


class C(object):
    READ = 1
    WRITE = 2

тогда доступ к атрибутам класса будет осуществляться так:


import my_package.my_module

my_package.my_module.C.READ

Не беспокойтесь, мы не заставим пользователя каждый раз печатать полное имя. Мы дадим ему возможность использовать несколько версий оператора import, чтобы варьировать степень «близости» неймспейсов:


import my_package.my_module

my_package.my_module.C.READ

from my_package import my_module

my_module.C.READ

from my_package.my_module import C

C.READ

Таким образом, одинаковые имена, заданные в разных пакетах, никогда не будут конфликтовать:


import json

j = json.load(file)

import pickle

p = pickle.load(file)

Тот факт, что каждый модуль имеет собственное пространство имён, означает также, что модификатор static нам не нужен. Мы, однако, вспомним об одной функции, которую выполнял static − инкапсуляции внутренних переменных. Чтобы показать коллегам, что данное имя (переменная, класс или модуль) не является публичным, мы начнём его с символа подчёркивания, например, _ignore_this. Это также может являться сигналом для IDE, чтобы не использовать данное имя в автодополнении.


Перегрузка функций


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


Системные API


Мы дадим пользователю полный доступ ко многим системным API, включая сокеты. Я не понимаю, почему авторы скриптовых языков всегда предлагают собственные хитроумные способы для того, чтобы открыть сокет. При этом они никогда не реализуют возможности Unix Socket API полностью. Они реализуют 5-6 функций, которые они понимают, и выбрасывают всё остальное. Python, в отличие от них, имеет стандартные модули для взаимодействия с ОС, реализующие каждый стандартный системный вызов. Это значит, что вы можете прямо сейчас открыть книгу Стивенса и начать писать код. И все ваши сокеты, процессы и форки будут работать именно так, как там написано. Да, возможно, Гвидо или ранние контрибьюторы Python сделали всё именно так, потому что им было лень писать свою имплементацию системных библиотек, лень заново объяснять пользователям, как работают сокеты. Но в результате они добились замечательного эффекта: вы можете перенести все ваши знания UNIX, полученные в С и C++, в среду Python.


Итак, мы определились с тем, какие фичи мы «займём» у C++ для создания нашего простого скриптового языка. Теперь надо определиться с тем, что мы хотим исправить.


Undefined behavior


Неизвестное поведение, неопределённое поведение, поведение, определяемое реализацией… Это всё − плохие идеи для языка, которым будут пользоваться школьники, учёные и дата-саентисты. Да и выигрыш в производительности, ради которого допускаются такие вещи, зачастую ничтожен по сравнению с неудобствами. Вместо этого мы объявим, что любая синтаксически верная программа даёт одинаковый результат на любой платформе. Мы будем описывать стандарт языка такими фразами, как «Python вычисляет все выражения слева направо» вместо того, чтобы пытаться переупорядочивать вычисления в зависимости от процессора, ОС или фазы луны. Если пользователь уверен, что порядок вычислений важен, он вправе должным образом переписать код: в конце концов, пользователь здесь главный.


Приоритеты операций


Вы, наверное, сталкивались с подобными ошибками: выражение


oflags & 0x80 == nflags & 0x80

всегда возвращает 0, потому что сравнение в C имеет более высокий приоритет, чем побитовые операции. Иначе говоря, это выражение вычисляется как


oflags & (0x80 == nflags) & 0x80

Ох уж этот C!


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


(oflags & 0x80) == (nflags & 0x80)

Другие улучшения


Читабельность кода важна для нас. Если арифметические операции языка C знакомы пользователю ещё по школьной арифметике, то путаница между логическими и побитовыми операциями − явный источник ошибок. Мы заменим двойной амперсанд на слово and, а двойную вертикальную черту − на слово or, чтобы наш язык больше походил на человеческую речь, чем на частокол «компьютерных» символов.


Мы оставим нашим логическим операторам возможность сокращённого вычисления (https://en.wikipedia.org/wiki/Short-circuit_evaluation), но также наделим их способностью возвращать финальное значение любого типа, а не только булевого. Тогда станут возможны выражения наподобие


s = error.message or 'Error'

В этом примере переменной будет присвоено значение error.message, если оно непустое, в противном случае − строка 'Error'.


Мы расширим идею C о том, что 0 эквивалентен false, на другие объекты, помимо целых. Например, на пустые строки и контейнеры.


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


Строгая vs нестрогая типизация


Ещё один важный вопрос в дизайне скриптового языка: строгость типизации. Многие в аудитории знакомы с JavaScript? Что будет, если число 3 отнять от строки '4'?


js> '4' - 3
1

Отлично! А если к строке '4' прибавить число 3?


js> '4' + 3
"43"

Это называют нестрогой (или слабой) типизацией. Это что-то вроде комплекса неполноценности, когда язык программирования думает, что программист осудит его, если он не сможет вернуть результат любого, даже заведомо бессмысленного, выражения, путём многократного приведения типов. Проблема в том, что приведение типов, которое слабо типизированный язык производит автоматически, очень редко приводит к осмысленному результату. Попробуем чуть более сложные преобразования:


js> [] + []
""

js> [] + {}
"[object Object]"

Мы рассчитываем, что операция сложения коммутативна, но что будет, если поменять слагаемые местами в последнем случае?


js> {} + []
0

JavaScript не одинок в своих проблемах. Perl в аналогичной ситуации также пытается вернуть хоть что-то:


perl> "3" + 1
4

И awk предпримет что-то в этом духе:


$ echo | awk '{print "3" + 1}'
4

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


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


В Python, как и в C++, подобные выражения вернут ошибку.


>>> '4' - 3
TypeError

>>> '4' + 3
TypeError

Потому что приведение типов, если оно действительно необходимо, несложно написать в явном виде:


>>> int('4') + 3
7

>>> '4' + str(3)
'43'

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


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


Продолжение: «Python как предельный случай C++. Часть 2/2».

Tags:
Hubs:
+22
Comments22

Articles