Программирование по своей сути является процессом инкрементальным (или постепенным, последовательным), и используемые нами языки программирования должны отражать этот факт. В данной статье рассматриваются несколько различных направлений, в которых движутся программные модели по мере своего развития, а также поднимается вопрос того, как потенциальные исследования в области юзабилити языков программирования послужат в будущем формированию концепции языков программирования, ориентированных на человека.
Выбираем правильную задачу
Какие серьёзные проблемы есть у языков программирования, которые мы используем в своей работе в 2018 году? Которые из них после решения смогут оказать наибольший эффект на следующее поколение программистов?
Если вас заинтересовал данный вопрос, рекомендуем к прочтению пост Грейдона Хоара (создателя Rust) «Что дальше?», а также пост Стивена Диля «Ближайшее будущее языков программирования».Для меня в этом вопросе спрятана самая привлекательная черта исследований языков программирования — дело в том, что инструменты и теории, которые мы разрабатываем, влияют не только на одну конкретную область, но и потенциально на всех, кто занимается программированием. Отсюда же проистекает и следующий вопрос: откуда, скажите на милость, нам знать о нуждах каждого программиста, живущего на Земле? Легко работать над языком X, основанном на новой теории типов, или над языком Y, в котором есть новая «фича», интересная лично мне — но как насчёт всех остальных программистов?
Это один из самых важных недостатков языков программирования как современной области исследований. Огромное количество подобных исследований проходит под знаменем интуиции самих исследователей, и вдобавок на них накладывается специфический опыт определённых людей, работавших с конкретными инструментами программирования, языками, средами и т.п. Очевидно, что интуитивный подход позволил нам продвинуться довольно далеко, раз мы смогли достичь нашего текущего уровня — подтверждая тезис о том, что умные люди чаще всего отличаются хорошей интуицией — но позвольте мне предположить, что очевидная стагнация в повсеместном применении современных практик исследований ЯП связана в первую очередь с недостатком внимания к конечному пользователю (проще говоря, к обычному программисту). Мнение, с которым мне доводилось сталкиваться неоднократно — это то, что последней большой и по-настоящему новой идеей был Prolog.
Мне кажется, что взгляд на языки программировния (ЯП, PL) сквозь линзы человеко-машинного взаимодействия (HCI) — это самая важная мета-проблема области сегодня. Больше, чем когда-либо, нам нужно сегодня проводить опросы, интервью, изучать опыт пользователей, привлекать социологов и психологов и так далее — для того, чтобы сформулировать основывающиеся на полученных данных гипотезы, которые коснутся «трудных» разделов дисциплины программирования. Нужно не просто сделать процесс программирования комфортным для тех, кто только начинает учиться ему, но и для всех остальных — от седых низкоуровневых системных разработчиков до молодежи в лице веб-разработчиков. Интерес к данному направлению уже начинает формироваться; к примеру, проводится конференция по CHI под названием Usability of Programming Languages Special Interest Group, появляются научные работы вроде «Эмпирического анализа распространения языков программирования» (Empirical Analysis of Programming Language Adoption), а также собираются рабочие группы по юзабилити языков.
Однако, пусть наши знания о юзабилити языков пока и не так велики, ничто не удерживает нас от продолжения работы над ключевыми проблемами ЯП, которые по нашему мнению принесут ощутимые результаты. Манифест, сформулированный мною далее, основывается по большей части на моем личном опыте — я занимаюсь программированием свыше десяти лет, занимался игровой разработкой (Lua, C), веб-сайтами (PHP, JS), высоконагруженными/распределенными системами (C++, Go, Rust), компиляторами (Standard ML, OCaml) и data science (Python, R). За это время я успел поработать над небольшими скриптами, личными проектами, открытым ПО, продуктами в крошечных (2 человека), маленьких (15 человек), средних (500 человек) и больших (2000+ человек) компаниях, и теперь занимаюсь научными исследованиями. Я изучал теорию языков программирования в Университете Карнеги — Меллона, а сегодня я занимаюсь преподаванием курса языков программирования CS242 в Стэнфорде.
Всё это я рассказал с той целью, чтобы вам было ясно: пусть нам и требуется гораздо больше данных, чтобы основательно подойти к обсуждению данных задач, я предельно постарался сформулировать обоснованное мнение по поводу проблем, которые существуют в современных языках программирования, имеют вес во многих различных сферах деятельности и действительно встречаются в реальном мире. Конечно, я не стану отрицать, что есть много такого, о чём я не знаю — поэтому, как обычно, я предлагаю вам прочесть данный пост со здоровой долей критицизма.
Думая постепенно
Я твёрдо верю в следующее: языки программирования должны проектироваться таким образом, чтобы они непосредственно соответствовали процессам программирования, которые протекают в голове программиста. Мы должны стремиться понять, как именно люди думают о программах, и попытаться определить, какие из процессов программирования понимаются человеческим разумом на интуитивном уровне. Здесь хватает всех видов увлекательных вопросов, например:
- Является ли императивное программирование более интуитивно доступным для людей, чем функциональное программирование? Если да, это происходит потому что так сконфигурирован наш мозг, или просто потому, что это — самая популярная форма программирования?
- Как далеко мы должны продвинуться в стремлении соответствовать естественным процессами, происходящим в голове человека? Или быть может, баланс наоборот должен быть сдвинут в сторону изменения образа мышления программистов?
- Насколько комментарии на самом деле влияют на наше понимание программ? Имена переменных? Типы? Поток управления?
Простое наблюдения за процессом человеческого программирования показывает, что этот процесс — инкрементальный. Никто не пишет всю программу с нуля за один проход, компилирует её и тут же релизит, после чего никогда не открывает её код снова. Программирование — это когда ты долго вкалываешь методом проб и ошибок, где продолжительность проб и серьезность ошибок тесно зависит от конкретной области и инструментария. Вот почему возможность исследовать вывод и быстрое время компиляции настолько значимы — к примеру, возможность изменить HTML-документ и по обновлению страницы незамедлительно увидеть, что произошло. Брет Виктор в своей статье «Learnable Programming» обсуждает эту идею в деталях.
Я называю данный процесс «постепенным программированием» (gradual programming).
Я бы использовал термин «инкрементальное» программирование, но инкрементальные вычисления уже имеют своё, отличное от моего и закрепленное значение, тем более, что термин «постепенный» («gradual») употребляется в среде энтузиастов ЯП в данном контексте.В то время, как парадигмы императивного или функционального программирования фокусируются на аспектах, лежащих в основе нашей ментальной модели программы, постепенное программирование описывает процесс, по которому формируется эта ментальная модель. В этом смысле, постепенное программирование это просто… программирование; но, как мне кажется, новый термин здесь уместен, поскольку нам он пригодится для того, чтобы не запутаться далее.
Насколько мне известно, единственным зафиксированным случаем использования термина «gradual programming» («постепенное программирование») помимо данной статьи является данная публикация, но там термину придается несколько иная перспектива. Одним из её авторов является Джереми Сайк — один из создателей gradual typing.
При постепенном программировании, программист следит за коэволюцией (параллельной эволюцией) двух вещей: 1) синтаксическим представлением программы, выраженной для компьютера на языке программирования и 2) концептуальным представлением программы, находящимся в голове программиста. В начале этого процесса, программист стартует без какого-либо синтаксиса (с пустого файла) и обычно вооружен весьма смутной идеей того, как итоговая программа должна будет работать. С этой точки, программист продолжает предпринимать небольшие шаги в направлении построения компонентов программы — до тех пор, пока не будет готова её финальная версия.
Если вы занимаетесь программированием, вы почти наверняка неоднократно проходили через этот процесс, и скорее всего более-менее узнали его в моем описании — однако, обычно большая часть нашего мыслительного процесса происходит неявно (например, внутри вашей головы), и никогда не предстает в форме коммуникации. Чтобы убедиться в существовании данного постепенного процесса, давайте вместе в деталях посмотрим на следующий пример. Допустим, я хочу написать программу, которая добавляет строку с текстом к файлу. В голове у меня есть некая модель программы, которая выглядит примерно следующим образом:
входной файл = некий другой ввод
входная строка = некий ввод
записываем входную строку в конец входного файла
Затем я решаю, на каком языке я буду писать эту программу — в нашем случае это будет Python. Для начала, вместо того, чтобы пытаться написать всю программу сразу, я просто возьму первую строку из модели и попробую написать её как есть в Python.
$ cat append.py
input_file = input()
print(input_file)
Здесь я принял несколько решений. Во-первых, я решил, что ввод будет произведен из
stdin
(для простоты), и использовал функцию input()
, стандартную библиотечную функцию Python. Я должен был придумать название для этого значения, input_file
, и это наименование должно было соответствовать принятым в Python синтаксическим соглашениям. Я также добавил выражение print
, которое не было частью моей оригинальной программной модели, но является частью временной программной модели, предназначенной для отладки моей маленькой программ. Затем я попробую выполнить её:$ echo "test.txt" | python append.py
Traceback (most recent call last):
File "append.py", line 1, in <module>
input_file = input()
File "<string>", line 1, in <module>
NameError: name 'test' is not defined
Упс, я перепутал
input()
и raw_input()
. Проблема была не с моей программной моделью — я все ещё думаю о программе точно таким же образом, что и ранее — а с моим «декодированием» в Python. Исправляю свою ошибку:$ cat append.py
input_file = raw_input()
input_line = raw_input()
print(input_file, input_line)
$ echo "test.txt\ntest" | python append.py
('test.txt', 'test')
Следом я должен выяснить, каким образом можно добавить строку в конец файла. В моей изначальной ментальной модели, это было инкапсулировано в выражение «запись входной строки в конец входного файла» («write input line to the end of input file»), но теперь настала пора превратить эту смутную идею в более конкретные шаги, которые я смогу с легкостью написать на Python. В частности, если у меня уже есть понимание того, как работает файловая система, то я знаю, что сначала я должен открыть файл в режиме добавления (append mode), записать строку, а затем закрыть файл.
После некоторых размышлений, моя ментальная модель начинает выглядеть следующим образом:
входной файл = некий другой ввод
входная строка = некий ввод
файл = открыть входной файл для записи в режиме добавления
записываем входную строку в конец входного файла
закрываем файл
Итак, теперь давайте «переведём» всё это на Python:
$ cat append.py
input_file = raw_input()
input_line = raw_input()
file = open(input_file, 'a')
file.write(input_line)
file.close()
$ echo "test.txt\ntest" | python append.py
$ cat test.txt
test
Успех! Опять же, целью нашего примера была демонстрация коэволюции синтаксической и концептуальной модели программы по мере работы над ней. Основываясь на моем опыте программирования, вместе с обучением других программированию, я могу сказать, что она приводит достаточно распространенный пример мыслительного процесса, который сопутствует тому, как программируют многие из нас.
Оси эволюции
Описанный выше пример продемонстрировал нам постепенную природу процесса программирования, но не пролил никакого света на то, как мы должны подходить к процессу создания инструментов, которые будут подходит данному процессу. Чтобы упростить себе задачу, давайте разобьем эволюцию программы на множество мелких осей эволюции. По существу, давайте спросим себя: какие виды полезной информации о своей программе разработчики узнают и понимают постепенно? После чего мы сможем предположить, как языки программирования могут помочь оптимизировать каждую из осей по отдельности.
1. Конкретика / абстракция (Concrete / abstract)
При создании программ общепринятым способом работы считается старт с конкретного примера, который вы пытаетесь реализовать, а затем обобщение (или абстрагирование) этого примера, которое производится для того, чтобы он мог покрыть более широкий набор случаев использования. Абстракция — это краеугольный камень языков программирования, обычно предоставляемый при помощи функций и интерфейсов. Например, мы можем превратить наш скрипт в функцию:
def append_line(input_file, input_line):
# наш код выше
append_line('test.txt', 'test')
append_line('test.txt', 'test again')
Однако, чем более неопределенна ваша модель изначально, тем сложнее вам будет немедленно перейти к абстрактному решению, поэтому данная эволюция от конкретики к абстракции сегодня часто наблюдается при работе с современными языками программирования (опять же, посмотрите главу «Create by Abstracting» в Learnable Programming).
2. Анонимность / поименованность (Anonymous / named)
Когда мы находимся в начале нашего процесса программирования на стадии итерации/экспериментов, естественно что мы как программисты хотим оптимизировать скорость написания кода, а не его чтения. Одной из форм подобной оптимизации являются короткие имена переменных и анонимные значения. Например, укороченный вариант первого варианта нашего скрипта мог бы выглядеть так:
s = raw_input()
f = open(s, 'a')
f.write(raw_input())
f.close()
Здесь имена переменных носят менее информативный характер, чем ранее: мы используем
s
вместо input_file
, f
вместо file
, а input_line
вообще лишилась имени. Однако, если это быстрее писать, а скрипт никогда никто не будет читать снова, почему бы и нет? Если мы планируем в дальнейшем использовать данный скрипт в большой кодовой базе, то мы можем начать инкрементально менять имена на более информативные — для того, чтобы граждане, проводящие code review, остались довольны. Вот ещё один пример постепенного изменения, которое легко применить на практике и которое является общеупотребимым среди программистов, пишущих на современных языках программирования.3. Императивность / декларативность (Imperative / declarative)
По целому ряду причин, линейный, последовательный императивный код воспринимается программистами более естественно по сравнению с функциональным/декларативным кодом по части их концептуальной программной модели. Например, простая трансформация списка будет почти наверняка использовать циклы
for
:in_l = [1, 2, 3, 4]
out_l = []
for x in in_l:
if x % 2 == 0:
out_l.append(x * 2)
В то время как более декларативная версия абстрагирует поток выполнения в предметно-ориентированные примитивы:
in_l = [1, 2, 3, 4]
out_l = map(lambda x: x * 2, filter(lambda x: x % 2 == 0, in_l))
Различие между этими двумя подходами является не только стилистическим — декларативный код обычно гораздо проще анализировать на структуру, например
map
(отображение, карта) можно распараллелить тривиальным образом, в то время как цикл for
в общем случае гораздо хуже подходит для этого. Подобные трансформации чаще всего случаются в языках, который поддерживают смесь императивного и функционального кода (по крайней мере — замыкания).4. Динамическая типизация / статическая типизация (Dynamically typed / statically typed)
Взлёт популярности динамически типизированных языков в последние 20 лет (Python, Javascript, R, Lua, ...) должен стать достаточным свидетельством того, что люди находят динамическую типизацию полезной — вне зависимости от того, по какую сторону баррикад вы находитесь, факт остается фактом. Несмотря на то, что у динамической типизации есть много преимуществ (различные структуры данных, свободная утиная типизация и т.д.), самым простым является повышение продуктивности при помощи опущения: типы переменных не требуется знать во время компиляции, поэтому программисту не приходится расходовать свою умственную энергию ещё и на это.
Однако, типы по-прежнему остаются чрезвычайно полезными инструментами обеспечения корректности и производительности, так что программист может захотеть постепенно добавить сигнатуры типов к нетипизированной программе в том случае, если он сможет быть уверен в том, что та или иная переменная должна иметь определенный тип. Подобная зарождающаяся идея, которую называют опциональной, или постепенной типизацией (gradual typing) уже завоевала признание в Python, Javascript, Julia, Clojure, Racket, Ruby, Hack и других языках.
Например, наша программа после переписывания могла бы выглядеть следующим образом:
input_file: String = raw_input()
input_line: String = raw_input()
file: File = open(input_file, 'a')
file.write(input_line)
file.close()
5. Динамическая деаллокация / статическая деаллокация (Dynamically deallocated / statically deallocated)
Вы можете взглянуть на управление памятью, или на время жизни, сквозь ту же призму, через которую мы посмотрели на типы. В 2018 году все языки программирования должны иметь безопасный доступ к памяти, единственный вопрос здесь — должна ли быть деаллокация памяти определена во время компиляции (как например в Rust с его borrow checker) или во время выполнения (как например в любом другом языке, в котором есть сборщик мусора). Сборка мусора — это, вне сомнения, большой плюс для производительности программиста — поэтому естественно, что наша изначальная программная модель не должна предполагать, сколько времени каждое значение должно прожить до того момента, когда случится деаллокация.
Однако, как и ранее, точечный контроль над сроком жизни значения по-прежнему бывает полезен для корректности и производительности. Владение и заимствование, наподобие реализованному в Rust, может помочь структурировать систему для исключения случаев data races при конкурентном программировании, также как и избежать необходимости использования сборщика мусора в рантайме.
Расширяя наш типизированный пример, это могло бы выглядеть следующим образом:
input_file: String = raw_input()
input_line: String = raw_input()
file: mut File = open(&input_file, 'a')
file.write(&input_line)
file.close()
Насколько мне известно, в отличие от опциональной, или постепенной типизации (gradual typing), работы в направлении создания постепенного (опционального) управления памятью не ведется (за исключением этой публикации).
6. Общее назначение / Предметная ориентированность (General-purpose / domain-specific)
Когда программист начинает писать программу, он хочет, чтобы каждая функция в его языке, доступная для использования в реализации, использовалась для достижения максимально возможной скорости прототипирования с целью увеличения продуктивности творческого процесса. Обычно это мало кому приходит в голову во время разработки ПО, за исключением разве что взгляда со стороны стиля кодирования («какое подмножество Python мне следует использовать?»).
Однако, растущая волна высокопроизводительных предметно-ориентированных языков вроде TensorFlow, Halide, Ebb, и Weld указывают нам на то, что если программист использует лишь малое подмножество программ общего назначения (например, дифференцируемые чистые функции), то оптимизатор может произвести существенно более эффективный код. С точки зрения постепенности, это предполагает возможность появление в будущем рабочего процесс, при котором программист постепенно сужает подмножество языка, которое он использует в отельной части программы с той целью, чтобы компилятор мог предоставить гораздо лучше оптимизированный бэкенд для неё.
Концепция постепенного программирования
Не то, чтобы эти оси можно было назвать новой идеей — в том плане, что, скажем, компромисс между статической и динамической типизацией известен довольно давно. Однако, чего мне хотелось вам продемонстрировать, так это то, что данные решение не являются единовременными и одноразовыми, — они могут меняться по мере разработки самой программы. Поэтому все оси скорее всего 1) меняются по мере эволюционирования отдельной программы, и 2) меняются при помощи точной координации, т.е. к примеру когда типизированный и нетипизированный код должен быть смешан в рамках одной системы. Это предаёт анафеме подход «всё или ничего», которого придерживаются сегодня большинство языков: всё либо должно быть типизированным, либо должно быть нетипизированным. Всё либо должно собираться сборщиком мусора, либо не должно им собираться вообще. Это вынуждает программистов сталкиваться с абсурдными компромиссами, вроде полной смены всей экосистемы языка ради получения преимуществ статической типизации.
В свете этого, продвинутое постепенное программирование подразумевает следующий процесс исследования:
- Обнаружение частей процесса программирования, которые меняются постепенно шаг-за-шагом во времени, но сейчас требуют неоправданного оверхеда или переключения между языками для использования в работе.
- Разработка языковых механизмов, которые позволят программистам постепенно двигаться вдоль конкретных осей с однородным программным окружением.
- Эмпирическая проверка на реальных живых программистах, работают ли предложенные механизмы на практике, и совпадают ли их результаты с гипотезами.
Каждый из этих шагов требует дальнейшего исследования. Я привёл первоначальный разбор моей перспективы важных инкрементальных частей процесса программирования, но я неизбежно упустил многие другие. Для некоторые из осей, упомянутых мною (управление памятью, специализация языка) по-прежнему не существует никаких задокументированных попыток систематизировать отношение к ним на уровне языка. Я думаю, что работа над расширяемой компиляцией поможет ускорить скорость разработки расширений языков по этим фронтам.
Даже для более проторенных территорий вроде gradual typing уже в 2016 году начали выходить публикации, чьи авторы задавались вопросом «Правда ли, что Sound Gradual Typing пришёл конец?» (живее всех живых и прекрасно себя чувствует, большое вам спасибо за беспокойство). CircleCI отказались от использования gradual typing в Clojure спустя два года. Пусть сама теория хорошо понятна и производительность работы растёт — на тему того, как программистам взаимодействовать с опциональными (постепенными) типами, нет никакой практической информации. Легко ли писать программы с помощью данной типизации? Частично типизированные программы более запутаны по сравнению с полностью типизированными/нетипизированными программами? Способны ли IDE решить какие-либо из приведенных здесь проблем? И так далее — на эти вопросы у нас нет ответов.
Ещё одним важным вопросом постепенного программирования является выбор между выводом типов и сигнатурами типов (аннотации) (inference vs. annotation). По мере того, как наши компиляторы становятся умнее, компилятору становится проще выводить информацию вроде типов, времени жизни и т.д. в том случае, когда программист не указал их явно. Однако, движки вывода далеки от совершенства, и в том случае, когда они не могут отработать как надо (насколько мне известно), каждая языковая возможность, основывающаяся на выводе, потребует явной аннотации от пользователя, в противопоставление подходящим динамическим проверкам.
Я представляю себе это следующим образом: постепенные системы имеют три режима функционирования: для любого конкретного вида программной информации (например, типа), он является или явно указанным (explicitly annotated), или выводимым (inferred), или отложенным на время выполнения (deferred to runtime).
Этот вопрос интересен сам по себе, если мы попробуем рассмотреть его с точки зрения HCI (человеко-компьютерного взаимодействия): насколько эффективно люди могут программировать в системе, где пропущенное указание типа может, или не может быть выведено? Как это влияет на юзабилити, производительность и корректность? Скорее всего, все эти вопросу станут ещё одним важным направлением исследований для постепенного программирования.
В целом, я приветствую те широкие возможности, которые смогут предоставить нам методы постепенного программирования. Когда они начнут завоёвывать популярность, программисты всех уровней навыков смогут получить пользу от языков, который лучше соответствуют их образу мышления.
Комментарии можно в том числе направлять на почту автору статьи, а также оставлять на Hacker News.