Стековое программирование с человеческим лицом
Думаю, многие из вас находили в интернете статьи и книги о стековом программировании и языке Forth. Сперва волна энтузиазма: как всё просто, логично, понятно и мощно! И почему же эти идеи имеют такое незначительное распространение? Почему так мало программистов реально используют языки вроде Форта? Через какое-то время подступает волна разочарования: да, интересная мысль, но как же тяжело читать исходный код, как же муторно ведётся работа с переменными, строками и дробными числами! Интересная игрушка, полезная группе байтослесарей, не более.
Часто на этом всё и заканчивается. Но лично я никак не мог примириться с мыслью о том, что изящное конкатенативное программирование так и останется в тени других идей. Да, трудности с чтением исходного кода. Да, синдром одной строки. Да, каждый раз для понимания алгоритма приходится в воображении транслировать программу и представлять себе стек, читая исходный код. Но неужели это те недостатки, которые обязательно присущи стековым языкам и без которых стековое программирование перестало бы быть стековым? Неужели никак нельзя хотя бы сгладить подобные недостатки и облегчить жизнь программистам? Оказывается, можно и нужно!
Проблема первая: синдром одной строкиСловосочетание «синдром одной строки» я впервые нашёл у Баррона во «Введении в языки программирования». И хотя термин не получил широкого распространения, он очень выразительно характеризует многие языки.Синдром одной строки характерен для тех языков, которые допускают вольную структуру исходного кода программы и имеют короткие, а иногда и вовсе односимвольные ключевые слова. Программист пытается «запихать» в одну строку как можно больше ключевых слов, из-за чего программы выглядят не слишком удобочитаемыми. Особенно ярко этот синдром проявляется в APL и его потомках, в Brainfuck и, конечно, в Форте. Посмотрите ещё раз на иллюстрацию в начале поста и посчитайте, сколько на одной строке в среднем слов.
Но это решаемая проблема. В Форте синдром одной строки появился благодаря пристрастиям Мура, который очень любит короткие и осмысленные английские слова. К сожалению, такие слова быстро заканчиваются, а суффиксы и префиксы (т.н. пространства имён на коленке) Мур не любит. Поэтому теперь весь мир наслаждается лаконичными иероглифами в духе »@! C@ C! /MOD CR \S P», налепленными в одну строку. Проблема элементарно решается подбором более удачных слов. Зачем писать:
: FOR-EXAMPLE OVER OVER + ROT ROT — * ; Если можно: define for-exemple (a b — {a+b}*{a-b}) over over (a b — a b a b) + (a b — a b a+b=c) rot rot (a b c — c a b) — * end А ещё лучше: define for-exemple [a b — r] [a b → a b a b] + [a b c (=a+b) → c a b] — / end Но о такой записи будет сказано ниже.Проблема вторая: код наизнанку Другая сложность — структуры управления наизнанку. Они, конечно, изящны с точки зрения идеи и реализации, но как же такой код трудно читать: : sign-test (n —) dup 0 < [ drop "отрицательное" ] [ zero? [ "ноль" ] [ "положительное" ] if ] if print ; В данном примере блоки кода элементарные и всё определение слова как на ладони. Но стоит хоть немного его усложнить, как программа покажется нечитаемой автору уже через неделю после написания.На помощь спешат самые обычные if и while из императивного программирования:
define sign-test [x] [x → x x] 0 > if «Больше нуля» else «Меньше или равно нулю» end-ifprint-string end Может быть, они не такие мощные и расширяемые, но зато очень понятные и практичные. А если уж так хочется создавать нечто вроде арифметического if из старого доброго Фортрана, механизм блоков кода на вершине стека можно включить в язык как дополнительный элемент и использовать его не всюду, а только там, где это действительно нужно и оправданно. Благо, кушать такой механизм не просит, да и реализация будет не слишком сложной.Что же касается Форта, то у него с этим другая проблема: всё те же слова. Мур не хотел добавлять в сложные конструкции одинаковые завершающие слова вроде END, поэтому IF, DO и другие слова должны закрываться своими уникальными словами. Поэтому мы и видим все эти IF ELSE THEN, которые вводят даже бывалых программистов в тупик. Если уж так не хочется усложнять парсер и механизм слов, почему бы не ввести слова вроде END-IF? Тут сказывается нелюбовь Мура к суффиксам и префиксам. Но, подобно первой проблеме, этот момент тоже легко решается и не является специфическим недостатком стековых языков.
Проблема третья: интерпретация в голове В силу целого ряда особенностей программы на Форте и других стековых языках сложно читать и вникать в их суть. Всё дело в том, что каждый раз при чтении блока кода, в котором вводится новое слово, приходится интерпретировать в воображении программу и на каждом шагу представлять, какие элементы в каком порядке находятся на стеке и как функции с ними работают. Надо ли говорить, что это местами очень утомительно и непродуктивно. Но самое неприятное в том, что, в отличие от предыдущих особенностей, которые просто исторически так неудачно сложились, необходимость в подобной интерпретации является вечным спутником стекового программирования.Конечно, избавиться от этого недостатка невозможно, но есть пути, которые позволяют значительно облегчить труд программиста по чтению исходного кода. И самое главное: первые шаги уже сделаны. Действительно, Форт-программисты приняли определённую нотацию для облегчения чтения исходного кода:
(до — после) Такие комментарии облегчают представление о числах на стеке, их количестве и порядке следования. Не нужно каждый раз напрягать воображение, чтобы понять, сколько чисел и в какой последовательности нужно поместить на стек перед вызовом функции и сколько чисел на стеке останется в результате вычислений. Но вот загадка: если такие комментарии очень наглядны и полезны, почему Форт-программисты их пишут только в начале определения слова, да и то не всегда? Какая религия мешает после кучи drop dup swap rot написать такой поясняющий комментарий? Вернёмся ко второму примеру кода. Конечно, это случай в вакууме, но в реальных сложных программах такие комментарии будут к месту:
define for-exemple (a b — {a+b}/{a-b}) over over (a b — a b a b) + (a b — a b a+b=c) rot rot (a b c — c a b) — / end Программисту нет нужды каждый раз после всех этих swap, rot и + моделировать в голове порядок чисел в стеке. Нужно лишь пробежать глазами по комментариям. Но вот новая проблема: записи (a b — a b a b) И over over аналогичны. Просто первая запись сделана в декларативном стиле, а вторая — в императивном. То есть строки в программе постоянно дублируются. С таким же успехом можно говорить об удобстве ассемблера: слева писать код с кучей mov и ret, а справа в комментариях a=a+1, а потом говорить об удобстве чтения. Ну да, чтения комментариев. Но их можно изловчиться писать так, что они при использовании любого языка программирования будут читаться очень легко и ясно. Из этого, конечно, не следует, что такие языки удобны. Они могут быть как угодно плохи с точки зрения чтения исходного кода.Возникает естественное желание объединить (a b — a b a b) и over over. Действительно, если писать комментарии в определённой нотации, то их может разбирать компилятор и вручную добавлять нужные слова для перестановки элементов стека. Комментарии в круглых скобках полностью игнорируются и предназначаются только для человека. Комментарии же в квадратных скобках нужны как человеку, так и транслятору. Транслятор анализирует такие комментарии, в которых человек пишет то, что ему нужно, а не то, как прийти к такому результату. То есть со стеком можно работать и в декларативном стиле.
Рассмотрим основные, так сказать, сорта подобных выражений:1. define foo [b a r] или [b a r — f o o]После имени новой функции в квадратных скобках нужно указать количество аргументов, которые должны быть на вершине стека. Если при вызове какой-либо функции, которая требует три аргумента, на стеке будет лишь два аргумента, то традиционная Форт-система выдаст ошибку: стек пуст. Но с такими комментариями транслятор сможет «заглянуть» в комментарий и определить имена недостающих аргументов: ага, тут не хватает аргумента r при вызове функции foo. Согласитесь, это намного информативнее, чем постное «стек пуст». Естественно, всё сказанное относится только к работе в интерактивном режиме, когда доступен исходный код. После компиляции эти комментарии пропадут, они нужны лишь для отладки и чтения исходного кода. Для сравнения, вывод ошибки в gforth:
: add-x-y (x y — x+y) compiled + ; ok 4 5 add-x-y. 9 ok 4 add-x-y. :6: Stack underflow 4 >>>add-x-y<<< . Backtrace: $7FF17383C310 + ok 2. [a b -> a]Следующие виды выражений отличаются от чисто описательных словом →, которое аналогично слову — в классических комментариях. Если количество элементов слева больше количества элементов справа, то транслятор приходит к выводу: некоторые элементы нужно выбросить, они больше не нужны. Если элемент находится на вершине стека, то такая конструкция преобразуется в drop, но если это не так, транслятор вначале осуществляет перестановку элементов стека, чтобы мусор очутился на вершине стека, после чего будет применён drop. Надо ли говорить, как такая запись облегчает жизнь программисту в том случае, если нужно удалить, скажем, два элемента из середины стека. Пусть транслятор сам решает, как это лучше сделать, программист лишь описывает то, что ему нужно.3. [a b → b a]Такие выражения означают перестановку элементов: количество элементов слева и справа от слова → одинаково и сами элементы одни и те же. Остаётся лишь подобрать оптимальную схему перестановки. Пусть это делает машина, люди уж так устроены, что длинные цепочки over swap drop rot вводят их в ступор.4. [a b → a a b b]Число элементов слева меньше числа элементов справа. Это говорит о том, что некоторые элементы нужно дублировать. А вот что это за элементы и в каком порядке они должны располагаться, пусть решает транслятор. Человеку мыслить в терминах swap dup rot dup неудобно.5. [a b → a b]Ничего не изменилось, чисто описательная конструкция. Пример применения дан ниже.Естественно, в таких декларативных выражениях можно использовать и обычные комментарии:
dup rot + [a b → a b (a + b)] Опишем некоторые слова Форта в новой форме:
define dup [x] [x → x x] end
define drop [x] [x → ] end
define swap [x y] [x y → y x] end
define over [x y z] [x y → x y x] end
define rot [x y z] [x y z → y z x] end Проблема четвёртая: числа с плавающей запятой Представим себе стек, состоящий из 32-битных ячеек, в которых хранятся целые числа. Всем такой стек хорош: и скоростью арифметических операций, и удобством работы с адресами переменных. Но если нам нужно умножить 3.14 на 4? Куда поместить дробное число? Если организовать стек как совокупность 64-разрядных ячеек, в которых хранятся числа с плавающей запятой и хранить целые числа как дробные с нулевой дробной частью, то сразу же испаряются такие достоинства, как скорость арифметических операций.Введём второй стек для чисел с плавающей запятой. Ячейки в нём будут, скажем, 64-разрядные, но их будет меньше. Все числа без точки кладутся на вершин целочисленного (integer) стека, а все числа с точкой (пусть и с нулевой дробной частью) хранятся в дробном (float) стеке. То есть мы можем умножить упоминаемые числа следующим образом:
4.0 3.14 *f где *f умножает дробные числа (аналогично +f, -f и так далее).Введём также новое декларативное выражение:[стек: a b c → стек: a b c]Которое берёт элементы из одного стека и помещает их в другой:
4 5 [integer: z1 z2 → float: z1 z2] 2 times print-float end-times В дробном стеке появятся числа 4.0 и 5.0. Обратная операция «обрубает» дробную часть.Определим новые слова: define integer→float [x] [integer: x → float: x] end
define float→integer [x] [float: x → intger: x] end Аналогично и со стеком возвратов (return stack).Пост получился довольно объёмным и местами спорным, поэтому значительная часть материала будет во второй части. Опять же таки, идеи и критика из обсуждения скорректируют план написания.