﻿Прежде, чем обсудить принципы исполнения программ, написанных на Lisp, давайте вспомним о том, что такое трансляторы, компиляторы и интерпретаторы.
Под трансляцией программы обычно подразумевается преобразование её из некоторого исходного языка в некоторый целевой язык.
И исходный, и целевой языки могут быть языками высокого уровня, например, существуют трансляторы с Паскаля на С.
Обычно же перевод выполняется с языка более высокого уровня на язык более низкого уровня.
Компилятор – это транслятор, который преобразует программу с языка высокого уровня в некоторое низкоуровневое представление, обычно в машинный код.
Как правило, компилятор читает программу целиком из файла с исходным текстом, выполняя лексический анализ, затем синтаксический анализ, строит в памяти некоторое промежуточное представление программы (его называют абстрактным синтаксическим деревом), после чего проходит по этому дереву и конвертирует промежуточное представление в нужный целевой формат, например, инструкции для процессоров архитектуры Intel x86.
Результат записывается в файл на диске.
Файл имеет особый формат, понятный операционной системе, и называется исполняемым модулем.
Он может быть запущен на выполнение и, будучи запущен, выполнит инструкции программы: в нашем простом примере создаст на диске файл, запишет туда некоторый текст и закроет файл.
Таким образом, мы компилируем программу один раз, а затем, получив исполняемый модуль, можем выполнять её столько раз, сколько нам нужно.
Понятно, что компиляция занимает значительное время по сравнению с запуском скомпилированной программы.
Если мы поменяем исходный код программы, то, чтобы получить новый исполняемый модуль, нам нужно будет скомпилировать программу заново.
Интерпретаторы работают несколько иначе.
Как правило, текст программы считывается интерпретатором не целиком, а частями, одно предложение за другим.
Будучи считанной, очередная инструкция программы анализируется, и, если не возникло ошибок, исполняется.
При этом даже если в памяти интерпретатора и сохраняется состояние программы и преобразованные инструкции из файла с исходным текстом, на диск результат этих преобразований в виде исполняемого модуля не записывается.
Когда программу нужно будет запустить снова, файл с исходным кодом (в таких случаях он часто называется сценарием или скриптом) снова передается на вход интерпретатору, и тот вновь осуществляет поочередное исполнение инструкций скрипта.
Понятно, что интерпретируемая программа будет работать медленнее заранее скомпилированного исполняемого модуля.
Однако отсутствие необходимости выполнять компиляцию и сборку проекта каждый раз, когда в исходный текст вносятся изменения, приводящее к более высокой скорости разработки, является сильной стороной интерпретируемых языков.
Сделаем несколько замечаний.
Во-первых, интерпретация и компиляция – в определенном смысле равнозначные подходы, каждый обладает своими преимуществами.
Во-вторых, есть языки, которые допускают и компиляцию, и интерпретацию.
Примерами преимущественно компилируемых языков могут служить Паскаль, С, С++, Ада.
Примерами преимущественно интерпретируемых языков могут служить bash, ранние версии Lisp и ранние версии Basic.
Многие скриптовые языки, традиционно относимые к интерпретируемым, в настоящее время являются, по сути, компилируемыми языками: их интерпретаторы сначала целиком прочитывают и анализируют исходный код программы, строят абстрактное синтаксическое дерево, а затем сразу начинают выполнение, без сохранения на диск результатов компиляции.
При последующем запуске скрипта этап компиляции повторяется.
Типичными примерами таких языков могут служить Perl и Ruby.
Многие скриптовые языки, например, Python или Perl 6, могут сохранять промежуточное представление программы на диске, это ускоряет последующее выполнение программы, но не является обязательным шагом.
Другие языки, ориентированные в основном на достижение максимальной переносимости программ, используют компиляцию в промежуточное представление как обязательный шаг.
В результате компиляции получается файл с байт-кодом, т. е. инструкциями не процессора целевой платформы, а инструкциями некоторой виртуальной машины.
Позднее байт-код передается на вход этой машине (иногда называемой средой исполнения) и переводится ей в инструкции целевой платформы, т. е. того процессора, на котором работает виртуальная машина.
Компилировать программу в байт-код можно на одной платформе, а исполнять – совсем на другой.
Java может служить примером наиболее известного языка, использующего такой подход.
А как же выполняются программы, написанные на Lisp?
Если мы говорим о какой-либо реализации стандарта Common Lisp, то можно сказать, что поддерживаются оба подхода к выполнению программ.
Во-первых, программы на Lisp могут интерпретироваться.
Это наиболее традиционный вариант исполнения, в этом отношении Lisp похож на скриптовые языки.
Таким подходом особенно удобно пользоваться, если жестких требований к скорости выполнения программы нет, а сама она умещается в один файл.
Во-вторых, программу на Lisp можно скомпилировать в платформонезависимый байт-код, в этом случае при последующем запуске она будет выполняться несколько быстрее.
К сожалению, у каждого компилятора свой формат файлов с промежуточным представлением, поэтому байт-код, скомпилированный, например, CLISP, не получится выполнить с помощью SBCL.
Наконец, можно сохранить программу и в виде исполняемого модуля, однако эта возможность предусматривается не всеми компиляторами Common Lisp.
Отдельный вопрос, который сейчас мы не будем обсуждать, касается принципов построения больших проектов на Lisp, включающих множество файлов с исходным кодом.
В наших примерах мы почти всегда будем считать, что программа содержится в одном файле с исходным кодом.
Мы вернемся к этому вопросу, когда будем обсуждать пакеты и принципы модульной разработки программ на Lisp.
Всеми реализациями Common Lisp поддерживается интерактивный режим исполнения инструкций, в котором после запуска интерпретатор ожидает ввода очередной команды от пользователя.
После того, как пользователь вводит команду и нажимает на Enter, интерпретатор разбирает введенную строку, исполняет её, затем выводит в консоль результат и ожидает нового ввода.
Такой режим известен как Read-Eval-Print Loop, или REPL.
Для того чтобы войти в интерактивный режим, достаточно запустить интерпретатор без дополнительных параметров.
После вывода приветственной информации интерпретатор показывает приглашение, в котором указывает номер очередной инструкции.
Если строка, которую мы передаем интерпретатору, содержит ошибки, как это случилось со второй командой, Lisp напечатает сообщение об ошибке (в данном случае сообщение о том, что 40 – не имя функции) и предложит варианты устранения ошибки.
Если ввести :r1, то далее Lisp предложит заменить ошибочное значение на другое и повторит разбор команды.
Если ввести :r2, то Lisp прекратит выполнение команды с ошибкой.
Сравните вторую и четвертую команды.
Чтобы сложить два числа, нам нужно записать выражение немного по-другому.
Для того чтобы выйти из интерпретатора, достаточно ввести команду (quit) или (exit), а в Linux и OS X можно нажать комбинацию клавиш Ctrl-D.
Любая программа на Lisp является последовательностью так называемых форм.
Формы – это s-выражения, которые считываются интерпретатором и исполняются.
А s-выражение, в свою очередь, может быть представлено либо в виде отдельного значения, иногда называемого атомом, либо в виде списка, содержащего другие s-выражения, то есть другие списки и атомы.
Значения в списке разделяются пробелами, а сам список берется в круглые скобки.
S-выражения могут использоваться и для записи данных, и для записи команд.
Собственно, когда-то давно предполагалось, что s-выражения будут использоваться для данных, а для команд Маккарти предполагал использовать m-выражения, но первые реализации компиляторов подтвердили, что списки одинаково хорошо годятся и для данных, и для команд.
В результате Lisp стал первым языком, обладающим интересным свойством: код и данные в нем представляются одинаково.
Работу со списками как структурами данных мы рассмотрим немного позже.
А m-выражения в Lisp так и не понадобились.
Следует отметить, что любая правильная программа на Lisp является совокупностью s-выражений, однако не любое s-выражение является правильной программой на Lisp, в чем мы могли убедиться, обсуждая предыдущий пример.
В чем же дело?
Дело в том, что правильные формы в Lisp должны быть записаны с помощью префиксной нотации.
Префиксная нотация (польская запись) предполагает, что сначала записывается знак операции, а за ним следуют операнды.
Правильная форма в Lisp представляет собой или атом, или список, первый элемент которого указывает, какие действия необходимо выполнить с остальными элементами.
Сравните выражение проверки на равенство, записанное на С, и то же самое по смыслу выражение, записанное на Lisp: первым в списке идет знак операции, за ним – операнды.
Такой способ записи может поначалу показаться неудобным, однако он значительно способствует регулярности языка и отсутствию замысловатых синтаксических конструкций.
Впрочем, любители замысловатых синтаксических конструкций могут не унывать: макросы, о которых мы поговорим несколько позже, позволяют создавать в Lisp синтаксические конструкции любой степени сложности.
Также обратите внимание на то, что в случае использования инфиксной записи всегда возникает вопрос о приоритете операций.
В С приоритет сложения выше, чем приоритет проверки на равенство, поэтому дополнительных скобок не требуется.
В Lisp же нет нужды в самом понятии ранга операции.
К настоящему моменту вы уже обладаете достаточными знаниями, чтобы написать свою первую программу на Lisp.
Для простоты воспользуемся обычным текстовым редактором и компилятором Lisp.
Создадим в некотором каталоге текстовый файл, в который запишем одну единственную форму.
Это вызов функции print, выводящей в стандартный поток вывода строку "Hello, Lisp!".
Файлам с исходным кодом на Lisp обычно дают расширение .lisp или .lsp.
Далее, запустив интерпретатор, мы можем загрузить наш файл с исходным кодом для исполнения.
Для этого вызовем функцию load и передадим ей строку, содержащую путь к файлу с исходным кодом программы.
Если все прошло успешно, то интерпретатор считает команду из файла, и в результате мы увидим в консоли строку с приветствием.
Для того чтобы скомпилировать файл в байт-код, можем воспользоваться функцией compile-file.
Ей в качестве аргумента также передается путь к файлу с исходным кодом.
В том же каталоге, где мы сохранили файл hello.lisp, появится файл hello.fas.
После этого мы можем снова загрузить нашу программу, передав функции load путь к файлу с байт-кодом.
Мы можем проделать все те же операции и из командной строки.
Передав компилятору в качестве параметра в командной строке путь к файлу с исходным кодом, мы заставим его выполнить инструкции, которые там содержатся.
Для компиляции в байт-код нужно запустить компилятор с параметром –c.
Наконец, мы можем запустить программу, передав среде исполнения путь к файлу с байт-кодом.
Итак, наша лекция подошла к концу.
Что нового мы узнали о функциональном программировании и языке Lisp?
Во-первых, мы очень кратко обсудили историю развития языков программирования и вычислительной техники и показали роль различных подходов (парадигм) в этом развитии.
Во-вторых, мы поговорили об отличиях функциональной парадигмы от императивной, о том, какими преимуществами и недостатками обладает каждый из подходов к составлению программ.
Мы выбрали Lisp в качестве языка, который позволит нам познакомиться с приемами и базовыми концепциями функционального программирования.
Этот выбор не случаен, и мы обсудили, почему один из старейших языков программирования актуален, интересен и полезен на практике до сих пор.
Наконец, мы изучили основы синтаксиса Lisp и написали простейшую программу на Lisp, после чего выяснили, как её компилировать и запускать на выполнение.